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