Skip to main content

shaperail_codegen/
validator.rs

1use shaperail_core::{FieldType, 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            // Hooks must be non-empty strings
110            if let Some(hooks) = &ep.hooks {
111                for hook in hooks {
112                    if hook.is_empty() {
113                        errors.push(err(&format!(
114                            "resource '{res}': endpoint '{action}' has an empty hook name"
115                        )));
116                    }
117                }
118            }
119
120            if let Some(events) = &ep.events {
121                for event in events {
122                    if event.is_empty() {
123                        errors.push(err(&format!(
124                            "resource '{res}': endpoint '{action}' has an empty event name"
125                        )));
126                    }
127                }
128            }
129
130            if let Some(jobs) = &ep.jobs {
131                for job in jobs {
132                    if job.is_empty() {
133                        errors.push(err(&format!(
134                            "resource '{res}': endpoint '{action}' has an empty job name"
135                        )));
136                    }
137                }
138            }
139
140            // Input fields must exist in schema
141            if let Some(input) = &ep.input {
142                for field_name in input {
143                    if !rd.schema.contains_key(field_name) {
144                        errors.push(err(&format!(
145                            "resource '{res}': endpoint '{action}' input field '{field_name}' not found in schema"
146                        )));
147                    }
148                }
149            }
150
151            // Filter fields must exist in schema
152            if let Some(filters) = &ep.filters {
153                for field_name in filters {
154                    if !rd.schema.contains_key(field_name) {
155                        errors.push(err(&format!(
156                            "resource '{res}': endpoint '{action}' filter field '{field_name}' not found in schema"
157                        )));
158                    }
159                }
160            }
161
162            // Search fields must exist in schema
163            if let Some(search) = &ep.search {
164                for field_name in search {
165                    if !rd.schema.contains_key(field_name) {
166                        errors.push(err(&format!(
167                            "resource '{res}': endpoint '{action}' search field '{field_name}' not found in schema"
168                        )));
169                    }
170                }
171            }
172
173            // Sort fields must exist in schema
174            if let Some(sort) = &ep.sort {
175                for field_name in sort {
176                    if !rd.schema.contains_key(field_name) {
177                        errors.push(err(&format!(
178                            "resource '{res}': endpoint '{action}' sort field '{field_name}' not found in schema"
179                        )));
180                    }
181                }
182            }
183
184            // soft_delete requires updated_at field in schema
185            if ep.soft_delete && !rd.schema.contains_key("updated_at") {
186                errors.push(err(&format!(
187                    "resource '{res}': endpoint '{action}' has soft_delete but schema has no 'updated_at' field"
188                )));
189            }
190
191            if ep.upload.is_some() {
192                errors.push(err(&format!(
193                    "resource '{res}': endpoint '{action}' uses upload, but upload endpoints are not yet supported by the runtime"
194                )));
195            }
196        }
197    }
198
199    // Relation validation
200    if let Some(relations) = &rd.relations {
201        for (name, rel) in relations {
202            use shaperail_core::RelationType;
203
204            // belongs_to should have key
205            if rel.relation_type == RelationType::BelongsTo && rel.key.is_none() {
206                errors.push(err(&format!(
207                    "resource '{res}': relation '{name}' is belongs_to but has no key"
208                )));
209            }
210
211            // has_many/has_one should have foreign_key
212            if matches!(
213                rel.relation_type,
214                RelationType::HasMany | RelationType::HasOne
215            ) && rel.foreign_key.is_none()
216            {
217                errors.push(err(&format!(
218                    "resource '{res}': relation '{name}' is {} but has no foreign_key",
219                    rel.relation_type
220                )));
221            }
222
223            // belongs_to key must exist in schema
224            if let Some(key) = &rel.key {
225                if !rd.schema.contains_key(key) {
226                    errors.push(err(&format!(
227                        "resource '{res}': relation '{name}' key '{key}' not found in schema"
228                    )));
229                }
230            }
231        }
232    }
233
234    // Index validation
235    if let Some(indexes) = &rd.indexes {
236        for (i, idx) in indexes.iter().enumerate() {
237            if idx.fields.is_empty() {
238                errors.push(err(&format!("resource '{res}': index {i} has no fields")));
239            }
240            for field_name in &idx.fields {
241                if !rd.schema.contains_key(field_name) {
242                    errors.push(err(&format!(
243                        "resource '{res}': index {i} references field '{field_name}' not in schema"
244                    )));
245                }
246            }
247            if let Some(order) = &idx.order {
248                if order != "asc" && order != "desc" {
249                    errors.push(err(&format!(
250                        "resource '{res}': index {i} has invalid order '{order}', must be 'asc' or 'desc'"
251                    )));
252                }
253            }
254        }
255    }
256
257    errors
258}
259
260fn err(message: &str) -> ValidationError {
261    ValidationError {
262        message: message.to_string(),
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use crate::parser::parse_resource;
270
271    #[test]
272    fn valid_resource_passes() {
273        let yaml = include_str!("../../resources/users.yaml");
274        let rd = parse_resource(yaml).unwrap();
275        let errors = validate_resource(&rd);
276        assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
277    }
278
279    #[test]
280    fn enum_without_values() {
281        let yaml = r#"
282resource: items
283version: 1
284schema:
285  id: { type: uuid, primary: true, generated: true }
286  status: { type: enum, required: true }
287"#;
288        let rd = parse_resource(yaml).unwrap();
289        let errors = validate_resource(&rd);
290        assert!(errors
291            .iter()
292            .any(|e| e.message.contains("type enum but has no values")));
293    }
294
295    #[test]
296    fn ref_field_not_uuid() {
297        let yaml = r#"
298resource: items
299version: 1
300schema:
301  id: { type: uuid, primary: true, generated: true }
302  org_id: { type: string, ref: organizations.id }
303"#;
304        let rd = parse_resource(yaml).unwrap();
305        let errors = validate_resource(&rd);
306        assert!(errors
307            .iter()
308            .any(|e| e.message.contains("has ref but is not type uuid")));
309    }
310
311    #[test]
312    fn missing_primary_key() {
313        let yaml = r#"
314resource: items
315version: 1
316schema:
317  name: { type: string, required: true }
318"#;
319        let rd = parse_resource(yaml).unwrap();
320        let errors = validate_resource(&rd);
321        assert!(errors
322            .iter()
323            .any(|e| e.message.contains("must have a primary key")));
324    }
325
326    #[test]
327    fn soft_delete_without_updated_at() {
328        let yaml = r#"
329resource: items
330version: 1
331schema:
332  id: { type: uuid, primary: true, generated: true }
333  name: { type: string, required: true }
334endpoints:
335  delete:
336    method: DELETE
337    path: /items/:id
338    auth: [admin]
339    soft_delete: true
340"#;
341        let rd = parse_resource(yaml).unwrap();
342        let errors = validate_resource(&rd);
343        assert!(errors.iter().any(|e| e
344            .message
345            .contains("soft_delete but schema has no 'updated_at'")));
346    }
347
348    #[test]
349    fn input_field_not_in_schema() {
350        let yaml = r#"
351resource: items
352version: 1
353schema:
354  id: { type: uuid, primary: true, generated: true }
355  name: { type: string, required: true }
356endpoints:
357  create:
358    method: POST
359    path: /items
360    auth: [admin]
361    input: [name, nonexistent]
362"#;
363        let rd = parse_resource(yaml).unwrap();
364        let errors = validate_resource(&rd);
365        assert!(errors.iter().any(|e| e
366            .message
367            .contains("input field 'nonexistent' not found in schema")));
368    }
369
370    #[test]
371    fn belongs_to_without_key() {
372        let yaml = r#"
373resource: items
374version: 1
375schema:
376  id: { type: uuid, primary: true, generated: true }
377relations:
378  org: { resource: organizations, type: belongs_to }
379"#;
380        let rd = parse_resource(yaml).unwrap();
381        let errors = validate_resource(&rd);
382        assert!(errors
383            .iter()
384            .any(|e| e.message.contains("belongs_to but has no key")));
385    }
386
387    #[test]
388    fn has_many_without_foreign_key() {
389        let yaml = r#"
390resource: items
391version: 1
392schema:
393  id: { type: uuid, primary: true, generated: true }
394relations:
395  orders: { resource: orders, type: has_many }
396"#;
397        let rd = parse_resource(yaml).unwrap();
398        let errors = validate_resource(&rd);
399        assert!(errors
400            .iter()
401            .any(|e| e.message.contains("has_many but has no foreign_key")));
402    }
403
404    #[test]
405    fn index_references_missing_field() {
406        let yaml = r#"
407resource: items
408version: 1
409schema:
410  id: { type: uuid, primary: true, generated: true }
411indexes:
412  - fields: [missing_field]
413"#;
414        let rd = parse_resource(yaml).unwrap();
415        let errors = validate_resource(&rd);
416        assert!(errors.iter().any(|e| e
417            .message
418            .contains("references field 'missing_field' not in schema")));
419    }
420
421    #[test]
422    fn error_message_format() {
423        let yaml = r#"
424resource: users
425version: 1
426schema:
427  id: { type: uuid, primary: true, generated: true }
428  role: { type: enum }
429"#;
430        let rd = parse_resource(yaml).unwrap();
431        let errors = validate_resource(&rd);
432        assert_eq!(
433            errors[0].message,
434            "resource 'users': field 'role' is type enum but has no values"
435        );
436    }
437
438    #[test]
439    fn upload_endpoint_not_supported() {
440        let yaml = r#"
441resource: assets
442version: 1
443schema:
444  id: { type: uuid, primary: true, generated: true }
445  file_path: { type: string, required: true }
446  updated_at: { type: timestamp, generated: true }
447endpoints:
448  upload:
449    method: POST
450    path: /assets/upload
451    upload:
452      field: file_path
453      storage: local
454      max_size: 5mb
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("upload endpoints are not yet supported")));
461    }
462}