Skip to main content

shaperail_codegen/
openapi.rs

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