realworld 后端 API 响应格式规范:六大资源 JSON 结构详解与 Hurl 测试验证实践
【免费下载链接】realworld"The mother of all demo apps" — Exemplary fullstack Medium.com clone powered by React, Angular, Node, Django, and many more项目地址: https://gitcode.com/GitHub_Trending/re/realworld
本文围绕 realworld 官方文档docs/src/content/docs/specifications/backend/api-response-format.md展开,完整解读该 Medium 风格全栈应用对后端实现提出的响应格式契约:User、Profile、Article、Comment、Tags 六类 JSON 对象的结构、可空字段语义与时间戳格式;并结合 OpenAPI 规范 中的 schema 定义和 Hurl 测试套件 的真实断言,说明每一条格式要求是如何被自动化测试逐字段校验的。读完本文,你可以对照规范实现自己的后端,并用仓库自带的 Hurl 用例逐条验证响应结构是否合规。
契约地位:文档只是摘要,测试才是真相
在 后端规范入口文档 中明确声明:各文档页面只是对契约的文字摘要,真正定义契约的是 OpenAPI 规范 和 Hurl 测试套件——当文字描述与测试不一致时,以测试为准。因此本文在逐条解读响应结构的同时,均附上 Hurl 断言作为可执行依据。
所有后端实现首先必须满足的通用要求:
- Content-Type 必须正确:响应需返回
Content-Type: application/json; charset=utf-8。这是 api-response-format.md 对 "JSON Objects returned by API" 的第一条硬性要求。 - 顶层包装键(envelope key)固定:每个成功响应都用资源名单数或复数作为唯一顶层键,如
user、profile、article、articles、comment、comments、tags。这一命名与 OpenAPI 中各 Response schema 的required字段一一对应,例如UserResponse要求顶层必含user,MultipleArticlesResponse要求必含articles和articlesCount(见 openapi.yml 中components.responses的定义)。 - 字段命名采用 camelCase:如
tagList、createdAt、favoritesCount,不要用 snake_case。 - 时间戳采用 ISO 8601(date-time):OpenAPI 中
createdAt/updatedAt均声明为format: date-time,例如2016-02-18T03:22:56.637Z。Hurl 测试通过正则^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}来校验该格式。 - 可空字段返回 JSON
null而非缺省或空字符串:bio、image等字段在 User 和 Profile 对象中允许为null(OpenAPI 中声明为type: [string, 'null'])。
User:认证相关响应对象
用于注册(POST /api/users)、登录(POST /api/users/login)、获取当前用户(GET /api/user)和更新用户(PUT /api/user)四类端点,标准响应如下:
{ "user": { "email": "jake@jake.jake", "token": "jwt.token.here", "username": "jake", "bio": null, "image": null } }结合 OpenAPI 的 User schema 可以确认字段要求:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
email | string | 是 | 用户邮箱 |
token | string | 是 | JWT 令牌,用于后续受保护请求的Authorization: Token xxx头 |
username | string | 是 | 用户名 |
bio | string | null | 是 | 个人简介,可为null |
image | string | null | 是 | 头像 URL,可为null |
三个字段语义要点,均来自 Hurl 测试 auth.hurl 的实际断言:
bio与image未设置时必须是null:注册和登录响应的断言为$.user.bio == null、$.user.image == null,而不是省略该键。- 空字符串会被规范化为
null:Hurl 中专门有 "Update user bio to empty string - should normalize to null" 用例,PUT /api/user提交bio: ""后断言$.user.bio == null,且后续GET /api/user验证该规范化结果被持久化。image同理。 token必须是非空字符串:断言为$.user.token isString且$.user.token not isEmpty。测试流程正是靠这一字段做后续请求的鉴权([Captures] reg_token: jsonpath "$.user.token")。
Profile:用户公开资料对象
Profile 是 User 的"对外视图"——不包含email和token,额外增加following布尔量:
{ "profile": { "username": "jake", "bio": "I work at statefarm", "image": "https://api.realworld.io/images/smiley-cyrus.jpg", "following": false } }对应端点:获取资料(GET /api/profiles/{username},鉴权可选)、关注(POST /api/profiles/{username}/follow)、取关(DELETE /api/profiles/{username}/follow,鉴权必须)。OpenAPI 中Profileschema 的必填字段为bio、following、image、username,其中bio和image允许null,following为 boolean。
Profile 还以嵌套对象形式出现在 Article 和 Comment 中(见下文的author字段),其following值取决于当前请求是否携带用户身份:未鉴权时通常为false。这意味着你的后端在渲染文章、评论列表时,需要按"当前观察者"计算following,而不是直接复存数据库值。
Single Article:单篇文章响应对象
文章创建(POST /api/articles,201)、读取(GET /api/articles/{slug},200)、更新(PUT /api/articles/{slug},200)、收藏/取消收藏(POST|DELETE /api/articles/{slug}/favorite,200)均返回该结构:
{ "article": { "slug": "how-to-train-your-dragon", "title": "How to train your dragon", "description": "Ever wonder how?", "body": "It takes a Jacobian", "tagList": ["dragons", "training"], "createdAt": "2016-02-18T03:22:56.637Z", "updatedAt": "2016-02-18T03:48:35.824Z", "favorited": false, "favoritesCount": 0, "author": { "username": "jake", "bio": "I work at statefarm", "image": "https://i.stack.imgur.com/xHWG8.jpg", "following": false } } }OpenAPIArticleschema 的必填字段为:author、body、createdAt、description、favorited、favoritesCount、slug、tagList、title、updatedAt。几个容易被实现者忽略的行为,articles.hurl 都有对应断言:
tagList顺序保留且是字符串数组:创建时提交的["d_xxx", "t_xxx"]在响应中被断言为tagList[0] == "d_xxx"、tagList[1] == "t_xxx"。- 新建文章初始状态:
favorited == false、favoritesCount == 0、author.username等于创建者。 - 更新时间戳语义:更新正文后断言
$.article.createdAt保持不变、$.article.updatedAt发生变化(测试先[Captures]捕获创建时的两个时间戳,再在PUT后比对)。 - 更新时省略
tagList则保留原标签:仅提交body的PUT请求后,断言tagList仍为 2 项且内容不变;而显式提交tagList: []表示清空,tagList: null则应被拒绝(Hurl 断言该请求返回 422)。
Multiple Articles:文章列表响应对象与 body 移除规则
列表端点GET /api/articles(支持tag/author/favorited过滤参数及offset/limit分页)与GET /api/articles/feed返回如下结构:
{ "articles": [{ "slug": "how-to-train-your-dragon", "title": "How to train your dragon", "description": "Ever wonder how?", "tagList": ["dragons", "training"], "createdAt": "2016-02-18T03:22:56.637Z", "updatedAt": "2016-02-18T03:48:35.824Z", "favorited": false, "favoritesCount": 0, "author": { "username": "jake", "bio": "I work at statefarm", "image": "https://i.stack.imgur.com/xHWG8.jpg", "following": false } }, { "slug": "how-to-train-your-dragon-2", "title": "How to train your dragon 2", "description": "So toothless", "tagList": ["dragons", "training"], "createdAt": "2016-02-18T03:22:56.637Z", "updatedAt": "2016-02-18T03:48:35.824Z", "favorited": false, "favoritesCount": 0, "author": { "username": "jake", "bio": "I work at statefarm", "image": "https://i.stack.imgur.com/xHWG8.jpg", "following": false } }], "articlesCount": 2 }关键变更(原文档的 caution 提示):自 2024-08-16 起,出于性能考虑,获取文章列表的端点不再返回文章的body字段。受影响的端点为:
GET /api/articlesGET /api/articles/feed
这一契约变更在仓库中有两处可交叉验证的证据:
- OpenAPI 规范 的
MultipleArticlesResponse中,articles数组内联定义的必填字段为author、createdAt、description、favorited、favoritesCount、slug、tagList、title、updatedAt——没有body;而SingleArticleResponse引用的Articleschema 是包含body的。 - articles.hurl 对全部五种列表场景(无鉴权全局列表、按作者过滤、带鉴权列表、带鉴权按作者过滤、按 tag 过滤)都显式断言
jsonpath "$.articles[0].body" not exists,同时逐字段校验title/slug/description为字符串、tagList为列表、时间戳匹配 ISO 8601 正则、favorited为布尔、favoritesCount为整数。
分页参数在 OpenAPI 的 parameters 定义 中有约束:offset为不小于 0 的整数(跳过前 N 条),limit为不小于 1 的整数、默认 20。articlesCount表示满足过滤条件的总条数,与当前页的articles长度无关。仓库中 pagination.hurl 用limit=1配合offset逐页取回文章,验证了"最近优先 + 分页"的排序语义。
Comments:单条与多条评论响应对象
单条评论(创建POST /api/articles/{slug}/comments返回 201):
{ "comment": { "id": 1, "createdAt": "2016-02-18T03:22:56.637Z", "updatedAt": "2016-02-18T03:22:56.637Z", "body": "It takes a Jacobian", "author": { "username": "jake", "bio": "I work at statefarm", "image": "https://i.stack.imgur.com/xHWG8.jpg", "following": false } } }多条评论(GET /api/articles/{slug}/comments,鉴权可选):
{ "comments": [{ "id": 1, "createdAt": "2016-02-18T03:22:56.637Z", "updatedAt": "2016-02-18T03:22:56.637Z", "body": "It takes a Jacobian", "author": { "username": "jake", "bio": "I work at statefarm", "image": "https://i.stack.imgur.com/xHWG8.jpg", "following": false } }] }OpenAPICommentschema 必填字段为author、body、createdAt、id、updatedAt,其中id为整数(Hurl 断言$.comment.id isInteger)。comments.hurl 的验证要点:
- 创建评论后按
id捕获变量,用于后续DELETE /api/articles/{slug}/comments/{id}(204 无响应体)。 - 列表断言
$.comments为列表且元素级校验id/body/时间戳格式/author.username,并区分了带鉴权与不带鉴权两种列表请求——两种情况下响应结构必须一致。
List of Tags:标签列表响应对象
GET /api/tags(无需鉴权)返回字符串数组,且 OpenAPITagsResponse要求顶层键tags必须存在:
{ "tags": [ "reactjs", "angularjs" ] }tags.hurl 的测试流程展示了该端点的典型用法:先注册用户并创建带标签的文章(tagList: ["h_xxx", "t_xxx"]),再断言$.tags为列表、长度不小于 1、包含刚创建的两个标签,且各元素为字符串。
错误响应:统一的多态错误信封
Error handling 文档 定义了与上述成功响应并列的失败响应格式——所有校验失败返回422,其余状态码为401(未提供鉴权)、403(无权操作)、404(资源不存在)。错误体统一为:
{ "errors":{ "body": [ "can't be empty" ] } }即顶层errors对象的键是出错字段/资源名,值为字符串数组。OpenAPI 的GenericErrorModelschema 与此一致(errors为键到字符串数组的对象)。实际状态码对应的键名约定可从 OpenAPI 各错误响应示例与 Hurl 断言中读出:
| 状态码 | 场景 | 键名示例 | 依据 |
|---|---|---|---|
| 401 | 缺少有效 token | token: ["is missing"] | OpenAPIUnauthorized响应示例 |
| 409 | 注册时用户名/邮箱已被占用 | username: ["has already been taken"] | OpenAPIConflictError示例 |
| 404 | 资源不存在 | article: ["not found"]、resource: ["not found"] | OpenAPINotFound示例;articles.hurl 删除后断言$.errors.article[0] == "not found" |
| 422 | 字段校验失败 | title: ["can't be blank"]等 | OpenAPIGenericError示例 |
错误键名在 404/403 场景下"标识资源类型"(article、comment、profile 等)这一点,直接写在 OpenAPI 对NotFound/Forbidden响应的 description 中,实现时请让键名与资源类型对应,方便前端和测试稳定解析。
用 Hurl 测试套件验证你的实现
仓库自带的 Hurl 集合覆盖了上述全部响应格式,是契约的可执行形态。运行方式(见 run-api-tests-hurl.sh):
# 安装 hurl 后,指向你的本地 API 运行全部用例 HOST=http://localhost:8000 ./specs/api/run-api-tests-hurl.sh # 或只跑与响应格式直接相关的文件 HOST=http://localhost:8000 ./specs/api/run-api-tests-hurl.sh specs/api/hurl/auth.hurl specs/api/hurl/articles.hurl脚本细节:默认HOST为http://localhost:8000;每轮运行自动生成uid变量(时间戳+进程号),使各用例注册互不冲突的用户;以--test --jobs 1串行执行,保证各文件内的前置请求(注册、建文章)先于断言发生。你也可以用 Bruno 集合 在 GUI 中逐条查看等效请求,两套工具由 hurl-to-bruno.js 转换保持同步。
对照检查清单(实现者视角):
- 所有成功响应是否为
application/json; charset=utf-8; - 顶层包装键(
user/profile/article/articles/comment/comments/tags)是否拼写正确; bio/image空值是否输出null,且提交空字符串时是否被规范化为null并持久化;- 时间戳是否符合
YYYY-MM-DDTHH:mm:ss(date-time)格式; GET /api/articles与GET /api/articles/feed是否已剔除body,且articlesCount始终返回;- 列表接口是否支持
offset(>=0)/limit(>=1,默认 20); - 错误响应是否为 422/401/403/404 +
{"errors": {key: [messages]}}信封。
只要这七条全部通过 Hurl 断言,你的后端就满足了 api-response-format.md 定义的完整响应契约,可以与 realworld 生态中的任何前端实现互通。
【免费下载链接】realworld"The mother of all demo apps" — Exemplary fullstack Medium.com clone powered by React, Angular, Node, Django, and many more项目地址: https://gitcode.com/GitHub_Trending/re/realworld
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考