diff --git a/README.md b/README.md index 5974a8b..9ebe038 100644 --- a/README.md +++ b/README.md @@ -169,11 +169,10 @@ pub struct Hello { #[metadata] #[derive(Serialize, Deserialize, Default)] struct Age { - // #[meta(into = String)] + #[meta(into = String)] age: AgeInner, } -#[metadata] #[derive(Serialize, Deserialize, Default)] struct AgeInner { age: u8, diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..bf2b6cb --- /dev/null +++ b/src/error.rs @@ -0,0 +1,27 @@ +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) + } +} diff --git a/src/lib.rs b/src/lib.rs index 9860fc9..a794e61 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,9 +1,12 @@ #![doc = include_str!("../README.md")] +pub mod error; + pub use gluer_macros::{extract, metadata}; use axum::routing::MethodRouter; use axum::Router; +use error::{Error, Result}; use std::{collections::BTreeMap, fs::File, io::Write}; use crate::Type::{Json, Path, Query, QueryMap, Unknown}; @@ -64,7 +67,7 @@ where } /// Generate frontend TypeScript API client from the API routes. - pub fn generate_client>(&self, path: P) -> Result<(), String> { + pub fn generate_client>(&self, path: P) -> Result<()> { let fetch_api_function = r#" async function fetch_api(endpoint: string, options: RequestInit): Promise { const response = await fetch(endpoint, { headers: { @@ -95,41 +98,31 @@ where let mut ts_interfaces: BTreeMap = BTreeMap::new(); for route in &self.api_routes { - if !route.fn_info.structs.is_empty() { - for struct_info in route.fn_info.structs { - Self::deps(struct_info.dependencies, &mut ts_interfaces); - } - Self::deps(route.fn_info.structs, &mut ts_interfaces); - } + Route::resolving_dependencies(route.fn_info.structs, &mut ts_interfaces)?; let params_type = route .fn_info .params .iter() - .map(|Field { name: _, ty }| ty_to_ts(ty, &[], &ts_interfaces).unwrap()) - .collect::>(); - let response_type = ty_to_ts(route.fn_info.response, &[], &ts_interfaces).unwrap(); + .map(|Field { name: _, ty }| Type::::ty_to_ts(ty, &[], &ts_interfaces)) + .collect::>>()?; + let response_type = + Type::::ty_to_ts(route.fn_info.response, &[], &ts_interfaces)?; if ts_functions.contains_key(route.fn_name) { - return Err(format!( + return Err(Error::Ts(format!( "Function with name '{}' already exists", route.fn_name, - )); + ))); } else { ts_functions.insert( route.fn_name.to_string(), - generate_ts_function( - route.url, - route.method, - route.fn_name, - params_type, - response_type, - ), + route.generate_ts_function(params_type, response_type), ); } } - write_to_file( + Self::write_to_file( path, fetch_api_function, namespace_start, @@ -137,28 +130,39 @@ where ts_interfaces, ts_functions, ) - .map_err(|e| format!("Failed to write to file: {}", e))?; - - Ok(()) } - fn deps(dependencies: &[StructInfo], ts_interfaces: &mut BTreeMap) { - for StructInfo { - name, - generics, - fields, - dependencies, - } in dependencies - { - if !ts_interfaces.contains_key(&name.to_string()) { - let ts_interfaces_clone = ts_interfaces.clone(); - ts_interfaces.insert( - name.to_string(), - generate_ts_interface(name, generics, fields, &ts_interfaces_clone), - ); + fn write_to_file>( + path: P, + fetch_api_function: &str, + namespace_start: &str, + namespace_end: &str, + ts_interfaces: BTreeMap, + ts_functions: BTreeMap, + ) -> Result<()> { + let mut file = File::create(path)?; + + file.write_all(namespace_start.as_bytes())?; + + for interface in ts_interfaces.values() { + file.write_all(interface.as_bytes())?; + file.write_all(b"\n").unwrap(); + } + + file.write_all(fetch_api_function.as_bytes())?; + file.write_all(b"\n").unwrap(); + + for (i, function) in ts_functions.values().enumerate() { + file.write_all(function.as_bytes())?; + if ts_functions.len() - 1 > i { + file.write_all(b"\n").unwrap(); } - Self::deps(dependencies, ts_interfaces); } + + file.write_all(namespace_end.as_bytes())?; + file.write_all(b"\n").unwrap(); + + Ok(()) } /// Convert into an `axum::Router`. @@ -185,6 +189,78 @@ pub struct Route<'a> { pub fn_info: FnInfo<'a>, } +impl<'a> Route<'a> { + fn resolving_dependencies( + dependencies: &[StructInfo], + ts_interfaces: &mut BTreeMap, + ) -> Result<()> { + for struct_info in dependencies { + Self::resolving_dependencies(struct_info.dependencies, ts_interfaces)?; + if !ts_interfaces.contains_key(&struct_info.name.to_string()) { + let ts_interfaces_clone = ts_interfaces.clone(); + ts_interfaces.insert( + struct_info.name.to_string(), + struct_info.generate_ts_interface(&ts_interfaces_clone)?, + ); + } + } + Ok(()) + } + + fn generate_ts_function( + &self, + params_type: Vec>, + response_type: Type, + ) -> String { + let mut url = self.url.to_string(); + + let params_str = params_type + .iter() + .filter_map(|ty| match ty { + Json(ty) => Some(format!("data: {}", ty)), + Path(ty) => Some(format!("path: {}", ty)), + Query(ty) => Some(format!("query: {}", ty)), + QueryMap(ty) => Some(format!("queryMap: Record{}", ty)), + Unknown(_) => None, + }) + .collect::>() + .join(", "); + + let body_assignment = if params_str.contains("data") { + "\n body: JSON.stringify(data)" + } else { + "" + }; + + if params_str.contains("path") { + url = url.split(":").next().unwrap().to_string(); + url += "${encodeURIComponent(path)}"; + } + + if params_str.contains("queryMap") { + url += "?"; + url += "${new URLSearchParams(queryMap).toString()}"; + } else if params_str.contains("query") { + url += "${query_str(query)}"; + } + + format!( + r#" export async function {fn_name}({params_str}): Promise<{response_type}> {{ + return fetch_api(`{url}`, {{ + method: "{method}", {body_assignment} + }}); + }} +"#, + fn_name = self.fn_name, + params_str = params_str, + response_type = response_type.unwrap(), + url = url, + method = self.method.to_uppercase(), + body_assignment = body_assignment + ) + } +} + /// Function information. #[derive(Clone, Copy, Debug)] pub struct FnInfo<'a> { @@ -202,6 +278,23 @@ pub struct StructInfo<'a> { pub dependencies: &'a [StructInfo<'a>], } +impl<'a> StructInfo<'a> { + fn generate_ts_interface(&self, ts_interfaces: &BTreeMap) -> Result { + let generics_str = if self.generics.is_empty() { + "".to_string() + } else { + format!("<{}>", self.generics.join(", ")) + }; + let mut interface = format!(" export interface {}{} {{\n", self.name, generics_str); + for Field { name, ty } in self.fields { + let ty = Type::::ty_to_ts(ty, self.generics, ts_interfaces)?; + interface.push_str(&format!(" {}: {};\n", name, ty.unwrap())); + } + interface.push_str(" }\n"); + Ok(interface) + } +} + /// Field information. #[derive(Debug)] pub struct Field<'a> { @@ -209,81 +302,6 @@ pub struct Field<'a> { pub ty: &'a str, } -fn generate_ts_interface( - struct_name: &str, - generics: &[&str], - fields: &[Field], - ts_interfaces: &BTreeMap, -) -> String { - let generics_str = if generics.is_empty() { - "".to_string() - } else { - format!("<{}>", generics.join(", ")) - }; - let mut interface = format!(" export interface {}{} {{\n", struct_name, generics_str); - for Field { name, ty } in fields { - let ty = ty_to_ts(ty, generics, ts_interfaces).unwrap(); - interface.push_str(&format!(" {}: {};\n", name, ty.unwrap())); - } - interface.push_str(" }\n"); - interface -} - -fn generate_ts_function( - url: &str, - method: &str, - fn_name: &str, - params_type: Vec>, - response_type: Type, -) -> String { - let mut url = url.to_string(); - - let params_str = params_type - .iter() - .filter_map(|ty| match ty { - Json(ty) => Some(format!("data: {}", ty)), - Path(ty) => Some(format!("path: {}", ty)), - Query(ty) => Some(format!("query: {}", ty)), - QueryMap(ty) => Some(format!("queryMap: Record{}", ty)), - Unknown(_) => None, - }) - .collect::>() - .join(", "); - - let body_assignment = if params_str.contains("data") { - "\n body: JSON.stringify(data)" - } else { - "" - }; - - if params_str.contains("path") { - url = url.split(":").next().unwrap().to_string(); - url += "${encodeURIComponent(path)}"; - } - - if params_str.contains("queryMap") { - url += "?"; - url += "${new URLSearchParams(queryMap).toString()}"; - } else if params_str.contains("query") { - url += "${query_str(query)}"; - } - - format!( - r#" export async function {fn_name}({params_str}): Promise<{response_type}> {{ - return fetch_api(`{url}`, {{ - method: "{method}", {body_assignment} - }}); - }} -"#, - fn_name = fn_name, - params_str = params_str, - response_type = response_type.unwrap(), - url = url, - method = method.to_uppercase(), - body_assignment = body_assignment - ) -} - #[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Debug)] enum Type { Unknown(T), @@ -303,170 +321,144 @@ impl Type { Type::QueryMap(t) => t, } } -} -fn ty_to_ts<'a>( - ty: &'a str, - generics: &[&str], - ts_interfaces: &'a BTreeMap, -) -> Result, String> { - let ty = ty.trim().replace(" ", ""); - if ts_interfaces.contains_key(ty.as_str()) { - return Ok(Unknown(ty.to_string())); + fn ty_to_ts<'a>( + ty: &'a str, + generics: &[&str], + ts_interfaces: &'a BTreeMap, + ) -> Result> { + let ty = ty.trim().replace(" ", ""); + if ts_interfaces.contains_key(ty.as_str()) { + return Ok(Unknown(ty.to_string())); + } + + Ok(match ty.as_str() { + "str" | "String" => Unknown(String::from("string")), + "usize" | "isize" | "u8" | "u16" | "u32" | "u64" | "i8" | "i16" | "i32" | "i64" + | "f32" | "f64" => Unknown(String::from("number")), + "bool" => Unknown(String::from("boolean")), + "()" => Unknown(String::from("void")), + t if t.starts_with("Vec<") => { + let inner_ty = &t[4..t.len() - 1]; + let ty = Self::ty_to_ts(inner_ty, generics, ts_interfaces)?.unwrap(); + Unknown(format!("{}[]", ty)) + } + t if t.starts_with("Html<") => { + let inner_ty = &t[5..t.len() - 1]; + let ty = Self::ty_to_ts(inner_ty, generics, ts_interfaces)?.unwrap(); + Unknown(ty) + } + t if t.starts_with("Json<") => { + let inner_ty = &t[5..t.len() - 1]; + let ty = Self::ty_to_ts(inner_ty, generics, ts_interfaces)?.unwrap(); + Json(ty) + } + t if t.starts_with("Path<") => { + let inner_ty = &t[5..t.len() - 1]; + let ty = Self::ty_to_ts(inner_ty, generics, ts_interfaces)?.unwrap(); + Path(ty) + } + t if t.starts_with("Query { + let inner_ty = &t[13..t.len() - 1]; + let ty = Self::ty_to_ts(inner_ty, generics, ts_interfaces)?.unwrap(); + QueryMap(ty) + } + t if t.starts_with("Query<") => { + let inner_ty = &t[6..t.len() - 1]; + let ty = Self::ty_to_ts(inner_ty, generics, ts_interfaces)?.unwrap(); + Query(ty) + } + t if t.starts_with("Result<") => { + let inner_ty = &t[7..t.len() - 1]; + let ty = Self::ty_to_ts(inner_ty, generics, ts_interfaces)?.unwrap(); + Unknown(format!("{} | any", ty)) + } + t if t.starts_with("Option<") => { + let inner_ty = &t[7..t.len() - 1]; + let ty = Self::ty_to_ts(inner_ty, generics, ts_interfaces)?.unwrap(); + Unknown(format!("{} | null", ty)) + } + t if t.starts_with("&") => Self::ty_to_ts(&t[1..t.len()], generics, ts_interfaces)?, + t if t.starts_with("'static") => { + Self::ty_to_ts(&t[7..t.len()], generics, ts_interfaces)? + } + t if t.contains('<') && t.contains('>') => { + Self::parse_generic_type(t, generics, ts_interfaces)? + } + t => { + if let Some(t) = generics.iter().find(|p| **p == t) { + Unknown(t.to_string()) + } else { + return Err(Error::Ts(format!( + "Type '{}' couldn't be converted to TypeScript", + ty + ))); + } + } + }) } - Ok(match ty.as_str() { - "str" | "String" => Unknown(String::from("string")), - "usize" | "isize" | "u8" | "u16" | "u32" | "u64" | "i8" | "i16" | "i32" | "i64" | "f32" - | "f64" => Unknown(String::from("number")), - "bool" => Unknown(String::from("boolean")), - "()" => Unknown(String::from("void")), - t if t.starts_with("Vec<") => { - let inner_ty = &t[4..t.len() - 1]; - let ty = ty_to_ts(inner_ty, generics, ts_interfaces)?.unwrap(); - Unknown(format!("{}[]", ty)) - } - t if t.starts_with("Html<") => { - let inner_ty = &t[5..t.len() - 1]; - let ty = ty_to_ts(inner_ty, generics, ts_interfaces)?.unwrap(); - Unknown(ty) - } - t if t.starts_with("Json<") => { - let inner_ty = &t[5..t.len() - 1]; - let ty = ty_to_ts(inner_ty, generics, ts_interfaces)?.unwrap(); - Json(ty) - } - t if t.starts_with("Path<") => { - let inner_ty = &t[5..t.len() - 1]; - let ty = ty_to_ts(inner_ty, generics, ts_interfaces)?.unwrap(); - Path(ty) - } - t if t.starts_with("Query { - let inner_ty = &t[13..t.len() - 1]; - let ty = ty_to_ts(inner_ty, generics, ts_interfaces)?.unwrap(); - QueryMap(ty) - } - t if t.starts_with("Query<") => { - let inner_ty = &t[6..t.len() - 1]; - let ty = ty_to_ts(inner_ty, generics, ts_interfaces)?.unwrap(); - Query(ty) - } - t if t.starts_with("Result<") => { - let inner_ty = &t[7..t.len() - 1]; - let ty = ty_to_ts(inner_ty, generics, ts_interfaces)?.unwrap(); - Unknown(format!("{} | any", ty)) - } - t if t.starts_with("Option<") => { - let inner_ty = &t[7..t.len() - 1]; - let ty = ty_to_ts(inner_ty, generics, ts_interfaces)?.unwrap(); - Unknown(format!("{} | null", ty)) - } - t if t.starts_with("&") => ty_to_ts(&t[1..t.len()], generics, ts_interfaces)?, - t if t.starts_with("'static") => ty_to_ts(&t[7..t.len()], generics, ts_interfaces)?, - t if t.contains('<') && t.contains('>') => parse_generic_type(t, generics, ts_interfaces)?, - t => { - if let Some(t) = generics.iter().find(|p| **p == t) { - Unknown(t.to_string()) + fn parse_generic_type<'a>( + t: &'a str, + generics: &[&str], + ts_interfaces: &'a BTreeMap, + ) -> Result> { + let mut base_ty = String::new(); + let mut generic_params = String::new(); + let mut depth = 0; + let mut inside_generic = false; + + for c in t.chars() { + if c == '<' { + depth += 1; + if depth == 1 { + inside_generic = true; + continue; + } + } else if c == '>' { + depth -= 1; + if depth == 0 { + inside_generic = false; + continue; + } + } + + if inside_generic { + generic_params.push(c); } else { - return Err(format!("Type '{}' couldn't be converted to TypeScript", ty)); + base_ty.push(c); } } - }) -} -fn parse_generic_type<'a>( - t: &'a str, - generics: &[&str], - ts_interfaces: &'a BTreeMap, -) -> Result, String> { - let mut base_ty = String::new(); - let mut generic_params = String::new(); - let mut depth = 0; - let mut inside_generic = false; - - for c in t.chars() { - if c == '<' { - depth += 1; - if depth == 1 { - inside_generic = true; - continue; - } - } else if c == '>' { - depth -= 1; - if depth == 0 { - inside_generic = false; + let mut params = Vec::new(); + let mut current_param = String::new(); + let mut param_depth = 0; + + for c in generic_params.chars() { + if c == '<' { + param_depth += 1; + } else if c == '>' { + param_depth -= 1; + } else if c == ',' && param_depth == 0 { + params.push(current_param.trim().to_string()); + current_param.clear(); continue; } + current_param.push(c); } - - if inside_generic { - generic_params.push(c); - } else { - base_ty.push(c); - } - } - - let mut params = Vec::new(); - let mut current_param = String::new(); - let mut param_depth = 0; - - for c in generic_params.chars() { - if c == '<' { - param_depth += 1; - } else if c == '>' { - param_depth -= 1; - } else if c == ',' && param_depth == 0 { + if !current_param.is_empty() { params.push(current_param.trim().to_string()); - current_param.clear(); - continue; } - current_param.push(c); - } - if !current_param.is_empty() { - params.push(current_param.trim().to_string()); - } - - let generic_ts = params - .into_iter() - .map(|param| ty_to_ts(¶m, generics, ts_interfaces)) - .collect::, _>>()? - .into_iter() - .map(|t| t.unwrap()) - .collect::>() - .join(", "); - - Ok(Unknown(format!("{}<{}>", base_ty, generic_ts))) -} - -fn write_to_file>( - path: P, - fetch_api_function: &str, - namespace_start: &str, - namespace_end: &str, - ts_interfaces: BTreeMap, - ts_functions: BTreeMap, -) -> std::io::Result<()> { - let mut file = File::create(path)?; - - file.write_all(namespace_start.as_bytes())?; - for interface in ts_interfaces.values() { - file.write_all(interface.as_bytes())?; - file.write_all(b"\n").unwrap(); - } + let generic_ts = params + .into_iter() + .map(|param| Self::ty_to_ts(¶m, generics, ts_interfaces)) + .collect::>>()? + .into_iter() + .map(|t| t.unwrap()) + .collect::>() + .join(", "); - file.write_all(fetch_api_function.as_bytes())?; - file.write_all(b"\n").unwrap(); - - for (i, function) in ts_functions.values().enumerate() { - file.write_all(function.as_bytes())?; - if ts_functions.len() - 1 > i { - file.write_all(b"\n").unwrap(); - } + Ok(Unknown(format!("{}<{}>", base_ty, generic_ts))) } - - file.write_all(namespace_end.as_bytes())?; - file.write_all(b"\n").unwrap(); - - Ok(()) } diff --git a/tests/api.ts b/tests/api.ts index 52abb7e..b347f1e 100644 --- a/tests/api.ts +++ b/tests/api.ts @@ -1,10 +1,6 @@ namespace api { export interface Age { - age: AgeInner; - } - - export interface AgeInner { - age: number; + age: string; } export interface Hello { @@ -39,7 +35,7 @@ namespace api { return ''; } - export async function add_root(path: number, data: Hello, string>, string>): Promise { + export async function add_root(path: number, data: Hello, Huh>, string>): Promise { return fetch_api(`/${encodeURIComponent(path)}`, { method: "POST", body: JSON.stringify(data) diff --git a/tests/main.rs b/tests/main.rs index 877ba21..58a013f 100644 --- a/tests/main.rs +++ b/tests/main.rs @@ -23,11 +23,10 @@ pub struct Hello { #[metadata] #[derive(Serialize, Deserialize, Default)] struct Age { - // #[meta(into = String)] + #[meta(into = String)] age: AgeInner, } -#[metadata] #[derive(Serialize, Deserialize, Default)] struct AgeInner { age: u8,