use console::style;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use syn::visit::Visit;
use syn::{Attribute, Fields, FnArg, ItemFn, ItemStruct, Type};
use walkdir::WalkDir;
#[derive(Debug, Clone, PartialEq)]
pub enum HttpMethod {
Get,
Post,
Put,
Patch,
Delete,
}
impl HttpMethod {
fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"get" => Some(HttpMethod::Get),
"post" => Some(HttpMethod::Post),
"put" => Some(HttpMethod::Put),
"patch" => Some(HttpMethod::Patch),
"delete" => Some(HttpMethod::Delete),
_ => None,
}
}
fn to_ts_method(&self) -> &'static str {
match self {
HttpMethod::Get => "get",
HttpMethod::Post => "post",
HttpMethod::Put => "put",
HttpMethod::Patch => "patch",
HttpMethod::Delete => "delete",
}
}
pub fn as_str_upper(&self) -> &'static str {
match self {
HttpMethod::Get => "GET",
HttpMethod::Post => "POST",
HttpMethod::Put => "PUT",
HttpMethod::Patch => "PATCH",
HttpMethod::Delete => "DELETE",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RoutesJson {
pub routes: Vec<RouteJson>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RouteJson {
pub method: String,
pub path: String,
pub handler: String,
pub name: Option<String>,
pub middleware: Vec<String>,
}
pub fn routes_to_json(routes: &[GeneratedRoute]) -> RoutesJson {
RoutesJson {
routes: routes
.iter()
.map(|r| RouteJson {
method: r.definition.method.as_str_upper().to_string(),
path: r.definition.path.clone(),
handler: format!(
"{}::{}",
r.definition.handler_module, r.definition.handler_fn
),
name: r.definition.name.clone(),
middleware: Vec::new(),
})
.collect(),
}
}
pub fn generate_json_string(project_path: &Path) -> Result<String, String> {
let routes = scan_routes(project_path)?;
let json = routes_to_json(&routes);
serde_json::to_string_pretty(&json).map_err(|e| format!("JSON serialize: {e}"))
}
pub fn run_json() {
let project_path = Path::new(".");
if !project_path.join("Cargo.toml").exists() {
eprintln!(
"{} Not a Ferro project (no Cargo.toml found)",
style("Error:").red().bold()
);
std::process::exit(1);
}
match generate_json_string(project_path) {
Ok(json) => {
println!("{json}");
}
Err(e) => {
eprintln!("{} {}", style("Error:").red().bold(), e);
std::process::exit(1);
}
}
}
#[derive(Debug, Clone)]
pub struct PathParam {
pub name: String,
}
#[derive(Debug, Clone)]
pub struct RouteDefinition {
pub method: HttpMethod,
pub path: String,
pub handler_module: String, pub handler_fn: String, pub name: Option<String>, pub path_params: Vec<PathParam>,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct HandlerInfo {
pub name: String,
pub has_handler_attr: bool,
pub request_type: Option<String>,
}
#[derive(Debug, Clone)]
pub struct FormRequestStruct {
pub name: String,
pub fields: Vec<FormRequestField>,
}
#[derive(Debug, Clone)]
pub struct FormRequestField {
pub name: String,
pub ty: RustType,
}
#[derive(Debug, Clone)]
pub enum RustType {
String,
Number,
Bool,
Option(Box<RustType>),
Vec(Box<RustType>),
Custom(String),
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct GeneratedRoute {
pub definition: RouteDefinition,
pub handler_info: Option<HandlerInfo>,
pub request_struct: Option<FormRequestStruct>,
}
pub fn parse_routes_file(content: &str) -> Vec<RouteDefinition> {
let mut routes = Vec::new();
let route_pattern = Regex::new(
r#"(get|post|put|patch|delete)!\s*\(\s*"([^"]+)"\s*,\s*([a-zA-Z_][a-zA-Z0-9_:]*)\s*\)(?:\s*\.name\s*\(\s*"([^"]+)"\s*\))?"#
).unwrap();
let param_pattern = Regex::new(r#"\{(\w+)\}"#).unwrap();
for cap in route_pattern.captures_iter(content) {
let method_str = cap.get(1).map(|m| m.as_str()).unwrap_or("");
let path = cap.get(2).map(|m| m.as_str()).unwrap_or("");
let handler_path = cap.get(3).map(|m| m.as_str()).unwrap_or("");
let name = cap.get(4).map(|m| m.as_str().to_string());
let method = match HttpMethod::from_str(method_str) {
Some(m) => m,
None => continue,
};
let parts: Vec<&str> = handler_path.rsplitn(2, "::").collect();
let (handler_fn, handler_module) = if parts.len() == 2 {
(parts[0].to_string(), parts[1].to_string())
} else {
continue;
};
let path_params: Vec<PathParam> = param_pattern
.captures_iter(path)
.filter_map(|cap| {
cap.get(1).map(|m| PathParam {
name: m.as_str().to_string(),
})
})
.collect();
routes.push(RouteDefinition {
method,
path: path.to_string(),
handler_module,
handler_fn,
name,
path_params,
});
}
routes
}
struct HandlerVisitor {
handlers: Vec<HandlerInfo>,
}
impl HandlerVisitor {
fn new() -> Self {
Self {
handlers: Vec::new(),
}
}
fn has_handler_attr(&self, attrs: &[Attribute]) -> bool {
attrs.iter().any(|attr| attr.path().is_ident("handler"))
}
fn extract_request_type(&self, func: &ItemFn) -> Option<String> {
if let Some(FnArg::Typed(pat_type)) = func.sig.inputs.first() {
return self.type_to_string(&pat_type.ty);
}
None
}
fn type_to_string(&self, ty: &Type) -> Option<String> {
match ty {
Type::Path(type_path) => {
let segments: Vec<String> = type_path
.path
.segments
.iter()
.map(|s| s.ident.to_string())
.collect();
let type_name = segments.last()?.clone();
if type_name == "Request" {
return None;
}
Some(type_name)
}
_ => None,
}
}
}
impl<'ast> Visit<'ast> for HandlerVisitor {
fn visit_item_fn(&mut self, node: &'ast ItemFn) {
let has_handler = self.has_handler_attr(&node.attrs);
let request_type = if has_handler {
self.extract_request_type(node)
} else {
None
};
self.handlers.push(HandlerInfo {
name: node.sig.ident.to_string(),
has_handler_attr: has_handler,
request_type,
});
syn::visit::visit_item_fn(self, node);
}
}
struct FormRequestVisitor {
structs: Vec<FormRequestStruct>,
}
impl FormRequestVisitor {
fn new() -> Self {
Self {
structs: Vec::new(),
}
}
fn has_form_request_attr(&self, attrs: &[Attribute]) -> bool {
for attr in attrs {
if attr.path().is_ident("form_request") {
return true;
}
if attr.path().is_ident("derive") {
if let Ok(nested) = attr.parse_args_with(
syn::punctuated::Punctuated::<syn::Path, syn::Token![,]>::parse_terminated,
) {
for path in nested {
if path.is_ident("FormRequest") {
return true;
}
}
}
}
}
false
}
fn parse_type(ty: &Type) -> RustType {
match ty {
Type::Path(type_path) => {
let segment = type_path.path.segments.last().unwrap();
let ident = segment.ident.to_string();
match ident.as_str() {
"String" | "str" => RustType::String,
"i8" | "i16" | "i32" | "i64" | "i128" | "isize" | "u8" | "u16" | "u32"
| "u64" | "u128" | "usize" | "f32" | "f64" => RustType::Number,
"bool" => RustType::Bool,
"Option" => {
if let syn::PathArguments::AngleBracketed(args) = &segment.arguments {
if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() {
return RustType::Option(Box::new(Self::parse_type(inner_ty)));
}
}
RustType::Option(Box::new(RustType::Custom("unknown".to_string())))
}
"Vec" => {
if let syn::PathArguments::AngleBracketed(args) = &segment.arguments {
if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() {
return RustType::Vec(Box::new(Self::parse_type(inner_ty)));
}
}
RustType::Vec(Box::new(RustType::Custom("unknown".to_string())))
}
other => RustType::Custom(other.to_string()),
}
}
Type::Reference(type_ref) => {
if let Type::Path(inner) = &*type_ref.elem {
if inner
.path
.segments
.last()
.map(|s| s.ident == "str")
.unwrap_or(false)
{
return RustType::String;
}
}
Self::parse_type(&type_ref.elem)
}
_ => RustType::Custom("unknown".to_string()),
}
}
}
impl<'ast> Visit<'ast> for FormRequestVisitor {
fn visit_item_struct(&mut self, node: &'ast ItemStruct) {
if self.has_form_request_attr(&node.attrs) {
let name = node.ident.to_string();
let fields = match &node.fields {
Fields::Named(named) => named
.named
.iter()
.filter_map(|f| {
f.ident.as_ref().map(|ident| FormRequestField {
name: ident.to_string(),
ty: Self::parse_type(&f.ty),
})
})
.collect(),
_ => Vec::new(),
};
self.structs.push(FormRequestStruct { name, fields });
}
syn::visit::visit_item_struct(self, node);
}
}
fn scan_controller_handlers(content: &str) -> Vec<HandlerInfo> {
if let Ok(syntax) = syn::parse_file(content) {
let mut visitor = HandlerVisitor::new();
visitor.visit_file(&syntax);
return visitor.handlers;
}
Vec::new()
}
fn scan_form_requests(project_path: &Path) -> HashMap<String, FormRequestStruct> {
let src_path = project_path.join("src");
let mut form_requests = HashMap::new();
for entry in WalkDir::new(&src_path)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().map(|ext| ext == "rs").unwrap_or(false))
{
if let Ok(content) = fs::read_to_string(entry.path()) {
if let Ok(syntax) = syn::parse_file(&content) {
let mut visitor = FormRequestVisitor::new();
visitor.visit_file(&syntax);
for s in visitor.structs {
form_requests.insert(s.name.clone(), s);
}
}
}
}
form_requests
}
fn resolve_module_to_file(project_path: &Path, module_path: &str) -> Option<std::path::PathBuf> {
let parts: Vec<&str> = module_path.split("::").collect();
if parts.is_empty() {
return None;
}
let file_path = project_path
.join("src")
.join(parts.join("/"))
.with_extension("rs");
if file_path.exists() {
return Some(file_path);
}
let mod_path = project_path
.join("src")
.join(parts.join("/"))
.join("mod.rs");
if mod_path.exists() {
return Some(mod_path);
}
None
}
pub fn scan_routes(project_path: &Path) -> Result<Vec<GeneratedRoute>, String> {
let routes_file = project_path.join("src/routes.rs");
if !routes_file.exists() {
return Err("src/routes.rs not found".to_string());
}
let routes_content =
fs::read_to_string(&routes_file).map_err(|e| format!("Failed to read routes.rs: {e}"))?;
let route_definitions = parse_routes_file(&routes_content);
let form_requests = scan_form_requests(project_path);
let mut generated_routes = Vec::new();
for def in route_definitions {
let handler_info = if let Some(controller_file) =
resolve_module_to_file(project_path, &def.handler_module)
{
if let Ok(content) = fs::read_to_string(&controller_file) {
let handlers = scan_controller_handlers(&content);
handlers.into_iter().find(|h| h.name == def.handler_fn)
} else {
None
}
} else {
None
};
let request_struct = handler_info
.as_ref()
.and_then(|h| h.request_type.as_ref())
.and_then(|type_name| form_requests.get(type_name).cloned());
generated_routes.push(GeneratedRoute {
definition: def,
handler_info,
request_struct,
});
}
Ok(generated_routes)
}
fn rust_type_to_ts(ty: &RustType) -> String {
match ty {
RustType::String => "string".to_string(),
RustType::Number => "number".to_string(),
RustType::Bool => "boolean".to_string(),
RustType::Option(inner) => format!("{} | null", rust_type_to_ts(inner)),
RustType::Vec(inner) => format!("{}[]", rust_type_to_ts(inner)),
RustType::Custom(name) => name.clone(),
}
}
pub fn generate_typescript(routes: &[GeneratedRoute]) -> String {
let mut output = String::new();
output.push_str("// This file is auto-generated by Ferro. Do not edit manually.\n");
output.push_str("// Run `ferro generate-types` to regenerate.\n");
output.push_str("// Compatible with Inertia.js v2+ UrlMethodPair interface\n\n");
output.push_str("import type { Method } from '@inertiajs/core';\n\n");
output.push_str("// Route configuration - compatible with Inertia's UrlMethodPair\n");
output.push_str("export interface RouteConfig<TData = void> {\n");
output.push_str(" url: string;\n");
output.push_str(" method: Method; // 'get' | 'post' | 'put' | 'patch' | 'delete'\n");
output.push_str(" data?: TData;\n");
output.push_str("}\n\n");
let mut form_request_types: Vec<&FormRequestStruct> = routes
.iter()
.filter_map(|r| r.request_struct.as_ref())
.collect();
form_request_types.sort_by(|a, b| a.name.cmp(&b.name));
form_request_types.dedup_by(|a, b| a.name == b.name);
if !form_request_types.is_empty() {
output.push_str("// Request types (from #[form_request] structs)\n");
for form_req in &form_request_types {
output.push_str(&format!("export interface {} {{\n", form_req.name));
for field in &form_req.fields {
let ts_type = rust_type_to_ts(&field.ty);
output.push_str(&format!(" {}: {};\n", field.name, ts_type));
}
output.push_str("}\n\n");
}
}
let routes_with_params: Vec<&GeneratedRoute> = routes
.iter()
.filter(|r| !r.definition.path_params.is_empty())
.collect();
if !routes_with_params.is_empty() {
output.push_str("// Path parameter types\n");
for route in &routes_with_params {
let interface_name = generate_params_interface_name(route);
output.push_str(&format!("export interface {interface_name} {{\n"));
for param in &route.definition.path_params {
output.push_str(&format!(" {}: string;\n", param.name));
}
output.push_str("}\n\n");
}
}
let mut modules: HashMap<String, Vec<&GeneratedRoute>> = HashMap::new();
for route in routes {
let module_name = extract_controller_name(&route.definition.handler_module);
modules.entry(module_name).or_default().push(route);
}
output.push_str("// Controller namespace - mirrors backend structure\n");
output.push_str("export const controllers = {\n");
let mut module_names: Vec<&String> = modules.keys().collect();
module_names.sort();
for (i, module_name) in module_names.iter().enumerate() {
let module_routes = modules.get(*module_name).unwrap();
output.push_str(&format!(" {module_name}: {{\n"));
let mut used_names: HashMap<String, usize> = HashMap::new();
for (j, route) in module_routes.iter().enumerate() {
let base_fn_name = &route.definition.handler_fn;
let fn_name = if let Some(count) = used_names.get(base_fn_name) {
if let Some(name) = &route.definition.name {
name.split('.')
.next_back()
.unwrap_or(base_fn_name)
.to_string()
} else {
let path_name = route
.definition
.path
.trim_start_matches('/')
.replace(['/', '{', '}', '-'], "_");
if path_name.is_empty() {
format!("{}_{}", base_fn_name, count + 1)
} else {
path_name
}
}
} else {
base_fn_name.clone()
};
*used_names.entry(base_fn_name.clone()).or_insert(0) += 1;
let method = route.definition.method.to_ts_method();
let has_params = !route.definition.path_params.is_empty();
let has_data = route.request_struct.is_some();
let (params_signature, return_type) = if has_params && has_data {
let params_type = generate_params_interface_name(route);
let data_type = route.request_struct.as_ref().unwrap().name.clone();
(
format!("params: {params_type}, data: {data_type}"),
format!("RouteConfig<{data_type}>"),
)
} else if has_params {
let params_type = generate_params_interface_name(route);
(format!("params: {params_type}"), "RouteConfig".to_string())
} else if has_data {
let data_type = route.request_struct.as_ref().unwrap().name.clone();
(
format!("data: {data_type}"),
format!("RouteConfig<{data_type}>"),
)
} else {
(String::new(), "RouteConfig".to_string())
};
let url = if has_params {
generate_url_with_params(&route.definition.path)
} else {
format!("'{}'", route.definition.path)
};
let data_prop = if has_data { ", data" } else { "" };
let comma = if j < module_routes.len() - 1 { "," } else { "" };
output.push_str(&format!(
" {fn_name}: ({params_signature}): {return_type} => ({{ url: {url}, method: '{method}'{data_prop} }}){comma}\n"
));
}
let comma = if i < module_names.len() - 1 { "," } else { "" };
output.push_str(&format!(" }}{comma}\n"));
}
output.push_str("} as const;\n\n");
let named_routes: Vec<&GeneratedRoute> = routes
.iter()
.filter(|r| r.definition.name.is_some())
.collect();
if !named_routes.is_empty() {
output.push_str("// Named routes lookup\n");
output.push_str("export const routes = {\n");
for (i, route) in named_routes.iter().enumerate() {
let name = route.definition.name.as_ref().unwrap();
let module = extract_controller_name(&route.definition.handler_module);
let fn_name = &route.definition.handler_fn;
let comma = if i < named_routes.len() - 1 { "," } else { "" };
output.push_str(&format!(
" '{name}': controllers.{module}.{fn_name}{comma}\n"
));
}
output.push_str("} as const;\n");
}
output
}
fn generate_params_interface_name(route: &GeneratedRoute) -> String {
let module = extract_controller_name(&route.definition.handler_module);
let fn_name = &route.definition.handler_fn;
format!(
"{}{}Params",
to_pascal_case(&module),
to_pascal_case(fn_name)
)
}
fn to_pascal_case(s: &str) -> String {
s.split('_')
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().chain(chars).collect(),
}
})
.collect()
}
fn extract_controller_name(module_path: &str) -> String {
let after_controllers = module_path
.strip_prefix("controllers::")
.unwrap_or(module_path);
let segments: Vec<&str> = after_controllers.split("::").collect();
if segments.is_empty() {
return "unknown".to_string();
}
segments.join("_")
}
fn generate_url_with_params(path: &str) -> String {
let param_pattern = Regex::new(r#"\{(\w+)\}"#).unwrap();
let mut result = path.to_string();
for cap in param_pattern.captures_iter(path) {
let full_match = cap.get(0).unwrap().as_str();
let param_name = cap.get(1).unwrap().as_str();
result = result.replace(full_match, &format!("${{params.{param_name}}}"));
}
format!("`{result}`")
}
pub fn generate_routes_to_file(project_path: &Path, output_path: &Path) -> Result<usize, String> {
let routes = scan_routes(project_path)?;
if routes.is_empty() {
return Ok(0);
}
if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create output directory: {e}"))?;
}
let typescript = generate_typescript(&routes);
fs::write(output_path, typescript)
.map_err(|e| format!("Failed to write TypeScript file: {e}"))?;
Ok(routes.len())
}
pub fn run(output: Option<String>) {
let project_path = Path::new(".");
let cargo_toml = project_path.join("Cargo.toml");
if !cargo_toml.exists() {
eprintln!(
"{} Not a Ferro project (no Cargo.toml found)",
style("Error:").red().bold()
);
std::process::exit(1);
}
let output_path = output
.map(std::path::PathBuf::from)
.unwrap_or_else(|| project_path.join("frontend/src/types/routes.ts"));
println!(
"{}",
style("Scanning routes for type-safe generation...").cyan()
);
match generate_routes_to_file(project_path, &output_path) {
Ok(0) => {
println!("{}", style("No routes found in src/routes.rs").yellow());
}
Ok(count) => {
println!("{} Found {} route(s)", style("->").green(), count);
println!("{} Generated {}", style("✓").green(), output_path.display());
}
Err(e) => {
eprintln!("{} {}", style("Error:").red().bold(), e);
std::process::exit(1);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_controller_name_flat() {
assert_eq!(extract_controller_name("controllers::user"), "user");
}
#[test]
fn test_extract_controller_name_nested_single() {
assert_eq!(
extract_controller_name("controllers::shelter::dashboard"),
"shelter_dashboard"
);
}
#[test]
fn test_extract_controller_name_nested_deep() {
assert_eq!(
extract_controller_name("controllers::admin::settings::security"),
"admin_settings_security"
);
}
#[test]
fn test_extract_controller_name_no_prefix() {
assert_eq!(extract_controller_name("user"), "user");
}
#[test]
fn test_extract_controller_name_empty_after_prefix() {
assert_eq!(extract_controller_name("controllers::"), "");
}
#[test]
fn test_to_pascal_case_single_word() {
assert_eq!(to_pascal_case("user"), "User");
}
#[test]
fn test_to_pascal_case_snake_case() {
assert_eq!(to_pascal_case("user_profile"), "UserProfile");
}
#[test]
fn test_to_pascal_case_multi_segment() {
assert_eq!(to_pascal_case("admin_user_settings"), "AdminUserSettings");
}
#[test]
fn test_parse_routes_file_simple() {
let content = r#"
get!("/users", controllers::user::index);
post!("/users", controllers::user::store);
"#;
let routes = parse_routes_file(content);
assert_eq!(routes.len(), 2);
assert_eq!(routes[0].method, HttpMethod::Get);
assert_eq!(routes[0].path, "/users");
assert_eq!(routes[0].handler_module, "controllers::user");
assert_eq!(routes[0].handler_fn, "index");
}
#[test]
fn test_parse_routes_file_with_params() {
let content = r#"
get!("/users/{id}", controllers::user::show);
delete!("/users/{id}/posts/{post_id}", controllers::post::destroy);
"#;
let routes = parse_routes_file(content);
assert_eq!(routes.len(), 2);
assert_eq!(routes[0].path_params.len(), 1);
assert_eq!(routes[0].path_params[0].name, "id");
assert_eq!(routes[1].path_params.len(), 2);
assert_eq!(routes[1].path_params[0].name, "id");
assert_eq!(routes[1].path_params[1].name, "post_id");
}
#[test]
fn test_parse_routes_file_with_name() {
let content = r#"
get!("/", controllers::home::index).name("home");
get!("/dashboard", controllers::dashboard::index).name("dashboard.index");
"#;
let routes = parse_routes_file(content);
assert_eq!(routes.len(), 2);
assert_eq!(routes[0].name, Some("home".to_string()));
assert_eq!(routes[1].name, Some("dashboard.index".to_string()));
}
#[test]
fn test_parse_routes_file_nested_controller() {
let content = r#"
get!("/shelter/dashboard", controllers::shelter::dashboard::index);
get!("/adopter/dashboard", controllers::adopter::dashboard::index);
"#;
let routes = parse_routes_file(content);
assert_eq!(routes.len(), 2);
assert_eq!(routes[0].handler_module, "controllers::shelter::dashboard");
assert_eq!(routes[1].handler_module, "controllers::adopter::dashboard");
}
#[test]
fn test_generate_url_with_params() {
let result = generate_url_with_params("/users/{id}");
assert_eq!(result, "`/users/${params.id}`");
}
#[test]
fn test_generate_url_with_multiple_params() {
let result = generate_url_with_params("/users/{id}/posts/{post_id}");
assert_eq!(result, "`/users/${params.id}/posts/${params.post_id}`");
}
#[test]
fn test_generate_params_interface_name() {
let route = GeneratedRoute {
definition: RouteDefinition {
method: HttpMethod::Get,
path: "/users/{id}".to_string(),
handler_module: "controllers::user".to_string(),
handler_fn: "show".to_string(),
name: None,
path_params: vec![PathParam {
name: "id".to_string(),
}],
},
handler_info: None,
request_struct: None,
};
let name = generate_params_interface_name(&route);
assert_eq!(name, "UserShowParams");
}
#[test]
fn test_generate_params_interface_name_nested_controller() {
let route = GeneratedRoute {
definition: RouteDefinition {
method: HttpMethod::Get,
path: "/shelter/applications/{id}".to_string(),
handler_module: "controllers::shelter::applications".to_string(),
handler_fn: "show".to_string(),
name: None,
path_params: vec![PathParam {
name: "id".to_string(),
}],
},
handler_info: None,
request_struct: None,
};
let name = generate_params_interface_name(&route);
assert_eq!(name, "ShelterApplicationsShowParams");
}
fn make_route(
method: HttpMethod,
path: &str,
handler_module: &str,
handler_fn: &str,
name: Option<&str>,
) -> GeneratedRoute {
GeneratedRoute {
definition: RouteDefinition {
method,
path: path.to_string(),
handler_module: handler_module.to_string(),
handler_fn: handler_fn.to_string(),
name: name.map(String::from),
path_params: Vec::new(),
},
handler_info: None,
request_struct: None,
}
}
#[test]
fn json_single_get_route_serializes_to_stable_shape() {
let routes = vec![make_route(
HttpMethod::Get,
"/users",
"controllers::user",
"index",
None,
)];
let json = routes_to_json(&routes);
let s = serde_json::to_string(&json).unwrap();
assert_eq!(
s,
r#"{"routes":[{"method":"GET","path":"/users","handler":"controllers::user::index","name":null,"middleware":[]}]}"#
);
}
#[test]
fn json_named_route_round_trips() {
let routes = vec![make_route(
HttpMethod::Get,
"/users/{id}",
"controllers::user",
"show",
Some("users.show"),
)];
let json = routes_to_json(&routes);
let s = serde_json::to_string(&json).unwrap();
let parsed: RoutesJson = serde_json::from_str(&s).unwrap();
assert_eq!(parsed, json);
assert_eq!(parsed.routes[0].name.as_deref(), Some("users.show"));
}
#[test]
fn json_patch_method_is_uppercase() {
let routes = vec![make_route(
HttpMethod::Patch,
"/users/{id}",
"controllers::user",
"update",
None,
)];
let json = routes_to_json(&routes);
assert_eq!(json.routes[0].method, "PATCH");
}
#[test]
fn json_handler_combines_module_and_fn() {
let routes = vec![make_route(
HttpMethod::Post,
"/posts",
"controllers::blog::post",
"store",
None,
)];
let json = routes_to_json(&routes);
assert_eq!(json.routes[0].handler, "controllers::blog::post::store");
}
#[test]
fn json_omits_path_params_field() {
let mut r = make_route(
HttpMethod::Get,
"/users/{id}",
"controllers::user",
"show",
None,
);
r.definition.path_params = vec![PathParam {
name: "id".to_string(),
}];
let json = routes_to_json(&[r]);
let s = serde_json::to_string(&json).unwrap();
assert!(!s.contains("path_params"));
assert!(!s.contains("\"params\""));
}
#[test]
fn json_middleware_always_present_even_when_empty() {
let routes = vec![make_route(
HttpMethod::Delete,
"/users/{id}",
"controllers::user",
"destroy",
None,
)];
let json = routes_to_json(&routes);
let s = serde_json::to_string(&json).unwrap();
assert!(s.contains("\"middleware\":[]"));
assert_eq!(json.routes[0].middleware, Vec::<String>::new());
}
#[test]
fn json_three_route_fixture_round_trips() {
let routes = vec![
make_route(
HttpMethod::Get,
"/users",
"controllers::user",
"index",
Some("users.index"),
),
make_route(
HttpMethod::Post,
"/users",
"controllers::user",
"store",
Some("users.store"),
),
make_route(
HttpMethod::Delete,
"/users/{id}",
"controllers::user",
"destroy",
Some("users.destroy"),
),
];
let json = routes_to_json(&routes);
let s = serde_json::to_string_pretty(&json).unwrap();
let parsed: RoutesJson = serde_json::from_str(&s).unwrap();
assert_eq!(parsed, json);
assert_eq!(parsed.routes.len(), 3);
assert_eq!(parsed.routes[0].method, "GET");
assert_eq!(parsed.routes[1].method, "POST");
assert_eq!(parsed.routes[2].method, "DELETE");
}
}