Skip to main content

ferro_cli/commands/
make_api.rs

1//! `ferro make:api` command — scaffolds a complete REST API layer for existing models.
2//!
3//! Generates CRUD controllers, API resources, request validation types,
4//! route registration with API key middleware, OpenAPI docs endpoint,
5//! and API key migration.
6
7use 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
15// ---------------------------------------------------------------------------
16// Sensitive field auto-exclusion
17// ---------------------------------------------------------------------------
18
19/// Field names that are auto-excluded from API resources (response serialization).
20/// Matched case-insensitively, exact match only.
21const 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
32/// Filter fields for API resource generation, excluding sensitive and user-specified fields.
33///
34/// Returns references to fields that should be included in the resource.
35/// When `include_all` is true, no auto-exclusion is applied (user `--exclude` still applies).
36pub(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            // Check user-specified exclusions (always applied)
47            if exclude.iter().any(|e| e.to_lowercase() == name_lower) {
48                return false;
49            }
50
51            // Check sensitive field patterns (unless --include-all)
52            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// ---------------------------------------------------------------------------
66// Model metadata types
67// ---------------------------------------------------------------------------
68
69/// Parsed model information extracted from source files via syn.
70#[derive(Debug, Clone)]
71struct ModelInfo {
72    /// PascalCase struct name (e.g., "User")
73    name: String,
74    /// Module name under `crate::models::` (e.g., "users" for `crate::models::users`)
75    module_name: String,
76    /// Table name from `#[sea_orm(table_name = "...")]`
77    table_name: Option<String>,
78    /// All struct fields
79    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
90// ---------------------------------------------------------------------------
91// AST visitor for model detection
92// ---------------------------------------------------------------------------
93
94struct 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            // The struct is typically "Model"; skip unless it's an entity model
186            // (DeriveEntityModel is on the "Model" struct inside the entity module)
187            if name == "Model" {
188                // Capture parent module name from attributes instead
189                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(), // Set later by scan_models
194                    table_name: table,
195                    fields,
196                });
197            }
198        }
199        syn::visit::visit_item_struct(self, node);
200    }
201}
202
203// ---------------------------------------------------------------------------
204// Model scanning
205// ---------------------------------------------------------------------------
206
207/// Scan `src/models/` for model files and extract metadata via syn AST parsing.
208fn 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        // Skip mod.rs
228        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            // Entity files typically use plural table names (users.rs, todos.rs).
244            // Singularize to get the model name (User, Todo) and use the singular
245            // form as the snake_name key for generated file paths.
246            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            // module_name is the actual Rust module path under crate::models::
259            // For entity files, the re-export module matches the entity file name.
260            model.module_name = file_stem.clone();
261
262            let pascal_name = to_pascal_case(&singular_stem);
263            model.name = pascal_name.clone();
264            // If no table name was extracted, derive from file name
265            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
275/// Resolve which models to generate API for.
276fn 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
306// ---------------------------------------------------------------------------
307// Per-model code generation
308// ---------------------------------------------------------------------------
309
310/// Generate the API controller for a model.
311fn 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    // Build set_field calls for store (non-PK, non-auto fields)
331    let store_fields = build_store_fields(&model.fields);
332    // Build optional set_field calls for update
333    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
429/// Generate the API resource for a model.
430fn 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    // Filter fields for the resource (excludes sensitive fields)
448    let included_fields = filter_resource_fields(&model.fields, exclude, include_all);
449
450    // Report excluded fields
451    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    // Build resource fields and From impl assignments using filtered fields
467    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
509/// Generate request types for a model.
510fn 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
572// ---------------------------------------------------------------------------
573// Field mapping helpers
574// ---------------------------------------------------------------------------
575
576/// Fields to skip in generated request/store/update code.
577fn 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
584/// Build `.set_field(form.field)` lines for the store handler.
585///
586/// For nullable model fields, the create request has `Option<T>` while the
587/// builder setter expects the inner type. We use `unwrap_or_default()` to
588/// provide a sensible default when `None` is passed.
589fn 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
606/// Build conditional set_field lines for the update handler.
607fn 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
620/// Build struct field definitions for the resource (filtered field list).
621fn 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
629/// Build ResourceMap field calls for to_resource (filtered field list).
630fn 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
642/// Build From<Model> field assignments (filtered field list).
643fn 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/// Build struct field definitions for all fields (unfiltered convenience wrapper).
651#[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/// Build ResourceMap field calls for all fields (unfiltered convenience wrapper).
658#[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/// Build From<Model> assignments for all fields (unfiltered convenience wrapper).
665#[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
671/// Build create request struct fields.
672fn 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
683/// Build update request struct fields (all optional).
684///
685/// For model fields that are already `Option<T>`, the update request field is
686/// `Option<T>` (not `Option<Option<T>>`). This means `None` = "don't change"
687/// and `Some(value)` = "set to value". Explicitly setting a nullable field to
688/// NULL requires custom code beyond the generated scaffold.
689fn 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                // Already Option<T> in the model — keep as Option<T> in update request
696                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
706/// Map a Rust type from the model to a suitable resource field type.
707fn resource_rust_type(rust_type: &str) -> String {
708    // Strip Option wrapper if present for display, keep as-is
709    rust_type.to_string()
710}
711
712/// Map a model's Rust type to a request field type.
713fn request_rust_type(rust_type: &str, is_nullable: bool) -> String {
714    if is_nullable {
715        // Already Option<T>, keep it
716        return rust_type.to_string();
717    }
718    // Map DateTime types to String for request input
719    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
728// ---------------------------------------------------------------------------
729// String utilities
730// ---------------------------------------------------------------------------
731
732fn 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
774/// Best-effort singularization of a plural English noun.
775///
776/// Handles common suffixes: -ies -> -y, -ses/-xes/-ches/-shes -> strip -es, -s -> strip.
777fn singularize(name: &str) -> String {
778    if name.ends_with("ies") && name.len() > 3 {
779        // "categories" -> "category"
780        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        // "statuses" -> "status", "boxes" -> "box"
787        name[..name.len() - 2].to_string()
788    } else if name.ends_with('s') && !name.ends_with("ss") {
789        // "users" -> "user", "todos" -> "todo"
790        name[..name.len() - 1].to_string()
791    } else {
792        name.to_string()
793    }
794}
795
796// ---------------------------------------------------------------------------
797/// Read the project name from `./Cargo.toml`, falling back to "my-app".
798fn 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
818// Public entry point (Task 1: model detection + per-model generation only)
819// ---------------------------------------------------------------------------
820
821/// Run the `make:api` command.
822///
823/// Generates API controllers, resources, and request types for the specified
824/// models (or all models if `--all` is set).
825pub 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    // Confirmation prompt unless --yes
855    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 infrastructure files
892    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    // Print comprehensive post-scaffold guidance
902    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
1018// ---------------------------------------------------------------------------
1019// Infrastructure file generation (called from run)
1020// ---------------------------------------------------------------------------
1021
1022/// Generate src/api/mod.rs with module declarations.
1023fn 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        // Append new model modules if they're not already declared
1032        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        // Ensure routes and docs modules are declared
1042        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
1070/// Generate src/api/routes.rs with route registration.
1071fn 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
1126/// Generate src/api/docs.rs with OpenAPI documentation handlers.
1127fn 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
1181/// Create or update src/resources/mod.rs to include generated resource modules.
1182fn 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
1214/// Create or update src/requests/mod.rs to include generated request modules.
1215fn 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
1247/// Generate the API keys migration.
1248fn 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    // Check if migration already exists
1262    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.rs
1354    update_migrations_mod(&migration_name);
1355
1356    println!(
1357        "   {} Created {}/{}",
1358        style("✓").green(),
1359        migrations_dir.display(),
1360        file_name
1361    );
1362}
1363
1364/// Update the migrations mod.rs to include the new migration.
1365fn 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    // Find where to insert (after existing pub mod m* lines)
1381    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    // Add to Migrator vec
1391    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
1425/// Generate src/models/api_key.rs.
1426fn 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    // Update models mod.rs
1482    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
1494/// Generate src/providers/api_key_provider.rs.
1495fn 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// ---------------------------------------------------------------------------
1579// Tests
1580// ---------------------------------------------------------------------------
1581
1582#[cfg(test)]
1583mod tests {
1584    use super::*;
1585
1586    /// Build a realistic test model with mixed field types.
1587    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    // -----------------------------------------------------------------------
1640    // String utility tests
1641    // -----------------------------------------------------------------------
1642
1643    #[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"); // ends with ss
1672    }
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    // -----------------------------------------------------------------------
1702    // Auto field detection
1703    // -----------------------------------------------------------------------
1704
1705    #[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    // -----------------------------------------------------------------------
1741    // Controller template tests
1742    // -----------------------------------------------------------------------
1743
1744    #[test]
1745    fn controller_uses_sync_db_connection() {
1746        // The controller template contains the DB::connection() call pattern.
1747        // Verify it never uses .await (DB::connection is sync).
1748        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    // -----------------------------------------------------------------------
1804    // Resource template tests
1805    // -----------------------------------------------------------------------
1806
1807    #[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    // -----------------------------------------------------------------------
1836    // Request template tests
1837    // -----------------------------------------------------------------------
1838
1839    #[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        // bio is already Option<String> — should stay Option<String>, not Option<Option<String>>
1871        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    // -----------------------------------------------------------------------
1879    // Regression: known bad patterns must be absent
1880    // -----------------------------------------------------------------------
1881
1882    #[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        // The controller template should use Vec<{Resource}>, not Vec<serde_json::Value>.
1892        // We verify by checking that the known template format string uses the typed vec.
1893        let template_fragment = "Vec<{pascal}Resource>";
1894        assert!(!template_fragment.contains("Vec<serde_json::Value>"));
1895    }
1896
1897    // -----------------------------------------------------------------------
1898    // Model resolution and name derivation
1899    // -----------------------------------------------------------------------
1900
1901    #[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    // -----------------------------------------------------------------------
1960    // Type mapping
1961    // -----------------------------------------------------------------------
1962
1963    #[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    // -----------------------------------------------------------------------
1983    // filter_resource_fields tests
1984    // -----------------------------------------------------------------------
1985
1986    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        // "token" should exclude "token" but NOT "created_at" or "token_time"
2061        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        // --include-all disables auto-exclusion but --exclude still works
2085        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}