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/// Cache configuration for an endpoint.
110#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
111#[serde(deny_unknown_fields)]
112pub struct CacheSpec {
113    /// Time-to-live in seconds.
114    pub ttl: u64,
115    /// Events that invalidate this cache.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub invalidate_on: Option<Vec<String>>,
118}
119
120/// File upload configuration for an endpoint.
121#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
122#[serde(deny_unknown_fields)]
123pub struct UploadSpec {
124    /// Schema field that stores the file URL.
125    pub field: String,
126    /// Storage backend (e.g., "s3", "gcs", "local").
127    pub storage: String,
128    /// Maximum file size (e.g., "5mb").
129    pub max_size: String,
130    /// Allowed file extensions.
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub types: Option<Vec<String>>,
133}
134
135/// Controller specification for synchronous in-request business logic.
136///
137/// Declared per-endpoint in the resource YAML:
138/// ```yaml
139/// controller:
140///   before: validate_org
141///   after: enrich_response
142/// ```
143///
144/// Functions live in `resources/<resource>.controller.rs` and are called
145/// synchronously within the request lifecycle (before/after the DB operation).
146#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
147#[serde(deny_unknown_fields)]
148pub struct ControllerSpec {
149    /// Function to call before the DB operation.
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub before: Option<String>,
152    /// Function to call after the DB operation.
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub after: Option<String>,
155}
156
157/// Known endpoint conventions. When the endpoint action name matches one of these,
158/// the method and path are inferred automatically.
159pub 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
170/// Apply convention-based defaults to all endpoints in a resource.
171/// Fills in missing `method` and `path` based on the endpoint name.
172pub 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
190/// WASM plugin prefix used in controller `before`/`after` fields.
191///
192/// When a controller name starts with `wasm:`, the remainder is interpreted
193/// as a path to a `.wasm` plugin file. Example:
194/// ```yaml
195/// controller:
196///   before: "wasm:./plugins/my_validator.wasm"
197/// ```
198pub const WASM_HOOK_PREFIX: &str = "wasm:";
199
200impl ControllerSpec {
201    /// Returns `true` if the `before` controller references a WASM plugin.
202    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    /// Returns `true` if the `after` controller references a WASM plugin.
209    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    /// Extracts the WASM plugin path from a `before` controller, if present.
216    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    /// Extracts the WASM plugin path from an `after` controller, if present.
224    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/// Specification for a single endpoint in a resource.
233///
234/// Matches the YAML format:
235/// ```yaml
236/// list:
237///   method: GET
238///   path: /users
239///   auth: [member, admin]
240///   pagination: cursor
241/// ```
242///
243/// When the endpoint name matches a known convention (list, get, create, update, delete),
244/// `method` and `path` can be omitted and will be inferred from the resource name.
245/// Use `apply_endpoint_defaults()` after parsing to fill them in.
246#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
247#[serde(deny_unknown_fields)]
248pub struct EndpointSpec {
249    /// HTTP method (GET, POST, PATCH, PUT, DELETE).
250    /// Optional when endpoint name is a known convention (list, get, create, update, delete).
251    #[serde(default, skip_serializing_if = "Option::is_none")]
252    pub method: Option<HttpMethod>,
253
254    /// URL path pattern (e.g., "/users", "/users/:id").
255    /// Optional when endpoint name is a known convention (list, get, create, update, delete).
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub path: Option<String>,
258
259    /// Authentication/authorization rule.
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    pub auth: Option<AuthRule>,
262
263    /// Fields accepted as input for create/update.
264    #[serde(default, skip_serializing_if = "Option::is_none")]
265    pub input: Option<Vec<String>>,
266
267    /// Fields available as query filters.
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub filters: Option<Vec<String>>,
270
271    /// Fields included in full-text search.
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub search: Option<Vec<String>>,
274
275    /// Pagination style for list endpoints.
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub pagination: Option<PaginationStyle>,
278
279    /// Fields available for sorting.
280    #[serde(default, skip_serializing_if = "Option::is_none")]
281    pub sort: Option<Vec<String>>,
282
283    /// Cache configuration.
284    #[serde(default, skip_serializing_if = "Option::is_none")]
285    pub cache: Option<CacheSpec>,
286
287    /// Controller functions for synchronous in-request business logic.
288    #[serde(default, skip_serializing_if = "Option::is_none")]
289    pub controller: Option<ControllerSpec>,
290
291    /// Events to emit after successful execution.
292    #[serde(default, skip_serializing_if = "Option::is_none")]
293    pub events: Option<Vec<String>>,
294
295    /// Background jobs to enqueue after successful execution.
296    #[serde(default, skip_serializing_if = "Option::is_none")]
297    pub jobs: Option<Vec<String>>,
298
299    /// File upload configuration.
300    #[serde(default, skip_serializing_if = "Option::is_none")]
301    pub upload: Option<UploadSpec>,
302
303    /// Whether this endpoint performs a soft delete.
304    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
305    pub soft_delete: bool,
306}
307
308impl EndpointSpec {
309    /// Returns the resolved HTTP method. Panics if method is None
310    /// (should never happen after `apply_endpoint_defaults`).
311    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    /// Returns the resolved path. Panics if path is None
318    /// (should never happen after `apply_endpoint_defaults`).
319    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}