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
5 changes: 5 additions & 0 deletions .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ jobs:
run: |
cargo test -p gotcha_macro --no-default-features

- name: Run tests (gotcha_core)
run: |
cargo test -p gotcha_core --all-features
cargo test -p gotcha_core --no-default-features

clippy:
name: Clippy
runs-on: ubuntu-latest
Expand Down
79 changes: 78 additions & 1 deletion MIGRATION.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Migration Guide

- [Unreleased: HTTP response contracts](#unreleased-http-response-contracts)
- [Unreleased: application and route composition](#unreleased-application-and-route-composition)
- [Unreleased: documented method routers](#unreleased-documented-method-routers)
- [Unreleased: OpenAPI transform composition](#unreleased-openapi-transform-composition)
Expand All @@ -11,6 +12,82 @@

---

# 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>`.
The HTTP-specific `Schematic::empty_body` hook has been removed; `()` has its own response impl.

Built-in contracts now match the response representation:

| Return type | Documented response |
| --- | --- |
| `String`, `&str`, boxed/Cow strings | `200 text/plain` |
| `Json<T>` | `200 application/json`, using `T`'s schema |
| `Html<T>` | `200 text/html` |
| `Bytes`, `Vec<u8>`, byte slices, boxed/Cow bytes | `200 application/octet-stream`, binary string schema |
| `()` | `200`, no body; `Json<()>` remains JSON |
| `(StatusCode, T)` | `default`, with `T`'s response bodies; the status is a runtime value |
| `StatusCode` / raw `Response` | `default`, with no body schema inferred |

Media keys omit parameters such as `charset=utf-8`. Regenerate client/spec snapshots that
previously described text or binary responses as JSON.

Use `WithStatus<T, STATUS>` to keep a fixed status in the return type and the actual response:

```rust
use gotcha::{Json, WithStatus};

let created: WithStatus<_, 201> = WithStatus::new(Json("created"));
let no_content: WithStatus<_, 204> = WithStatus::new(());
```

This wrapper is available without the `openapi` feature. Statuses outside 100..=599 fail during
compilation when constructed. Informational statuses, 204, 205, and 304 discard bodies and body
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
`gotcha::response::default_response::<ApiError>("application/json", "Error")`.

For runtime status tuples or additional responses, declare the contract on the handler:

```rust,ignore
#[api(
responses(
response(status = 404, body = "ApiError", description = "Not found"),
response(status = 409, body = "ApiError", description = "Duplicate")
),
drop_default
)]
async fn lookup() -> Result<Json<User>, (StatusCode, Json<ApiError>)> {
// Return the documented statuses here.
}
```

`body` names a **data type implementing `Schematic`**, not `Json<T>` or another HTTP wrapper.
`content_type` defaults to `application/json` when a body is present; set it explicitly for CSV
or other media. Omit `body` for an empty response. Duplicate statuses, invalid body types and
body declarations on bodyless statuses are rejected by the macro.

Explicit entries replace inference for their status only. `drop_default` removes the inferred
default, and at least one response must remain. These declarations change documentation only;
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.

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
retain media alternatives, differing schemas for one media type form `anyOf`, and identical
definitions are reused. Later header/link/example keys win. Distinct response-level `$ref`s
cannot be unioned and fail at assembly; use inline responses with schema-level references instead.

---

# Unreleased: application and route composition

**`Gotcha::nest` and `Gotcha::merge` now accept `GotchaRouter<GotchaContext<S, C>>`, not another
Expand Down Expand Up @@ -332,7 +409,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** need `E: ErrorResponsible`. This is implemented for any `E: Schematic` and for axum's `(StatusCode, Json<E>)` idiom, so most code needs no change.
- **`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.
- **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
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,26 @@ complete document: top-level `security` is global even when set by a child. Use
security for individual routes. Later writes win; maps and lists are not implicitly merged.
See [the migration guide](MIGRATION.md#unreleased-openapi-transform-composition) for details.

Response contracts come from HTTP return types: `Json<T>` documents JSON, `String` documents
plain text, `Html<T>` documents HTML, and byte bodies document binary content. `Schematic` only
describes data. Use `WithStatus<T, STATUS>` for a status shared by the response and its document:

```rust
use gotcha::prelude::*;

#[cfg_attr(feature = "openapi", gotcha::api)]
async fn create() -> WithStatus<Json<String>, 201> {
WithStatus::new(Json("created".into()))
}
```

`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.

### Configuration System

Create a `configurations/application.toml` file. Your application's own settings live at the top
Expand Down
2 changes: 2 additions & 0 deletions gotcha/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ pub mod error;
pub mod openapi;
pub mod params;
pub mod prelude;
pub mod response;
pub use response::WithStatus;
/// The router that tracks OpenAPI operations alongside axum routes.
pub mod router;
pub mod routing;
Expand Down
1 change: 1 addition & 0 deletions gotcha/src/prelude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ pub use crate::builder::{EmptyConfig, EmptyState, Gotcha};
// Essential traits and types
pub use crate::config::{ConfigWrapper, GotchaConfigLoader, ServerConfig};
pub use crate::error::{GotchaError, GotchaResult};
pub use crate::response::WithStatus;
pub use crate::router::Responder;
pub use crate::{config, state, GotchaApp, GotchaConfig, GotchaContext, GotchaRouter};

Expand Down
71 changes: 71 additions & 0 deletions gotcha/src/response.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
//! Response contracts and wrappers that keep runtime behavior and documentation together.
use axum::{
body::Body,
http::{header, StatusCode},
response::{IntoResponse, Response},
};

#[cfg(feature = "openapi")]
pub use gotcha_core::responsible::{default_response, empty_response, merge_responses, response, Responsible};

/// Override a response's status both at runtime and in OpenAPI.
///
/// ```rust
/// use gotcha::{Json, WithStatus};
/// let created: WithStatus<_, 201> = WithStatus::new(Json("created"));
/// let no_content: WithStatus<_, 204> = WithStatus::new(());
/// ```
///
/// Statuses must be in 100..=599. Informational responses, 204, 205, and 304 discard the body.
/// Like Axum's `(StatusCode, T)`, this overrides the inner status, including an inner error.
/// Prefer `Result<WithStatus<T, 201>, E>` to keep error statuses independent.
///
/// ```compile_fail
/// use gotcha::WithStatus;
/// let invalid: WithStatus<_, 999> = WithStatus::new(());
/// ```
#[derive(Clone, Debug)]
pub struct WithStatus<T, const STATUS: u16>(T);

impl<T, const STATUS: u16> WithStatus<T, STATUS> {
const VALID: () = assert!(STATUS >= 100 && STATUS <= 599, "status must be in 100..=599");
/// Wrap a response with a fixed status.
pub const fn new(inner: T) -> Self {
let () = Self::VALID;
Self(inner)
}
/// Recover the wrapped value.
pub fn into_inner(self) -> T {
self.0
}
fn has_body() -> bool {
STATUS >= 200 && !matches!(STATUS, 204 | 205 | 304)
}
}

impl<T: IntoResponse, const STATUS: u16> IntoResponse for WithStatus<T, STATUS> {
fn into_response(self) -> Response {
let () = Self::VALID;
let mut response = self.0.into_response();
*response.status_mut() = StatusCode::from_u16(STATUS).expect("validated status");
if !Self::has_body() {
*response.body_mut() = Body::empty();
response.headers_mut().remove(header::CONTENT_TYPE);
response.headers_mut().remove(header::CONTENT_LENGTH);
response.headers_mut().remove(header::TRANSFER_ENCODING);
}
response
}
}

#[cfg(feature = "openapi")]
impl<T: Responsible, const STATUS: u16> Responsible for WithStatus<T, STATUS> {
fn response() -> oas::Responses {
let () = Self::VALID;
if Self::has_body() {
gotcha_core::responsible::with_status(T::response(), STATUS)
} else {
empty_response(STATUS, "no content")
}
}
}
6 changes: 6 additions & 0 deletions gotcha/tests/pass/openapi/result_responses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ struct ApiError {
message: String,
}

impl Responsible for ApiError {
fn response() -> gotcha::oas::Responses {
gotcha::response::default_response::<Self>("application/json", Self::doc().unwrap())
}
}

fn main() {
let responses = <Result<Json<User>, ApiError> as Responsible>::response();

Expand Down
Loading
Loading