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(before) = &controller.before {
200 if before.is_empty() {
201 errors.push(err(&format!(
202 "resource '{res}': endpoint '{action}' has an empty controller.before name"
203 )));
204 }
205 validate_controller_name(res, action, "before", before, &mut errors);
206 }
207 if let Some(after) = &controller.after {
208 if after.is_empty() {
209 errors.push(err(&format!(
210 "resource '{res}': endpoint '{action}' has an empty controller.after name"
211 )));
212 }
213 validate_controller_name(res, action, "after", after, &mut errors);
214 }
215 }
216
217 if let Some(events) = &ep.events {
218 for event in events {
219 if event.is_empty() {
220 errors.push(err(&format!(
221 "resource '{res}': endpoint '{action}' has an empty event name"
222 )));
223 }
224 }
225 }
226
227 if let Some(jobs) = &ep.jobs {
228 for job in jobs {
229 if job.is_empty() {
230 errors.push(err(&format!(
231 "resource '{res}': endpoint '{action}' has an empty job name"
232 )));
233 }
234 }
235 }
236
237 if let Some(input) = &ep.input {
239 for field_name in input {
240 if !rd.schema.contains_key(field_name) {
241 errors.push(err(&format!(
242 "resource '{res}': endpoint '{action}' input field '{field_name}' not found in schema"
243 )));
244 }
245 }
246 }
247
248 if let Some(filters) = &ep.filters {
250 for field_name in filters {
251 if !rd.schema.contains_key(field_name) {
252 errors.push(err(&format!(
253 "resource '{res}': endpoint '{action}' filter field '{field_name}' not found in schema"
254 )));
255 }
256 }
257 }
258
259 if let Some(search) = &ep.search {
261 for field_name in search {
262 if !rd.schema.contains_key(field_name) {
263 errors.push(err(&format!(
264 "resource '{res}': endpoint '{action}' search field '{field_name}' not found in schema"
265 )));
266 }
267 }
268 }
269
270 if let Some(sort) = &ep.sort {
272 for field_name in sort {
273 if !rd.schema.contains_key(field_name) {
274 errors.push(err(&format!(
275 "resource '{res}': endpoint '{action}' sort field '{field_name}' not found in schema"
276 )));
277 }
278 }
279 }
280
281 if ep.soft_delete && !rd.schema.contains_key("deleted_at") {
283 errors.push(err(&format!(
284 "resource '{res}': endpoint '{action}' has soft_delete but schema has no 'deleted_at' field"
285 )));
286 }
287
288 if let Some(upload) = &ep.upload {
289 match ep.method.as_ref() {
290 Some(HttpMethod::Post | HttpMethod::Patch | HttpMethod::Put) => {}
291 Some(_) => errors.push(err(&format!(
292 "resource '{res}': endpoint '{action}' uses upload but method must be POST, PATCH, or PUT"
293 ))),
294 None => {} }
296
297 match rd.schema.get(&upload.field) {
298 Some(field) if field.field_type == FieldType::File => {}
299 Some(_) => errors.push(err(&format!(
300 "resource '{res}': endpoint '{action}' upload field '{}' must be type file",
301 upload.field
302 ))),
303 None => errors.push(err(&format!(
304 "resource '{res}': endpoint '{action}' upload field '{}' not found in schema",
305 upload.field
306 ))),
307 }
308
309 if !matches!(upload.storage.as_str(), "local" | "s3" | "gcs" | "azure") {
310 errors.push(err(&format!(
311 "resource '{res}': endpoint '{action}' upload storage '{}' is invalid",
312 upload.storage
313 )));
314 }
315
316 if !ep
317 .input
318 .as_ref()
319 .is_some_and(|fields| fields.iter().any(|field| field == &upload.field))
320 {
321 errors.push(err(&format!(
322 "resource '{res}': endpoint '{action}' upload field '{}' must appear in input",
323 upload.field
324 )));
325 }
326
327 for (suffix, expected_types) in [
328 ("filename", &[FieldType::String][..]),
329 ("mime_type", &[FieldType::String][..]),
330 ("size", &[FieldType::Integer, FieldType::Bigint][..]),
331 ] {
332 let companion = format!("{}_{}", upload.field, suffix);
333 if let Some(field) = rd.schema.get(&companion) {
334 if !expected_types.contains(&field.field_type) {
335 let expected = expected_types
336 .iter()
337 .map(ToString::to_string)
338 .collect::<Vec<_>>()
339 .join(" or ");
340 errors.push(err(&format!(
341 "resource '{res}': companion upload field '{companion}' must be type {expected}"
342 )));
343 }
344 }
345 }
346 }
347 }
348 }
349
350 if let Some(relations) = &rd.relations {
352 for (name, rel) in relations {
353 use shaperail_core::RelationType;
354
355 if rel.relation_type == RelationType::BelongsTo && rel.key.is_none() {
357 errors.push(err(&format!(
358 "resource '{res}': relation '{name}' is belongs_to but has no key"
359 )));
360 }
361
362 if matches!(
364 rel.relation_type,
365 RelationType::HasMany | RelationType::HasOne
366 ) && rel.foreign_key.is_none()
367 {
368 errors.push(err(&format!(
369 "resource '{res}': relation '{name}' is {} but has no foreign_key",
370 rel.relation_type
371 )));
372 }
373
374 if let Some(key) = &rel.key {
376 if !rd.schema.contains_key(key) {
377 errors.push(err(&format!(
378 "resource '{res}': relation '{name}' key '{key}' not found in schema"
379 )));
380 }
381 }
382 }
383 }
384
385 if let Some(indexes) = &rd.indexes {
387 for (i, idx) in indexes.iter().enumerate() {
388 if idx.fields.is_empty() {
389 errors.push(err(&format!("resource '{res}': index {i} has no fields")));
390 }
391 for field_name in &idx.fields {
392 if !rd.schema.contains_key(field_name) {
393 errors.push(err(&format!(
394 "resource '{res}': index {i} references field '{field_name}' not in schema"
395 )));
396 }
397 }
398 if let Some(order) = &idx.order {
399 if order != "asc" && order != "desc" {
400 errors.push(err(&format!(
401 "resource '{res}': index {i} has invalid order '{order}', must be 'asc' or 'desc'"
402 )));
403 }
404 }
405 }
406 }
407
408 errors
409}
410
411fn validate_controller_name(
413 res: &str,
414 action: &str,
415 phase: &str,
416 name: &str,
417 errors: &mut Vec<ValidationError>,
418) {
419 if let Some(wasm_path) = name.strip_prefix(WASM_HOOK_PREFIX) {
420 if wasm_path.is_empty() {
421 errors.push(err(&format!(
422 "resource '{res}': endpoint '{action}' controller.{phase} has 'wasm:' prefix but no path"
423 )));
424 } else if !wasm_path.ends_with(".wasm") {
425 errors.push(err(&format!(
426 "resource '{res}': endpoint '{action}' controller.{phase} WASM path must end with '.wasm', got '{wasm_path}'"
427 )));
428 }
429 }
430}
431
432fn err(message: &str) -> ValidationError {
433 ValidationError {
434 message: message.to_string(),
435 }
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441 use crate::parser::parse_resource;
442
443 #[test]
444 fn valid_resource_passes() {
445 let yaml = include_str!("../../resources/users.yaml");
446 let rd = parse_resource(yaml).unwrap();
447 let errors = validate_resource(&rd);
448 assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
449 }
450
451 #[test]
452 fn enum_without_values() {
453 let yaml = r#"
454resource: items
455version: 1
456schema:
457 id: { type: uuid, primary: true, generated: true }
458 status: { type: enum, required: true }
459"#;
460 let rd = parse_resource(yaml).unwrap();
461 let errors = validate_resource(&rd);
462 assert!(errors
463 .iter()
464 .any(|e| e.message.contains("type enum but has no values")));
465 }
466
467 #[test]
468 fn ref_field_not_uuid() {
469 let yaml = r#"
470resource: items
471version: 1
472schema:
473 id: { type: uuid, primary: true, generated: true }
474 org_id: { type: string, ref: organizations.id }
475"#;
476 let rd = parse_resource(yaml).unwrap();
477 let errors = validate_resource(&rd);
478 assert!(errors
479 .iter()
480 .any(|e| e.message.contains("has ref but is not type uuid")));
481 }
482
483 #[test]
484 fn missing_primary_key() {
485 let yaml = r#"
486resource: items
487version: 1
488schema:
489 name: { type: string, required: true }
490"#;
491 let rd = parse_resource(yaml).unwrap();
492 let errors = validate_resource(&rd);
493 assert!(errors
494 .iter()
495 .any(|e| e.message.contains("must have a primary key")));
496 }
497
498 #[test]
499 fn soft_delete_without_deleted_at() {
500 let yaml = r#"
501resource: items
502version: 1
503schema:
504 id: { type: uuid, primary: true, generated: true }
505 name: { type: string, required: true }
506endpoints:
507 delete:
508 method: DELETE
509 path: /items/:id
510 auth: [admin]
511 soft_delete: true
512"#;
513 let rd = parse_resource(yaml).unwrap();
514 let errors = validate_resource(&rd);
515 assert!(errors.iter().any(|e| e
516 .message
517 .contains("soft_delete but schema has no 'deleted_at'")));
518 }
519
520 #[test]
521 fn input_field_not_in_schema() {
522 let yaml = r#"
523resource: items
524version: 1
525schema:
526 id: { type: uuid, primary: true, generated: true }
527 name: { type: string, required: true }
528endpoints:
529 create:
530 method: POST
531 path: /items
532 auth: [admin]
533 input: [name, nonexistent]
534"#;
535 let rd = parse_resource(yaml).unwrap();
536 let errors = validate_resource(&rd);
537 assert!(errors.iter().any(|e| e
538 .message
539 .contains("input field 'nonexistent' not found in schema")));
540 }
541
542 #[test]
543 fn belongs_to_without_key() {
544 let yaml = r#"
545resource: items
546version: 1
547schema:
548 id: { type: uuid, primary: true, generated: true }
549relations:
550 org: { resource: organizations, type: belongs_to }
551"#;
552 let rd = parse_resource(yaml).unwrap();
553 let errors = validate_resource(&rd);
554 assert!(errors
555 .iter()
556 .any(|e| e.message.contains("belongs_to but has no key")));
557 }
558
559 #[test]
560 fn has_many_without_foreign_key() {
561 let yaml = r#"
562resource: items
563version: 1
564schema:
565 id: { type: uuid, primary: true, generated: true }
566relations:
567 orders: { resource: orders, type: has_many }
568"#;
569 let rd = parse_resource(yaml).unwrap();
570 let errors = validate_resource(&rd);
571 assert!(errors
572 .iter()
573 .any(|e| e.message.contains("has_many but has no foreign_key")));
574 }
575
576 #[test]
577 fn index_references_missing_field() {
578 let yaml = r#"
579resource: items
580version: 1
581schema:
582 id: { type: uuid, primary: true, generated: true }
583indexes:
584 - fields: [missing_field]
585"#;
586 let rd = parse_resource(yaml).unwrap();
587 let errors = validate_resource(&rd);
588 assert!(errors.iter().any(|e| e
589 .message
590 .contains("references field 'missing_field' not in schema")));
591 }
592
593 #[test]
594 fn error_message_format() {
595 let yaml = r#"
596resource: users
597version: 1
598schema:
599 id: { type: uuid, primary: true, generated: true }
600 role: { type: enum }
601"#;
602 let rd = parse_resource(yaml).unwrap();
603 let errors = validate_resource(&rd);
604 assert_eq!(
605 errors[0].message,
606 "resource 'users': field 'role' is type enum but has no values"
607 );
608 }
609
610 #[test]
611 fn wasm_controller_valid_path() {
612 let yaml = r#"
613resource: items
614version: 1
615schema:
616 id: { type: uuid, primary: true, generated: true }
617 name: { type: string, required: true }
618endpoints:
619 create:
620 method: POST
621 path: /items
622 input: [name]
623 controller: { before: "wasm:./plugins/my_validator.wasm" }
624"#;
625 let rd = parse_resource(yaml).unwrap();
626 let errors = validate_resource(&rd);
627 assert!(
628 errors.is_empty(),
629 "Expected no errors for valid WASM controller, got: {errors:?}"
630 );
631 }
632
633 #[test]
634 fn wasm_controller_missing_extension() {
635 let yaml = r#"
636resource: items
637version: 1
638schema:
639 id: { type: uuid, primary: true, generated: true }
640 name: { type: string, required: true }
641endpoints:
642 create:
643 method: POST
644 path: /items
645 input: [name]
646 controller: { before: "wasm:./plugins/my_validator" }
647"#;
648 let rd = parse_resource(yaml).unwrap();
649 let errors = validate_resource(&rd);
650 assert!(errors
651 .iter()
652 .any(|e| e.message.contains("WASM path must end with '.wasm'")));
653 }
654
655 #[test]
656 fn wasm_controller_empty_path() {
657 let yaml = r#"
658resource: items
659version: 1
660schema:
661 id: { type: uuid, primary: true, generated: true }
662 name: { type: string, required: true }
663endpoints:
664 create:
665 method: POST
666 path: /items
667 input: [name]
668 controller: { before: "wasm:" }
669"#;
670 let rd = parse_resource(yaml).unwrap();
671 let errors = validate_resource(&rd);
672 assert!(errors
673 .iter()
674 .any(|e| e.message.contains("'wasm:' prefix but no path")));
675 }
676
677 #[test]
678 fn upload_endpoint_valid_when_file_field_declared() {
679 let yaml = r#"
680resource: assets
681version: 1
682schema:
683 id: { type: uuid, primary: true, generated: true }
684 file: { type: file, required: true }
685 file_filename: { type: string }
686 file_mime_type: { type: string }
687 file_size: { type: bigint }
688 updated_at: { type: timestamp, generated: true }
689endpoints:
690 upload:
691 method: POST
692 path: /assets/upload
693 input: [file]
694 upload:
695 field: file
696 storage: local
697 max_size: 5mb
698"#;
699 let rd = parse_resource(yaml).unwrap();
700 let errors = validate_resource(&rd);
701 assert!(
702 errors.is_empty(),
703 "Expected valid upload resource, got {errors:?}"
704 );
705 }
706
707 #[test]
708 fn upload_endpoint_requires_file_field() {
709 let yaml = r#"
710resource: assets
711version: 1
712schema:
713 id: { type: uuid, primary: true, generated: true }
714 file_path: { type: string, required: true }
715endpoints:
716 upload:
717 method: POST
718 path: /assets/upload
719 input: [file_path]
720 upload:
721 field: file_path
722 storage: local
723 max_size: 5mb
724"#;
725 let rd = parse_resource(yaml).unwrap();
726 let errors = validate_resource(&rd);
727 assert!(errors.iter().any(|e| e
728 .message
729 .contains("upload field 'file_path' must be type file")));
730 }
731
732 #[test]
733 fn tenant_key_valid_uuid_field() {
734 let yaml = r#"
735resource: projects
736version: 1
737tenant_key: org_id
738schema:
739 id: { type: uuid, primary: true, generated: true }
740 org_id: { type: uuid, ref: organizations.id, required: true }
741 name: { type: string, required: true }
742"#;
743 let rd = parse_resource(yaml).unwrap();
744 let errors = validate_resource(&rd);
745 assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
746 }
747
748 #[test]
749 fn tenant_key_missing_field() {
750 let yaml = r#"
751resource: projects
752version: 1
753tenant_key: org_id
754schema:
755 id: { type: uuid, primary: true, generated: true }
756 name: { type: string, required: true }
757"#;
758 let rd = parse_resource(yaml).unwrap();
759 let errors = validate_resource(&rd);
760 assert!(errors.iter().any(|e| e
761 .message
762 .contains("tenant_key 'org_id' not found in schema")));
763 }
764
765 #[test]
766 fn transient_field_valid() {
767 let yaml = r#"
768resource: users
769version: 1
770schema:
771 id: { type: uuid, primary: true, generated: true }
772 password: { type: string, transient: true, min: 12, required: true }
773 password_hash: { type: string, required: true }
774endpoints:
775 create:
776 method: POST
777 path: /users
778 input: [password]
779 controller: { before: hash_password }
780"#;
781 let rd = parse_resource(yaml).unwrap();
782 let errors = validate_resource(&rd);
783 assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
784 }
785
786 #[test]
787 fn transient_field_dead_when_not_in_input() {
788 let yaml = r#"
789resource: users
790version: 1
791schema:
792 id: { type: uuid, primary: true, generated: true }
793 password: { type: string, transient: true, min: 12 }
794endpoints:
795 create:
796 method: POST
797 path: /users
798 input: []
799"#;
800 let rd = parse_resource(yaml).unwrap();
801 let errors = validate_resource(&rd);
802 assert!(errors.iter().any(|e| e
803 .message
804 .contains("transient field 'password' is not declared in any endpoint's input")));
805 }
806
807 #[test]
808 fn transient_field_rejects_primary() {
809 let yaml = r#"
810resource: users
811version: 1
812schema:
813 bad: { type: uuid, transient: true, primary: true }
814"#;
815 let rd = parse_resource(yaml).unwrap();
816 let errors = validate_resource(&rd);
817 assert!(errors
818 .iter()
819 .any(|e| e.message.contains("cannot be both transient and primary")));
820 }
821
822 #[test]
823 fn transient_field_rejects_generated() {
824 let yaml = r#"
825resource: users
826version: 1
827schema:
828 id: { type: uuid, primary: true, generated: true }
829 bad: { type: timestamp, transient: true, generated: true }
830endpoints:
831 create:
832 method: POST
833 path: /users
834 input: [bad]
835"#;
836 let rd = parse_resource(yaml).unwrap();
837 let errors = validate_resource(&rd);
838 assert!(errors
839 .iter()
840 .any(|e| e.message.contains("cannot be both transient and generated")));
841 }
842
843 #[test]
844 fn transient_field_rejects_ref() {
845 let yaml = r#"
846resource: users
847version: 1
848schema:
849 id: { type: uuid, primary: true, generated: true }
850 bad: { type: uuid, transient: true, ref: orgs.id }
851endpoints:
852 create:
853 method: POST
854 path: /users
855 input: [bad]
856"#;
857 let rd = parse_resource(yaml).unwrap();
858 let errors = validate_resource(&rd);
859 assert!(errors.iter().any(|e| e
860 .message
861 .contains("cannot be both transient and have a ref")));
862 }
863
864 #[test]
865 fn transient_field_rejects_unique() {
866 let yaml = r#"
867resource: users
868version: 1
869schema:
870 id: { type: uuid, primary: true, generated: true }
871 bad: { type: string, transient: true, unique: true }
872endpoints:
873 create:
874 method: POST
875 path: /users
876 input: [bad]
877"#;
878 let rd = parse_resource(yaml).unwrap();
879 let errors = validate_resource(&rd);
880 assert!(errors
881 .iter()
882 .any(|e| e.message.contains("cannot be both transient and unique")));
883 }
884
885 #[test]
886 fn transient_field_rejects_default() {
887 let yaml = r#"
888resource: users
889version: 1
890schema:
891 id: { type: uuid, primary: true, generated: true }
892 bad: { type: string, transient: true, default: "x" }
893endpoints:
894 create:
895 method: POST
896 path: /users
897 input: [bad]
898"#;
899 let rd = parse_resource(yaml).unwrap();
900 let errors = validate_resource(&rd);
901 assert!(errors.iter().any(|e| e
902 .message
903 .contains("cannot be both transient and have a default")));
904 }
905
906 #[test]
907 fn tenant_key_wrong_type() {
908 let yaml = r#"
909resource: projects
910version: 1
911tenant_key: org_name
912schema:
913 id: { type: uuid, primary: true, generated: true }
914 org_name: { type: string, required: true }
915"#;
916 let rd = parse_resource(yaml).unwrap();
917 let errors = validate_resource(&rd);
918 assert!(errors.iter().any(|e| e
919 .message
920 .contains("tenant_key 'org_name' must reference a uuid field")));
921 }
922}