cortex_runtime/compiler/
codegen_openapi.rs1use crate::compiler::models::*;
6
7pub fn generate_openapi(schema: &CompiledSchema) -> String {
9 let mut out = String::new();
10 let domain = &schema.domain;
11
12 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 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 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 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 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 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 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 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
244fn 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}