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, Serialize, Deserialize)]
111#[serde(deny_unknown_fields)]
112pub struct CacheSpec {
113 pub ttl: u64,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub invalidate_on: Option<Vec<String>>,
118}
119
120#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
122#[serde(deny_unknown_fields)]
123pub struct UploadSpec {
124 pub field: String,
126 pub storage: String,
128 pub max_size: String,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub types: Option<Vec<String>>,
133}
134
135#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
147#[serde(deny_unknown_fields)]
148pub struct ControllerSpec {
149 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub before: Option<String>,
152 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub after: Option<String>,
155}
156
157pub fn endpoint_convention(action: &str, resource_name: &str) -> Option<(HttpMethod, String)> {
160 match action {
161 "list" => Some((HttpMethod::Get, format!("/{resource_name}"))),
162 "get" => Some((HttpMethod::Get, format!("/{resource_name}/:id"))),
163 "create" => Some((HttpMethod::Post, format!("/{resource_name}"))),
164 "update" => Some((HttpMethod::Patch, format!("/{resource_name}/:id"))),
165 "delete" => Some((HttpMethod::Delete, format!("/{resource_name}/:id"))),
166 _ => None,
167 }
168}
169
170pub fn apply_endpoint_defaults(resource: &mut super::ResourceDefinition) {
173 let resource_name = resource.resource.clone();
174 if let Some(ref mut endpoints) = resource.endpoints {
175 for (action, ep) in endpoints.iter_mut() {
176 if let Some((default_method, default_path)) =
177 endpoint_convention(action, &resource_name)
178 {
179 if ep.method.is_none() {
180 ep.method = Some(default_method);
181 }
182 if ep.path.is_none() {
183 ep.path = Some(default_path);
184 }
185 }
186 }
187 }
188}
189
190pub const WASM_HOOK_PREFIX: &str = "wasm:";
199
200impl ControllerSpec {
201 pub fn has_wasm_before(&self) -> bool {
203 self.before
204 .as_ref()
205 .is_some_and(|s| s.starts_with(WASM_HOOK_PREFIX))
206 }
207
208 pub fn has_wasm_after(&self) -> bool {
210 self.after
211 .as_ref()
212 .is_some_and(|s| s.starts_with(WASM_HOOK_PREFIX))
213 }
214
215 pub fn wasm_before_path(&self) -> Option<&str> {
217 self.before
218 .as_ref()
219 .filter(|s| s.starts_with(WASM_HOOK_PREFIX))
220 .map(|s| &s[WASM_HOOK_PREFIX.len()..])
221 }
222
223 pub fn wasm_after_path(&self) -> Option<&str> {
225 self.after
226 .as_ref()
227 .filter(|s| s.starts_with(WASM_HOOK_PREFIX))
228 .map(|s| &s[WASM_HOOK_PREFIX.len()..])
229 }
230}
231
232#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
247#[serde(deny_unknown_fields)]
248pub struct EndpointSpec {
249 #[serde(default, skip_serializing_if = "Option::is_none")]
252 pub method: Option<HttpMethod>,
253
254 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub path: Option<String>,
258
259 #[serde(default, skip_serializing_if = "Option::is_none")]
261 pub auth: Option<AuthRule>,
262
263 #[serde(default, skip_serializing_if = "Option::is_none")]
265 pub input: Option<Vec<String>>,
266
267 #[serde(default, skip_serializing_if = "Option::is_none")]
269 pub filters: Option<Vec<String>>,
270
271 #[serde(default, skip_serializing_if = "Option::is_none")]
273 pub search: Option<Vec<String>>,
274
275 #[serde(default, skip_serializing_if = "Option::is_none")]
277 pub pagination: Option<PaginationStyle>,
278
279 #[serde(default, skip_serializing_if = "Option::is_none")]
281 pub sort: Option<Vec<String>>,
282
283 #[serde(default, skip_serializing_if = "Option::is_none")]
285 pub cache: Option<CacheSpec>,
286
287 #[serde(default, skip_serializing_if = "Option::is_none")]
289 pub controller: Option<ControllerSpec>,
290
291 #[serde(default, skip_serializing_if = "Option::is_none")]
293 pub events: Option<Vec<String>>,
294
295 #[serde(default, skip_serializing_if = "Option::is_none")]
297 pub jobs: Option<Vec<String>>,
298
299 #[serde(default, skip_serializing_if = "Option::is_none")]
301 pub upload: Option<UploadSpec>,
302
303 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
305 pub soft_delete: bool,
306}
307
308impl EndpointSpec {
309 pub fn method(&self) -> &HttpMethod {
312 self.method
313 .as_ref()
314 .expect("EndpointSpec.method must be set — call apply_endpoint_defaults() first")
315 }
316
317 pub fn path(&self) -> &str {
320 self.path
321 .as_deref()
322 .expect("EndpointSpec.path must be set — call apply_endpoint_defaults() first")
323 }
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329
330 #[test]
331 fn http_method_display() {
332 assert_eq!(HttpMethod::Get.to_string(), "GET");
333 assert_eq!(HttpMethod::Post.to_string(), "POST");
334 assert_eq!(HttpMethod::Patch.to_string(), "PATCH");
335 assert_eq!(HttpMethod::Put.to_string(), "PUT");
336 assert_eq!(HttpMethod::Delete.to_string(), "DELETE");
337 }
338
339 #[test]
340 fn auth_rule_public() {
341 let auth: AuthRule = serde_json::from_str(r#""public""#).unwrap();
342 assert!(auth.is_public());
343
344 let roles: AuthRule = serde_json::from_str(r#"["admin", "member"]"#).unwrap();
345 assert!(!roles.is_public());
346 if let AuthRule::Roles(r) = &roles {
347 assert_eq!(r.len(), 2);
348 }
349 }
350
351 #[test]
352 fn auth_rule_owner_standalone() {
353 let auth: AuthRule = serde_json::from_str(r#""owner""#).unwrap();
354 assert!(auth.is_owner());
355 assert!(auth.allows_owner());
356 assert!(!auth.is_public());
357 }
358
359 #[test]
360 fn auth_rule_owner_in_roles() {
361 let auth: AuthRule = serde_json::from_str(r#"["owner", "admin"]"#).unwrap();
362 assert!(auth.allows_owner());
363 assert!(!auth.is_owner());
364 }
365
366 #[test]
367 fn pagination_style_serde() {
368 let p: PaginationStyle = serde_json::from_str("\"cursor\"").unwrap();
369 assert_eq!(p, PaginationStyle::Cursor);
370 let p: PaginationStyle = serde_json::from_str("\"offset\"").unwrap();
371 assert_eq!(p, PaginationStyle::Offset);
372 }
373
374 #[test]
375 fn cache_spec_minimal() {
376 let json = r#"{"ttl": 60}"#;
377 let cs: CacheSpec = serde_json::from_str(json).unwrap();
378 assert_eq!(cs.ttl, 60);
379 assert!(cs.invalidate_on.is_none());
380 }
381
382 #[test]
383 fn cache_spec_with_invalidation() {
384 let json = r#"{"ttl": 120, "invalidate_on": ["create", "delete"]}"#;
385 let cs: CacheSpec = serde_json::from_str(json).unwrap();
386 assert_eq!(cs.invalidate_on.as_ref().unwrap().len(), 2);
387 }
388
389 #[test]
390 fn upload_spec_serde() {
391 let json = r#"{"field": "avatar_url", "storage": "s3", "max_size": "5mb", "types": ["jpg", "png"]}"#;
392 let us: UploadSpec = serde_json::from_str(json).unwrap();
393 assert_eq!(us.field, "avatar_url");
394 assert_eq!(us.types.as_ref().unwrap().len(), 2);
395 }
396
397 #[test]
398 fn endpoint_spec_list() {
399 let json = r#"{
400 "method": "GET",
401 "path": "/users",
402 "auth": ["member", "admin"],
403 "filters": ["role", "org_id"],
404 "search": ["name", "email"],
405 "pagination": "cursor",
406 "cache": {"ttl": 60}
407 }"#;
408 let ep: EndpointSpec = serde_json::from_str(json).unwrap();
409 assert_eq!(*ep.method(), HttpMethod::Get);
410 assert_eq!(ep.path(), "/users");
411 assert_eq!(ep.filters.as_ref().unwrap().len(), 2);
412 assert_eq!(ep.pagination, Some(PaginationStyle::Cursor));
413 assert!(!ep.soft_delete);
414 }
415
416 #[test]
417 fn endpoint_spec_create() {
418 let json = r#"{
419 "method": "POST",
420 "path": "/users",
421 "auth": ["admin"],
422 "input": ["email", "name", "role", "org_id"],
423 "controller": {"before": "validate_org"},
424 "events": ["user.created"],
425 "jobs": ["send_welcome_email"]
426 }"#;
427 let ep: EndpointSpec = serde_json::from_str(json).unwrap();
428 assert_eq!(*ep.method(), HttpMethod::Post);
429 let ctrl = ep.controller.as_ref().unwrap();
430 assert_eq!(ctrl.before.as_deref(), Some("validate_org"));
431 assert!(ctrl.after.is_none());
432 assert_eq!(ep.jobs.as_ref().unwrap(), &["send_welcome_email"]);
433 }
434
435 #[test]
436 fn controller_spec_full() {
437 let json = r#"{"before": "check_input", "after": "enrich"}"#;
438 let cs: ControllerSpec = serde_json::from_str(json).unwrap();
439 assert_eq!(cs.before.as_deref(), Some("check_input"));
440 assert_eq!(cs.after.as_deref(), Some("enrich"));
441 }
442
443 #[test]
444 fn controller_spec_after_only() {
445 let json = r#"{"after": "enrich"}"#;
446 let cs: ControllerSpec = serde_json::from_str(json).unwrap();
447 assert!(cs.before.is_none());
448 assert_eq!(cs.after.as_deref(), Some("enrich"));
449 }
450
451 #[test]
452 fn controller_wasm_before_detection() {
453 let json = r#"{"before": "wasm:./plugins/my_validator.wasm"}"#;
454 let cs: ControllerSpec = serde_json::from_str(json).unwrap();
455 assert!(cs.has_wasm_before());
456 assert!(!cs.has_wasm_after());
457 assert_eq!(cs.wasm_before_path(), Some("./plugins/my_validator.wasm"));
458 assert_eq!(cs.wasm_after_path(), None);
459 }
460
461 #[test]
462 fn controller_wasm_after_detection() {
463 let json = r#"{"after": "wasm:./plugins/my_enricher.wasm"}"#;
464 let cs: ControllerSpec = serde_json::from_str(json).unwrap();
465 assert!(!cs.has_wasm_before());
466 assert!(cs.has_wasm_after());
467 assert_eq!(cs.wasm_after_path(), Some("./plugins/my_enricher.wasm"));
468 }
469
470 #[test]
471 fn controller_rust_not_detected_as_wasm() {
472 let json = r#"{"before": "validate_org"}"#;
473 let cs: ControllerSpec = serde_json::from_str(json).unwrap();
474 assert!(!cs.has_wasm_before());
475 assert_eq!(cs.wasm_before_path(), None);
476 }
477
478 #[test]
479 fn hooks_key_rejected() {
480 let json = r#"{
481 "method": "POST",
482 "path": "/users",
483 "hooks": ["validate_org"]
484 }"#;
485 let result = serde_json::from_str::<EndpointSpec>(json);
486 assert!(result.is_err());
487 let err = result.unwrap_err().to_string();
488 assert!(
489 err.contains("unknown field"),
490 "Expected deny_unknown_fields to reject 'hooks', got: {err}"
491 );
492 }
493
494 #[test]
495 fn endpoint_spec_delete_soft() {
496 let json = r#"{
497 "method": "DELETE",
498 "path": "/users/:id",
499 "auth": ["admin"],
500 "soft_delete": true
501 }"#;
502 let ep: EndpointSpec = serde_json::from_str(json).unwrap();
503 assert!(ep.soft_delete);
504 }
505}