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(
427 resource: &str,
428 action: &str,
429 endpoint: &shaperail_core::EndpointSpec,
430) -> Option<ValidationError> {
431 const CRUD_ACTIONS: &[&str] = &[
432 "list",
433 "get",
434 "create",
435 "update",
436 "delete",
437 "bulk_create",
438 "bulk_delete",
439 ];
440 if CRUD_ACTIONS.contains(&action) {
441 return None;
442 }
443 let has_after = endpoint
445 .controller
446 .as_ref()
447 .and_then(|c| c.after.as_deref())
448 .is_some();
449 if has_after {
450 return Some(err(&format!(
451 "resource '{resource}': endpoint '{action}' declares `controller: {{ after: ... }}`, \
452 but `after:` controllers are only valid on conventional CRUD endpoints \
453 (list / get / create / update / delete / bulk_create / bulk_delete).\n\
454 \n\
455 Custom endpoints generate their own response via `handler:`, so the runtime \
456 has no place to merge `ctx.response_extras` or pass through after-hook \
457 mutations. Use a `before:` controller for shared setup (auth augmentation, \
458 tenant scoping, request validation), and put response-shaping logic inside \
459 the handler itself."
460 )));
461 }
462 None
463}
464
465fn validate_controller_name(
467 res: &str,
468 action: &str,
469 phase: &str,
470 name: &str,
471 errors: &mut Vec<ValidationError>,
472) {
473 if let Some(wasm_path) = name.strip_prefix(WASM_HOOK_PREFIX) {
474 if wasm_path.is_empty() {
475 errors.push(err(&format!(
476 "resource '{res}': endpoint '{action}' controller.{phase} has 'wasm:' prefix but no path"
477 )));
478 } else if !wasm_path.ends_with(".wasm") {
479 errors.push(err(&format!(
480 "resource '{res}': endpoint '{action}' controller.{phase} WASM path must end with '.wasm', got '{wasm_path}'"
481 )));
482 }
483 }
484}
485
486fn err(message: &str) -> ValidationError {
487 ValidationError {
488 message: message.to_string(),
489 }
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495 use crate::parser::parse_resource;
496
497 #[test]
498 fn valid_resource_passes() {
499 let yaml = include_str!("../../resources/users.yaml");
500 let rd = parse_resource(yaml).unwrap();
501 let errors = validate_resource(&rd);
502 assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
503 }
504
505 #[test]
506 fn enum_without_values() {
507 let yaml = r#"
508resource: items
509version: 1
510schema:
511 id: { type: uuid, primary: true, generated: true }
512 status: { type: enum, required: true }
513"#;
514 let rd = parse_resource(yaml).unwrap();
515 let errors = validate_resource(&rd);
516 assert!(errors
517 .iter()
518 .any(|e| e.message.contains("type enum but has no values")));
519 }
520
521 #[test]
522 fn ref_field_not_uuid() {
523 let yaml = r#"
524resource: items
525version: 1
526schema:
527 id: { type: uuid, primary: true, generated: true }
528 org_id: { type: string, ref: organizations.id }
529"#;
530 let rd = parse_resource(yaml).unwrap();
531 let errors = validate_resource(&rd);
532 assert!(errors
533 .iter()
534 .any(|e| e.message.contains("has ref but is not type uuid")));
535 }
536
537 #[test]
538 fn missing_primary_key() {
539 let yaml = r#"
540resource: items
541version: 1
542schema:
543 name: { type: string, required: true }
544"#;
545 let rd = parse_resource(yaml).unwrap();
546 let errors = validate_resource(&rd);
547 assert!(errors
548 .iter()
549 .any(|e| e.message.contains("must have a primary key")));
550 }
551
552 #[test]
553 fn soft_delete_without_deleted_at() {
554 let yaml = r#"
555resource: items
556version: 1
557schema:
558 id: { type: uuid, primary: true, generated: true }
559 name: { type: string, required: true }
560endpoints:
561 delete:
562 method: DELETE
563 path: /items/:id
564 auth: [admin]
565 soft_delete: true
566"#;
567 let rd = parse_resource(yaml).unwrap();
568 let errors = validate_resource(&rd);
569 assert!(errors.iter().any(|e| e
570 .message
571 .contains("soft_delete but schema has no 'deleted_at'")));
572 }
573
574 #[test]
575 fn input_field_not_in_schema() {
576 let yaml = r#"
577resource: items
578version: 1
579schema:
580 id: { type: uuid, primary: true, generated: true }
581 name: { type: string, required: true }
582endpoints:
583 create:
584 method: POST
585 path: /items
586 auth: [admin]
587 input: [name, nonexistent]
588"#;
589 let rd = parse_resource(yaml).unwrap();
590 let errors = validate_resource(&rd);
591 assert!(errors.iter().any(|e| e
592 .message
593 .contains("input field 'nonexistent' not found in schema")));
594 }
595
596 #[test]
597 fn belongs_to_without_key() {
598 let yaml = r#"
599resource: items
600version: 1
601schema:
602 id: { type: uuid, primary: true, generated: true }
603relations:
604 org: { resource: organizations, type: belongs_to }
605"#;
606 let rd = parse_resource(yaml).unwrap();
607 let errors = validate_resource(&rd);
608 assert!(errors
609 .iter()
610 .any(|e| e.message.contains("belongs_to but has no key")));
611 }
612
613 #[test]
614 fn has_many_without_foreign_key() {
615 let yaml = r#"
616resource: items
617version: 1
618schema:
619 id: { type: uuid, primary: true, generated: true }
620relations:
621 orders: { resource: orders, type: has_many }
622"#;
623 let rd = parse_resource(yaml).unwrap();
624 let errors = validate_resource(&rd);
625 assert!(errors
626 .iter()
627 .any(|e| e.message.contains("has_many but has no foreign_key")));
628 }
629
630 #[test]
631 fn index_references_missing_field() {
632 let yaml = r#"
633resource: items
634version: 1
635schema:
636 id: { type: uuid, primary: true, generated: true }
637indexes:
638 - fields: [missing_field]
639"#;
640 let rd = parse_resource(yaml).unwrap();
641 let errors = validate_resource(&rd);
642 assert!(errors.iter().any(|e| e
643 .message
644 .contains("references field 'missing_field' not in schema")));
645 }
646
647 #[test]
648 fn error_message_format() {
649 let yaml = r#"
650resource: users
651version: 1
652schema:
653 id: { type: uuid, primary: true, generated: true }
654 role: { type: enum }
655"#;
656 let rd = parse_resource(yaml).unwrap();
657 let errors = validate_resource(&rd);
658 assert_eq!(
659 errors[0].message,
660 "resource 'users': field 'role' is type enum but has no values"
661 );
662 }
663
664 #[test]
665 fn wasm_controller_valid_path() {
666 let yaml = r#"
667resource: items
668version: 1
669schema:
670 id: { type: uuid, primary: true, generated: true }
671 name: { type: string, required: true }
672endpoints:
673 create:
674 method: POST
675 path: /items
676 input: [name]
677 controller: { before: "wasm:./plugins/my_validator.wasm" }
678"#;
679 let rd = parse_resource(yaml).unwrap();
680 let errors = validate_resource(&rd);
681 assert!(
682 errors.is_empty(),
683 "Expected no errors for valid WASM controller, got: {errors:?}"
684 );
685 }
686
687 #[test]
688 fn wasm_controller_missing_extension() {
689 let yaml = r#"
690resource: items
691version: 1
692schema:
693 id: { type: uuid, primary: true, generated: true }
694 name: { type: string, required: true }
695endpoints:
696 create:
697 method: POST
698 path: /items
699 input: [name]
700 controller: { before: "wasm:./plugins/my_validator" }
701"#;
702 let rd = parse_resource(yaml).unwrap();
703 let errors = validate_resource(&rd);
704 assert!(errors
705 .iter()
706 .any(|e| e.message.contains("WASM path must end with '.wasm'")));
707 }
708
709 #[test]
710 fn wasm_controller_empty_path() {
711 let yaml = r#"
712resource: items
713version: 1
714schema:
715 id: { type: uuid, primary: true, generated: true }
716 name: { type: string, required: true }
717endpoints:
718 create:
719 method: POST
720 path: /items
721 input: [name]
722 controller: { before: "wasm:" }
723"#;
724 let rd = parse_resource(yaml).unwrap();
725 let errors = validate_resource(&rd);
726 assert!(errors
727 .iter()
728 .any(|e| e.message.contains("'wasm:' prefix but no path")));
729 }
730
731 #[test]
732 fn upload_endpoint_valid_when_file_field_declared() {
733 let yaml = r#"
734resource: assets
735version: 1
736schema:
737 id: { type: uuid, primary: true, generated: true }
738 file: { type: file, required: true }
739 file_filename: { type: string }
740 file_mime_type: { type: string }
741 file_size: { type: bigint }
742 updated_at: { type: timestamp, generated: true }
743endpoints:
744 upload:
745 method: POST
746 path: /assets/upload
747 input: [file]
748 upload:
749 field: file
750 storage: local
751 max_size: 5mb
752"#;
753 let rd = parse_resource(yaml).unwrap();
754 let errors = validate_resource(&rd);
755 assert!(
756 errors.is_empty(),
757 "Expected valid upload resource, got {errors:?}"
758 );
759 }
760
761 #[test]
762 fn upload_endpoint_requires_file_field() {
763 let yaml = r#"
764resource: assets
765version: 1
766schema:
767 id: { type: uuid, primary: true, generated: true }
768 file_path: { type: string, required: true }
769endpoints:
770 upload:
771 method: POST
772 path: /assets/upload
773 input: [file_path]
774 upload:
775 field: file_path
776 storage: local
777 max_size: 5mb
778"#;
779 let rd = parse_resource(yaml).unwrap();
780 let errors = validate_resource(&rd);
781 assert!(errors.iter().any(|e| e
782 .message
783 .contains("upload field 'file_path' must be type file")));
784 }
785
786 #[test]
787 fn tenant_key_valid_uuid_field() {
788 let yaml = r#"
789resource: projects
790version: 1
791tenant_key: org_id
792schema:
793 id: { type: uuid, primary: true, generated: true }
794 org_id: { type: uuid, ref: organizations.id, required: true }
795 name: { type: string, required: true }
796"#;
797 let rd = parse_resource(yaml).unwrap();
798 let errors = validate_resource(&rd);
799 assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
800 }
801
802 #[test]
803 fn tenant_key_missing_field() {
804 let yaml = r#"
805resource: projects
806version: 1
807tenant_key: org_id
808schema:
809 id: { type: uuid, primary: true, generated: true }
810 name: { type: string, required: true }
811"#;
812 let rd = parse_resource(yaml).unwrap();
813 let errors = validate_resource(&rd);
814 assert!(errors.iter().any(|e| e
815 .message
816 .contains("tenant_key 'org_id' not found in schema")));
817 }
818
819 #[test]
820 fn transient_field_valid() {
821 let yaml = r#"
822resource: users
823version: 1
824schema:
825 id: { type: uuid, primary: true, generated: true }
826 password: { type: string, transient: true, min: 12, required: true }
827 password_hash: { type: string, required: true }
828endpoints:
829 create:
830 method: POST
831 path: /users
832 input: [password]
833 controller: { before: hash_password }
834"#;
835 let rd = parse_resource(yaml).unwrap();
836 let errors = validate_resource(&rd);
837 assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
838 }
839
840 #[test]
841 fn transient_field_dead_when_not_in_input() {
842 let yaml = r#"
843resource: users
844version: 1
845schema:
846 id: { type: uuid, primary: true, generated: true }
847 password: { type: string, transient: true, min: 12 }
848endpoints:
849 create:
850 method: POST
851 path: /users
852 input: []
853"#;
854 let rd = parse_resource(yaml).unwrap();
855 let errors = validate_resource(&rd);
856 assert!(errors.iter().any(|e| e
857 .message
858 .contains("transient field 'password' is not declared in any endpoint's input")));
859 }
860
861 #[test]
862 fn transient_field_rejects_primary() {
863 let yaml = r#"
864resource: users
865version: 1
866schema:
867 bad: { type: uuid, transient: true, primary: true }
868"#;
869 let rd = parse_resource(yaml).unwrap();
870 let errors = validate_resource(&rd);
871 assert!(errors
872 .iter()
873 .any(|e| e.message.contains("cannot be both transient and primary")));
874 }
875
876 #[test]
877 fn transient_field_rejects_generated() {
878 let yaml = r#"
879resource: users
880version: 1
881schema:
882 id: { type: uuid, primary: true, generated: true }
883 bad: { type: timestamp, transient: true, generated: true }
884endpoints:
885 create:
886 method: POST
887 path: /users
888 input: [bad]
889"#;
890 let rd = parse_resource(yaml).unwrap();
891 let errors = validate_resource(&rd);
892 assert!(errors
893 .iter()
894 .any(|e| e.message.contains("cannot be both transient and generated")));
895 }
896
897 #[test]
898 fn transient_field_rejects_ref() {
899 let yaml = r#"
900resource: users
901version: 1
902schema:
903 id: { type: uuid, primary: true, generated: true }
904 bad: { type: uuid, transient: true, ref: orgs.id }
905endpoints:
906 create:
907 method: POST
908 path: /users
909 input: [bad]
910"#;
911 let rd = parse_resource(yaml).unwrap();
912 let errors = validate_resource(&rd);
913 assert!(errors.iter().any(|e| e
914 .message
915 .contains("cannot be both transient and have a ref")));
916 }
917
918 #[test]
919 fn transient_field_rejects_unique() {
920 let yaml = r#"
921resource: users
922version: 1
923schema:
924 id: { type: uuid, primary: true, generated: true }
925 bad: { type: string, transient: true, unique: true }
926endpoints:
927 create:
928 method: POST
929 path: /users
930 input: [bad]
931"#;
932 let rd = parse_resource(yaml).unwrap();
933 let errors = validate_resource(&rd);
934 assert!(errors
935 .iter()
936 .any(|e| e.message.contains("cannot be both transient and unique")));
937 }
938
939 #[test]
940 fn transient_field_rejects_default() {
941 let yaml = r#"
942resource: users
943version: 1
944schema:
945 id: { type: uuid, primary: true, generated: true }
946 bad: { type: string, transient: true, default: "x" }
947endpoints:
948 create:
949 method: POST
950 path: /users
951 input: [bad]
952"#;
953 let rd = parse_resource(yaml).unwrap();
954 let errors = validate_resource(&rd);
955 assert!(errors.iter().any(|e| e
956 .message
957 .contains("cannot be both transient and have a default")));
958 }
959
960 #[test]
961 fn tenant_key_wrong_type() {
962 let yaml = r#"
963resource: projects
964version: 1
965tenant_key: org_name
966schema:
967 id: { type: uuid, primary: true, generated: true }
968 org_name: { type: string, required: true }
969"#;
970 let rd = parse_resource(yaml).unwrap();
971 let errors = validate_resource(&rd);
972 assert!(errors.iter().any(|e| e
973 .message
974 .contains("tenant_key 'org_name' must reference a uuid field")));
975 }
976
977 #[test]
978 fn reject_after_controller_on_custom_endpoint() {
979 let yaml = r#"
980resource: agents
981version: 1
982schema:
983 id: { type: uuid, primary: true, generated: true }
984endpoints:
985 regenerate_secret:
986 method: POST
987 path: /agents/:id/regenerate_secret
988 auth: [admin]
989 controller: { after: my_after }
990"#;
991 let rd = parse_resource(yaml).unwrap();
992 let errors = validate_resource(&rd);
993 assert!(
994 errors
995 .iter()
996 .any(|e| e.message.contains("declares `controller: { after: ... }`")),
997 "expected a CustomEndpointWithAfterController error, got: {errors:?}"
998 );
999 }
1000
1001 #[test]
1002 fn allow_before_controller_on_custom_endpoint() {
1003 let yaml = r#"
1004resource: agents
1005version: 1
1006schema:
1007 id: { type: uuid, primary: true, generated: true }
1008endpoints:
1009 regenerate_secret:
1010 method: POST
1011 path: /agents/:id/regenerate_secret
1012 auth: [admin]
1013 controller: { before: my_before }
1014"#;
1015 let rd = parse_resource(yaml).unwrap();
1016 let errors = validate_resource(&rd);
1017 assert!(
1018 errors.is_empty(),
1019 "before:-only controller on a custom endpoint should validate clean, got: {errors:?}"
1020 );
1021 }
1022
1023 #[test]
1024 fn allow_controller_on_crud_endpoints() {
1025 let yaml = r#"
1026resource: agents
1027version: 1
1028schema:
1029 id: { type: uuid, primary: true, generated: true }
1030 name: { type: string, required: true }
1031endpoints:
1032 create:
1033 method: POST
1034 path: /agents
1035 input: [name]
1036 controller: { before: my_before }
1037"#;
1038 let rd = parse_resource(yaml).unwrap();
1039 let errors = validate_resource(&rd);
1040 assert!(
1041 !errors.iter().any(|e| e
1042 .message
1043 .contains("declares `controller:` but is a custom endpoint")),
1044 "create endpoint with controller should NOT trip the rule, got: {errors:?}"
1045 );
1046 }
1047}