1use serde_json::{json, Value};
2
3pub fn generate_resource_json_schema() -> Value {
9 json!({
10 "$schema": "https://json-schema.org/draft/2020-12/schema",
11 "$id": "https://shaperail.dev/schema/resource.v1.json",
12 "title": "Shaperail Resource Definition",
13 "description": "Schema for Shaperail resource YAML files. Defines a single API resource with its fields, endpoints, relations, and indexes.",
14 "type": "object",
15 "required": ["resource", "version", "schema"],
16 "additionalProperties": false,
17 "properties": {
18 "resource": {
19 "type": "string",
20 "description": "Snake_case plural name of the resource (e.g., 'users', 'blog_posts').",
21 "pattern": "^[a-z][a-z0-9_]*$"
22 },
23 "version": {
24 "type": "integer",
25 "description": "Schema version number (starts at 1). Drives route prefix: /v{version}/...",
26 "minimum": 1
27 },
28 "db": {
29 "type": "string",
30 "description": "Named database connection for this resource (M14 multi-DB). Default: 'default'."
31 },
32 "tenant_key": {
33 "type": "string",
34 "description": "Tenant isolation key (M18). References a uuid schema field that identifies the tenant. When set, all queries are automatically scoped to the authenticated user's tenant_id claim."
35 },
36 "schema": {
37 "type": "object",
38 "description": "Field definitions keyed by field name. At least one field with primary: true is required.",
39 "minProperties": 1,
40 "additionalProperties": { "$ref": "#/$defs/FieldSchema" }
41 },
42 "endpoints": {
43 "type": "object",
44 "description": "Endpoint definitions keyed by action name (e.g., 'list', 'get', 'create', 'update', 'delete').",
45 "additionalProperties": { "$ref": "#/$defs/EndpointSpec" }
46 },
47 "relations": {
48 "type": "object",
49 "description": "Relationship definitions keyed by relation name.",
50 "additionalProperties": { "$ref": "#/$defs/RelationSpec" }
51 },
52 "indexes": {
53 "type": "array",
54 "description": "Additional database indexes.",
55 "items": { "$ref": "#/$defs/IndexSpec" }
56 }
57 },
58 "$defs": {
59 "FieldType": {
60 "type": "string",
61 "enum": ["uuid", "string", "integer", "number", "boolean", "timestamp", "date", "enum", "json", "array", "file"],
62 "description": "The data type of a field."
63 },
64 "FieldSchema": {
65 "type": "object",
66 "description": "Definition of a single field in a resource schema.",
67 "required": ["type"],
68 "additionalProperties": false,
69 "properties": {
70 "type": { "$ref": "#/$defs/FieldType" },
71 "primary": {
72 "type": "boolean",
73 "default": false,
74 "description": "Whether this field is the primary key. Exactly one field must be primary."
75 },
76 "generated": {
77 "type": "boolean",
78 "default": false,
79 "description": "Whether this field is auto-generated (e.g., uuid v4, timestamps)."
80 },
81 "required": {
82 "type": "boolean",
83 "default": false,
84 "description": "Whether this field is required (NOT NULL + validated on input)."
85 },
86 "unique": {
87 "type": "boolean",
88 "default": false,
89 "description": "Whether this field has a unique constraint."
90 },
91 "nullable": {
92 "type": "boolean",
93 "default": false,
94 "description": "Whether this field is explicitly nullable."
95 },
96 "ref": {
97 "type": "string",
98 "description": "Foreign key reference in 'resource.field' format (e.g., 'organizations.id'). Field type must be uuid.",
99 "pattern": "^[a-z][a-z0-9_]*\\.[a-z][a-z0-9_]*$"
100 },
101 "min": {
102 "description": "Minimum value (number) or minimum length (string)."
103 },
104 "max": {
105 "description": "Maximum value (number) or maximum length (string)."
106 },
107 "format": {
108 "type": "string",
109 "description": "String format validation. Only valid when type is 'string'.",
110 "enum": ["email", "url", "uuid"]
111 },
112 "values": {
113 "type": "array",
114 "items": { "type": "string" },
115 "minItems": 1,
116 "description": "Allowed values for enum-type fields. Required when type is 'enum'."
117 },
118 "default": {
119 "description": "Default value for this field."
120 },
121 "sensitive": {
122 "type": "boolean",
123 "default": false,
124 "description": "Whether this field contains sensitive data (redacted in logs)."
125 },
126 "search": {
127 "type": "boolean",
128 "default": false,
129 "description": "Whether this field is included in full-text search."
130 },
131 "items": {
132 "type": "string",
133 "description": "Element type for array fields. Required when type is 'array'.",
134 "enum": ["uuid", "string", "integer", "number", "boolean", "timestamp", "date"]
135 }
136 }
137 },
138 "HttpMethod": {
139 "type": "string",
140 "enum": ["GET", "POST", "PATCH", "PUT", "DELETE"]
141 },
142 "AuthRule": {
143 "description": "Authentication rule. Use 'public' for no auth, 'owner' for ownership check, or an array of role strings.",
144 "oneOf": [
145 { "const": "public" },
146 { "const": "owner" },
147 {
148 "type": "array",
149 "items": { "type": "string" },
150 "minItems": 1,
151 "description": "Requires JWT with one of these roles. Use 'owner' in the array to combine role + ownership check."
152 }
153 ]
154 },
155 "PaginationStyle": {
156 "type": "string",
157 "enum": ["cursor", "offset"],
158 "description": "Pagination strategy for list endpoints."
159 },
160 "CacheSpec": {
161 "type": "object",
162 "description": "Cache configuration for an endpoint.",
163 "required": ["ttl"],
164 "additionalProperties": false,
165 "properties": {
166 "ttl": {
167 "type": "integer",
168 "minimum": 1,
169 "description": "Time-to-live in seconds."
170 },
171 "invalidate_on": {
172 "type": "array",
173 "items": { "type": "string", "enum": ["create", "update", "delete"] },
174 "description": "Events that invalidate this cache."
175 }
176 }
177 },
178 "UploadSpec": {
179 "type": "object",
180 "description": "File upload configuration for an endpoint.",
181 "required": ["field", "storage", "max_size"],
182 "additionalProperties": false,
183 "properties": {
184 "field": {
185 "type": "string",
186 "description": "Schema field that stores the file URL. Must be type 'file'."
187 },
188 "storage": {
189 "type": "string",
190 "enum": ["local", "s3", "gcs", "azure"],
191 "description": "Storage backend."
192 },
193 "max_size": {
194 "type": "string",
195 "description": "Maximum file size (e.g., '5mb', '10mb').",
196 "pattern": "^[0-9]+[kmg]b$"
197 },
198 "types": {
199 "type": "array",
200 "items": { "type": "string" },
201 "description": "Allowed file extensions (e.g., ['jpg', 'png', 'pdf'])."
202 }
203 }
204 },
205 "ControllerSpec": {
206 "type": "object",
207 "description": "Controller specification for synchronous in-request business logic. Functions live in resources/<resource>.controller.rs.",
208 "additionalProperties": false,
209 "properties": {
210 "before": {
211 "description": "Function name (or list of function names) called before the DB operation. Hooks run in declaration order; first error short-circuits. Prefix any name with 'wasm:' for WASM plugins (e.g., 'wasm:./plugins/validator.wasm').",
212 "oneOf": [
213 { "type": "string" },
214 { "type": "array", "items": { "type": "string" }, "minItems": 1 }
215 ]
216 },
217 "after": {
218 "description": "Function name (or list of function names) called after the DB operation. Hooks run in declaration order; first error short-circuits. Prefix any name with 'wasm:' for WASM plugins.",
219 "oneOf": [
220 { "type": "string" },
221 { "type": "array", "items": { "type": "string" }, "minItems": 1 }
222 ]
223 }
224 }
225 },
226 "EndpointSpec": {
227 "type": "object",
228 "description": "Specification for a single endpoint in a resource.",
229 "additionalProperties": false,
230 "properties": {
231 "method": { "$ref": "#/$defs/HttpMethod" },
232 "path": {
233 "type": "string",
234 "description": "URL path pattern (e.g., '/users', '/users/:id'). Auto-prefixed with /v{version}.",
235 "pattern": "^/"
236 },
237 "auth": { "$ref": "#/$defs/AuthRule" },
238 "input": {
239 "type": "array",
240 "items": { "type": "string" },
241 "description": "Schema fields accepted as input for create/update. Each must exist in schema."
242 },
243 "filters": {
244 "type": "array",
245 "items": { "type": "string" },
246 "description": "Schema fields available as query filters. Each must exist in schema."
247 },
248 "search": {
249 "type": "array",
250 "items": { "type": "string" },
251 "description": "Schema fields included in full-text search. Each must exist in schema."
252 },
253 "pagination": { "$ref": "#/$defs/PaginationStyle" },
254 "sort": {
255 "type": "array",
256 "items": { "type": "string" },
257 "description": "Schema fields available for sorting. Each must exist in schema."
258 },
259 "cache": { "$ref": "#/$defs/CacheSpec" },
260 "controller": { "$ref": "#/$defs/ControllerSpec" },
261 "events": {
262 "type": "array",
263 "items": { "type": "string" },
264 "description": "Events to emit after successful execution (e.g., ['user.created'])."
265 },
266 "jobs": {
267 "type": "array",
268 "items": { "type": "string" },
269 "description": "Background jobs to enqueue after successful execution (e.g., ['send_welcome_email'])."
270 },
271 "upload": { "$ref": "#/$defs/UploadSpec" },
272 "soft_delete": {
273 "type": "boolean",
274 "default": false,
275 "description": "Whether this endpoint performs a soft delete. Requires a 'deleted_at' nullable timestamp field in schema."
276 }
277 }
278 },
279 "RelationType": {
280 "type": "string",
281 "enum": ["belongs_to", "has_many", "has_one"]
282 },
283 "RelationSpec": {
284 "type": "object",
285 "description": "Specification for a relationship to another resource.",
286 "required": ["resource", "type"],
287 "additionalProperties": false,
288 "properties": {
289 "resource": {
290 "type": "string",
291 "description": "Name of the related resource."
292 },
293 "type": { "$ref": "#/$defs/RelationType" },
294 "key": {
295 "type": "string",
296 "description": "Local foreign key field. Required for belongs_to."
297 },
298 "foreign_key": {
299 "type": "string",
300 "description": "Foreign key on the related resource. Required for has_many and has_one."
301 }
302 }
303 },
304 "IndexSpec": {
305 "type": "object",
306 "description": "Specification for a database index.",
307 "required": ["fields"],
308 "additionalProperties": false,
309 "properties": {
310 "fields": {
311 "type": "array",
312 "items": { "type": "string" },
313 "minItems": 1,
314 "description": "Fields included in this index. Each must exist in schema."
315 },
316 "unique": {
317 "type": "boolean",
318 "default": false,
319 "description": "Whether this is a unique index."
320 },
321 "order": {
322 "type": "string",
323 "enum": ["asc", "desc"],
324 "description": "Sort order for the index."
325 }
326 }
327 }
328 }
329 })
330}
331
332pub fn render_json_schema() -> String {
334 serde_json::to_string_pretty(&generate_resource_json_schema())
335 .expect("JSON schema serialization cannot fail")
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341
342 #[test]
343 fn schema_is_valid_json() {
344 let schema = generate_resource_json_schema();
345 assert_eq!(schema["type"], "object");
346 assert!(
347 schema["$defs"]["FieldType"]["enum"]
348 .as_array()
349 .unwrap()
350 .len()
351 == 11
352 );
353 }
354
355 #[test]
356 fn schema_requires_resource_version_schema() {
357 let schema = generate_resource_json_schema();
358 let required = schema["required"].as_array().unwrap();
359 assert!(required.contains(&json!("resource")));
360 assert!(required.contains(&json!("version")));
361 assert!(required.contains(&json!("schema")));
362 }
363
364 #[test]
365 fn schema_disallows_additional_properties() {
366 let schema = generate_resource_json_schema();
367 assert_eq!(schema["additionalProperties"], false);
368 }
369
370 #[test]
371 fn render_produces_nonempty_string() {
372 let rendered = render_json_schema();
373 assert!(rendered.len() > 1000);
374 assert!(rendered.contains("$schema"));
375 }
376}