use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum HttpMethod {
Get,
Post,
Patch,
Put,
Delete,
}
impl std::fmt::Display for HttpMethod {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
Self::Get => "GET",
Self::Post => "POST",
Self::Patch => "PATCH",
Self::Put => "PUT",
Self::Delete => "DELETE",
};
write!(f, "{s}")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthRule {
Public,
Owner,
Roles(Vec<String>),
}
impl Serialize for AuthRule {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self {
Self::Public => serializer.serialize_str("public"),
Self::Owner => serializer.serialize_str("owner"),
Self::Roles(roles) => roles.serialize(serializer),
}
}
}
impl<'de> Deserialize<'de> for AuthRule {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = serde_json::Value::deserialize(deserializer)?;
match &value {
serde_json::Value::String(s) if s == "public" => Ok(Self::Public),
serde_json::Value::String(s) if s == "owner" => Ok(Self::Owner),
serde_json::Value::Array(_) => {
let roles: Vec<String> =
serde_json::from_value(value).map_err(serde::de::Error::custom)?;
Ok(Self::Roles(roles))
}
_ => Err(serde::de::Error::custom(
"auth must be \"public\", \"owner\", or an array of role strings",
)),
}
}
}
impl std::fmt::Display for AuthRule {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Public => write!(f, "public"),
Self::Owner => write!(f, "owner"),
Self::Roles(roles) => write!(f, "{}", roles.join(", ")),
}
}
}
impl AuthRule {
pub fn is_public(&self) -> bool {
matches!(self, Self::Public)
}
pub fn is_owner(&self) -> bool {
matches!(self, Self::Owner)
}
pub fn allows_owner(&self) -> bool {
match self {
Self::Owner => true,
Self::Roles(roles) => roles.iter().any(|r| r == "owner"),
Self::Public => false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PaginationStyle {
Cursor,
Offset,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RateLimitSpec {
pub max_requests: u64,
pub window_secs: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CacheSpec {
pub ttl: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub invalidate_on: Option<Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct UploadSpec {
pub field: String,
pub storage: String,
pub max_size: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub types: Option<Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum HookList {
Single(String),
Multi(Vec<String>),
}
impl HookList {
pub fn names(&self) -> &[String] {
match self {
HookList::Single(name) => std::slice::from_ref(name),
HookList::Multi(names) => names.as_slice(),
}
}
pub fn has_wasm(&self) -> bool {
self.names().iter().any(|n| n.starts_with(WASM_HOOK_PREFIX))
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ControllerSpec {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub before: Option<HookList>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub after: Option<HookList>,
}
pub fn endpoint_convention(action: &str, resource_name: &str) -> Option<(HttpMethod, String)> {
match action {
"list" => Some((HttpMethod::Get, format!("/{resource_name}"))),
"get" => Some((HttpMethod::Get, format!("/{resource_name}/:id"))),
"create" => Some((HttpMethod::Post, format!("/{resource_name}"))),
"update" => Some((HttpMethod::Patch, format!("/{resource_name}/:id"))),
"delete" => Some((HttpMethod::Delete, format!("/{resource_name}/:id"))),
_ => None,
}
}
pub fn apply_endpoint_defaults(resource: &mut super::ResourceDefinition) {
let resource_name = resource.resource.clone();
if let Some(ref mut endpoints) = resource.endpoints {
for (action, ep) in endpoints.iter_mut() {
if let Some((default_method, default_path)) =
endpoint_convention(action, &resource_name)
{
if ep.method.is_none() {
ep.method = Some(default_method);
}
if ep.path.is_none() {
ep.path = Some(default_path);
}
}
}
}
}
pub const WASM_HOOK_PREFIX: &str = "wasm:";
impl ControllerSpec {
pub fn has_wasm_before(&self) -> bool {
self.before.as_ref().is_some_and(|h| h.has_wasm())
}
pub fn has_wasm_after(&self) -> bool {
self.after.as_ref().is_some_and(|h| h.has_wasm())
}
pub fn before_names(&self) -> &[String] {
self.before.as_ref().map(|h| h.names()).unwrap_or(&[])
}
pub fn after_names(&self) -> &[String] {
self.after.as_ref().map(|h| h.names()).unwrap_or(&[])
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SubscriberSpec {
pub event: String,
pub handler: String,
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EndpointSpec {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub method: Option<HttpMethod>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth: Option<AuthRule>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub filters: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub search: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pagination: Option<PaginationStyle>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sort: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache: Option<CacheSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub controller: Option<ControllerSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub events: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub jobs: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subscribers: Option<Vec<SubscriberSpec>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub handler: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub upload: Option<UploadSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rate_limit: Option<RateLimitSpec>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub soft_delete: bool,
}
impl EndpointSpec {
pub fn method(&self) -> &HttpMethod {
self.method
.as_ref()
.expect("EndpointSpec.method must be set — call apply_endpoint_defaults() first")
}
pub fn path(&self) -> &str {
self.path
.as_deref()
.expect("EndpointSpec.path must be set — call apply_endpoint_defaults() first")
}
}
pub fn to_brace_path(path: &str) -> String {
let bytes = path.as_bytes();
let mut out = String::with_capacity(path.len() + 4);
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b':' && i + 1 < bytes.len() && is_ident_start(bytes[i + 1]) {
let start = i + 1;
let mut end = start + 1;
while end < bytes.len() && is_ident_continue(bytes[end]) {
end += 1;
}
out.push('{');
out.push_str(&path[start..end]);
out.push('}');
i = end;
} else {
let ch = path[i..].chars().next().expect("non-empty remainder");
out.push(ch);
i += ch.len_utf8();
}
}
out
}
#[inline]
fn is_ident_start(b: u8) -> bool {
b.is_ascii_alphabetic() || b == b'_'
}
#[inline]
fn is_ident_continue(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_'
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn to_brace_path_id_only() {
assert_eq!(to_brace_path("/users/:id"), "/users/{id}");
}
#[test]
fn to_brace_path_multiple_named_params() {
assert_eq!(
to_brace_path("/vendors/:vendor_id/webhook/:webhook_path_token"),
"/vendors/{vendor_id}/webhook/{webhook_path_token}"
);
}
#[test]
fn to_brace_path_no_params() {
assert_eq!(to_brace_path("/users"), "/users");
assert_eq!(to_brace_path(""), "");
}
#[test]
fn to_brace_path_leaves_non_identifier_colons_alone() {
assert_eq!(to_brace_path("/a:/b"), "/a:/b");
assert_eq!(to_brace_path("/a/:/b"), "/a/:/b");
}
#[test]
fn to_brace_path_underscore_and_digits_in_param() {
assert_eq!(to_brace_path("/x/:_param/y"), "/x/{_param}/y");
assert_eq!(to_brace_path("/x/:p1/y"), "/x/{p1}/y");
}
#[test]
fn to_brace_path_consecutive_params() {
assert_eq!(to_brace_path("/:a/:b"), "/{a}/{b}");
}
#[test]
fn http_method_display() {
assert_eq!(HttpMethod::Get.to_string(), "GET");
assert_eq!(HttpMethod::Post.to_string(), "POST");
assert_eq!(HttpMethod::Patch.to_string(), "PATCH");
assert_eq!(HttpMethod::Put.to_string(), "PUT");
assert_eq!(HttpMethod::Delete.to_string(), "DELETE");
}
#[test]
fn auth_rule_public() {
let auth: AuthRule = serde_json::from_str(r#""public""#).unwrap();
assert!(auth.is_public());
let roles: AuthRule = serde_json::from_str(r#"["admin", "member"]"#).unwrap();
assert!(!roles.is_public());
if let AuthRule::Roles(r) = &roles {
assert_eq!(r.len(), 2);
}
}
#[test]
fn auth_rule_owner_standalone() {
let auth: AuthRule = serde_json::from_str(r#""owner""#).unwrap();
assert!(auth.is_owner());
assert!(auth.allows_owner());
assert!(!auth.is_public());
}
#[test]
fn auth_rule_owner_in_roles() {
let auth: AuthRule = serde_json::from_str(r#"["owner", "admin"]"#).unwrap();
assert!(auth.allows_owner());
assert!(!auth.is_owner());
}
#[test]
fn pagination_style_serde() {
let p: PaginationStyle = serde_json::from_str("\"cursor\"").unwrap();
assert_eq!(p, PaginationStyle::Cursor);
let p: PaginationStyle = serde_json::from_str("\"offset\"").unwrap();
assert_eq!(p, PaginationStyle::Offset);
}
#[test]
fn cache_spec_minimal() {
let json = r#"{"ttl": 60}"#;
let cs: CacheSpec = serde_json::from_str(json).unwrap();
assert_eq!(cs.ttl, 60);
assert!(cs.invalidate_on.is_none());
}
#[test]
fn cache_spec_with_invalidation() {
let json = r#"{"ttl": 120, "invalidate_on": ["create", "delete"]}"#;
let cs: CacheSpec = serde_json::from_str(json).unwrap();
assert_eq!(cs.invalidate_on.as_ref().unwrap().len(), 2);
}
#[test]
fn upload_spec_serde() {
let json = r#"{"field": "avatar_url", "storage": "s3", "max_size": "5mb", "types": ["jpg", "png"]}"#;
let us: UploadSpec = serde_json::from_str(json).unwrap();
assert_eq!(us.field, "avatar_url");
assert_eq!(us.types.as_ref().unwrap().len(), 2);
}
#[test]
fn endpoint_spec_list() {
let json = r#"{
"method": "GET",
"path": "/users",
"auth": ["member", "admin"],
"filters": ["role", "org_id"],
"search": ["name", "email"],
"pagination": "cursor",
"cache": {"ttl": 60}
}"#;
let ep: EndpointSpec = serde_json::from_str(json).unwrap();
assert_eq!(*ep.method(), HttpMethod::Get);
assert_eq!(ep.path(), "/users");
assert_eq!(ep.filters.as_ref().unwrap().len(), 2);
assert_eq!(ep.pagination, Some(PaginationStyle::Cursor));
assert!(!ep.soft_delete);
}
#[test]
fn endpoint_spec_create() {
let json = r#"{
"method": "POST",
"path": "/users",
"auth": ["admin"],
"input": ["email", "name", "role", "org_id"],
"controller": {"before": "validate_org"},
"events": ["user.created"],
"jobs": ["send_welcome_email"]
}"#;
let ep: EndpointSpec = serde_json::from_str(json).unwrap();
assert_eq!(*ep.method(), HttpMethod::Post);
let ctrl = ep.controller.as_ref().unwrap();
assert_eq!(
ctrl.before.as_ref().unwrap().names(),
&["validate_org".to_string()]
);
assert!(ctrl.after.is_none());
assert_eq!(ep.jobs.as_ref().unwrap(), &["send_welcome_email"]);
}
#[test]
fn controller_spec_full() {
let json = r#"{"before": "check_input", "after": "enrich"}"#;
let cs: ControllerSpec = serde_json::from_str(json).unwrap();
assert_eq!(
cs.before.as_ref().unwrap().names(),
&["check_input".to_string()]
);
assert_eq!(cs.after.as_ref().unwrap().names(), &["enrich".to_string()]);
}
#[test]
fn controller_spec_after_only() {
let json = r#"{"after": "enrich"}"#;
let cs: ControllerSpec = serde_json::from_str(json).unwrap();
assert!(cs.before.is_none());
assert_eq!(cs.after.as_ref().unwrap().names(), &["enrich".to_string()]);
}
#[test]
fn controller_wasm_before_detection() {
let json = r#"{"before": "wasm:./plugins/my_validator.wasm"}"#;
let cs: ControllerSpec = serde_json::from_str(json).unwrap();
assert!(cs.has_wasm_before());
assert!(!cs.has_wasm_after());
}
#[test]
fn controller_wasm_after_detection() {
let json = r#"{"after": "wasm:./plugins/my_enricher.wasm"}"#;
let cs: ControllerSpec = serde_json::from_str(json).unwrap();
assert!(!cs.has_wasm_before());
assert!(cs.has_wasm_after());
}
#[test]
fn controller_rust_not_detected_as_wasm() {
let json = r#"{"before": "validate_org"}"#;
let cs: ControllerSpec = serde_json::from_str(json).unwrap();
assert!(!cs.has_wasm_before());
}
#[test]
fn hooks_key_rejected() {
let json = r#"{
"method": "POST",
"path": "/users",
"hooks": ["validate_org"]
}"#;
let result = serde_json::from_str::<EndpointSpec>(json);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("unknown field"),
"Expected deny_unknown_fields to reject 'hooks', got: {err}"
);
}
#[test]
fn endpoint_spec_delete_soft() {
let json = r#"{
"method": "DELETE",
"path": "/users/:id",
"auth": ["admin"],
"soft_delete": true
}"#;
let ep: EndpointSpec = serde_json::from_str(json).unwrap();
assert!(ep.soft_delete);
}
#[test]
fn rate_limit_spec_parses_from_yaml() {
let yaml = "max_requests: 50\nwindow_secs: 30\n";
let spec: RateLimitSpec = serde_yaml::from_str(yaml).unwrap();
assert_eq!(spec.max_requests, 50);
assert_eq!(spec.window_secs, 30);
}
#[test]
fn endpoint_spec_rate_limit_field_roundtrips() {
let yaml = r#"
auth: [member]
rate_limit:
max_requests: 100
window_secs: 60
"#;
let spec: EndpointSpec = serde_yaml::from_str(yaml).unwrap();
let rl = spec.rate_limit.unwrap();
assert_eq!(rl.max_requests, 100);
assert_eq!(rl.window_secs, 60);
}
#[test]
fn endpoint_spec_rate_limit_absent_is_none() {
let yaml = "auth: [member]\n";
let spec: EndpointSpec = serde_yaml::from_str(yaml).unwrap();
assert!(spec.rate_limit.is_none());
}
#[test]
fn endpoint_spec_subscribers_parse() {
let yaml = r#"
auth: [admin]
events: [user.created]
subscribers:
- event: user.created
handler: send_welcome_email
- event: "*.deleted"
handler: cleanup_resources
"#;
let spec: EndpointSpec = serde_yaml::from_str(yaml).unwrap();
let subs = spec.subscribers.as_ref().unwrap();
assert_eq!(subs.len(), 2);
assert_eq!(subs[0].event, "user.created");
assert_eq!(subs[0].handler, "send_welcome_email");
assert_eq!(subs[1].event, "*.deleted");
}
#[test]
fn subscriber_spec_unknown_field_rejected() {
let yaml = r#"
subscribers:
- event: user.created
handler: send_welcome_email
extra: bad_field
"#;
let result = serde_yaml::from_str::<EndpointSpec>(yaml);
assert!(result.is_err());
}
#[test]
fn custom_endpoint_handler_field_parses() {
let yaml = r#"
method: POST
path: /invite
auth: [admin]
input: [email, role]
handler: invite_user
"#;
let ep: EndpointSpec = serde_yaml::from_str(yaml).unwrap();
assert_eq!(ep.handler.as_deref(), Some("invite_user"));
assert_eq!(ep.input.as_ref().unwrap().len(), 2);
}
#[test]
fn endpoint_without_handler_has_none() {
let yaml = "auth: [member]\n";
let ep: EndpointSpec = serde_yaml::from_str(yaml).unwrap();
assert!(ep.handler.is_none());
}
#[test]
fn endpoint_convention_known_actions() {
assert_eq!(
endpoint_convention("list", "users"),
Some((HttpMethod::Get, "/users".to_string()))
);
assert_eq!(
endpoint_convention("get", "users"),
Some((HttpMethod::Get, "/users/:id".to_string()))
);
assert_eq!(
endpoint_convention("create", "users"),
Some((HttpMethod::Post, "/users".to_string()))
);
assert_eq!(
endpoint_convention("update", "users"),
Some((HttpMethod::Patch, "/users/:id".to_string()))
);
assert_eq!(
endpoint_convention("delete", "users"),
Some((HttpMethod::Delete, "/users/:id".to_string()))
);
}
#[test]
fn endpoint_convention_unknown_action_returns_none() {
assert_eq!(endpoint_convention("archive", "users"), None);
assert_eq!(endpoint_convention("custom_action", "orders"), None);
assert_eq!(endpoint_convention("", "users"), None);
}
#[test]
fn endpoint_convention_uses_resource_name() {
let (method, path) = endpoint_convention("list", "orders").unwrap();
assert_eq!(method, HttpMethod::Get);
assert_eq!(path, "/orders");
let (_, path) = endpoint_convention("get", "blog_posts").unwrap();
assert_eq!(path, "/blog_posts/:id");
}
#[test]
fn apply_endpoint_defaults_fills_missing_method_and_path() {
use crate::ResourceDefinition;
use indexmap::IndexMap;
let mut endpoints = IndexMap::new();
endpoints.insert(
"list".to_string(),
EndpointSpec {
method: None,
path: None,
auth: None,
..Default::default()
},
);
endpoints.insert(
"create".to_string(),
EndpointSpec {
method: None,
path: None,
auth: None,
..Default::default()
},
);
let mut rd = ResourceDefinition {
resource: "posts".to_string(),
version: 1,
db: None,
tenant_key: None,
schema: IndexMap::new(),
endpoints: Some(endpoints),
relations: None,
indexes: None,
};
crate::apply_endpoint_defaults(&mut rd);
let eps = rd.endpoints.as_ref().unwrap();
assert_eq!(*eps["list"].method.as_ref().unwrap(), HttpMethod::Get);
assert_eq!(eps["list"].path.as_deref(), Some("/posts"));
assert_eq!(*eps["create"].method.as_ref().unwrap(), HttpMethod::Post);
assert_eq!(eps["create"].path.as_deref(), Some("/posts"));
}
#[test]
fn apply_endpoint_defaults_does_not_overwrite_explicit_values() {
use crate::ResourceDefinition;
use indexmap::IndexMap;
let mut endpoints = IndexMap::new();
endpoints.insert(
"list".to_string(),
EndpointSpec {
method: Some(HttpMethod::Post), path: Some("/custom/path".to_string()), auth: None,
..Default::default()
},
);
let mut rd = ResourceDefinition {
resource: "items".to_string(),
version: 1,
db: None,
tenant_key: None,
schema: IndexMap::new(),
endpoints: Some(endpoints),
relations: None,
indexes: None,
};
crate::apply_endpoint_defaults(&mut rd);
let eps = rd.endpoints.as_ref().unwrap();
assert_eq!(*eps["list"].method.as_ref().unwrap(), HttpMethod::Post);
assert_eq!(eps["list"].path.as_deref(), Some("/custom/path"));
}
#[test]
fn apply_endpoint_defaults_unknown_action_not_filled() {
use crate::ResourceDefinition;
use indexmap::IndexMap;
let mut endpoints = IndexMap::new();
endpoints.insert(
"archive".to_string(),
EndpointSpec {
method: None,
path: None,
auth: None,
..Default::default()
},
);
let mut rd = ResourceDefinition {
resource: "items".to_string(),
version: 1,
db: None,
tenant_key: None,
schema: IndexMap::new(),
endpoints: Some(endpoints),
relations: None,
indexes: None,
};
crate::apply_endpoint_defaults(&mut rd);
let eps = rd.endpoints.as_ref().unwrap();
assert!(eps["archive"].method.is_none());
assert!(eps["archive"].path.is_none());
}
#[test]
fn apply_endpoint_defaults_no_endpoints_is_noop() {
use crate::ResourceDefinition;
use indexmap::IndexMap;
let mut rd = ResourceDefinition {
resource: "items".to_string(),
version: 1,
db: None,
tenant_key: None,
schema: IndexMap::new(),
endpoints: None,
relations: None,
indexes: None,
};
crate::apply_endpoint_defaults(&mut rd);
assert!(rd.endpoints.is_none());
}
#[test]
fn auth_rule_display() {
assert_eq!(AuthRule::Public.to_string(), "public");
assert_eq!(AuthRule::Owner.to_string(), "owner");
assert_eq!(
AuthRule::Roles(vec!["admin".to_string(), "member".to_string()]).to_string(),
"admin, member"
);
assert_eq!(
AuthRule::Roles(vec!["viewer".to_string()]).to_string(),
"viewer"
);
}
#[test]
fn auth_rule_serde_roundtrip_public() {
let rule = AuthRule::Public;
let json = serde_json::to_string(&rule).unwrap();
assert_eq!(json, r#""public""#);
let back: AuthRule = serde_json::from_str(&json).unwrap();
assert_eq!(back, AuthRule::Public);
}
#[test]
fn auth_rule_serde_roundtrip_owner() {
let rule = AuthRule::Owner;
let json = serde_json::to_string(&rule).unwrap();
assert_eq!(json, r#""owner""#);
let back: AuthRule = serde_json::from_str(&json).unwrap();
assert_eq!(back, AuthRule::Owner);
}
#[test]
fn auth_rule_serde_roundtrip_roles() {
let rule = AuthRule::Roles(vec!["admin".to_string(), "member".to_string()]);
let json = serde_json::to_string(&rule).unwrap();
let back: AuthRule = serde_json::from_str(&json).unwrap();
assert_eq!(back, rule);
}
#[test]
fn auth_rule_deserialize_invalid_type_fails() {
let result = serde_json::from_str::<AuthRule>("42");
assert!(result.is_err());
}
#[test]
fn auth_rule_deserialize_unknown_string_fails() {
let result = serde_json::from_str::<AuthRule>(r#""superadmin""#);
assert!(result.is_err());
}
#[test]
fn http_method_serde_roundtrip() {
for m in [
HttpMethod::Get,
HttpMethod::Post,
HttpMethod::Patch,
HttpMethod::Put,
HttpMethod::Delete,
] {
let json = serde_json::to_string(&m).unwrap();
let back: HttpMethod = serde_json::from_str(&json).unwrap();
assert_eq!(m, back);
}
}
#[test]
fn controller_before_accepts_scalar_string() {
let json = r#"{"before": "validate_org"}"#;
let cs: ControllerSpec = serde_json::from_str(json).unwrap();
let names = cs.before.as_ref().unwrap().names();
assert_eq!(names, &["validate_org".to_string()]);
}
#[test]
fn controller_before_accepts_array() {
let json = r#"{"before": ["a", "b", "c"]}"#;
let cs: ControllerSpec = serde_json::from_str(json).unwrap();
let names = cs.before.as_ref().unwrap().names();
assert_eq!(names, &["a".to_string(), "b".to_string(), "c".to_string()]);
}
#[test]
fn controller_after_accepts_array() {
let json = r#"{"after": ["enrich_a", "enrich_b"]}"#;
let cs: ControllerSpec = serde_json::from_str(json).unwrap();
let names = cs.after.as_ref().unwrap().names();
assert_eq!(names, &["enrich_a".to_string(), "enrich_b".to_string()]);
}
#[test]
fn hook_list_wasm_detection_works_for_array() {
let json = r#"{"before": ["validate_x", "wasm:./plugin.wasm"]}"#;
let cs: ControllerSpec = serde_json::from_str(json).unwrap();
assert!(cs.has_wasm_before());
}
}