Skip to main content

shaperail_core/
endpoint.rs

1use serde::{Deserialize, Serialize};
2
3/// HTTP method for an endpoint.
4#[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/// Authentication rule for an endpoint.
28///
29/// Deserializes from `"public"`, `"owner"`, or an array of role strings.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum AuthRule {
32    /// No authentication required.
33    Public,
34    /// JWT user ID must match the record's owner field.
35    Owner,
36    /// Requires JWT with one of these roles.
37    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    /// Returns true if this rule allows public (unauthenticated) access.
80    pub fn is_public(&self) -> bool {
81        matches!(self, Self::Public)
82    }
83
84    /// Returns true if this rule requires ownership check.
85    pub fn is_owner(&self) -> bool {
86        matches!(self, Self::Owner)
87    }
88
89    /// Returns true if "owner" appears in the roles list or is the standalone Owner variant.
90    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/// Pagination strategy for list endpoints.
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(rename_all = "lowercase")]
102pub enum PaginationStyle {
103    /// Cursor-based pagination (default).
104    Cursor,
105    /// Offset-based pagination.
106    Offset,
107}
108
109/// Per-endpoint rate limiting configuration.
110///
111/// Declared in resource YAML:
112/// ```yaml
113/// list:
114///   auth: [member]
115///   rate_limit: { max_requests: 100, window_secs: 60 }
116/// ```
117///
118/// Requires Redis. Silently skipped if Redis is not configured.
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120#[serde(deny_unknown_fields)]
121pub struct RateLimitSpec {
122    /// Maximum requests allowed within the window.
123    pub max_requests: u64,
124    /// Window duration in seconds.
125    pub window_secs: u64,
126}
127
128/// Cache configuration for an endpoint.
129#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
130#[serde(deny_unknown_fields)]
131pub struct CacheSpec {
132    /// Time-to-live in seconds.
133    pub ttl: u64,
134    /// Events that invalidate this cache.
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub invalidate_on: Option<Vec<String>>,
137}
138
139/// File upload configuration for an endpoint.
140#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
141#[serde(deny_unknown_fields)]
142pub struct UploadSpec {
143    /// Schema field that stores the file URL.
144    pub field: String,
145    /// Storage backend (e.g., "s3", "gcs", "local").
146    pub storage: String,
147    /// Maximum file size (e.g., "5mb").
148    pub max_size: String,
149    /// Allowed file extensions.
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub types: Option<Vec<String>>,
152}
153
154/// One or more controller hook names declared under `before:` or `after:`.
155///
156/// YAML accepts both the scalar form (single hook) and the array form
157/// (chain of hooks). Rust callers iterate via `names()` regardless of shape.
158///
159/// ```yaml
160/// controller: { before: validate_org }                      # Single
161/// controller: { before: [check_currency, check_org_match] } # Multi
162/// ```
163#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
164#[serde(untagged)]
165pub enum HookList {
166    Single(String),
167    Multi(Vec<String>),
168}
169
170impl HookList {
171    /// Returns the hook names as a slice, regardless of whether the YAML
172    /// declared the single or multi form.
173    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    /// Returns `true` if any name in the list begins with `wasm:`.
181    pub fn has_wasm(&self) -> bool {
182        self.names().iter().any(|n| n.starts_with(WASM_HOOK_PREFIX))
183    }
184}
185
186/// Controller specification for synchronous in-request business logic.
187///
188/// Declared per-endpoint in the resource YAML:
189/// ```yaml
190/// controller:
191///   before: validate_org
192///   after: enrich_response
193/// ```
194///
195/// Functions live in `resources/<resource>.controller.rs` and are called
196/// synchronously within the request lifecycle (before/after the DB operation).
197#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
198#[serde(deny_unknown_fields)]
199pub struct ControllerSpec {
200    /// Function(s) to call before the DB operation. Runs in declaration order;
201    /// first error short-circuits.
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub before: Option<HookList>,
204    /// Function(s) to call after the DB operation. Runs in declaration order;
205    /// first error short-circuits.
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub after: Option<HookList>,
208}
209
210/// Known endpoint conventions. When the endpoint action name matches one of these,
211/// the method and path are inferred automatically.
212pub 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
223/// Apply convention-based defaults to all endpoints in a resource.
224/// Fills in missing `method` and `path` based on the endpoint name.
225pub 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
243/// WASM plugin prefix used in controller `before`/`after` fields.
244///
245/// When a controller name starts with `wasm:`, the remainder is interpreted
246/// as a path to a `.wasm` plugin file. Example:
247/// ```yaml
248/// controller:
249///   before: "wasm:./plugins/my_validator.wasm"
250/// ```
251pub const WASM_HOOK_PREFIX: &str = "wasm:";
252
253impl ControllerSpec {
254    /// Returns `true` if any `before` hook references a WASM plugin.
255    pub fn has_wasm_before(&self) -> bool {
256        self.before.as_ref().is_some_and(|h| h.has_wasm())
257    }
258
259    /// Returns `true` if any `after` hook references a WASM plugin.
260    pub fn has_wasm_after(&self) -> bool {
261        self.after.as_ref().is_some_and(|h| h.has_wasm())
262    }
263
264    /// Iterates `before` hook names; empty if no controller is declared.
265    pub fn before_names(&self) -> &[String] {
266        self.before.as_ref().map(|h| h.names()).unwrap_or(&[])
267    }
268
269    /// Iterates `after` hook names; empty if no controller is declared.
270    pub fn after_names(&self) -> &[String] {
271        self.after.as_ref().map(|h| h.names()).unwrap_or(&[])
272    }
273}
274
275/// A subscriber declaration within an endpoint — auto-registered at startup.
276///
277/// ```yaml
278/// subscribers:
279///   - event: user.created
280///     handler: send_welcome_email
281/// ```
282#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
283#[serde(deny_unknown_fields)]
284pub struct SubscriberSpec {
285    /// Event name pattern (e.g., "user.created", "*.deleted").
286    pub event: String,
287    /// Handler function name in `resources/<resource>.controller.rs`.
288    pub handler: String,
289}
290
291/// Specification for a single endpoint in a resource.
292///
293/// Matches the YAML format:
294/// ```yaml
295/// list:
296///   method: GET
297///   path: /users
298///   auth: [member, admin]
299///   pagination: cursor
300/// ```
301///
302/// When the endpoint name matches a known convention (list, get, create, update, delete),
303/// `method` and `path` can be omitted and will be inferred from the resource name.
304/// Use `apply_endpoint_defaults()` after parsing to fill them in.
305#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
306#[serde(deny_unknown_fields)]
307pub struct EndpointSpec {
308    /// HTTP method (GET, POST, PATCH, PUT, DELETE).
309    /// Optional when endpoint name is a known convention (list, get, create, update, delete).
310    #[serde(default, skip_serializing_if = "Option::is_none")]
311    pub method: Option<HttpMethod>,
312
313    /// URL path pattern (e.g., "/users", "/users/:id").
314    /// Optional when endpoint name is a known convention (list, get, create, update, delete).
315    #[serde(default, skip_serializing_if = "Option::is_none")]
316    pub path: Option<String>,
317
318    /// Authentication/authorization rule.
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    pub auth: Option<AuthRule>,
321
322    /// Fields accepted as input for create/update.
323    #[serde(default, skip_serializing_if = "Option::is_none")]
324    pub input: Option<Vec<String>>,
325
326    /// Fields available as query filters.
327    #[serde(default, skip_serializing_if = "Option::is_none")]
328    pub filters: Option<Vec<String>>,
329
330    /// Fields included in full-text search.
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    pub search: Option<Vec<String>>,
333
334    /// Pagination style for list endpoints.
335    #[serde(default, skip_serializing_if = "Option::is_none")]
336    pub pagination: Option<PaginationStyle>,
337
338    /// Fields available for sorting.
339    #[serde(default, skip_serializing_if = "Option::is_none")]
340    pub sort: Option<Vec<String>>,
341
342    /// Cache configuration.
343    #[serde(default, skip_serializing_if = "Option::is_none")]
344    pub cache: Option<CacheSpec>,
345
346    /// Controller functions for synchronous in-request business logic.
347    #[serde(default, skip_serializing_if = "Option::is_none")]
348    pub controller: Option<ControllerSpec>,
349
350    /// Events to emit after successful execution.
351    #[serde(default, skip_serializing_if = "Option::is_none")]
352    pub events: Option<Vec<String>>,
353
354    /// Background jobs to enqueue after successful execution.
355    #[serde(default, skip_serializing_if = "Option::is_none")]
356    pub jobs: Option<Vec<String>>,
357
358    /// Event subscribers auto-registered at startup; each entry maps an event pattern to a handler function.
359    #[serde(default, skip_serializing_if = "Option::is_none")]
360    pub subscribers: Option<Vec<SubscriberSpec>>,
361
362    /// Handler function name for non-convention endpoints.
363    /// Required when the endpoint action name is not list/get/create/update/delete.
364    /// The function must be defined in `resources/<resource>.controller.rs`.
365    #[serde(default, skip_serializing_if = "Option::is_none")]
366    pub handler: Option<String>,
367
368    /// File upload configuration.
369    #[serde(default, skip_serializing_if = "Option::is_none")]
370    pub upload: Option<UploadSpec>,
371
372    /// Per-endpoint rate limiting. Requires Redis. Skipped if Redis is not configured.
373    #[serde(default, skip_serializing_if = "Option::is_none")]
374    pub rate_limit: Option<RateLimitSpec>,
375
376    /// Whether this endpoint performs a soft delete.
377    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
378    pub soft_delete: bool,
379}
380
381impl EndpointSpec {
382    /// Returns the resolved HTTP method. Panics if method is None
383    /// (should never happen after `apply_endpoint_defaults`).
384    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    /// Returns the resolved path. Panics if path is None
391    /// (should never happen after `apply_endpoint_defaults`).
392    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
399/// Converts an Express-style path (with `:param` segments) to a brace-style
400/// path (with `{param}` segments) used by Actix-router and OpenAPI 3.1.
401///
402/// A `:param` segment is recognised when the colon is followed by a Rust-like
403/// identifier (`[A-Za-z_][A-Za-z0-9_]*`). Any colon not followed by an
404/// identifier character is left untouched.
405///
406/// Examples:
407/// - `/users/:id` → `/users/{id}`
408/// - `/vendors/:vendor_id/webhook/:token` → `/vendors/{vendor_id}/webhook/{token}`
409/// - `/literal:colon` → `/literal:colon` (colon not followed by identifier)
410pub 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            // SAFETY: we only consumed ASCII identifier bytes.
423            out.push_str(&path[start..end]);
424            out.push('}');
425            i = end;
426        } else {
427            // Push one UTF-8 char from the original string, advancing `i` by
428            // its byte length. (`bytes[i]` is the leading byte; multibyte
429            // chars are not identifier bytes anyway, so this branch handles
430            // them transparently.)
431            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        // A bare `:` not followed by an identifier byte is part of the literal path.
475        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    // -- endpoint_convention tests --
750
751    #[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    // -- apply_endpoint_defaults tests --
792
793    #[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),         // overridden
848                path: Some("/custom/path".to_string()), // overridden
849                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        // Explicitly set values must not be overwritten
869        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        // Unknown actions must not get defaults
903        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        // Must not panic
925        crate::apply_endpoint_defaults(&mut rd);
926        assert!(rd.endpoints.is_none());
927    }
928
929    // -- AuthRule::Display tests --
930
931    #[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    // -- AuthRule serialization roundtrip --
946
947    #[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        // A number is neither a string nor an array — must fail
976        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        // "superadmin" is not "public" or "owner"
983        let result = serde_json::from_str::<AuthRule>(r#""superadmin""#);
984        assert!(result.is_err());
985    }
986
987    // -- HttpMethod serde --
988
989    #[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    // -- HookList tests --
1005
1006    #[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}