Skip to main content

shaperail_core/
resource.rs

1use indexmap::IndexMap;
2use serde::{Deserialize, Serialize};
3
4use crate::{EndpointSpec, FieldSchema, IndexSpec, RelationSpec};
5
6/// Complete definition of a Shaperail resource, parsed from a resource YAML file.
7///
8/// This is the central type that all codegen and runtime modules consume.
9///
10/// ```yaml
11/// resource: users
12/// version: 1
13/// schema:
14///   id: { type: uuid, primary: true, generated: true }
15///   email: { type: string, format: email, unique: true, required: true }
16/// ```
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
18#[serde(deny_unknown_fields)]
19pub struct ResourceDefinition {
20    /// Snake_case plural name of the resource (e.g., "users").
21    pub resource: String,
22
23    /// Schema version number (starts at 1).
24    pub version: u32,
25
26    /// Named database connection for this resource (M14). Default: "default".
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub db: Option<String>,
29
30    /// Tenant isolation key (M18). References a schema field (must be type uuid)
31    /// that identifies the tenant. When set, all queries are automatically scoped
32    /// to the authenticated user's `tenant_id` claim. `super_admin` bypasses the filter.
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub tenant_key: Option<String>,
35
36    /// Field definitions, keyed by field name. Uses IndexMap to preserve declaration order.
37    pub schema: IndexMap<String, FieldSchema>,
38
39    /// Endpoint definitions, keyed by action name (e.g., "list", "create").
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub endpoints: Option<IndexMap<String, EndpointSpec>>,
42
43    /// Relationship definitions, keyed by relation name.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub relations: Option<IndexMap<String, RelationSpec>>,
46
47    /// Additional database indexes.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub indexes: Option<Vec<IndexSpec>>,
50}
51
52#[cfg(test)]
53mod tests {
54    use super::*;
55    use crate::{
56        AuthRule, CacheSpec, EndpointSpec, FieldType, HttpMethod, PaginationStyle, RelationType,
57    };
58
59    fn sample_resource() -> ResourceDefinition {
60        let mut schema = IndexMap::new();
61        schema.insert(
62            "id".to_string(),
63            FieldSchema {
64                field_type: FieldType::Uuid,
65                primary: true,
66                generated: true,
67                required: false,
68                unique: false,
69                nullable: false,
70                reference: None,
71                min: None,
72                max: None,
73                format: None,
74                values: None,
75                default: None,
76                sensitive: false,
77                search: false,
78                items: None,
79                transient: false,
80            },
81        );
82        schema.insert(
83            "email".to_string(),
84            FieldSchema {
85                field_type: FieldType::String,
86                primary: false,
87                generated: false,
88                required: true,
89                unique: true,
90                nullable: false,
91                reference: None,
92                min: None,
93                max: None,
94                format: Some("email".to_string()),
95                values: None,
96                default: None,
97                sensitive: false,
98                search: false,
99                items: None,
100                transient: false,
101            },
102        );
103
104        let mut endpoints = IndexMap::new();
105        endpoints.insert(
106            "list".to_string(),
107            EndpointSpec {
108                method: Some(HttpMethod::Get),
109                path: Some("/users".to_string()),
110                auth: Some(AuthRule::Roles(vec![
111                    "member".to_string(),
112                    "admin".to_string(),
113                ])),
114                input: None,
115                filters: Some(vec!["role".to_string()]),
116                search: Some(vec!["email".to_string()]),
117                pagination: Some(PaginationStyle::Cursor),
118                sort: None,
119                cache: Some(CacheSpec {
120                    ttl: 60,
121                    invalidate_on: None,
122                }),
123                controller: None,
124                events: None,
125                jobs: None,
126                subscribers: None,
127                handler: None,
128                upload: None,
129                rate_limit: None,
130                soft_delete: false,
131            },
132        );
133
134        let mut relations = IndexMap::new();
135        relations.insert(
136            "orders".to_string(),
137            RelationSpec {
138                resource: "orders".to_string(),
139                relation_type: RelationType::HasMany,
140                key: None,
141                foreign_key: Some("user_id".to_string()),
142            },
143        );
144
145        ResourceDefinition {
146            resource: "users".to_string(),
147            version: 1,
148            db: None,
149            tenant_key: None,
150            schema,
151            endpoints: Some(endpoints),
152            relations: Some(relations),
153            indexes: Some(vec![IndexSpec {
154                fields: vec!["created_at".to_string()],
155                unique: false,
156                order: Some("desc".to_string()),
157            }]),
158        }
159    }
160
161    #[test]
162    fn resource_definition_construction() {
163        let rd = sample_resource();
164        assert_eq!(rd.resource, "users");
165        assert_eq!(rd.version, 1);
166        assert_eq!(rd.schema.len(), 2);
167        assert!(rd.schema.contains_key("id"));
168        assert!(rd.schema.contains_key("email"));
169    }
170
171    #[test]
172    fn resource_definition_serde_roundtrip() {
173        let rd = sample_resource();
174        let json = serde_json::to_string_pretty(&rd).unwrap();
175        let back: ResourceDefinition = serde_json::from_str(&json).unwrap();
176        assert_eq!(rd, back);
177    }
178
179    #[test]
180    fn resource_definition_preserves_field_order() {
181        let rd = sample_resource();
182        let keys: Vec<&String> = rd.schema.keys().collect();
183        assert_eq!(keys, vec!["id", "email"]);
184    }
185
186    #[test]
187    fn resource_definition_optional_sections() {
188        let rd = ResourceDefinition {
189            resource: "tags".to_string(),
190            version: 1,
191            db: None,
192            tenant_key: None,
193            schema: IndexMap::new(),
194            endpoints: None,
195            relations: None,
196            indexes: None,
197        };
198        assert!(rd.endpoints.is_none());
199        assert!(rd.relations.is_none());
200        assert!(rd.indexes.is_none());
201
202        let json = serde_json::to_string(&rd).unwrap();
203        assert!(!json.contains("endpoints"));
204        assert!(!json.contains("relations"));
205        assert!(!json.contains("indexes"));
206    }
207}