From 087813fab9ccc44154fb56d3188c891d588608d0 Mon Sep 17 00:00:00 2001 From: Valerio Ageno <51341197+Valerioageno@users.noreply.github.com> Date: Thu, 21 Nov 2024 19:02:11 +0100 Subject: [PATCH] Add support for `api/` folder (#129) * feat: detect api/ folder from tuono CLI * feat: prevent adding the API in the data endpoints list * feat: create basic API handler proc macro * feat: parse api/ and build .tuono/main.rs file * chore: remove unused cargo dependencies * refactor: tuono base health_check * fix: remove failing test * feat: update version to v0.14.0 --- crates/tuono/Cargo.toml | 3 +- crates/tuono/src/app.rs | 20 ++++++ crates/tuono/src/route.rs | 56 ++++++++++++++- crates/tuono/src/source_builder.rs | 46 +++++++------ crates/tuono_lib/Cargo.toml | 4 +- crates/tuono_lib/src/lib.rs | 2 +- crates/tuono_lib_macros/Cargo.toml | 2 +- crates/tuono_lib_macros/src/api.rs | 69 +++++++++++++++++++ crates/tuono_lib_macros/src/handler.rs | 49 ++----------- crates/tuono_lib_macros/src/lib.rs | 7 ++ crates/tuono_lib_macros/src/utils.rs | 47 +++++++++++++ examples/tuono/src/routes/api/health_check.rs | 7 ++ packages/fs-router-vite-plugin/package.json | 2 +- packages/lazy-fn-vite-plugin/package.json | 2 +- packages/router/package.json | 2 +- packages/tuono/package.json | 2 +- 16 files changed, 246 insertions(+), 74 deletions(-) create mode 100644 crates/tuono_lib_macros/src/api.rs create mode 100644 crates/tuono_lib_macros/src/utils.rs create mode 100644 examples/tuono/src/routes/api/health_check.rs diff --git a/crates/tuono/Cargo.toml b/crates/tuono/Cargo.toml index 60762d04..97d4ca85 100644 --- a/crates/tuono/Cargo.toml +++ b/crates/tuono/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tuono" -version = "0.13.3" +version = "0.14.0" edition = "2021" authors = ["V. Ageno "] description = "Superfast React fullstack framework" @@ -32,3 +32,4 @@ regex = "1.10.4" reqwest = {version = "0.12.4", features =["blocking", "json"]} serde_json = "1.0" fs_extra = "1.3.0" +http = "1.1.0" diff --git a/crates/tuono/src/app.rs b/crates/tuono/src/app.rs index 5aaec93d..d800444c 100644 --- a/crates/tuono/src/app.rs +++ b/crates/tuono/src/app.rs @@ -1,5 +1,7 @@ use glob::glob; use glob::GlobError; +use http::Method; +use std::collections::hash_set::HashSet; use std::collections::{hash_map::Entry, HashMap}; use std::fs::File; use std::io::prelude::*; @@ -115,6 +117,24 @@ impl App { .spawn() .expect("Failed to run the rust server") } + + pub fn get_used_http_methods(&self) -> HashSet { + let mut acc = HashSet::new(); + + for (_, route) in self.route_map.clone().into_iter() { + if route.axum_info.is_some() { + acc.insert(Method::GET); + } + if !route.is_api() { + continue; + } + for method in route.api_data.unwrap().methods.into_iter() { + acc.insert(method); + } + } + + acc + } } #[cfg(test)] diff --git a/crates/tuono/src/route.rs b/crates/tuono/src/route.rs index 3c005a9a..b37a181b 100644 --- a/crates/tuono/src/route.rs +++ b/crates/tuono/src/route.rs @@ -1,17 +1,19 @@ use fs_extra::dir::create_all; +use http::Method; use regex::Regex; use reqwest::blocking::Client; use reqwest::Url; use std::fs::File; use std::io; use std::path::PathBuf; +use std::str::FromStr; fn has_dynamic_path(route: &str) -> bool { let regex = Regex::new(r"\[(.*?)\]").expect("Failed to create the regex"); regex.is_match(route) } -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, PartialEq, Eq, Clone)] pub struct AxumInfo { // Path for importing the module pub module_import: String, @@ -65,11 +67,56 @@ impl AxumInfo { // TODO: to be extended with common scenarios const NO_HTML_EXTENSIONS: [&str; 1] = ["xml"]; -#[derive(Debug, PartialEq, Eq)] +// TODO: Refine this function to catch +// if the methods are commented. +fn read_http_methods_from_file(path: &String) -> Vec { + let regex = Regex::new(r"tuono_lib::api\((.*?)\)]").expect("Failed to create API regex"); + + let file = fs_extra::file::read_to_string(path).expect("Failed to read API file"); + + regex + .find_iter(&file) + .map(|proc_macro| { + let http_method = proc_macro + .as_str() + // Extract just the element surrounded by the phrantesist. + .replace("tuono_lib::api(", "") + .replace(")]", ""); + Method::from_str(http_method.as_str()).unwrap_or(Method::GET) + }) + .collect::>() +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct ApiData { + pub methods: Vec, +} + +impl ApiData { + pub fn new(path: &String) -> Option { + if !path.starts_with("/api/") { + return None; + } + + let base_path = std::env::current_dir().expect("Failed to get the base_path"); + + let file_path = base_path + .join(format!("src/routes{path}.rs")) + .to_str() + .unwrap() + .to_string(); + let methods = read_http_methods_from_file(&file_path); + + Some(ApiData { methods }) + } +} + +#[derive(Debug, PartialEq, Eq, Clone)] pub struct Route { path: String, pub is_dynamic: bool, pub axum_info: Option, + pub api_data: Option, } impl Route { @@ -78,9 +125,14 @@ impl Route { path: cleaned_path.clone(), axum_info: None, is_dynamic: has_dynamic_path(&cleaned_path), + api_data: ApiData::new(&cleaned_path), } } + pub fn is_api(&self) -> bool { + self.api_data.is_some() + } + pub fn update_axum_info(&mut self) { self.axum_info = Some(AxumInfo::new(self.path.clone())) } diff --git a/crates/tuono/src/source_builder.rs b/crates/tuono/src/source_builder.rs index 43c93189..490660af 100644 --- a/crates/tuono/src/source_builder.rs +++ b/crates/tuono/src/source_builder.rs @@ -82,13 +82,23 @@ fn create_routes_declaration(routes: &HashMap) -> String { module_import, } = axum_info.as_ref().unwrap(); - route_declarations.push_str(&format!( - r#".route("{axum_route}", get({module_import}::route))"# - )); - let slash = if axum_route.ends_with('/') { "" } else { "/" }; - route_declarations.push_str(&format!( - r#".route("/__tuono/data{axum_route}{slash}data.json", get({module_import}::api))"# - )); + if !route.is_api() { + route_declarations.push_str(&format!( + r#".route("{axum_route}", get({module_import}::route))"# + )); + let slash = if axum_route.ends_with('/') { "" } else { "/" }; + + route_declarations.push_str(&format!( + r#".route("/__tuono/data{axum_route}{slash}data.json", get({module_import}::api))"# + )); + } else { + for method in route.api_data.as_ref().unwrap().methods.clone() { + let method = method.to_string().to_lowercase(); + route_declarations.push_str(&format!( + r#".route("{axum_route}", {method}({module_import}::{method}__tuono_internal_api))"# + )); + } + } } } @@ -164,20 +174,16 @@ fn generate_axum_source(app: &App, mode: Mode) -> String { }, ); - let has_server_handlers = app - .route_map - .iter() - .filter(|(_, route)| route.axum_info.is_some()) - .collect::>() - .is_empty(); - - if !has_server_handlers { - return src.replace( - "// AXUM_GET_ROUTE_HANDLER", - "use tuono_lib::axum::routing::get;", - ); + let mut import_http_handler = String::new(); + + let used_http_methods = app.get_used_http_methods(); + + for method in used_http_methods.into_iter() { + let method = method.to_string().to_lowercase(); + import_http_handler.push_str(&format!("use tuono_lib::axum::routing::{method};\n")) } - src + + src.replace("// AXUM_GET_ROUTE_HANDLER", &import_http_handler) } pub fn check_tuono_folder() -> io::Result<()> { diff --git a/crates/tuono_lib/Cargo.toml b/crates/tuono_lib/Cargo.toml index 260a6b1c..cdfeae12 100644 --- a/crates/tuono_lib/Cargo.toml +++ b/crates/tuono_lib/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tuono_lib" -version = "0.13.3" +version = "0.14.0" edition = "2021" authors = ["V. Ageno "] description = "Superfast React fullstack framework" @@ -31,7 +31,7 @@ either = "1.13.0" tower-http = {version = "0.6.0", features = ["fs"]} colored = "2.1.0" -tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.13.3"} +tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.14.0"} # Match the same version used by axum tokio-tungstenite = "0.24.0" futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] } diff --git a/crates/tuono_lib/src/lib.rs b/crates/tuono_lib/src/lib.rs index d02b4ff9..fd844e5d 100644 --- a/crates/tuono_lib/src/lib.rs +++ b/crates/tuono_lib/src/lib.rs @@ -15,7 +15,7 @@ pub use payload::Payload; pub use request::Request; pub use response::{Props, Response}; pub use server::Server; -pub use tuono_lib_macros::handler; +pub use tuono_lib_macros::{api, handler}; // Re-exports pub use axum; diff --git a/crates/tuono_lib_macros/Cargo.toml b/crates/tuono_lib_macros/Cargo.toml index 6c09ce91..b38d5988 100644 --- a/crates/tuono_lib_macros/Cargo.toml +++ b/crates/tuono_lib_macros/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tuono_lib_macros" -version = "0.13.3" +version = "0.14.0" edition = "2021" description = "Superfast React fullstack framework" homepage = "https://tuono.dev" diff --git a/crates/tuono_lib_macros/src/api.rs b/crates/tuono_lib_macros/src/api.rs new file mode 100644 index 00000000..4ba987ec --- /dev/null +++ b/crates/tuono_lib_macros/src/api.rs @@ -0,0 +1,69 @@ +use crate::utils::{ + crate_application_state_extractor, create_struct_fn_arg, import_main_application_state, + params_argument, request_argument, +}; +use proc_macro::{Span, TokenStream}; +use quote::quote; +use syn::punctuated::Punctuated; +use syn::token::Comma; +use syn::{parse_macro_input, FnArg, Ident, ItemFn, Pat}; + +pub fn api_core(attrs: TokenStream, item: TokenStream) -> TokenStream { + let item = parse_macro_input!(item as ItemFn); + let http_method = parse_macro_input!(attrs as Ident) + .to_string() + .to_lowercase(); + + let api_fn_name = Ident::new( + &format!("{}__tuono_internal_api", http_method), + Span::call_site().into(), + ); + + let fn_name = &item.sig.ident; + let return_type = &item.sig.output; + + let mut argument_names: Punctuated = Punctuated::new(); + let mut axum_arguments: Punctuated = Punctuated::new(); + + // Fn Arguments minus the first which always is the request + for (i, arg) in item.sig.inputs.iter().enumerate() { + if i == 0 { + axum_arguments.insert(i, params_argument()); + continue; + } + + if i == 1 { + axum_arguments.insert(1, create_struct_fn_arg()) + } + + if let FnArg::Typed(pat_type) = arg { + let index = i - 1; + let argument_name = *pat_type.pat.clone(); + argument_names.insert(index, argument_name.clone()); + } + } + + axum_arguments.insert(axum_arguments.len(), request_argument()); + + let application_state_extractor = crate_application_state_extractor(argument_names.clone()); + let application_state_import = import_main_application_state(argument_names.clone()); + + quote! { + #application_state_import + + #item + + pub async fn #api_fn_name(#axum_arguments)#return_type { + + #application_state_extractor + + let pathname = request.uri(); + let headers = request.headers(); + + let req = tuono_lib::Request::new(pathname.to_owned(), headers.to_owned(), params); + + #fn_name(req.clone(), #argument_names).await + } + } + .into() +} diff --git a/crates/tuono_lib_macros/src/handler.rs b/crates/tuono_lib_macros/src/handler.rs index f01b79e3..74b115c0 100644 --- a/crates/tuono_lib_macros/src/handler.rs +++ b/crates/tuono_lib_macros/src/handler.rs @@ -1,51 +1,14 @@ +use crate::utils::{ + crate_application_state_extractor, create_struct_fn_arg, import_main_application_state, + params_argument, request_argument, +}; + use proc_macro::TokenStream; use quote::quote; use syn::punctuated::Punctuated; use syn::token::Comma; -use syn::{parse2, parse_macro_input, parse_quote, FnArg, ItemFn, Pat, Stmt}; - -fn create_struct_fn_arg() -> FnArg { - parse2(quote! { - tuono_lib::axum::extract::State(state): tuono_lib::axum::extract::State - }) - .unwrap() -} - -fn import_main_application_state(argument_names: Punctuated) -> Option { - if !argument_names.is_empty() { - let local: Stmt = parse_quote!( - use crate::tuono_main_state::ApplicationState; - ); - return Some(local); - } - - None -} +use syn::{parse_macro_input, FnArg, ItemFn, Pat}; -fn crate_application_state_extractor(argument_names: Punctuated) -> Option { - if !argument_names.is_empty() { - let use_item: Stmt = parse_quote!(let ApplicationState { #argument_names } = state;); - return Some(use_item); - } - - None -} - -fn params_argument() -> FnArg { - parse2(quote! { - tuono_lib::axum::extract::Path(params): tuono_lib::axum::extract::Path< - std::collections::HashMap - > - }) - .unwrap() -} - -fn request_argument() -> FnArg { - parse2(quote! { - request: tuono_lib::axum::extract::Request - }) - .unwrap() -} pub fn handler_core(_args: TokenStream, item: TokenStream) -> TokenStream { let item = parse_macro_input!(item as ItemFn); diff --git a/crates/tuono_lib_macros/src/lib.rs b/crates/tuono_lib_macros/src/lib.rs index 6e643cd3..5d102486 100644 --- a/crates/tuono_lib_macros/src/lib.rs +++ b/crates/tuono_lib_macros/src/lib.rs @@ -1,9 +1,16 @@ extern crate proc_macro; use proc_macro::TokenStream; +mod api; mod handler; +mod utils; #[proc_macro_attribute] pub fn handler(args: TokenStream, item: TokenStream) -> TokenStream { handler::handler_core(args, item) } + +#[proc_macro_attribute] +pub fn api(args: TokenStream, item: TokenStream) -> TokenStream { + api::api_core(args, item) +} diff --git a/crates/tuono_lib_macros/src/utils.rs b/crates/tuono_lib_macros/src/utils.rs new file mode 100644 index 00000000..df2a5a77 --- /dev/null +++ b/crates/tuono_lib_macros/src/utils.rs @@ -0,0 +1,47 @@ +use quote::quote; +use syn::punctuated::Punctuated; +use syn::token::Comma; +use syn::{parse2, parse_quote, FnArg, Pat, Stmt}; + +pub fn create_struct_fn_arg() -> FnArg { + parse2(quote! { + tuono_lib::axum::extract::State(state): tuono_lib::axum::extract::State + }) + .unwrap() +} + +pub fn import_main_application_state(argument_names: Punctuated) -> Option { + if !argument_names.is_empty() { + let local: Stmt = parse_quote!( + use crate::tuono_main_state::ApplicationState; + ); + return Some(local); + } + + None +} + +pub fn crate_application_state_extractor(argument_names: Punctuated) -> Option { + if !argument_names.is_empty() { + let use_item: Stmt = parse_quote!(let ApplicationState { #argument_names } = state;); + return Some(use_item); + } + + None +} + +pub fn params_argument() -> FnArg { + parse2(quote! { + tuono_lib::axum::extract::Path(params): tuono_lib::axum::extract::Path< + std::collections::HashMap + > + }) + .unwrap() +} + +pub fn request_argument() -> FnArg { + parse2(quote! { + request: tuono_lib::axum::extract::Request + }) + .unwrap() +} diff --git a/examples/tuono/src/routes/api/health_check.rs b/examples/tuono/src/routes/api/health_check.rs new file mode 100644 index 00000000..41bad244 --- /dev/null +++ b/examples/tuono/src/routes/api/health_check.rs @@ -0,0 +1,7 @@ +use tuono_lib::axum::http::StatusCode; +use tuono_lib::Request; + +#[tuono_lib::api(GET)] +pub async fn my_get_request(_req: Request) -> StatusCode { + StatusCode::OK +} diff --git a/packages/fs-router-vite-plugin/package.json b/packages/fs-router-vite-plugin/package.json index a9927d08..b3cd2287 100644 --- a/packages/fs-router-vite-plugin/package.json +++ b/packages/fs-router-vite-plugin/package.json @@ -1,6 +1,6 @@ { "name": "tuono-fs-router-vite-plugin", - "version": "0.13.3", + "version": "0.14.0", "description": "Plugin for the tuono's file system router. Tuono is the react/rust fullstack framework", "homepage": "https://tuono.dev", "scripts": { diff --git a/packages/lazy-fn-vite-plugin/package.json b/packages/lazy-fn-vite-plugin/package.json index 07402b30..768dc256 100644 --- a/packages/lazy-fn-vite-plugin/package.json +++ b/packages/lazy-fn-vite-plugin/package.json @@ -1,6 +1,6 @@ { "name": "tuono-lazy-fn-vite-plugin", - "version": "0.13.3", + "version": "0.14.0", "description": "Plugin for the tuono's lazy fn. Tuono is the react/rust fullstack framework", "homepage": "https://tuono.dev", "scripts": { diff --git a/packages/router/package.json b/packages/router/package.json index e5db6548..8ce46433 100644 --- a/packages/router/package.json +++ b/packages/router/package.json @@ -1,6 +1,6 @@ { "name": "tuono-router", - "version": "0.13.3", + "version": "0.14.0", "description": "React routing component for the framework tuono. Tuono is the react/rust fullstack framework", "homepage": "https://tuono.dev", "scripts": { diff --git a/packages/tuono/package.json b/packages/tuono/package.json index 0a40b3fd..e2a58f8b 100644 --- a/packages/tuono/package.json +++ b/packages/tuono/package.json @@ -1,6 +1,6 @@ { "name": "tuono", - "version": "0.13.3", + "version": "0.14.0", "description": "Superfast React fullstack framework", "homepage": "https://tuono.dev", "scripts": {