diff --git a/README.md b/README.md index 9270a1d..6f77986 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ macro `#[openapi]`. ## Example (with axum-integration feature). -```rust,compile +```rust,no_run use axum::{extract::Query, Json}; use okapi_operation::{axum_integration::*, *}; use serde::Deserialize; @@ -45,22 +45,13 @@ async fn echo_post( } fn main() { - // Here you can also add security schemes, other operations, modify internal OpenApi object. - let oas_builder = OpenApiBuilder::new("Demo", "1.0.0"); - let app = Router::new() .route("/echo/get", get(openapi_handler!(echo_get))) .route("/echo/post", post(openapi_handler!(echo_post))) - .route_openapi_specification("/openapi", oas_builder) + .finish_openapi("/openapi", "Demo", "1.0.0") .expect("no problem"); - - let fut = async { - axum::Server::bind(&"0.0.0.0:3000".parse().unwrap()) - .serve(app.into_make_service()) - .await - .unwrap(); - }; - //tokio::runtime::Runtime::new().block_on(fut); + let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); + axum::serve(listener, app.into_make_service()).await.unwrap() } ``` @@ -71,9 +62,9 @@ fn main() { certain `axum` types): * Compatibility with `axum`: since integration heavely rely on `axum` types, this crate will be compatible only with few (maybe even one) last versions of `axum`; - * Currently supported `axum` versions: `0.6.x`. -* `axum-yaml`: enables ability to serve the spec in yaml format in case of present `Accept` header with `yaml` value. - Otherwise, in case of values `json|*/*` or empty, `json`'s being served. + * Currently supported `axum` versions: `0.7.x`. +* `yaml`: enables ability to serve the spec in yaml format in case of present `Accept` header with `yaml` value. + Otherwise, in case of values `json|*/*` or empty, `json`'s being served (currently affects only `axum-integration`). ## TODO diff --git a/okapi-examples/Cargo.toml b/okapi-examples/Cargo.toml index ab72e8b..cd974fa 100644 --- a/okapi-examples/Cargo.toml +++ b/okapi-examples/Cargo.toml @@ -10,5 +10,5 @@ axum = "0.7" axum-extra = { version = "0.9", features = ["typed-header"] } tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] } -okapi-operation = { path = "../okapi-operation", features = ["default", "yaml"] } +okapi-operation = { path = "../okapi-operation", features = ["default", "yaml", "axum-integration"] } okapi-operation-macro = { path = "../okapi-operation-macro" } diff --git a/okapi-examples/src/main.rs b/okapi-examples/src/main.rs index 08c39f8..be8d19f 100644 --- a/okapi-examples/src/main.rs +++ b/okapi-examples/src/main.rs @@ -35,13 +35,10 @@ async fn echo_post( #[tokio::main] async fn main() { - // Here you can also add security schemes, other operations, modify internal OpenApi object. - let oas_builder = OpenApiBuilder::new("Demo", "1.0.0"); - let app = Router::new() .route("/echo/get", get(openapi_handler!(echo_get))) .route("/echo/post", post(openapi_handler!(echo_post))) - .route_openapi_specification("/openapi", oas_builder) + .finish_openapi("/openapi", "Demo", "1.0.0") .expect("no problem"); let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); diff --git a/okapi-operation/CHANGELOG.md b/okapi-operation/CHANGELOG.md index 51617a6..33eeb2d 100644 --- a/okapi-operation/CHANGELOG.md +++ b/okapi-operation/CHANGELOG.md @@ -3,12 +3,35 @@ All notable changes to this project will be documented in the changelog of the r This project follows the [Semantic Versioning standard](https://semver.org/). -## [0.3.0 (Unreleased)] - 2023-12-03 +## [0.3.0-rc1] - 2023-12-03 ### Notable changes - - `axum` integration updated to be used with axum 0.7. Also this makes library unusable with older versions of `axum`. + - `axum` integration updated to be used with axum 0.7. Also this makes library unusable with older versions of `axum`; + - `OpenApiBuilder` rewritten, now providing more safe API to inner specification; + - Simplified usage of `axum-integration::Router` - it is now unnecessary to provide `OpenApiBuilder`. + +### Added + - New methods for `OpenApiBuilder` for setting variuos fields in inner specification; + - `OpenApiBuilder::build()` method for building specification (replaced `generate_spec()`); + - `OpenApiBuilder` inside `axum-integration::Router`, which allow to omit explicit vreation of builder; + - New methods in `axum-integration::Router`: + - `set_openapi_builder_template` - replace `OpenApiBuilder` inside `Router`; + - `update_openapi_builder_template` - update `OpenApiBuilder` inside `Router`; + - `openapi_builder_template_mut` - get mutable reference to `OpenApiBuilder` from `Router`; + - `generate_openapi_builder` - generate `OpenApiBuilder` from `Router`; + - (!) `finish_openapi` - builder OpenAPI specification, mount it to path and return `axum::Router` (replaces `route_openapi_specification` method). ### Changed - - `axum` integration types updated to be used with axum 0.7. + - (breaking) `axum` integration types updated to be used with axum 0.7. + +### Removed + - (breaking) `set_openapi_version`, because underlying library compatible only with OpenAPI `3.0.x` (`x` is 0 to 3, changes between versions minor). Now generated specification always have OpenAPI version `3.0.0`; + - (breaking) Bunch of old methods from `OpenApiBuilder`; + - (breaking) `axum-integration::Router::route_openapi_specification()` (replaced by `finish_openapi` method). + +### Fixed + - (breaking) `OpenApiBuilder::add_operations` now use passed paths as is. Previously it converted it from `axum` format to OpenAPI, which could mess up integration with another framework. This change does not affect `axum` integration; + - (breaking) Feature `axum-integration` disabled by default, it was enabled by mistake previously; + - Minor documentation fixes. ## [0.2.2] - 2023-12-03 diff --git a/okapi-operation/Cargo.toml b/okapi-operation/Cargo.toml index a13caf6..85731e9 100644 --- a/okapi-operation/Cargo.toml +++ b/okapi-operation/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "okapi-operation" description = "Procedural macro for generating OpenAPI operation specification (using okapi)" -version = "0.3.0" +version = "0.3.0-rc1" authors = ["Andrey Kononov flowneee3@gmail.com"] edition = "2021" license = "MIT" @@ -28,11 +28,11 @@ serde_yaml = { version = "0.8", optional = true } [dev-dependencies] axum = "0.7" axum-extra = { version = "0.9", features = ["typed-header"] } -tokio = "1" +tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] } [features] -default = ["macro", "axum-integration"] +default = ["macro"] macro = ["okapi-operation-macro"] diff --git a/okapi-operation/docs/axum_integration.md b/okapi-operation/docs/axum_integration.md index 22b7171..17cf02d 100644 --- a/okapi-operation/docs/axum_integration.md +++ b/okapi-operation/docs/axum_integration.md @@ -1,5 +1,9 @@ # Integration with axum +- [`Integration with axum`](#-integration-with-axum-) + * [Example](#example)) + * [Customizing `OpenApiBuilder`](#customizing-openapibuilder) + This module provide integration with [`axum`] based on `#[openapi]` macro. Integration is done by replacing [`axum::Router`] and [`axum::routing::MethodRouter`] with drop-in replacements, which support binding OpenAPI operation to handler using [`openapi_handler`] and [`openapi_service`] macros. @@ -8,7 +12,7 @@ Integration is done by replacing [`axum::Router`] and [`axum::routing::MethodRou This is example from root of this crate, but this time with [`axum_integration`]. -```rust,compile +```no_run use axum::{extract::Query, Json}; use okapi_operation::{axum_integration::*, *}; use serde::Deserialize; @@ -43,21 +47,48 @@ async fn echo_post( Json(body.0.data) } -fn main() { - // Here you can also add security schemes, other operations, modify internal OpenApi object. - let oas_builder = OpenApiBuilder::new("Demo", "1.0.0"); - +#[tokio::main] +async fn main() { let app = Router::new() .route("/echo/get", get(openapi_handler!(echo_get))) .route("/echo/post", post(openapi_handler!(echo_post))) - .route_openapi_specification("/openapi", oas_builder) + .finish_openapi("/openapi", "Demo", "1.0.0") .expect("no problem"); - let fut = async { - let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); - axum::serve(listener, app.into_make_service()).await.unwrap() - }; - //tokio::runtime::Runtime::new().block_on(fut); + let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); + axum::serve(listener, app.into_make_service()).await.unwrap() } ``` +## Customizing [`OpenApiBuilder`] + +By default [`Router`] have an empty [`OpenApiBuilder::default()`] inside, which is used as template when generating specification. The only 2 mandatory fields in specification is set when calling [`Router::finish_openapi`]. + +If you need to customize builder template, you can either: + +* access existing builder with [`Router::openapi_builder_template_mut`] (example below) or [`Router::update_openapi_builder_template`]; +* prepare your own builder and set it with [`Router::set_openapi_builder_template`]. + +```no_run +use axum::{extract::Query, Json}; +use okapi_operation::{axum_integration::*, *}; +use serde::Deserialize; + +#[tokio::main] +async fn main() { + let mut app = Router::new(); + + // Setting description and ToS. + app.openapi_builder_template_mut() + .description("Some description") + .terms_of_service("Terms of Service"); + + // Proceed as usual. + let app = app + .finish_openapi("/openapi", "Demo", "1.0.0") + .expect("no problem"); + + let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); + axum::serve(listener, app.into_make_service()).await.unwrap() +} +``` diff --git a/okapi-operation/docs/root.md b/okapi-operation/docs/root.md index 10fa41e..c475c7e 100644 --- a/okapi-operation/docs/root.md +++ b/okapi-operation/docs/root.md @@ -1,7 +1,7 @@ # `okapi-operation` - [`okapi-operation`](#-okapi-operation-) - * [Example (using axum, but without axum_integration feature)](#example-using-axum-but-without-axum_integration-feature)) + * [Example (using axum, but without axum_integration feature)](#example-using-axum-but-without-axum_integration-feature) * [`openapi` macro](#openapi-macro) + [Minimal example](#minimal-example) + [Operation attributes](#operation-attributes) @@ -31,7 +31,7 @@ Crate which allow to generate OpenAPI's operation definitions (using types from ## Example (using axum, but without axum_integration feature) -```rust,ignore +```ignore use axum::{ extract::Query, http::Method, @@ -74,9 +74,9 @@ async fn echo_post( async fn openapi_spec() -> Json { let generate_spec = || { OpenApiBuilder::new("Echo API", "1.0.0") - .add_operation("/echo/get", Method::GET, echo_get__openapi)? - .add_operation("/echo/post", Method::POST, echo_post__openapi)? - .generate_spec() + .try_operation("/echo/get", Method::GET, echo_get__openapi)? + .try_operation("/echo/post", Method::POST, echo_post__openapi)? + .build() }; generate_spec().map(Json).expect("Should not fail") } @@ -88,10 +88,8 @@ async fn main() { .route("/echo/post", post(echo_post)) .route("/openapi", get(openapi_spec)); - axum::Server::bind(&"0.0.0.0:3000".parse().unwrap()) - .serve(app.into_make_service()) - .await - .unwrap(); + let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); + axum::serve(listener, app.into_make_service()).await.unwrap() } ``` @@ -107,7 +105,7 @@ Since most attributes taken from OpenAPI specification directly, refer to [OpenA Macro doesn't have any mandatory attributes. -```rust,compile +```compile # use okapi_operation::*; #[openapi] async fn handler() {} @@ -119,7 +117,7 @@ All attributes is translated into same fields of [`okapi::openapi3::Operation`]. Tags is provided as single string, which later is separated by comma. -```rust,compile +```no_run # use okapi_operation::*; #[openapi( summary = "Simple handler", @@ -135,7 +133,7 @@ async fn handler() {} External documentation can be set for operation. It is translated to [`okapi::openapi3::ExternalDocs`]. -```rust,compile +```no_run # use okapi_operation::*; #[openapi( external_docs( @@ -170,7 +168,7 @@ This definition translated to [`okapi::openapi3::Parameter`] with [`okapi::opena * style (string, optional) - how parameter is serialized (see [OpenAPI docs](https://swagger.io/docs/specification/serialization/)); * schema (path, mandatory) - path to type of parameter. -```rust,compile +```no_run # use okapi_operation::*; #[openapi( parameters( @@ -201,7 +199,7 @@ async fn handler() {} * allow_reserved (bool, optional) - allow reserved characters `:/?#[]@!$&'()*+,;=` in parameter; * schema (path, mandatory) - path to type of parameter. -```rust,compile +```no_run # use okapi_operation::*; #[openapi( parameters( @@ -233,7 +231,7 @@ async fn handler() {} Unlike header and query parameters, all path parameters is mandatory. -```rust,compile +```no_run # use okapi_operation::*; #[openapi( parameters( @@ -261,7 +259,7 @@ async fn handler() {} * allow_empty_value (bool, optional) - allow empty value for this parameter; * schema (path, mandatory) - path to type of parameter. -```rust,compile +```no_run # use okapi_operation::*; #[openapi( parameters( @@ -281,7 +279,7 @@ async fn handler() {} #### Reference -```rust,compile +```no_run # use okapi_operation::*; #[openapi( parameters( @@ -295,7 +293,7 @@ async fn handler() {} Specifying multiple parameters is supported: -```rust,compile +```no_run # use okapi_operation::*; #[openapi( parameters( @@ -338,7 +336,7 @@ Request body definition have following attributes: * required (bool, optional); * content (path, optional) - path to type, which schema should be used. If not speified, argument's type is used. -```rust,compile +```no_run # use okapi_operation::*; # use okapi::schemars::*; # struct Json(T); @@ -378,7 +376,7 @@ Responses can be: Return type should implement [`ToResponses`] trait. -```rust,compile +```no_run # use okapi_operation::*; # use okapi::schemars::*; # struct Json(T); @@ -399,7 +397,7 @@ async fn handler() -> Json { If return type doesn't implement [`ToResponses`], it can be ignored with special attribute `ignore_return_type`: -```rust,compile +```no_run # use okapi_operation::*; #[openapi( responses( @@ -427,7 +425,7 @@ Single response have following attributes: * content (path, mandatory) - path to type, which provide schemas for this response; * headers (list, optional) - list of headers (definition is the same as in request parameters). References to header is also allowed. -```rust,compile +```no_run # use okapi_operation::*; # use okapi::schemars::*; # struct Json(T); @@ -468,7 +466,7 @@ async fn handler() { Responses can be generated from type, which implement [`ToResponses`]: -```rust,compile +```no_run # use okapi_operation::*; # use okapi::schemars::*; # struct Json(T); @@ -498,7 +496,7 @@ Reference to response have following attributes: * status (string, mandatory) - HTTP status (or pattern like 2XX, 3XX). To define defautl fallback type, use special `default` value; * reference (string, mandatory). -```rust,compile +```no_run # use okapi_operation::*; #[openapi( responses( @@ -524,7 +522,7 @@ first occurence is used. Responses merged in following order: * references; * from types. -```rust,compile +```no_run # use okapi_operation::*; # use okapi::schemars::*; # struct Json(T); @@ -566,7 +564,7 @@ Security scheme have following attributes: If multiple schemes specified, they are combined as OR. AND is not currently supported. -```rust,compile +```no_run # use okapi_operation::*; #[openapi( security( @@ -616,9 +614,9 @@ async fn handler2() -> Json { fn generate_openapi_specification() -> Result { OpenApiBuilder::new("Demo", "1.0.0") - .add_operation("/handle/1", Method::POST, handler1__openapi)? - .add_operation("/handle/2", Method::GET, handler2__openapi)? - .generate_spec() + .operation("/handle/1", Method::POST, handler1__openapi) + .operation("/handle/2", Method::GET, handler2__openapi) + .build() } assert!(generate_openapi_specification().is_ok()); diff --git a/okapi-operation/src/axum_integration/handler_traits.rs b/okapi-operation/src/axum_integration/handler_traits.rs index b5013fe..64b5df3 100644 --- a/okapi-operation/src/axum_integration/handler_traits.rs +++ b/okapi-operation/src/axum_integration/handler_traits.rs @@ -163,6 +163,8 @@ where #[cfg(test)] mod tests { + #![allow(clippy::let_underscore_future)] + use std::convert::Infallible; use axum::{ @@ -228,7 +230,6 @@ mod tests { .on_service( MethodFilter::POST, service2 - .clone() .with_openapi(openapi_generator) .with_openapi(openapi_generator), ) diff --git a/okapi-operation/src/axum_integration/mod.rs b/okapi-operation/src/axum_integration/mod.rs index 1210cb0..c0ddd3f 100644 --- a/okapi-operation/src/axum_integration/mod.rs +++ b/okapi-operation/src/axum_integration/mod.rs @@ -6,7 +6,7 @@ pub use paste::paste; pub use self::{ handler_traits::{HandlerExt, HandlerWithOperation, ServiceExt, ServiceWithOperation}, method_router::*, - router::Router, + router::{Router, DEFAULT_OPENAPI_PATH}, }; #[cfg(feature = "yaml")] @@ -17,6 +17,7 @@ mod method_router; mod operations; mod router; mod trait_impls; +mod utils; use axum::response::{IntoResponse, Response}; use axum::{extract::State, Json}; diff --git a/okapi-operation/src/axum_integration/router.rs b/okapi-operation/src/axum_integration/router.rs index aef11f6..dadd83c 100644 --- a/okapi-operation/src/axum_integration/router.rs +++ b/okapi-operation/src/axum_integration/router.rs @@ -4,16 +4,17 @@ use axum::{ extract::Request, handler::Handler, http::Method, response::IntoResponse, routing::Route, Router as AxumRouter, }; -use okapi::openapi3::OpenApi; use tower::{Layer, Service}; -use crate::OpenApiBuilder; - use super::{ get, method_router::{MethodRouter, MethodRouterOperations}, operations::RoutesOperations, + utils::convert_axum_path_to_openapi, }; +use crate::OpenApiBuilder; + +pub const DEFAULT_OPENAPI_PATH: &str = "/openapi"; /// Drop-in replacement for [`axum::Router`], which supports OpenAPI operations. /// @@ -23,6 +24,7 @@ use super::{ pub struct Router { axum_router: AxumRouter, routes_operations_map: HashMap, + openapi_builder_template: OpenApiBuilder, } impl From> for Router { @@ -30,6 +32,7 @@ impl From> for Router { Self { axum_router: value, routes_operations_map: Default::default(), + openapi_builder_template: OpenApiBuilder::default(), } } } @@ -61,6 +64,7 @@ where Self { axum_router: AxumRouter::new(), routes_operations_map: HashMap::new(), + openapi_builder_template: OpenApiBuilder::default(), } } @@ -218,6 +222,7 @@ where Router { axum_router: self.axum_router.layer(layer), routes_operations_map: self.routes_operations_map, + openapi_builder_template: self.openapi_builder_template, } } @@ -235,6 +240,7 @@ where Router { axum_router: self.axum_router.route_layer(layer), routes_operations_map: self.routes_operations_map, + openapi_builder_template: self.openapi_builder_template, } } @@ -283,6 +289,7 @@ where Router { axum_router: self.axum_router.with_state(state), routes_operations_map: self.routes_operations_map, + openapi_builder_template: self.openapi_builder_template, } } @@ -304,10 +311,59 @@ where RoutesOperations::new(self.routes_operations_map.clone()) } - // TODO: refactor, I don't like this API + /// Generate [`OpenApiBuilder`] from current router. + /// + /// Generated builder will be based on current builder template, + /// have all routes and types, present in this router. + /// + /// If template was not set, then [`OpenApiBuilder::default()`] is used. + pub fn generate_openapi_builder(&self) -> OpenApiBuilder { + let routes = self.routes_operations().openapi_operation_generators(); + let mut builder = self.openapi_builder_template.clone(); + // Don't use try_operations since duplicates should be checked + // when mounting route to axum router. + builder.operations( + routes + .into_iter() + .map(|((x, y), z)| (convert_axum_path_to_openapi(&x), y, z)), + ); + builder + } + + /// Set [`OpenApiBuilder`] template for this router. + /// + /// By default [`OpenApiBuilder::default()`] is used. + pub fn set_openapi_builder_template(&mut self, builder: OpenApiBuilder) -> &mut Self { + self.openapi_builder_template = builder; + self + } + + /// Update [`OpenApiBuilder`] template of this router. + /// + /// By default [`OpenApiBuilder::default()`] is used. + pub fn update_openapi_builder_template(&mut self, f: F) -> &mut Self + where + F: FnOnce(&mut OpenApiBuilder), + { + f(&mut self.openapi_builder_template); + self + } + + /// Get mutable reference to [`OpenApiBuilder`] template of this router. + /// + /// By default [`OpenApiBuilder::default()`] is set. + pub fn openapi_builder_template_mut(&mut self) -> &mut OpenApiBuilder { + &mut self.openapi_builder_template + } + /// Generate OpenAPI specification, mount it to inner router and return inner [`axum::Router`]. /// - /// This method is just for convenience and should be used after all routes mounted to root router. + /// Specification is based on [`OpenApiBuilder`] template, if one was set previously. + /// If template was not set, then [`OpenApiBuilder::default()`] is used. + /// + /// Note that passed `title` and `version` will override same values in OpenAPI builder template. + /// + /// By default specification served at [`DEFAULT_OPENAPI_PATH`] (`/openapi`). /// /// # Example /// @@ -318,39 +374,42 @@ where /// /// let app = Router::new().route("/", get(openapi_handler!(handler))); /// # async { - /// let oas_builder = OpenApiBuilder::new("Demo", "1.0.0"); - /// let app = app.route_openapi_specification("/openapi", oas_builder).expect("ok"); + /// let app = app.finish_openapi("/openapi", "Demo", "1.0.0").expect("ok"); /// # let listener = tokio::net::TcpListener::bind("").await.unwrap(); /// # axum::serve(listener, app.into_make_service()).await.unwrap() /// # }; /// ``` - pub fn route_openapi_specification( + pub fn finish_openapi<'a>( mut self, - path: &str, - mut openapi_builder: OpenApiBuilder, + serve_path: impl Into>, + title: impl Into, + version: impl Into, ) -> Result, anyhow::Error> { - let mut routes = self.routes_operations().openapi_operation_generators(); - let _ = routes.insert( - (path.to_string(), Method::GET), - super::serve_openapi_spec__openapi, - ); - let spec = openapi_builder - .add_operations(routes.into_iter().map(|((x, y), z)| (x, y, z)))? - .generate_spec()?; - self = self.route(path, get(super::serve_openapi_spec).with_state(spec)); + let serve_path = serve_path.into().unwrap_or(DEFAULT_OPENAPI_PATH); + + // Don't use try_operation since duplicates should be checked + // when mounting route to axum router. + let spec = self + .generate_openapi_builder() + .operation( + convert_axum_path_to_openapi(serve_path), + Method::GET, + super::serve_openapi_spec__openapi, + ) + .title(title) + .version(version) + .build()?; + + self = self.route(serve_path, get(super::serve_openapi_spec).with_state(spec)); + Ok(self.axum_router) } - - // fn add_routes_to_openapi_builder( - // &self, - // builder: &mut OpenApiBuilder, - // ) -> Result<(), anyhow::Error> { - // let mut routes = self.routes_operations().openapi_operation_generators(); - // } } #[cfg(test)] mod tests { + #![allow(clippy::let_underscore_future)] + use axum::{http::Method, routing::get as axum_get}; use okapi::openapi3::Operation; use tokio::net::TcpListener; diff --git a/okapi-operation/src/utils.rs b/okapi-operation/src/axum_integration/utils.rs similarity index 100% rename from okapi-operation/src/utils.rs rename to okapi-operation/src/axum_integration/utils.rs diff --git a/okapi-operation/src/builder.rs b/okapi-operation/src/builder.rs index 69d949d..2a67061 100644 --- a/okapi-operation/src/builder.rs +++ b/okapi-operation/src/builder.rs @@ -1,32 +1,43 @@ +use std::collections::HashMap; + +use anyhow::{bail, Context}; use http::Method; -use okapi::openapi3::{Info, OpenApi, SecurityRequirement, SecurityScheme}; +use okapi::openapi3::{ + Contact, ExternalDocs, License, OpenApi, SecurityRequirement, SecurityScheme, Server, Tag, +}; -use crate::{components::Components, utils::convert_axum_path_to_openapi, OperationGenerator}; +use crate::{components::Components, OperationGenerator}; /// OpenAPI specificatrion builder. #[derive(Clone)] pub struct OpenApiBuilder { spec: OpenApi, components: Components, + operations: HashMap<(String, Method), OperationGenerator>, } -impl OpenApiBuilder { - /// Create new builder with specified title and version. - pub fn new(title: &str, spec_version: &str) -> Self { +impl Default for OpenApiBuilder { + fn default() -> Self { let spec = OpenApi { openapi: OpenApi::default_version(), - info: Info { - title: title.into(), - version: spec_version.into(), - ..Default::default() - }, ..Default::default() }; Self { spec, components: Components::new(Default::default()), + operations: HashMap::new(), } } +} + +impl OpenApiBuilder { + /// Create new builder with specified title and version + pub fn new(title: &str, version: &str) -> Self { + let mut this = Self::default(); + this.title(title); + this.version(version); + this + } /// Alter default [`Components`]. /// @@ -39,26 +50,85 @@ impl OpenApiBuilder { self } - /// Set OpenAPI version (should be 3.0.x). - pub fn set_openapi_version(&mut self, version: String) -> &mut Self { - self.spec.openapi = version; - self + /// Add single operation. + /// + /// Throws an error if (path, method) pair is already present. + pub fn try_operation( + &mut self, + path: T, + method: Method, + generator: OperationGenerator, + ) -> Result<&mut Self, anyhow::Error> + where + T: Into, + { + let path = path.into(); + if self + .operations + .insert((path.clone(), method.clone()), generator) + .is_some() + { + bail!("{method} {path} is already present in specification"); + }; + Ok(self) } - /// Access to inner [`okapi::openapi3::OpenApi`]. - pub fn spec_mut(&mut self) -> &mut OpenApi { - &mut self.spec + /// Add multiple operations. + /// + /// Throws an error if any (path, method) pair is already present. + pub fn try_operations(&mut self, operations: I) -> Result<&mut Self, anyhow::Error> + where + I: Iterator, + S: Into, + { + for (path, method, f) in operations { + self.try_operation(path, method, f)?; + } + Ok(self) + } + + /// Add single operation. + /// + /// Replaces operation if (path, method) pair is already present. + pub fn operation( + &mut self, + path: T, + method: Method, + generator: OperationGenerator, + ) -> &mut Self + where + T: Into, + { + let _ = self.try_operation(path, method, generator); + self } - /// Add security scheme definition. - pub fn add_security_def(&mut self, name: N, sec: SecurityScheme) -> &mut Self + /// Add multiple operations. + /// + /// Replaces operation if (path, method) pair is already present. + pub fn operations(&mut self, operations: I) -> &mut Self where - N: Into, + I: Iterator, + S: Into, { - self.components.add_security(name, sec); + for (path, method, f) in operations { + self.operation(path, method, f); + } self } + /// Access inner [`okapi::openapi3::OpenApi`]. + /// + /// **Warning!** This allows raw access to underlying `OpenApi` object, + /// which might break generated specification. + /// + /// # NOTE + /// + /// Components are overwritten on building specification. + pub fn spec_mut(&mut self) -> &mut OpenApi { + &mut self.spec + } + /// Apply security scheme globally. pub fn apply_global_security(&mut self, name: N, scopes: S) -> &mut Self where @@ -71,52 +141,128 @@ impl OpenApiBuilder { self } - /// Add single operation. - pub fn add_operation( - &mut self, - path: &str, - method: Method, - generator: OperationGenerator, - ) -> Result<&mut Self, anyhow::Error> { - let operation_schema = generator(&mut self.components)?; - let path = self.spec.paths.entry(path.into()).or_default(); - if method == Method::DELETE { - path.delete = Some(operation_schema); - } else if method == Method::GET { - path.get = Some(operation_schema); - } else if method == Method::HEAD { - path.head = Some(operation_schema); - } else if method == Method::OPTIONS { - path.options = Some(operation_schema); - } else if method == Method::PATCH { - path.patch = Some(operation_schema); - } else if method == Method::POST { - path.post = Some(operation_schema); - } else if method == Method::PUT { - path.put = Some(operation_schema); - } else if method == Method::TRACE { - path.trace = Some(operation_schema); - } else { - return Err(anyhow::anyhow!("Unsupported method {}", method)); - } - Ok(self) - } + /// Generate [`okapi::openapi3::OpenApi`] specification. + /// + /// This method can be called repeatedly on the same object. + pub fn build(&mut self) -> Result { + let mut spec = self.spec.clone(); - /// Add multiple operations. - pub fn add_operations( - &mut self, - operations: impl Iterator, - ) -> Result<&mut Self, anyhow::Error> { - for (path, method, f) in operations { - self.add_operation(&convert_axum_path_to_openapi(&path), method, f)?; + for ((path, method), generator) in &self.operations { + try_add_path( + &mut spec, + &mut self.components, + path, + method.clone(), + *generator, + ) + .with_context(|| format!("Failed to add {method} {path}"))?; } - Ok(self) - } - /// Generate [`okapi::openapi3::OpenApi`] specification. - pub fn generate_spec(&mut self) -> Result { - let mut spec = self.spec.clone(); spec.components = Some(self.components.okapi_components()?); + Ok(spec) } + + // Helpers to set OpenApi info/servers/tags/... as is + + /// Set specification title. + /// + /// Empty string by default. + pub fn title(&mut self, title: impl Into) -> &mut Self { + self.spec.info.title = title.into(); + self + } + + /// Set specification version. + /// + /// Empty string by default. + pub fn version(&mut self, version: impl Into) -> &mut Self { + self.spec.info.version = version.into(); + self + } + + /// Add description to specification. + pub fn description(&mut self, description: impl Into) -> &mut Self { + self.spec.info.description = Some(description.into()); + self + } + + /// Add contact to specification. + pub fn contact(&mut self, contact: Contact) -> &mut Self { + self.spec.info.contact = Some(contact); + self + } + + /// Add license to specification. + pub fn license(&mut self, license: License) -> &mut Self { + self.spec.info.license = Some(license); + self + } + + /// Add terms_of_service to specification. + pub fn terms_of_service(&mut self, terms_of_service: impl Into) -> &mut Self { + self.spec.info.terms_of_service = Some(terms_of_service.into()); + self + } + + /// Add server to specification. + pub fn server(&mut self, server: Server) -> &mut Self { + self.spec.servers.push(server); + self + } + + /// Add tag to specification. + pub fn tag(&mut self, tag: Tag) -> &mut Self { + self.spec.tags.push(tag); + self + } + + /// Set external documentation for specification. + pub fn external_docs(&mut self, docs: ExternalDocs) -> &mut Self { + let _ = self.spec.external_docs.insert(docs); + self + } + + /// Add security scheme definition to specification. + pub fn security_scheme(&mut self, name: N, sec: SecurityScheme) -> &mut Self + where + N: Into, + { + self.components.add_security_scheme(name, sec); + self + } +} + +fn try_add_path( + spec: &mut OpenApi, + components: &mut Components, + path: &str, + method: Method, + generator: OperationGenerator, +) -> Result<(), anyhow::Error> { + let operation_schema = generator(components)?; + let path_str = path; + let path = spec.paths.entry(path.into()).or_default(); + if method == Method::DELETE { + path.delete = Some(operation_schema); + } else if method == Method::GET { + path.get = Some(operation_schema); + } else if method == Method::HEAD { + path.head = Some(operation_schema); + } else if method == Method::OPTIONS { + path.options = Some(operation_schema); + } else if method == Method::PATCH { + path.patch = Some(operation_schema); + } else if method == Method::POST { + path.post = Some(operation_schema); + } else if method == Method::PUT { + path.put = Some(operation_schema); + } else if method == Method::TRACE { + path.trace = Some(operation_schema); + } else { + return Err(anyhow::anyhow!( + "Unsupported method {method} (at {path_str})" + )); + } + Ok(()) } diff --git a/okapi-operation/src/components.rs b/okapi-operation/src/components.rs index 0a4a4a0..a2e57b4 100644 --- a/okapi-operation/src/components.rs +++ b/okapi-operation/src/components.rs @@ -70,7 +70,7 @@ impl Components { } /// Add security scheme to components. - pub fn add_security(&mut self, name: N, sec: SecurityScheme) + pub fn add_security_scheme(&mut self, name: N, sec: SecurityScheme) where N: Into, { diff --git a/okapi-operation/src/lib.rs b/okapi-operation/src/lib.rs index b1bcf75..aac5494 100644 --- a/okapi-operation/src/lib.rs +++ b/okapi-operation/src/lib.rs @@ -28,7 +28,6 @@ mod builder; mod components; mod to_media_types; mod to_responses; -mod utils; /// Empty type alias (for using in attribute values). pub type Empty = ();