Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 40 additions & 7 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@
# Unreleased: HTTP response contracts

**`Schematic` no longer implies `Responsible`.** A data schema does not determine how a value is
sent over HTTP. Custom return types implement `Responsible` separately, alongside `IntoResponse`;
they can still derive `Schematic`. Ordinary JSON data should be returned as `Json<T>`.
sent over HTTP. For automatic response inference, custom HTTP types implement `Responsible`
separately, alongside `IntoResponse`; they can still derive `Schematic`.
Ordinary JSON data should be returned as `Json<T>`.
The HTTP-specific `Schematic::empty_body` hook has been removed; `()` has its own response impl.

Built-in contracts now match the response representation:
Expand Down Expand Up @@ -48,10 +49,11 @@ compilation when constructed. Informational statuses, 204, 205, and 304 discard
headers. As with Axum's status tuple, the outer status overrides an inner error status too;
prefer `Result<WithStatus<Json<User>, 201>, E>` to keep `E`'s status independent.

**`Result<T, E>` now requires `Responsible` on both sides.** `ErrorResponsible` and its schema
blanket impl have been removed. Migrate custom errors to `Responsible`, or use an explicit HTTP
type such as `WithStatus<Json<ApiError>, 409>`. A schema-only error no longer automatically adds
`default`. To retain a dynamic JSON error contract, implement `Responsible` using
**Automatic inference for `Result<T, E>` requires `Responsible` on both sides.** `ErrorResponsible`
and its schema blanket impl have been removed. Migrate custom errors to `Responsible`, use a fixed
HTTP type such as `WithStatus<Json<ApiError>, 409>`, or declare the endpoint's complete error contract
with `errors(...)` as shown below. A schema-only error no longer automatically adds `default`.
To retain a dynamic JSON error contract, implement `Responsible` using
`gotcha::response::default_response::<ApiError>("application/json", "Error")`.

For runtime status tuples or additional responses, declare the contract on the handler:
Expand Down Expand Up @@ -79,6 +81,37 @@ default, and at least one response must remain. These declarations change docume
the handler remains responsible for sending the declared status and content type. Declared body
schemas participate in the same component collection and reference rewriting as inferred bodies.

For a `thiserror` enum that already implements `IntoResponse`, use `errors(...)` to describe all
errors for this endpoint without implementing `Responsible` on the enum:

```rust,ignore
#[api(errors(
response(status = 404, body = "ErrorBody", description = "Not found"),
response(status = 409, body = "ErrorBody", description = "Duplicate"),
response(status = 500, body = "ErrorBody", description = "Internal error")
))]
async fn create_user() -> Result<Json<User>, ApiError> {
// ApiError::into_response() sends the actual status and ErrorBody.
}
```

`ErrorBody` is the serialized data type and must implement `Schematic`; `ApiError` itself needs
neither `Schematic` nor `Responsible`. The success type still needs `Responsible`. Type aliases
such as `type ApiResult<T> = Result<T, ApiError>` work as well. See the
[README example](README.md#openapi-documentation) for a complete `thiserror` implementation.

`errors(...)` requires a `Result` return type and at least one response, using the same declaration
fields and validation as `responses(...)`. It completely replaces inference of the error branch,
even if the error type implements `Responsible`. Another endpoint using the same enum can declare
a different error set. The success contract is kept in full, including any dynamic `default`;
`drop_default` is not needed to suppress error inference.

Assembly order is: infer the success branch, merge the declared error alternatives, apply
`responses(...)` overrides, then apply an explicit `drop_default`. If success and error branches
share a status, their media types and schemas are combined using the same rules as ordinary
`Result` inference. With no `errors(...)`, existing inference and `responses(...)` behavior are
unchanged. None of these attributes changes runtime HTTP behavior.

Public helpers in `gotcha::response` support custom contracts: `response::<T>(status, media,
description)`, `empty_response(status, description)`, `default_response::<T>(media, description)`,
and `merge_responses(&mut target, other)`. The latter is also used by `Result`: duplicate statuses
Expand Down Expand Up @@ -409,7 +442,7 @@ gotcha = { version = "0.4", features = ["openapi"] }
## 6. Smaller changes

- **Validation rejections return `422`**, not `400`. `400` is still used for a malformed body. Every error now carries a readable `message`.
- **`Result<T, E>` handlers** now require `Responsible` on both sides; see [HTTP response contracts](#unreleased-http-response-contracts) for migrating schema-only errors and the former `ErrorResponsible` trait.
- **`Result<T, E>` handlers** require `Responsible` on both sides for automatic inference, or can use `#[api(errors(...))]` for explicit error documentation; see [HTTP response contracts](#unreleased-http-response-contracts) for migration.
- **Handlers returning nothing** now compile (they previously failed with `E0782`) and document an empty body.
- **`Operable`** gained `summary` and `security` fields; only relevant if you construct it by hand rather than through `#[api]`.
- **axum 0.8** also removed `#[async_trait]` from its extractor traits. A hand-written `FromRequest` / `FromRequestParts` impl should drop the attribute and use a plain `async fn`.
Expand Down
50 changes: 44 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,12 +244,50 @@ async fn create() -> WithStatus<Json<String>, 201> {
}
```

`Result<T, E>` combines both HTTP response contracts. Custom response/error types can implement
`Responsible` alongside `Schematic`, using the helpers in `gotcha::response`. For dynamic status
codes, `#[api(responses(response(status = 404, body = "ApiError")), drop_default)]` declares the
additional response and removes the inferred default. Declarations describe the contract; they
do not change the handler's HTTP behavior. See [the migration guide](MIGRATION.md#unreleased-http-response-contracts)
for custom errors, media types, and inference/override rules.
`Result<T, E>` combines both HTTP response contracts by default. Custom response/error types can
implement `Responsible` using the helpers in `gotcha::response`.

To document errors on each endpoint, use `#[api(errors(...))]`. Only the success response is inferred;
your error enum needs `IntoResponse` for HTTP behavior, with no `Responsible` implementation:

```rust
use gotcha::{Json, axum::{http::StatusCode, response::{IntoResponse, Response}}};

#[derive(Debug, thiserror::Error)]
enum ApiError {
#[error("User not found")]
NotFound,
#[error("User already exists")]
Conflict,
}

impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let status = match self {
Self::NotFound => StatusCode::NOT_FOUND,
Self::Conflict => StatusCode::CONFLICT,
};
(status, Json(self.to_string())).into_response()
}
}

#[cfg_attr(feature = "openapi", gotcha::api(errors(
response(status = 404, body = "String", description = "User not found"),
response(status = 409, body = "String", description = "User already exists")
)))]
async fn create_user() -> Result<Json<String>, ApiError> {
Err(ApiError::Conflict)
}
```

Each endpoint declares its complete error set; the same enum can be shared by endpoints with
different errors. `body` describes the actual wire data, which may be separate from the error enum.
`errors(...)` also supports return type aliases such as `ApiResult<T>`.

Use `responses(...)` to add or override individual statuses while keeping response inference;
`drop_default` explicitly removes an inferred default. Declarations only affect documentation.
See [the migration guide](MIGRATION.md#unreleased-http-response-contracts) for media types and
inference/override rules.

### Configuration System

Expand Down
4 changes: 4 additions & 0 deletions gotcha/src/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ use axum::{
#[cfg(feature = "openapi")]
pub use gotcha_core::responsible::{default_response, empty_response, merge_responses, response, Responsible};

#[cfg(feature = "openapi")]
#[doc(hidden)]
pub use gotcha_core::responsible::ResultResponse;

/// Override a response's status both at runtime and in OpenAPI.
///
/// ```rust
Expand Down
145 changes: 145 additions & 0 deletions gotcha/tests/explicit_error_responses.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
#![cfg(feature = "openapi")]

use gotcha::axum::{
body::{to_bytes, Body},
http::{Request, StatusCode},
response::{IntoResponse, Response},
};
use gotcha::{api, ConfigWrapper, EmptyConfig, GotchaApp, GotchaContext, GotchaResult, GotchaRouter, Json, Path, Schematic, WithStatus};
use serde::Serialize;
use serde_json::{json, Value};
use tower::ServiceExt;

#[derive(Serialize, Schematic)]
struct User {
id: u32,
}

#[derive(Serialize, Schematic)]
struct ErrorBody {
message: String,
}

// No Responsible or Schematic impl: only the wire body needs a schema.
#[derive(Debug, thiserror::Error)]
enum ApiError {
#[error("user not found")]
NotFound,
#[error("user already exists")]
Conflict,
#[error("internal error")]
Internal,
}

impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let status = match self {
Self::NotFound => StatusCode::NOT_FOUND,
Self::Conflict => StatusCode::CONFLICT,
Self::Internal => StatusCode::INTERNAL_SERVER_ERROR,
};
(status, Json(ErrorBody { message: self.to_string() })).into_response()
}
}

type ApiResult<T> = std::result::Result<T, ApiError>;

#[api(errors(
response(status = 404, body = "ErrorBody", description = "Missing user"),
response(status = 409, body = "ErrorBody", description = "Duplicate user"),
response(status = 500, body = "ErrorBody", description = "Internal failure")
))]
async fn create(Path(id): Path<u32>) -> ApiResult<WithStatus<Json<User>, 201>> {
match id {
7 => Ok(WithStatus::new(Json(User { id }))),
0 => Err(ApiError::Conflict),
1 => Err(ApiError::NotFound),
_ => Err(ApiError::Internal),
}
}

#[api(errors(response(status = 404, body = "ErrorBody")))]
async fn lookup(Path(id): Path<u32>) -> Result<Json<User>, ApiError> {
if id == 7 {
Ok(Json(User { id }))
} else {
Err(ApiError::NotFound)
}
}

#[api(
errors(response(status = 409, body = "ErrorBody")),
responses(response(status = 409, body = "ErrorBody", description = "Operation-specific conflict"))
)]
async fn dynamic() -> std::result::Result<(StatusCode, Json<User>), ApiError> {
Ok((StatusCode::ACCEPTED, Json(User { id: 7 })))
}

struct App;
impl GotchaApp for App {
type State = ();
type Config = EmptyConfig;

async fn state(&self, _: &ConfigWrapper<EmptyConfig>) -> GotchaResult<()> {
Ok(())
}

fn routes(&self, router: GotchaRouter<GotchaContext<(), EmptyConfig>>) -> GotchaRouter<GotchaContext<(), EmptyConfig>> {
router.get("/create/{id}", create).get("/lookup/{id}", lookup).get("/dynamic", dynamic)
}
}

async fn request(router: &gotcha::axum::Router, path: &str) -> (u16, Value) {
let response = router.clone().oneshot(Request::builder().uri(path).body(Body::empty()).unwrap()).await.unwrap();
let status = response.status().as_u16();
assert_eq!(response.headers()["content-type"], "application/json");
let body = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
(status, serde_json::from_slice(&body).unwrap())
}

#[tokio::test]
async fn handlers_declare_their_own_errors_without_an_error_response_trait() {
let router = App
.build_router(GotchaContext {
state: (),
config: ConfigWrapper::default(),
})
.await
.unwrap();
let (_, spec) = request(&router, "/openapi.json").await;
let responses = |path: &str| spec["paths"][path]["get"]["responses"].as_object().unwrap();
assert_eq!(
responses("/create/{id}").keys().map(String::as_str).collect::<Vec<_>>(),
["201", "404", "409", "500"]
);
assert_eq!(responses("/lookup/{id}").keys().map(String::as_str).collect::<Vec<_>>(), ["200", "404"]);
assert_eq!(responses("/dynamic").keys().map(String::as_str).collect::<Vec<_>>(), ["409", "default"]);
assert_eq!(responses("/create/{id}")["409"]["description"], "Duplicate user");
assert_eq!(responses("/dynamic")["409"]["description"], "Operation-specific conflict");

for (path, operation, status, body, schema) in [
("/create/7", "/create/{id}", 201, json!({"id": 7}), "User"),
("/create/1", "/create/{id}", 404, json!({"message": "user not found"}), "ErrorBody"),
("/create/0", "/create/{id}", 409, json!({"message": "user already exists"}), "ErrorBody"),
("/create/9", "/create/{id}", 500, json!({"message": "internal error"}), "ErrorBody"),
("/lookup/1", "/lookup/{id}", 404, json!({"message": "user not found"}), "ErrorBody"),
("/dynamic", "/dynamic", 202, json!({"id": 7}), "User"),
] {
assert_eq!(request(&router, path).await, (status, body), "{path}");
let declared = responses(operation)
.get(&status.to_string())
.unwrap_or_else(|| &responses(operation)["default"]);
assert_eq!(
declared["content"]["application/json"]["schema"]["$ref"],
format!("#/components/schemas/{schema}")
);
}
let schemas = spec["components"]["schemas"].as_object().unwrap();
assert_eq!(schemas.keys().map(String::as_str).collect::<Vec<_>>(), ["ErrorBody", "User"]);
assert_eq!(schemas["ErrorBody"]["properties"]["message"]["type"], "string");
}

#[test]
fn explicit_errors_preserve_the_other_response_bounds() {
trybuild::TestCases::new().compile_fail("tests/fail/error_responses/*.rs");
}
8 changes: 8 additions & 0 deletions gotcha/tests/fail/error_responses/requires_result.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
use gotcha::api;

#[api(errors(response(status = 404)))]
async fn handler() -> String {
"hello".into()
}

fn main() {}
11 changes: 11 additions & 0 deletions gotcha/tests/fail/error_responses/requires_result.stderr
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
error[E0277]: the trait bound `std::string::String: gotcha::response::ResultResponse` is not satisfied
--> tests/fail/error_responses/requires_result.rs:4:23
|
4 | async fn handler() -> String {
| ^^^^^^ the trait `gotcha::response::ResultResponse` is not implemented for `std::string::String`
|
help: the trait `gotcha::response::ResultResponse` is implemented for `Result<T, E>`
--> $WORKSPACE/gotcha_core/src/responsible.rs
|
| impl<T: Responsible, E> ResultResponse for Result<T, E> {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
11 changes: 11 additions & 0 deletions gotcha/tests/fail/error_responses/requires_success_contract.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
use gotcha::{api, Json};

#[derive(serde::Serialize)]
struct NoSchema;

#[api(errors(response(status = 404)))]
async fn handler() -> Result<Json<NoSchema>, gotcha::axum::http::StatusCode> {
Ok(Json(NoSchema))
}

fn main() {}
23 changes: 23 additions & 0 deletions gotcha/tests/fail/error_responses/requires_success_contract.stderr
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
error[E0277]: the trait bound `NoSchema: Schematic` is not satisfied
--> tests/fail/error_responses/requires_success_contract.rs:7:23
|
7 | async fn handler() -> Result<Json<NoSchema>, gotcha::axum::http::StatusCode> {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ unsatisfied trait bound
|
help: the trait `Schematic` is not implemented for `NoSchema`
--> tests/fail/error_responses/requires_success_contract.rs:4:1
|
4 | struct NoSchema;
| ^^^^^^^^^^^^^^^
= help: the following other types implement trait `Schematic`:
&T
&str
()
HashMap<K, V>
HashSet<T>
JsonValue
Vec<T>
bigdecimal::BigDecimal
and $N others
= note: required for `gotcha::Json<NoSchema>` to implement `Responsible`
= note: required for `Result<gotcha::Json<NoSchema>, StatusCode>` to implement `gotcha::response::ResultResponse`
16 changes: 16 additions & 0 deletions gotcha/tests/fail/error_responses/responses_still_infers_errors.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
use gotcha::{api, axum::response::{IntoResponse, Response}};

struct UndocumentedError;

impl IntoResponse for UndocumentedError {
fn into_response(self) -> Response {
gotcha::axum::http::StatusCode::NOT_FOUND.into_response()
}
}

#[api(responses(response(status = 404)), drop_default)]
async fn handler() -> Result<String, UndocumentedError> {
Err(UndocumentedError)
}

fn main() {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
error[E0277]: the trait bound `UndocumentedError: Responsible` is not satisfied
--> tests/fail/error_responses/responses_still_infers_errors.rs:12:23
|
12 | async fn handler() -> Result<String, UndocumentedError> {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ unsatisfied trait bound
|
help: the trait `Responsible` is not implemented for `UndocumentedError`
--> tests/fail/error_responses/responses_still_infers_errors.rs:3:1
|
3 | struct UndocumentedError;
| ^^^^^^^^^^^^^^^^^^^^^^^^
= help: the following other types implement trait `Responsible`:
&[u8]
&str
()
(StatusCode, T)
Box<[u8]>
Box<str>
Cow<'_, [u8]>
Cow<'_, str>
and $N others
= note: required for `Result<std::string::String, UndocumentedError>` to implement `Responsible`
Loading
Loading