feat(shared-ts): federation shared utilities for apollo subgraphs

- RouterAuthGuard: validate Router-Authorization header (ADR-036)

- DataLoader factory: request-scoped batching (ADR-035)

- ScopeTokenService: Redis-backed scope token (ADR-041)

- GraphqlContext: build context from HTTP headers

- FederationExceptionFilter: HTTP-to-GraphQL error mapping
This commit is contained in:
SpecialX
2026-07-14 23:46:51 +08:00
parent 3c2ea50c7f
commit 5fcb831a18
9 changed files with 1074 additions and 4 deletions

View File

@@ -0,0 +1,60 @@
/**
* GraphQL Context - Apollo Router 下传的请求上下文
*
* Apollo Router 通过 headers 传递以下信息到子图:
* - authorization: JWT token透传
* - x-user-id: 已认证用户 ID
* - x-user-role: 用户角色
* - x-dataScope: 数据范围ALL / SCHOOL / GRADE / CLASS / STUDENT
* - x-request-id: 请求追踪 ID
*
* 子图从 HTTP headers 提取后构造 GraphqlContext注入 Resolver。
*/
export type DataScope = "ALL" | "SCHOOL" | "GRADE" | "CLASS" | "STUDENT";
export interface GraphqlContextInput {
userId?: string;
userRole?: string;
dataScope?: DataScope;
requestId?: string;
jwt?: string;
}
export class GraphqlContext {
readonly userId?: string;
readonly userRole?: string;
readonly dataScope: DataScope;
readonly requestId: string;
readonly jwt?: string;
constructor(input: GraphqlContextInput) {
this.userId = input.userId;
this.userRole = input.userRole;
this.dataScope = input.dataScope ?? "ALL";
this.requestId = input.requestId ?? "unknown";
this.jwt = input.jwt;
}
/** 是否管理员(全量可见) */
get isAdmin(): boolean {
return this.dataScope === "ALL" || this.userRole === "admin";
}
/** 是否已认证 */
get isAuthenticated(): boolean {
return !!this.userId;
}
/** 从 HTTP headers 构造NestJS @Context 提取) */
static fromHeaders(
headers: Record<string, string | undefined>,
): GraphqlContext {
return new GraphqlContext({
userId: headers["x-user-id"],
userRole: headers["x-user-role"],
dataScope: headers["x-datapscope"] as DataScope | undefined,
requestId: headers["x-request-id"],
jwt: headers["authorization"]?.replace(/^Bearer\s+/i, ""),
});
}
}

View File

@@ -0,0 +1,44 @@
/**
* DataLoader 工厂 - @key Reference Resolver 强制请求合并
*
* 强制约束v2.1 §3.6 / ADR-035
* 所有子图的 @key 解析器Reference Resolver必须使用 DataLoader
* 进行请求合并Batching禁止 N+1 查询。
*
* 示例:
* ```typescript
* const examLoader = createDataLoader<string, Exam>(async (examIds) => {
* const exams = await examRepo.findByIds([...examIds]);
* const map = new Map(exams.map((e) => [e.examId, e]));
* return examIds.map((id) => map.get(id) ?? new Error(`Exam ${id} not found`));
* });
*
* @ResolveReference()
* resolveReference(ref: { examId: string }): Promise<Exam> {
* return this.examLoader.load(ref.examId);
* }
* ```
*/
import DataLoader from "dataloader";
export type BatchLoadFn<K, V> = (
keys: ReadonlyArray<K>,
) => Promise<Array<V | Error>>;
/**
* 创建 DataLoader 实例
*
* 工厂模式确保每个 GraphQL 请求独立 DataLoader 实例(请求级缓存)。
* 在 NestJS 中通过REQUEST scope provider或GraphQL Context注入。
*/
export function createDataLoader<K, V>(
batchLoadFn: BatchLoadFn<K, V>,
): DataLoader<K, V, string> {
return new DataLoader<K, V, string>(batchLoadFn, {
// 使用 JSON.stringify 作为 cacheKeyFn支持对象/数组 key
cacheKeyFn: (key: K): string =>
typeof key === "string" ? key : JSON.stringify(key),
// 请求级缓存,不共享
cache: true,
});
}

View File

@@ -0,0 +1,86 @@
/**
* FederationExceptionFilter - 联邦层错误格式化
*
* Apollo Router 期望子图返回结构化 GraphQL error包含 extensions.code。
* 此 filter 将 NestJS 异常转为符合 Apollo Federation 规范的格式。
*/
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
Logger,
} from "@nestjs/common";
import { GraphQLError } from "graphql";
@Catch()
export class FederationExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(FederationExceptionFilter.name);
catch(exception: unknown, _host: ArgumentsHost): void {
// GraphQL 错误已经格式化,直接抛出
if (exception instanceof GraphQLError) {
throw exception;
}
// HTTP 异常映射为 GraphQL error
if (exception instanceof HttpException) {
const status = exception.getStatus();
const response = exception.getResponse();
const message =
typeof response === "string"
? response
: ((response as { message?: string }).message ?? exception.message);
const code = this.mapStatusToCode(status);
this.logger.warn(
`GraphQL exception: ${code} ${message} (status=${status})`,
);
throw new GraphQLError(message, {
extensions: {
code,
status,
...(typeof response === "object" && response !== null
? (response as Record<string, unknown>)
: {}),
},
});
}
// 未知异常
this.logger.error(
`Unhandled GraphQL exception: ${
exception instanceof Error ? exception.stack : String(exception)
}`,
);
throw new GraphQLError("Internal server error", {
extensions: {
code: "INTERNAL_SERVER_ERROR",
status: 500,
},
});
}
private mapStatusToCode(status: number): string {
switch (status) {
case 400:
return "BAD_REQUEST";
case 401:
return "UNAUTHENTICATED";
case 403:
return "FORBIDDEN";
case 404:
return "NOT_FOUND";
case 409:
return "CONFLICT";
case 422:
return "UNPROCESSABLE_ENTITY";
case 429:
return "TOO_MANY_REQUESTS";
default:
return status >= 500 ? "INTERNAL_SERVER_ERROR" : "UNKNOWN";
}
}
}

View File

@@ -0,0 +1,28 @@
/**
* Apollo Federation 共享工具包v2.1
*
* 提供:
* - RouterAuthGuard校验 Apollo Router 信任凭证,拒绝非 Router 的直接 GraphQL 请求
* - DataLoader 工厂:@key Reference Resolver 强制使用 DataLoader 请求合并
* - ScopeToken大规模 ID 列表优化Redis Set 引用
* - GraphQL Context统一上下文类型
* - FederationExceptionFilter联邦层错误格式化
*/
export {
RouterAuthGuard,
ROUTER_AUTH_HEADER,
type RouterAuthConfig,
} from "./router-auth.guard.js";
export { createDataLoader, type BatchLoadFn } from "./dataloader.factory.js";
export {
ScopeTokenService,
SCOPE_TOKEN_TTL_SECONDS,
type ScopeToken,
} from "./scope-token.js";
export {
GraphqlContext,
type GraphqlContextInput,
type DataScope,
} from "./context.js";
export { FederationExceptionFilter } from "./exception.filter.js";

View File

@@ -0,0 +1,76 @@
/**
* RouterAuthGuard - Apollo Router 信任凭证校验
*
* 强制约束v2.1 §3.7 / ADR-036
* Apollo Router 请求子图时必须携带 Router-Authorization Header
* 各服务 NestJS Guard 拦截并校验,拒绝任何非 Router 发起的 GraphQL 请求。
*
* 部署模式:
* - 生产ROUTER_AUTH_SECRET 环境变量配置共享密钥
* - 开发DEV_MODE=true 时跳过校验(仅限本地)
*/
import {
CanActivate,
ExecutionContext,
Injectable,
ForbiddenException,
Logger,
} from "@nestjs/common";
export const ROUTER_AUTH_HEADER = "router-authorization";
export interface RouterAuthConfig {
/** 共享密钥(生产环境由 Secret 注入) */
secret: string;
/** 开发模式跳过校验 */
devMode?: boolean;
}
@Injectable()
export class RouterAuthGuard implements CanActivate {
private readonly logger = new Logger(RouterAuthGuard.name);
private readonly config: RouterAuthConfig;
constructor(config: RouterAuthConfig) {
this.config = config;
}
canActivate(ctx: ExecutionContext): boolean {
if (this.config.devMode === true) {
return true;
}
const req = ctx.switchToHttp().getRequest<{
headers: Record<string, string | undefined>;
url: string;
}>();
// 健康检查端点豁免
if (req.url?.startsWith("/health") || req.url?.startsWith("/ready")) {
return true;
}
const routerAuth = req.headers[ROUTER_AUTH_HEADER];
const expected = this.config.secret;
if (!expected) {
this.logger.error(
`${ROUTER_AUTH_HEADER} secret not configured (ROUTER_AUTH_SECRET env required)`,
);
throw new ForbiddenException(
"Router authorization not configured on server",
);
}
if (!routerAuth || routerAuth !== expected) {
this.logger.warn(
`Direct GraphQL access denied (path=${req.url}); must go through Apollo Router`,
);
throw new ForbiddenException(
"Direct GraphQL access denied; must go through Apollo Router",
);
}
return true;
}
}

View File

@@ -0,0 +1,105 @@
/**
* ScopeToken - 大规模 ID 列表优化
*
* 强制约束v2.1 §5.2 / ADR-041
* 不直接在 GraphQL 联邦中传递全量 ID 数组,而是传递一个轻量的 ScopeToken。
* iam 子图计算出 visibleClassIds 后,存入 Redis Set生成并返回极短的 scopeToken。
* Router 仅通过 @requires 传递 scopeToken。
* core-edu 拿到 scopeToken 后,从 Redis SMEMBERS 获取实际 ID 数组。
*
* Token 格式:`usr:{userId}:cls_scope`(或 `usr:{userId}:stu_scope` 等)
* Redis Key`scope:{token}`
* Redis 类型SET
* TTL5 分钟SCOPE_TOKEN_TTL_SECONDS
*/
import type { Redis } from "ioredis";
export const SCOPE_TOKEN_TTL_SECONDS = 300; // 5 分钟
export type ScopeToken = string & { readonly __brand: "ScopeToken" };
export type ScopeKind = "cls_scope" | "stu_scope" | "sub_scope" | "notif_scope";
/**
* ScopeToken 服务
*
* 职责:
* - 生成 token基于 userId + kind
* - 将 ID 列表存入 Redis Set带 TTL
* - 读取 token 对应的 ID 列表SMEMBERS
* - 支持 "ALL" 特殊值(跳过 Redis全量可见
*/
export class ScopeTokenService {
constructor(private readonly redis: Redis) {}
/**
* 生成并存储 ScopeToken
*
* @param userId 用户 ID
* @param kind Scope 类型
* @param ids 可见 ID 列表
* @returns ScopeToken如 "usr:123:cls_scope")或 "ALL"ids 为空表示全量可见)
*/
async issueScopeToken(
userId: string,
kind: ScopeKind,
ids: string[],
): Promise<ScopeToken | "ALL"> {
// 空列表在业务上通常表示"全量可见"(管理员),返回 "ALL" 特殊值
if (ids.length === 0) {
return "ALL";
}
const token = `usr:${userId}:${kind}` as ScopeToken;
const redisKey = `scope:${token}`;
// 用 pipeline 减少 RTT
const pipeline = this.redis.pipeline();
pipeline.del(redisKey);
pipeline.sadd(redisKey, ...ids);
pipeline.expire(redisKey, SCOPE_TOKEN_TTL_SECONDS);
await pipeline.exec();
return token;
}
/**
* 解析 ScopeToken 为实际 ID 列表
*
* @param token ScopeToken 或 "ALL"
* @returns ID 列表;"ALL" 返回 null表示无过滤
*/
async resolveScopeToken(token: ScopeToken | "ALL"): Promise<string[] | null> {
if (token === "ALL") {
return null; // null 表示全量可见,调用方应跳过 WHERE IN 过滤
}
const redisKey = `scope:${token}`;
const ids = await this.redis.smembers(redisKey);
if (ids.length === 0) {
// token 过期或不存在调用方应触发重新签发401/403 由 Resolver 处理)
return [];
}
return ids;
}
/**
* 刷新 token TTL访问时续期
*/
async refreshTtl(token: ScopeToken): Promise<void> {
if (token === "ALL") return;
const redisKey = `scope:${token}`;
await this.redis.expire(redisKey, SCOPE_TOKEN_TTL_SECONDS);
}
/**
* 撤销 token用户权限变更时
*/
async revokeToken(userId: string, kind: ScopeKind): Promise<void> {
const token = `usr:${userId}:${kind}` as ScopeToken;
const redisKey = `scope:${token}`;
await this.redis.del(redisKey);
}
}