1use shaperail_core::{FieldType, HttpMethod, ResourceDefinition, WASM_HOOK_PREFIX};
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(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 if let Some(endpoints) = &rd.endpoints {
127 for (action, ep) in endpoints {
128 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 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 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 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 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 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 => {} }
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 if let Some(relations) = &rd.relations {
294 for (name, rel) in relations {
295 use shaperail_core::RelationType;
296
297 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 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 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 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
353fn 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}