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/// Specification for a single endpoint in a resource.
136///
137/// Matches the YAML format:
138/// ```yaml
139/// list:
140///   method: GET
141///   path: /users
142///   auth: [member, admin]
143///   pagination: cursor
144/// ```
145#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
146#[serde(deny_unknown_fields)]
147pub struct EndpointSpec {
148    /// HTTP method (GET, POST, PATCH, PUT, DELETE).
149    pub method: HttpMethod,
150
151    /// URL path pattern (e.g., "/users", "/users/:id").
152    pub path: String,
153
154    /// Authentication/authorization rule.
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub auth: Option<AuthRule>,
157
158    /// Fields accepted as input for create/update.
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub input: Option<Vec<String>>,
161
162    /// Fields available as query filters.
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub filters: Option<Vec<String>>,
165
166    /// Fields included in full-text search.
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub search: Option<Vec<String>>,
169
170    /// Pagination style for list endpoints.
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub pagination: Option<PaginationStyle>,
173
174    /// Fields available for sorting.
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub sort: Option<Vec<String>>,
177
178    /// Cache configuration.
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    pub cache: Option<CacheSpec>,
181
182    /// Hook function names to execute.
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub hooks: Option<Vec<String>>,
185
186    /// Events to emit after successful execution.
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub events: Option<Vec<String>>,
189
190    /// Background jobs to enqueue after successful execution.
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub jobs: Option<Vec<String>>,
193
194    /// File upload configuration.
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub upload: Option<UploadSpec>,
197
198    /// Whether this endpoint performs a soft delete.
199    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
200    pub soft_delete: bool,
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn http_method_display() {
209        assert_eq!(HttpMethod::Get.to_string(), "GET");
210        assert_eq!(HttpMethod::Post.to_string(), "POST");
211        assert_eq!(HttpMethod::Patch.to_string(), "PATCH");
212        assert_eq!(HttpMethod::Put.to_string(), "PUT");
213        assert_eq!(HttpMethod::Delete.to_string(), "DELETE");
214    }
215
216    #[test]
217    fn auth_rule_public() {
218        let auth: AuthRule = serde_json::from_str(r#""public""#).unwrap();
219        assert!(auth.is_public());
220
221        let roles: AuthRule = serde_json::from_str(r#"["admin", "member"]"#).unwrap();
222        assert!(!roles.is_public());
223        if let AuthRule::Roles(r) = &roles {
224            assert_eq!(r.len(), 2);
225        }
226    }
227
228    #[test]
229    fn auth_rule_owner_standalone() {
230        let auth: AuthRule = serde_json::from_str(r#""owner""#).unwrap();
231        assert!(auth.is_owner());
232        assert!(auth.allows_owner());
233        assert!(!auth.is_public());
234    }
235
236    #[test]
237    fn auth_rule_owner_in_roles() {
238        let auth: AuthRule = serde_json::from_str(r#"["owner", "admin"]"#).unwrap();
239        assert!(auth.allows_owner());
240        assert!(!auth.is_owner());
241    }
242
243    #[test]
244    fn pagination_style_serde() {
245        let p: PaginationStyle = serde_json::from_str("\"cursor\"").unwrap();
246        assert_eq!(p, PaginationStyle::Cursor);
247        let p: PaginationStyle = serde_json::from_str("\"offset\"").unwrap();
248        assert_eq!(p, PaginationStyle::Offset);
249    }
250
251    #[test]
252    fn cache_spec_minimal() {
253        let json = r#"{"ttl": 60}"#;
254        let cs: CacheSpec = serde_json::from_str(json).unwrap();
255        assert_eq!(cs.ttl, 60);
256        assert!(cs.invalidate_on.is_none());
257    }
258
259    #[test]
260    fn cache_spec_with_invalidation() {
261        let json = r#"{"ttl": 120, "invalidate_on": ["create", "delete"]}"#;
262        let cs: CacheSpec = serde_json::from_str(json).unwrap();
263        assert_eq!(cs.invalidate_on.as_ref().unwrap().len(), 2);
264    }
265
266    #[test]
267    fn upload_spec_serde() {
268        let json = r#"{"field": "avatar_url", "storage": "s3", "max_size": "5mb", "types": ["jpg", "png"]}"#;
269        let us: UploadSpec = serde_json::from_str(json).unwrap();
270        assert_eq!(us.field, "avatar_url");
271        assert_eq!(us.types.as_ref().unwrap().len(), 2);
272    }
273
274    #[test]
275    fn endpoint_spec_list() {
276        let json = r#"{
277            "method": "GET",
278            "path": "/users",
279            "auth": ["member", "admin"],
280            "filters": ["role", "org_id"],
281            "search": ["name", "email"],
282            "pagination": "cursor",
283            "cache": {"ttl": 60}
284        }"#;
285        let ep: EndpointSpec = serde_json::from_str(json).unwrap();
286        assert_eq!(ep.method, HttpMethod::Get);
287        assert_eq!(ep.path, "/users");
288        assert_eq!(ep.filters.as_ref().unwrap().len(), 2);
289        assert_eq!(ep.pagination, Some(PaginationStyle::Cursor));
290        assert!(!ep.soft_delete);
291    }
292
293    #[test]
294    fn endpoint_spec_create() {
295        let json = r#"{
296            "method": "POST",
297            "path": "/users",
298            "auth": ["admin"],
299            "input": ["email", "name", "role", "org_id"],
300            "hooks": ["validate_org"],
301            "events": ["user.created"],
302            "jobs": ["send_welcome_email"]
303        }"#;
304        let ep: EndpointSpec = serde_json::from_str(json).unwrap();
305        assert_eq!(ep.method, HttpMethod::Post);
306        assert_eq!(ep.hooks.as_ref().unwrap(), &["validate_org"]);
307        assert_eq!(ep.jobs.as_ref().unwrap(), &["send_welcome_email"]);
308    }
309
310    #[test]
311    fn endpoint_spec_delete_soft() {
312        let json = r#"{
313            "method": "DELETE",
314            "path": "/users/:id",
315            "auth": ["admin"],
316            "soft_delete": true
317        }"#;
318        let ep: EndpointSpec = serde_json::from_str(json).unwrap();
319        assert!(ep.soft_delete);
320    }
321}