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

- [Unreleased: explicit OpenAPI endpoints](#unreleased-explicit-openapi-endpoints)
- [Unreleased: message task handles](#unreleased-message-task-handles)
- [Unreleased: static OpenAPI descriptors](#unreleased-static-openapi-descriptors)
- [Unreleased: owned scheduled tasks](#unreleased-owned-scheduled-tasks)
Expand All @@ -17,6 +18,82 @@

---

# Unreleased: explicit OpenAPI endpoints

**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.
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
`None` to serve just the document or a single UI.

For the trait API, explicitly configure the application:

```rust,no_run
use gotcha::{ConfigWrapper, EmptyConfig, GotchaApp, GotchaContext, GotchaResult, GotchaRouter, OpenApiEndpoints};

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

fn openapi_endpoints(&self) -> Option<OpenApiEndpoints> {
Some(OpenApiEndpoints::default())
}

fn routes(&self, router: GotchaRouter<GotchaContext<(), EmptyConfig>>) -> GotchaRouter<GotchaContext<(), EmptyConfig>> {
router.get("/health", || async { "ok" })
}

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

Endpoint configuration belongs to the top-level application. Nesting or merging business routers
does not enable, relocate, or override documentation endpoints. Both UI pages use the configured
`json_path`. Paths must be distinct literal absolute URI paths, with no query, fragment, or dot
segments; percent-encode other characters. Invalid endpoint configuration fails assembly, while
collisions with existing business routes retain Axum's normal conflict behavior (panic).

## Middleware scope

`Gotcha::layer` and `GotchaRouter::layer` keep their existing Axum semantics: they cover business
routes already registered, not routes added afterwards or application documentation. If you want
private business routes and public documentation, keep authentication on those business routes.

Use `.app_layer(auth_layer)` on the builder for authentication, CORS, or other middleware that must
cover every application endpoint and fallback. It applies after routes and documentation are
assembled, even when registered before them. Repeated calls wrap in Axum order: the last registered
application layer sees incoming requests first. `with_cors()` retains its existing `layer` scope;
use `app_layer(CorsLayer::permissive())` when CORS should also cover documentation.

The corresponding trait hook is `fn finish_router(&self, router: axum::Router) -> axum::Router`.
Return `router.layer(auth_layer)` from it to cover the complete application. This hook runs after
documentation is mounted, without requiring an override of `build_router`. An existing custom
`build_router` still owns its whole assembly and must invoke any desired finalization itself.
Routes added through this native Axum hook do not contribute OpenAPI metadata.

## Export without HTTP

Use `Gotcha::into_openapi()` or `GotchaRouter::into_openapi()` to consume a route definition and
return its `oas::OpenAPIV3`. `GotchaApp::openapi_document()` creates a fresh definition through
`routes()` and consumes that. All three use the same generation and transform pipeline as the
HTTP document; no side channel or HTTP request is needed.

Export does not load configuration, initialize application state, bind a listener, register tasks,
or apply application middleware. User route registration and document transforms still run, so
any side effects written in those callbacks remain. Endpoint paths do not affect the document.
Transforms keep their `FnOnce` captures, nested ordering, and one execution per document.
When documentation is disabled and no export is requested, transforms are not executed at all.

The OpenAPI example demonstrates exporting with `cargo run -p openapi -- --export-openapi`.
It prints JSON and exits without starting the application.

---

# Unreleased: message task handles

`Message` and `Messager` remain available in the same module and root exports, without a feature
Expand Down
47 changes: 45 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,14 +297,57 @@ 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.

Visit these endpoints when running:
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:

```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,
}));
# }
```

`layer` covers business routes already registered, following Axum's ordering. Use builder
`app_layer` or the trait's `finish_router` hook for authentication that must cover **every endpoint**,
including documentation and fallbacks. Application layers run after documentation is mounted,
regardless of when `with_openapi()` was called. Keeping authentication on business routes leaves
explicitly enabled documentation public.

Export the complete document with `Gotcha::into_openapi()`, `GotchaRouter::into_openapi()`, or
`GotchaApp::openapi_document()`. Export needs no listener or runtime and does not initialize
application state, load configuration, register tasks, or install application layers:

```rust
# #[cfg(feature = "openapi")]
# {
use gotcha::Gotcha;
let spec = Gotcha::new()
.openapi(|mut spec| { spec.info.title = "My API".into(); spec })
.into_openapi();
let json = serde_json::to_string_pretty(&spec).unwrap();
# }
```

The OpenAPI example supports `cargo run -p openapi -- --export-openapi` for a standalone JSON export.
See [the migration guide](MIGRATION.md#unreleased-explicit-openapi-endpoints) for both APIs and
middleware scope changes.

Customize the document with `.openapi(|mut spec| { /* edits */ spec })` on either `Gotcha` or
`GotchaRouter`. Repeated calls compose in registration order. Child subtrees run in `nest`/`merge`
insertion order, then the parent's own callbacks run, once at assembly. All callbacks edit the
insertion order, then the parent's own callbacks run, once per exported or served document. All callbacks edit the
complete document: top-level `security` is global even when set by a child. Use operation-level
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.
Expand Down
4 changes: 4 additions & 0 deletions examples/extension/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ impl GotchaApp for App {
type State = ();
type Config = Config;

fn openapi_endpoints(&self) -> Option<gotcha::OpenApiEndpoints> {
Some(gotcha::OpenApiEndpoints::default())
}

fn routes(&self, router: GotchaRouter<GotchaContext<Self::State, Self::Config>>) -> GotchaRouter<GotchaContext<Self::State, Self::Config>> {
router
.get("/health", health)
Expand Down
15 changes: 12 additions & 3 deletions examples/openapi/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ struct App {}
impl GotchaApp for App {
type State = ();
type Config = Config;
fn openapi_endpoints(&self) -> Option<gotcha::OpenApiEndpoints> {
Some(gotcha::OpenApiEndpoints::default())
}

fn routes(&self, router: GotchaRouter<GotchaContext<(), Config>>) -> GotchaRouter<GotchaContext<(), Config>> {
router
.get("/", hello_world)
Expand All @@ -113,8 +117,13 @@ impl GotchaApp for App {
}
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
App {}.run().await?;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let app = App {};
if std::env::args().nth(1).as_deref() == Some("--export-openapi") {
// No configuration loading, state initialization, listener, or background tasks.
println!("{}", serde_json::to_string_pretty(&app.openapi_document())?);
return Ok(());
}
tokio::runtime::Runtime::new()?.block_on(app.run())?;
Ok(())
}
1 change: 0 additions & 1 deletion gotcha/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@ axum-prometheus = { version = "0.8", optional = true }
axum-macros = "0.5"
# No features here — the `cors` / `static_files` flags each turn on the one they need.
tower-http = { version = "0.6", optional = true, default-features = false }
cfg-if = { workspace = true }
thiserror = "1.0"
serde_json = "1"
bigdecimal = "0.4"
Expand Down
Loading
Loading