From 8ed93482cc3b129ecb1fbbfb12e60f0b86462f2b Mon Sep 17 00:00:00 2001 From: NexVeridian Date: Tue, 12 Nov 2024 01:42:13 -0800 Subject: [PATCH 01/28] inti --- Cargo.toml | 16 ++++++++++++++++ src/controller/app_routes.rs | 34 +++++++++++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index c1426717e..27d1e9e53 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,13 @@ cli = ["dep:clap"] testing = ["dep:axum-test"] with-db = ["dep:sea-orm", "dep:sea-orm-migration", "loco-gen/with-db"] channels = ["dep:socketioxide"] +openapi = [ + "dep:utoipa", + "dep:utoipa-axum", + "dep:utoipa-swagger-ui", + "dep:utoipa-redoc", + "dep:utoipa-scalar", +] # Storage features all_storage = ["storage_aws_s3", "storage_azure", "storage_gcp"] storage_aws_s3 = ["object_store/aws"] @@ -123,6 +130,15 @@ uuid = { version = "1.10.0", features = ["v4", "fast-rng"] } # A socket.io server implementation socketioxide = { version = "0.14.0", features = ["state"], optional = true } +# OpenAPI +utoipa = { version = "5.0.0", optional = true } +utoipa-axum = { version = "0.1.0", optional = true } +utoipa-swagger-ui = { version = "8.0", features = [ + "axum", + "vendored", +], optional = true } +utoipa-redoc = { version = "5.0.0", features = ["axum"], optional = true } +utoipa-scalar = { version = "0.2.0", features = ["axum"], optional = true } # File Upload object_store = { version = "0.11.0", default-features = false } diff --git a/src/controller/app_routes.rs b/src/controller/app_routes.rs index 32b6cfa5e..4d17a6797 100644 --- a/src/controller/app_routes.rs +++ b/src/controller/app_routes.rs @@ -6,6 +6,19 @@ use std::{fmt, sync::OnceLock}; use axum::Router as AXRouter; use regex::Regex; +#[cfg(feature = "openapi")] +use utoipa::{ + openapi::security::{ApiKey, ApiKeyValue, HttpAuthScheme, HttpBuilder, SecurityScheme}, + Modify, OpenApi, +}; +#[cfg(feature = "openapi")] +use utoipa_axum::{router::OpenApiRouter, routes}; +#[cfg(feature = "openapi")] +use utoipa_redoc::{Redoc, Servable}; +#[cfg(feature = "openapi")] +use utoipa_scalar::{Scalar, Servable as ScalarServable}; +#[cfg(feature = "openapi")] +use utoipa_swagger_ui::SwaggerUi; #[cfg(feature = "channels")] use super::channels::AppChannels; @@ -202,11 +215,30 @@ impl AppRoutes { // using the router directly, and ServiceBuilder has been reported to give // issues in compile times itself (https://github.com/rust-lang/crates.io/pull/7443). // + #[cfg(feature = "openapi")] + let mut api_router = { + #[derive(OpenApi)] + #[openapi(info(title = "API Documentation", description = "API Documentation"))] + struct ApiDoc; + OpenApiRouter::with_openapi(ApiDoc::openapi()) + }; + for router in self.collect() { tracing::info!("{}", router.to_string()); - app = app.route(&router.uri, router.method); + #[cfg(not(feature = "openapi"))] + { + app = app.route(&router.uri, router.method); + } + #[cfg(feature = "openapi")] + { + app = app.route(&router.uri, router.method.clone()); + api_router = api_router.route(&router.uri, router.method); + } } + #[cfg(feature = "openapi")] + let (_, api) = api_router.split_for_parts(); + #[cfg(feature = "channels")] if let Some(channels) = self.channels.as_ref() { tracing::info!("[Middleware] +channels"); From 6deca2b583f808a4967205948b53a5d9bb096d75 Mon Sep 17 00:00:00 2001 From: NexVeridian Date: Tue, 12 Nov 2024 17:15:23 -0800 Subject: [PATCH 02/28] hook initial_openapi_spec --- src/app.rs | 56 ++++++++++++++++++++++++++++++++++++ src/controller/app_routes.rs | 7 +---- 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/src/app.rs b/src/app.rs index 26cd1c7ff..6ae7790c6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -219,6 +219,62 @@ pub trait Hooks: Send { /// This function allows users to perform any necessary cleanup or final /// actions before the application stops completely. async fn on_shutdown(_ctx: &AppContext) {} + + /// Modify the OpenAPI spec before the routes are added, allowing you to edit (openapi::info)[https://docs.rs/utoipa/latest/utoipa/openapi/info/struct.Info.html] + /// # Examples + /// ```rust ignore + /// fn inital_openapi_spec() { + /// #[derive(OpenApi)] + /// #[openapi(info( + /// title = "Loco Demo", + /// description = "This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project." + /// ))] + /// struct ApiDoc; + /// ApiDoc::openapi() + /// } + /// ``` + /// + /// With SecurityAddon + /// ```rust ignore + /// fn inital_openapi_spec() { + /// #[derive(OpenApi)] + /// #[openapi(modifiers(&SecurityAddon), info( + /// title = "Loco Demo", + /// description = "This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project." + /// ))] + /// struct ApiDoc; + /// + /// // TODO set the jwt token location + /// // let auth_location = ctx.config.auth.as_ref(); + /// + /// struct SecurityAddon; + /// impl Modify for SecurityAddon { + /// fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + /// if let Some(components) = openapi.components.as_mut() { + /// components.add_security_schemes_from_iter([ + /// ( + /// "jwt_token", + /// SecurityScheme::Http( + /// HttpBuilder::new() + /// .scheme(HttpAuthScheme::Bearer) + /// .bearer_format("JWT") + /// .build(), + /// ), + /// ), + /// ( + /// "api_key", + /// SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new("apikey"))), + /// ), + /// ]); + /// } + /// } + /// } + /// ApiDoc::openapi() + /// } + /// ``` + #[cfg(feature = "openapi")] + #[must_use] + fn inital_openapi_spec(_ctx: &AppContext) -> utoipa::openapi::OpenApi; } /// An initializer. diff --git a/src/controller/app_routes.rs b/src/controller/app_routes.rs index 4d17a6797..2ce3c2455 100644 --- a/src/controller/app_routes.rs +++ b/src/controller/app_routes.rs @@ -216,12 +216,7 @@ impl AppRoutes { // issues in compile times itself (https://github.com/rust-lang/crates.io/pull/7443). // #[cfg(feature = "openapi")] - let mut api_router = { - #[derive(OpenApi)] - #[openapi(info(title = "API Documentation", description = "API Documentation"))] - struct ApiDoc; - OpenApiRouter::with_openapi(ApiDoc::openapi()) - }; + let mut api_router = OpenApiRouter::with_openapi(H::inital_openapi_spec(&ctx)); for router in self.collect() { tracing::info!("{}", router.to_string()); From def3dd54d9050acbffb67a5f7567ab04bf62cc07 Mon Sep 17 00:00:00 2001 From: NexVeridian Date: Wed, 13 Nov 2024 01:55:11 -0800 Subject: [PATCH 03/28] config.yaml and merge hosted doc with router --- src/config.rs | 26 ++++++++++++++++++++++++++ src/controller/app_routes.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/src/config.rs b/src/config.rs index 31eeccb1a..5dc2139d5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -414,6 +414,9 @@ pub struct Server { /// logging, and error handling. #[serde(default)] pub middlewares: middleware::Config, + /// OpenAPI configuration + #[cfg(feature = "openapi")] + pub openapi: OpenAPI, } fn default_binding() -> String { @@ -426,6 +429,29 @@ impl Server { format!("{}:{}", self.host, self.port) } } + +/// OpenAPI configuration +#[cfg(feature = "openapi")] +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct OpenAPI { + /// URL for where to host the redoc OpenAPI doc, example: /redoc + pub redoc_url: String, + /// URL for where to host the swagger OpenAPI doc, example: /scalar + pub scalar_url: String, + /// Swagger configuration + pub swagger: Swagger, +} + +/// OpenAPI Swagger configuration +#[cfg(feature = "openapi")] +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Swagger { + /// URL for where to host the swagger OpenAPI doc, example: /swagger-ui + pub swagger_url: String, + /// URL for openapi.json, for example: /api-docs/openapi.json + pub openapi_url: String, +} + /// Background worker configuration /// Example (development): /// ```yaml diff --git a/src/controller/app_routes.rs b/src/controller/app_routes.rs index 2ce3c2455..1aec8f3ee 100644 --- a/src/controller/app_routes.rs +++ b/src/controller/app_routes.rs @@ -234,6 +234,32 @@ impl AppRoutes { #[cfg(feature = "openapi")] let (_, api) = api_router.split_for_parts(); + #[cfg(feature = "openapi")] + { + app = app.merge(Redoc::with_url( + ctx.config.server.openapi.redoc_url.clone(), + api.clone(), + )) + } + + #[cfg(feature = "openapi")] + { + app = app.merge(Scalar::with_url( + ctx.config.server.openapi.scalar_url.clone(), + api.clone(), + )) + } + + #[cfg(feature = "openapi")] + { + app = app.merge( + SwaggerUi::new(ctx.config.server.openapi.swagger.swagger_url.clone()).url( + ctx.config.server.openapi.swagger.openapi_url.clone(), + api.clone(), + ), + ) + } + #[cfg(feature = "channels")] if let Some(channels) = self.channels.as_ref() { tracing::info!("[Middleware] +channels"); From 8db44438cbcdcf48ea2d6f20d76fe2132583add5 Mon Sep 17 00:00:00 2001 From: NexVeridian Date: Wed, 13 Nov 2024 01:55:35 -0800 Subject: [PATCH 04/28] update existing tests --- src/environment.rs | 1 + src/tests_cfg/config.rs | 9 +++++++++ src/tests_cfg/db.rs | 14 ++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/src/environment.rs b/src/environment.rs index 4beb8fd08..b56d72846 100644 --- a/src/environment.rs +++ b/src/environment.rs @@ -131,6 +131,7 @@ mod tests { } #[test] + #[cfg(not(feature = "openapi"))] fn test_from_folder() { let config = Environment::Development.load_from_folder(Path::new("examples/demo/config")); assert!(config.is_ok()); diff --git a/src/tests_cfg/config.rs b/src/tests_cfg/config.rs index e27ab38a3..8216a4b34 100644 --- a/src/tests_cfg/config.rs +++ b/src/tests_cfg/config.rs @@ -23,6 +23,15 @@ pub fn test_config() -> Config { host: "localhost".to_string(), ident: None, middlewares: middleware::Config::default(), + #[cfg(feature = "openapi")] + openapi: config::OpenAPI { + redoc_url: "/redoc".to_string(), + scalar_url: "/scalar".to_string(), + swagger: config::Swagger { + swagger_url: "/swagger-ui".to_string(), + openapi_url: "/api-docs/openapi.json".to_string(), + }, + }, }, #[cfg(feature = "with-db")] database: config::Database { diff --git a/src/tests_cfg/db.rs b/src/tests_cfg/db.rs index a609b4420..b63def806 100644 --- a/src/tests_cfg/db.rs +++ b/src/tests_cfg/db.rs @@ -4,6 +4,9 @@ use async_trait::async_trait; use sea_orm::DatabaseConnection; pub use sea_orm_migration::prelude::*; +#[cfg(feature = "openapi")] +use utoipa::OpenApi; + #[cfg(feature = "channels")] use crate::controller::channels::AppChannels; use crate::{ @@ -126,4 +129,15 @@ impl Hooks for AppHook { fn register_channels(_ctx: &AppContext) -> AppChannels { unimplemented!(); } + + #[cfg(feature = "openapi")] + fn inital_openapi_spec(_ctx: &AppContext) -> utoipa::openapi::OpenApi { + #[derive(OpenApi)] + #[openapi(info( + title = "Loco Demo", + description = "This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project." + ))] + struct ApiDoc; + ApiDoc::openapi() + } } From 34d10e6b098680916303be28d2c4ea89f055ff64 Mon Sep 17 00:00:00 2001 From: NexVeridian Date: Wed, 13 Nov 2024 17:18:26 -0800 Subject: [PATCH 05/28] test: OpenAPI config from file --- examples/demo/config/OpenAPI.yaml | 160 ++++++++++++++++++++++++++++++ src/environment.rs | 8 ++ 2 files changed, 168 insertions(+) create mode 100644 examples/demo/config/OpenAPI.yaml diff --git a/examples/demo/config/OpenAPI.yaml b/examples/demo/config/OpenAPI.yaml new file mode 100644 index 000000000..d8ee881bd --- /dev/null +++ b/examples/demo/config/OpenAPI.yaml @@ -0,0 +1,160 @@ +# Loco configuration file documentation + +# Application logging configuration +logger: + # Enable or disable logging. + enable: false + # Log level, options: trace, debug, info, warn or error. + level: error + # Define the logging format. options: compact, pretty or json + format: compact + # By default the logger has filtering only logs that came from your code or logs that came from `loco` framework. to see all third party libraries + # Uncomment the line below to override to see all third party libraries you can enable this config and override the logger filters. + # override_filter: trace + +# Web server configuration +server: + # Port on which the server will listen. the server binding is 0.0.0.0:{PORT} + port: 5150 + # The UI hostname or IP address that mailers will point to. + host: http://localhost + # Out of the box middleware configuration. to disable middleware you can changed the `enable` field to `false` of comment the middleware block + middlewares: + # Allows to limit the payload size request. payload that bigger than this file will blocked the request. + limit_payload: + # Enable/Disable the middleware. + enable: true + # the limit size. can be b,kb,kib,mb,mib,gb,gib + body_limit: 5mb + # Generating a unique request ID and enhancing logging with additional information such as the start and completion of request processing, latency, status code, and other request details. + logger: + # Enable/Disable the middleware. + enable: true + # when your code is panicked, the request still returns 500 status code. + catch_panic: + # Enable/Disable the middleware. + enable: true + # Timeout for incoming requests middleware. requests that take more time from the configuration will cute and 408 status code will returned. + timeout_request: + # Enable/Disable the middleware. + enable: true + # Duration time in milliseconds. + timeout: 5000 + static_assets: + enable: true + must_exist: true + precompressed: true + folder: + path: assets + fallback: index.html + compression: + enable: true + cors: + enable: true + # Set the value of the [`Access-Control-Allow-Origin`][mdn] header + # allow_origins: + # - https://loco.rs + # Set the value of the [`Access-Control-Allow-Headers`][mdn] header + # allow_headers: + # - Content-Type + # Set the value of the [`Access-Control-Allow-Methods`][mdn] header + # allow_methods: + # - POST + # Set the value of the [`Access-Control-Max-Age`][mdn] header in seconds + # max_age: 3600 + openapi: + redoc_url: "/redoc" + scalar_url: "/scalar" + swagger: + swagger_url: "/swagger-ui" + openapi_url: "/api-docs/openapi.json" + +# Worker Configuration +workers: + # specifies the worker mode. Options: + # - BackgroundQueue - Workers operate asynchronously in the background, processing queued. + # - ForegroundBlocking - Workers operate in the foreground and block until tasks are completed. + # - BackgroundAsync - Workers operate asynchronously in the background, processing tasks with async capabilities. + mode: ForegroundBlocking + +# Mailer Configuration. +mailer: + # SMTP mailer configuration. + smtp: + # Enable/Disable smtp mailer. + enable: true + # SMTP server host. e.x localhost, smtp.gmail.com + host: localhost + # SMTP server port + port: 1025 + # Use secure connection (SSL/TLS). + secure: false + # auth: + # user: + # password: + stub: true + +# Initializers Configuration +# initializers: +# oauth2: +# authorization_code: # Authorization code grant type +# - client_identifier: google # Identifier for the OAuth2 provider. Replace 'google' with your provider's name if different, must be unique within the oauth2 config. +# ... other fields + +# Database Configuration +database: + # Database connection URI + uri: {{get_env(name="DATABASE_URL", default="postgres://loco:loco@localhost:5432/loco_app")}} + # When enabled, the sql query will be logged. + enable_logging: false + # Set the timeout duration when acquiring a connection. + connect_timeout: 500 + # Set the idle duration before closing a connection. + idle_timeout: 500 + # Minimum number of connections for a pool. + min_connections: 1 + # Maximum number of connections for a pool. + max_connections: 1 + # Run migration up when application loaded + auto_migrate: true + # Truncate database when application loaded. This is a dangerous operation, make sure that you using this flag only on dev environments or test mode + dangerously_truncate: true + # Recreating schema when application loaded. This is a dangerous operation, make sure that you using this flag only on dev environments or test mode + dangerously_recreate: true + +# Queue Configuration +queue: + kind: Redis + # Redis connection URI + uri: {{get_env(name="REDIS_URL", default="redis://127.0.0.1")}} + # Dangerously flush all data in Redis on startup. dangerous operation, make sure that you using this flag only on dev environments or test mode + dangerously_flush: false + +# Authentication Configuration +auth: + # JWT authentication + jwt: + # Secret key for token generation and verification + secret: PqRwLF2rhHe8J22oBeHy + # Token expiration time in seconds + expiration: 604800 # 7 days + +scheduler: + output: stdout + jobs: + write_content: + shell: true + run: "echo loco >> ./scheduler.txt" + schedule: run every 1 second + output: silent + tags: ['base', 'infra'] + + run_task: + run: "foo" + schedule: "at 10:00 am" + + list_if_users: + run: "user_report" + shell: true + schedule: "* 2 * * * *" + tags: ['base', 'users'] \ No newline at end of file diff --git a/src/environment.rs b/src/environment.rs index b56d72846..08c8c8dec 100644 --- a/src/environment.rs +++ b/src/environment.rs @@ -136,4 +136,12 @@ mod tests { let config = Environment::Development.load_from_folder(Path::new("examples/demo/config")); assert!(config.is_ok()); } + + #[test] + #[cfg(feature = "openapi")] + fn test_from_folder_openapi() { + let config = Environment::Any("OpenAPI".to_string()) + .load_from_folder(Path::new("examples/demo/config")); + assert!(config.is_ok()); + } } From c88704211d7be9d345a604ca38379d8b5a633767 Mon Sep 17 00:00:00 2001 From: NexVeridian Date: Wed, 13 Nov 2024 18:24:32 -0800 Subject: [PATCH 06/28] snapshot tests --- tests/controller/mod.rs | 1 + tests/controller/openapi.rs | 51 +++++++++++++++++++++++++++++++++++++ tests/infra_cfg/server.rs | 30 ++++++++++++++++++++++ 3 files changed, 82 insertions(+) create mode 100644 tests/controller/openapi.rs diff --git a/tests/controller/mod.rs b/tests/controller/mod.rs index a350ef0c7..d98d4fe52 100644 --- a/tests/controller/mod.rs +++ b/tests/controller/mod.rs @@ -1 +1,2 @@ mod middlewares; +mod openapi; diff --git a/tests/controller/openapi.rs b/tests/controller/openapi.rs new file mode 100644 index 000000000..1434da316 --- /dev/null +++ b/tests/controller/openapi.rs @@ -0,0 +1,51 @@ +use insta::assert_debug_snapshot; +use loco_rs::{prelude::*, tests_cfg}; +use rstest::rstest; +use serial_test::serial; + +use crate::infra_cfg; + +macro_rules! configure_insta { + ($($expr:expr),*) => { + let mut settings = insta::Settings::clone_current(); + settings.set_prepend_module_to_snapshot(false); + settings.set_snapshot_suffix("openapi"); + let _guard = settings.bind_to_scope(); + }; +} + +#[rstest] +#[case("/redoc")] +#[case("/scalar")] +#[case("/swagger-ui")] +#[tokio::test] +#[serial] +async fn openapi(#[case] test_name: &str) { + configure_insta!(); + + let ctx: AppContext = tests_cfg::app::get_app_context().await; + + match test_name { + "/redoc" => assert_eq!(ctx.config.server.openapi.redoc_url, test_name), + "/scalar" => assert_eq!(ctx.config.server.openapi.scalar_url, test_name), + _ => assert_eq!(ctx.config.server.openapi.swagger.swagger_url, test_name), + } + + let handle = infra_cfg::server::start_from_ctx(ctx).await; + + let res = reqwest::Client::new() + .request( + reqwest::Method::OPTIONS, + infra_cfg::server::get_base_url() + test_name, + ) + .send() + .await + .expect("valid response") + .text() + .await + .unwrap(); + + assert_debug_snapshot!(format!("openapi_[{test_name}]"), res); + + handle.abort(); +} diff --git a/tests/infra_cfg/server.rs b/tests/infra_cfg/server.rs index 771d9088f..71181cb22 100644 --- a/tests/infra_cfg/server.rs +++ b/tests/infra_cfg/server.rs @@ -7,6 +7,8 @@ //! hardcoded ports and bindings. use loco_rs::{boot, controller::AppRoutes, prelude::*, tests_cfg::db::AppHook}; +#[cfg(feature = "openapi")] +use {serde::Serialize, utoipa::ToSchema}; /// The port on which the test server will run. const TEST_PORT_SERVER: i32 = 5555; @@ -29,6 +31,28 @@ async fn post_action(_body: axum::body::Bytes) -> Result { format::render().text("text response") } +#[cfg(feature = "openapi")] +#[derive(Serialize, Debug, ToSchema)] +pub struct Album { + title: String, + rating: u32, +} + +#[cfg(feature = "openapi")] +#[utoipa::path( + get, + path = "/album", + responses( + (status = 200, description = "Album found", body = Album), + ), +)] +async fn get_action_openapi() -> Result { + format::json(Album { + title: "VH II".to_string(), + rating: 10, + }) +} + /// Starts the server using the provided Loco [`boot::BootResult`] result. /// It uses hardcoded server parameters such as the port and binding address. /// @@ -57,9 +81,15 @@ pub async fn start_from_boot(boot_result: boot::BootResult) -> tokio::task::Join pub async fn start_from_ctx(ctx: AppContext) -> tokio::task::JoinHandle<()> { let app_router = AppRoutes::empty() .add_route( + #[cfg(not(feature = "openapi"))] Routes::new() .add("/", get(get_action)) .add("/", post(post_action)), + #[cfg(feature = "openapi")] + Routes::new() + .add("/", get(get_action)) + .add("/", post(post_action)) + .add("/album", get(get_action_openapi)), ) .to_router::(ctx.clone(), axum::Router::new()) .expect("to router"); From 4ed2668d1a8dee129228d6df95664578d57aed7d Mon Sep 17 00:00:00 2001 From: NexVeridian Date: Wed, 13 Nov 2024 18:50:22 -0800 Subject: [PATCH 07/28] fix snapshot path --- tests/controller/openapi.rs | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/tests/controller/openapi.rs b/tests/controller/openapi.rs index 1434da316..30f3ea049 100644 --- a/tests/controller/openapi.rs +++ b/tests/controller/openapi.rs @@ -20,7 +20,7 @@ macro_rules! configure_insta { #[case("/swagger-ui")] #[tokio::test] #[serial] -async fn openapi(#[case] test_name: &str) { +async fn openapi(#[case] mut test_name: &str) { configure_insta!(); let ctx: AppContext = tests_cfg::app::get_app_context().await; @@ -33,19 +33,24 @@ async fn openapi(#[case] test_name: &str) { let handle = infra_cfg::server::start_from_ctx(ctx).await; + test_name = test_name.trim_start_matches("/"); let res = reqwest::Client::new() .request( - reqwest::Method::OPTIONS, + reqwest::Method::GET, infra_cfg::server::get_base_url() + test_name, ) .send() .await - .expect("valid response") - .text() - .await - .unwrap(); - - assert_debug_snapshot!(format!("openapi_[{test_name}]"), res); + .expect("valid response"); + + assert_debug_snapshot!( + format!("openapi_[{test_name}]"), + ( + res.status().to_string(), + res.url().to_string(), + res.text().await.unwrap(), + ) + ); handle.abort(); } From 2aa3d66f42c47102d1519a848bfad2e3cbc45f8f Mon Sep 17 00:00:00 2001 From: NexVeridian Date: Wed, 13 Nov 2024 19:08:31 -0800 Subject: [PATCH 08/28] match title, upload snapshots --- tests/controller/openapi.rs | 9 ++++++++- tests/controller/snapshots/openapi_[redoc]@openapi.snap | 9 +++++++++ tests/controller/snapshots/openapi_[scalar]@openapi.snap | 9 +++++++++ .../snapshots/openapi_[swagger-ui]@openapi.snap | 9 +++++++++ 4 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 tests/controller/snapshots/openapi_[redoc]@openapi.snap create mode 100644 tests/controller/snapshots/openapi_[scalar]@openapi.snap create mode 100644 tests/controller/snapshots/openapi_[swagger-ui]@openapi.snap diff --git a/tests/controller/openapi.rs b/tests/controller/openapi.rs index 30f3ea049..8f1603fbd 100644 --- a/tests/controller/openapi.rs +++ b/tests/controller/openapi.rs @@ -48,7 +48,14 @@ async fn openapi(#[case] mut test_name: &str) { ( res.status().to_string(), res.url().to_string(), - res.text().await.unwrap(), + res.text() + .await + .unwrap() + .lines() + .find(|line| line.contains("")) + .and_then(|line| { line.split("<title>").nth(1)?.split("").next() }) + .unwrap_or_default() + .to_string(), ) ); diff --git a/tests/controller/snapshots/openapi_[redoc]@openapi.snap b/tests/controller/snapshots/openapi_[redoc]@openapi.snap new file mode 100644 index 000000000..206aee7d8 --- /dev/null +++ b/tests/controller/snapshots/openapi_[redoc]@openapi.snap @@ -0,0 +1,9 @@ +--- +source: tests/controller/openapi.rs +expression: "(res.status().to_string(), res.url().to_string(),\nres.text().await.unwrap().lines().find(|line|\nline.contains(\"\")).map(|line|\n{\n let start = line.find(\"<title>\").unwrap() + 7; let end =\n line.find(\"\").unwrap(); line[start..end].to_string()\n}).unwrap_or_default(),)" +--- +( + "200 OK", + "http://localhost:5555/redoc", + "Redoc", +) diff --git a/tests/controller/snapshots/openapi_[scalar]@openapi.snap b/tests/controller/snapshots/openapi_[scalar]@openapi.snap new file mode 100644 index 000000000..b2cd8a853 --- /dev/null +++ b/tests/controller/snapshots/openapi_[scalar]@openapi.snap @@ -0,0 +1,9 @@ +--- +source: tests/controller/openapi.rs +expression: "(res.status().to_string(), res.url().to_string(),\nres.text().await.unwrap().lines().find(|line|\nline.contains(\"\")).map(|line|\n{\n let start = line.find(\"<title>\").unwrap() + 7; let end =\n line.find(\"\").unwrap(); line[start..end].to_string()\n}).unwrap_or_default(),)" +--- +( + "200 OK", + "http://localhost:5555/scalar", + "Scalar", +) diff --git a/tests/controller/snapshots/openapi_[swagger-ui]@openapi.snap b/tests/controller/snapshots/openapi_[swagger-ui]@openapi.snap new file mode 100644 index 000000000..1683ddcfa --- /dev/null +++ b/tests/controller/snapshots/openapi_[swagger-ui]@openapi.snap @@ -0,0 +1,9 @@ +--- +source: tests/controller/openapi.rs +expression: "(res.status().to_string(), res.url().to_string(),\nres.text().await.unwrap().lines().find(|line|\nline.contains(\"\")).map(|line|\n{\n let start = line.find(\"<title>\").unwrap() + 7; let end =\n line.find(\"\").unwrap(); line[start..end].to_string()\n}).unwrap_or_default(),)" +--- +( + "200 OK", + "http://localhost:5555/swagger-ui/", + "Swagger UI", +) From 0a304c149e1a3d5feb375a588d4fa17edf246972 Mon Sep 17 00:00:00 2001 From: NexVeridian Date: Wed, 13 Nov 2024 19:53:08 -0800 Subject: [PATCH 09/28] OpenAPI json snapshot test --- tests/controller/openapi.rs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/controller/openapi.rs b/tests/controller/openapi.rs index 8f1603fbd..8a4c9ca62 100644 --- a/tests/controller/openapi.rs +++ b/tests/controller/openapi.rs @@ -61,3 +61,33 @@ async fn openapi(#[case] mut test_name: &str) { handle.abort(); } + +#[tokio::test] +#[serial] +async fn openapi_json() { + configure_insta!(); + + let ctx: AppContext = tests_cfg::app::get_app_context().await; + + let handle = infra_cfg::server::start_from_ctx(ctx).await; + + let res = reqwest::Client::new() + .request( + reqwest::Method::GET, + infra_cfg::server::get_base_url() + "api-docs/openapi.json", + ) + .send() + .await + .expect("valid response"); + + assert_debug_snapshot!( + "openapi_json", + ( + res.status().to_string(), + res.url().to_string(), + res.text().await.unwrap(), + ) + ); + + handle.abort(); +} From 8c0fd96c1cb7412311c686eb0381f603e07150a9 Mon Sep 17 00:00:00 2001 From: NexVeridian Date: Fri, 15 Nov 2024 19:04:33 -0800 Subject: [PATCH 10/28] Another snapshot --- tests/controller/snapshots/openapi_json@openapi.snap | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 tests/controller/snapshots/openapi_json@openapi.snap diff --git a/tests/controller/snapshots/openapi_json@openapi.snap b/tests/controller/snapshots/openapi_json@openapi.snap new file mode 100644 index 000000000..dff6658c9 --- /dev/null +++ b/tests/controller/snapshots/openapi_json@openapi.snap @@ -0,0 +1,9 @@ +--- +source: tests/controller/openapi.rs +expression: "(res.status().to_string(), res.url().to_string(), res.text().await.unwrap(),)" +--- +( + "200 OK", + "http://localhost:5555/api-docs/openapi.json", + "{\"openapi\":\"3.1.0\",\"info\":{\"title\":\"Loco Demo\",\"description\":\"This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project.\",\"contact\":{\"name\":\"Dotan Nahum\",\"email\":\"dotan@rng0.io\"},\"license\":{\"name\":\"Apache-2.0\"},\"version\":\"0.12.0\"},\"paths\":{\"/album\":{\"get\":{\"operationId\":\"get_action_openapi\",\"responses\":{\"200\":{\"description\":\"Album found\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Album\"}}}}}}}},\"components\":{\"schemas\":{\"Album\":{\"type\":\"object\",\"required\":[\"title\",\"rating\"],\"properties\":{\"rating\":{\"type\":\"integer\",\"format\":\"int32\",\"minimum\":0},\"title\":{\"type\":\"string\"}}}}}}", +) From 5f63b08f6311be3f1b5ba86f40522c1dd48b4981 Mon Sep 17 00:00:00 2001 From: NexVeridian Date: Fri, 15 Nov 2024 19:06:23 -0800 Subject: [PATCH 11/28] LocoMethodRouter --- src/controller/app_routes.rs | 29 +++++++++------ src/controller/describe.rs | 4 +- src/controller/routes.rs | 71 +++++++++++++++++++++++++++++++++--- tests/infra_cfg/server.rs | 4 +- 4 files changed, 85 insertions(+), 23 deletions(-) diff --git a/src/controller/app_routes.rs b/src/controller/app_routes.rs index 1aec8f3ee..af2d9d1d3 100644 --- a/src/controller/app_routes.rs +++ b/src/controller/app_routes.rs @@ -2,9 +2,9 @@ //! configuring routes in an Axum application. It allows you to define route //! prefixes, add routes, and configure middlewares for the application. -use std::{fmt, sync::OnceLock}; +use std::{borrow::Borrow, fmt, sync::OnceLock}; -use axum::Router as AXRouter; +use axum::{handler::Handler, Router as AXRouter}; use regex::Regex; #[cfg(feature = "openapi")] use utoipa::{ @@ -12,7 +12,10 @@ use utoipa::{ Modify, OpenApi, }; #[cfg(feature = "openapi")] -use utoipa_axum::{router::OpenApiRouter, routes}; +use utoipa_axum::{ + router::{OpenApiRouter, UtoipaMethodRouterExt}, + routes, +}; #[cfg(feature = "openapi")] use utoipa_redoc::{Redoc, Servable}; #[cfg(feature = "openapi")] @@ -22,6 +25,7 @@ use utoipa_swagger_ui::SwaggerUi; #[cfg(feature = "channels")] use super::channels::AppChannels; +use super::routes::LocoMethodRouter; use crate::{ app::{AppContext, Hooks}, controller::{middleware::MiddlewareLayer, routes::Routes}, @@ -47,7 +51,7 @@ pub struct AppRoutes { pub struct ListRoutes { pub uri: String, pub actions: Vec, - pub method: axum::routing::MethodRouter, + pub method: LocoMethodRouter, } impl fmt::Display for ListRoutes { @@ -220,14 +224,15 @@ impl AppRoutes { for router in self.collect() { tracing::info!("{}", router.to_string()); - #[cfg(not(feature = "openapi"))] - { - app = app.route(&router.uri, router.method); - } - #[cfg(feature = "openapi")] - { - app = app.route(&router.uri, router.method.clone()); - api_router = api_router.route(&router.uri, router.method); + match router.method { + LocoMethodRouter::Axum(method) => { + app = app.route(&router.uri, method); + } + #[cfg(feature = "openapi")] + LocoMethodRouter::Utoipa(method) => { + app = app.route(&router.uri, method.2.clone().with_state::(())); + api_router = api_router.routes(method.with_state::(())); + } } } diff --git a/src/controller/describe.rs b/src/controller/describe.rs index dc168cf35..a46876df2 100644 --- a/src/controller/describe.rs +++ b/src/controller/describe.rs @@ -3,8 +3,6 @@ use std::sync::OnceLock; use axum::{http, routing::MethodRouter}; use regex::Regex; -use crate::app::AppContext; - static DESCRIBE_METHOD_ACTION: OnceLock = OnceLock::new(); fn get_describe_method_action() -> &'static Regex { @@ -16,7 +14,7 @@ fn get_describe_method_action() -> &'static Regex { /// Currently axum not exposed the action type of the router. for hold extra /// information about routers we need to convert the `method` to string and /// capture the details -pub fn method_action(method: &MethodRouter) -> Vec { +pub fn method_action(method: &MethodRouter) -> Vec { let method_str = format!("{method:?}"); get_describe_method_action() diff --git a/src/controller/routes.rs b/src/controller/routes.rs index fc482e739..02496589c 100644 --- a/src/controller/routes.rs +++ b/src/controller/routes.rs @@ -1,10 +1,13 @@ -use std::convert::Infallible; +use std::{convert::Infallible, fmt}; use axum::{extract::Request, response::IntoResponse, routing::Route}; use tower::{Layer, Service}; +#[cfg(feature = "openapi")] +use utoipa_axum::router::{UtoipaMethodRouter, UtoipaMethodRouterExt}; use super::describe; use crate::app::AppContext; + #[derive(Clone, Default, Debug)] pub struct Routes { pub prefix: Option, @@ -12,10 +15,17 @@ pub struct Routes { // pub version: Option, } -#[derive(Clone, Default, Debug)] +#[derive(Clone)] +pub enum LocoMethodRouter { + Axum(axum::routing::MethodRouter), + #[cfg(feature = "openapi")] + Utoipa(UtoipaMethodRouter), +} + +#[derive(Clone, Debug)] pub struct Handler { pub uri: String, - pub method: axum::routing::MethodRouter, + pub method: LocoMethodRouter, pub actions: Vec, } @@ -78,11 +88,17 @@ impl Routes { /// Routes::new().add("/_ping", get(ping)); /// ```` #[must_use] - pub fn add(mut self, uri: &str, method: axum::routing::MethodRouter) -> Self { - describe::method_action(&method); + pub fn add(mut self, uri: &str, method: impl Into) -> Self { + let method = method.into(); + let actions = match &method { + LocoMethodRouter::Axum(m) => describe::method_action(m), + #[cfg(feature = "openapi")] + LocoMethodRouter::Utoipa(m) => describe::method_action(&m.2), + }; + self.handlers.push(Handler { uri: uri.to_owned(), - actions: describe::method_action(&method), + actions, method, }); self @@ -156,3 +172,46 @@ impl Routes { } } } + +impl fmt::Debug for LocoMethodRouter { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Axum(router) => write!(f, "{:?}", router), + #[cfg(feature = "openapi")] + Self::Utoipa(router) => { + // Get the axum::routing::MethodRouter from the UtoipaMethodRouter wrapper + write!(f, "{:?}", router.2) + } + } + } +} + +impl LocoMethodRouter { + pub fn layer(self, layer: L) -> Self + where + L: Layer + Clone + Send + 'static, + L::Service: Service + Clone + Send + 'static, + >::Response: IntoResponse + 'static, + >::Error: Into + 'static, + >::Future: Send + 'static, + { + match self { + LocoMethodRouter::Axum(router) => LocoMethodRouter::Axum(router.layer(layer)), + #[cfg(feature = "openapi")] + LocoMethodRouter::Utoipa(router) => LocoMethodRouter::Utoipa(router.layer(layer)), + } + } +} + +impl From> for LocoMethodRouter { + fn from(router: axum::routing::MethodRouter) -> Self { + LocoMethodRouter::Axum(router) + } +} + +#[cfg(feature = "openapi")] +impl From for LocoMethodRouter { + fn from(router: UtoipaMethodRouter) -> Self { + LocoMethodRouter::Utoipa(router) + } +} diff --git a/tests/infra_cfg/server.rs b/tests/infra_cfg/server.rs index 71181cb22..6709dd006 100644 --- a/tests/infra_cfg/server.rs +++ b/tests/infra_cfg/server.rs @@ -8,7 +8,7 @@ use loco_rs::{boot, controller::AppRoutes, prelude::*, tests_cfg::db::AppHook}; #[cfg(feature = "openapi")] -use {serde::Serialize, utoipa::ToSchema}; +use {serde::Serialize, utoipa::ToSchema, utoipa_axum::routes}; /// The port on which the test server will run. const TEST_PORT_SERVER: i32 = 5555; @@ -89,7 +89,7 @@ pub async fn start_from_ctx(ctx: AppContext) -> tokio::task::JoinHandle<()> { Routes::new() .add("/", get(get_action)) .add("/", post(post_action)) - .add("/album", get(get_action_openapi)), + .add("/album", routes!(get_action_openapi)), ) .to_router::(ctx.clone(), axum::Router::new()) .expect("to router"); From 1c67cc68c305d357a96fec696ed517b3bf112a3c Mon Sep 17 00:00:00 2001 From: NexVeridian Date: Mon, 18 Nov 2024 21:53:06 -0800 Subject: [PATCH 12/28] fix AppContext --- src/controller/app_routes.rs | 7 ++++--- src/controller/describe.rs | 4 +++- src/controller/routes.rs | 18 +++++++++++------- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/controller/app_routes.rs b/src/controller/app_routes.rs index af2d9d1d3..d041db24e 100644 --- a/src/controller/app_routes.rs +++ b/src/controller/app_routes.rs @@ -220,7 +220,8 @@ impl AppRoutes { // issues in compile times itself (https://github.com/rust-lang/crates.io/pull/7443). // #[cfg(feature = "openapi")] - let mut api_router = OpenApiRouter::with_openapi(H::inital_openapi_spec(&ctx)); + let mut api_router: OpenApiRouter = + OpenApiRouter::with_openapi(H::inital_openapi_spec(&ctx)); for router in self.collect() { tracing::info!("{}", router.to_string()); @@ -230,8 +231,8 @@ impl AppRoutes { } #[cfg(feature = "openapi")] LocoMethodRouter::Utoipa(method) => { - app = app.route(&router.uri, method.2.clone().with_state::(())); - api_router = api_router.routes(method.with_state::(())); + app = app.route(&router.uri, method.2.clone()); + api_router = api_router.routes(method.with_state(ctx.clone())); } } } diff --git a/src/controller/describe.rs b/src/controller/describe.rs index a46876df2..dc168cf35 100644 --- a/src/controller/describe.rs +++ b/src/controller/describe.rs @@ -3,6 +3,8 @@ use std::sync::OnceLock; use axum::{http, routing::MethodRouter}; use regex::Regex; +use crate::app::AppContext; + static DESCRIBE_METHOD_ACTION: OnceLock = OnceLock::new(); fn get_describe_method_action() -> &'static Regex { @@ -14,7 +16,7 @@ fn get_describe_method_action() -> &'static Regex { /// Currently axum not exposed the action type of the router. for hold extra /// information about routers we need to convert the `method` to string and /// capture the details -pub fn method_action(method: &MethodRouter) -> Vec { +pub fn method_action(method: &MethodRouter) -> Vec { let method_str = format!("{method:?}"); get_describe_method_action() diff --git a/src/controller/routes.rs b/src/controller/routes.rs index 02496589c..743e89593 100644 --- a/src/controller/routes.rs +++ b/src/controller/routes.rs @@ -1,6 +1,10 @@ use std::{convert::Infallible, fmt}; -use axum::{extract::Request, response::IntoResponse, routing::Route}; +use axum::{ + extract::Request, + response::IntoResponse, + routing::{MethodRouter, Route}, +}; use tower::{Layer, Service}; #[cfg(feature = "openapi")] use utoipa_axum::router::{UtoipaMethodRouter, UtoipaMethodRouterExt}; @@ -17,9 +21,9 @@ pub struct Routes { #[derive(Clone)] pub enum LocoMethodRouter { - Axum(axum::routing::MethodRouter), + Axum(MethodRouter), #[cfg(feature = "openapi")] - Utoipa(UtoipaMethodRouter), + Utoipa(UtoipaMethodRouter), } #[derive(Clone, Debug)] @@ -203,15 +207,15 @@ impl LocoMethodRouter { } } -impl From> for LocoMethodRouter { - fn from(router: axum::routing::MethodRouter) -> Self { +impl From> for LocoMethodRouter { + fn from(router: MethodRouter) -> Self { LocoMethodRouter::Axum(router) } } #[cfg(feature = "openapi")] -impl From for LocoMethodRouter { - fn from(router: UtoipaMethodRouter) -> Self { +impl From> for LocoMethodRouter { + fn from(router: UtoipaMethodRouter) -> Self { LocoMethodRouter::Utoipa(router) } } From c1e953e44888b8e5898fc08e46743de06a89c74b Mon Sep 17 00:00:00 2001 From: NexVeridian Date: Mon, 18 Nov 2024 21:52:48 -0800 Subject: [PATCH 13/28] missing cfg for tests cfg for tests --- tests/controller/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/controller/mod.rs b/tests/controller/mod.rs index d98d4fe52..93c6a08c1 100644 --- a/tests/controller/mod.rs +++ b/tests/controller/mod.rs @@ -1,2 +1,3 @@ mod middlewares; +#[cfg(feature = "openapi")] mod openapi; From 74f78cc615e47bbc36201e70c240e6533326b9b5 Mon Sep 17 00:00:00 2001 From: NexVeridian Date: Tue, 19 Nov 2024 05:18:32 -0800 Subject: [PATCH 14/28] clippy --- src/controller/app_routes.rs | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/src/controller/app_routes.rs b/src/controller/app_routes.rs index d041db24e..3f9caa242 100644 --- a/src/controller/app_routes.rs +++ b/src/controller/app_routes.rs @@ -2,20 +2,12 @@ //! configuring routes in an Axum application. It allows you to define route //! prefixes, add routes, and configure middlewares for the application. -use std::{borrow::Borrow, fmt, sync::OnceLock}; +use std::{fmt, sync::OnceLock}; -use axum::{handler::Handler, Router as AXRouter}; +use axum::Router as AXRouter; use regex::Regex; #[cfg(feature = "openapi")] -use utoipa::{ - openapi::security::{ApiKey, ApiKeyValue, HttpAuthScheme, HttpBuilder, SecurityScheme}, - Modify, OpenApi, -}; -#[cfg(feature = "openapi")] -use utoipa_axum::{ - router::{OpenApiRouter, UtoipaMethodRouterExt}, - routes, -}; +use utoipa_axum::router::{OpenApiRouter, UtoipaMethodRouterExt}; #[cfg(feature = "openapi")] use utoipa_redoc::{Redoc, Servable}; #[cfg(feature = "openapi")] From ac0f1ce33034a0195f0be96183536c36bb486d31 Mon Sep 17 00:00:00 2001 From: NexVeridian Date: Wed, 20 Nov 2024 09:44:05 -0800 Subject: [PATCH 15/28] openapi.josn and openapi.yaml endpoints for all types --- Cargo.toml | 2 +- examples/demo/config/OpenAPI.yaml | 18 +++- src/config.rs | 52 ++++++++--- src/controller/app_routes.rs | 88 ++++++++++++++++--- src/tests_cfg/config.rs | 19 ++-- tests/controller/openapi.rs | 19 ++-- ...pec_[api-docs__openapi.json]@openapi.snap} | 2 +- ...spec_[api-docs__openapi.yaml]@openapi.snap | 9 ++ ...pi_spec_[redoc__openapi.json]@openapi.snap | 9 ++ ...pi_spec_[redoc__openapi.yaml]@openapi.snap | 9 ++ ...i_spec_[scalar__openapi.json]@openapi.snap | 9 ++ ...i_spec_[scalar__openapi.yaml]@openapi.snap | 9 ++ 12 files changed, 203 insertions(+), 42 deletions(-) rename tests/controller/snapshots/{openapi_json@openapi.snap => openapi_spec_[api-docs__openapi.json]@openapi.snap} (93%) create mode 100644 tests/controller/snapshots/openapi_spec_[api-docs__openapi.yaml]@openapi.snap create mode 100644 tests/controller/snapshots/openapi_spec_[redoc__openapi.json]@openapi.snap create mode 100644 tests/controller/snapshots/openapi_spec_[redoc__openapi.yaml]@openapi.snap create mode 100644 tests/controller/snapshots/openapi_spec_[scalar__openapi.json]@openapi.snap create mode 100644 tests/controller/snapshots/openapi_spec_[scalar__openapi.yaml]@openapi.snap diff --git a/Cargo.toml b/Cargo.toml index 27d1e9e53..f5999f560 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -131,7 +131,7 @@ uuid = { version = "1.10.0", features = ["v4", "fast-rng"] } socketioxide = { version = "0.14.0", features = ["state"], optional = true } # OpenAPI -utoipa = { version = "5.0.0", optional = true } +utoipa = { version = "5.0.0", features = ["yaml"], optional = true } utoipa-axum = { version = "0.1.0", optional = true } utoipa-swagger-ui = { version = "8.0", features = [ "axum", diff --git a/examples/demo/config/OpenAPI.yaml b/examples/demo/config/OpenAPI.yaml index d8ee881bd..80c7c8bd2 100644 --- a/examples/demo/config/OpenAPI.yaml +++ b/examples/demo/config/OpenAPI.yaml @@ -63,11 +63,21 @@ server: # Set the value of the [`Access-Control-Max-Age`][mdn] header in seconds # max_age: 3600 openapi: - redoc_url: "/redoc" - scalar_url: "/scalar" + redoc: + !Redoc + url: /redoc + spec_json_url: /redoc/openapi.json + spec_yaml_url: /redoc/openapi.yaml + scalar: + !Scalar + url: /scalar + spec_json_url: /scalar/openapi.json + spec_yaml_url: /scalar/openapi.yaml swagger: - swagger_url: "/swagger-ui" - openapi_url: "/api-docs/openapi.json" + !Swagger + url: /swagger-ui + spec_json_url: /api-docs/openapi.json + spec_yaml_url: /api-docs/openapi.yaml # Worker Configuration workers: diff --git a/src/config.rs b/src/config.rs index 5dc2139d5..3440378f3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -434,22 +434,52 @@ impl Server { #[cfg(feature = "openapi")] #[derive(Debug, Clone, Deserialize, Serialize)] pub struct OpenAPI { - /// URL for where to host the redoc OpenAPI doc, example: /redoc - pub redoc_url: String, - /// URL for where to host the swagger OpenAPI doc, example: /scalar - pub scalar_url: String, + /// Redoc configuration + pub redoc: OpenAPIType, + /// Scalar configuration + pub scalar: OpenAPIType, /// Swagger configuration - pub swagger: Swagger, + pub swagger: OpenAPIType, } -/// OpenAPI Swagger configuration #[cfg(feature = "openapi")] #[derive(Debug, Clone, Deserialize, Serialize)] -pub struct Swagger { - /// URL for where to host the swagger OpenAPI doc, example: /swagger-ui - pub swagger_url: String, - /// URL for openapi.json, for example: /api-docs/openapi.json - pub openapi_url: String, +pub enum OpenAPIType { + Redoc { + /// URL for where to host the redoc OpenAPI doc, example: /redoc + url: String, + /// URL for openapi.json, for example: /openapi.json + spec_json_url: Option, + /// URL for openapi.yaml, for example: /openapi.yaml + spec_yaml_url: Option, + }, + Scalar { + /// URL for where to host the swagger OpenAPI doc, example: /scalar + url: String, + /// URL for openapi.json, for example: /openapi.json + spec_json_url: Option, + /// URL for openapi.yaml, for example: /openapi.yaml + spec_yaml_url: Option, + }, + Swagger { + /// URL for where to host the swagger OpenAPI doc, example: /swagger-ui + url: String, + /// URL for openapi.json, for example: /api-docs/openapi.json + spec_json_url: String, + /// URL for openapi.yaml, for example: /openapi.yaml + spec_yaml_url: Option, + }, +} + +#[cfg(feature = "openapi")] +impl OpenAPIType { + pub fn url(&self) -> &String { + match self { + OpenAPIType::Redoc { url, .. } + | OpenAPIType::Scalar { url, .. } + | OpenAPIType::Swagger { url, .. } => url, + } + } } /// Background worker configuration diff --git a/src/controller/app_routes.rs b/src/controller/app_routes.rs index 3f9caa242..3e88142d6 100644 --- a/src/controller/app_routes.rs +++ b/src/controller/app_routes.rs @@ -4,9 +4,13 @@ use std::{fmt, sync::OnceLock}; +#[cfg(feature = "openapi")] +use axum::routing::get; use axum::Router as AXRouter; use regex::Regex; #[cfg(feature = "openapi")] +use utoipa::openapi::OpenApi; +#[cfg(feature = "openapi")] use utoipa_axum::router::{OpenApiRouter, UtoipaMethodRouterExt}; #[cfg(feature = "openapi")] use utoipa_redoc::{Redoc, Servable}; @@ -18,8 +22,11 @@ use utoipa_swagger_ui::SwaggerUi; #[cfg(feature = "channels")] use super::channels::AppChannels; use super::routes::LocoMethodRouter; +#[cfg(feature = "openapi")] +use crate::controller::{format, Response}; use crate::{ app::{AppContext, Hooks}, + config::OpenAPIType, controller::{middleware::MiddlewareLayer, routes::Routes}, Result, }; @@ -30,6 +37,19 @@ fn get_normalize_url() -> &'static Regex { NORMALIZE_URL.get_or_init(|| Regex::new(r"/+").unwrap()) } +#[cfg(feature = "openapi")] +static OPENAPI_SPEC: OnceLock = OnceLock::new(); + +#[cfg(feature = "openapi")] +fn set_openapi_spec(api: OpenApi) -> &'static OpenApi { + OPENAPI_SPEC.get_or_init(|| api) +} + +#[cfg(feature = "openapi")] +fn get_openapi_spec() -> &'static OpenApi { + OPENAPI_SPEC.get().unwrap() +} + /// Represents the routes of the application. #[derive(Clone)] pub struct AppRoutes { @@ -231,31 +251,46 @@ impl AppRoutes { #[cfg(feature = "openapi")] let (_, api) = api_router.split_for_parts(); + #[cfg(feature = "openapi")] + set_openapi_spec(api); #[cfg(feature = "openapi")] { - app = app.merge(Redoc::with_url( - ctx.config.server.openapi.redoc_url.clone(), - api.clone(), - )) + if let OpenAPIType::Redoc { + url, + spec_json_url, + spec_yaml_url, + } = ctx.config.server.openapi.redoc.clone() + { + app = app.merge(Redoc::with_url(url, get_openapi_spec().clone())); + app = add_openapi_endpoints(app, spec_json_url, spec_yaml_url); + } } #[cfg(feature = "openapi")] { - app = app.merge(Scalar::with_url( - ctx.config.server.openapi.scalar_url.clone(), - api.clone(), - )) + if let OpenAPIType::Scalar { + url, + spec_json_url, + spec_yaml_url, + } = ctx.config.server.openapi.scalar.clone() + { + app = app.merge(Scalar::with_url(url, get_openapi_spec().clone())); + app = add_openapi_endpoints(app, spec_json_url, spec_yaml_url); + } } #[cfg(feature = "openapi")] { - app = app.merge( - SwaggerUi::new(ctx.config.server.openapi.swagger.swagger_url.clone()).url( - ctx.config.server.openapi.swagger.openapi_url.clone(), - api.clone(), - ), - ) + if let OpenAPIType::Swagger { + url, + spec_json_url, + spec_yaml_url, + } = ctx.config.server.openapi.swagger.clone() + { + app = app.merge(SwaggerUi::new(url).url(spec_json_url, get_openapi_spec().clone())); + app = add_openapi_endpoints(app, None, spec_yaml_url); + } } #[cfg(feature = "channels")] @@ -302,6 +337,31 @@ impl AppRoutes { } } +#[cfg(feature = "openapi")] +async fn openapi_spec_json() -> Result { + format::json(get_openapi_spec()) +} + +#[cfg(feature = "openapi")] +async fn openapi_spec_yaml() -> Result { + format::text(&get_openapi_spec().to_yaml()?) +} + +#[cfg(feature = "openapi")] +fn add_openapi_endpoints( + mut app: AXRouter, + json_url: Option, + yaml_url: Option, +) -> AXRouter { + if let Some(json_url) = json_url { + app = app.route(&json_url, get(openapi_spec_json)); + } + if let Some(yaml_url) = yaml_url { + app = app.route(&yaml_url, get(openapi_spec_yaml)); + } + app +} + #[cfg(test)] mod tests { diff --git a/src/tests_cfg/config.rs b/src/tests_cfg/config.rs index 8216a4b34..871c6aea1 100644 --- a/src/tests_cfg/config.rs +++ b/src/tests_cfg/config.rs @@ -25,11 +25,20 @@ pub fn test_config() -> Config { middlewares: middleware::Config::default(), #[cfg(feature = "openapi")] openapi: config::OpenAPI { - redoc_url: "/redoc".to_string(), - scalar_url: "/scalar".to_string(), - swagger: config::Swagger { - swagger_url: "/swagger-ui".to_string(), - openapi_url: "/api-docs/openapi.json".to_string(), + redoc: config::OpenAPIType::Redoc { + url: "/redoc".to_string(), + spec_json_url: Some("/redoc/openapi.json".to_string()), + spec_yaml_url: Some("/redoc/openapi.yaml".to_string()), + }, + scalar: config::OpenAPIType::Scalar { + url: "/scalar".to_string(), + spec_json_url: Some("/scalar/openapi.json".to_string()), + spec_yaml_url: Some("/scalar/openapi.yaml".to_string()), + }, + swagger: config::OpenAPIType::Swagger { + url: "/swagger-ui".to_string(), + spec_json_url: "/api-docs/openapi.json".to_string(), + spec_yaml_url: Some("/api-docs/openapi.yaml".to_string()), }, }, }, diff --git a/tests/controller/openapi.rs b/tests/controller/openapi.rs index 8a4c9ca62..7d7b6da44 100644 --- a/tests/controller/openapi.rs +++ b/tests/controller/openapi.rs @@ -26,9 +26,9 @@ async fn openapi(#[case] mut test_name: &str) { let ctx: AppContext = tests_cfg::app::get_app_context().await; match test_name { - "/redoc" => assert_eq!(ctx.config.server.openapi.redoc_url, test_name), - "/scalar" => assert_eq!(ctx.config.server.openapi.scalar_url, test_name), - _ => assert_eq!(ctx.config.server.openapi.swagger.swagger_url, test_name), + "/redoc" => assert_eq!(ctx.config.server.openapi.redoc.url(), test_name), + "/scalar" => assert_eq!(ctx.config.server.openapi.scalar.url(), test_name), + _ => assert_eq!(ctx.config.server.openapi.swagger.url(), test_name), } let handle = infra_cfg::server::start_from_ctx(ctx).await; @@ -62,9 +62,16 @@ async fn openapi(#[case] mut test_name: &str) { handle.abort(); } +#[rstest] +#[case("redoc/openapi.json")] +#[case("scalar/openapi.json")] +#[case("api-docs/openapi.json")] +#[case("redoc/openapi.yaml")] +#[case("scalar/openapi.yaml")] +#[case("api-docs/openapi.yaml")] #[tokio::test] #[serial] -async fn openapi_json() { +async fn openapi_spec(#[case] test_name: &str) { configure_insta!(); let ctx: AppContext = tests_cfg::app::get_app_context().await; @@ -74,14 +81,14 @@ async fn openapi_json() { let res = reqwest::Client::new() .request( reqwest::Method::GET, - infra_cfg::server::get_base_url() + "api-docs/openapi.json", + infra_cfg::server::get_base_url() + test_name, ) .send() .await .expect("valid response"); assert_debug_snapshot!( - "openapi_json", + format!("openapi_spec_[{test_name}]"), ( res.status().to_string(), res.url().to_string(), diff --git a/tests/controller/snapshots/openapi_json@openapi.snap b/tests/controller/snapshots/openapi_spec_[api-docs__openapi.json]@openapi.snap similarity index 93% rename from tests/controller/snapshots/openapi_json@openapi.snap rename to tests/controller/snapshots/openapi_spec_[api-docs__openapi.json]@openapi.snap index dff6658c9..2e94c0cb7 100644 --- a/tests/controller/snapshots/openapi_json@openapi.snap +++ b/tests/controller/snapshots/openapi_spec_[api-docs__openapi.json]@openapi.snap @@ -5,5 +5,5 @@ expression: "(res.status().to_string(), res.url().to_string(), res.text().await. ( "200 OK", "http://localhost:5555/api-docs/openapi.json", - "{\"openapi\":\"3.1.0\",\"info\":{\"title\":\"Loco Demo\",\"description\":\"This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project.\",\"contact\":{\"name\":\"Dotan Nahum\",\"email\":\"dotan@rng0.io\"},\"license\":{\"name\":\"Apache-2.0\"},\"version\":\"0.12.0\"},\"paths\":{\"/album\":{\"get\":{\"operationId\":\"get_action_openapi\",\"responses\":{\"200\":{\"description\":\"Album found\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Album\"}}}}}}}},\"components\":{\"schemas\":{\"Album\":{\"type\":\"object\",\"required\":[\"title\",\"rating\"],\"properties\":{\"rating\":{\"type\":\"integer\",\"format\":\"int32\",\"minimum\":0},\"title\":{\"type\":\"string\"}}}}}}", + "{\"openapi\":\"3.1.0\",\"info\":{\"title\":\"Loco Demo\",\"description\":\"This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project.\",\"contact\":{\"name\":\"Dotan Nahum\",\"email\":\"dotan@rng0.io\"},\"license\":{\"name\":\"Apache-2.0\"},\"version\":\"0.13.0\"},\"paths\":{\"/album\":{\"get\":{\"operationId\":\"get_action_openapi\",\"responses\":{\"200\":{\"description\":\"Album found\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Album\"}}}}}}}},\"components\":{\"schemas\":{\"Album\":{\"type\":\"object\",\"required\":[\"title\",\"rating\"],\"properties\":{\"rating\":{\"type\":\"integer\",\"format\":\"int32\",\"minimum\":0},\"title\":{\"type\":\"string\"}}}}}}", ) diff --git a/tests/controller/snapshots/openapi_spec_[api-docs__openapi.yaml]@openapi.snap b/tests/controller/snapshots/openapi_spec_[api-docs__openapi.yaml]@openapi.snap new file mode 100644 index 000000000..d4c5b70b6 --- /dev/null +++ b/tests/controller/snapshots/openapi_spec_[api-docs__openapi.yaml]@openapi.snap @@ -0,0 +1,9 @@ +--- +source: tests/controller/openapi.rs +expression: "(res.status().to_string(), res.url().to_string(), res.text().await.unwrap(),)" +--- +( + "200 OK", + "http://localhost:5555/api-docs/openapi.yaml", + "openapi: 3.1.0\ninfo:\n title: Loco Demo\n description: This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project.\n contact:\n name: Dotan Nahum\n email: dotan@rng0.io\n license:\n name: Apache-2.0\n version: 0.13.0\npaths:\n /album:\n get:\n operationId: get_action_openapi\n responses:\n '200':\n description: Album found\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/Album'\ncomponents:\n schemas:\n Album:\n type: object\n required:\n - title\n - rating\n properties:\n rating:\n type: integer\n format: int32\n minimum: 0\n title:\n type: string\n", +) diff --git a/tests/controller/snapshots/openapi_spec_[redoc__openapi.json]@openapi.snap b/tests/controller/snapshots/openapi_spec_[redoc__openapi.json]@openapi.snap new file mode 100644 index 000000000..63b9b2020 --- /dev/null +++ b/tests/controller/snapshots/openapi_spec_[redoc__openapi.json]@openapi.snap @@ -0,0 +1,9 @@ +--- +source: tests/controller/openapi.rs +expression: "(res.status().to_string(), res.url().to_string(), res.text().await.unwrap(),)" +--- +( + "200 OK", + "http://localhost:5555/redoc/openapi.json", + "{\"openapi\":\"3.1.0\",\"info\":{\"title\":\"Loco Demo\",\"description\":\"This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project.\",\"contact\":{\"name\":\"Dotan Nahum\",\"email\":\"dotan@rng0.io\"},\"license\":{\"name\":\"Apache-2.0\"},\"version\":\"0.13.0\"},\"paths\":{\"/album\":{\"get\":{\"operationId\":\"get_action_openapi\",\"responses\":{\"200\":{\"description\":\"Album found\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Album\"}}}}}}}},\"components\":{\"schemas\":{\"Album\":{\"type\":\"object\",\"required\":[\"title\",\"rating\"],\"properties\":{\"rating\":{\"type\":\"integer\",\"format\":\"int32\",\"minimum\":0},\"title\":{\"type\":\"string\"}}}}}}", +) diff --git a/tests/controller/snapshots/openapi_spec_[redoc__openapi.yaml]@openapi.snap b/tests/controller/snapshots/openapi_spec_[redoc__openapi.yaml]@openapi.snap new file mode 100644 index 000000000..882eb0f97 --- /dev/null +++ b/tests/controller/snapshots/openapi_spec_[redoc__openapi.yaml]@openapi.snap @@ -0,0 +1,9 @@ +--- +source: tests/controller/openapi.rs +expression: "(res.status().to_string(), res.url().to_string(), res.text().await.unwrap(),)" +--- +( + "200 OK", + "http://localhost:5555/redoc/openapi.yaml", + "openapi: 3.1.0\ninfo:\n title: Loco Demo\n description: This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project.\n contact:\n name: Dotan Nahum\n email: dotan@rng0.io\n license:\n name: Apache-2.0\n version: 0.13.0\npaths:\n /album:\n get:\n operationId: get_action_openapi\n responses:\n '200':\n description: Album found\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/Album'\ncomponents:\n schemas:\n Album:\n type: object\n required:\n - title\n - rating\n properties:\n rating:\n type: integer\n format: int32\n minimum: 0\n title:\n type: string\n", +) diff --git a/tests/controller/snapshots/openapi_spec_[scalar__openapi.json]@openapi.snap b/tests/controller/snapshots/openapi_spec_[scalar__openapi.json]@openapi.snap new file mode 100644 index 000000000..1ce12df84 --- /dev/null +++ b/tests/controller/snapshots/openapi_spec_[scalar__openapi.json]@openapi.snap @@ -0,0 +1,9 @@ +--- +source: tests/controller/openapi.rs +expression: "(res.status().to_string(), res.url().to_string(), res.text().await.unwrap(),)" +--- +( + "200 OK", + "http://localhost:5555/scalar/openapi.json", + "{\"openapi\":\"3.1.0\",\"info\":{\"title\":\"Loco Demo\",\"description\":\"This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project.\",\"contact\":{\"name\":\"Dotan Nahum\",\"email\":\"dotan@rng0.io\"},\"license\":{\"name\":\"Apache-2.0\"},\"version\":\"0.13.0\"},\"paths\":{\"/album\":{\"get\":{\"operationId\":\"get_action_openapi\",\"responses\":{\"200\":{\"description\":\"Album found\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Album\"}}}}}}}},\"components\":{\"schemas\":{\"Album\":{\"type\":\"object\",\"required\":[\"title\",\"rating\"],\"properties\":{\"rating\":{\"type\":\"integer\",\"format\":\"int32\",\"minimum\":0},\"title\":{\"type\":\"string\"}}}}}}", +) diff --git a/tests/controller/snapshots/openapi_spec_[scalar__openapi.yaml]@openapi.snap b/tests/controller/snapshots/openapi_spec_[scalar__openapi.yaml]@openapi.snap new file mode 100644 index 000000000..26d65b74b --- /dev/null +++ b/tests/controller/snapshots/openapi_spec_[scalar__openapi.yaml]@openapi.snap @@ -0,0 +1,9 @@ +--- +source: tests/controller/openapi.rs +expression: "(res.status().to_string(), res.url().to_string(), res.text().await.unwrap(),)" +--- +( + "200 OK", + "http://localhost:5555/scalar/openapi.yaml", + "openapi: 3.1.0\ninfo:\n title: Loco Demo\n description: This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project.\n contact:\n name: Dotan Nahum\n email: dotan@rng0.io\n license:\n name: Apache-2.0\n version: 0.13.0\npaths:\n /album:\n get:\n operationId: get_action_openapi\n responses:\n '200':\n description: Album found\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/Album'\ncomponents:\n schemas:\n Album:\n type: object\n required:\n - title\n - rating\n properties:\n rating:\n type: integer\n format: int32\n minimum: 0\n title:\n type: string\n", +) From 2082e12660d67f2adc39569ea742cbfa11c25233 Mon Sep 17 00:00:00 2001 From: NexVeridian Date: Thu, 21 Nov 2024 05:00:18 -0800 Subject: [PATCH 16/28] SecurityAddon --- src/app.rs | 43 ++++---------- src/auth/mod.rs | 2 + src/auth/openapi.rs | 57 +++++++++++++++++++ src/tests_cfg/db.rs | 17 ++++-- tests/controller/openapi.rs | 51 ++++++++++++++++- .../openapi_security_[Cookie]@openapi.snap | 9 +++ .../openapi_security_[Query]@openapi.snap | 9 +++ ...spec_[api-docs__openapi.json]@openapi.snap | 2 +- ...spec_[api-docs__openapi.yaml]@openapi.snap | 2 +- ...pi_spec_[redoc__openapi.json]@openapi.snap | 2 +- ...pi_spec_[redoc__openapi.yaml]@openapi.snap | 2 +- ...i_spec_[scalar__openapi.json]@openapi.snap | 2 +- ...i_spec_[scalar__openapi.yaml]@openapi.snap | 2 +- 13 files changed, 156 insertions(+), 44 deletions(-) create mode 100644 src/auth/openapi.rs create mode 100644 tests/controller/snapshots/openapi_security_[Cookie]@openapi.snap create mode 100644 tests/controller/snapshots/openapi_security_[Query]@openapi.snap diff --git a/src/app.rs b/src/app.rs index 6ae7790c6..41d956fc7 100644 --- a/src/app.rs +++ b/src/app.rs @@ -223,7 +223,7 @@ pub trait Hooks: Send { /// Modify the OpenAPI spec before the routes are added, allowing you to edit (openapi::info)[https://docs.rs/utoipa/latest/utoipa/openapi/info/struct.Info.html] /// # Examples /// ```rust ignore - /// fn inital_openapi_spec() { + /// fn inital_openapi_spec(_ctx: &AppContext) -> utoipa::openapi::OpenApi { /// #[derive(OpenApi)] /// #[openapi(info( /// title = "Loco Demo", @@ -236,39 +236,18 @@ pub trait Hooks: Send { /// /// With SecurityAddon /// ```rust ignore - /// fn inital_openapi_spec() { + /// fn inital_openapi_spec(ctx: &AppContext) -> utoipa::openapi::OpenApi { + /// set_jwt_location(ctx); + /// /// #[derive(OpenApi)] - /// #[openapi(modifiers(&SecurityAddon), info( - /// title = "Loco Demo", - /// description = "This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project." - /// ))] + /// #[openapi( + /// modifiers(&SecurityAddon), + /// info( + /// title = "Loco Demo", + /// description = "This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project." + /// ) + /// )] /// struct ApiDoc; - /// - /// // TODO set the jwt token location - /// // let auth_location = ctx.config.auth.as_ref(); - /// - /// struct SecurityAddon; - /// impl Modify for SecurityAddon { - /// fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { - /// if let Some(components) = openapi.components.as_mut() { - /// components.add_security_schemes_from_iter([ - /// ( - /// "jwt_token", - /// SecurityScheme::Http( - /// HttpBuilder::new() - /// .scheme(HttpAuthScheme::Bearer) - /// .bearer_format("JWT") - /// .build(), - /// ), - /// ), - /// ( - /// "api_key", - /// SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new("apikey"))), - /// ), - /// ]); - /// } - /// } - /// } /// ApiDoc::openapi() /// } /// ``` diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 3114f8423..ffcce5a29 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -1,2 +1,4 @@ #[cfg(feature = "auth_jwt")] pub mod jwt; +#[cfg(feature = "openapi")] +pub mod openapi; diff --git a/src/auth/openapi.rs b/src/auth/openapi.rs new file mode 100644 index 000000000..0d4ff992d --- /dev/null +++ b/src/auth/openapi.rs @@ -0,0 +1,57 @@ +use std::sync::OnceLock; +use utoipa::{ + openapi::security::{ApiKey, ApiKeyValue, HttpAuthScheme, HttpBuilder, SecurityScheme}, + Modify, +}; + +use crate::{app::AppContext, config::JWTLocation}; + +static JWT_LOCATION: OnceLock = OnceLock::new(); + +pub fn set_jwt_location(ctx: &AppContext) -> &'static JWTLocation { + JWT_LOCATION.get_or_init(|| { + ctx.config + .auth + .as_ref() + .and_then(|auth| auth.jwt.as_ref()) + .and_then(|jwt| jwt.location.as_ref()) + .unwrap_or(&JWTLocation::Bearer) + .clone() + }) +} + +fn get_jwt_location() -> &'static JWTLocation { + JWT_LOCATION.get().unwrap() +} + +pub struct SecurityAddon; + +impl Modify for SecurityAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + if let Some(components) = openapi.components.as_mut() { + components.add_security_schemes_from_iter([ + ( + "jwt_token", + match get_jwt_location() { + JWTLocation::Bearer => SecurityScheme::Http( + HttpBuilder::new() + .scheme(HttpAuthScheme::Bearer) + .bearer_format("JWT") + .build(), + ), + JWTLocation::Query { name } => { + SecurityScheme::ApiKey(ApiKey::Query(ApiKeyValue::new(name))) + } + JWTLocation::Cookie { name } => { + SecurityScheme::ApiKey(ApiKey::Cookie(ApiKeyValue::new(name))) + } + }, + ), + ( + "api_key", + SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new("apikey"))), + ), + ]); + } + } +} diff --git a/src/tests_cfg/db.rs b/src/tests_cfg/db.rs index b63def806..fab784d8d 100644 --- a/src/tests_cfg/db.rs +++ b/src/tests_cfg/db.rs @@ -7,6 +7,8 @@ pub use sea_orm_migration::prelude::*; #[cfg(feature = "openapi")] use utoipa::OpenApi; +#[cfg(feature = "openapi")] +use crate::auth::openapi::{set_jwt_location, SecurityAddon}; #[cfg(feature = "channels")] use crate::controller::channels::AppChannels; use crate::{ @@ -131,12 +133,17 @@ impl Hooks for AppHook { } #[cfg(feature = "openapi")] - fn inital_openapi_spec(_ctx: &AppContext) -> utoipa::openapi::OpenApi { + fn inital_openapi_spec(ctx: &AppContext) -> utoipa::openapi::OpenApi { + set_jwt_location(ctx); + #[derive(OpenApi)] - #[openapi(info( - title = "Loco Demo", - description = "This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project." - ))] + #[openapi( + modifiers(&SecurityAddon), + info( + title = "Loco Demo", + description = "This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project." + ) + )] struct ApiDoc; ApiDoc::openapi() } diff --git a/tests/controller/openapi.rs b/tests/controller/openapi.rs index 7d7b6da44..677b56320 100644 --- a/tests/controller/openapi.rs +++ b/tests/controller/openapi.rs @@ -1,5 +1,9 @@ use insta::assert_debug_snapshot; -use loco_rs::{prelude::*, tests_cfg}; +use loco_rs::{ + config::{Auth, JWTLocation, JWT}, + prelude::*, + tests_cfg, +}; use rstest::rstest; use serial_test::serial; @@ -98,3 +102,48 @@ async fn openapi_spec(#[case] test_name: &str) { handle.abort(); } + +#[rstest] +#[case(JWTLocation::Query { name: "JWT".to_string() })] +#[case(JWTLocation::Cookie { name: "JWT".to_string() })] +#[tokio::test] +#[serial] +async fn openapi_security(#[case] location: JWTLocation) { + configure_insta!(); + + let mut ctx: AppContext = tests_cfg::app::get_app_context().await; + ctx.config.auth = Some(Auth { + jwt: Some(JWT { + location: Some(location.clone()), + secret: "PqRwLF2rhHe8J22oBeHy".to_string(), + expiration: 604800, + }), + }); + + let handle = infra_cfg::server::start_from_ctx(ctx).await; + + let res = reqwest::Client::new() + .request( + reqwest::Method::GET, + infra_cfg::server::get_base_url() + "api-docs/openapi.json", + ) + .send() + .await + .expect("valid response"); + + let test_name = match location { + JWTLocation::Query { .. } => "Query", + JWTLocation::Cookie { .. } => "Cookie", + _ => "Bearer", + }; + assert_debug_snapshot!( + format!("openapi_security_[{test_name}]"), + ( + res.status().to_string(), + res.url().to_string(), + res.text().await.unwrap(), + ) + ); + + handle.abort(); +} diff --git a/tests/controller/snapshots/openapi_security_[Cookie]@openapi.snap b/tests/controller/snapshots/openapi_security_[Cookie]@openapi.snap new file mode 100644 index 000000000..a7da4694e --- /dev/null +++ b/tests/controller/snapshots/openapi_security_[Cookie]@openapi.snap @@ -0,0 +1,9 @@ +--- +source: tests/controller/openapi.rs +expression: "(res.status().to_string(), res.url().to_string(), res.text().await.unwrap(),)" +--- +( + "200 OK", + "http://localhost:5555/api-docs/openapi.json", + "{\"openapi\":\"3.1.0\",\"info\":{\"title\":\"Loco Demo\",\"description\":\"This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project.\",\"contact\":{\"name\":\"Dotan Nahum\",\"email\":\"dotan@rng0.io\"},\"license\":{\"name\":\"Apache-2.0\"},\"version\":\"0.13.1\"},\"paths\":{\"/album\":{\"get\":{\"operationId\":\"get_action_openapi\",\"responses\":{\"200\":{\"description\":\"Album found\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Album\"}}}}}}}},\"components\":{\"schemas\":{\"Album\":{\"type\":\"object\",\"required\":[\"title\",\"rating\"],\"properties\":{\"rating\":{\"type\":\"integer\",\"format\":\"int32\",\"minimum\":0},\"title\":{\"type\":\"string\"}}}},\"securitySchemes\":{\"api_key\":{\"type\":\"apiKey\",\"in\":\"header\",\"name\":\"apikey\"},\"jwt_token\":{\"type\":\"apiKey\",\"in\":\"cookie\",\"name\":\"JWT\"}}}}", +) diff --git a/tests/controller/snapshots/openapi_security_[Query]@openapi.snap b/tests/controller/snapshots/openapi_security_[Query]@openapi.snap new file mode 100644 index 000000000..ba7a7f6d6 --- /dev/null +++ b/tests/controller/snapshots/openapi_security_[Query]@openapi.snap @@ -0,0 +1,9 @@ +--- +source: tests/controller/openapi.rs +expression: "(res.status().to_string(), res.url().to_string(), res.text().await.unwrap(),)" +--- +( + "200 OK", + "http://localhost:5555/api-docs/openapi.json", + "{\"openapi\":\"3.1.0\",\"info\":{\"title\":\"Loco Demo\",\"description\":\"This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project.\",\"contact\":{\"name\":\"Dotan Nahum\",\"email\":\"dotan@rng0.io\"},\"license\":{\"name\":\"Apache-2.0\"},\"version\":\"0.13.1\"},\"paths\":{\"/album\":{\"get\":{\"operationId\":\"get_action_openapi\",\"responses\":{\"200\":{\"description\":\"Album found\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Album\"}}}}}}}},\"components\":{\"schemas\":{\"Album\":{\"type\":\"object\",\"required\":[\"title\",\"rating\"],\"properties\":{\"rating\":{\"type\":\"integer\",\"format\":\"int32\",\"minimum\":0},\"title\":{\"type\":\"string\"}}}},\"securitySchemes\":{\"api_key\":{\"type\":\"apiKey\",\"in\":\"header\",\"name\":\"apikey\"},\"jwt_token\":{\"type\":\"apiKey\",\"in\":\"query\",\"name\":\"JWT\"}}}}", +) diff --git a/tests/controller/snapshots/openapi_spec_[api-docs__openapi.json]@openapi.snap b/tests/controller/snapshots/openapi_spec_[api-docs__openapi.json]@openapi.snap index 2e94c0cb7..7c8d04153 100644 --- a/tests/controller/snapshots/openapi_spec_[api-docs__openapi.json]@openapi.snap +++ b/tests/controller/snapshots/openapi_spec_[api-docs__openapi.json]@openapi.snap @@ -5,5 +5,5 @@ expression: "(res.status().to_string(), res.url().to_string(), res.text().await. ( "200 OK", "http://localhost:5555/api-docs/openapi.json", - "{\"openapi\":\"3.1.0\",\"info\":{\"title\":\"Loco Demo\",\"description\":\"This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project.\",\"contact\":{\"name\":\"Dotan Nahum\",\"email\":\"dotan@rng0.io\"},\"license\":{\"name\":\"Apache-2.0\"},\"version\":\"0.13.0\"},\"paths\":{\"/album\":{\"get\":{\"operationId\":\"get_action_openapi\",\"responses\":{\"200\":{\"description\":\"Album found\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Album\"}}}}}}}},\"components\":{\"schemas\":{\"Album\":{\"type\":\"object\",\"required\":[\"title\",\"rating\"],\"properties\":{\"rating\":{\"type\":\"integer\",\"format\":\"int32\",\"minimum\":0},\"title\":{\"type\":\"string\"}}}}}}", + "{\"openapi\":\"3.1.0\",\"info\":{\"title\":\"Loco Demo\",\"description\":\"This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project.\",\"contact\":{\"name\":\"Dotan Nahum\",\"email\":\"dotan@rng0.io\"},\"license\":{\"name\":\"Apache-2.0\"},\"version\":\"0.13.1\"},\"paths\":{\"/album\":{\"get\":{\"operationId\":\"get_action_openapi\",\"responses\":{\"200\":{\"description\":\"Album found\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Album\"}}}}}}}},\"components\":{\"schemas\":{\"Album\":{\"type\":\"object\",\"required\":[\"title\",\"rating\"],\"properties\":{\"rating\":{\"type\":\"integer\",\"format\":\"int32\",\"minimum\":0},\"title\":{\"type\":\"string\"}}}},\"securitySchemes\":{\"api_key\":{\"type\":\"apiKey\",\"in\":\"header\",\"name\":\"apikey\"},\"jwt_token\":{\"type\":\"http\",\"scheme\":\"bearer\",\"bearerFormat\":\"JWT\"}}}}", ) diff --git a/tests/controller/snapshots/openapi_spec_[api-docs__openapi.yaml]@openapi.snap b/tests/controller/snapshots/openapi_spec_[api-docs__openapi.yaml]@openapi.snap index d4c5b70b6..d1545ccb0 100644 --- a/tests/controller/snapshots/openapi_spec_[api-docs__openapi.yaml]@openapi.snap +++ b/tests/controller/snapshots/openapi_spec_[api-docs__openapi.yaml]@openapi.snap @@ -5,5 +5,5 @@ expression: "(res.status().to_string(), res.url().to_string(), res.text().await. ( "200 OK", "http://localhost:5555/api-docs/openapi.yaml", - "openapi: 3.1.0\ninfo:\n title: Loco Demo\n description: This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project.\n contact:\n name: Dotan Nahum\n email: dotan@rng0.io\n license:\n name: Apache-2.0\n version: 0.13.0\npaths:\n /album:\n get:\n operationId: get_action_openapi\n responses:\n '200':\n description: Album found\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/Album'\ncomponents:\n schemas:\n Album:\n type: object\n required:\n - title\n - rating\n properties:\n rating:\n type: integer\n format: int32\n minimum: 0\n title:\n type: string\n", + "openapi: 3.1.0\ninfo:\n title: Loco Demo\n description: This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project.\n contact:\n name: Dotan Nahum\n email: dotan@rng0.io\n license:\n name: Apache-2.0\n version: 0.13.1\npaths:\n /album:\n get:\n operationId: get_action_openapi\n responses:\n '200':\n description: Album found\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/Album'\ncomponents:\n schemas:\n Album:\n type: object\n required:\n - title\n - rating\n properties:\n rating:\n type: integer\n format: int32\n minimum: 0\n title:\n type: string\n securitySchemes:\n api_key:\n type: apiKey\n in: header\n name: apikey\n jwt_token:\n type: http\n scheme: bearer\n bearerFormat: JWT\n", ) diff --git a/tests/controller/snapshots/openapi_spec_[redoc__openapi.json]@openapi.snap b/tests/controller/snapshots/openapi_spec_[redoc__openapi.json]@openapi.snap index 63b9b2020..887ccaf11 100644 --- a/tests/controller/snapshots/openapi_spec_[redoc__openapi.json]@openapi.snap +++ b/tests/controller/snapshots/openapi_spec_[redoc__openapi.json]@openapi.snap @@ -5,5 +5,5 @@ expression: "(res.status().to_string(), res.url().to_string(), res.text().await. ( "200 OK", "http://localhost:5555/redoc/openapi.json", - "{\"openapi\":\"3.1.0\",\"info\":{\"title\":\"Loco Demo\",\"description\":\"This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project.\",\"contact\":{\"name\":\"Dotan Nahum\",\"email\":\"dotan@rng0.io\"},\"license\":{\"name\":\"Apache-2.0\"},\"version\":\"0.13.0\"},\"paths\":{\"/album\":{\"get\":{\"operationId\":\"get_action_openapi\",\"responses\":{\"200\":{\"description\":\"Album found\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Album\"}}}}}}}},\"components\":{\"schemas\":{\"Album\":{\"type\":\"object\",\"required\":[\"title\",\"rating\"],\"properties\":{\"rating\":{\"type\":\"integer\",\"format\":\"int32\",\"minimum\":0},\"title\":{\"type\":\"string\"}}}}}}", + "{\"openapi\":\"3.1.0\",\"info\":{\"title\":\"Loco Demo\",\"description\":\"This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project.\",\"contact\":{\"name\":\"Dotan Nahum\",\"email\":\"dotan@rng0.io\"},\"license\":{\"name\":\"Apache-2.0\"},\"version\":\"0.13.1\"},\"paths\":{\"/album\":{\"get\":{\"operationId\":\"get_action_openapi\",\"responses\":{\"200\":{\"description\":\"Album found\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Album\"}}}}}}}},\"components\":{\"schemas\":{\"Album\":{\"type\":\"object\",\"required\":[\"title\",\"rating\"],\"properties\":{\"rating\":{\"type\":\"integer\",\"format\":\"int32\",\"minimum\":0},\"title\":{\"type\":\"string\"}}}},\"securitySchemes\":{\"api_key\":{\"type\":\"apiKey\",\"in\":\"header\",\"name\":\"apikey\"},\"jwt_token\":{\"type\":\"http\",\"scheme\":\"bearer\",\"bearerFormat\":\"JWT\"}}}}", ) diff --git a/tests/controller/snapshots/openapi_spec_[redoc__openapi.yaml]@openapi.snap b/tests/controller/snapshots/openapi_spec_[redoc__openapi.yaml]@openapi.snap index 882eb0f97..349bf7210 100644 --- a/tests/controller/snapshots/openapi_spec_[redoc__openapi.yaml]@openapi.snap +++ b/tests/controller/snapshots/openapi_spec_[redoc__openapi.yaml]@openapi.snap @@ -5,5 +5,5 @@ expression: "(res.status().to_string(), res.url().to_string(), res.text().await. ( "200 OK", "http://localhost:5555/redoc/openapi.yaml", - "openapi: 3.1.0\ninfo:\n title: Loco Demo\n description: This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project.\n contact:\n name: Dotan Nahum\n email: dotan@rng0.io\n license:\n name: Apache-2.0\n version: 0.13.0\npaths:\n /album:\n get:\n operationId: get_action_openapi\n responses:\n '200':\n description: Album found\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/Album'\ncomponents:\n schemas:\n Album:\n type: object\n required:\n - title\n - rating\n properties:\n rating:\n type: integer\n format: int32\n minimum: 0\n title:\n type: string\n", + "openapi: 3.1.0\ninfo:\n title: Loco Demo\n description: This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project.\n contact:\n name: Dotan Nahum\n email: dotan@rng0.io\n license:\n name: Apache-2.0\n version: 0.13.1\npaths:\n /album:\n get:\n operationId: get_action_openapi\n responses:\n '200':\n description: Album found\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/Album'\ncomponents:\n schemas:\n Album:\n type: object\n required:\n - title\n - rating\n properties:\n rating:\n type: integer\n format: int32\n minimum: 0\n title:\n type: string\n securitySchemes:\n api_key:\n type: apiKey\n in: header\n name: apikey\n jwt_token:\n type: http\n scheme: bearer\n bearerFormat: JWT\n", ) diff --git a/tests/controller/snapshots/openapi_spec_[scalar__openapi.json]@openapi.snap b/tests/controller/snapshots/openapi_spec_[scalar__openapi.json]@openapi.snap index 1ce12df84..4243cbd18 100644 --- a/tests/controller/snapshots/openapi_spec_[scalar__openapi.json]@openapi.snap +++ b/tests/controller/snapshots/openapi_spec_[scalar__openapi.json]@openapi.snap @@ -5,5 +5,5 @@ expression: "(res.status().to_string(), res.url().to_string(), res.text().await. ( "200 OK", "http://localhost:5555/scalar/openapi.json", - "{\"openapi\":\"3.1.0\",\"info\":{\"title\":\"Loco Demo\",\"description\":\"This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project.\",\"contact\":{\"name\":\"Dotan Nahum\",\"email\":\"dotan@rng0.io\"},\"license\":{\"name\":\"Apache-2.0\"},\"version\":\"0.13.0\"},\"paths\":{\"/album\":{\"get\":{\"operationId\":\"get_action_openapi\",\"responses\":{\"200\":{\"description\":\"Album found\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Album\"}}}}}}}},\"components\":{\"schemas\":{\"Album\":{\"type\":\"object\",\"required\":[\"title\",\"rating\"],\"properties\":{\"rating\":{\"type\":\"integer\",\"format\":\"int32\",\"minimum\":0},\"title\":{\"type\":\"string\"}}}}}}", + "{\"openapi\":\"3.1.0\",\"info\":{\"title\":\"Loco Demo\",\"description\":\"This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project.\",\"contact\":{\"name\":\"Dotan Nahum\",\"email\":\"dotan@rng0.io\"},\"license\":{\"name\":\"Apache-2.0\"},\"version\":\"0.13.1\"},\"paths\":{\"/album\":{\"get\":{\"operationId\":\"get_action_openapi\",\"responses\":{\"200\":{\"description\":\"Album found\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Album\"}}}}}}}},\"components\":{\"schemas\":{\"Album\":{\"type\":\"object\",\"required\":[\"title\",\"rating\"],\"properties\":{\"rating\":{\"type\":\"integer\",\"format\":\"int32\",\"minimum\":0},\"title\":{\"type\":\"string\"}}}},\"securitySchemes\":{\"api_key\":{\"type\":\"apiKey\",\"in\":\"header\",\"name\":\"apikey\"},\"jwt_token\":{\"type\":\"http\",\"scheme\":\"bearer\",\"bearerFormat\":\"JWT\"}}}}", ) diff --git a/tests/controller/snapshots/openapi_spec_[scalar__openapi.yaml]@openapi.snap b/tests/controller/snapshots/openapi_spec_[scalar__openapi.yaml]@openapi.snap index 26d65b74b..ffe1847cd 100644 --- a/tests/controller/snapshots/openapi_spec_[scalar__openapi.yaml]@openapi.snap +++ b/tests/controller/snapshots/openapi_spec_[scalar__openapi.yaml]@openapi.snap @@ -5,5 +5,5 @@ expression: "(res.status().to_string(), res.url().to_string(), res.text().await. ( "200 OK", "http://localhost:5555/scalar/openapi.yaml", - "openapi: 3.1.0\ninfo:\n title: Loco Demo\n description: This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project.\n contact:\n name: Dotan Nahum\n email: dotan@rng0.io\n license:\n name: Apache-2.0\n version: 0.13.0\npaths:\n /album:\n get:\n operationId: get_action_openapi\n responses:\n '200':\n description: Album found\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/Album'\ncomponents:\n schemas:\n Album:\n type: object\n required:\n - title\n - rating\n properties:\n rating:\n type: integer\n format: int32\n minimum: 0\n title:\n type: string\n", + "openapi: 3.1.0\ninfo:\n title: Loco Demo\n description: This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project.\n contact:\n name: Dotan Nahum\n email: dotan@rng0.io\n license:\n name: Apache-2.0\n version: 0.13.1\npaths:\n /album:\n get:\n operationId: get_action_openapi\n responses:\n '200':\n description: Album found\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/Album'\ncomponents:\n schemas:\n Album:\n type: object\n required:\n - title\n - rating\n properties:\n rating:\n type: integer\n format: int32\n minimum: 0\n title:\n type: string\n securitySchemes:\n api_key:\n type: apiKey\n in: header\n name: apikey\n jwt_token:\n type: http\n scheme: bearer\n bearerFormat: JWT\n", ) From ea4891941fffeb8d1e5441c9a9d57eaeb5bba261 Mon Sep 17 00:00:00 2001 From: NexVeridian Date: Sat, 23 Nov 2024 06:10:06 -0800 Subject: [PATCH 17/28] fix cfg flag for import --- src/controller/app_routes.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/controller/app_routes.rs b/src/controller/app_routes.rs index 3e88142d6..4520e1955 100644 --- a/src/controller/app_routes.rs +++ b/src/controller/app_routes.rs @@ -22,14 +22,16 @@ use utoipa_swagger_ui::SwaggerUi; #[cfg(feature = "channels")] use super::channels::AppChannels; use super::routes::LocoMethodRouter; -#[cfg(feature = "openapi")] -use crate::controller::{format, Response}; use crate::{ app::{AppContext, Hooks}, - config::OpenAPIType, controller::{middleware::MiddlewareLayer, routes::Routes}, Result, }; +#[cfg(feature = "openapi")] +use crate::{ + config::OpenAPIType, + controller::{format, Response}, +}; static NORMALIZE_URL: OnceLock = OnceLock::new(); From 022909d108066c32610b4bcf7f19001ff989b084 Mon Sep 17 00:00:00 2001 From: NexVeridian Date: Tue, 26 Nov 2024 20:37:26 -0800 Subject: [PATCH 18/28] fix: snapshots and imports --- Cargo.toml | 3 +-- src/controller/app_routes.rs | 5 ++++- .../snapshots/openapi_security_[Cookie]@openapi.snap | 2 +- .../snapshots/openapi_security_[Query]@openapi.snap | 2 +- .../openapi_spec_[api-docs__openapi.json]@openapi.snap | 2 +- .../openapi_spec_[api-docs__openapi.yaml]@openapi.snap | 2 +- .../openapi_spec_[redoc__openapi.json]@openapi.snap | 2 +- .../openapi_spec_[redoc__openapi.yaml]@openapi.snap | 2 +- .../openapi_spec_[scalar__openapi.json]@openapi.snap | 2 +- .../openapi_spec_[scalar__openapi.yaml]@openapi.snap | 2 +- 10 files changed, 13 insertions(+), 11 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 8b90a4018..aff074d99 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,7 +35,6 @@ auth_jwt = ["dep:jsonwebtoken"] cli = ["dep:clap"] testing = ["dep:axum-test"] with-db = ["dep:sea-orm", "dep:sea-orm-migration", "loco-gen/with-db"] -channels = ["dep:socketioxide"] openapi = [ "dep:utoipa", "dep:utoipa-axum", @@ -129,7 +128,7 @@ cfg-if = "1" uuid = { version = "1.10.0", features = ["v4", "fast-rng"] } # OpenAPI -utoipa = { version = "5.0.0", optional = true } +utoipa = { version = "5.0.0", features = ["yaml"], optional = true } utoipa-axum = { version = "0.1.0", optional = true } utoipa-swagger-ui = { version = "8.0", features = [ "axum", diff --git a/src/controller/app_routes.rs b/src/controller/app_routes.rs index 1efc8104d..351d4769e 100644 --- a/src/controller/app_routes.rs +++ b/src/controller/app_routes.rs @@ -21,7 +21,10 @@ use utoipa_swagger_ui::SwaggerUi; use crate::{ app::{AppContext, Hooks}, - controller::{middleware::MiddlewareLayer, routes::Routes}, + controller::{ + middleware::MiddlewareLayer, + routes::{LocoMethodRouter, Routes}, + }, Result, }; #[cfg(feature = "openapi")] diff --git a/tests/controller/snapshots/openapi_security_[Cookie]@openapi.snap b/tests/controller/snapshots/openapi_security_[Cookie]@openapi.snap index a7da4694e..339e18539 100644 --- a/tests/controller/snapshots/openapi_security_[Cookie]@openapi.snap +++ b/tests/controller/snapshots/openapi_security_[Cookie]@openapi.snap @@ -5,5 +5,5 @@ expression: "(res.status().to_string(), res.url().to_string(), res.text().await. ( "200 OK", "http://localhost:5555/api-docs/openapi.json", - "{\"openapi\":\"3.1.0\",\"info\":{\"title\":\"Loco Demo\",\"description\":\"This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project.\",\"contact\":{\"name\":\"Dotan Nahum\",\"email\":\"dotan@rng0.io\"},\"license\":{\"name\":\"Apache-2.0\"},\"version\":\"0.13.1\"},\"paths\":{\"/album\":{\"get\":{\"operationId\":\"get_action_openapi\",\"responses\":{\"200\":{\"description\":\"Album found\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Album\"}}}}}}}},\"components\":{\"schemas\":{\"Album\":{\"type\":\"object\",\"required\":[\"title\",\"rating\"],\"properties\":{\"rating\":{\"type\":\"integer\",\"format\":\"int32\",\"minimum\":0},\"title\":{\"type\":\"string\"}}}},\"securitySchemes\":{\"api_key\":{\"type\":\"apiKey\",\"in\":\"header\",\"name\":\"apikey\"},\"jwt_token\":{\"type\":\"apiKey\",\"in\":\"cookie\",\"name\":\"JWT\"}}}}", + "{\"openapi\":\"3.1.0\",\"info\":{\"title\":\"Loco Demo\",\"description\":\"This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project.\",\"contact\":{\"name\":\"Dotan Nahum\",\"email\":\"dotan@rng0.io\"},\"license\":{\"name\":\"Apache-2.0\"},\"version\":\"0.13.2\"},\"paths\":{\"/album\":{\"get\":{\"operationId\":\"get_action_openapi\",\"responses\":{\"200\":{\"description\":\"Album found\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Album\"}}}}}}}},\"components\":{\"schemas\":{\"Album\":{\"type\":\"object\",\"required\":[\"title\",\"rating\"],\"properties\":{\"rating\":{\"type\":\"integer\",\"format\":\"int32\",\"minimum\":0},\"title\":{\"type\":\"string\"}}}},\"securitySchemes\":{\"api_key\":{\"type\":\"apiKey\",\"in\":\"header\",\"name\":\"apikey\"},\"jwt_token\":{\"type\":\"apiKey\",\"in\":\"cookie\",\"name\":\"JWT\"}}}}", ) diff --git a/tests/controller/snapshots/openapi_security_[Query]@openapi.snap b/tests/controller/snapshots/openapi_security_[Query]@openapi.snap index ba7a7f6d6..a20ba2eaf 100644 --- a/tests/controller/snapshots/openapi_security_[Query]@openapi.snap +++ b/tests/controller/snapshots/openapi_security_[Query]@openapi.snap @@ -5,5 +5,5 @@ expression: "(res.status().to_string(), res.url().to_string(), res.text().await. ( "200 OK", "http://localhost:5555/api-docs/openapi.json", - "{\"openapi\":\"3.1.0\",\"info\":{\"title\":\"Loco Demo\",\"description\":\"This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project.\",\"contact\":{\"name\":\"Dotan Nahum\",\"email\":\"dotan@rng0.io\"},\"license\":{\"name\":\"Apache-2.0\"},\"version\":\"0.13.1\"},\"paths\":{\"/album\":{\"get\":{\"operationId\":\"get_action_openapi\",\"responses\":{\"200\":{\"description\":\"Album found\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Album\"}}}}}}}},\"components\":{\"schemas\":{\"Album\":{\"type\":\"object\",\"required\":[\"title\",\"rating\"],\"properties\":{\"rating\":{\"type\":\"integer\",\"format\":\"int32\",\"minimum\":0},\"title\":{\"type\":\"string\"}}}},\"securitySchemes\":{\"api_key\":{\"type\":\"apiKey\",\"in\":\"header\",\"name\":\"apikey\"},\"jwt_token\":{\"type\":\"apiKey\",\"in\":\"query\",\"name\":\"JWT\"}}}}", + "{\"openapi\":\"3.1.0\",\"info\":{\"title\":\"Loco Demo\",\"description\":\"This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project.\",\"contact\":{\"name\":\"Dotan Nahum\",\"email\":\"dotan@rng0.io\"},\"license\":{\"name\":\"Apache-2.0\"},\"version\":\"0.13.2\"},\"paths\":{\"/album\":{\"get\":{\"operationId\":\"get_action_openapi\",\"responses\":{\"200\":{\"description\":\"Album found\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Album\"}}}}}}}},\"components\":{\"schemas\":{\"Album\":{\"type\":\"object\",\"required\":[\"title\",\"rating\"],\"properties\":{\"rating\":{\"type\":\"integer\",\"format\":\"int32\",\"minimum\":0},\"title\":{\"type\":\"string\"}}}},\"securitySchemes\":{\"api_key\":{\"type\":\"apiKey\",\"in\":\"header\",\"name\":\"apikey\"},\"jwt_token\":{\"type\":\"apiKey\",\"in\":\"query\",\"name\":\"JWT\"}}}}", ) diff --git a/tests/controller/snapshots/openapi_spec_[api-docs__openapi.json]@openapi.snap b/tests/controller/snapshots/openapi_spec_[api-docs__openapi.json]@openapi.snap index 7c8d04153..7defe700e 100644 --- a/tests/controller/snapshots/openapi_spec_[api-docs__openapi.json]@openapi.snap +++ b/tests/controller/snapshots/openapi_spec_[api-docs__openapi.json]@openapi.snap @@ -5,5 +5,5 @@ expression: "(res.status().to_string(), res.url().to_string(), res.text().await. ( "200 OK", "http://localhost:5555/api-docs/openapi.json", - "{\"openapi\":\"3.1.0\",\"info\":{\"title\":\"Loco Demo\",\"description\":\"This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project.\",\"contact\":{\"name\":\"Dotan Nahum\",\"email\":\"dotan@rng0.io\"},\"license\":{\"name\":\"Apache-2.0\"},\"version\":\"0.13.1\"},\"paths\":{\"/album\":{\"get\":{\"operationId\":\"get_action_openapi\",\"responses\":{\"200\":{\"description\":\"Album found\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Album\"}}}}}}}},\"components\":{\"schemas\":{\"Album\":{\"type\":\"object\",\"required\":[\"title\",\"rating\"],\"properties\":{\"rating\":{\"type\":\"integer\",\"format\":\"int32\",\"minimum\":0},\"title\":{\"type\":\"string\"}}}},\"securitySchemes\":{\"api_key\":{\"type\":\"apiKey\",\"in\":\"header\",\"name\":\"apikey\"},\"jwt_token\":{\"type\":\"http\",\"scheme\":\"bearer\",\"bearerFormat\":\"JWT\"}}}}", + "{\"openapi\":\"3.1.0\",\"info\":{\"title\":\"Loco Demo\",\"description\":\"This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project.\",\"contact\":{\"name\":\"Dotan Nahum\",\"email\":\"dotan@rng0.io\"},\"license\":{\"name\":\"Apache-2.0\"},\"version\":\"0.13.2\"},\"paths\":{\"/album\":{\"get\":{\"operationId\":\"get_action_openapi\",\"responses\":{\"200\":{\"description\":\"Album found\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Album\"}}}}}}}},\"components\":{\"schemas\":{\"Album\":{\"type\":\"object\",\"required\":[\"title\",\"rating\"],\"properties\":{\"rating\":{\"type\":\"integer\",\"format\":\"int32\",\"minimum\":0},\"title\":{\"type\":\"string\"}}}},\"securitySchemes\":{\"api_key\":{\"type\":\"apiKey\",\"in\":\"header\",\"name\":\"apikey\"},\"jwt_token\":{\"type\":\"http\",\"scheme\":\"bearer\",\"bearerFormat\":\"JWT\"}}}}", ) diff --git a/tests/controller/snapshots/openapi_spec_[api-docs__openapi.yaml]@openapi.snap b/tests/controller/snapshots/openapi_spec_[api-docs__openapi.yaml]@openapi.snap index d1545ccb0..63d51e810 100644 --- a/tests/controller/snapshots/openapi_spec_[api-docs__openapi.yaml]@openapi.snap +++ b/tests/controller/snapshots/openapi_spec_[api-docs__openapi.yaml]@openapi.snap @@ -5,5 +5,5 @@ expression: "(res.status().to_string(), res.url().to_string(), res.text().await. ( "200 OK", "http://localhost:5555/api-docs/openapi.yaml", - "openapi: 3.1.0\ninfo:\n title: Loco Demo\n description: This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project.\n contact:\n name: Dotan Nahum\n email: dotan@rng0.io\n license:\n name: Apache-2.0\n version: 0.13.1\npaths:\n /album:\n get:\n operationId: get_action_openapi\n responses:\n '200':\n description: Album found\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/Album'\ncomponents:\n schemas:\n Album:\n type: object\n required:\n - title\n - rating\n properties:\n rating:\n type: integer\n format: int32\n minimum: 0\n title:\n type: string\n securitySchemes:\n api_key:\n type: apiKey\n in: header\n name: apikey\n jwt_token:\n type: http\n scheme: bearer\n bearerFormat: JWT\n", + "openapi: 3.1.0\ninfo:\n title: Loco Demo\n description: This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project.\n contact:\n name: Dotan Nahum\n email: dotan@rng0.io\n license:\n name: Apache-2.0\n version: 0.13.2\npaths:\n /album:\n get:\n operationId: get_action_openapi\n responses:\n '200':\n description: Album found\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/Album'\ncomponents:\n schemas:\n Album:\n type: object\n required:\n - title\n - rating\n properties:\n rating:\n type: integer\n format: int32\n minimum: 0\n title:\n type: string\n securitySchemes:\n api_key:\n type: apiKey\n in: header\n name: apikey\n jwt_token:\n type: http\n scheme: bearer\n bearerFormat: JWT\n", ) diff --git a/tests/controller/snapshots/openapi_spec_[redoc__openapi.json]@openapi.snap b/tests/controller/snapshots/openapi_spec_[redoc__openapi.json]@openapi.snap index 887ccaf11..c5f499d57 100644 --- a/tests/controller/snapshots/openapi_spec_[redoc__openapi.json]@openapi.snap +++ b/tests/controller/snapshots/openapi_spec_[redoc__openapi.json]@openapi.snap @@ -5,5 +5,5 @@ expression: "(res.status().to_string(), res.url().to_string(), res.text().await. ( "200 OK", "http://localhost:5555/redoc/openapi.json", - "{\"openapi\":\"3.1.0\",\"info\":{\"title\":\"Loco Demo\",\"description\":\"This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project.\",\"contact\":{\"name\":\"Dotan Nahum\",\"email\":\"dotan@rng0.io\"},\"license\":{\"name\":\"Apache-2.0\"},\"version\":\"0.13.1\"},\"paths\":{\"/album\":{\"get\":{\"operationId\":\"get_action_openapi\",\"responses\":{\"200\":{\"description\":\"Album found\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Album\"}}}}}}}},\"components\":{\"schemas\":{\"Album\":{\"type\":\"object\",\"required\":[\"title\",\"rating\"],\"properties\":{\"rating\":{\"type\":\"integer\",\"format\":\"int32\",\"minimum\":0},\"title\":{\"type\":\"string\"}}}},\"securitySchemes\":{\"api_key\":{\"type\":\"apiKey\",\"in\":\"header\",\"name\":\"apikey\"},\"jwt_token\":{\"type\":\"http\",\"scheme\":\"bearer\",\"bearerFormat\":\"JWT\"}}}}", + "{\"openapi\":\"3.1.0\",\"info\":{\"title\":\"Loco Demo\",\"description\":\"This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project.\",\"contact\":{\"name\":\"Dotan Nahum\",\"email\":\"dotan@rng0.io\"},\"license\":{\"name\":\"Apache-2.0\"},\"version\":\"0.13.2\"},\"paths\":{\"/album\":{\"get\":{\"operationId\":\"get_action_openapi\",\"responses\":{\"200\":{\"description\":\"Album found\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Album\"}}}}}}}},\"components\":{\"schemas\":{\"Album\":{\"type\":\"object\",\"required\":[\"title\",\"rating\"],\"properties\":{\"rating\":{\"type\":\"integer\",\"format\":\"int32\",\"minimum\":0},\"title\":{\"type\":\"string\"}}}},\"securitySchemes\":{\"api_key\":{\"type\":\"apiKey\",\"in\":\"header\",\"name\":\"apikey\"},\"jwt_token\":{\"type\":\"http\",\"scheme\":\"bearer\",\"bearerFormat\":\"JWT\"}}}}", ) diff --git a/tests/controller/snapshots/openapi_spec_[redoc__openapi.yaml]@openapi.snap b/tests/controller/snapshots/openapi_spec_[redoc__openapi.yaml]@openapi.snap index 349bf7210..8d4d56411 100644 --- a/tests/controller/snapshots/openapi_spec_[redoc__openapi.yaml]@openapi.snap +++ b/tests/controller/snapshots/openapi_spec_[redoc__openapi.yaml]@openapi.snap @@ -5,5 +5,5 @@ expression: "(res.status().to_string(), res.url().to_string(), res.text().await. ( "200 OK", "http://localhost:5555/redoc/openapi.yaml", - "openapi: 3.1.0\ninfo:\n title: Loco Demo\n description: This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project.\n contact:\n name: Dotan Nahum\n email: dotan@rng0.io\n license:\n name: Apache-2.0\n version: 0.13.1\npaths:\n /album:\n get:\n operationId: get_action_openapi\n responses:\n '200':\n description: Album found\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/Album'\ncomponents:\n schemas:\n Album:\n type: object\n required:\n - title\n - rating\n properties:\n rating:\n type: integer\n format: int32\n minimum: 0\n title:\n type: string\n securitySchemes:\n api_key:\n type: apiKey\n in: header\n name: apikey\n jwt_token:\n type: http\n scheme: bearer\n bearerFormat: JWT\n", + "openapi: 3.1.0\ninfo:\n title: Loco Demo\n description: This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project.\n contact:\n name: Dotan Nahum\n email: dotan@rng0.io\n license:\n name: Apache-2.0\n version: 0.13.2\npaths:\n /album:\n get:\n operationId: get_action_openapi\n responses:\n '200':\n description: Album found\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/Album'\ncomponents:\n schemas:\n Album:\n type: object\n required:\n - title\n - rating\n properties:\n rating:\n type: integer\n format: int32\n minimum: 0\n title:\n type: string\n securitySchemes:\n api_key:\n type: apiKey\n in: header\n name: apikey\n jwt_token:\n type: http\n scheme: bearer\n bearerFormat: JWT\n", ) diff --git a/tests/controller/snapshots/openapi_spec_[scalar__openapi.json]@openapi.snap b/tests/controller/snapshots/openapi_spec_[scalar__openapi.json]@openapi.snap index 4243cbd18..cc1afc65e 100644 --- a/tests/controller/snapshots/openapi_spec_[scalar__openapi.json]@openapi.snap +++ b/tests/controller/snapshots/openapi_spec_[scalar__openapi.json]@openapi.snap @@ -5,5 +5,5 @@ expression: "(res.status().to_string(), res.url().to_string(), res.text().await. ( "200 OK", "http://localhost:5555/scalar/openapi.json", - "{\"openapi\":\"3.1.0\",\"info\":{\"title\":\"Loco Demo\",\"description\":\"This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project.\",\"contact\":{\"name\":\"Dotan Nahum\",\"email\":\"dotan@rng0.io\"},\"license\":{\"name\":\"Apache-2.0\"},\"version\":\"0.13.1\"},\"paths\":{\"/album\":{\"get\":{\"operationId\":\"get_action_openapi\",\"responses\":{\"200\":{\"description\":\"Album found\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Album\"}}}}}}}},\"components\":{\"schemas\":{\"Album\":{\"type\":\"object\",\"required\":[\"title\",\"rating\"],\"properties\":{\"rating\":{\"type\":\"integer\",\"format\":\"int32\",\"minimum\":0},\"title\":{\"type\":\"string\"}}}},\"securitySchemes\":{\"api_key\":{\"type\":\"apiKey\",\"in\":\"header\",\"name\":\"apikey\"},\"jwt_token\":{\"type\":\"http\",\"scheme\":\"bearer\",\"bearerFormat\":\"JWT\"}}}}", + "{\"openapi\":\"3.1.0\",\"info\":{\"title\":\"Loco Demo\",\"description\":\"This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project.\",\"contact\":{\"name\":\"Dotan Nahum\",\"email\":\"dotan@rng0.io\"},\"license\":{\"name\":\"Apache-2.0\"},\"version\":\"0.13.2\"},\"paths\":{\"/album\":{\"get\":{\"operationId\":\"get_action_openapi\",\"responses\":{\"200\":{\"description\":\"Album found\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Album\"}}}}}}}},\"components\":{\"schemas\":{\"Album\":{\"type\":\"object\",\"required\":[\"title\",\"rating\"],\"properties\":{\"rating\":{\"type\":\"integer\",\"format\":\"int32\",\"minimum\":0},\"title\":{\"type\":\"string\"}}}},\"securitySchemes\":{\"api_key\":{\"type\":\"apiKey\",\"in\":\"header\",\"name\":\"apikey\"},\"jwt_token\":{\"type\":\"http\",\"scheme\":\"bearer\",\"bearerFormat\":\"JWT\"}}}}", ) diff --git a/tests/controller/snapshots/openapi_spec_[scalar__openapi.yaml]@openapi.snap b/tests/controller/snapshots/openapi_spec_[scalar__openapi.yaml]@openapi.snap index ffe1847cd..3258fc558 100644 --- a/tests/controller/snapshots/openapi_spec_[scalar__openapi.yaml]@openapi.snap +++ b/tests/controller/snapshots/openapi_spec_[scalar__openapi.yaml]@openapi.snap @@ -5,5 +5,5 @@ expression: "(res.status().to_string(), res.url().to_string(), res.text().await. ( "200 OK", "http://localhost:5555/scalar/openapi.yaml", - "openapi: 3.1.0\ninfo:\n title: Loco Demo\n description: This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project.\n contact:\n name: Dotan Nahum\n email: dotan@rng0.io\n license:\n name: Apache-2.0\n version: 0.13.1\npaths:\n /album:\n get:\n operationId: get_action_openapi\n responses:\n '200':\n description: Album found\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/Album'\ncomponents:\n schemas:\n Album:\n type: object\n required:\n - title\n - rating\n properties:\n rating:\n type: integer\n format: int32\n minimum: 0\n title:\n type: string\n securitySchemes:\n api_key:\n type: apiKey\n in: header\n name: apikey\n jwt_token:\n type: http\n scheme: bearer\n bearerFormat: JWT\n", + "openapi: 3.1.0\ninfo:\n title: Loco Demo\n description: This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project.\n contact:\n name: Dotan Nahum\n email: dotan@rng0.io\n license:\n name: Apache-2.0\n version: 0.13.2\npaths:\n /album:\n get:\n operationId: get_action_openapi\n responses:\n '200':\n description: Album found\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/Album'\ncomponents:\n schemas:\n Album:\n type: object\n required:\n - title\n - rating\n properties:\n rating:\n type: integer\n format: int32\n minimum: 0\n title:\n type: string\n securitySchemes:\n api_key:\n type: apiKey\n in: header\n name: apikey\n jwt_token:\n type: http\n scheme: bearer\n bearerFormat: JWT\n", ) From 00abce3261fc5ab0dd9f3a8da2b5ae8f88ca50aa Mon Sep 17 00:00:00 2001 From: NexVeridian Date: Thu, 28 Nov 2024 09:23:07 -0800 Subject: [PATCH 19/28] split feature openapi into feature swagger-ui redoc scalar, extract some out of app_routes --- Cargo.toml | 12 ++- src/app.rs | 6 +- src/auth/mod.rs | 6 +- src/config.rs | 24 +++++- src/controller/app_routes.rs | 112 +++++++++++-------------- src/controller/middleware/remote_ip.rs | 4 +- src/controller/mod.rs | 10 ++- src/controller/openapi.rs | 45 ++++++++++ src/controller/routes.rs | 37 ++++++-- src/environment.rs | 8 +- src/tests_cfg/config.rs | 2 +- src/tests_cfg/db.rs | 8 +- tests/controller/mod.rs | 2 +- tests/infra_cfg/server.rs | 10 +-- 14 files changed, 185 insertions(+), 101 deletions(-) create mode 100644 src/controller/openapi.rs diff --git a/Cargo.toml b/Cargo.toml index aff074d99..dac32ccbb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,13 +35,11 @@ auth_jwt = ["dep:jsonwebtoken"] cli = ["dep:clap"] testing = ["dep:axum-test"] with-db = ["dep:sea-orm", "dep:sea-orm-migration", "loco-gen/with-db"] -openapi = [ - "dep:utoipa", - "dep:utoipa-axum", - "dep:utoipa-swagger-ui", - "dep:utoipa-redoc", - "dep:utoipa-scalar", -] +# OpenAPI features +all_openapi = ["openapi_swagger", "openapi_redoc", "openapi_scalar"] +openapi_swagger = ["dep:utoipa", "dep:utoipa-axum", "dep:utoipa-swagger-ui"] +openapi_redoc = ["dep:utoipa", "dep:utoipa-axum", "dep:utoipa-redoc"] +openapi_scalar = ["dep:utoipa", "dep:utoipa-axum", "dep:utoipa-scalar"] # Storage features all_storage = ["storage_aws_s3", "storage_azure", "storage_gcp"] storage_aws_s3 = ["object_store/aws"] diff --git a/src/app.rs b/src/app.rs index 8d141c3a1..89c3a318e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -245,7 +245,11 @@ pub trait Hooks: Send { /// ApiDoc::openapi() /// } /// ``` - #[cfg(feature = "openapi")] + #[cfg(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + feature = "openapi_scalar" + ))] #[must_use] fn inital_openapi_spec(_ctx: &AppContext) -> utoipa::openapi::OpenApi; } diff --git a/src/auth/mod.rs b/src/auth/mod.rs index ffcce5a29..214d8d36b 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -1,4 +1,8 @@ #[cfg(feature = "auth_jwt")] pub mod jwt; -#[cfg(feature = "openapi")] +#[cfg(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + feature = "openapi_scalar" +))] pub mod openapi; diff --git a/src/config.rs b/src/config.rs index 3440378f3..5093d40b3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -415,7 +415,11 @@ pub struct Server { #[serde(default)] pub middlewares: middleware::Config, /// OpenAPI configuration - #[cfg(feature = "openapi")] + #[cfg(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + feature = "openapi_scalar" + ))] pub openapi: OpenAPI, } @@ -431,7 +435,11 @@ impl Server { } /// OpenAPI configuration -#[cfg(feature = "openapi")] +#[cfg(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + feature = "openapi_scalar" +))] #[derive(Debug, Clone, Deserialize, Serialize)] pub struct OpenAPI { /// Redoc configuration @@ -442,7 +450,11 @@ pub struct OpenAPI { pub swagger: OpenAPIType, } -#[cfg(feature = "openapi")] +#[cfg(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + feature = "openapi_scalar" +))] #[derive(Debug, Clone, Deserialize, Serialize)] pub enum OpenAPIType { Redoc { @@ -471,7 +483,11 @@ pub enum OpenAPIType { }, } -#[cfg(feature = "openapi")] +#[cfg(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + feature = "openapi_scalar" +))] impl OpenAPIType { pub fn url(&self) -> &String { match self { diff --git a/src/controller/app_routes.rs b/src/controller/app_routes.rs index 351d4769e..8055a22aa 100644 --- a/src/controller/app_routes.rs +++ b/src/controller/app_routes.rs @@ -4,20 +4,20 @@ use std::{fmt, sync::OnceLock}; -#[cfg(feature = "openapi")] -use axum::routing::get; use axum::Router as AXRouter; use regex::Regex; -#[cfg(feature = "openapi")] -use utoipa::openapi::OpenApi; -#[cfg(feature = "openapi")] -use utoipa_axum::router::{OpenApiRouter, UtoipaMethodRouterExt}; -#[cfg(feature = "openapi")] +#[cfg(feature = "openapi_redoc")] use utoipa_redoc::{Redoc, Servable}; -#[cfg(feature = "openapi")] +#[cfg(feature = "openapi_scalar")] use utoipa_scalar::{Scalar, Servable as ScalarServable}; -#[cfg(feature = "openapi")] +#[cfg(feature = "openapi_swagger")] use utoipa_swagger_ui::SwaggerUi; +#[cfg(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + feature = "openapi_scalar" +))] +use utoipa_axum::router::{OpenApiRouter, UtoipaMethodRouterExt}; use crate::{ app::{AppContext, Hooks}, @@ -27,10 +27,14 @@ use crate::{ }, Result, }; -#[cfg(feature = "openapi")] +#[cfg(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + feature = "openapi_scalar" +))] use crate::{ config::OpenAPIType, - controller::{format, Response}, + controller::openapi, }; static NORMALIZE_URL: OnceLock = OnceLock::new(); @@ -39,19 +43,6 @@ fn get_normalize_url() -> &'static Regex { NORMALIZE_URL.get_or_init(|| Regex::new(r"/+").unwrap()) } -#[cfg(feature = "openapi")] -static OPENAPI_SPEC: OnceLock = OnceLock::new(); - -#[cfg(feature = "openapi")] -fn set_openapi_spec(api: OpenApi) -> &'static OpenApi { - OPENAPI_SPEC.get_or_init(|| api) -} - -#[cfg(feature = "openapi")] -fn get_openapi_spec() -> &'static OpenApi { - OPENAPI_SPEC.get().unwrap() -} - /// Represents the routes of the application. #[derive(Clone)] pub struct AppRoutes { @@ -222,7 +213,11 @@ impl AppRoutes { // using the router directly, and ServiceBuilder has been reported to give // issues in compile times itself (https://github.com/rust-lang/crates.io/pull/7443). // - #[cfg(feature = "openapi")] + #[cfg(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + feature = "openapi_scalar" + ))] let mut api_router: OpenApiRouter = OpenApiRouter::with_openapi(H::inital_openapi_spec(&ctx)); @@ -232,7 +227,11 @@ impl AppRoutes { LocoMethodRouter::Axum(method) => { app = app.route(&router.uri, method); } - #[cfg(feature = "openapi")] + #[cfg(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + feature = "openapi_scalar" + ))] LocoMethodRouter::Utoipa(method) => { app = app.route(&router.uri, method.2.clone()); api_router = api_router.routes(method.with_state(ctx.clone())); @@ -240,12 +239,20 @@ impl AppRoutes { } } - #[cfg(feature = "openapi")] + #[cfg(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + feature = "openapi_scalar" + ))] let (_, api) = api_router.split_for_parts(); - #[cfg(feature = "openapi")] - set_openapi_spec(api); - - #[cfg(feature = "openapi")] + #[cfg(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + feature = "openapi_scalar" + ))] + openapi::set_openapi_spec(api); + + #[cfg(feature = "openapi_redoc")] { if let OpenAPIType::Redoc { url, @@ -253,12 +260,12 @@ impl AppRoutes { spec_yaml_url, } = ctx.config.server.openapi.redoc.clone() { - app = app.merge(Redoc::with_url(url, get_openapi_spec().clone())); - app = add_openapi_endpoints(app, spec_json_url, spec_yaml_url); + app = app.merge(Redoc::with_url(url, openapi::get_openapi_spec().clone())); + app = openapi::add_openapi_endpoints(app, spec_json_url, spec_yaml_url); } } - #[cfg(feature = "openapi")] + #[cfg(feature = "openapi_scalar")] { if let OpenAPIType::Scalar { url, @@ -266,12 +273,12 @@ impl AppRoutes { spec_yaml_url, } = ctx.config.server.openapi.scalar.clone() { - app = app.merge(Scalar::with_url(url, get_openapi_spec().clone())); - app = add_openapi_endpoints(app, spec_json_url, spec_yaml_url); + app = app.merge(Scalar::with_url(url, openapi::get_openapi_spec().clone())); + app = openapi::add_openapi_endpoints(app, spec_json_url, spec_yaml_url); } } - #[cfg(feature = "openapi")] + #[cfg(feature = "openapi_swagger")] { if let OpenAPIType::Swagger { url, @@ -279,8 +286,10 @@ impl AppRoutes { spec_yaml_url, } = ctx.config.server.openapi.swagger.clone() { - app = app.merge(SwaggerUi::new(url).url(spec_json_url, get_openapi_spec().clone())); - app = add_openapi_endpoints(app, None, spec_yaml_url); + app = app.merge( + SwaggerUi::new(url).url(spec_json_url, openapi::get_openapi_spec().clone()), + ); + app = openapi::add_openapi_endpoints(app, None, spec_yaml_url); } } @@ -294,31 +303,6 @@ impl AppRoutes { } } -#[cfg(feature = "openapi")] -async fn openapi_spec_json() -> Result { - format::json(get_openapi_spec()) -} - -#[cfg(feature = "openapi")] -async fn openapi_spec_yaml() -> Result { - format::text(&get_openapi_spec().to_yaml()?) -} - -#[cfg(feature = "openapi")] -fn add_openapi_endpoints( - mut app: AXRouter, - json_url: Option, - yaml_url: Option, -) -> AXRouter { - if let Some(json_url) = json_url { - app = app.route(&json_url, get(openapi_spec_json)); - } - if let Some(yaml_url) = yaml_url { - app = app.route(&yaml_url, get(openapi_spec_yaml)); - } - app -} - #[cfg(test)] mod tests { diff --git a/src/controller/middleware/remote_ip.rs b/src/controller/middleware/remote_ip.rs index ce2415918..9419c9193 100644 --- a/src/controller/middleware/remote_ip.rs +++ b/src/controller/middleware/remote_ip.rs @@ -151,7 +151,7 @@ fn maybe_get_forwarded( let forwarded = xffs.join(","); - return forwarded + forwarded .split(',') .map(str::trim) .map(str::parse) @@ -179,7 +179,7 @@ fn maybe_get_forwarded( > The first trustworthy X-Forwarded-For IP address may belong to an untrusted intermediate > proxy rather than the actual client computer, but it is the only IP suitable for security uses. */ - .next_back(); + .next_back() } #[derive(Copy, Clone, Debug)] diff --git a/src/controller/mod.rs b/src/controller/mod.rs index 54c6f9870..417edd6fa 100644 --- a/src/controller/mod.rs +++ b/src/controller/mod.rs @@ -42,11 +42,11 @@ //! AppRoutes::with_default_routes() //! // .add_route(controllers::notes::routes()) //! } -//! +//! //! async fn boot(mode: StartMode, environment: &Environment) -> Result{ //! create_app::(mode, environment).await //! } -//! +//! //! async fn connect_workers(_ctx: &AppContext, _queue: &Queue) -> Result<()> { //! Ok(()) //! } @@ -83,6 +83,12 @@ pub mod format; #[cfg(feature = "with-db")] mod health; pub mod middleware; +#[cfg(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + feature = "openapi_scalar" +))] +mod openapi; mod ping; mod routes; pub mod views; diff --git a/src/controller/openapi.rs b/src/controller/openapi.rs new file mode 100644 index 000000000..53a21f1f9 --- /dev/null +++ b/src/controller/openapi.rs @@ -0,0 +1,45 @@ +use std::sync::OnceLock; + +use axum::{routing::get, Router as AXRouter}; +use utoipa::openapi::OpenApi; + +use crate::{ + app::AppContext, + controller::{ + format, + Response, + }, + Result, +}; + +static OPENAPI_SPEC: OnceLock = OnceLock::new(); + +pub fn set_openapi_spec(api: OpenApi) -> &'static OpenApi { + OPENAPI_SPEC.get_or_init(|| api) +} + +pub fn get_openapi_spec() -> &'static OpenApi { + OPENAPI_SPEC.get().unwrap() +} + +pub async fn openapi_spec_json() -> Result { + format::json(get_openapi_spec()) +} + +pub async fn openapi_spec_yaml() -> Result { + format::text(&get_openapi_spec().to_yaml()?) +} + +pub fn add_openapi_endpoints( + mut app: AXRouter, + json_url: Option, + yaml_url: Option, +) -> AXRouter { + if let Some(json_url) = json_url { + app = app.route(&json_url, get(openapi_spec_json)); + } + if let Some(yaml_url) = yaml_url { + app = app.route(&yaml_url, get(openapi_spec_yaml)); + } + app +} diff --git a/src/controller/routes.rs b/src/controller/routes.rs index 743e89593..d4aef0ae4 100644 --- a/src/controller/routes.rs +++ b/src/controller/routes.rs @@ -6,7 +6,11 @@ use axum::{ routing::{MethodRouter, Route}, }; use tower::{Layer, Service}; -#[cfg(feature = "openapi")] +#[cfg(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + feature = "openapi_scalar" +))] use utoipa_axum::router::{UtoipaMethodRouter, UtoipaMethodRouterExt}; use super::describe; @@ -22,7 +26,11 @@ pub struct Routes { #[derive(Clone)] pub enum LocoMethodRouter { Axum(MethodRouter), - #[cfg(feature = "openapi")] + #[cfg(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + feature = "openapi_scalar" + ))] Utoipa(UtoipaMethodRouter), } @@ -61,7 +69,6 @@ impl Routes { /// format::json(Health { ok: true }) /// } /// Routes::at("status").add("/_ping", get(ping)); - /// /// ```` #[must_use] pub fn at(prefix: &str) -> Self { @@ -96,7 +103,11 @@ impl Routes { let method = method.into(); let actions = match &method { LocoMethodRouter::Axum(m) => describe::method_action(m), - #[cfg(feature = "openapi")] + #[cfg(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + feature = "openapi_scalar" + ))] LocoMethodRouter::Utoipa(m) => describe::method_action(&m.2), }; @@ -181,7 +192,11 @@ impl fmt::Debug for LocoMethodRouter { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Axum(router) => write!(f, "{:?}", router), - #[cfg(feature = "openapi")] + #[cfg(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + feature = "openapi_scalar" + ))] Self::Utoipa(router) => { // Get the axum::routing::MethodRouter from the UtoipaMethodRouter wrapper write!(f, "{:?}", router.2) @@ -201,7 +216,11 @@ impl LocoMethodRouter { { match self { LocoMethodRouter::Axum(router) => LocoMethodRouter::Axum(router.layer(layer)), - #[cfg(feature = "openapi")] + #[cfg(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + feature = "openapi_scalar" + ))] LocoMethodRouter::Utoipa(router) => LocoMethodRouter::Utoipa(router.layer(layer)), } } @@ -213,7 +232,11 @@ impl From> for LocoMethodRouter { } } -#[cfg(feature = "openapi")] +#[cfg(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + feature = "openapi_scalar" +))] impl From> for LocoMethodRouter { fn from(router: UtoipaMethodRouter) -> Self { LocoMethodRouter::Utoipa(router) diff --git a/src/environment.rs b/src/environment.rs index 08c8c8dec..246989acf 100644 --- a/src/environment.rs +++ b/src/environment.rs @@ -131,14 +131,18 @@ mod tests { } #[test] - #[cfg(not(feature = "openapi"))] + #[cfg(not(feature = "all_openapi"))] fn test_from_folder() { let config = Environment::Development.load_from_folder(Path::new("examples/demo/config")); assert!(config.is_ok()); } #[test] - #[cfg(feature = "openapi")] + #[cfg(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + feature = "openapi_scalar" + ))] fn test_from_folder_openapi() { let config = Environment::Any("OpenAPI".to_string()) .load_from_folder(Path::new("examples/demo/config")); diff --git a/src/tests_cfg/config.rs b/src/tests_cfg/config.rs index 871c6aea1..4b8db803a 100644 --- a/src/tests_cfg/config.rs +++ b/src/tests_cfg/config.rs @@ -23,7 +23,7 @@ pub fn test_config() -> Config { host: "localhost".to_string(), ident: None, middlewares: middleware::Config::default(), - #[cfg(feature = "openapi")] + #[cfg(feature = "all_openapi")] openapi: config::OpenAPI { redoc: config::OpenAPIType::Redoc { url: "/redoc".to_string(), diff --git a/src/tests_cfg/db.rs b/src/tests_cfg/db.rs index 86f5f72f3..ae6d10500 100644 --- a/src/tests_cfg/db.rs +++ b/src/tests_cfg/db.rs @@ -3,10 +3,10 @@ use std::path::Path; use async_trait::async_trait; use sea_orm::DatabaseConnection; pub use sea_orm_migration::prelude::*; - -#[cfg(feature = "openapi")] +#[cfg(feature = "all_openapi")] use utoipa::OpenApi; -#[cfg(feature = "openapi")] + +#[cfg(feature = "all_openapi")] use crate::auth::openapi::{set_jwt_location, SecurityAddon}; use crate::{ app::{AppContext, Hooks, Initializer}, @@ -123,7 +123,7 @@ impl Hooks for AppHook { Ok(()) } - #[cfg(feature = "openapi")] + #[cfg(feature = "all_openapi")] fn inital_openapi_spec(ctx: &AppContext) -> utoipa::openapi::OpenApi { set_jwt_location(ctx); diff --git a/tests/controller/mod.rs b/tests/controller/mod.rs index 93c6a08c1..c46b8bf4d 100644 --- a/tests/controller/mod.rs +++ b/tests/controller/mod.rs @@ -1,3 +1,3 @@ mod middlewares; -#[cfg(feature = "openapi")] +#[cfg(feature = "all_openapi")] mod openapi; diff --git a/tests/infra_cfg/server.rs b/tests/infra_cfg/server.rs index 6709dd006..f1ebda077 100644 --- a/tests/infra_cfg/server.rs +++ b/tests/infra_cfg/server.rs @@ -7,7 +7,7 @@ //! hardcoded ports and bindings. use loco_rs::{boot, controller::AppRoutes, prelude::*, tests_cfg::db::AppHook}; -#[cfg(feature = "openapi")] +#[cfg(feature = "all_openapi")] use {serde::Serialize, utoipa::ToSchema, utoipa_axum::routes}; /// The port on which the test server will run. @@ -31,14 +31,14 @@ async fn post_action(_body: axum::body::Bytes) -> Result { format::render().text("text response") } -#[cfg(feature = "openapi")] +#[cfg(feature = "all_openapi")] #[derive(Serialize, Debug, ToSchema)] pub struct Album { title: String, rating: u32, } -#[cfg(feature = "openapi")] +#[cfg(feature = "all_openapi")] #[utoipa::path( get, path = "/album", @@ -81,11 +81,11 @@ pub async fn start_from_boot(boot_result: boot::BootResult) -> tokio::task::Join pub async fn start_from_ctx(ctx: AppContext) -> tokio::task::JoinHandle<()> { let app_router = AppRoutes::empty() .add_route( - #[cfg(not(feature = "openapi"))] + #[cfg(not(feature = "all_openapi"))] Routes::new() .add("/", get(get_action)) .add("/", post(post_action)), - #[cfg(feature = "openapi")] + #[cfg(feature = "all_openapi")] Routes::new() .add("/", get(get_action)) .add("/", post(post_action)) From 2e7e750b91c0c956b17b4b315684dbbd65fe939f Mon Sep 17 00:00:00 2001 From: NexVeridian Date: Sun, 1 Dec 2024 16:53:46 -0800 Subject: [PATCH 20/28] move OpenAPIType.url --- src/config.rs | 15 --------------- tests/controller/openapi.rs | 16 +++++++++++++++- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/src/config.rs b/src/config.rs index 5093d40b3..66ef53e9e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -483,21 +483,6 @@ pub enum OpenAPIType { }, } -#[cfg(any( - feature = "openapi_swagger", - feature = "openapi_redoc", - feature = "openapi_scalar" -))] -impl OpenAPIType { - pub fn url(&self) -> &String { - match self { - OpenAPIType::Redoc { url, .. } - | OpenAPIType::Scalar { url, .. } - | OpenAPIType::Swagger { url, .. } => url, - } - } -} - /// Background worker configuration /// Example (development): /// ```yaml diff --git a/tests/controller/openapi.rs b/tests/controller/openapi.rs index 677b56320..c6475964c 100644 --- a/tests/controller/openapi.rs +++ b/tests/controller/openapi.rs @@ -1,6 +1,6 @@ use insta::assert_debug_snapshot; use loco_rs::{ - config::{Auth, JWTLocation, JWT}, + config::{Auth, JWTLocation, OpenAPIType, JWT}, prelude::*, tests_cfg, }; @@ -18,6 +18,20 @@ macro_rules! configure_insta { }; } +trait OpenAPITrait { + fn url(&self) -> &String; +} + +impl OpenAPITrait for OpenAPIType { + fn url(&self) -> &String { + match self { + OpenAPIType::Redoc { url, .. } + | OpenAPIType::Scalar { url, .. } + | OpenAPIType::Swagger { url, .. } => url, + } + } +} + #[rstest] #[case("/redoc")] #[case("/scalar")] From e27be7fa7802a663e7c8174477acbc045fd87c21 Mon Sep 17 00:00:00 2001 From: NexVeridian Date: Sun, 1 Dec 2024 18:24:47 -0800 Subject: [PATCH 21/28] drop test for JWT_LOCATION.get_or_init, not possible with cargo test --- src/auth/openapi.rs | 27 ++++++---- src/tests_cfg/db.rs | 4 +- tests/controller/openapi.rs | 51 +------------------ .../openapi_security_[Cookie]@openapi.snap | 9 ---- .../openapi_security_[Query]@openapi.snap | 9 ---- 5 files changed, 20 insertions(+), 80 deletions(-) delete mode 100644 tests/controller/snapshots/openapi_security_[Cookie]@openapi.snap delete mode 100644 tests/controller/snapshots/openapi_security_[Query]@openapi.snap diff --git a/src/auth/openapi.rs b/src/auth/openapi.rs index 0d4ff992d..5b5b931ab 100644 --- a/src/auth/openapi.rs +++ b/src/auth/openapi.rs @@ -1,4 +1,5 @@ use std::sync::OnceLock; + use utoipa::{ openapi::security::{ApiKey, ApiKeyValue, HttpAuthScheme, HttpBuilder, SecurityScheme}, Modify, @@ -8,16 +9,22 @@ use crate::{app::AppContext, config::JWTLocation}; static JWT_LOCATION: OnceLock = OnceLock::new(); -pub fn set_jwt_location(ctx: &AppContext) -> &'static JWTLocation { - JWT_LOCATION.get_or_init(|| { - ctx.config - .auth - .as_ref() - .and_then(|auth| auth.jwt.as_ref()) - .and_then(|jwt| jwt.location.as_ref()) - .unwrap_or(&JWTLocation::Bearer) - .clone() - }) +pub fn get_jwt_location_from_ctx(ctx: &AppContext) -> JWTLocation { + ctx.config + .auth + .as_ref() + .and_then(|auth| auth.jwt.as_ref()) + .and_then(|jwt| jwt.location.as_ref()) + .unwrap_or(&JWTLocation::Bearer) + .clone() +} + +pub fn set_jwt_location_ctx(ctx: &AppContext) -> &'static JWTLocation { + set_jwt_location(get_jwt_location_from_ctx(ctx)) +} + +pub fn set_jwt_location(jwt_location: JWTLocation) -> &'static JWTLocation { + JWT_LOCATION.get_or_init(|| jwt_location) } fn get_jwt_location() -> &'static JWTLocation { diff --git a/src/tests_cfg/db.rs b/src/tests_cfg/db.rs index ae6d10500..d77db55c2 100644 --- a/src/tests_cfg/db.rs +++ b/src/tests_cfg/db.rs @@ -7,7 +7,7 @@ pub use sea_orm_migration::prelude::*; use utoipa::OpenApi; #[cfg(feature = "all_openapi")] -use crate::auth::openapi::{set_jwt_location, SecurityAddon}; +use crate::auth::openapi::{set_jwt_location_ctx, SecurityAddon}; use crate::{ app::{AppContext, Hooks, Initializer}, bgworker::Queue, @@ -125,7 +125,7 @@ impl Hooks for AppHook { #[cfg(feature = "all_openapi")] fn inital_openapi_spec(ctx: &AppContext) -> utoipa::openapi::OpenApi { - set_jwt_location(ctx); + set_jwt_location_ctx(ctx); #[derive(OpenApi)] #[openapi( diff --git a/tests/controller/openapi.rs b/tests/controller/openapi.rs index c6475964c..77e3ca44e 100644 --- a/tests/controller/openapi.rs +++ b/tests/controller/openapi.rs @@ -1,9 +1,5 @@ use insta::assert_debug_snapshot; -use loco_rs::{ - config::{Auth, JWTLocation, OpenAPIType, JWT}, - prelude::*, - tests_cfg, -}; +use loco_rs::{config::OpenAPIType, prelude::*, tests_cfg}; use rstest::rstest; use serial_test::serial; @@ -116,48 +112,3 @@ async fn openapi_spec(#[case] test_name: &str) { handle.abort(); } - -#[rstest] -#[case(JWTLocation::Query { name: "JWT".to_string() })] -#[case(JWTLocation::Cookie { name: "JWT".to_string() })] -#[tokio::test] -#[serial] -async fn openapi_security(#[case] location: JWTLocation) { - configure_insta!(); - - let mut ctx: AppContext = tests_cfg::app::get_app_context().await; - ctx.config.auth = Some(Auth { - jwt: Some(JWT { - location: Some(location.clone()), - secret: "PqRwLF2rhHe8J22oBeHy".to_string(), - expiration: 604800, - }), - }); - - let handle = infra_cfg::server::start_from_ctx(ctx).await; - - let res = reqwest::Client::new() - .request( - reqwest::Method::GET, - infra_cfg::server::get_base_url() + "api-docs/openapi.json", - ) - .send() - .await - .expect("valid response"); - - let test_name = match location { - JWTLocation::Query { .. } => "Query", - JWTLocation::Cookie { .. } => "Cookie", - _ => "Bearer", - }; - assert_debug_snapshot!( - format!("openapi_security_[{test_name}]"), - ( - res.status().to_string(), - res.url().to_string(), - res.text().await.unwrap(), - ) - ); - - handle.abort(); -} diff --git a/tests/controller/snapshots/openapi_security_[Cookie]@openapi.snap b/tests/controller/snapshots/openapi_security_[Cookie]@openapi.snap deleted file mode 100644 index 339e18539..000000000 --- a/tests/controller/snapshots/openapi_security_[Cookie]@openapi.snap +++ /dev/null @@ -1,9 +0,0 @@ ---- -source: tests/controller/openapi.rs -expression: "(res.status().to_string(), res.url().to_string(), res.text().await.unwrap(),)" ---- -( - "200 OK", - "http://localhost:5555/api-docs/openapi.json", - "{\"openapi\":\"3.1.0\",\"info\":{\"title\":\"Loco Demo\",\"description\":\"This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project.\",\"contact\":{\"name\":\"Dotan Nahum\",\"email\":\"dotan@rng0.io\"},\"license\":{\"name\":\"Apache-2.0\"},\"version\":\"0.13.2\"},\"paths\":{\"/album\":{\"get\":{\"operationId\":\"get_action_openapi\",\"responses\":{\"200\":{\"description\":\"Album found\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Album\"}}}}}}}},\"components\":{\"schemas\":{\"Album\":{\"type\":\"object\",\"required\":[\"title\",\"rating\"],\"properties\":{\"rating\":{\"type\":\"integer\",\"format\":\"int32\",\"minimum\":0},\"title\":{\"type\":\"string\"}}}},\"securitySchemes\":{\"api_key\":{\"type\":\"apiKey\",\"in\":\"header\",\"name\":\"apikey\"},\"jwt_token\":{\"type\":\"apiKey\",\"in\":\"cookie\",\"name\":\"JWT\"}}}}", -) diff --git a/tests/controller/snapshots/openapi_security_[Query]@openapi.snap b/tests/controller/snapshots/openapi_security_[Query]@openapi.snap deleted file mode 100644 index a20ba2eaf..000000000 --- a/tests/controller/snapshots/openapi_security_[Query]@openapi.snap +++ /dev/null @@ -1,9 +0,0 @@ ---- -source: tests/controller/openapi.rs -expression: "(res.status().to_string(), res.url().to_string(), res.text().await.unwrap(),)" ---- -( - "200 OK", - "http://localhost:5555/api-docs/openapi.json", - "{\"openapi\":\"3.1.0\",\"info\":{\"title\":\"Loco Demo\",\"description\":\"This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project.\",\"contact\":{\"name\":\"Dotan Nahum\",\"email\":\"dotan@rng0.io\"},\"license\":{\"name\":\"Apache-2.0\"},\"version\":\"0.13.2\"},\"paths\":{\"/album\":{\"get\":{\"operationId\":\"get_action_openapi\",\"responses\":{\"200\":{\"description\":\"Album found\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Album\"}}}}}}}},\"components\":{\"schemas\":{\"Album\":{\"type\":\"object\",\"required\":[\"title\",\"rating\"],\"properties\":{\"rating\":{\"type\":\"integer\",\"format\":\"int32\",\"minimum\":0},\"title\":{\"type\":\"string\"}}}},\"securitySchemes\":{\"api_key\":{\"type\":\"apiKey\",\"in\":\"header\",\"name\":\"apikey\"},\"jwt_token\":{\"type\":\"apiKey\",\"in\":\"query\",\"name\":\"JWT\"}}}}", -) From b848348811faa804e41404e9f56fc3de4ae5399d Mon Sep 17 00:00:00 2001 From: NexVeridian Date: Sun, 1 Dec 2024 18:50:44 -0800 Subject: [PATCH 22/28] rstest feature flagged cases --- src/controller/app_routes.rs | 28 +++++++++++----------------- src/environment.rs | 6 +++++- src/tests_cfg/config.rs | 6 +++++- src/tests_cfg/db.rs | 18 +++++++++++++++--- tests/controller/mod.rs | 6 +++++- tests/controller/openapi.rs | 35 ++++++++++++++++++++++++++--------- tests/infra_cfg/server.rs | 30 +++++++++++++++++++++++++----- 7 files changed, 92 insertions(+), 37 deletions(-) diff --git a/src/controller/app_routes.rs b/src/controller/app_routes.rs index 8055a22aa..85badb46f 100644 --- a/src/controller/app_routes.rs +++ b/src/controller/app_routes.rs @@ -6,18 +6,18 @@ use std::{fmt, sync::OnceLock}; use axum::Router as AXRouter; use regex::Regex; -#[cfg(feature = "openapi_redoc")] -use utoipa_redoc::{Redoc, Servable}; -#[cfg(feature = "openapi_scalar")] -use utoipa_scalar::{Scalar, Servable as ScalarServable}; -#[cfg(feature = "openapi_swagger")] -use utoipa_swagger_ui::SwaggerUi; #[cfg(any( feature = "openapi_swagger", feature = "openapi_redoc", feature = "openapi_scalar" ))] use utoipa_axum::router::{OpenApiRouter, UtoipaMethodRouterExt}; +#[cfg(feature = "openapi_redoc")] +use utoipa_redoc::{Redoc, Servable}; +#[cfg(feature = "openapi_scalar")] +use utoipa_scalar::{Scalar, Servable as ScalarServable}; +#[cfg(feature = "openapi_swagger")] +use utoipa_swagger_ui::SwaggerUi; use crate::{ app::{AppContext, Hooks}, @@ -32,10 +32,7 @@ use crate::{ feature = "openapi_redoc", feature = "openapi_scalar" ))] -use crate::{ - config::OpenAPIType, - controller::openapi, -}; +use crate::{config::OpenAPIType, controller::openapi}; static NORMALIZE_URL: OnceLock = OnceLock::new(); @@ -244,13 +241,10 @@ impl AppRoutes { feature = "openapi_redoc", feature = "openapi_scalar" ))] - let (_, api) = api_router.split_for_parts(); - #[cfg(any( - feature = "openapi_swagger", - feature = "openapi_redoc", - feature = "openapi_scalar" - ))] - openapi::set_openapi_spec(api); + { + let (_, api) = api_router.split_for_parts(); + openapi::set_openapi_spec(api); + } #[cfg(feature = "openapi_redoc")] { diff --git a/src/environment.rs b/src/environment.rs index 246989acf..2aa1ee146 100644 --- a/src/environment.rs +++ b/src/environment.rs @@ -131,7 +131,11 @@ mod tests { } #[test] - #[cfg(not(feature = "all_openapi"))] + #[cfg(not(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + feature = "openapi_scalar" + )))] fn test_from_folder() { let config = Environment::Development.load_from_folder(Path::new("examples/demo/config")); assert!(config.is_ok()); diff --git a/src/tests_cfg/config.rs b/src/tests_cfg/config.rs index 4b8db803a..67e1e8acd 100644 --- a/src/tests_cfg/config.rs +++ b/src/tests_cfg/config.rs @@ -23,7 +23,11 @@ pub fn test_config() -> Config { host: "localhost".to_string(), ident: None, middlewares: middleware::Config::default(), - #[cfg(feature = "all_openapi")] + #[cfg(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + feature = "openapi_scalar" + ))] openapi: config::OpenAPI { redoc: config::OpenAPIType::Redoc { url: "/redoc".to_string(), diff --git a/src/tests_cfg/db.rs b/src/tests_cfg/db.rs index d77db55c2..9c79fc62a 100644 --- a/src/tests_cfg/db.rs +++ b/src/tests_cfg/db.rs @@ -3,10 +3,18 @@ use std::path::Path; use async_trait::async_trait; use sea_orm::DatabaseConnection; pub use sea_orm_migration::prelude::*; -#[cfg(feature = "all_openapi")] +#[cfg(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + feature = "openapi_scalar" +))] use utoipa::OpenApi; -#[cfg(feature = "all_openapi")] +#[cfg(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + feature = "openapi_scalar" +))] use crate::auth::openapi::{set_jwt_location_ctx, SecurityAddon}; use crate::{ app::{AppContext, Hooks, Initializer}, @@ -123,7 +131,11 @@ impl Hooks for AppHook { Ok(()) } - #[cfg(feature = "all_openapi")] + #[cfg(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + feature = "openapi_scalar" + ))] fn inital_openapi_spec(ctx: &AppContext) -> utoipa::openapi::OpenApi { set_jwt_location_ctx(ctx); diff --git a/tests/controller/mod.rs b/tests/controller/mod.rs index c46b8bf4d..baedd334e 100644 --- a/tests/controller/mod.rs +++ b/tests/controller/mod.rs @@ -1,3 +1,7 @@ mod middlewares; -#[cfg(feature = "all_openapi")] +#[cfg(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + feature = "openapi_scalar" +))] mod openapi; diff --git a/tests/controller/openapi.rs b/tests/controller/openapi.rs index 77e3ca44e..21bbb7746 100644 --- a/tests/controller/openapi.rs +++ b/tests/controller/openapi.rs @@ -29,12 +29,16 @@ impl OpenAPITrait for OpenAPIType { } #[rstest] -#[case("/redoc")] -#[case("/scalar")] -#[case("/swagger-ui")] +#[cfg_attr(feature = "openapi_swagger", case("/swagger-ui"))] +#[cfg_attr(feature = "openapi_redoc", case("/redoc"))] +#[cfg_attr(feature = "openapi_scalar", case("/scalar"))] +#[case("")] #[tokio::test] #[serial] async fn openapi(#[case] mut test_name: &str) { + if test_name.is_empty() { + return; + } configure_insta!(); let ctx: AppContext = tests_cfg::app::get_app_context().await; @@ -77,15 +81,28 @@ async fn openapi(#[case] mut test_name: &str) { } #[rstest] -#[case("redoc/openapi.json")] -#[case("scalar/openapi.json")] -#[case("api-docs/openapi.json")] -#[case("redoc/openapi.yaml")] -#[case("scalar/openapi.yaml")] -#[case("api-docs/openapi.yaml")] +#[cfg_attr( + feature = "openapi_swagger", + case("api-docs/openapi.json"), + case("api-docs/openapi.yaml") +)] +#[cfg_attr( + feature = "openapi_redoc", + case("redoc/openapi.json"), + case("redoc/openapi.yaml") +)] +#[cfg_attr( + feature = "openapi_scalar", + case("scalar/openapi.json"), + case("scalar/openapi.yaml") +)] +#[case("")] #[tokio::test] #[serial] async fn openapi_spec(#[case] test_name: &str) { + if test_name.is_empty() { + return; + } configure_insta!(); let ctx: AppContext = tests_cfg::app::get_app_context().await; diff --git a/tests/infra_cfg/server.rs b/tests/infra_cfg/server.rs index f1ebda077..1d09a3498 100644 --- a/tests/infra_cfg/server.rs +++ b/tests/infra_cfg/server.rs @@ -7,7 +7,11 @@ //! hardcoded ports and bindings. use loco_rs::{boot, controller::AppRoutes, prelude::*, tests_cfg::db::AppHook}; -#[cfg(feature = "all_openapi")] +#[cfg(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + feature = "openapi_scalar" +))] use {serde::Serialize, utoipa::ToSchema, utoipa_axum::routes}; /// The port on which the test server will run. @@ -31,14 +35,22 @@ async fn post_action(_body: axum::body::Bytes) -> Result { format::render().text("text response") } -#[cfg(feature = "all_openapi")] +#[cfg(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + feature = "openapi_scalar" +))] #[derive(Serialize, Debug, ToSchema)] pub struct Album { title: String, rating: u32, } -#[cfg(feature = "all_openapi")] +#[cfg(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + feature = "openapi_scalar" +))] #[utoipa::path( get, path = "/album", @@ -81,11 +93,19 @@ pub async fn start_from_boot(boot_result: boot::BootResult) -> tokio::task::Join pub async fn start_from_ctx(ctx: AppContext) -> tokio::task::JoinHandle<()> { let app_router = AppRoutes::empty() .add_route( - #[cfg(not(feature = "all_openapi"))] + #[cfg(not(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + feature = "openapi_scalar" + )))] Routes::new() .add("/", get(get_action)) .add("/", post(post_action)), - #[cfg(feature = "all_openapi")] + #[cfg(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + feature = "openapi_scalar" + ))] Routes::new() .add("/", get(get_action)) .add("/", post(post_action)) From 7a76f3a5da8d0d380caebbe8d36fbf771070bacb Mon Sep 17 00:00:00 2001 From: NexVeridian Date: Tue, 3 Dec 2024 15:14:35 -0800 Subject: [PATCH 23/28] some docs --- src/auth/openapi.rs | 1 + src/config.rs | 25 ++++++++++++++++++++++--- src/controller/app_routes.rs | 2 ++ src/controller/openapi.rs | 8 ++++---- src/controller/routes.rs | 31 ++++++++++++++++++++++++++++++- 5 files changed, 59 insertions(+), 8 deletions(-) diff --git a/src/auth/openapi.rs b/src/auth/openapi.rs index 5b5b931ab..4923d9150 100644 --- a/src/auth/openapi.rs +++ b/src/auth/openapi.rs @@ -33,6 +33,7 @@ fn get_jwt_location() -> &'static JWTLocation { pub struct SecurityAddon; +/// Adds security to the OpenAPI doc, using the JWT location in the config impl Modify for SecurityAddon { fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { if let Some(components) = openapi.components.as_mut() { diff --git a/src/config.rs b/src/config.rs index 66ef53e9e..7e21ec800 100644 --- a/src/config.rs +++ b/src/config.rs @@ -443,10 +443,29 @@ impl Server { #[derive(Debug, Clone, Deserialize, Serialize)] pub struct OpenAPI { /// Redoc configuration + /// Example: + /// ```yaml + /// redoc: + /// !Redoc + /// url: /redoc + /// ``` pub redoc: OpenAPIType, /// Scalar configuration + /// Example: + /// ```yaml + /// scalar: + /// !Scalar + /// url: /scalar + /// ``` pub scalar: OpenAPIType, /// Swagger configuration + /// Example: + /// ```yaml + /// swagger: + /// !Swagger + /// url: /swagger + /// spec_json_url: /openapi.json + /// ``` pub swagger: OpenAPIType, } @@ -458,7 +477,7 @@ pub struct OpenAPI { #[derive(Debug, Clone, Deserialize, Serialize)] pub enum OpenAPIType { Redoc { - /// URL for where to host the redoc OpenAPI doc, example: /redoc + /// URL for where to host the redoc OpenAPI spec, example: /redoc url: String, /// URL for openapi.json, for example: /openapi.json spec_json_url: Option, @@ -466,7 +485,7 @@ pub enum OpenAPIType { spec_yaml_url: Option, }, Scalar { - /// URL for where to host the swagger OpenAPI doc, example: /scalar + /// URL for where to host the swagger OpenAPI spec, example: /scalar url: String, /// URL for openapi.json, for example: /openapi.json spec_json_url: Option, @@ -474,7 +493,7 @@ pub enum OpenAPIType { spec_yaml_url: Option, }, Swagger { - /// URL for where to host the swagger OpenAPI doc, example: /swagger-ui + /// URL for where to host the swagger OpenAPI spec, example: /swagger-ui url: String, /// URL for openapi.json, for example: /api-docs/openapi.json spec_json_url: String, diff --git a/src/controller/app_routes.rs b/src/controller/app_routes.rs index 85badb46f..fd3819df5 100644 --- a/src/controller/app_routes.rs +++ b/src/controller/app_routes.rs @@ -242,10 +242,12 @@ impl AppRoutes { feature = "openapi_scalar" ))] { + // Collect the OpenAPI spec let (_, api) = api_router.split_for_parts(); openapi::set_openapi_spec(api); } + // Serve the OpenAPI spec using the enabled OpenAPI visualizers #[cfg(feature = "openapi_redoc")] { if let OpenAPIType::Redoc { diff --git a/src/controller/openapi.rs b/src/controller/openapi.rs index 53a21f1f9..38123c714 100644 --- a/src/controller/openapi.rs +++ b/src/controller/openapi.rs @@ -5,10 +5,7 @@ use utoipa::openapi::OpenApi; use crate::{ app::AppContext, - controller::{ - format, - Response, - }, + controller::{format, Response}, Result, }; @@ -22,14 +19,17 @@ pub fn get_openapi_spec() -> &'static OpenApi { OPENAPI_SPEC.get().unwrap() } +/// Axum handler that returns the OpenAPI spec as JSON pub async fn openapi_spec_json() -> Result { format::json(get_openapi_spec()) } +/// Axum handler that returns the OpenAPI spec as YAML pub async fn openapi_spec_yaml() -> Result { format::text(&get_openapi_spec().to_yaml()?) } +/// Adds the OpenAPI endpoints the app router pub fn add_openapi_endpoints( mut app: AXRouter, json_url: Option, diff --git a/src/controller/routes.rs b/src/controller/routes.rs index d4aef0ae4..6f5bcad32 100644 --- a/src/controller/routes.rs +++ b/src/controller/routes.rs @@ -97,7 +97,36 @@ impl Routes { /// format::json(Health { ok: true }) /// } /// Routes::new().add("/_ping", get(ping)); - /// ```` + /// ``` + /// + /// ## Adding a endpoint, and add it to the OpenAPI documentation + /// ```rust ignore + /// use loco_rs::prelude::*; + /// use serde::Serialize; + /// use utoipa::ToSchema; + /// use utoipa_axum::routes; + /// + /// #[derive(Serialize, ToSchema)] + /// struct Health { + /// pub ok: bool, + /// } + /// + /// /// Ping + /// /// + /// /// This endpoint is used to check the health of the service. + /// #[utoipa::path( + /// get, + /// tag = "Health", + /// path = "/_ping", + /// responses( + /// (status = 200, body = Health), + /// ), + /// )] + /// async fn ping() -> Result { + /// format::json(Health { ok: true }) + /// } + /// Routes::new().add("/_ping", routes!(ping)); + /// ``` #[must_use] pub fn add(mut self, uri: &str, method: impl Into) -> Self { let method = method.into(); From 162ae444c633047cf7d6cd7dc02f1c31984815d7 Mon Sep 17 00:00:00 2001 From: NexVeridian Date: Tue, 3 Dec 2024 15:25:07 -0800 Subject: [PATCH 24/28] clippy --- src/controller/routes.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/controller/routes.rs b/src/controller/routes.rs index 6f5bcad32..557038b45 100644 --- a/src/controller/routes.rs +++ b/src/controller/routes.rs @@ -99,7 +99,7 @@ impl Routes { /// Routes::new().add("/_ping", get(ping)); /// ``` /// - /// ## Adding a endpoint, and add it to the OpenAPI documentation + /// ## Adding a endpoint, and add it to the `OpenAPI` documentation /// ```rust ignore /// use loco_rs::prelude::*; /// use serde::Serialize; @@ -220,7 +220,7 @@ impl Routes { impl fmt::Debug for LocoMethodRouter { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Axum(router) => write!(f, "{:?}", router), + Self::Axum(router) => write!(f, "{router:?}"), #[cfg(any( feature = "openapi_swagger", feature = "openapi_redoc", @@ -244,7 +244,7 @@ impl LocoMethodRouter { >::Future: Send + 'static, { match self { - LocoMethodRouter::Axum(router) => LocoMethodRouter::Axum(router.layer(layer)), + Self::Axum(router) => Self::Axum(router.layer(layer)), #[cfg(any( feature = "openapi_swagger", feature = "openapi_redoc", @@ -257,7 +257,7 @@ impl LocoMethodRouter { impl From> for LocoMethodRouter { fn from(router: MethodRouter) -> Self { - LocoMethodRouter::Axum(router) + Self::Axum(router) } } From e1a64a6d9e50e743dad88a5ae89e7feac96e98a0 Mon Sep 17 00:00:00 2001 From: NexVeridian Date: Fri, 13 Dec 2024 14:38:27 -0800 Subject: [PATCH 25/28] docs: docs-site OpenAPI --- docs-site/content/docs/the-app/controller.md | 113 +++++++++++++++++++ src/app.rs | 2 +- 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/docs-site/content/docs/the-app/controller.md b/docs-site/content/docs/the-app/controller.md index c4f4b6233..80b133e24 100644 --- a/docs-site/content/docs/the-app/controller.md +++ b/docs-site/content/docs/the-app/controller.md @@ -759,6 +759,119 @@ impl Hooks for App { } ``` +# OpenAPI Integration Setup +The Loco OpenAPI integration is generated using [`Utopia`](https://github.com/juhaku/utoipa) + +## `Cargo.toml` features flages +Edit your `Cargo.toml` file and add one or multiple of the following features flages: +- `swagger-ui` +- `redoc` +- `scalar` +- `all_openapi` + +```toml +loco-rs = { version = "0.13", features = ["scalar"] } +``` + +## Configuration +Add the corresponding OpenAPI visualizer to the config file +```yaml +#... +server: + ... + openapi: + redoc: + !Redoc + url: /redoc + # spec_json_url: /redoc/openapi.json + # spec_yaml_url: /redoc/openapi.yaml + scalar: + !Scalar + url: /scalar + # spec_json_url: /scalar/openapi.json + # spec_yaml_url: /scalar/openapi.yaml + swagger: + !Swagger + url: /swagger-ui + spec_json_url: /api-docs/openapi.json # spec_json_url is required for swagger-ui + # spec_yaml_url: /api-docs/openapi.yaml +``` +## Inital OpenAPI Spec +Modifies the OpenAPI spec before the routes are added, allowing you to edit [openapi::info](https://docs.rs/utoipa/latest/utoipa/openapi/info/struct.Info.html) + +```rust +// src/app.rs +use utoipa::OpenApi; +use loco_rs::auth::openapi::{set_jwt_location_ctx, SecurityAddon}; + +impl Hooks for App { + #... + fn inital_openapi_spec(ctx: &AppContext) -> utoipa::openapi::OpenApi { + set_jwt_location_ctx(ctx); + + #[derive(OpenApi)] + #[openapi( + modifiers(&SecurityAddon), + info( + title = "Loco Demo", + description = "This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project." + ) + )] + struct ApiDoc; + ApiDoc::openapi() + } +``` + +## Generating the OpenAPI spec for a route +Only routes that are annotated with `utoipa::path` will be included in the OpenAPI spec. + +```rust +#[utoipa::path( + get, + path = "/album", + responses( + (status = 200, description = "Album found", body = Album), + ), +)] +async fn get_action_openapi() -> Result { + format::json(Album { + title: "VH II".to_string(), + rating: 10, + }) +} +``` + +Make sure to add `#[derive(ToSchema)]` on any struct that included in `utoipa::path`. +```rust +use utoipa::ToSchema; + +#[derive(Serialize, Debug, ToSchema)] +pub struct Album { + title: String, + rating: u32, +} +``` + +## Adding routes to the OpenAPI spec visualizer +Swap the `axum::routing::MethodFilter` to `routes!` +### Before +```rust +Routes::new() + .add("/album", get(get_action_openapi)), +``` +### After +```rust +use utoipa_axum::routes; + +Routes::new() + .add("/album", routes!(get_action_openapi)), +``` +### Note: do not add multiple routes inside the `routes!` macro +```rust +Routes::new() + .add("/album", routes!(get_action_1_do_not_do_this, get_action_2_do_not_do_this)), +``` + # Pagination In many scenarios, when querying data and returning responses to users, pagination is crucial. In `Loco`, we provide a straightforward method to paginate your data and maintain a consistent pagination response schema for your API responses. diff --git a/src/app.rs b/src/app.rs index 89c3a318e..ba5e1c7a0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -214,7 +214,7 @@ pub trait Hooks: Send { /// actions before the application stops completely. async fn on_shutdown(_ctx: &AppContext) {} - /// Modify the OpenAPI spec before the routes are added, allowing you to edit (openapi::info)[https://docs.rs/utoipa/latest/utoipa/openapi/info/struct.Info.html] + /// Modifies the OpenAPI spec before the routes are added, allowing you to edit [openapi::info](https://docs.rs/utoipa/latest/utoipa/openapi/info/struct.Info.html) /// # Examples /// ```rust ignore /// fn inital_openapi_spec(_ctx: &AppContext) -> utoipa::openapi::OpenApi { From 8872a10c1c8c1ef937b1baef25646660664f5ce2 Mon Sep 17 00:00:00 2001 From: NexVeridian Date: Fri, 13 Dec 2024 17:10:58 -0800 Subject: [PATCH 26/28] docs: SecurityAddon --- docs-site/content/docs/the-app/controller.md | 23 +++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/docs-site/content/docs/the-app/controller.md b/docs-site/content/docs/the-app/controller.md index 80b133e24..72f2d01af 100644 --- a/docs-site/content/docs/the-app/controller.md +++ b/docs-site/content/docs/the-app/controller.md @@ -797,7 +797,7 @@ server: # spec_yaml_url: /api-docs/openapi.yaml ``` ## Inital OpenAPI Spec -Modifies the OpenAPI spec before the routes are added, allowing you to edit [openapi::info](https://docs.rs/utoipa/latest/utoipa/openapi/info/struct.Info.html) +Modifies the OpenAPI spec before the routes are added, allowing you to edit [`openapi::info`](https://docs.rs/utoipa/latest/utoipa/openapi/info/struct.Info.html) ```rust // src/app.rs @@ -823,7 +823,7 @@ impl Hooks for App { ``` ## Generating the OpenAPI spec for a route -Only routes that are annotated with `utoipa::path` will be included in the OpenAPI spec. +Only routes that are annotated with [`utoipa::path`](https://docs.rs/utoipa/latest/utoipa/attr.path.html) will be included in the OpenAPI spec. ```rust #[utoipa::path( @@ -841,7 +841,7 @@ async fn get_action_openapi() -> Result { } ``` -Make sure to add `#[derive(ToSchema)]` on any struct that included in `utoipa::path`. +Make sure to add `#[derive(ToSchema)]` on any struct that included in [`utoipa::path`](https://docs.rs/utoipa/latest/utoipa/attr.path.html). ```rust use utoipa::ToSchema; @@ -852,6 +852,23 @@ pub struct Album { } ``` +If `modifiers(&SecurityAddon)` is set in `inital_openapi_spec`, you can document the per route security in `utoipa::path`: +- `security(("jwt_token" = []))` +- `security(("api_key" = []))` +- or leave blank to remove security from the route `security(())` + +Example: +```rust +#[utoipa::path( + get, + path = "/album", + security(("jwt_token" = [])), + responses( + (status = 200, description = "Album found", body = Album), + ), +)] +``` + ## Adding routes to the OpenAPI spec visualizer Swap the `axum::routing::MethodFilter` to `routes!` ### Before From e352e882bb2b0f2a5d858c166a750761abfc7396 Mon Sep 17 00:00:00 2001 From: NexVeridian Date: Sun, 15 Dec 2024 19:35:58 -0800 Subject: [PATCH 27/28] feat: format::yaml --- src/controller/format.rs | 30 +++++++++++++++++++ src/controller/openapi.rs | 2 +- ...__format__tests__yaml_response_format.snap | 14 +++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 src/controller/snapshots/loco_rs__controller__format__tests__yaml_response_format.snap diff --git a/src/controller/format.rs b/src/controller/format.rs index 5d10900fe..e08e7d415 100644 --- a/src/controller/format.rs +++ b/src/controller/format.rs @@ -140,6 +140,27 @@ pub fn html(content: &str) -> Result { Ok(Html(content.to_string()).into_response()) } +/// Returns a YAML response +/// +/// # Example: +/// +/// ```rust +/// pub async fn openapi_spec_yaml() -> Result { +/// format::yaml(&get_openapi_spec().to_yaml()?) +/// } +/// ``` +/// +/// # Errors +/// +/// Currently this function doesn't return any error. this is for feature +/// functionality +pub fn yaml(content: &str) -> Result { + Ok(Builder::new() + .header(header::CONTENT_TYPE, "application/yaml") + .body(Body::from(content.to_string()))? + .into_response()) +} + /// Returns an redirect response /// /// # Example: @@ -441,6 +462,15 @@ mod tests { assert_eq!(response_body_to_string(response).await, response_content); } + #[tokio::test] + async fn yaml_response_format() { + let response_content: &str = "openapi: 3.1.0\ninfo:\n title: Loco Demo\n "; + let response = yaml(response_content).unwrap(); + + assert_debug_snapshot!(response); + assert_eq!(response_body_to_string(response).await, response_content); + } + #[tokio::test] async fn redirect_response() { let response = redirect("https://loco.rs").unwrap(); diff --git a/src/controller/openapi.rs b/src/controller/openapi.rs index 38123c714..cea8bcab1 100644 --- a/src/controller/openapi.rs +++ b/src/controller/openapi.rs @@ -26,7 +26,7 @@ pub async fn openapi_spec_json() -> Result { /// Axum handler that returns the OpenAPI spec as YAML pub async fn openapi_spec_yaml() -> Result { - format::text(&get_openapi_spec().to_yaml()?) + format::yaml(&get_openapi_spec().to_yaml()?) } /// Adds the OpenAPI endpoints the app router diff --git a/src/controller/snapshots/loco_rs__controller__format__tests__yaml_response_format.snap b/src/controller/snapshots/loco_rs__controller__format__tests__yaml_response_format.snap new file mode 100644 index 000000000..49dc44b2e --- /dev/null +++ b/src/controller/snapshots/loco_rs__controller__format__tests__yaml_response_format.snap @@ -0,0 +1,14 @@ +--- +source: src/controller/format.rs +expression: response +--- +Response { + status: 200, + version: HTTP/1.1, + headers: { + "content-type": "application/yaml", + }, + body: Body( + UnsyncBoxBody, + ), +} From dc97291c73c1396a830a3c420470e0e878d414a7 Mon Sep 17 00:00:00 2001 From: NexVeridian Date: Sun, 15 Dec 2024 19:46:06 -0800 Subject: [PATCH 28/28] tests: add headers content-type to snapshots --- tests/controller/openapi.rs | 1 + .../openapi_spec_[api-docs__openapi.json]@openapi.snap | 3 ++- .../openapi_spec_[api-docs__openapi.yaml]@openapi.snap | 3 ++- .../snapshots/openapi_spec_[redoc__openapi.json]@openapi.snap | 3 ++- .../snapshots/openapi_spec_[redoc__openapi.yaml]@openapi.snap | 3 ++- .../snapshots/openapi_spec_[scalar__openapi.json]@openapi.snap | 3 ++- .../snapshots/openapi_spec_[scalar__openapi.yaml]@openapi.snap | 3 ++- 7 files changed, 13 insertions(+), 6 deletions(-) diff --git a/tests/controller/openapi.rs b/tests/controller/openapi.rs index 21bbb7746..900532368 100644 --- a/tests/controller/openapi.rs +++ b/tests/controller/openapi.rs @@ -123,6 +123,7 @@ async fn openapi_spec(#[case] test_name: &str) { ( res.status().to_string(), res.url().to_string(), + res.headers().get("content-type").unwrap().to_owned(), res.text().await.unwrap(), ) ); diff --git a/tests/controller/snapshots/openapi_spec_[api-docs__openapi.json]@openapi.snap b/tests/controller/snapshots/openapi_spec_[api-docs__openapi.json]@openapi.snap index 7defe700e..c3494ef85 100644 --- a/tests/controller/snapshots/openapi_spec_[api-docs__openapi.json]@openapi.snap +++ b/tests/controller/snapshots/openapi_spec_[api-docs__openapi.json]@openapi.snap @@ -1,9 +1,10 @@ --- source: tests/controller/openapi.rs -expression: "(res.status().to_string(), res.url().to_string(), res.text().await.unwrap(),)" +expression: "(res.status().to_string(), res.url().to_string(),\nres.headers().get(\"content-type\").unwrap().to_owned(),\nres.text().await.unwrap(),)" --- ( "200 OK", "http://localhost:5555/api-docs/openapi.json", + "application/json", "{\"openapi\":\"3.1.0\",\"info\":{\"title\":\"Loco Demo\",\"description\":\"This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project.\",\"contact\":{\"name\":\"Dotan Nahum\",\"email\":\"dotan@rng0.io\"},\"license\":{\"name\":\"Apache-2.0\"},\"version\":\"0.13.2\"},\"paths\":{\"/album\":{\"get\":{\"operationId\":\"get_action_openapi\",\"responses\":{\"200\":{\"description\":\"Album found\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Album\"}}}}}}}},\"components\":{\"schemas\":{\"Album\":{\"type\":\"object\",\"required\":[\"title\",\"rating\"],\"properties\":{\"rating\":{\"type\":\"integer\",\"format\":\"int32\",\"minimum\":0},\"title\":{\"type\":\"string\"}}}},\"securitySchemes\":{\"api_key\":{\"type\":\"apiKey\",\"in\":\"header\",\"name\":\"apikey\"},\"jwt_token\":{\"type\":\"http\",\"scheme\":\"bearer\",\"bearerFormat\":\"JWT\"}}}}", ) diff --git a/tests/controller/snapshots/openapi_spec_[api-docs__openapi.yaml]@openapi.snap b/tests/controller/snapshots/openapi_spec_[api-docs__openapi.yaml]@openapi.snap index 63d51e810..bf129dbbf 100644 --- a/tests/controller/snapshots/openapi_spec_[api-docs__openapi.yaml]@openapi.snap +++ b/tests/controller/snapshots/openapi_spec_[api-docs__openapi.yaml]@openapi.snap @@ -1,9 +1,10 @@ --- source: tests/controller/openapi.rs -expression: "(res.status().to_string(), res.url().to_string(), res.text().await.unwrap(),)" +expression: "(res.status().to_string(), res.url().to_string(),\nres.headers().get(\"content-type\").unwrap().to_owned(),\nres.text().await.unwrap(),)" --- ( "200 OK", "http://localhost:5555/api-docs/openapi.yaml", + "application/yaml", "openapi: 3.1.0\ninfo:\n title: Loco Demo\n description: This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project.\n contact:\n name: Dotan Nahum\n email: dotan@rng0.io\n license:\n name: Apache-2.0\n version: 0.13.2\npaths:\n /album:\n get:\n operationId: get_action_openapi\n responses:\n '200':\n description: Album found\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/Album'\ncomponents:\n schemas:\n Album:\n type: object\n required:\n - title\n - rating\n properties:\n rating:\n type: integer\n format: int32\n minimum: 0\n title:\n type: string\n securitySchemes:\n api_key:\n type: apiKey\n in: header\n name: apikey\n jwt_token:\n type: http\n scheme: bearer\n bearerFormat: JWT\n", ) diff --git a/tests/controller/snapshots/openapi_spec_[redoc__openapi.json]@openapi.snap b/tests/controller/snapshots/openapi_spec_[redoc__openapi.json]@openapi.snap index c5f499d57..57f842b7b 100644 --- a/tests/controller/snapshots/openapi_spec_[redoc__openapi.json]@openapi.snap +++ b/tests/controller/snapshots/openapi_spec_[redoc__openapi.json]@openapi.snap @@ -1,9 +1,10 @@ --- source: tests/controller/openapi.rs -expression: "(res.status().to_string(), res.url().to_string(), res.text().await.unwrap(),)" +expression: "(res.status().to_string(), res.url().to_string(),\nres.headers().get(\"content-type\").unwrap().to_owned(),\nres.text().await.unwrap(),)" --- ( "200 OK", "http://localhost:5555/redoc/openapi.json", + "application/json", "{\"openapi\":\"3.1.0\",\"info\":{\"title\":\"Loco Demo\",\"description\":\"This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project.\",\"contact\":{\"name\":\"Dotan Nahum\",\"email\":\"dotan@rng0.io\"},\"license\":{\"name\":\"Apache-2.0\"},\"version\":\"0.13.2\"},\"paths\":{\"/album\":{\"get\":{\"operationId\":\"get_action_openapi\",\"responses\":{\"200\":{\"description\":\"Album found\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Album\"}}}}}}}},\"components\":{\"schemas\":{\"Album\":{\"type\":\"object\",\"required\":[\"title\",\"rating\"],\"properties\":{\"rating\":{\"type\":\"integer\",\"format\":\"int32\",\"minimum\":0},\"title\":{\"type\":\"string\"}}}},\"securitySchemes\":{\"api_key\":{\"type\":\"apiKey\",\"in\":\"header\",\"name\":\"apikey\"},\"jwt_token\":{\"type\":\"http\",\"scheme\":\"bearer\",\"bearerFormat\":\"JWT\"}}}}", ) diff --git a/tests/controller/snapshots/openapi_spec_[redoc__openapi.yaml]@openapi.snap b/tests/controller/snapshots/openapi_spec_[redoc__openapi.yaml]@openapi.snap index 8d4d56411..a621a70e5 100644 --- a/tests/controller/snapshots/openapi_spec_[redoc__openapi.yaml]@openapi.snap +++ b/tests/controller/snapshots/openapi_spec_[redoc__openapi.yaml]@openapi.snap @@ -1,9 +1,10 @@ --- source: tests/controller/openapi.rs -expression: "(res.status().to_string(), res.url().to_string(), res.text().await.unwrap(),)" +expression: "(res.status().to_string(), res.url().to_string(),\nres.headers().get(\"content-type\").unwrap().to_owned(),\nres.text().await.unwrap(),)" --- ( "200 OK", "http://localhost:5555/redoc/openapi.yaml", + "application/yaml", "openapi: 3.1.0\ninfo:\n title: Loco Demo\n description: This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project.\n contact:\n name: Dotan Nahum\n email: dotan@rng0.io\n license:\n name: Apache-2.0\n version: 0.13.2\npaths:\n /album:\n get:\n operationId: get_action_openapi\n responses:\n '200':\n description: Album found\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/Album'\ncomponents:\n schemas:\n Album:\n type: object\n required:\n - title\n - rating\n properties:\n rating:\n type: integer\n format: int32\n minimum: 0\n title:\n type: string\n securitySchemes:\n api_key:\n type: apiKey\n in: header\n name: apikey\n jwt_token:\n type: http\n scheme: bearer\n bearerFormat: JWT\n", ) diff --git a/tests/controller/snapshots/openapi_spec_[scalar__openapi.json]@openapi.snap b/tests/controller/snapshots/openapi_spec_[scalar__openapi.json]@openapi.snap index cc1afc65e..75cc6eb5c 100644 --- a/tests/controller/snapshots/openapi_spec_[scalar__openapi.json]@openapi.snap +++ b/tests/controller/snapshots/openapi_spec_[scalar__openapi.json]@openapi.snap @@ -1,9 +1,10 @@ --- source: tests/controller/openapi.rs -expression: "(res.status().to_string(), res.url().to_string(), res.text().await.unwrap(),)" +expression: "(res.status().to_string(), res.url().to_string(),\nres.headers().get(\"content-type\").unwrap().to_owned(),\nres.text().await.unwrap(),)" --- ( "200 OK", "http://localhost:5555/scalar/openapi.json", + "application/json", "{\"openapi\":\"3.1.0\",\"info\":{\"title\":\"Loco Demo\",\"description\":\"This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project.\",\"contact\":{\"name\":\"Dotan Nahum\",\"email\":\"dotan@rng0.io\"},\"license\":{\"name\":\"Apache-2.0\"},\"version\":\"0.13.2\"},\"paths\":{\"/album\":{\"get\":{\"operationId\":\"get_action_openapi\",\"responses\":{\"200\":{\"description\":\"Album found\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Album\"}}}}}}}},\"components\":{\"schemas\":{\"Album\":{\"type\":\"object\",\"required\":[\"title\",\"rating\"],\"properties\":{\"rating\":{\"type\":\"integer\",\"format\":\"int32\",\"minimum\":0},\"title\":{\"type\":\"string\"}}}},\"securitySchemes\":{\"api_key\":{\"type\":\"apiKey\",\"in\":\"header\",\"name\":\"apikey\"},\"jwt_token\":{\"type\":\"http\",\"scheme\":\"bearer\",\"bearerFormat\":\"JWT\"}}}}", ) diff --git a/tests/controller/snapshots/openapi_spec_[scalar__openapi.yaml]@openapi.snap b/tests/controller/snapshots/openapi_spec_[scalar__openapi.yaml]@openapi.snap index 3258fc558..2e42cf28a 100644 --- a/tests/controller/snapshots/openapi_spec_[scalar__openapi.yaml]@openapi.snap +++ b/tests/controller/snapshots/openapi_spec_[scalar__openapi.yaml]@openapi.snap @@ -1,9 +1,10 @@ --- source: tests/controller/openapi.rs -expression: "(res.status().to_string(), res.url().to_string(), res.text().await.unwrap(),)" +expression: "(res.status().to_string(), res.url().to_string(),\nres.headers().get(\"content-type\").unwrap().to_owned(),\nres.text().await.unwrap(),)" --- ( "200 OK", "http://localhost:5555/scalar/openapi.yaml", + "application/yaml", "openapi: 3.1.0\ninfo:\n title: Loco Demo\n description: This app is a kitchensink for various capabilities and examples of the [Loco](https://loco.rs) project.\n contact:\n name: Dotan Nahum\n email: dotan@rng0.io\n license:\n name: Apache-2.0\n version: 0.13.2\npaths:\n /album:\n get:\n operationId: get_action_openapi\n responses:\n '200':\n description: Album found\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/Album'\ncomponents:\n schemas:\n Album:\n type: object\n required:\n - title\n - rating\n properties:\n rating:\n type: integer\n format: int32\n minimum: 0\n title:\n type: string\n securitySchemes:\n api_key:\n type: apiKey\n in: header\n name: apikey\n jwt_token:\n type: http\n scheme: bearer\n bearerFormat: JWT\n", )