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