Skip to main content

cortex_runtime/compiler/
codegen_openapi.rs

1//! OpenAPI 3.0 spec generator.
2//!
3//! Generates a YAML OpenAPI specification from the compiled schema.
4
5use crate::compiler::models::*;
6
7/// Generate an OpenAPI 3.0 YAML specification.
8pub fn generate_openapi(schema: &CompiledSchema) -> String {
9    let mut out = String::new();
10    let domain = &schema.domain;
11
12    // Info section
13    out.push_str(&format!(
14        r#"openapi: 3.0.3
15info:
16  title: {domain} — Auto-Generated API
17  description: API compiled by Cortex Web Compiler from {domain}'s public structured data
18  version: "1.0"
19  contact:
20    name: Cortex Web Compiler
21"#
22    ));
23
24    // Paths
25    out.push_str("paths:\n");
26
27    for model in &schema.models {
28        if model.instance_count <= 1 {
29            continue;
30        }
31
32        let path_name = to_url_path(&model.name);
33
34        // GET /models — list/search
35        out.push_str(&format!("  /{path_name}:\n"));
36        out.push_str("    get:\n");
37        out.push_str(&format!("      summary: Search {}s\n", model.name));
38        out.push_str(&format!("      operationId: search{}\n", model.name));
39        out.push_str("      parameters:\n");
40        out.push_str("        - name: query\n");
41        out.push_str("          in: query\n");
42        out.push_str("          schema:\n");
43        out.push_str("            type: string\n");
44        out.push_str("        - name: limit\n");
45        out.push_str("          in: query\n");
46        out.push_str("          schema:\n");
47        out.push_str("            type: integer\n");
48        out.push_str("            default: 20\n");
49
50        // Add filter params for numeric fields
51        for field in &model.fields {
52            if field.feature_dim.is_some() {
53                match field.field_type {
54                    FieldType::Float | FieldType::Integer => {
55                        out.push_str(&format!("        - name: {}_min\n", field.name));
56                        out.push_str("          in: query\n");
57                        out.push_str("          schema:\n");
58                        out.push_str(&format!(
59                            "            type: {}\n",
60                            if field.field_type == FieldType::Integer {
61                                "integer"
62                            } else {
63                                "number"
64                            }
65                        ));
66                        out.push_str(&format!("        - name: {}_max\n", field.name));
67                        out.push_str("          in: query\n");
68                        out.push_str("          schema:\n");
69                        out.push_str(&format!(
70                            "            type: {}\n",
71                            if field.field_type == FieldType::Integer {
72                                "integer"
73                            } else {
74                                "number"
75                            }
76                        ));
77                    }
78                    _ => {}
79                }
80            }
81        }
82
83        out.push_str("      responses:\n");
84        out.push_str("        '200':\n");
85        out.push_str("          description: Successful response\n");
86        out.push_str("          content:\n");
87        out.push_str("            application/json:\n");
88        out.push_str("              schema:\n");
89        out.push_str("                type: array\n");
90        out.push_str("                items:\n");
91        out.push_str(&format!(
92            "                  $ref: '#/components/schemas/{}'\n",
93            model.name
94        ));
95
96        // GET /models/{nodeId} — get by ID
97        out.push_str(&format!("  /{path_name}/{{nodeId}}:\n"));
98        out.push_str("    get:\n");
99        out.push_str(&format!("      summary: Get {} by node ID\n", model.name));
100        out.push_str(&format!("      operationId: get{}\n", model.name));
101        out.push_str("      parameters:\n");
102        out.push_str("        - name: nodeId\n");
103        out.push_str("          in: path\n");
104        out.push_str("          required: true\n");
105        out.push_str("          schema:\n");
106        out.push_str("            type: integer\n");
107        out.push_str("      responses:\n");
108        out.push_str("        '200':\n");
109        out.push_str("          description: Successful response\n");
110        out.push_str("          content:\n");
111        out.push_str("            application/json:\n");
112        out.push_str("              schema:\n");
113        out.push_str(&format!(
114            "                $ref: '#/components/schemas/{}'\n",
115            model.name
116        ));
117    }
118
119    // Action paths
120    for action in &schema.actions {
121        if action.is_instance_method {
122            let model_path = to_url_path(&action.belongs_to);
123            let action_path = action.name.replace('_', "-");
124            out.push_str(&format!("  /{model_path}/{{nodeId}}/{action_path}:\n"));
125            out.push_str(&format!("    {}:\n", action.http_method.to_lowercase()));
126            out.push_str(&format!(
127                "      summary: {} on {}\n",
128                action.name.replace('_', " "),
129                action.belongs_to
130            ));
131            out.push_str(&format!(
132                "      operationId: {}{}\n",
133                action.name, action.belongs_to
134            ));
135            out.push_str("      parameters:\n");
136            out.push_str("        - name: nodeId\n");
137            out.push_str("          in: path\n");
138            out.push_str("          required: true\n");
139            out.push_str("          schema:\n");
140            out.push_str("            type: integer\n");
141
142            if !action.params.is_empty() {
143                let non_node_params: Vec<&ActionParam> = action
144                    .params
145                    .iter()
146                    .filter(|p| p.name != "node_id")
147                    .collect();
148                if !non_node_params.is_empty() && action.http_method == "POST" {
149                    out.push_str("      requestBody:\n");
150                    out.push_str("        content:\n");
151                    out.push_str("          application/json:\n");
152                    out.push_str("            schema:\n");
153                    out.push_str("              type: object\n");
154                    out.push_str("              properties:\n");
155                    for param in &non_node_params {
156                        out.push_str(&format!("                {}:\n", param.name));
157                        let type_str = match param.param_type {
158                            FieldType::Integer => "integer",
159                            FieldType::Float => "number",
160                            FieldType::Bool => "boolean",
161                            _ => "string",
162                        };
163                        out.push_str(&format!("                  type: {type_str}\n"));
164                    }
165                }
166            }
167
168            out.push_str("      responses:\n");
169            out.push_str("        '200':\n");
170            out.push_str("          description: Action completed\n");
171        }
172    }
173
174    // Component schemas
175    out.push_str("components:\n");
176    out.push_str("  schemas:\n");
177
178    for model in &schema.models {
179        out.push_str(&format!("    {}:\n", model.name));
180        out.push_str("      type: object\n");
181        out.push_str("      properties:\n");
182
183        for field in &model.fields {
184            out.push_str(&format!("        {}:\n", field.name));
185            match &field.field_type {
186                FieldType::String | FieldType::Url => {
187                    out.push_str("          type: string\n");
188                }
189                FieldType::Float => {
190                    out.push_str("          type: number\n");
191                }
192                FieldType::Integer => {
193                    out.push_str("          type: integer\n");
194                }
195                FieldType::Bool => {
196                    out.push_str("          type: boolean\n");
197                }
198                FieldType::DateTime => {
199                    out.push_str("          type: string\n");
200                    out.push_str("          format: date-time\n");
201                }
202                FieldType::Enum(variants) => {
203                    out.push_str("          type: string\n");
204                    out.push_str("          enum:\n");
205                    for v in variants {
206                        out.push_str(&format!("            - {v}\n"));
207                    }
208                }
209                FieldType::Array(inner) => {
210                    out.push_str("          type: array\n");
211                    out.push_str("          items:\n");
212                    let inner_type = match inner.as_ref() {
213                        FieldType::String | FieldType::Url => "string",
214                        FieldType::Float => "number",
215                        FieldType::Integer => "integer",
216                        _ => "string",
217                    };
218                    out.push_str(&format!("            type: {inner_type}\n"));
219                }
220                FieldType::Object(name) => {
221                    out.push_str(&format!("          $ref: '#/components/schemas/{name}'\n"));
222                }
223            }
224        }
225
226        // Required fields
227        let required: Vec<&str> = model
228            .fields
229            .iter()
230            .filter(|f| !f.nullable)
231            .map(|f| f.name.as_str())
232            .collect();
233        if !required.is_empty() {
234            out.push_str("      required:\n");
235            for r in &required {
236                out.push_str(&format!("        - {r}\n"));
237            }
238        }
239    }
240
241    out
242}
243
244/// Convert a model name to a URL path segment.
245fn to_url_path(name: &str) -> String {
246    let mut result = String::new();
247    for (i, c) in name.chars().enumerate() {
248        if c.is_uppercase() && i > 0 {
249            result.push('-');
250        }
251        result.push(c.to_lowercase().next().unwrap_or(c));
252    }
253    result.push('s');
254    result
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use chrono::Utc;
261
262    fn test_schema() -> CompiledSchema {
263        CompiledSchema {
264            domain: "test.com".to_string(),
265            compiled_at: Utc::now(),
266            models: vec![DataModel {
267                name: "Product".to_string(),
268                schema_org_type: "Product".to_string(),
269                fields: vec![
270                    ModelField {
271                        name: "name".to_string(),
272                        field_type: FieldType::String,
273                        source: FieldSource::JsonLd,
274                        confidence: 0.99,
275                        nullable: false,
276                        example_values: vec![],
277                        feature_dim: None,
278                    },
279                    ModelField {
280                        name: "price".to_string(),
281                        field_type: FieldType::Float,
282                        source: FieldSource::JsonLd,
283                        confidence: 0.99,
284                        nullable: true,
285                        example_values: vec![],
286                        feature_dim: Some(48),
287                    },
288                ],
289                instance_count: 50,
290                example_urls: vec![],
291                search_action: None,
292                list_url: None,
293            }],
294            actions: vec![],
295            relationships: vec![],
296            stats: SchemaStats {
297                total_models: 1,
298                total_fields: 2,
299                total_instances: 50,
300                avg_confidence: 0.99,
301            },
302        }
303    }
304
305    #[test]
306    fn test_generate_openapi() {
307        let spec = generate_openapi(&test_schema());
308        assert!(spec.contains("openapi: 3.0.3"));
309        assert!(spec.contains("test.com"));
310        assert!(spec.contains("/products:"));
311        assert!(spec.contains("Product:"));
312        assert!(spec.contains("type: number"));
313    }
314
315    #[test]
316    fn test_to_url_path() {
317        assert_eq!(to_url_path("Product"), "products");
318        assert_eq!(to_url_path("ForumPost"), "forum-posts");
319        assert_eq!(to_url_path("FAQ"), "f-a-qs");
320    }
321}