shaperail_codegen/
validator.rs1use shaperail_core::{FieldType, HttpMethod, ResourceDefinition};
2
3#[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
15pub fn validate_resource(rd: &ResourceDefinition) -> Vec<ValidationError> {
20 let mut errors = Vec::new();
21 let res = &rd.resource;
22
23 if res.is_empty() {
25 errors.push(err("resource name must not be empty"));
26 }
27
28 if rd.version == 0 {
30 errors.push(err(&format!("resource '{res}': version must be >= 1")));
31 }
32
33 if rd.schema.is_empty() {
35 errors.push(err(&format!(
36 "resource '{res}': schema must have at least one field"
37 )));
38 }
39
40 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 for (name, field) in &rd.schema {
54 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 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 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 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 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 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 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 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 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 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 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 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 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 if let Some(relations) = &rd.relations {
260 for (name, rel) in relations {
261 use shaperail_core::RelationType;
262
263 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 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 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 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}