auto_http_derive/
lib.rs

1use proc_macro::TokenStream;
2use proc_macro2::{Delimiter::Parenthesis, Group, Ident, Literal, Punct, Spacing, Span};
3use quote::*;
4use regex::Regex;
5use std::{fs::read_to_string, path::Path};
6
7mod shared;
8
9use shared::{append_module_as_str, walk_through_dir};
10
11fn http_specs(module_path: &String) -> [String; 2] {
12    let module_pattern =
13        Regex::new("crate::app::(\\w+)::api::(\\w+)").expect("Is not possible to mount regexp");
14    let mut spec = [String::from("get"), String::from("")];
15    let caps = module_pattern.captures(&module_path).expect(&format!(
16        "The module `{}` don't follow the convetion",
17        module_path
18    ));
19    let module_domain = inflector::string::pluralize::to_plural(caps.get(1).unwrap().as_str());
20    let module_name = caps.get(2).unwrap().as_str();
21
22    if !module_name.starts_with("get") && !module_name.starts_with("post") {
23        panic!(
24            "{} - invalid module name, api modules should starts with `post` or `get`",
25            module_path
26        )
27    }
28
29    let mut module_pieces = module_name.split("_");
30    spec[0] = module_pieces.next().unwrap().to_string();
31    spec[1] = format!("/{}", module_domain);
32
33    let mut set_param = false;
34    while let Some(piece) = module_pieces.next() {
35        // If the module name have piece name equal to module name don't set it on endpoint
36        // This rule is for case where developer not feel comfortable with mod name like: get or
37        // post, and give name to module like get_products where the domain of api is products too.
38        if spec[1].ends_with(piece) {
39            continue;
40        }
41
42        // When a piece of module name is uppercase or is called id in this case it is a parameter and need
43        // to be setted on endpoint
44        if piece.to_uppercase() == piece || piece == "id" {
45            spec[1] = format!("{}/:{}", spec[1], piece.to_lowercase());
46            // I don't have any idea about how determine if it have more piece to set a slash
47            set_param = true;
48            continue;
49        }
50
51        // It previne to append more data where it is url parameter
52        if set_param {
53            spec[1] += "/";
54            set_param = false;
55        }
56
57        if spec[1].ends_with(&module_domain) {
58            spec[1] += "/";
59        }
60
61        if spec[1].ends_with("/") {
62            spec[1] = format!("{}{}", spec[1], piece);
63            continue;
64        }
65
66        // In normal case it append module name piece with "-"
67        // ex: module_name: get_profile_posts -> endpoint: profile-posts
68        spec[1] = format!("{}-{}", spec[1], piece);
69    }
70
71    spec
72}
73
74#[derive(Debug)]
75struct RouteToken {
76    method: String,
77    endpoint: String,
78    handle: String,
79}
80
81impl RouteToken {
82    fn new(module_path: String, fn_name: Option<&str>) -> Self {
83        let spec = http_specs(&module_path);
84        RouteToken {
85            method: spec[0].clone(),
86            endpoint: spec[1].clone(),
87            handle: module_path + "::" + fn_name.unwrap_or("handle"),
88        }
89    }
90}
91
92impl ToTokens for RouteToken {
93    fn to_tokens(&self, tokens: &mut __private::TokenStream) {
94        let mut handle_token = proc_macro2::TokenStream::new();
95        let mut route_call_tokens = proc_macro2::TokenStream::new();
96        // "endpoint/here"
97        route_call_tokens.append(Literal::string(&self.endpoint));
98        route_call_tokens.append(Punct::new(',', Spacing::Alone));
99        // ex: axum::routing::{get|post}
100        append_module_as_str(
101            format!("axum::routing::{}", self.method).as_str(),
102            &mut route_call_tokens,
103        );
104        // ex: crate::products::api::get_products::handle
105        append_module_as_str(&self.handle, &mut handle_token);
106        route_call_tokens.append(Group::new(Parenthesis, handle_token));
107
108        tokens.append(Punct::new('.', Spacing::Alone));
109        tokens.append(Ident::new("route", Span::call_site()));
110        tokens.append(Group::new(Parenthesis, route_call_tokens));
111    }
112}
113
114fn read_code(path: &str) {
115    let tokens: proc_macro2::TokenStream = read_to_string(path).unwrap().parse().unwrap();
116    // for token in tokens {
117    //     match token {
118    //         proc_macro2::TokenTree::Ident(ident) => {
119    //             if ident.to_string().eq("handle") {
120    //                 println!("{:?}", ident)
121    //             }
122    //         }
123    //         proc_macro2::TokenTree::Group(group) => {
124    //             println!("{:?}", group);
125    //         }
126    //         _ => {}
127    //     }
128    // }
129}
130
131fn write_configure_router_function(
132    container: &str,
133    input: proc_macro2::TokenStream,
134    routes: &Vec<RouteToken>,
135) -> proc_macro2::TokenStream {
136    let mut fn_name = String::new();
137
138    if container.starts_with("crate::") && container.starts_with("super::") {
139        panic!("The first attribute needs to have fully namespace, like crate::Container or super::Container.");
140    }
141
142    for token in input.clone() {
143        match token {
144            proc_macro2::TokenTree::Ident(ident) => {
145                if !["pub", "async", "fn"].contains(&ident.to_string().as_str()) {
146                    fn_name = ident.to_string();
147                    break;
148                }
149            }
150            _ => {}
151        }
152    }
153
154    if fn_name.is_empty() {
155        panic!("Cannot get name from token {:?}", input.to_string());
156    }
157
158    let founded_routes = routes.len();
159    let ident = format_ident!("{}", fn_name);
160    let mut container_type = quote! {};
161    append_module_as_str(container, &mut container_type);
162    quote! {
163        pub struct Request {}
164
165        pub fn #ident() -> axum::Router<#container_type> {
166            println!("{} configured routes.", #founded_routes);
167            let mut router = axum::Router::<#container_type>::new()
168            #(#routes)*;
169            router
170        }
171    }
172}
173
174/// This macro iterate through all matches directories, when it founds
175/// some rs files it looks for function called handle and automatically
176/// configure it on axum.
177/// The first parameter is the file tree pattern
178/// always use it with '*' wildcards patterns ex: /src/app/*/api/*.rs
179/// The second parameter of this macro is the container struct name, container
180/// means to state type that the router are going to receiver
181/// example of usage
182/// config/mod.rs
183/// #[configure_routes("./src/app/*/api/*.rs", crate::Container)]
184/// pub fn config_routes(){}
185#[proc_macro_attribute]
186pub fn configure_routes(input: TokenStream, body: TokenStream) -> TokenStream {
187    let attrs_string = input.to_string().replace("\"", "");
188    let (pattern, container) = attrs_string
189        .split_once(",")
190        .expect("This macro needs two attributes: file tree pattern and container mod.");
191
192    if !pattern.ends_with(".rs") {
193        panic!(
194            "The provided: {} pattern needs to ends with `.rs` file extension.",
195            pattern
196        );
197    }
198
199    let root_directory = pattern
200        .split_once("*")
201        .expect("only `/src/*/path/*.rs` pattern is acceptable.")
202        .0;
203
204    let path = Path::new(root_directory);
205    let regexp = Regex::new(&pattern.replace("*", "\\w+")).expect(&format!(
206        "Impossibly to build the provided pattern {}",
207        pattern
208    ));
209
210    if !path.is_dir() {
211        panic!(
212            "Directory {} not found.",
213            path.as_os_str().to_str().unwrap()
214        );
215    }
216
217    let mut routes = vec![];
218    walk_through_dir(path, &mut |_, entry| {
219        let str = entry.to_str().unwrap().replace("\\", "/");
220        // If the current file is under DOMAIN_NAME/api/ tree set it as a route
221        if regexp.is_match(&str) && !str.ends_with("mod.rs") {
222            read_code(&str);
223            let ident = format!(
224                "crate::{}",
225                str.trim_start_matches("./src/")
226                    .trim_end_matches(".rs")
227                    .replace("/", "::")
228            );
229
230            routes.push(RouteToken::new(ident, None));
231        }
232    });
233
234    write_configure_router_function(container, body.into(), &routes).into()
235}
236
237#[cfg(test)]
238mod tests {
239    use std::str::FromStr;
240
241    use proc_macro::TokenStream;
242    use quote::*;
243
244    use crate::{configure_routes, http_specs, write_configure_router_function, RouteToken};
245
246    #[test]
247    fn generate_routes() {
248        let route_tokens = vec![
249            RouteToken::new("crate::app::products::api::get_products".to_owned(), None),
250            RouteToken::new("crate::app::products::api::get_id".to_owned(), None),
251            RouteToken::new("crate::app::products::api::post_products".to_owned(), None),
252        ];
253
254        let expected = quote! {
255            let mut router = Router::<Container>::new()
256            .route("/products", axum::routing::get(crate::app::products::api::get_products::handle))
257            .route("/products/:id", axum::routing::get(crate::app::products::api::get_id::handle))
258            .route("/products", axum::routing::post(crate::app::products::api::post_products::handle));
259        };
260
261        let tokens = quote! {
262            let mut router = Router::<Container>::new()
263            #(#route_tokens)*;
264        };
265
266        assert_eq!(tokens.to_string(), expected.to_string())
267    }
268
269    #[test]
270    fn route_token_to_token_stream() {
271        let token = RouteToken::new("crate::app::products::api::get_products".to_owned(), None);
272        let comp = quote! { .route("/products", axum::routing::get(crate::app::products::api::get_products::handle)) };
273        assert_eq!(token.into_token_stream().to_string(), comp.to_string());
274    }
275
276    #[test]
277    fn test_http_specs() {
278        let expected = [String::from("get"), String::from("/products")];
279        assert_eq!(
280            expected,
281            http_specs(&String::from("crate::app::products::api::get_products"))
282        );
283    }
284
285    #[test]
286    fn test_http_specs_with_id() {
287        let expected = [String::from("get"), String::from("/products/:id")];
288        assert_eq!(
289            expected,
290            http_specs(&String::from("crate::app::products::api::get_id"))
291        );
292    }
293
294    #[test]
295    fn test_http_specs_multiple_params() {
296        let expected = [
297            String::from("get"),
298            String::from("/products/:id/order/:order"),
299        ];
300        assert_eq!(
301            expected,
302            http_specs(&String::from(
303                "crate::app::products::api::get_id_order_ORDER"
304            ))
305        );
306    }
307
308    #[test]
309    fn test_http_specs_post_method() {
310        let expected = [String::from("post"), String::from("/products")];
311        assert_eq!(
312            expected,
313            http_specs(&String::from("crate::app::products::api::post_products"))
314        );
315    }
316
317    #[test]
318    fn test_http_specs_only_module_method() {
319        let expected = [String::from("post"), String::from("/products")];
320        assert_eq!(
321            expected,
322            http_specs(&String::from("crate::app::products::api::post"))
323        );
324    }
325
326    #[test]
327    fn test_transform_configure_routes_fn() {
328        // No routes
329        let routes = vec![];
330
331        let input = quote! {
332            pub fn config_routes() {}
333        };
334
335        let generated =
336            write_configure_router_function("crate::Container", input, &routes).to_string();
337
338        let expected = quote! {
339            pub fn config_routes() -> axum::Router<crate::Container> {
340                println!("{} configured routes.", 0usize);
341                let mut router = axum::Router::<crate::Container>::new();
342                router
343            }
344        };
345
346        assert_eq!(generated, expected.to_string());
347    }
348}