142 lines
8.4 KiB
Markdown
142 lines
8.4 KiB
Markdown
# API 设计约束
|
||
|
||
本文是所有 Backend REST 端点都必须遵守的**规则**;具体端点见 [api.md](api.md)。规则参考了 [Microsoft Azure REST API Guidelines](https://github.com/microsoft/api-guidelines/blob/vNext/azure/Guidelines.md) 和 RFC 9457,并根据本项目做了取舍。内部协议(Probe↔Agent、Agent↔Backend)的兼容规则见 [protocol.md](protocol.md) 第 4 节。
|
||
|
||
## 1. 契约生成链路
|
||
|
||
```text
|
||
Minimal API 端点(TypedResults 返回类型 + 显式元数据)
|
||
│ dotnet build(Microsoft.Extensions.ApiDescription.Server)
|
||
▼
|
||
backend/openapi/v1.json(OpenAPI 3.1,提交到仓库)
|
||
│ CI:git diff --exit-code,未提交的变更会导致失败
|
||
▼
|
||
web:openapi-typescript 生成类型,调用方的类型错误会直接暴露
|
||
```
|
||
|
||
- 端点**必须**声明精确的返回类型,例如 `Results<Ok<AgentDto>, NotFound<ProblemDetails>>`,不能返回 `IResult` 或匿名对象,否则 OpenAPI 文档会不准确。
|
||
- 每个端点都必须设置 `WithName("agents_list")`(即 operationId,snake_case、`资源_动作`)、`WithSummary`、`WithTags`。
|
||
- 请求和响应 DTO 放在 `Monitor.Backend.Api/Contracts/`,并注册到 Backend 的 JSON 源生成上下文。**不要直接暴露 `Monitor.Protocol` 中的线协议类型**,因为 REST 与线协议的演进节奏不同。
|
||
- **结构强类型,指标弱类型**:资源结构(Agent、任务、测试等)必须是强类型 DTO;而**指标值和评分**统一使用字典 `metrics: { "<metric_id>": number }`、`scores: { "<model_id>": number }`,键的含义、单位、版本由 `/api/public/catalog/*` 给出。这样新增指标或模型不需要改 OpenAPI,也不需要改前端类型。
|
||
|
||
## 2. 版本与兼容性
|
||
|
||
- 当前版本为 v1,路径前缀为 `/api/public`、`/api/admin`、`/api/agent/v1`。出现破坏性变更时,新增 `/api/v2/public`、`/api/v2/admin` 等路径。v1 内只允许**非破坏性变更**:
|
||
|
||
| 非破坏性变更(v1 内允许) | 破坏性变更(需要新建 v2) |
|
||
| --- | --- |
|
||
| 新增端点 | 删除或重命名端点、字段 |
|
||
| 响应中新增字段 | 修改字段类型、单位或语义 |
|
||
| 新增可选的请求字段或查询参数 | 可选字段改为必填 |
|
||
| 枚举新增值 | 删除枚举值 |
|
||
| 放宽校验 | 收紧校验、修改默认值 |
|
||
| 新增错误码 | 修改已有错误码对应的 HTTP 状态 |
|
||
|
||
- 客户端必须忽略未知字段,也必须能处理未知的枚举值。
|
||
- 需要 v2 时,v1 和 v2 至少并存一个发布周期;OpenAPI 文档按版本分文件(`v1.json`、`v2.json`)。
|
||
|
||
## 3. URL 与方法
|
||
|
||
- **按调用方分层**:`/api/public`(只读展示)、`/api/admin`(管理)、`/api/agent`(节点)。同一个资源在不同层中可以有不同的 DTO,例如 public 层不暴露 `pubkey`、`local_limits`。
|
||
- 路径全部小写,资源名用复数名词,多个单词用 `-` 连接:`/api/admin/join-tokens`。
|
||
- 嵌套最多一层;子资源一旦有全局唯一 ID,就提供顶层路径(`/probes/{probe_id}`)。
|
||
- 查询参数和 JSON 字段使用 snake_case。
|
||
|
||
| 操作 | 方法 | 成功状态 | 说明 |
|
||
| --- | --- | --- | --- |
|
||
| 列表 | GET 集合 | 200 | 分页 |
|
||
| 读取 | GET 单个 | 200 | |
|
||
| 创建 | POST 集合 | 201 + `Location` | 响应体返回完整资源 |
|
||
| 部分更新 | PATCH | 200 | `application/merge-patch+json`(RFC 7396) |
|
||
| 删除 | DELETE | 204 | 资源不存在时同样返回 204(幂等) |
|
||
| 非 CRUD 动作 | POST `/{resource}/{id}/{verb}` | 200 或 204 | 例如 `revoke`、`rotate-token` |
|
||
|
||
- GET 请求不能产生副作用。PUT 不使用:配置类资源都通过 PATCH 修改。
|
||
|
||
## 4. 数据表示
|
||
|
||
| 类别 | 规则 | 示例 |
|
||
| --- | --- | --- |
|
||
| 时间点 | RFC 3339 UTC,以 `Z` 结尾,精确到毫秒。**唯一例外**:列式时序响应中的时间轴数组 `t` 使用 Unix 毫秒(见 [query.md](query.md)) | `2026-09-27T10:00:00.000Z` |
|
||
| 时长 | 整数 + 单位后缀 | `interval_ms: 1000` |
|
||
| 延迟 | 带小数的毫秒值(指标字典中的单位见目录) | `rtt_p95_ms: 58.93` |
|
||
| 百分比 | 0–100 的数字 + `_pct` | `loss_pct: 0.5` |
|
||
| 计数 | 整数,不使用浮点数 | `attempts: 600` |
|
||
| ID | 带类型前缀的 ULID 字符串 | `prb_01J9Z...` |
|
||
| 枚举 | snake_case 字符串 | `"udp_echo"` |
|
||
| 缺失值 | 字段始终存在;未知值写 `null`,不省略字段 | `"last_seen_at": null` |
|
||
| 布尔值 | 使用 `is_`/`has_` 前缀,或者用意义明确的形容词 | `online`、`enabled` |
|
||
|
||
## 5. 错误
|
||
|
||
所有 4xx/5xx 响应都使用 `application/problem+json`(RFC 9457),并附加扩展字段:
|
||
|
||
```json
|
||
{
|
||
"type": "about:blank",
|
||
"title": "Validation failed",
|
||
"status": 400,
|
||
"code": "validation_failed",
|
||
"detail": "interval_ms must be >= 200",
|
||
"trace_id": "00-4bf92f...-01",
|
||
"errors": { "interval_ms": ["must be >= 200"] }
|
||
}
|
||
```
|
||
|
||
- `code` 取自共享的 `ErrorCode` 枚举,是客户端做判断的**唯一**依据;`title`/`detail` 只供人阅读,内容可能变化。
|
||
- `errors` 只在 400 `validation_failed` 中出现,键名是 JSON 字段路径。
|
||
- 5xx 响应不能泄露异常信息或调用栈,只提供 `trace_id`,用于在日志中定位问题。
|
||
|
||
| 状态码 | 使用场景 |
|
||
| --- | --- |
|
||
| 400 | 请求格式错误或校验失败 |
|
||
| 401 | 未认证 |
|
||
| 404 | 资源不存在 |
|
||
| 403 | 已认证但作用域不足 |
|
||
| 409 | 名称冲突、状态冲突(例如 `probe_offline`) |
|
||
| 410 | 资源曾经存在但已过保留期(例如 `raw_expired`) |
|
||
| 412 | `If-Match` 不匹配 |
|
||
| 413 | 请求体超过上限 |
|
||
| 422 | 语义上无法满足(例如 `range_too_large`) |
|
||
| 429 | 超过限流,需要带 `Retry-After` |
|
||
| 503 | 存储不可用,或正在关闭 |
|
||
|
||
## 6. 集合与查询
|
||
|
||
- 分页由服务端驱动:`?limit=`(默认 50,最大 500)和 `?cursor=`(不透明字符串);响应格式为 `{ "items": [...], "next_cursor": string | null }`。排序必须稳定(按 ID 或时间 + ID)。
|
||
- 时间范围参数统一使用 `from`/`to`,区间为左闭右开 `[from, to)`。缺省时 `to = now`,`from = to − 1h`。
|
||
- 所有查询都有上限(点数、时间跨度、结果条数);超出时返回 422 并在 `detail` 中给出可行的替代方案,不能悄悄截断。
|
||
|
||
## 7. 并发控制
|
||
|
||
- 配置类资源(Agent、Probe、任务)在响应中返回 `ETag`,PATCH/DELETE 支持 `If-Match`,不匹配时返回 412。没有带 `If-Match` 的请求按“最后写入者获胜”处理。
|
||
|
||
## 8. 安全
|
||
|
||
- 只提供 HTTPS。认证方式有两种:
|
||
- 浏览器使用 Cookie 会话:`HttpOnly; Secure; SameSite=Strict`;所有非 GET 请求都必须带 `X-CSRF-Token` 请求头;
|
||
- 脚本使用 `Authorization: Bearer <api_token>`,作用域为 `read` 或 `admin`。
|
||
- 凭据不能放在 URL 或查询参数中;SSE 依靠 Cookie 或同源令牌认证。
|
||
- 登录接口需要限流(例如每个 IP 每分钟 10 次),失败时统一返回 401,不区分“用户不存在”和“密码错误”。
|
||
- public 层根据站点设置和 `public_visible` 过滤数据:被隐藏的资源对匿名访客一律返回 404,而不是 403。
|
||
- 令牌明文只在创建或轮换的响应中出现一次;这类响应带 `Cache-Control: no-store`。
|
||
- 请求体上限为 64 KiB;查询接口有超时(默认 10 秒)。
|
||
|
||
## 9. 实时推送(SSE)
|
||
|
||
- 实时数据使用 `text/event-stream`。`event` 名称就是数据类型(`line`、`node`、`point`、`test`);`data` 是单行 JSON,遵守第 4 节的数据表示规则。
|
||
- 每 15 秒发送一行注释作为心跳。重连后服务端先推送当前快照,再推送增量,不补发历史。
|
||
- 订阅的主题数和连接数都有上限,超出时返回 429 `too_many_streams`。
|
||
- 推送的内容必须能由对应的 REST 查询重建:SSE 只负责“更快地获得变化”,不承载独有的数据。
|
||
|
||
## 10. 新增端点检查清单
|
||
|
||
1. 符合第 3 节的路径与方法规则,并有 operationId、summary 和 tag;
|
||
2. 返回类型精确(`TypedResults`),错误使用 problem+json,错误码来自 `ErrorCode`;
|
||
3. DTO 符合第 4 节的数据表示规则,并注册到 JSON 源生成上下文;
|
||
4. 列表支持分页,查询有上限;
|
||
5. 选择正确的层(public / admin / agent),public 层只暴露可以公开的字段;
|
||
6. 重新生成并提交 `openapi/v1.json`,在 PR 中说明是否属于破坏性变更;
|
||
7. 测试覆盖:成功路径、校验失败、404、鉴权失败、隐藏资源;
|
||
8. 同步更新 [api.md](api.md)。
|