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
62 changes: 59 additions & 3 deletions MIGRATION.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Migration Guide

- [Unreleased: OpenAPI 3.2](#unreleased-openapi-32)
- [Unreleased: HTTP/1 is always enabled](#unreleased-http1-is-always-enabled)
- [Unreleased: derive dependency paths](#unreleased-derive-dependency-paths)
- [Unreleased: explicit OpenAPI endpoints](#unreleased-explicit-openapi-endpoints)
Expand All @@ -20,6 +21,61 @@

---

# Unreleased: OpenAPI 3.2

Generated documents now use **OpenAPI 3.2.0** and `oas 0.3`. This changes both the exported
schema format and the public `oas` types used by custom schema/response implementations.
Consumers need tooling that understands OpenAPI 3.2 and JSON Schema 2020-12; changing the
`openapi` string back to 3.0 does not convert the schemas.

## Generated schemas

- `Option<T>` becomes `anyOf: [T, {"type": "null"}]`, including references and enums.
The shared `gotcha_core::nullable_schema` helper preserves non-null constraints and never
changes the referenced component. `Schematic::nullable()` remains available and the default
generator uses the same helper. Existing field-presence rules are unchanged.
- Exclusive validation bounds become numeric `exclusiveMinimum` / `exclusiveMaximum` values,
replacing the 3.0 boolean flags. Inclusive bounds remain `minimum` / `maximum`.
- `#[schematic(example = ...)]` keeps its source syntax and JSON value type, but emits a
one-element schema `examples` array. Media-type examples keep their OpenAPI representation.
- `Json<()>` has a `{"type":"null"}` schema. A bare `()` response still has no body.
- Raw binary responses use `application/octet-stream` with an unconstrained `{}` schema,
without asserting that the bytes are a JSON string or using the old `binary` format.

## Documentation viewers

The default documentation endpoints are now `/openapi.json` and `/scalar`. The bundled Redoc
rejects 3.2 documents, so `redoc_path` defaults to `None`; the explicit configuration field is
retained. Use Scalar for generated documents.

Browser verification confirmed that Scalar loads 3.2 documents and renders ordinary responses,
nullable references, enums, and examples. It currently displays an `itemSchema`-only SSE response
as "No Body"; stream-item rendering is not supported by the tested UI. Consumers can inspect
`itemSchema` in the exported JSON. Scalar continues to load from its existing CDN URL.

## Custom schemas and responses

`Schematic` and `EnhancedSchema` remain in `gotcha_core`; standalone libraries do not need
Axum or the gotcha facade. Existing derive dependency aliases and crate overrides still work.

- `Schema._type` is now `Option<SchemaType>`: use `Some("string".into())` for a single type,
or `SchemaType::Multiple` for a type union. Prefer `..Schema::default()` in struct literals.
- Schema-bearing fields such as `MediaType.schema`, `Parameter.schema`, and component entries
now use `SchemaValue`, which represents an object or a boolean. Convert a `Schema` with
`.into()`; inspect an object with `.as_object()` / `.as_object_mut()`.
- Set schema references with `Schema::reference(...)` or `Schema._ref`, rather than duplicating
`$ref` in `extras`. Siblings such as `description` and constraints are preserved.
- `MediaType` has a new `item_schema` field; initialize it to `None` in existing literals.
Reference collection and response unions support it, as well as `Schema.content_schema`.
`contentMediaType` describes encoded data inside a string; it is separate from the HTTP media type.
- Custom implementations that construct schemas directly must migrate legacy `nullable`,
exclusive bounds, examples, and binary assumptions themselves. The oas version constructor
does not translate schemas. Use `nullable_schema` when an object schema must also admit null.

This release migrates Gotcha's generated subset, not every OpenAPI 3.2 object or keyword.
See the [oas migration guide](https://github.com/Kilerd/oas/blob/main/MIGRATION.md) for remaining
model differences. Typed SSE annotations and WebSocket/AsyncAPI contracts remain separate work.

# Unreleased: HTTP/1 is always enabled

The `http1` feature has been removed. It existed to support Cloudflare Worker integration,
Expand Down Expand Up @@ -68,8 +124,8 @@ when their trait version matches gotcha's.
**The `openapi` feature no longer exposes HTTP documentation by itself.** It enables schema and
operation metadata. Applications choose whether and where to serve the resulting document.

For the builder, add `.with_openapi()` to retain the previous `/openapi.json`, `/redoc`, and
`/scalar` endpoints. This formerly empty method now enables the default endpoint configuration.
For the builder, add `.with_openapi()` to enable `/openapi.json` and `/scalar`.
This formerly empty method now enables the default endpoint configuration.
Use `.openapi_endpoints(Some(OpenApiEndpoints { ... }))` for custom paths, or
`.openapi_endpoints(None)` to disable all endpoints. The last configuration call wins;
`with_openapi()` resets custom paths to their defaults. `redoc_path` and `scalar_path` can each be
Expand Down Expand Up @@ -569,7 +625,7 @@ response schemas or security requirements. Overlapping handlers follow Axum's co
Combined filters such as `routing::on(MethodFilter::GET.or(MethodFilter::POST), handler)` now
document every selected OpenAPI method. Implicit HEAD handling for GET is unchanged and does
not add a HEAD operation; explicitly register HEAD to document it. CONNECT remains executable
but is not represented in OpenAPI 3.0.
but is not represented by the current oas PathItem model.

---

Expand Down
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,25 +306,28 @@ Names must be nonempty and use only ASCII letters, digits, `.`, `-`, or `_`. Cho
explicit name: duplicate overrides also warn and receive distinct module-prefixed names.
See [the migration guide](MIGRATION.md#unreleased-schema-identity-and-component-names) for details.

Generated documents use OpenAPI 3.2.0 and JSON Schema 2020-12 semantics. See the
[OpenAPI migration notes](MIGRATION.md#unreleased-openapi-32) for custom schemas and tooling compatibility.

Documentation HTTP endpoints are disabled by default. Enable them explicitly with
`Gotcha::with_openapi()`, or return `Some(OpenApiEndpoints::default())` from
`GotchaApp::openapi_endpoints()`. The default paths are:

- `/redoc` - ReDoc documentation interface
- `/scalar` - Scalar documentation interface
- `/openapi.json` - Raw OpenAPI specification

Use `openapi_endpoints(Some(config))` to customize paths, and `None` to disable all endpoints.
Each UI can also be disabled independently:
Set `scalar_path` to `None` to serve only JSON. The legacy `redoc_path` is disabled by default
because the bundled Redoc does not support OpenAPI 3.2.

```rust
# #[cfg(feature = "openapi")]
# {
use gotcha::{Gotcha, OpenApiEndpoints};
let app = Gotcha::new().openapi_endpoints(Some(OpenApiEndpoints {
json_path: "/docs/schema.json".into(),
redoc_path: Some("/docs/redoc".into()),
scalar_path: None,
redoc_path: None,
scalar_path: Some("/docs/scalar".into()),
}));
# }
```
Expand Down
2 changes: 1 addition & 1 deletion gotcha/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ tracing-subscriber = {version="0.3", features=["env-filter"]}
log = "0.4"
cron = {version = "0.12.0", optional = true}
chrono = "0.4.23"
oas = { version = "0.1", optional = true }
oas = { version = "0.3", optional = true }
mofa = "0.2"
# HTTP/1 serving is a required capability, including with default features disabled.
axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "form", "json", "query", "multipart", "ws", "matched-path", "original-uri"] }
Expand Down
7 changes: 3 additions & 4 deletions gotcha/src/assembly.rs
Original file line number Diff line number Diff line change
Expand Up @@ -227,9 +227,8 @@ mod tests {
for _ in 0..2 {
assert_eq!(request(&router, "/openapi.json").await, (StatusCode::OK, expected.clone()));
}
for path in ["/redoc", "/scalar"] {
assert_eq!(request(&router, path).await.0, StatusCode::OK);
}
assert_eq!(request(&router, "/scalar").await.0, StatusCode::OK);
assert_eq!(request(&router, "/redoc").await.0, StatusCode::NOT_FOUND);
}
assert_eq!(app.calls.load(Ordering::SeqCst), 5);
let spec: serde_json::Value = serde_json::from_slice(&expected).unwrap();
Expand All @@ -248,7 +247,7 @@ mod tests {
let mut builder = application_builder(&app).with_openapi();
for router in [builder.router(context()).await.unwrap(), app.build_router(context()).await.unwrap()] {
assert_eq!(request(&router, "/api/hello").await.0, StatusCode::UNAUTHORIZED);
for path in ["/openapi.json", "/redoc", "/scalar"] {
for path in ["/openapi.json", "/scalar"] {
let expected = if matches!(protection, Protection::Application) {
StatusCode::UNAUTHORIZED
} else {
Expand Down
2 changes: 1 addition & 1 deletion gotcha/src/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -800,7 +800,7 @@ where
self.layer(CorsLayer::permissive())
}

/// Enable the default OpenAPI JSON, Redoc, and Scalar endpoints.
/// Enable the default OpenAPI JSON and Scalar endpoints.
///
/// The `openapi` feature alone does not expose HTTP documentation. This replaces any
/// previous endpoint configuration with [`crate::OpenApiEndpoints::default`]. Business
Expand Down
10 changes: 6 additions & 4 deletions gotcha/src/openapi/endpoints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,16 @@ use crate::{GotchaError, GotchaResult};
/// use gotcha::{Gotcha, OpenApiEndpoints};
/// let app = Gotcha::new().openapi_endpoints(Some(OpenApiEndpoints {
/// json_path: "/docs/schema.json".into(),
/// redoc_path: Some("/docs/redoc".into()),
/// scalar_path: None,
/// redoc_path: None,
/// scalar_path: Some("/docs/scalar".into()),
/// }));
/// ```
#[derive(Clone, Debug)]
pub struct OpenApiEndpoints {
/// Path serving the generated JSON document. Defaults to `/openapi.json`.
pub json_path: String,
/// Redoc UI path, or `None` to disable it. Defaults to `/redoc`.
/// Legacy Redoc UI path. Disabled by default: the bundled Redoc rejects OpenAPI 3.2.
/// Use Scalar for generated documents.
pub redoc_path: Option<String>,
/// Scalar UI path, or `None` to disable it. Defaults to `/scalar`.
pub scalar_path: Option<String>,
Expand All @@ -39,7 +40,7 @@ impl Default for OpenApiEndpoints {
fn default() -> Self {
Self {
json_path: "/openapi.json".into(),
redoc_path: Some("/redoc".into()),
redoc_path: None,
scalar_path: Some("/scalar".into()),
}
}
Expand Down Expand Up @@ -136,6 +137,7 @@ mod tests {
..Default::default()
},
OpenApiEndpoints {
redoc_path: Some("/redoc".into()),
scalar_path: Some("/redoc".into()),
..Default::default()
},
Expand Down
11 changes: 6 additions & 5 deletions gotcha/src/openapi/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
//!
//! - Automatic OpenAPI spec generation from route definitions
//! - Support for operation parameters, request bodies, and responses
//! - Built-in Redoc and Scalar UI for API documentation viewing
//! - Scalar UI for API documentation viewing
//! - Grouping operations by tags
//! - Parameter validation and type information
//!
Expand All @@ -29,7 +29,8 @@
//! }
//! ```
//!
//! The default endpoint configuration serves `/openapi.json`, `/redoc`, and `/scalar`.
//! Generated documents use OpenAPI 3.2.0. The default endpoints are `/openapi.json` and `/scalar`.
//! The legacy Redoc UI is disabled by default because it does not support 3.2.
//! Use [`crate::GotchaRouter::into_openapi`] to export the document without HTTP.

use std::collections::{BTreeMap, HashMap};
Expand Down Expand Up @@ -177,7 +178,7 @@ pub fn generate_openapi(operables: HashMap<(String, Method), &'static Operable>)
});

let components = (!schemas.is_empty()).then(|| Components {
schemas: Some(schemas.into_iter().map(|(name, schema)| (name, Referenceable::Data(schema))).collect()),
schemas: Some(schemas.into_iter().map(|(name, schema)| (name, schema.into())).collect()),
responses: None,
parameters: None,
examples: None,
Expand All @@ -202,7 +203,7 @@ pub fn generate_openapi(operables: HashMap<(String, Method), &'static Operable>)
components,
security: None,
tags: None,
openapi: "3.0.0".to_string(),
openapi: "3.2.0".to_string(),
external_docs: None,
extras: None,
};
Expand All @@ -212,7 +213,7 @@ pub fn generate_openapi(operables: HashMap<(String, Method), &'static Operable>)
added_tags.iter().for_each(|tag| {
if let Some(tags) = &mut spec.tags {
if !tags.iter().any(|each| each.name.eq(tag)) {
tags.push(Tag::new(tag, None))
tags.push(Tag::new(tag, None::<String>))
}
}
})
Expand Down
4 changes: 2 additions & 2 deletions gotcha/src/params.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ fn cookie_value<'a>(header: &'a str, name: &str) -> Option<&'a str> {

#[cfg(feature = "openapi")]
mod openapi {
use gotcha_core::oas::{Parameter, ParameterIn, Referenceable, RequestBody};
use gotcha_core::oas::{Parameter, ParameterIn, RequestBody};
use gotcha_core::Schematic;

use super::{Cookie, CookieParam, Header, HeaderParam};
Expand All @@ -203,7 +203,7 @@ mod openapi {
style: None,
explode: None,
allow_reserved: None,
schema: Some(Referenceable::Data(schema.schema)),
schema: Some(schema.schema.into()),
example: None,
examples: None,
content: None,
Expand Down
2 changes: 1 addition & 1 deletion gotcha/src/routing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ impl<State: Clone + Send + Sync + 'static> MethodRouter<State> {
///
/// Documents each explicitly selected OpenAPI method when the handler has `#[api]`.
/// Axum's implicit HEAD handling for GET is retained, but does not add a HEAD operation.
/// CONNECT has no OpenAPI 3.0 operation field and is left undocumented.
/// CONNECT is not represented by the current oas PathItem model and is left undocumented.
pub fn on<H, T>(mut self, filter: MethodFilter, handler: H) -> Self
where
H: Handler<T, State>,
Expand Down
28 changes: 20 additions & 8 deletions gotcha/tests/pass/openapi/complex_struct.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,31 @@
"type": "integer"
},
"option_string": {
"type": "string",
"nullable": true
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"data": {
"type": "array",
"items": {
"type": "integer"
},
"nullable": true
"anyOf": [
{
"type": "array",
"items": {
"type": "integer"
}
},
{
"type": "null"
}
]
}
},
"required": [
"page",
"size"
]
}
}
17 changes: 13 additions & 4 deletions gotcha/tests/pass/openapi/rust_decimal.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,19 @@
"format": "decimal"
},
"discount": {
"type": "string",
"format": "decimal",
"nullable": true
"anyOf": [
{
"type": "string",
"format": "decimal"
},
{
"type": "null"
}
]
}
},
"required": ["name", "price"]
"required": [
"name",
"price"
]
}
6 changes: 3 additions & 3 deletions gotcha/tests/pass/openapi/schematic_field_attrs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,15 @@ fn main() {
let name = &props["name"];
assert_eq!(name["title"], "Name");
assert_eq!(name["description"], "The product name", "explicit description overrides doc comment");
assert_eq!(name["example"], "Widget");
assert_eq!(name["examples"][0], "Widget");

// `format` lands on the schema's dedicated field.
assert_eq!(props["contact"]["format"], "email");

// example/default keep their numeric JSON type.
let quantity = &props["quantity"];
assert!(quantity["example"].is_number(), "typed example stays a number, not the string \"42\"");
assert_eq!(quantity["example"], 42);
assert!(quantity["examples"][0].is_number(), "typed example stays a number, not the string \"42\"");
assert_eq!(quantity["examples"][0], 42);
assert_eq!(quantity["default"], 10);

// Enum-variant fields get the same treatment (asserted via the serialized schema so the
Expand Down
10 changes: 5 additions & 5 deletions gotcha/tests/pass/openapi/validate_to_schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,11 @@ fn main() {
assert_eq!(props["age"]["minimum"], 0.0);
assert_eq!(props["age"]["maximum"], 150.0);

// Exclusive bounds → minimum/maximum + exclusiveMinimum/Maximum (the OpenAPI 3.0 form).
assert_eq!(props["ratio"]["minimum"], 0.0);
assert_eq!(props["ratio"]["exclusiveMinimum"], true);
assert_eq!(props["ratio"]["maximum"], 10.0);
assert_eq!(props["ratio"]["exclusiveMaximum"], true);
// Exclusive bounds are numeric JSON Schema constraints.
assert!(props["ratio"].get("minimum").is_none());
assert_eq!(props["ratio"]["exclusiveMinimum"], 0.0);
assert!(props["ratio"].get("maximum").is_none());
assert_eq!(props["ratio"]["exclusiveMaximum"], 10.0);

// `length` on a collection → minItems / maxItems.
assert_eq!(props["tags"]["minItems"], 1);
Expand Down
2 changes: 1 addition & 1 deletion gotcha/tests/response_contracts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ async fn response_contracts_match_http_and_collect_explicit_components() {
assert_eq!(spec["paths"]["/text"]["get"]["responses"]["200"]["description"], "Plain greeting");
assert_eq!(
spec["paths"]["/binary"]["get"]["responses"]["200"]["content"]["application/octet-stream"]["schema"],
json!({"type":"string","format":"binary"})
json!({})
);
assert!(spec["components"]["schemas"]["Record"].is_object());
assert!(spec["components"]["schemas"]["Problem"].is_object());
Expand Down
Loading
Loading