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