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 if field.transient {
108 if field.primary {
109 errors.push(err(&format!(
110 "resource '{res}': field '{name}' cannot be both transient and primary"
111 )));
112 }
113 if field.generated {
114 errors.push(err(&format!(
115 "resource '{res}': field '{name}' cannot be both transient and generated"
116 )));
117 }
118 if field.reference.is_some() {
119 errors.push(err(&format!(
120 "resource '{res}': field '{name}' cannot be both transient and have a ref (foreign keys imply persistence)"
121 )));
122 }
123 if field.unique {
124 errors.push(err(&format!(
125 "resource '{res}': field '{name}' cannot be both transient and unique (unique constraints require persistence)"
126 )));
127 }
128 if field.default.is_some() {
129 errors.push(err(&format!(
130 "resource '{res}': field '{name}' cannot be both transient and have a default (defaults apply to stored columns)"
131 )));
132 }
133 }
134 }
135
136 let transient_fields: Vec<&String> = rd
139 .schema
140 .iter()
141 .filter(|(_, f)| f.transient)
142 .map(|(name, _)| name)
143 .collect();
144 if !transient_fields.is_empty() {
145 let referenced: std::collections::HashSet<&str> = rd
146 .endpoints
147 .as_ref()
148 .map(|eps| {
149 eps.values()
150 .filter_map(|ep| ep.input.as_ref())
151 .flat_map(|inputs| inputs.iter().map(|s| s.as_str()))
152 .collect()
153 })
154 .unwrap_or_default();
155 for name in transient_fields {
156 if !referenced.contains(name.as_str()) {
157 errors.push(err(&format!(
158 "resource '{res}': transient field '{name}' is not declared in any endpoint's input: list (the field would be unreachable)"
159 )));
160 }
161 }
162 }
163
164 if let Some(ref tenant_key) = rd.tenant_key {
166 match rd.schema.get(tenant_key) {
167 Some(field) => {
168 if field.field_type != FieldType::Uuid {
169 errors.push(err(&format!(
170 "resource '{res}': tenant_key '{tenant_key}' must reference a uuid field, found {}",
171 field.field_type
172 )));
173 }
174 }
175 None => {
176 errors.push(err(&format!(
177 "resource '{res}': tenant_key '{tenant_key}' not found in schema"
178 )));
179 }
180 }
181 }
182
183 if let Some(endpoints) = &rd.endpoints {
185 for (action, ep) in endpoints {
186 if ep.method.is_none() {
188 errors.push(err(&format!(
189 "resource '{res}': endpoint '{action}' has no method. Use a known action name (list, get, create, update, delete) or set method explicitly"
190 )));
191 }
192 if ep.path.is_none() {
193 errors.push(err(&format!(
194 "resource '{res}': endpoint '{action}' has no path. Use a known action name (list, get, create, update, delete) or set path explicitly"
195 )));
196 }
197
198 if let Some(controller) = &ep.controller {
199 if let Some(e) = validate_controller_only_on_crud(res, action, ep) {
201 errors.push(e);
202 }
203
204 if let Some(before) = &controller.before {
205 if before.is_empty() {
206 errors.push(err(&format!(
207 "resource '{res}': endpoint '{action}' has an empty controller.before name"
208 )));
209 }
210 validate_controller_name(res, action, "before", before, &mut errors);
211 }
212 if let Some(after) = &controller.after {
213 if after.is_empty() {
214 errors.push(err(&format!(
215 "resource '{res}': endpoint '{action}' has an empty controller.after name"
216 )));
217 }
218 validate_controller_name(res, action, "after", after, &mut errors);
219 }
220 }
221
222 if let Some(events) = &ep.events {
223 for event in events {
224 if event.is_empty() {
225 errors.push(err(&format!(
226 "resource '{res}': endpoint '{action}' has an empty event name"
227 )));
228 }
229 }
230 }
231
232 if let Some(jobs) = &ep.jobs {
233 for job in jobs {
234 if job.is_empty() {
235 errors.push(err(&format!(
236 "resource '{res}': endpoint '{action}' has an empty job name"
237 )));
238 }
239 }
240 }
241
242 if let Some(input) = &ep.input {
244 for field_name in input {
245 if !rd.schema.contains_key(field_name) {
246 errors.push(err(&format!(
247 "resource '{res}': endpoint '{action}' input field '{field_name}' not found in schema"
248 )));
249 }
250 }
251 }
252
253 if let Some(filters) = &ep.filters {
255 for field_name in filters {
256 if !rd.schema.contains_key(field_name) {
257 errors.push(err(&format!(
258 "resource '{res}': endpoint '{action}' filter field '{field_name}' not found in schema"
259 )));
260 }
261 }
262 }
263
264 if let Some(search) = &ep.search {
266 for field_name in search {
267 if !rd.schema.contains_key(field_name) {
268 errors.push(err(&format!(
269 "resource '{res}': endpoint '{action}' search field '{field_name}' not found in schema"
270 )));
271 }
272 }
273 }
274
275 if let Some(sort) = &ep.sort {
277 for field_name in sort {
278 if !rd.schema.contains_key(field_name) {
279 errors.push(err(&format!(
280 "resource '{res}': endpoint '{action}' sort field '{field_name}' not found in schema"
281 )));
282 }
283 }
284 }
285
286 if ep.soft_delete && !rd.schema.contains_key("deleted_at") {
288 errors.push(err(&format!(
289 "resource '{res}': endpoint '{action}' has soft_delete but schema has no 'deleted_at' field"
290 )));
291 }
292
293 if let Some(upload) = &ep.upload {
294 match ep.method.as_ref() {
295 Some(HttpMethod::Post | HttpMethod::Patch | HttpMethod::Put) => {}
296 Some(_) => errors.push(err(&format!(
297 "resource '{res}': endpoint '{action}' uses upload but method must be POST, PATCH, or PUT"
298 ))),
299 None => {} }
301
302 match rd.schema.get(&upload.field) {
303 Some(field) if field.field_type == FieldType::File => {}
304 Some(_) => errors.push(err(&format!(
305 "resource '{res}': endpoint '{action}' upload field '{}' must be type file",
306 upload.field
307 ))),
308 None => errors.push(err(&format!(
309 "resource '{res}': endpoint '{action}' upload field '{}' not found in schema",
310 upload.field
311 ))),
312 }
313
314 if !matches!(upload.storage.as_str(), "local" | "s3" | "gcs" | "azure") {
315 errors.push(err(&format!(
316 "resource '{res}': endpoint '{action}' upload storage '{}' is invalid",
317 upload.storage
318 )));
319 }
320
321 if !ep
322 .input
323 .as_ref()
324 .is_some_and(|fields| fields.iter().any(|field| field == &upload.field))
325 {
326 errors.push(err(&format!(
327 "resource '{res}': endpoint '{action}' upload field '{}' must appear in input",
328 upload.field
329 )));
330 }
331
332 for (suffix, expected_types) in [
333 ("filename", &[FieldType::String][..]),
334 ("mime_type", &[FieldType::String][..]),
335 ("size", &[FieldType::Integer, FieldType::Bigint][..]),
336 ] {
337 let companion = format!("{}_{}", upload.field, suffix);
338 if let Some(field) = rd.schema.get(&companion) {
339 if !expected_types.contains(&field.field_type) {
340 let expected = expected_types
341 .iter()
342 .map(ToString::to_string)
343 .collect::<Vec<_>>()
344 .join(" or ");
345 errors.push(err(&format!(
346 "resource '{res}': companion upload field '{companion}' must be type {expected}"
347 )));
348 }
349 }
350 }
351 }
352 }
353 }
354
355 if let Some(relations) = &rd.relations {
357 for (name, rel) in relations {
358 use shaperail_core::RelationType;
359
360 if rel.relation_type == RelationType::BelongsTo && rel.key.is_none() {
362 errors.push(err(&format!(
363 "resource '{res}': relation '{name}' is belongs_to but has no key"
364 )));
365 }
366
367 if matches!(
369 rel.relation_type,
370 RelationType::HasMany | RelationType::HasOne
371 ) && rel.foreign_key.is_none()
372 {
373 errors.push(err(&format!(
374 "resource '{res}': relation '{name}' is {} but has no foreign_key",
375 rel.relation_type
376 )));
377 }
378
379 if let Some(key) = &rel.key {
381 if !rd.schema.contains_key(key) {
382 errors.push(err(&format!(
383 "resource '{res}': relation '{name}' key '{key}' not found in schema"
384 )));
385 }
386 }
387 }
388 }
389
390 if let Some(indexes) = &rd.indexes {
392 for (i, idx) in indexes.iter().enumerate() {
393 if idx.fields.is_empty() {
394 errors.push(err(&format!("resource '{res}': index {i} has no fields")));
395 }
396 for field_name in &idx.fields {
397 if !rd.schema.contains_key(field_name) {
398 errors.push(err(&format!(
399 "resource '{res}': index {i} references field '{field_name}' not in schema"
400 )));
401 }
402 }
403 if let Some(order) = &idx.order {
404 if order != "asc" && order != "desc" {
405 errors.push(err(&format!(
406 "resource '{res}': index {i} has invalid order '{order}', must be 'asc' or 'desc'"
407 )));
408 }
409 }
410 }
411 }
412
413 errors
414}
415
416fn validate_controller_only_on_crud(
422 resource: &str,
423 action: &str,
424 endpoint: &shaperail_core::EndpointSpec,
425) -> Option<ValidationError> {
426 const CRUD_ACTIONS: &[&str] = &[
427 "list",
428 "get",
429 "create",
430 "update",
431 "delete",
432 "bulk_create",
433 "bulk_delete",
434 ];
435 if !CRUD_ACTIONS.contains(&action) && endpoint.controller.is_some() {
436 return Some(err(&format!(
437 "resource '{resource}': endpoint '{action}' declares `controller:` but is a custom \
438 endpoint (dispatched via `handler:`). `controller` is only valid on conventional CRUD \
439 endpoints (list / get / create / update / delete / bulk_create / bulk_delete). For \
440 shared logic on custom handlers, use the typed `Subject` and tenant helpers from \
441 `shaperail_runtime::auth` directly inside your handler."
442 )));
443 }
444 None
445}
446
447fn validate_controller_name(
449 res: &str,
450 action: &str,
451 phase: &str,
452 name: &str,
453 errors: &mut Vec<ValidationError>,
454) {
455 if let Some(wasm_path) = name.strip_prefix(WASM_HOOK_PREFIX) {
456 if wasm_path.is_empty() {
457 errors.push(err(&format!(
458 "resource '{res}': endpoint '{action}' controller.{phase} has 'wasm:' prefix but no path"
459 )));
460 } else if !wasm_path.ends_with(".wasm") {
461 errors.push(err(&format!(
462 "resource '{res}': endpoint '{action}' controller.{phase} WASM path must end with '.wasm', got '{wasm_path}'"
463 )));
464 }
465 }
466}
467
468fn err(message: &str) -> ValidationError {
469 ValidationError {
470 message: message.to_string(),
471 }
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477 use crate::parser::parse_resource;
478
479 #[test]
480 fn valid_resource_passes() {
481 let yaml = include_str!("../../resources/users.yaml");
482 let rd = parse_resource(yaml).unwrap();
483 let errors = validate_resource(&rd);
484 assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
485 }
486
487 #[test]
488 fn enum_without_values() {
489 let yaml = r#"
490resource: items
491version: 1
492schema:
493 id: { type: uuid, primary: true, generated: true }
494 status: { type: enum, required: true }
495"#;
496 let rd = parse_resource(yaml).unwrap();
497 let errors = validate_resource(&rd);
498 assert!(errors
499 .iter()
500 .any(|e| e.message.contains("type enum but has no values")));
501 }
502
503 #[test]
504 fn ref_field_not_uuid() {
505 let yaml = r#"
506resource: items
507version: 1
508schema:
509 id: { type: uuid, primary: true, generated: true }
510 org_id: { type: string, ref: organizations.id }
511"#;
512 let rd = parse_resource(yaml).unwrap();
513 let errors = validate_resource(&rd);
514 assert!(errors
515 .iter()
516 .any(|e| e.message.contains("has ref but is not type uuid")));
517 }
518
519 #[test]
520 fn missing_primary_key() {
521 let yaml = r#"
522resource: items
523version: 1
524schema:
525 name: { type: string, required: true }
526"#;
527 let rd = parse_resource(yaml).unwrap();
528 let errors = validate_resource(&rd);
529 assert!(errors
530 .iter()
531 .any(|e| e.message.contains("must have a primary key")));
532 }
533
534 #[test]
535 fn soft_delete_without_deleted_at() {
536 let yaml = r#"
537resource: items
538version: 1
539schema:
540 id: { type: uuid, primary: true, generated: true }
541 name: { type: string, required: true }
542endpoints:
543 delete:
544 method: DELETE
545 path: /items/:id
546 auth: [admin]
547 soft_delete: true
548"#;
549 let rd = parse_resource(yaml).unwrap();
550 let errors = validate_resource(&rd);
551 assert!(errors.iter().any(|e| e
552 .message
553 .contains("soft_delete but schema has no 'deleted_at'")));
554 }
555
556 #[test]
557 fn input_field_not_in_schema() {
558 let yaml = r#"
559resource: items
560version: 1
561schema:
562 id: { type: uuid, primary: true, generated: true }
563 name: { type: string, required: true }
564endpoints:
565 create:
566 method: POST
567 path: /items
568 auth: [admin]
569 input: [name, nonexistent]
570"#;
571 let rd = parse_resource(yaml).unwrap();
572 let errors = validate_resource(&rd);
573 assert!(errors.iter().any(|e| e
574 .message
575 .contains("input field 'nonexistent' not found in schema")));
576 }
577
578 #[test]
579 fn belongs_to_without_key() {
580 let yaml = r#"
581resource: items
582version: 1
583schema:
584 id: { type: uuid, primary: true, generated: true }
585relations:
586 org: { resource: organizations, type: belongs_to }
587"#;
588 let rd = parse_resource(yaml).unwrap();
589 let errors = validate_resource(&rd);
590 assert!(errors
591 .iter()
592 .any(|e| e.message.contains("belongs_to but has no key")));
593 }
594
595 #[test]
596 fn has_many_without_foreign_key() {
597 let yaml = r#"
598resource: items
599version: 1
600schema:
601 id: { type: uuid, primary: true, generated: true }
602relations:
603 orders: { resource: orders, type: has_many }
604"#;
605 let rd = parse_resource(yaml).unwrap();
606 let errors = validate_resource(&rd);
607 assert!(errors
608 .iter()
609 .any(|e| e.message.contains("has_many but has no foreign_key")));
610 }
611
612 #[test]
613 fn index_references_missing_field() {
614 let yaml = r#"
615resource: items
616version: 1
617schema:
618 id: { type: uuid, primary: true, generated: true }
619indexes:
620 - fields: [missing_field]
621"#;
622 let rd = parse_resource(yaml).unwrap();
623 let errors = validate_resource(&rd);
624 assert!(errors.iter().any(|e| e
625 .message
626 .contains("references field 'missing_field' not in schema")));
627 }
628
629 #[test]
630 fn error_message_format() {
631 let yaml = r#"
632resource: users
633version: 1
634schema:
635 id: { type: uuid, primary: true, generated: true }
636 role: { type: enum }
637"#;
638 let rd = parse_resource(yaml).unwrap();
639 let errors = validate_resource(&rd);
640 assert_eq!(
641 errors[0].message,
642 "resource 'users': field 'role' is type enum but has no values"
643 );
644 }
645
646 #[test]
647 fn wasm_controller_valid_path() {
648 let yaml = r#"
649resource: items
650version: 1
651schema:
652 id: { type: uuid, primary: true, generated: true }
653 name: { type: string, required: true }
654endpoints:
655 create:
656 method: POST
657 path: /items
658 input: [name]
659 controller: { before: "wasm:./plugins/my_validator.wasm" }
660"#;
661 let rd = parse_resource(yaml).unwrap();
662 let errors = validate_resource(&rd);
663 assert!(
664 errors.is_empty(),
665 "Expected no errors for valid WASM controller, got: {errors:?}"
666 );
667 }
668
669 #[test]
670 fn wasm_controller_missing_extension() {
671 let yaml = r#"
672resource: items
673version: 1
674schema:
675 id: { type: uuid, primary: true, generated: true }
676 name: { type: string, required: true }
677endpoints:
678 create:
679 method: POST
680 path: /items
681 input: [name]
682 controller: { before: "wasm:./plugins/my_validator" }
683"#;
684 let rd = parse_resource(yaml).unwrap();
685 let errors = validate_resource(&rd);
686 assert!(errors
687 .iter()
688 .any(|e| e.message.contains("WASM path must end with '.wasm'")));
689 }
690
691 #[test]
692 fn wasm_controller_empty_path() {
693 let yaml = r#"
694resource: items
695version: 1
696schema:
697 id: { type: uuid, primary: true, generated: true }
698 name: { type: string, required: true }
699endpoints:
700 create:
701 method: POST
702 path: /items
703 input: [name]
704 controller: { before: "wasm:" }
705"#;
706 let rd = parse_resource(yaml).unwrap();
707 let errors = validate_resource(&rd);
708 assert!(errors
709 .iter()
710 .any(|e| e.message.contains("'wasm:' prefix but no path")));
711 }
712
713 #[test]
714 fn upload_endpoint_valid_when_file_field_declared() {
715 let yaml = r#"
716resource: assets
717version: 1
718schema:
719 id: { type: uuid, primary: true, generated: true }
720 file: { type: file, required: true }
721 file_filename: { type: string }
722 file_mime_type: { type: string }
723 file_size: { type: bigint }
724 updated_at: { type: timestamp, generated: true }
725endpoints:
726 upload:
727 method: POST
728 path: /assets/upload
729 input: [file]
730 upload:
731 field: file
732 storage: local
733 max_size: 5mb
734"#;
735 let rd = parse_resource(yaml).unwrap();
736 let errors = validate_resource(&rd);
737 assert!(
738 errors.is_empty(),
739 "Expected valid upload resource, got {errors:?}"
740 );
741 }
742
743 #[test]
744 fn upload_endpoint_requires_file_field() {
745 let yaml = r#"
746resource: assets
747version: 1
748schema:
749 id: { type: uuid, primary: true, generated: true }
750 file_path: { type: string, required: true }
751endpoints:
752 upload:
753 method: POST
754 path: /assets/upload
755 input: [file_path]
756 upload:
757 field: file_path
758 storage: local
759 max_size: 5mb
760"#;
761 let rd = parse_resource(yaml).unwrap();
762 let errors = validate_resource(&rd);
763 assert!(errors.iter().any(|e| e
764 .message
765 .contains("upload field 'file_path' must be type file")));
766 }
767
768 #[test]
769 fn tenant_key_valid_uuid_field() {
770 let yaml = r#"
771resource: projects
772version: 1
773tenant_key: org_id
774schema:
775 id: { type: uuid, primary: true, generated: true }
776 org_id: { type: uuid, ref: organizations.id, required: true }
777 name: { type: string, required: true }
778"#;
779 let rd = parse_resource(yaml).unwrap();
780 let errors = validate_resource(&rd);
781 assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
782 }
783
784 #[test]
785 fn tenant_key_missing_field() {
786 let yaml = r#"
787resource: projects
788version: 1
789tenant_key: org_id
790schema:
791 id: { type: uuid, primary: true, generated: true }
792 name: { type: string, required: true }
793"#;
794 let rd = parse_resource(yaml).unwrap();
795 let errors = validate_resource(&rd);
796 assert!(errors.iter().any(|e| e
797 .message
798 .contains("tenant_key 'org_id' not found in schema")));
799 }
800
801 #[test]
802 fn transient_field_valid() {
803 let yaml = r#"
804resource: users
805version: 1
806schema:
807 id: { type: uuid, primary: true, generated: true }
808 password: { type: string, transient: true, min: 12, required: true }
809 password_hash: { type: string, required: true }
810endpoints:
811 create:
812 method: POST
813 path: /users
814 input: [password]
815 controller: { before: hash_password }
816"#;
817 let rd = parse_resource(yaml).unwrap();
818 let errors = validate_resource(&rd);
819 assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
820 }
821
822 #[test]
823 fn transient_field_dead_when_not_in_input() {
824 let yaml = r#"
825resource: users
826version: 1
827schema:
828 id: { type: uuid, primary: true, generated: true }
829 password: { type: string, transient: true, min: 12 }
830endpoints:
831 create:
832 method: POST
833 path: /users
834 input: []
835"#;
836 let rd = parse_resource(yaml).unwrap();
837 let errors = validate_resource(&rd);
838 assert!(errors.iter().any(|e| e
839 .message
840 .contains("transient field 'password' is not declared in any endpoint's input")));
841 }
842
843 #[test]
844 fn transient_field_rejects_primary() {
845 let yaml = r#"
846resource: users
847version: 1
848schema:
849 bad: { type: uuid, transient: true, primary: true }
850"#;
851 let rd = parse_resource(yaml).unwrap();
852 let errors = validate_resource(&rd);
853 assert!(errors
854 .iter()
855 .any(|e| e.message.contains("cannot be both transient and primary")));
856 }
857
858 #[test]
859 fn transient_field_rejects_generated() {
860 let yaml = r#"
861resource: users
862version: 1
863schema:
864 id: { type: uuid, primary: true, generated: true }
865 bad: { type: timestamp, transient: true, generated: true }
866endpoints:
867 create:
868 method: POST
869 path: /users
870 input: [bad]
871"#;
872 let rd = parse_resource(yaml).unwrap();
873 let errors = validate_resource(&rd);
874 assert!(errors
875 .iter()
876 .any(|e| e.message.contains("cannot be both transient and generated")));
877 }
878
879 #[test]
880 fn transient_field_rejects_ref() {
881 let yaml = r#"
882resource: users
883version: 1
884schema:
885 id: { type: uuid, primary: true, generated: true }
886 bad: { type: uuid, transient: true, ref: orgs.id }
887endpoints:
888 create:
889 method: POST
890 path: /users
891 input: [bad]
892"#;
893 let rd = parse_resource(yaml).unwrap();
894 let errors = validate_resource(&rd);
895 assert!(errors.iter().any(|e| e
896 .message
897 .contains("cannot be both transient and have a ref")));
898 }
899
900 #[test]
901 fn transient_field_rejects_unique() {
902 let yaml = r#"
903resource: users
904version: 1
905schema:
906 id: { type: uuid, primary: true, generated: true }
907 bad: { type: string, transient: true, unique: true }
908endpoints:
909 create:
910 method: POST
911 path: /users
912 input: [bad]
913"#;
914 let rd = parse_resource(yaml).unwrap();
915 let errors = validate_resource(&rd);
916 assert!(errors
917 .iter()
918 .any(|e| e.message.contains("cannot be both transient and unique")));
919 }
920
921 #[test]
922 fn transient_field_rejects_default() {
923 let yaml = r#"
924resource: users
925version: 1
926schema:
927 id: { type: uuid, primary: true, generated: true }
928 bad: { type: string, transient: true, default: "x" }
929endpoints:
930 create:
931 method: POST
932 path: /users
933 input: [bad]
934"#;
935 let rd = parse_resource(yaml).unwrap();
936 let errors = validate_resource(&rd);
937 assert!(errors.iter().any(|e| e
938 .message
939 .contains("cannot be both transient and have a default")));
940 }
941
942 #[test]
943 fn tenant_key_wrong_type() {
944 let yaml = r#"
945resource: projects
946version: 1
947tenant_key: org_name
948schema:
949 id: { type: uuid, primary: true, generated: true }
950 org_name: { type: string, required: true }
951"#;
952 let rd = parse_resource(yaml).unwrap();
953 let errors = validate_resource(&rd);
954 assert!(errors.iter().any(|e| e
955 .message
956 .contains("tenant_key 'org_name' must reference a uuid field")));
957 }
958
959 #[test]
960 fn reject_controller_on_custom_endpoint() {
961 let yaml = r#"
962resource: agents
963version: 1
964schema:
965 id: { type: uuid, primary: true, generated: true }
966endpoints:
967 regenerate_secret:
968 method: POST
969 path: /agents/:id/regenerate_secret
970 auth: [admin]
971 controller: { before: my_before }
972"#;
973 let rd = parse_resource(yaml).unwrap();
974 let errors = validate_resource(&rd);
975 assert!(
976 errors.iter().any(|e| e
977 .message
978 .contains("declares `controller:` but is a custom endpoint")),
979 "expected a CustomEndpointWithController error, got: {errors:?}"
980 );
981 }
982
983 #[test]
984 fn allow_controller_on_crud_endpoints() {
985 let yaml = r#"
986resource: agents
987version: 1
988schema:
989 id: { type: uuid, primary: true, generated: true }
990 name: { type: string, required: true }
991endpoints:
992 create:
993 method: POST
994 path: /agents
995 input: [name]
996 controller: { before: my_before }
997"#;
998 let rd = parse_resource(yaml).unwrap();
999 let errors = validate_resource(&rd);
1000 assert!(
1001 !errors.iter().any(|e| e
1002 .message
1003 .contains("declares `controller:` but is a custom endpoint")),
1004 "create endpoint with controller should NOT trip the rule, got: {errors:?}"
1005 );
1006 }
1007}