diff --git a/Cargo.toml b/Cargo.toml index be586f6e0..347aeb9e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,11 @@ auth_jwt = ["dep:jsonwebtoken"] cli = ["dep:clap"] testing = ["dep:axum-test", "dep:scraper"] with-db = ["dep:sea-orm", "dep:sea-orm-migration", "loco-gen/with-db"] +# 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"] @@ -129,6 +134,16 @@ cfg-if = "1" uuid = { version = "1.10.0", features = ["v4", "fast-rng"] } +# OpenAPI +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", + "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/docs-site/content/docs/the-app/controller.md b/docs-site/content/docs/the-app/controller.md index c4f4b6233..72f2d01af 100644 --- a/docs-site/content/docs/the-app/controller.md +++ b/docs-site/content/docs/the-app/controller.md @@ -759,6 +759,136 @@ 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`](https://docs.rs/utoipa/latest/utoipa/attr.path.html) 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`](https://docs.rs/utoipa/latest/utoipa/attr.path.html). +```rust +use utoipa::ToSchema; + +#[derive(Serialize, Debug, ToSchema)] +pub struct Album { + title: String, + rating: u32, +} +``` + +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 +```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/examples/demo/config/OpenAPI.yaml b/examples/demo/config/OpenAPI.yaml new file mode 100644 index 000000000..80c7c8bd2 --- /dev/null +++ b/examples/demo/config/OpenAPI.yaml @@ -0,0 +1,170 @@ +# 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: + !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_yaml_url: /api-docs/openapi.yaml + +# 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/app.rs b/src/app.rs index 34cd6784f..ba5e1c7a0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -213,6 +213,45 @@ 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) {} + + /// 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 { + /// #[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(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." + /// ) + /// )] + /// struct ApiDoc; + /// ApiDoc::openapi() + /// } + /// ``` + #[cfg(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + feature = "openapi_scalar" + ))] + #[must_use] + fn inital_openapi_spec(_ctx: &AppContext) -> utoipa::openapi::OpenApi; } /// An initializer. diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 3114f8423..214d8d36b 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -1,2 +1,8 @@ #[cfg(feature = "auth_jwt")] pub mod jwt; +#[cfg(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + feature = "openapi_scalar" +))] +pub mod openapi; diff --git a/src/auth/openapi.rs b/src/auth/openapi.rs new file mode 100644 index 000000000..4923d9150 --- /dev/null +++ b/src/auth/openapi.rs @@ -0,0 +1,65 @@ +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 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 { + JWT_LOCATION.get().unwrap() +} + +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() { + 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/config.rs b/src/config.rs index 69d1a73e0..182e11b6a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -414,6 +414,13 @@ pub struct Server { /// logging, and error handling. #[serde(default)] pub middlewares: middleware::Config, + /// OpenAPI configuration + #[cfg(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + feature = "openapi_scalar" + ))] + pub openapi: OpenAPI, } fn default_binding() -> String { @@ -426,6 +433,75 @@ impl Server { format!("{}:{}", self.host, self.port) } } + +/// OpenAPI configuration +#[cfg(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + feature = "openapi_scalar" +))] +#[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, +} + +#[cfg(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + feature = "openapi_scalar" +))] +#[derive(Debug, Clone, Deserialize, Serialize)] +pub enum OpenAPIType { + 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, + /// URL for openapi.yaml, for example: /openapi.yaml + spec_yaml_url: Option, + }, + 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, + /// URL for openapi.yaml, for example: /openapi.yaml + spec_yaml_url: Option, + }, + Swagger { + /// 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, + /// URL for openapi.yaml, for example: /openapi.yaml + spec_yaml_url: Option, + }, +} + /// Background worker configuration /// Example (development): /// ```yaml diff --git a/src/controller/app_routes.rs b/src/controller/app_routes.rs index d9d812ade..fd3819df5 100644 --- a/src/controller/app_routes.rs +++ b/src/controller/app_routes.rs @@ -6,12 +6,33 @@ use std::{fmt, sync::OnceLock}; use axum::Router as AXRouter; use regex::Regex; +#[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}, - controller::{middleware::MiddlewareLayer, routes::Routes}, + controller::{ + middleware::MiddlewareLayer, + routes::{LocoMethodRouter, Routes}, + }, Result, }; +#[cfg(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + feature = "openapi_scalar" +))] +use crate::{config::OpenAPIType, controller::openapi}; static NORMALIZE_URL: OnceLock = OnceLock::new(); @@ -30,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 { @@ -189,9 +210,83 @@ 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(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + feature = "openapi_scalar" + ))] + let mut api_router: OpenApiRouter = + OpenApiRouter::with_openapi(H::inital_openapi_spec(&ctx)); + for router in self.collect() { tracing::info!("{}", router.to_string()); - app = app.route(&router.uri, router.method); + match router.method { + LocoMethodRouter::Axum(method) => { + app = app.route(&router.uri, method); + } + #[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())); + } + } + } + + #[cfg(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + 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 { + url, + spec_json_url, + spec_yaml_url, + } = ctx.config.server.openapi.redoc.clone() + { + 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_scalar")] + { + if let OpenAPIType::Scalar { + url, + spec_json_url, + spec_yaml_url, + } = ctx.config.server.openapi.scalar.clone() + { + 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_swagger")] + { + 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, openapi::get_openapi_spec().clone()), + ); + app = openapi::add_openapi_endpoints(app, None, spec_yaml_url); + } } let middlewares = self.middlewares::(&ctx); 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/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..cea8bcab1 --- /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() +} + +/// 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::yaml(&get_openapi_spec().to_yaml()?) +} + +/// Adds the OpenAPI endpoints the app router +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 fc482e739..557038b45 100644 --- a/src/controller/routes.rs +++ b/src/controller/routes.rs @@ -1,10 +1,21 @@ -use std::convert::Infallible; +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(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + feature = "openapi_scalar" +))] +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 +23,21 @@ pub struct Routes { // pub version: Option, } -#[derive(Clone, Default, Debug)] +#[derive(Clone)] +pub enum LocoMethodRouter { + Axum(MethodRouter), + #[cfg(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + feature = "openapi_scalar" + ))] + Utoipa(UtoipaMethodRouter), +} + +#[derive(Clone, Debug)] pub struct Handler { pub uri: String, - pub method: axum::routing::MethodRouter, + pub method: LocoMethodRouter, pub actions: Vec, } @@ -47,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 { @@ -76,13 +97,52 @@ 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: 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(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + feature = "openapi_scalar" + ))] + 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 +216,58 @@ 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(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) + } + } + } +} + +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 { + Self::Axum(router) => Self::Axum(router.layer(layer)), + #[cfg(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + feature = "openapi_scalar" + ))] + LocoMethodRouter::Utoipa(router) => LocoMethodRouter::Utoipa(router.layer(layer)), + } + } +} + +impl From> for LocoMethodRouter { + fn from(router: MethodRouter) -> Self { + Self::Axum(router) + } +} + +#[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/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, + ), +} diff --git a/src/environment.rs b/src/environment.rs index 52ea42b0b..74a847c1f 100644 --- a/src/environment.rs +++ b/src/environment.rs @@ -132,8 +132,25 @@ mod tests { } #[test] + #[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()); } + + #[test] + #[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")); + assert!(config.is_ok()); + } } diff --git a/src/tests_cfg/config.rs b/src/tests_cfg/config.rs index e27ab38a3..67e1e8acd 100644 --- a/src/tests_cfg/config.rs +++ b/src/tests_cfg/config.rs @@ -23,6 +23,28 @@ pub fn test_config() -> Config { host: "localhost".to_string(), ident: None, middlewares: middleware::Config::default(), + #[cfg(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + feature = "openapi_scalar" + ))] + openapi: config::OpenAPI { + 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()), + }, + }, }, #[cfg(feature = "with-db")] database: config::Database { diff --git a/src/tests_cfg/db.rs b/src/tests_cfg/db.rs index 136b60077..9c79fc62a 100644 --- a/src/tests_cfg/db.rs +++ b/src/tests_cfg/db.rs @@ -3,7 +3,19 @@ use std::path::Path; use async_trait::async_trait; use sea_orm::DatabaseConnection; pub use sea_orm_migration::prelude::*; - +#[cfg(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + feature = "openapi_scalar" +))] +use utoipa::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}, bgworker::Queue, @@ -118,4 +130,24 @@ impl Hooks for AppHook { async fn seed(_db: &DatabaseConnection, _base: &Path) -> Result<()> { Ok(()) } + + #[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); + + #[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() + } } diff --git a/tests/controller/mod.rs b/tests/controller/mod.rs index a350ef0c7..baedd334e 100644 --- a/tests/controller/mod.rs +++ b/tests/controller/mod.rs @@ -1 +1,7 @@ mod middlewares; +#[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 new file mode 100644 index 000000000..900532368 --- /dev/null +++ b/tests/controller/openapi.rs @@ -0,0 +1,132 @@ +use insta::assert_debug_snapshot; +use loco_rs::{config::OpenAPIType, 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(); + }; +} + +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] +#[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; + + 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.url(), test_name), + } + + 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::GET, + infra_cfg::server::get_base_url() + test_name, + ) + .send() + .await + .expect("valid response"); + + assert_debug_snapshot!( + format!("openapi_[{test_name}]"), + ( + res.status().to_string(), + res.url().to_string(), + res.text() + .await + .unwrap() + .lines() + .find(|line| line.contains("")) + .and_then(|line| { line.split("<title>").nth(1)?.split("").next() }) + .unwrap_or_default() + .to_string(), + ) + ); + + handle.abort(); +} + +#[rstest] +#[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; + + 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() + test_name, + ) + .send() + .await + .expect("valid response"); + + assert_debug_snapshot!( + format!("openapi_spec_[{test_name}]"), + ( + res.status().to_string(), + res.url().to_string(), + res.headers().get("content-type").unwrap().to_owned(), + res.text().await.unwrap(), + ) + ); + + handle.abort(); +} 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", +) 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 new file mode 100644 index 000000000..c3494ef85 --- /dev/null +++ b/tests/controller/snapshots/openapi_spec_[api-docs__openapi.json]@openapi.snap @@ -0,0 +1,10 @@ +--- +source: tests/controller/openapi.rs +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 new file mode 100644 index 000000000..bf129dbbf --- /dev/null +++ b/tests/controller/snapshots/openapi_spec_[api-docs__openapi.yaml]@openapi.snap @@ -0,0 +1,10 @@ +--- +source: tests/controller/openapi.rs +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 new file mode 100644 index 000000000..57f842b7b --- /dev/null +++ b/tests/controller/snapshots/openapi_spec_[redoc__openapi.json]@openapi.snap @@ -0,0 +1,10 @@ +--- +source: tests/controller/openapi.rs +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 new file mode 100644 index 000000000..a621a70e5 --- /dev/null +++ b/tests/controller/snapshots/openapi_spec_[redoc__openapi.yaml]@openapi.snap @@ -0,0 +1,10 @@ +--- +source: tests/controller/openapi.rs +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 new file mode 100644 index 000000000..75cc6eb5c --- /dev/null +++ b/tests/controller/snapshots/openapi_spec_[scalar__openapi.json]@openapi.snap @@ -0,0 +1,10 @@ +--- +source: tests/controller/openapi.rs +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 new file mode 100644 index 000000000..2e42cf28a --- /dev/null +++ b/tests/controller/snapshots/openapi_spec_[scalar__openapi.yaml]@openapi.snap @@ -0,0 +1,10 @@ +--- +source: tests/controller/openapi.rs +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", +) diff --git a/tests/infra_cfg/server.rs b/tests/infra_cfg/server.rs index 771d9088f..1d09a3498 100644 --- a/tests/infra_cfg/server.rs +++ b/tests/infra_cfg/server.rs @@ -7,6 +7,12 @@ //! hardcoded ports and bindings. use loco_rs::{boot, controller::AppRoutes, prelude::*, tests_cfg::db::AppHook}; +#[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. const TEST_PORT_SERVER: i32 = 5555; @@ -29,6 +35,36 @@ async fn post_action(_body: axum::body::Bytes) -> Result { format::render().text("text response") } +#[cfg(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + feature = "openapi_scalar" +))] +#[derive(Serialize, Debug, ToSchema)] +pub struct Album { + title: String, + rating: u32, +} + +#[cfg(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + feature = "openapi_scalar" +))] +#[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 +93,23 @@ 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(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + feature = "openapi_scalar" + )))] Routes::new() .add("/", get(get_action)) .add("/", post(post_action)), + #[cfg(any( + feature = "openapi_swagger", + feature = "openapi_redoc", + feature = "openapi_scalar" + ))] + Routes::new() + .add("/", get(get_action)) + .add("/", post(post_action)) + .add("/album", routes!(get_action_openapi)), ) .to_router::(ctx.clone(), axum::Router::new()) .expect("to router");