Skip to main content

intent_codegen/
openapi.rs

1//! OpenAPI 3.0 spec generator for IntentLang specifications.
2//!
3//! Generates an OpenAPI 3.0.3 JSON document from a parsed `.intent` AST.
4//! The output matches the REST API served by `intent serve`:
5//! - Each action becomes `POST /actions/{ActionName}`
6//! - Each entity becomes a JSON Schema component
7//! - Contracts (requires/ensures/invariants) are documented in descriptions
8
9use intent_parser::ast;
10use serde_json::{Map, Value, json};
11
12use crate::{doc_text, format_ensures_item, format_expr, to_snake_case};
13
14/// Generate an OpenAPI 3.0.3 spec from a parsed intent file.
15pub fn generate(file: &ast::File) -> Value {
16    let mut schemas = Map::new();
17    let mut paths = Map::new();
18
19    // Collect entity names for $ref resolution
20    let entity_names: Vec<String> = file
21        .items
22        .iter()
23        .filter_map(|item| match item {
24            ast::TopLevelItem::Entity(e) => Some(e.name.clone()),
25            _ => None,
26        })
27        .collect();
28
29    // Collect invariant descriptions for action documentation
30    let invariants: Vec<&ast::InvariantDecl> = file
31        .items
32        .iter()
33        .filter_map(|item| match item {
34            ast::TopLevelItem::Invariant(inv) => Some(inv),
35            _ => None,
36        })
37        .collect();
38
39    for item in &file.items {
40        match item {
41            ast::TopLevelItem::Entity(entity) => {
42                let schema = entity_to_schema(entity, &entity_names);
43                schemas.insert(entity.name.clone(), schema);
44            }
45            ast::TopLevelItem::Action(action) => {
46                let path = action_to_path(action, &entity_names, &invariants);
47                let route = format!("/actions/{}", action.name);
48                paths.insert(route, path);
49            }
50            _ => {}
51        }
52    }
53
54    // Add shared schemas: Violation, ActionResult
55    schemas.insert("Violation".to_string(), violation_schema());
56    schemas.insert("ActionResult".to_string(), action_result_schema());
57
58    let mut info = Map::new();
59    info.insert(
60        "title".to_string(),
61        json!(format!("{} API", file.module.name)),
62    );
63    info.insert("version".to_string(), json!("0.1.0"));
64    if let Some(doc) = &file.doc {
65        info.insert("description".to_string(), json!(doc_text(doc)));
66    }
67
68    json!({
69        "openapi": "3.0.3",
70        "info": Value::Object(info),
71        "paths": Value::Object(paths),
72        "components": {
73            "schemas": Value::Object(schemas)
74        }
75    })
76}
77
78// ── Entity → JSON Schema ──────────────────────────────────────
79
80fn entity_to_schema(entity: &ast::EntityDecl, entity_names: &[String]) -> Value {
81    let mut properties = Map::new();
82    let mut required = Vec::new();
83
84    for field in &entity.fields {
85        let schema = type_to_schema(&field.ty, entity_names);
86        properties.insert(field.name.clone(), schema);
87        if !field.ty.optional {
88            required.push(json!(field.name));
89        }
90    }
91
92    let mut schema = Map::new();
93    schema.insert("type".to_string(), json!("object"));
94    schema.insert("properties".to_string(), Value::Object(properties));
95    if !required.is_empty() {
96        schema.insert("required".to_string(), Value::Array(required));
97    }
98    if let Some(doc) = &entity.doc {
99        schema.insert("description".to_string(), json!(doc_text(doc)));
100    }
101
102    Value::Object(schema)
103}
104
105// ── Type → JSON Schema ───────────────────────────────────────
106
107fn type_to_schema(ty: &ast::TypeExpr, entity_names: &[String]) -> Value {
108    let base = type_kind_to_schema(&ty.ty, entity_names);
109    if ty.optional {
110        // OpenAPI 3.0: nullable flag
111        if let Value::Object(mut obj) = base {
112            obj.insert("nullable".to_string(), json!(true));
113            Value::Object(obj)
114        } else {
115            base
116        }
117    } else {
118        base
119    }
120}
121
122fn type_kind_to_schema(kind: &ast::TypeKind, entity_names: &[String]) -> Value {
123    match kind {
124        ast::TypeKind::Simple(name) => simple_type_schema(name, entity_names),
125        ast::TypeKind::Parameterized { name, .. } => simple_type_schema(name, entity_names),
126        ast::TypeKind::Union(variants) => {
127            let names: Vec<&str> = variants
128                .iter()
129                .filter_map(|v| match v {
130                    ast::TypeKind::Simple(n) => Some(n.as_str()),
131                    _ => None,
132                })
133                .collect();
134            json!({
135                "type": "string",
136                "enum": names
137            })
138        }
139        ast::TypeKind::List(inner) => {
140            json!({
141                "type": "array",
142                "items": type_to_schema(inner, entity_names)
143            })
144        }
145        ast::TypeKind::Set(inner) => {
146            json!({
147                "type": "array",
148                "items": type_to_schema(inner, entity_names),
149                "uniqueItems": true
150            })
151        }
152        ast::TypeKind::Map(_k, v) => {
153            json!({
154                "type": "object",
155                "additionalProperties": type_to_schema(v, entity_names)
156            })
157        }
158    }
159}
160
161fn simple_type_schema(name: &str, entity_names: &[String]) -> Value {
162    match name {
163        "UUID" => json!({ "type": "string", "format": "uuid" }),
164        "String" => json!({ "type": "string" }),
165        "Int" => json!({ "type": "integer", "format": "int64" }),
166        "Decimal" => json!({ "type": "number" }),
167        "Bool" => json!({ "type": "boolean" }),
168        "DateTime" => json!({ "type": "string", "format": "date-time" }),
169        "Email" => json!({ "type": "string", "format": "email" }),
170        "URL" => json!({ "type": "string", "format": "uri" }),
171        "CurrencyCode" => json!({ "type": "string" }),
172        other => {
173            if entity_names.contains(&other.to_string()) {
174                json!({ "$ref": format!("#/components/schemas/{other}") })
175            } else {
176                json!({ "type": "string" })
177            }
178        }
179    }
180}
181
182// ── Action → Path Item ──────────────────────────────────────
183
184fn action_to_path(
185    action: &ast::ActionDecl,
186    entity_names: &[String],
187    invariants: &[&ast::InvariantDecl],
188) -> Value {
189    let mut description_parts = Vec::new();
190
191    if let Some(doc) = &action.doc {
192        description_parts.push(doc_text(doc));
193    }
194
195    // Document preconditions
196    if let Some(req) = &action.requires {
197        description_parts.push("**Preconditions (requires):**".to_string());
198        for cond in &req.conditions {
199            description_parts.push(format!("- `{}`", format_expr(cond)));
200        }
201    }
202
203    // Document postconditions
204    if let Some(ens) = &action.ensures {
205        description_parts.push("**Postconditions (ensures):**".to_string());
206        for item in &ens.items {
207            description_parts.push(format!("- `{}`", format_ensures_item(item)));
208        }
209    }
210
211    // Document properties
212    if let Some(props) = &action.properties {
213        description_parts.push("**Properties:**".to_string());
214        for entry in &props.entries {
215            description_parts.push(format!(
216                "- {}: {}",
217                entry.key,
218                crate::format_prop_value(&entry.value)
219            ));
220        }
221    }
222
223    // Document relevant invariants
224    if !invariants.is_empty() {
225        description_parts.push("**Invariants:**".to_string());
226        for inv in invariants {
227            let mut line = format!("- {}", inv.name);
228            if let Some(doc) = &inv.doc {
229                line.push_str(&format!(": {}", doc_text(doc)));
230            }
231            description_parts.push(line);
232        }
233    }
234
235    let description = description_parts.join("\n\n");
236
237    // Build request body schema
238    let request_schema = action_request_schema(action, entity_names);
239
240    let fn_name = to_snake_case(&action.name);
241
242    json!({
243        "post": {
244            "operationId": fn_name,
245            "summary": format!("Execute the {} action", action.name),
246            "description": description,
247            "requestBody": {
248                "required": true,
249                "content": {
250                    "application/json": {
251                        "schema": request_schema
252                    }
253                }
254            },
255            "responses": {
256                "200": {
257                    "description": "Action executed successfully, all contracts satisfied",
258                    "content": {
259                        "application/json": {
260                            "schema": { "$ref": "#/components/schemas/ActionResult" }
261                        }
262                    }
263                },
264                "422": {
265                    "description": "Contract violation (precondition, postcondition, or invariant failed)",
266                    "content": {
267                        "application/json": {
268                            "schema": { "$ref": "#/components/schemas/ActionResult" }
269                        }
270                    }
271                },
272                "400": {
273                    "description": "Malformed request or runtime error"
274                }
275            }
276        }
277    })
278}
279
280fn action_request_schema(action: &ast::ActionDecl, entity_names: &[String]) -> Value {
281    let mut param_properties = Map::new();
282    let mut param_required = Vec::new();
283
284    for param in &action.params {
285        let schema = type_to_schema(&param.ty, entity_names);
286        param_properties.insert(param.name.clone(), schema);
287        if !param.ty.optional {
288            param_required.push(json!(param.name));
289        }
290    }
291
292    let mut params_schema = Map::new();
293    params_schema.insert("type".to_string(), json!("object"));
294    params_schema.insert("properties".to_string(), Value::Object(param_properties));
295    if !param_required.is_empty() {
296        params_schema.insert("required".to_string(), Value::Array(param_required));
297    }
298
299    json!({
300        "type": "object",
301        "required": ["params"],
302        "properties": {
303            "params": Value::Object(params_schema),
304            "state": {
305                "type": "object",
306                "description": "Entity instances keyed by type name (for quantifier/invariant evaluation)",
307                "additionalProperties": {
308                    "type": "array",
309                    "items": {}
310                }
311            }
312        }
313    })
314}
315
316// ── Shared schemas ─────────────────────────────────────────
317
318fn violation_schema() -> Value {
319    json!({
320        "type": "object",
321        "required": ["kind", "message"],
322        "properties": {
323            "kind": {
324                "type": "string",
325                "enum": ["precondition_failed", "postcondition_failed", "invariant_violated", "edge_guard_triggered"]
326            },
327            "message": {
328                "type": "string"
329            }
330        }
331    })
332}
333
334fn action_result_schema() -> Value {
335    json!({
336        "type": "object",
337        "required": ["ok", "new_params", "violations"],
338        "properties": {
339            "ok": {
340                "type": "boolean",
341                "description": "Whether all contracts were satisfied"
342            },
343            "new_params": {
344                "type": "object",
345                "description": "Updated parameter values after postcondition application",
346                "additionalProperties": {}
347            },
348            "violations": {
349                "type": "array",
350                "items": {
351                    "$ref": "#/components/schemas/Violation"
352                },
353                "description": "List of contract violations (empty if ok is true)"
354            }
355        }
356    })
357}