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            },
80        );
81        schema.insert(
82            "email".to_string(),
83            FieldSchema {
84                field_type: FieldType::String,
85                primary: false,
86                generated: false,
87                required: true,
88                unique: true,
89                nullable: false,
90                reference: None,
91                min: None,
92                max: None,
93                format: Some("email".to_string()),
94                values: None,
95                default: None,
96                sensitive: false,
97                search: false,
98                items: None,
99            },
100        );
101
102        let mut endpoints = IndexMap::new();
103        endpoints.insert(
104            "list".to_string(),
105            EndpointSpec {
106                method: Some(HttpMethod::Get),
107                path: Some("/users".to_string()),
108                auth: Some(AuthRule::Roles(vec![
109                    "member".to_string(),
110                    "admin".to_string(),
111                ])),
112                input: None,
113                filters: Some(vec!["role".to_string()]),
114                search: Some(vec!["email".to_string()]),
115                pagination: Some(PaginationStyle::Cursor),
116                sort: None,
117                cache: Some(CacheSpec {
118                    ttl: 60,
119                    invalidate_on: None,
120                }),
121                controller: None,
122                events: None,
123                jobs: None,
124                upload: None,
125                soft_delete: false,
126            },
127        );
128
129        let mut relations = IndexMap::new();
130        relations.insert(
131            "orders".to_string(),
132            RelationSpec {
133                resource: "orders".to_string(),
134                relation_type: RelationType::HasMany,
135                key: None,
136                foreign_key: Some("user_id".to_string()),
137            },
138        );
139
140        ResourceDefinition {
141            resource: "users".to_string(),
142            version: 1,
143            db: None,
144            tenant_key: None,
145            schema,
146            endpoints: Some(endpoints),
147            relations: Some(relations),
148            indexes: Some(vec![IndexSpec {
149                fields: vec!["created_at".to_string()],
150                unique: false,
151                order: Some("desc".to_string()),
152            }]),
153        }
154    }
155
156    #[test]
157    fn resource_definition_construction() {
158        let rd = sample_resource();
159        assert_eq!(rd.resource, "users");
160        assert_eq!(rd.version, 1);
161        assert_eq!(rd.schema.len(), 2);
162        assert!(rd.schema.contains_key("id"));
163        assert!(rd.schema.contains_key("email"));
164    }
165
166    #[test]
167    fn resource_definition_serde_roundtrip() {
168        let rd = sample_resource();
169        let json = serde_json::to_string_pretty(&rd).unwrap();
170        let back: ResourceDefinition = serde_json::from_str(&json).unwrap();
171        assert_eq!(rd, back);
172    }
173
174    #[test]
175    fn resource_definition_preserves_field_order() {
176        let rd = sample_resource();
177        let keys: Vec<&String> = rd.schema.keys().collect();
178        assert_eq!(keys, vec!["id", "email"]);
179    }
180
181    #[test]
182    fn resource_definition_optional_sections() {
183        let rd = ResourceDefinition {
184            resource: "tags".to_string(),
185            version: 1,
186            db: None,
187            tenant_key: None,
188            schema: IndexMap::new(),
189            endpoints: None,
190            relations: None,
191            indexes: None,
192        };
193        assert!(rd.endpoints.is_none());
194        assert!(rd.relations.is_none());
195        assert!(rd.indexes.is_none());
196
197        let json = serde_json::to_string(&rd).unwrap();
198        assert!(!json.contains("endpoints"));
199        assert!(!json.contains("relations"));
200        assert!(!json.contains("indexes"));
201    }
202}