Skip to main content

shaperail_codegen/
openapi.rs

1use std::collections::BTreeMap;
2
3use shaperail_core::{
4    AuthRule, EndpointSpec, FieldSchema, FieldType, HttpMethod, PaginationStyle, ProjectConfig,
5    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 =
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    // Convert BTreeMap<String, BTreeMap<String, Value>> to Value for paths
93    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
123/// Serialize the spec to JSON with deterministic key ordering.
124pub fn to_json(spec: &serde_json::Value) -> Result<String, serde_json::Error> {
125    serde_json::to_string_pretty(spec)
126}
127
128/// Serialize the spec to YAML with deterministic key ordering.
129pub 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    // Add format override from schema (e.g., "email")
325    if let Some(format) = &schema.format {
326        // Don't override format already set by type (uuid, date-time, etc.)
327        if !obj.contains_key("format") {
328            obj.insert("format".to_string(), serde_json::json!(format));
329        }
330    }
331
332    // Add min/max constraints
333    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    // Add default
357    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    // Parameters
380    let mut parameters = Vec::new();
381
382    // Path parameters
383    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    // Filter parameters
393    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    // Search parameter
406    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    // Sort parameter
419    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    // Pagination parameters
430    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    // Field selection
468    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    // Request body
486    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    // Responses
521    let mut responses = BTreeMap::new();
522
523    // Success response
524    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        // List response with pagination meta
537        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    // Standard error responses
588    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    // Security
624    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    // Vendor extensions
642    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("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        // Input schemas still include sensitive fields (the API may legitimately accept them).
1021        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        // /users should have GET and POST
1053        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        // /v1/users/{id} should have PATCH and DELETE
1058        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        // Check create endpoint has 401, 403, 422, 429, 500
1116        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        // Check get (list) has 401, 403, 429, 500 but NOT 404 (no :id)
1126        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        // Check update has 404 (has :id)
1131        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        // delete endpoint has auth: [admin]
1160        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        // list endpoint has auth: [member, admin]
1168        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        // tags resource has list: auth: public
1180        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        // ErrorResponse schema is always present
1343        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        // Resources added in reverse order — schema keys must be in alpha order
1350        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        // Aaccounts must appear before Zorders in alphabetical order
1368        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}