Skip to main content

shaperail_codegen/
openapi.rs

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