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            if let Some(controller) = &ep.controller {
199                // controller: is only valid on conventional CRUD endpoints.
200                if let Some(e) = validate_controller_only_on_crud(res, action, ep) {
201                    errors.push(e);
202                }
203
204                if let Some(before) = &controller.before {
205                    if before.is_empty() {
206                        errors.push(err(&format!(
207                            "resource '{res}': endpoint '{action}' has an empty controller.before name"
208                        )));
209                    }
210                    validate_controller_name(res, action, "before", before, &mut errors);
211                }
212                if let Some(after) = &controller.after {
213                    if after.is_empty() {
214                        errors.push(err(&format!(
215                            "resource '{res}': endpoint '{action}' has an empty controller.after name"
216                        )));
217                    }
218                    validate_controller_name(res, action, "after", after, &mut errors);
219                }
220            }
221
222            if let Some(events) = &ep.events {
223                for event in events {
224                    if event.is_empty() {
225                        errors.push(err(&format!(
226                            "resource '{res}': endpoint '{action}' has an empty event name"
227                        )));
228                    }
229                }
230            }
231
232            if let Some(jobs) = &ep.jobs {
233                for job in jobs {
234                    if job.is_empty() {
235                        errors.push(err(&format!(
236                            "resource '{res}': endpoint '{action}' has an empty job name"
237                        )));
238                    }
239                }
240            }
241
242            // Input fields must exist in schema
243            if let Some(input) = &ep.input {
244                for field_name in input {
245                    if !rd.schema.contains_key(field_name) {
246                        errors.push(err(&format!(
247                            "resource '{res}': endpoint '{action}' input field '{field_name}' not found in schema"
248                        )));
249                    }
250                }
251            }
252
253            // Filter fields must exist in schema
254            if let Some(filters) = &ep.filters {
255                for field_name in filters {
256                    if !rd.schema.contains_key(field_name) {
257                        errors.push(err(&format!(
258                            "resource '{res}': endpoint '{action}' filter field '{field_name}' not found in schema"
259                        )));
260                    }
261                }
262            }
263
264            // Search fields must exist in schema
265            if let Some(search) = &ep.search {
266                for field_name in search {
267                    if !rd.schema.contains_key(field_name) {
268                        errors.push(err(&format!(
269                            "resource '{res}': endpoint '{action}' search field '{field_name}' not found in schema"
270                        )));
271                    }
272                }
273            }
274
275            // Sort fields must exist in schema
276            if let Some(sort) = &ep.sort {
277                for field_name in sort {
278                    if !rd.schema.contains_key(field_name) {
279                        errors.push(err(&format!(
280                            "resource '{res}': endpoint '{action}' sort field '{field_name}' not found in schema"
281                        )));
282                    }
283                }
284            }
285
286            // soft_delete requires deleted_at field in schema
287            if ep.soft_delete && !rd.schema.contains_key("deleted_at") {
288                errors.push(err(&format!(
289                    "resource '{res}': endpoint '{action}' has soft_delete but schema has no 'deleted_at' field"
290                )));
291            }
292
293            if let Some(upload) = &ep.upload {
294                match ep.method.as_ref() {
295                    Some(HttpMethod::Post | HttpMethod::Patch | HttpMethod::Put) => {}
296                    Some(_) => errors.push(err(&format!(
297                        "resource '{res}': endpoint '{action}' uses upload but method must be POST, PATCH, or PUT"
298                    ))),
299                    None => {} // already reported above
300                }
301
302                match rd.schema.get(&upload.field) {
303                    Some(field) if field.field_type == FieldType::File => {}
304                    Some(_) => errors.push(err(&format!(
305                        "resource '{res}': endpoint '{action}' upload field '{}' must be type file",
306                        upload.field
307                    ))),
308                    None => errors.push(err(&format!(
309                        "resource '{res}': endpoint '{action}' upload field '{}' not found in schema",
310                        upload.field
311                    ))),
312                }
313
314                if !matches!(upload.storage.as_str(), "local" | "s3" | "gcs" | "azure") {
315                    errors.push(err(&format!(
316                        "resource '{res}': endpoint '{action}' upload storage '{}' is invalid",
317                        upload.storage
318                    )));
319                }
320
321                if !ep
322                    .input
323                    .as_ref()
324                    .is_some_and(|fields| fields.iter().any(|field| field == &upload.field))
325                {
326                    errors.push(err(&format!(
327                        "resource '{res}': endpoint '{action}' upload field '{}' must appear in input",
328                        upload.field
329                    )));
330                }
331
332                for (suffix, expected_types) in [
333                    ("filename", &[FieldType::String][..]),
334                    ("mime_type", &[FieldType::String][..]),
335                    ("size", &[FieldType::Integer, FieldType::Bigint][..]),
336                ] {
337                    let companion = format!("{}_{}", upload.field, suffix);
338                    if let Some(field) = rd.schema.get(&companion) {
339                        if !expected_types.contains(&field.field_type) {
340                            let expected = expected_types
341                                .iter()
342                                .map(ToString::to_string)
343                                .collect::<Vec<_>>()
344                                .join(" or ");
345                            errors.push(err(&format!(
346                                "resource '{res}': companion upload field '{companion}' must be type {expected}"
347                            )));
348                        }
349                    }
350                }
351            }
352        }
353    }
354
355    // Relation validation
356    if let Some(relations) = &rd.relations {
357        for (name, rel) in relations {
358            use shaperail_core::RelationType;
359
360            // belongs_to should have key
361            if rel.relation_type == RelationType::BelongsTo && rel.key.is_none() {
362                errors.push(err(&format!(
363                    "resource '{res}': relation '{name}' is belongs_to but has no key"
364                )));
365            }
366
367            // has_many/has_one should have foreign_key
368            if matches!(
369                rel.relation_type,
370                RelationType::HasMany | RelationType::HasOne
371            ) && rel.foreign_key.is_none()
372            {
373                errors.push(err(&format!(
374                    "resource '{res}': relation '{name}' is {} but has no foreign_key",
375                    rel.relation_type
376                )));
377            }
378
379            // belongs_to key must exist in schema
380            if let Some(key) = &rel.key {
381                if !rd.schema.contains_key(key) {
382                    errors.push(err(&format!(
383                        "resource '{res}': relation '{name}' key '{key}' not found in schema"
384                    )));
385                }
386            }
387        }
388    }
389
390    // Index validation
391    if let Some(indexes) = &rd.indexes {
392        for (i, idx) in indexes.iter().enumerate() {
393            if idx.fields.is_empty() {
394                errors.push(err(&format!("resource '{res}': index {i} has no fields")));
395            }
396            for field_name in &idx.fields {
397                if !rd.schema.contains_key(field_name) {
398                    errors.push(err(&format!(
399                        "resource '{res}': index {i} references field '{field_name}' not in schema"
400                    )));
401                }
402            }
403            if let Some(order) = &idx.order {
404                if order != "asc" && order != "desc" {
405                    errors.push(err(&format!(
406                        "resource '{res}': index {i} has invalid order '{order}', must be 'asc' or 'desc'"
407                    )));
408                }
409            }
410        }
411    }
412
413    errors
414}
415
416/// Rejects `controller:` declarations on non-CRUD (custom) endpoints.
417///
418/// `controller:` is dispatched exclusively by the CRUD pipeline. Custom endpoints
419/// use `handler:` and own their own request/response flow — declaring a controller
420/// on them was previously a silent no-op. Now it is a hard validation error.
421fn validate_controller_only_on_crud(
422    resource: &str,
423    action: &str,
424    endpoint: &shaperail_core::EndpointSpec,
425) -> Option<ValidationError> {
426    const CRUD_ACTIONS: &[&str] = &[
427        "list",
428        "get",
429        "create",
430        "update",
431        "delete",
432        "bulk_create",
433        "bulk_delete",
434    ];
435    if !CRUD_ACTIONS.contains(&action) && endpoint.controller.is_some() {
436        return Some(err(&format!(
437            "resource '{resource}': endpoint '{action}' declares `controller:` but is a custom \
438             endpoint (dispatched via `handler:`). `controller` is only valid on conventional CRUD \
439             endpoints (list / get / create / update / delete / bulk_create / bulk_delete). For \
440             shared logic on custom handlers, use the typed `Subject` and tenant helpers from \
441             `shaperail_runtime::auth` directly inside your handler."
442        )));
443    }
444    None
445}
446
447/// Validates a controller name — either a Rust function name or a `wasm:` prefixed path.
448fn validate_controller_name(
449    res: &str,
450    action: &str,
451    phase: &str,
452    name: &str,
453    errors: &mut Vec<ValidationError>,
454) {
455    if let Some(wasm_path) = name.strip_prefix(WASM_HOOK_PREFIX) {
456        if wasm_path.is_empty() {
457            errors.push(err(&format!(
458                "resource '{res}': endpoint '{action}' controller.{phase} has 'wasm:' prefix but no path"
459            )));
460        } else if !wasm_path.ends_with(".wasm") {
461            errors.push(err(&format!(
462                "resource '{res}': endpoint '{action}' controller.{phase} WASM path must end with '.wasm', got '{wasm_path}'"
463            )));
464        }
465    }
466}
467
468fn err(message: &str) -> ValidationError {
469    ValidationError {
470        message: message.to_string(),
471    }
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477    use crate::parser::parse_resource;
478
479    #[test]
480    fn valid_resource_passes() {
481        let yaml = include_str!("../../resources/users.yaml");
482        let rd = parse_resource(yaml).unwrap();
483        let errors = validate_resource(&rd);
484        assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
485    }
486
487    #[test]
488    fn enum_without_values() {
489        let yaml = r#"
490resource: items
491version: 1
492schema:
493  id: { type: uuid, primary: true, generated: true }
494  status: { type: enum, required: true }
495"#;
496        let rd = parse_resource(yaml).unwrap();
497        let errors = validate_resource(&rd);
498        assert!(errors
499            .iter()
500            .any(|e| e.message.contains("type enum but has no values")));
501    }
502
503    #[test]
504    fn ref_field_not_uuid() {
505        let yaml = r#"
506resource: items
507version: 1
508schema:
509  id: { type: uuid, primary: true, generated: true }
510  org_id: { type: string, ref: organizations.id }
511"#;
512        let rd = parse_resource(yaml).unwrap();
513        let errors = validate_resource(&rd);
514        assert!(errors
515            .iter()
516            .any(|e| e.message.contains("has ref but is not type uuid")));
517    }
518
519    #[test]
520    fn missing_primary_key() {
521        let yaml = r#"
522resource: items
523version: 1
524schema:
525  name: { type: string, required: true }
526"#;
527        let rd = parse_resource(yaml).unwrap();
528        let errors = validate_resource(&rd);
529        assert!(errors
530            .iter()
531            .any(|e| e.message.contains("must have a primary key")));
532    }
533
534    #[test]
535    fn soft_delete_without_deleted_at() {
536        let yaml = r#"
537resource: items
538version: 1
539schema:
540  id: { type: uuid, primary: true, generated: true }
541  name: { type: string, required: true }
542endpoints:
543  delete:
544    method: DELETE
545    path: /items/:id
546    auth: [admin]
547    soft_delete: true
548"#;
549        let rd = parse_resource(yaml).unwrap();
550        let errors = validate_resource(&rd);
551        assert!(errors.iter().any(|e| e
552            .message
553            .contains("soft_delete but schema has no 'deleted_at'")));
554    }
555
556    #[test]
557    fn input_field_not_in_schema() {
558        let yaml = r#"
559resource: items
560version: 1
561schema:
562  id: { type: uuid, primary: true, generated: true }
563  name: { type: string, required: true }
564endpoints:
565  create:
566    method: POST
567    path: /items
568    auth: [admin]
569    input: [name, nonexistent]
570"#;
571        let rd = parse_resource(yaml).unwrap();
572        let errors = validate_resource(&rd);
573        assert!(errors.iter().any(|e| e
574            .message
575            .contains("input field 'nonexistent' not found in schema")));
576    }
577
578    #[test]
579    fn belongs_to_without_key() {
580        let yaml = r#"
581resource: items
582version: 1
583schema:
584  id: { type: uuid, primary: true, generated: true }
585relations:
586  org: { resource: organizations, type: belongs_to }
587"#;
588        let rd = parse_resource(yaml).unwrap();
589        let errors = validate_resource(&rd);
590        assert!(errors
591            .iter()
592            .any(|e| e.message.contains("belongs_to but has no key")));
593    }
594
595    #[test]
596    fn has_many_without_foreign_key() {
597        let yaml = r#"
598resource: items
599version: 1
600schema:
601  id: { type: uuid, primary: true, generated: true }
602relations:
603  orders: { resource: orders, type: has_many }
604"#;
605        let rd = parse_resource(yaml).unwrap();
606        let errors = validate_resource(&rd);
607        assert!(errors
608            .iter()
609            .any(|e| e.message.contains("has_many but has no foreign_key")));
610    }
611
612    #[test]
613    fn index_references_missing_field() {
614        let yaml = r#"
615resource: items
616version: 1
617schema:
618  id: { type: uuid, primary: true, generated: true }
619indexes:
620  - fields: [missing_field]
621"#;
622        let rd = parse_resource(yaml).unwrap();
623        let errors = validate_resource(&rd);
624        assert!(errors.iter().any(|e| e
625            .message
626            .contains("references field 'missing_field' not in schema")));
627    }
628
629    #[test]
630    fn error_message_format() {
631        let yaml = r#"
632resource: users
633version: 1
634schema:
635  id: { type: uuid, primary: true, generated: true }
636  role: { type: enum }
637"#;
638        let rd = parse_resource(yaml).unwrap();
639        let errors = validate_resource(&rd);
640        assert_eq!(
641            errors[0].message,
642            "resource 'users': field 'role' is type enum but has no values"
643        );
644    }
645
646    #[test]
647    fn wasm_controller_valid_path() {
648        let yaml = r#"
649resource: items
650version: 1
651schema:
652  id: { type: uuid, primary: true, generated: true }
653  name: { type: string, required: true }
654endpoints:
655  create:
656    method: POST
657    path: /items
658    input: [name]
659    controller: { before: "wasm:./plugins/my_validator.wasm" }
660"#;
661        let rd = parse_resource(yaml).unwrap();
662        let errors = validate_resource(&rd);
663        assert!(
664            errors.is_empty(),
665            "Expected no errors for valid WASM controller, got: {errors:?}"
666        );
667    }
668
669    #[test]
670    fn wasm_controller_missing_extension() {
671        let yaml = r#"
672resource: items
673version: 1
674schema:
675  id: { type: uuid, primary: true, generated: true }
676  name: { type: string, required: true }
677endpoints:
678  create:
679    method: POST
680    path: /items
681    input: [name]
682    controller: { before: "wasm:./plugins/my_validator" }
683"#;
684        let rd = parse_resource(yaml).unwrap();
685        let errors = validate_resource(&rd);
686        assert!(errors
687            .iter()
688            .any(|e| e.message.contains("WASM path must end with '.wasm'")));
689    }
690
691    #[test]
692    fn wasm_controller_empty_path() {
693        let yaml = r#"
694resource: items
695version: 1
696schema:
697  id: { type: uuid, primary: true, generated: true }
698  name: { type: string, required: true }
699endpoints:
700  create:
701    method: POST
702    path: /items
703    input: [name]
704    controller: { before: "wasm:" }
705"#;
706        let rd = parse_resource(yaml).unwrap();
707        let errors = validate_resource(&rd);
708        assert!(errors
709            .iter()
710            .any(|e| e.message.contains("'wasm:' prefix but no path")));
711    }
712
713    #[test]
714    fn upload_endpoint_valid_when_file_field_declared() {
715        let yaml = r#"
716resource: assets
717version: 1
718schema:
719  id: { type: uuid, primary: true, generated: true }
720  file: { type: file, required: true }
721  file_filename: { type: string }
722  file_mime_type: { type: string }
723  file_size: { type: bigint }
724  updated_at: { type: timestamp, generated: true }
725endpoints:
726  upload:
727    method: POST
728    path: /assets/upload
729    input: [file]
730    upload:
731      field: file
732      storage: local
733      max_size: 5mb
734"#;
735        let rd = parse_resource(yaml).unwrap();
736        let errors = validate_resource(&rd);
737        assert!(
738            errors.is_empty(),
739            "Expected valid upload resource, got {errors:?}"
740        );
741    }
742
743    #[test]
744    fn upload_endpoint_requires_file_field() {
745        let yaml = r#"
746resource: assets
747version: 1
748schema:
749  id: { type: uuid, primary: true, generated: true }
750  file_path: { type: string, required: true }
751endpoints:
752  upload:
753    method: POST
754    path: /assets/upload
755    input: [file_path]
756    upload:
757      field: file_path
758      storage: local
759      max_size: 5mb
760"#;
761        let rd = parse_resource(yaml).unwrap();
762        let errors = validate_resource(&rd);
763        assert!(errors.iter().any(|e| e
764            .message
765            .contains("upload field 'file_path' must be type file")));
766    }
767
768    #[test]
769    fn tenant_key_valid_uuid_field() {
770        let yaml = r#"
771resource: projects
772version: 1
773tenant_key: org_id
774schema:
775  id: { type: uuid, primary: true, generated: true }
776  org_id: { type: uuid, ref: organizations.id, required: true }
777  name: { type: string, required: true }
778"#;
779        let rd = parse_resource(yaml).unwrap();
780        let errors = validate_resource(&rd);
781        assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
782    }
783
784    #[test]
785    fn tenant_key_missing_field() {
786        let yaml = r#"
787resource: projects
788version: 1
789tenant_key: org_id
790schema:
791  id: { type: uuid, primary: true, generated: true }
792  name: { type: string, required: true }
793"#;
794        let rd = parse_resource(yaml).unwrap();
795        let errors = validate_resource(&rd);
796        assert!(errors.iter().any(|e| e
797            .message
798            .contains("tenant_key 'org_id' not found in schema")));
799    }
800
801    #[test]
802    fn transient_field_valid() {
803        let yaml = r#"
804resource: users
805version: 1
806schema:
807  id:            { type: uuid, primary: true, generated: true }
808  password:      { type: string, transient: true, min: 12, required: true }
809  password_hash: { type: string, required: true }
810endpoints:
811  create:
812    method: POST
813    path: /users
814    input: [password]
815    controller: { before: hash_password }
816"#;
817        let rd = parse_resource(yaml).unwrap();
818        let errors = validate_resource(&rd);
819        assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
820    }
821
822    #[test]
823    fn transient_field_dead_when_not_in_input() {
824        let yaml = r#"
825resource: users
826version: 1
827schema:
828  id:       { type: uuid, primary: true, generated: true }
829  password: { type: string, transient: true, min: 12 }
830endpoints:
831  create:
832    method: POST
833    path: /users
834    input: []
835"#;
836        let rd = parse_resource(yaml).unwrap();
837        let errors = validate_resource(&rd);
838        assert!(errors.iter().any(|e| e
839            .message
840            .contains("transient field 'password' is not declared in any endpoint's input")));
841    }
842
843    #[test]
844    fn transient_field_rejects_primary() {
845        let yaml = r#"
846resource: users
847version: 1
848schema:
849  bad: { type: uuid, transient: true, primary: true }
850"#;
851        let rd = parse_resource(yaml).unwrap();
852        let errors = validate_resource(&rd);
853        assert!(errors
854            .iter()
855            .any(|e| e.message.contains("cannot be both transient and primary")));
856    }
857
858    #[test]
859    fn transient_field_rejects_generated() {
860        let yaml = r#"
861resource: users
862version: 1
863schema:
864  id:  { type: uuid, primary: true, generated: true }
865  bad: { type: timestamp, transient: true, generated: true }
866endpoints:
867  create:
868    method: POST
869    path: /users
870    input: [bad]
871"#;
872        let rd = parse_resource(yaml).unwrap();
873        let errors = validate_resource(&rd);
874        assert!(errors
875            .iter()
876            .any(|e| e.message.contains("cannot be both transient and generated")));
877    }
878
879    #[test]
880    fn transient_field_rejects_ref() {
881        let yaml = r#"
882resource: users
883version: 1
884schema:
885  id:  { type: uuid, primary: true, generated: true }
886  bad: { type: uuid, transient: true, ref: orgs.id }
887endpoints:
888  create:
889    method: POST
890    path: /users
891    input: [bad]
892"#;
893        let rd = parse_resource(yaml).unwrap();
894        let errors = validate_resource(&rd);
895        assert!(errors.iter().any(|e| e
896            .message
897            .contains("cannot be both transient and have a ref")));
898    }
899
900    #[test]
901    fn transient_field_rejects_unique() {
902        let yaml = r#"
903resource: users
904version: 1
905schema:
906  id:  { type: uuid, primary: true, generated: true }
907  bad: { type: string, transient: true, unique: true }
908endpoints:
909  create:
910    method: POST
911    path: /users
912    input: [bad]
913"#;
914        let rd = parse_resource(yaml).unwrap();
915        let errors = validate_resource(&rd);
916        assert!(errors
917            .iter()
918            .any(|e| e.message.contains("cannot be both transient and unique")));
919    }
920
921    #[test]
922    fn transient_field_rejects_default() {
923        let yaml = r#"
924resource: users
925version: 1
926schema:
927  id:  { type: uuid, primary: true, generated: true }
928  bad: { type: string, transient: true, default: "x" }
929endpoints:
930  create:
931    method: POST
932    path: /users
933    input: [bad]
934"#;
935        let rd = parse_resource(yaml).unwrap();
936        let errors = validate_resource(&rd);
937        assert!(errors.iter().any(|e| e
938            .message
939            .contains("cannot be both transient and have a default")));
940    }
941
942    #[test]
943    fn tenant_key_wrong_type() {
944        let yaml = r#"
945resource: projects
946version: 1
947tenant_key: org_name
948schema:
949  id: { type: uuid, primary: true, generated: true }
950  org_name: { type: string, required: true }
951"#;
952        let rd = parse_resource(yaml).unwrap();
953        let errors = validate_resource(&rd);
954        assert!(errors.iter().any(|e| e
955            .message
956            .contains("tenant_key 'org_name' must reference a uuid field")));
957    }
958
959    #[test]
960    fn reject_controller_on_custom_endpoint() {
961        let yaml = r#"
962resource: agents
963version: 1
964schema:
965  id: { type: uuid, primary: true, generated: true }
966endpoints:
967  regenerate_secret:
968    method: POST
969    path: /agents/:id/regenerate_secret
970    auth: [admin]
971    controller: { before: my_before }
972"#;
973        let rd = parse_resource(yaml).unwrap();
974        let errors = validate_resource(&rd);
975        assert!(
976            errors.iter().any(|e| e
977                .message
978                .contains("declares `controller:` but is a custom endpoint")),
979            "expected a CustomEndpointWithController error, got: {errors:?}"
980        );
981    }
982
983    #[test]
984    fn allow_controller_on_crud_endpoints() {
985        let yaml = r#"
986resource: agents
987version: 1
988schema:
989  id: { type: uuid, primary: true, generated: true }
990  name: { type: string, required: true }
991endpoints:
992  create:
993    method: POST
994    path: /agents
995    input: [name]
996    controller: { before: my_before }
997"#;
998        let rd = parse_resource(yaml).unwrap();
999        let errors = validate_resource(&rd);
1000        assert!(
1001            !errors.iter().any(|e| e
1002                .message
1003                .contains("declares `controller:` but is a custom endpoint")),
1004            "create endpoint with controller should NOT trip the rule, got: {errors:?}"
1005        );
1006    }
1007}