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(e) = validate_handler_only_on_custom(res, action, ep) {
203 errors.push(e);
204 }
205
206 if let Some(controller) = &ep.controller {
207 if let Some(e) = validate_controller_only_on_crud(res, action, ep) {
209 errors.push(e);
210 }
211
212 if let Some(before) = &controller.before {
213 if before.is_empty() {
214 errors.push(err(&format!(
215 "resource '{res}': endpoint '{action}' has an empty controller.before name"
216 )));
217 }
218 validate_controller_name(res, action, "before", before, &mut errors);
219 }
220 if let Some(after) = &controller.after {
221 if after.is_empty() {
222 errors.push(err(&format!(
223 "resource '{res}': endpoint '{action}' has an empty controller.after name"
224 )));
225 }
226 validate_controller_name(res, action, "after", after, &mut errors);
227 }
228 }
229
230 if let Some(events) = &ep.events {
231 for event in events {
232 if event.is_empty() {
233 errors.push(err(&format!(
234 "resource '{res}': endpoint '{action}' has an empty event name"
235 )));
236 }
237 }
238 }
239
240 if let Some(jobs) = &ep.jobs {
241 for job in jobs {
242 if job.is_empty() {
243 errors.push(err(&format!(
244 "resource '{res}': endpoint '{action}' has an empty job name"
245 )));
246 }
247 }
248 }
249
250 if let Some(input) = &ep.input {
252 for field_name in input {
253 if !rd.schema.contains_key(field_name) {
254 errors.push(err(&format!(
255 "resource '{res}': endpoint '{action}' input field '{field_name}' not found in schema"
256 )));
257 }
258 }
259 }
260
261 if let Some(filters) = &ep.filters {
263 for field_name in filters {
264 if !rd.schema.contains_key(field_name) {
265 errors.push(err(&format!(
266 "resource '{res}': endpoint '{action}' filter field '{field_name}' not found in schema"
267 )));
268 }
269 }
270 }
271
272 if let Some(search) = &ep.search {
274 for field_name in search {
275 if !rd.schema.contains_key(field_name) {
276 errors.push(err(&format!(
277 "resource '{res}': endpoint '{action}' search field '{field_name}' not found in schema"
278 )));
279 }
280 }
281 }
282
283 if let Some(sort) = &ep.sort {
285 for field_name in sort {
286 if !rd.schema.contains_key(field_name) {
287 errors.push(err(&format!(
288 "resource '{res}': endpoint '{action}' sort field '{field_name}' not found in schema"
289 )));
290 }
291 }
292 }
293
294 if ep.soft_delete && !rd.schema.contains_key("deleted_at") {
296 errors.push(err(&format!(
297 "resource '{res}': endpoint '{action}' has soft_delete but schema has no 'deleted_at' field"
298 )));
299 }
300
301 if let Some(upload) = &ep.upload {
302 match ep.method.as_ref() {
303 Some(HttpMethod::Post | HttpMethod::Patch | HttpMethod::Put) => {}
304 Some(_) => errors.push(err(&format!(
305 "resource '{res}': endpoint '{action}' uses upload but method must be POST, PATCH, or PUT"
306 ))),
307 None => {} }
309
310 match rd.schema.get(&upload.field) {
311 Some(field) if field.field_type == FieldType::File => {}
312 Some(_) => errors.push(err(&format!(
313 "resource '{res}': endpoint '{action}' upload field '{}' must be type file",
314 upload.field
315 ))),
316 None => errors.push(err(&format!(
317 "resource '{res}': endpoint '{action}' upload field '{}' not found in schema",
318 upload.field
319 ))),
320 }
321
322 if !matches!(upload.storage.as_str(), "local" | "s3" | "gcs" | "azure") {
323 errors.push(err(&format!(
324 "resource '{res}': endpoint '{action}' upload storage '{}' is invalid",
325 upload.storage
326 )));
327 }
328
329 if !ep
330 .input
331 .as_ref()
332 .is_some_and(|fields| fields.iter().any(|field| field == &upload.field))
333 {
334 errors.push(err(&format!(
335 "resource '{res}': endpoint '{action}' upload field '{}' must appear in input",
336 upload.field
337 )));
338 }
339
340 for (suffix, expected_types) in [
341 ("filename", &[FieldType::String][..]),
342 ("mime_type", &[FieldType::String][..]),
343 ("size", &[FieldType::Integer, FieldType::Bigint][..]),
344 ] {
345 let companion = format!("{}_{}", upload.field, suffix);
346 if let Some(field) = rd.schema.get(&companion) {
347 if !expected_types.contains(&field.field_type) {
348 let expected = expected_types
349 .iter()
350 .map(ToString::to_string)
351 .collect::<Vec<_>>()
352 .join(" or ");
353 errors.push(err(&format!(
354 "resource '{res}': companion upload field '{companion}' must be type {expected}"
355 )));
356 }
357 }
358 }
359 }
360 }
361 }
362
363 if let Some(relations) = &rd.relations {
365 for (name, rel) in relations {
366 use shaperail_core::RelationType;
367
368 if rel.relation_type == RelationType::BelongsTo && rel.key.is_none() {
370 errors.push(err(&format!(
371 "resource '{res}': relation '{name}' is belongs_to but has no key"
372 )));
373 }
374
375 if matches!(
377 rel.relation_type,
378 RelationType::HasMany | RelationType::HasOne
379 ) && rel.foreign_key.is_none()
380 {
381 errors.push(err(&format!(
382 "resource '{res}': relation '{name}' is {} but has no foreign_key",
383 rel.relation_type
384 )));
385 }
386
387 if let Some(key) = &rel.key {
389 if !rd.schema.contains_key(key) {
390 errors.push(err(&format!(
391 "resource '{res}': relation '{name}' key '{key}' not found in schema"
392 )));
393 }
394 }
395 }
396 }
397
398 if let Some(indexes) = &rd.indexes {
400 for (i, idx) in indexes.iter().enumerate() {
401 if idx.fields.is_empty() {
402 errors.push(err(&format!("resource '{res}': index {i} has no fields")));
403 }
404 for field_name in &idx.fields {
405 if !rd.schema.contains_key(field_name) {
406 errors.push(err(&format!(
407 "resource '{res}': index {i} references field '{field_name}' not in schema"
408 )));
409 }
410 }
411 if let Some(order) = &idx.order {
412 if order != "asc" && order != "desc" {
413 errors.push(err(&format!(
414 "resource '{res}': index {i} has invalid order '{order}', must be 'asc' or 'desc'"
415 )));
416 }
417 }
418 }
419 }
420
421 errors
422}
423
424fn validate_controller_only_on_crud(
435 resource: &str,
436 action: &str,
437 endpoint: &shaperail_core::EndpointSpec,
438) -> Option<ValidationError> {
439 const CRUD_ACTIONS: &[&str] = &[
440 "list",
441 "get",
442 "create",
443 "update",
444 "delete",
445 "bulk_create",
446 "bulk_delete",
447 ];
448 if CRUD_ACTIONS.contains(&action) {
449 return None;
450 }
451 let has_after = endpoint
453 .controller
454 .as_ref()
455 .and_then(|c| c.after.as_deref())
456 .is_some();
457 if has_after {
458 return Some(err(&format!(
459 "resource '{resource}': endpoint '{action}' declares `controller: {{ after: ... }}`, \
460 but `after:` controllers are only valid on conventional CRUD endpoints \
461 (list / get / create / update / delete / bulk_create / bulk_delete).\n\
462 \n\
463 Custom endpoints generate their own response via `handler:`, so the runtime \
464 has no place to merge `ctx.response_extras` or pass through after-hook \
465 mutations. Use a `before:` controller for shared setup (auth augmentation, \
466 tenant scoping, request validation), and put response-shaping logic inside \
467 the handler itself."
468 )));
469 }
470 None
471}
472
473fn validate_handler_only_on_custom(
482 resource: &str,
483 action: &str,
484 endpoint: &shaperail_core::EndpointSpec,
485) -> Option<ValidationError> {
486 let handler = endpoint.handler.as_deref()?;
487 if !crate::rust::HANDLER_CONVENTIONS.contains(&action) {
488 return None;
489 }
490 Some(err(&format!(
491 "resource '{resource}': endpoint '{action}' declares `handler: {handler}`, \
492 but `handler:` is only valid on custom (non-convention) endpoints. \
493 The convention actions list / get / create / update / delete are \
494 dispatched by the runtime CRUD path; a `handler:` declaration on \
495 them would be silently dropped at codegen time.\n\
496 \n\
497 To attach a custom handler at the same HTTP method+path, rename the \
498 endpoint key to a non-convention action (e.g. `post_{resource}`) and \
499 set `method:` and `path:` explicitly:\n\
500 \n\
501 endpoints:\n \
502 post_{resource}:\n \
503 method: POST\n \
504 path: /{resource}\n \
505 handler: {handler}\n\
506 \n\
507 To customize standard CRUD behavior without replacing the runtime \
508 path, use `controller: {{ before: ... }}` for setup logic and \
509 `controller: {{ after: ... }}` for response shaping."
510 )))
511}
512
513fn validate_controller_name(
515 res: &str,
516 action: &str,
517 phase: &str,
518 name: &str,
519 errors: &mut Vec<ValidationError>,
520) {
521 if let Some(wasm_path) = name.strip_prefix(WASM_HOOK_PREFIX) {
522 if wasm_path.is_empty() {
523 errors.push(err(&format!(
524 "resource '{res}': endpoint '{action}' controller.{phase} has 'wasm:' prefix but no path"
525 )));
526 } else if !wasm_path.ends_with(".wasm") {
527 errors.push(err(&format!(
528 "resource '{res}': endpoint '{action}' controller.{phase} WASM path must end with '.wasm', got '{wasm_path}'"
529 )));
530 }
531 }
532}
533
534fn err(message: &str) -> ValidationError {
535 ValidationError {
536 message: message.to_string(),
537 }
538}
539
540#[cfg(test)]
541mod tests {
542 use super::*;
543 use crate::parser::parse_resource;
544
545 #[test]
546 fn valid_resource_passes() {
547 let yaml = include_str!("../../resources/users.yaml");
548 let rd = parse_resource(yaml).unwrap();
549 let errors = validate_resource(&rd);
550 assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
551 }
552
553 #[test]
554 fn enum_without_values() {
555 let yaml = r#"
556resource: items
557version: 1
558schema:
559 id: { type: uuid, primary: true, generated: true }
560 status: { type: enum, required: true }
561"#;
562 let rd = parse_resource(yaml).unwrap();
563 let errors = validate_resource(&rd);
564 assert!(errors
565 .iter()
566 .any(|e| e.message.contains("type enum but has no values")));
567 }
568
569 #[test]
570 fn ref_field_not_uuid() {
571 let yaml = r#"
572resource: items
573version: 1
574schema:
575 id: { type: uuid, primary: true, generated: true }
576 org_id: { type: string, ref: organizations.id }
577"#;
578 let rd = parse_resource(yaml).unwrap();
579 let errors = validate_resource(&rd);
580 assert!(errors
581 .iter()
582 .any(|e| e.message.contains("has ref but is not type uuid")));
583 }
584
585 #[test]
586 fn missing_primary_key() {
587 let yaml = r#"
588resource: items
589version: 1
590schema:
591 name: { type: string, required: true }
592"#;
593 let rd = parse_resource(yaml).unwrap();
594 let errors = validate_resource(&rd);
595 assert!(errors
596 .iter()
597 .any(|e| e.message.contains("must have a primary key")));
598 }
599
600 #[test]
601 fn soft_delete_without_deleted_at() {
602 let yaml = r#"
603resource: items
604version: 1
605schema:
606 id: { type: uuid, primary: true, generated: true }
607 name: { type: string, required: true }
608endpoints:
609 delete:
610 method: DELETE
611 path: /items/:id
612 auth: [admin]
613 soft_delete: true
614"#;
615 let rd = parse_resource(yaml).unwrap();
616 let errors = validate_resource(&rd);
617 assert!(errors.iter().any(|e| e
618 .message
619 .contains("soft_delete but schema has no 'deleted_at'")));
620 }
621
622 #[test]
623 fn input_field_not_in_schema() {
624 let yaml = r#"
625resource: items
626version: 1
627schema:
628 id: { type: uuid, primary: true, generated: true }
629 name: { type: string, required: true }
630endpoints:
631 create:
632 method: POST
633 path: /items
634 auth: [admin]
635 input: [name, nonexistent]
636"#;
637 let rd = parse_resource(yaml).unwrap();
638 let errors = validate_resource(&rd);
639 assert!(errors.iter().any(|e| e
640 .message
641 .contains("input field 'nonexistent' not found in schema")));
642 }
643
644 #[test]
645 fn belongs_to_without_key() {
646 let yaml = r#"
647resource: items
648version: 1
649schema:
650 id: { type: uuid, primary: true, generated: true }
651relations:
652 org: { resource: organizations, type: belongs_to }
653"#;
654 let rd = parse_resource(yaml).unwrap();
655 let errors = validate_resource(&rd);
656 assert!(errors
657 .iter()
658 .any(|e| e.message.contains("belongs_to but has no key")));
659 }
660
661 #[test]
662 fn has_many_without_foreign_key() {
663 let yaml = r#"
664resource: items
665version: 1
666schema:
667 id: { type: uuid, primary: true, generated: true }
668relations:
669 orders: { resource: orders, type: has_many }
670"#;
671 let rd = parse_resource(yaml).unwrap();
672 let errors = validate_resource(&rd);
673 assert!(errors
674 .iter()
675 .any(|e| e.message.contains("has_many but has no foreign_key")));
676 }
677
678 #[test]
679 fn index_references_missing_field() {
680 let yaml = r#"
681resource: items
682version: 1
683schema:
684 id: { type: uuid, primary: true, generated: true }
685indexes:
686 - fields: [missing_field]
687"#;
688 let rd = parse_resource(yaml).unwrap();
689 let errors = validate_resource(&rd);
690 assert!(errors.iter().any(|e| e
691 .message
692 .contains("references field 'missing_field' not in schema")));
693 }
694
695 #[test]
696 fn error_message_format() {
697 let yaml = r#"
698resource: users
699version: 1
700schema:
701 id: { type: uuid, primary: true, generated: true }
702 role: { type: enum }
703"#;
704 let rd = parse_resource(yaml).unwrap();
705 let errors = validate_resource(&rd);
706 assert_eq!(
707 errors[0].message,
708 "resource 'users': field 'role' is type enum but has no values"
709 );
710 }
711
712 #[test]
713 fn wasm_controller_valid_path() {
714 let yaml = r#"
715resource: items
716version: 1
717schema:
718 id: { type: uuid, primary: true, generated: true }
719 name: { type: string, required: true }
720endpoints:
721 create:
722 method: POST
723 path: /items
724 input: [name]
725 controller: { before: "wasm:./plugins/my_validator.wasm" }
726"#;
727 let rd = parse_resource(yaml).unwrap();
728 let errors = validate_resource(&rd);
729 assert!(
730 errors.is_empty(),
731 "Expected no errors for valid WASM controller, got: {errors:?}"
732 );
733 }
734
735 #[test]
736 fn wasm_controller_missing_extension() {
737 let yaml = r#"
738resource: items
739version: 1
740schema:
741 id: { type: uuid, primary: true, generated: true }
742 name: { type: string, required: true }
743endpoints:
744 create:
745 method: POST
746 path: /items
747 input: [name]
748 controller: { before: "wasm:./plugins/my_validator" }
749"#;
750 let rd = parse_resource(yaml).unwrap();
751 let errors = validate_resource(&rd);
752 assert!(errors
753 .iter()
754 .any(|e| e.message.contains("WASM path must end with '.wasm'")));
755 }
756
757 #[test]
758 fn wasm_controller_empty_path() {
759 let yaml = r#"
760resource: items
761version: 1
762schema:
763 id: { type: uuid, primary: true, generated: true }
764 name: { type: string, required: true }
765endpoints:
766 create:
767 method: POST
768 path: /items
769 input: [name]
770 controller: { before: "wasm:" }
771"#;
772 let rd = parse_resource(yaml).unwrap();
773 let errors = validate_resource(&rd);
774 assert!(errors
775 .iter()
776 .any(|e| e.message.contains("'wasm:' prefix but no path")));
777 }
778
779 #[test]
780 fn upload_endpoint_valid_when_file_field_declared() {
781 let yaml = r#"
782resource: assets
783version: 1
784schema:
785 id: { type: uuid, primary: true, generated: true }
786 file: { type: file, required: true }
787 file_filename: { type: string }
788 file_mime_type: { type: string }
789 file_size: { type: bigint }
790 updated_at: { type: timestamp, generated: true }
791endpoints:
792 upload:
793 method: POST
794 path: /assets/upload
795 input: [file]
796 upload:
797 field: file
798 storage: local
799 max_size: 5mb
800"#;
801 let rd = parse_resource(yaml).unwrap();
802 let errors = validate_resource(&rd);
803 assert!(
804 errors.is_empty(),
805 "Expected valid upload resource, got {errors:?}"
806 );
807 }
808
809 #[test]
810 fn upload_endpoint_requires_file_field() {
811 let yaml = r#"
812resource: assets
813version: 1
814schema:
815 id: { type: uuid, primary: true, generated: true }
816 file_path: { type: string, required: true }
817endpoints:
818 upload:
819 method: POST
820 path: /assets/upload
821 input: [file_path]
822 upload:
823 field: file_path
824 storage: local
825 max_size: 5mb
826"#;
827 let rd = parse_resource(yaml).unwrap();
828 let errors = validate_resource(&rd);
829 assert!(errors.iter().any(|e| e
830 .message
831 .contains("upload field 'file_path' must be type file")));
832 }
833
834 #[test]
835 fn tenant_key_valid_uuid_field() {
836 let yaml = r#"
837resource: projects
838version: 1
839tenant_key: org_id
840schema:
841 id: { type: uuid, primary: true, generated: true }
842 org_id: { type: uuid, ref: organizations.id, required: true }
843 name: { type: string, required: true }
844"#;
845 let rd = parse_resource(yaml).unwrap();
846 let errors = validate_resource(&rd);
847 assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
848 }
849
850 #[test]
851 fn tenant_key_missing_field() {
852 let yaml = r#"
853resource: projects
854version: 1
855tenant_key: org_id
856schema:
857 id: { type: uuid, primary: true, generated: true }
858 name: { type: string, required: true }
859"#;
860 let rd = parse_resource(yaml).unwrap();
861 let errors = validate_resource(&rd);
862 assert!(errors.iter().any(|e| e
863 .message
864 .contains("tenant_key 'org_id' not found in schema")));
865 }
866
867 #[test]
868 fn transient_field_valid() {
869 let yaml = r#"
870resource: users
871version: 1
872schema:
873 id: { type: uuid, primary: true, generated: true }
874 password: { type: string, transient: true, min: 12, required: true }
875 password_hash: { type: string, required: true }
876endpoints:
877 create:
878 method: POST
879 path: /users
880 input: [password]
881 controller: { before: hash_password }
882"#;
883 let rd = parse_resource(yaml).unwrap();
884 let errors = validate_resource(&rd);
885 assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
886 }
887
888 #[test]
889 fn transient_field_dead_when_not_in_input() {
890 let yaml = r#"
891resource: users
892version: 1
893schema:
894 id: { type: uuid, primary: true, generated: true }
895 password: { type: string, transient: true, min: 12 }
896endpoints:
897 create:
898 method: POST
899 path: /users
900 input: []
901"#;
902 let rd = parse_resource(yaml).unwrap();
903 let errors = validate_resource(&rd);
904 assert!(errors.iter().any(|e| e
905 .message
906 .contains("transient field 'password' is not declared in any endpoint's input")));
907 }
908
909 #[test]
910 fn transient_field_rejects_primary() {
911 let yaml = r#"
912resource: users
913version: 1
914schema:
915 bad: { type: uuid, transient: true, primary: true }
916"#;
917 let rd = parse_resource(yaml).unwrap();
918 let errors = validate_resource(&rd);
919 assert!(errors
920 .iter()
921 .any(|e| e.message.contains("cannot be both transient and primary")));
922 }
923
924 #[test]
925 fn transient_field_rejects_generated() {
926 let yaml = r#"
927resource: users
928version: 1
929schema:
930 id: { type: uuid, primary: true, generated: true }
931 bad: { type: timestamp, transient: true, generated: true }
932endpoints:
933 create:
934 method: POST
935 path: /users
936 input: [bad]
937"#;
938 let rd = parse_resource(yaml).unwrap();
939 let errors = validate_resource(&rd);
940 assert!(errors
941 .iter()
942 .any(|e| e.message.contains("cannot be both transient and generated")));
943 }
944
945 #[test]
946 fn transient_field_rejects_ref() {
947 let yaml = r#"
948resource: users
949version: 1
950schema:
951 id: { type: uuid, primary: true, generated: true }
952 bad: { type: uuid, transient: true, ref: orgs.id }
953endpoints:
954 create:
955 method: POST
956 path: /users
957 input: [bad]
958"#;
959 let rd = parse_resource(yaml).unwrap();
960 let errors = validate_resource(&rd);
961 assert!(errors.iter().any(|e| e
962 .message
963 .contains("cannot be both transient and have a ref")));
964 }
965
966 #[test]
967 fn transient_field_rejects_unique() {
968 let yaml = r#"
969resource: users
970version: 1
971schema:
972 id: { type: uuid, primary: true, generated: true }
973 bad: { type: string, transient: true, unique: true }
974endpoints:
975 create:
976 method: POST
977 path: /users
978 input: [bad]
979"#;
980 let rd = parse_resource(yaml).unwrap();
981 let errors = validate_resource(&rd);
982 assert!(errors
983 .iter()
984 .any(|e| e.message.contains("cannot be both transient and unique")));
985 }
986
987 #[test]
988 fn transient_field_rejects_default() {
989 let yaml = r#"
990resource: users
991version: 1
992schema:
993 id: { type: uuid, primary: true, generated: true }
994 bad: { type: string, transient: true, default: "x" }
995endpoints:
996 create:
997 method: POST
998 path: /users
999 input: [bad]
1000"#;
1001 let rd = parse_resource(yaml).unwrap();
1002 let errors = validate_resource(&rd);
1003 assert!(errors.iter().any(|e| e
1004 .message
1005 .contains("cannot be both transient and have a default")));
1006 }
1007
1008 #[test]
1009 fn tenant_key_wrong_type() {
1010 let yaml = r#"
1011resource: projects
1012version: 1
1013tenant_key: org_name
1014schema:
1015 id: { type: uuid, primary: true, generated: true }
1016 org_name: { type: string, required: true }
1017"#;
1018 let rd = parse_resource(yaml).unwrap();
1019 let errors = validate_resource(&rd);
1020 assert!(errors.iter().any(|e| e
1021 .message
1022 .contains("tenant_key 'org_name' must reference a uuid field")));
1023 }
1024
1025 #[test]
1026 fn reject_after_controller_on_custom_endpoint() {
1027 let yaml = r#"
1028resource: agents
1029version: 1
1030schema:
1031 id: { type: uuid, primary: true, generated: true }
1032endpoints:
1033 regenerate_secret:
1034 method: POST
1035 path: /agents/:id/regenerate_secret
1036 auth: [admin]
1037 controller: { after: my_after }
1038"#;
1039 let rd = parse_resource(yaml).unwrap();
1040 let errors = validate_resource(&rd);
1041 assert!(
1042 errors
1043 .iter()
1044 .any(|e| e.message.contains("declares `controller: { after: ... }`")),
1045 "expected a CustomEndpointWithAfterController error, got: {errors:?}"
1046 );
1047 }
1048
1049 #[test]
1050 fn allow_before_controller_on_custom_endpoint() {
1051 let yaml = r#"
1052resource: agents
1053version: 1
1054schema:
1055 id: { type: uuid, primary: true, generated: true }
1056endpoints:
1057 regenerate_secret:
1058 method: POST
1059 path: /agents/:id/regenerate_secret
1060 auth: [admin]
1061 controller: { before: my_before }
1062"#;
1063 let rd = parse_resource(yaml).unwrap();
1064 let errors = validate_resource(&rd);
1065 assert!(
1066 errors.is_empty(),
1067 "before:-only controller on a custom endpoint should validate clean, got: {errors:?}"
1068 );
1069 }
1070
1071 #[test]
1072 fn allow_controller_on_crud_endpoints() {
1073 let yaml = r#"
1074resource: agents
1075version: 1
1076schema:
1077 id: { type: uuid, primary: true, generated: true }
1078 name: { type: string, required: true }
1079endpoints:
1080 create:
1081 method: POST
1082 path: /agents
1083 input: [name]
1084 controller: { before: my_before }
1085"#;
1086 let rd = parse_resource(yaml).unwrap();
1087 let errors = validate_resource(&rd);
1088 assert!(
1089 !errors.iter().any(|e| e
1090 .message
1091 .contains("declares `controller:` but is a custom endpoint")),
1092 "create endpoint with controller should NOT trip the rule, got: {errors:?}"
1093 );
1094 }
1095
1096 #[test]
1097 fn handler_on_convention_endpoint_rejected() {
1098 for action in &["list", "get", "create", "update", "delete"] {
1099 let yaml = format!(
1100 r#"
1101resource: items
1102version: 1
1103schema:
1104 id: {{ type: uuid, primary: true, generated: true }}
1105 name: {{ type: string, required: true }}
1106endpoints:
1107 {action}:
1108 handler: my_handler
1109"#
1110 );
1111 let rd = parse_resource(&yaml).unwrap();
1112 let errors = validate_resource(&rd);
1113 assert!(
1114 errors
1115 .iter()
1116 .any(|e| e.message.contains("`handler: my_handler`")
1117 && e.message
1118 .contains("only valid on custom (non-convention) endpoints")),
1119 "expected handler-on-convention error for action '{action}', got: {errors:?}"
1120 );
1121 }
1122 }
1123
1124 #[test]
1125 fn handler_on_custom_endpoint_allowed() {
1126 let yaml = r#"
1127resource: items
1128version: 1
1129schema:
1130 id: { type: uuid, primary: true, generated: true }
1131 name: { type: string, required: true }
1132endpoints:
1133 post_item:
1134 method: POST
1135 path: /items
1136 handler: create_item_custom
1137"#;
1138 let rd = parse_resource(yaml).unwrap();
1139 let errors = validate_resource(&rd);
1140 assert!(
1141 !errors
1142 .iter()
1143 .any(|e| e.message.contains("only valid on custom")),
1144 "custom endpoint with handler must not trip the rule, got: {errors:?}"
1145 );
1146 }
1147
1148 #[test]
1149 fn convention_endpoint_without_handler_allowed() {
1150 let yaml = r#"
1151resource: items
1152version: 1
1153schema:
1154 id: { type: uuid, primary: true, generated: true }
1155 name: { type: string, required: true }
1156endpoints:
1157 create:
1158 input: [name]
1159"#;
1160 let rd = parse_resource(yaml).unwrap();
1161 let errors = validate_resource(&rd);
1162 assert!(
1163 !errors
1164 .iter()
1165 .any(|e| e.message.contains("only valid on custom")),
1166 "convention endpoint without handler must not trip the rule, got: {errors:?}"
1167 );
1168 }
1169}