From d7e556167ec3e4cf27001721f3be2f90d4adfbdb Mon Sep 17 00:00:00 2001 From: photino Date: Mon, 4 Sep 2023 13:39:31 +0800 Subject: [PATCH] Improve the support for OpenAPI docs --- examples/actix-app/assets/docs/rapidoc.html | 15 ++ examples/actix-app/config/config.dev.toml | 3 + examples/actix-app/config/config.prod.toml | 3 + .../actix-app/config/openapi/OPENAPI.toml | 17 +++ examples/actix-app/config/openapi/auth.toml | 9 +- examples/actix-app/config/openapi/tag.toml | 9 +- examples/actix-app/config/openapi/user.toml | 10 ++ examples/actix-app/src/middleware/access.rs | 7 + examples/axum-app/assets/docs/rapidoc.html | 15 ++ examples/axum-app/config/config.dev.toml | 3 + examples/axum-app/config/config.prod.toml | 3 + examples/axum-app/config/openapi/OPENAPI.toml | 17 +++ examples/axum-app/config/openapi/auth.toml | 9 +- examples/axum-app/config/openapi/tag.toml | 7 + examples/axum-app/config/openapi/user.toml | 9 ++ examples/axum-app/src/middleware/access.rs | 2 +- zino-core/src/application/mod.rs | 9 +- zino-core/src/openapi/mod.rs | 120 ++++++++++++++++ zino-core/src/openapi/parser.rs | 131 ++++++++++++++++++ zino-model/src/user/jwt_auth.rs | 6 +- zino/Cargo.toml | 2 +- zino/src/application/actix_cluster.rs | 32 ++++- zino/src/application/axum_cluster.rs | 37 +++-- zino/src/lib.rs | 1 + 24 files changed, 446 insertions(+), 30 deletions(-) create mode 100644 examples/actix-app/assets/docs/rapidoc.html create mode 100644 examples/actix-app/config/openapi/OPENAPI.toml create mode 100644 examples/axum-app/assets/docs/rapidoc.html create mode 100644 examples/axum-app/config/openapi/OPENAPI.toml diff --git a/examples/actix-app/assets/docs/rapidoc.html b/examples/actix-app/assets/docs/rapidoc.html new file mode 100644 index 00000000..692b22be --- /dev/null +++ b/examples/actix-app/assets/docs/rapidoc.html @@ -0,0 +1,15 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/examples/actix-app/config/config.dev.toml b/examples/actix-app/config/config.dev.toml index 2a644409..994c2844 100644 --- a/examples/actix-app/config/config.dev.toml +++ b/examples/actix-app/config/config.dev.toml @@ -72,3 +72,6 @@ span = { "http.method" = "string", "http.target" = "string", "http.status_code" [connector.variables] app-name = "data-cube" + +[openapi] +custom-html = "assets/docs/rapidoc.html" diff --git a/examples/actix-app/config/config.prod.toml b/examples/actix-app/config/config.prod.toml index c63cd10f..ef6fb470 100644 --- a/examples/actix-app/config/config.prod.toml +++ b/examples/actix-app/config/config.prod.toml @@ -68,3 +68,6 @@ span = { "http.method" = "string", "http.target" = "string", "http.status_code" [connector.variables] app-name = "data-cube" + +[openapi] +show-docs = false diff --git a/examples/actix-app/config/openapi/OPENAPI.toml b/examples/actix-app/config/openapi/OPENAPI.toml new file mode 100644 index 00000000..7b1ea5de --- /dev/null +++ b/examples/actix-app/config/openapi/OPENAPI.toml @@ -0,0 +1,17 @@ +[info] +title = "DataCube API" +description = """ +An example for [actix-web] integration. + +[actix-web]: https://crates.io/crates/actix-web +""" +contact = { url = "https://github.com/photino/zino" } +license = "MIT" + +[security_schemes.jwt_auth] +type = "http" +scheme = "bearer" +bearer_format = "JWT" + +[[securities]] +name = "jwt_auth" diff --git a/examples/actix-app/config/openapi/auth.toml b/examples/actix-app/config/openapi/auth.toml index 0315ad60..ef2d564a 100644 --- a/examples/actix-app/config/openapi/auth.toml +++ b/examples/actix-app/config/openapi/auth.toml @@ -1,7 +1,10 @@ +name = "Authentication" + [[endpoints]] path = "/auth/login" method = "POST" -description = "Account login" +summary = "Account login" +securities = [] [endpoints.body] type = "object" @@ -11,9 +14,9 @@ password = { type = "string", format = "password", description = "User password" [[endpoints]] path = "/auth/refresh" method = "GET" -description = "Refresh access token" +summary = "Refreshes access token" [[endpoints]] path = "/auth/logout" method = "POST" -description = "Account logout" +summary = "Account logout" diff --git a/examples/actix-app/config/openapi/tag.toml b/examples/actix-app/config/openapi/tag.toml index 4066b87c..85ab2a43 100644 --- a/examples/actix-app/config/openapi/tag.toml +++ b/examples/actix-app/config/openapi/tag.toml @@ -1,6 +1,9 @@ +name = "Tags" + [[endpoints]] path = "/tag/new" method = "POST" +summary = "Creates a new tag" [endpoints.body] schema = "newTag" @@ -8,10 +11,12 @@ schema = "newTag" [[endpoints]] path = "/tag/{tag_id}/delete" method = "POST" +summary = "Deletes a tag by ID" [[endpoints]] path = "/tag/{tag_id}/update" method = "POST" +summary = "Updates a tag by ID" [endpoints.body] schema = "tagInfo" @@ -19,10 +24,12 @@ schema = "tagInfo" [[endpoints]] path = "/tag/{tag_id}/view" method = "GET" +summary = "Gets a tag by ID" [[endpoints]] path = "/tag/list" method = "GET" +summary = "Finds a list of tags" [endpoints.query] category = { type = "string", description = "Tag category" } @@ -58,4 +65,4 @@ translations = [ translations = [ ["Active", "😄"], ["Inactive", "😴"], -] \ No newline at end of file +] diff --git a/examples/actix-app/config/openapi/user.toml b/examples/actix-app/config/openapi/user.toml index 301577d5..cda203cf 100644 --- a/examples/actix-app/config/openapi/user.toml +++ b/examples/actix-app/config/openapi/user.toml @@ -1,6 +1,9 @@ +name = "Users" + [[endpoints]] path = "/user/new" method = "POST" +summary = "Creates a new user" [endpoints.body] schema = "newUser" @@ -8,10 +11,12 @@ schema = "newUser" [[endpoints]] path = "/user/{user_id}/delete" method = "POST" +summary = "Deletes a user by ID" [[endpoints]] path = "/user/{user_id}/update" method = "POST" +summary = "Updates a user by ID" [endpoints.body] schema = "userInfo" @@ -19,10 +24,12 @@ schema = "userInfo" [[endpoints]] path = "/user/{user_id}/view" method = "GET" +summary = "Gets a user by ID" [[endpoints]] path = "/user/list" method = "GET" +summary = "Finds a list of users" [endpoints.query] roles = { type = "string", description = "User roles" } @@ -31,6 +38,7 @@ tags = { type = "string", description = "User tags" } [[endpoints]] path = "/user/import" method = "POST" +summary = "Imports the user data" [endpoints.body] schema = "userData" @@ -38,6 +46,7 @@ schema = "userData" [[endpoints]] path = "/user/export" method = "GET" +summary = "Exports the user data" [endpoints.query] format = { type = "string", enum = ["csv", "json", "jsonlines", "msgpack"], default = "json", description = "File format" } @@ -100,3 +109,4 @@ translations = [ ["$span:24h", "Updated within 1 day"], ["$span:7d", "Updated within 1 week"], ] + diff --git a/examples/actix-app/src/middleware/access.rs b/examples/actix-app/src/middleware/access.rs index 95c1d589..49c1c882 100644 --- a/examples/actix-app/src/middleware/access.rs +++ b/examples/actix-app/src/middleware/access.rs @@ -51,6 +51,13 @@ where user_session.set_session_id(session_id); } req.set_data(user_session); + } else { + return Box::pin(async move { + let message = "401 Unauthorized: login is required"; + let rejection = Rejection::with_message(message).context(&req).into(); + let result: zino::Result = Err(rejection); + return result.map_err(|err| err.into()); + }); } let req = ServiceRequest::from(req); diff --git a/examples/axum-app/assets/docs/rapidoc.html b/examples/axum-app/assets/docs/rapidoc.html new file mode 100644 index 00000000..692b22be --- /dev/null +++ b/examples/axum-app/assets/docs/rapidoc.html @@ -0,0 +1,15 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/examples/axum-app/config/config.dev.toml b/examples/axum-app/config/config.dev.toml index 49bfdab8..563199ec 100644 --- a/examples/axum-app/config/config.dev.toml +++ b/examples/axum-app/config/config.dev.toml @@ -72,3 +72,6 @@ span = { "http.method" = "string", "http.target" = "string", "http.status_code" [connector.variables] app-name = "data-cube" + +[openapi] +custom-html = "assets/docs/rapidoc.html" diff --git a/examples/axum-app/config/config.prod.toml b/examples/axum-app/config/config.prod.toml index 50fc0d66..b3e1ade8 100644 --- a/examples/axum-app/config/config.prod.toml +++ b/examples/axum-app/config/config.prod.toml @@ -68,3 +68,6 @@ span = { "http.method" = "string", "http.target" = "string", "http.status_code" [connector.variables] app-name = "data-cube" + +[openapi] +show-docs = false diff --git a/examples/axum-app/config/openapi/OPENAPI.toml b/examples/axum-app/config/openapi/OPENAPI.toml new file mode 100644 index 00000000..c03045cc --- /dev/null +++ b/examples/axum-app/config/openapi/OPENAPI.toml @@ -0,0 +1,17 @@ +[info] +title = "DataCube API" +description = """ +An example for [axum] integration. + +[axum]: https://crates.io/crates/axum +""" +contact = { url = "https://github.com/photino/zino" } +license = "MIT" + +[security_schemes.jwt_auth] +type = "http" +scheme = "bearer" +bearer_format = "JWT" + +[[securities]] +name = "jwt_auth" diff --git a/examples/axum-app/config/openapi/auth.toml b/examples/axum-app/config/openapi/auth.toml index 0315ad60..ef2d564a 100644 --- a/examples/axum-app/config/openapi/auth.toml +++ b/examples/axum-app/config/openapi/auth.toml @@ -1,7 +1,10 @@ +name = "Authentication" + [[endpoints]] path = "/auth/login" method = "POST" -description = "Account login" +summary = "Account login" +securities = [] [endpoints.body] type = "object" @@ -11,9 +14,9 @@ password = { type = "string", format = "password", description = "User password" [[endpoints]] path = "/auth/refresh" method = "GET" -description = "Refresh access token" +summary = "Refreshes access token" [[endpoints]] path = "/auth/logout" method = "POST" -description = "Account logout" +summary = "Account logout" diff --git a/examples/axum-app/config/openapi/tag.toml b/examples/axum-app/config/openapi/tag.toml index 879ca398..85ab2a43 100644 --- a/examples/axum-app/config/openapi/tag.toml +++ b/examples/axum-app/config/openapi/tag.toml @@ -1,6 +1,9 @@ +name = "Tags" + [[endpoints]] path = "/tag/new" method = "POST" +summary = "Creates a new tag" [endpoints.body] schema = "newTag" @@ -8,10 +11,12 @@ schema = "newTag" [[endpoints]] path = "/tag/{tag_id}/delete" method = "POST" +summary = "Deletes a tag by ID" [[endpoints]] path = "/tag/{tag_id}/update" method = "POST" +summary = "Updates a tag by ID" [endpoints.body] schema = "tagInfo" @@ -19,10 +24,12 @@ schema = "tagInfo" [[endpoints]] path = "/tag/{tag_id}/view" method = "GET" +summary = "Gets a tag by ID" [[endpoints]] path = "/tag/list" method = "GET" +summary = "Finds a list of tags" [endpoints.query] category = { type = "string", description = "Tag category" } diff --git a/examples/axum-app/config/openapi/user.toml b/examples/axum-app/config/openapi/user.toml index b7067033..cda203cf 100644 --- a/examples/axum-app/config/openapi/user.toml +++ b/examples/axum-app/config/openapi/user.toml @@ -1,6 +1,9 @@ +name = "Users" + [[endpoints]] path = "/user/new" method = "POST" +summary = "Creates a new user" [endpoints.body] schema = "newUser" @@ -8,10 +11,12 @@ schema = "newUser" [[endpoints]] path = "/user/{user_id}/delete" method = "POST" +summary = "Deletes a user by ID" [[endpoints]] path = "/user/{user_id}/update" method = "POST" +summary = "Updates a user by ID" [endpoints.body] schema = "userInfo" @@ -19,10 +24,12 @@ schema = "userInfo" [[endpoints]] path = "/user/{user_id}/view" method = "GET" +summary = "Gets a user by ID" [[endpoints]] path = "/user/list" method = "GET" +summary = "Finds a list of users" [endpoints.query] roles = { type = "string", description = "User roles" } @@ -31,6 +38,7 @@ tags = { type = "string", description = "User tags" } [[endpoints]] path = "/user/import" method = "POST" +summary = "Imports the user data" [endpoints.body] schema = "userData" @@ -38,6 +46,7 @@ schema = "userData" [[endpoints]] path = "/user/export" method = "GET" +summary = "Exports the user data" [endpoints.query] format = { type = "string", enum = ["csv", "json", "jsonlines", "msgpack"], default = "json", description = "File format" } diff --git a/examples/axum-app/src/middleware/access.rs b/examples/axum-app/src/middleware/access.rs index bd8e1fac..204a1c59 100644 --- a/examples/axum-app/src/middleware/access.rs +++ b/examples/axum-app/src/middleware/access.rs @@ -19,7 +19,7 @@ pub async fn init_user_session(mut req: Request, next: Next) -> Result reject!(req, unauthorized, err), } - } else if req.request_method() == "POST" { + } else { reject!(req, unauthorized, "login is required"); } Ok(next.run(req.into()).await) diff --git a/zino-core/src/application/mod.rs b/zino-core/src/application/mod.rs index cecfe908..e0396c78 100644 --- a/zino-core/src/application/mod.rs +++ b/zino-core/src/application/mod.rs @@ -19,7 +19,7 @@ use std::{ thread, }; use toml::value::Table; -use utoipa::openapi::{Info, OpenApi, OpenApiBuilder}; +use utoipa::openapi::{OpenApi, OpenApiBuilder}; mod metrics_exporter; mod secret_key; @@ -101,10 +101,13 @@ pub trait Application { #[inline] fn openapi() -> OpenApi { OpenApiBuilder::new() - .info(Info::new(Self::name(), Self::version())) - .paths(openapi::default_paths()) + .paths(openapi::default_paths()) // should come first to load OpenAPI files .components(Some(openapi::default_components())) .tags(Some(openapi::default_tags())) + .servers(Some(openapi::default_servers())) + .security(Some(openapi::default_securities())) + .external_docs(openapi::default_external_docs()) + .info(openapi::openapi_info(Self::name(), Self::version())) .build() } diff --git a/zino-core/src/openapi/mod.rs b/zino-core/src/openapi/mod.rs index dc18ee36..d2fc83e9 100644 --- a/zino-core/src/openapi/mod.rs +++ b/zino-core/src/openapi/mod.rs @@ -12,12 +12,16 @@ use std::{ use toml::Table; use utoipa::openapi::{ content::ContentBuilder, + external_docs::ExternalDocs, + info::{Contact, Info, License}, path::{PathItem, Paths, PathsBuilder}, response::ResponseBuilder, schema::{ Components, ComponentsBuilder, KnownFormat, Object, ObjectBuilder, Ref, SchemaFormat, SchemaType, }, + security::SecurityRequirement, + server::Server, tag::Tag, }; @@ -28,6 +32,49 @@ mod webhook; pub(crate) use model::translate_model_entry; pub(crate) use webhook::get_webhook; +/// Constructs the OpenAPI `Info` object. +pub(crate) fn openapi_info(title: &str, version: &str) -> Info { + let mut info = Info::new(title, version); + if let Some(config) = OPENAPI_INFO.get() { + if let Some(title) = config.get_str("title") { + info.title = title.to_owned(); + } + if let Some(description) = config.get_str("description") { + info.description = Some(description.to_owned()); + } + if let Some(terms_of_service) = config.get_str("terms_of_service") { + info.terms_of_service = Some(terms_of_service.to_owned()); + } + if let Some(contact_config) = config.get_table("contact") { + let mut contact = Contact::new(); + if let Some(contact_name) = contact_config.get_str("name") { + contact.name = Some(contact_name.to_owned()); + } + if let Some(contact_url) = contact_config.get_str("url") { + contact.url = Some(contact_url.to_owned()); + } + if let Some(contact_email) = contact_config.get_str("email") { + contact.email = Some(contact_email.to_owned()); + } + info.contact = Some(contact); + } + if let Some(license) = config.get_str("license") { + info.license = Some(License::new(license)); + } else if let Some(license_config) = config.get_table("license") { + let license_name = license_config.get_str("name").unwrap_or_default(); + let mut license = License::new(license_name); + if let Some(license_url) = license_config.get_str("url") { + license.url = Some(license_url.to_owned()); + } + info.license = Some(license); + } + if let Some(version) = config.get_str("version") { + info.version = version.to_owned(); + } + } + info +} + /// Returns the default OpenAPI paths. pub(crate) fn default_paths() -> Paths { let mut paths_builder = PathsBuilder::new(); @@ -163,6 +210,23 @@ pub(crate) fn default_tags() -> Vec { OPENAPI_TAGS.get_or_init(Vec::new).clone() } +/// Returns the default OpenAPI servers. +pub(crate) fn default_servers() -> Vec { + OPENAPI_SERVERS + .get_or_init(|| vec![Server::new("/")]) + .clone() +} + +/// Returns the default OpenAPI security requirements. +pub(crate) fn default_securities() -> Vec { + OPENAPI_SECURITIES.get_or_init(Vec::new).clone() +} + +/// Returns the default OpenAPI external docs. +pub(crate) fn default_external_docs() -> Option { + OPENAPI_EXTERNAL_DOCS.get().cloned() +} + /// OpenAPI paths. static OPENAPI_PATHS: LazyLock> = LazyLock::new(|| { let mut paths: BTreeMap = BTreeMap::new(); @@ -183,6 +247,50 @@ static OPENAPI_PATHS: LazyLock> = LazyLock::new(|| { }) .parse::() .expect("fail to parse the OpenAPI file as a TOML table"); + if file.file_name() == "OPENAPI.toml" { + if let Some(info_config) = openapi_config.get_table("info") && + OPENAPI_INFO.set(info_config.clone()).is_err() + { + panic!("fail to set OpenAPI info"); + } + if let Some(servers) = openapi_config.get_array("servers") { + let servers = servers + .iter() + .filter_map(|v| v.as_table()) + .map(parser::parse_server) + .collect::>(); + if OPENAPI_SERVERS.set(servers).is_err() { + panic!("fail to set OpenAPI servers"); + } + } + if let Some(security_schemes) = openapi_config.get_table("security_schemes") { + for (name, scheme) in security_schemes { + if let Some(scheme_config) = scheme.as_table() { + let scheme = parser::parse_security_scheme(scheme_config); + components_builder = + components_builder.security_scheme(name, scheme); + } + } + } + if let Some(securities) = openapi_config.get_array("securities") { + let security_requirements = securities + .iter() + .filter_map(|v| v.as_table()) + .map(parser::parse_security_requirement) + .collect::>(); + if OPENAPI_SECURITIES.set(security_requirements).is_err() { + panic!("fail to set OpenAPI security requirements"); + } + } + if let Some(external_docs) = openapi_config.get_table("external_docs") { + let external_docs = parser::parse_external_docs(external_docs); + if OPENAPI_EXTERNAL_DOCS.set(external_docs).is_err() { + panic!("fail to set OpenAPI external docs"); + } + } + continue; + } + let name = openapi_config .get_str("name") .map(|s| s.to_owned()) @@ -260,12 +368,24 @@ static OPENAPI_PATHS: LazyLock> = LazyLock::new(|| { paths }); +/// OpenAPI info. +static OPENAPI_INFO: OnceLock
= OnceLock::new(); + /// OpenAPI components. static OPENAPI_COMPONENTS: OnceLock = OnceLock::new(); /// OpenAPI tags. static OPENAPI_TAGS: OnceLock> = OnceLock::new(); +/// OpenAPI servers. +static OPENAPI_SERVERS: OnceLock> = OnceLock::new(); + +/// OpenAPI securities. +static OPENAPI_SECURITIES: OnceLock> = OnceLock::new(); + +/// OpenAPI external docs. +static OPENAPI_EXTERNAL_DOCS: OnceLock = OnceLock::new(); + /// Model definitions. static MODEL_DEFINITIONS: OnceLock> = OnceLock::new(); diff --git a/zino-core/src/openapi/parser.rs b/zino-core/src/openapi/parser.rs index f50f0fe5..1e5f2681 100644 --- a/zino-core/src/openapi/parser.rs +++ b/zino-core/src/openapi/parser.rs @@ -3,15 +3,19 @@ use crate::{ TomlValue, }; use convert_case::{Case, Casing}; +use std::collections::BTreeMap; use toml::Table; use utoipa::openapi::{ content::Content, + external_docs::ExternalDocs, path::{Operation, OperationBuilder, Parameter, ParameterBuilder, ParameterIn, PathItemType}, request_body::{RequestBody, RequestBodyBuilder}, schema::{ Array, ArrayBuilder, KnownFormat, Object, ObjectBuilder, Ref, Schema, SchemaFormat, SchemaType, }, + security::{HttpAuthScheme, HttpBuilder, SecurityRequirement, SecurityScheme}, + server::{Server, ServerVariableBuilder}, tag::{Tag, TagBuilder}, Deprecated, RefOr, Required, }; @@ -25,6 +29,10 @@ pub(super) fn parse_tag(name: &str, config: &Table) -> Tag { if let Some(description) = config.get_str("description") { tag_builder = tag_builder.description(Some(description)); } + if let Some(external_docs) = config.get_table("external_docs") { + let external_docs = parse_external_docs(external_docs); + tag_builder = tag_builder.external_docs(Some(external_docs)); + } tag_builder.build() } @@ -41,6 +49,29 @@ pub(super) fn parse_operation(name: &str, path: &str, config: &Table) -> Operati if let Some(tag) = config.get_str("tag") { operation_builder = operation_builder.tag(tag); } + if let Some(servers) = config.get_array("servers") { + let servers = servers + .iter() + .filter_map(|v| v.as_table()) + .map(parse_server) + .collect::>(); + operation_builder = operation_builder.servers(Some(servers)); + } + if let Some(server) = config.get_table("server") { + operation_builder = operation_builder.server(parse_server(server)); + } + if let Some(securities) = config.get_array("securities") { + let security_requirements = securities + .iter() + .filter_map(|v| v.as_table()) + .map(parse_security_requirement) + .collect::>(); + operation_builder = operation_builder.securities(Some(security_requirements)); + } + if let Some(security) = config.get_table("security") { + let security_requirement = parse_security_requirement(security); + operation_builder = operation_builder.security(security_requirement); + } if let Some(summary) = config.get_str("summary") { operation_builder = operation_builder.summary(Some(summary)); } @@ -430,3 +461,103 @@ fn parse_request_body(config: &Table) -> RequestBody { .content("application/json", Content::new(schema)) .build() } + +/// Parses the security scheme. +pub(super) fn parse_security_scheme(config: &Table) -> SecurityScheme { + let schema_type = config.get_str("type").unwrap_or("unkown"); + match schema_type { + "http" => { + let mut http_builder = HttpBuilder::new(); + if let Some(scheme) = config.get_str("scheme") { + let http_auth_scheme = match scheme { + "bearer" => HttpAuthScheme::Bearer, + "digest" => HttpAuthScheme::Digest, + "hoba" => HttpAuthScheme::Hoba, + "mutual" => HttpAuthScheme::Mutual, + "negotiate" => HttpAuthScheme::Negotiate, + "oauth" => HttpAuthScheme::OAuth, + "scram-sha1" => HttpAuthScheme::ScramSha1, + "scram-sha256" => HttpAuthScheme::ScramSha256, + "vapid" => HttpAuthScheme::Vapid, + _ => HttpAuthScheme::Basic, + }; + http_builder = http_builder.scheme(http_auth_scheme); + } + if let Some(bearer_format) = config.get_str("bearer_format") { + http_builder = http_builder.bearer_format(bearer_format); + } + if let Some(description) = config.get_str("description") { + http_builder = http_builder.description(Some(description.to_owned())); + } + SecurityScheme::Http(http_builder.build()) + } + _ => SecurityScheme::MutualTls { + description: config.get_str("description").map(|s| s.to_owned()), + }, + } +} + +/// Parses the security requirement. +pub(super) fn parse_security_requirement(config: &Table) -> SecurityRequirement { + if let Some(name) = config.get_str("name") { + let scopes = config.get_str_array("scopes").unwrap_or_default(); + SecurityRequirement::new(name, scopes) + } else { + SecurityRequirement::default() + } +} + +/// Parses the server. +pub(super) fn parse_server(config: &Table) -> Server { + if let Some(url) = config.get_str("url") { + let mut server = Server::new(url); + if let Some(description) = config.get_str("description") { + server.description = Some(description.to_owned()); + } + if let Some(variables) = config.get_table("variables") { + let mut server_variables = BTreeMap::new(); + for (name, value) in variables { + let mut variable_builder = ServerVariableBuilder::new(); + match value { + TomlValue::String(s) => { + variable_builder = variable_builder.default_value(s); + } + TomlValue::Array(vec) => { + let enum_values = vec.iter().filter_map(|v| v.as_str()); + variable_builder = variable_builder.enum_values(Some(enum_values)); + } + TomlValue::Table(table) => { + if let Some(value) = table.get_str("default") { + variable_builder = variable_builder.default_value(value); + } + if let Some(description) = table.get_str("description") { + variable_builder = variable_builder.description(Some(description)); + } + if let Some(values) = table.get_str_array("enum") { + variable_builder = variable_builder.enum_values(Some(values)); + } + } + _ => (), + } + server_variables.insert(name.to_owned(), variable_builder.build()); + } + server.variables = Some(server_variables); + } + server + } else { + Server::default() + } +} + +/// Parses the external docs. +pub(super) fn parse_external_docs(config: &Table) -> ExternalDocs { + if let Some(url) = config.get_str("url") { + let mut external_docs = ExternalDocs::new(url); + if let Some(description) = config.get_str("description") { + external_docs.description = Some(description.to_owned()); + } + external_docs + } else { + ExternalDocs::default() + } +} diff --git a/zino-model/src/user/jwt_auth.rs b/zino-model/src/user/jwt_auth.rs index 00ecdf2b..22ecdd98 100644 --- a/zino-model/src/user/jwt_auth.rs +++ b/zino-model/src/user/jwt_auth.rs @@ -216,13 +216,15 @@ where })?; let data = claims.data(); if let Some(role_field) = Self::ROLE_FIELD && - data.get("roles") != user.get(role_field) + let Some(roles) = data.get("roles") && + user.get(role_field) != Some(roles) { let message = format!("403 Forbidden: invalid for the `{role_field}` field"); return Err(Error::new(message)); } if let Some(tenant_id_field) = Self::TENANT_ID_FIELD && - data.get("tenant_id") != user.get(tenant_id_field) + let Some(tenant_id) = data.get("tenant_id") && + user.get(tenant_id_field) != Some(tenant_id) { let message = format!("403 Forbidden: invalid for the `{tenant_id_field}` field"); return Err(Error::new(message)); diff --git a/zino/Cargo.toml b/zino/Cargo.toml index b06d7016..6b1ff691 100644 --- a/zino/Cargo.toml +++ b/zino/Cargo.toml @@ -119,7 +119,7 @@ version = "0.8.0" features= ["signed"] [dependencies.tower-http] -version = "0.4.3" +version = "0.4.4" optional = true features = [ "add-extension", diff --git a/zino/src/application/actix_cluster.rs b/zino/src/application/actix_cluster.rs index 6194c012..4f51891f 100644 --- a/zino/src/application/actix_cluster.rs +++ b/zino/src/application/actix_cluster.rs @@ -8,7 +8,7 @@ use actix_web::{ web::{self, FormConfig, JsonConfig, PayloadConfig}, App, HttpServer, Responder, }; -use std::path::PathBuf; +use std::{fs, path::PathBuf}; use utoipa_rapidoc::RapiDoc; use zino_core::{ application::Application, @@ -51,11 +51,11 @@ impl Application for ActixCluster { let mut body_limit = 100 * 1024 * 1024; // 100MB let mut public_dir = PathBuf::new(); let default_public_dir = Self::relative_path("public"); - if let Some(server) = Self::config().get_table("server") { - if let Some(limit) = server.get_usize("body-limit") { + if let Some(server_config) = Self::config().get_table("server") { + if let Some(limit) = server_config.get_usize("body-limit") { body_limit = limit; } - if let Some(dir) = server.get_str("public-dir") { + if let Some(dir) = server_config.get_str("public-dir") { public_dir.push(dir); } else { public_dir = default_public_dir; @@ -93,12 +93,9 @@ impl Application for ActixCluster { let res = file.into_response(&req); Ok(ServiceResponse::new(req, res)) })); - let rapidoc = RapiDoc::with_openapi("/api-docs/openapi.json", Self::openapi()) - .path("/rapidoc"); let mut app = App::new() .route("/", index_file_handler) .service(static_files) - .service(rapidoc) .default_service(web::to(|req: Request| async { let res = Response::new(StatusCode::NOT_FOUND); ActixResponse::from(res).respond_to(&req.into()) @@ -106,6 +103,27 @@ impl Application for ActixCluster { for route in &routes { app = app.configure(route); } + + // Render OpenAPI docs. + if let Some(openapi_config) = app_state.get_config("openapi") { + if openapi_config.get_bool("show-docs") != Some(false) { + let mut rapidoc = + RapiDoc::with_openapi("/api-docs/openapi.json", Self::openapi()) + .path("/rapidoc"); + if let Some(custom_html) = openapi_config.get_str("custom-html") && + let Ok(html) = fs::read_to_string(Self::relative_path(custom_html)) + { + rapidoc = rapidoc.custom_html(html.leak()); + } + app = app.service(rapidoc); + } + } else { + let rapidoc = + RapiDoc::with_openapi("/api-docs/openapi.json", Self::openapi()) + .path("/rapidoc"); + app = app.service(rapidoc); + } + app.app_data(FormConfig::default().limit(body_limit)) .app_data(JsonConfig::default().limit(body_limit)) .app_data(PayloadConfig::default().limit(body_limit)) diff --git a/zino/src/application/axum_cluster.rs b/zino/src/application/axum_cluster.rs index 4167c586..0870760c 100644 --- a/zino/src/application/axum_cluster.rs +++ b/zino/src/application/axum_cluster.rs @@ -6,7 +6,9 @@ use axum::{ middleware::from_fn, routing, BoxError, Router, Server, }; -use std::{convert::Infallible, net::SocketAddr, path::PathBuf, sync::LazyLock, time::Duration}; +use std::{ + convert::Infallible, fs, net::SocketAddr, path::PathBuf, sync::LazyLock, time::Duration, +}; use tokio::runtime::Builder; use tower::{ timeout::{error::Elapsed, TimeoutLayer}, @@ -70,14 +72,14 @@ impl Application for AxumCluster { let mut request_timeout = Duration::from_secs(10); // 10 seconds let mut public_dir = PathBuf::new(); let default_public_dir = Self::relative_path("public"); - if let Some(server) = Self::config().get_table("server") { - if let Some(limit) = server.get_usize("body-limit") { + if let Some(server_config) = Self::config().get_table("server") { + if let Some(limit) = server_config.get_usize("body-limit") { body_limit = limit; } - if let Some(timeout) = server.get_duration("request-timeout") { + if let Some(timeout) = server_config.get_duration("request-timeout") { request_timeout = timeout; } - if let Some(dir) = server.get_str("public-dir") { + if let Some(dir) = server_config.get_str("public-dir") { public_dir.push(dir); } else { public_dir = default_public_dir; @@ -101,18 +103,35 @@ impl Application for AxumCluster { let app_env = app_state.env(); let listeners = app_state.listeners(); let servers = listeners.iter().map(|listener| { - let rapidoc = RapiDoc::with_openapi("/api-docs/openapi.json", Self::openapi()) - .path("/rapidoc"); let mut app = Router::new() .route_service("/", serve_file.clone()) .nest_service("/public", serve_dir.clone()) .route("/sse", routing::get(endpoint::sse_handler)) - .route("/websocket", routing::get(endpoint::websocket_handler)) - .merge(rapidoc); + .route("/websocket", routing::get(endpoint::websocket_handler)); for route in &routes { app = app.merge(route.clone()); } + // Render OpenAPI docs. + if let Some(openapi_config) = app_state.get_config("openapi") { + if openapi_config.get_bool("show-docs") != Some(false) { + let rapidoc = + RapiDoc::with_openapi("/api-docs/openapi.json", Self::openapi()) + .path("/rapidoc"); + if let Some(custom_html) = openapi_config.get_str("custom-html") && + let Ok(html) = fs::read_to_string(Self::relative_path(custom_html)) + { + app = app.merge(rapidoc.custom_html(html.as_str())); + } else { + app = app.merge(rapidoc); + } + } + } else { + let rapidoc = RapiDoc::with_openapi("/api-docs/openapi.json", Self::openapi()) + .path("/rapidoc"); + app = app.merge(rapidoc); + } + app = app .fallback_service(tower::service_fn(|req| async { let req = AxumExtractor::from(req); diff --git a/zino/src/lib.rs b/zino/src/lib.rs index a1738603..5ca05750 100644 --- a/zino/src/lib.rs +++ b/zino/src/lib.rs @@ -52,6 +52,7 @@ #![feature(async_fn_in_trait)] #![feature(doc_auto_cfg)] #![feature(lazy_cell)] +#![feature(let_chains)] #![feature(result_option_inspect)] #![forbid(unsafe_code)]