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 = ep.path.replace(":id", "{id}");
78                let method = ep.method.to_string().to_lowercase();
79
80                let operation = build_operation(&struct_name, &resource.resource, action, ep);
81
82                let entry = paths
83                    .entry(openapi_path)
84                    .or_insert_with(BTreeMap::<String, serde_json::Value>::new);
85                entry.insert(method, operation);
86            }
87        }
88    }
89
90    // Convert BTreeMap<String, BTreeMap<String, Value>> to Value for paths
91    let paths_value: serde_json::Value = serde_json::to_value(&paths)
92        .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new()));
93
94    serde_json::json!({
95        "openapi": "3.1.0",
96        "info": {
97            "title": config.project,
98            "version": "1.0.0"
99        },
100        "paths": paths_value,
101        "components": {
102            "schemas": serde_json::Value::Object(
103                schemas.into_iter().collect()
104            ),
105            "securitySchemes": {
106                "bearerAuth": {
107                    "type": "http",
108                    "scheme": "bearer",
109                    "bearerFormat": "JWT"
110                },
111                "apiKeyAuth": {
112                    "type": "apiKey",
113                    "in": "header",
114                    "name": "X-API-Key"
115                }
116            }
117        }
118    })
119}
120
121/// Serialize the spec to JSON with deterministic key ordering.
122pub fn to_json(spec: &serde_json::Value) -> Result<String, serde_json::Error> {
123    serde_json::to_string_pretty(spec)
124}
125
126/// Serialize the spec to YAML with deterministic key ordering.
127pub fn to_yaml(spec: &serde_json::Value) -> Result<String, serde_yaml::Error> {
128    serde_yaml::to_string(spec)
129}
130
131fn build_resource_schema(resource: &ResourceDefinition) -> serde_json::Value {
132    let mut properties = BTreeMap::new();
133    let mut required_fields = Vec::new();
134
135    for (name, schema) in &resource.schema {
136        properties.insert(name.clone(), field_schema_to_openapi(schema));
137        if schema.required && !schema.generated {
138            required_fields.push(serde_json::Value::String(name.clone()));
139        }
140    }
141
142    let mut result = serde_json::json!({
143        "type": "object",
144        "properties": serde_json::Value::Object(properties.into_iter().collect()),
145    });
146
147    if !required_fields.is_empty() {
148        result["required"] = serde_json::Value::Array(required_fields);
149    }
150
151    result
152}
153
154fn build_input_schema(
155    resource: &ResourceDefinition,
156    input_fields: &[String],
157    is_create: bool,
158) -> serde_json::Value {
159    let mut properties = BTreeMap::new();
160    let mut required_fields = Vec::new();
161
162    for field_name in input_fields {
163        if let Some(schema) = resource.schema.get(field_name) {
164            properties.insert(field_name.clone(), field_schema_to_openapi(schema));
165            if is_create && schema.required {
166                required_fields.push(serde_json::Value::String(field_name.clone()));
167            }
168        }
169    }
170
171    let mut result = serde_json::json!({
172        "type": "object",
173        "properties": serde_json::Value::Object(properties.into_iter().collect()),
174    });
175
176    if !required_fields.is_empty() {
177        result["required"] = serde_json::Value::Array(required_fields);
178    }
179
180    result
181}
182
183fn field_schema_to_openapi(schema: &FieldSchema) -> serde_json::Value {
184    let mut obj = BTreeMap::new();
185
186    match &schema.field_type {
187        FieldType::Uuid => {
188            obj.insert("type".to_string(), serde_json::json!("string"));
189            obj.insert("format".to_string(), serde_json::json!("uuid"));
190        }
191        FieldType::String => {
192            obj.insert("type".to_string(), serde_json::json!("string"));
193        }
194        FieldType::Integer => {
195            obj.insert("type".to_string(), serde_json::json!("integer"));
196        }
197        FieldType::Bigint => {
198            obj.insert("type".to_string(), serde_json::json!("integer"));
199            obj.insert("format".to_string(), serde_json::json!("int64"));
200        }
201        FieldType::Number => {
202            obj.insert("type".to_string(), serde_json::json!("number"));
203        }
204        FieldType::Boolean => {
205            obj.insert("type".to_string(), serde_json::json!("boolean"));
206        }
207        FieldType::Timestamp => {
208            obj.insert("type".to_string(), serde_json::json!("string"));
209            obj.insert("format".to_string(), serde_json::json!("date-time"));
210        }
211        FieldType::Date => {
212            obj.insert("type".to_string(), serde_json::json!("string"));
213            obj.insert("format".to_string(), serde_json::json!("date"));
214        }
215        FieldType::Enum => {
216            obj.insert("type".to_string(), serde_json::json!("string"));
217            if let Some(values) = &schema.values {
218                obj.insert("enum".to_string(), serde_json::json!(values));
219            }
220        }
221        FieldType::Json => {
222            obj.insert("type".to_string(), serde_json::json!("object"));
223        }
224        FieldType::Array => {
225            obj.insert("type".to_string(), serde_json::json!("array"));
226            obj.insert("items".to_string(), serde_json::json!({}));
227        }
228        FieldType::File => {
229            obj.insert("type".to_string(), serde_json::json!("string"));
230            obj.insert("format".to_string(), serde_json::json!("uri"));
231        }
232    }
233
234    // Add format override from schema (e.g., "email")
235    if let Some(format) = &schema.format {
236        // Don't override format already set by type (uuid, date-time, etc.)
237        if !obj.contains_key("format") {
238            obj.insert("format".to_string(), serde_json::json!(format));
239        }
240    }
241
242    // Add min/max constraints
243    if let Some(min) = &schema.min {
244        match &schema.field_type {
245            FieldType::String => {
246                obj.insert("minLength".to_string(), min.clone());
247            }
248            FieldType::Integer | FieldType::Bigint | FieldType::Number => {
249                obj.insert("minimum".to_string(), min.clone());
250            }
251            _ => {}
252        }
253    }
254    if let Some(max) = &schema.max {
255        match &schema.field_type {
256            FieldType::String => {
257                obj.insert("maxLength".to_string(), max.clone());
258            }
259            FieldType::Integer | FieldType::Bigint | FieldType::Number => {
260                obj.insert("maximum".to_string(), max.clone());
261            }
262            _ => {}
263        }
264    }
265
266    // Add default
267    if let Some(default) = &schema.default {
268        obj.insert("default".to_string(), default.clone());
269    }
270
271    serde_json::Value::Object(obj.into_iter().collect())
272}
273
274fn build_operation(
275    struct_name: &str,
276    resource_name: &str,
277    action: &str,
278    ep: &EndpointSpec,
279) -> serde_json::Value {
280    let mut operation = BTreeMap::new();
281
282    operation.insert(
283        "operationId".to_string(),
284        serde_json::json!(format!("{resource_name}_{action}")),
285    );
286    operation.insert("tags".to_string(), serde_json::json!([resource_name]));
287
288    // Parameters
289    let mut parameters = Vec::new();
290
291    // Path parameters
292    if ep.path.contains(":id") {
293        parameters.push(serde_json::json!({
294            "name": "id",
295            "in": "path",
296            "required": true,
297            "schema": { "type": "string", "format": "uuid" }
298        }));
299    }
300
301    // Filter parameters
302    if let Some(filters) = &ep.filters {
303        for filter in filters {
304            parameters.push(serde_json::json!({
305                "name": format!("filter[{filter}]"),
306                "in": "query",
307                "required": false,
308                "schema": { "type": "string" },
309                "description": format!("Filter by {filter}")
310            }));
311        }
312    }
313
314    // Search parameter
315    if let Some(search_fields) = &ep.search {
316        if !search_fields.is_empty() {
317            parameters.push(serde_json::json!({
318                "name": "search",
319                "in": "query",
320                "required": false,
321                "schema": { "type": "string" },
322                "description": format!("Full-text search across: {}", search_fields.join(", "))
323            }));
324        }
325    }
326
327    // Sort parameter
328    if ep.sort.is_some() || ep.pagination.is_some() {
329        parameters.push(serde_json::json!({
330            "name": "sort",
331            "in": "query",
332            "required": false,
333            "schema": { "type": "string" },
334            "description": "Sort fields (prefix with - for descending, e.g., -created_at,name)"
335        }));
336    }
337
338    // Pagination parameters
339    if let Some(pagination) = &ep.pagination {
340        match pagination {
341            PaginationStyle::Cursor => {
342                parameters.push(serde_json::json!({
343                    "name": "cursor",
344                    "in": "query",
345                    "required": false,
346                    "schema": { "type": "string" },
347                    "description": "Cursor for the next page"
348                }));
349                parameters.push(serde_json::json!({
350                    "name": "limit",
351                    "in": "query",
352                    "required": false,
353                    "schema": { "type": "integer", "default": 20, "minimum": 1, "maximum": 100 },
354                    "description": "Number of items per page"
355                }));
356            }
357            PaginationStyle::Offset => {
358                parameters.push(serde_json::json!({
359                    "name": "offset",
360                    "in": "query",
361                    "required": false,
362                    "schema": { "type": "integer", "default": 0, "minimum": 0 },
363                    "description": "Number of items to skip"
364                }));
365                parameters.push(serde_json::json!({
366                    "name": "limit",
367                    "in": "query",
368                    "required": false,
369                    "schema": { "type": "integer", "default": 20, "minimum": 1, "maximum": 100 },
370                    "description": "Number of items per page"
371                }));
372            }
373        }
374    }
375
376    // Field selection
377    if ep.method == HttpMethod::Get {
378        parameters.push(serde_json::json!({
379            "name": "fields",
380            "in": "query",
381            "required": false,
382            "schema": { "type": "string" },
383            "description": "Comma-separated list of fields to include in response"
384        }));
385    }
386
387    if !parameters.is_empty() {
388        operation.insert(
389            "parameters".to_string(),
390            serde_json::Value::Array(parameters),
391        );
392    }
393
394    // Request body
395    if let Some(input_fields) = &ep.input {
396        if !input_fields.is_empty() {
397            let input_schema_name = format!("{struct_name}{}Input", to_pascal_case(action));
398            operation.insert(
399                "requestBody".to_string(),
400                serde_json::json!({
401                    "required": true,
402                    "content": {
403                        "application/json": {
404                            "schema": {
405                                "$ref": format!("#/components/schemas/{input_schema_name}")
406                            }
407                        }
408                    }
409                }),
410            );
411        }
412    }
413
414    // Responses
415    let mut responses = BTreeMap::new();
416
417    // Success response
418    let success_status = match ep.method {
419        HttpMethod::Post => "201",
420        HttpMethod::Delete => "204",
421        _ => "200",
422    };
423
424    if ep.method == HttpMethod::Delete {
425        responses.insert(
426            success_status.to_string(),
427            serde_json::json!({ "description": "Deleted successfully" }),
428        );
429    } else if ep.pagination.is_some() {
430        // List response with pagination meta
431        responses.insert(
432            success_status.to_string(),
433            serde_json::json!({
434                "description": "Successful response",
435                "content": {
436                    "application/json": {
437                        "schema": {
438                            "type": "object",
439                            "properties": {
440                                "data": {
441                                    "type": "array",
442                                    "items": {
443                                        "$ref": format!("#/components/schemas/{struct_name}")
444                                    }
445                                },
446                                "meta": {
447                                    "type": "object",
448                                    "properties": {
449                                        "cursor": { "type": "string" },
450                                        "has_more": { "type": "boolean" },
451                                        "total": { "type": "integer" }
452                                    }
453                                }
454                            }
455                        }
456                    }
457                }
458            }),
459        );
460    } else {
461        responses.insert(
462            success_status.to_string(),
463            serde_json::json!({
464                "description": "Successful response",
465                "content": {
466                    "application/json": {
467                        "schema": {
468                            "type": "object",
469                            "properties": {
470                                "data": {
471                                    "$ref": format!("#/components/schemas/{struct_name}")
472                                }
473                            }
474                        }
475                    }
476                }
477            }),
478        );
479    }
480
481    // Standard error responses
482    let error_ref = serde_json::json!({
483        "content": {
484            "application/json": {
485                "schema": {
486                    "$ref": "#/components/schemas/ErrorResponse"
487                }
488            }
489        }
490    });
491
492    let mut add_error = |status: &str, description: &str| {
493        let mut resp = error_ref.clone();
494        resp["description"] = serde_json::json!(description);
495        responses.insert(status.to_string(), resp);
496    };
497
498    add_error("401", "Unauthorized");
499    add_error("403", "Forbidden");
500
501    if ep.path.contains(":id") {
502        add_error("404", "Not found");
503    }
504
505    if ep.input.is_some() {
506        add_error("422", "Validation error");
507    }
508
509    add_error("429", "Rate limited");
510    add_error("500", "Internal server error");
511
512    operation.insert(
513        "responses".to_string(),
514        serde_json::Value::Object(responses.into_iter().collect()),
515    );
516
517    // Security
518    if let Some(auth) = &ep.auth {
519        if !auth.is_public() {
520            operation.insert(
521                "security".to_string(),
522                serde_json::json!([
523                    { "bearerAuth": [] },
524                    { "apiKeyAuth": [] }
525                ]),
526            );
527        }
528    }
529
530    // Vendor extensions
531    if let Some(hooks) = &ep.hooks {
532        if !hooks.is_empty() {
533            operation.insert("x-shaperail-hooks".to_string(), serde_json::json!(hooks));
534        }
535    }
536    if let Some(events) = &ep.events {
537        if !events.is_empty() {
538            operation.insert("x-shaperail-events".to_string(), serde_json::json!(events));
539        }
540    }
541
542    serde_json::Value::Object(operation.into_iter().collect())
543}
544
545fn to_pascal_case(s: &str) -> String {
546    s.split('_')
547        .map(|word| {
548            let mut chars = word.chars();
549            match chars.next() {
550                None => String::new(),
551                Some(c) => {
552                    let upper: String = c.to_uppercase().collect();
553                    upper + &chars.as_str().to_lowercase()
554                }
555            }
556        })
557        .collect()
558}
559
560#[cfg(test)]
561mod tests {
562    use super::*;
563    use indexmap::IndexMap;
564    use shaperail_core::{
565        AuthRule, CacheSpec, FieldSchema, FieldType, HttpMethod, PaginationStyle,
566    };
567
568    fn test_config() -> ProjectConfig {
569        ProjectConfig {
570            project: "test-api".to_string(),
571            port: 3000,
572            workers: shaperail_core::WorkerCount::Auto,
573            database: None,
574            cache: None,
575            auth: None,
576            storage: None,
577            logging: None,
578            events: None,
579        }
580    }
581
582    fn sample_resource() -> ResourceDefinition {
583        let mut schema = IndexMap::new();
584        schema.insert(
585            "id".to_string(),
586            FieldSchema {
587                field_type: FieldType::Uuid,
588                primary: true,
589                generated: true,
590                required: false,
591                unique: false,
592                nullable: false,
593                reference: None,
594                min: None,
595                max: None,
596                format: None,
597                values: None,
598                default: None,
599                sensitive: false,
600                search: false,
601                items: None,
602            },
603        );
604        schema.insert(
605            "email".to_string(),
606            FieldSchema {
607                field_type: FieldType::String,
608                primary: false,
609                generated: false,
610                required: true,
611                unique: true,
612                nullable: false,
613                reference: None,
614                min: None,
615                max: None,
616                format: Some("email".to_string()),
617                values: None,
618                default: None,
619                sensitive: false,
620                search: true,
621                items: None,
622            },
623        );
624        schema.insert(
625            "name".to_string(),
626            FieldSchema {
627                field_type: FieldType::String,
628                primary: false,
629                generated: false,
630                required: true,
631                unique: false,
632                nullable: false,
633                reference: None,
634                min: Some(serde_json::json!(1)),
635                max: Some(serde_json::json!(200)),
636                format: None,
637                values: None,
638                default: None,
639                sensitive: false,
640                search: true,
641                items: None,
642            },
643        );
644        schema.insert(
645            "role".to_string(),
646            FieldSchema {
647                field_type: FieldType::Enum,
648                primary: false,
649                generated: false,
650                required: true,
651                unique: false,
652                nullable: false,
653                reference: None,
654                min: None,
655                max: None,
656                format: None,
657                values: Some(vec![
658                    "admin".to_string(),
659                    "member".to_string(),
660                    "viewer".to_string(),
661                ]),
662                default: Some(serde_json::json!("member")),
663                sensitive: false,
664                search: false,
665                items: None,
666            },
667        );
668        schema.insert(
669            "created_at".to_string(),
670            FieldSchema {
671                field_type: FieldType::Timestamp,
672                primary: false,
673                generated: true,
674                required: false,
675                unique: false,
676                nullable: false,
677                reference: None,
678                min: None,
679                max: None,
680                format: None,
681                values: None,
682                default: None,
683                sensitive: false,
684                search: false,
685                items: None,
686            },
687        );
688
689        let mut endpoints = IndexMap::new();
690        endpoints.insert(
691            "list".to_string(),
692            EndpointSpec {
693                method: HttpMethod::Get,
694                path: "/users".to_string(),
695                auth: Some(AuthRule::Roles(vec![
696                    "member".to_string(),
697                    "admin".to_string(),
698                ])),
699                input: None,
700                filters: Some(vec!["role".to_string()]),
701                search: Some(vec!["name".to_string(), "email".to_string()]),
702                pagination: Some(PaginationStyle::Cursor),
703                sort: None,
704                cache: Some(CacheSpec {
705                    ttl: 60,
706                    invalidate_on: None,
707                }),
708                hooks: None,
709                events: None,
710                jobs: None,
711                upload: None,
712                soft_delete: false,
713            },
714        );
715        endpoints.insert(
716            "create".to_string(),
717            EndpointSpec {
718                method: HttpMethod::Post,
719                path: "/users".to_string(),
720                auth: Some(AuthRule::Roles(vec!["admin".to_string()])),
721                input: Some(vec![
722                    "email".to_string(),
723                    "name".to_string(),
724                    "role".to_string(),
725                ]),
726                filters: None,
727                search: None,
728                pagination: None,
729                sort: None,
730                cache: None,
731                hooks: Some(vec!["validate_org".to_string()]),
732                events: Some(vec!["user.created".to_string()]),
733                jobs: Some(vec!["send_welcome_email".to_string()]),
734                upload: None,
735                soft_delete: false,
736            },
737        );
738        endpoints.insert(
739            "update".to_string(),
740            EndpointSpec {
741                method: HttpMethod::Patch,
742                path: "/users/:id".to_string(),
743                auth: Some(AuthRule::Roles(vec![
744                    "admin".to_string(),
745                    "owner".to_string(),
746                ])),
747                input: Some(vec!["name".to_string(), "role".to_string()]),
748                filters: None,
749                search: None,
750                pagination: None,
751                sort: None,
752                cache: None,
753                hooks: None,
754                events: None,
755                jobs: None,
756                upload: None,
757                soft_delete: false,
758            },
759        );
760        endpoints.insert(
761            "delete".to_string(),
762            EndpointSpec {
763                method: HttpMethod::Delete,
764                path: "/users/:id".to_string(),
765                auth: Some(AuthRule::Roles(vec!["admin".to_string()])),
766                input: None,
767                filters: None,
768                search: None,
769                pagination: None,
770                sort: None,
771                cache: None,
772                hooks: None,
773                events: None,
774                jobs: None,
775                upload: None,
776                soft_delete: true,
777            },
778        );
779
780        ResourceDefinition {
781            resource: "users".to_string(),
782            version: 1,
783            schema,
784            endpoints: Some(endpoints),
785            relations: None,
786            indexes: None,
787        }
788    }
789
790    #[test]
791    fn generates_valid_openapi_31_spec() {
792        let config = test_config();
793        let resources = vec![sample_resource()];
794        let spec = generate(&config, &resources);
795
796        assert_eq!(spec["openapi"], "3.1.0");
797        assert_eq!(spec["info"]["title"], "test-api");
798        assert_eq!(spec["info"]["version"], "1.0.0");
799        assert!(spec["paths"].is_object());
800        assert!(spec["components"]["schemas"].is_object());
801        assert!(spec["components"]["securitySchemes"].is_object());
802    }
803
804    #[test]
805    fn deterministic_output() {
806        let config = test_config();
807        let resources = vec![sample_resource()];
808
809        let spec1 = generate(&config, &resources);
810        let spec2 = generate(&config, &resources);
811
812        let json1 = to_json(&spec1).expect("serialize 1");
813        let json2 = to_json(&spec2).expect("serialize 2");
814
815        assert_eq!(json1, json2, "OpenAPI spec must be deterministic");
816    }
817
818    #[test]
819    fn documents_all_endpoints() {
820        let config = test_config();
821        let resources = vec![sample_resource()];
822        let spec = generate(&config, &resources);
823
824        let paths = spec["paths"].as_object().expect("paths object");
825
826        // /users should have GET and POST
827        let users_path = paths.get("/users").expect("/users path");
828        assert!(users_path.get("get").is_some(), "GET /users");
829        assert!(users_path.get("post").is_some(), "POST /users");
830
831        // /users/{id} should have PATCH and DELETE
832        let users_id_path = paths.get("/users/{id}").expect("/users/{{id}} path");
833        assert!(users_id_path.get("patch").is_some(), "PATCH /users/{{id}}");
834        assert!(
835            users_id_path.get("delete").is_some(),
836            "DELETE /users/{{id}}"
837        );
838    }
839
840    #[test]
841    fn pagination_params_documented() {
842        let config = test_config();
843        let resources = vec![sample_resource()];
844        let spec = generate(&config, &resources);
845
846        let list_op = &spec["paths"]["/users"]["get"];
847        let params = list_op["parameters"].as_array().expect("params array");
848
849        let param_names: Vec<&str> = params.iter().filter_map(|p| p["name"].as_str()).collect();
850
851        assert!(param_names.contains(&"cursor"), "cursor param");
852        assert!(param_names.contains(&"limit"), "limit param");
853    }
854
855    #[test]
856    fn filter_params_documented() {
857        let config = test_config();
858        let resources = vec![sample_resource()];
859        let spec = generate(&config, &resources);
860
861        let list_op = &spec["paths"]["/users"]["get"];
862        let params = list_op["parameters"].as_array().expect("params array");
863
864        let param_names: Vec<&str> = params.iter().filter_map(|p| p["name"].as_str()).collect();
865
866        assert!(param_names.contains(&"filter[role]"), "filter[role] param");
867    }
868
869    #[test]
870    fn search_param_documented() {
871        let config = test_config();
872        let resources = vec![sample_resource()];
873        let spec = generate(&config, &resources);
874
875        let list_op = &spec["paths"]["/users"]["get"];
876        let params = list_op["parameters"].as_array().expect("params array");
877
878        let param_names: Vec<&str> = params.iter().filter_map(|p| p["name"].as_str()).collect();
879
880        assert!(param_names.contains(&"search"), "search param");
881    }
882
883    #[test]
884    fn standard_error_responses() {
885        let config = test_config();
886        let resources = vec![sample_resource()];
887        let spec = generate(&config, &resources);
888
889        // Check create endpoint has 401, 403, 422, 429, 500
890        let create_op = &spec["paths"]["/users"]["post"];
891        let responses = create_op["responses"].as_object().expect("responses");
892
893        assert!(responses.contains_key("401"), "401 Unauthorized");
894        assert!(responses.contains_key("403"), "403 Forbidden");
895        assert!(responses.contains_key("422"), "422 Validation error");
896        assert!(responses.contains_key("429"), "429 Rate limited");
897        assert!(responses.contains_key("500"), "500 Internal server error");
898
899        // Check get (list) has 401, 403, 429, 500 but NOT 404 (no :id)
900        let list_op = &spec["paths"]["/users"]["get"];
901        let list_responses = list_op["responses"].as_object().expect("responses");
902        assert!(!list_responses.contains_key("404"), "list has no 404");
903
904        // Check update has 404 (has :id)
905        let update_op = &spec["paths"]["/users/{id}"]["patch"];
906        let update_responses = update_op["responses"].as_object().expect("responses");
907        assert!(update_responses.contains_key("404"), "update has 404");
908    }
909
910    #[test]
911    fn vendor_extensions() {
912        let config = test_config();
913        let resources = vec![sample_resource()];
914        let spec = generate(&config, &resources);
915
916        let create_op = &spec["paths"]["/users"]["post"];
917        assert_eq!(
918            create_op["x-shaperail-hooks"],
919            serde_json::json!(["validate_org"])
920        );
921        assert_eq!(
922            create_op["x-shaperail-events"],
923            serde_json::json!(["user.created"])
924        );
925    }
926
927    #[test]
928    fn enum_values_in_schema() {
929        let config = test_config();
930        let resources = vec![sample_resource()];
931        let spec = generate(&config, &resources);
932
933        let role_prop = &spec["components"]["schemas"]["Users"]["properties"]["role"];
934        assert_eq!(
935            role_prop["enum"],
936            serde_json::json!(["admin", "member", "viewer"])
937        );
938        assert_eq!(role_prop["default"], serde_json::json!("member"));
939    }
940
941    #[test]
942    fn input_schemas_generated() {
943        let config = test_config();
944        let resources = vec![sample_resource()];
945        let spec = generate(&config, &resources);
946
947        let schemas = spec["components"]["schemas"].as_object().expect("schemas");
948        assert!(
949            schemas.contains_key("UsersCreateInput"),
950            "create input schema"
951        );
952        assert!(
953            schemas.contains_key("UsersUpdateInput"),
954            "update input schema"
955        );
956    }
957
958    #[test]
959    fn request_body_references_input_schema() {
960        let config = test_config();
961        let resources = vec![sample_resource()];
962        let spec = generate(&config, &resources);
963
964        let create_op = &spec["paths"]["/users"]["post"];
965        let schema_ref = &create_op["requestBody"]["content"]["application/json"]["schema"]["$ref"];
966        assert_eq!(schema_ref, "#/components/schemas/UsersCreateInput");
967    }
968
969    #[test]
970    fn security_on_authenticated_endpoints() {
971        let config = test_config();
972        let resources = vec![sample_resource()];
973        let spec = generate(&config, &resources);
974
975        let list_op = &spec["paths"]["/users"]["get"];
976        assert!(
977            list_op["security"].is_array(),
978            "auth endpoints have security"
979        );
980    }
981
982    #[test]
983    fn string_constraints_in_schema() {
984        let config = test_config();
985        let resources = vec![sample_resource()];
986        let spec = generate(&config, &resources);
987
988        let name_prop = &spec["components"]["schemas"]["Users"]["properties"]["name"];
989        assert_eq!(name_prop["minLength"], 1);
990        assert_eq!(name_prop["maxLength"], 200);
991    }
992
993    #[test]
994    fn json_and_yaml_output() {
995        let config = test_config();
996        let resources = vec![sample_resource()];
997        let spec = generate(&config, &resources);
998
999        let json = to_json(&spec).expect("json");
1000        assert!(json.contains("\"openapi\": \"3.1.0\""));
1001
1002        let yaml = to_yaml(&spec).expect("yaml");
1003        assert!(yaml.contains("openapi: 3.1.0"));
1004    }
1005
1006    #[test]
1007    fn delete_returns_204() {
1008        let config = test_config();
1009        let resources = vec![sample_resource()];
1010        let spec = generate(&config, &resources);
1011
1012        let delete_op = &spec["paths"]["/users/{id}"]["delete"];
1013        let responses = delete_op["responses"].as_object().expect("responses");
1014        assert!(responses.contains_key("204"), "delete returns 204");
1015    }
1016
1017    #[test]
1018    fn list_response_envelope() {
1019        let config = test_config();
1020        let resources = vec![sample_resource()];
1021        let spec = generate(&config, &resources);
1022
1023        let list_resp = &spec["paths"]["/users"]["get"]["responses"]["200"]["content"]
1024            ["application/json"]["schema"];
1025        assert!(list_resp["properties"]["data"]["type"] == "array");
1026        assert!(list_resp["properties"]["meta"]["type"] == "object");
1027    }
1028
1029    #[test]
1030    fn error_response_schema_exists() {
1031        let config = test_config();
1032        let resources = vec![sample_resource()];
1033        let spec = generate(&config, &resources);
1034
1035        let schemas = spec["components"]["schemas"].as_object().expect("schemas");
1036        assert!(schemas.contains_key("ErrorResponse"));
1037
1038        let err = &schemas["ErrorResponse"];
1039        assert!(err["properties"]["error"]["properties"]["code"].is_object());
1040        assert!(err["properties"]["error"]["properties"]["status"].is_object());
1041        assert!(err["properties"]["error"]["properties"]["message"].is_object());
1042    }
1043}