1use std::collections::BTreeMap;
2
3use shaperail_core::{
4 EndpointSpec, FieldSchema, FieldType, HttpMethod, PaginationStyle, ProjectConfig,
5 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 =
78 format!("/v{}{}", resource.version, ep.path().replace(":id", "{id}"));
79 let method = ep.method().to_string().to_lowercase();
80
81 let operation =
82 build_operation(&struct_name, resource, &resource.resource, action, ep);
83
84 let entry = paths
85 .entry(openapi_path)
86 .or_insert_with(BTreeMap::<String, serde_json::Value>::new);
87 entry.insert(method, operation);
88 }
89 }
90 }
91
92 let paths_value: serde_json::Value = serde_json::to_value(&paths)
94 .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new()));
95
96 serde_json::json!({
97 "openapi": "3.1.0",
98 "info": {
99 "title": config.project,
100 "version": "1.0.0"
101 },
102 "paths": paths_value,
103 "components": {
104 "schemas": serde_json::Value::Object(
105 schemas.into_iter().collect()
106 ),
107 "securitySchemes": {
108 "bearerAuth": {
109 "type": "http",
110 "scheme": "bearer",
111 "bearerFormat": "JWT"
112 },
113 "apiKeyAuth": {
114 "type": "apiKey",
115 "in": "header",
116 "name": "X-API-Key"
117 }
118 }
119 }
120 })
121}
122
123pub fn to_json(spec: &serde_json::Value) -> Result<String, serde_json::Error> {
125 serde_json::to_string_pretty(spec)
126}
127
128pub fn to_yaml(spec: &serde_json::Value) -> Result<String, serde_yaml::Error> {
130 serde_yaml::to_string(spec)
131}
132
133fn build_resource_schema(resource: &ResourceDefinition) -> serde_json::Value {
134 let mut properties = BTreeMap::new();
135 let mut required_fields = Vec::new();
136
137 for (name, schema) in &resource.schema {
138 properties.insert(name.clone(), field_schema_to_openapi(schema));
139 if schema.required && !schema.generated {
140 required_fields.push(serde_json::Value::String(name.clone()));
141 }
142 }
143
144 let mut result = serde_json::json!({
145 "type": "object",
146 "properties": serde_json::Value::Object(properties.into_iter().collect()),
147 });
148
149 if !required_fields.is_empty() {
150 result["required"] = serde_json::Value::Array(required_fields);
151 }
152
153 result
154}
155
156fn build_input_schema(
157 resource: &ResourceDefinition,
158 input_fields: &[String],
159 is_create: bool,
160) -> serde_json::Value {
161 let mut properties = BTreeMap::new();
162 let mut required_fields = Vec::new();
163
164 for field_name in input_fields {
165 if let Some(schema) = resource.schema.get(field_name) {
166 properties.insert(field_name.clone(), field_schema_to_openapi(schema));
167 if is_create && schema.required {
168 required_fields.push(serde_json::Value::String(field_name.clone()));
169 }
170 }
171 }
172
173 let mut result = serde_json::json!({
174 "type": "object",
175 "properties": serde_json::Value::Object(properties.into_iter().collect()),
176 });
177
178 if !required_fields.is_empty() {
179 result["required"] = serde_json::Value::Array(required_fields);
180 }
181
182 result
183}
184
185fn build_multipart_input_schema(
186 resource: &ResourceDefinition,
187 input_fields: &[String],
188 upload_field: &str,
189 is_create: bool,
190) -> serde_json::Value {
191 let mut properties = BTreeMap::new();
192 let mut required_fields = Vec::new();
193
194 for field_name in input_fields {
195 if let Some(schema) = resource.schema.get(field_name) {
196 let property = if field_name == upload_field {
197 serde_json::json!({
198 "type": "string",
199 "format": "binary"
200 })
201 } else {
202 field_schema_to_openapi(schema)
203 };
204
205 properties.insert(field_name.clone(), property);
206 if is_create && schema.required {
207 required_fields.push(serde_json::Value::String(field_name.clone()));
208 }
209 }
210 }
211
212 let mut result = serde_json::json!({
213 "type": "object",
214 "properties": serde_json::Value::Object(properties.into_iter().collect()),
215 });
216
217 if !required_fields.is_empty() {
218 result["required"] = serde_json::Value::Array(required_fields);
219 }
220
221 result
222}
223
224fn field_schema_to_openapi(schema: &FieldSchema) -> serde_json::Value {
225 let mut obj = BTreeMap::new();
226
227 match &schema.field_type {
228 FieldType::Uuid => {
229 obj.insert("type".to_string(), serde_json::json!("string"));
230 obj.insert("format".to_string(), serde_json::json!("uuid"));
231 }
232 FieldType::String => {
233 obj.insert("type".to_string(), serde_json::json!("string"));
234 }
235 FieldType::Integer => {
236 obj.insert("type".to_string(), serde_json::json!("integer"));
237 }
238 FieldType::Bigint => {
239 obj.insert("type".to_string(), serde_json::json!("integer"));
240 obj.insert("format".to_string(), serde_json::json!("int64"));
241 }
242 FieldType::Number => {
243 obj.insert("type".to_string(), serde_json::json!("number"));
244 }
245 FieldType::Boolean => {
246 obj.insert("type".to_string(), serde_json::json!("boolean"));
247 }
248 FieldType::Timestamp => {
249 obj.insert("type".to_string(), serde_json::json!("string"));
250 obj.insert("format".to_string(), serde_json::json!("date-time"));
251 }
252 FieldType::Date => {
253 obj.insert("type".to_string(), serde_json::json!("string"));
254 obj.insert("format".to_string(), serde_json::json!("date"));
255 }
256 FieldType::Enum => {
257 obj.insert("type".to_string(), serde_json::json!("string"));
258 if let Some(values) = &schema.values {
259 obj.insert("enum".to_string(), serde_json::json!(values));
260 }
261 }
262 FieldType::Json => {
263 obj.insert("type".to_string(), serde_json::json!("object"));
264 }
265 FieldType::Array => {
266 obj.insert("type".to_string(), serde_json::json!("array"));
267 obj.insert("items".to_string(), serde_json::json!({}));
268 }
269 FieldType::File => {
270 obj.insert("type".to_string(), serde_json::json!("string"));
271 obj.insert("format".to_string(), serde_json::json!("uri"));
272 }
273 }
274
275 if let Some(format) = &schema.format {
277 if !obj.contains_key("format") {
279 obj.insert("format".to_string(), serde_json::json!(format));
280 }
281 }
282
283 if let Some(min) = &schema.min {
285 match &schema.field_type {
286 FieldType::String => {
287 obj.insert("minLength".to_string(), min.clone());
288 }
289 FieldType::Integer | FieldType::Bigint | FieldType::Number => {
290 obj.insert("minimum".to_string(), min.clone());
291 }
292 _ => {}
293 }
294 }
295 if let Some(max) = &schema.max {
296 match &schema.field_type {
297 FieldType::String => {
298 obj.insert("maxLength".to_string(), max.clone());
299 }
300 FieldType::Integer | FieldType::Bigint | FieldType::Number => {
301 obj.insert("maximum".to_string(), max.clone());
302 }
303 _ => {}
304 }
305 }
306
307 if let Some(default) = &schema.default {
309 obj.insert("default".to_string(), default.clone());
310 }
311
312 serde_json::Value::Object(obj.into_iter().collect())
313}
314
315fn build_operation(
316 struct_name: &str,
317 resource: &ResourceDefinition,
318 resource_name: &str,
319 action: &str,
320 ep: &EndpointSpec,
321) -> serde_json::Value {
322 let mut operation = BTreeMap::new();
323
324 operation.insert(
325 "operationId".to_string(),
326 serde_json::json!(format!("{resource_name}_{action}")),
327 );
328 operation.insert("tags".to_string(), serde_json::json!([resource_name]));
329
330 let mut parameters = Vec::new();
332
333 if ep.path().contains(":id") {
335 parameters.push(serde_json::json!({
336 "name": "id",
337 "in": "path",
338 "required": true,
339 "schema": { "type": "string", "format": "uuid" }
340 }));
341 }
342
343 if let Some(filters) = &ep.filters {
345 for filter in filters {
346 parameters.push(serde_json::json!({
347 "name": format!("filter[{filter}]"),
348 "in": "query",
349 "required": false,
350 "schema": { "type": "string" },
351 "description": format!("Filter by {filter}")
352 }));
353 }
354 }
355
356 if let Some(search_fields) = &ep.search {
358 if !search_fields.is_empty() {
359 parameters.push(serde_json::json!({
360 "name": "search",
361 "in": "query",
362 "required": false,
363 "schema": { "type": "string" },
364 "description": format!("Full-text search across: {}", search_fields.join(", "))
365 }));
366 }
367 }
368
369 if ep.sort.is_some() || ep.pagination.is_some() {
371 parameters.push(serde_json::json!({
372 "name": "sort",
373 "in": "query",
374 "required": false,
375 "schema": { "type": "string" },
376 "description": "Sort fields (prefix with - for descending, e.g., -created_at,name)"
377 }));
378 }
379
380 if let Some(pagination) = &ep.pagination {
382 match pagination {
383 PaginationStyle::Cursor => {
384 parameters.push(serde_json::json!({
385 "name": "cursor",
386 "in": "query",
387 "required": false,
388 "schema": { "type": "string" },
389 "description": "Cursor for the next page"
390 }));
391 parameters.push(serde_json::json!({
392 "name": "limit",
393 "in": "query",
394 "required": false,
395 "schema": { "type": "integer", "default": 20, "minimum": 1, "maximum": 100 },
396 "description": "Number of items per page"
397 }));
398 }
399 PaginationStyle::Offset => {
400 parameters.push(serde_json::json!({
401 "name": "offset",
402 "in": "query",
403 "required": false,
404 "schema": { "type": "integer", "default": 0, "minimum": 0 },
405 "description": "Number of items to skip"
406 }));
407 parameters.push(serde_json::json!({
408 "name": "limit",
409 "in": "query",
410 "required": false,
411 "schema": { "type": "integer", "default": 20, "minimum": 1, "maximum": 100 },
412 "description": "Number of items per page"
413 }));
414 }
415 }
416 }
417
418 if *ep.method() == HttpMethod::Get {
420 parameters.push(serde_json::json!({
421 "name": "fields",
422 "in": "query",
423 "required": false,
424 "schema": { "type": "string" },
425 "description": "Comma-separated list of fields to include in response"
426 }));
427 }
428
429 if !parameters.is_empty() {
430 operation.insert(
431 "parameters".to_string(),
432 serde_json::Value::Array(parameters),
433 );
434 }
435
436 if let Some(input_fields) = &ep.input {
438 if !input_fields.is_empty() {
439 let request_body = if let Some(upload) = &ep.upload {
440 serde_json::json!({
441 "required": true,
442 "content": {
443 "multipart/form-data": {
444 "schema": build_multipart_input_schema(
445 resource,
446 input_fields,
447 &upload.field,
448 action == "create",
449 )
450 }
451 }
452 })
453 } else {
454 let input_schema_name = format!("{struct_name}{}Input", to_pascal_case(action));
455 serde_json::json!({
456 "required": true,
457 "content": {
458 "application/json": {
459 "schema": {
460 "$ref": format!("#/components/schemas/{input_schema_name}")
461 }
462 }
463 }
464 })
465 };
466
467 operation.insert("requestBody".to_string(), request_body);
468 }
469 }
470
471 let mut responses = BTreeMap::new();
473
474 let success_status = match *ep.method() {
476 HttpMethod::Post => "201",
477 HttpMethod::Delete => "204",
478 _ => "200",
479 };
480
481 if *ep.method() == HttpMethod::Delete {
482 responses.insert(
483 success_status.to_string(),
484 serde_json::json!({ "description": "Deleted successfully" }),
485 );
486 } else if ep.pagination.is_some() {
487 responses.insert(
489 success_status.to_string(),
490 serde_json::json!({
491 "description": "Successful response",
492 "content": {
493 "application/json": {
494 "schema": {
495 "type": "object",
496 "properties": {
497 "data": {
498 "type": "array",
499 "items": {
500 "$ref": format!("#/components/schemas/{struct_name}")
501 }
502 },
503 "meta": {
504 "type": "object",
505 "properties": {
506 "cursor": { "type": "string" },
507 "has_more": { "type": "boolean" },
508 "total": { "type": "integer" }
509 }
510 }
511 }
512 }
513 }
514 }
515 }),
516 );
517 } else {
518 responses.insert(
519 success_status.to_string(),
520 serde_json::json!({
521 "description": "Successful response",
522 "content": {
523 "application/json": {
524 "schema": {
525 "type": "object",
526 "properties": {
527 "data": {
528 "$ref": format!("#/components/schemas/{struct_name}")
529 }
530 }
531 }
532 }
533 }
534 }),
535 );
536 }
537
538 let error_ref = serde_json::json!({
540 "content": {
541 "application/json": {
542 "schema": {
543 "$ref": "#/components/schemas/ErrorResponse"
544 }
545 }
546 }
547 });
548
549 let mut add_error = |status: &str, description: &str| {
550 let mut resp = error_ref.clone();
551 resp["description"] = serde_json::json!(description);
552 responses.insert(status.to_string(), resp);
553 };
554
555 add_error("401", "Unauthorized");
556 add_error("403", "Forbidden");
557
558 if ep.path().contains(":id") {
559 add_error("404", "Not found");
560 }
561
562 if ep.input.is_some() {
563 add_error("422", "Validation error");
564 }
565
566 add_error("429", "Rate limited");
567 add_error("500", "Internal server error");
568
569 operation.insert(
570 "responses".to_string(),
571 serde_json::Value::Object(responses.into_iter().collect()),
572 );
573
574 if let Some(auth) = &ep.auth {
576 if !auth.is_public() {
577 operation.insert(
578 "security".to_string(),
579 serde_json::json!([
580 { "bearerAuth": [] },
581 { "apiKeyAuth": [] }
582 ]),
583 );
584 }
585 }
586
587 if let Some(controller) = &ep.controller {
589 let mut ctrl = serde_json::Map::new();
590 if let Some(before) = &controller.before {
591 ctrl.insert("before".to_string(), serde_json::json!(before));
592 }
593 if let Some(after) = &controller.after {
594 ctrl.insert("after".to_string(), serde_json::json!(after));
595 }
596 operation.insert(
597 "x-shaperail-controller".to_string(),
598 serde_json::json!(ctrl),
599 );
600 }
601 if let Some(events) = &ep.events {
602 if !events.is_empty() {
603 operation.insert("x-shaperail-events".to_string(), serde_json::json!(events));
604 }
605 }
606
607 serde_json::Value::Object(operation.into_iter().collect())
608}
609
610fn to_pascal_case(s: &str) -> String {
611 s.split('_')
612 .map(|word| {
613 let mut chars = word.chars();
614 match chars.next() {
615 None => String::new(),
616 Some(c) => {
617 let upper: String = c.to_uppercase().collect();
618 upper + &chars.as_str().to_lowercase()
619 }
620 }
621 })
622 .collect()
623}
624
625#[cfg(test)]
626mod tests {
627 use super::*;
628 use indexmap::IndexMap;
629 use shaperail_core::{
630 AuthRule, CacheSpec, FieldSchema, FieldType, HttpMethod, PaginationStyle, UploadSpec,
631 };
632
633 fn test_config() -> ProjectConfig {
634 ProjectConfig {
635 project: "test-api".to_string(),
636 port: 3000,
637 workers: shaperail_core::WorkerCount::Auto,
638 database: None,
639 databases: None,
640 cache: None,
641 auth: None,
642 storage: None,
643 logging: None,
644 events: None,
645 protocols: vec!["rest".to_string()],
646 graphql: None,
647 grpc: None,
648 }
649 }
650
651 fn sample_resource() -> ResourceDefinition {
652 let mut schema = IndexMap::new();
653 schema.insert(
654 "id".to_string(),
655 FieldSchema {
656 field_type: FieldType::Uuid,
657 primary: true,
658 generated: true,
659 required: false,
660 unique: false,
661 nullable: false,
662 reference: None,
663 min: None,
664 max: None,
665 format: None,
666 values: None,
667 default: None,
668 sensitive: false,
669 search: false,
670 items: None,
671 },
672 );
673 schema.insert(
674 "email".to_string(),
675 FieldSchema {
676 field_type: FieldType::String,
677 primary: false,
678 generated: false,
679 required: true,
680 unique: true,
681 nullable: false,
682 reference: None,
683 min: None,
684 max: None,
685 format: Some("email".to_string()),
686 values: None,
687 default: None,
688 sensitive: false,
689 search: true,
690 items: None,
691 },
692 );
693 schema.insert(
694 "name".to_string(),
695 FieldSchema {
696 field_type: FieldType::String,
697 primary: false,
698 generated: false,
699 required: true,
700 unique: false,
701 nullable: false,
702 reference: None,
703 min: Some(serde_json::json!(1)),
704 max: Some(serde_json::json!(200)),
705 format: None,
706 values: None,
707 default: None,
708 sensitive: false,
709 search: true,
710 items: None,
711 },
712 );
713 schema.insert(
714 "role".to_string(),
715 FieldSchema {
716 field_type: FieldType::Enum,
717 primary: false,
718 generated: false,
719 required: true,
720 unique: false,
721 nullable: false,
722 reference: None,
723 min: None,
724 max: None,
725 format: None,
726 values: Some(vec![
727 "admin".to_string(),
728 "member".to_string(),
729 "viewer".to_string(),
730 ]),
731 default: Some(serde_json::json!("member")),
732 sensitive: false,
733 search: false,
734 items: None,
735 },
736 );
737 schema.insert(
738 "created_at".to_string(),
739 FieldSchema {
740 field_type: FieldType::Timestamp,
741 primary: false,
742 generated: true,
743 required: false,
744 unique: false,
745 nullable: false,
746 reference: None,
747 min: None,
748 max: None,
749 format: None,
750 values: None,
751 default: None,
752 sensitive: false,
753 search: false,
754 items: None,
755 },
756 );
757
758 let mut endpoints = IndexMap::new();
759 endpoints.insert(
760 "list".to_string(),
761 EndpointSpec {
762 method: Some(HttpMethod::Get),
763 path: Some("/users".to_string()),
764 auth: Some(AuthRule::Roles(vec![
765 "member".to_string(),
766 "admin".to_string(),
767 ])),
768 input: None,
769 filters: Some(vec!["role".to_string()]),
770 search: Some(vec!["name".to_string(), "email".to_string()]),
771 pagination: Some(PaginationStyle::Cursor),
772 sort: None,
773 cache: Some(CacheSpec {
774 ttl: 60,
775 invalidate_on: None,
776 }),
777 controller: None,
778 events: None,
779 jobs: None,
780 upload: None,
781 soft_delete: false,
782 },
783 );
784 endpoints.insert(
785 "create".to_string(),
786 EndpointSpec {
787 method: Some(HttpMethod::Post),
788 path: Some("/users".to_string()),
789 auth: Some(AuthRule::Roles(vec!["admin".to_string()])),
790 input: Some(vec![
791 "email".to_string(),
792 "name".to_string(),
793 "role".to_string(),
794 ]),
795 filters: None,
796 search: None,
797 pagination: None,
798 sort: None,
799 cache: None,
800 controller: Some(shaperail_core::ControllerSpec {
801 before: Some("validate_org".to_string()),
802 after: None,
803 }),
804 events: Some(vec!["user.created".to_string()]),
805 jobs: Some(vec!["send_welcome_email".to_string()]),
806 upload: None,
807 soft_delete: false,
808 },
809 );
810 endpoints.insert(
811 "update".to_string(),
812 EndpointSpec {
813 method: Some(HttpMethod::Patch),
814 path: Some("/users/:id".to_string()),
815 auth: Some(AuthRule::Roles(vec![
816 "admin".to_string(),
817 "owner".to_string(),
818 ])),
819 input: Some(vec!["name".to_string(), "role".to_string()]),
820 filters: None,
821 search: None,
822 pagination: None,
823 sort: None,
824 cache: None,
825 controller: None,
826 events: None,
827 jobs: None,
828 upload: None,
829 soft_delete: false,
830 },
831 );
832 endpoints.insert(
833 "delete".to_string(),
834 EndpointSpec {
835 method: Some(HttpMethod::Delete),
836 path: Some("/users/:id".to_string()),
837 auth: Some(AuthRule::Roles(vec!["admin".to_string()])),
838 input: None,
839 filters: None,
840 search: None,
841 pagination: None,
842 sort: None,
843 cache: None,
844 controller: None,
845 events: None,
846 jobs: None,
847 upload: None,
848 soft_delete: true,
849 },
850 );
851
852 ResourceDefinition {
853 resource: "users".to_string(),
854 version: 1,
855 db: None,
856 tenant_key: None,
857 schema,
858 endpoints: Some(endpoints),
859 relations: None,
860 indexes: None,
861 }
862 }
863
864 fn upload_resource() -> ResourceDefinition {
865 let mut schema = IndexMap::new();
866 schema.insert(
867 "id".to_string(),
868 FieldSchema {
869 field_type: FieldType::Uuid,
870 primary: true,
871 generated: true,
872 required: false,
873 unique: false,
874 nullable: false,
875 reference: None,
876 min: None,
877 max: None,
878 format: None,
879 values: None,
880 default: None,
881 sensitive: false,
882 search: false,
883 items: None,
884 },
885 );
886 schema.insert(
887 "title".to_string(),
888 FieldSchema {
889 field_type: FieldType::String,
890 primary: false,
891 generated: false,
892 required: true,
893 unique: false,
894 nullable: false,
895 reference: None,
896 min: Some(serde_json::json!(1)),
897 max: Some(serde_json::json!(200)),
898 format: None,
899 values: None,
900 default: None,
901 sensitive: false,
902 search: false,
903 items: None,
904 },
905 );
906 schema.insert(
907 "attachment".to_string(),
908 FieldSchema {
909 field_type: FieldType::File,
910 primary: false,
911 generated: false,
912 required: true,
913 unique: false,
914 nullable: false,
915 reference: None,
916 min: None,
917 max: None,
918 format: None,
919 values: None,
920 default: None,
921 sensitive: false,
922 search: false,
923 items: None,
924 },
925 );
926
927 let mut endpoints = IndexMap::new();
928 endpoints.insert(
929 "create".to_string(),
930 EndpointSpec {
931 method: Some(HttpMethod::Post),
932 path: Some("/assets".to_string()),
933 auth: None,
934 input: Some(vec!["title".to_string(), "attachment".to_string()]),
935 filters: None,
936 search: None,
937 pagination: None,
938 sort: None,
939 cache: None,
940 controller: None,
941 events: None,
942 jobs: None,
943 upload: Some(UploadSpec {
944 field: "attachment".to_string(),
945 storage: "local".to_string(),
946 max_size: "5mb".to_string(),
947 types: Some(vec!["image/png".to_string()]),
948 }),
949 soft_delete: false,
950 },
951 );
952
953 ResourceDefinition {
954 resource: "assets".to_string(),
955 version: 1,
956 db: None,
957 tenant_key: None,
958 schema,
959 endpoints: Some(endpoints),
960 relations: None,
961 indexes: None,
962 }
963 }
964
965 #[test]
966 fn generates_valid_openapi_31_spec() {
967 let config = test_config();
968 let resources = vec![sample_resource()];
969 let spec = generate(&config, &resources);
970
971 assert_eq!(spec["openapi"], "3.1.0");
972 assert_eq!(spec["info"]["title"], "test-api");
973 assert_eq!(spec["info"]["version"], "1.0.0");
974 assert!(spec["paths"].is_object());
975 assert!(spec["components"]["schemas"].is_object());
976 assert!(spec["components"]["securitySchemes"].is_object());
977 }
978
979 #[test]
980 fn deterministic_output() {
981 let config = test_config();
982 let resources = vec![sample_resource()];
983
984 let spec1 = generate(&config, &resources);
985 let spec2 = generate(&config, &resources);
986
987 let json1 = to_json(&spec1).expect("serialize 1");
988 let json2 = to_json(&spec2).expect("serialize 2");
989
990 assert_eq!(json1, json2, "OpenAPI spec must be deterministic");
991 }
992
993 #[test]
994 fn documents_all_endpoints() {
995 let config = test_config();
996 let resources = vec![sample_resource()];
997 let spec = generate(&config, &resources);
998
999 let paths = spec["paths"].as_object().expect("paths object");
1000
1001 let users_path = paths.get("/v1/users").expect("/v1/users path");
1003 assert!(users_path.get("get").is_some(), "GET /v1/users");
1004 assert!(users_path.get("post").is_some(), "POST /v1/users");
1005
1006 let users_id_path = paths.get("/v1/users/{id}").expect("/v1/users/{{id}} path");
1008 assert!(users_id_path.get("patch").is_some(), "PATCH /users/{{id}}");
1009 assert!(
1010 users_id_path.get("delete").is_some(),
1011 "DELETE /users/{{id}}"
1012 );
1013 }
1014
1015 #[test]
1016 fn pagination_params_documented() {
1017 let config = test_config();
1018 let resources = vec![sample_resource()];
1019 let spec = generate(&config, &resources);
1020
1021 let list_op = &spec["paths"]["/v1/users"]["get"];
1022 let params = list_op["parameters"].as_array().expect("params array");
1023
1024 let param_names: Vec<&str> = params.iter().filter_map(|p| p["name"].as_str()).collect();
1025
1026 assert!(param_names.contains(&"cursor"), "cursor param");
1027 assert!(param_names.contains(&"limit"), "limit param");
1028 }
1029
1030 #[test]
1031 fn filter_params_documented() {
1032 let config = test_config();
1033 let resources = vec![sample_resource()];
1034 let spec = generate(&config, &resources);
1035
1036 let list_op = &spec["paths"]["/v1/users"]["get"];
1037 let params = list_op["parameters"].as_array().expect("params array");
1038
1039 let param_names: Vec<&str> = params.iter().filter_map(|p| p["name"].as_str()).collect();
1040
1041 assert!(param_names.contains(&"filter[role]"), "filter[role] param");
1042 }
1043
1044 #[test]
1045 fn search_param_documented() {
1046 let config = test_config();
1047 let resources = vec![sample_resource()];
1048 let spec = generate(&config, &resources);
1049
1050 let list_op = &spec["paths"]["/v1/users"]["get"];
1051 let params = list_op["parameters"].as_array().expect("params array");
1052
1053 let param_names: Vec<&str> = params.iter().filter_map(|p| p["name"].as_str()).collect();
1054
1055 assert!(param_names.contains(&"search"), "search param");
1056 }
1057
1058 #[test]
1059 fn standard_error_responses() {
1060 let config = test_config();
1061 let resources = vec![sample_resource()];
1062 let spec = generate(&config, &resources);
1063
1064 let create_op = &spec["paths"]["/v1/users"]["post"];
1066 let responses = create_op["responses"].as_object().expect("responses");
1067
1068 assert!(responses.contains_key("401"), "401 Unauthorized");
1069 assert!(responses.contains_key("403"), "403 Forbidden");
1070 assert!(responses.contains_key("422"), "422 Validation error");
1071 assert!(responses.contains_key("429"), "429 Rate limited");
1072 assert!(responses.contains_key("500"), "500 Internal server error");
1073
1074 let list_op = &spec["paths"]["/v1/users"]["get"];
1076 let list_responses = list_op["responses"].as_object().expect("responses");
1077 assert!(!list_responses.contains_key("404"), "list has no 404");
1078
1079 let update_op = &spec["paths"]["/v1/users/{id}"]["patch"];
1081 let update_responses = update_op["responses"].as_object().expect("responses");
1082 assert!(update_responses.contains_key("404"), "update has 404");
1083 }
1084
1085 #[test]
1086 fn vendor_extensions() {
1087 let config = test_config();
1088 let resources = vec![sample_resource()];
1089 let spec = generate(&config, &resources);
1090
1091 let create_op = &spec["paths"]["/v1/users"]["post"];
1092 assert_eq!(
1093 create_op["x-shaperail-controller"],
1094 serde_json::json!({"before": "validate_org"})
1095 );
1096 assert_eq!(
1097 create_op["x-shaperail-events"],
1098 serde_json::json!(["user.created"])
1099 );
1100 }
1101
1102 #[test]
1103 fn enum_values_in_schema() {
1104 let config = test_config();
1105 let resources = vec![sample_resource()];
1106 let spec = generate(&config, &resources);
1107
1108 let role_prop = &spec["components"]["schemas"]["Users"]["properties"]["role"];
1109 assert_eq!(
1110 role_prop["enum"],
1111 serde_json::json!(["admin", "member", "viewer"])
1112 );
1113 assert_eq!(role_prop["default"], serde_json::json!("member"));
1114 }
1115
1116 #[test]
1117 fn input_schemas_generated() {
1118 let config = test_config();
1119 let resources = vec![sample_resource()];
1120 let spec = generate(&config, &resources);
1121
1122 let schemas = spec["components"]["schemas"].as_object().expect("schemas");
1123 assert!(
1124 schemas.contains_key("UsersCreateInput"),
1125 "create input schema"
1126 );
1127 assert!(
1128 schemas.contains_key("UsersUpdateInput"),
1129 "update input schema"
1130 );
1131 }
1132
1133 #[test]
1134 fn request_body_references_input_schema() {
1135 let config = test_config();
1136 let resources = vec![sample_resource()];
1137 let spec = generate(&config, &resources);
1138
1139 let create_op = &spec["paths"]["/v1/users"]["post"];
1140 let schema_ref = &create_op["requestBody"]["content"]["application/json"]["schema"]["$ref"];
1141 assert_eq!(schema_ref, "#/components/schemas/UsersCreateInput");
1142 }
1143
1144 #[test]
1145 fn upload_request_body_uses_multipart_form_data() {
1146 let config = test_config();
1147 let resources = vec![upload_resource()];
1148 let spec = generate(&config, &resources);
1149
1150 let create_op = &spec["paths"]["/v1/assets"]["post"];
1151 let schema = &create_op["requestBody"]["content"]["multipart/form-data"]["schema"];
1152
1153 assert_eq!(schema["properties"]["attachment"]["type"], "string");
1154 assert_eq!(schema["properties"]["attachment"]["format"], "binary");
1155 assert_eq!(schema["properties"]["title"]["type"], "string");
1156 }
1157
1158 #[test]
1159 fn security_on_authenticated_endpoints() {
1160 let config = test_config();
1161 let resources = vec![sample_resource()];
1162 let spec = generate(&config, &resources);
1163
1164 let list_op = &spec["paths"]["/v1/users"]["get"];
1165 assert!(
1166 list_op["security"].is_array(),
1167 "auth endpoints have security"
1168 );
1169 }
1170
1171 #[test]
1172 fn string_constraints_in_schema() {
1173 let config = test_config();
1174 let resources = vec![sample_resource()];
1175 let spec = generate(&config, &resources);
1176
1177 let name_prop = &spec["components"]["schemas"]["Users"]["properties"]["name"];
1178 assert_eq!(name_prop["minLength"], 1);
1179 assert_eq!(name_prop["maxLength"], 200);
1180 }
1181
1182 #[test]
1183 fn json_and_yaml_output() {
1184 let config = test_config();
1185 let resources = vec![sample_resource()];
1186 let spec = generate(&config, &resources);
1187
1188 let json = to_json(&spec).expect("json");
1189 assert!(json.contains("\"openapi\": \"3.1.0\""));
1190
1191 let yaml = to_yaml(&spec).expect("yaml");
1192 assert!(yaml.contains("openapi: 3.1.0"));
1193 }
1194
1195 #[test]
1196 fn delete_returns_204() {
1197 let config = test_config();
1198 let resources = vec![sample_resource()];
1199 let spec = generate(&config, &resources);
1200
1201 let delete_op = &spec["paths"]["/v1/users/{id}"]["delete"];
1202 let responses = delete_op["responses"].as_object().expect("responses");
1203 assert!(responses.contains_key("204"), "delete returns 204");
1204 }
1205
1206 #[test]
1207 fn list_response_envelope() {
1208 let config = test_config();
1209 let resources = vec![sample_resource()];
1210 let spec = generate(&config, &resources);
1211
1212 let list_resp = &spec["paths"]["/v1/users"]["get"]["responses"]["200"]["content"]
1213 ["application/json"]["schema"];
1214 assert!(list_resp["properties"]["data"]["type"] == "array");
1215 assert!(list_resp["properties"]["meta"]["type"] == "object");
1216 }
1217
1218 #[test]
1219 fn error_response_schema_exists() {
1220 let config = test_config();
1221 let resources = vec![sample_resource()];
1222 let spec = generate(&config, &resources);
1223
1224 let schemas = spec["components"]["schemas"].as_object().expect("schemas");
1225 assert!(schemas.contains_key("ErrorResponse"));
1226
1227 let err = &schemas["ErrorResponse"];
1228 assert!(err["properties"]["error"]["properties"]["code"].is_object());
1229 assert!(err["properties"]["error"]["properties"]["status"].is_object());
1230 assert!(err["properties"]["error"]["properties"]["message"].is_object());
1231 }
1232}