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: { after: ... }` declarations on non-CRUD (custom) endpoints.
417///
418/// `before:` controllers are now permitted on custom endpoints — the runtime builds
419/// a `Context` with auto-populated `tenant_id`, dispatches the before-hook, and
420/// stashes the resulting `Context` into `req.extensions_mut()` so the custom handler
421/// can read it.
422///
423/// `after:` controllers remain rejected on custom endpoints because the custom handler
424/// owns the response shape — there is no `data:` envelope for the runtime to merge
425/// `ctx.response_extras` into, and no consistent hook point after the handler returns.
426fn validate_controller_only_on_crud(
427    resource: &str,
428    action: &str,
429    endpoint: &shaperail_core::EndpointSpec,
430) -> Option<ValidationError> {
431    const CRUD_ACTIONS: &[&str] = &[
432        "list",
433        "get",
434        "create",
435        "update",
436        "delete",
437        "bulk_create",
438        "bulk_delete",
439    ];
440    if CRUD_ACTIONS.contains(&action) {
441        return None;
442    }
443    // Custom endpoint: allow before-only controller, reject after-controller.
444    let has_after = endpoint
445        .controller
446        .as_ref()
447        .and_then(|c| c.after.as_deref())
448        .is_some();
449    if has_after {
450        return Some(err(&format!(
451            "resource '{resource}': endpoint '{action}' declares `controller: {{ after: ... }}`, \
452             but `after:` controllers are only valid on conventional CRUD endpoints \
453             (list / get / create / update / delete / bulk_create / bulk_delete).\n\
454             \n\
455             Custom endpoints generate their own response via `handler:`, so the runtime \
456             has no place to merge `ctx.response_extras` or pass through after-hook \
457             mutations. Use a `before:` controller for shared setup (auth augmentation, \
458             tenant scoping, request validation), and put response-shaping logic inside \
459             the handler itself."
460        )));
461    }
462    None
463}
464
465/// Validates a controller name — either a Rust function name or a `wasm:` prefixed path.
466fn validate_controller_name(
467    res: &str,
468    action: &str,
469    phase: &str,
470    name: &str,
471    errors: &mut Vec<ValidationError>,
472) {
473    if let Some(wasm_path) = name.strip_prefix(WASM_HOOK_PREFIX) {
474        if wasm_path.is_empty() {
475            errors.push(err(&format!(
476                "resource '{res}': endpoint '{action}' controller.{phase} has 'wasm:' prefix but no path"
477            )));
478        } else if !wasm_path.ends_with(".wasm") {
479            errors.push(err(&format!(
480                "resource '{res}': endpoint '{action}' controller.{phase} WASM path must end with '.wasm', got '{wasm_path}'"
481            )));
482        }
483    }
484}
485
486fn err(message: &str) -> ValidationError {
487    ValidationError {
488        message: message.to_string(),
489    }
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495    use crate::parser::parse_resource;
496
497    #[test]
498    fn valid_resource_passes() {
499        let yaml = include_str!("../../resources/users.yaml");
500        let rd = parse_resource(yaml).unwrap();
501        let errors = validate_resource(&rd);
502        assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
503    }
504
505    #[test]
506    fn enum_without_values() {
507        let yaml = r#"
508resource: items
509version: 1
510schema:
511  id: { type: uuid, primary: true, generated: true }
512  status: { type: enum, required: true }
513"#;
514        let rd = parse_resource(yaml).unwrap();
515        let errors = validate_resource(&rd);
516        assert!(errors
517            .iter()
518            .any(|e| e.message.contains("type enum but has no values")));
519    }
520
521    #[test]
522    fn ref_field_not_uuid() {
523        let yaml = r#"
524resource: items
525version: 1
526schema:
527  id: { type: uuid, primary: true, generated: true }
528  org_id: { type: string, ref: organizations.id }
529"#;
530        let rd = parse_resource(yaml).unwrap();
531        let errors = validate_resource(&rd);
532        assert!(errors
533            .iter()
534            .any(|e| e.message.contains("has ref but is not type uuid")));
535    }
536
537    #[test]
538    fn missing_primary_key() {
539        let yaml = r#"
540resource: items
541version: 1
542schema:
543  name: { type: string, required: true }
544"#;
545        let rd = parse_resource(yaml).unwrap();
546        let errors = validate_resource(&rd);
547        assert!(errors
548            .iter()
549            .any(|e| e.message.contains("must have a primary key")));
550    }
551
552    #[test]
553    fn soft_delete_without_deleted_at() {
554        let yaml = r#"
555resource: items
556version: 1
557schema:
558  id: { type: uuid, primary: true, generated: true }
559  name: { type: string, required: true }
560endpoints:
561  delete:
562    method: DELETE
563    path: /items/:id
564    auth: [admin]
565    soft_delete: true
566"#;
567        let rd = parse_resource(yaml).unwrap();
568        let errors = validate_resource(&rd);
569        assert!(errors.iter().any(|e| e
570            .message
571            .contains("soft_delete but schema has no 'deleted_at'")));
572    }
573
574    #[test]
575    fn input_field_not_in_schema() {
576        let yaml = r#"
577resource: items
578version: 1
579schema:
580  id: { type: uuid, primary: true, generated: true }
581  name: { type: string, required: true }
582endpoints:
583  create:
584    method: POST
585    path: /items
586    auth: [admin]
587    input: [name, nonexistent]
588"#;
589        let rd = parse_resource(yaml).unwrap();
590        let errors = validate_resource(&rd);
591        assert!(errors.iter().any(|e| e
592            .message
593            .contains("input field 'nonexistent' not found in schema")));
594    }
595
596    #[test]
597    fn belongs_to_without_key() {
598        let yaml = r#"
599resource: items
600version: 1
601schema:
602  id: { type: uuid, primary: true, generated: true }
603relations:
604  org: { resource: organizations, type: belongs_to }
605"#;
606        let rd = parse_resource(yaml).unwrap();
607        let errors = validate_resource(&rd);
608        assert!(errors
609            .iter()
610            .any(|e| e.message.contains("belongs_to but has no key")));
611    }
612
613    #[test]
614    fn has_many_without_foreign_key() {
615        let yaml = r#"
616resource: items
617version: 1
618schema:
619  id: { type: uuid, primary: true, generated: true }
620relations:
621  orders: { resource: orders, type: has_many }
622"#;
623        let rd = parse_resource(yaml).unwrap();
624        let errors = validate_resource(&rd);
625        assert!(errors
626            .iter()
627            .any(|e| e.message.contains("has_many but has no foreign_key")));
628    }
629
630    #[test]
631    fn index_references_missing_field() {
632        let yaml = r#"
633resource: items
634version: 1
635schema:
636  id: { type: uuid, primary: true, generated: true }
637indexes:
638  - fields: [missing_field]
639"#;
640        let rd = parse_resource(yaml).unwrap();
641        let errors = validate_resource(&rd);
642        assert!(errors.iter().any(|e| e
643            .message
644            .contains("references field 'missing_field' not in schema")));
645    }
646
647    #[test]
648    fn error_message_format() {
649        let yaml = r#"
650resource: users
651version: 1
652schema:
653  id: { type: uuid, primary: true, generated: true }
654  role: { type: enum }
655"#;
656        let rd = parse_resource(yaml).unwrap();
657        let errors = validate_resource(&rd);
658        assert_eq!(
659            errors[0].message,
660            "resource 'users': field 'role' is type enum but has no values"
661        );
662    }
663
664    #[test]
665    fn wasm_controller_valid_path() {
666        let yaml = r#"
667resource: items
668version: 1
669schema:
670  id: { type: uuid, primary: true, generated: true }
671  name: { type: string, required: true }
672endpoints:
673  create:
674    method: POST
675    path: /items
676    input: [name]
677    controller: { before: "wasm:./plugins/my_validator.wasm" }
678"#;
679        let rd = parse_resource(yaml).unwrap();
680        let errors = validate_resource(&rd);
681        assert!(
682            errors.is_empty(),
683            "Expected no errors for valid WASM controller, got: {errors:?}"
684        );
685    }
686
687    #[test]
688    fn wasm_controller_missing_extension() {
689        let yaml = r#"
690resource: items
691version: 1
692schema:
693  id: { type: uuid, primary: true, generated: true }
694  name: { type: string, required: true }
695endpoints:
696  create:
697    method: POST
698    path: /items
699    input: [name]
700    controller: { before: "wasm:./plugins/my_validator" }
701"#;
702        let rd = parse_resource(yaml).unwrap();
703        let errors = validate_resource(&rd);
704        assert!(errors
705            .iter()
706            .any(|e| e.message.contains("WASM path must end with '.wasm'")));
707    }
708
709    #[test]
710    fn wasm_controller_empty_path() {
711        let yaml = r#"
712resource: items
713version: 1
714schema:
715  id: { type: uuid, primary: true, generated: true }
716  name: { type: string, required: true }
717endpoints:
718  create:
719    method: POST
720    path: /items
721    input: [name]
722    controller: { before: "wasm:" }
723"#;
724        let rd = parse_resource(yaml).unwrap();
725        let errors = validate_resource(&rd);
726        assert!(errors
727            .iter()
728            .any(|e| e.message.contains("'wasm:' prefix but no path")));
729    }
730
731    #[test]
732    fn upload_endpoint_valid_when_file_field_declared() {
733        let yaml = r#"
734resource: assets
735version: 1
736schema:
737  id: { type: uuid, primary: true, generated: true }
738  file: { type: file, required: true }
739  file_filename: { type: string }
740  file_mime_type: { type: string }
741  file_size: { type: bigint }
742  updated_at: { type: timestamp, generated: true }
743endpoints:
744  upload:
745    method: POST
746    path: /assets/upload
747    input: [file]
748    upload:
749      field: file
750      storage: local
751      max_size: 5mb
752"#;
753        let rd = parse_resource(yaml).unwrap();
754        let errors = validate_resource(&rd);
755        assert!(
756            errors.is_empty(),
757            "Expected valid upload resource, got {errors:?}"
758        );
759    }
760
761    #[test]
762    fn upload_endpoint_requires_file_field() {
763        let yaml = r#"
764resource: assets
765version: 1
766schema:
767  id: { type: uuid, primary: true, generated: true }
768  file_path: { type: string, required: true }
769endpoints:
770  upload:
771    method: POST
772    path: /assets/upload
773    input: [file_path]
774    upload:
775      field: file_path
776      storage: local
777      max_size: 5mb
778"#;
779        let rd = parse_resource(yaml).unwrap();
780        let errors = validate_resource(&rd);
781        assert!(errors.iter().any(|e| e
782            .message
783            .contains("upload field 'file_path' must be type file")));
784    }
785
786    #[test]
787    fn tenant_key_valid_uuid_field() {
788        let yaml = r#"
789resource: projects
790version: 1
791tenant_key: org_id
792schema:
793  id: { type: uuid, primary: true, generated: true }
794  org_id: { type: uuid, ref: organizations.id, required: true }
795  name: { type: string, required: true }
796"#;
797        let rd = parse_resource(yaml).unwrap();
798        let errors = validate_resource(&rd);
799        assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
800    }
801
802    #[test]
803    fn tenant_key_missing_field() {
804        let yaml = r#"
805resource: projects
806version: 1
807tenant_key: org_id
808schema:
809  id: { type: uuid, primary: true, generated: true }
810  name: { type: string, required: true }
811"#;
812        let rd = parse_resource(yaml).unwrap();
813        let errors = validate_resource(&rd);
814        assert!(errors.iter().any(|e| e
815            .message
816            .contains("tenant_key 'org_id' not found in schema")));
817    }
818
819    #[test]
820    fn transient_field_valid() {
821        let yaml = r#"
822resource: users
823version: 1
824schema:
825  id:            { type: uuid, primary: true, generated: true }
826  password:      { type: string, transient: true, min: 12, required: true }
827  password_hash: { type: string, required: true }
828endpoints:
829  create:
830    method: POST
831    path: /users
832    input: [password]
833    controller: { before: hash_password }
834"#;
835        let rd = parse_resource(yaml).unwrap();
836        let errors = validate_resource(&rd);
837        assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
838    }
839
840    #[test]
841    fn transient_field_dead_when_not_in_input() {
842        let yaml = r#"
843resource: users
844version: 1
845schema:
846  id:       { type: uuid, primary: true, generated: true }
847  password: { type: string, transient: true, min: 12 }
848endpoints:
849  create:
850    method: POST
851    path: /users
852    input: []
853"#;
854        let rd = parse_resource(yaml).unwrap();
855        let errors = validate_resource(&rd);
856        assert!(errors.iter().any(|e| e
857            .message
858            .contains("transient field 'password' is not declared in any endpoint's input")));
859    }
860
861    #[test]
862    fn transient_field_rejects_primary() {
863        let yaml = r#"
864resource: users
865version: 1
866schema:
867  bad: { type: uuid, transient: true, primary: true }
868"#;
869        let rd = parse_resource(yaml).unwrap();
870        let errors = validate_resource(&rd);
871        assert!(errors
872            .iter()
873            .any(|e| e.message.contains("cannot be both transient and primary")));
874    }
875
876    #[test]
877    fn transient_field_rejects_generated() {
878        let yaml = r#"
879resource: users
880version: 1
881schema:
882  id:  { type: uuid, primary: true, generated: true }
883  bad: { type: timestamp, transient: true, generated: true }
884endpoints:
885  create:
886    method: POST
887    path: /users
888    input: [bad]
889"#;
890        let rd = parse_resource(yaml).unwrap();
891        let errors = validate_resource(&rd);
892        assert!(errors
893            .iter()
894            .any(|e| e.message.contains("cannot be both transient and generated")));
895    }
896
897    #[test]
898    fn transient_field_rejects_ref() {
899        let yaml = r#"
900resource: users
901version: 1
902schema:
903  id:  { type: uuid, primary: true, generated: true }
904  bad: { type: uuid, transient: true, ref: orgs.id }
905endpoints:
906  create:
907    method: POST
908    path: /users
909    input: [bad]
910"#;
911        let rd = parse_resource(yaml).unwrap();
912        let errors = validate_resource(&rd);
913        assert!(errors.iter().any(|e| e
914            .message
915            .contains("cannot be both transient and have a ref")));
916    }
917
918    #[test]
919    fn transient_field_rejects_unique() {
920        let yaml = r#"
921resource: users
922version: 1
923schema:
924  id:  { type: uuid, primary: true, generated: true }
925  bad: { type: string, transient: true, unique: true }
926endpoints:
927  create:
928    method: POST
929    path: /users
930    input: [bad]
931"#;
932        let rd = parse_resource(yaml).unwrap();
933        let errors = validate_resource(&rd);
934        assert!(errors
935            .iter()
936            .any(|e| e.message.contains("cannot be both transient and unique")));
937    }
938
939    #[test]
940    fn transient_field_rejects_default() {
941        let yaml = r#"
942resource: users
943version: 1
944schema:
945  id:  { type: uuid, primary: true, generated: true }
946  bad: { type: string, transient: true, default: "x" }
947endpoints:
948  create:
949    method: POST
950    path: /users
951    input: [bad]
952"#;
953        let rd = parse_resource(yaml).unwrap();
954        let errors = validate_resource(&rd);
955        assert!(errors.iter().any(|e| e
956            .message
957            .contains("cannot be both transient and have a default")));
958    }
959
960    #[test]
961    fn tenant_key_wrong_type() {
962        let yaml = r#"
963resource: projects
964version: 1
965tenant_key: org_name
966schema:
967  id: { type: uuid, primary: true, generated: true }
968  org_name: { type: string, required: true }
969"#;
970        let rd = parse_resource(yaml).unwrap();
971        let errors = validate_resource(&rd);
972        assert!(errors.iter().any(|e| e
973            .message
974            .contains("tenant_key 'org_name' must reference a uuid field")));
975    }
976
977    #[test]
978    fn reject_after_controller_on_custom_endpoint() {
979        let yaml = r#"
980resource: agents
981version: 1
982schema:
983  id: { type: uuid, primary: true, generated: true }
984endpoints:
985  regenerate_secret:
986    method: POST
987    path: /agents/:id/regenerate_secret
988    auth: [admin]
989    controller: { after: my_after }
990"#;
991        let rd = parse_resource(yaml).unwrap();
992        let errors = validate_resource(&rd);
993        assert!(
994            errors
995                .iter()
996                .any(|e| e.message.contains("declares `controller: { after: ... }`")),
997            "expected a CustomEndpointWithAfterController error, got: {errors:?}"
998        );
999    }
1000
1001    #[test]
1002    fn allow_before_controller_on_custom_endpoint() {
1003        let yaml = r#"
1004resource: agents
1005version: 1
1006schema:
1007  id: { type: uuid, primary: true, generated: true }
1008endpoints:
1009  regenerate_secret:
1010    method: POST
1011    path: /agents/:id/regenerate_secret
1012    auth: [admin]
1013    controller: { before: my_before }
1014"#;
1015        let rd = parse_resource(yaml).unwrap();
1016        let errors = validate_resource(&rd);
1017        assert!(
1018            errors.is_empty(),
1019            "before:-only controller on a custom endpoint should validate clean, got: {errors:?}"
1020        );
1021    }
1022
1023    #[test]
1024    fn allow_controller_on_crud_endpoints() {
1025        let yaml = r#"
1026resource: agents
1027version: 1
1028schema:
1029  id: { type: uuid, primary: true, generated: true }
1030  name: { type: string, required: true }
1031endpoints:
1032  create:
1033    method: POST
1034    path: /agents
1035    input: [name]
1036    controller: { before: my_before }
1037"#;
1038        let rd = parse_resource(yaml).unwrap();
1039        let errors = validate_resource(&rd);
1040        assert!(
1041            !errors.iter().any(|e| e
1042                .message
1043                .contains("declares `controller:` but is a custom endpoint")),
1044            "create endpoint with controller should NOT trip the rule, got: {errors:?}"
1045        );
1046    }
1047}