diff --git a/MIGRATION.md b/MIGRATION.md index 644f13f..6ce6cf7 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -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) @@ -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 { + Some(OpenApiEndpoints::default()) + } + + fn routes(&self, router: GotchaRouter>) -> GotchaRouter> { + router.get("/health", || async { "ok" }) + } + + async fn state(&self, _: &ConfigWrapper) -> 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 diff --git a/README.md b/README.md index 5321cec..37f86ed 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/examples/extension/src/main.rs b/examples/extension/src/main.rs index 55c613e..75fb1f8 100644 --- a/examples/extension/src/main.rs +++ b/examples/extension/src/main.rs @@ -69,6 +69,10 @@ impl GotchaApp for App { type State = (); type Config = Config; + fn openapi_endpoints(&self) -> Option { + Some(gotcha::OpenApiEndpoints::default()) + } + fn routes(&self, router: GotchaRouter>) -> GotchaRouter> { router .get("/health", health) diff --git a/examples/openapi/src/main.rs b/examples/openapi/src/main.rs index 3143974..4eb9b8c 100644 --- a/examples/openapi/src/main.rs +++ b/examples/openapi/src/main.rs @@ -95,6 +95,10 @@ struct App {} impl GotchaApp for App { type State = (); type Config = Config; + fn openapi_endpoints(&self) -> Option { + Some(gotcha::OpenApiEndpoints::default()) + } + fn routes(&self, router: GotchaRouter>) -> GotchaRouter> { router .get("/", hello_world) @@ -113,8 +117,13 @@ impl GotchaApp for App { } } -#[tokio::main] -async fn main() -> Result<(), Box> { - App {}.run().await?; +fn main() -> Result<(), Box> { + 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(()) } diff --git a/gotcha/Cargo.toml b/gotcha/Cargo.toml index 597b955..6c470cc 100644 --- a/gotcha/Cargo.toml +++ b/gotcha/Cargo.toml @@ -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" diff --git a/gotcha/src/assembly.rs b/gotcha/src/assembly.rs new file mode 100644 index 0000000..b1bd67f --- /dev/null +++ b/gotcha/src/assembly.rs @@ -0,0 +1,333 @@ +//! Assemble application endpoints before applying application-wide middleware. + +use axum::Router; + +use crate::{GotchaResult, GotchaRouter}; + +pub(crate) fn assemble( + router: GotchaRouter, state: State, #[cfg(feature = "openapi")] endpoints: Option, finish: impl FnOnce(Router) -> Router, +) -> GotchaResult { + #[cfg(feature = "openapi")] + let router = if let Some(endpoints) = endpoints { + endpoints.validate()?; + let (router, spec) = router.into_openapi_parts(); + endpoints.mount(router.with_state(state), spec) + } else { + router.into_axum_router(state) + }; + #[cfg(not(feature = "openapi"))] + let router = router.into_axum_router(state); + + Ok(finish(router)) +} + +#[cfg(test)] +mod tests { + use axum::{ + body::{to_bytes, Body}, + extract::{Request, State}, + http::{HeaderValue, StatusCode}, + middleware::{from_fn_with_state, Next}, + response::Response, + Router, + }; + use tower::ServiceExt; + + use crate::{startup::Application, ConfigWrapper, EmptyConfig, Gotcha, GotchaApp, GotchaContext, GotchaResult, GotchaRouter}; + + fn context() -> GotchaContext<(), EmptyConfig> { + GotchaContext { + state: (), + config: ConfigWrapper::default(), + } + } + + async fn request(router: &Router, path: &str) -> (StatusCode, Vec) { + let response = router.clone().oneshot(Request::builder().uri(path).body(Body::empty()).unwrap()).await.unwrap(); + (response.status(), to_bytes(response.into_body(), 4 * 1024 * 1024).await.unwrap().to_vec()) + } + + async fn mark(State(name): State<&'static str>, mut request: Request, next: Next) -> Response { + request.headers_mut().append("x-order", HeaderValue::from_static(name)); + next.run(request).await + } + + #[tokio::test] + async fn application_layers_cover_later_routes_and_fallback_in_axum_order() { + async fn order(request: Request) -> String { + request + .headers() + .get_all("x-order") + .iter() + .map(|value| value.to_str().unwrap()) + .collect::>() + .join(",") + } + struct App; + impl GotchaApp for App { + type State = (); + type Config = EmptyConfig; + async fn state(&self, _: &ConfigWrapper) -> GotchaResult<()> { + Ok(()) + } + fn routes(&self, router: GotchaRouter>) -> GotchaRouter> { + router.get("/late", order).fallback(order) + } + fn finish_router(&self, router: Router) -> Router { + router.layer(from_fn_with_state("inner", mark)).layer(from_fn_with_state("outer", mark)) + } + } + let mut builder = Gotcha::from_state(()) + .app_layer(from_fn_with_state("inner", mark)) + .get("/late", order) + .fallback(order) + .app_layer(from_fn_with_state("outer", mark)); + for router in [builder.router(context()).await.unwrap(), App.build_router(context()).await.unwrap()] { + for path in ["/late", "/missing"] { + assert_eq!(request(&router, path).await, (StatusCode::OK, b"outer,inner".to_vec())); + } + } + } + + #[cfg(feature = "openapi")] + mod docs { + use super::*; + use crate::{api, OpenApiEndpoints}; + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }; + + #[api] + async fn hello() -> String { + "hello".into() + } + + type Context = GotchaContext<(), EmptyConfig>; + + fn routes(router: GotchaRouter, calls: Arc) -> GotchaRouter { + // A Send-only, consumed capture keeps the FnOnce export contract exercised. + let title = std::cell::Cell::new(String::from("Exported API")); + router.nest("/api", GotchaRouter::default().get("/hello", hello)).openapi(move |mut spec| { + spec.info.title = title.into_inner(); + calls.fetch_add(1, Ordering::SeqCst); + spec + }) + } + + #[derive(Clone, Copy, Default)] + enum Protection { + #[default] + None, + Business, + Application, + } + + async fn deny(_: Request, _: Next) -> StatusCode { + StatusCode::UNAUTHORIZED + } + + #[derive(Default)] + struct App { + endpoints: Option, + calls: Arc, + protection: Protection, + } + + impl GotchaApp for App { + type State = (); + type Config = EmptyConfig; + async fn config(&self) -> GotchaResult> { + panic!("export must not load configuration") + } + async fn state(&self, _: &ConfigWrapper) -> GotchaResult<()> { + panic!("export must not initialize state") + } + #[cfg(feature = "task")] + async fn tasks(&self, _: &mut crate::TaskScheduler<(), EmptyConfig>) -> GotchaResult<()> { + panic!("export must not register tasks") + } + fn openapi_endpoints(&self) -> Option { + self.endpoints.clone() + } + fn routes(&self, router: GotchaRouter) -> GotchaRouter { + let router = routes(router, self.calls.clone()); + if matches!(self.protection, Protection::Business) { + router.layer(axum::middleware::from_fn(deny)) + } else { + router + } + } + fn finish_router(&self, router: Router) -> Router { + if matches!(self.protection, Protection::Application) { + router.layer(axum::middleware::from_fn(deny)) + } else { + router + } + } + } + + fn application_builder(app: &App) -> Gotcha<(), EmptyConfig> { + let mut builder = Gotcha::from_state(()); + if matches!(app.protection, Protection::Application) { + // Registration happens before business routes and documentation options. + builder = builder.app_layer(axum::middleware::from_fn(deny)); + } + builder.routes(|router| app.routes(router)) + } + + #[tokio::test] + async fn default_and_explicitly_disabled_docs_do_not_generate_or_reserve_routes() { + let app = App::default(); + let mut default = application_builder(&app); + let mut disabled = application_builder(&app).with_openapi().openapi_endpoints(None); + for router in [ + default.router(context()).await.unwrap(), + disabled.router(context()).await.unwrap(), + app.build_router(context()).await.unwrap(), + ] { + assert_eq!(request(&router, "/api/hello").await.0, StatusCode::OK); + for path in ["/openapi.json", "/redoc", "/scalar"] { + assert_eq!(request(&router, path).await.0, StatusCode::NOT_FOUND); + } + } + assert_eq!(app.calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn invalid_endpoint_configuration_fails_before_document_generation() { + let endpoints = OpenApiEndpoints { + json_path: "relative.json".into(), + ..Default::default() + }; + let app = App { + endpoints: Some(endpoints.clone()), + ..Default::default() + }; + let mut builder = application_builder(&app).openapi_endpoints(Some(endpoints)); + for result in [builder.router(context()).await, app.build_router(context()).await] { + assert!(result.unwrap_err().to_string().contains("OpenAPI endpoint")); + } + assert_eq!(app.calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn explicit_docs_and_exports_share_the_complete_document_and_run_transforms_once() { + let app = App { + endpoints: Some(OpenApiEndpoints::default()), + ..Default::default() + }; + let expected = serde_json::to_vec(&app.openapi_document()).unwrap(); + assert_eq!(serde_json::to_vec(&application_builder(&app).into_openapi()).unwrap(), expected); + assert_eq!(serde_json::to_vec(&app.routes(GotchaRouter::default()).into_openapi()).unwrap(), expected); + let mut builder = application_builder(&app).with_openapi(); + let routers = [builder.router(context()).await.unwrap(), app.build_router(context()).await.unwrap()]; + assert_eq!(app.calls.load(Ordering::SeqCst), 5); + for router in routers { + 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!(app.calls.load(Ordering::SeqCst), 5); + let spec: serde_json::Value = serde_json::from_slice(&expected).unwrap(); + assert_eq!(spec["info"]["title"], "Exported API"); + assert_eq!(spec["paths"].as_object().unwrap().keys().collect::>(), ["/api/hello"]); + } + + #[tokio::test] + async fn application_and_business_auth_have_explicit_documentation_scopes() { + for protection in [Protection::Business, Protection::Application] { + let app = App { + endpoints: Some(OpenApiEndpoints::default()), + protection, + ..Default::default() + }; + 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"] { + let expected = if matches!(protection, Protection::Application) { + StatusCode::UNAUTHORIZED + } else { + StatusCode::OK + }; + assert_eq!(request(&router, path).await.0, expected); + } + } + } + } + + #[tokio::test] + async fn custom_paths_update_both_uis_and_individual_uis_can_be_disabled() { + let endpoints = OpenApiEndpoints { + json_path: "/docs/api's&schema.json".into(), + redoc_path: Some("/docs/redoc".into()), + scalar_path: Some("/docs/scalar".into()), + }; + let app = App { + endpoints: Some(endpoints.clone()), + ..Default::default() + }; + let mut builder = application_builder(&app).with_openapi().openapi_endpoints(Some(endpoints)); + for router in [builder.router(context()).await.unwrap(), app.build_router(context()).await.unwrap()] { + assert_eq!(request(&router, "/docs/api's&schema.json").await.0, StatusCode::OK); + for (path, attribute) in [ + ("/docs/redoc", "spec-url='/docs/api's&schema.json'"), + ("/docs/scalar", "data-url=\"/docs/api's&schema.json\""), + ] { + let (status, html) = request(&router, path).await; + assert_eq!(status, StatusCode::OK); + assert!(String::from_utf8(html).unwrap().contains(attribute)); + } + for path in ["/openapi.json", "/redoc", "/scalar"] { + assert_eq!(request(&router, path).await.0, StatusCode::NOT_FOUND); + } + } + let endpoints = OpenApiEndpoints { + redoc_path: None, + scalar_path: None, + ..Default::default() + }; + let app = App { + endpoints: Some(endpoints.clone()), + ..Default::default() + }; + let mut builder = application_builder(&app).openapi_endpoints(Some(endpoints)); + for router in [builder.router(context()).await.unwrap(), app.build_router(context()).await.unwrap()] { + assert_eq!(request(&router, "/openapi.json").await.0, StatusCode::OK); + assert_eq!(request(&router, "/redoc").await.0, StatusCode::NOT_FOUND); + assert_eq!(request(&router, "/scalar").await.0, StatusCode::NOT_FOUND); + } + } + + #[test] + fn export_needs_no_runtime_and_does_not_initialize_the_application() { + #[derive(Clone)] + struct NeverDefault; + impl Default for NeverDefault { + fn default() -> Self { + panic!("export must not construct default state") + } + } + #[derive(Clone)] + struct NeverLayer; + impl tower_layer::Layer for NeverLayer { + type Service = axum::routing::Route; + fn layer(&self, _: Self::Service) -> Self::Service { + panic!("export must not install application middleware") + } + } + let builder = Gotcha::with_state::() + .with_file_config("this-file-must-not-be-opened-during-export.toml") + .app_layer(NeverLayer) + .get("/hello", hello); + #[cfg(feature = "task")] + let builder = builder.tasks(|_| panic!("export must not register tasks")); + assert!(builder.into_openapi().paths.contains_key("/hello")); + assert!(App::default().openapi_document().paths.contains_key("/api/hello")); + } + } +} diff --git a/gotcha/src/builder.rs b/gotcha/src/builder.rs index 9025030..6cb4233 100644 --- a/gotcha/src/builder.rs +++ b/gotcha/src/builder.rs @@ -42,6 +42,8 @@ use crate::GotchaContext; #[cfg(feature = "task")] type TaskRegistrar = Box) + Send>; +type AppLayer = Box axum::Router + Send>; + enum StateSource { Provided(S), Default(fn() -> S), @@ -115,6 +117,9 @@ where C: Clone + Send + Sync + 'static, { router: GotchaRouter>, + app_layers: Vec, + #[cfg(feature = "openapi")] + openapi_endpoints: Option, listen_options: ListenOptions, config_error_policy: ConfigErrorPolicy, shutdown_signal: Option + Send>>>, @@ -268,6 +273,9 @@ where fn from_sources(state: StateSource, config: Configuration) -> Self { Self { router: GotchaRouter::default(), + app_layers: Vec::new(), + #[cfg(feature = "openapi")] + openapi_endpoints: None, listen_options: ListenOptions::default(), config_error_policy: ConfigErrorPolicy::Strict, shutdown_signal: None, @@ -668,7 +676,8 @@ where self } - /// Add a layer to the application + /// Apply a layer to business routes already registered, following Axum's ordering. + /// Documentation endpoints are not covered. Use [`Self::app_layer`] for every endpoint. pub fn layer(mut self, layer: L) -> Self where L: Layer + Clone + Send + Sync + 'static, @@ -681,6 +690,24 @@ where self } + /// Apply middleware to the final application, including documentation and fallback routes. + /// + /// Layers are deferred until all routes are assembled, so this covers routes registered + /// before or after this call. Repeated calls follow Axum's layering order: the last layer + /// receives the request first. The layer is installed once, not reconstructed per request. + /// Use [`Self::layer`] to cover only the business routes registered so far. + pub fn app_layer(mut self, layer: L) -> Self + where + L: Layer + Clone + Send + Sync + 'static, + L::Service: Service + Clone + Send + Sync + 'static, + >::Response: Responder + 'static, + >::Error: Into + 'static, + >::Future: Send + 'static, + { + self.app_layers.push(Box::new(move |router| router.layer(layer))); + self + } + /// Set a fallback handler for requests that don't match any route pub fn fallback(mut self, handler: H) -> Self where @@ -691,10 +718,12 @@ where self } - /// Customize the generated OpenAPI spec before it is served at `/openapi.json`. + /// Customize the generated OpenAPI document when it is exported or served. /// /// Repeated calls append transforms. Child subtrees run in `nest`/`merge` insertion order, - /// then this application's own transforms run in registration order, once at assembly. + /// then this application's own transforms run in registration order, once per document. + /// This does not enable HTTP documentation. Without serving or exporting a document, + /// callbacks are not executed. /// All transforms receive the complete document; top-level security is global even when /// set by a child. See [`GotchaRouter::openapi`](crate::GotchaRouter::openapi) for scope and /// conflict rules, shared with the trait API. @@ -771,13 +800,35 @@ where self.layer(CorsLayer::permissive()) } - /// Add OpenAPI support (requires "openapi" feature) + /// Enable the default OpenAPI JSON, Redoc, and Scalar endpoints. + /// + /// The `openapi` feature alone does not expose HTTP documentation. This replaces any + /// previous endpoint configuration with [`crate::OpenApiEndpoints::default`]. Business + /// `layer` middleware does not cover these endpoints; `app_layer` middleware does. #[cfg(feature = "openapi")] pub fn with_openapi(self) -> Self { - // OpenAPI routes are automatically added when the feature is enabled + self.openapi_endpoints(Some(crate::OpenApiEndpoints::default())) + } + + /// Configure documentation paths, or pass `None` to disable every documentation endpoint. + /// The last configuration call wins. This is application configuration, not route metadata. + #[cfg(feature = "openapi")] + pub fn openapi_endpoints(mut self, endpoints: Option) -> Self { + self.openapi_endpoints = endpoints; self } + /// Consume the application builder and export its complete OpenAPI document without HTTP. + /// + /// Uses the same generation and transform pipeline as documentation endpoints. Does not + /// load configuration, construct default state, register tasks, install application layers, + /// or start a server. Registered routes and `FnOnce` transforms are consumed; endpoint + /// settings do not affect the document. User transforms execute normally. + #[cfg(feature = "openapi")] + pub fn into_openapi(self) -> oas::OpenAPIV3 { + self.router.into_openapi() + } + /// Start the server and listen on the configured address /// /// # Example @@ -852,7 +903,13 @@ where } async fn router(&mut self, context: GotchaContext) -> GotchaResult { - Ok(std::mem::take(&mut self.router).into_axum_router(context)) + crate::assembly::assemble( + std::mem::take(&mut self.router), + context, + #[cfg(feature = "openapi")] + self.openapi_endpoints.take(), + |router| std::mem::take(&mut self.app_layers).into_iter().fold(router, |router, layer| layer(router)), + ) } #[cfg(feature = "task")] @@ -999,10 +1056,7 @@ mod tests { *sink.lock().unwrap() = Some(spec.info.title.clone()); spec }); - let _ = app.router.into_axum_router(GotchaContext { - state: EmptyState::default(), - config: ConfigWrapper::default(), - }); + let _ = app.into_openapi(); assert_eq!(captured.lock().unwrap().as_deref(), Some("nested merged parent")); } diff --git a/gotcha/src/lib.rs b/gotcha/src/lib.rs index f566acc..b5d39c3 100644 --- a/gotcha/src/lib.rs +++ b/gotcha/src/lib.rs @@ -8,6 +8,10 @@ // skipped everywhere else. It puts a "requires feature X" badge on each gated item. #![cfg_attr(docsrs, feature(doc_cfg))] +// Exercise the public macros (which emit `::gotcha` paths) in crate-local assembly tests. +#[cfg(test)] +extern crate self as gotcha; + pub use async_trait::async_trait; /// WebSocket upgrade and the socket itself. The frame type stays behind `ws::Message`, since /// [`Message`] is already the message-system trait. @@ -60,7 +64,7 @@ pub use oas; pub use crate::message::{Message, Messager}; #[cfg(feature = "openapi")] #[cfg_attr(docsrs, doc(cfg(feature = "openapi")))] -pub use crate::openapi::Operable; +pub use crate::openapi::{OpenApiEndpoints, Operable}; pub use crate::params::{Cookie, CookieParam, Header, HeaderParam, ParamRejection}; pub use crate::validation::{Valid, ValidRejection}; /// axum's typed-header extractor and the header types it works with. `TypedHeader` documents @@ -70,6 +74,7 @@ pub use axum_extra::{headers, TypedHeader}; /// Use with the [`Valid`] extractor. pub use validator::Validate; +mod assembly; pub mod builder; pub mod config; pub mod error; @@ -227,12 +232,48 @@ pub trait GotchaApp: Sized + Send + Sync { async { Ok(()) } } - /// Assemble the final axum router. Override only to wrap the whole application. + /// Select the application's documentation endpoints. Defaults to `None` (disabled). + /// Return `Some(OpenApiEndpoints::default())` to enable the standard JSON and UI paths. + #[cfg(feature = "openapi")] + fn openapi_endpoints(&self) -> Option { + None + } + + /// Generate the complete OpenAPI document from `routes()`, without starting the app. + /// + /// Does not load configuration, initialize state, bind a listener, register tasks, + /// or call `finish_router`. Each call creates and consumes a fresh route definition, + /// applying its transforms once, as HTTP documentation assembly does. Route registration + /// and user transforms still execute, so their own side effects remain the caller's concern. + #[cfg(feature = "openapi")] + fn openapi_document(&self) -> oas::OpenAPIV3 { + self.routes(GotchaRouter::default()).into_openapi() + } + + /// Apply application-wide middleware after business routes and documentation are mounted. + /// + /// For example, return `router.layer(auth)` to protect every endpoint. This hook runs once + /// in the default `build_router` implementation and is not called during document export. + /// Routes added here use native Axum routing and are not included in OpenAPI metadata. + fn finish_router(&self, router: axum::Router) -> axum::Router { + router + } + + /// Assemble business routes, optional documentation, and then `finish_router`. + /// + /// Prefer `finish_router` for whole-application middleware. Overriding this method takes + /// responsibility for the complete assembly, including documentation and final middleware. fn build_router(&self, context: GotchaContext) -> impl std::future::Future> + Send { async move { let router = GotchaRouter::>::default(); let router = self.routes(router); - Ok(router.into_axum_router(context)) + assembly::assemble( + router, + context, + #[cfg(feature = "openapi")] + self.openapi_endpoints(), + |router| self.finish_router(router), + ) } } diff --git a/gotcha/src/openapi/endpoints.rs b/gotcha/src/openapi/endpoints.rs new file mode 100644 index 0000000..333d55b --- /dev/null +++ b/gotcha/src/openapi/endpoints.rs @@ -0,0 +1,153 @@ +use axum::{body::Bytes, response::Html, routing::get, Router}; +use oas::OpenAPIV3; + +use crate::{GotchaError, GotchaResult}; + +/// HTTP paths for explicitly enabled OpenAPI documentation. +/// +/// Applications expose no documentation endpoints by default. Pass `Some(config)` +/// to [`Gotcha::openapi_endpoints`](crate::Gotcha::openapi_endpoints), or return it +/// from [`GotchaApp::openapi_endpoints`](crate::GotchaApp::openapi_endpoints). +/// [`Gotcha::with_openapi`](crate::Gotcha::with_openapi) enables the default paths. +/// +/// Paths must be distinct, literal, absolute URI paths without a query, fragment, +/// or `.` / `..` segment. Percent-encode characters outside the URI path character set. +/// Invalid configuration returns an error during router assembly. Collisions with +/// existing business routes follow Axum's usual route-conflict behavior (panic). +/// The UI pages load the document from `json_path`, relative to the origin root; +/// configure the externally reachable path when serving behind a reverse proxy. +/// +/// ``` +/// 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, +/// })); +/// ``` +#[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`. + pub redoc_path: Option, + /// Scalar UI path, or `None` to disable it. Defaults to `/scalar`. + pub scalar_path: Option, +} + +impl Default for OpenApiEndpoints { + fn default() -> Self { + Self { + json_path: "/openapi.json".into(), + redoc_path: Some("/redoc".into()), + scalar_path: Some("/scalar".into()), + } + } +} + +impl OpenApiEndpoints { + pub(crate) fn validate(&self) -> GotchaResult<()> { + let paths = [Some(self.json_path.as_str()), self.redoc_path.as_deref(), self.scalar_path.as_deref()]; + for (index, path) in paths.iter().enumerate() { + if let Some(path) = path { + validate_path(path)?; + if paths[..index].contains(&Some(path)) { + return Err(GotchaError::message(format!("duplicate OpenAPI endpoint path: {path}"))); + } + } + } + Ok(()) + } + + pub(crate) fn mount(self, mut router: Router, spec: OpenAPIV3) -> Router { + router = router.route(&self.json_path, get(move || async move { axum::Json(spec.clone()) })); + for (path, template) in [ + (self.redoc_path, include_str!("../../statics/redoc.html")), + (self.scalar_path, include_str!("../../statics/scalar.html")), + ] { + if let Some(path) = path { + // Render once at assembly; Bytes clones share the large embedded UI asset. + let html = Bytes::from(render_ui(template, &self.json_path)); + router = router.route(&path, get(move || async move { Html(html) })); + } + } + router + } +} + +fn validate_path(path: &str) -> GotchaResult<()> { + let parsed = path.parse::(); + if !path.starts_with('/') + || path.starts_with("//") + || !path.bytes().all(|byte| byte.is_ascii_alphanumeric() || b"/-._~!$&'()*+,;=:@%".contains(&byte)) + || path.split('/').any(|segment| { + // Browsers normalize literal and percent-encoded dot segments before requesting a URL. + let decoded = segment.replace("%2e", ".").replace("%2E", "."); + segment.starts_with([':', '*']) || matches!(decoded.as_str(), "." | "..") + }) + || !matches!(parsed, Ok(parsed) if parsed.path() == path && parsed.query().is_none()) + { + return Err(GotchaError::message(format!("OpenAPI endpoint must be a literal absolute URI path: {path:?}"))); + } + Ok(()) +} + +fn render_ui(template: &str, json_path: &str) -> String { + let escaped = json_path + .replace('&', "&") + .replace('"', """) + .replace('\'', "'") + .replace('<', "<") + .replace('>', ">"); + template.replacen("__GOTCHA_OPENAPI_URL__", &escaped, 1) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn endpoint_paths_are_literal_absolute_and_distinct() { + OpenApiEndpoints::default().validate().unwrap(); + for path in ["/", "/docs/schema.json", "/docs/api's&schema.json"] { + validate_path(path).unwrap(); + } + for path in [ + "", + "schema.json", + "//other-host/schema", + "/{id}", + "/{*rest}", + "/:id", + "/*rest", + "/schema?x=1", + "/schema#part", + "/has space", + "/docs/../schema", + "/docs/%2E./schema", + "/docs\\schema", + "/docs/\"schema", + ] { + assert!(validate_path(path).is_err(), "{path:?}"); + } + for endpoints in [ + OpenApiEndpoints { + redoc_path: Some("/openapi.json".into()), + ..Default::default() + }, + OpenApiEndpoints { + scalar_path: Some("/redoc".into()), + ..Default::default() + }, + ] { + assert!(endpoints.validate().is_err()); + } + OpenApiEndpoints { + redoc_path: None, + scalar_path: None, + ..Default::default() + } + .validate() + .unwrap(); + } +} diff --git a/gotcha/src/openapi/mod.rs b/gotcha/src/openapi/mod.rs index 10a50ad..50b7552 100644 --- a/gotcha/src/openapi/mod.rs +++ b/gotcha/src/openapi/mod.rs @@ -1,7 +1,9 @@ //! # OpenAPI Module //! //! This module provides OpenAPI documentation generation capabilities for Gotcha web applications. -//! It is enabled by default but can be disabled by opting out of the "openapi" feature. +//! Enable the `openapi` feature to use it. HTTP documentation endpoints require +//! explicit application configuration through [`crate::Gotcha::with_openapi`] or +//! [`crate::GotchaApp::openapi_endpoints`]. //! //! ## Features //! @@ -27,20 +29,19 @@ //! } //! ``` //! -//! The generated spec is served at `/openapi.json`, with the Redoc UI at `/redoc` -//! and the Scalar UI at `/scalar` when the feature is enabled. +//! The default endpoint configuration serves `/openapi.json`, `/redoc`, and `/scalar`. +//! Use [`crate::GotchaRouter::into_openapi`] to export the document without HTTP. use std::collections::{BTreeMap, HashMap}; use axum::http::Method; -use axum::response::Html; use convert_case::{Case, Casing}; use either::Either; use oas::{Components, Info, OpenAPIV3, Operation, Parameter, PathItem, Referenceable, RequestBody, Responses, SecurityRequirement, Tag}; use regex::Regex; -use crate::Responder; - +mod endpoints; +pub use endpoints::OpenApiEndpoints; pub mod schematic; pub(crate) mod transforms; @@ -52,14 +53,6 @@ pub(crate) mod transforms; /// names. Before 0.8 axum used `:id`, and every path had to be translated here. pub(crate) static PATH_VARIABLE_PATTERN: &str = r"\{([^}]+)\}"; -pub(crate) async fn openapi_html() -> impl Responder { - Html(include_str!("../../statics/redoc.html")) -} - -pub(crate) async fn scalar_html() -> impl Responder { - Html(include_str!("../../statics/scalar.html")) -} - /// What one handler argument contributes: either operation parameters or the request body. pub type ParamType = Either, RequestBody>; diff --git a/gotcha/src/prelude.rs b/gotcha/src/prelude.rs index e13d5d9..a533839 100644 --- a/gotcha/src/prelude.rs +++ b/gotcha/src/prelude.rs @@ -56,7 +56,7 @@ pub type Result> = std::result:: // Feature-specific exports #[cfg(feature = "openapi")] -pub use crate::{api, Responsible, Schematic}; +pub use crate::{api, OpenApiEndpoints, Responsible, Schematic}; #[cfg(feature = "cors")] pub use crate::layers::CorsLayer; diff --git a/gotcha/src/router.rs b/gotcha/src/router.rs index 0fea2ac..7443df3 100644 --- a/gotcha/src/router.rs +++ b/gotcha/src/router.rs @@ -53,7 +53,7 @@ macro_rules! implement_method { pub struct GotchaRouter { #[cfg(feature = "openapi")] /// The operations for the router, kept as their `Operable` descriptors: the `Operation` is - /// only built during `into_axum_router`, so every route's schemas are generated inside a + /// only built when documentation is requested, so every route's schemas are generated inside a /// single collection scope and can share `components/schemas`. pub(crate) operations: std::collections::HashMap<(String, Method), &'static Operable>, /// Child transforms in composition order, followed by this router's own transforms. @@ -202,7 +202,12 @@ impl GotchaRouter { } } - /// add a layer to the router + /// Apply a layer to business routes already registered on this router. + /// + /// As in Axum, routes added afterwards are not covered. Documentation endpoints + /// belong to the application and are not covered by this layer. Use + /// [`Gotcha::app_layer`](crate::Gotcha::app_layer) or + /// [`GotchaApp::finish_router`](crate::GotchaApp::finish_router) for application-wide middleware. /// # Examples /// /// ```rust,no_run @@ -274,7 +279,7 @@ impl GotchaRouter { } } - /// Customize the generated OpenAPI spec before it is served at `/openapi.json`. + /// Customize the generated OpenAPI document when it is exported or served. /// /// The transform receives the fully-generated [`oas::OpenAPIV3`] (with every route's /// operation already filled in) and returns the spec to serve, so you can set the @@ -282,7 +287,9 @@ impl GotchaRouter { /// /// Repeated calls append transforms in registration order. At assembly, child subtrees /// run in `nest`/`merge` insertion order, then this router's own transforms run, even if - /// registered before its children. Each callback runs exactly once, never per request. + /// registered before its children. Each callback runs once per generated document, never + /// per request. Registering a transform does not enable documentation endpoints; if no + /// document is served or exported, callbacks are not executed. /// `merge` treats its argument as a child, so grouping routers can change precedence. /// /// Every callback receives the complete document, including prefixed paths and collected @@ -309,26 +316,25 @@ impl GotchaRouter { self } - /// Finalize this router into a plain `axum::Router`, injecting `state`. + /// Consume the router and return its complete OpenAPI document, without HTTP. /// - /// When the `openapi` feature is enabled, this also mounts the generated - /// spec at `/openapi.json` and the Redoc / Scalar UIs at `/redoc` and - /// `/scalar`. This is the single assembly path shared by both the - /// [`GotchaApp`](crate::GotchaApp) trait and the [`Gotcha`](crate::Gotcha) - /// builder. + /// Generates schemas and applies every transform once, using the same pipeline + /// as documentation endpoints. Consuming the router allows transforms to retain + /// their `FnOnce` captures. No application state or runtime is required. + #[cfg(feature = "openapi")] + pub fn into_openapi(self) -> oas::OpenAPIV3 { + self.into_openapi_parts().1 + } + + #[cfg(feature = "openapi")] + pub(crate) fn into_openapi_parts(self) -> (Router, oas::OpenAPIV3) { + let spec = self.openapi_transforms.apply(crate::openapi::generate_openapi(self.operations)); + (self.router, spec) + } + + /// Inject state without generating or mounting documentation. pub(crate) fn into_axum_router(self, state: State) -> Router { - cfg_if::cfg_if! { - if #[cfg(feature = "openapi")] { - let openapi_spec = self.openapi_transforms.apply(crate::openapi::generate_openapi(self.operations)); - self.router - .with_state(state) - .route("/openapi.json", axum::routing::get(move || async move { axum::Json(openapi_spec.clone()) })) - .route("/redoc", axum::routing::get(crate::openapi::openapi_html)) - .route("/scalar", axum::routing::get(crate::openapi::scalar_html)) - } else { - self.router.with_state(state) - } - } + self.router.with_state(state) } } @@ -389,7 +395,7 @@ mod tests { }) .route_raw("/health", axum::routing::get(|| async { "ok" })) .fallback(|| async { "not found" }); - let _ = router.into_axum_router(()); + let _ = router.into_openapi(); assert!(*ran.lock().unwrap(), "transform set before route_raw()/fallback() must still apply"); } diff --git a/gotcha/statics/redoc.html b/gotcha/statics/redoc.html index 5e47189..3d92f24 100644 --- a/gotcha/statics/redoc.html +++ b/gotcha/statics/redoc.html @@ -18,7 +18,7 @@ - + + data-url="__GOTCHA_OPENAPI_URL__"> \ No newline at end of file diff --git a/gotcha/tests/explicit_error_responses.rs b/gotcha/tests/explicit_error_responses.rs index 00e399c..f97fddc 100644 --- a/gotcha/tests/explicit_error_responses.rs +++ b/gotcha/tests/explicit_error_responses.rs @@ -84,6 +84,10 @@ impl GotchaApp for App { Ok(()) } + fn openapi_endpoints(&self) -> Option { + Some(gotcha::OpenApiEndpoints::default()) + } + fn routes(&self, router: GotchaRouter>) -> GotchaRouter> { router.get("/create/{id}", create).get("/lookup/{id}", lookup).get("/dynamic", dynamic) } diff --git a/gotcha/tests/method_routing.rs b/gotcha/tests/method_routing.rs index 80dbba5..49b0ca6 100644 --- a/gotcha/tests/method_routing.rs +++ b/gotcha/tests/method_routing.rs @@ -36,6 +36,11 @@ impl GotchaApp for App { Ok(()) } + #[cfg(feature = "openapi")] + fn openapi_endpoints(&self) -> Option { + Some(gotcha::OpenApiEndpoints::default()) + } + fn routes(&self, router: GotchaRouter) -> GotchaRouter { let methods = routing::get(read).merge(routing::post(write)).layer(Extension("composed")); router diff --git a/gotcha/tests/nested_openapi_paths.rs b/gotcha/tests/nested_openapi_paths.rs index 5164390..bf0730f 100644 --- a/gotcha/tests/nested_openapi_paths.rs +++ b/gotcha/tests/nested_openapi_paths.rs @@ -21,6 +21,10 @@ impl GotchaApp for NestedApp { Ok(()) } + fn openapi_endpoints(&self) -> Option { + Some(gotcha::OpenApiEndpoints::default()) + } + fn routes(&self, router: GotchaRouter) -> GotchaRouter { router.nest( "/tenants/{tenant}", diff --git a/gotcha/tests/openapi_transforms.rs b/gotcha/tests/openapi_transforms.rs index 1d77fc2..df0ea1f 100644 --- a/gotcha/tests/openapi_transforms.rs +++ b/gotcha/tests/openapi_transforms.rs @@ -24,6 +24,10 @@ impl GotchaApp for App { Ok(()) } + fn openapi_endpoints(&self) -> Option { + Some(gotcha::OpenApiEndpoints::default()) + } + fn routes(&self, router: GotchaRouter) -> GotchaRouter { let child_calls = self.0.clone(); let merged_calls = self.0.clone(); diff --git a/gotcha/tests/response_contracts.rs b/gotcha/tests/response_contracts.rs index bb071b7..f15cb60 100644 --- a/gotcha/tests/response_contracts.rs +++ b/gotcha/tests/response_contracts.rs @@ -96,6 +96,10 @@ impl GotchaApp for App { async fn state(&self, _: &ConfigWrapper) -> GotchaResult<()> { Ok(()) } + fn openapi_endpoints(&self) -> Option { + Some(gotcha::OpenApiEndpoints::default()) + } + fn routes(&self, router: GotchaRouter>) -> GotchaRouter> { router .get("/declared", declared)