diff --git a/.gitignore b/.gitignore index 378adf0..904a39a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ -/target +*target /Cargo.lock -git_cmd.md \ No newline at end of file +git_cmd.md diff --git a/Cargo.toml b/Cargo.toml index e011e0d..786d9c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,26 +1,27 @@ [package] name = "utoipa_auto_discovery" -version = "0.3.0" +version = "0.4.0" edition = "2021" -authors = ["RxDiscovery"] -rust-version = "1.69" -keywords = ["utoipa","openapi","swagger", "path", "auto"] +authors = ["RxDiscovery", "ProbablyClem"] +rust-version = "1.69" +keywords = ["utoipa", "openapi", "swagger", "path", "auto"] description = "Rust Macros to automate the addition of Paths/Schemas to Utoipa crate, simulating Reflection during the compilation phase" -categories = ["parsing","development-tools::procedural-macro-helpers","web-programming"] +categories = [ + "parsing", + "development-tools::procedural-macro-helpers", + "web-programming", +] license = "MIT OR Apache-2.0" readme = "README.md" repository = "https://github.com/rxdiscovery/utoipa_auto_discovery" homepage = "https://github.com/rxdiscovery/utoipa_auto_discovery" - - [lib] -proc-macro = true -[dependencies] -quote = "1.0.28" -syn = { version ="2.0.18", features = [ "full" ]} -proc-macro2 = "1.0.59" +[dependencies] +utoipa-auto-macro = { version = "0.4.0", path = "./utoipa-auto-macro" } -[build-dependencies] +[dev-dependencies] +utoipa = { version = "4.1.0", features = ["preserve_path_order"] } +syn = { version = "2.0.39", features = ["extra-traits", "full"] } diff --git a/README.md b/README.md index 10728df..321eb0f 100644 --- a/README.md +++ b/README.md @@ -16,20 +16,18 @@ Utoipa is a great crate for generating documentation (openapi/swagger) via sourc But since Rust is a static programming language, we don't have the possibility of automatically discovering paths and dto in runtime and adding them to the documentation, -for APIs with just a few endpoints, it's not that much trouble to add controller functions one by one, and DTOs one by one. +For APIs with just a few endpoints, it's not that much trouble to add controller functions one by one, and DTOs one by one. -if you have hundreds or even thousands of endpoints, the code becomes very verbose and difficult to maintain. +But, if you have hundreds or even thousands of endpoints, the code becomes very verbose and difficult to maintain. -ex : +Ex : ```rust -... - #[derive(OpenApi)] #[openapi( paths( - // <================================ all functions 1 to N + // <================================ All functions 1 to N test_controller::service::func_get_1, test_controller::service::func_get_2, test_controller::service::func_get_3, @@ -51,44 +49,54 @@ ex : )] pub struct ApiDoc; -... - ``` -The aim of crate **utoipa_auto_discovery** is to propose a macro that automates the detection of methods carrying Utoipa macros (`#[utoipa::path(...]`), and adds them automatically. (it also detects sub-modules.) +The goal of this crate is to propose a macro that automates the detection of methods carrying Utoipa macros (`#[utoipa::path(...]`), and adds them automatically. (it also detects sub-modules.) + +It also detects struct that derive `ToSchema` for the `components(schemas)` section, and the `ToResponse` for the `components(responses)` section. + +# Features + +- [x] Automatic recursive path detection +- [x] Automatic import from module +- [x] Automatic import from src folder +- [x] Automatic model detection +- [x] Automatic response detection -# how to use it +# How to use it -simply add the crate `utoipa_auto_discovery` to the project +Simply add the crate `utoipa_auto_discovery` to the project ``` cargo add utoipa_auto_discovery ``` -import macro +Import macro ```rust use utoipa_auto_discovery::utoipa_auto_discovery; ``` -then add the `#[utoipa_auto_discovery]` macro just before the #[derive(OpenApi)] and `#[openapi]` macros. +Then add the `#[utoipa_auto_discovery]` macro just before the #[derive(OpenApi)] and `#[openapi]` macros. -## important !! +## Important !! -Put `#[utoipa_auto_discovery]` before #[derive(OpenApi)] and `#[openapi]` macros. +Put `#[utoipa_auto_discovery]` before `#[derive(OpenApi)] `and `#[openapi]` macros. ```rust -#[utoipa_auto_discovery(paths = "( MODULE_TREE::MODULE_NAME => MODULE_SRC_FILE_PATH ) ; ( MODULE_TREE::MODULE_NAME => MODULE_SRC_FILE_PATH ) ; ... ;")] +#[utoipa_auto_discovery(paths = "MODULE_SRC_FILE_PATH, MODULE_SRC_FILE_PATH, ...")] ``` -the paths receives a String which must respect this structure : +The paths receives a String which must respect this structure : -`" ( MODULE_TREE_PATH => MODULE_SRC_FILE_PATH ) ;"` +`"MODULE_SRC_FILE_PATH, MODULE_SRC_FILE_PATH, ..."` -you can add several pairs (Module Path => Src Path ) by separating them with a semicolon ";". +You can add several paths by separating them with a coma `","`. -Here's an example of how to add all the methods contained in the test_controller and test2_controller modules. -you can also combine automatic and manual addition, as here we've added a method manually to the documentation "other_controller::get_users". +### Import from src folder + +If no path is specified, the macro will automatically scan the `src` folder and add all the methods carrying the `#[utoipa::path(...)]` macro, and all structs deriving `ToSchema` and `ToResponse`. +Here's an example of how to add all the methods contained in the src code. ```rust ... @@ -96,8 +104,55 @@ you can also combine automatic and manual addition, as here we've added a method use utoipa_auto_discovery::utoipa_auto_discovery; ... +#[utoipa_auto_discovery] +#[derive(OpenApi)] +#[openapi( + tags( + (name = "todo", description = "Todo management endpoints.") + ), + modifiers(&SecurityAddon) +)] + +pub struct ApiDoc; + +... + +``` + +### Import from module + +Here's an example of how to add all the methods and structs contained in the rest module. + +```rust + +use utoipa_auto_discovery::utoipa_auto_discovery; + #[utoipa_auto_discovery( - paths = "( crate::rest::test_controller => ./src/rest/test_controller.rs ) ; ( crate::rest::test2_controller => ./src/rest/test2_controller.rs )" + paths = "./src/rest" + )] +#[derive(OpenApi)] +#[openapi( + tags( + (name = "todo", description = "Todo management endpoints.") + ), + modifiers(&SecurityAddon) +)] + +pub struct ApiDoc; + +``` + +### Import from filename + +Here's an example of how to add all the methods contained in the test_controller and test2_controller modules. +you can also combine automatic and manual addition, as here we've added a method manually to the documentation "other_controller::get_users", and a schema "TestDTO". + +```rust + +use utoipa_auto_discovery::utoipa_auto_discovery; + +#[utoipa_auto_discovery( + paths = "./src/rest/test_controller.rs,./src/rest/test2_controller.rs " )] #[derive(OpenApi)] #[openapi( @@ -116,11 +171,11 @@ use utoipa_auto_discovery::utoipa_auto_discovery; pub struct ApiDoc; -... + ``` -## exclude a method of automatic scanning +## Exclude a method from automatic scanning you can exclude a function from the Doc Path list by adding the following macro `#[utoipa_ignore]` . @@ -142,11 +197,21 @@ ex: ``` -## note +## Exclude a struct from automatic scanning -sub-modules within a module containing methods tagged with utoipa::path are also automatically detected. +you can also exclude a struct from the models and reponses list by adding the following macro `#[utoipa_ignore]` . -# Features +ex: + +```rust + #[utoipa_ignore] //<============== this Macro + #[derive(ToSchema)] + struct ModelToIgnore { + // your CODE + } + +``` + +## Note -- [x] automatic path detection -- [ ] automatic schema detection (in progress) +Sub-modules within a module containing methods tagged with utoipa::path are also automatically detected. diff --git a/src/lib.rs b/src/lib.rs index 5705a6c..b3cda00 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,259 +1,29 @@ -extern crate proc_macro; +#![allow(dead_code)] // This code is used in the tests -use std::{fs::File, io::Read, path::PathBuf}; +pub use utoipa_auto_macro::*; -use proc_macro::TokenStream; +#[cfg(test)] +mod test { + use utoipa::OpenApi; + use utoipa_auto_macro::utoipa_auto_discovery; -use quote::{quote, ToTokens}; -use syn::parse_macro_input; + #[utoipa::path(post, path = "/route1")] + pub fn route1() {} -fn rem_first_and_last(value: &str) -> &str { - let mut chars = value.chars(); - chars.next(); - chars.next_back(); - chars.as_str() -} - -fn parse_file>(filepath: T) -> Result { - let pb: PathBuf = filepath.into(); - - if pb.is_file() { - let mut file = File::open(pb).unwrap(); - let mut content = String::new(); - file.read_to_string(&mut content).unwrap(); - - let sf = syn::parse_file(&content).unwrap(); - Ok(sf) - } else { - Err(()) - } -} - -fn get_all_mod_uto_functions(item: &syn::ItemMod, fns_name: &mut Vec) { - let sub_items = &item.content.iter().next().unwrap().1; - - let mod_name = item.ident.to_string(); - - for it in sub_items { - match it { - syn::Item::Mod(m) => get_all_mod_uto_functions(m, fns_name), - syn::Item::Fn(f) => { - if !f.attrs.is_empty() - && !f.attrs.iter().any(|attr| { - if let Some(name) = attr.path().get_ident() { - name.eq("utoipa_ignore") - } else { - false - } - }) - { - for i in 0..f.attrs.len() { - if f.attrs[i] - .meta - .path() - .segments - .iter() - .any(|item| item.ident.eq("utoipa")) - { - fns_name.push(format!("{}::{}", mod_name, f.sig.ident)); - } - } - } - } - - _ => {} - } - } -} - -fn get_all_uto_functions(src_path: String) -> Vec { - let mut fns_name: Vec = vec![]; - - let sc = parse_file(src_path); - if let Ok(sc) = sc { - let items = sc.items; - - for i in items { - match i { - syn::Item::Mod(m) => get_all_mod_uto_functions(&m, &mut fns_name), - syn::Item::Fn(f) => { - if !f.attrs.is_empty() - && !f.attrs.iter().any(|attr| { - if let Some(name) = attr.path().get_ident() { - name.eq("utoipa_ignore") - } else { - false - } - }) - { - for i in 0..f.attrs.len() { - if f.attrs[i] - .meta - .path() - .segments - .iter() - .any(|item| item.ident.eq("utoipa")) - { - fns_name.push(f.sig.ident.to_string()); - } - } - } - } + #[utoipa::path(post, path = "/route2")] + pub fn route2() {} - _ => {} - } - } - } - - fns_name -} - -#[proc_macro_attribute] -pub fn utoipa_ignore( - _attr: proc_macro::TokenStream, - item: proc_macro::TokenStream, -) -> proc_macro::TokenStream { - let input = parse_macro_input!(item as syn::Item); - let code = quote!( - #input - ); - - TokenStream::from(code) -} - -#[proc_macro_attribute] -pub fn utoipa_auto_discovery( - attr: proc_macro::TokenStream, - item: proc_macro::TokenStream, -) -> proc_macro::TokenStream { - let mut input = parse_macro_input!(item as syn::ItemStruct); - - let mut paths: String = "".to_string(); + #[utoipa::path(post, path = "/route3")] + pub fn route3() {} - if !attr.is_empty() { - let mut it = attr.into_iter(); - let tok = it.next(); + /// Discover from the crate root auto + #[utoipa_auto_discovery] + #[derive(OpenApi)] + #[openapi(info(title = "Percentage API", version = "1.0.0"))] + pub struct CrateAutoApiDocs {} - if let Some(proc_macro::TokenTree::Ident(ident)) = tok { - if ident.to_string().eq("paths") { - let tok = it.next(); - if let Some(proc_macro::TokenTree::Punct(punct)) = tok { - if punct.to_string().eq("=") { - let tok = it.next(); - if let Some(tok) = tok { - match tok { - proc_macro::TokenTree::Literal(lit) => { - paths = lit.to_string(); - } - _ => { - panic!("malformed paths !") - } - } - } - } - } - } - } + #[test] + fn test_crate_auto_import_path() { + assert_eq!(CrateAutoApiDocs::openapi().paths.paths.len(), 3) } - - let mut pairs: Vec<(String, String)> = vec![]; - - let str_paths = trim_parentheses(rem_first_and_last(paths.as_str())); - - if str_paths.contains('|') { - panic!("Please use the new syntax ! paths=\"(MODULE_TREE_PATH => MODULE_SRC_PATH) ;\"") - } - - let paths = str_paths.split(';'); - - for p in paths { - let pair = p.split_once("=>"); - - if let Some(pair) = pair { - pairs.push((trim_whites(pair.0), trim_whites(pair.1))); - } - } - - if !pairs.is_empty() { - let mut uto_paths: String = String::new(); - - for p in pairs { - let list_fn = get_all_uto_functions(p.1); - - if !list_fn.is_empty() { - for i in list_fn { - uto_paths.push_str(format!("{}::{},", p.0, i).as_str()); - } - } - } - - let attrs = &mut input.attrs; - - if !attrs.iter().any(|elm| elm.path().is_ident("derive")) { - panic!("Please put utoipa_auto_discovery before #[derive] and #[openapi]"); - } - - if !attrs.iter().any(|elm| elm.path().is_ident("openapi")) { - panic!("Please put utoipa_auto_discovery before #[derive] and #[openapi]"); - } - - let mut is_ok: bool = false; - #[warn(clippy::needless_range_loop)] - for i in 0..attrs.len() { - if attrs[i].path().is_ident("openapi") { - is_ok = true; - let mut src_uto_macro = attrs[i].to_token_stream().to_string(); - - src_uto_macro = src_uto_macro.replace("#[openapi(", ""); - src_uto_macro = src_uto_macro.replace(")]", ""); - - if !src_uto_macro.contains("paths(") { - let new_paths = format!("paths({}", uto_paths); - src_uto_macro = format!("{}), {}", new_paths, src_uto_macro); - - let stream: proc_macro2::TokenStream = src_uto_macro.parse().unwrap(); - - let new_attr: syn::Attribute = syn::parse_quote! { #[openapi( #stream )] }; - - attrs[i] = new_attr; - } else { - let new_paths = format!("paths({}", uto_paths); - - src_uto_macro = src_uto_macro.replace("paths(", new_paths.as_str()); - - let stream: proc_macro2::TokenStream = src_uto_macro.parse().unwrap(); - - let new_attr: syn::Attribute = syn::parse_quote! { #[openapi( #stream )] }; - - attrs[i] = new_attr; - } - } - } - - if !is_ok { - panic!("No utoipa::openapi Macro found !"); - } - } - - let code = quote!( - #input - ); - - TokenStream::from(code) -} - -fn trim_whites(str: &str) -> String { - let s = str.trim(); - - let s: String = s.replace('\n', ""); - - s -} - -fn trim_parentheses(str: &str) -> String { - let s = str.trim(); - - let s: String = s.replace(['(', ')'], ""); - - s } diff --git a/tests/controllers/controller1.rs b/tests/controllers/controller1.rs new file mode 100644 index 0000000..4906187 --- /dev/null +++ b/tests/controllers/controller1.rs @@ -0,0 +1,9 @@ +#![allow(dead_code)] // This code is used in the tests +use utoipa_auto_macro::utoipa_ignore; + +#[utoipa::path(post, path = "/route1")] +pub fn route1() {} + +#[utoipa_ignore] +#[utoipa::path(post, path = "/route-ignored")] +pub fn route_ignored() {} diff --git a/tests/controllers/controller2.rs b/tests/controllers/controller2.rs new file mode 100644 index 0000000..43480f7 --- /dev/null +++ b/tests/controllers/controller2.rs @@ -0,0 +1,4 @@ +#![allow(dead_code)] // This code is used in the tests + +#[utoipa::path(post, path = "/route3")] +pub fn route3() {} diff --git a/tests/controllers/mod.rs b/tests/controllers/mod.rs new file mode 100644 index 0000000..fbfc6bc --- /dev/null +++ b/tests/controllers/mod.rs @@ -0,0 +1,2 @@ +pub mod controller1; +pub mod controller2; diff --git a/tests/models.rs b/tests/models.rs new file mode 100644 index 0000000..1dc7f41 --- /dev/null +++ b/tests/models.rs @@ -0,0 +1,12 @@ +#![allow(dead_code)] // This code is used in the tests +use utoipa::{ToResponse, ToSchema}; +use utoipa_auto_macro::utoipa_ignore; + +#[derive(ToSchema)] +pub struct ModelSchema; +#[derive(ToResponse)] +pub struct ModelResponse; + +#[utoipa_ignore] +#[derive(ToSchema)] +pub struct IgnoredModelSchema; diff --git a/tests/test.rs b/tests/test.rs new file mode 100644 index 0000000..3242ddf --- /dev/null +++ b/tests/test.rs @@ -0,0 +1,121 @@ +mod controllers; +mod models; +use utoipa::OpenApi; +use utoipa_auto_discovery::utoipa_auto_discovery; +// Discover from multiple controllers +#[utoipa_auto_discovery( + paths = "( crate::controllers::controller1 => ./tests/controllers/controller1.rs) ; ( crate::controllers::controller2 => ./tests/controllers/controller2.rs )" +)] +#[derive(OpenApi)] +#[openapi(info(title = "Percentage API", version = "1.0.0"))] +pub struct MultiControllerApiDocs {} + +#[test] +fn test_path_import() { + assert_eq!(MultiControllerApiDocs::openapi().paths.paths.len(), 2) +} + +/// Discover from a single controller +#[utoipa_auto_discovery( + paths = "( crate::controllers::controller1 => ./tests/controllers/controller1.rs)" +)] +#[derive(OpenApi)] +#[openapi(info(title = "Percentage API", version = "1.0.0"))] +pub struct SingleControllerApiDocs {} + +#[test] +fn test_ignored_path() { + assert_eq!(SingleControllerApiDocs::openapi().paths.paths.len(), 1) +} + +/// Discover with manual path +#[utoipa_auto_discovery(paths = "./tests/controllers/controller1.rs")] +#[derive(OpenApi)] +#[openapi( + info(title = "Percentage API", version = "1.0.0"), + paths(controllers::controller2::route3) +)] +pub struct SingleControllerManualPathApiDocs {} + +#[test] +fn test_manual_path() { + assert_eq!( + SingleControllerManualPathApiDocs::openapi() + .paths + .paths + .len(), + 2 + ) +} +/// Discover from a module root +#[utoipa_auto_discovery(paths = "( crate::controllers => ./tests/controllers)")] +#[derive(OpenApi)] +#[openapi(info(title = "Percentage API", version = "1.0.0"))] +pub struct ModuleApiDocs {} +#[test] +fn test_module_import_path() { + assert_eq!(ModuleApiDocs::openapi().paths.paths.len(), 2) +} + +/// Discover from the crate root +#[utoipa_auto_discovery(paths = "./tests")] +#[derive(OpenApi)] +#[openapi(info(title = "Percentage API", version = "1.0.0"))] +pub struct CrateApiDocs {} + +#[test] +fn test_crate_import_path() { + assert_eq!(CrateApiDocs::openapi().paths.paths.len(), 2) +} + +// Discover from multiple controllers new syntax +#[utoipa_auto_discovery( + paths = "./tests/controllers/controller1.rs, ./tests/controllers/controller2.rs" +)] +#[derive(OpenApi)] +#[openapi(info(title = "Percentage API", version = "1.0.0"))] +pub struct MultiControllerNoModuleApiDocs {} + +#[test] +fn test_path_import_no_module() { + assert_eq!( + MultiControllerNoModuleApiDocs::openapi().paths.paths.len(), + 2 + ) +} + +// Discover from multiple controllers new syntax +#[utoipa_auto_discovery(paths = "./tests/models.rs")] +#[derive(OpenApi)] +#[openapi(info(title = "Percentage API", version = "1.0.0"))] +pub struct ModelsImportApiDocs {} + +#[test] +fn test_path_import_schema() { + assert_eq!( + ModelsImportApiDocs::openapi() + .components + .expect("no components") + .schemas + .len(), + 1 + ) +} + +// Discover from multiple controllers new syntax +#[utoipa_auto_discovery(paths = "./tests/models.rs")] +#[derive(OpenApi)] +#[openapi(info(title = "Percentage API", version = "1.0.0"))] +pub struct ResponsesImportApiDocs {} + +#[test] +fn test_path_import_responses() { + assert_eq!( + ResponsesImportApiDocs::openapi() + .components + .expect("no components") + .responses + .len(), + 1 + ) +} diff --git a/utoipa-auto-core/Cargo.lock b/utoipa-auto-core/Cargo.lock new file mode 100644 index 0000000..676e702 --- /dev/null +++ b/utoipa-auto-core/Cargo.lock @@ -0,0 +1,178 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "hashbrown" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" + +[[package]] +name = "indexmap" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d530e1a18b1cb4c484e6e34556a0d948706958449fca0cab753d649f2bce3d1f" +dependencies = [ + "equivalent", + "hashbrown", + "serde", +] + +[[package]] +name = "itoa" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38" + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.70" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39278fbbf5fb4f646ce651690877f89d1c5811a3d4acb27700c1cb3cdb78fd3b" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ryu" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741" + +[[package]] +name = "serde" +version = "1.0.193" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25dd9975e68d0cb5aa1120c288333fc98731bd1dd12f561e468ea4728c042b89" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.193" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43576ca501357b9b071ac53cdc7da8ef0cbd9493d8df094cd821777ea6e894d3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.39", +] + +[[package]] +name = "serde_json" +version = "1.0.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d1c7e3eac408d115102c4c24ad393e0821bb3a5df4d506a80f85f7a742a526b" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23e78b90f2fcf45d3e842032ce32e3f2d1545ba6636271dcbf24fa306d87be7a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "utoipa" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ff05e3bac2c9428f57ade702667753ca3f5cf085e2011fe697de5bfd49aa72d" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-auto-core" +version = "0.4.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.39", + "utoipa", +] + +[[package]] +name = "utoipa-gen" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0b6f4667edd64be0e820d6631a60433a269710b6ee89ac39525b872b76d61d" +dependencies = [ + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.39", +] + +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" diff --git a/utoipa-auto-core/Cargo.toml b/utoipa-auto-core/Cargo.toml new file mode 100644 index 0000000..6c71581 --- /dev/null +++ b/utoipa-auto-core/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "utoipa-auto-core" +version = "0.4.0" + +[lib] + + +[dependencies] +quote = "1.0.28" +syn = { version = "2.0.18", features = ["full"] } +proc-macro2 = "1.0.59" + +[dev-dependencies] +utoipa = { version = "4.1.0", features = ["preserve_path_order"] } diff --git a/utoipa-auto-core/src/attribute_utils.rs b/utoipa-auto-core/src/attribute_utils.rs new file mode 100644 index 0000000..db61212 --- /dev/null +++ b/utoipa-auto-core/src/attribute_utils.rs @@ -0,0 +1,289 @@ +use quote::ToTokens; +use syn::Attribute; + +pub fn update_openapi_macro_attributes( + macro_attibutes: &mut Vec, + uto_paths: &String, + uto_models: &String, + uto_reponses: &String, +) { + let mut is_ok = false; + #[warn(clippy::needless_range_loop)] + for i in 0..macro_attibutes.len() { + if !macro_attibutes[i].path().is_ident("openapi") { + continue; + } + is_ok = true; + let mut src_uto_macro = macro_attibutes[i].to_token_stream().to_string(); + + src_uto_macro = src_uto_macro.replace("#[openapi(", ""); + src_uto_macro = src_uto_macro.replace(")]", ""); + macro_attibutes[i] = + build_new_openapi_attributes(src_uto_macro, uto_paths, uto_models, uto_reponses); + } + if !is_ok { + panic!("No utoipa::openapi Macro found !"); + } +} + +/// Build the new openapi macro attribute with the newly discovered paths +pub fn build_new_openapi_attributes( + src_uto_macro: String, + uto_paths: &String, + uto_models: &String, + uto_reponses: &String, +) -> Attribute { + let paths = extract_paths(src_uto_macro.clone()); + let schemas = extract_schemas(src_uto_macro.clone()); + let responses = extract_responses(src_uto_macro.clone()); + let src_uto_macro = remove_paths(src_uto_macro); + let src_uto_macro = remove_schemas(src_uto_macro); + let src_uto_macro = remove_responses(src_uto_macro); + let src_uto_macro = remove_components(src_uto_macro); + + let paths = format!("{}{}", uto_paths, paths); + let schemas = format!("{}{}", uto_models, schemas); + let responses = format!("{}{}", uto_reponses, responses); + let src_uto_macro = format!( + "paths({}),components(schemas({}),responses({})),{}", + paths, schemas, responses, src_uto_macro + ) + .replace(",,", ","); + + let stream: proc_macro2::TokenStream = src_uto_macro.parse().unwrap(); + + syn::parse_quote! { #[openapi( #stream )] } +} + +fn remove_paths(src_uto_macro: String) -> String { + if src_uto_macro.contains("paths(") { + let paths = src_uto_macro.split("paths(").collect::>()[1]; + let paths = paths.split(")").collect::>()[0]; + src_uto_macro + .replace(format!("paths({})", paths).as_str(), "") + .replace(",,", ",") + } else { + src_uto_macro + } +} + +fn remove_schemas(src_uto_macro: String) -> String { + if src_uto_macro.contains("schemas(") { + let schemas = src_uto_macro.split("schemas(").collect::>()[1]; + let schemas = schemas.split(")").collect::>()[0]; + src_uto_macro + .replace(format!("schemas({})", schemas).as_str(), "") + .replace(",,", ",") + } else { + src_uto_macro + } +} + +fn remove_components(src_uto_macro: String) -> String { + if src_uto_macro.contains("components(") { + let components = src_uto_macro.split("components(").collect::>()[1]; + let components = components.split(")").collect::>()[0]; + src_uto_macro + .replace(format!("components({})", components).as_str(), "") + .replace(",,", ",") + } else { + src_uto_macro + } +} + +fn remove_responses(src_uto_macro: String) -> String { + if src_uto_macro.contains("responses(") { + let responses = src_uto_macro.split("responses(").collect::>()[1]; + let responses = responses.split(")").collect::>()[0]; + src_uto_macro + .replace(format!("responses({})", responses).as_str(), "") + .replace(",,", ",") + } else { + src_uto_macro + } +} + +fn extract_paths(src_uto_macro: String) -> String { + if src_uto_macro.contains("paths(") { + let paths = src_uto_macro.split("paths(").collect::>()[1]; + let paths = paths.split(")").collect::>()[0]; + paths.to_string() + } else { + "".to_string() + } +} + +fn extract_schemas(src_uto_macro: String) -> String { + if src_uto_macro.contains("schemas(") { + let schemas = src_uto_macro.split("schemas(").collect::>()[1]; + let schemas = schemas.split(")").collect::>()[0]; + schemas.to_string() + } else { + "".to_string() + } +} + +fn extract_responses(src_uto_macro: String) -> String { + if src_uto_macro.contains("responses(") { + let responses = src_uto_macro.split("responses(").collect::>()[1]; + let responses = responses.split(")").collect::>()[0]; + responses.to_string() + } else { + "".to_string() + } +} +#[cfg(test)] +mod test { + use quote::ToTokens; + + #[test] + fn test_remove_paths() { + assert_eq!( + super::remove_paths("description(test),paths(p1),info(test)".to_string()), + "description(test),info(test)".to_string() + ); + } + + #[test] + fn test_extract_paths() { + assert_eq!( + super::extract_paths("paths(p1)".to_string()), + "p1".to_string() + ); + } + + #[test] + fn test_extract_paths_empty() { + assert_eq!(super::extract_paths("".to_string()), "".to_string()); + } + + #[test] + fn test_build_new_openapi_attributes() { + assert_eq!( + super::build_new_openapi_attributes( + "".to_string(), + &"./src".to_string(), + &"".to_string(), + &"".to_string(), + ) + .to_token_stream() + .to_string() + .replace(" ", ""), + "#[openapi(paths(./src),components(schemas(),responses()),)]".to_string() + ); + } + + #[test] + fn test_build_new_openapi_attributes_path_replace() { + assert_eq!( + super::build_new_openapi_attributes( + "paths(p1)".to_string(), + &"./src,".to_string(), + &"".to_string(), + &"".to_string(), + ) + .to_token_stream() + .to_string() + .replace(" ", ""), + "#[openapi(paths(./src,p1),components(schemas(),responses()),)]".to_string() + ); + } + + #[test] + fn test_build_new_openapi_attributes_components() { + assert_eq!( + super::build_new_openapi_attributes( + "paths(p1)".to_string(), + &"./src,".to_string(), + &"model".to_string(), + &"".to_string() + ) + .to_token_stream() + .to_string() + .replace(" ", ""), + "#[openapi(paths(./src,p1),components(schemas(model),responses()),)]".to_string() + ); + } + + #[test] + fn test_build_new_openapi_attributes_components_schemas_replace() { + assert_eq!( + super::build_new_openapi_attributes( + "paths(p1), components(schemas(m1))".to_string(), + &"./src,".to_string(), + &"model,".to_string(), + &"".to_string(), + ) + .to_token_stream() + .to_string() + .replace(" ", ""), + "#[openapi(paths(./src,p1),components(schemas(model,m1),responses()),)]".to_string() + ); + } + + #[test] + fn test_build_new_openapi_attributes_components_responses_replace() { + assert_eq!( + super::build_new_openapi_attributes( + "paths(p1), components(responses(r1))".to_string(), + &"./src,".to_string(), + &"".to_string(), + &"response,".to_string(), + ) + .to_token_stream() + .to_string() + .replace(" ", ""), + "#[openapi(paths(./src,p1),components(schemas(),responses(response,r1)),)]".to_string() + ); + } + + #[test] + fn test_build_new_openapi_attributes_components_responses_schemas_replace() { + assert_eq!( + super::build_new_openapi_attributes( + "paths(p1), components(responses(r1), schemas(m1))".to_string(), + &"./src,".to_string(), + &"model,".to_string(), + &"response,".to_string(), + ) + .to_token_stream() + .to_string() + .replace(" ", ""), + "#[openapi(paths(./src,p1),components(schemas(model,m1),responses(response,r1)),)]" + .to_string() + ); + } + + #[test] + fn test_build_new_openapi_attributes_components_responses_schemas() { + assert_eq!( + super::build_new_openapi_attributes( + "paths(p1), components(responses(r1), schemas(m1))".to_string(), + &"./src,".to_string(), + &"".to_string(), + &"response,".to_string(), + ) + .to_token_stream() + .to_string() + .replace(" ", ""), + "#[openapi(paths(./src,p1),components(schemas(m1),responses(response,r1)),)]" + .to_string() + ); + } + + #[test] + fn test_build_new_openapi_attributes_components_schemas_reponses() { + assert_eq!( + super::build_new_openapi_attributes( + "paths(p1), components(schemas(m1), responses(r1))".to_string(), + &"./src,".to_string(), + &"model,".to_string(), + &"".to_string(), + ) + .to_token_stream() + .to_string() + .replace(" ", ""), + "#[openapi(paths(./src,p1),components(schemas(model,m1),responses(r1)),)]".to_string() + ); + } +} diff --git a/utoipa-auto-core/src/discover.rs b/utoipa-auto-core/src/discover.rs new file mode 100644 index 0000000..090b146 --- /dev/null +++ b/utoipa-auto-core/src/discover.rs @@ -0,0 +1,147 @@ +use std::vec; + +use syn::{punctuated::Punctuated, ItemFn, ItemMod, ItemStruct, Meta, Token}; + +use crate::file_utils::{extract_module_name_from_path, parse_files}; + +/// Discover everything from a file, will explore folder recursively +pub fn discover_from_file(src_path: String) -> (Vec, Vec, Vec) { + let mut fns_name: Vec = vec![]; + let mut models_name: Vec = vec![]; + let mut responses_name: Vec = vec![]; + let files = + parse_files(&src_path).unwrap_or_else(|_| panic!("Failed to parse file {}", src_path)); + + for file in files { + let filename = file.0; + let file = file.1; + for i in file.items { + let fns = match &i { + syn::Item::Mod(m) => parse_module_fns(&m), + syn::Item::Fn(f) => parse_function(&f), + _ => vec![], + }; + let (models, reponses) = match i { + syn::Item::Mod(m) => parse_module_structs(&m), + syn::Item::Struct(s) => parse_struct(&s), + _ => (vec![], vec![]), + }; + for fn_name in fns { + fns_name.push(build_path(&filename, &fn_name)); + } + for model_name in models { + models_name.push(build_path(&filename, &model_name)); + } + for response_name in reponses { + responses_name.push(build_path(&filename, &response_name)); + } + } + } + (fns_name, models_name, responses_name) +} + +fn build_path(file_name: &String, fn_name: &String) -> String { + format!("{}::{}", extract_module_name_from_path(file_name), fn_name) +} + +/// Search for ToSchema and ToResponse implementations +fn parse_struct(t: &ItemStruct) -> (Vec, Vec) { + let mut models_name: Vec = vec![]; + let mut responses_name: Vec = vec![]; + let attrs = &t.attrs; + for attr in attrs { + let meta = &attr.meta; + if meta.path().is_ident("utoipa_ignore") { + return (vec![], vec![]); + } + if meta.path().is_ident("derive") { + let nested = attr + .parse_args_with(Punctuated::::parse_terminated) + .unwrap(); + for nested_meta in nested { + if nested_meta.path().is_ident("ToSchema") { + models_name.push(t.ident.to_string()); + } + if nested_meta.path().is_ident("ToResponse") { + responses_name.push(t.ident.to_string()); + } + } + } + } + + (models_name, responses_name) +} + +fn parse_module_fns(m: &ItemMod) -> Vec { + let mut fns_name: Vec = vec![]; + if let Some((_, items)) = &m.content { + for it in items { + match it { + syn::Item::Mod(m) => fns_name.append(&mut parse_module_fns(m)), + syn::Item::Fn(f) => fns_name.append( + &mut parse_function(f) + .into_iter() + .map(|item| format!("{}::{}", m.ident, item)) + .collect::>(), + ), + + _ => {} + } + } + } + fns_name +} + +fn parse_module_structs(m: &ItemMod) -> (Vec, Vec) { + let mut models_name: Vec = vec![]; + let mut responses_name: Vec = vec![]; + if let Some((_, items)) = &m.content { + for it in items { + let (models, reponses) = match it { + syn::Item::Mod(m) => parse_module_structs(m), + syn::Item::Struct(s) => parse_struct(s), + + _ => (vec![], vec![]), + }; + for model_name in models { + models_name.push(format!("{}::{}", m.ident, model_name)); + } + for response_name in reponses { + responses_name.push(format!("{}::{}", m.ident, response_name)); + } + } + } + (models_name, responses_name) +} + +fn parse_function(f: &ItemFn) -> Vec { + let mut fns_name: Vec = vec![]; + if should_parse_fn(f) { + for i in 0..f.attrs.len() { + if f.attrs[i] + .meta + .path() + .segments + .iter() + .any(|item| item.ident.eq("utoipa")) + { + fns_name.push(f.sig.ident.to_string()); + } + } + } + fns_name +} + +fn should_parse_fn(f: &ItemFn) -> bool { + !f.attrs.is_empty() && !is_ignored(f) +} + +fn is_ignored(f: &ItemFn) -> bool { + f.attrs.iter().any(|attr| { + if let Some(name) = attr.path().get_ident() { + name.eq("utoipa_ignore") + } else { + false + } + }) +} diff --git a/utoipa-auto-core/src/file_utils.rs b/utoipa-auto-core/src/file_utils.rs new file mode 100644 index 0000000..e350a51 --- /dev/null +++ b/utoipa-auto-core/src/file_utils.rs @@ -0,0 +1,120 @@ +use std::{ + fs::{self, File}, + io::{self, Read}, + path::PathBuf, +}; + +pub fn parse_file>(filepath: T) -> Result { + let pb: PathBuf = filepath.into(); + + if !pb.is_file() { + panic!("File not found: {:?}", pb); + } + + let mut file = File::open(&pb)?; + let mut content = String::new(); + file.read_to_string(&mut content)?; + + Ok(syn::parse_file(&content).unwrap_or_else(move |_| panic!("Failed to parse file {:?}", pb))) +} + +/// Parse all the files in the given path +pub fn parse_files>(path: T) -> Result, io::Error> { + let mut files: Vec<(String, syn::File)> = vec![]; + + let pb: PathBuf = path.into(); + if pb.is_file() { + // we only parse rust files + if is_rust_file(&pb) { + files.push((pb.to_str().unwrap().to_string(), parse_file(pb)?)); + } + } else { + for entry in fs::read_dir(pb)? { + let entry = entry?; + let path = entry.path(); + if path.is_file() && is_rust_file(&path) { + files.push((path.to_str().unwrap().to_string(), parse_file(path)?)); + } else { + files.append(&mut parse_files(path)?); + } + } + } + Ok(files) +} + +fn is_rust_file(path: &PathBuf) -> bool { + path.is_file() && path.extension().unwrap().to_str().unwrap().eq("rs") +} + +/// Extract the module name from the file path +/// # Example +/// ``` +/// let module_name = extract_module_name_from_path( +/// &"./utoipa-auto-macro/tests/controllers/controller1.rs".to_string() +/// ); +/// assert_eq!( +/// module_name, +/// "crate::tests::controllers::controller1".to_string() +/// ); +/// ``` +pub fn extract_module_name_from_path(path: &String) -> String { + let mut path = path.to_string(); + if path.ends_with(".rs") { + path = path.replace(".rs", ""); + } + if path.ends_with("/mod") { + path = path.replace("/mod", ""); + } + if path.ends_with("/lib") { + path = path.replace("/lib", ""); + } + if path.ends_with("/main") { + path = path.replace("/main", ""); + } + path = path.replace("./", ""); + //remove first word + let mut path_vec = path.split('/').collect::>(); + path_vec[0] = "crate"; + path_vec.join("::") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_module_name_from_path() { + assert_eq!( + extract_module_name_from_path( + &"./utoipa-auto-macro/tests/controllers/controller1.rs".to_string() + ), + "crate::tests::controllers::controller1" + ); + } + + #[test] + fn test_extract_module_name_from_mod() { + assert_eq!( + extract_module_name_from_path( + &"./utoipa-auto-macro/tests/controllers/mod.rs".to_string() + ), + "crate::tests::controllers" + ); + } + + #[test] + fn test_extract_module_name_from_lib() { + assert_eq!( + extract_module_name_from_path(&"./src/lib.rs".to_string()), + "crate" + ); + } + + #[test] + fn test_extract_module_name_from_main() { + assert_eq!( + extract_module_name_from_path(&"./src/main.rs".to_string()), + "crate" + ); + } +} diff --git a/utoipa-auto-core/src/lib.rs b/utoipa-auto-core/src/lib.rs new file mode 100644 index 0000000..c3acd8a --- /dev/null +++ b/utoipa-auto-core/src/lib.rs @@ -0,0 +1,9 @@ +pub mod attribute_utils; +pub mod discover; +pub mod file_utils; +pub mod string_utils; +pub mod token_utils; +extern crate proc_macro; +extern crate proc_macro2; +extern crate quote; +extern crate syn; diff --git a/utoipa-auto-core/src/pair.rs b/utoipa-auto-core/src/pair.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/utoipa-auto-core/src/pair.rs @@ -0,0 +1 @@ + diff --git a/utoipa-auto-core/src/string_utils.rs b/utoipa-auto-core/src/string_utils.rs new file mode 100644 index 0000000..03eea0f --- /dev/null +++ b/utoipa-auto-core/src/string_utils.rs @@ -0,0 +1,142 @@ +use crate::discover::discover_from_file; + +pub fn rem_first_and_last(value: &str) -> &str { + let mut chars = value.chars(); + chars.next(); + chars.next_back(); + chars.as_str() +} + +pub fn trim_whites(str: &str) -> String { + let s = str.trim(); + + let s: String = s.replace('\n', ""); + + s +} + +pub fn trim_parentheses(str: &str) -> String { + let s = str.trim(); + + let s: String = s.replace(['(', ')'], ""); + + s +} + +/// Extract the file paths from the attributes +/// Support the old syntax (MODULE_TREE_PATH => MODULE_SRC_PATH) ; (MODULE_TREE_PATH => MODULE_SRC_PATH) ; +/// and the new syntax MODULE_SRC_PATH, MODULE_SRC_PATH +/// +/// # Example +/// ``` +/// let paths = extract_paths( +/// "\"(utoipa_auto_macro::tests::controllers::controller1 => ./utoipa-auto-macro/tests/controllers/controller1.rs) ; (utoipa_auto_macro::tests::controllers::controller2 => ./utoipa-auto-macro/tests/controllers/controller2.rs)\"" +/// .to_string() +/// ); +/// assert_eq!( +/// paths, +/// vec![ +/// "./utoipa-auto-macro/tests/controllers/controller1.rs".to_string(), +/// "./utoipa-auto-macro/tests/controllers/controller2.rs".to_string() +/// ] +/// ); +/// ``` +pub fn extract_paths(attributes: String) -> Vec { + let paths; + let attributes = trim_parentheses(rem_first_and_last(&attributes.as_str())); + + if attributes.contains('|') { + panic!("Please use the new syntax ! paths=\"(MODULE_TREE_PATH => MODULE_SRC_PATH) ;\"") + } + if attributes.contains("=>") { + paths = extract_paths_arrow(attributes); + } else { + paths = extract_paths_coma(attributes); + } + if paths.is_empty() { + panic!("utoipa_auto_discovery: No paths specified !") + } + paths +} + +// (MODULE_TREE_PATH => MODULE_SRC_PATH) ; (MODULE_TREE_PATH => MODULE_SRC_PATH) ; +// extract the paths from the string +// Here for legacy support +fn extract_paths_arrow(attributes: String) -> Vec { + let mut paths: Vec = vec![]; + let attributes = attributes.split(';'); + + for p in attributes { + let pair = p.split_once("=>"); + + if let Some(pair) = pair { + paths.push(trim_whites(pair.1)); + } + } + paths +} + +// MODULE_SRC_PATH, MODULE_SRC_PATH +fn extract_paths_coma(attributes: String) -> Vec { + let mut paths: Vec = vec![]; + let attributes = attributes.split(','); + + for p in attributes { + paths.push(trim_whites(p)); + } + paths +} + +/// Return the list of all the functions with the #[utoipa] attribute +/// and the list of all the structs with the #[derive(ToSchema)] attribute +/// and the list of all the structs with the #[derive(ToResponse)] attribute +pub fn discover(paths: Vec) -> (String, String, String) { + let mut uto_paths: String = String::new(); + let mut uto_models: String = String::new(); + let mut uto_reponses: String = String::new(); + for p in paths { + let (list_fn, list_model, list_reponse) = discover_from_file(p); + // We need to add a coma after each path + for i in list_fn { + uto_paths.push_str(format!("{},", i).as_str()); + } + for i in list_model { + uto_models.push_str(format!("{},", i).as_str()); + } + for i in list_reponse { + uto_reponses.push_str(format!("{},", i).as_str()); + } + } + (uto_paths, uto_models, uto_reponses) +} + +#[cfg(test)] +mod test { + #[test] + fn test_extract_paths_arrow() { + assert_eq!( + super::extract_paths( + "\"(utoipa_auto_macro::tests::controllers::controller1 => ./utoipa-auto-macro/tests/controllers/controller1.rs) ; (utoipa_auto_macro::tests::controllers::controller2 => ./utoipa-auto-macro/tests/controllers/controller2.rs)\"" + .to_string() + ), + vec![ + "./utoipa-auto-macro/tests/controllers/controller1.rs".to_string(), + "./utoipa-auto-macro/tests/controllers/controller2.rs".to_string() + ] + ); + } + + #[test] + fn test_extract_paths_coma() { + assert_eq!( + super::extract_paths( + "\"./utoipa-auto-macro/tests/controllers/controller1.rs, ./utoipa-auto-macro/tests/controllers/controller2.rs\"" + .to_string() + ), + vec![ + "./utoipa-auto-macro/tests/controllers/controller1.rs".to_string(), + "./utoipa-auto-macro/tests/controllers/controller2.rs".to_string() + ] + ); + } +} diff --git a/utoipa-auto-core/src/token_utils.rs b/utoipa-auto-core/src/token_utils.rs new file mode 100644 index 0000000..dc32651 --- /dev/null +++ b/utoipa-auto-core/src/token_utils.rs @@ -0,0 +1,63 @@ +use proc_macro::TokenStream; +use quote::quote; +use syn::Attribute; +/// Extract the paths string attribute from the proc_macro::TokenStream +/// +/// If none is specified, we use the default path "./src" +pub fn extract_attributes(stream: proc_macro2::TokenStream) -> String { + let mut paths: String = "".to_string(); + let mut has_paths = false; + if !stream.is_empty() { + let mut it = stream.into_iter(); + let tok = it.next(); + + if let Some(proc_macro2::TokenTree::Ident(ident)) = tok { + if ident.to_string().eq("paths") { + has_paths = true; + let tok = it.next(); + if let Some(proc_macro2::TokenTree::Punct(punct)) = tok { + if punct.to_string().eq("=") { + let tok = it.next(); + if let Some(tok) = tok { + match tok { + proc_macro2::TokenTree::Literal(lit) => { + paths = lit.to_string(); + } + _ => { + panic!("malformed paths !") + } + } + } + } + } + } + } + } + // if no paths specified, we use the default path "./src" + if !has_paths { + "\"./src\"".to_string() + } else { + paths + } +} + +/// Check if the macro is placed before the #[derive] and #[openapi] attributes +/// Otherwise, panic! +pub fn check_macro_placement(attrs: Vec) { + if !attrs.iter().any(|elm| elm.path().is_ident("derive")) { + panic!("Please put utoipa_auto_discovery before #[derive] and #[openapi]"); + } + + if !attrs.iter().any(|elm| elm.path().is_ident("openapi")) { + panic!("Please put utoipa_auto_discovery before #[derive] and #[openapi]"); + } +} + +// Output the macro back to the compiler +pub fn output_macro(openapi_macro: syn::ItemStruct) -> proc_macro::TokenStream { + let code = quote!( + #openapi_macro + ); + + TokenStream::from(code) +} diff --git a/utoipa-auto-macro/Cargo.lock b/utoipa-auto-macro/Cargo.lock new file mode 100644 index 0000000..a1e4774 --- /dev/null +++ b/utoipa-auto-macro/Cargo.lock @@ -0,0 +1,57 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "proc-macro2" +version = "1.0.70" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39278fbbf5fb4f646ce651690877f89d1c5811a3d4acb27700c1cb3cdb78fd3b" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "syn" +version = "2.0.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23e78b90f2fcf45d3e842032ce32e3f2d1545ba6636271dcbf24fa306d87be7a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "utoipa-auto-core" +version = "0.4.0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "utoipa-auto-macro" +version = "0.4.0" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "utoipa-auto-core", +] diff --git a/utoipa-auto-macro/Cargo.toml b/utoipa-auto-macro/Cargo.toml new file mode 100644 index 0000000..00ada4c --- /dev/null +++ b/utoipa-auto-macro/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "utoipa-auto-macro" +version = "0.4.0" +edition = "2021" +authors = ["RxDiscovery"] +rust-version = "1.69" +keywords = ["utoipa", "openapi", "swagger", "path", "auto"] +description = "Rust Macros to automate the addition of Paths/Schemas to Utoipa crate, simulating Reflection during the compilation phase" +categories = [ + "parsing", + "development-tools::procedural-macro-helpers", + "web-programming", +] +license = "MIT OR Apache-2.0" +readme = "README.md" +repository = "https://github.com/rxdiscovery/utoipa_auto_discovery" +homepage = "https://github.com/rxdiscovery/utoipa_auto_discovery" + + +[lib] +proc-macro = true + +[dependencies] +utoipa-auto-core = { version = "0.4.0", path = "../utoipa-auto-core" } +quote = "1.0.28" +syn = { version = "2.0.18", features = ["full"] } +proc-macro2 = "1.0.59" diff --git a/utoipa-auto-macro/src/lib.rs b/utoipa-auto-macro/src/lib.rs new file mode 100644 index 0000000..ddc1e42 --- /dev/null +++ b/utoipa-auto-macro/src/lib.rs @@ -0,0 +1,60 @@ +extern crate proc_macro; + +use attribute_utils::update_openapi_macro_attributes; +use proc_macro::TokenStream; + +use quote::quote; +use string_utils::{discover, extract_paths}; +use syn::parse_macro_input; +use token_utils::{check_macro_placement, extract_attributes, output_macro}; +use utoipa_auto_core::{attribute_utils, string_utils, token_utils}; + +/// Macro to automatically discover all the functions with the #[utoipa] attribute +/// And the struct deriving ToSchema and ToResponse +#[proc_macro_attribute] +pub fn utoipa_auto_discovery( + attributes: proc_macro::TokenStream, // #[utoipa_auto_discovery(paths = "(MODULE_TREE_PATH => MODULE_SRC_PATH) ;")] + item: proc_macro::TokenStream, // #[openapi(paths = "")] +) -> proc_macro::TokenStream { + // (MODULE_TREE_PATH => MODULE_SRC_PATH) ; (MODULE_TREE_PATH => MODULE_SRC_PATH) ; ... + let paths: String = extract_attributes(attributes.into()); + // [(MODULE_TREE_PATH, MODULE_SRC_PATH)] + let paths: Vec = extract_paths(paths); + + // #[openapi(...)] + let mut openapi_macro = parse_macro_input!(item as syn::ItemStruct); + + // Discover all the functions with the #[utoipa] attribute + let (uto_paths, uto_models, uto_reponses): (String, String, String) = discover(paths); + + // extract the openapi macro attributes : #[openapi(openapi_macro_attibutes)] + let openapi_macro_attibutes = &mut openapi_macro.attrs; + + // Check if the macro is placed before the #[derive] and #[openapi] attributes + check_macro_placement(openapi_macro_attibutes.clone()); + + // Update the openapi macro attributes with the newly discovered paths + update_openapi_macro_attributes( + openapi_macro_attibutes, + &uto_paths, + &uto_models, + &uto_reponses, + ); + + // Output the macro back to the compiler + output_macro(openapi_macro) +} + +/// Ignore the function from the auto discovery +#[proc_macro_attribute] +pub fn utoipa_ignore( + _attr: proc_macro::TokenStream, + item: proc_macro::TokenStream, +) -> proc_macro::TokenStream { + let input = parse_macro_input!(item as syn::Item); + let code = quote!( + #input + ); + + TokenStream::from(code) +}