Skip to content

[openapi] A handler cannot declare its own status codes or content types #76

Closed

Description

@Kilerd

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:

  1. The success status is hardcoded. json_response::<T>() inserts the key "200" (responsible.rs:29-45). There is no 201, no 204, no way to say otherwise.

  2. A type cannot be both Schematic and custom-Responsible. impl<T: Schematic> Responsible for T (responsible.rs:82-96) is a blanket. A local wrapper that does not implement Schematic can carry its own Responsible — verified, that compiles. But the moment it also derives Schematic (which is how you get the body type into components), 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_response is private (responsible.rs:23), so implementing Responsible yourself means constructing oas::Responses / Response / MediaType / Referenceable by hand, with no helper exposed and no example anywhere in the crate.

  3. default is mandatory and unremovable. impl<E: Schematic> ErrorResponsible for E unconditionally does responses.default = Some(..) (responsible.rs:128-132). A Result<Json<T>, ApiError> handler always gets a default entry, even when the real contract is a specific 404.

  4. The content type is always application/json. The same json_response::<T>() hardcodes the media-type key (responsible.rs:34). A file-download route serving application/octet-stream has 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" hardcoded
  • gotcha_core-0.4.1/src/responsible.rs:82-96 — blanket impl<T: Schematic> Responsible for T
  • gotcha_core-0.4.1/src/responsible.rs:128-132 — default unconditionally set

How remote-hand works around it

Two modules, both pure workaround:

  • server-rs/src/api/created.rs — a Created<T> wrapper that implements IntoResponse as (StatusCode::CREATED, Json(..)) and delegates Schematic to T, so the derived response is right about the body and wrong about the status. Delegating Schematic is what forecloses a custom Responsible (point 2 above), and hand-rolling one would have meant hand-rolling oas::Responses.
  • server-rs/src/openapi_ext.rs — a declarative registry of (method, path, status) -> body type patches applied in the single allowed GotchaRouter::openapi(|spec| ..) pass. It inserts the missing 404/409, moves the derived 200 to 201, and carries a drop_default flag whose only job is deleting the default entry 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 Responsible impl 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 default entry when the handler enumerates its errors explicitly.

Activity

  1. Kilerd commented on Sep 13, 2026

    @Kilerd
    OwnerAuthor

    本轮架构审查补充:已有 String 返回类型的媒体类型就不匹配

    在 commit 4aa39cf 的本地源码上实际运行确认:

    async fn hello() -> String { "hello".into() }
    // hello().await.into_response(): content-type = text/plain; charset=utf-8
    // <String as Responsible>::response(): content[application/json]

    不只是缺少自定义响应能力,现有 impl<T: Schematic> Responsible for T 已将“数据类型能生成 schema”错误推导为“通过 JSON 传输”。

    建议补充验收:

    • String / &str 文档与真实 text/plain 响应一致。
    • Json / Json 仍正确描述 JSON。
    • 单位类型及自定义状态/媒体类型返回值保持正确。
    • Schematic 只描述数据结构,HTTP 状态码和媒体类型由响应类型/显式描述承担。
    • 缩小 blanket impl 的影响并给出兼容迁移方案。

    证据:gotcha_core/src/responsible.rs:82。本轮为行为复现,尚未修复。

  2. Kilerd commented on Sep 13, 2026

    @Kilerd
    OwnerAuthor

    已在 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 支持统一收集和重写。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions