Effect 的 HttpApi 怎么一份定义同时得到服务端、客户端和文档?
手写路由加手写 fetch,意味着同一个接口的路径、参数和错误形状要在服务端和客户端各写一遍,然后祈祷它们不漂移。
HttpApi 的做法是只写一份 Schema 驱动的接口声明,服务端实现、类型化客户端、OpenAPI 文档都从它派生;这里有一条硬约束:定义文件不能 import 任何服务端实现。
约束的来源很直接:定义一旦依赖服务端代码,客户端打包时就会把服务端代码带进去,所以定义单独成文件。定义里的参数、payload、错误都用 Schema 描述,Schema 是 Effect 在系统边界做解码的方式,见Effect Schema:在系统边界做解码,一份定义多处使用。要提前说明的是,这些模块在 effect 4.0.5 里是 effect/http-api,文档标注为 unstable,小版本之间 API 可能调整。
接口定义文件怎么写?
下面定义一个 users 组,两个端点,两个带状态码的错误:
// file: api.ts
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "effect/http-api"
export class UserNotFound extends Schema.TaggedError<UserNotFound>()(
"UserNotFound",
{ id: Schema.Int },
{ httpApiStatus: 404 }
) {}
export class UpstreamTimeout extends Schema.TaggedError<UpstreamTimeout>()(
"UpstreamTimeout",
{},
{ httpApiStatus: 504 }
) {}
export class UserView extends Schema.Class<UserView>("UserView")({
id: Schema.Int,
name: Schema.String
}) {}
export class UsersGroup extends HttpApiGroup.make("users").add(
HttpApiEndpoint.get("getById", "/users/:id", {
params: { id: Schema.Int },
success: UserView,
error: [UserNotFound, UpstreamTimeout]
}),
HttpApiEndpoint.post("create", "/users", {
payload: Schema.Struct({ name: Schema.NonEmptyString }),
success: UserView
})
) {}
export class Api extends HttpApi.make("app").add(UsersGroup) {}注意两件事。一是错误类直接带状态码:{ httpApiStatus: 404 } 作为 TaggedError 的第三个参数,状态码和错误是同一个事实,只写在一处。二是超时在这里是契约的一部分:UpstreamTimeout 显式出现在 error 列表里,对应 504。调用方在类型里就能看到「这个接口可能超时」,而不是靠运行时的一个 500 去猜。
服务端 handler 怎么实现,解码失败会怎样?
handler 的返回类型被端点定义约束:失败只能是声明过的错误,写一个没声明的错误类型会编译失败。
// file: server.ts
import { Effect, Layer, Ref } from "effect"
import { HttpRouter, HttpServer } from "effect/http"
import { HttpApiBuilder } from "effect/http-api"
import { Api, UpstreamTimeout, UserNotFound, UserView } from "./api.js"
export const UsersHandlers = HttpApiBuilder.group(
Api,
"users",
Effect.fn(function*(handlers) {
const store = yield* Ref.make(new Map<number, UserView>([[1, new UserView({ id: 1, name: "alice" })]]))
return handlers
.handle("getById", ({ params }) =>
Effect.gen(function*() {
const user = (yield* Ref.get(store)).get(params.id)
if (user === undefined) return yield* new UserNotFound({ id: params.id })
if (user.name === "slow") {
yield* Effect.never.pipe(Effect.timeout("50 millis"), Effect.mapError(() => new UpstreamTimeout()))
}
return user
}))
.handle("create", ({ payload }) =>
Ref.modify(store, (m) => {
const user = new UserView({ id: m.size + 1, name: payload.name })
return [user, new Map(m).set(user.id, user)]
}))
})
)
export const AppRoutes = HttpApiBuilder.layer(Api).pipe(Layer.provide(UsersHandlers))
export const web = HttpRouter.toWebHandler(AppRoutes.pipe(Layer.provide(HttpServer.layerServices)))handle 拿到的 params 和 payload 已经是解码后的值,params.id 是 number 而不是字符串。解码失败根本不会进入 handler:我实测 /users/abc 返回 400,name 为空的 POST 也是 400,没有声明的路由是 404,都不需要你写一行校验代码。
客户端怎么从同一份定义派生?
同一份 Api 可以直接生成客户端,错误通道里就是你声明过的 UserNotFound:
// file: serve.ts
import { NodeHttpServer } from "@effect/platform-node"
import { Effect, Layer } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpRouter } from "effect/http"
import { HttpApiClient } from "effect/http-api"
import { createServer } from "node:http"
import { Api } from "./api.js"
import { AppRoutes } from "./server.js"
const Server = HttpRouter.serve(AppRoutes).pipe(
Layer.provide(NodeHttpServer.layer(createServer, { port: 3456 }))
)
const program = Effect.gen(function*() {
const client = yield* HttpApiClient.make(Api, {
transformClient: HttpClient.mapRequest(HttpClientRequest.prependUrl("http://localhost:3456"))
})
const ok = yield* client.users.getById({ params: { id: 1 } })
const err = yield* client.users.getById({ params: { id: 7 } }).pipe(Effect.flip)
console.log(ok, err._tag, err instanceof Error)
}).pipe(Effect.provide(FetchHttpClient.layer))
await Effect.scoped(Layer.build(Server).pipe(Effect.andThen(program))).pipe(Effect.runPromise)我实际起了一个 Node 服务再用这个客户端调用,getById 对 id 7 得到的是 UserNotFound 这个类的实例,_tag 和字段都保留,不是一个只有状态码的通用异常。服务端的错误、传输的 JSON、客户端的错误是同一个 Schema 的两端,这是 HttpApi 相对于手写路由加手写 fetch 的根本差别:两边无法漂移。
线上你会用 Layer.launch 配 NodeRuntime.runMain 来启动,上面为了能在脚本里自己收尾,用的是 Layer.build 加 scope。@effect/platform-node 这类运行时适配仍是独立的包。
版本是 effect 4.0.5。api.ts、server.ts、serve.ts这条链路我在 Node 上跑过:/users/abc与空name的 POST 返回 400、未声明路由返回 404、id 7 在客户端得到UserNotFound实例。「OpenAPI 文档也从同一份定义派生」这一点来自模块的设计描述,我没有生成并检查过文档产物;Layer.launch加NodeRuntime.runMain的线上启动方式也没有跑过。这些模块的文档标注为 unstable,API 可能随小版本调整。