ci/woodpecker/push/main-amd64-build Pipeline was successful
- Group docs by category with an index (docs/README.md); fix every link, code comment and the protocol conformance test that read docs/protocol.md. - access.md and security.md rewritten to what is implemented: removed the unadopted member-proof chain, roles and recovery key that do not exist, the old implementation order and the budgets; gaps now carry a status. - enrollment, deployment, api: the install flow is the static, hash-checked script with environment variables; no default admin. - README and architecture: new topology and update-flow diagrams (operator passkey, CI signing, registry feed, auth and trust modules).
8.4 KiB
8.4 KiB
API 设计约束
本文是所有 Backend REST 端点都必须遵守的规则;具体端点见 api.md。规则参考了 Microsoft Azure REST API Guidelines 和 RFC 9457,并根据本项目做了取舍。内部协议(Probe↔Agent、Agent↔Backend)的兼容规则见 protocol.md 第 4 节。
1. 契约生成链路
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 放在
MoShiro.Backend.Api/Contracts/,并注册到 Backend 的 JSON 源生成上下文。不要直接暴露MoShiro.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) |
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),并附加扩展字段:
{
"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只在 400validation_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。
- 浏览器使用 Cookie 会话:
- 凭据不能放在 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. 新增端点检查清单
- 符合第 3 节的路径与方法规则,并有 operationId、summary 和 tag;
- 返回类型精确(
TypedResults),错误使用 problem+json,错误码来自ErrorCode; - DTO 符合第 4 节的数据表示规则,并注册到 JSON 源生成上下文;
- 列表支持分页,查询有上限;
- 选择正确的层(public / admin / agent),public 层只暴露可以公开的字段;
- 重新生成并提交
openapi/v1.json,在 PR 中说明是否属于破坏性变更; - 测试覆盖:成功路径、校验失败、404、鉴权失败、隐藏资源;
- 同步更新 api.md。