Skip to main content

shaperail_codegen/
validator.rs

1use shaperail_core::{FieldType, HttpMethod, ResourceDefinition};
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    // Endpoint validation
107    if let Some(endpoints) = &rd.endpoints {
108        for (action, ep) in endpoints {
109            if let Some(controller) = &ep.controller {
110                if let Some(before) = &controller.before {
111                    if before.is_empty() {
112                        errors.push(err(&format!(
113                            "resource '{res}': endpoint '{action}' has an empty controller.before name"
114                        )));
115                    }
116                }
117                if let Some(after) = &controller.after {
118                    if after.is_empty() {
119                        errors.push(err(&format!(
120                            "resource '{res}': endpoint '{action}' has an empty controller.after name"
121                        )));
122                    }
123                }
124            }
125
126            if let Some(events) = &ep.events {
127                for event in events {
128                    if event.is_empty() {
129                        errors.push(err(&format!(
130                            "resource '{res}': endpoint '{action}' has an empty event name"
131                        )));
132                    }
133                }
134            }
135
136            if let Some(jobs) = &ep.jobs {
137                for job in jobs {
138                    if job.is_empty() {
139                        errors.push(err(&format!(
140                            "resource '{res}': endpoint '{action}' has an empty job name"
141                        )));
142                    }
143                }
144            }
145
146            // Input fields must exist in schema
147            if let Some(input) = &ep.input {
148                for field_name in input {
149                    if !rd.schema.contains_key(field_name) {
150                        errors.push(err(&format!(
151                            "resource '{res}': endpoint '{action}' input field '{field_name}' not found in schema"
152                        )));
153                    }
154                }
155            }
156
157            // Filter fields must exist in schema
158            if let Some(filters) = &ep.filters {
159                for field_name in filters {
160                    if !rd.schema.contains_key(field_name) {
161                        errors.push(err(&format!(
162                            "resource '{res}': endpoint '{action}' filter field '{field_name}' not found in schema"
163                        )));
164                    }
165                }
166            }
167
168            // Search fields must exist in schema
169            if let Some(search) = &ep.search {
170                for field_name in search {
171                    if !rd.schema.contains_key(field_name) {
172                        errors.push(err(&format!(
173                            "resource '{res}': endpoint '{action}' search field '{field_name}' not found in schema"
174                        )));
175                    }
176                }
177            }
178
179            // Sort fields must exist in schema
180            if let Some(sort) = &ep.sort {
181                for field_name in sort {
182                    if !rd.schema.contains_key(field_name) {
183                        errors.push(err(&format!(
184                            "resource '{res}': endpoint '{action}' sort field '{field_name}' not found in schema"
185                        )));
186                    }
187                }
188            }
189
190            // soft_delete requires updated_at field in schema
191            if ep.soft_delete && !rd.schema.contains_key("updated_at") {
192                errors.push(err(&format!(
193                    "resource '{res}': endpoint '{action}' has soft_delete but schema has no 'updated_at' field"
194                )));
195            }
196
197            if let Some(upload) = &ep.upload {
198                match ep.method {
199                    HttpMethod::Post | HttpMethod::Patch | HttpMethod::Put => {}
200                    _ => errors.push(err(&format!(
201                        "resource '{res}': endpoint '{action}' uses upload but method must be POST, PATCH, or PUT"
202                    ))),
203                }
204
205                match rd.schema.get(&upload.field) {
206                    Some(field) if field.field_type == FieldType::File => {}
207                    Some(_) => errors.push(err(&format!(
208                        "resource '{res}': endpoint '{action}' upload field '{}' must be type file",
209                        upload.field
210                    ))),
211                    None => errors.push(err(&format!(
212                        "resource '{res}': endpoint '{action}' upload field '{}' not found in schema",
213                        upload.field
214                    ))),
215                }
216
217                if !matches!(upload.storage.as_str(), "local" | "s3" | "gcs" | "azure") {
218                    errors.push(err(&format!(
219                        "resource '{res}': endpoint '{action}' upload storage '{}' is invalid",
220                        upload.storage
221                    )));
222                }
223
224                if !ep
225                    .input
226                    .as_ref()
227                    .is_some_and(|fields| fields.iter().any(|field| field == &upload.field))
228                {
229                    errors.push(err(&format!(
230                        "resource '{res}': endpoint '{action}' upload field '{}' must appear in input",
231                        upload.field
232                    )));
233                }
234
235                for (suffix, expected_types) in [
236                    ("filename", &[FieldType::String][..]),
237                    ("mime_type", &[FieldType::String][..]),
238                    ("size", &[FieldType::Integer, FieldType::Bigint][..]),
239                ] {
240                    let companion = format!("{}_{}", upload.field, suffix);
241                    if let Some(field) = rd.schema.get(&companion) {
242                        if !expected_types.contains(&field.field_type) {
243                            let expected = expected_types
244                                .iter()
245                                .map(ToString::to_string)
246                                .collect::<Vec<_>>()
247                                .join(" or ");
248                            errors.push(err(&format!(
249                                "resource '{res}': companion upload field '{companion}' must be type {expected}"
250                            )));
251                        }
252                    }
253                }
254            }
255        }
256    }
257
258    // Relation validation
259    if let Some(relations) = &rd.relations {
260        for (name, rel) in relations {
261            use shaperail_core::RelationType;
262
263            // belongs_to should have key
264            if rel.relation_type == RelationType::BelongsTo && rel.key.is_none() {
265                errors.push(err(&format!(
266                    "resource '{res}': relation '{name}' is belongs_to but has no key"
267                )));
268            }
269
270            // has_many/has_one should have foreign_key
271            if matches!(
272                rel.relation_type,
273                RelationType::HasMany | RelationType::HasOne
274            ) && rel.foreign_key.is_none()
275            {
276                errors.push(err(&format!(
277                    "resource '{res}': relation '{name}' is {} but has no foreign_key",
278                    rel.relation_type
279                )));
280            }
281
282            // belongs_to key must exist in schema
283            if let Some(key) = &rel.key {
284                if !rd.schema.contains_key(key) {
285                    errors.push(err(&format!(
286                        "resource '{res}': relation '{name}' key '{key}' not found in schema"
287                    )));
288                }
289            }
290        }
291    }
292
293    // Index validation
294    if let Some(indexes) = &rd.indexes {
295        for (i, idx) in indexes.iter().enumerate() {
296            if idx.fields.is_empty() {
297                errors.push(err(&format!("resource '{res}': index {i} has no fields")));
298            }
299            for field_name in &idx.fields {
300                if !rd.schema.contains_key(field_name) {
301                    errors.push(err(&format!(
302                        "resource '{res}': index {i} references field '{field_name}' not in schema"
303                    )));
304                }
305            }
306            if let Some(order) = &idx.order {
307                if order != "asc" && order != "desc" {
308                    errors.push(err(&format!(
309                        "resource '{res}': index {i} has invalid order '{order}', must be 'asc' or 'desc'"
310                    )));
311                }
312            }
313        }
314    }
315
316    errors
317}
318
319fn err(message: &str) -> ValidationError {
320    ValidationError {
321        message: message.to_string(),
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328    use crate::parser::parse_resource;
329
330    #[test]
331    fn valid_resource_passes() {
332        let yaml = include_str!("../../resources/users.yaml");
333        let rd = parse_resource(yaml).unwrap();
334        let errors = validate_resource(&rd);
335        assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
336    }
337
338    #[test]
339    fn enum_without_values() {
340        let yaml = r#"
341resource: items
342version: 1
343schema:
344  id: { type: uuid, primary: true, generated: true }
345  status: { type: enum, required: true }
346"#;
347        let rd = parse_resource(yaml).unwrap();
348        let errors = validate_resource(&rd);
349        assert!(errors
350            .iter()
351            .any(|e| e.message.contains("type enum but has no values")));
352    }
353
354    #[test]
355    fn ref_field_not_uuid() {
356        let yaml = r#"
357resource: items
358version: 1
359schema:
360  id: { type: uuid, primary: true, generated: true }
361  org_id: { type: string, ref: organizations.id }
362"#;
363        let rd = parse_resource(yaml).unwrap();
364        let errors = validate_resource(&rd);
365        assert!(errors
366            .iter()
367            .any(|e| e.message.contains("has ref but is not type uuid")));
368    }
369
370    #[test]
371    fn missing_primary_key() {
372        let yaml = r#"
373resource: items
374version: 1
375schema:
376  name: { type: string, required: true }
377"#;
378        let rd = parse_resource(yaml).unwrap();
379        let errors = validate_resource(&rd);
380        assert!(errors
381            .iter()
382            .any(|e| e.message.contains("must have a primary key")));
383    }
384
385    #[test]
386    fn soft_delete_without_updated_at() {
387        let yaml = r#"
388resource: items
389version: 1
390schema:
391  id: { type: uuid, primary: true, generated: true }
392  name: { type: string, required: true }
393endpoints:
394  delete:
395    method: DELETE
396    path: /items/:id
397    auth: [admin]
398    soft_delete: true
399"#;
400        let rd = parse_resource(yaml).unwrap();
401        let errors = validate_resource(&rd);
402        assert!(errors.iter().any(|e| e
403            .message
404            .contains("soft_delete but schema has no 'updated_at'")));
405    }
406
407    #[test]
408    fn input_field_not_in_schema() {
409        let yaml = r#"
410resource: items
411version: 1
412schema:
413  id: { type: uuid, primary: true, generated: true }
414  name: { type: string, required: true }
415endpoints:
416  create:
417    method: POST
418    path: /items
419    auth: [admin]
420    input: [name, nonexistent]
421"#;
422        let rd = parse_resource(yaml).unwrap();
423        let errors = validate_resource(&rd);
424        assert!(errors.iter().any(|e| e
425            .message
426            .contains("input field 'nonexistent' not found in schema")));
427    }
428
429    #[test]
430    fn belongs_to_without_key() {
431        let yaml = r#"
432resource: items
433version: 1
434schema:
435  id: { type: uuid, primary: true, generated: true }
436relations:
437  org: { resource: organizations, type: belongs_to }
438"#;
439        let rd = parse_resource(yaml).unwrap();
440        let errors = validate_resource(&rd);
441        assert!(errors
442            .iter()
443            .any(|e| e.message.contains("belongs_to but has no key")));
444    }
445
446    #[test]
447    fn has_many_without_foreign_key() {
448        let yaml = r#"
449resource: items
450version: 1
451schema:
452  id: { type: uuid, primary: true, generated: true }
453relations:
454  orders: { resource: orders, type: has_many }
455"#;
456        let rd = parse_resource(yaml).unwrap();
457        let errors = validate_resource(&rd);
458        assert!(errors
459            .iter()
460            .any(|e| e.message.contains("has_many but has no foreign_key")));
461    }
462
463    #[test]
464    fn index_references_missing_field() {
465        let yaml = r#"
466resource: items
467version: 1
468schema:
469  id: { type: uuid, primary: true, generated: true }
470indexes:
471  - fields: [missing_field]
472"#;
473        let rd = parse_resource(yaml).unwrap();
474        let errors = validate_resource(&rd);
475        assert!(errors.iter().any(|e| e
476            .message
477            .contains("references field 'missing_field' not in schema")));
478    }
479
480    #[test]
481    fn error_message_format() {
482        let yaml = r#"
483resource: users
484version: 1
485schema:
486  id: { type: uuid, primary: true, generated: true }
487  role: { type: enum }
488"#;
489        let rd = parse_resource(yaml).unwrap();
490        let errors = validate_resource(&rd);
491        assert_eq!(
492            errors[0].message,
493            "resource 'users': field 'role' is type enum but has no values"
494        );
495    }
496
497    #[test]
498    fn upload_endpoint_valid_when_file_field_declared() {
499        let yaml = r#"
500resource: assets
501version: 1
502schema:
503  id: { type: uuid, primary: true, generated: true }
504  file: { type: file, required: true }
505  file_filename: { type: string }
506  file_mime_type: { type: string }
507  file_size: { type: bigint }
508  updated_at: { type: timestamp, generated: true }
509endpoints:
510  upload:
511    method: POST
512    path: /assets/upload
513    input: [file]
514    upload:
515      field: file
516      storage: local
517      max_size: 5mb
518"#;
519        let rd = parse_resource(yaml).unwrap();
520        let errors = validate_resource(&rd);
521        assert!(
522            errors.is_empty(),
523            "Expected valid upload resource, got {errors:?}"
524        );
525    }
526
527    #[test]
528    fn upload_endpoint_requires_file_field() {
529        let yaml = r#"
530resource: assets
531version: 1
532schema:
533  id: { type: uuid, primary: true, generated: true }
534  file_path: { type: string, required: true }
535endpoints:
536  upload:
537    method: POST
538    path: /assets/upload
539    input: [file_path]
540    upload:
541      field: file_path
542      storage: local
543      max_size: 5mb
544"#;
545        let rd = parse_resource(yaml).unwrap();
546        let errors = validate_resource(&rd);
547        assert!(errors.iter().any(|e| e
548            .message
549            .contains("upload field 'file_path' must be type file")));
550    }
551}