Skip to content

Commit

Permalink
Add support for api/ folder (#129)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
Valerioageno authored Nov 21, 2024
1 parent 3a4e739 commit 087813f
Show file tree
Hide file tree
Showing 16 changed files with 246 additions and 74 deletions.
3 changes: 2 additions & 1 deletion crates/tuono/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "tuono"
version = "0.13.3"
version = "0.14.0"
edition = "2021"
authors = ["V. Ageno <[email protected]>"]
description = "Superfast React fullstack framework"
Expand Down Expand Up @@ -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"
20 changes: 20 additions & 0 deletions crates/tuono/src/app.rs
Original file line number Diff line number Diff line change
@@ -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::*;
Expand Down Expand Up @@ -115,6 +117,24 @@ impl App {
.spawn()
.expect("Failed to run the rust server")
}

pub fn get_used_http_methods(&self) -> HashSet<Method> {
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)]
Expand Down
56 changes: 54 additions & 2 deletions crates/tuono/src/route.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<Method> {
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::<Vec<Method>>()
}

#[derive(Debug, PartialEq, Eq, Clone)]
pub struct ApiData {
pub methods: Vec<Method>,
}

impl ApiData {
pub fn new(path: &String) -> Option<Self> {
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<AxumInfo>,
pub api_data: Option<ApiData>,
}

impl Route {
Expand All @@ -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()))
}
Expand Down
46 changes: 26 additions & 20 deletions crates/tuono/src/source_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,23 @@ fn create_routes_declaration(routes: &HashMap<String, Route>) -> 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))"#
));
}
}
}
}

Expand Down Expand Up @@ -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::<Vec<(&String, &Route)>>()
.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<()> {
Expand Down
4 changes: 2 additions & 2 deletions crates/tuono_lib/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "tuono_lib"
version = "0.13.3"
version = "0.14.0"
edition = "2021"
authors = ["V. Ageno <[email protected]>"]
description = "Superfast React fullstack framework"
Expand Down Expand Up @@ -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"] }
Expand Down
2 changes: 1 addition & 1 deletion crates/tuono_lib/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion crates/tuono_lib_macros/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
69 changes: 69 additions & 0 deletions crates/tuono_lib_macros/src/api.rs
Original file line number Diff line number Diff line change
@@ -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<Pat, Comma> = Punctuated::new();
let mut axum_arguments: Punctuated<FnArg, Comma> = 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()
}
49 changes: 6 additions & 43 deletions crates/tuono_lib_macros/src/handler.rs
Original file line number Diff line number Diff line change
@@ -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<ApplicationState>
})
.unwrap()
}

fn import_main_application_state(argument_names: Punctuated<Pat, Comma>) -> Option<Stmt> {
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<Pat, Comma>) -> Option<Stmt> {
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<String, String>
>
})
.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);

Expand Down
7 changes: 7 additions & 0 deletions crates/tuono_lib_macros/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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)
}
Loading

0 comments on commit 087813f

Please sign in to comment.