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:
60
packages/shared-ts/src/federation/context.ts
Normal file
60
packages/shared-ts/src/federation/context.ts
Normal 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, ""),
|
||||
});
|
||||
}
|
||||
}
|
||||
44
packages/shared-ts/src/federation/dataloader.factory.ts
Normal file
44
packages/shared-ts/src/federation/dataloader.factory.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
86
packages/shared-ts/src/federation/exception.filter.ts
Normal file
86
packages/shared-ts/src/federation/exception.filter.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
}
|
||||
28
packages/shared-ts/src/federation/index.ts
Normal file
28
packages/shared-ts/src/federation/index.ts
Normal 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";
|
||||
76
packages/shared-ts/src/federation/router-auth.guard.ts
Normal file
76
packages/shared-ts/src/federation/router-auth.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
105
packages/shared-ts/src/federation/scope-token.ts
Normal file
105
packages/shared-ts/src/federation/scope-token.ts
Normal 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
|
||||
* TTL:5 分钟(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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user