第3章:服务与 API —— 类型化端点、路径参数与错误处理
约 5 分钟 · 更新于 2026-09-02
API 是 Encore 的第一原语。本章覆盖 api() 的全部选项、四种签名、路径语法、自定义状态码、17 个标准错误码,以及 api.raw 与 api.static 两个特殊形态。
一、服务 (Service)
服务是 Encore 的部署与组织单元:一个目录 + 一个 encore.service.ts = 一个服务。
typescript
// order/encore.service.ts
import { Service } from "encore.dev/service";
export default new Service("order");
规则:
- 服务名小写、能描述领域(order、payment,不要 service1);
- 服务不能嵌套——order/refund/encore.service.ts 会报错;服务内部组织代码用普通子目录;
- 目录下所有 .ts 文件里被 api() 包装的导出,自动归属这个服务。
单服务还是多服务?第 6 章给决策表;起步建议先单服务,出现明确边界再拆。
二、api():类型化端点
typescript
import { api } from "encore.dev/api";
interface CreateOrderRequest {
flightNo: string;
passengerName: string;
}
interface CreateOrderResponse {
orderId: string;
status: string;
}
// 创建订单
export const createOrder = api(
{ method: "POST", path: "/orders", expose: true },
async (req: CreateOrderRequest): Promise<CreateOrderResponse> => {
// 业务逻辑
return { orderId: "ORD123", status: "created" };
},
);
一个端点 = 选项对象 + 类型化异步函数。请求/响应类型就是普通 TypeScript 接口——它们同时是:运行时校验 schema(Rust 层执行)、API 文档、生成客户端的类型来源。
全部选项
| 选项 | 类型 | 说明 |
|---|
| method | string | HTTP 方法:GET / POST / PUT / PATCH / DELETE,"*" 匹配所有 |
| path | string | URL 路径,支持 :param 与 *wildcard;省略则默认 /服务名.函数名 |
| expose | boolean | 默认 false:仅服务间可调。true 才对公网开放 |
| auth | boolean | true 要求请求通过鉴权处理器(第 10 章) |
| sensitive | boolean | true 时该端点的请求/响应在追踪中自动脱敏——支付、实名信息类接口必配 |
| tags | string[] | 打标签,供中间件按标签定向(第 12 章) |
expose: false 默认私有是安全设计
内部 API 想被外网调必须显式声明。对照传统框架"写了路由就是公网可达",这条默认值直接消灭一类误暴露事故。值得抄进任何技术栈的团队规范。
四种签名
typescript
// 1. 请求 + 响应
export const createUser = api(
{ method: "POST", path: "/users", expose: true },
async (req: CreateRequest): Promise<CreateResponse> => { /* ... */ },
);
// 2. 只有响应(GET 列表类)
export const listUsers = api(
{ method: "GET", path: "/users", expose: true },
async (): Promise<ListResponse> => { /* ... */ },
);
// 3. 只有请求(删除类)
export const deleteUser = api(
{ method: "DELETE", path: "/users/:id", expose: true },
async (req: DeleteRequest): Promise<void> => { /* ... */ },
);
// 4. 都没有(健康检查类)
export const ping = api(
{ method: "GET", path: "/ping", expose: true },
async (): Promise<void> => { /* ... */ },
);
也可以用泛型参数形式 api<Req, Resp>(options, handler),两种写法等价。
三、路径参数
typescript
// :param —— 单段占位,映射到请求接口的同名字段
export const getOrder = api(
{ method: "GET", path: "/orders/:id", expose: true },
async ({ id }: { id: string }): Promise<Order> => { /* ... */ },
);
// *wildcard —— 通配剩余所有段
export const proxy = api(
{ method: "GET", path: "/files/*path", expose: true },
async ({ path }: { path: string }): Promise<FileInfo> => { /* ... */ },
);
路径字段类型可以是 string 或 number(自动转换并校验)。路径、查询串、请求头、请求体四种参数来源的完整规则在第 4 章。
四、自定义 HTTP 状态码
默认成功返回 200。需要别的状态码时,在响应接口里加一个 HttpStatus 类型字段:
typescript
import { api, HttpStatus } from "encore.dev/api";
interface CreateResponse {
id: string;
status: HttpStatus;
}
export const create = api(
{ method: "POST", path: "/items", expose: true },
async (req: CreateRequest): Promise<CreateResponse> => {
const item = await createItem(req);
return { id: item.id, status: HttpStatus.Created }; // 返回 201
},
);
五、错误处理:APIError 与 17 个标准错误码
Encore 的错误约定:抛 APIError,不要返回错误对象。
typescript
import { APIError, ErrCode } from "encore.dev/api";
// 完整形式
throw new APIError(ErrCode.NotFound, "order not found");
// 简写(推荐)
throw APIError.notFound("order not found");
throw APIError.invalidArgument("flightNo is required");
throw APIError.unauthenticated("invalid token");
客户端收到的统一错误格式:
json
{
"code": "not_found",
"message": "order not found",
"details": null
}
错误码全表(名称 → 线上字符串 → HTTP 状态)
| ErrCode | 字符串 | HTTP | 典型场景 |
|---|
| OK | ok | 200 | — |
| Canceled | canceled | 499 | 客户端断开 |
| Unknown | unknown | 500 | 未归类错误 |
| InvalidArgument | invalid_argument | 400 | 参数不合法 |
| DeadlineExceeded | deadline_exceeded | 504 | 超时 |
| NotFound | not_found | 404 | 资源不存在 |
| AlreadyExists | already_exists | 409 | 重复创建 |
| PermissionDenied | permission_denied | 403 | 已登录但无权限 |
| ResourceExhausted | resource_exhausted | 429 | 限流/配额 |
| FailedPrecondition | failed_precondition | 400 | 状态机不允许(如"未支付订单不能退票") |
| Aborted | aborted | 409 | 并发冲突 |
| OutOfRange | out_of_range | 400 | 越界 |
| Unimplemented | unimplemented | 501 | 未实现 |
| Internal | internal | 500 | 内部错误 |
| Unavailable | unavailable | 503 | 依赖不可用 |
| DataLoss | data_loss | 500 | 数据损坏 |
| Unauthenticated | unauthenticated | 401 | 未登录/凭据无效 |
附加结构化细节用 withDetails:
typescript
throw APIError.failedPrecondition("order not payable").withDetails({
orderId: id,
currentStatus: "CANCELLED",
});
业务错误码选型经验
订单状态不允许操作用 FailedPrecondition 而不是 InvalidArgument——参数本身是合法的,是状态不满足前置条件。并发写冲突用 Aborted。这套错误码源自 Google API 设计规范 (google.rpc.Code),跨语言通用。
六、api.raw:接管原始 HTTP
需要直接控制请求/响应时(第三方 webhook 验签、自定义响应格式、接 GraphQL 服务器)用 api.raw,签名是 Node.js 风格的 (req, resp):
typescript
import { api } from "encore.dev/api";
// 接收支付网关回调:需要读原始 body 验签
export const paymentCallback = api.raw(
{ expose: true, path: "/callback/payment", method: "POST" },
async (req, resp) => {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as Buffer);
const rawBody = Buffer.concat(chunks).toString("utf8");
if (!verifySignature(req.headers["x-signature"] as string, rawBody)) {
resp.writeHead(401);
resp.end("invalid signature");
return;
}
// ……处理业务……
resp.writeHead(200, { "Content-Type": "application/json" });
resp.end(JSON.stringify({ received: true }));
},
);
raw 端点绕过类型校验,但仍在追踪与中间件覆盖内。GraphQL 接入(Apollo Server 挂在 raw 端点上)官方有完整模板,属于同一模式。
七、api.static:静态文件与 SPA 托管
typescript
import { api } from "encore.dev/api";
// 前缀托管:./assets 下的文件挂到 /static/* 下
export const assets = api.static(
{ expose: true, path: "/static/*path", dir: "./assets" },
);
// 根路径兜底托管(SPA 场景):注意是 !path 不是 *path
export const frontend = api.static(
{ expose: true, path: "/!path", dir: "./dist", notFound: "./dist/index.html" },
);
- *path:标准通配,占住整个前缀;
- !path:兜底路由——只接住没有被任何其他 API 匹配的路径,因此可以安全挂在根路径 /,单页应用 (SPA) 前端和 API 共存一个域名;
- notFound:自定义 404 页面;目录根的 index.html 自动服务。
静态目录是构建期依赖(本机 B2B 项目踩坑)
api.static 声明的 dir 与 notFound 文件在静态分析期就必须存在——干净 clone 下来没有前端构建产物时,整个应用直接构建失败。解法:把占位 index.html 提交进仓库。
八、跨服务调用预告
其他服务的 API 通过 ~encore/clients 以函数调用形式使用(第 6 章详解):
typescript
import { order } from "~encore/clients";
const detail = await order.getOrder({ id: "ORD123" });
九、常见误区
- 忘写 expose: true 然后奇怪"为什么 curl 404"——默认私有。
- 用返回值传错误({ success: false })——破坏错误码语义、客户端类型和追踪标记,一律抛 APIError。
- 所有 4xx 都用 invalidArgument——状态类错误应该用 FailedPrecondition / Aborted,见错误码表。
- 支付/实名接口忘了 sensitive: true——载荷会被明文记进 trace。
- 用 api.raw 逃避类型系统写普通业务接口——raw 只留给 webhook / 特殊响应格式。
- SPA 托管用 *path 挂根路径——会吃掉所有 API 路由,必须用 !path。
十、实践练习
- 写一个 flight 服务,含 GET /flights/:flightNo(查航班)与 POST /flights(录入航班),在 API Explorer 里调通。
- 给查询端点加"航班不存在→APIError.notFound"与"航班号格式非法→invalidArgument"两种错误,用 curl 验证响应体里的 code 字段。
- 创建接口返回 201:给响应加 HttpStatus.Created。
- 写一个 api.raw 端点接收模拟支付回调,读取原始 body 并原样回显 JSON。
- 把任意前端构建产物(或一个手写 index.html)用 api.static + /!path 挂到根路径,确认它与 /flights API 共存不冲突。
十一、总结
- 服务 = 目录 + encore.service.ts,不可嵌套;API = api(选项, 类型化异步函数)。
- 六个选项里最重要的三个:expose 默认私有、auth 接鉴权、sensitive 追踪脱敏。
- 请求/响应接口一式三用:运行时校验、文档、客户端类型。
- 错误处理只有一条路:抛 APIError,17 个标准码映射 HTTP 状态,withDetails 带结构化细节。
- api.raw 接管原始 HTTP(webhook/GraphQL),api.static 托管静态资源(SPA 用 !path 兜底)。
请继续阅读:第4章:请求校验。
原始资料引用