1use std::collections::BTreeMap;
2
3use shaperail_core::{
4 AuthRule, 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 if let AuthRule::Roles(roles) = auth {
589 if !roles.is_empty() {
590 operation.insert("x-shaperail-auth".to_string(), serde_json::json!(roles));
591 }
592 }
593 }
594
595 if let Some(controller) = &ep.controller {
597 let mut ctrl = serde_json::Map::new();
598 if let Some(before) = &controller.before {
599 ctrl.insert("before".to_string(), serde_json::json!(before));
600 }
601 if let Some(after) = &controller.after {
602 ctrl.insert("after".to_string(), serde_json::json!(after));
603 }
604 operation.insert(
605 "x-shaperail-controller".to_string(),
606 serde_json::json!(ctrl),
607 );
608 }
609 if let Some(events) = &ep.events {
610 if !events.is_empty() {
611 operation.insert("x-shaperail-events".to_string(), serde_json::json!(events));
612 }
613 }
614
615 serde_json::Value::Object(operation.into_iter().collect())
616}
617
618fn to_pascal_case(s: &str) -> String {
619 s.split('_')
620 .map(|word| {
621 let mut chars = word.chars();
622 match chars.next() {
623 None => String::new(),
624 Some(c) => {
625 let upper: String = c.to_uppercase().collect();
626 upper + &chars.as_str().to_lowercase()
627 }
628 }
629 })
630 .collect()
631}
632
633#[cfg(test)]
634mod tests {
635 use super::*;
636 use indexmap::IndexMap;
637 use shaperail_core::{
638 AuthRule, CacheSpec, FieldSchema, FieldType, HttpMethod, PaginationStyle, UploadSpec,
639 };
640
641 fn test_config() -> ProjectConfig {
642 ProjectConfig {
643 project: "test-api".to_string(),
644 port: 3000,
645 workers: shaperail_core::WorkerCount::Auto,
646 databases: None,
647 cache: None,
648 auth: None,
649 storage: None,
650 logging: None,
651 events: None,
652 protocols: vec!["rest".to_string()],
653 graphql: None,
654 grpc: None,
655 }
656 }
657
658 fn sample_resource() -> ResourceDefinition {
659 let mut schema = IndexMap::new();
660 schema.insert(
661 "id".to_string(),
662 FieldSchema {
663 field_type: FieldType::Uuid,
664 primary: true,
665 generated: true,
666 required: false,
667 unique: false,
668 nullable: false,
669 reference: None,
670 min: None,
671 max: None,
672 format: None,
673 values: None,
674 default: None,
675 sensitive: false,
676 search: false,
677 items: None,
678 transient: false,
679 },
680 );
681 schema.insert(
682 "email".to_string(),
683 FieldSchema {
684 field_type: FieldType::String,
685 primary: false,
686 generated: false,
687 required: true,
688 unique: true,
689 nullable: false,
690 reference: None,
691 min: None,
692 max: None,
693 format: Some("email".to_string()),
694 values: None,
695 default: None,
696 sensitive: false,
697 search: true,
698 items: None,
699 transient: false,
700 },
701 );
702 schema.insert(
703 "name".to_string(),
704 FieldSchema {
705 field_type: FieldType::String,
706 primary: false,
707 generated: false,
708 required: true,
709 unique: false,
710 nullable: false,
711 reference: None,
712 min: Some(serde_json::json!(1)),
713 max: Some(serde_json::json!(200)),
714 format: None,
715 values: None,
716 default: None,
717 sensitive: false,
718 search: true,
719 items: None,
720 transient: false,
721 },
722 );
723 schema.insert(
724 "role".to_string(),
725 FieldSchema {
726 field_type: FieldType::Enum,
727 primary: false,
728 generated: false,
729 required: true,
730 unique: false,
731 nullable: false,
732 reference: None,
733 min: None,
734 max: None,
735 format: None,
736 values: Some(vec![
737 "admin".to_string(),
738 "member".to_string(),
739 "viewer".to_string(),
740 ]),
741 default: Some(serde_json::json!("member")),
742 sensitive: false,
743 search: false,
744 items: None,
745 transient: false,
746 },
747 );
748 schema.insert(
749 "created_at".to_string(),
750 FieldSchema {
751 field_type: FieldType::Timestamp,
752 primary: false,
753 generated: true,
754 required: false,
755 unique: false,
756 nullable: false,
757 reference: None,
758 min: None,
759 max: None,
760 format: None,
761 values: None,
762 default: None,
763 sensitive: false,
764 search: false,
765 items: None,
766 transient: false,
767 },
768 );
769
770 let mut endpoints = IndexMap::new();
771 endpoints.insert(
772 "list".to_string(),
773 EndpointSpec {
774 method: Some(HttpMethod::Get),
775 path: Some("/users".to_string()),
776 auth: Some(AuthRule::Roles(vec![
777 "member".to_string(),
778 "admin".to_string(),
779 ])),
780 filters: Some(vec!["role".to_string()]),
781 search: Some(vec!["name".to_string(), "email".to_string()]),
782 pagination: Some(PaginationStyle::Cursor),
783 cache: Some(CacheSpec {
784 ttl: 60,
785 invalidate_on: None,
786 }),
787 ..Default::default()
788 },
789 );
790 endpoints.insert(
791 "create".to_string(),
792 EndpointSpec {
793 method: Some(HttpMethod::Post),
794 path: Some("/users".to_string()),
795 auth: Some(AuthRule::Roles(vec!["admin".to_string()])),
796 input: Some(vec![
797 "email".to_string(),
798 "name".to_string(),
799 "role".to_string(),
800 ]),
801 controller: Some(shaperail_core::ControllerSpec {
802 before: Some("validate_org".to_string()),
803 after: None,
804 }),
805 events: Some(vec!["user.created".to_string()]),
806 jobs: Some(vec!["send_welcome_email".to_string()]),
807 ..Default::default()
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 ..Default::default()
821 },
822 );
823 endpoints.insert(
824 "delete".to_string(),
825 EndpointSpec {
826 method: Some(HttpMethod::Delete),
827 path: Some("/users/:id".to_string()),
828 auth: Some(AuthRule::Roles(vec!["admin".to_string()])),
829 soft_delete: true,
830 ..Default::default()
831 },
832 );
833
834 ResourceDefinition {
835 resource: "users".to_string(),
836 version: 1,
837 db: None,
838 tenant_key: None,
839 schema,
840 endpoints: Some(endpoints),
841 relations: None,
842 indexes: None,
843 }
844 }
845
846 fn upload_resource() -> ResourceDefinition {
847 let mut schema = IndexMap::new();
848 schema.insert(
849 "id".to_string(),
850 FieldSchema {
851 field_type: FieldType::Uuid,
852 primary: true,
853 generated: true,
854 required: false,
855 unique: false,
856 nullable: false,
857 reference: None,
858 min: None,
859 max: None,
860 format: None,
861 values: None,
862 default: None,
863 sensitive: false,
864 search: false,
865 items: None,
866 transient: false,
867 },
868 );
869 schema.insert(
870 "title".to_string(),
871 FieldSchema {
872 field_type: FieldType::String,
873 primary: false,
874 generated: false,
875 required: true,
876 unique: false,
877 nullable: false,
878 reference: None,
879 min: Some(serde_json::json!(1)),
880 max: Some(serde_json::json!(200)),
881 format: None,
882 values: None,
883 default: None,
884 sensitive: false,
885 search: false,
886 items: None,
887 transient: false,
888 },
889 );
890 schema.insert(
891 "attachment".to_string(),
892 FieldSchema {
893 field_type: FieldType::File,
894 primary: false,
895 generated: false,
896 required: true,
897 unique: false,
898 nullable: false,
899 reference: None,
900 min: None,
901 max: None,
902 format: None,
903 values: None,
904 default: None,
905 sensitive: false,
906 search: false,
907 items: None,
908 transient: false,
909 },
910 );
911
912 let mut endpoints = IndexMap::new();
913 endpoints.insert(
914 "create".to_string(),
915 EndpointSpec {
916 method: Some(HttpMethod::Post),
917 path: Some("/assets".to_string()),
918 input: Some(vec!["title".to_string(), "attachment".to_string()]),
919 upload: Some(UploadSpec {
920 field: "attachment".to_string(),
921 storage: "local".to_string(),
922 max_size: "5mb".to_string(),
923 types: Some(vec!["image/png".to_string()]),
924 }),
925 ..Default::default()
926 },
927 );
928
929 ResourceDefinition {
930 resource: "assets".to_string(),
931 version: 1,
932 db: None,
933 tenant_key: None,
934 schema,
935 endpoints: Some(endpoints),
936 relations: None,
937 indexes: None,
938 }
939 }
940
941 #[test]
942 fn generates_valid_openapi_31_spec() {
943 let config = test_config();
944 let resources = vec![sample_resource()];
945 let spec = generate(&config, &resources);
946
947 assert_eq!(spec["openapi"], "3.1.0");
948 assert_eq!(spec["info"]["title"], "test-api");
949 assert_eq!(spec["info"]["version"], "1.0.0");
950 assert!(spec["paths"].is_object());
951 assert!(spec["components"]["schemas"].is_object());
952 assert!(spec["components"]["securitySchemes"].is_object());
953 }
954
955 #[test]
956 fn sensitive_field_omitted_from_response_schema() {
957 let config = test_config();
958 let mut resource = sample_resource();
959 resource.schema.get_mut("email").unwrap().sensitive = true;
960 let spec = generate(&config, &[resource]);
961
962 let users_props = spec["components"]["schemas"]["Users"]["properties"]
963 .as_object()
964 .expect("Users response schema should have properties");
965 assert!(
966 !users_props.contains_key("email"),
967 "sensitive `email` must be omitted from response schema, got: {users_props:?}"
968 );
969 assert!(
970 users_props.contains_key("name"),
971 "non-sensitive fields must remain in response schema"
972 );
973
974 let input_props = spec["components"]["schemas"]["UsersCreateInput"]["properties"]
976 .as_object()
977 .expect("UsersCreateInput should exist");
978 assert!(
979 input_props.contains_key("email"),
980 "sensitive fields must remain in request schemas when declared in input:"
981 );
982 }
983
984 #[test]
985 fn deterministic_output() {
986 let config = test_config();
987 let resources = vec![sample_resource()];
988
989 let spec1 = generate(&config, &resources);
990 let spec2 = generate(&config, &resources);
991
992 let json1 = to_json(&spec1).expect("serialize 1");
993 let json2 = to_json(&spec2).expect("serialize 2");
994
995 assert_eq!(json1, json2, "OpenAPI spec must be deterministic");
996 }
997
998 #[test]
999 fn documents_all_endpoints() {
1000 let config = test_config();
1001 let resources = vec![sample_resource()];
1002 let spec = generate(&config, &resources);
1003
1004 let paths = spec["paths"].as_object().expect("paths object");
1005
1006 let users_path = paths.get("/v1/users").expect("/v1/users path");
1008 assert!(users_path.get("get").is_some(), "GET /v1/users");
1009 assert!(users_path.get("post").is_some(), "POST /v1/users");
1010
1011 let users_id_path = paths.get("/v1/users/{id}").expect("/v1/users/{{id}} path");
1013 assert!(users_id_path.get("patch").is_some(), "PATCH /users/{{id}}");
1014 assert!(
1015 users_id_path.get("delete").is_some(),
1016 "DELETE /users/{{id}}"
1017 );
1018 }
1019
1020 #[test]
1021 fn pagination_params_documented() {
1022 let config = test_config();
1023 let resources = vec![sample_resource()];
1024 let spec = generate(&config, &resources);
1025
1026 let list_op = &spec["paths"]["/v1/users"]["get"];
1027 let params = list_op["parameters"].as_array().expect("params array");
1028
1029 let param_names: Vec<&str> = params.iter().filter_map(|p| p["name"].as_str()).collect();
1030
1031 assert!(param_names.contains(&"cursor"), "cursor param");
1032 assert!(param_names.contains(&"limit"), "limit param");
1033 }
1034
1035 #[test]
1036 fn filter_params_documented() {
1037 let config = test_config();
1038 let resources = vec![sample_resource()];
1039 let spec = generate(&config, &resources);
1040
1041 let list_op = &spec["paths"]["/v1/users"]["get"];
1042 let params = list_op["parameters"].as_array().expect("params array");
1043
1044 let param_names: Vec<&str> = params.iter().filter_map(|p| p["name"].as_str()).collect();
1045
1046 assert!(param_names.contains(&"filter[role]"), "filter[role] param");
1047 }
1048
1049 #[test]
1050 fn search_param_documented() {
1051 let config = test_config();
1052 let resources = vec![sample_resource()];
1053 let spec = generate(&config, &resources);
1054
1055 let list_op = &spec["paths"]["/v1/users"]["get"];
1056 let params = list_op["parameters"].as_array().expect("params array");
1057
1058 let param_names: Vec<&str> = params.iter().filter_map(|p| p["name"].as_str()).collect();
1059
1060 assert!(param_names.contains(&"search"), "search param");
1061 }
1062
1063 #[test]
1064 fn standard_error_responses() {
1065 let config = test_config();
1066 let resources = vec![sample_resource()];
1067 let spec = generate(&config, &resources);
1068
1069 let create_op = &spec["paths"]["/v1/users"]["post"];
1071 let responses = create_op["responses"].as_object().expect("responses");
1072
1073 assert!(responses.contains_key("401"), "401 Unauthorized");
1074 assert!(responses.contains_key("403"), "403 Forbidden");
1075 assert!(responses.contains_key("422"), "422 Validation error");
1076 assert!(responses.contains_key("429"), "429 Rate limited");
1077 assert!(responses.contains_key("500"), "500 Internal server error");
1078
1079 let list_op = &spec["paths"]["/v1/users"]["get"];
1081 let list_responses = list_op["responses"].as_object().expect("responses");
1082 assert!(!list_responses.contains_key("404"), "list has no 404");
1083
1084 let update_op = &spec["paths"]["/v1/users/{id}"]["patch"];
1086 let update_responses = update_op["responses"].as_object().expect("responses");
1087 assert!(update_responses.contains_key("404"), "update has 404");
1088 }
1089
1090 #[test]
1091 fn vendor_extensions() {
1092 let config = test_config();
1093 let resources = vec![sample_resource()];
1094 let spec = generate(&config, &resources);
1095
1096 let create_op = &spec["paths"]["/v1/users"]["post"];
1097 assert_eq!(
1098 create_op["x-shaperail-controller"],
1099 serde_json::json!({"before": "validate_org"})
1100 );
1101 assert_eq!(
1102 create_op["x-shaperail-events"],
1103 serde_json::json!(["user.created"])
1104 );
1105 }
1106
1107 #[test]
1108 fn x_shaperail_auth_emitted_for_role_endpoints() {
1109 let config = test_config();
1110 let resources = vec![sample_resource()];
1111 let spec = generate(&config, &resources);
1112
1113 let delete_op = &spec["paths"]["/v1/users/{id}"]["delete"];
1115 assert_eq!(
1116 delete_op["x-shaperail-auth"],
1117 serde_json::json!(["admin"]),
1118 "delete op must carry x-shaperail-auth"
1119 );
1120
1121 let list_op = &spec["paths"]["/v1/users"]["get"];
1123 assert_eq!(
1124 list_op["x-shaperail-auth"],
1125 serde_json::json!(["member", "admin"]),
1126 "list op must carry x-shaperail-auth"
1127 );
1128 }
1129
1130 #[test]
1131 fn x_shaperail_auth_omitted_for_public_endpoints() {
1132 let config = test_config();
1133 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";
1135 let rd = crate::parser::parse_resource(yaml).unwrap();
1136 let spec = generate(&config, &[rd]);
1137
1138 let list_op = &spec["paths"]["/v1/agents"]["get"];
1139 assert!(
1140 list_op.get("x-shaperail-auth").is_none()
1141 || list_op["x-shaperail-auth"] == serde_json::Value::Null,
1142 "public endpoints must NOT carry x-shaperail-auth"
1143 );
1144 }
1145
1146 #[test]
1147 fn enum_values_in_schema() {
1148 let config = test_config();
1149 let resources = vec![sample_resource()];
1150 let spec = generate(&config, &resources);
1151
1152 let role_prop = &spec["components"]["schemas"]["Users"]["properties"]["role"];
1153 assert_eq!(
1154 role_prop["enum"],
1155 serde_json::json!(["admin", "member", "viewer"])
1156 );
1157 assert_eq!(role_prop["default"], serde_json::json!("member"));
1158 }
1159
1160 #[test]
1161 fn input_schemas_generated() {
1162 let config = test_config();
1163 let resources = vec![sample_resource()];
1164 let spec = generate(&config, &resources);
1165
1166 let schemas = spec["components"]["schemas"].as_object().expect("schemas");
1167 assert!(
1168 schemas.contains_key("UsersCreateInput"),
1169 "create input schema"
1170 );
1171 assert!(
1172 schemas.contains_key("UsersUpdateInput"),
1173 "update input schema"
1174 );
1175 }
1176
1177 #[test]
1178 fn request_body_references_input_schema() {
1179 let config = test_config();
1180 let resources = vec![sample_resource()];
1181 let spec = generate(&config, &resources);
1182
1183 let create_op = &spec["paths"]["/v1/users"]["post"];
1184 let schema_ref = &create_op["requestBody"]["content"]["application/json"]["schema"]["$ref"];
1185 assert_eq!(schema_ref, "#/components/schemas/UsersCreateInput");
1186 }
1187
1188 #[test]
1189 fn upload_request_body_uses_multipart_form_data() {
1190 let config = test_config();
1191 let resources = vec![upload_resource()];
1192 let spec = generate(&config, &resources);
1193
1194 let create_op = &spec["paths"]["/v1/assets"]["post"];
1195 let schema = &create_op["requestBody"]["content"]["multipart/form-data"]["schema"];
1196
1197 assert_eq!(schema["properties"]["attachment"]["type"], "string");
1198 assert_eq!(schema["properties"]["attachment"]["format"], "binary");
1199 assert_eq!(schema["properties"]["title"]["type"], "string");
1200 }
1201
1202 #[test]
1203 fn security_on_authenticated_endpoints() {
1204 let config = test_config();
1205 let resources = vec![sample_resource()];
1206 let spec = generate(&config, &resources);
1207
1208 let list_op = &spec["paths"]["/v1/users"]["get"];
1209 assert!(
1210 list_op["security"].is_array(),
1211 "auth endpoints have security"
1212 );
1213 }
1214
1215 #[test]
1216 fn string_constraints_in_schema() {
1217 let config = test_config();
1218 let resources = vec![sample_resource()];
1219 let spec = generate(&config, &resources);
1220
1221 let name_prop = &spec["components"]["schemas"]["Users"]["properties"]["name"];
1222 assert_eq!(name_prop["minLength"], 1);
1223 assert_eq!(name_prop["maxLength"], 200);
1224 }
1225
1226 #[test]
1227 fn json_and_yaml_output() {
1228 let config = test_config();
1229 let resources = vec![sample_resource()];
1230 let spec = generate(&config, &resources);
1231
1232 let json = to_json(&spec).expect("json");
1233 assert!(json.contains("\"openapi\": \"3.1.0\""));
1234
1235 let yaml = to_yaml(&spec).expect("yaml");
1236 assert!(yaml.contains("openapi: 3.1.0"));
1237 }
1238
1239 #[test]
1240 fn delete_returns_204() {
1241 let config = test_config();
1242 let resources = vec![sample_resource()];
1243 let spec = generate(&config, &resources);
1244
1245 let delete_op = &spec["paths"]["/v1/users/{id}"]["delete"];
1246 let responses = delete_op["responses"].as_object().expect("responses");
1247 assert!(responses.contains_key("204"), "delete returns 204");
1248 }
1249
1250 #[test]
1251 fn list_response_envelope() {
1252 let config = test_config();
1253 let resources = vec![sample_resource()];
1254 let spec = generate(&config, &resources);
1255
1256 let list_resp = &spec["paths"]["/v1/users"]["get"]["responses"]["200"]["content"]
1257 ["application/json"]["schema"];
1258 assert!(list_resp["properties"]["data"]["type"] == "array");
1259 assert!(list_resp["properties"]["meta"]["type"] == "object");
1260 }
1261
1262 #[test]
1263 fn error_response_schema_exists() {
1264 let config = test_config();
1265 let resources = vec![sample_resource()];
1266 let spec = generate(&config, &resources);
1267
1268 let schemas = spec["components"]["schemas"].as_object().expect("schemas");
1269 assert!(schemas.contains_key("ErrorResponse"));
1270
1271 let err = &schemas["ErrorResponse"];
1272 assert!(err["properties"]["error"]["properties"]["code"].is_object());
1273 assert!(err["properties"]["error"]["properties"]["status"].is_object());
1274 assert!(err["properties"]["error"]["properties"]["message"].is_object());
1275 }
1276}