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 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 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 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 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 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 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 if let Some(relations) = &rd.relations {
281 for (name, rel) in relations {
282 use shaperail_core::RelationType;
283
284 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 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 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 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
340fn 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}