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 let items_obj = if let Some(items) = &schema.items {
271 let mut item = serde_json::Map::new();
272 let element_type = match items.field_type {
273 FieldType::String | FieldType::Enum | FieldType::Uuid | FieldType::File => {
274 "string"
275 }
276 FieldType::Integer => "integer",
277 FieldType::Bigint => "integer",
278 FieldType::Number => "number",
279 FieldType::Boolean => "boolean",
280 FieldType::Timestamp | FieldType::Date => "string",
281 FieldType::Json => "object",
282 FieldType::Array => "array",
283 };
284 item.insert("type".to_string(), serde_json::json!(element_type));
285 if matches!(items.field_type, FieldType::Uuid) {
286 item.insert("format".to_string(), serde_json::json!("uuid"));
287 }
288 if matches!(items.field_type, FieldType::Timestamp) {
289 item.insert("format".to_string(), serde_json::json!("date-time"));
290 }
291 if matches!(items.field_type, FieldType::Date) {
292 item.insert("format".to_string(), serde_json::json!("date"));
293 }
294 if let Some(format) = &items.format {
295 item.insert("format".to_string(), serde_json::json!(format));
296 }
297 if let Some(values) = &items.values {
298 item.insert("enum".to_string(), serde_json::json!(values));
299 }
300 if let Some(min) = &items.min {
301 let key = if matches!(items.field_type, FieldType::String | FieldType::Enum) {
302 "minLength"
303 } else {
304 "minimum"
305 };
306 item.insert(key.to_string(), min.clone());
307 }
308 if let Some(max) = &items.max {
309 let key = if matches!(items.field_type, FieldType::String | FieldType::Enum) {
310 "maxLength"
311 } else {
312 "maximum"
313 };
314 item.insert(key.to_string(), max.clone());
315 }
316 serde_json::Value::Object(item)
317 } else {
318 serde_json::json!({})
319 };
320 obj.insert("items".to_string(), items_obj);
321 }
322 FieldType::File => {
323 obj.insert("type".to_string(), serde_json::json!("string"));
324 obj.insert("format".to_string(), serde_json::json!("uri"));
325 }
326 }
327
328 if let Some(format) = &schema.format {
330 if !obj.contains_key("format") {
332 obj.insert("format".to_string(), serde_json::json!(format));
333 }
334 }
335
336 if let Some(min) = &schema.min {
338 match &schema.field_type {
339 FieldType::String => {
340 obj.insert("minLength".to_string(), min.clone());
341 }
342 FieldType::Integer | FieldType::Bigint | FieldType::Number => {
343 obj.insert("minimum".to_string(), min.clone());
344 }
345 _ => {}
346 }
347 }
348 if let Some(max) = &schema.max {
349 match &schema.field_type {
350 FieldType::String => {
351 obj.insert("maxLength".to_string(), max.clone());
352 }
353 FieldType::Integer | FieldType::Bigint | FieldType::Number => {
354 obj.insert("maximum".to_string(), max.clone());
355 }
356 _ => {}
357 }
358 }
359
360 if let Some(default) = &schema.default {
362 obj.insert("default".to_string(), default.clone());
363 }
364
365 serde_json::Value::Object(obj.into_iter().collect())
366}
367
368fn build_operation(
369 struct_name: &str,
370 resource: &ResourceDefinition,
371 resource_name: &str,
372 action: &str,
373 ep: &EndpointSpec,
374) -> serde_json::Value {
375 let mut operation = BTreeMap::new();
376
377 operation.insert(
378 "operationId".to_string(),
379 serde_json::json!(format!("{resource_name}_{action}")),
380 );
381 operation.insert("tags".to_string(), serde_json::json!([resource_name]));
382
383 let mut parameters = Vec::new();
385
386 if ep.path().contains(":id") {
388 parameters.push(serde_json::json!({
389 "name": "id",
390 "in": "path",
391 "required": true,
392 "schema": { "type": "string", "format": "uuid" }
393 }));
394 }
395
396 if let Some(filters) = &ep.filters {
398 for filter in filters {
399 parameters.push(serde_json::json!({
400 "name": format!("filter[{filter}]"),
401 "in": "query",
402 "required": false,
403 "schema": { "type": "string" },
404 "description": format!("Filter by {filter}")
405 }));
406 }
407 }
408
409 if let Some(search_fields) = &ep.search {
411 if !search_fields.is_empty() {
412 parameters.push(serde_json::json!({
413 "name": "search",
414 "in": "query",
415 "required": false,
416 "schema": { "type": "string" },
417 "description": format!("Full-text search across: {}", search_fields.join(", "))
418 }));
419 }
420 }
421
422 if ep.sort.is_some() || ep.pagination.is_some() {
424 parameters.push(serde_json::json!({
425 "name": "sort",
426 "in": "query",
427 "required": false,
428 "schema": { "type": "string" },
429 "description": "Sort fields (prefix with - for descending, e.g., -created_at,name)"
430 }));
431 }
432
433 if let Some(pagination) = &ep.pagination {
435 match pagination {
436 PaginationStyle::Cursor => {
437 parameters.push(serde_json::json!({
438 "name": "cursor",
439 "in": "query",
440 "required": false,
441 "schema": { "type": "string" },
442 "description": "Cursor for the next page"
443 }));
444 parameters.push(serde_json::json!({
445 "name": "limit",
446 "in": "query",
447 "required": false,
448 "schema": { "type": "integer", "default": 20, "minimum": 1, "maximum": 100 },
449 "description": "Number of items per page"
450 }));
451 }
452 PaginationStyle::Offset => {
453 parameters.push(serde_json::json!({
454 "name": "offset",
455 "in": "query",
456 "required": false,
457 "schema": { "type": "integer", "default": 0, "minimum": 0 },
458 "description": "Number of items to skip"
459 }));
460 parameters.push(serde_json::json!({
461 "name": "limit",
462 "in": "query",
463 "required": false,
464 "schema": { "type": "integer", "default": 20, "minimum": 1, "maximum": 100 },
465 "description": "Number of items per page"
466 }));
467 }
468 }
469 }
470
471 if *ep.method() == HttpMethod::Get {
473 parameters.push(serde_json::json!({
474 "name": "fields",
475 "in": "query",
476 "required": false,
477 "schema": { "type": "string" },
478 "description": "Comma-separated list of fields to include in response"
479 }));
480 }
481
482 if !parameters.is_empty() {
483 operation.insert(
484 "parameters".to_string(),
485 serde_json::Value::Array(parameters),
486 );
487 }
488
489 if let Some(input_fields) = &ep.input {
491 if !input_fields.is_empty() {
492 let request_body = if let Some(upload) = &ep.upload {
493 serde_json::json!({
494 "required": true,
495 "content": {
496 "multipart/form-data": {
497 "schema": build_multipart_input_schema(
498 resource,
499 input_fields,
500 &upload.field,
501 action == "create",
502 )
503 }
504 }
505 })
506 } else {
507 let input_schema_name = format!("{struct_name}{}Input", to_pascal_case(action));
508 serde_json::json!({
509 "required": true,
510 "content": {
511 "application/json": {
512 "schema": {
513 "$ref": format!("#/components/schemas/{input_schema_name}")
514 }
515 }
516 }
517 })
518 };
519
520 operation.insert("requestBody".to_string(), request_body);
521 }
522 }
523
524 let mut responses = BTreeMap::new();
526
527 let success_status = match *ep.method() {
529 HttpMethod::Post => "201",
530 HttpMethod::Delete => "204",
531 _ => "200",
532 };
533
534 if *ep.method() == HttpMethod::Delete {
535 responses.insert(
536 success_status.to_string(),
537 serde_json::json!({ "description": "Deleted successfully" }),
538 );
539 } else if ep.pagination.is_some() {
540 responses.insert(
542 success_status.to_string(),
543 serde_json::json!({
544 "description": "Successful response",
545 "content": {
546 "application/json": {
547 "schema": {
548 "type": "object",
549 "properties": {
550 "data": {
551 "type": "array",
552 "items": {
553 "$ref": format!("#/components/schemas/{struct_name}")
554 }
555 },
556 "meta": {
557 "type": "object",
558 "properties": {
559 "cursor": { "type": "string" },
560 "has_more": { "type": "boolean" },
561 "total": { "type": "integer" }
562 }
563 }
564 }
565 }
566 }
567 }
568 }),
569 );
570 } else {
571 responses.insert(
572 success_status.to_string(),
573 serde_json::json!({
574 "description": "Successful response",
575 "content": {
576 "application/json": {
577 "schema": {
578 "type": "object",
579 "properties": {
580 "data": {
581 "$ref": format!("#/components/schemas/{struct_name}")
582 }
583 }
584 }
585 }
586 }
587 }),
588 );
589 }
590
591 let error_ref = serde_json::json!({
593 "content": {
594 "application/json": {
595 "schema": {
596 "$ref": "#/components/schemas/ErrorResponse"
597 }
598 }
599 }
600 });
601
602 let mut add_error = |status: &str, description: &str| {
603 let mut resp = error_ref.clone();
604 resp["description"] = serde_json::json!(description);
605 responses.insert(status.to_string(), resp);
606 };
607
608 add_error("401", "Unauthorized");
609 add_error("403", "Forbidden");
610
611 if ep.path().contains(":id") {
612 add_error("404", "Not found");
613 }
614
615 if ep.input.is_some() {
616 add_error("422", "Validation error");
617 }
618
619 add_error("429", "Rate limited");
620 add_error("500", "Internal server error");
621
622 operation.insert(
623 "responses".to_string(),
624 serde_json::Value::Object(responses.into_iter().collect()),
625 );
626
627 if let Some(auth) = &ep.auth {
629 if !auth.is_public() {
630 operation.insert(
631 "security".to_string(),
632 serde_json::json!([
633 { "bearerAuth": [] },
634 { "apiKeyAuth": [] }
635 ]),
636 );
637 }
638 if let AuthRule::Roles(roles) = auth {
639 if !roles.is_empty() {
640 operation.insert("x-shaperail-auth".to_string(), serde_json::json!(roles));
641 }
642 }
643 }
644
645 if let Some(controller) = &ep.controller {
647 let mut ctrl = serde_json::Map::new();
648 if let Some(before) = &controller.before {
649 ctrl.insert("before".to_string(), serde_json::json!(before));
650 }
651 if let Some(after) = &controller.after {
652 ctrl.insert("after".to_string(), serde_json::json!(after));
653 }
654 operation.insert(
655 "x-shaperail-controller".to_string(),
656 serde_json::json!(ctrl),
657 );
658 }
659 if let Some(events) = &ep.events {
660 if !events.is_empty() {
661 operation.insert("x-shaperail-events".to_string(), serde_json::json!(events));
662 }
663 }
664
665 serde_json::Value::Object(operation.into_iter().collect())
666}
667
668fn to_pascal_case(s: &str) -> String {
669 s.split('_')
670 .map(|word| {
671 let mut chars = word.chars();
672 match chars.next() {
673 None => String::new(),
674 Some(c) => {
675 let upper: String = c.to_uppercase().collect();
676 upper + &chars.as_str().to_lowercase()
677 }
678 }
679 })
680 .collect()
681}
682
683#[cfg(test)]
684mod tests {
685 use super::*;
686 use indexmap::IndexMap;
687 use shaperail_core::{
688 AuthRule, CacheSpec, FieldSchema, FieldType, HttpMethod, PaginationStyle, UploadSpec,
689 };
690
691 fn test_config() -> ProjectConfig {
692 ProjectConfig {
693 project: "test-api".to_string(),
694 port: 3000,
695 workers: shaperail_core::WorkerCount::Auto,
696 databases: None,
697 cache: None,
698 auth: None,
699 storage: None,
700 logging: None,
701 events: None,
702 protocols: vec!["rest".to_string()],
703 graphql: None,
704 grpc: None,
705 }
706 }
707
708 fn sample_resource() -> ResourceDefinition {
709 let mut schema = IndexMap::new();
710 schema.insert(
711 "id".to_string(),
712 FieldSchema {
713 field_type: FieldType::Uuid,
714 primary: true,
715 generated: true,
716 required: false,
717 unique: false,
718 nullable: false,
719 reference: None,
720 min: None,
721 max: None,
722 format: None,
723 values: None,
724 default: None,
725 sensitive: false,
726 search: false,
727 items: None,
728 transient: false,
729 },
730 );
731 schema.insert(
732 "email".to_string(),
733 FieldSchema {
734 field_type: FieldType::String,
735 primary: false,
736 generated: false,
737 required: true,
738 unique: true,
739 nullable: false,
740 reference: None,
741 min: None,
742 max: None,
743 format: Some("email".to_string()),
744 values: None,
745 default: None,
746 sensitive: false,
747 search: true,
748 items: None,
749 transient: false,
750 },
751 );
752 schema.insert(
753 "name".to_string(),
754 FieldSchema {
755 field_type: FieldType::String,
756 primary: false,
757 generated: false,
758 required: true,
759 unique: false,
760 nullable: false,
761 reference: None,
762 min: Some(serde_json::json!(1)),
763 max: Some(serde_json::json!(200)),
764 format: None,
765 values: None,
766 default: None,
767 sensitive: false,
768 search: true,
769 items: None,
770 transient: false,
771 },
772 );
773 schema.insert(
774 "role".to_string(),
775 FieldSchema {
776 field_type: FieldType::Enum,
777 primary: false,
778 generated: false,
779 required: true,
780 unique: false,
781 nullable: false,
782 reference: None,
783 min: None,
784 max: None,
785 format: None,
786 values: Some(vec![
787 "admin".to_string(),
788 "member".to_string(),
789 "viewer".to_string(),
790 ]),
791 default: Some(serde_json::json!("member")),
792 sensitive: false,
793 search: false,
794 items: None,
795 transient: false,
796 },
797 );
798 schema.insert(
799 "created_at".to_string(),
800 FieldSchema {
801 field_type: FieldType::Timestamp,
802 primary: false,
803 generated: true,
804 required: false,
805 unique: false,
806 nullable: false,
807 reference: None,
808 min: None,
809 max: None,
810 format: None,
811 values: None,
812 default: None,
813 sensitive: false,
814 search: false,
815 items: None,
816 transient: false,
817 },
818 );
819
820 let mut endpoints = IndexMap::new();
821 endpoints.insert(
822 "list".to_string(),
823 EndpointSpec {
824 method: Some(HttpMethod::Get),
825 path: Some("/users".to_string()),
826 auth: Some(AuthRule::Roles(vec![
827 "member".to_string(),
828 "admin".to_string(),
829 ])),
830 filters: Some(vec!["role".to_string()]),
831 search: Some(vec!["name".to_string(), "email".to_string()]),
832 pagination: Some(PaginationStyle::Cursor),
833 cache: Some(CacheSpec {
834 ttl: 60,
835 invalidate_on: None,
836 }),
837 ..Default::default()
838 },
839 );
840 endpoints.insert(
841 "create".to_string(),
842 EndpointSpec {
843 method: Some(HttpMethod::Post),
844 path: Some("/users".to_string()),
845 auth: Some(AuthRule::Roles(vec!["admin".to_string()])),
846 input: Some(vec![
847 "email".to_string(),
848 "name".to_string(),
849 "role".to_string(),
850 ]),
851 controller: Some(shaperail_core::ControllerSpec {
852 before: Some("validate_org".to_string()),
853 after: None,
854 }),
855 events: Some(vec!["user.created".to_string()]),
856 jobs: Some(vec!["send_welcome_email".to_string()]),
857 ..Default::default()
858 },
859 );
860 endpoints.insert(
861 "update".to_string(),
862 EndpointSpec {
863 method: Some(HttpMethod::Patch),
864 path: Some("/users/:id".to_string()),
865 auth: Some(AuthRule::Roles(vec![
866 "admin".to_string(),
867 "owner".to_string(),
868 ])),
869 input: Some(vec!["name".to_string(), "role".to_string()]),
870 ..Default::default()
871 },
872 );
873 endpoints.insert(
874 "delete".to_string(),
875 EndpointSpec {
876 method: Some(HttpMethod::Delete),
877 path: Some("/users/:id".to_string()),
878 auth: Some(AuthRule::Roles(vec!["admin".to_string()])),
879 soft_delete: true,
880 ..Default::default()
881 },
882 );
883
884 ResourceDefinition {
885 resource: "users".to_string(),
886 version: 1,
887 db: None,
888 tenant_key: None,
889 schema,
890 endpoints: Some(endpoints),
891 relations: None,
892 indexes: None,
893 }
894 }
895
896 fn upload_resource() -> ResourceDefinition {
897 let mut schema = IndexMap::new();
898 schema.insert(
899 "id".to_string(),
900 FieldSchema {
901 field_type: FieldType::Uuid,
902 primary: true,
903 generated: true,
904 required: false,
905 unique: false,
906 nullable: false,
907 reference: None,
908 min: None,
909 max: None,
910 format: None,
911 values: None,
912 default: None,
913 sensitive: false,
914 search: false,
915 items: None,
916 transient: false,
917 },
918 );
919 schema.insert(
920 "title".to_string(),
921 FieldSchema {
922 field_type: FieldType::String,
923 primary: false,
924 generated: false,
925 required: true,
926 unique: false,
927 nullable: false,
928 reference: None,
929 min: Some(serde_json::json!(1)),
930 max: Some(serde_json::json!(200)),
931 format: None,
932 values: None,
933 default: None,
934 sensitive: false,
935 search: false,
936 items: None,
937 transient: false,
938 },
939 );
940 schema.insert(
941 "attachment".to_string(),
942 FieldSchema {
943 field_type: FieldType::File,
944 primary: false,
945 generated: false,
946 required: true,
947 unique: false,
948 nullable: false,
949 reference: None,
950 min: None,
951 max: None,
952 format: None,
953 values: None,
954 default: None,
955 sensitive: false,
956 search: false,
957 items: None,
958 transient: false,
959 },
960 );
961
962 let mut endpoints = IndexMap::new();
963 endpoints.insert(
964 "create".to_string(),
965 EndpointSpec {
966 method: Some(HttpMethod::Post),
967 path: Some("/assets".to_string()),
968 input: Some(vec!["title".to_string(), "attachment".to_string()]),
969 upload: Some(UploadSpec {
970 field: "attachment".to_string(),
971 storage: "local".to_string(),
972 max_size: "5mb".to_string(),
973 types: Some(vec!["image/png".to_string()]),
974 }),
975 ..Default::default()
976 },
977 );
978
979 ResourceDefinition {
980 resource: "assets".to_string(),
981 version: 1,
982 db: None,
983 tenant_key: None,
984 schema,
985 endpoints: Some(endpoints),
986 relations: None,
987 indexes: None,
988 }
989 }
990
991 #[test]
992 fn generates_valid_openapi_31_spec() {
993 let config = test_config();
994 let resources = vec![sample_resource()];
995 let spec = generate(&config, &resources);
996
997 assert_eq!(spec["openapi"], "3.1.0");
998 assert_eq!(spec["info"]["title"], "test-api");
999 assert_eq!(spec["info"]["version"], "1.0.0");
1000 assert!(spec["paths"].is_object());
1001 assert!(spec["components"]["schemas"].is_object());
1002 assert!(spec["components"]["securitySchemes"].is_object());
1003 }
1004
1005 #[test]
1006 fn sensitive_field_omitted_from_response_schema() {
1007 let config = test_config();
1008 let mut resource = sample_resource();
1009 resource.schema.get_mut("email").unwrap().sensitive = true;
1010 let spec = generate(&config, &[resource]);
1011
1012 let users_props = spec["components"]["schemas"]["Users"]["properties"]
1013 .as_object()
1014 .expect("Users response schema should have properties");
1015 assert!(
1016 !users_props.contains_key("email"),
1017 "sensitive `email` must be omitted from response schema, got: {users_props:?}"
1018 );
1019 assert!(
1020 users_props.contains_key("name"),
1021 "non-sensitive fields must remain in response schema"
1022 );
1023
1024 let input_props = spec["components"]["schemas"]["UsersCreateInput"]["properties"]
1026 .as_object()
1027 .expect("UsersCreateInput should exist");
1028 assert!(
1029 input_props.contains_key("email"),
1030 "sensitive fields must remain in request schemas when declared in input:"
1031 );
1032 }
1033
1034 #[test]
1035 fn deterministic_output() {
1036 let config = test_config();
1037 let resources = vec![sample_resource()];
1038
1039 let spec1 = generate(&config, &resources);
1040 let spec2 = generate(&config, &resources);
1041
1042 let json1 = to_json(&spec1).expect("serialize 1");
1043 let json2 = to_json(&spec2).expect("serialize 2");
1044
1045 assert_eq!(json1, json2, "OpenAPI spec must be deterministic");
1046 }
1047
1048 #[test]
1049 fn documents_all_endpoints() {
1050 let config = test_config();
1051 let resources = vec![sample_resource()];
1052 let spec = generate(&config, &resources);
1053
1054 let paths = spec["paths"].as_object().expect("paths object");
1055
1056 let users_path = paths.get("/v1/users").expect("/v1/users path");
1058 assert!(users_path.get("get").is_some(), "GET /v1/users");
1059 assert!(users_path.get("post").is_some(), "POST /v1/users");
1060
1061 let users_id_path = paths.get("/v1/users/{id}").expect("/v1/users/{{id}} path");
1063 assert!(users_id_path.get("patch").is_some(), "PATCH /users/{{id}}");
1064 assert!(
1065 users_id_path.get("delete").is_some(),
1066 "DELETE /users/{{id}}"
1067 );
1068 }
1069
1070 #[test]
1071 fn pagination_params_documented() {
1072 let config = test_config();
1073 let resources = vec![sample_resource()];
1074 let spec = generate(&config, &resources);
1075
1076 let list_op = &spec["paths"]["/v1/users"]["get"];
1077 let params = list_op["parameters"].as_array().expect("params array");
1078
1079 let param_names: Vec<&str> = params.iter().filter_map(|p| p["name"].as_str()).collect();
1080
1081 assert!(param_names.contains(&"cursor"), "cursor param");
1082 assert!(param_names.contains(&"limit"), "limit param");
1083 }
1084
1085 #[test]
1086 fn filter_params_documented() {
1087 let config = test_config();
1088 let resources = vec![sample_resource()];
1089 let spec = generate(&config, &resources);
1090
1091 let list_op = &spec["paths"]["/v1/users"]["get"];
1092 let params = list_op["parameters"].as_array().expect("params array");
1093
1094 let param_names: Vec<&str> = params.iter().filter_map(|p| p["name"].as_str()).collect();
1095
1096 assert!(param_names.contains(&"filter[role]"), "filter[role] param");
1097 }
1098
1099 #[test]
1100 fn search_param_documented() {
1101 let config = test_config();
1102 let resources = vec![sample_resource()];
1103 let spec = generate(&config, &resources);
1104
1105 let list_op = &spec["paths"]["/v1/users"]["get"];
1106 let params = list_op["parameters"].as_array().expect("params array");
1107
1108 let param_names: Vec<&str> = params.iter().filter_map(|p| p["name"].as_str()).collect();
1109
1110 assert!(param_names.contains(&"search"), "search param");
1111 }
1112
1113 #[test]
1114 fn standard_error_responses() {
1115 let config = test_config();
1116 let resources = vec![sample_resource()];
1117 let spec = generate(&config, &resources);
1118
1119 let create_op = &spec["paths"]["/v1/users"]["post"];
1121 let responses = create_op["responses"].as_object().expect("responses");
1122
1123 assert!(responses.contains_key("401"), "401 Unauthorized");
1124 assert!(responses.contains_key("403"), "403 Forbidden");
1125 assert!(responses.contains_key("422"), "422 Validation error");
1126 assert!(responses.contains_key("429"), "429 Rate limited");
1127 assert!(responses.contains_key("500"), "500 Internal server error");
1128
1129 let list_op = &spec["paths"]["/v1/users"]["get"];
1131 let list_responses = list_op["responses"].as_object().expect("responses");
1132 assert!(!list_responses.contains_key("404"), "list has no 404");
1133
1134 let update_op = &spec["paths"]["/v1/users/{id}"]["patch"];
1136 let update_responses = update_op["responses"].as_object().expect("responses");
1137 assert!(update_responses.contains_key("404"), "update has 404");
1138 }
1139
1140 #[test]
1141 fn vendor_extensions() {
1142 let config = test_config();
1143 let resources = vec![sample_resource()];
1144 let spec = generate(&config, &resources);
1145
1146 let create_op = &spec["paths"]["/v1/users"]["post"];
1147 assert_eq!(
1148 create_op["x-shaperail-controller"],
1149 serde_json::json!({"before": "validate_org"})
1150 );
1151 assert_eq!(
1152 create_op["x-shaperail-events"],
1153 serde_json::json!(["user.created"])
1154 );
1155 }
1156
1157 #[test]
1158 fn x_shaperail_auth_emitted_for_role_endpoints() {
1159 let config = test_config();
1160 let resources = vec![sample_resource()];
1161 let spec = generate(&config, &resources);
1162
1163 let delete_op = &spec["paths"]["/v1/users/{id}"]["delete"];
1165 assert_eq!(
1166 delete_op["x-shaperail-auth"],
1167 serde_json::json!(["admin"]),
1168 "delete op must carry x-shaperail-auth"
1169 );
1170
1171 let list_op = &spec["paths"]["/v1/users"]["get"];
1173 assert_eq!(
1174 list_op["x-shaperail-auth"],
1175 serde_json::json!(["member", "admin"]),
1176 "list op must carry x-shaperail-auth"
1177 );
1178 }
1179
1180 #[test]
1181 fn x_shaperail_auth_omitted_for_public_endpoints() {
1182 let config = test_config();
1183 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";
1185 let rd = crate::parser::parse_resource(yaml).unwrap();
1186 let spec = generate(&config, &[rd]);
1187
1188 let list_op = &spec["paths"]["/v1/agents"]["get"];
1189 assert!(
1190 list_op.get("x-shaperail-auth").is_none()
1191 || list_op["x-shaperail-auth"] == serde_json::Value::Null,
1192 "public endpoints must NOT carry x-shaperail-auth"
1193 );
1194 }
1195
1196 #[test]
1197 fn enum_values_in_schema() {
1198 let config = test_config();
1199 let resources = vec![sample_resource()];
1200 let spec = generate(&config, &resources);
1201
1202 let role_prop = &spec["components"]["schemas"]["Users"]["properties"]["role"];
1203 assert_eq!(
1204 role_prop["enum"],
1205 serde_json::json!(["admin", "member", "viewer"])
1206 );
1207 assert_eq!(role_prop["default"], serde_json::json!("member"));
1208 }
1209
1210 #[test]
1211 fn input_schemas_generated() {
1212 let config = test_config();
1213 let resources = vec![sample_resource()];
1214 let spec = generate(&config, &resources);
1215
1216 let schemas = spec["components"]["schemas"].as_object().expect("schemas");
1217 assert!(
1218 schemas.contains_key("UsersCreateInput"),
1219 "create input schema"
1220 );
1221 assert!(
1222 schemas.contains_key("UsersUpdateInput"),
1223 "update input schema"
1224 );
1225 }
1226
1227 #[test]
1228 fn request_body_references_input_schema() {
1229 let config = test_config();
1230 let resources = vec![sample_resource()];
1231 let spec = generate(&config, &resources);
1232
1233 let create_op = &spec["paths"]["/v1/users"]["post"];
1234 let schema_ref = &create_op["requestBody"]["content"]["application/json"]["schema"]["$ref"];
1235 assert_eq!(schema_ref, "#/components/schemas/UsersCreateInput");
1236 }
1237
1238 #[test]
1239 fn upload_request_body_uses_multipart_form_data() {
1240 let config = test_config();
1241 let resources = vec![upload_resource()];
1242 let spec = generate(&config, &resources);
1243
1244 let create_op = &spec["paths"]["/v1/assets"]["post"];
1245 let schema = &create_op["requestBody"]["content"]["multipart/form-data"]["schema"];
1246
1247 assert_eq!(schema["properties"]["attachment"]["type"], "string");
1248 assert_eq!(schema["properties"]["attachment"]["format"], "binary");
1249 assert_eq!(schema["properties"]["title"]["type"], "string");
1250 }
1251
1252 #[test]
1253 fn security_on_authenticated_endpoints() {
1254 let config = test_config();
1255 let resources = vec![sample_resource()];
1256 let spec = generate(&config, &resources);
1257
1258 let list_op = &spec["paths"]["/v1/users"]["get"];
1259 assert!(
1260 list_op["security"].is_array(),
1261 "auth endpoints have security"
1262 );
1263 }
1264
1265 #[test]
1266 fn string_constraints_in_schema() {
1267 let config = test_config();
1268 let resources = vec![sample_resource()];
1269 let spec = generate(&config, &resources);
1270
1271 let name_prop = &spec["components"]["schemas"]["Users"]["properties"]["name"];
1272 assert_eq!(name_prop["minLength"], 1);
1273 assert_eq!(name_prop["maxLength"], 200);
1274 }
1275
1276 #[test]
1277 fn json_and_yaml_output() {
1278 let config = test_config();
1279 let resources = vec![sample_resource()];
1280 let spec = generate(&config, &resources);
1281
1282 let json = to_json(&spec).expect("json");
1283 assert!(json.contains("\"openapi\": \"3.1.0\""));
1284
1285 let yaml = to_yaml(&spec).expect("yaml");
1286 assert!(yaml.contains("openapi: 3.1.0"));
1287 }
1288
1289 #[test]
1290 fn delete_returns_204() {
1291 let config = test_config();
1292 let resources = vec![sample_resource()];
1293 let spec = generate(&config, &resources);
1294
1295 let delete_op = &spec["paths"]["/v1/users/{id}"]["delete"];
1296 let responses = delete_op["responses"].as_object().expect("responses");
1297 assert!(responses.contains_key("204"), "delete returns 204");
1298 }
1299
1300 #[test]
1301 fn list_response_envelope() {
1302 let config = test_config();
1303 let resources = vec![sample_resource()];
1304 let spec = generate(&config, &resources);
1305
1306 let list_resp = &spec["paths"]["/v1/users"]["get"]["responses"]["200"]["content"]
1307 ["application/json"]["schema"];
1308 assert!(list_resp["properties"]["data"]["type"] == "array");
1309 assert!(list_resp["properties"]["meta"]["type"] == "object");
1310 }
1311
1312 #[test]
1313 fn error_response_schema_exists() {
1314 let config = test_config();
1315 let resources = vec![sample_resource()];
1316 let spec = generate(&config, &resources);
1317
1318 let schemas = spec["components"]["schemas"].as_object().expect("schemas");
1319 assert!(schemas.contains_key("ErrorResponse"));
1320
1321 let err = &schemas["ErrorResponse"];
1322 assert!(err["properties"]["error"]["properties"]["code"].is_object());
1323 assert!(err["properties"]["error"]["properties"]["status"].is_object());
1324 assert!(err["properties"]["error"]["properties"]["message"].is_object());
1325 }
1326
1327 #[test]
1328 fn openapi_array_items_emits_element_constraints() {
1329 let mut items = shaperail_core::ItemsSpec::of(shaperail_core::FieldType::String);
1330 items.min = Some(serde_json::json!(3));
1331 items.max = Some(serde_json::json!(3));
1332
1333 let mut schema = IndexMap::new();
1334 schema.insert(
1335 "currencies".to_string(),
1336 shaperail_core::FieldSchema {
1337 field_type: shaperail_core::FieldType::Array,
1338 items: Some(items),
1339 primary: false,
1340 generated: false,
1341 required: false,
1342 unique: false,
1343 nullable: false,
1344 reference: None,
1345 min: None,
1346 max: None,
1347 format: None,
1348 values: None,
1349 default: None,
1350 sensitive: false,
1351 search: false,
1352 transient: false,
1353 },
1354 );
1355 let resource = shaperail_core::ResourceDefinition {
1356 resource: "vendors".to_string(),
1357 version: 1,
1358 db: None,
1359 tenant_key: None,
1360 schema,
1361 endpoints: None,
1362 relations: None,
1363 indexes: None,
1364 };
1365
1366 let config = test_config();
1367 let spec = generate(&config, &[resource]);
1368 let json = serde_json::to_string(&spec).unwrap();
1369 assert!(
1370 json.contains("\"minLength\":3"),
1371 "minLength missing: {json}"
1372 );
1373 assert!(
1374 json.contains("\"maxLength\":3"),
1375 "maxLength missing: {json}"
1376 );
1377 }
1378}