1use console::style;
8use quote::ToTokens;
9use std::fs;
10use std::path::Path;
11use syn::visit::Visit;
12use syn::{Attribute, Fields, ItemStruct, Type};
13use walkdir::WalkDir;
14
15const SENSITIVE_FIELD_PATTERNS: &[&str] = &[
22 "password",
23 "password_hash",
24 "hashed_password",
25 "secret",
26 "token",
27 "api_key",
28 "hashed_key",
29 "remember_token",
30];
31
32pub(crate) fn filter_resource_fields<'a>(
37 fields: &'a [FieldInfo],
38 exclude: &[String],
39 include_all: bool,
40) -> Vec<&'a FieldInfo> {
41 fields
42 .iter()
43 .filter(|f| {
44 let name_lower = f.name.to_lowercase();
45
46 if exclude.iter().any(|e| e.to_lowercase() == name_lower) {
48 return false;
49 }
50
51 if !include_all
53 && SENSITIVE_FIELD_PATTERNS
54 .iter()
55 .any(|p| p.to_lowercase() == name_lower)
56 {
57 return false;
58 }
59
60 true
61 })
62 .collect()
63}
64
65#[derive(Debug, Clone)]
71struct ModelInfo {
72 name: String,
74 module_name: String,
76 table_name: Option<String>,
78 fields: Vec<FieldInfo>,
80}
81
82#[derive(Debug, Clone)]
83pub(crate) struct FieldInfo {
84 pub(crate) name: String,
85 pub(crate) rust_type: String,
86 pub(crate) is_primary_key: bool,
87 pub(crate) is_nullable: bool,
88}
89
90struct ModelVisitor {
95 models: Vec<ModelInfo>,
96}
97
98impl ModelVisitor {
99 fn new() -> Self {
100 Self { models: Vec::new() }
101 }
102
103 fn has_model_derive(attrs: &[Attribute]) -> bool {
104 for attr in attrs {
105 if attr.path().is_ident("derive") {
106 if let Ok(nested) = attr.parse_args_with(
107 syn::punctuated::Punctuated::<syn::Path, syn::Token![,]>::parse_terminated,
108 ) {
109 for path in nested {
110 let ident = path.segments.last().map(|s| s.ident.to_string());
111 if matches!(
112 ident.as_deref(),
113 Some("DeriveEntityModel") | Some("FerroModel")
114 ) {
115 return true;
116 }
117 }
118 }
119 }
120 }
121 false
122 }
123
124 fn extract_table_name(attrs: &[Attribute]) -> Option<String> {
125 for attr in attrs {
126 if attr.path().is_ident("sea_orm") {
127 if let Ok(syn::Meta::NameValue(nv)) = attr.parse_args::<syn::Meta>() {
128 if nv.path.is_ident("table_name") {
129 if let syn::Expr::Lit(syn::ExprLit {
130 lit: syn::Lit::Str(s),
131 ..
132 }) = nv.value
133 {
134 return Some(s.value());
135 }
136 }
137 }
138 }
139 }
140 None
141 }
142
143 fn is_field_primary_key(attrs: &[Attribute]) -> bool {
144 for attr in attrs {
145 if attr.path().is_ident("sea_orm") {
146 let tokens = attr.meta.to_token_stream().to_string();
147 if tokens.contains("primary_key") {
148 return true;
149 }
150 }
151 }
152 false
153 }
154
155 fn type_to_string(ty: &Type) -> String {
156 ty.to_token_stream().to_string().replace(' ', "")
157 }
158
159 fn extract_fields(fields: &Fields) -> Vec<FieldInfo> {
160 let mut result = Vec::new();
161 if let Fields::Named(named) = fields {
162 for field in &named.named {
163 if let Some(ident) = &field.ident {
164 let name = ident.to_string();
165 let rust_type = Self::type_to_string(&field.ty);
166 let is_nullable = rust_type.starts_with("Option<");
167 let is_primary_key = Self::is_field_primary_key(&field.attrs);
168 result.push(FieldInfo {
169 name,
170 rust_type,
171 is_primary_key,
172 is_nullable,
173 });
174 }
175 }
176 }
177 result
178 }
179}
180
181impl<'ast> Visit<'ast> for ModelVisitor {
182 fn visit_item_struct(&mut self, node: &'ast ItemStruct) {
183 if Self::has_model_derive(&node.attrs) {
184 let name = node.ident.to_string();
185 if name == "Model" {
188 let table = Self::extract_table_name(&node.attrs);
190 let fields = Self::extract_fields(&node.fields);
191 self.models.push(ModelInfo {
192 name: name.clone(),
193 module_name: String::new(), table_name: table,
195 fields,
196 });
197 }
198 }
199 syn::visit::visit_item_struct(self, node);
200 }
201}
202
203fn scan_models(project_root: &Path) -> Vec<(String, ModelInfo)> {
209 let models_dir = project_root.join("src/models");
210 if !models_dir.exists() || !models_dir.is_dir() {
211 return Vec::new();
212 }
213
214 let mut results = Vec::new();
215
216 for entry in WalkDir::new(&models_dir)
217 .into_iter()
218 .filter_map(|e| e.ok())
219 .filter(|e| e.path().extension().is_some_and(|ext| ext == "rs"))
220 {
221 let file_stem = entry
222 .path()
223 .file_stem()
224 .map(|s| s.to_string_lossy().to_string())
225 .unwrap_or_default();
226
227 if file_stem == "mod" {
229 continue;
230 }
231
232 let Ok(content) = fs::read_to_string(entry.path()) else {
233 continue;
234 };
235 let Ok(syntax) = syn::parse_file(&content) else {
236 continue;
237 };
238
239 let mut visitor = ModelVisitor::new();
240 visitor.visit_file(&syntax);
241
242 for mut model in visitor.models {
243 let is_entity_file = entry
247 .path()
248 .parent()
249 .and_then(|p| p.file_name())
250 .is_some_and(|dir| dir == "entities");
251
252 let singular_stem = if is_entity_file {
253 singularize(&file_stem)
254 } else {
255 file_stem.clone()
256 };
257
258 model.module_name = file_stem.clone();
261
262 let pascal_name = to_pascal_case(&singular_stem);
263 model.name = pascal_name.clone();
264 if model.table_name.is_none() {
266 model.table_name = Some(pluralize(&singular_stem));
267 }
268 results.push((singular_stem.clone(), model));
269 }
270 }
271
272 results
273}
274
275fn resolve_models(
277 requested: &[String],
278 all: bool,
279 available: &[(String, ModelInfo)],
280) -> Vec<(String, ModelInfo)> {
281 if all {
282 return available.to_vec();
283 }
284
285 let mut resolved = Vec::new();
286 for name in requested {
287 let snake = to_snake_case(name);
288 let pascal = to_pascal_case(&snake);
289 if let Some(found) = available
290 .iter()
291 .find(|(sn, mi)| *sn == snake || mi.name == pascal || mi.name == *name)
292 {
293 resolved.push(found.clone());
294 } else {
295 eprintln!(
296 "{} Model '{}' not found in src/models/",
297 style("Error:").red().bold(),
298 name
299 );
300 std::process::exit(1);
301 }
302 }
303 resolved
304}
305
306fn generate_controller(snake_name: &str, model: &ModelInfo) {
312 let api_dir = Path::new("src/api");
313 if !api_dir.exists() {
314 fs::create_dir_all(api_dir).expect("Failed to create src/api/ directory");
315 }
316
317 let file_path = api_dir.join(format!("{snake_name}_api.rs"));
318 if file_path.exists() {
319 println!(
320 " {} src/api/{snake_name}_api.rs (already exists)",
321 style("skip").yellow()
322 );
323 return;
324 }
325
326 let pascal = &model.name;
327 let plural_default = pluralize(snake_name);
328 let plural = model.table_name.as_deref().unwrap_or(&plural_default);
329
330 let store_fields = build_store_fields(&model.fields);
332 let update_fields = build_update_fields(&model.fields);
334
335 let mod_name = &model.module_name;
336
337 let content = format!(
338 r#"//! {pascal} API controller
339//!
340//! Generated with `ferro make:api`
341
342use ferro::{{handler, Request, Response, HttpResponse}};
343use crate::models::{mod_name}::{{self, Entity as {pascal}}};
344use sea_orm::{{EntityTrait, PaginatorTrait}};
345use crate::resources::{snake_name}_resource::{pascal}Resource;
346use crate::requests::{snake_name}_request::{{Create{pascal}Request, Update{pascal}Request}};
347
348/// List {plural} with pagination
349///
350/// GET /api/v1/{plural}
351#[handler]
352pub async fn index(req: Request) -> Response {{
353 let page: u64 = req.query_as_or("page", 1u64).max(1);
354 let per_page: u64 = req.query_as_or("per_page", 15u64).clamp(1, 100);
355 let db = ferro::DB::connection()
356 .map_err(|e| HttpResponse::json(ferro::serde_json::json!({{"error": e.to_string()}})).status(500))?;
357 let paginator = {pascal}::find().paginate(db.inner(), per_page);
358 let total = paginator
359 .num_items()
360 .await
361 .map_err(|e| HttpResponse::json(ferro::serde_json::json!({{"error": e.to_string()}})).status(500))?;
362 let items = paginator
363 .fetch_page(page - 1)
364 .await
365 .map_err(|e| HttpResponse::json(ferro::serde_json::json!({{"error": e.to_string()}})).status(500))?;
366 let resources: Vec<{pascal}Resource> = items.into_iter().map({pascal}Resource::from).collect();
367 let meta = ferro::PaginationMeta::new(page, per_page, total);
368 Ok(ferro::ResourceCollection::paginated(resources, meta).to_response(&req))
369}}
370
371/// Show a single {snake_name}
372///
373/// GET /api/v1/{plural}/{{id}}
374#[handler]
375pub async fn show(req: Request, {snake_name}: {mod_name}::Model) -> Response {{
376 Ok(ferro::Resource::to_wrapped_response(&{pascal}Resource::from({snake_name}), &req))
377}}
378
379/// Create a new {snake_name}
380///
381/// POST /api/v1/{plural}
382#[handler]
383pub async fn store(form: Create{pascal}Request) -> Response {{
384 let model = {mod_name}::Model::create()
385{store_fields} .insert()
386 .await
387 .map_err(|e| HttpResponse::json(ferro::serde_json::json!({{"error": e.to_string()}})).status(500))?;
388 Ok(HttpResponse::json(ferro::serde_json::json!({{"data": ferro::serde_json::json!({{
389 "id": model.id
390 }})}})).status(201))
391}}
392
393/// Update an existing {snake_name}
394///
395/// PUT /api/v1/{plural}/{{id}}
396#[handler]
397pub async fn update({snake_name}: {mod_name}::Model, form: Update{pascal}Request) -> Response {{
398 let mut builder = {snake_name}.update();
399{update_fields} let updated = builder
400 .save()
401 .await
402 .map_err(|e| HttpResponse::json(ferro::serde_json::json!({{"error": e.to_string()}})).status(500))?;
403 Ok(HttpResponse::json(ferro::serde_json::json!({{"data": ferro::serde_json::json!({{
404 "id": updated.id
405 }})}})))
406}}
407
408/// Delete a {snake_name}
409///
410/// DELETE /api/v1/{plural}/{{id}}
411#[handler]
412pub async fn destroy({snake_name}: {mod_name}::Model) -> Response {{
413 {snake_name}
414 .delete()
415 .await
416 .map_err(|e| HttpResponse::json(ferro::serde_json::json!({{"error": e.to_string()}})).status(500))?;
417 Ok(HttpResponse::json(ferro::serde_json::json!({{"message": "Deleted"}})).status(200))
418}}
419"#,
420 );
421
422 fs::write(&file_path, content).expect("Failed to write API controller file");
423 println!(
424 " {} Created src/api/{snake_name}_api.rs",
425 style("✓").green()
426 );
427}
428
429fn generate_resource(snake_name: &str, model: &ModelInfo, exclude: &[String], include_all: bool) {
431 let resources_dir = Path::new("src/resources");
432 if !resources_dir.exists() {
433 fs::create_dir_all(resources_dir).expect("Failed to create src/resources/ directory");
434 }
435
436 let file_path = resources_dir.join(format!("{snake_name}_resource.rs"));
437 if file_path.exists() {
438 println!(
439 " {} src/resources/{snake_name}_resource.rs (already exists)",
440 style("skip").yellow()
441 );
442 return;
443 }
444
445 let pascal = &model.name;
446
447 let included_fields = filter_resource_fields(&model.fields, exclude, include_all);
449
450 let excluded_names: Vec<&str> = model
452 .fields
453 .iter()
454 .filter(|f| !included_fields.iter().any(|inc| inc.name == f.name))
455 .map(|f| f.name.as_str())
456 .collect();
457 if !excluded_names.is_empty() {
458 println!(
459 " {} Auto-excluded sensitive fields from {}Resource: {}",
460 style("ℹ").blue(),
461 pascal,
462 excluded_names.join(", ")
463 );
464 }
465
466 let resource_fields = build_resource_fields_filtered(&included_fields);
468 let from_assignments = build_from_assignments_filtered(&included_fields);
469
470 let mod_name = &model.module_name;
471
472 let content = format!(
473 r#"//! {pascal} API resource
474//!
475//! Generated with `ferro make:api`
476
477use ferro::{{serde_json, Resource, ResourceMap, Request}};
478use crate::models::{mod_name};
479
480/// API representation of {pascal}.
481pub struct {pascal}Resource {{
482{resource_fields}
483}}
484
485impl Resource for {pascal}Resource {{
486 fn to_resource(&self, _req: &Request) -> serde_json::Value {{
487 let mut map = ResourceMap::new();
488{from_assignments} map.build()
489 }}
490}}
491
492impl From<{mod_name}::Model> for {pascal}Resource {{
493 fn from(model: {mod_name}::Model) -> Self {{
494 Self {{
495{model_to_resource} }}
496 }}
497}}
498"#,
499 model_to_resource = build_model_to_resource_filtered(&included_fields),
500 );
501
502 fs::write(&file_path, content).expect("Failed to write API resource file");
503 println!(
504 " {} Created src/resources/{snake_name}_resource.rs",
505 style("✓").green()
506 );
507}
508
509fn generate_request(snake_name: &str, model: &ModelInfo) {
511 let requests_dir = Path::new("src/requests");
512 if !requests_dir.exists() {
513 fs::create_dir_all(requests_dir).expect("Failed to create src/requests/ directory");
514 }
515
516 let file_path = requests_dir.join(format!("{snake_name}_request.rs"));
517 if file_path.exists() {
518 println!(
519 " {} src/requests/{snake_name}_request.rs (already exists)",
520 style("skip").yellow()
521 );
522 return;
523 }
524
525 let pascal = &model.name;
526
527 let create_fields = build_create_request_fields(&model.fields);
528 let update_fields = build_update_request_fields(&model.fields);
529
530 let content = format!(
531 r#"//! {pascal} API request types
532//!
533//! Generated with `ferro make:api`
534
535use ferro::{{serde::Deserialize, FormRequest}};
536
537/// Request body for creating a new {pascal}.
538#[derive(Deserialize)]
539pub struct Create{pascal}Request {{
540{create_fields}}}
541
542impl ferro::Validate for Create{pascal}Request {{
543 fn validate(&self) -> Result<(), ferro::validator::ValidationErrors> {{
544 Ok(())
545 }}
546}}
547
548impl FormRequest for Create{pascal}Request {{}}
549
550/// Request body for updating an existing {pascal} (all fields optional).
551#[derive(Deserialize)]
552pub struct Update{pascal}Request {{
553{update_fields}}}
554
555impl ferro::Validate for Update{pascal}Request {{
556 fn validate(&self) -> Result<(), ferro::validator::ValidationErrors> {{
557 Ok(())
558 }}
559}}
560
561impl FormRequest for Update{pascal}Request {{}}
562"#,
563 );
564
565 fs::write(&file_path, content).expect("Failed to write API request file");
566 println!(
567 " {} Created src/requests/{snake_name}_request.rs",
568 style("✓").green()
569 );
570}
571
572fn is_auto_field(field: &FieldInfo) -> bool {
578 field.is_primary_key
579 || field.name == "created_at"
580 || field.name == "updated_at"
581 || field.name == "deleted_at"
582}
583
584fn build_store_fields(fields: &[FieldInfo]) -> String {
590 fields
591 .iter()
592 .filter(|f| !is_auto_field(f))
593 .map(|f| {
594 if f.is_nullable {
595 format!(
596 " .set_{name}(form.{name}.clone().unwrap_or_default())\n",
597 name = f.name
598 )
599 } else {
600 format!(" .set_{}(form.{}.clone())\n", f.name, f.name)
601 }
602 })
603 .collect()
604}
605
606fn build_update_fields(fields: &[FieldInfo]) -> String {
608 fields
609 .iter()
610 .filter(|f| !is_auto_field(f))
611 .map(|f| {
612 format!(
613 " if let Some(ref v) = form.{name} {{ builder = builder.set_{name}(v.clone()); }}\n",
614 name = f.name
615 )
616 })
617 .collect()
618}
619
620fn build_resource_fields_filtered(fields: &[&FieldInfo]) -> String {
622 fields
623 .iter()
624 .map(|f| format!(" pub {}: {},", f.name, resource_rust_type(&f.rust_type)))
625 .collect::<Vec<_>>()
626 .join("\n")
627}
628
629fn build_from_assignments_filtered(fields: &[&FieldInfo]) -> String {
631 fields
632 .iter()
633 .map(|f| {
634 format!(
635 " map = map.field(\"{name}\", serde_json::json!(self.{name}));\n",
636 name = f.name
637 )
638 })
639 .collect()
640}
641
642fn build_model_to_resource_filtered(fields: &[&FieldInfo]) -> String {
644 fields
645 .iter()
646 .map(|f| format!(" {name}: model.{name}.clone(),\n", name = f.name))
647 .collect()
648}
649
650#[cfg(test)]
652fn build_resource_fields(fields: &[FieldInfo]) -> String {
653 let refs: Vec<&FieldInfo> = fields.iter().collect();
654 build_resource_fields_filtered(&refs)
655}
656
657#[cfg(test)]
659fn build_from_assignments(fields: &[FieldInfo]) -> String {
660 let refs: Vec<&FieldInfo> = fields.iter().collect();
661 build_from_assignments_filtered(&refs)
662}
663
664#[cfg(test)]
666fn build_model_to_resource(fields: &[FieldInfo]) -> String {
667 let refs: Vec<&FieldInfo> = fields.iter().collect();
668 build_model_to_resource_filtered(&refs)
669}
670
671fn build_create_request_fields(fields: &[FieldInfo]) -> String {
673 fields
674 .iter()
675 .filter(|f| !is_auto_field(f))
676 .map(|f| {
677 let rust_type = request_rust_type(&f.rust_type, f.is_nullable);
678 format!(" pub {}: {},\n", f.name, rust_type)
679 })
680 .collect()
681}
682
683fn build_update_request_fields(fields: &[FieldInfo]) -> String {
690 fields
691 .iter()
692 .filter(|f| !is_auto_field(f))
693 .map(|f| {
694 if f.is_nullable {
695 let rust_type = request_rust_type(&f.rust_type, true);
697 format!(" pub {}: {},\n", f.name, rust_type)
698 } else {
699 let inner = request_rust_type(&f.rust_type, false);
700 format!(" pub {}: Option<{}>,\n", f.name, inner)
701 }
702 })
703 .collect()
704}
705
706fn resource_rust_type(rust_type: &str) -> String {
708 rust_type.to_string()
710}
711
712fn request_rust_type(rust_type: &str, is_nullable: bool) -> String {
714 if is_nullable {
715 return rust_type.to_string();
717 }
718 if rust_type.contains("DateTime") || rust_type.contains("DateTimeUtc") {
720 return "String".to_string();
721 }
722 if rust_type.contains("NaiveDate") || rust_type == "Date" {
723 return "String".to_string();
724 }
725 rust_type.to_string()
726}
727
728fn to_snake_case(name: &str) -> String {
733 let mut result = String::new();
734 for (i, c) in name.chars().enumerate() {
735 if c.is_uppercase() {
736 if i > 0 {
737 result.push('_');
738 }
739 result.push(c.to_ascii_lowercase());
740 } else {
741 result.push(c);
742 }
743 }
744 result
745}
746
747fn to_pascal_case(name: &str) -> String {
748 name.split('_')
749 .map(|word| {
750 let mut chars = word.chars();
751 match chars.next() {
752 None => String::new(),
753 Some(first) => first.to_uppercase().chain(chars).collect(),
754 }
755 })
756 .collect()
757}
758
759fn pluralize(name: &str) -> String {
760 if name.ends_with('s') || name.ends_with('x') || name.ends_with("ch") || name.ends_with("sh") {
761 format!("{name}es")
762 } else if name.ends_with('y')
763 && !name.ends_with("ay")
764 && !name.ends_with("ey")
765 && !name.ends_with("oy")
766 && !name.ends_with("uy")
767 {
768 format!("{}ies", &name[..name.len() - 1])
769 } else {
770 format!("{name}s")
771 }
772}
773
774fn singularize(name: &str) -> String {
778 if name.ends_with("ies") && name.len() > 3 {
779 format!("{}y", &name[..name.len() - 3])
781 } else if name.ends_with("ses")
782 || name.ends_with("xes")
783 || name.ends_with("ches")
784 || name.ends_with("shes")
785 {
786 name[..name.len() - 2].to_string()
788 } else if name.ends_with('s') && !name.ends_with("ss") {
789 name[..name.len() - 1].to_string()
791 } else {
792 name.to_string()
793 }
794}
795
796fn read_app_name() -> String {
799 fs::read_to_string("Cargo.toml")
800 .ok()
801 .and_then(|content| {
802 for line in content.lines() {
803 let trimmed = line.trim();
804 if trimmed.starts_with("name") {
805 if let Some(value) = trimmed.split('=').nth(1) {
806 let name = value.trim().trim_matches('"').trim_matches('\'');
807 if !name.is_empty() {
808 return Some(name.to_string());
809 }
810 }
811 }
812 }
813 None
814 })
815 .unwrap_or_else(|| "my-app".to_string())
816}
817
818pub fn run(models: Vec<String>, all: bool, yes: bool, exclude: Vec<String>, include_all: bool) {
826 if models.is_empty() && !all {
827 eprintln!(
828 "{} Specify model names or use --all to scaffold API for all models",
829 style("Error:").red().bold()
830 );
831 eprintln!(" Usage: ferro make:api User Post");
832 eprintln!(" Usage: ferro make:api --all");
833 std::process::exit(1);
834 }
835
836 let project_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
837 let available = scan_models(&project_root);
838
839 if available.is_empty() {
840 eprintln!(
841 "{} No models found in src/models/. Create models first with `ferro make:scaffold`.",
842 style("Error:").red().bold()
843 );
844 std::process::exit(1);
845 }
846
847 let selected = resolve_models(&models, all, &available);
848
849 if selected.is_empty() {
850 eprintln!("{} No matching models found", style("Error:").red().bold());
851 std::process::exit(1);
852 }
853
854 if !yes {
856 let names: Vec<&str> = selected.iter().map(|(_, m)| m.name.as_str()).collect();
857 println!(
858 "\n{} Scaffold API for: {}",
859 style("?").cyan().bold(),
860 names.join(", ")
861 );
862 let confirmed = dialoguer::Confirm::new()
863 .with_prompt("Proceed with generation?")
864 .default(true)
865 .interact()
866 .unwrap_or(false);
867 if !confirmed {
868 println!("Aborted.");
869 return;
870 }
871 }
872
873 println!(
874 "\n{} Generating API scaffold...\n",
875 style("▸").cyan().bold()
876 );
877
878 let mut generated_files: Vec<String> = Vec::new();
879
880 for (snake_name, model) in &selected {
881 println!(" {} {}", style("Model:").bold(), style(&model.name).cyan());
882 generate_controller(snake_name, model);
883 generate_resource(snake_name, model, &exclude, include_all);
884 generate_request(snake_name, model);
885 generated_files.push(format!("src/api/{snake_name}_api.rs"));
886 generated_files.push(format!("src/resources/{snake_name}_resource.rs"));
887 generated_files.push(format!("src/requests/{snake_name}_request.rs"));
888 println!();
889 }
890
891 generate_api_mod(&selected);
893 generate_api_routes(&selected);
894 generate_api_docs();
895 generate_resources_mod(&selected);
896 generate_requests_mod(&selected);
897 generate_api_key_migration();
898 generate_api_key_model();
899 generate_api_key_provider();
900
901 let model_names: Vec<&str> = selected.iter().map(|(_, m)| m.name.as_str()).collect();
903 let app_name = read_app_name();
904
905 println!();
906 println!(
907 " {}",
908 style("═══════════════════════════════════════════════").bold()
909 );
910 println!(
911 " {} {}",
912 style("API scaffold complete! Generated for:").bold(),
913 style(model_names.join(", ")).cyan().bold()
914 );
915 println!(
916 " {}",
917 style("═══════════════════════════════════════════════").bold()
918 );
919
920 println!("\n Generated files:");
921 for (snake_name, _) in &selected {
922 println!(" Controllers: src/api/{snake_name}_api.rs");
923 println!(" Resources: src/resources/{snake_name}_resource.rs");
924 println!(" Requests: src/requests/{snake_name}_request.rs");
925 }
926 println!(" Routes: src/api/routes.rs");
927 println!(" Docs: src/api/docs.rs");
928 println!(" API Keys: src/models/api_key.rs, src/providers/api_key_provider.rs");
929 println!(" Migration: src/migrations/m..._create_api_keys_table.rs");
930
931 println!(
932 "\n {}",
933 style("───────────────────────────────────────────────").dim()
934 );
935 println!(" {}", style("Setup Steps").bold());
936 println!(
937 " {}",
938 style("───────────────────────────────────────────────").dim()
939 );
940
941 println!("\n 1. Wire up routes in src/main.rs:");
942 println!(" {}", style("mod api;").cyan());
943 println!(" // In route registration:");
944 println!(" {}", style("api::routes::api_routes()").cyan());
945 println!(" {}", style("api::docs::docs_routes()").cyan());
946 println!();
947 println!(" 2. Register the API key provider:");
948 println!(
949 " {}",
950 style("App::bind::<dyn ApiKeyProvider>(Box::new(ApiKeyProviderImpl::new()));").cyan()
951 );
952 println!();
953 println!(" 3. Run the migration:");
954 println!(" {}", style("ferro db:migrate").cyan());
955 println!();
956 println!(" 4. Generate an API key:");
957 println!(" {}", style(r#"ferro make:api-key "My App""#).cyan());
958 println!();
959 println!(" 5. Verify the API works:");
960 println!(
961 " {}",
962 style("ferro api:check --api-key fe_live_...").cyan()
963 );
964
965 println!(
966 "\n {}",
967 style("───────────────────────────────────────────────").dim()
968 );
969 println!(" {}", style("MCP Integration").bold());
970 println!(
971 " {}",
972 style("───────────────────────────────────────────────").dim()
973 );
974
975 println!("\n To connect this API to an AI agent via MCP, add to your");
976 println!(" MCP host configuration:");
977
978 println!(
979 "\n {} (~/.claude/claude_desktop_config.json):",
980 style("Claude Desktop").bold()
981 );
982 println!(" {{");
983 println!(" \"mcpServers\": {{");
984 println!(" \"{app_name}-api\": {{");
985 println!(" \"command\": \"ferro-api-mcp\",");
986 println!(" \"args\": [");
987 println!(" \"--spec-url\", \"http://localhost:8080/api/openapi.json\",");
988 println!(" \"--api-key\", \"fe_live_...\"");
989 println!(" ]");
990 println!(" }}");
991 println!(" }}");
992 println!(" }}");
993
994 println!("\n {} (~/.claude.json):", style("Claude Code").bold());
995 println!(" {{");
996 println!(" \"mcpServers\": {{");
997 println!(" \"{app_name}-api\": {{");
998 println!(" \"command\": \"ferro-api-mcp\",");
999 println!(" \"args\": [");
1000 println!(" \"--spec-url\", \"http://localhost:8080/api/openapi.json\",");
1001 println!(" \"--api-key\", \"fe_live_...\"");
1002 println!(" ]");
1003 println!(" }}");
1004 println!(" }}");
1005 println!(" }}");
1006
1007 println!(
1008 "\n Docs: {}",
1009 style("https://docs.ferro-rs.dev/features/api-mcp.html").underlined()
1010 );
1011 println!(
1012 " {}",
1013 style("═══════════════════════════════════════════════").bold()
1014 );
1015 println!();
1016}
1017
1018fn generate_api_mod(models: &[(String, ModelInfo)]) {
1024 let api_dir = Path::new("src/api");
1025 if !api_dir.exists() {
1026 fs::create_dir_all(api_dir).expect("Failed to create src/api/ directory");
1027 }
1028
1029 let mod_path = api_dir.join("mod.rs");
1030 if mod_path.exists() {
1031 let existing = fs::read_to_string(&mod_path).unwrap_or_default();
1033 let mut additions = String::new();
1034 for (snake_name, _) in models {
1035 let decl = format!("pub mod {snake_name}_api;");
1036 if !existing.contains(&decl) {
1037 additions.push_str(&decl);
1038 additions.push('\n');
1039 }
1040 }
1041 if !existing.contains("pub mod routes;") {
1043 additions.push_str("pub mod routes;\n");
1044 }
1045 if !existing.contains("pub mod docs;") {
1046 additions.push_str("pub mod docs;\n");
1047 }
1048 if !additions.is_empty() {
1049 let updated = format!("{existing}{additions}");
1050 fs::write(&mod_path, updated).expect("Failed to update src/api/mod.rs");
1051 println!(" {} Updated src/api/mod.rs", style("✓").green());
1052 } else {
1053 println!(
1054 " {} src/api/mod.rs (already up-to-date)",
1055 style("skip").yellow()
1056 );
1057 }
1058 } else {
1059 let mut content = String::from("// Auto-generated API modules\n");
1060 for (snake_name, _) in models {
1061 content.push_str(&format!("pub mod {snake_name}_api;\n"));
1062 }
1063 content.push_str("pub mod routes;\n");
1064 content.push_str("pub mod docs;\n");
1065 fs::write(&mod_path, content).expect("Failed to write src/api/mod.rs");
1066 println!(" {} Created src/api/mod.rs", style("✓").green());
1067 }
1068}
1069
1070fn generate_api_routes(models: &[(String, ModelInfo)]) {
1072 let file_path = Path::new("src/api/routes.rs");
1073 if file_path.exists() {
1074 println!(
1075 " {} src/api/routes.rs (already exists)",
1076 style("skip").yellow()
1077 );
1078 return;
1079 }
1080
1081 let mut route_blocks = String::new();
1082 for (snake_name, model) in models {
1083 let plural_default = pluralize(snake_name);
1084 let plural = model.table_name.as_deref().unwrap_or(&plural_default);
1085 let pk = model
1086 .fields
1087 .iter()
1088 .find(|f| f.is_primary_key)
1089 .map(|f| f.name.as_str())
1090 .unwrap_or("id");
1091
1092 route_blocks.push_str(&format!(
1093 r#"
1094 // {pascal} CRUD
1095 get!("/{plural}", {snake_name}_api::index).name("api.{plural}.index"),
1096 post!("/{plural}", {snake_name}_api::store).name("api.{plural}.store"),
1097 get!("/{plural}/:{pk}", {snake_name}_api::show).name("api.{plural}.show"),
1098 put!("/{plural}/:{pk}", {snake_name}_api::update).name("api.{plural}.update"),
1099 delete!("/{plural}/:{pk}", {snake_name}_api::destroy).name("api.{plural}.destroy"),
1100"#,
1101 pascal = model.name,
1102 ));
1103 }
1104
1105 let content = format!(
1106 r#"//! API route registration
1107//!
1108//! Generated with `ferro make:api`
1109
1110use ferro::*;
1111use crate::api::*;
1112
1113pub fn api_routes() -> GroupDef {{
1114 group!("/api/v1", {{{route_blocks}
1115 }})
1116 .middleware(ApiKeyMiddleware::new())
1117 .middleware(Throttle::named("api"))
1118}}
1119"#,
1120 );
1121
1122 fs::write(file_path, content).expect("Failed to write src/api/routes.rs");
1123 println!(" {} Created src/api/routes.rs", style("✓").green());
1124}
1125
1126fn generate_api_docs() {
1128 let file_path = Path::new("src/api/docs.rs");
1129 if file_path.exists() {
1130 println!(
1131 " {} src/api/docs.rs (already exists)",
1132 style("skip").yellow()
1133 );
1134 return;
1135 }
1136
1137 let content = r#"//! API documentation routes
1138//!
1139//! Generated with `ferro make:api`
1140
1141use ferro::*;
1142
1143pub fn docs_routes() -> GroupDef {
1144 group!("/api", {
1145 get!("/docs", api_docs).name("api.docs"),
1146 get!("/openapi.json", openapi_json).name("api.openapi"),
1147 })
1148}
1149
1150#[handler]
1151pub async fn api_docs() -> Response {
1152 let config = OpenApiConfig {
1153 title: ferro::env("APP_NAME", "API".to_string()),
1154 version: "1.0.0".to_string(),
1155 description: Some("Auto-generated API documentation".to_string()),
1156 api_prefix: "/api/".to_string(),
1157 };
1158 let routes = get_registered_routes();
1159 let resp = openapi_docs_response(&config, &routes);
1160 Ok(resp)
1161}
1162
1163#[handler]
1164pub async fn openapi_json() -> Response {
1165 let config = OpenApiConfig {
1166 title: ferro::env("APP_NAME", "API".to_string()),
1167 version: "1.0.0".to_string(),
1168 description: Some("Auto-generated API documentation".to_string()),
1169 api_prefix: "/api/".to_string(),
1170 };
1171 let routes = get_registered_routes();
1172 let resp = openapi_json_response(&config, &routes);
1173 Ok(resp)
1174}
1175"#;
1176
1177 fs::write(file_path, content).expect("Failed to write src/api/docs.rs");
1178 println!(" {} Created src/api/docs.rs", style("✓").green());
1179}
1180
1181fn generate_resources_mod(models: &[(String, ModelInfo)]) {
1183 let resources_dir = Path::new("src/resources");
1184 if !resources_dir.exists() {
1185 return;
1186 }
1187
1188 let mod_path = resources_dir.join("mod.rs");
1189 if mod_path.exists() {
1190 let existing = fs::read_to_string(&mod_path).unwrap_or_default();
1191 let mut additions = String::new();
1192 for (snake_name, _) in models {
1193 let decl = format!("pub mod {snake_name}_resource;");
1194 if !existing.contains(&decl) {
1195 additions.push_str(&decl);
1196 additions.push('\n');
1197 }
1198 }
1199 if !additions.is_empty() {
1200 let updated = format!("{existing}{additions}");
1201 fs::write(&mod_path, updated).expect("Failed to update src/resources/mod.rs");
1202 println!(" {} Updated src/resources/mod.rs", style("✓").green());
1203 }
1204 } else {
1205 let mut content = String::new();
1206 for (snake_name, _) in models {
1207 content.push_str(&format!("pub mod {snake_name}_resource;\n"));
1208 }
1209 fs::write(&mod_path, content).expect("Failed to write src/resources/mod.rs");
1210 println!(" {} Created src/resources/mod.rs", style("✓").green());
1211 }
1212}
1213
1214fn generate_requests_mod(models: &[(String, ModelInfo)]) {
1216 let requests_dir = Path::new("src/requests");
1217 if !requests_dir.exists() {
1218 fs::create_dir_all(requests_dir).expect("Failed to create src/requests/ directory");
1219 }
1220
1221 let mod_path = requests_dir.join("mod.rs");
1222 if mod_path.exists() {
1223 let existing = fs::read_to_string(&mod_path).unwrap_or_default();
1224 let mut additions = String::new();
1225 for (snake_name, _) in models {
1226 let decl = format!("pub mod {snake_name}_request;");
1227 if !existing.contains(&decl) {
1228 additions.push_str(&decl);
1229 additions.push('\n');
1230 }
1231 }
1232 if !additions.is_empty() {
1233 let updated = format!("{existing}{additions}");
1234 fs::write(&mod_path, updated).expect("Failed to update src/requests/mod.rs");
1235 println!(" {} Updated src/requests/mod.rs", style("✓").green());
1236 }
1237 } else {
1238 let mut content = String::new();
1239 for (snake_name, _) in models {
1240 content.push_str(&format!("pub mod {snake_name}_request;\n"));
1241 }
1242 fs::write(&mod_path, content).expect("Failed to write src/requests/mod.rs");
1243 println!(" {} Created src/requests/mod.rs", style("✓").green());
1244 }
1245}
1246
1247fn generate_api_key_migration() {
1249 let migrations_dir = if Path::new("src/migrations").exists() {
1250 Path::new("src/migrations")
1251 } else if Path::new("src/database/migrations").exists() {
1252 Path::new("src/database/migrations")
1253 } else {
1254 println!(
1255 " {} migrations directory not found, skipping migration generation",
1256 style("warn").yellow()
1257 );
1258 return;
1259 };
1260
1261 if let Ok(entries) = fs::read_dir(migrations_dir) {
1263 for entry in entries.flatten() {
1264 let name = entry.file_name().to_string_lossy().to_string();
1265 if name.contains("create_api_keys_table") {
1266 println!(
1267 " {} API keys migration (already exists)",
1268 style("skip").yellow()
1269 );
1270 return;
1271 }
1272 }
1273 }
1274
1275 let timestamp = chrono::Utc::now().format("%Y%m%d%H%M%S").to_string();
1276 let migration_name = format!("m{timestamp}_create_api_keys_table");
1277 let file_name = format!("{migration_name}.rs");
1278 let file_path = migrations_dir.join(&file_name);
1279
1280 let content = r#"use sea_orm_migration::prelude::*;
1281
1282#[derive(DeriveMigrationName)]
1283pub struct Migration;
1284
1285#[async_trait::async_trait]
1286impl MigrationTrait for Migration {
1287 async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
1288 manager
1289 .create_table(
1290 Table::create()
1291 .table(ApiKeys::Table)
1292 .if_not_exists()
1293 .col(
1294 ColumnDef::new(ApiKeys::Id)
1295 .big_integer()
1296 .not_null()
1297 .auto_increment()
1298 .primary_key(),
1299 )
1300 .col(ColumnDef::new(ApiKeys::Name).string().not_null())
1301 .col(ColumnDef::new(ApiKeys::Prefix).string_len(16).not_null())
1302 .col(ColumnDef::new(ApiKeys::HashedKey).string_len(64).not_null())
1303 .col(ColumnDef::new(ApiKeys::Scopes).text().null())
1304 .col(ColumnDef::new(ApiKeys::LastUsedAt).timestamp_with_time_zone().null())
1305 .col(ColumnDef::new(ApiKeys::ExpiresAt).timestamp_with_time_zone().null())
1306 .col(ColumnDef::new(ApiKeys::RevokedAt).timestamp_with_time_zone().null())
1307 .col(
1308 ColumnDef::new(ApiKeys::CreatedAt)
1309 .timestamp_with_time_zone()
1310 .not_null()
1311 .default(Expr::current_timestamp()),
1312 )
1313 .to_owned(),
1314 )
1315 .await?;
1316
1317 manager
1318 .create_index(
1319 Index::create()
1320 .name("idx_api_keys_prefix")
1321 .table(ApiKeys::Table)
1322 .col(ApiKeys::Prefix)
1323 .to_owned(),
1324 )
1325 .await
1326 }
1327
1328 async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
1329 manager
1330 .drop_table(Table::drop().table(ApiKeys::Table).to_owned())
1331 .await
1332 }
1333}
1334
1335#[derive(Iden)]
1336pub enum ApiKeys {
1337 Table,
1338 Id,
1339 Name,
1340 Prefix,
1341 HashedKey,
1342 Scopes,
1343 LastUsedAt,
1344 ExpiresAt,
1345 RevokedAt,
1346 CreatedAt,
1347}
1348"#
1349 .to_string();
1350
1351 fs::write(&file_path, content).expect("Failed to write migration file");
1352
1353 update_migrations_mod(&migration_name);
1355
1356 println!(
1357 " {} Created {}/{}",
1358 style("✓").green(),
1359 migrations_dir.display(),
1360 file_name
1361 );
1362}
1363
1364fn update_migrations_mod(migration_name: &str) {
1366 let mod_path = if Path::new("src/migrations/mod.rs").exists() {
1367 Path::new("src/migrations/mod.rs")
1368 } else if Path::new("src/database/migrations/mod.rs").exists() {
1369 Path::new("src/database/migrations/mod.rs")
1370 } else {
1371 return;
1372 };
1373
1374 let content = fs::read_to_string(mod_path).unwrap_or_default();
1375 let mod_declaration = format!("pub mod {migration_name};");
1376 if content.contains(&mod_declaration) {
1377 return;
1378 }
1379
1380 let mut lines: Vec<String> = content.lines().map(String::from).collect();
1382 let mut insert_index = 0;
1383 for (i, line) in lines.iter().enumerate() {
1384 if line.starts_with("pub mod m") {
1385 insert_index = i + 1;
1386 }
1387 }
1388 lines.insert(insert_index, mod_declaration.clone());
1389
1390 let migrator_addition = format!(" Box::new({migration_name}::Migration),");
1392 let mut result = lines.join("\n");
1393
1394 if result.contains("vec![]") {
1395 result = result.replace("vec![]", &format!("vec![\n{migrator_addition}\n ]"));
1396 } else if result.contains("vec![") {
1397 let mut final_result = String::new();
1398 let mut in_migrations = false;
1399 let mut bracket_depth = 0;
1400
1401 for line in result.lines() {
1402 if line.contains("fn migrations()") {
1403 in_migrations = true;
1404 }
1405 if in_migrations {
1406 if line.contains("vec![") {
1407 bracket_depth += 1;
1408 }
1409 if line.trim() == "]" && bracket_depth == 1 {
1410 final_result.push_str(&migrator_addition);
1411 final_result.push('\n');
1412 bracket_depth = 0;
1413 in_migrations = false;
1414 }
1415 }
1416 final_result.push_str(line);
1417 final_result.push('\n');
1418 }
1419 result = final_result;
1420 }
1421
1422 fs::write(mod_path, result).expect("Failed to update migrations mod.rs");
1423}
1424
1425fn generate_api_key_model() {
1427 let models_dir = Path::new("src/models");
1428 if !models_dir.exists() {
1429 fs::create_dir_all(models_dir).expect("Failed to create src/models/ directory");
1430 }
1431
1432 let file_path = models_dir.join("api_key.rs");
1433 if file_path.exists() {
1434 println!(
1435 " {} src/models/api_key.rs (already exists)",
1436 style("skip").yellow()
1437 );
1438 return;
1439 }
1440
1441 let content = r#"//! API key model
1442
1443use ferro::database::{Model as DatabaseModel, ModelMut, QueryBuilder};
1444use ferro::serde::Serialize;
1445use sea_orm::entity::prelude::*;
1446
1447#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize)]
1448#[sea_orm(table_name = "api_keys")]
1449pub struct Model {
1450 #[sea_orm(primary_key)]
1451 pub id: i64,
1452 pub name: String,
1453 pub prefix: String,
1454 pub hashed_key: String,
1455 pub scopes: Option<String>,
1456 pub last_used_at: Option<DateTimeUtc>,
1457 pub expires_at: Option<DateTimeUtc>,
1458 pub revoked_at: Option<DateTimeUtc>,
1459 pub created_at: DateTimeUtc,
1460}
1461
1462#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
1463pub enum Relation {}
1464
1465impl ActiveModelBehavior for ActiveModel {}
1466
1467impl DatabaseModel for Entity {}
1468impl ModelMut for Entity {}
1469
1470pub type ApiKey = Model;
1471
1472impl Model {
1473 pub fn query() -> QueryBuilder<Entity> {
1474 QueryBuilder::new()
1475 }
1476}
1477"#;
1478
1479 fs::write(&file_path, content).expect("Failed to write API key model file");
1480
1481 let mod_path = models_dir.join("mod.rs");
1483 if mod_path.exists() {
1484 let existing = fs::read_to_string(&mod_path).unwrap_or_default();
1485 if !existing.contains("pub mod api_key;") {
1486 let updated = format!("{existing}pub mod api_key;\n");
1487 fs::write(&mod_path, updated).expect("Failed to update models mod.rs");
1488 }
1489 }
1490
1491 println!(" {} Created src/models/api_key.rs", style("✓").green());
1492}
1493
1494fn generate_api_key_provider() {
1496 let providers_dir = Path::new("src/providers");
1497 if !providers_dir.exists() {
1498 fs::create_dir_all(providers_dir).expect("Failed to create src/providers/ directory");
1499 }
1500
1501 let file_path = providers_dir.join("api_key_provider.rs");
1502 if file_path.exists() {
1503 println!(
1504 " {} src/providers/api_key_provider.rs (already exists)",
1505 style("skip").yellow()
1506 );
1507 return;
1508 }
1509
1510 let content = r#"//! API key provider implementation
1511//!
1512//! Generated with `ferro make:api`
1513
1514use ferro::{async_trait, serde_json, ApiKeyInfo, ApiKeyProvider, verify_api_key_hash};
1515use crate::models::api_key::{self, Entity as ApiKey};
1516use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
1517
1518/// Database-backed API key provider.
1519///
1520/// Register this as a service in your bootstrap:
1521/// ```rust,ignore
1522/// App::bind::<dyn ApiKeyProvider>(Box::new(ApiKeyProviderImpl));
1523/// ```
1524pub struct ApiKeyProviderImpl;
1525
1526#[async_trait]
1527impl ApiKeyProvider for ApiKeyProviderImpl {
1528 async fn verify_key(&self, raw_key: &str) -> Result<ApiKeyInfo, ()> {
1529 let prefix = &raw_key[..16.min(raw_key.len())];
1530
1531 let db = ferro::DB::connection().map_err(|_| ())?;
1532 let record = ApiKey::find()
1533 .filter(api_key::Column::Prefix.eq(prefix))
1534 .one(db.inner())
1535 .await
1536 .map_err(|_| ())?
1537 .ok_or(())?;
1538
1539 // Check revocation
1540 if record.revoked_at.is_some() {
1541 return Err(());
1542 }
1543
1544 // Check expiry
1545 if let Some(expires_at) = record.expires_at {
1546 if expires_at < chrono::Utc::now() {
1547 return Err(());
1548 }
1549 }
1550
1551 // Constant-time hash verification
1552 if !verify_api_key_hash(raw_key, &record.hashed_key) {
1553 return Err(());
1554 }
1555
1556 let scopes: Vec<String> = record
1557 .scopes
1558 .as_deref()
1559 .and_then(|s| serde_json::from_str(s).ok())
1560 .unwrap_or_default();
1561
1562 Ok(ApiKeyInfo {
1563 id: record.id,
1564 name: record.name,
1565 scopes,
1566 })
1567 }
1568}
1569"#;
1570
1571 fs::write(&file_path, content).expect("Failed to write API key provider file");
1572 println!(
1573 " {} Created src/providers/api_key_provider.rs",
1574 style("✓").green()
1575 );
1576}
1577
1578#[cfg(test)]
1583mod tests {
1584 use super::*;
1585
1586 fn test_model() -> ModelInfo {
1588 ModelInfo {
1589 name: "User".to_string(),
1590 module_name: "users".to_string(),
1591 table_name: Some("users".to_string()),
1592 fields: vec![
1593 FieldInfo {
1594 name: "id".to_string(),
1595 rust_type: "i32".to_string(),
1596 is_primary_key: true,
1597 is_nullable: false,
1598 },
1599 FieldInfo {
1600 name: "name".to_string(),
1601 rust_type: "String".to_string(),
1602 is_primary_key: false,
1603 is_nullable: false,
1604 },
1605 FieldInfo {
1606 name: "email".to_string(),
1607 rust_type: "String".to_string(),
1608 is_primary_key: false,
1609 is_nullable: false,
1610 },
1611 FieldInfo {
1612 name: "bio".to_string(),
1613 rust_type: "Option<String>".to_string(),
1614 is_primary_key: false,
1615 is_nullable: true,
1616 },
1617 FieldInfo {
1618 name: "is_active".to_string(),
1619 rust_type: "bool".to_string(),
1620 is_primary_key: false,
1621 is_nullable: false,
1622 },
1623 FieldInfo {
1624 name: "created_at".to_string(),
1625 rust_type: "String".to_string(),
1626 is_primary_key: false,
1627 is_nullable: false,
1628 },
1629 FieldInfo {
1630 name: "updated_at".to_string(),
1631 rust_type: "String".to_string(),
1632 is_primary_key: false,
1633 is_nullable: false,
1634 },
1635 ],
1636 }
1637 }
1638
1639 #[test]
1644 fn singularize_regular_s() {
1645 assert_eq!(singularize("users"), "user");
1646 assert_eq!(singularize("todos"), "todo");
1647 assert_eq!(singularize("posts"), "post");
1648 }
1649
1650 #[test]
1651 fn singularize_ies() {
1652 assert_eq!(singularize("categories"), "category");
1653 assert_eq!(singularize("companies"), "company");
1654 }
1655
1656 #[test]
1657 fn singularize_ses_xes() {
1658 assert_eq!(singularize("statuses"), "status");
1659 assert_eq!(singularize("boxes"), "box");
1660 }
1661
1662 #[test]
1663 fn singularize_ches_shes() {
1664 assert_eq!(singularize("matches"), "match");
1665 assert_eq!(singularize("dishes"), "dish");
1666 }
1667
1668 #[test]
1669 fn singularize_already_singular() {
1670 assert_eq!(singularize("user"), "user");
1671 assert_eq!(singularize("address"), "address"); }
1673
1674 #[test]
1675 fn pluralize_basic() {
1676 assert_eq!(pluralize("user"), "users");
1677 assert_eq!(pluralize("todo"), "todos");
1678 }
1679
1680 #[test]
1681 fn pluralize_special_endings() {
1682 assert_eq!(pluralize("status"), "statuses");
1683 assert_eq!(pluralize("box"), "boxes");
1684 assert_eq!(pluralize("category"), "categories");
1685 }
1686
1687 #[test]
1688 fn to_pascal_case_basic() {
1689 assert_eq!(to_pascal_case("user"), "User");
1690 assert_eq!(to_pascal_case("api_key"), "ApiKey");
1691 assert_eq!(to_pascal_case("blog_post"), "BlogPost");
1692 }
1693
1694 #[test]
1695 fn to_snake_case_basic() {
1696 assert_eq!(to_snake_case("User"), "user");
1697 assert_eq!(to_snake_case("ApiKey"), "api_key");
1698 assert_eq!(to_snake_case("BlogPost"), "blog_post");
1699 }
1700
1701 #[test]
1706 fn auto_field_detects_primary_key() {
1707 let field = FieldInfo {
1708 name: "id".to_string(),
1709 rust_type: "i32".to_string(),
1710 is_primary_key: true,
1711 is_nullable: false,
1712 };
1713 assert!(is_auto_field(&field));
1714 }
1715
1716 #[test]
1717 fn auto_field_detects_timestamps() {
1718 for name in ["created_at", "updated_at", "deleted_at"] {
1719 let field = FieldInfo {
1720 name: name.to_string(),
1721 rust_type: "String".to_string(),
1722 is_primary_key: false,
1723 is_nullable: false,
1724 };
1725 assert!(is_auto_field(&field), "{name} should be auto-field");
1726 }
1727 }
1728
1729 #[test]
1730 fn auto_field_skips_regular_fields() {
1731 let field = FieldInfo {
1732 name: "email".to_string(),
1733 rust_type: "String".to_string(),
1734 is_primary_key: false,
1735 is_nullable: false,
1736 };
1737 assert!(!is_auto_field(&field));
1738 }
1739
1740 #[test]
1745 fn controller_uses_sync_db_connection() {
1746 let template = "ferro::DB::connection()\n .map_err";
1749 assert!(
1750 !template.contains("connection().await"),
1751 "DB::connection() must not use .await (sync call)"
1752 );
1753 }
1754
1755 #[test]
1756 fn controller_store_fields_skip_auto() {
1757 let model = test_model();
1758 let store = build_store_fields(&model.fields);
1759 assert!(!store.contains("set_id("), "PK should be skipped");
1760 assert!(
1761 !store.contains("set_created_at("),
1762 "created_at should be skipped"
1763 );
1764 assert!(
1765 !store.contains("set_updated_at("),
1766 "updated_at should be skipped"
1767 );
1768 assert!(store.contains("set_name(form.name.clone())"));
1769 assert!(store.contains("set_email(form.email.clone())"));
1770 assert!(store.contains("set_is_active(form.is_active.clone())"));
1771 }
1772
1773 #[test]
1774 fn controller_store_handles_nullable() {
1775 let model = test_model();
1776 let store = build_store_fields(&model.fields);
1777 assert!(
1778 store.contains("set_bio(form.bio.clone().unwrap_or_default())"),
1779 "nullable field should use unwrap_or_default"
1780 );
1781 }
1782
1783 #[test]
1784 fn controller_update_fields_use_conditional_set() {
1785 let model = test_model();
1786 let update = build_update_fields(&model.fields);
1787 assert!(update.contains("if let Some(ref v) = form.name"));
1788 assert!(update.contains("builder = builder.set_name(v.clone())"));
1789 assert!(
1790 !update.contains("set_id("),
1791 "PK should be skipped in update"
1792 );
1793 }
1794
1795 #[test]
1796 fn controller_update_fields_skip_timestamps() {
1797 let model = test_model();
1798 let update = build_update_fields(&model.fields);
1799 assert!(!update.contains("set_created_at("));
1800 assert!(!update.contains("set_updated_at("));
1801 }
1802
1803 #[test]
1808 fn resource_fields_include_all_fields() {
1809 let model = test_model();
1810 let fields = build_resource_fields(&model.fields);
1811 assert!(fields.contains("pub id: i32"));
1812 assert!(fields.contains("pub name: String"));
1813 assert!(fields.contains("pub bio: Option<String>"));
1814 assert!(fields.contains("pub created_at: String"));
1815 }
1816
1817 #[test]
1818 fn resource_from_assignments_all_fields() {
1819 let model = test_model();
1820 let assignments = build_from_assignments(&model.fields);
1821 assert!(assignments.contains("map.field(\"id\""));
1822 assert!(assignments.contains("map.field(\"name\""));
1823 assert!(assignments.contains("map.field(\"bio\""));
1824 }
1825
1826 #[test]
1827 fn resource_model_to_resource_clones_all() {
1828 let model = test_model();
1829 let assigns = build_model_to_resource(&model.fields);
1830 assert!(assigns.contains("id: model.id.clone()"));
1831 assert!(assigns.contains("email: model.email.clone()"));
1832 assert!(assigns.contains("bio: model.bio.clone()"));
1833 }
1834
1835 #[test]
1840 fn create_request_skips_auto_fields() {
1841 let model = test_model();
1842 let fields = build_create_request_fields(&model.fields);
1843 assert!(!fields.contains("pub id:"));
1844 assert!(!fields.contains("pub created_at:"));
1845 assert!(!fields.contains("pub updated_at:"));
1846 assert!(fields.contains("pub name: String"));
1847 assert!(fields.contains("pub email: String"));
1848 }
1849
1850 #[test]
1851 fn create_request_preserves_nullable() {
1852 let model = test_model();
1853 let fields = build_create_request_fields(&model.fields);
1854 assert!(fields.contains("pub bio: Option<String>"));
1855 }
1856
1857 #[test]
1858 fn update_request_wraps_in_option() {
1859 let model = test_model();
1860 let fields = build_update_request_fields(&model.fields);
1861 assert!(fields.contains("pub name: Option<String>"));
1862 assert!(fields.contains("pub email: Option<String>"));
1863 assert!(fields.contains("pub is_active: Option<bool>"));
1864 }
1865
1866 #[test]
1867 fn update_request_no_double_option() {
1868 let model = test_model();
1869 let fields = build_update_request_fields(&model.fields);
1870 assert!(fields.contains("pub bio: Option<String>"));
1872 assert!(
1873 !fields.contains("Option<Option<"),
1874 "nullable fields should not be double-wrapped"
1875 );
1876 }
1877
1878 #[test]
1883 fn no_connection_await_in_store_fields() {
1884 let model = test_model();
1885 let store = build_store_fields(&model.fields);
1886 assert!(!store.contains("connection().await"));
1887 }
1888
1889 #[test]
1890 fn no_serde_json_value_vec() {
1891 let template_fragment = "Vec<{pascal}Resource>";
1894 assert!(!template_fragment.contains("Vec<serde_json::Value>"));
1895 }
1896
1897 #[test]
1902 fn resolve_models_by_singular_name() {
1903 let available = vec![(
1904 "user".to_string(),
1905 ModelInfo {
1906 name: "User".to_string(),
1907 module_name: "users".to_string(),
1908 table_name: Some("users".to_string()),
1909 fields: vec![],
1910 },
1911 )];
1912 let result = resolve_models(&["User".to_string()], false, &available);
1913 assert_eq!(result.len(), 1);
1914 assert_eq!(result[0].1.name, "User");
1915 }
1916
1917 #[test]
1918 fn resolve_models_by_snake_case() {
1919 let available = vec![(
1920 "blog_post".to_string(),
1921 ModelInfo {
1922 name: "BlogPost".to_string(),
1923 module_name: "blog_posts".to_string(),
1924 table_name: Some("blog_posts".to_string()),
1925 fields: vec![],
1926 },
1927 )];
1928 let result = resolve_models(&["blog_post".to_string()], false, &available);
1929 assert_eq!(result.len(), 1);
1930 assert_eq!(result[0].1.name, "BlogPost");
1931 }
1932
1933 #[test]
1934 fn resolve_models_all_flag() {
1935 let available = vec![
1936 (
1937 "user".to_string(),
1938 ModelInfo {
1939 name: "User".to_string(),
1940 module_name: "users".to_string(),
1941 table_name: Some("users".to_string()),
1942 fields: vec![],
1943 },
1944 ),
1945 (
1946 "todo".to_string(),
1947 ModelInfo {
1948 name: "Todo".to_string(),
1949 module_name: "todos".to_string(),
1950 table_name: Some("todos".to_string()),
1951 fields: vec![],
1952 },
1953 ),
1954 ];
1955 let result = resolve_models(&[], true, &available);
1956 assert_eq!(result.len(), 2);
1957 }
1958
1959 #[test]
1964 fn request_rust_type_datetime_becomes_string() {
1965 assert_eq!(request_rust_type("DateTime", false), "String");
1966 assert_eq!(request_rust_type("DateTimeUtc", false), "String");
1967 assert_eq!(request_rust_type("NaiveDate", false), "String");
1968 }
1969
1970 #[test]
1971 fn request_rust_type_nullable_passthrough() {
1972 assert_eq!(request_rust_type("Option<String>", true), "Option<String>");
1973 }
1974
1975 #[test]
1976 fn request_rust_type_regular_passthrough() {
1977 assert_eq!(request_rust_type("String", false), "String");
1978 assert_eq!(request_rust_type("i32", false), "i32");
1979 assert_eq!(request_rust_type("bool", false), "bool");
1980 }
1981
1982 fn make_field(name: &str) -> FieldInfo {
1987 FieldInfo {
1988 name: name.to_string(),
1989 rust_type: "String".to_string(),
1990 is_primary_key: false,
1991 is_nullable: false,
1992 }
1993 }
1994
1995 #[test]
1996 fn filter_excludes_password_hash_by_default() {
1997 let fields = vec![
1998 make_field("id"),
1999 make_field("email"),
2000 make_field("password_hash"),
2001 ];
2002 let result = filter_resource_fields(&fields, &[], false);
2003 let names: Vec<&str> = result.iter().map(|f| f.name.as_str()).collect();
2004 assert!(names.contains(&"id"));
2005 assert!(names.contains(&"email"));
2006 assert!(!names.contains(&"password_hash"));
2007 }
2008
2009 #[test]
2010 fn filter_keeps_non_sensitive_fields() {
2011 let fields = vec![
2012 make_field("id"),
2013 make_field("email"),
2014 make_field("name"),
2015 make_field("created_at"),
2016 ];
2017 let result = filter_resource_fields(&fields, &[], false);
2018 assert_eq!(result.len(), 4);
2019 }
2020
2021 #[test]
2022 fn filter_custom_exclude_removes_field() {
2023 let fields = vec![make_field("id"), make_field("email"), make_field("name")];
2024 let exclude = vec!["email".to_string()];
2025 let result = filter_resource_fields(&fields, &exclude, false);
2026 let names: Vec<&str> = result.iter().map(|f| f.name.as_str()).collect();
2027 assert!(!names.contains(&"email"));
2028 assert!(names.contains(&"id"));
2029 assert!(names.contains(&"name"));
2030 }
2031
2032 #[test]
2033 fn filter_include_all_keeps_sensitive_fields() {
2034 let fields = vec![
2035 make_field("id"),
2036 make_field("password_hash"),
2037 make_field("hashed_key"),
2038 make_field("secret"),
2039 ];
2040 let result = filter_resource_fields(&fields, &[], true);
2041 assert_eq!(result.len(), 4);
2042 }
2043
2044 #[test]
2045 fn filter_case_insensitive_matching() {
2046 let fields = vec![
2047 make_field("Password_Hash"),
2048 make_field("SECRET"),
2049 make_field("email"),
2050 ];
2051 let result = filter_resource_fields(&fields, &[], false);
2052 let names: Vec<&str> = result.iter().map(|f| f.name.as_str()).collect();
2053 assert!(!names.contains(&"Password_Hash"));
2054 assert!(!names.contains(&"SECRET"));
2055 assert!(names.contains(&"email"));
2056 }
2057
2058 #[test]
2059 fn filter_exact_match_only() {
2060 let fields = vec![
2062 make_field("token"),
2063 make_field("created_at"),
2064 make_field("token_time"),
2065 ];
2066 let result = filter_resource_fields(&fields, &[], false);
2067 let names: Vec<&str> = result.iter().map(|f| f.name.as_str()).collect();
2068 assert!(
2069 !names.contains(&"token"),
2070 "exact match 'token' should be excluded"
2071 );
2072 assert!(
2073 names.contains(&"created_at"),
2074 "unrelated field should remain"
2075 );
2076 assert!(
2077 names.contains(&"token_time"),
2078 "substring match should NOT be excluded"
2079 );
2080 }
2081
2082 #[test]
2083 fn filter_include_all_still_respects_custom_exclude() {
2084 let fields = vec![
2086 make_field("password_hash"),
2087 make_field("email"),
2088 make_field("name"),
2089 ];
2090 let exclude = vec!["email".to_string()];
2091 let result = filter_resource_fields(&fields, &exclude, true);
2092 let names: Vec<&str> = result.iter().map(|f| f.name.as_str()).collect();
2093 assert!(
2094 names.contains(&"password_hash"),
2095 "include_all keeps sensitive fields"
2096 );
2097 assert!(!names.contains(&"email"), "custom exclude still works");
2098 assert!(names.contains(&"name"));
2099 }
2100
2101 #[test]
2102 fn filter_all_sensitive_patterns_excluded() {
2103 let fields: Vec<FieldInfo> = SENSITIVE_FIELD_PATTERNS
2104 .iter()
2105 .map(|p| make_field(p))
2106 .collect();
2107 let result = filter_resource_fields(&fields, &[], false);
2108 assert!(
2109 result.is_empty(),
2110 "all sensitive patterns should be excluded, got: {:?}",
2111 result.iter().map(|f| &f.name).collect::<Vec<_>>()
2112 );
2113 }
2114}