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)]
146#[serde(deny_unknown_fields)]
147pub struct EndpointSpec {
148 pub method: HttpMethod,
150
151 pub path: String,
153
154 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub auth: Option<AuthRule>,
157
158 #[serde(default, skip_serializing_if = "Option::is_none")]
160 pub input: Option<Vec<String>>,
161
162 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub filters: Option<Vec<String>>,
165
166 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub search: Option<Vec<String>>,
169
170 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub pagination: Option<PaginationStyle>,
173
174 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub sort: Option<Vec<String>>,
177
178 #[serde(default, skip_serializing_if = "Option::is_none")]
180 pub cache: Option<CacheSpec>,
181
182 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub hooks: Option<Vec<String>>,
185
186 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub events: Option<Vec<String>>,
189
190 #[serde(default, skip_serializing_if = "Option::is_none")]
192 pub jobs: Option<Vec<String>>,
193
194 #[serde(default, skip_serializing_if = "Option::is_none")]
196 pub upload: Option<UploadSpec>,
197
198 #[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}