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
106    // Tenant key validation (M18)
107    if let Some(ref tenant_key) = rd.tenant_key {
108        match rd.schema.get(tenant_key) {
109            Some(field) => {
110                if field.field_type != FieldType::Uuid {
111                    errors.push(err(&format!(
112                        "resource '{res}': tenant_key '{tenant_key}' must reference a uuid field, found {}",
113                        field.field_type
114                    )));
115                }
116            }
117            None => {
118                errors.push(err(&format!(
119                    "resource '{res}': tenant_key '{tenant_key}' not found in schema"
120                )));
121            }
122        }
123    }
124
125    // Endpoint validation
126    if let Some(endpoints) = &rd.endpoints {
127        for (action, ep) in endpoints {
128            // method and path must be set (either explicitly or via convention defaults)
129            if ep.method.is_none() {
130                errors.push(err(&format!(
131                    "resource '{res}': endpoint '{action}' has no method. Use a known action name (list, get, create, update, delete) or set method explicitly"
132                )));
133            }
134            if ep.path.is_none() {
135                errors.push(err(&format!(
136                    "resource '{res}': endpoint '{action}' has no path. Use a known action name (list, get, create, update, delete) or set path explicitly"
137                )));
138            }
139
140            if let Some(controller) = &ep.controller {
141                if let Some(before) = &controller.before {
142                    if before.is_empty() {
143                        errors.push(err(&format!(
144                            "resource '{res}': endpoint '{action}' has an empty controller.before name"
145                        )));
146                    }
147                    validate_controller_name(res, action, "before", before, &mut errors);
148                }
149                if let Some(after) = &controller.after {
150                    if after.is_empty() {
151                        errors.push(err(&format!(
152                            "resource '{res}': endpoint '{action}' has an empty controller.after name"
153                        )));
154                    }
155                    validate_controller_name(res, action, "after", after, &mut errors);
156                }
157            }
158
159            if let Some(events) = &ep.events {
160                for event in events {
161                    if event.is_empty() {
162                        errors.push(err(&format!(
163                            "resource '{res}': endpoint '{action}' has an empty event name"
164                        )));
165                    }
166                }
167            }
168
169            if let Some(jobs) = &ep.jobs {
170                for job in jobs {
171                    if job.is_empty() {
172                        errors.push(err(&format!(
173                            "resource '{res}': endpoint '{action}' has an empty job name"
174                        )));
175                    }
176                }
177            }
178
179            // Input fields must exist in schema
180            if let Some(input) = &ep.input {
181                for field_name in input {
182                    if !rd.schema.contains_key(field_name) {
183                        errors.push(err(&format!(
184                            "resource '{res}': endpoint '{action}' input field '{field_name}' not found in schema"
185                        )));
186                    }
187                }
188            }
189
190            // Filter fields must exist in schema
191            if let Some(filters) = &ep.filters {
192                for field_name in filters {
193                    if !rd.schema.contains_key(field_name) {
194                        errors.push(err(&format!(
195                            "resource '{res}': endpoint '{action}' filter field '{field_name}' not found in schema"
196                        )));
197                    }
198                }
199            }
200
201            // Search fields must exist in schema
202            if let Some(search) = &ep.search {
203                for field_name in search {
204                    if !rd.schema.contains_key(field_name) {
205                        errors.push(err(&format!(
206                            "resource '{res}': endpoint '{action}' search field '{field_name}' not found in schema"
207                        )));
208                    }
209                }
210            }
211
212            // Sort fields must exist in schema
213            if let Some(sort) = &ep.sort {
214                for field_name in sort {
215                    if !rd.schema.contains_key(field_name) {
216                        errors.push(err(&format!(
217                            "resource '{res}': endpoint '{action}' sort field '{field_name}' not found in schema"
218                        )));
219                    }
220                }
221            }
222
223            // soft_delete requires updated_at field in schema
224            if ep.soft_delete && !rd.schema.contains_key("updated_at") {
225                errors.push(err(&format!(
226                    "resource '{res}': endpoint '{action}' has soft_delete but schema has no 'updated_at' field"
227                )));
228            }
229
230            if let Some(upload) = &ep.upload {
231                match ep.method.as_ref() {
232                    Some(HttpMethod::Post | HttpMethod::Patch | HttpMethod::Put) => {}
233                    Some(_) => errors.push(err(&format!(
234                        "resource '{res}': endpoint '{action}' uses upload but method must be POST, PATCH, or PUT"
235                    ))),
236                    None => {} // already reported above
237                }
238
239                match rd.schema.get(&upload.field) {
240                    Some(field) if field.field_type == FieldType::File => {}
241                    Some(_) => errors.push(err(&format!(
242                        "resource '{res}': endpoint '{action}' upload field '{}' must be type file",
243                        upload.field
244                    ))),
245                    None => errors.push(err(&format!(
246                        "resource '{res}': endpoint '{action}' upload field '{}' not found in schema",
247                        upload.field
248                    ))),
249                }
250
251                if !matches!(upload.storage.as_str(), "local" | "s3" | "gcs" | "azure") {
252                    errors.push(err(&format!(
253                        "resource '{res}': endpoint '{action}' upload storage '{}' is invalid",
254                        upload.storage
255                    )));
256                }
257
258                if !ep
259                    .input
260                    .as_ref()
261                    .is_some_and(|fields| fields.iter().any(|field| field == &upload.field))
262                {
263                    errors.push(err(&format!(
264                        "resource '{res}': endpoint '{action}' upload field '{}' must appear in input",
265                        upload.field
266                    )));
267                }
268
269                for (suffix, expected_types) in [
270                    ("filename", &[FieldType::String][..]),
271                    ("mime_type", &[FieldType::String][..]),
272                    ("size", &[FieldType::Integer, FieldType::Bigint][..]),
273                ] {
274                    let companion = format!("{}_{}", upload.field, suffix);
275                    if let Some(field) = rd.schema.get(&companion) {
276                        if !expected_types.contains(&field.field_type) {
277                            let expected = expected_types
278                                .iter()
279                                .map(ToString::to_string)
280                                .collect::<Vec<_>>()
281                                .join(" or ");
282                            errors.push(err(&format!(
283                                "resource '{res}': companion upload field '{companion}' must be type {expected}"
284                            )));
285                        }
286                    }
287                }
288            }
289        }
290    }
291
292    // Relation validation
293    if let Some(relations) = &rd.relations {
294        for (name, rel) in relations {
295            use shaperail_core::RelationType;
296
297            // belongs_to should have key
298            if rel.relation_type == RelationType::BelongsTo && rel.key.is_none() {
299                errors.push(err(&format!(
300                    "resource '{res}': relation '{name}' is belongs_to but has no key"
301                )));
302            }
303
304            // has_many/has_one should have foreign_key
305            if matches!(
306                rel.relation_type,
307                RelationType::HasMany | RelationType::HasOne
308            ) && rel.foreign_key.is_none()
309            {
310                errors.push(err(&format!(
311                    "resource '{res}': relation '{name}' is {} but has no foreign_key",
312                    rel.relation_type
313                )));
314            }
315
316            // belongs_to key must exist in schema
317            if let Some(key) = &rel.key {
318                if !rd.schema.contains_key(key) {
319                    errors.push(err(&format!(
320                        "resource '{res}': relation '{name}' key '{key}' not found in schema"
321                    )));
322                }
323            }
324        }
325    }
326
327    // Index validation
328    if let Some(indexes) = &rd.indexes {
329        for (i, idx) in indexes.iter().enumerate() {
330            if idx.fields.is_empty() {
331                errors.push(err(&format!("resource '{res}': index {i} has no fields")));
332            }
333            for field_name in &idx.fields {
334                if !rd.schema.contains_key(field_name) {
335                    errors.push(err(&format!(
336                        "resource '{res}': index {i} references field '{field_name}' not in schema"
337                    )));
338                }
339            }
340            if let Some(order) = &idx.order {
341                if order != "asc" && order != "desc" {
342                    errors.push(err(&format!(
343                        "resource '{res}': index {i} has invalid order '{order}', must be 'asc' or 'desc'"
344                    )));
345                }
346            }
347        }
348    }
349
350    errors
351}
352
353/// Validates a controller name — either a Rust function name or a `wasm:` prefixed path.
354fn validate_controller_name(
355    res: &str,
356    action: &str,
357    phase: &str,
358    name: &str,
359    errors: &mut Vec<ValidationError>,
360) {
361    if let Some(wasm_path) = name.strip_prefix(WASM_HOOK_PREFIX) {
362        if wasm_path.is_empty() {
363            errors.push(err(&format!(
364                "resource '{res}': endpoint '{action}' controller.{phase} has 'wasm:' prefix but no path"
365            )));
366        } else if !wasm_path.ends_with(".wasm") {
367            errors.push(err(&format!(
368                "resource '{res}': endpoint '{action}' controller.{phase} WASM path must end with '.wasm', got '{wasm_path}'"
369            )));
370        }
371    }
372}
373
374fn err(message: &str) -> ValidationError {
375    ValidationError {
376        message: message.to_string(),
377    }
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383    use crate::parser::parse_resource;
384
385    #[test]
386    fn valid_resource_passes() {
387        let yaml = include_str!("../../resources/users.yaml");
388        let rd = parse_resource(yaml).unwrap();
389        let errors = validate_resource(&rd);
390        assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
391    }
392
393    #[test]
394    fn enum_without_values() {
395        let yaml = r#"
396resource: items
397version: 1
398schema:
399  id: { type: uuid, primary: true, generated: true }
400  status: { type: enum, required: true }
401"#;
402        let rd = parse_resource(yaml).unwrap();
403        let errors = validate_resource(&rd);
404        assert!(errors
405            .iter()
406            .any(|e| e.message.contains("type enum but has no values")));
407    }
408
409    #[test]
410    fn ref_field_not_uuid() {
411        let yaml = r#"
412resource: items
413version: 1
414schema:
415  id: { type: uuid, primary: true, generated: true }
416  org_id: { type: string, ref: organizations.id }
417"#;
418        let rd = parse_resource(yaml).unwrap();
419        let errors = validate_resource(&rd);
420        assert!(errors
421            .iter()
422            .any(|e| e.message.contains("has ref but is not type uuid")));
423    }
424
425    #[test]
426    fn missing_primary_key() {
427        let yaml = r#"
428resource: items
429version: 1
430schema:
431  name: { type: string, required: true }
432"#;
433        let rd = parse_resource(yaml).unwrap();
434        let errors = validate_resource(&rd);
435        assert!(errors
436            .iter()
437            .any(|e| e.message.contains("must have a primary key")));
438    }
439
440    #[test]
441    fn soft_delete_without_updated_at() {
442        let yaml = r#"
443resource: items
444version: 1
445schema:
446  id: { type: uuid, primary: true, generated: true }
447  name: { type: string, required: true }
448endpoints:
449  delete:
450    method: DELETE
451    path: /items/:id
452    auth: [admin]
453    soft_delete: true
454"#;
455        let rd = parse_resource(yaml).unwrap();
456        let errors = validate_resource(&rd);
457        assert!(errors.iter().any(|e| e
458            .message
459            .contains("soft_delete but schema has no 'updated_at'")));
460    }
461
462    #[test]
463    fn input_field_not_in_schema() {
464        let yaml = r#"
465resource: items
466version: 1
467schema:
468  id: { type: uuid, primary: true, generated: true }
469  name: { type: string, required: true }
470endpoints:
471  create:
472    method: POST
473    path: /items
474    auth: [admin]
475    input: [name, nonexistent]
476"#;
477        let rd = parse_resource(yaml).unwrap();
478        let errors = validate_resource(&rd);
479        assert!(errors.iter().any(|e| e
480            .message
481            .contains("input field 'nonexistent' not found in schema")));
482    }
483
484    #[test]
485    fn belongs_to_without_key() {
486        let yaml = r#"
487resource: items
488version: 1
489schema:
490  id: { type: uuid, primary: true, generated: true }
491relations:
492  org: { resource: organizations, type: belongs_to }
493"#;
494        let rd = parse_resource(yaml).unwrap();
495        let errors = validate_resource(&rd);
496        assert!(errors
497            .iter()
498            .any(|e| e.message.contains("belongs_to but has no key")));
499    }
500
501    #[test]
502    fn has_many_without_foreign_key() {
503        let yaml = r#"
504resource: items
505version: 1
506schema:
507  id: { type: uuid, primary: true, generated: true }
508relations:
509  orders: { resource: orders, type: has_many }
510"#;
511        let rd = parse_resource(yaml).unwrap();
512        let errors = validate_resource(&rd);
513        assert!(errors
514            .iter()
515            .any(|e| e.message.contains("has_many but has no foreign_key")));
516    }
517
518    #[test]
519    fn index_references_missing_field() {
520        let yaml = r#"
521resource: items
522version: 1
523schema:
524  id: { type: uuid, primary: true, generated: true }
525indexes:
526  - fields: [missing_field]
527"#;
528        let rd = parse_resource(yaml).unwrap();
529        let errors = validate_resource(&rd);
530        assert!(errors.iter().any(|e| e
531            .message
532            .contains("references field 'missing_field' not in schema")));
533    }
534
535    #[test]
536    fn error_message_format() {
537        let yaml = r#"
538resource: users
539version: 1
540schema:
541  id: { type: uuid, primary: true, generated: true }
542  role: { type: enum }
543"#;
544        let rd = parse_resource(yaml).unwrap();
545        let errors = validate_resource(&rd);
546        assert_eq!(
547            errors[0].message,
548            "resource 'users': field 'role' is type enum but has no values"
549        );
550    }
551
552    #[test]
553    fn wasm_controller_valid_path() {
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  create:
562    method: POST
563    path: /items
564    input: [name]
565    controller: { before: "wasm:./plugins/my_validator.wasm" }
566"#;
567        let rd = parse_resource(yaml).unwrap();
568        let errors = validate_resource(&rd);
569        assert!(
570            errors.is_empty(),
571            "Expected no errors for valid WASM controller, got: {errors:?}"
572        );
573    }
574
575    #[test]
576    fn wasm_controller_missing_extension() {
577        let yaml = r#"
578resource: items
579version: 1
580schema:
581  id: { type: uuid, primary: true, generated: true }
582  name: { type: string, required: true }
583endpoints:
584  create:
585    method: POST
586    path: /items
587    input: [name]
588    controller: { before: "wasm:./plugins/my_validator" }
589"#;
590        let rd = parse_resource(yaml).unwrap();
591        let errors = validate_resource(&rd);
592        assert!(errors
593            .iter()
594            .any(|e| e.message.contains("WASM path must end with '.wasm'")));
595    }
596
597    #[test]
598    fn wasm_controller_empty_path() {
599        let yaml = r#"
600resource: items
601version: 1
602schema:
603  id: { type: uuid, primary: true, generated: true }
604  name: { type: string, required: true }
605endpoints:
606  create:
607    method: POST
608    path: /items
609    input: [name]
610    controller: { before: "wasm:" }
611"#;
612        let rd = parse_resource(yaml).unwrap();
613        let errors = validate_resource(&rd);
614        assert!(errors
615            .iter()
616            .any(|e| e.message.contains("'wasm:' prefix but no path")));
617    }
618
619    #[test]
620    fn upload_endpoint_valid_when_file_field_declared() {
621        let yaml = r#"
622resource: assets
623version: 1
624schema:
625  id: { type: uuid, primary: true, generated: true }
626  file: { type: file, required: true }
627  file_filename: { type: string }
628  file_mime_type: { type: string }
629  file_size: { type: bigint }
630  updated_at: { type: timestamp, generated: true }
631endpoints:
632  upload:
633    method: POST
634    path: /assets/upload
635    input: [file]
636    upload:
637      field: file
638      storage: local
639      max_size: 5mb
640"#;
641        let rd = parse_resource(yaml).unwrap();
642        let errors = validate_resource(&rd);
643        assert!(
644            errors.is_empty(),
645            "Expected valid upload resource, got {errors:?}"
646        );
647    }
648
649    #[test]
650    fn upload_endpoint_requires_file_field() {
651        let yaml = r#"
652resource: assets
653version: 1
654schema:
655  id: { type: uuid, primary: true, generated: true }
656  file_path: { type: string, required: true }
657endpoints:
658  upload:
659    method: POST
660    path: /assets/upload
661    input: [file_path]
662    upload:
663      field: file_path
664      storage: local
665      max_size: 5mb
666"#;
667        let rd = parse_resource(yaml).unwrap();
668        let errors = validate_resource(&rd);
669        assert!(errors.iter().any(|e| e
670            .message
671            .contains("upload field 'file_path' must be type file")));
672    }
673
674    #[test]
675    fn tenant_key_valid_uuid_field() {
676        let yaml = r#"
677resource: projects
678version: 1
679tenant_key: org_id
680schema:
681  id: { type: uuid, primary: true, generated: true }
682  org_id: { type: uuid, ref: organizations.id, required: true }
683  name: { type: string, required: true }
684"#;
685        let rd = parse_resource(yaml).unwrap();
686        let errors = validate_resource(&rd);
687        assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
688    }
689
690    #[test]
691    fn tenant_key_missing_field() {
692        let yaml = r#"
693resource: projects
694version: 1
695tenant_key: org_id
696schema:
697  id: { type: uuid, primary: true, generated: true }
698  name: { type: string, required: true }
699"#;
700        let rd = parse_resource(yaml).unwrap();
701        let errors = validate_resource(&rd);
702        assert!(errors.iter().any(|e| e
703            .message
704            .contains("tenant_key 'org_id' not found in schema")));
705    }
706
707    #[test]
708    fn tenant_key_wrong_type() {
709        let yaml = r#"
710resource: projects
711version: 1
712tenant_key: org_name
713schema:
714  id: { type: uuid, primary: true, generated: true }
715  org_name: { type: string, required: true }
716"#;
717        let rd = parse_resource(yaml).unwrap();
718        let errors = validate_resource(&rd);
719        assert!(errors.iter().any(|e| e
720            .message
721            .contains("tenant_key 'org_name' must reference a uuid field")));
722    }
723}