1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
5#[serde(rename_all = "UPPERCASE")]
6pub enum HttpMethod {
7 Get,
8 Post,
9 Patch,
10 Put,
11 Delete,
12}
13
14impl std::fmt::Display for HttpMethod {
15 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16 let s = match self {
17 Self::Get => "GET",
18 Self::Post => "POST",
19 Self::Patch => "PATCH",
20 Self::Put => "PUT",
21 Self::Delete => "DELETE",
22 };
23 write!(f, "{s}")
24 }
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum AuthRule {
32 Public,
34 Owner,
36 Roles(Vec<String>),
38}
39
40impl Serialize for AuthRule {
41 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
42 match self {
43 Self::Public => serializer.serialize_str("public"),
44 Self::Owner => serializer.serialize_str("owner"),
45 Self::Roles(roles) => roles.serialize(serializer),
46 }
47 }
48}
49
50impl<'de> Deserialize<'de> for AuthRule {
51 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
52 let value = serde_json::Value::deserialize(deserializer)?;
53 match &value {
54 serde_json::Value::String(s) if s == "public" => Ok(Self::Public),
55 serde_json::Value::String(s) if s == "owner" => Ok(Self::Owner),
56 serde_json::Value::Array(_) => {
57 let roles: Vec<String> =
58 serde_json::from_value(value).map_err(serde::de::Error::custom)?;
59 Ok(Self::Roles(roles))
60 }
61 _ => Err(serde::de::Error::custom(
62 "auth must be \"public\", \"owner\", or an array of role strings",
63 )),
64 }
65 }
66}
67
68impl std::fmt::Display for AuthRule {
69 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70 match self {
71 Self::Public => write!(f, "public"),
72 Self::Owner => write!(f, "owner"),
73 Self::Roles(roles) => write!(f, "{}", roles.join(", ")),
74 }
75 }
76}
77
78impl AuthRule {
79 pub fn is_public(&self) -> bool {
81 matches!(self, Self::Public)
82 }
83
84 pub fn is_owner(&self) -> bool {
86 matches!(self, Self::Owner)
87 }
88
89 pub fn allows_owner(&self) -> bool {
91 match self {
92 Self::Owner => true,
93 Self::Roles(roles) => roles.iter().any(|r| r == "owner"),
94 Self::Public => false,
95 }
96 }
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(rename_all = "lowercase")]
102pub enum PaginationStyle {
103 Cursor,
105 Offset,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120#[serde(deny_unknown_fields)]
121pub struct RateLimitSpec {
122 pub max_requests: u64,
124 pub window_secs: u64,
126}
127
128#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
130#[serde(deny_unknown_fields)]
131pub struct CacheSpec {
132 pub ttl: u64,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub invalidate_on: Option<Vec<String>>,
137}
138
139#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
141#[serde(deny_unknown_fields)]
142pub struct UploadSpec {
143 pub field: String,
145 pub storage: String,
147 pub max_size: String,
149 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub types: Option<Vec<String>>,
152}
153
154#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
164#[serde(untagged)]
165pub enum HookList {
166 Single(String),
167 Multi(Vec<String>),
168}
169
170impl HookList {
171 pub fn names(&self) -> &[String] {
174 match self {
175 HookList::Single(name) => std::slice::from_ref(name),
176 HookList::Multi(names) => names.as_slice(),
177 }
178 }
179
180 pub fn has_wasm(&self) -> bool {
182 self.names().iter().any(|n| n.starts_with(WASM_HOOK_PREFIX))
183 }
184}
185
186#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
198#[serde(deny_unknown_fields)]
199pub struct ControllerSpec {
200 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub before: Option<HookList>,
204 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub after: Option<HookList>,
208}
209
210pub fn endpoint_convention(action: &str, resource_name: &str) -> Option<(HttpMethod, String)> {
213 match action {
214 "list" => Some((HttpMethod::Get, format!("/{resource_name}"))),
215 "get" => Some((HttpMethod::Get, format!("/{resource_name}/:id"))),
216 "create" => Some((HttpMethod::Post, format!("/{resource_name}"))),
217 "update" => Some((HttpMethod::Patch, format!("/{resource_name}/:id"))),
218 "delete" => Some((HttpMethod::Delete, format!("/{resource_name}/:id"))),
219 _ => None,
220 }
221}
222
223pub fn apply_endpoint_defaults(resource: &mut super::ResourceDefinition) {
226 let resource_name = resource.resource.clone();
227 if let Some(ref mut endpoints) = resource.endpoints {
228 for (action, ep) in endpoints.iter_mut() {
229 if let Some((default_method, default_path)) =
230 endpoint_convention(action, &resource_name)
231 {
232 if ep.method.is_none() {
233 ep.method = Some(default_method);
234 }
235 if ep.path.is_none() {
236 ep.path = Some(default_path);
237 }
238 }
239 }
240 }
241}
242
243pub const WASM_HOOK_PREFIX: &str = "wasm:";
252
253impl ControllerSpec {
254 pub fn has_wasm_before(&self) -> bool {
256 self.before.as_ref().is_some_and(|h| h.has_wasm())
257 }
258
259 pub fn has_wasm_after(&self) -> bool {
261 self.after.as_ref().is_some_and(|h| h.has_wasm())
262 }
263
264 pub fn before_names(&self) -> &[String] {
266 self.before.as_ref().map(|h| h.names()).unwrap_or(&[])
267 }
268
269 pub fn after_names(&self) -> &[String] {
271 self.after.as_ref().map(|h| h.names()).unwrap_or(&[])
272 }
273}
274
275#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
283#[serde(deny_unknown_fields)]
284pub struct SubscriberSpec {
285 pub event: String,
287 pub handler: String,
289}
290
291#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
306#[serde(deny_unknown_fields)]
307pub struct EndpointSpec {
308 #[serde(default, skip_serializing_if = "Option::is_none")]
311 pub method: Option<HttpMethod>,
312
313 #[serde(default, skip_serializing_if = "Option::is_none")]
316 pub path: Option<String>,
317
318 #[serde(default, skip_serializing_if = "Option::is_none")]
320 pub auth: Option<AuthRule>,
321
322 #[serde(default, skip_serializing_if = "Option::is_none")]
324 pub input: Option<Vec<String>>,
325
326 #[serde(default, skip_serializing_if = "Option::is_none")]
328 pub filters: Option<Vec<String>>,
329
330 #[serde(default, skip_serializing_if = "Option::is_none")]
332 pub search: Option<Vec<String>>,
333
334 #[serde(default, skip_serializing_if = "Option::is_none")]
336 pub pagination: Option<PaginationStyle>,
337
338 #[serde(default, skip_serializing_if = "Option::is_none")]
340 pub sort: Option<Vec<String>>,
341
342 #[serde(default, skip_serializing_if = "Option::is_none")]
344 pub cache: Option<CacheSpec>,
345
346 #[serde(default, skip_serializing_if = "Option::is_none")]
348 pub controller: Option<ControllerSpec>,
349
350 #[serde(default, skip_serializing_if = "Option::is_none")]
352 pub events: Option<Vec<String>>,
353
354 #[serde(default, skip_serializing_if = "Option::is_none")]
356 pub jobs: Option<Vec<String>>,
357
358 #[serde(default, skip_serializing_if = "Option::is_none")]
360 pub subscribers: Option<Vec<SubscriberSpec>>,
361
362 #[serde(default, skip_serializing_if = "Option::is_none")]
366 pub handler: Option<String>,
367
368 #[serde(default, skip_serializing_if = "Option::is_none")]
370 pub upload: Option<UploadSpec>,
371
372 #[serde(default, skip_serializing_if = "Option::is_none")]
374 pub rate_limit: Option<RateLimitSpec>,
375
376 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
378 pub soft_delete: bool,
379}
380
381impl EndpointSpec {
382 pub fn method(&self) -> &HttpMethod {
385 self.method
386 .as_ref()
387 .expect("EndpointSpec.method must be set — call apply_endpoint_defaults() first")
388 }
389
390 pub fn path(&self) -> &str {
393 self.path
394 .as_deref()
395 .expect("EndpointSpec.path must be set — call apply_endpoint_defaults() first")
396 }
397}
398
399pub fn to_brace_path(path: &str) -> String {
411 let bytes = path.as_bytes();
412 let mut out = String::with_capacity(path.len() + 4);
413 let mut i = 0;
414 while i < bytes.len() {
415 if bytes[i] == b':' && i + 1 < bytes.len() && is_ident_start(bytes[i + 1]) {
416 let start = i + 1;
417 let mut end = start + 1;
418 while end < bytes.len() && is_ident_continue(bytes[end]) {
419 end += 1;
420 }
421 out.push('{');
422 out.push_str(&path[start..end]);
424 out.push('}');
425 i = end;
426 } else {
427 let ch = path[i..].chars().next().expect("non-empty remainder");
432 out.push(ch);
433 i += ch.len_utf8();
434 }
435 }
436 out
437}
438
439#[inline]
440fn is_ident_start(b: u8) -> bool {
441 b.is_ascii_alphabetic() || b == b'_'
442}
443
444#[inline]
445fn is_ident_continue(b: u8) -> bool {
446 b.is_ascii_alphanumeric() || b == b'_'
447}
448
449#[cfg(test)]
450mod tests {
451 use super::*;
452
453 #[test]
454 fn to_brace_path_id_only() {
455 assert_eq!(to_brace_path("/users/:id"), "/users/{id}");
456 }
457
458 #[test]
459 fn to_brace_path_multiple_named_params() {
460 assert_eq!(
461 to_brace_path("/vendors/:vendor_id/webhook/:webhook_path_token"),
462 "/vendors/{vendor_id}/webhook/{webhook_path_token}"
463 );
464 }
465
466 #[test]
467 fn to_brace_path_no_params() {
468 assert_eq!(to_brace_path("/users"), "/users");
469 assert_eq!(to_brace_path(""), "");
470 }
471
472 #[test]
473 fn to_brace_path_leaves_non_identifier_colons_alone() {
474 assert_eq!(to_brace_path("/a:/b"), "/a:/b");
476 assert_eq!(to_brace_path("/a/:/b"), "/a/:/b");
477 }
478
479 #[test]
480 fn to_brace_path_underscore_and_digits_in_param() {
481 assert_eq!(to_brace_path("/x/:_param/y"), "/x/{_param}/y");
482 assert_eq!(to_brace_path("/x/:p1/y"), "/x/{p1}/y");
483 }
484
485 #[test]
486 fn to_brace_path_consecutive_params() {
487 assert_eq!(to_brace_path("/:a/:b"), "/{a}/{b}");
488 }
489
490 #[test]
491 fn http_method_display() {
492 assert_eq!(HttpMethod::Get.to_string(), "GET");
493 assert_eq!(HttpMethod::Post.to_string(), "POST");
494 assert_eq!(HttpMethod::Patch.to_string(), "PATCH");
495 assert_eq!(HttpMethod::Put.to_string(), "PUT");
496 assert_eq!(HttpMethod::Delete.to_string(), "DELETE");
497 }
498
499 #[test]
500 fn auth_rule_public() {
501 let auth: AuthRule = serde_json::from_str(r#""public""#).unwrap();
502 assert!(auth.is_public());
503
504 let roles: AuthRule = serde_json::from_str(r#"["admin", "member"]"#).unwrap();
505 assert!(!roles.is_public());
506 if let AuthRule::Roles(r) = &roles {
507 assert_eq!(r.len(), 2);
508 }
509 }
510
511 #[test]
512 fn auth_rule_owner_standalone() {
513 let auth: AuthRule = serde_json::from_str(r#""owner""#).unwrap();
514 assert!(auth.is_owner());
515 assert!(auth.allows_owner());
516 assert!(!auth.is_public());
517 }
518
519 #[test]
520 fn auth_rule_owner_in_roles() {
521 let auth: AuthRule = serde_json::from_str(r#"["owner", "admin"]"#).unwrap();
522 assert!(auth.allows_owner());
523 assert!(!auth.is_owner());
524 }
525
526 #[test]
527 fn pagination_style_serde() {
528 let p: PaginationStyle = serde_json::from_str("\"cursor\"").unwrap();
529 assert_eq!(p, PaginationStyle::Cursor);
530 let p: PaginationStyle = serde_json::from_str("\"offset\"").unwrap();
531 assert_eq!(p, PaginationStyle::Offset);
532 }
533
534 #[test]
535 fn cache_spec_minimal() {
536 let json = r#"{"ttl": 60}"#;
537 let cs: CacheSpec = serde_json::from_str(json).unwrap();
538 assert_eq!(cs.ttl, 60);
539 assert!(cs.invalidate_on.is_none());
540 }
541
542 #[test]
543 fn cache_spec_with_invalidation() {
544 let json = r#"{"ttl": 120, "invalidate_on": ["create", "delete"]}"#;
545 let cs: CacheSpec = serde_json::from_str(json).unwrap();
546 assert_eq!(cs.invalidate_on.as_ref().unwrap().len(), 2);
547 }
548
549 #[test]
550 fn upload_spec_serde() {
551 let json = r#"{"field": "avatar_url", "storage": "s3", "max_size": "5mb", "types": ["jpg", "png"]}"#;
552 let us: UploadSpec = serde_json::from_str(json).unwrap();
553 assert_eq!(us.field, "avatar_url");
554 assert_eq!(us.types.as_ref().unwrap().len(), 2);
555 }
556
557 #[test]
558 fn endpoint_spec_list() {
559 let json = r#"{
560 "method": "GET",
561 "path": "/users",
562 "auth": ["member", "admin"],
563 "filters": ["role", "org_id"],
564 "search": ["name", "email"],
565 "pagination": "cursor",
566 "cache": {"ttl": 60}
567 }"#;
568 let ep: EndpointSpec = serde_json::from_str(json).unwrap();
569 assert_eq!(*ep.method(), HttpMethod::Get);
570 assert_eq!(ep.path(), "/users");
571 assert_eq!(ep.filters.as_ref().unwrap().len(), 2);
572 assert_eq!(ep.pagination, Some(PaginationStyle::Cursor));
573 assert!(!ep.soft_delete);
574 }
575
576 #[test]
577 fn endpoint_spec_create() {
578 let json = r#"{
579 "method": "POST",
580 "path": "/users",
581 "auth": ["admin"],
582 "input": ["email", "name", "role", "org_id"],
583 "controller": {"before": "validate_org"},
584 "events": ["user.created"],
585 "jobs": ["send_welcome_email"]
586 }"#;
587 let ep: EndpointSpec = serde_json::from_str(json).unwrap();
588 assert_eq!(*ep.method(), HttpMethod::Post);
589 let ctrl = ep.controller.as_ref().unwrap();
590 assert_eq!(
591 ctrl.before.as_ref().unwrap().names(),
592 &["validate_org".to_string()]
593 );
594 assert!(ctrl.after.is_none());
595 assert_eq!(ep.jobs.as_ref().unwrap(), &["send_welcome_email"]);
596 }
597
598 #[test]
599 fn controller_spec_full() {
600 let json = r#"{"before": "check_input", "after": "enrich"}"#;
601 let cs: ControllerSpec = serde_json::from_str(json).unwrap();
602 assert_eq!(
603 cs.before.as_ref().unwrap().names(),
604 &["check_input".to_string()]
605 );
606 assert_eq!(cs.after.as_ref().unwrap().names(), &["enrich".to_string()]);
607 }
608
609 #[test]
610 fn controller_spec_after_only() {
611 let json = r#"{"after": "enrich"}"#;
612 let cs: ControllerSpec = serde_json::from_str(json).unwrap();
613 assert!(cs.before.is_none());
614 assert_eq!(cs.after.as_ref().unwrap().names(), &["enrich".to_string()]);
615 }
616
617 #[test]
618 fn controller_wasm_before_detection() {
619 let json = r#"{"before": "wasm:./plugins/my_validator.wasm"}"#;
620 let cs: ControllerSpec = serde_json::from_str(json).unwrap();
621 assert!(cs.has_wasm_before());
622 assert!(!cs.has_wasm_after());
623 }
624
625 #[test]
626 fn controller_wasm_after_detection() {
627 let json = r#"{"after": "wasm:./plugins/my_enricher.wasm"}"#;
628 let cs: ControllerSpec = serde_json::from_str(json).unwrap();
629 assert!(!cs.has_wasm_before());
630 assert!(cs.has_wasm_after());
631 }
632
633 #[test]
634 fn controller_rust_not_detected_as_wasm() {
635 let json = r#"{"before": "validate_org"}"#;
636 let cs: ControllerSpec = serde_json::from_str(json).unwrap();
637 assert!(!cs.has_wasm_before());
638 }
639
640 #[test]
641 fn hooks_key_rejected() {
642 let json = r#"{
643 "method": "POST",
644 "path": "/users",
645 "hooks": ["validate_org"]
646 }"#;
647 let result = serde_json::from_str::<EndpointSpec>(json);
648 assert!(result.is_err());
649 let err = result.unwrap_err().to_string();
650 assert!(
651 err.contains("unknown field"),
652 "Expected deny_unknown_fields to reject 'hooks', got: {err}"
653 );
654 }
655
656 #[test]
657 fn endpoint_spec_delete_soft() {
658 let json = r#"{
659 "method": "DELETE",
660 "path": "/users/:id",
661 "auth": ["admin"],
662 "soft_delete": true
663 }"#;
664 let ep: EndpointSpec = serde_json::from_str(json).unwrap();
665 assert!(ep.soft_delete);
666 }
667
668 #[test]
669 fn rate_limit_spec_parses_from_yaml() {
670 let yaml = "max_requests: 50\nwindow_secs: 30\n";
671 let spec: RateLimitSpec = serde_yaml::from_str(yaml).unwrap();
672 assert_eq!(spec.max_requests, 50);
673 assert_eq!(spec.window_secs, 30);
674 }
675
676 #[test]
677 fn endpoint_spec_rate_limit_field_roundtrips() {
678 let yaml = r#"
679auth: [member]
680rate_limit:
681 max_requests: 100
682 window_secs: 60
683"#;
684 let spec: EndpointSpec = serde_yaml::from_str(yaml).unwrap();
685 let rl = spec.rate_limit.unwrap();
686 assert_eq!(rl.max_requests, 100);
687 assert_eq!(rl.window_secs, 60);
688 }
689
690 #[test]
691 fn endpoint_spec_rate_limit_absent_is_none() {
692 let yaml = "auth: [member]\n";
693 let spec: EndpointSpec = serde_yaml::from_str(yaml).unwrap();
694 assert!(spec.rate_limit.is_none());
695 }
696
697 #[test]
698 fn endpoint_spec_subscribers_parse() {
699 let yaml = r#"
700auth: [admin]
701events: [user.created]
702subscribers:
703 - event: user.created
704 handler: send_welcome_email
705 - event: "*.deleted"
706 handler: cleanup_resources
707"#;
708 let spec: EndpointSpec = serde_yaml::from_str(yaml).unwrap();
709 let subs = spec.subscribers.as_ref().unwrap();
710 assert_eq!(subs.len(), 2);
711 assert_eq!(subs[0].event, "user.created");
712 assert_eq!(subs[0].handler, "send_welcome_email");
713 assert_eq!(subs[1].event, "*.deleted");
714 }
715
716 #[test]
717 fn subscriber_spec_unknown_field_rejected() {
718 let yaml = r#"
719subscribers:
720 - event: user.created
721 handler: send_welcome_email
722 extra: bad_field
723"#;
724 let result = serde_yaml::from_str::<EndpointSpec>(yaml);
725 assert!(result.is_err());
726 }
727
728 #[test]
729 fn custom_endpoint_handler_field_parses() {
730 let yaml = r#"
731method: POST
732path: /invite
733auth: [admin]
734input: [email, role]
735handler: invite_user
736"#;
737 let ep: EndpointSpec = serde_yaml::from_str(yaml).unwrap();
738 assert_eq!(ep.handler.as_deref(), Some("invite_user"));
739 assert_eq!(ep.input.as_ref().unwrap().len(), 2);
740 }
741
742 #[test]
743 fn endpoint_without_handler_has_none() {
744 let yaml = "auth: [member]\n";
745 let ep: EndpointSpec = serde_yaml::from_str(yaml).unwrap();
746 assert!(ep.handler.is_none());
747 }
748
749 #[test]
752 fn endpoint_convention_known_actions() {
753 assert_eq!(
754 endpoint_convention("list", "users"),
755 Some((HttpMethod::Get, "/users".to_string()))
756 );
757 assert_eq!(
758 endpoint_convention("get", "users"),
759 Some((HttpMethod::Get, "/users/:id".to_string()))
760 );
761 assert_eq!(
762 endpoint_convention("create", "users"),
763 Some((HttpMethod::Post, "/users".to_string()))
764 );
765 assert_eq!(
766 endpoint_convention("update", "users"),
767 Some((HttpMethod::Patch, "/users/:id".to_string()))
768 );
769 assert_eq!(
770 endpoint_convention("delete", "users"),
771 Some((HttpMethod::Delete, "/users/:id".to_string()))
772 );
773 }
774
775 #[test]
776 fn endpoint_convention_unknown_action_returns_none() {
777 assert_eq!(endpoint_convention("archive", "users"), None);
778 assert_eq!(endpoint_convention("custom_action", "orders"), None);
779 assert_eq!(endpoint_convention("", "users"), None);
780 }
781
782 #[test]
783 fn endpoint_convention_uses_resource_name() {
784 let (method, path) = endpoint_convention("list", "orders").unwrap();
785 assert_eq!(method, HttpMethod::Get);
786 assert_eq!(path, "/orders");
787 let (_, path) = endpoint_convention("get", "blog_posts").unwrap();
788 assert_eq!(path, "/blog_posts/:id");
789 }
790
791 #[test]
794 fn apply_endpoint_defaults_fills_missing_method_and_path() {
795 use crate::ResourceDefinition;
796 use indexmap::IndexMap;
797
798 let mut endpoints = IndexMap::new();
799 endpoints.insert(
800 "list".to_string(),
801 EndpointSpec {
802 method: None,
803 path: None,
804 auth: None,
805 ..Default::default()
806 },
807 );
808 endpoints.insert(
809 "create".to_string(),
810 EndpointSpec {
811 method: None,
812 path: None,
813 auth: None,
814 ..Default::default()
815 },
816 );
817
818 let mut rd = ResourceDefinition {
819 resource: "posts".to_string(),
820 version: 1,
821 db: None,
822 tenant_key: None,
823 schema: IndexMap::new(),
824 endpoints: Some(endpoints),
825 relations: None,
826 indexes: None,
827 };
828
829 crate::apply_endpoint_defaults(&mut rd);
830
831 let eps = rd.endpoints.as_ref().unwrap();
832 assert_eq!(*eps["list"].method.as_ref().unwrap(), HttpMethod::Get);
833 assert_eq!(eps["list"].path.as_deref(), Some("/posts"));
834 assert_eq!(*eps["create"].method.as_ref().unwrap(), HttpMethod::Post);
835 assert_eq!(eps["create"].path.as_deref(), Some("/posts"));
836 }
837
838 #[test]
839 fn apply_endpoint_defaults_does_not_overwrite_explicit_values() {
840 use crate::ResourceDefinition;
841 use indexmap::IndexMap;
842
843 let mut endpoints = IndexMap::new();
844 endpoints.insert(
845 "list".to_string(),
846 EndpointSpec {
847 method: Some(HttpMethod::Post), path: Some("/custom/path".to_string()), auth: None,
850 ..Default::default()
851 },
852 );
853
854 let mut rd = ResourceDefinition {
855 resource: "items".to_string(),
856 version: 1,
857 db: None,
858 tenant_key: None,
859 schema: IndexMap::new(),
860 endpoints: Some(endpoints),
861 relations: None,
862 indexes: None,
863 };
864
865 crate::apply_endpoint_defaults(&mut rd);
866
867 let eps = rd.endpoints.as_ref().unwrap();
868 assert_eq!(*eps["list"].method.as_ref().unwrap(), HttpMethod::Post);
870 assert_eq!(eps["list"].path.as_deref(), Some("/custom/path"));
871 }
872
873 #[test]
874 fn apply_endpoint_defaults_unknown_action_not_filled() {
875 use crate::ResourceDefinition;
876 use indexmap::IndexMap;
877
878 let mut endpoints = IndexMap::new();
879 endpoints.insert(
880 "archive".to_string(),
881 EndpointSpec {
882 method: None,
883 path: None,
884 auth: None,
885 ..Default::default()
886 },
887 );
888
889 let mut rd = ResourceDefinition {
890 resource: "items".to_string(),
891 version: 1,
892 db: None,
893 tenant_key: None,
894 schema: IndexMap::new(),
895 endpoints: Some(endpoints),
896 relations: None,
897 indexes: None,
898 };
899
900 crate::apply_endpoint_defaults(&mut rd);
901
902 let eps = rd.endpoints.as_ref().unwrap();
904 assert!(eps["archive"].method.is_none());
905 assert!(eps["archive"].path.is_none());
906 }
907
908 #[test]
909 fn apply_endpoint_defaults_no_endpoints_is_noop() {
910 use crate::ResourceDefinition;
911 use indexmap::IndexMap;
912
913 let mut rd = ResourceDefinition {
914 resource: "items".to_string(),
915 version: 1,
916 db: None,
917 tenant_key: None,
918 schema: IndexMap::new(),
919 endpoints: None,
920 relations: None,
921 indexes: None,
922 };
923
924 crate::apply_endpoint_defaults(&mut rd);
926 assert!(rd.endpoints.is_none());
927 }
928
929 #[test]
932 fn auth_rule_display() {
933 assert_eq!(AuthRule::Public.to_string(), "public");
934 assert_eq!(AuthRule::Owner.to_string(), "owner");
935 assert_eq!(
936 AuthRule::Roles(vec!["admin".to_string(), "member".to_string()]).to_string(),
937 "admin, member"
938 );
939 assert_eq!(
940 AuthRule::Roles(vec!["viewer".to_string()]).to_string(),
941 "viewer"
942 );
943 }
944
945 #[test]
948 fn auth_rule_serde_roundtrip_public() {
949 let rule = AuthRule::Public;
950 let json = serde_json::to_string(&rule).unwrap();
951 assert_eq!(json, r#""public""#);
952 let back: AuthRule = serde_json::from_str(&json).unwrap();
953 assert_eq!(back, AuthRule::Public);
954 }
955
956 #[test]
957 fn auth_rule_serde_roundtrip_owner() {
958 let rule = AuthRule::Owner;
959 let json = serde_json::to_string(&rule).unwrap();
960 assert_eq!(json, r#""owner""#);
961 let back: AuthRule = serde_json::from_str(&json).unwrap();
962 assert_eq!(back, AuthRule::Owner);
963 }
964
965 #[test]
966 fn auth_rule_serde_roundtrip_roles() {
967 let rule = AuthRule::Roles(vec!["admin".to_string(), "member".to_string()]);
968 let json = serde_json::to_string(&rule).unwrap();
969 let back: AuthRule = serde_json::from_str(&json).unwrap();
970 assert_eq!(back, rule);
971 }
972
973 #[test]
974 fn auth_rule_deserialize_invalid_type_fails() {
975 let result = serde_json::from_str::<AuthRule>("42");
977 assert!(result.is_err());
978 }
979
980 #[test]
981 fn auth_rule_deserialize_unknown_string_fails() {
982 let result = serde_json::from_str::<AuthRule>(r#""superadmin""#);
984 assert!(result.is_err());
985 }
986
987 #[test]
990 fn http_method_serde_roundtrip() {
991 for m in [
992 HttpMethod::Get,
993 HttpMethod::Post,
994 HttpMethod::Patch,
995 HttpMethod::Put,
996 HttpMethod::Delete,
997 ] {
998 let json = serde_json::to_string(&m).unwrap();
999 let back: HttpMethod = serde_json::from_str(&json).unwrap();
1000 assert_eq!(m, back);
1001 }
1002 }
1003
1004 #[test]
1007 fn controller_before_accepts_scalar_string() {
1008 let json = r#"{"before": "validate_org"}"#;
1009 let cs: ControllerSpec = serde_json::from_str(json).unwrap();
1010 let names = cs.before.as_ref().unwrap().names();
1011 assert_eq!(names, &["validate_org".to_string()]);
1012 }
1013
1014 #[test]
1015 fn controller_before_accepts_array() {
1016 let json = r#"{"before": ["a", "b", "c"]}"#;
1017 let cs: ControllerSpec = serde_json::from_str(json).unwrap();
1018 let names = cs.before.as_ref().unwrap().names();
1019 assert_eq!(names, &["a".to_string(), "b".to_string(), "c".to_string()]);
1020 }
1021
1022 #[test]
1023 fn controller_after_accepts_array() {
1024 let json = r#"{"after": ["enrich_a", "enrich_b"]}"#;
1025 let cs: ControllerSpec = serde_json::from_str(json).unwrap();
1026 let names = cs.after.as_ref().unwrap().names();
1027 assert_eq!(names, &["enrich_a".to_string(), "enrich_b".to_string()]);
1028 }
1029
1030 #[test]
1031 fn hook_list_wasm_detection_works_for_array() {
1032 let json = r#"{"before": ["validate_x", "wasm:./plugin.wasm"]}"#;
1033 let cs: ControllerSpec = serde_json::from_str(json).unwrap();
1034 assert!(cs.has_wasm_before());
1035 }
1036}