From ba74e76c7da8fde273f4f907ae95e05a92b62131 Mon Sep 17 00:00:00 2001 From: nwrenger Date: Mon, 5 Aug 2024 12:22:39 +0200 Subject: [PATCH 1/2] :fire: Deleted previous macro code & co --- macros/.gitignore | 2 - macros/Cargo.toml | 19 -- macros/src/lib.rs | 681 ---------------------------------------------- src/error.rs | 27 -- 4 files changed, 729 deletions(-) delete mode 100644 macros/.gitignore delete mode 100644 macros/Cargo.toml delete mode 100644 macros/src/lib.rs delete mode 100644 src/error.rs diff --git a/macros/.gitignore b/macros/.gitignore deleted file mode 100644 index 96ef6c0..0000000 --- a/macros/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -/target -Cargo.lock diff --git a/macros/Cargo.toml b/macros/Cargo.toml deleted file mode 100644 index 947424d..0000000 --- a/macros/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "gluer-macros" -version = "0.6.1" -edition = "2021" -authors = ["Nils Wrenger "] -description = "Procedural macros for the gluer framework" -keywords = ["parser", "api", "macro"] -categories = ["accessibility", "web-programming", "api-bindings"] -rust-version = "1.64.0" -repository = "https://github.com/nwrenger/gluer" -license = "MIT" - -[lib] -proc-macro = true - -[dependencies] -quote = "1.0" -syn = { version = "2.0.62", features = ["full"] } -proc-macro2 = "1.0" diff --git a/macros/src/lib.rs b/macros/src/lib.rs deleted file mode 100644 index 8ed71f2..0000000 --- a/macros/src/lib.rs +++ /dev/null @@ -1,681 +0,0 @@ -use proc_macro::{self as pc}; -use proc_macro2::TokenStream; -use quote::{quote, ToTokens, TokenStreamExt}; -use std::{collections::HashMap, fmt, vec}; -use syn::{ - bracketed, parenthesized, parse::Parse, punctuated::Punctuated, spanned::Spanned, token::Comma, - Type, -}; - -fn s_err(span: proc_macro2::Span, msg: impl fmt::Display) -> syn::Error { - syn::Error::new(span, msg) -} - -/// Extract the metadata of `axum`'s `MethodRouter` syntax. Use inside the `Api::route` function. -#[proc_macro] -pub fn extract(input: pc::TokenStream) -> pc::TokenStream { - match extract_inner(input.into()) { - Ok(result) => result.into(), - Err(e) => e.to_compile_error().into(), - } -} - -fn extract_inner(input: TokenStream) -> syn::Result { - let ExtractArgs { - routes: original_routes, - } = syn::parse2::(input.clone())?; - - let routes = original_routes.iter().map(|Route { method, handler }| { - let method_name = method.to_string(); - let handler_name = handler.to_string(); - - let fn_info = syn::Ident::new(&handler_name, proc_macro2::Span::call_site()); - let fn_info = quote! { #fn_info::metadata() }; - - quote! { - gluer::Route { - url: String::new(), - method: String::from(#method_name), - fn_name: String::from(#handler_name), - fn_info: #fn_info, - } - } - }); - - Ok(quote! { ( #(#original_routes).*, vec![#(#routes,)*] )}) -} - -struct ExtractArgs { - routes: Vec, -} - -impl Parse for ExtractArgs { - fn parse(input: syn::parse::ParseStream) -> syn::Result { - let mut routes = vec![]; - - while !input.is_empty() { - let route = input.parse()?; - routes.push(route); - - if !input.is_empty() { - input.parse::()?; - } - } - - Ok(ExtractArgs { routes }) - } -} - -struct Route { - method: syn::Ident, - handler: syn::Ident, -} - -impl Parse for Route { - fn parse(input: syn::parse::ParseStream) -> syn::Result { - let method = input.parse()?; - let content; - parenthesized!(content in input); - let handler = content.parse()?; - - Ok(Route { method, handler }) - } -} - -impl ToTokens for Route { - fn to_tokens(&self, tokens: &mut TokenStream) { - let method = &self.method; - let handler = &self.handler; - tokens.extend(quote! { #method(#handler::#handler) }); - } -} - -/// Use before structs, functions, enums or types to generate metadata for -/// the API via `extract!` or for dependent elements. -/// -/// ## Attributes -/// - `custom = [Type, *]`: Specify here types which are named equally to std types but are custom. -/// -/// ## Struct Attributes -/// -/// - `#[meta(into = Type)]`: Specify a type to convert the field into. -/// - `#[meta(skip)]`: Skip the field. -#[proc_macro_attribute] -pub fn metadata(args: pc::TokenStream, input: pc::TokenStream) -> pc::TokenStream { - match metadata_inner(args.into(), input.into()) { - Ok(result) => result.into(), - Err(e) => e.into_compile_error().into(), - } -} - -fn metadata_inner(args: TokenStream, input: TokenStream) -> syn::Result { - let span = input.span(); - let item = syn::parse2::(input)?; - let args = syn::parse2::(args)?; - - let out = match item { - syn::Item::Struct(item_struct) => generate_struct(item_struct, args)?, - syn::Item::Enum(item_enum) => generate_enum(item_enum, args)?, - syn::Item::Type(item_type) => generate_type(item_type, args)?, - syn::Item::Fn(item_fn) => generate_function(item_fn, args)?, - _ => return Err(s_err(span, "Expected struct, function, enum or type")), - }; - - Ok(quote! { - #out - }) -} - -struct MetadataAttr { - custom: Vec, -} - -impl syn::parse::Parse for MetadataAttr { - fn parse(input: syn::parse::ParseStream) -> syn::Result { - let mut ret = MetadataAttr { custom: vec![] }; - - if !input.is_empty() { - let ident = syn::Ident::parse(input)?; - ::parse(input)?; - match ident.to_string().as_str() { - "custom" => { - let content; - bracketed!(content in input); - let parsed_content: Punctuated = - Punctuated::parse_terminated(&content)?; - - for ty in parsed_content { - match &ty { - Type::Path(path) => { - let segments = &path.path.segments.last().unwrap(); - let ident = &segments.ident; - ret.custom.push(ident.to_token_stream().to_string()); - } - _ => return Err(s_err(ty.span(), "expected the type")), - } - } - } - _ => return Err(s_err(ident.span(), "unknown argument")), - }; - if !input.is_empty() { - ::parse(input)?; - } - } - - Ok(ret) - } -} - -fn generate_struct( - mut item_struct: syn::ItemStruct, - metadata_attr: MetadataAttr, -) -> syn::Result { - let struct_name_ident = item_struct.ident.clone(); - let generics_ident_no_types = - if let Some(g) = extract_type_params_as_type(&item_struct.generics)? { - quote! { #g } - } else { - quote! {} - }; - let generics_ident = item_struct.generics.clone(); - let struct_name = struct_name_ident.to_string(); - let vis = &item_struct.vis; - let generics: Vec = item_struct - .generics - .type_params() - .map(|type_param| type_param.ident.to_string()) - .collect(); - - let mut dependencies: HashMap> = HashMap::new(); - - let item_struct_fields = item_struct.fields.clone(); - - let fields = item_struct_fields - .iter() - .enumerate() - .filter_map(|(i, field)| { - let ident = match field.ident.clone() { - Some(ident) => ident.to_string(), - None => return Some(Err(s_err(field.span(), "Unnamed field not supported"))), - }; - - let meta_attr = match parse_field_attr(&field.attrs) { - Ok(meta_attr) => meta_attr, - Err(e) => return Some(Err(e)), - }; - - let MetaAttr { into, skip } = meta_attr; - - // Clean off all "meta" attributes - if let Some(field) = item_struct.fields.iter_mut().nth(i) { - field.attrs.retain(|attr| !attr.path().is_ident("meta")); - } - - let field_ty = if let Some(conv_fn) = into.clone() { - conv_fn - } else { - field.ty.clone() - }; - - if skip { - return None; - } - - if let Some(ty) = check_rust_type(&field_ty, &metadata_attr.custom) { - process_rust_type(&ty, &mut dependencies, &generics); - Some(Ok((ident, ty))) - } else { - Some(Err(s_err(field.span(), "Unsupported field type"))) - } - }) - .collect::>>()?; - - let generics_quote = generics.iter().map(|generic| { - quote! { String::from(#generic) } - }); - - let fields_quote = fields.iter().map(|(ident, ty)| { - quote! { gluer::Field { name: String::from(#ident), ty: #ty } } - }); - - let dependencies_quote = dependencies - .iter() - .map(|(struct_name, generics_info)| generate_type_metadata(struct_name, generics_info)) - .collect::, syn::Error>>()?; - - let item_struct = quote! { #item_struct }; - - Ok(quote! { - #item_struct - - impl #generics_ident #struct_name_ident #generics_ident_no_types { - #vis fn metadata() -> gluer::TypeCategory { - gluer::TypeCategory::Struct( - gluer::TypeInfo { - name: String::from(#struct_name), - generics: vec![#(#generics_quote),*], - fields: vec![#(#fields_quote),*], - dependencies: vec![#(#dependencies_quote),*], - } - ) - } - } - }) -} - -struct MetaAttr { - into: Option, - skip: bool, -} - -fn parse_field_attr(attrs: &[syn::Attribute]) -> syn::Result { - let mut meta_attr = MetaAttr { - into: None, - skip: false, - }; - - for attr in attrs { - if !attr.path().is_ident("meta") { - continue; - } - - attr.parse_nested_meta(|meta| { - if meta.path.is_ident("into") { - meta.input.parse::()?; - let ty: syn::Type = meta.input.parse()?; - meta_attr.into = Some(ty); - return Ok(()); - } - - if meta.path.is_ident("skip") { - meta_attr.skip = true; - return Ok(()); - } - Err(meta.error("expected #[meta(into = Type)] or #[meta(skip)]")) - })?; - } - - Ok(meta_attr) -} - -fn generate_enum(item_enum: syn::ItemEnum, _: MetadataAttr) -> syn::Result { - if !item_enum.generics.params.is_empty() { - return Err(s_err( - item_enum.generics.span(), - "Generics and Lifetimes not supported for enums", - )); - } - - let enum_name_ident = item_enum.ident.clone(); - let enum_name = enum_name_ident.to_string(); - let vis = &item_enum.vis; - - let variants = item_enum - .variants - .iter() - .map(|variant| { - if !variant.fields.is_empty() { - return Err(s_err( - variant.fields.span(), - "Enums with values are not supported", - )); - } - let ident = variant.ident.to_string(); - Ok(quote! { gluer::Field { name: String::from(#ident), ty: gluer::RustType::None }}) - }) - .collect::>>()?; - - let item_enum = quote! { #item_enum }; - - Ok(quote! { - #item_enum - - impl #enum_name_ident { - #vis fn metadata() -> gluer::TypeCategory { - gluer::TypeCategory::Enum( - gluer::TypeInfo { - name: String::from(#enum_name), - generics: vec![], - fields: vec![#(#variants),*], - dependencies: vec![], - } - ) - } - } - }) -} - -fn generate_type( - item_type: syn::ItemType, - metadata_attr: MetadataAttr, -) -> syn::Result { - let type_name_ident = item_type.ident.clone(); - let type_name = type_name_ident.to_string(); - let vis = &item_type.vis; - let generics_ident_no_types = if let Some(g) = extract_type_params_as_type(&item_type.generics)? - { - quote! { #g } - } else { - quote! {} - }; - let generics_ident = item_type.generics.clone(); - let generics: Vec = item_type - .generics - .type_params() - .map(|type_param| type_param.ident.to_string()) - .collect(); - - let mut dependencies: HashMap> = HashMap::new(); - - let ty = check_rust_type(&item_type.ty, &metadata_attr.custom) - .ok_or_else(|| s_err(item_type.ty.span(), "Unsupported type"))?; - - process_rust_type(&ty, &mut dependencies, &generics); - - let trait_ident = syn::Ident::new( - &format!("{}Metadata", type_name), - proc_macro2::Span::call_site(), - ); - - let generics_quote = generics.iter().map(|generic| { - quote! { String::from(#generic) } - }); - - let dependencies_quote = dependencies - .iter() - .map(|(struct_name, generics_info)| generate_type_metadata(struct_name, generics_info)) - .collect::, syn::Error>>()?; - - Ok(quote! { - #item_type - - #vis trait #trait_ident { - fn metadata() -> gluer::TypeCategory; - } - - impl #generics_ident #trait_ident for #type_name_ident #generics_ident_no_types { - fn metadata() -> gluer::TypeCategory { - gluer::TypeCategory::Type( - gluer::TypeInfo { - name: String::from(#type_name), - generics: vec![#(#generics_quote),*], - fields: vec![gluer::Field { name: String::new(), ty: #ty }], - dependencies: vec![#(#dependencies_quote)*], - } - ) - } - } - }) -} - -fn extract_type_params_as_type(generics: &syn::Generics) -> syn::Result> { - let type_params: Vec = generics - .type_params() - .map(|type_param| type_param.ident.to_string()) - .collect(); - - if type_params.is_empty() { - return Ok(None); - } - - Ok(Some(syn::parse_str(&format!( - "<{}>", - type_params.join(", ") - ))?)) -} - -fn generate_function( - item_fn: syn::ItemFn, - metadata_attr: MetadataAttr, -) -> syn::Result { - let fn_name_ident = item_fn.sig.ident.clone(); - let vis = &item_fn.vis; - let mut dependencies = HashMap::new(); - - let params = item_fn - .sig - .inputs - .iter() - .filter_map(|param| match param { - syn::FnArg::Typed(syn::PatType { pat, ty, .. }) => { - let pat = pat.to_token_stream().to_string(); - if let Some(rust_type) = check_rust_type(ty, &metadata_attr.custom) { - process_rust_type(&rust_type, &mut dependencies, &[]); - - Some(Ok((pat, rust_type))) - } else { - None - } - } - syn::FnArg::Receiver(_) => { - Some(Err(s_err(param.span(), "Receiver parameter not allowed"))) - } - }) - .collect::>>()?; - - let response = match &item_fn.sig.output { - syn::ReturnType::Type(_, ty) => { - if let Some(rust_type) = check_rust_type(ty, &metadata_attr.custom) { - process_rust_type(&rust_type, &mut dependencies, &[]); - - rust_type - } else { - return Err(s_err(ty.span(), "Unsupported return type")); - } - } - syn::ReturnType::Default => RustType::BuiltIn("()".to_string()), - }; - - let params_types = params.iter().map(|(pat, ty)| { - quote! { gluer::Field { name: String::from(#pat), ty: #ty } } - }); - - let dependencies_quote = dependencies - .iter() - .map(|(struct_name, generics_info)| generate_type_metadata(struct_name, generics_info)) - .collect::, syn::Error>>()?; - - Ok(quote! { - #[allow(non_camel_case_types, missing_docs)] - #vis struct #fn_name_ident; - - impl #fn_name_ident { - #item_fn - - #vis fn metadata() -> gluer::FnInfo { - gluer::FnInfo { - params: vec![#(#params_types),*], - response: #response, - types: vec![#(#dependencies_quote),*], - } - } - } - }) -} - -fn process_rust_type( - rust_type: &RustType, - dependencies: &mut HashMap>, - generics: &[String], -) { - match rust_type { - RustType::Custom(inner_ty) => { - if !dependencies.contains_key(inner_ty) && !generics.contains(inner_ty) { - dependencies.entry(inner_ty.clone()).or_default(); - } - } - RustType::CustomGeneric(outer_ty, inner_tys) => { - if !dependencies.contains_key(outer_ty) && !generics.contains(outer_ty) { - dependencies - .entry(outer_ty.clone()) - .or_default() - .extend(inner_tys.clone()); - } - for inner_ty in inner_tys { - process_rust_type(inner_ty, dependencies, generics); - } - } - RustType::Tuple(inner_tys) => { - for inner_ty in inner_tys { - process_rust_type(inner_ty, dependencies, generics); - } - } - RustType::Generic(_, inner_tys) => { - for inner_ty in inner_tys { - process_rust_type(inner_ty, dependencies, generics); - } - } - _ => {} - } -} - -fn generate_type_metadata( - type_name: &str, - generics_info: &[RustType], -) -> syn::Result { - let struct_ident = syn::Ident::new(type_name, proc_macro2::Span::call_site()); - - let times = generics_info.len(); - let generics_placeholder = (0..times).map(|_| quote! { () }); - - Ok(quote! { - #struct_ident::<#(#generics_placeholder),*>::metadata() - }) -} - -const RUST_TYPES: &[&str] = &[ - "bool", "char", "str", "u8", "u16", "u32", "u64", "u128", "i8", "i16", "i32", "i64", "i128", - "usize", "isize", "f32", "f64", "String", -]; - -const SKIP_TYPES: &[&str] = &["State", "Headers", "Bytes", "Request", "Extension"]; - -const BUILTIN_GENERICS: &[&str] = &[ - "Query", "HashMap", "Path", "Vec", "Json", "Option", "Result", -]; - -#[derive(Debug, PartialEq, Clone)] -enum RustType { - BuiltIn(String), - Generic(String, Vec), - Tuple(Vec), - Custom(String), - CustomGeneric(String, Vec), -} - -impl ToTokens for RustType { - fn to_tokens(&self, tokens: &mut TokenStream) { - match &self { - RustType::BuiltIn(value) => { - tokens.append_all(quote! { gluer::RustType::BuiltIn(String::from(#value)) }); - } - RustType::Generic(name, types) => { - let types = types.iter().map(|ty| { - quote! { #ty, } - }); - tokens.append_all( - quote! { gluer::RustType::Generic(String::from(#name), vec![#(#types)*]) }, - ); - } - RustType::Tuple(types) => { - let types = types.iter().map(|ty| { - quote! { #ty, } - }); - tokens.append_all(quote! { gluer::RustType::Tuple(vec![#(#types)*]) }); - } - RustType::Custom(name) => { - tokens.append_all(quote! { gluer::RustType::Custom(String::from(#name)) }); - } - RustType::CustomGeneric(name, types) => { - let types = types.iter().map(|ty| { - quote! { #ty, } - }); - tokens.append_all( - quote! { gluer::RustType::CustomGeneric(String::from(#name), vec![#(#types)*]) }, - ); - } - } - } -} - -fn is_builtin_type(ident: &syn::Ident) -> bool { - RUST_TYPES.contains(&ident.to_string().as_str()) -} - -fn is_skip_type(ident: &syn::Ident) -> bool { - SKIP_TYPES.contains(&ident.to_string().as_str()) -} - -fn is_builtin_generic(ident: &syn::Ident) -> bool { - BUILTIN_GENERICS.contains(&ident.to_string().as_str()) -} - -fn is_custom(ident: &syn::Ident, custom: &[String]) -> bool { - custom.contains(&ident.to_string()) -} - -fn check_rust_type(ty: &syn::Type, custom: &[String]) -> Option { - match ty { - syn::Type::Path(type_path) => { - let segment = type_path.path.segments.last().unwrap(); - let ident = &segment.ident; - - if is_builtin_type(ident) && !is_custom(ident, custom) { - Some(RustType::BuiltIn(ident.to_string())) - } else if is_skip_type(ident) && !is_custom(ident, custom) { - None - } else if is_builtin_generic(ident) && !is_custom(ident, custom) { - if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { - let inner_types: Vec = args - .args - .iter() - .filter_map(|arg| { - if let syn::GenericArgument::Type(inner_ty) = arg { - check_rust_type(inner_ty, custom) - } else { - None - } - }) - .collect(); - Some(RustType::Generic(ident.to_string(), inner_types)) - } else { - Some(RustType::Generic(ident.to_string(), vec![])) - } - } else if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { - let inner_types: Vec = args - .args - .iter() - .filter_map(|arg| { - if let syn::GenericArgument::Type(inner_ty) = arg { - check_rust_type(inner_ty, custom) - } else { - None - } - }) - .collect(); - Some(RustType::CustomGeneric(ident.to_string(), inner_types)) - } else { - Some(RustType::Custom(ident.to_string())) - } - } - syn::Type::Reference(syn::TypeReference { elem, .. }) - | syn::Type::Paren(syn::TypeParen { elem, .. }) - | syn::Type::Group(syn::TypeGroup { elem, .. }) => check_rust_type(elem, custom), - - syn::Type::Tuple(type_tuple) => { - if type_tuple.elems.is_empty() { - return Some(RustType::BuiltIn("()".to_string())); - } - let inner_types: Vec = type_tuple - .elems - .iter() - .filter_map(|t| check_rust_type(t, custom)) - .collect(); - Some(RustType::Tuple(inner_types)) - } - syn::Type::Slice(syn::TypeSlice { elem, .. }) - | syn::Type::Array(syn::TypeArray { elem, .. }) => check_rust_type(elem, custom) - .map(|inner| RustType::Generic("Vec".to_string(), vec![inner])), - _ => None, - } -} diff --git a/src/error.rs b/src/error.rs deleted file mode 100644 index bf2b6cb..0000000 --- a/src/error.rs +++ /dev/null @@ -1,27 +0,0 @@ -use std::fmt::Debug; - -/// Result type containing the custom error enum -pub type Result = std::result::Result; - -/// Error type containing all error cases with extra metadata -pub enum Error { - /// An io operation failed - FileSystem(std::io::Error), - /// Parsing to Typescript failed - Ts(String), -} - -impl Debug for Error { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Error::FileSystem(e) => write!(f, "FileSystem: {}", e), - Error::Ts(e) => write!(f, "Ts: {}", e), - } - } -} - -impl From for Error { - fn from(e: std::io::Error) -> Self { - Error::FileSystem(e) - } -} From a22a0a8b9e0b04740b555d259fab07bf157c1242 Mon Sep 17 00:00:00 2001 From: nwrenger Date: Mon, 5 Aug 2024 12:51:44 +0200 Subject: [PATCH 2/2] :sparkles: Made generation on macro expansion (comp time) again --- Cargo.toml | 9 +- README.md | 66 +-- src/lib.rs | 1292 ++++++++++++++++++++++++++++++++++++++----------- tests/main.rs | 16 +- 4 files changed, 1040 insertions(+), 343 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 88b6451..9464c61 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gluer" -version = "0.6.1" +version = "0.7.0" edition = "2021" authors = ["Nils Wrenger "] description = "A wrapper for Rust frameworks that eliminates redundant type and function definitions between the frontend and backend" @@ -12,11 +12,14 @@ readme = "README.md" license = "MIT" [lib] +proc-macro = true [dependencies] -gluer-macros = { path = "macros", version = "0.6.1" } -axum = "0.7.5" +quote = "1.0" +syn = { version = "2.0.62", features = ["full"] } +proc-macro2 = "1.0" [dev-dependencies] +axum = "0.7.5" tokio = { version = "1.39.2", features = ["macros", "rt-multi-thread"] } serde = { version = "1.0", features = ["derive"] } diff --git a/README.md b/README.md index bd8042b..fe346d2 100644 --- a/README.md +++ b/README.md @@ -16,12 +16,13 @@ Add this to your `Cargo.toml`: ```toml [dependencies] -gluer = "0.6.1" +gluer = "0.7.0" ``` ## Features - Define routing and API generation as outlined in [How to use](#how-to-use). +- Everything is done on macro expansion (compile time), even the generating of the TypeScript file. - Infer input and output types of functions. - Support `axum`'s types completely. - Convert Rust structs to TypeScript interfaces. @@ -120,16 +121,16 @@ async fn book_state() -> Json { ``` ### Step 2: Add Routes - -Use the `Api` wrapper around `axum`'s Router to add routes. Utilize the `extract!` macro to gather all necessary information from the functions. Note that inline functions cannot be used, as the function names of the generated TypeScript file are inferred from the handler function names. +Use the `route!` macro with `axum`'s Router to add routes. This enables the `generate` macro to identify the route and generate corresponding functions, structs, types, and enums. Note that inline functions cannot be used because the function names in the generated TypeScript file are inferred from the handler function names. ```rust use axum::{ routing::get, Json, + Router, extract::Path, }; -use gluer::{Api, extract, metadata}; +use gluer::{route, metadata}; // without `#[metadata]`, it's non-API-important async fn root() -> String { @@ -142,43 +143,26 @@ async fn hello(Path(h): Path) -> Json { h.into() } -let mut app: Api<()> = Api::new() - // Add non-API-important routes or state by - // accessing axum's Router directly via inner_router - .inner_router(|f| f.route("/", get(root))) - // Add API-important routes with the route function - .route("/:hello", extract!(get(hello))); +let mut app: Router<()> = Router::new() + // Add non-API-important directly on the router + .route("/", get(root)); +// Add API-important routes with the route macro +route!(app, "/:hello", get(hello)); + ``` ### Step 3: Generate API -Generate the API file using the `generate_client` function on the `Api` struct. This generates the TypeScript file. You can specify a different `base` and a path, where the file should be generated to and with what name. - -```rust,no_run -use gluer::Api; +Generate the API file using the `generate` macro. This generates the TypeScript file on macro expansion (compile time). You have to specify the `root directory` of your current project, normally `src`, a `path`, where the file should be generated to and with what name, and a different `base`, `""` means no different base. -let app: Api<()> = Api::new(); +```rust +use gluer::generate; -app.generate_client("tests/api.ts", ""); +// Make sure to change "tests" to "src" when copying this example +generate!("tests", "tests/api.ts", ""); ``` -### Step 4: Use the Wrapped Router - -To start your server, get the inner router using the `into_router` function. - -```rust,no_run -use gluer::Api; - -#[tokio::main] -async fn main() { - let app: Api<()> = Api::new(); - - let listener = tokio::net::TcpListener::bind("127.0.0.1:8080") - .await - .unwrap(); - axum::serve(listener, app.into_router()).await.unwrap(); -} -``` +And now you can just simply use the router to start your server or do different things, the API should be already generated by your LSP! ## Complete Example @@ -188,9 +172,9 @@ Below is a complete example demonstrating the use of `gluer` with `axum`: use axum::{ extract::{Path, Query}, routing::get, - Json, + Json, Router, }; -use gluer::{extract, metadata, Api}; +use gluer::{generate, metadata, route}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -265,16 +249,18 @@ type S = String; #[tokio::main] async fn main() { - let app: Api<()> = Api::new() - .route("/:p", extract!(get(fetch_root).post(add_root))) - .route("/char/:path/metadata/:path", extract!(get(get_alphabet))); + let mut _app: Router = Router::new(); + + route!(_app, "/:p", get(fetch_root).post(add_root)); + route!(_app, "/char/:path/metadata/:path", get(get_alphabet)); - app.generate_client("tests/api.ts", "").unwrap(); + // Make sure to change "tests" to "src" when copying this example + generate!("tests", "tests/api.ts", ""); let _listener = tokio::net::TcpListener::bind("127.0.0.1:8080") .await .unwrap(); // starts the server, comment in and rename `_listener` to run it - // axum::serve(listener, app.into_router()).await.unwrap(); + // axum::serve(listener, app).await.unwrap(); } ``` diff --git a/src/lib.rs b/src/lib.rs index 498becb..3225c5f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,225 +1,490 @@ #![doc = include_str!("../README.md")] -pub mod error; +use proc_macro as pc; +use proc_macro2::TokenStream; +use quote::{quote, ToTokens}; +use std::{ + collections::{BTreeMap, HashMap}, + fmt, fs, + io::Write, + vec, +}; +use syn::{ + bracketed, parenthesized, + parse::Parse, + punctuated::Punctuated, + spanned::Spanned, + token::{Brace, Comma}, + Item, Stmt, StmtMacro, Type, +}; -pub use gluer_macros::{extract, metadata}; +fn s_err(span: proc_macro2::Span, msg: impl fmt::Display) -> syn::Error { + syn::Error::new(span, msg) +} -use axum::routing::MethodRouter; -use axum::Router; -use error::{Error, Result}; -use std::{collections::BTreeMap, fs::File, io::Write, vec}; +/// Use this for defining the routes of the router, this is kind of a wrapper, needed for the `generate` function to find this. +/// +/// ## Parameters +/// - `router_ident`: The ident of the router variable. +/// - `url`: The URL of the route. +/// - `base`: The base URL for the API. +/// +/// ## Note +/// When using state, make sure to return the router with the state, like this: +/// ```rust +/// use axum::{Router, routing::get, extract::State}; +/// use gluer::route; +/// +/// async fn fetch_root(State(_): State<()>) -> String { String::new() } +/// +/// let mut router = Router::new(); +/// +/// route!(router, "/api", get(fetch_root)); +/// +/// router.with_state::<()>(()); // <- here +#[proc_macro] +pub fn route(input: pc::TokenStream) -> pc::TokenStream { + match route_inner(input.into()) { + Ok(result) => result.into(), + Err(e) => e.to_compile_error().into(), + } +} -/// Wrapper around `axum::Router` that allows for generating TypeScript API clients. -pub struct Api { - router: Router, - api_routes: Vec, +fn route_inner(input: TokenStream) -> syn::Result { + let RouteArgs { + router_ident, + url, + routes, + } = syn::parse2::(input)?; + Ok(quote! { + #router_ident = #router_ident.route(#url, #(#routes).*); + }) } -impl Api -where - S: Clone + Send + Sync + 'static, -{ - /// Create a new `Api`. - pub fn new() -> Self { - Self { - router: Router::new(), - api_routes: vec![], +struct RouteArgs { + router_ident: syn::Ident, + url: syn::LitStr, + routes: Vec, +} + +impl Parse for RouteArgs { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let mut routes = vec![]; + + let router_ident = input.parse::()?; + input.parse::()?; + let url = input.parse::()?; + input.parse::()?; + + while !input.is_empty() { + let route = input.parse()?; + routes.push(route); + + if !input.is_empty() { + input.parse::()?; + } } + + Ok(RouteArgs { + router_ident, + url, + routes, + }) } +} - /// Add a route to the API and `axum::Router`. - pub fn route(mut self, path: &str, extracted_metadata: (MethodRouter, Vec)) -> Self { - let router = self.router.route(path, extracted_metadata.0); - self.api_routes.extend({ - let mut routes = vec![]; - for route in extracted_metadata.1 { - routes.push(Route { - url: path.to_string(), - method: route.method, - fn_name: route.fn_name, - fn_info: route.fn_info, - }); - } - routes - }); +struct MethodRouter { + method: syn::Ident, + handler: syn::Ident, +} - Self { - router, - api_routes: self.api_routes, - } +impl Parse for MethodRouter { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let method = input.parse()?; + let content; + parenthesized!(content in input); + let handler = content.parse()?; + + Ok(MethodRouter { method, handler }) } +} - /// Access the inner `axum::Router` via a Function `f`. - pub fn inner_router(self, f: F) -> Api - where - F: Fn(Router) -> Router, - { - Api { - router: f(self.router), - api_routes: self.api_routes, - } +impl ToTokens for MethodRouter { + fn to_tokens(&self, tokens: &mut TokenStream) { + let method = &self.method; + let handler = &self.handler; + tokens.extend(quote! { #method(#handler) }); } +} - /// Generates a TypeScript API client for the frontend from the API routes. - /// - /// ## Parameters - /// - `path`: Specifies the directory and filename where the generated file will be saved. - /// - `base`: Specifies the base URL for the API. - /// - /// ## Notes - /// Ensure that the `base` URL does not end with a slash (`/`). For example: - /// - Use `""` for no base URL if you are utilizing `axum`'s static file serving. - /// - Use `"http://localhost:8080"` for a local server. - pub fn generate_client>(&self, path: P, base: &str) -> Result<()> { - let base = format!("const BASE = '{}';\n", base); - let basic_functions = r#" async function fetch_api(endpoint: string, options: RequestInit): Promise { - const response = await fetch(endpoint, { - headers: { - "Content-Type": "application/json", - ...options.headers, - }, - ...options, - }); - return response.json(); +/// Use before structs, functions, enums or types to be findable by the `generate` function. +/// +/// ## Attributes +/// - `custom = [Type, *]`: Specify here types which are named equally to std types but are custom. +/// +/// ## Struct Attributes +/// +/// - `#[meta(into = Type)]`: Specify a type to convert the field into. +/// - `#[meta(skip)]`: Skip the field. +#[proc_macro_attribute] +pub fn metadata(args: pc::TokenStream, input: pc::TokenStream) -> pc::TokenStream { + match metadata_inner(args.into(), input.into()) { + Ok(result) => result.into(), + Err(e) => e.into_compile_error().into(), } +} - function query_str(params: Record): string { - if (params) { - let data: Record = {}; - for (let key in params) { - if (params[key] != null) data[key] = params[key].toString(); - } - // the URLSearchParams escapes any problematic values - return '?' + new URLSearchParams(data).toString(); - } - return ''; - } -"#; - let namespace_start = "namespace api {\n"; - let namespace_end = "}\n\nexport default api;"; +fn metadata_inner(args: TokenStream, input: TokenStream) -> syn::Result { + let span = input.span(); + let item = syn::parse2::(input)?; + let _ = syn::parse2::(args)?; - let mut parsed_ts = - ParsedTypeScript::new(&base, basic_functions, namespace_start, namespace_end); + let out = match item { + syn::Item::Struct(mut struct_item) => { + // Clean off all "meta" attributes + for field in struct_item.fields.iter_mut() { + field.attrs.retain(|attr| !attr.path().is_ident("meta")); + } + quote! { #struct_item } + } + syn::Item::Enum(enum_item) => quote! { #enum_item }, + syn::Item::Type(type_item) => quote! { #type_item}, + syn::Item::Fn(fn_item) => quote! { #fn_item }, + _ => return Err(s_err(span, "Expected struct, function, enum or type")), + }; - for route in &self.api_routes { - Route::resolving_dependencies( - &route.fn_info.types, - &mut parsed_ts.interfaces, - &mut parsed_ts.enum_types, - &mut parsed_ts.type_types, - )?; + Ok(out) +} - let params_type = route - .fn_info - .params - .iter() - .map(|Field { name: _, ty }| { - ty.to_api_type( - &[], - &parsed_ts.interfaces, - &parsed_ts.enum_types, - &parsed_ts.type_types, - ) - }) - .collect::>>()?; - let response_type = route.fn_info.response.to_api_type( - &[], - &parsed_ts.interfaces, - &parsed_ts.enum_types, - &parsed_ts.type_types, - )?; - - if parsed_ts.functions.contains_key(&route.fn_name) { - return Err(Error::Ts(format!( - "Function with name '{}' already exists", - route.fn_name, - ))); - } else { - parsed_ts.functions.insert( - route.fn_name.to_string(), - route.generate_ts_function(params_type, response_type), - ); +struct MetadataAttr { + custom: Vec, +} + +impl syn::parse::Parse for MetadataAttr { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let mut ret = MetadataAttr { custom: vec![] }; + + if !input.is_empty() { + let ident = syn::Ident::parse(input)?; + ::parse(input)?; + match ident.to_string().as_str() { + "custom" => { + let content; + bracketed!(content in input); + let parsed_content: Punctuated = + Punctuated::parse_terminated(&content)?; + + for ty in parsed_content { + match &ty { + Type::Path(path) => { + let segments = &path.path.segments.last().unwrap(); + let ident = &segments.ident; + ret.custom.push(ident.to_token_stream().to_string()); + } + _ => return Err(s_err(ty.span(), "Expected the type")), + } + } + } + _ => return Err(s_err(ident.span(), "Unknown argument")), + }; + if !input.is_empty() { + ::parse(input)?; } } - parsed_ts.write_to_file(path) - } - - /// Convert into an `axum::Router`. - pub fn into_router(self) -> Router { - self.router + Ok(ret) } } -impl Default for Api -where - S: Clone + Send + Sync + 'static, -{ - fn default() -> Self { - Self::new() +/// Generates a TypeScript API client for the frontend from the API routes. +/// +/// ## Parameters +/// - `root_dir`: The root directory of the Rust files. +/// - `path`: The directory and filename where the generated file will be saved. +/// - `base`: The base URL for the API. +/// +/// ## Notes +/// Ensure that the `base` URL does not end with a slash (`/`). For example: +/// - Use `""` for no base URL if you are utilizing `axum`'s static file serving. +#[proc_macro] +pub fn generate(input: pc::TokenStream) -> pc::TokenStream { + match generate_inner(input.into()) { + Ok(result) => result.into(), + Err(e) => e.to_compile_error().into(), } } -#[derive(Debug)] -/// Route information. -pub struct Route { - pub url: String, - pub method: String, - pub fn_name: String, - pub fn_info: FnInfo, -} +fn generate_inner(input: TokenStream) -> syn::Result { + let GenerateArgs { + root_dir, + path, + base, + } = syn::parse2::(input.clone())?; -impl Route { - fn resolving_dependencies( - dependencies: &[TypeCategory], - interfaces: &mut BTreeMap, - enum_types: &mut BTreeMap, - type_types: &mut BTreeMap, - ) -> Result<()> { - for type_info in dependencies { - match type_info { - TypeCategory::Struct(type_info) => { - Self::resolving_dependencies( - &type_info.dependencies, - interfaces, - enum_types, - type_types, - )?; - if !interfaces.contains_key(&type_info.name.to_string()) { - interfaces.insert( - type_info.name.to_string(), - type_info.generate_interface(interfaces, enum_types, type_types)?, - ); + let mut routes = Vec::new(); + let mut fn_infos = HashMap::new(); + let mut type_infos = HashMap::new(); + + let mut parsed_ts = ParsedTypeScript::filled(&base); // todo: base + + fn process_dir( + dir: &std::path::Path, + routes: &mut Vec, + fn_infos: &mut HashMap, + type_infos: &mut HashMap, + ) -> syn::Result<()> { + for entry in fs::read_dir(dir).map_err(|e| { + s_err( + proc_macro2::Span::call_site(), + format!("Couldn't read dir entry: {}", e), + ) + })? { + let entry = entry.map_err(|e| { + s_err( + proc_macro2::Span::call_site(), + format!("Couldn't read dir entry: {}", e), + ) + })?; + let path = entry.path(); + if path.is_dir() { + process_dir(&path, routes, fn_infos, type_infos)?; + } else if path.extension().and_then(|s| s.to_str()) == Some("rs") { + let content = fs::read_to_string(&path).map_err(|e| { + s_err( + proc_macro2::Span::call_site(), + format!("Couldn't read file to string: {}", e), + ) + })?; + let syntax = syn::parse_file(&content)?; + process_syntax(&syntax.items, routes, fn_infos, type_infos)?; + } + } + Ok(()) + } + + fn process_syntax( + syntax: &Vec, + routes: &mut Vec, + fn_infos: &mut HashMap, + type_infos: &mut HashMap, + ) -> syn::Result<()> { + for item in syntax { + match item { + Item::Enum(item_enum) => { + for attr in &item_enum.attrs { + if attr.path().is_ident("metadata") { + let metadata_attr = attr + .parse_args::() + .unwrap_or(MetadataAttr { custom: vec![] }); + let enum_type = generate_enum(item_enum.clone(), metadata_attr)?; + if !type_infos.contains_key(&enum_type.name) { + type_infos + .insert(enum_type.name.clone(), TypeCategory::Enum(enum_type)); + } + } } } - TypeCategory::Enum(type_info) => { - if let std::collections::btree_map::Entry::Vacant(e) = - enum_types.entry(type_info.name.to_string()) - { - e.insert(type_info.generate_enum_type()?); + Item::Struct(item_struct) => { + for attr in &item_struct.attrs { + if attr.path().is_ident("metadata") { + let metadata_attr = attr + .parse_args::() + .unwrap_or(MetadataAttr { custom: vec![] }); + let struct_type = generate_struct(item_struct.clone(), metadata_attr)?; + if !type_infos.contains_key(&struct_type.name) { + type_infos.insert( + struct_type.name.clone(), + TypeCategory::Struct(struct_type), + ); + } + } } } - TypeCategory::Type(type_info) => { - Self::resolving_dependencies( - &type_info.dependencies, - interfaces, - enum_types, - type_types, - )?; - if !type_types.contains_key(&type_info.name.to_string()) { - type_types.insert( - type_info.name.to_string(), - type_info.generate_type_type(interfaces, enum_types, type_types)?, - ); + Item::Type(item_type) => { + for attr in &item_type.attrs { + if attr.path().is_ident("metadata") { + let metadata_attr = attr + .parse_args::() + .unwrap_or(MetadataAttr { custom: vec![] }); + let type_type = generate_type(item_type.clone(), metadata_attr)?; + if !type_infos.contains_key(&type_type.name) { + type_infos + .insert(type_type.name.clone(), TypeCategory::Type(type_type)); + } + } } } + Item::Fn(item_fn) => { + for stmt in &item_fn.block.stmts { + if let Stmt::Macro(StmtMacro { mac, .. }) = stmt { + if mac.path.is_ident("route") { + let RouteArgs { + url, + routes: method_routes, + .. + } = syn::parse2::(mac.tokens.clone())?; + + for route in method_routes { + let method = route.method.to_string().to_uppercase(); + let handler = route.handler.to_string(); + let route = Route { + url: url.value(), + method: method.clone(), + handler, + }; + if !routes.contains(&route) { + routes.push(route); + } + } + } + } + } + for attr in &item_fn.attrs { + if attr.path().is_ident("metadata") { + let metadata_attr = attr + .parse_args::() + .unwrap_or(MetadataAttr { custom: vec![] }); + let fn_info = generate_function(item_fn.clone(), metadata_attr)?; + if !fn_infos.contains_key(&fn_info.name) { + fn_infos.insert(fn_info.name.clone(), fn_info); + } + } + } + } + Item::Mod(item_mod) => { + process_syntax( + &item_mod + .content + .as_ref() + .unwrap_or(&(Brace::default(), vec![])) + .1, + routes, + fn_infos, + type_infos, + )?; + } + _ => {} } } Ok(()) } - fn generate_ts_function(&self, params_type: Vec, response_type: ApiType) -> String { - let mut url = self.url.to_string(); + process_dir( + std::path::Path::new(&root_dir), + &mut routes, + &mut fn_infos, + &mut type_infos, + )?; + + for route in routes { + let fn_info = fn_infos.get(&route.handler).ok_or(s_err( + proc_macro2::Span::call_site(), + format!( + "Function '{}' not found, add the `#[metadata] attribute to the definition", + route.handler + ), + ))?; + for ty in &fn_info.types { + let ty = type_infos.get(ty).ok_or(s_err( + proc_macro2::Span::call_site(), + format!( + "Dependency '{}' not found, add the `#[metadata] attribute to the definition", + ty + ), + ))?; + ty.resolving_dependencies(&type_infos, &mut parsed_ts)?; + } + + if parsed_ts.functions.contains_key(&fn_info.name) { + return Err(s_err( + proc_macro2::Span::call_site(), + format!("Function with name '{}' already exists", fn_info.name,), + )); + } else { + parsed_ts.functions.insert( + fn_info.name.to_string(), + fn_info.generate_ts_function(&route, &parsed_ts)?, + ); + } + } + + parsed_ts + .write_to_file(path) + .map_err(|e| s_err(proc_macro2::Span::call_site(), e))?; + + Ok(quote! {}) +} + +struct GenerateArgs { + root_dir: String, + path: String, + base: String, +} + +impl Parse for GenerateArgs { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let root_dir = input.parse::()?.value(); + input.parse::()?; + let path = input.parse::()?.value(); + input.parse::()?; + let base = input.parse::()?.value(); + + if !input.is_empty() { + input.parse::()?; + } + + Ok(GenerateArgs { + root_dir, + path, + base, + }) + } +} + +#[derive(Clone, Debug, PartialEq)] +/// Route information. +struct Route { + url: String, + method: String, + handler: String, +} + +/// Function information. +#[derive(Clone, Debug)] +struct FnInfo { + name: String, + params: Vec, + response: RustType, + types: Vec, +} + +impl FnInfo { + fn generate_ts_function( + &self, + route: &Route, + parsed_ts: &ParsedTypeScript, + ) -> syn::Result { + let mut url = route.url.to_string(); + + let params_type = self + .params + .iter() + .map(|Field { name: _, ty }| { + ty.to_api_type( + &[], + &parsed_ts.interfaces, + &parsed_ts.enum_types, + &parsed_ts.type_types, + ) + }) + .collect::>>()?; + + let response_type = self.response.to_api_type( + &[], + &parsed_ts.interfaces, + &parsed_ts.enum_types, + &parsed_ts.type_types, + )?; let params_str = params_type .iter() @@ -266,46 +531,359 @@ impl Route { url += "${query_str(query)}"; } - format!( + Ok(format!( r#" export async function {fn_name}({params_str}): Promise<{response_type}> {{ return fetch_api(`${{BASE}}{url}`, {{ method: "{method}", {body_assignment} }}); }} "#, - fn_name = self.fn_name, + fn_name = self.name, params_str = params_str, response_type = response_type.unwrap(), url = url, - method = self.method.to_uppercase(), + method = route.method.to_uppercase(), body_assignment = body_assignment - ) + )) } } -/// Function information. -#[derive(Clone, Debug)] -pub struct FnInfo { - pub params: Vec, - pub response: RustType, - pub types: Vec, -} - /// Information type. #[derive(Clone, Debug)] -pub enum TypeCategory { +enum TypeCategory { Struct(TypeInfo), Enum(TypeInfo), Type(TypeInfo), } +impl TypeCategory { + fn resolving_dependencies( + &self, + dependencies: &HashMap, + parsed_ts: &mut ParsedTypeScript, + ) -> syn::Result<()> { + match self { + TypeCategory::Struct(type_info) => { + for dependency in &type_info.dependencies { + Self::resolving_dependencies( + dependencies.get(dependency).ok_or(s_err( + proc_macro2::Span::call_site(), + format!("Dependency '{}' not found, add the `#[metadata] attribute to the definition", dependency), + ))?, + dependencies, + parsed_ts, + )?; + } + if !parsed_ts + .interfaces + .contains_key(&type_info.name.to_string()) + { + parsed_ts.interfaces.insert( + type_info.name.to_string(), + type_info.generate_interface( + &parsed_ts.interfaces, + &parsed_ts.enum_types, + &parsed_ts.type_types, + )?, + ); + } + } + TypeCategory::Enum(type_info) => { + if let std::collections::btree_map::Entry::Vacant(e) = + parsed_ts.enum_types.entry(type_info.name.to_string()) + { + e.insert(type_info.generate_enum_type()?); + } + } + TypeCategory::Type(type_info) => { + for dependency in &type_info.dependencies { + Self::resolving_dependencies( + dependencies.get(dependency).ok_or(s_err( + proc_macro2::Span::call_site(), + format!("Dependency '{}' not found, add the `#[metadata] attribute to the definition", dependency), + ))?, + dependencies, + parsed_ts, + )?; + } + if !parsed_ts + .type_types + .contains_key(&type_info.name.to_string()) + { + parsed_ts.type_types.insert( + type_info.name.to_string(), + type_info.generate_type_type( + &parsed_ts.interfaces, + &parsed_ts.enum_types, + &parsed_ts.type_types, + )?, + ); + } + } + } + Ok(()) + } +} + +fn generate_struct( + item_struct: syn::ItemStruct, + metadata_attr: MetadataAttr, +) -> syn::Result { + let struct_name_ident = item_struct.ident.clone(); + let struct_name = struct_name_ident.to_string(); + let generics: Vec = item_struct + .generics + .type_params() + .map(|type_param| type_param.ident.to_string()) + .collect(); + + let mut dependencies: Vec = Vec::new(); + + let item_struct_fields = item_struct.fields.clone(); + + let fields = item_struct_fields + .iter() + .filter_map(|field| { + let ident = match field.ident.clone() { + Some(ident) => ident.to_string(), + None => { + return Some(Err(s_err( + field.span(), + "Unnamed fields like `self` are not supported", + ))) + } + }; + + let meta_attr = match parse_field_attr(&field.attrs) { + Ok(meta_attr) => meta_attr, + Err(e) => return Some(Err(e)), + }; + + let MetaAttr { into, skip } = meta_attr; + + let field_ty = if let Some(conv_fn) = into.clone() { + conv_fn + } else { + field.ty.clone() + }; + + if skip { + return None; + } + + if let Some(ty) = to_rust_type(&field_ty, &metadata_attr.custom) { + process_rust_type(&ty, &mut dependencies, &generics); + Some(Ok((ident, ty))) + } else { + Some(Err(s_err(field.span(), "Unsupported Rust Type"))) + } + }) + .collect::>>()?; + + let fields_info: Vec = fields + .iter() + .map(|(ident, ty)| Field { + name: ident.clone(), + ty: ty.clone(), + }) + .collect(); + + Ok(TypeInfo { + name: struct_name, + generics, + fields: fields_info, + dependencies, + }) +} + +struct MetaAttr { + into: Option, + skip: bool, +} + +fn parse_field_attr(attrs: &[syn::Attribute]) -> syn::Result { + let mut meta_attr = MetaAttr { + into: None, + skip: false, + }; + + for attr in attrs { + if !attr.path().is_ident("meta") { + continue; + } + + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("into") { + meta.input.parse::()?; + let ty: syn::Type = meta.input.parse()?; + meta_attr.into = Some(ty); + return Ok(()); + } + + if meta.path.is_ident("skip") { + meta_attr.skip = true; + return Ok(()); + } + Err(meta.error("Expected #[meta(into = Type)] or #[meta(skip)]")) + })?; + } + + Ok(meta_attr) +} + +fn generate_enum(item_enum: syn::ItemEnum, _: MetadataAttr) -> syn::Result { + if !item_enum.generics.params.is_empty() { + return Err(s_err( + item_enum.generics.span(), + "Generics and Lifetimes not supported for enums", + )); + } + + let enum_name_ident = item_enum.ident.clone(); + let enum_name = enum_name_ident.to_string(); + + let fields = item_enum + .variants + .iter() + .map(|variant| { + if !variant.fields.is_empty() { + return Err(s_err( + variant.fields.span(), + "Enums with values are not supported", + )); + } + let ident = variant.ident.to_string(); + Ok(Field { + name: ident, + ty: RustType::None, + }) + }) + .collect::>>()?; + + Ok(TypeInfo { + name: enum_name, + generics: vec![], + fields, + dependencies: vec![], + }) +} + +fn generate_type(item_type: syn::ItemType, metadata_attr: MetadataAttr) -> syn::Result { + let type_name_ident = item_type.ident.clone(); + let type_name = type_name_ident.to_string(); + let generics: Vec = item_type + .generics + .type_params() + .map(|type_param| type_param.ident.to_string()) + .collect(); + + let mut dependencies: Vec = Vec::new(); + + let ty = to_rust_type(&item_type.ty, &metadata_attr.custom) + .ok_or_else(|| s_err(item_type.ty.span(), "Unsupported type"))?; + + process_rust_type(&ty, &mut dependencies, &generics); + + Ok(TypeInfo { + name: type_name, + generics, + fields: vec![Field { + name: String::new(), + ty, + }], + dependencies, + }) +} + +fn generate_function(item_fn: syn::ItemFn, metadata_attr: MetadataAttr) -> syn::Result { + let fn_name_ident = item_fn.sig.ident.clone(); + let name = fn_name_ident.to_string(); + let mut dependencies = Vec::new(); + + let params = item_fn + .sig + .inputs + .iter() + .filter_map(|param| match param { + syn::FnArg::Typed(syn::PatType { pat, ty, .. }) => { + let pat = pat.to_token_stream().to_string(); + if let Some(rust_type) = to_rust_type(ty, &metadata_attr.custom) { + process_rust_type(&rust_type, &mut dependencies, &[]); + Some(Ok((pat, rust_type))) + } else { + None + } + } + syn::FnArg::Receiver(_) => { + Some(Err(s_err(param.span(), "Receiver parameter not allowed"))) + } + }) + .collect::>>()?; + + let response = match &item_fn.sig.output { + syn::ReturnType::Type(_, ty) => { + if let Some(rust_type) = to_rust_type(ty, &metadata_attr.custom) { + process_rust_type(&rust_type, &mut dependencies, &[]); + rust_type + } else { + return Err(s_err(ty.span(), "Unsupported return type")); + } + } + syn::ReturnType::Default => RustType::BuiltIn("()".to_string()), + }; + + let params_info: Vec = params + .iter() + .map(|(pat, ty)| Field { + name: pat.clone(), + ty: ty.clone(), + }) + .collect(); + + Ok(FnInfo { + name, + params: params_info, + response, + types: dependencies, + }) +} + +fn process_rust_type(rust_type: &RustType, dependencies: &mut Vec, generics: &[String]) { + match rust_type { + RustType::Custom(inner_ty) => { + if !dependencies.contains(inner_ty) && !generics.contains(inner_ty) { + dependencies.push(inner_ty.clone()); + } + } + RustType::CustomGeneric(outer_ty, inner_tys) => { + if !dependencies.contains(outer_ty) && !generics.contains(outer_ty) { + dependencies.push(outer_ty.clone()); + } + for inner_ty in inner_tys { + process_rust_type(inner_ty, dependencies, generics); + } + } + RustType::Tuple(inner_tys) => { + for inner_ty in inner_tys { + process_rust_type(inner_ty, dependencies, generics); + } + } + RustType::Generic(_, inner_tys) => { + for inner_ty in inner_tys { + process_rust_type(inner_ty, dependencies, generics); + } + } + _ => {} + } +} + /// Type information. #[derive(Clone, Debug)] -pub struct TypeInfo { - pub name: String, - pub generics: Vec, - pub fields: Vec, - pub dependencies: Vec, +struct TypeInfo { + name: String, + generics: Vec, + fields: Vec, + dependencies: Vec, } impl TypeInfo { @@ -314,7 +892,7 @@ impl TypeInfo { interfaces: &BTreeMap, enum_types: &BTreeMap, type_types: &BTreeMap, - ) -> Result { + ) -> syn::Result { let generics_str = if self.generics.is_empty() { "".to_string() } else { @@ -329,7 +907,7 @@ impl TypeInfo { Ok(interface) } - fn generate_enum_type(&self) -> Result { + fn generate_enum_type(&self) -> syn::Result { let mut enum_type = format!(" export type {} = ", self.name); for (i, field) in self.fields.iter().enumerate() { enum_type.push_str(&format!( @@ -351,7 +929,7 @@ impl TypeInfo { interfaces: &BTreeMap, enum_types: &BTreeMap, type_types: &BTreeMap, - ) -> Result { + ) -> syn::Result { let mut type_type = format!( " export type {}{} = ", self.name, @@ -380,13 +958,138 @@ impl TypeInfo { /// Field information. #[derive(Clone, Debug)] -pub struct Field { - pub name: String, - pub ty: RustType, +struct Field { + name: String, + ty: RustType, +} + +#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Debug)] +enum ApiType { + Unknown(String), + Json(String), + Path(String), + PathTuple(String), + Query(String), + QueryMap(String), +} + +impl ApiType { + fn unwrap(&self) -> String { + match self { + ApiType::Unknown(t) => t.to_string(), + ApiType::Json(t) => t.to_string(), + ApiType::Path(t) => t.to_string(), + ApiType::PathTuple(t) => t.to_string(), + ApiType::Query(t) => t.to_string(), + ApiType::QueryMap(t) => t.to_string(), + } + } +} + +struct ParsedTypeScript<'a> { + base: String, + basic_functions: &'a str, + namespace_start: &'a str, + namespace_end: &'a str, + interfaces: BTreeMap, + functions: BTreeMap, + enum_types: BTreeMap, + type_types: BTreeMap, +} + +impl<'a> ParsedTypeScript<'a> { + fn new( + base: String, + basic_functions: &'a str, + namespace_start: &'a str, + namespace_end: &'a str, + ) -> Self { + Self { + base, + basic_functions, + namespace_start, + namespace_end, + interfaces: BTreeMap::new(), + functions: BTreeMap::new(), + enum_types: BTreeMap::new(), + type_types: BTreeMap::new(), + } + } + + fn filled(base: &'a str) -> ParsedTypeScript { + let base = format!("const BASE = '{}';\n", base); + let basic_functions = r#" async function fetch_api(endpoint: string, options: RequestInit): Promise { + const response = await fetch(endpoint, { + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + ...options, + }); + return response.json(); + } + + function query_str(params: Record): string { + if (params) { + let data: Record = {}; + for (let key in params) { + if (params[key] != null) data[key] = params[key].toString(); + } + // the URLSearchParams escapes any problematic values + return '?' + new URLSearchParams(data).toString(); + } + return ''; + } +"#; + let namespace_start = "namespace api {\n"; + let namespace_end = "}\n\nexport default api;"; + + ParsedTypeScript::new(base, basic_functions, namespace_start, namespace_end) + } + + fn write_to_file>(&self, path: P) -> std::io::Result<()> { + // todo: errors + let mut file = fs::File::create(path)?; + + file.write_all(self.base.as_bytes())?; + file.write_all(b"\n")?; + + file.write_all(self.namespace_start.as_bytes())?; + + for interface in self.interfaces.values() { + file.write_all(interface.as_bytes())?; + file.write_all(b"\n")?; + } + + for enum_type in self.enum_types.values() { + file.write_all(enum_type.as_bytes())?; + file.write_all(b"\n")?; + } + + for type_type in self.type_types.values() { + file.write_all(type_type.as_bytes())?; + file.write_all(b"\n")?; + } + + file.write_all(self.basic_functions.as_bytes())?; + file.write_all(b"\n")?; + + for (i, function) in self.functions.values().enumerate() { + file.write_all(function.as_bytes())?; + if self.functions.len() - 1 > i { + file.write_all(b"\n")?; + } + } + + file.write_all(self.namespace_end.as_bytes())?; + file.write_all(b"\n")?; + + Ok(()) + } } #[derive(Debug, PartialEq, Clone)] -pub enum RustType { +enum RustType { BuiltIn(String), Generic(String, Vec), Tuple(Vec), @@ -410,10 +1113,13 @@ impl RustType { interfaces: &'a BTreeMap, enum_types: &'a BTreeMap, type_types: &'a BTreeMap, - ) -> Result { + ) -> syn::Result { if let Some(t) = generics.iter().find(|p| **p == self.unwrap()) { return Ok(ApiType::Unknown(t.to_string())); } + if *self == Self::None { + return Ok(ApiType::Unknown(String::new())); + } match &self { Self::BuiltIn(ty) => match ty.as_str() { @@ -473,7 +1179,7 @@ impl RustType { } "HashMap" => { if inner_tys.len() != 2 { - return Err(Error::Ts(format!( + return Err(s_err(proc_macro2::Span::call_site(), format!( "HashMap must have two inner types, found {}. When wanting to use a custom type, set that on the metadata via `#[metadata(custom = [Type])]`", inner_tys.len() ))); @@ -490,7 +1196,7 @@ impl RustType { } "Result" => { if inner_tys.len() != 2 { - return Err(Error::Ts(format!( + return Err(s_err(proc_macro2::Span::call_site(),format!( "Result type must have two inner types, found {}. When wanting to use a custom type, set that on the metadata via `#[metadata(custom = [Type])]`", inner_tys.len() ))); @@ -537,10 +1243,10 @@ impl RustType { } _ => {} }; - Err(Error::Ts(format!( - "RustType '{:?}' couldn't be converted to TypeScript", - self - ))) + Err(s_err( + proc_macro2::Span::call_site(), + format!("RustType '{:?}' couldn't be converted to TypeScript", self), + )) } fn join_generic( @@ -549,11 +1255,11 @@ impl RustType { interfaces: &BTreeMap, enum_types: &BTreeMap, type_types: &BTreeMap, - ) -> Result { + ) -> syn::Result { Ok(tys .iter() .map(|t| t.to_api_type(generics, interfaces, enum_types, type_types)) - .collect::>>()? + .collect::>>()? .iter() .map(|t| t.unwrap()) .collect::>() @@ -561,95 +1267,95 @@ impl RustType { } } -#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Debug)] -enum ApiType { - Unknown(String), - Json(String), - Path(String), - PathTuple(String), - Query(String), - QueryMap(String), -} +const RUST_TYPES: &[&str] = &[ + "bool", "char", "str", "u8", "u16", "u32", "u64", "u128", "i8", "i16", "i32", "i64", "i128", + "usize", "isize", "f32", "f64", "String", +]; -impl ApiType { - fn unwrap(&self) -> String { - match self { - ApiType::Unknown(t) => t.to_string(), - ApiType::Json(t) => t.to_string(), - ApiType::Path(t) => t.to_string(), - ApiType::PathTuple(t) => t.to_string(), - ApiType::Query(t) => t.to_string(), - ApiType::QueryMap(t) => t.to_string(), - } - } -} +const SKIP_TYPES: &[&str] = &["State", "Headers", "Bytes", "Request", "Extension"]; -struct ParsedTypeScript<'a> { - base: &'a str, - basic_functions: &'a str, - namespace_start: &'a str, - namespace_end: &'a str, - interfaces: BTreeMap, - functions: BTreeMap, - enum_types: BTreeMap, - type_types: BTreeMap, -} - -impl<'a> ParsedTypeScript<'a> { - fn new( - base: &'a str, - basic_functions: &'a str, - namespace_start: &'a str, - namespace_end: &'a str, - ) -> Self { - Self { - base, - basic_functions, - namespace_start, - namespace_end, - interfaces: BTreeMap::new(), - functions: BTreeMap::new(), - enum_types: BTreeMap::new(), - type_types: BTreeMap::new(), - } - } +const BUILTIN_GENERICS: &[&str] = &[ + "Query", "HashMap", "Path", "Vec", "Json", "Option", "Result", +]; - fn write_to_file>(&self, path: P) -> Result<()> { - let mut file = File::create(path)?; +fn is_builtin_type(ident: &syn::Ident) -> bool { + RUST_TYPES.contains(&ident.to_string().as_str()) +} - file.write_all(self.base.as_bytes())?; - file.write_all(b"\n").unwrap(); +fn is_skip_type(ident: &syn::Ident) -> bool { + SKIP_TYPES.contains(&ident.to_string().as_str()) +} - file.write_all(self.namespace_start.as_bytes())?; +fn is_builtin_generic(ident: &syn::Ident) -> bool { + BUILTIN_GENERICS.contains(&ident.to_string().as_str()) +} - for interface in self.interfaces.values() { - file.write_all(interface.as_bytes())?; - file.write_all(b"\n").unwrap(); - } +fn is_custom(ident: &syn::Ident, custom: &[String]) -> bool { + custom.contains(&ident.to_string()) +} - for enum_type in self.enum_types.values() { - file.write_all(enum_type.as_bytes())?; - file.write_all(b"\n").unwrap(); - } +fn to_rust_type(ty: &syn::Type, custom: &[String]) -> Option { + match ty { + syn::Type::Path(type_path) => { + let segment = type_path.path.segments.last().unwrap(); + let ident = &segment.ident; - for type_type in self.type_types.values() { - file.write_all(type_type.as_bytes())?; - file.write_all(b"\n").unwrap(); + if is_builtin_type(ident) && !is_custom(ident, custom) { + Some(RustType::BuiltIn(ident.to_string())) + } else if is_skip_type(ident) && !is_custom(ident, custom) { + None + } else if is_builtin_generic(ident) && !is_custom(ident, custom) { + if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { + let inner_types: Vec = args + .args + .iter() + .filter_map(|arg| { + if let syn::GenericArgument::Type(inner_ty) = arg { + to_rust_type(inner_ty, custom) + } else { + None + } + }) + .collect(); + Some(RustType::Generic(ident.to_string(), inner_types)) + } else { + Some(RustType::Generic(ident.to_string(), vec![])) + } + } else if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { + let inner_types: Vec = args + .args + .iter() + .filter_map(|arg| { + if let syn::GenericArgument::Type(inner_ty) = arg { + to_rust_type(inner_ty, custom) + } else { + None + } + }) + .collect(); + Some(RustType::CustomGeneric(ident.to_string(), inner_types)) + } else { + Some(RustType::Custom(ident.to_string())) + } } + syn::Type::Reference(syn::TypeReference { elem, .. }) + | syn::Type::Paren(syn::TypeParen { elem, .. }) + | syn::Type::Group(syn::TypeGroup { elem, .. }) => to_rust_type(elem, custom), - file.write_all(self.basic_functions.as_bytes())?; - file.write_all(b"\n").unwrap(); - - for (i, function) in self.functions.values().enumerate() { - file.write_all(function.as_bytes())?; - if self.functions.len() - 1 > i { - file.write_all(b"\n").unwrap(); + syn::Type::Tuple(type_tuple) => { + if type_tuple.elems.is_empty() { + return Some(RustType::BuiltIn("()".to_string())); } + let inner_types: Vec = type_tuple + .elems + .iter() + .filter_map(|t| to_rust_type(t, custom)) + .collect(); + Some(RustType::Tuple(inner_types)) } - - file.write_all(self.namespace_end.as_bytes())?; - file.write_all(b"\n").unwrap(); - - Ok(()) + syn::Type::Slice(syn::TypeSlice { elem, .. }) + | syn::Type::Array(syn::TypeArray { elem, .. }) => to_rust_type(elem, custom) + .map(|inner| RustType::Generic("Vec".to_string(), vec![inner])), + _ => None, } } diff --git a/tests/main.rs b/tests/main.rs index 8b07ef3..ca936d9 100644 --- a/tests/main.rs +++ b/tests/main.rs @@ -1,9 +1,9 @@ use axum::{ extract::{Path, Query}, routing::get, - Json, + Json, Router, }; -use gluer::{extract, metadata, Api}; +use gluer::{generate, metadata, route}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -78,15 +78,17 @@ type S = String; #[tokio::test] async fn main_test() { - let app: Api<()> = Api::new() - .route("/:p", extract!(get(fetch_root).post(add_root))) - .route("/char/:path/metadata/:path", extract!(get(get_alphabet))); + let mut _app: Router = Router::new(); - app.generate_client("tests/api.ts", "").unwrap(); + route!(_app, "/:p", get(fetch_root).post(add_root)); + route!(_app, "/char/:path/metadata/:path", get(get_alphabet)); + + // Make sure to change "tests" to "src" when copying this example + generate!("tests", "tests/api.ts", ""); let _listener = tokio::net::TcpListener::bind("127.0.0.1:8080") .await .unwrap(); // starts the server, comment in and rename `_listener` to run it - // axum::serve(listener, app.into_router()).await.unwrap(); + // axum::serve(listener, app).await.unwrap(); }