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