Skip to main content

shaperail_codegen/
validator.rs

1use shaperail_core::{FieldType, HttpMethod, ResourceDefinition, WASM_HOOK_PREFIX};
2
3/// A semantic validation error for a resource definition.
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub struct ValidationError {
6    pub message: String,
7}
8
9impl std::fmt::Display for ValidationError {
10    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
11        write!(f, "{}", self.message)
12    }
13}
14
15/// Validate a parsed `ResourceDefinition` for semantic correctness.
16///
17/// Returns a list of all validation errors found. An empty list means the
18/// resource is valid.
19pub fn validate_resource(rd: &ResourceDefinition) -> Vec<ValidationError> {
20    let mut errors = Vec::new();
21    let res = &rd.resource;
22
23    // Resource name must not be empty
24    if res.is_empty() {
25        errors.push(err("resource name must not be empty"));
26    }
27
28    // Version must be >= 1
29    if rd.version == 0 {
30        errors.push(err(&format!("resource '{res}': version must be >= 1")));
31    }
32
33    // Schema must have at least one field
34    if rd.schema.is_empty() {
35        errors.push(err(&format!(
36            "resource '{res}': schema must have at least one field"
37        )));
38    }
39
40    // Must have exactly one primary key
41    let primary_count = rd.schema.values().filter(|f| f.primary).count();
42    if primary_count == 0 {
43        errors.push(err(&format!(
44            "resource '{res}': schema must have a primary key field"
45        )));
46    } else if primary_count > 1 {
47        errors.push(err(&format!(
48            "resource '{res}': schema must have exactly one primary key, found {primary_count}"
49        )));
50    }
51
52    // Per-field validation
53    for (name, field) in &rd.schema {
54        // Enum type requires values
55        if field.field_type == FieldType::Enum && field.values.is_none() {
56            errors.push(err(&format!(
57                "resource '{res}': field '{name}' is type enum but has no values"
58            )));
59        }
60
61        // Non-enum type should not have values
62        if field.field_type != FieldType::Enum && field.values.is_some() {
63            errors.push(err(&format!(
64                "resource '{res}': field '{name}' has values but is not type enum"
65            )));
66        }
67
68        // Ref field must be uuid type
69        if field.reference.is_some() && field.field_type != FieldType::Uuid {
70            errors.push(err(&format!(
71                "resource '{res}': field '{name}' has ref but is not type uuid"
72            )));
73        }
74
75        // Ref format must be "resource.field"
76        if let Some(ref reference) = field.reference {
77            if !reference.contains('.') {
78                errors.push(err(&format!(
79                    "resource '{res}': field '{name}' ref must be in 'resource.field' format, got '{reference}'"
80                )));
81            }
82        }
83
84        // Array type requires items
85        if field.field_type == FieldType::Array && field.items.is_none() {
86            errors.push(err(&format!(
87                "resource '{res}': field '{name}' is type array but has no items"
88            )));
89        }
90
91        // Format only valid for string type
92        if field.format.is_some() && field.field_type != FieldType::String {
93            errors.push(err(&format!(
94                "resource '{res}': field '{name}' has format but is not type string"
95            )));
96        }
97
98        // Primary key should be generated or required
99        if field.primary && !field.generated && !field.required {
100            errors.push(err(&format!(
101                "resource '{res}': primary key field '{name}' must be generated or required"
102            )));
103        }
104
105        // Transient field constraints — `transient: true` means input-only, never persisted.
106        // Combinations that imply persistence are nonsensical and rejected loudly.
107        if field.transient {
108            if field.primary {
109                errors.push(err(&format!(
110                    "resource '{res}': field '{name}' cannot be both transient and primary"
111                )));
112            }
113            if field.generated {
114                errors.push(err(&format!(
115                    "resource '{res}': field '{name}' cannot be both transient and generated"
116                )));
117            }
118            if field.reference.is_some() {
119                errors.push(err(&format!(
120                    "resource '{res}': field '{name}' cannot be both transient and have a ref (foreign keys imply persistence)"
121                )));
122            }
123            if field.unique {
124                errors.push(err(&format!(
125                    "resource '{res}': field '{name}' cannot be both transient and unique (unique constraints require persistence)"
126                )));
127            }
128            if field.default.is_some() {
129                errors.push(err(&format!(
130                    "resource '{res}': field '{name}' cannot be both transient and have a default (defaults apply to stored columns)"
131                )));
132            }
133        }
134    }
135
136    // Transient fields must appear in at least one endpoint's `input:` list — otherwise
137    // they're unreachable: never populated, never validated, never seen anywhere.
138    let transient_fields: Vec<&String> = rd
139        .schema
140        .iter()
141        .filter(|(_, f)| f.transient)
142        .map(|(name, _)| name)
143        .collect();
144    if !transient_fields.is_empty() {
145        let referenced: std::collections::HashSet<&str> = rd
146            .endpoints
147            .as_ref()
148            .map(|eps| {
149                eps.values()
150                    .filter_map(|ep| ep.input.as_ref())
151                    .flat_map(|inputs| inputs.iter().map(|s| s.as_str()))
152                    .collect()
153            })
154            .unwrap_or_default();
155        for name in transient_fields {
156            if !referenced.contains(name.as_str()) {
157                errors.push(err(&format!(
158                    "resource '{res}': transient field '{name}' is not declared in any endpoint's input: list (the field would be unreachable)"
159                )));
160            }
161        }
162    }
163
164    // Tenant key validation (M18)
165    if let Some(ref tenant_key) = rd.tenant_key {
166        match rd.schema.get(tenant_key) {
167            Some(field) => {
168                if field.field_type != FieldType::Uuid {
169                    errors.push(err(&format!(
170                        "resource '{res}': tenant_key '{tenant_key}' must reference a uuid field, found {}",
171                        field.field_type
172                    )));
173                }
174            }
175            None => {
176                errors.push(err(&format!(
177                    "resource '{res}': tenant_key '{tenant_key}' not found in schema"
178                )));
179            }
180        }
181    }
182
183    // Endpoint validation
184    if let Some(endpoints) = &rd.endpoints {
185        for (action, ep) in endpoints {
186            // method and path must be set (either explicitly or via convention defaults)
187            if ep.method.is_none() {
188                errors.push(err(&format!(
189                    "resource '{res}': endpoint '{action}' has no method. Use a known action name (list, get, create, update, delete) or set method explicitly"
190                )));
191            }
192            if ep.path.is_none() {
193                errors.push(err(&format!(
194                    "resource '{res}': endpoint '{action}' has no path. Use a known action name (list, get, create, update, delete) or set path explicitly"
195                )));
196            }
197
198            // handler: is only valid on custom (non-convention) endpoints.
199            // The runtime dispatches list/get/create/update/delete via the
200            // standard CRUD path, so a handler: declaration on those keys
201            // would be silently ignored at codegen time.
202            if let Some(e) = validate_handler_only_on_custom(res, action, ep) {
203                errors.push(e);
204            }
205
206            if let Some(controller) = &ep.controller {
207                // controller: is only valid on conventional CRUD endpoints.
208                if let Some(e) = validate_controller_only_on_crud(res, action, ep) {
209                    errors.push(e);
210                }
211
212                if let Some(before) = &controller.before {
213                    if before.is_empty() {
214                        errors.push(err(&format!(
215                            "resource '{res}': endpoint '{action}' has an empty controller.before name"
216                        )));
217                    }
218                    validate_controller_name(res, action, "before", before, &mut errors);
219                }
220                if let Some(after) = &controller.after {
221                    if after.is_empty() {
222                        errors.push(err(&format!(
223                            "resource '{res}': endpoint '{action}' has an empty controller.after name"
224                        )));
225                    }
226                    validate_controller_name(res, action, "after", after, &mut errors);
227                }
228            }
229
230            if let Some(events) = &ep.events {
231                for event in events {
232                    if event.is_empty() {
233                        errors.push(err(&format!(
234                            "resource '{res}': endpoint '{action}' has an empty event name"
235                        )));
236                    }
237                }
238            }
239
240            if let Some(jobs) = &ep.jobs {
241                for job in jobs {
242                    if job.is_empty() {
243                        errors.push(err(&format!(
244                            "resource '{res}': endpoint '{action}' has an empty job name"
245                        )));
246                    }
247                }
248            }
249
250            // Input fields must exist in schema
251            if let Some(input) = &ep.input {
252                for field_name in input {
253                    if !rd.schema.contains_key(field_name) {
254                        errors.push(err(&format!(
255                            "resource '{res}': endpoint '{action}' input field '{field_name}' not found in schema"
256                        )));
257                    }
258                }
259            }
260
261            // Filter fields must exist in schema
262            if let Some(filters) = &ep.filters {
263                for field_name in filters {
264                    if !rd.schema.contains_key(field_name) {
265                        errors.push(err(&format!(
266                            "resource '{res}': endpoint '{action}' filter field '{field_name}' not found in schema"
267                        )));
268                    }
269                }
270            }
271
272            // Search fields must exist in schema
273            if let Some(search) = &ep.search {
274                for field_name in search {
275                    if !rd.schema.contains_key(field_name) {
276                        errors.push(err(&format!(
277                            "resource '{res}': endpoint '{action}' search field '{field_name}' not found in schema"
278                        )));
279                    }
280                }
281            }
282
283            // Sort fields must exist in schema
284            if let Some(sort) = &ep.sort {
285                for field_name in sort {
286                    if !rd.schema.contains_key(field_name) {
287                        errors.push(err(&format!(
288                            "resource '{res}': endpoint '{action}' sort field '{field_name}' not found in schema"
289                        )));
290                    }
291                }
292            }
293
294            // soft_delete requires deleted_at field in schema
295            if ep.soft_delete && !rd.schema.contains_key("deleted_at") {
296                errors.push(err(&format!(
297                    "resource '{res}': endpoint '{action}' has soft_delete but schema has no 'deleted_at' field"
298                )));
299            }
300
301            if let Some(upload) = &ep.upload {
302                match ep.method.as_ref() {
303                    Some(HttpMethod::Post | HttpMethod::Patch | HttpMethod::Put) => {}
304                    Some(_) => errors.push(err(&format!(
305                        "resource '{res}': endpoint '{action}' uses upload but method must be POST, PATCH, or PUT"
306                    ))),
307                    None => {} // already reported above
308                }
309
310                match rd.schema.get(&upload.field) {
311                    Some(field) if field.field_type == FieldType::File => {}
312                    Some(_) => errors.push(err(&format!(
313                        "resource '{res}': endpoint '{action}' upload field '{}' must be type file",
314                        upload.field
315                    ))),
316                    None => errors.push(err(&format!(
317                        "resource '{res}': endpoint '{action}' upload field '{}' not found in schema",
318                        upload.field
319                    ))),
320                }
321
322                if !matches!(upload.storage.as_str(), "local" | "s3" | "gcs" | "azure") {
323                    errors.push(err(&format!(
324                        "resource '{res}': endpoint '{action}' upload storage '{}' is invalid",
325                        upload.storage
326                    )));
327                }
328
329                if !ep
330                    .input
331                    .as_ref()
332                    .is_some_and(|fields| fields.iter().any(|field| field == &upload.field))
333                {
334                    errors.push(err(&format!(
335                        "resource '{res}': endpoint '{action}' upload field '{}' must appear in input",
336                        upload.field
337                    )));
338                }
339
340                for (suffix, expected_types) in [
341                    ("filename", &[FieldType::String][..]),
342                    ("mime_type", &[FieldType::String][..]),
343                    ("size", &[FieldType::Integer, FieldType::Bigint][..]),
344                ] {
345                    let companion = format!("{}_{}", upload.field, suffix);
346                    if let Some(field) = rd.schema.get(&companion) {
347                        if !expected_types.contains(&field.field_type) {
348                            let expected = expected_types
349                                .iter()
350                                .map(ToString::to_string)
351                                .collect::<Vec<_>>()
352                                .join(" or ");
353                            errors.push(err(&format!(
354                                "resource '{res}': companion upload field '{companion}' must be type {expected}"
355                            )));
356                        }
357                    }
358                }
359            }
360        }
361    }
362
363    // Relation validation
364    if let Some(relations) = &rd.relations {
365        for (name, rel) in relations {
366            use shaperail_core::RelationType;
367
368            // belongs_to should have key
369            if rel.relation_type == RelationType::BelongsTo && rel.key.is_none() {
370                errors.push(err(&format!(
371                    "resource '{res}': relation '{name}' is belongs_to but has no key"
372                )));
373            }
374
375            // has_many/has_one should have foreign_key
376            if matches!(
377                rel.relation_type,
378                RelationType::HasMany | RelationType::HasOne
379            ) && rel.foreign_key.is_none()
380            {
381                errors.push(err(&format!(
382                    "resource '{res}': relation '{name}' is {} but has no foreign_key",
383                    rel.relation_type
384                )));
385            }
386
387            // belongs_to key must exist in schema
388            if let Some(key) = &rel.key {
389                if !rd.schema.contains_key(key) {
390                    errors.push(err(&format!(
391                        "resource '{res}': relation '{name}' key '{key}' not found in schema"
392                    )));
393                }
394            }
395        }
396    }
397
398    // Index validation
399    if let Some(indexes) = &rd.indexes {
400        for (i, idx) in indexes.iter().enumerate() {
401            if idx.fields.is_empty() {
402                errors.push(err(&format!("resource '{res}': index {i} has no fields")));
403            }
404            for field_name in &idx.fields {
405                if !rd.schema.contains_key(field_name) {
406                    errors.push(err(&format!(
407                        "resource '{res}': index {i} references field '{field_name}' not in schema"
408                    )));
409                }
410            }
411            if let Some(order) = &idx.order {
412                if order != "asc" && order != "desc" {
413                    errors.push(err(&format!(
414                        "resource '{res}': index {i} has invalid order '{order}', must be 'asc' or 'desc'"
415                    )));
416                }
417            }
418        }
419    }
420
421    errors
422}
423
424/// Rejects `controller: { after: ... }` declarations on non-CRUD (custom) endpoints.
425///
426/// `before:` controllers are now permitted on custom endpoints — the runtime builds
427/// a `Context` with auto-populated `tenant_id`, dispatches the before-hook, and
428/// stashes the resulting `Context` into `req.extensions_mut()` so the custom handler
429/// can read it.
430///
431/// `after:` controllers remain rejected on custom endpoints because the custom handler
432/// owns the response shape — there is no `data:` envelope for the runtime to merge
433/// `ctx.response_extras` into, and no consistent hook point after the handler returns.
434fn validate_controller_only_on_crud(
435    resource: &str,
436    action: &str,
437    endpoint: &shaperail_core::EndpointSpec,
438) -> Option<ValidationError> {
439    const CRUD_ACTIONS: &[&str] = &[
440        "list",
441        "get",
442        "create",
443        "update",
444        "delete",
445        "bulk_create",
446        "bulk_delete",
447    ];
448    if CRUD_ACTIONS.contains(&action) {
449        return None;
450    }
451    // Custom endpoint: allow before-only controller, reject after-controller.
452    let has_after = endpoint
453        .controller
454        .as_ref()
455        .and_then(|c| c.after.as_deref())
456        .is_some();
457    if has_after {
458        return Some(err(&format!(
459            "resource '{resource}': endpoint '{action}' declares `controller: {{ after: ... }}`, \
460             but `after:` controllers are only valid on conventional CRUD endpoints \
461             (list / get / create / update / delete / bulk_create / bulk_delete).\n\
462             \n\
463             Custom endpoints generate their own response via `handler:`, so the runtime \
464             has no place to merge `ctx.response_extras` or pass through after-hook \
465             mutations. Use a `before:` controller for shared setup (auth augmentation, \
466             tenant scoping, request validation), and put response-shaping logic inside \
467             the handler itself."
468        )));
469    }
470    None
471}
472
473/// Rejects `handler:` declarations on convention endpoint action keys.
474///
475/// The runtime dispatches the convention actions (`list`, `get`, `create`,
476/// `update`, `delete`) via the standard CRUD path. The codegen helper
477/// `collect_custom_handlers` filters these out, so a `handler:` declaration
478/// on a convention key is silently dropped — the user's function is never
479/// registered, never compiled, and never called. Surface this as a hard
480/// validation error with an actionable workaround.
481fn validate_handler_only_on_custom(
482    resource: &str,
483    action: &str,
484    endpoint: &shaperail_core::EndpointSpec,
485) -> Option<ValidationError> {
486    let handler = endpoint.handler.as_deref()?;
487    if !crate::rust::HANDLER_CONVENTIONS.contains(&action) {
488        return None;
489    }
490    Some(err(&format!(
491        "resource '{resource}': endpoint '{action}' declares `handler: {handler}`, \
492         but `handler:` is only valid on custom (non-convention) endpoints. \
493         The convention actions list / get / create / update / delete are \
494         dispatched by the runtime CRUD path; a `handler:` declaration on \
495         them would be silently dropped at codegen time.\n\
496         \n\
497         To attach a custom handler at the same HTTP method+path, rename the \
498         endpoint key to a non-convention action (e.g. `post_{resource}`) and \
499         set `method:` and `path:` explicitly:\n\
500         \n\
501         endpoints:\n  \
502           post_{resource}:\n    \
503             method: POST\n    \
504             path: /{resource}\n    \
505             handler: {handler}\n\
506         \n\
507         To customize standard CRUD behavior without replacing the runtime \
508         path, use `controller: {{ before: ... }}` for setup logic and \
509         `controller: {{ after: ... }}` for response shaping."
510    )))
511}
512
513/// Validates a controller name — either a Rust function name or a `wasm:` prefixed path.
514fn validate_controller_name(
515    res: &str,
516    action: &str,
517    phase: &str,
518    name: &str,
519    errors: &mut Vec<ValidationError>,
520) {
521    if let Some(wasm_path) = name.strip_prefix(WASM_HOOK_PREFIX) {
522        if wasm_path.is_empty() {
523            errors.push(err(&format!(
524                "resource '{res}': endpoint '{action}' controller.{phase} has 'wasm:' prefix but no path"
525            )));
526        } else if !wasm_path.ends_with(".wasm") {
527            errors.push(err(&format!(
528                "resource '{res}': endpoint '{action}' controller.{phase} WASM path must end with '.wasm', got '{wasm_path}'"
529            )));
530        }
531    }
532}
533
534fn err(message: &str) -> ValidationError {
535    ValidationError {
536        message: message.to_string(),
537    }
538}
539
540#[cfg(test)]
541mod tests {
542    use super::*;
543    use crate::parser::parse_resource;
544
545    #[test]
546    fn valid_resource_passes() {
547        let yaml = include_str!("../../resources/users.yaml");
548        let rd = parse_resource(yaml).unwrap();
549        let errors = validate_resource(&rd);
550        assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
551    }
552
553    #[test]
554    fn enum_without_values() {
555        let yaml = r#"
556resource: items
557version: 1
558schema:
559  id: { type: uuid, primary: true, generated: true }
560  status: { type: enum, required: true }
561"#;
562        let rd = parse_resource(yaml).unwrap();
563        let errors = validate_resource(&rd);
564        assert!(errors
565            .iter()
566            .any(|e| e.message.contains("type enum but has no values")));
567    }
568
569    #[test]
570    fn ref_field_not_uuid() {
571        let yaml = r#"
572resource: items
573version: 1
574schema:
575  id: { type: uuid, primary: true, generated: true }
576  org_id: { type: string, ref: organizations.id }
577"#;
578        let rd = parse_resource(yaml).unwrap();
579        let errors = validate_resource(&rd);
580        assert!(errors
581            .iter()
582            .any(|e| e.message.contains("has ref but is not type uuid")));
583    }
584
585    #[test]
586    fn missing_primary_key() {
587        let yaml = r#"
588resource: items
589version: 1
590schema:
591  name: { type: string, required: true }
592"#;
593        let rd = parse_resource(yaml).unwrap();
594        let errors = validate_resource(&rd);
595        assert!(errors
596            .iter()
597            .any(|e| e.message.contains("must have a primary key")));
598    }
599
600    #[test]
601    fn soft_delete_without_deleted_at() {
602        let yaml = r#"
603resource: items
604version: 1
605schema:
606  id: { type: uuid, primary: true, generated: true }
607  name: { type: string, required: true }
608endpoints:
609  delete:
610    method: DELETE
611    path: /items/:id
612    auth: [admin]
613    soft_delete: true
614"#;
615        let rd = parse_resource(yaml).unwrap();
616        let errors = validate_resource(&rd);
617        assert!(errors.iter().any(|e| e
618            .message
619            .contains("soft_delete but schema has no 'deleted_at'")));
620    }
621
622    #[test]
623    fn input_field_not_in_schema() {
624        let yaml = r#"
625resource: items
626version: 1
627schema:
628  id: { type: uuid, primary: true, generated: true }
629  name: { type: string, required: true }
630endpoints:
631  create:
632    method: POST
633    path: /items
634    auth: [admin]
635    input: [name, nonexistent]
636"#;
637        let rd = parse_resource(yaml).unwrap();
638        let errors = validate_resource(&rd);
639        assert!(errors.iter().any(|e| e
640            .message
641            .contains("input field 'nonexistent' not found in schema")));
642    }
643
644    #[test]
645    fn belongs_to_without_key() {
646        let yaml = r#"
647resource: items
648version: 1
649schema:
650  id: { type: uuid, primary: true, generated: true }
651relations:
652  org: { resource: organizations, type: belongs_to }
653"#;
654        let rd = parse_resource(yaml).unwrap();
655        let errors = validate_resource(&rd);
656        assert!(errors
657            .iter()
658            .any(|e| e.message.contains("belongs_to but has no key")));
659    }
660
661    #[test]
662    fn has_many_without_foreign_key() {
663        let yaml = r#"
664resource: items
665version: 1
666schema:
667  id: { type: uuid, primary: true, generated: true }
668relations:
669  orders: { resource: orders, type: has_many }
670"#;
671        let rd = parse_resource(yaml).unwrap();
672        let errors = validate_resource(&rd);
673        assert!(errors
674            .iter()
675            .any(|e| e.message.contains("has_many but has no foreign_key")));
676    }
677
678    #[test]
679    fn index_references_missing_field() {
680        let yaml = r#"
681resource: items
682version: 1
683schema:
684  id: { type: uuid, primary: true, generated: true }
685indexes:
686  - fields: [missing_field]
687"#;
688        let rd = parse_resource(yaml).unwrap();
689        let errors = validate_resource(&rd);
690        assert!(errors.iter().any(|e| e
691            .message
692            .contains("references field 'missing_field' not in schema")));
693    }
694
695    #[test]
696    fn error_message_format() {
697        let yaml = r#"
698resource: users
699version: 1
700schema:
701  id: { type: uuid, primary: true, generated: true }
702  role: { type: enum }
703"#;
704        let rd = parse_resource(yaml).unwrap();
705        let errors = validate_resource(&rd);
706        assert_eq!(
707            errors[0].message,
708            "resource 'users': field 'role' is type enum but has no values"
709        );
710    }
711
712    #[test]
713    fn wasm_controller_valid_path() {
714        let yaml = r#"
715resource: items
716version: 1
717schema:
718  id: { type: uuid, primary: true, generated: true }
719  name: { type: string, required: true }
720endpoints:
721  create:
722    method: POST
723    path: /items
724    input: [name]
725    controller: { before: "wasm:./plugins/my_validator.wasm" }
726"#;
727        let rd = parse_resource(yaml).unwrap();
728        let errors = validate_resource(&rd);
729        assert!(
730            errors.is_empty(),
731            "Expected no errors for valid WASM controller, got: {errors:?}"
732        );
733    }
734
735    #[test]
736    fn wasm_controller_missing_extension() {
737        let yaml = r#"
738resource: items
739version: 1
740schema:
741  id: { type: uuid, primary: true, generated: true }
742  name: { type: string, required: true }
743endpoints:
744  create:
745    method: POST
746    path: /items
747    input: [name]
748    controller: { before: "wasm:./plugins/my_validator" }
749"#;
750        let rd = parse_resource(yaml).unwrap();
751        let errors = validate_resource(&rd);
752        assert!(errors
753            .iter()
754            .any(|e| e.message.contains("WASM path must end with '.wasm'")));
755    }
756
757    #[test]
758    fn wasm_controller_empty_path() {
759        let yaml = r#"
760resource: items
761version: 1
762schema:
763  id: { type: uuid, primary: true, generated: true }
764  name: { type: string, required: true }
765endpoints:
766  create:
767    method: POST
768    path: /items
769    input: [name]
770    controller: { before: "wasm:" }
771"#;
772        let rd = parse_resource(yaml).unwrap();
773        let errors = validate_resource(&rd);
774        assert!(errors
775            .iter()
776            .any(|e| e.message.contains("'wasm:' prefix but no path")));
777    }
778
779    #[test]
780    fn upload_endpoint_valid_when_file_field_declared() {
781        let yaml = r#"
782resource: assets
783version: 1
784schema:
785  id: { type: uuid, primary: true, generated: true }
786  file: { type: file, required: true }
787  file_filename: { type: string }
788  file_mime_type: { type: string }
789  file_size: { type: bigint }
790  updated_at: { type: timestamp, generated: true }
791endpoints:
792  upload:
793    method: POST
794    path: /assets/upload
795    input: [file]
796    upload:
797      field: file
798      storage: local
799      max_size: 5mb
800"#;
801        let rd = parse_resource(yaml).unwrap();
802        let errors = validate_resource(&rd);
803        assert!(
804            errors.is_empty(),
805            "Expected valid upload resource, got {errors:?}"
806        );
807    }
808
809    #[test]
810    fn upload_endpoint_requires_file_field() {
811        let yaml = r#"
812resource: assets
813version: 1
814schema:
815  id: { type: uuid, primary: true, generated: true }
816  file_path: { type: string, required: true }
817endpoints:
818  upload:
819    method: POST
820    path: /assets/upload
821    input: [file_path]
822    upload:
823      field: file_path
824      storage: local
825      max_size: 5mb
826"#;
827        let rd = parse_resource(yaml).unwrap();
828        let errors = validate_resource(&rd);
829        assert!(errors.iter().any(|e| e
830            .message
831            .contains("upload field 'file_path' must be type file")));
832    }
833
834    #[test]
835    fn tenant_key_valid_uuid_field() {
836        let yaml = r#"
837resource: projects
838version: 1
839tenant_key: org_id
840schema:
841  id: { type: uuid, primary: true, generated: true }
842  org_id: { type: uuid, ref: organizations.id, required: true }
843  name: { type: string, required: true }
844"#;
845        let rd = parse_resource(yaml).unwrap();
846        let errors = validate_resource(&rd);
847        assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
848    }
849
850    #[test]
851    fn tenant_key_missing_field() {
852        let yaml = r#"
853resource: projects
854version: 1
855tenant_key: org_id
856schema:
857  id: { type: uuid, primary: true, generated: true }
858  name: { type: string, required: true }
859"#;
860        let rd = parse_resource(yaml).unwrap();
861        let errors = validate_resource(&rd);
862        assert!(errors.iter().any(|e| e
863            .message
864            .contains("tenant_key 'org_id' not found in schema")));
865    }
866
867    #[test]
868    fn transient_field_valid() {
869        let yaml = r#"
870resource: users
871version: 1
872schema:
873  id:            { type: uuid, primary: true, generated: true }
874  password:      { type: string, transient: true, min: 12, required: true }
875  password_hash: { type: string, required: true }
876endpoints:
877  create:
878    method: POST
879    path: /users
880    input: [password]
881    controller: { before: hash_password }
882"#;
883        let rd = parse_resource(yaml).unwrap();
884        let errors = validate_resource(&rd);
885        assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
886    }
887
888    #[test]
889    fn transient_field_dead_when_not_in_input() {
890        let yaml = r#"
891resource: users
892version: 1
893schema:
894  id:       { type: uuid, primary: true, generated: true }
895  password: { type: string, transient: true, min: 12 }
896endpoints:
897  create:
898    method: POST
899    path: /users
900    input: []
901"#;
902        let rd = parse_resource(yaml).unwrap();
903        let errors = validate_resource(&rd);
904        assert!(errors.iter().any(|e| e
905            .message
906            .contains("transient field 'password' is not declared in any endpoint's input")));
907    }
908
909    #[test]
910    fn transient_field_rejects_primary() {
911        let yaml = r#"
912resource: users
913version: 1
914schema:
915  bad: { type: uuid, transient: true, primary: true }
916"#;
917        let rd = parse_resource(yaml).unwrap();
918        let errors = validate_resource(&rd);
919        assert!(errors
920            .iter()
921            .any(|e| e.message.contains("cannot be both transient and primary")));
922    }
923
924    #[test]
925    fn transient_field_rejects_generated() {
926        let yaml = r#"
927resource: users
928version: 1
929schema:
930  id:  { type: uuid, primary: true, generated: true }
931  bad: { type: timestamp, transient: true, generated: true }
932endpoints:
933  create:
934    method: POST
935    path: /users
936    input: [bad]
937"#;
938        let rd = parse_resource(yaml).unwrap();
939        let errors = validate_resource(&rd);
940        assert!(errors
941            .iter()
942            .any(|e| e.message.contains("cannot be both transient and generated")));
943    }
944
945    #[test]
946    fn transient_field_rejects_ref() {
947        let yaml = r#"
948resource: users
949version: 1
950schema:
951  id:  { type: uuid, primary: true, generated: true }
952  bad: { type: uuid, transient: true, ref: orgs.id }
953endpoints:
954  create:
955    method: POST
956    path: /users
957    input: [bad]
958"#;
959        let rd = parse_resource(yaml).unwrap();
960        let errors = validate_resource(&rd);
961        assert!(errors.iter().any(|e| e
962            .message
963            .contains("cannot be both transient and have a ref")));
964    }
965
966    #[test]
967    fn transient_field_rejects_unique() {
968        let yaml = r#"
969resource: users
970version: 1
971schema:
972  id:  { type: uuid, primary: true, generated: true }
973  bad: { type: string, transient: true, unique: true }
974endpoints:
975  create:
976    method: POST
977    path: /users
978    input: [bad]
979"#;
980        let rd = parse_resource(yaml).unwrap();
981        let errors = validate_resource(&rd);
982        assert!(errors
983            .iter()
984            .any(|e| e.message.contains("cannot be both transient and unique")));
985    }
986
987    #[test]
988    fn transient_field_rejects_default() {
989        let yaml = r#"
990resource: users
991version: 1
992schema:
993  id:  { type: uuid, primary: true, generated: true }
994  bad: { type: string, transient: true, default: "x" }
995endpoints:
996  create:
997    method: POST
998    path: /users
999    input: [bad]
1000"#;
1001        let rd = parse_resource(yaml).unwrap();
1002        let errors = validate_resource(&rd);
1003        assert!(errors.iter().any(|e| e
1004            .message
1005            .contains("cannot be both transient and have a default")));
1006    }
1007
1008    #[test]
1009    fn tenant_key_wrong_type() {
1010        let yaml = r#"
1011resource: projects
1012version: 1
1013tenant_key: org_name
1014schema:
1015  id: { type: uuid, primary: true, generated: true }
1016  org_name: { type: string, required: true }
1017"#;
1018        let rd = parse_resource(yaml).unwrap();
1019        let errors = validate_resource(&rd);
1020        assert!(errors.iter().any(|e| e
1021            .message
1022            .contains("tenant_key 'org_name' must reference a uuid field")));
1023    }
1024
1025    #[test]
1026    fn reject_after_controller_on_custom_endpoint() {
1027        let yaml = r#"
1028resource: agents
1029version: 1
1030schema:
1031  id: { type: uuid, primary: true, generated: true }
1032endpoints:
1033  regenerate_secret:
1034    method: POST
1035    path: /agents/:id/regenerate_secret
1036    auth: [admin]
1037    controller: { after: my_after }
1038"#;
1039        let rd = parse_resource(yaml).unwrap();
1040        let errors = validate_resource(&rd);
1041        assert!(
1042            errors
1043                .iter()
1044                .any(|e| e.message.contains("declares `controller: { after: ... }`")),
1045            "expected a CustomEndpointWithAfterController error, got: {errors:?}"
1046        );
1047    }
1048
1049    #[test]
1050    fn allow_before_controller_on_custom_endpoint() {
1051        let yaml = r#"
1052resource: agents
1053version: 1
1054schema:
1055  id: { type: uuid, primary: true, generated: true }
1056endpoints:
1057  regenerate_secret:
1058    method: POST
1059    path: /agents/:id/regenerate_secret
1060    auth: [admin]
1061    controller: { before: my_before }
1062"#;
1063        let rd = parse_resource(yaml).unwrap();
1064        let errors = validate_resource(&rd);
1065        assert!(
1066            errors.is_empty(),
1067            "before:-only controller on a custom endpoint should validate clean, got: {errors:?}"
1068        );
1069    }
1070
1071    #[test]
1072    fn allow_controller_on_crud_endpoints() {
1073        let yaml = r#"
1074resource: agents
1075version: 1
1076schema:
1077  id: { type: uuid, primary: true, generated: true }
1078  name: { type: string, required: true }
1079endpoints:
1080  create:
1081    method: POST
1082    path: /agents
1083    input: [name]
1084    controller: { before: my_before }
1085"#;
1086        let rd = parse_resource(yaml).unwrap();
1087        let errors = validate_resource(&rd);
1088        assert!(
1089            !errors.iter().any(|e| e
1090                .message
1091                .contains("declares `controller:` but is a custom endpoint")),
1092            "create endpoint with controller should NOT trip the rule, got: {errors:?}"
1093        );
1094    }
1095
1096    #[test]
1097    fn handler_on_convention_endpoint_rejected() {
1098        for action in &["list", "get", "create", "update", "delete"] {
1099            let yaml = format!(
1100                r#"
1101resource: items
1102version: 1
1103schema:
1104  id: {{ type: uuid, primary: true, generated: true }}
1105  name: {{ type: string, required: true }}
1106endpoints:
1107  {action}:
1108    handler: my_handler
1109"#
1110            );
1111            let rd = parse_resource(&yaml).unwrap();
1112            let errors = validate_resource(&rd);
1113            assert!(
1114                errors
1115                    .iter()
1116                    .any(|e| e.message.contains("`handler: my_handler`")
1117                        && e.message
1118                            .contains("only valid on custom (non-convention) endpoints")),
1119                "expected handler-on-convention error for action '{action}', got: {errors:?}"
1120            );
1121        }
1122    }
1123
1124    #[test]
1125    fn handler_on_custom_endpoint_allowed() {
1126        let yaml = r#"
1127resource: items
1128version: 1
1129schema:
1130  id: { type: uuid, primary: true, generated: true }
1131  name: { type: string, required: true }
1132endpoints:
1133  post_item:
1134    method: POST
1135    path: /items
1136    handler: create_item_custom
1137"#;
1138        let rd = parse_resource(yaml).unwrap();
1139        let errors = validate_resource(&rd);
1140        assert!(
1141            !errors
1142                .iter()
1143                .any(|e| e.message.contains("only valid on custom")),
1144            "custom endpoint with handler must not trip the rule, got: {errors:?}"
1145        );
1146    }
1147
1148    #[test]
1149    fn convention_endpoint_without_handler_allowed() {
1150        let yaml = r#"
1151resource: items
1152version: 1
1153schema:
1154  id: { type: uuid, primary: true, generated: true }
1155  name: { type: string, required: true }
1156endpoints:
1157  create:
1158    input: [name]
1159"#;
1160        let rd = parse_resource(yaml).unwrap();
1161        let errors = validate_resource(&rd);
1162        assert!(
1163            !errors
1164                .iter()
1165                .any(|e| e.message.contains("only valid on custom")),
1166            "convention endpoint without handler must not trip the rule, got: {errors:?}"
1167        );
1168    }
1169}