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 let names = before.names();
254 if names.is_empty() {
255 errors.push(err(&format!(
256 "[SR063] resource '{res}': endpoint '{action}' has an empty controller.before list"
257 )));
258 }
259 for name in names {
260 if name.is_empty() {
261 errors.push(err(&format!(
262 "resource '{res}': endpoint '{action}' has an empty controller.before name"
263 )));
264 continue;
265 }
266 validate_controller_name(res, action, "before", name, &mut errors);
267 }
268 }
269 if let Some(after) = &controller.after {
270 let names = after.names();
271 if names.is_empty() {
272 errors.push(err(&format!(
273 "[SR063] resource '{res}': endpoint '{action}' has an empty controller.after list"
274 )));
275 }
276 for name in names {
277 if name.is_empty() {
278 errors.push(err(&format!(
279 "resource '{res}': endpoint '{action}' has an empty controller.after name"
280 )));
281 continue;
282 }
283 validate_controller_name(res, action, "after", name, &mut errors);
284 }
285 }
286 }
287
288 if let Some(events) = &ep.events {
289 for event in events {
290 if event.is_empty() {
291 errors.push(err(&format!(
292 "resource '{res}': endpoint '{action}' has an empty event name"
293 )));
294 }
295 }
296 }
297
298 if let Some(jobs) = &ep.jobs {
299 for job in jobs {
300 if job.is_empty() {
301 errors.push(err(&format!(
302 "resource '{res}': endpoint '{action}' has an empty job name"
303 )));
304 }
305 }
306 }
307
308 if let Some(input) = &ep.input {
310 for field_name in input {
311 if !rd.schema.contains_key(field_name) {
312 errors.push(err(&format!(
313 "resource '{res}': endpoint '{action}' input field '{field_name}' not found in schema"
314 )));
315 }
316 }
317 }
318
319 if let Some(filters) = &ep.filters {
321 for field_name in filters {
322 if !rd.schema.contains_key(field_name) {
323 errors.push(err(&format!(
324 "resource '{res}': endpoint '{action}' filter field '{field_name}' not found in schema"
325 )));
326 }
327 }
328 }
329
330 if let Some(search) = &ep.search {
332 for field_name in search {
333 if !rd.schema.contains_key(field_name) {
334 errors.push(err(&format!(
335 "resource '{res}': endpoint '{action}' search field '{field_name}' not found in schema"
336 )));
337 }
338 }
339 }
340
341 if let Some(sort) = &ep.sort {
343 for field_name in sort {
344 if !rd.schema.contains_key(field_name) {
345 errors.push(err(&format!(
346 "resource '{res}': endpoint '{action}' sort field '{field_name}' not found in schema"
347 )));
348 }
349 }
350 }
351
352 if ep.soft_delete && !rd.schema.contains_key("deleted_at") {
354 errors.push(err(&format!(
355 "resource '{res}': endpoint '{action}' has soft_delete but schema has no 'deleted_at' field"
356 )));
357 }
358
359 if let Some(upload) = &ep.upload {
360 match ep.method.as_ref() {
361 Some(HttpMethod::Post | HttpMethod::Patch | HttpMethod::Put) => {}
362 Some(_) => errors.push(err(&format!(
363 "resource '{res}': endpoint '{action}' uses upload but method must be POST, PATCH, or PUT"
364 ))),
365 None => {} }
367
368 match rd.schema.get(&upload.field) {
369 Some(field) if field.field_type == FieldType::File => {}
370 Some(_) => errors.push(err(&format!(
371 "resource '{res}': endpoint '{action}' upload field '{}' must be type file",
372 upload.field
373 ))),
374 None => errors.push(err(&format!(
375 "resource '{res}': endpoint '{action}' upload field '{}' not found in schema",
376 upload.field
377 ))),
378 }
379
380 if !matches!(upload.storage.as_str(), "local" | "s3" | "gcs" | "azure") {
381 errors.push(err(&format!(
382 "resource '{res}': endpoint '{action}' upload storage '{}' is invalid",
383 upload.storage
384 )));
385 }
386
387 if !ep
388 .input
389 .as_ref()
390 .is_some_and(|fields| fields.iter().any(|field| field == &upload.field))
391 {
392 errors.push(err(&format!(
393 "resource '{res}': endpoint '{action}' upload field '{}' must appear in input",
394 upload.field
395 )));
396 }
397
398 for (suffix, expected_types) in [
399 ("filename", &[FieldType::String][..]),
400 ("mime_type", &[FieldType::String][..]),
401 ("size", &[FieldType::Integer][..]),
402 ] {
403 let companion = format!("{}_{}", upload.field, suffix);
404 if let Some(field) = rd.schema.get(&companion) {
405 if !expected_types.contains(&field.field_type) {
406 let expected = expected_types
407 .iter()
408 .map(ToString::to_string)
409 .collect::<Vec<_>>()
410 .join(" or ");
411 errors.push(err(&format!(
412 "resource '{res}': companion upload field '{companion}' must be type {expected}"
413 )));
414 }
415 }
416 }
417 }
418 }
419 }
420
421 if let Some(relations) = &rd.relations {
423 for (name, rel) in relations {
424 use shaperail_core::RelationType;
425
426 if rel.relation_type == RelationType::BelongsTo && rel.key.is_none() {
428 errors.push(err(&format!(
429 "resource '{res}': relation '{name}' is belongs_to but has no key"
430 )));
431 }
432
433 if matches!(
435 rel.relation_type,
436 RelationType::HasMany | RelationType::HasOne
437 ) && rel.foreign_key.is_none()
438 {
439 errors.push(err(&format!(
440 "resource '{res}': relation '{name}' is {} but has no foreign_key",
441 rel.relation_type
442 )));
443 }
444
445 if let Some(key) = &rel.key {
447 if !rd.schema.contains_key(key) {
448 errors.push(err(&format!(
449 "resource '{res}': relation '{name}' key '{key}' not found in schema"
450 )));
451 }
452 }
453 }
454 }
455
456 if let Some(indexes) = &rd.indexes {
458 for (i, idx) in indexes.iter().enumerate() {
459 if idx.fields.is_empty() {
460 errors.push(err(&format!("resource '{res}': index {i} has no fields")));
461 }
462 for field_name in &idx.fields {
463 if !rd.schema.contains_key(field_name) {
464 errors.push(err(&format!(
465 "resource '{res}': index {i} references field '{field_name}' not in schema"
466 )));
467 }
468 }
469 if let Some(order) = &idx.order {
470 if order != "asc" && order != "desc" {
471 errors.push(err(&format!(
472 "resource '{res}': index {i} has invalid order '{order}', must be 'asc' or 'desc'"
473 )));
474 }
475 }
476 }
477 }
478
479 errors
480}
481
482fn validate_controller_only_on_crud(
493 resource: &str,
494 action: &str,
495 endpoint: &shaperail_core::EndpointSpec,
496) -> Option<ValidationError> {
497 const CRUD_ACTIONS: &[&str] = &[
498 "list",
499 "get",
500 "create",
501 "update",
502 "delete",
503 "bulk_create",
504 "bulk_delete",
505 ];
506 if CRUD_ACTIONS.contains(&action) {
507 return None;
508 }
509 let has_after = endpoint
511 .controller
512 .as_ref()
513 .map(|c| !c.after_names().is_empty())
514 .unwrap_or(false);
515 if has_after {
516 return Some(err(&format!(
517 "resource '{resource}': endpoint '{action}' declares `controller: {{ after: ... }}`, \
518 but `after:` controllers are only valid on conventional CRUD endpoints \
519 (list / get / create / update / delete / bulk_create / bulk_delete).\n\
520 \n\
521 Custom endpoints generate their own response via `handler:`, so the runtime \
522 has no place to merge `ctx.response_extras` or pass through after-hook \
523 mutations. Use a `before:` controller for shared setup (auth augmentation, \
524 tenant scoping, request validation), and put response-shaping logic inside \
525 the handler itself."
526 )));
527 }
528 None
529}
530
531fn validate_handler_only_on_custom(
540 resource: &str,
541 action: &str,
542 endpoint: &shaperail_core::EndpointSpec,
543) -> Option<ValidationError> {
544 let handler = endpoint.handler.as_deref()?;
545 if !crate::rust::HANDLER_CONVENTIONS.contains(&action) {
546 return None;
547 }
548 Some(err(&format!(
549 "resource '{resource}': endpoint '{action}' declares `handler: {handler}`, \
550 but `handler:` is only valid on custom (non-convention) endpoints. \
551 The convention actions list / get / create / update / delete are \
552 dispatched by the runtime CRUD path; a `handler:` declaration on \
553 them would be silently dropped at codegen time.\n\
554 \n\
555 To attach a custom handler at the same HTTP method+path, rename the \
556 endpoint key to a non-convention action (e.g. `post_{resource}`) and \
557 set `method:` and `path:` explicitly:\n\
558 \n\
559 endpoints:\n \
560 post_{resource}:\n \
561 method: POST\n \
562 path: /{resource}\n \
563 handler: {handler}\n\
564 \n\
565 To customize standard CRUD behavior without replacing the runtime \
566 path, use `controller: {{ before: ... }}` for setup logic and \
567 `controller: {{ after: ... }}` for response shaping."
568 )))
569}
570
571fn validate_controller_name(
573 res: &str,
574 action: &str,
575 phase: &str,
576 name: &str,
577 errors: &mut Vec<ValidationError>,
578) {
579 if let Some(wasm_path) = name.strip_prefix(WASM_HOOK_PREFIX) {
580 if wasm_path.is_empty() {
581 errors.push(err(&format!(
582 "resource '{res}': endpoint '{action}' controller.{phase} has 'wasm:' prefix but no path"
583 )));
584 } else if !wasm_path.ends_with(".wasm") {
585 errors.push(err(&format!(
586 "resource '{res}': endpoint '{action}' controller.{phase} WASM path must end with '.wasm', got '{wasm_path}'"
587 )));
588 }
589 }
590}
591
592fn err(message: &str) -> ValidationError {
593 ValidationError {
594 message: message.to_string(),
595 }
596}
597
598#[cfg(test)]
599mod tests {
600 use super::*;
601 use crate::parser::parse_resource;
602
603 #[test]
604 fn valid_resource_passes() {
605 let yaml = include_str!("../../resources/users.yaml");
606 let rd = parse_resource(yaml).unwrap();
607 let errors = validate_resource(&rd);
608 assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
609 }
610
611 #[test]
612 fn enum_without_values() {
613 let yaml = r#"
614resource: items
615version: 1
616schema:
617 id: { type: uuid, primary: true, generated: true }
618 status: { type: enum, required: true }
619"#;
620 let rd = parse_resource(yaml).unwrap();
621 let errors = validate_resource(&rd);
622 assert!(errors
623 .iter()
624 .any(|e| e.message.contains("type enum but has no values")));
625 }
626
627 #[test]
628 fn ref_field_not_uuid() {
629 let yaml = r#"
630resource: items
631version: 1
632schema:
633 id: { type: uuid, primary: true, generated: true }
634 org_id: { type: string, ref: organizations.id }
635"#;
636 let rd = parse_resource(yaml).unwrap();
637 let errors = validate_resource(&rd);
638 assert!(errors
639 .iter()
640 .any(|e| e.message.contains("has ref but is not type uuid")));
641 }
642
643 #[test]
644 fn missing_primary_key() {
645 let yaml = r#"
646resource: items
647version: 1
648schema:
649 name: { type: string, required: true }
650"#;
651 let rd = parse_resource(yaml).unwrap();
652 let errors = validate_resource(&rd);
653 assert!(errors
654 .iter()
655 .any(|e| e.message.contains("must have a primary key")));
656 }
657
658 #[test]
659 fn soft_delete_without_deleted_at() {
660 let yaml = r#"
661resource: items
662version: 1
663schema:
664 id: { type: uuid, primary: true, generated: true }
665 name: { type: string, required: true }
666endpoints:
667 delete:
668 method: DELETE
669 path: /items/:id
670 auth: [admin]
671 soft_delete: true
672"#;
673 let rd = parse_resource(yaml).unwrap();
674 let errors = validate_resource(&rd);
675 assert!(errors.iter().any(|e| e
676 .message
677 .contains("soft_delete but schema has no 'deleted_at'")));
678 }
679
680 #[test]
681 fn input_field_not_in_schema() {
682 let yaml = r#"
683resource: items
684version: 1
685schema:
686 id: { type: uuid, primary: true, generated: true }
687 name: { type: string, required: true }
688endpoints:
689 create:
690 method: POST
691 path: /items
692 auth: [admin]
693 input: [name, nonexistent]
694"#;
695 let rd = parse_resource(yaml).unwrap();
696 let errors = validate_resource(&rd);
697 assert!(errors.iter().any(|e| e
698 .message
699 .contains("input field 'nonexistent' not found in schema")));
700 }
701
702 #[test]
703 fn belongs_to_without_key() {
704 let yaml = r#"
705resource: items
706version: 1
707schema:
708 id: { type: uuid, primary: true, generated: true }
709relations:
710 org: { resource: organizations, type: belongs_to }
711"#;
712 let rd = parse_resource(yaml).unwrap();
713 let errors = validate_resource(&rd);
714 assert!(errors
715 .iter()
716 .any(|e| e.message.contains("belongs_to but has no key")));
717 }
718
719 #[test]
720 fn has_many_without_foreign_key() {
721 let yaml = r#"
722resource: items
723version: 1
724schema:
725 id: { type: uuid, primary: true, generated: true }
726relations:
727 orders: { resource: orders, type: has_many }
728"#;
729 let rd = parse_resource(yaml).unwrap();
730 let errors = validate_resource(&rd);
731 assert!(errors
732 .iter()
733 .any(|e| e.message.contains("has_many but has no foreign_key")));
734 }
735
736 #[test]
737 fn index_references_missing_field() {
738 let yaml = r#"
739resource: items
740version: 1
741schema:
742 id: { type: uuid, primary: true, generated: true }
743indexes:
744 - fields: [missing_field]
745"#;
746 let rd = parse_resource(yaml).unwrap();
747 let errors = validate_resource(&rd);
748 assert!(errors.iter().any(|e| e
749 .message
750 .contains("references field 'missing_field' not in schema")));
751 }
752
753 #[test]
754 fn error_message_format() {
755 let yaml = r#"
756resource: users
757version: 1
758schema:
759 id: { type: uuid, primary: true, generated: true }
760 role: { type: enum }
761"#;
762 let rd = parse_resource(yaml).unwrap();
763 let errors = validate_resource(&rd);
764 assert_eq!(
765 errors[0].message,
766 "resource 'users': field 'role' is type enum but has no values"
767 );
768 }
769
770 #[test]
771 fn wasm_controller_valid_path() {
772 let yaml = r#"
773resource: items
774version: 1
775schema:
776 id: { type: uuid, primary: true, generated: true }
777 name: { type: string, required: true }
778endpoints:
779 create:
780 method: POST
781 path: /items
782 input: [name]
783 controller: { before: "wasm:./plugins/my_validator.wasm" }
784"#;
785 let rd = parse_resource(yaml).unwrap();
786 let errors = validate_resource(&rd);
787 assert!(
788 errors.is_empty(),
789 "Expected no errors for valid WASM controller, got: {errors:?}"
790 );
791 }
792
793 #[test]
794 fn wasm_controller_missing_extension() {
795 let yaml = r#"
796resource: items
797version: 1
798schema:
799 id: { type: uuid, primary: true, generated: true }
800 name: { type: string, required: true }
801endpoints:
802 create:
803 method: POST
804 path: /items
805 input: [name]
806 controller: { before: "wasm:./plugins/my_validator" }
807"#;
808 let rd = parse_resource(yaml).unwrap();
809 let errors = validate_resource(&rd);
810 assert!(errors
811 .iter()
812 .any(|e| e.message.contains("WASM path must end with '.wasm'")));
813 }
814
815 #[test]
816 fn wasm_controller_empty_path() {
817 let yaml = r#"
818resource: items
819version: 1
820schema:
821 id: { type: uuid, primary: true, generated: true }
822 name: { type: string, required: true }
823endpoints:
824 create:
825 method: POST
826 path: /items
827 input: [name]
828 controller: { before: "wasm:" }
829"#;
830 let rd = parse_resource(yaml).unwrap();
831 let errors = validate_resource(&rd);
832 assert!(errors
833 .iter()
834 .any(|e| e.message.contains("'wasm:' prefix but no path")));
835 }
836
837 #[test]
838 fn upload_endpoint_valid_when_file_field_declared() {
839 let yaml = r#"
840resource: assets
841version: 1
842schema:
843 id: { type: uuid, primary: true, generated: true }
844 file: { type: file, required: true }
845 file_filename: { type: string }
846 file_mime_type: { type: string }
847 file_size: { type: integer }
848 updated_at: { type: timestamp, generated: true }
849endpoints:
850 upload:
851 method: POST
852 path: /assets/upload
853 input: [file]
854 upload:
855 field: file
856 storage: local
857 max_size: 5mb
858"#;
859 let rd = parse_resource(yaml).unwrap();
860 let errors = validate_resource(&rd);
861 assert!(
862 errors.is_empty(),
863 "Expected valid upload resource, got {errors:?}"
864 );
865 }
866
867 #[test]
868 fn upload_endpoint_requires_file_field() {
869 let yaml = r#"
870resource: assets
871version: 1
872schema:
873 id: { type: uuid, primary: true, generated: true }
874 file_path: { type: string, required: true }
875endpoints:
876 upload:
877 method: POST
878 path: /assets/upload
879 input: [file_path]
880 upload:
881 field: file_path
882 storage: local
883 max_size: 5mb
884"#;
885 let rd = parse_resource(yaml).unwrap();
886 let errors = validate_resource(&rd);
887 assert!(errors.iter().any(|e| e
888 .message
889 .contains("upload field 'file_path' must be type file")));
890 }
891
892 #[test]
893 fn tenant_key_valid_uuid_field() {
894 let yaml = r#"
895resource: projects
896version: 1
897tenant_key: org_id
898schema:
899 id: { type: uuid, primary: true, generated: true }
900 org_id: { type: uuid, ref: organizations.id, required: true }
901 name: { type: string, required: true }
902"#;
903 let rd = parse_resource(yaml).unwrap();
904 let errors = validate_resource(&rd);
905 assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
906 }
907
908 #[test]
909 fn tenant_key_missing_field() {
910 let yaml = r#"
911resource: projects
912version: 1
913tenant_key: org_id
914schema:
915 id: { type: uuid, primary: true, generated: true }
916 name: { type: string, required: true }
917"#;
918 let rd = parse_resource(yaml).unwrap();
919 let errors = validate_resource(&rd);
920 assert!(errors.iter().any(|e| e
921 .message
922 .contains("tenant_key 'org_id' not found in schema")));
923 }
924
925 #[test]
926 fn transient_field_valid() {
927 let yaml = r#"
928resource: users
929version: 1
930schema:
931 id: { type: uuid, primary: true, generated: true }
932 password: { type: string, transient: true, min: 12, required: true }
933 password_hash: { type: string, required: true }
934endpoints:
935 create:
936 method: POST
937 path: /users
938 input: [password]
939 controller: { before: hash_password }
940"#;
941 let rd = parse_resource(yaml).unwrap();
942 let errors = validate_resource(&rd);
943 assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
944 }
945
946 #[test]
947 fn transient_field_dead_when_not_in_input() {
948 let yaml = r#"
949resource: users
950version: 1
951schema:
952 id: { type: uuid, primary: true, generated: true }
953 password: { type: string, transient: true, min: 12 }
954endpoints:
955 create:
956 method: POST
957 path: /users
958 input: []
959"#;
960 let rd = parse_resource(yaml).unwrap();
961 let errors = validate_resource(&rd);
962 assert!(errors.iter().any(|e| e
963 .message
964 .contains("transient field 'password' is not declared in any endpoint's input")));
965 }
966
967 #[test]
968 fn transient_field_rejects_primary() {
969 let yaml = r#"
970resource: users
971version: 1
972schema:
973 bad: { type: uuid, transient: true, primary: true }
974"#;
975 let rd = parse_resource(yaml).unwrap();
976 let errors = validate_resource(&rd);
977 assert!(errors
978 .iter()
979 .any(|e| e.message.contains("cannot be both transient and primary")));
980 }
981
982 #[test]
983 fn transient_field_rejects_generated() {
984 let yaml = r#"
985resource: users
986version: 1
987schema:
988 id: { type: uuid, primary: true, generated: true }
989 bad: { type: timestamp, transient: true, generated: true }
990endpoints:
991 create:
992 method: POST
993 path: /users
994 input: [bad]
995"#;
996 let rd = parse_resource(yaml).unwrap();
997 let errors = validate_resource(&rd);
998 assert!(errors
999 .iter()
1000 .any(|e| e.message.contains("cannot be both transient and generated")));
1001 }
1002
1003 #[test]
1004 fn transient_field_rejects_ref() {
1005 let yaml = r#"
1006resource: users
1007version: 1
1008schema:
1009 id: { type: uuid, primary: true, generated: true }
1010 bad: { type: uuid, transient: true, ref: orgs.id }
1011endpoints:
1012 create:
1013 method: POST
1014 path: /users
1015 input: [bad]
1016"#;
1017 let rd = parse_resource(yaml).unwrap();
1018 let errors = validate_resource(&rd);
1019 assert!(errors.iter().any(|e| e
1020 .message
1021 .contains("cannot be both transient and have a ref")));
1022 }
1023
1024 #[test]
1025 fn transient_field_rejects_unique() {
1026 let yaml = r#"
1027resource: users
1028version: 1
1029schema:
1030 id: { type: uuid, primary: true, generated: true }
1031 bad: { type: string, transient: true, unique: true }
1032endpoints:
1033 create:
1034 method: POST
1035 path: /users
1036 input: [bad]
1037"#;
1038 let rd = parse_resource(yaml).unwrap();
1039 let errors = validate_resource(&rd);
1040 assert!(errors
1041 .iter()
1042 .any(|e| e.message.contains("cannot be both transient and unique")));
1043 }
1044
1045 #[test]
1046 fn transient_field_rejects_default() {
1047 let yaml = r#"
1048resource: users
1049version: 1
1050schema:
1051 id: { type: uuid, primary: true, generated: true }
1052 bad: { type: string, transient: true, default: "x" }
1053endpoints:
1054 create:
1055 method: POST
1056 path: /users
1057 input: [bad]
1058"#;
1059 let rd = parse_resource(yaml).unwrap();
1060 let errors = validate_resource(&rd);
1061 assert!(errors.iter().any(|e| e
1062 .message
1063 .contains("cannot be both transient and have a default")));
1064 }
1065
1066 #[test]
1067 fn tenant_key_wrong_type() {
1068 let yaml = r#"
1069resource: projects
1070version: 1
1071tenant_key: org_name
1072schema:
1073 id: { type: uuid, primary: true, generated: true }
1074 org_name: { type: string, required: true }
1075"#;
1076 let rd = parse_resource(yaml).unwrap();
1077 let errors = validate_resource(&rd);
1078 assert!(errors.iter().any(|e| e
1079 .message
1080 .contains("tenant_key 'org_name' must reference a uuid field")));
1081 }
1082
1083 #[test]
1084 fn reject_after_controller_on_custom_endpoint() {
1085 let yaml = r#"
1086resource: agents
1087version: 1
1088schema:
1089 id: { type: uuid, primary: true, generated: true }
1090endpoints:
1091 regenerate_secret:
1092 method: POST
1093 path: /agents/:id/regenerate_secret
1094 auth: [admin]
1095 controller: { after: my_after }
1096"#;
1097 let rd = parse_resource(yaml).unwrap();
1098 let errors = validate_resource(&rd);
1099 assert!(
1100 errors
1101 .iter()
1102 .any(|e| e.message.contains("declares `controller: { after: ... }`")),
1103 "expected a CustomEndpointWithAfterController error, got: {errors:?}"
1104 );
1105 }
1106
1107 #[test]
1108 fn allow_before_controller_on_custom_endpoint() {
1109 let yaml = r#"
1110resource: agents
1111version: 1
1112schema:
1113 id: { type: uuid, primary: true, generated: true }
1114endpoints:
1115 regenerate_secret:
1116 method: POST
1117 path: /agents/:id/regenerate_secret
1118 auth: [admin]
1119 controller: { before: my_before }
1120"#;
1121 let rd = parse_resource(yaml).unwrap();
1122 let errors = validate_resource(&rd);
1123 assert!(
1124 errors.is_empty(),
1125 "before:-only controller on a custom endpoint should validate clean, got: {errors:?}"
1126 );
1127 }
1128
1129 #[test]
1130 fn allow_controller_on_crud_endpoints() {
1131 let yaml = r#"
1132resource: agents
1133version: 1
1134schema:
1135 id: { type: uuid, primary: true, generated: true }
1136 name: { type: string, required: true }
1137endpoints:
1138 create:
1139 method: POST
1140 path: /agents
1141 input: [name]
1142 controller: { before: my_before }
1143"#;
1144 let rd = parse_resource(yaml).unwrap();
1145 let errors = validate_resource(&rd);
1146 assert!(
1147 !errors.iter().any(|e| e
1148 .message
1149 .contains("declares `controller:` but is a custom endpoint")),
1150 "create endpoint with controller should NOT trip the rule, got: {errors:?}"
1151 );
1152 }
1153
1154 #[test]
1155 fn handler_on_convention_endpoint_rejected() {
1156 for action in &["list", "get", "create", "update", "delete"] {
1157 let yaml = format!(
1158 r#"
1159resource: items
1160version: 1
1161schema:
1162 id: {{ type: uuid, primary: true, generated: true }}
1163 name: {{ type: string, required: true }}
1164endpoints:
1165 {action}:
1166 handler: my_handler
1167"#
1168 );
1169 let rd = parse_resource(&yaml).unwrap();
1170 let errors = validate_resource(&rd);
1171 assert!(
1172 errors
1173 .iter()
1174 .any(|e| e.message.contains("`handler: my_handler`")
1175 && e.message
1176 .contains("only valid on custom (non-convention) endpoints")),
1177 "expected handler-on-convention error for action '{action}', got: {errors:?}"
1178 );
1179 }
1180 }
1181
1182 #[test]
1183 fn handler_on_custom_endpoint_allowed() {
1184 let yaml = r#"
1185resource: items
1186version: 1
1187schema:
1188 id: { type: uuid, primary: true, generated: true }
1189 name: { type: string, required: true }
1190endpoints:
1191 post_item:
1192 method: POST
1193 path: /items
1194 handler: create_item_custom
1195"#;
1196 let rd = parse_resource(yaml).unwrap();
1197 let errors = validate_resource(&rd);
1198 assert!(
1199 !errors
1200 .iter()
1201 .any(|e| e.message.contains("only valid on custom")),
1202 "custom endpoint with handler must not trip the rule, got: {errors:?}"
1203 );
1204 }
1205
1206 #[test]
1207 fn convention_endpoint_without_handler_allowed() {
1208 let yaml = r#"
1209resource: items
1210version: 1
1211schema:
1212 id: { type: uuid, primary: true, generated: true }
1213 name: { type: string, required: true }
1214endpoints:
1215 create:
1216 input: [name]
1217"#;
1218 let rd = parse_resource(yaml).unwrap();
1219 let errors = validate_resource(&rd);
1220 assert!(
1221 !errors
1222 .iter()
1223 .any(|e| e.message.contains("only valid on custom")),
1224 "convention endpoint without handler must not trip the rule, got: {errors:?}"
1225 );
1226 }
1227
1228 fn array_field(items: shaperail_core::ItemsSpec) -> shaperail_core::FieldSchema {
1229 shaperail_core::FieldSchema {
1230 field_type: shaperail_core::FieldType::Array,
1231 primary: false,
1232 generated: false,
1233 required: false,
1234 unique: false,
1235 nullable: false,
1236 reference: None,
1237 min: None,
1238 max: None,
1239 format: None,
1240 values: None,
1241 default: None,
1242 sensitive: false,
1243 search: false,
1244 items: Some(items),
1245 transient: false,
1246 }
1247 }
1248
1249 fn resource_with_array(
1250 name: &str,
1251 field: shaperail_core::FieldSchema,
1252 ) -> shaperail_core::ResourceDefinition {
1253 let mut schema = indexmap::IndexMap::new();
1254 schema.insert(name.to_string(), field);
1255 shaperail_core::ResourceDefinition {
1256 resource: "test".to_string(),
1257 version: 1,
1258 db: None,
1259 tenant_key: None,
1260 schema,
1261 endpoints: None,
1262 relations: None,
1263 indexes: None,
1264 }
1265 }
1266
1267 #[test]
1270 fn empty_resource_name_is_rejected() {
1271 use indexmap::IndexMap;
1272 use shaperail_core::{FieldSchema, FieldType, ResourceDefinition};
1273
1274 let mut schema = IndexMap::new();
1275 schema.insert(
1276 "id".to_string(),
1277 FieldSchema {
1278 field_type: FieldType::Uuid,
1279 primary: true,
1280 generated: true,
1281 required: false,
1282 unique: false,
1283 nullable: false,
1284 reference: None,
1285 min: None,
1286 max: None,
1287 format: None,
1288 values: None,
1289 default: None,
1290 sensitive: false,
1291 search: false,
1292 items: None,
1293 transient: false,
1294 },
1295 );
1296 let rd = ResourceDefinition {
1297 resource: String::new(),
1298 version: 1,
1299 db: None,
1300 tenant_key: None,
1301 schema,
1302 endpoints: None,
1303 relations: None,
1304 indexes: None,
1305 };
1306 let errors = validate_resource(&rd);
1307 assert!(
1308 errors
1309 .iter()
1310 .any(|e| e.message.contains("resource name must not be empty")),
1311 "Expected empty name error, got: {errors:?}"
1312 );
1313 }
1314
1315 #[test]
1316 fn version_zero_is_rejected() {
1317 let yaml = r#"
1318resource: items
1319version: 0
1320schema:
1321 id: { type: uuid, primary: true, generated: true }
1322"#;
1323 let rd = parse_resource(yaml).unwrap();
1324 let errors = validate_resource(&rd);
1325 assert!(
1326 errors
1327 .iter()
1328 .any(|e| e.message.contains("version must be >= 1")),
1329 "Expected version error, got: {errors:?}"
1330 );
1331 }
1332
1333 #[test]
1334 fn multiple_primary_keys_rejected() {
1335 let yaml = r#"
1336resource: items
1337version: 1
1338schema:
1339 id: { type: uuid, primary: true, generated: true }
1340 alt: { type: uuid, primary: true, generated: true }
1341 name: { type: string, required: true }
1342"#;
1343 let rd = parse_resource(yaml).unwrap();
1344 let errors = validate_resource(&rd);
1345 assert!(
1346 errors
1347 .iter()
1348 .any(|e| e.message.contains("exactly one primary key")),
1349 "Expected multiple-PK error, got: {errors:?}"
1350 );
1351 }
1352
1353 #[test]
1354 fn non_enum_field_with_values_rejected() {
1355 let yaml = r#"
1356resource: items
1357version: 1
1358schema:
1359 id: { type: uuid, primary: true, generated: true }
1360 name: { type: string, required: true, values: ["a", "b"] }
1361"#;
1362 let rd = parse_resource(yaml).unwrap();
1363 let errors = validate_resource(&rd);
1364 assert!(
1365 errors
1366 .iter()
1367 .any(|e| e.message.contains("has values but is not type enum")),
1368 "Expected non-enum-with-values error, got: {errors:?}"
1369 );
1370 }
1371
1372 #[test]
1373 fn array_field_without_items_rejected() {
1374 let yaml = r#"
1375resource: items
1376version: 1
1377schema:
1378 id: { type: uuid, primary: true, generated: true }
1379 tags: { type: array }
1380"#;
1381 let rd = parse_resource(yaml).unwrap();
1382 let errors = validate_resource(&rd);
1383 assert!(
1384 errors
1385 .iter()
1386 .any(|e| e.message.contains("type array but has no items")),
1387 "Expected array-no-items error, got: {errors:?}"
1388 );
1389 }
1390
1391 #[test]
1392 fn format_on_non_string_rejected() {
1393 let yaml = r#"
1394resource: items
1395version: 1
1396schema:
1397 id: { type: uuid, primary: true, generated: true }
1398 age: { type: integer, required: true, format: email }
1399"#;
1400 let rd = parse_resource(yaml).unwrap();
1401 let errors = validate_resource(&rd);
1402 assert!(
1403 errors
1404 .iter()
1405 .any(|e| e.message.contains("has format but is not type string")),
1406 "Expected format-on-non-string error, got: {errors:?}"
1407 );
1408 }
1409
1410 #[test]
1411 fn primary_key_not_generated_or_required_rejected() {
1412 let yaml = r#"
1413resource: items
1414version: 1
1415schema:
1416 id: { type: uuid, primary: true }
1417"#;
1418 let rd = parse_resource(yaml).unwrap();
1419 let errors = validate_resource(&rd);
1420 assert!(
1421 errors
1422 .iter()
1423 .any(|e| e.message.contains("must be generated or required")),
1424 "Expected primary-not-generated-or-required error, got: {errors:?}"
1425 );
1426 }
1427
1428 #[test]
1429 fn filter_field_not_in_schema_rejected() {
1430 let yaml = r#"
1431resource: items
1432version: 1
1433schema:
1434 id: { type: uuid, primary: true, generated: true }
1435 name: { type: string, required: true }
1436endpoints:
1437 list:
1438 auth: public
1439 filters: [name, nonexistent_filter]
1440"#;
1441 let rd = parse_resource(yaml).unwrap();
1442 let errors = validate_resource(&rd);
1443 assert!(
1444 errors.iter().any(|e| e
1445 .message
1446 .contains("filter field 'nonexistent_filter' not found")),
1447 "Expected filter-field error, got: {errors:?}"
1448 );
1449 }
1450
1451 #[test]
1452 fn search_field_not_in_schema_rejected() {
1453 let yaml = r#"
1454resource: items
1455version: 1
1456schema:
1457 id: { type: uuid, primary: true, generated: true }
1458 name: { type: string, required: true }
1459endpoints:
1460 list:
1461 auth: public
1462 search: [name, missing_search_field]
1463"#;
1464 let rd = parse_resource(yaml).unwrap();
1465 let errors = validate_resource(&rd);
1466 assert!(
1467 errors.iter().any(|e| e
1468 .message
1469 .contains("search field 'missing_search_field' not found")),
1470 "Expected search-field error, got: {errors:?}"
1471 );
1472 }
1473
1474 #[test]
1475 fn sort_field_not_in_schema_rejected() {
1476 let yaml = r#"
1477resource: items
1478version: 1
1479schema:
1480 id: { type: uuid, primary: true, generated: true }
1481 name: { type: string, required: true }
1482endpoints:
1483 list:
1484 auth: public
1485 sort: [name, unknown_sort]
1486"#;
1487 let rd = parse_resource(yaml).unwrap();
1488 let errors = validate_resource(&rd);
1489 assert!(
1490 errors
1491 .iter()
1492 .any(|e| e.message.contains("sort field 'unknown_sort' not found")),
1493 "Expected sort-field error, got: {errors:?}"
1494 );
1495 }
1496
1497 #[test]
1498 fn relation_key_not_in_schema_rejected() {
1499 let yaml = r#"
1500resource: items
1501version: 1
1502schema:
1503 id: { type: uuid, primary: true, generated: true }
1504relations:
1505 org: { resource: organizations, type: belongs_to, key: missing_key }
1506"#;
1507 let rd = parse_resource(yaml).unwrap();
1508 let errors = validate_resource(&rd);
1509 assert!(
1510 errors
1511 .iter()
1512 .any(|e| e.message.contains("key 'missing_key' not found in schema")),
1513 "Expected relation-key error, got: {errors:?}"
1514 );
1515 }
1516
1517 #[test]
1518 fn index_empty_fields_rejected() {
1519 use indexmap::IndexMap;
1520 use shaperail_core::{FieldSchema, FieldType, IndexSpec, ResourceDefinition};
1521
1522 let mut schema = IndexMap::new();
1523 schema.insert(
1524 "id".to_string(),
1525 FieldSchema {
1526 field_type: FieldType::Uuid,
1527 primary: true,
1528 generated: true,
1529 required: false,
1530 unique: false,
1531 nullable: false,
1532 reference: None,
1533 min: None,
1534 max: None,
1535 format: None,
1536 values: None,
1537 default: None,
1538 sensitive: false,
1539 search: false,
1540 items: None,
1541 transient: false,
1542 },
1543 );
1544 let rd = ResourceDefinition {
1545 resource: "items".to_string(),
1546 version: 1,
1547 db: None,
1548 tenant_key: None,
1549 schema,
1550 endpoints: None,
1551 relations: None,
1552 indexes: Some(vec![IndexSpec {
1553 fields: vec![],
1554 unique: false,
1555 order: None,
1556 }]),
1557 };
1558 let errors = validate_resource(&rd);
1559 assert!(
1560 errors
1561 .iter()
1562 .any(|e| e.message.contains("index 0 has no fields")),
1563 "Expected empty-index error, got: {errors:?}"
1564 );
1565 }
1566
1567 #[test]
1568 fn index_invalid_order_rejected() {
1569 let yaml = r#"
1570resource: items
1571version: 1
1572schema:
1573 id: { type: uuid, primary: true, generated: true }
1574 created_at: { type: timestamp, generated: true }
1575indexes:
1576 - fields: [created_at]
1577 order: INVALID
1578"#;
1579 let rd = parse_resource(yaml).unwrap();
1580 let errors = validate_resource(&rd);
1581 assert!(
1582 errors
1583 .iter()
1584 .any(|e| e.message.contains("invalid order 'INVALID'")),
1585 "Expected invalid-order error, got: {errors:?}"
1586 );
1587 }
1588
1589 #[test]
1590 fn valid_index_asc_and_desc_accepted() {
1591 let yaml = r#"
1592resource: items
1593version: 1
1594schema:
1595 id: { type: uuid, primary: true, generated: true }
1596 created_at: { type: timestamp, generated: true }
1597 name: { type: string, required: true }
1598indexes:
1599 - fields: [created_at]
1600 order: desc
1601 - fields: [name]
1602 order: asc
1603"#;
1604 let rd = parse_resource(yaml).unwrap();
1605 let errors = validate_resource(&rd);
1606 assert!(
1607 errors.is_empty(),
1608 "Expected no errors for valid indexes, got: {errors:?}"
1609 );
1610 }
1611
1612 #[test]
1613 fn ref_missing_dot_separator_rejected() {
1614 let yaml = r#"
1615resource: items
1616version: 1
1617schema:
1618 id: { type: uuid, primary: true, generated: true }
1619 org_id: { type: uuid, ref: organizations }
1620"#;
1621 let rd = parse_resource(yaml).unwrap();
1622 let errors = validate_resource(&rd);
1623 assert!(
1624 errors
1625 .iter()
1626 .any(|e| e.message.contains("'resource.field' format")),
1627 "Expected bad-ref-format error, got: {errors:?}"
1628 );
1629 }
1630
1631 #[test]
1632 fn has_one_without_foreign_key_rejected() {
1633 let yaml = r#"
1634resource: users
1635version: 1
1636schema:
1637 id: { type: uuid, primary: true, generated: true }
1638relations:
1639 profile: { resource: profiles, type: has_one }
1640"#;
1641 let rd = parse_resource(yaml).unwrap();
1642 let errors = validate_resource(&rd);
1643 assert!(
1644 errors
1645 .iter()
1646 .any(|e| e.message.contains("has_one but has no foreign_key")),
1647 "Expected has_one error, got: {errors:?}"
1648 );
1649 }
1650
1651 #[test]
1652 fn empty_event_name_rejected() {
1653 let yaml = r#"
1654resource: items
1655version: 1
1656schema:
1657 id: { type: uuid, primary: true, generated: true }
1658endpoints:
1659 create:
1660 method: POST
1661 path: /items
1662 events: [""]
1663"#;
1664 let rd = parse_resource(yaml).unwrap();
1665 let errors = validate_resource(&rd);
1666 assert!(
1667 errors
1668 .iter()
1669 .any(|e| e.message.contains("empty event name")),
1670 "Expected empty-event error, got: {errors:?}"
1671 );
1672 }
1673
1674 #[test]
1675 fn empty_job_name_rejected() {
1676 let yaml = r#"
1677resource: items
1678version: 1
1679schema:
1680 id: { type: uuid, primary: true, generated: true }
1681endpoints:
1682 create:
1683 method: POST
1684 path: /items
1685 jobs: [""]
1686"#;
1687 let rd = parse_resource(yaml).unwrap();
1688 let errors = validate_resource(&rd);
1689 assert!(
1690 errors.iter().any(|e| e.message.contains("empty job name")),
1691 "Expected empty-job error, got: {errors:?}"
1692 );
1693 }
1694
1695 #[test]
1696 fn upload_missing_from_input_rejected() {
1697 let yaml = r#"
1698resource: assets
1699version: 1
1700schema:
1701 id: { type: uuid, primary: true, generated: true }
1702 file: { type: file, required: true }
1703endpoints:
1704 upload:
1705 method: POST
1706 path: /assets/upload
1707 input: []
1708 upload:
1709 field: file
1710 storage: s3
1711 max_size: 5mb
1712"#;
1713 let rd = parse_resource(yaml).unwrap();
1714 let errors = validate_resource(&rd);
1715 assert!(
1716 errors.iter().any(|e| e
1717 .message
1718 .contains("upload field 'file' must appear in input")),
1719 "Expected upload-not-in-input error, got: {errors:?}"
1720 );
1721 }
1722
1723 #[test]
1724 fn upload_invalid_storage_rejected() {
1725 let yaml = r#"
1726resource: assets
1727version: 1
1728schema:
1729 id: { type: uuid, primary: true, generated: true }
1730 file: { type: file, required: true }
1731endpoints:
1732 upload:
1733 method: POST
1734 path: /assets/upload
1735 input: [file]
1736 upload:
1737 field: file
1738 storage: dropbox
1739 max_size: 5mb
1740"#;
1741 let rd = parse_resource(yaml).unwrap();
1742 let errors = validate_resource(&rd);
1743 assert!(
1744 errors
1745 .iter()
1746 .any(|e| e.message.contains("upload storage 'dropbox' is invalid")),
1747 "Expected invalid-storage error, got: {errors:?}"
1748 );
1749 }
1750
1751 #[test]
1752 fn items_nested_array_rejected() {
1753 let field = array_field(shaperail_core::ItemsSpec::of(
1754 shaperail_core::FieldType::Array,
1755 ));
1756 let rd = resource_with_array("nested", field);
1757 let errors = validate_resource(&rd);
1758 assert!(errors
1759 .iter()
1760 .any(|e| e.message.contains("nested array") || e.message.contains("type: json")));
1761 }
1762
1763 #[test]
1764 fn items_enum_requires_values() {
1765 let field = array_field(shaperail_core::ItemsSpec::of(
1766 shaperail_core::FieldType::Enum,
1767 ));
1768 let rd = resource_with_array("flags", field);
1769 let errors = validate_resource(&rd);
1770 assert!(errors.iter().any(|e| e.message.contains("values")));
1771 }
1772
1773 #[test]
1774 fn items_format_only_on_string() {
1775 let mut items = shaperail_core::ItemsSpec::of(shaperail_core::FieldType::Integer);
1776 items.format = Some("email".to_string());
1777 let field = array_field(items);
1778 let rd = resource_with_array("nums", field);
1779 let errors = validate_resource(&rd);
1780 assert!(errors.iter().any(|e| e.message.contains("format")));
1781 }
1782
1783 #[test]
1784 fn items_ref_requires_uuid() {
1785 let mut items = shaperail_core::ItemsSpec::of(shaperail_core::FieldType::String);
1786 items.reference = Some("organizations.id".to_string());
1787 let field = array_field(items);
1788 let rd = resource_with_array("badrefs", field);
1789 let errors = validate_resource(&rd);
1790 assert!(errors
1791 .iter()
1792 .any(|e| e.message.contains("ref") && e.message.contains("uuid")));
1793 }
1794
1795 #[test]
1796 fn items_ref_format_must_be_resource_dot_field() {
1797 let mut items = shaperail_core::ItemsSpec::of(shaperail_core::FieldType::Uuid);
1798 items.reference = Some("organizations".to_string()); let field = array_field(items);
1800 let rd = resource_with_array("orgs", field);
1801 let errors = validate_resource(&rd);
1802 assert!(errors.iter().any(|e| e.message.contains("resource.field")));
1803 }
1804
1805 #[test]
1806 fn items_uuid_ref_valid() {
1807 let mut items = shaperail_core::ItemsSpec::of(shaperail_core::FieldType::Uuid);
1808 items.reference = Some("organizations.id".to_string());
1809 let field = array_field(items);
1810 let rd = resource_with_array("tags", field);
1811 let errors = validate_resource(&rd);
1812 assert!(errors.iter().all(|e| !e.message.contains("tags")));
1814 }
1815
1816 #[test]
1817 fn bigint_type_produces_friendly_error() {
1818 let yaml = r#"
1819resource: invoices
1820version: 1
1821schema:
1822 id: { type: uuid, primary: true, generated: true }
1823 amount_cents: { type: bigint, required: true }
1824"#;
1825 let err = parse_resource(yaml).unwrap_err();
1826 let msg = err.to_string();
1827 assert!(
1828 msg.contains("removed in v0.13.0"),
1829 "Expected removal message, got: {msg}"
1830 );
1831 assert!(
1832 msg.contains("use 'integer'"),
1833 "Expected migration hint, got: {msg}"
1834 );
1835 assert!(
1836 msg.contains("E_BIGINT_REMOVED"),
1837 "Expected error code, got: {msg}"
1838 );
1839 }
1840}