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 可能随小版本调整。