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 let Some(items_spec) = &field.items {
93 if items_spec.field_type == FieldType::Array {
95 errors.push(err(&format!(
96 "resource '{res}': field '{name}' has nested array items; use type: json for nested arrays"
97 )));
98 }
99
100 if items_spec.field_type == FieldType::Enum && items_spec.values.is_none() {
102 errors.push(err(&format!(
103 "resource '{res}': field '{name}' has enum items but no values; add `values: [...]` to items"
104 )));
105 }
106
107 if items_spec.format.is_some() && items_spec.field_type != FieldType::String {
109 errors.push(err(&format!(
110 "resource '{res}': field '{name}' has items.format but items.type is not string"
111 )));
112 }
113
114 if items_spec.reference.is_some() && items_spec.field_type != FieldType::Uuid {
116 errors.push(err(&format!(
117 "resource '{res}': field '{name}' has items.ref but items.type is not uuid"
118 )));
119 }
120
121 if let Some(reference) = &items_spec.reference {
123 if !reference.contains('.') {
124 errors.push(err(&format!(
125 "resource '{res}': field '{name}' items.ref must be in 'resource.field' format, got '{reference}'"
126 )));
127 }
128 }
129 }
130
131 if field.format.is_some() && field.field_type != FieldType::String {
133 errors.push(err(&format!(
134 "resource '{res}': field '{name}' has format but is not type string"
135 )));
136 }
137
138 if field.primary && !field.generated && !field.required {
140 errors.push(err(&format!(
141 "resource '{res}': primary key field '{name}' must be generated or required"
142 )));
143 }
144
145 if field.transient {
148 if field.primary {
149 errors.push(err(&format!(
150 "resource '{res}': field '{name}' cannot be both transient and primary"
151 )));
152 }
153 if field.generated {
154 errors.push(err(&format!(
155 "resource '{res}': field '{name}' cannot be both transient and generated"
156 )));
157 }
158 if field.reference.is_some() {
159 errors.push(err(&format!(
160 "resource '{res}': field '{name}' cannot be both transient and have a ref (foreign keys imply persistence)"
161 )));
162 }
163 if field.unique {
164 errors.push(err(&format!(
165 "resource '{res}': field '{name}' cannot be both transient and unique (unique constraints require persistence)"
166 )));
167 }
168 if field.default.is_some() {
169 errors.push(err(&format!(
170 "resource '{res}': field '{name}' cannot be both transient and have a default (defaults apply to stored columns)"
171 )));
172 }
173 }
174 }
175
176 let transient_fields: Vec<&String> = rd
179 .schema
180 .iter()
181 .filter(|(_, f)| f.transient)
182 .map(|(name, _)| name)
183 .collect();
184 if !transient_fields.is_empty() {
185 let referenced: std::collections::HashSet<&str> = rd
186 .endpoints
187 .as_ref()
188 .map(|eps| {
189 eps.values()
190 .filter_map(|ep| ep.input.as_ref())
191 .flat_map(|inputs| inputs.iter().map(|s| s.as_str()))
192 .collect()
193 })
194 .unwrap_or_default();
195 for name in transient_fields {
196 if !referenced.contains(name.as_str()) {
197 errors.push(err(&format!(
198 "resource '{res}': transient field '{name}' is not declared in any endpoint's input: list (the field would be unreachable)"
199 )));
200 }
201 }
202 }
203
204 if let Some(ref tenant_key) = rd.tenant_key {
206 match rd.schema.get(tenant_key) {
207 Some(field) => {
208 if field.field_type != FieldType::Uuid {
209 errors.push(err(&format!(
210 "resource '{res}': tenant_key '{tenant_key}' must reference a uuid field, found {}",
211 field.field_type
212 )));
213 }
214 }
215 None => {
216 errors.push(err(&format!(
217 "resource '{res}': tenant_key '{tenant_key}' not found in schema"
218 )));
219 }
220 }
221 }
222
223 if let Some(endpoints) = &rd.endpoints {
225 for (action, ep) in endpoints {
226 if ep.method.is_none() {
228 errors.push(err(&format!(
229 "resource '{res}': endpoint '{action}' has no method. Use a known action name (list, get, create, update, delete) or set method explicitly"
230 )));
231 }
232 if ep.path.is_none() {
233 errors.push(err(&format!(
234 "resource '{res}': endpoint '{action}' has no path. Use a known action name (list, get, create, update, delete) or set path explicitly"
235 )));
236 }
237
238 if let Some(e) = validate_handler_only_on_custom(res, action, ep) {
243 errors.push(e);
244 }
245
246 if let Some(controller) = &ep.controller {
247 if let Some(e) = validate_controller_only_on_crud(res, action, ep) {
249 errors.push(e);
250 }
251
252 if let Some(before) = &controller.before {
253 if before.is_empty() {
254 errors.push(err(&format!(
255 "resource '{res}': endpoint '{action}' has an empty controller.before name"
256 )));
257 }
258 validate_controller_name(res, action, "before", before, &mut errors);
259 }
260 if let Some(after) = &controller.after {
261 if after.is_empty() {
262 errors.push(err(&format!(
263 "resource '{res}': endpoint '{action}' has an empty controller.after name"
264 )));
265 }
266 validate_controller_name(res, action, "after", after, &mut errors);
267 }
268 }
269
270 if let Some(events) = &ep.events {
271 for event in events {
272 if event.is_empty() {
273 errors.push(err(&format!(
274 "resource '{res}': endpoint '{action}' has an empty event name"
275 )));
276 }
277 }
278 }
279
280 if let Some(jobs) = &ep.jobs {
281 for job in jobs {
282 if job.is_empty() {
283 errors.push(err(&format!(
284 "resource '{res}': endpoint '{action}' has an empty job name"
285 )));
286 }
287 }
288 }
289
290 if let Some(input) = &ep.input {
292 for field_name in input {
293 if !rd.schema.contains_key(field_name) {
294 errors.push(err(&format!(
295 "resource '{res}': endpoint '{action}' input field '{field_name}' not found in schema"
296 )));
297 }
298 }
299 }
300
301 if let Some(filters) = &ep.filters {
303 for field_name in filters {
304 if !rd.schema.contains_key(field_name) {
305 errors.push(err(&format!(
306 "resource '{res}': endpoint '{action}' filter field '{field_name}' not found in schema"
307 )));
308 }
309 }
310 }
311
312 if let Some(search) = &ep.search {
314 for field_name in search {
315 if !rd.schema.contains_key(field_name) {
316 errors.push(err(&format!(
317 "resource '{res}': endpoint '{action}' search field '{field_name}' not found in schema"
318 )));
319 }
320 }
321 }
322
323 if let Some(sort) = &ep.sort {
325 for field_name in sort {
326 if !rd.schema.contains_key(field_name) {
327 errors.push(err(&format!(
328 "resource '{res}': endpoint '{action}' sort field '{field_name}' not found in schema"
329 )));
330 }
331 }
332 }
333
334 if ep.soft_delete && !rd.schema.contains_key("deleted_at") {
336 errors.push(err(&format!(
337 "resource '{res}': endpoint '{action}' has soft_delete but schema has no 'deleted_at' field"
338 )));
339 }
340
341 if let Some(upload) = &ep.upload {
342 match ep.method.as_ref() {
343 Some(HttpMethod::Post | HttpMethod::Patch | HttpMethod::Put) => {}
344 Some(_) => errors.push(err(&format!(
345 "resource '{res}': endpoint '{action}' uses upload but method must be POST, PATCH, or PUT"
346 ))),
347 None => {} }
349
350 match rd.schema.get(&upload.field) {
351 Some(field) if field.field_type == FieldType::File => {}
352 Some(_) => errors.push(err(&format!(
353 "resource '{res}': endpoint '{action}' upload field '{}' must be type file",
354 upload.field
355 ))),
356 None => errors.push(err(&format!(
357 "resource '{res}': endpoint '{action}' upload field '{}' not found in schema",
358 upload.field
359 ))),
360 }
361
362 if !matches!(upload.storage.as_str(), "local" | "s3" | "gcs" | "azure") {
363 errors.push(err(&format!(
364 "resource '{res}': endpoint '{action}' upload storage '{}' is invalid",
365 upload.storage
366 )));
367 }
368
369 if !ep
370 .input
371 .as_ref()
372 .is_some_and(|fields| fields.iter().any(|field| field == &upload.field))
373 {
374 errors.push(err(&format!(
375 "resource '{res}': endpoint '{action}' upload field '{}' must appear in input",
376 upload.field
377 )));
378 }
379
380 for (suffix, expected_types) in [
381 ("filename", &[FieldType::String][..]),
382 ("mime_type", &[FieldType::String][..]),
383 ("size", &[FieldType::Integer, FieldType::Bigint][..]),
384 ] {
385 let companion = format!("{}_{}", upload.field, suffix);
386 if let Some(field) = rd.schema.get(&companion) {
387 if !expected_types.contains(&field.field_type) {
388 let expected = expected_types
389 .iter()
390 .map(ToString::to_string)
391 .collect::<Vec<_>>()
392 .join(" or ");
393 errors.push(err(&format!(
394 "resource '{res}': companion upload field '{companion}' must be type {expected}"
395 )));
396 }
397 }
398 }
399 }
400 }
401 }
402
403 if let Some(relations) = &rd.relations {
405 for (name, rel) in relations {
406 use shaperail_core::RelationType;
407
408 if rel.relation_type == RelationType::BelongsTo && rel.key.is_none() {
410 errors.push(err(&format!(
411 "resource '{res}': relation '{name}' is belongs_to but has no key"
412 )));
413 }
414
415 if matches!(
417 rel.relation_type,
418 RelationType::HasMany | RelationType::HasOne
419 ) && rel.foreign_key.is_none()
420 {
421 errors.push(err(&format!(
422 "resource '{res}': relation '{name}' is {} but has no foreign_key",
423 rel.relation_type
424 )));
425 }
426
427 if let Some(key) = &rel.key {
429 if !rd.schema.contains_key(key) {
430 errors.push(err(&format!(
431 "resource '{res}': relation '{name}' key '{key}' not found in schema"
432 )));
433 }
434 }
435 }
436 }
437
438 if let Some(indexes) = &rd.indexes {
440 for (i, idx) in indexes.iter().enumerate() {
441 if idx.fields.is_empty() {
442 errors.push(err(&format!("resource '{res}': index {i} has no fields")));
443 }
444 for field_name in &idx.fields {
445 if !rd.schema.contains_key(field_name) {
446 errors.push(err(&format!(
447 "resource '{res}': index {i} references field '{field_name}' not in schema"
448 )));
449 }
450 }
451 if let Some(order) = &idx.order {
452 if order != "asc" && order != "desc" {
453 errors.push(err(&format!(
454 "resource '{res}': index {i} has invalid order '{order}', must be 'asc' or 'desc'"
455 )));
456 }
457 }
458 }
459 }
460
461 errors
462}
463
464fn validate_controller_only_on_crud(
475 resource: &str,
476 action: &str,
477 endpoint: &shaperail_core::EndpointSpec,
478) -> Option<ValidationError> {
479 const CRUD_ACTIONS: &[&str] = &[
480 "list",
481 "get",
482 "create",
483 "update",
484 "delete",
485 "bulk_create",
486 "bulk_delete",
487 ];
488 if CRUD_ACTIONS.contains(&action) {
489 return None;
490 }
491 let has_after = endpoint
493 .controller
494 .as_ref()
495 .and_then(|c| c.after.as_deref())
496 .is_some();
497 if has_after {
498 return Some(err(&format!(
499 "resource '{resource}': endpoint '{action}' declares `controller: {{ after: ... }}`, \
500 but `after:` controllers are only valid on conventional CRUD endpoints \
501 (list / get / create / update / delete / bulk_create / bulk_delete).\n\
502 \n\
503 Custom endpoints generate their own response via `handler:`, so the runtime \
504 has no place to merge `ctx.response_extras` or pass through after-hook \
505 mutations. Use a `before:` controller for shared setup (auth augmentation, \
506 tenant scoping, request validation), and put response-shaping logic inside \
507 the handler itself."
508 )));
509 }
510 None
511}
512
513fn validate_handler_only_on_custom(
522 resource: &str,
523 action: &str,
524 endpoint: &shaperail_core::EndpointSpec,
525) -> Option<ValidationError> {
526 let handler = endpoint.handler.as_deref()?;
527 if !crate::rust::HANDLER_CONVENTIONS.contains(&action) {
528 return None;
529 }
530 Some(err(&format!(
531 "resource '{resource}': endpoint '{action}' declares `handler: {handler}`, \
532 but `handler:` is only valid on custom (non-convention) endpoints. \
533 The convention actions list / get / create / update / delete are \
534 dispatched by the runtime CRUD path; a `handler:` declaration on \
535 them would be silently dropped at codegen time.\n\
536 \n\
537 To attach a custom handler at the same HTTP method+path, rename the \
538 endpoint key to a non-convention action (e.g. `post_{resource}`) and \
539 set `method:` and `path:` explicitly:\n\
540 \n\
541 endpoints:\n \
542 post_{resource}:\n \
543 method: POST\n \
544 path: /{resource}\n \
545 handler: {handler}\n\
546 \n\
547 To customize standard CRUD behavior without replacing the runtime \
548 path, use `controller: {{ before: ... }}` for setup logic and \
549 `controller: {{ after: ... }}` for response shaping."
550 )))
551}
552
553fn validate_controller_name(
555 res: &str,
556 action: &str,
557 phase: &str,
558 name: &str,
559 errors: &mut Vec<ValidationError>,
560) {
561 if let Some(wasm_path) = name.strip_prefix(WASM_HOOK_PREFIX) {
562 if wasm_path.is_empty() {
563 errors.push(err(&format!(
564 "resource '{res}': endpoint '{action}' controller.{phase} has 'wasm:' prefix but no path"
565 )));
566 } else if !wasm_path.ends_with(".wasm") {
567 errors.push(err(&format!(
568 "resource '{res}': endpoint '{action}' controller.{phase} WASM path must end with '.wasm', got '{wasm_path}'"
569 )));
570 }
571 }
572}
573
574fn err(message: &str) -> ValidationError {
575 ValidationError {
576 message: message.to_string(),
577 }
578}
579
580#[cfg(test)]
581mod tests {
582 use super::*;
583 use crate::parser::parse_resource;
584
585 #[test]
586 fn valid_resource_passes() {
587 let yaml = include_str!("../../resources/users.yaml");
588 let rd = parse_resource(yaml).unwrap();
589 let errors = validate_resource(&rd);
590 assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
591 }
592
593 #[test]
594 fn enum_without_values() {
595 let yaml = r#"
596resource: items
597version: 1
598schema:
599 id: { type: uuid, primary: true, generated: true }
600 status: { type: enum, required: true }
601"#;
602 let rd = parse_resource(yaml).unwrap();
603 let errors = validate_resource(&rd);
604 assert!(errors
605 .iter()
606 .any(|e| e.message.contains("type enum but has no values")));
607 }
608
609 #[test]
610 fn ref_field_not_uuid() {
611 let yaml = r#"
612resource: items
613version: 1
614schema:
615 id: { type: uuid, primary: true, generated: true }
616 org_id: { type: string, ref: organizations.id }
617"#;
618 let rd = parse_resource(yaml).unwrap();
619 let errors = validate_resource(&rd);
620 assert!(errors
621 .iter()
622 .any(|e| e.message.contains("has ref but is not type uuid")));
623 }
624
625 #[test]
626 fn missing_primary_key() {
627 let yaml = r#"
628resource: items
629version: 1
630schema:
631 name: { type: string, required: true }
632"#;
633 let rd = parse_resource(yaml).unwrap();
634 let errors = validate_resource(&rd);
635 assert!(errors
636 .iter()
637 .any(|e| e.message.contains("must have a primary key")));
638 }
639
640 #[test]
641 fn soft_delete_without_deleted_at() {
642 let yaml = r#"
643resource: items
644version: 1
645schema:
646 id: { type: uuid, primary: true, generated: true }
647 name: { type: string, required: true }
648endpoints:
649 delete:
650 method: DELETE
651 path: /items/:id
652 auth: [admin]
653 soft_delete: true
654"#;
655 let rd = parse_resource(yaml).unwrap();
656 let errors = validate_resource(&rd);
657 assert!(errors.iter().any(|e| e
658 .message
659 .contains("soft_delete but schema has no 'deleted_at'")));
660 }
661
662 #[test]
663 fn input_field_not_in_schema() {
664 let yaml = r#"
665resource: items
666version: 1
667schema:
668 id: { type: uuid, primary: true, generated: true }
669 name: { type: string, required: true }
670endpoints:
671 create:
672 method: POST
673 path: /items
674 auth: [admin]
675 input: [name, nonexistent]
676"#;
677 let rd = parse_resource(yaml).unwrap();
678 let errors = validate_resource(&rd);
679 assert!(errors.iter().any(|e| e
680 .message
681 .contains("input field 'nonexistent' not found in schema")));
682 }
683
684 #[test]
685 fn belongs_to_without_key() {
686 let yaml = r#"
687resource: items
688version: 1
689schema:
690 id: { type: uuid, primary: true, generated: true }
691relations:
692 org: { resource: organizations, type: belongs_to }
693"#;
694 let rd = parse_resource(yaml).unwrap();
695 let errors = validate_resource(&rd);
696 assert!(errors
697 .iter()
698 .any(|e| e.message.contains("belongs_to but has no key")));
699 }
700
701 #[test]
702 fn has_many_without_foreign_key() {
703 let yaml = r#"
704resource: items
705version: 1
706schema:
707 id: { type: uuid, primary: true, generated: true }
708relations:
709 orders: { resource: orders, type: has_many }
710"#;
711 let rd = parse_resource(yaml).unwrap();
712 let errors = validate_resource(&rd);
713 assert!(errors
714 .iter()
715 .any(|e| e.message.contains("has_many but has no foreign_key")));
716 }
717
718 #[test]
719 fn index_references_missing_field() {
720 let yaml = r#"
721resource: items
722version: 1
723schema:
724 id: { type: uuid, primary: true, generated: true }
725indexes:
726 - fields: [missing_field]
727"#;
728 let rd = parse_resource(yaml).unwrap();
729 let errors = validate_resource(&rd);
730 assert!(errors.iter().any(|e| e
731 .message
732 .contains("references field 'missing_field' not in schema")));
733 }
734
735 #[test]
736 fn error_message_format() {
737 let yaml = r#"
738resource: users
739version: 1
740schema:
741 id: { type: uuid, primary: true, generated: true }
742 role: { type: enum }
743"#;
744 let rd = parse_resource(yaml).unwrap();
745 let errors = validate_resource(&rd);
746 assert_eq!(
747 errors[0].message,
748 "resource 'users': field 'role' is type enum but has no values"
749 );
750 }
751
752 #[test]
753 fn wasm_controller_valid_path() {
754 let yaml = r#"
755resource: items
756version: 1
757schema:
758 id: { type: uuid, primary: true, generated: true }
759 name: { type: string, required: true }
760endpoints:
761 create:
762 method: POST
763 path: /items
764 input: [name]
765 controller: { before: "wasm:./plugins/my_validator.wasm" }
766"#;
767 let rd = parse_resource(yaml).unwrap();
768 let errors = validate_resource(&rd);
769 assert!(
770 errors.is_empty(),
771 "Expected no errors for valid WASM controller, got: {errors:?}"
772 );
773 }
774
775 #[test]
776 fn wasm_controller_missing_extension() {
777 let yaml = r#"
778resource: items
779version: 1
780schema:
781 id: { type: uuid, primary: true, generated: true }
782 name: { type: string, required: true }
783endpoints:
784 create:
785 method: POST
786 path: /items
787 input: [name]
788 controller: { before: "wasm:./plugins/my_validator" }
789"#;
790 let rd = parse_resource(yaml).unwrap();
791 let errors = validate_resource(&rd);
792 assert!(errors
793 .iter()
794 .any(|e| e.message.contains("WASM path must end with '.wasm'")));
795 }
796
797 #[test]
798 fn wasm_controller_empty_path() {
799 let yaml = r#"
800resource: items
801version: 1
802schema:
803 id: { type: uuid, primary: true, generated: true }
804 name: { type: string, required: true }
805endpoints:
806 create:
807 method: POST
808 path: /items
809 input: [name]
810 controller: { before: "wasm:" }
811"#;
812 let rd = parse_resource(yaml).unwrap();
813 let errors = validate_resource(&rd);
814 assert!(errors
815 .iter()
816 .any(|e| e.message.contains("'wasm:' prefix but no path")));
817 }
818
819 #[test]
820 fn upload_endpoint_valid_when_file_field_declared() {
821 let yaml = r#"
822resource: assets
823version: 1
824schema:
825 id: { type: uuid, primary: true, generated: true }
826 file: { type: file, required: true }
827 file_filename: { type: string }
828 file_mime_type: { type: string }
829 file_size: { type: bigint }
830 updated_at: { type: timestamp, generated: true }
831endpoints:
832 upload:
833 method: POST
834 path: /assets/upload
835 input: [file]
836 upload:
837 field: file
838 storage: local
839 max_size: 5mb
840"#;
841 let rd = parse_resource(yaml).unwrap();
842 let errors = validate_resource(&rd);
843 assert!(
844 errors.is_empty(),
845 "Expected valid upload resource, got {errors:?}"
846 );
847 }
848
849 #[test]
850 fn upload_endpoint_requires_file_field() {
851 let yaml = r#"
852resource: assets
853version: 1
854schema:
855 id: { type: uuid, primary: true, generated: true }
856 file_path: { type: string, required: true }
857endpoints:
858 upload:
859 method: POST
860 path: /assets/upload
861 input: [file_path]
862 upload:
863 field: file_path
864 storage: local
865 max_size: 5mb
866"#;
867 let rd = parse_resource(yaml).unwrap();
868 let errors = validate_resource(&rd);
869 assert!(errors.iter().any(|e| e
870 .message
871 .contains("upload field 'file_path' must be type file")));
872 }
873
874 #[test]
875 fn tenant_key_valid_uuid_field() {
876 let yaml = r#"
877resource: projects
878version: 1
879tenant_key: org_id
880schema:
881 id: { type: uuid, primary: true, generated: true }
882 org_id: { type: uuid, ref: organizations.id, required: true }
883 name: { type: string, required: true }
884"#;
885 let rd = parse_resource(yaml).unwrap();
886 let errors = validate_resource(&rd);
887 assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
888 }
889
890 #[test]
891 fn tenant_key_missing_field() {
892 let yaml = r#"
893resource: projects
894version: 1
895tenant_key: org_id
896schema:
897 id: { type: uuid, primary: true, generated: true }
898 name: { type: string, required: true }
899"#;
900 let rd = parse_resource(yaml).unwrap();
901 let errors = validate_resource(&rd);
902 assert!(errors.iter().any(|e| e
903 .message
904 .contains("tenant_key 'org_id' not found in schema")));
905 }
906
907 #[test]
908 fn transient_field_valid() {
909 let yaml = r#"
910resource: users
911version: 1
912schema:
913 id: { type: uuid, primary: true, generated: true }
914 password: { type: string, transient: true, min: 12, required: true }
915 password_hash: { type: string, required: true }
916endpoints:
917 create:
918 method: POST
919 path: /users
920 input: [password]
921 controller: { before: hash_password }
922"#;
923 let rd = parse_resource(yaml).unwrap();
924 let errors = validate_resource(&rd);
925 assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
926 }
927
928 #[test]
929 fn transient_field_dead_when_not_in_input() {
930 let yaml = r#"
931resource: users
932version: 1
933schema:
934 id: { type: uuid, primary: true, generated: true }
935 password: { type: string, transient: true, min: 12 }
936endpoints:
937 create:
938 method: POST
939 path: /users
940 input: []
941"#;
942 let rd = parse_resource(yaml).unwrap();
943 let errors = validate_resource(&rd);
944 assert!(errors.iter().any(|e| e
945 .message
946 .contains("transient field 'password' is not declared in any endpoint's input")));
947 }
948
949 #[test]
950 fn transient_field_rejects_primary() {
951 let yaml = r#"
952resource: users
953version: 1
954schema:
955 bad: { type: uuid, transient: true, primary: true }
956"#;
957 let rd = parse_resource(yaml).unwrap();
958 let errors = validate_resource(&rd);
959 assert!(errors
960 .iter()
961 .any(|e| e.message.contains("cannot be both transient and primary")));
962 }
963
964 #[test]
965 fn transient_field_rejects_generated() {
966 let yaml = r#"
967resource: users
968version: 1
969schema:
970 id: { type: uuid, primary: true, generated: true }
971 bad: { type: timestamp, transient: true, generated: true }
972endpoints:
973 create:
974 method: POST
975 path: /users
976 input: [bad]
977"#;
978 let rd = parse_resource(yaml).unwrap();
979 let errors = validate_resource(&rd);
980 assert!(errors
981 .iter()
982 .any(|e| e.message.contains("cannot be both transient and generated")));
983 }
984
985 #[test]
986 fn transient_field_rejects_ref() {
987 let yaml = r#"
988resource: users
989version: 1
990schema:
991 id: { type: uuid, primary: true, generated: true }
992 bad: { type: uuid, transient: true, ref: orgs.id }
993endpoints:
994 create:
995 method: POST
996 path: /users
997 input: [bad]
998"#;
999 let rd = parse_resource(yaml).unwrap();
1000 let errors = validate_resource(&rd);
1001 assert!(errors.iter().any(|e| e
1002 .message
1003 .contains("cannot be both transient and have a ref")));
1004 }
1005
1006 #[test]
1007 fn transient_field_rejects_unique() {
1008 let yaml = r#"
1009resource: users
1010version: 1
1011schema:
1012 id: { type: uuid, primary: true, generated: true }
1013 bad: { type: string, transient: true, unique: true }
1014endpoints:
1015 create:
1016 method: POST
1017 path: /users
1018 input: [bad]
1019"#;
1020 let rd = parse_resource(yaml).unwrap();
1021 let errors = validate_resource(&rd);
1022 assert!(errors
1023 .iter()
1024 .any(|e| e.message.contains("cannot be both transient and unique")));
1025 }
1026
1027 #[test]
1028 fn transient_field_rejects_default() {
1029 let yaml = r#"
1030resource: users
1031version: 1
1032schema:
1033 id: { type: uuid, primary: true, generated: true }
1034 bad: { type: string, transient: true, default: "x" }
1035endpoints:
1036 create:
1037 method: POST
1038 path: /users
1039 input: [bad]
1040"#;
1041 let rd = parse_resource(yaml).unwrap();
1042 let errors = validate_resource(&rd);
1043 assert!(errors.iter().any(|e| e
1044 .message
1045 .contains("cannot be both transient and have a default")));
1046 }
1047
1048 #[test]
1049 fn tenant_key_wrong_type() {
1050 let yaml = r#"
1051resource: projects
1052version: 1
1053tenant_key: org_name
1054schema:
1055 id: { type: uuid, primary: true, generated: true }
1056 org_name: { type: string, required: true }
1057"#;
1058 let rd = parse_resource(yaml).unwrap();
1059 let errors = validate_resource(&rd);
1060 assert!(errors.iter().any(|e| e
1061 .message
1062 .contains("tenant_key 'org_name' must reference a uuid field")));
1063 }
1064
1065 #[test]
1066 fn reject_after_controller_on_custom_endpoint() {
1067 let yaml = r#"
1068resource: agents
1069version: 1
1070schema:
1071 id: { type: uuid, primary: true, generated: true }
1072endpoints:
1073 regenerate_secret:
1074 method: POST
1075 path: /agents/:id/regenerate_secret
1076 auth: [admin]
1077 controller: { after: my_after }
1078"#;
1079 let rd = parse_resource(yaml).unwrap();
1080 let errors = validate_resource(&rd);
1081 assert!(
1082 errors
1083 .iter()
1084 .any(|e| e.message.contains("declares `controller: { after: ... }`")),
1085 "expected a CustomEndpointWithAfterController error, got: {errors:?}"
1086 );
1087 }
1088
1089 #[test]
1090 fn allow_before_controller_on_custom_endpoint() {
1091 let yaml = r#"
1092resource: agents
1093version: 1
1094schema:
1095 id: { type: uuid, primary: true, generated: true }
1096endpoints:
1097 regenerate_secret:
1098 method: POST
1099 path: /agents/:id/regenerate_secret
1100 auth: [admin]
1101 controller: { before: my_before }
1102"#;
1103 let rd = parse_resource(yaml).unwrap();
1104 let errors = validate_resource(&rd);
1105 assert!(
1106 errors.is_empty(),
1107 "before:-only controller on a custom endpoint should validate clean, got: {errors:?}"
1108 );
1109 }
1110
1111 #[test]
1112 fn allow_controller_on_crud_endpoints() {
1113 let yaml = r#"
1114resource: agents
1115version: 1
1116schema:
1117 id: { type: uuid, primary: true, generated: true }
1118 name: { type: string, required: true }
1119endpoints:
1120 create:
1121 method: POST
1122 path: /agents
1123 input: [name]
1124 controller: { before: my_before }
1125"#;
1126 let rd = parse_resource(yaml).unwrap();
1127 let errors = validate_resource(&rd);
1128 assert!(
1129 !errors.iter().any(|e| e
1130 .message
1131 .contains("declares `controller:` but is a custom endpoint")),
1132 "create endpoint with controller should NOT trip the rule, got: {errors:?}"
1133 );
1134 }
1135
1136 #[test]
1137 fn handler_on_convention_endpoint_rejected() {
1138 for action in &["list", "get", "create", "update", "delete"] {
1139 let yaml = format!(
1140 r#"
1141resource: items
1142version: 1
1143schema:
1144 id: {{ type: uuid, primary: true, generated: true }}
1145 name: {{ type: string, required: true }}
1146endpoints:
1147 {action}:
1148 handler: my_handler
1149"#
1150 );
1151 let rd = parse_resource(&yaml).unwrap();
1152 let errors = validate_resource(&rd);
1153 assert!(
1154 errors
1155 .iter()
1156 .any(|e| e.message.contains("`handler: my_handler`")
1157 && e.message
1158 .contains("only valid on custom (non-convention) endpoints")),
1159 "expected handler-on-convention error for action '{action}', got: {errors:?}"
1160 );
1161 }
1162 }
1163
1164 #[test]
1165 fn handler_on_custom_endpoint_allowed() {
1166 let yaml = r#"
1167resource: items
1168version: 1
1169schema:
1170 id: { type: uuid, primary: true, generated: true }
1171 name: { type: string, required: true }
1172endpoints:
1173 post_item:
1174 method: POST
1175 path: /items
1176 handler: create_item_custom
1177"#;
1178 let rd = parse_resource(yaml).unwrap();
1179 let errors = validate_resource(&rd);
1180 assert!(
1181 !errors
1182 .iter()
1183 .any(|e| e.message.contains("only valid on custom")),
1184 "custom endpoint with handler must not trip the rule, got: {errors:?}"
1185 );
1186 }
1187
1188 #[test]
1189 fn convention_endpoint_without_handler_allowed() {
1190 let yaml = r#"
1191resource: items
1192version: 1
1193schema:
1194 id: { type: uuid, primary: true, generated: true }
1195 name: { type: string, required: true }
1196endpoints:
1197 create:
1198 input: [name]
1199"#;
1200 let rd = parse_resource(yaml).unwrap();
1201 let errors = validate_resource(&rd);
1202 assert!(
1203 !errors
1204 .iter()
1205 .any(|e| e.message.contains("only valid on custom")),
1206 "convention endpoint without handler must not trip the rule, got: {errors:?}"
1207 );
1208 }
1209
1210 fn array_field(items: shaperail_core::ItemsSpec) -> shaperail_core::FieldSchema {
1211 shaperail_core::FieldSchema {
1212 field_type: shaperail_core::FieldType::Array,
1213 primary: false,
1214 generated: false,
1215 required: false,
1216 unique: false,
1217 nullable: false,
1218 reference: None,
1219 min: None,
1220 max: None,
1221 format: None,
1222 values: None,
1223 default: None,
1224 sensitive: false,
1225 search: false,
1226 items: Some(items),
1227 transient: false,
1228 }
1229 }
1230
1231 fn resource_with_array(
1232 name: &str,
1233 field: shaperail_core::FieldSchema,
1234 ) -> shaperail_core::ResourceDefinition {
1235 let mut schema = indexmap::IndexMap::new();
1236 schema.insert(name.to_string(), field);
1237 shaperail_core::ResourceDefinition {
1238 resource: "test".to_string(),
1239 version: 1,
1240 db: None,
1241 tenant_key: None,
1242 schema,
1243 endpoints: None,
1244 relations: None,
1245 indexes: None,
1246 }
1247 }
1248
1249 #[test]
1250 fn items_nested_array_rejected() {
1251 let field = array_field(shaperail_core::ItemsSpec::of(
1252 shaperail_core::FieldType::Array,
1253 ));
1254 let rd = resource_with_array("nested", field);
1255 let errors = validate_resource(&rd);
1256 assert!(errors
1257 .iter()
1258 .any(|e| e.message.contains("nested array") || e.message.contains("type: json")));
1259 }
1260
1261 #[test]
1262 fn items_enum_requires_values() {
1263 let field = array_field(shaperail_core::ItemsSpec::of(
1264 shaperail_core::FieldType::Enum,
1265 ));
1266 let rd = resource_with_array("flags", field);
1267 let errors = validate_resource(&rd);
1268 assert!(errors.iter().any(|e| e.message.contains("values")));
1269 }
1270
1271 #[test]
1272 fn items_format_only_on_string() {
1273 let mut items = shaperail_core::ItemsSpec::of(shaperail_core::FieldType::Integer);
1274 items.format = Some("email".to_string());
1275 let field = array_field(items);
1276 let rd = resource_with_array("nums", field);
1277 let errors = validate_resource(&rd);
1278 assert!(errors.iter().any(|e| e.message.contains("format")));
1279 }
1280
1281 #[test]
1282 fn items_ref_requires_uuid() {
1283 let mut items = shaperail_core::ItemsSpec::of(shaperail_core::FieldType::String);
1284 items.reference = Some("organizations.id".to_string());
1285 let field = array_field(items);
1286 let rd = resource_with_array("badrefs", field);
1287 let errors = validate_resource(&rd);
1288 assert!(errors
1289 .iter()
1290 .any(|e| e.message.contains("ref") && e.message.contains("uuid")));
1291 }
1292
1293 #[test]
1294 fn items_ref_format_must_be_resource_dot_field() {
1295 let mut items = shaperail_core::ItemsSpec::of(shaperail_core::FieldType::Uuid);
1296 items.reference = Some("organizations".to_string()); let field = array_field(items);
1298 let rd = resource_with_array("orgs", field);
1299 let errors = validate_resource(&rd);
1300 assert!(errors.iter().any(|e| e.message.contains("resource.field")));
1301 }
1302
1303 #[test]
1304 fn items_uuid_ref_valid() {
1305 let mut items = shaperail_core::ItemsSpec::of(shaperail_core::FieldType::Uuid);
1306 items.reference = Some("organizations.id".to_string());
1307 let field = array_field(items);
1308 let rd = resource_with_array("tags", field);
1309 let errors = validate_resource(&rd);
1310 assert!(errors.iter().all(|e| !e.message.contains("tags")));
1312 }
1313}