1use std::collections::BTreeMap;
2
3use shaperail_core::{
4 to_brace_path, AuthRule, EndpointSpec, FieldSchema, FieldType, HttpMethod, PaginationStyle,
5 ProjectConfig, ResourceDefinition,
6};
7
8pub fn generate(config: &ProjectConfig, resources: &[ResourceDefinition]) -> serde_json::Value {
13 let mut paths = BTreeMap::new();
14 let mut schemas = BTreeMap::new();
15
16 schemas.insert(
18 "ErrorResponse".to_string(),
19 serde_json::json!({
20 "type": "object",
21 "properties": {
22 "error": {
23 "type": "object",
24 "properties": {
25 "code": { "type": "string" },
26 "status": { "type": "integer" },
27 "message": { "type": "string" },
28 "request_id": { "type": "string" },
29 "details": {
30 "type": "array",
31 "items": {
32 "type": "object",
33 "properties": {
34 "field": { "type": "string" },
35 "message": { "type": "string" }
36 }
37 }
38 }
39 },
40 "required": ["code", "status", "message"]
41 }
42 },
43 "required": ["error"]
44 }),
45 );
46
47 let mut sorted_resources: Vec<&ResourceDefinition> = resources.iter().collect();
49 sorted_resources.sort_by_key(|r| &r.resource);
50
51 for resource in sorted_resources {
52 let struct_name = to_pascal_case(&resource.resource);
53
54 schemas.insert(struct_name.clone(), build_resource_schema(resource));
56
57 if let Some(endpoints) = &resource.endpoints {
59 for (action, ep) in endpoints {
60 if let Some(input_fields) = &ep.input {
61 let input_name = format!("{struct_name}{}Input", to_pascal_case(action));
62 schemas.insert(
63 input_name,
64 build_input_schema(resource, input_fields, action == "create"),
65 );
66 }
67 }
68 }
69
70 if let Some(endpoints) = &resource.endpoints {
72 let mut sorted_endpoints: Vec<(&String, &EndpointSpec)> = endpoints.iter().collect();
74 sorted_endpoints.sort_by_key(|(name, _)| *name);
75
76 for (action, ep) in sorted_endpoints {
77 let openapi_path = format!("/v{}{}", resource.version, to_brace_path(ep.path()));
78 let method = ep.method().to_string().to_lowercase();
79
80 let operation =
81 build_operation(&struct_name, resource, &resource.resource, action, ep);
82
83 let entry = paths
84 .entry(openapi_path)
85 .or_insert_with(BTreeMap::<String, serde_json::Value>::new);
86 entry.insert(method, operation);
87 }
88 }
89 }
90
91 let paths_value: serde_json::Value = serde_json::to_value(&paths)
93 .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new()));
94
95 serde_json::json!({
96 "openapi": "3.1.0",
97 "info": {
98 "title": config.project,
99 "version": "1.0.0"
100 },
101 "paths": paths_value,
102 "components": {
103 "schemas": serde_json::Value::Object(
104 schemas.into_iter().collect()
105 ),
106 "securitySchemes": {
107 "bearerAuth": {
108 "type": "http",
109 "scheme": "bearer",
110 "bearerFormat": "JWT"
111 },
112 "apiKeyAuth": {
113 "type": "apiKey",
114 "in": "header",
115 "name": "X-API-Key"
116 }
117 }
118 }
119 })
120}
121
122pub fn to_json(spec: &serde_json::Value) -> Result<String, serde_json::Error> {
124 serde_json::to_string_pretty(spec)
125}
126
127pub fn to_yaml(spec: &serde_json::Value) -> Result<String, serde_yaml::Error> {
129 serde_yaml::to_string(spec)
130}
131
132fn build_resource_schema(resource: &ResourceDefinition) -> serde_json::Value {
133 let mut properties = BTreeMap::new();
134 let mut required_fields = Vec::new();
135
136 for (name, schema) in &resource.schema {
137 if schema.sensitive || schema.transient {
138 continue;
139 }
140 properties.insert(name.clone(), field_schema_to_openapi(schema));
141 if schema.required && !schema.generated {
142 required_fields.push(serde_json::Value::String(name.clone()));
143 }
144 }
145
146 let mut result = serde_json::json!({
147 "type": "object",
148 "properties": serde_json::Value::Object(properties.into_iter().collect()),
149 });
150
151 if !required_fields.is_empty() {
152 result["required"] = serde_json::Value::Array(required_fields);
153 }
154
155 result
156}
157
158fn build_input_schema(
159 resource: &ResourceDefinition,
160 input_fields: &[String],
161 is_create: bool,
162) -> serde_json::Value {
163 let mut properties = BTreeMap::new();
164 let mut required_fields = Vec::new();
165
166 for field_name in input_fields {
167 if let Some(schema) = resource.schema.get(field_name) {
168 properties.insert(field_name.clone(), field_schema_to_openapi(schema));
169 if is_create && schema.required {
170 required_fields.push(serde_json::Value::String(field_name.clone()));
171 }
172 }
173 }
174
175 let mut result = serde_json::json!({
176 "type": "object",
177 "properties": serde_json::Value::Object(properties.into_iter().collect()),
178 });
179
180 if !required_fields.is_empty() {
181 result["required"] = serde_json::Value::Array(required_fields);
182 }
183
184 result
185}
186
187fn build_multipart_input_schema(
188 resource: &ResourceDefinition,
189 input_fields: &[String],
190 upload_field: &str,
191 is_create: bool,
192) -> serde_json::Value {
193 let mut properties = BTreeMap::new();
194 let mut required_fields = Vec::new();
195
196 for field_name in input_fields {
197 if let Some(schema) = resource.schema.get(field_name) {
198 let property = if field_name == upload_field {
199 serde_json::json!({
200 "type": "string",
201 "format": "binary"
202 })
203 } else {
204 field_schema_to_openapi(schema)
205 };
206
207 properties.insert(field_name.clone(), property);
208 if is_create && schema.required {
209 required_fields.push(serde_json::Value::String(field_name.clone()));
210 }
211 }
212 }
213
214 let mut result = serde_json::json!({
215 "type": "object",
216 "properties": serde_json::Value::Object(properties.into_iter().collect()),
217 });
218
219 if !required_fields.is_empty() {
220 result["required"] = serde_json::Value::Array(required_fields);
221 }
222
223 result
224}
225
226fn field_schema_to_openapi(schema: &FieldSchema) -> serde_json::Value {
227 let mut obj = BTreeMap::new();
228
229 match &schema.field_type {
230 FieldType::Uuid => {
231 obj.insert("type".to_string(), serde_json::json!("string"));
232 obj.insert("format".to_string(), serde_json::json!("uuid"));
233 }
234 FieldType::String => {
235 obj.insert("type".to_string(), serde_json::json!("string"));
236 }
237 FieldType::Integer => {
238 obj.insert("type".to_string(), serde_json::json!("integer"));
239 obj.insert("format".to_string(), serde_json::json!("int64"));
240 }
241 FieldType::Number => {
242 obj.insert("type".to_string(), serde_json::json!("number"));
243 }
244 FieldType::Boolean => {
245 obj.insert("type".to_string(), serde_json::json!("boolean"));
246 }
247 FieldType::Timestamp => {
248 obj.insert("type".to_string(), serde_json::json!("string"));
249 obj.insert("format".to_string(), serde_json::json!("date-time"));
250 }
251 FieldType::Date => {
252 obj.insert("type".to_string(), serde_json::json!("string"));
253 obj.insert("format".to_string(), serde_json::json!("date"));
254 }
255 FieldType::Enum => {
256 obj.insert("type".to_string(), serde_json::json!("string"));
257 if let Some(values) = &schema.values {
258 obj.insert("enum".to_string(), serde_json::json!(values));
259 }
260 }
261 FieldType::Json => {
262 obj.insert("type".to_string(), serde_json::json!("object"));
263 }
264 FieldType::Array => {
265 obj.insert("type".to_string(), serde_json::json!("array"));
266 let items_obj = if let Some(items) = &schema.items {
267 let mut item = serde_json::Map::new();
268 let element_type = match items.field_type {
269 FieldType::String | FieldType::Enum | FieldType::Uuid | FieldType::File => {
270 "string"
271 }
272 FieldType::Integer => "integer",
273 FieldType::Number => "number",
274 FieldType::Boolean => "boolean",
275 FieldType::Timestamp | FieldType::Date => "string",
276 FieldType::Json => "object",
277 FieldType::Array => "array",
278 };
279 item.insert("type".to_string(), serde_json::json!(element_type));
280 if matches!(items.field_type, FieldType::Uuid) {
281 item.insert("format".to_string(), serde_json::json!("uuid"));
282 }
283 if matches!(items.field_type, FieldType::Timestamp) {
284 item.insert("format".to_string(), serde_json::json!("date-time"));
285 }
286 if matches!(items.field_type, FieldType::Date) {
287 item.insert("format".to_string(), serde_json::json!("date"));
288 }
289 if let Some(format) = &items.format {
290 item.insert("format".to_string(), serde_json::json!(format));
291 }
292 if let Some(values) = &items.values {
293 item.insert("enum".to_string(), serde_json::json!(values));
294 }
295 if let Some(min) = &items.min {
296 let key = if matches!(items.field_type, FieldType::String | FieldType::Enum) {
297 "minLength"
298 } else {
299 "minimum"
300 };
301 item.insert(key.to_string(), min.clone());
302 }
303 if let Some(max) = &items.max {
304 let key = if matches!(items.field_type, FieldType::String | FieldType::Enum) {
305 "maxLength"
306 } else {
307 "maximum"
308 };
309 item.insert(key.to_string(), max.clone());
310 }
311 serde_json::Value::Object(item)
312 } else {
313 serde_json::json!({})
314 };
315 obj.insert("items".to_string(), items_obj);
316 }
317 FieldType::File => {
318 obj.insert("type".to_string(), serde_json::json!("string"));
319 obj.insert("format".to_string(), serde_json::json!("uri"));
320 }
321 }
322
323 if let Some(format) = &schema.format {
325 if !obj.contains_key("format") {
327 obj.insert("format".to_string(), serde_json::json!(format));
328 }
329 }
330
331 if let Some(min) = &schema.min {
333 match &schema.field_type {
334 FieldType::String => {
335 obj.insert("minLength".to_string(), min.clone());
336 }
337 FieldType::Integer | FieldType::Number => {
338 obj.insert("minimum".to_string(), min.clone());
339 }
340 _ => {}
341 }
342 }
343 if let Some(max) = &schema.max {
344 match &schema.field_type {
345 FieldType::String => {
346 obj.insert("maxLength".to_string(), max.clone());
347 }
348 FieldType::Integer | FieldType::Number => {
349 obj.insert("maximum".to_string(), max.clone());
350 }
351 _ => {}
352 }
353 }
354
355 if let Some(default) = &schema.default {
357 obj.insert("default".to_string(), default.clone());
358 }
359
360 serde_json::Value::Object(obj.into_iter().collect())
361}
362
363fn build_operation(
364 struct_name: &str,
365 resource: &ResourceDefinition,
366 resource_name: &str,
367 action: &str,
368 ep: &EndpointSpec,
369) -> serde_json::Value {
370 let mut operation = BTreeMap::new();
371
372 operation.insert(
373 "operationId".to_string(),
374 serde_json::json!(format!("{resource_name}_{action}")),
375 );
376 operation.insert("tags".to_string(), serde_json::json!([resource_name]));
377
378 let mut parameters = Vec::new();
380
381 if ep.path().contains(":id") {
383 parameters.push(serde_json::json!({
384 "name": "id",
385 "in": "path",
386 "required": true,
387 "schema": { "type": "string", "format": "uuid" }
388 }));
389 }
390
391 if let Some(filters) = &ep.filters {
393 for filter in filters {
394 parameters.push(serde_json::json!({
395 "name": format!("filter[{filter}]"),
396 "in": "query",
397 "required": false,
398 "schema": { "type": "string" },
399 "description": format!("Filter by {filter}")
400 }));
401 }
402 }
403
404 if let Some(search_fields) = &ep.search {
406 if !search_fields.is_empty() {
407 parameters.push(serde_json::json!({
408 "name": "search",
409 "in": "query",
410 "required": false,
411 "schema": { "type": "string" },
412 "description": format!("Full-text search across: {}", search_fields.join(", "))
413 }));
414 }
415 }
416
417 if ep.sort.is_some() || ep.pagination.is_some() {
419 parameters.push(serde_json::json!({
420 "name": "sort",
421 "in": "query",
422 "required": false,
423 "schema": { "type": "string" },
424 "description": "Sort fields (prefix with - for descending, e.g., -created_at,name)"
425 }));
426 }
427
428 if let Some(pagination) = &ep.pagination {
430 match pagination {
431 PaginationStyle::Cursor => {
432 parameters.push(serde_json::json!({
433 "name": "cursor",
434 "in": "query",
435 "required": false,
436 "schema": { "type": "string" },
437 "description": "Cursor for the next page"
438 }));
439 parameters.push(serde_json::json!({
440 "name": "limit",
441 "in": "query",
442 "required": false,
443 "schema": { "type": "integer", "default": 20, "minimum": 1, "maximum": 100 },
444 "description": "Number of items per page"
445 }));
446 }
447 PaginationStyle::Offset => {
448 parameters.push(serde_json::json!({
449 "name": "offset",
450 "in": "query",
451 "required": false,
452 "schema": { "type": "integer", "default": 0, "minimum": 0 },
453 "description": "Number of items to skip"
454 }));
455 parameters.push(serde_json::json!({
456 "name": "limit",
457 "in": "query",
458 "required": false,
459 "schema": { "type": "integer", "default": 20, "minimum": 1, "maximum": 100 },
460 "description": "Number of items per page"
461 }));
462 }
463 }
464 }
465
466 if *ep.method() == HttpMethod::Get {
468 parameters.push(serde_json::json!({
469 "name": "fields",
470 "in": "query",
471 "required": false,
472 "schema": { "type": "string" },
473 "description": "Comma-separated list of fields to include in response"
474 }));
475 }
476
477 if !parameters.is_empty() {
478 operation.insert(
479 "parameters".to_string(),
480 serde_json::Value::Array(parameters),
481 );
482 }
483
484 if let Some(input_fields) = &ep.input {
486 if !input_fields.is_empty() {
487 let request_body = if let Some(upload) = &ep.upload {
488 serde_json::json!({
489 "required": true,
490 "content": {
491 "multipart/form-data": {
492 "schema": build_multipart_input_schema(
493 resource,
494 input_fields,
495 &upload.field,
496 action == "create",
497 )
498 }
499 }
500 })
501 } else {
502 let input_schema_name = format!("{struct_name}{}Input", to_pascal_case(action));
503 serde_json::json!({
504 "required": true,
505 "content": {
506 "application/json": {
507 "schema": {
508 "$ref": format!("#/components/schemas/{input_schema_name}")
509 }
510 }
511 }
512 })
513 };
514
515 operation.insert("requestBody".to_string(), request_body);
516 }
517 }
518
519 let mut responses = BTreeMap::new();
521
522 let success_status = match *ep.method() {
524 HttpMethod::Post => "201",
525 HttpMethod::Delete => "204",
526 _ => "200",
527 };
528
529 if *ep.method() == HttpMethod::Delete {
530 responses.insert(
531 success_status.to_string(),
532 serde_json::json!({ "description": "Deleted successfully" }),
533 );
534 } else if ep.pagination.is_some() {
535 responses.insert(
537 success_status.to_string(),
538 serde_json::json!({
539 "description": "Successful response",
540 "content": {
541 "application/json": {
542 "schema": {
543 "type": "object",
544 "properties": {
545 "data": {
546 "type": "array",
547 "items": {
548 "$ref": format!("#/components/schemas/{struct_name}")
549 }
550 },
551 "meta": {
552 "type": "object",
553 "properties": {
554 "cursor": { "type": "string" },
555 "has_more": { "type": "boolean" },
556 "total": { "type": "integer" }
557 }
558 }
559 }
560 }
561 }
562 }
563 }),
564 );
565 } else {
566 responses.insert(
567 success_status.to_string(),
568 serde_json::json!({
569 "description": "Successful response",
570 "content": {
571 "application/json": {
572 "schema": {
573 "type": "object",
574 "properties": {
575 "data": {
576 "$ref": format!("#/components/schemas/{struct_name}")
577 }
578 }
579 }
580 }
581 }
582 }),
583 );
584 }
585
586 let error_ref = serde_json::json!({
588 "content": {
589 "application/json": {
590 "schema": {
591 "$ref": "#/components/schemas/ErrorResponse"
592 }
593 }
594 }
595 });
596
597 let mut add_error = |status: &str, description: &str| {
598 let mut resp = error_ref.clone();
599 resp["description"] = serde_json::json!(description);
600 responses.insert(status.to_string(), resp);
601 };
602
603 add_error("401", "Unauthorized");
604 add_error("403", "Forbidden");
605
606 if ep.path().contains(":id") {
607 add_error("404", "Not found");
608 }
609
610 if ep.input.is_some() {
611 add_error("422", "Validation error");
612 }
613
614 add_error("429", "Rate limited");
615 add_error("500", "Internal server error");
616
617 operation.insert(
618 "responses".to_string(),
619 serde_json::Value::Object(responses.into_iter().collect()),
620 );
621
622 if let Some(auth) = &ep.auth {
624 if !auth.is_public() {
625 operation.insert(
626 "security".to_string(),
627 serde_json::json!([
628 { "bearerAuth": [] },
629 { "apiKeyAuth": [] }
630 ]),
631 );
632 }
633 if let AuthRule::Roles(roles) = auth {
634 if !roles.is_empty() {
635 operation.insert("x-shaperail-auth".to_string(), serde_json::json!(roles));
636 }
637 }
638 }
639
640 if let Some(controller) = &ep.controller {
642 let mut ctrl = serde_json::Map::new();
643 if let Some(before) = &controller.before {
644 ctrl.insert("before".to_string(), serde_json::json!(before));
645 }
646 if let Some(after) = &controller.after {
647 ctrl.insert("after".to_string(), serde_json::json!(after));
648 }
649 operation.insert(
650 "x-shaperail-controller".to_string(),
651 serde_json::json!(ctrl),
652 );
653 }
654 if let Some(events) = &ep.events {
655 if !events.is_empty() {
656 operation.insert("x-shaperail-events".to_string(), serde_json::json!(events));
657 }
658 }
659
660 serde_json::Value::Object(operation.into_iter().collect())
661}
662
663fn to_pascal_case(s: &str) -> String {
664 s.split('_')
665 .map(|word| {
666 let mut chars = word.chars();
667 match chars.next() {
668 None => String::new(),
669 Some(c) => {
670 let upper: String = c.to_uppercase().collect();
671 upper + &chars.as_str().to_lowercase()
672 }
673 }
674 })
675 .collect()
676}
677
678#[cfg(test)]
679mod tests {
680 use super::*;
681 use indexmap::IndexMap;
682 use shaperail_core::{
683 AuthRule, CacheSpec, FieldSchema, FieldType, HttpMethod, PaginationStyle, UploadSpec,
684 };
685
686 fn test_config() -> ProjectConfig {
687 ProjectConfig {
688 project: "test-api".to_string(),
689 port: 3000,
690 workers: shaperail_core::WorkerCount::Auto,
691 databases: None,
692 cache: None,
693 auth: None,
694 storage: None,
695 logging: None,
696 events: None,
697 protocols: vec!["rest".to_string()],
698 graphql: None,
699 grpc: None,
700 }
701 }
702
703 fn sample_resource() -> ResourceDefinition {
704 let mut schema = IndexMap::new();
705 schema.insert(
706 "id".to_string(),
707 FieldSchema {
708 field_type: FieldType::Uuid,
709 primary: true,
710 generated: true,
711 required: false,
712 unique: false,
713 nullable: false,
714 reference: None,
715 min: None,
716 max: None,
717 format: None,
718 values: None,
719 default: None,
720 sensitive: false,
721 search: false,
722 items: None,
723 transient: false,
724 },
725 );
726 schema.insert(
727 "email".to_string(),
728 FieldSchema {
729 field_type: FieldType::String,
730 primary: false,
731 generated: false,
732 required: true,
733 unique: true,
734 nullable: false,
735 reference: None,
736 min: None,
737 max: None,
738 format: Some("email".to_string()),
739 values: None,
740 default: None,
741 sensitive: false,
742 search: true,
743 items: None,
744 transient: false,
745 },
746 );
747 schema.insert(
748 "name".to_string(),
749 FieldSchema {
750 field_type: FieldType::String,
751 primary: false,
752 generated: false,
753 required: true,
754 unique: false,
755 nullable: false,
756 reference: None,
757 min: Some(serde_json::json!(1)),
758 max: Some(serde_json::json!(200)),
759 format: None,
760 values: None,
761 default: None,
762 sensitive: false,
763 search: true,
764 items: None,
765 transient: false,
766 },
767 );
768 schema.insert(
769 "role".to_string(),
770 FieldSchema {
771 field_type: FieldType::Enum,
772 primary: false,
773 generated: false,
774 required: true,
775 unique: false,
776 nullable: false,
777 reference: None,
778 min: None,
779 max: None,
780 format: None,
781 values: Some(vec![
782 "admin".to_string(),
783 "member".to_string(),
784 "viewer".to_string(),
785 ]),
786 default: Some(serde_json::json!("member")),
787 sensitive: false,
788 search: false,
789 items: None,
790 transient: false,
791 },
792 );
793 schema.insert(
794 "created_at".to_string(),
795 FieldSchema {
796 field_type: FieldType::Timestamp,
797 primary: false,
798 generated: true,
799 required: false,
800 unique: false,
801 nullable: false,
802 reference: None,
803 min: None,
804 max: None,
805 format: None,
806 values: None,
807 default: None,
808 sensitive: false,
809 search: false,
810 items: None,
811 transient: false,
812 },
813 );
814
815 let mut endpoints = IndexMap::new();
816 endpoints.insert(
817 "list".to_string(),
818 EndpointSpec {
819 method: Some(HttpMethod::Get),
820 path: Some("/users".to_string()),
821 auth: Some(AuthRule::Roles(vec![
822 "member".to_string(),
823 "admin".to_string(),
824 ])),
825 filters: Some(vec!["role".to_string()]),
826 search: Some(vec!["name".to_string(), "email".to_string()]),
827 pagination: Some(PaginationStyle::Cursor),
828 cache: Some(CacheSpec {
829 ttl: 60,
830 invalidate_on: None,
831 }),
832 ..Default::default()
833 },
834 );
835 endpoints.insert(
836 "create".to_string(),
837 EndpointSpec {
838 method: Some(HttpMethod::Post),
839 path: Some("/users".to_string()),
840 auth: Some(AuthRule::Roles(vec!["admin".to_string()])),
841 input: Some(vec![
842 "email".to_string(),
843 "name".to_string(),
844 "role".to_string(),
845 ]),
846 controller: Some(shaperail_core::ControllerSpec {
847 before: Some(shaperail_core::HookList::Single("validate_org".to_string())),
848 after: None,
849 }),
850 events: Some(vec!["user.created".to_string()]),
851 jobs: Some(vec!["send_welcome_email".to_string()]),
852 ..Default::default()
853 },
854 );
855 endpoints.insert(
856 "update".to_string(),
857 EndpointSpec {
858 method: Some(HttpMethod::Patch),
859 path: Some("/users/:id".to_string()),
860 auth: Some(AuthRule::Roles(vec![
861 "admin".to_string(),
862 "owner".to_string(),
863 ])),
864 input: Some(vec!["name".to_string(), "role".to_string()]),
865 ..Default::default()
866 },
867 );
868 endpoints.insert(
869 "delete".to_string(),
870 EndpointSpec {
871 method: Some(HttpMethod::Delete),
872 path: Some("/users/:id".to_string()),
873 auth: Some(AuthRule::Roles(vec!["admin".to_string()])),
874 soft_delete: true,
875 ..Default::default()
876 },
877 );
878
879 ResourceDefinition {
880 resource: "users".to_string(),
881 version: 1,
882 db: None,
883 tenant_key: None,
884 schema,
885 endpoints: Some(endpoints),
886 relations: None,
887 indexes: None,
888 }
889 }
890
891 fn upload_resource() -> ResourceDefinition {
892 let mut schema = IndexMap::new();
893 schema.insert(
894 "id".to_string(),
895 FieldSchema {
896 field_type: FieldType::Uuid,
897 primary: true,
898 generated: true,
899 required: false,
900 unique: false,
901 nullable: false,
902 reference: None,
903 min: None,
904 max: None,
905 format: None,
906 values: None,
907 default: None,
908 sensitive: false,
909 search: false,
910 items: None,
911 transient: false,
912 },
913 );
914 schema.insert(
915 "title".to_string(),
916 FieldSchema {
917 field_type: FieldType::String,
918 primary: false,
919 generated: false,
920 required: true,
921 unique: false,
922 nullable: false,
923 reference: None,
924 min: Some(serde_json::json!(1)),
925 max: Some(serde_json::json!(200)),
926 format: None,
927 values: None,
928 default: None,
929 sensitive: false,
930 search: false,
931 items: None,
932 transient: false,
933 },
934 );
935 schema.insert(
936 "attachment".to_string(),
937 FieldSchema {
938 field_type: FieldType::File,
939 primary: false,
940 generated: false,
941 required: true,
942 unique: false,
943 nullable: false,
944 reference: None,
945 min: None,
946 max: None,
947 format: None,
948 values: None,
949 default: None,
950 sensitive: false,
951 search: false,
952 items: None,
953 transient: false,
954 },
955 );
956
957 let mut endpoints = IndexMap::new();
958 endpoints.insert(
959 "create".to_string(),
960 EndpointSpec {
961 method: Some(HttpMethod::Post),
962 path: Some("/assets".to_string()),
963 input: Some(vec!["title".to_string(), "attachment".to_string()]),
964 upload: Some(UploadSpec {
965 field: "attachment".to_string(),
966 storage: "local".to_string(),
967 max_size: "5mb".to_string(),
968 types: Some(vec!["image/png".to_string()]),
969 }),
970 ..Default::default()
971 },
972 );
973
974 ResourceDefinition {
975 resource: "assets".to_string(),
976 version: 1,
977 db: None,
978 tenant_key: None,
979 schema,
980 endpoints: Some(endpoints),
981 relations: None,
982 indexes: None,
983 }
984 }
985
986 #[test]
987 fn generates_valid_openapi_31_spec() {
988 let config = test_config();
989 let resources = vec![sample_resource()];
990 let spec = generate(&config, &resources);
991
992 assert_eq!(spec["openapi"], "3.1.0");
993 assert_eq!(spec["info"]["title"], "test-api");
994 assert_eq!(spec["info"]["version"], "1.0.0");
995 assert!(spec["paths"].is_object());
996 assert!(spec["components"]["schemas"].is_object());
997 assert!(spec["components"]["securitySchemes"].is_object());
998 }
999
1000 #[test]
1001 fn sensitive_field_omitted_from_response_schema() {
1002 let config = test_config();
1003 let mut resource = sample_resource();
1004 resource.schema.get_mut("email").unwrap().sensitive = true;
1005 let spec = generate(&config, &[resource]);
1006
1007 let users_props = spec["components"]["schemas"]["Users"]["properties"]
1008 .as_object()
1009 .expect("Users response schema should have properties");
1010 assert!(
1011 !users_props.contains_key("email"),
1012 "sensitive `email` must be omitted from response schema, got: {users_props:?}"
1013 );
1014 assert!(
1015 users_props.contains_key("name"),
1016 "non-sensitive fields must remain in response schema"
1017 );
1018
1019 let input_props = spec["components"]["schemas"]["UsersCreateInput"]["properties"]
1021 .as_object()
1022 .expect("UsersCreateInput should exist");
1023 assert!(
1024 input_props.contains_key("email"),
1025 "sensitive fields must remain in request schemas when declared in input:"
1026 );
1027 }
1028
1029 #[test]
1030 fn deterministic_output() {
1031 let config = test_config();
1032 let resources = vec![sample_resource()];
1033
1034 let spec1 = generate(&config, &resources);
1035 let spec2 = generate(&config, &resources);
1036
1037 let json1 = to_json(&spec1).expect("serialize 1");
1038 let json2 = to_json(&spec2).expect("serialize 2");
1039
1040 assert_eq!(json1, json2, "OpenAPI spec must be deterministic");
1041 }
1042
1043 #[test]
1044 fn documents_all_endpoints() {
1045 let config = test_config();
1046 let resources = vec![sample_resource()];
1047 let spec = generate(&config, &resources);
1048
1049 let paths = spec["paths"].as_object().expect("paths object");
1050
1051 let users_path = paths.get("/v1/users").expect("/v1/users path");
1053 assert!(users_path.get("get").is_some(), "GET /v1/users");
1054 assert!(users_path.get("post").is_some(), "POST /v1/users");
1055
1056 let users_id_path = paths.get("/v1/users/{id}").expect("/v1/users/{{id}} path");
1058 assert!(users_id_path.get("patch").is_some(), "PATCH /users/{{id}}");
1059 assert!(
1060 users_id_path.get("delete").is_some(),
1061 "DELETE /users/{{id}}"
1062 );
1063 }
1064
1065 #[test]
1066 fn pagination_params_documented() {
1067 let config = test_config();
1068 let resources = vec![sample_resource()];
1069 let spec = generate(&config, &resources);
1070
1071 let list_op = &spec["paths"]["/v1/users"]["get"];
1072 let params = list_op["parameters"].as_array().expect("params array");
1073
1074 let param_names: Vec<&str> = params.iter().filter_map(|p| p["name"].as_str()).collect();
1075
1076 assert!(param_names.contains(&"cursor"), "cursor param");
1077 assert!(param_names.contains(&"limit"), "limit param");
1078 }
1079
1080 #[test]
1081 fn filter_params_documented() {
1082 let config = test_config();
1083 let resources = vec![sample_resource()];
1084 let spec = generate(&config, &resources);
1085
1086 let list_op = &spec["paths"]["/v1/users"]["get"];
1087 let params = list_op["parameters"].as_array().expect("params array");
1088
1089 let param_names: Vec<&str> = params.iter().filter_map(|p| p["name"].as_str()).collect();
1090
1091 assert!(param_names.contains(&"filter[role]"), "filter[role] param");
1092 }
1093
1094 #[test]
1095 fn search_param_documented() {
1096 let config = test_config();
1097 let resources = vec![sample_resource()];
1098 let spec = generate(&config, &resources);
1099
1100 let list_op = &spec["paths"]["/v1/users"]["get"];
1101 let params = list_op["parameters"].as_array().expect("params array");
1102
1103 let param_names: Vec<&str> = params.iter().filter_map(|p| p["name"].as_str()).collect();
1104
1105 assert!(param_names.contains(&"search"), "search param");
1106 }
1107
1108 #[test]
1109 fn standard_error_responses() {
1110 let config = test_config();
1111 let resources = vec![sample_resource()];
1112 let spec = generate(&config, &resources);
1113
1114 let create_op = &spec["paths"]["/v1/users"]["post"];
1116 let responses = create_op["responses"].as_object().expect("responses");
1117
1118 assert!(responses.contains_key("401"), "401 Unauthorized");
1119 assert!(responses.contains_key("403"), "403 Forbidden");
1120 assert!(responses.contains_key("422"), "422 Validation error");
1121 assert!(responses.contains_key("429"), "429 Rate limited");
1122 assert!(responses.contains_key("500"), "500 Internal server error");
1123
1124 let list_op = &spec["paths"]["/v1/users"]["get"];
1126 let list_responses = list_op["responses"].as_object().expect("responses");
1127 assert!(!list_responses.contains_key("404"), "list has no 404");
1128
1129 let update_op = &spec["paths"]["/v1/users/{id}"]["patch"];
1131 let update_responses = update_op["responses"].as_object().expect("responses");
1132 assert!(update_responses.contains_key("404"), "update has 404");
1133 }
1134
1135 #[test]
1136 fn vendor_extensions() {
1137 let config = test_config();
1138 let resources = vec![sample_resource()];
1139 let spec = generate(&config, &resources);
1140
1141 let create_op = &spec["paths"]["/v1/users"]["post"];
1142 assert_eq!(
1143 create_op["x-shaperail-controller"],
1144 serde_json::json!({"before": "validate_org"})
1145 );
1146 assert_eq!(
1147 create_op["x-shaperail-events"],
1148 serde_json::json!(["user.created"])
1149 );
1150 }
1151
1152 #[test]
1153 fn x_shaperail_auth_emitted_for_role_endpoints() {
1154 let config = test_config();
1155 let resources = vec![sample_resource()];
1156 let spec = generate(&config, &resources);
1157
1158 let delete_op = &spec["paths"]["/v1/users/{id}"]["delete"];
1160 assert_eq!(
1161 delete_op["x-shaperail-auth"],
1162 serde_json::json!(["admin"]),
1163 "delete op must carry x-shaperail-auth"
1164 );
1165
1166 let list_op = &spec["paths"]["/v1/users"]["get"];
1168 assert_eq!(
1169 list_op["x-shaperail-auth"],
1170 serde_json::json!(["member", "admin"]),
1171 "list op must carry x-shaperail-auth"
1172 );
1173 }
1174
1175 #[test]
1176 fn x_shaperail_auth_omitted_for_public_endpoints() {
1177 let config = test_config();
1178 let yaml = "resource: agents\nversion: 1\nschema:\n id: {type: uuid, primary: true, generated: true}\n name: {type: string, required: true}\nendpoints:\n list:\n auth: public\n";
1180 let rd = crate::parser::parse_resource(yaml).unwrap();
1181 let spec = generate(&config, &[rd]);
1182
1183 let list_op = &spec["paths"]["/v1/agents"]["get"];
1184 assert!(
1185 list_op.get("x-shaperail-auth").is_none()
1186 || list_op["x-shaperail-auth"] == serde_json::Value::Null,
1187 "public endpoints must NOT carry x-shaperail-auth"
1188 );
1189 }
1190
1191 #[test]
1192 fn enum_values_in_schema() {
1193 let config = test_config();
1194 let resources = vec![sample_resource()];
1195 let spec = generate(&config, &resources);
1196
1197 let role_prop = &spec["components"]["schemas"]["Users"]["properties"]["role"];
1198 assert_eq!(
1199 role_prop["enum"],
1200 serde_json::json!(["admin", "member", "viewer"])
1201 );
1202 assert_eq!(role_prop["default"], serde_json::json!("member"));
1203 }
1204
1205 #[test]
1206 fn input_schemas_generated() {
1207 let config = test_config();
1208 let resources = vec![sample_resource()];
1209 let spec = generate(&config, &resources);
1210
1211 let schemas = spec["components"]["schemas"].as_object().expect("schemas");
1212 assert!(
1213 schemas.contains_key("UsersCreateInput"),
1214 "create input schema"
1215 );
1216 assert!(
1217 schemas.contains_key("UsersUpdateInput"),
1218 "update input schema"
1219 );
1220 }
1221
1222 #[test]
1223 fn request_body_references_input_schema() {
1224 let config = test_config();
1225 let resources = vec![sample_resource()];
1226 let spec = generate(&config, &resources);
1227
1228 let create_op = &spec["paths"]["/v1/users"]["post"];
1229 let schema_ref = &create_op["requestBody"]["content"]["application/json"]["schema"]["$ref"];
1230 assert_eq!(schema_ref, "#/components/schemas/UsersCreateInput");
1231 }
1232
1233 #[test]
1234 fn upload_request_body_uses_multipart_form_data() {
1235 let config = test_config();
1236 let resources = vec![upload_resource()];
1237 let spec = generate(&config, &resources);
1238
1239 let create_op = &spec["paths"]["/v1/assets"]["post"];
1240 let schema = &create_op["requestBody"]["content"]["multipart/form-data"]["schema"];
1241
1242 assert_eq!(schema["properties"]["attachment"]["type"], "string");
1243 assert_eq!(schema["properties"]["attachment"]["format"], "binary");
1244 assert_eq!(schema["properties"]["title"]["type"], "string");
1245 }
1246
1247 #[test]
1248 fn security_on_authenticated_endpoints() {
1249 let config = test_config();
1250 let resources = vec![sample_resource()];
1251 let spec = generate(&config, &resources);
1252
1253 let list_op = &spec["paths"]["/v1/users"]["get"];
1254 assert!(
1255 list_op["security"].is_array(),
1256 "auth endpoints have security"
1257 );
1258 }
1259
1260 #[test]
1261 fn string_constraints_in_schema() {
1262 let config = test_config();
1263 let resources = vec![sample_resource()];
1264 let spec = generate(&config, &resources);
1265
1266 let name_prop = &spec["components"]["schemas"]["Users"]["properties"]["name"];
1267 assert_eq!(name_prop["minLength"], 1);
1268 assert_eq!(name_prop["maxLength"], 200);
1269 }
1270
1271 #[test]
1272 fn json_and_yaml_output() {
1273 let config = test_config();
1274 let resources = vec![sample_resource()];
1275 let spec = generate(&config, &resources);
1276
1277 let json = to_json(&spec).expect("json");
1278 assert!(json.contains("\"openapi\": \"3.1.0\""));
1279
1280 let yaml = to_yaml(&spec).expect("yaml");
1281 assert!(yaml.contains("openapi: 3.1.0"));
1282 }
1283
1284 #[test]
1285 fn delete_returns_204() {
1286 let config = test_config();
1287 let resources = vec![sample_resource()];
1288 let spec = generate(&config, &resources);
1289
1290 let delete_op = &spec["paths"]["/v1/users/{id}"]["delete"];
1291 let responses = delete_op["responses"].as_object().expect("responses");
1292 assert!(responses.contains_key("204"), "delete returns 204");
1293 }
1294
1295 #[test]
1296 fn list_response_envelope() {
1297 let config = test_config();
1298 let resources = vec![sample_resource()];
1299 let spec = generate(&config, &resources);
1300
1301 let list_resp = &spec["paths"]["/v1/users"]["get"]["responses"]["200"]["content"]
1302 ["application/json"]["schema"];
1303 assert!(list_resp["properties"]["data"]["type"] == "array");
1304 assert!(list_resp["properties"]["meta"]["type"] == "object");
1305 }
1306
1307 #[test]
1308 fn error_response_schema_exists() {
1309 let config = test_config();
1310 let resources = vec![sample_resource()];
1311 let spec = generate(&config, &resources);
1312
1313 let schemas = spec["components"]["schemas"].as_object().expect("schemas");
1314 assert!(schemas.contains_key("ErrorResponse"));
1315
1316 let err = &schemas["ErrorResponse"];
1317 assert!(err["properties"]["error"]["properties"]["code"].is_object());
1318 assert!(err["properties"]["error"]["properties"]["status"].is_object());
1319 assert!(err["properties"]["error"]["properties"]["message"].is_object());
1320 }
1321
1322 #[test]
1323 fn to_pascal_case_converts_snake_case() {
1324 assert_eq!(to_pascal_case("users"), "Users");
1325 assert_eq!(to_pascal_case("blog_posts"), "BlogPosts");
1326 assert_eq!(
1327 to_pascal_case("user_profile_settings"),
1328 "UserProfileSettings"
1329 );
1330 assert_eq!(to_pascal_case(""), "");
1331 assert_eq!(to_pascal_case("single"), "Single");
1332 }
1333
1334 #[test]
1335 fn generate_with_no_resources_still_valid() {
1336 let config = test_config();
1337 let spec = generate(&config, &[]);
1338 assert_eq!(spec["openapi"], "3.1.0");
1339 let paths = spec["paths"].as_object().expect("paths object");
1340 assert!(paths.is_empty(), "No paths for empty resources");
1341 let schemas = spec["components"]["schemas"].as_object().unwrap();
1343 assert!(schemas.contains_key("ErrorResponse"));
1344 }
1345
1346 #[test]
1347 fn generate_is_sorted_by_resource_name() {
1348 let config = test_config();
1350 let mut b_resource = sample_resource();
1351 b_resource.resource = "zorders".to_string();
1352 b_resource.endpoints = None;
1353 let mut a_resource = sample_resource();
1354 a_resource.resource = "aaccounts".to_string();
1355 a_resource.endpoints = None;
1356
1357 let spec = generate(&config, &[b_resource, a_resource]);
1358 let schemas = spec["components"]["schemas"].as_object().unwrap();
1359 let mut resource_keys: Vec<&str> = schemas
1360 .keys()
1361 .map(|k| k.as_str())
1362 .filter(|k| !k.contains("Input") && *k != "ErrorResponse")
1363 .collect();
1364 resource_keys.sort();
1365
1366 assert!(
1368 schemas.contains_key("Aaccounts"),
1369 "Expected Aaccounts schema"
1370 );
1371 assert!(schemas.contains_key("Zorders"), "Expected Zorders schema");
1372 let pos_a = resource_keys
1373 .iter()
1374 .position(|&k| k == "Aaccounts")
1375 .unwrap();
1376 let pos_z = resource_keys.iter().position(|&k| k == "Zorders").unwrap();
1377 assert!(
1378 pos_a < pos_z,
1379 "Aaccounts ({pos_a}) must come before Zorders ({pos_z})"
1380 );
1381 }
1382
1383 #[test]
1384 fn openapi_array_items_emits_element_constraints() {
1385 let mut items = shaperail_core::ItemsSpec::of(shaperail_core::FieldType::String);
1386 items.min = Some(serde_json::json!(3));
1387 items.max = Some(serde_json::json!(3));
1388
1389 let mut schema = IndexMap::new();
1390 schema.insert(
1391 "currencies".to_string(),
1392 shaperail_core::FieldSchema {
1393 field_type: shaperail_core::FieldType::Array,
1394 items: Some(items),
1395 primary: false,
1396 generated: false,
1397 required: false,
1398 unique: false,
1399 nullable: false,
1400 reference: None,
1401 min: None,
1402 max: None,
1403 format: None,
1404 values: None,
1405 default: None,
1406 sensitive: false,
1407 search: false,
1408 transient: false,
1409 },
1410 );
1411 let resource = shaperail_core::ResourceDefinition {
1412 resource: "vendors".to_string(),
1413 version: 1,
1414 db: None,
1415 tenant_key: None,
1416 schema,
1417 endpoints: None,
1418 relations: None,
1419 indexes: None,
1420 };
1421
1422 let config = test_config();
1423 let spec = generate(&config, &[resource]);
1424 let json = serde_json::to_string(&spec).unwrap();
1425 assert!(
1426 json.contains("\"minLength\":3"),
1427 "minLength missing: {json}"
1428 );
1429 assert!(
1430 json.contains("\"maxLength\":3"),
1431 "maxLength missing: {json}"
1432 );
1433 }
1434}