Repository navigation
[openapi] A handler cannot declare its own status codes or content types #76
Description
Problem
Field report from porting a ~60-route application (remote-hand) from @hono/zod-openapi to gotcha 0.4.1.
A route cannot declare the status codes it actually returns. POST /api/accounts answers 201 on success and 409 on a duplicate; GET /api/sessions/{id} answers 404. gotcha derives exactly one success response, always 200, and folds a Result's error side into a single default entry — so all three of those routes document the same thing, and the generated TypeScript client is wrong about every one of them.
Three separate walls, all in gotcha_core-0.4.1/src/responsible.rs:
-
The success status is hardcoded.
json_response::<T>()inserts the key"200"(responsible.rs:29-45). There is no201, no204, no way to say otherwise. -
A type cannot be both
Schematicand custom-Responsible.impl<T: Schematic> Responsible for T(responsible.rs:82-96) is a blanket. A local wrapper that does not implementSchematiccan carry its ownResponsible— verified, that compiles. But the moment it also derivesSchematic(which is how you get the body type intocomponents), you get:error[E0119]: conflicting implementations of trait `Responsible` for type `Created<_>` note: conflicting implementation in crate `gotcha_core`: - impl<T> Responsible for T where T: Schematic;And the hand-written path is bare:
json_responseis private (responsible.rs:23), so implementingResponsibleyourself means constructingoas::Responses/Response/MediaType/Referenceableby hand, with no helper exposed and no example anywhere in the crate. -
defaultis mandatory and unremovable.impl<E: Schematic> ErrorResponsible for Eunconditionally doesresponses.default = Some(..)(responsible.rs:128-132). AResult<Json<T>, ApiError>handler always gets adefaultentry, even when the real contract is a specific404. -
The content type is always
application/json. The samejson_response::<T>()hardcodes the media-type key (responsible.rs:34). A file-download route servingapplication/octet-streamhas no way to say so.
#33 closed on "a Result<Json<T>, ApiError> handler documents both the success and the error response", which does work. Its own Direction section also listed "Let handlers / #[api] declare status codes and per-response descriptions" — that half did not land, and it is the half a real application needs.
Where
gotcha_core-0.4.1/src/responsible.rs:29-45—"200"hardcodedgotcha_core-0.4.1/src/responsible.rs:82-96— blanketimpl<T: Schematic> Responsible for Tgotcha_core-0.4.1/src/responsible.rs:128-132—defaultunconditionally set
How remote-hand works around it
Two modules, both pure workaround:
server-rs/src/api/created.rs— aCreated<T>wrapper that implementsIntoResponseas(StatusCode::CREATED, Json(..))and delegatesSchematictoT, so the derived response is right about the body and wrong about the status. DelegatingSchematicis what forecloses a customResponsible(point 2 above), and hand-rolling one would have meant hand-rollingoas::Responses.server-rs/src/openapi_ext.rs— a declarative registry of(method, path, status) -> body typepatches applied in the single allowedGotchaRouter::openapi(|spec| ..)pass. It inserts the missing404/409, moves the derived200to201, and carries adrop_defaultflag whose only job is deleting thedefaultentry gotcha insists on.
For the octet-stream case, server-rs/src/files/mod.rs wraps the response in a FileBytes newtype with a hand-written Schematic that registers no component (type: string, format: binary), and the same registry swaps the JSON 200 for the application/octet-stream entry.
A test (server-rs/tests/probe.rs) asserts the patches took effect, because nothing else would catch it if an upgrade changed the derivation.
Suggested direction
- An
#[api(responses(404 = ApiError, 409 = ApiError))]form on the handler attribute, merged into the derived responses. - A status-carrying return wrapper in gotcha itself (
Created<T>,WithStatus<T, const S: u16>) so the success key is not always"200". - Either narrow the blanket
Responsibleimpl or provide an explicit escape hatch, so downstream crates can describe their own response types — which would also cover non-JSON bodies. - A way to suppress the
defaultentry when the handler enumerates its errors explicitly.
Activity
Kilerd commentedon Sep 13, 2026
本轮架构审查补充:已有 String 返回类型的媒体类型就不匹配
在 commit 4aa39cf 的本地源码上实际运行确认:
不只是缺少自定义响应能力,现有
impl<T: Schematic> Responsible for T已将“数据类型能生成 schema”错误推导为“通过 JSON 传输”。建议补充验收:
证据:gotcha_core/src/responsible.rs:82。本轮为行为复现,尚未修复。
Kilerd commentedon Sep 13, 2026
已在 PR #107 实现经确认的响应契约方案:移除 Schematic→Responsible blanket impl,分别描述文本/JSON/HTML/二进制/空响应;自定义类型可同时实现 schema 与 HTTP 契约,公共 response/empty_response/default_response/merge_responses helpers 用于构造描述。
WithStatus<T, STATUS>同时设置真实 HTTP 状态与文档状态。Result<T,E>两侧均使用 Responsible,精确的错误返回类型可直接产生 404/409,无强制 default。动态(StatusCode,T)仍记录 default,可用#[api(responses(response(status = 404, body = "ApiError")), drop_default)]显式声明并移除默认条目;支持 description/content_type,声明只改变文档。验证覆盖 201/204/404/409、文本/HTML/二进制/CSV、schema与Responsible共存、同状态媒体/schema合并、显式声明覆盖和仅由声明收集组件。默认/OpenAPI全套测试、core两种feature配置、macro测试、workspace编译/Clippy及格式检查通过;CI新增core单元测试执行,状态见PR。
迁移指南已说明 ErrorResponsible/Schematic::empty_body 的移除、动态状态与状态包装器的边界。不同 response-level refs 无法直接union,组装时明确失败;schema-level refs 支持统一收集和重写。