Effect HttpApi 中间件错误 Schema 声明机制:从单错误到多错误数组的演进
【免费下载链接】t3code项目地址: https://gitcode.com/GitHub_Trending/t3/t3code
导读
本文基于 Effect 生态中effect包的 unstable HttpApi 模块,深入剖析一项类型系统与运行时行为并重的 API 变更:允许 HttpApi 中间件(HttpApiMiddleware)通过数组形式声明多个错误 Schema。变更后,中间件声明的错误在响应状态解析、客户端解码和生成的 API Schema 三个环节上,与端点(Endpoint)错误的行为完全对齐。读完本文,你将掌握 HttpApi 中间件错误声明的类型约束、运行时归一化实现,以及如何在认证、鉴权、限流等横切关注点中声明多错误契约。
变更背景:一个 changeset 背后的类型契约升级
本次变更记录于 .changeset/pre/calm-carrots-march.md:
--- "effect": patch --- Allow unstable HttpApi middleware to declare multiple error schemas with arrays. Middleware errors now follow endpoint error behavior for response status resolution, client decoding, and generated API schemas.这是一次patch级别的变更:不破坏既有 API,而是放宽并统一中间件错误的表达能力。变更前,中间件只能声明单个错误 Schema;变更后,可以像端点那样声明一组 Schema(即错误联合),由框架负责:
- 响应状态解析(response status resolution):根据实际发生的错误类型,解析出对应的 HTTP 状态码;
- 客户端解码(client decoding):生成的客户端能够从响应体中按联合成员解码出正确的错误类型;
- 生成的 API Schema:OpenAPI 等派生 Schema 中正确呈现多个错误变体。
类型层:ErrorConstraint与ErrorSchemaFromConstraint
中间件错误声明在类型层面由HttpApiMiddleware.ts中的两个关键类型驱动(.repos/effect-smol/packages/effect/src/unstable/httpapi/HttpApiMiddleware.ts)。
错误约束:单个 Schema 或 Schema 数组
type ErrorConstraint = Schema.Top | ReadonlyArray<Schema.Top> type ErrorSchemaFromConstraint<E> = E extends ReadonlyArray<Schema.Constraint> ? E[number] : E extends Schema.Constraint ? E : neverErrorConstraint表示中间件错误既可以是单个 Schema,也可以是只读 Schema 数组;ErrorSchemaFromConstraint是提取逻辑:当约束是数组时,取数组元素类型E[number]构成联合;当约束是单个 Schema 时,直接使用该 Schema。这一条件类型正是"多错误声明"的类型层核心——数组中的每个成员都会被并入中间件失败的错误通道。
贯穿三个模型:服务端、安全中间件、类型标识
ErrorConstraint被三个模型共用,保证多错误声明在整个声明体系中一致生效:
// 普通服务端中间件:失败通道为 unhandled | ErrorSchemaFromConstraint<E>["Type"] export type HttpApiMiddleware<Provides, E extends ErrorConstraint, Requires> = ( httpEffect: Effect.Effect<HttpServerResponse, unhandled, Provides>, options: { readonly endpoint: HttpApiEndpoint.Top; readonly group: HttpApiGroup.Top } ) => Effect.Effect<HttpServerResponse, unhandled | ErrorSchemaFromConstraint<E>["Type"], Requires | HttpRouter.Provided> // 安全中间件:每个 security scheme 的处理器拥有同样的错误约束 export type HttpApiMiddlewareSecurity<...> = { readonly [K in keyof Security]: (...) => Effect.Effect< HttpServerResponse, unhandled | ErrorSchemaFromConstraint<E>["Type"], Requires | HttpRouter.Provided > } // 类型标识:error 字段保存原始 ErrorConstraint(含数组形态) export interface AnyId { readonly [TypeId]: { readonly provides: any readonly requires: any readonly error: ErrorConstraint readonly clientError: any readonly requiredForClient: boolean } }HttpApiMiddlewareSecurity的存在意味着:带 security 的中间件(如 Bearer 认证)同样支持多个错误 Schema——每个 scheme 的凭据解码与错误声明共享同一约束。这与 changeset 中"中间件错误遵循端点错误行为"的承诺一致。
提取工具类型:错误联合与编解码服务
由ErrorSchema<A>出发,派生出一整套面向错误联合的提取工具:
export type ErrorSchema<A> = A extends { readonly [TypeId]: { readonly error: infer E } } ? ErrorSchemaFromConstraint<E> : never export type Error<A> = ErrorSchema<A>["Type"] export type ErrorServicesEncode<A> = ErrorSchema<A>["EncodingServices"] export type ErrorServicesDecode<A> = ErrorSchema<A>["DecodingServices"]Error<A>:中间件失败时可能抛出的解码后错误类型联合;ErrorServicesEncode/ErrorServicesDecode:错误 Schema 各自携带的编码/解码服务需求,会被自动并入中间件实现与生成的客户端所需环境中。
运行时层:Service构造器与getError归一化
类型约束之外,运行时实现同样处理了"数组 vs 单个"两种形态。Service构造器(用于创建中间件服务类)的签名与实现如下:
export const Service = <Self, Config extends { requires?: any; provides?: any; clientError?: any } = ...>(): < const Id extends string, const Error extends ErrorConstraint = never, // 错误约束默认 never const Security extends Record<string, HttpApiSecurity.HttpApiSecurity> = never, RequiredForClient extends boolean = false >(id: Id, options?: { readonly error?: Error | undefined readonly security?: Security | undefined readonly requiredForClient?: RequiredForClient | undefined } | undefined) => ServiceClass<...> => (id: string, options?) => { // ... self.error = getError(options?.error) self.requiredForClient = options?.requiredForClient ?? false if (options?.security !== undefined) { if (Object.keys(options.security).length === 0) { throw new Error("HttpApiMiddleware.Service: security object must not be empty") } // ... } return self }getError:把任意输入归一化为只读错误集合
function getError(error: ErrorConstraint | undefined): ReadonlySet<Schema.Top> { if (error === undefined) return new Set() return new Set(Array.isArray(error) ? error : [error]) }这是本次变更在运行时的落点:
error未声明 → 空集合(不产生错误契约);- 声明单个 Schema → 包装为单元素集合;
- 声明 Schema 数组 → 展开为多元素集合。
ServiceClass的静态侧通过readonly error: ReadonlySet<Schema.Top>暴露该集合,供端点到中间件的校验、客户端生成与 OpenAPI 派生逻辑消费。AnyService接口同样以ReadonlySet<Schema.Top>保存错误集合,确保所有消费方看到统一的运行时视图。
错误如何被实际应用:HttpApiBuilder的中间件流水线
中间件声明只是契约,真正被"执行"是在HttpApiBuilder构建路由时。在 .repos/effect-smol/packages/effect/src/unstable/httpapi/HttpApiBuilder.ts 中,applyMiddleware遍历端点挂载的中间件并按序套用:
const applyMiddleware = <Group extends HttpApiGroup.Constraint, A extends Effect.Effect<any, any, any>>( // ... ) => { // ... for (const key_ of endpoint.middlewares) { const key = key_ as HttpApiMiddleware.AnyService const apply = HttpApiMiddleware.isSecurity(key) ? makeSecurityMiddleware(key, service) // 安全中间件走专用路径 : // 普通中间件套用 httpEffect => ... 的包装逻辑 } }- 普通中间件:把端点响应 Effect 包装为
httpEffect => ...形式,失败的错误通道中并入中间件声明的ErrorSchemaFromConstraint<E>["Type"]; - 安全中间件:通过
makeSecurityMiddleware为每个 security scheme 的凭据解码分配独立的处理器,securityMiddlewareCache(WeakMap)避免重复构建。
因此,当中间件以数组声明多个错误 Schema 时,端点执行链路中的失败要么来自端点自身、要么来自中间件联合中的任意成员——二者在HttpApiBuilder的错误通道中被统一处理,这正是 changeset 所述"遵循端点错误行为"的架构含义。
实战示例:用数组声明多个错误 Schema
结合 layerSchemaErrorTransform(把HttpApiSchemaError转换为自定义错误的层),演示多错误声明:
import { Effect, Schema } from "effect" import { HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiMiddleware } from "effect/unstable/httpapi" // 1. 定义两个不同的错误变体 class RateLimited extends Schema.TaggedError<RateLimited>()("RateLimited", { retryAfterSeconds: Schema.Number }) {} class Unauthorized extends Schema.TaggedError<Unauthorized>()("Unauthorized", {}) {} // 2. 以“数组”形式声明中间件可产生的多个错误 Schema class AuthGuard extends HttpApiMiddleware.Service<AuthGuard>()("api/AuthGuard", { error: [Unauthorized, RateLimited] // 数组 → 错误联合 }) {} // 3. 将端点产生的 schema 错误转换为中间件声明的错误 const AuthGuardLayer = HttpApiMiddleware.layerSchemaErrorTransform( AuthGuard, (schemaError) => Effect.fail(schemaError.kind === "RateLimited" ? new RateLimited({ retryAfterSeconds: 30 }) : new Unauthorized()) ) // 4. 端点与组正常声明 const endpoint = HttpApiEndpoint.get("example", "/").addError(RateLimited) const group = HttpApiGroup.make("examples").add(endpoint)要点说明:
error选项的类型为ErrorConstraint,即Schema.Top | ReadonlyArray<Schema.Top>,数组形式与单个形式均合法;- 声明后,
AuthGuard的静态error集合包含两个成员,Error<AuthGuard>的类型为Unauthorized | RateLimited; - 生成的客户端将按端点错误同样的机制,从响应中解码出对应错误变体,生成的 API Schema 也会同时呈现两个错误响应。
测试与类型验证覆盖
多错误声明的能力已进入仓库的测试与类型测试矩阵:
- .repos/effect-smol/packages/effect/typetest/unstable/httpapi/HttpApiMiddleware.tst.ts:针对中间件错误约束、客户端错误、
ForClient标记做编译期断言; - .repos/effect-smol/packages/effect/typetest/unstable/httpapi/HttpApiBuilder.tst.ts:验证中间件声明在组/端点构建中的类型收敛;
- .repos/effect-smol/packages/effect/test/unstable/httpapi/HttpApiBuilder.test.ts 与 .repos/effect-smol/packages/platform/node/test/HttpApi.test.ts:覆盖中间件套用、安全凭据处理与运行时错误路径;
- .repos/effect-smol/packages/effect/test/unstable/httpapi/OpenApi.test.ts 与 .repos/effect-smol/packages/platform/node/test/OpenApi.test.ts:验证错误联合正确投影到生成的 OpenAPI Schema。
若需在既有代码中启用该能力,注意HttpApi 模块目前位于unstable命名空间(导入路径为effect/unstable/httpapi),HttpApiMiddleware.Service要求@since 4.0.0,即effect4.x 及以上版本,并确认该 changeset 已随目标版本发布。
总结
本次 changeset 揭示的是一次"小改动、大统一"的类型系统演进:
- 声明能力:
HttpApiMiddleware.Service的error选项从单个 Schema 扩展为Schema.Top | ReadonlyArray<Schema.Top>,数组中的每个成员都成为中间件错误联合的组成部分; - 类型推导:
ErrorSchemaFromConstraint在类型层将数组约束展开为联合,Error<A>、ErrorServicesEncode/Decode<A>随之生效; - 运行时归一化:
getError将数组/单个/未声明统一收敛为ReadonlySet<Schema.Top>,供服务类静态元数据使用; - 行为对齐:中间件错误在响应状态解析、客户端解码、生成 API Schema 三个环节与端点错误完全一致,并由
HttpApiBuilder.applyMiddleware统一并入错误通道。
对于使用 Effect 构建 schema 驱动 HTTP API 的团队,这意味着认证、限流、授权等横切中间件现在可以像端点一样表达完整的错误契约,无需再依赖单个错误的妥协方案。
【免费下载链接】t3code项目地址: https://gitcode.com/GitHub_Trending/t3/t3code
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考