shaperail_codegen/
validator.rs1use shaperail_core::{FieldType, ResourceDefinition};
2
3#[derive(Debug, Clone, PartialEq, Eq)]
5pub struct ValidationError {
6 pub message: String,
7}
8
9impl std::fmt::Display for ValidationError {
10 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
11 write!(f, "{}", self.message)
12 }
13}
14
15pub fn validate_resource(rd: &ResourceDefinition) -> Vec<ValidationError> {
20 let mut errors = Vec::new();
21 let res = &rd.resource;
22
23 if res.is_empty() {
25 errors.push(err("resource name must not be empty"));
26 }
27
28 if rd.version == 0 {
30 errors.push(err(&format!("resource '{res}': version must be >= 1")));
31 }
32
33 if rd.schema.is_empty() {
35 errors.push(err(&format!(
36 "resource '{res}': schema must have at least one field"
37 )));
38 }
39
40 let primary_count = rd.schema.values().filter(|f| f.primary).count();
42 if primary_count == 0 {
43 errors.push(err(&format!(
44 "resource '{res}': schema must have a primary key field"
45 )));
46 } else if primary_count > 1 {
47 errors.push(err(&format!(
48 "resource '{res}': schema must have exactly one primary key, found {primary_count}"
49 )));
50 }
51
52 for (name, field) in &rd.schema {
54 if field.field_type == FieldType::Enum && field.values.is_none() {
56 errors.push(err(&format!(
57 "resource '{res}': field '{name}' is type enum but has no values"
58 )));
59 }
60
61 if field.field_type != FieldType::Enum && field.values.is_some() {
63 errors.push(err(&format!(
64 "resource '{res}': field '{name}' has values but is not type enum"
65 )));
66 }
67
68 if field.reference.is_some() && field.field_type != FieldType::Uuid {
70 errors.push(err(&format!(
71 "resource '{res}': field '{name}' has ref but is not type uuid"
72 )));
73 }
74
75 if let Some(ref reference) = field.reference {
77 if !reference.contains('.') {
78 errors.push(err(&format!(
79 "resource '{res}': field '{name}' ref must be in 'resource.field' format, got '{reference}'"
80 )));
81 }
82 }
83
84 if field.field_type == FieldType::Array && field.items.is_none() {
86 errors.push(err(&format!(
87 "resource '{res}': field '{name}' is type array but has no items"
88 )));
89 }
90
91 if field.format.is_some() && field.field_type != FieldType::String {
93 errors.push(err(&format!(
94 "resource '{res}': field '{name}' has format but is not type string"
95 )));
96 }
97
98 if field.primary && !field.generated && !field.required {
100 errors.push(err(&format!(
101 "resource '{res}': primary key field '{name}' must be generated or required"
102 )));
103 }
104 }
105
106 if let Some(endpoints) = &rd.endpoints {
108 for (action, ep) in endpoints {
109 if let Some(hooks) = &ep.hooks {
111 for hook in hooks {
112 if hook.is_empty() {
113 errors.push(err(&format!(
114 "resource '{res}': endpoint '{action}' has an empty hook name"
115 )));
116 }
117 }
118 }
119
120 if let Some(events) = &ep.events {
121 for event in events {
122 if event.is_empty() {
123 errors.push(err(&format!(
124 "resource '{res}': endpoint '{action}' has an empty event name"
125 )));
126 }
127 }
128 }
129
130 if let Some(jobs) = &ep.jobs {
131 for job in jobs {
132 if job.is_empty() {
133 errors.push(err(&format!(
134 "resource '{res}': endpoint '{action}' has an empty job name"
135 )));
136 }
137 }
138 }
139
140 if let Some(input) = &ep.input {
142 for field_name in input {
143 if !rd.schema.contains_key(field_name) {
144 errors.push(err(&format!(
145 "resource '{res}': endpoint '{action}' input field '{field_name}' not found in schema"
146 )));
147 }
148 }
149 }
150
151 if let Some(filters) = &ep.filters {
153 for field_name in filters {
154 if !rd.schema.contains_key(field_name) {
155 errors.push(err(&format!(
156 "resource '{res}': endpoint '{action}' filter field '{field_name}' not found in schema"
157 )));
158 }
159 }
160 }
161
162 if let Some(search) = &ep.search {
164 for field_name in search {
165 if !rd.schema.contains_key(field_name) {
166 errors.push(err(&format!(
167 "resource '{res}': endpoint '{action}' search field '{field_name}' not found in schema"
168 )));
169 }
170 }
171 }
172
173 if let Some(sort) = &ep.sort {
175 for field_name in sort {
176 if !rd.schema.contains_key(field_name) {
177 errors.push(err(&format!(
178 "resource '{res}': endpoint '{action}' sort field '{field_name}' not found in schema"
179 )));
180 }
181 }
182 }
183
184 if ep.soft_delete && !rd.schema.contains_key("updated_at") {
186 errors.push(err(&format!(
187 "resource '{res}': endpoint '{action}' has soft_delete but schema has no 'updated_at' field"
188 )));
189 }
190
191 if ep.upload.is_some() {
192 errors.push(err(&format!(
193 "resource '{res}': endpoint '{action}' uses upload, but upload endpoints are not yet supported by the runtime"
194 )));
195 }
196 }
197 }
198
199 if let Some(relations) = &rd.relations {
201 for (name, rel) in relations {
202 use shaperail_core::RelationType;
203
204 if rel.relation_type == RelationType::BelongsTo && rel.key.is_none() {
206 errors.push(err(&format!(
207 "resource '{res}': relation '{name}' is belongs_to but has no key"
208 )));
209 }
210
211 if matches!(
213 rel.relation_type,
214 RelationType::HasMany | RelationType::HasOne
215 ) && rel.foreign_key.is_none()
216 {
217 errors.push(err(&format!(
218 "resource '{res}': relation '{name}' is {} but has no foreign_key",
219 rel.relation_type
220 )));
221 }
222
223 if let Some(key) = &rel.key {
225 if !rd.schema.contains_key(key) {
226 errors.push(err(&format!(
227 "resource '{res}': relation '{name}' key '{key}' not found in schema"
228 )));
229 }
230 }
231 }
232 }
233
234 if let Some(indexes) = &rd.indexes {
236 for (i, idx) in indexes.iter().enumerate() {
237 if idx.fields.is_empty() {
238 errors.push(err(&format!("resource '{res}': index {i} has no fields")));
239 }
240 for field_name in &idx.fields {
241 if !rd.schema.contains_key(field_name) {
242 errors.push(err(&format!(
243 "resource '{res}': index {i} references field '{field_name}' not in schema"
244 )));
245 }
246 }
247 if let Some(order) = &idx.order {
248 if order != "asc" && order != "desc" {
249 errors.push(err(&format!(
250 "resource '{res}': index {i} has invalid order '{order}', must be 'asc' or 'desc'"
251 )));
252 }
253 }
254 }
255 }
256
257 errors
258}
259
260fn err(message: &str) -> ValidationError {
261 ValidationError {
262 message: message.to_string(),
263 }
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269 use crate::parser::parse_resource;
270
271 #[test]
272 fn valid_resource_passes() {
273 let yaml = include_str!("../../resources/users.yaml");
274 let rd = parse_resource(yaml).unwrap();
275 let errors = validate_resource(&rd);
276 assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
277 }
278
279 #[test]
280 fn enum_without_values() {
281 let yaml = r#"
282resource: items
283version: 1
284schema:
285 id: { type: uuid, primary: true, generated: true }
286 status: { type: enum, required: true }
287"#;
288 let rd = parse_resource(yaml).unwrap();
289 let errors = validate_resource(&rd);
290 assert!(errors
291 .iter()
292 .any(|e| e.message.contains("type enum but has no values")));
293 }
294
295 #[test]
296 fn ref_field_not_uuid() {
297 let yaml = r#"
298resource: items
299version: 1
300schema:
301 id: { type: uuid, primary: true, generated: true }
302 org_id: { type: string, ref: organizations.id }
303"#;
304 let rd = parse_resource(yaml).unwrap();
305 let errors = validate_resource(&rd);
306 assert!(errors
307 .iter()
308 .any(|e| e.message.contains("has ref but is not type uuid")));
309 }
310
311 #[test]
312 fn missing_primary_key() {
313 let yaml = r#"
314resource: items
315version: 1
316schema:
317 name: { type: string, required: true }
318"#;
319 let rd = parse_resource(yaml).unwrap();
320 let errors = validate_resource(&rd);
321 assert!(errors
322 .iter()
323 .any(|e| e.message.contains("must have a primary key")));
324 }
325
326 #[test]
327 fn soft_delete_without_updated_at() {
328 let yaml = r#"
329resource: items
330version: 1
331schema:
332 id: { type: uuid, primary: true, generated: true }
333 name: { type: string, required: true }
334endpoints:
335 delete:
336 method: DELETE
337 path: /items/:id
338 auth: [admin]
339 soft_delete: true
340"#;
341 let rd = parse_resource(yaml).unwrap();
342 let errors = validate_resource(&rd);
343 assert!(errors.iter().any(|e| e
344 .message
345 .contains("soft_delete but schema has no 'updated_at'")));
346 }
347
348 #[test]
349 fn input_field_not_in_schema() {
350 let yaml = r#"
351resource: items
352version: 1
353schema:
354 id: { type: uuid, primary: true, generated: true }
355 name: { type: string, required: true }
356endpoints:
357 create:
358 method: POST
359 path: /items
360 auth: [admin]
361 input: [name, nonexistent]
362"#;
363 let rd = parse_resource(yaml).unwrap();
364 let errors = validate_resource(&rd);
365 assert!(errors.iter().any(|e| e
366 .message
367 .contains("input field 'nonexistent' not found in schema")));
368 }
369
370 #[test]
371 fn belongs_to_without_key() {
372 let yaml = r#"
373resource: items
374version: 1
375schema:
376 id: { type: uuid, primary: true, generated: true }
377relations:
378 org: { resource: organizations, type: belongs_to }
379"#;
380 let rd = parse_resource(yaml).unwrap();
381 let errors = validate_resource(&rd);
382 assert!(errors
383 .iter()
384 .any(|e| e.message.contains("belongs_to but has no key")));
385 }
386
387 #[test]
388 fn has_many_without_foreign_key() {
389 let yaml = r#"
390resource: items
391version: 1
392schema:
393 id: { type: uuid, primary: true, generated: true }
394relations:
395 orders: { resource: orders, type: has_many }
396"#;
397 let rd = parse_resource(yaml).unwrap();
398 let errors = validate_resource(&rd);
399 assert!(errors
400 .iter()
401 .any(|e| e.message.contains("has_many but has no foreign_key")));
402 }
403
404 #[test]
405 fn index_references_missing_field() {
406 let yaml = r#"
407resource: items
408version: 1
409schema:
410 id: { type: uuid, primary: true, generated: true }
411indexes:
412 - fields: [missing_field]
413"#;
414 let rd = parse_resource(yaml).unwrap();
415 let errors = validate_resource(&rd);
416 assert!(errors.iter().any(|e| e
417 .message
418 .contains("references field 'missing_field' not in schema")));
419 }
420
421 #[test]
422 fn error_message_format() {
423 let yaml = r#"
424resource: users
425version: 1
426schema:
427 id: { type: uuid, primary: true, generated: true }
428 role: { type: enum }
429"#;
430 let rd = parse_resource(yaml).unwrap();
431 let errors = validate_resource(&rd);
432 assert_eq!(
433 errors[0].message,
434 "resource 'users': field 'role' is type enum but has no values"
435 );
436 }
437
438 #[test]
439 fn upload_endpoint_not_supported() {
440 let yaml = r#"
441resource: assets
442version: 1
443schema:
444 id: { type: uuid, primary: true, generated: true }
445 file_path: { type: string, required: true }
446 updated_at: { type: timestamp, generated: true }
447endpoints:
448 upload:
449 method: POST
450 path: /assets/upload
451 upload:
452 field: file_path
453 storage: local
454 max_size: 5mb
455"#;
456 let rd = parse_resource(yaml).unwrap();
457 let errors = validate_resource(&rd);
458 assert!(errors
459 .iter()
460 .any(|e| e.message.contains("upload endpoints are not yet supported")));
461 }
462}