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