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