Skip to main content

cufflink_types/
openapi.rs

1//! OpenAPI spec generation from a [`ServiceManifest`].
2//!
3//! This module is used by both the platform (server-side) and the CLI
4//! (local generation without deployment).
5
6use crate::{ColumnType, ServiceManifest, TableDefinition};
7use serde_json::{json, Map, Value};
8
9/// Generate a complete OpenAPI 3.1.0 specification from a service manifest.
10///
11/// `tenant_slug` is used to build the URL paths (`/svc/{tenant}/{service}/{table}`).
12/// For local generation (no deployment), pass any placeholder like `"local"`.
13pub fn generate_openapi(manifest: &ServiceManifest, tenant_slug: &str) -> Value {
14    let mut paths = Map::new();
15    let mut schemas = Map::new();
16
17    for table in &manifest.tables {
18        let base_path = format!("/svc/{}/{}/{}", tenant_slug, manifest.name, table.name);
19        let item_path = format!("{}/:id", base_path);
20        let schema_name = to_pascal_case(&table.name);
21        let create_schema_name = format!("{}Create", schema_name);
22
23        // Generate schema for the table
24        schemas.insert(schema_name.clone(), table_to_schema(table));
25        schemas.insert(create_schema_name.clone(), table_to_create_schema(table));
26
27        // List + Create endpoint
28        paths.insert(
29            base_path.clone(),
30            json!({
31                "get": {
32                    "summary": format!("List {}", table.name),
33                    "operationId": format!("list_{}", table.name),
34                    "tags": [table.name],
35                    "parameters": list_parameters(table),
36                    "responses": {
37                        "200": {
38                            "description": "Paginated list",
39                            "content": {
40                                "application/json": {
41                                    "schema": {
42                                        "type": "object",
43                                        "properties": {
44                                            "results": {
45                                                "type": "array",
46                                                "items": { "$ref": format!("#/components/schemas/{}", schema_name) }
47                                            },
48                                            "pagination": { "$ref": "#/components/schemas/Pagination" }
49                                        }
50                                    }
51                                }
52                            }
53                        }
54                    }
55                },
56                "post": {
57                    "summary": format!("Create {}", singular(&table.name)),
58                    "operationId": format!("create_{}", singular(&table.name)),
59                    "tags": [table.name],
60                    "requestBody": {
61                        "required": true,
62                        "content": {
63                            "application/json": {
64                                "schema": { "$ref": format!("#/components/schemas/{}", create_schema_name) }
65                            }
66                        }
67                    },
68                    "responses": {
69                        "200": {
70                            "description": "Created",
71                            "content": {
72                                "application/json": {
73                                    "schema": { "$ref": format!("#/components/schemas/{}", schema_name) }
74                                }
75                            }
76                        }
77                    }
78                }
79            }),
80        );
81
82        // Get, Update, Delete endpoint
83        paths.insert(
84            item_path,
85            json!({
86                "get": {
87                    "summary": format!("Get {} by ID", singular(&table.name)),
88                    "operationId": format!("get_{}", singular(&table.name)),
89                    "tags": [table.name],
90                    "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}],
91                    "responses": {
92                        "200": {
93                            "description": "Found",
94                            "content": {
95                                "application/json": {
96                                    "schema": { "$ref": format!("#/components/schemas/{}", schema_name) }
97                                }
98                            }
99                        },
100                        "404": { "description": "Not found" }
101                    }
102                },
103                "put": {
104                    "summary": format!("Update {}", singular(&table.name)),
105                    "operationId": format!("update_{}", singular(&table.name)),
106                    "tags": [table.name],
107                    "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}],
108                    "requestBody": {
109                        "required": true,
110                        "content": {
111                            "application/json": {
112                                "schema": { "$ref": format!("#/components/schemas/{}", create_schema_name) }
113                            }
114                        }
115                    },
116                    "responses": {
117                        "200": {
118                            "description": "Updated",
119                            "content": {
120                                "application/json": {
121                                    "schema": { "$ref": format!("#/components/schemas/{}", schema_name) }
122                                }
123                            }
124                        }
125                    }
126                },
127                "delete": {
128                    "summary": format!("Delete {}", singular(&table.name)),
129                    "operationId": format!("delete_{}", singular(&table.name)),
130                    "tags": [table.name],
131                    "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}],
132                    "responses": {
133                        "200": { "description": "Deleted" }
134                    }
135                }
136            }),
137        );
138    }
139
140    // Add custom WASM handler routes
141    for route in &manifest.custom_routes {
142        let full_path = format!(
143            "/svc/{}/{}/_fn/{}",
144            tenant_slug, manifest.name, route.handler
145        );
146        let method = route.method.to_lowercase();
147        let operation_id = route.handler.clone();
148
149        // Extract path parameters from :param segments
150        let mut parameters = Vec::new();
151        for segment in route.path.split('/') {
152            if let Some(param) = segment.strip_prefix(':') {
153                parameters.push(json!({
154                    "name": param,
155                    "in": "path",
156                    "required": true,
157                    "schema": { "type": "string" }
158                }));
159            }
160        }
161
162        let mut operation = json!({
163            "summary": format!("Custom: {}", route.handler),
164            "operationId": operation_id,
165            "tags": ["custom"],
166            "parameters": parameters,
167            "responses": {
168                "200": {
169                    "description": "Success",
170                    "content": {
171                        "application/json": {
172                            "schema": { "type": "object" }
173                        }
174                    }
175                }
176            }
177        });
178
179        // Add request body for POST/PUT/PATCH methods
180        if matches!(method.as_str(), "post" | "put" | "patch") {
181            operation["requestBody"] = json!({
182                "required": false,
183                "content": {
184                    "application/json": {
185                        "schema": { "type": "object" }
186                    }
187                }
188            });
189        }
190
191        let path_entry = paths.entry(full_path).or_insert_with(|| json!({}));
192        if let Some(obj) = path_entry.as_object_mut() {
193            obj.insert(method, operation);
194        }
195    }
196
197    // Add Pagination schema
198    schemas.insert(
199        "Pagination".into(),
200        json!({
201            "type": "object",
202            "properties": {
203                "current_page": { "type": "integer" },
204                "per_page": { "type": "integer" },
205                "total_records": { "type": "integer" },
206                "total_pages": { "type": "integer" }
207            }
208        }),
209    );
210
211    json!({
212        "openapi": "3.1.0",
213        "info": {
214            "title": manifest.name,
215            "version": manifest.version.as_deref().unwrap_or("0.1.0")
216        },
217        "paths": Value::Object(paths),
218        "components": {
219            "schemas": Value::Object(schemas)
220        }
221    })
222}
223
224fn column_to_openapi_type(col_type: &ColumnType) -> Value {
225    match col_type {
226        ColumnType::Uuid => json!({"type": "string", "format": "uuid"}),
227        ColumnType::Text => json!({"type": "string"}),
228        ColumnType::Integer => json!({"type": "integer", "format": "int32"}),
229        ColumnType::BigInteger => json!({"type": "integer", "format": "int64"}),
230        ColumnType::Float => json!({"type": "number", "format": "float"}),
231        ColumnType::Double => json!({"type": "number", "format": "double"}),
232        ColumnType::Boolean => json!({"type": "boolean"}),
233        ColumnType::Timestamp => json!({"type": "string", "format": "date-time"}),
234        ColumnType::Date => json!({"type": "string", "format": "date"}),
235        ColumnType::Jsonb => json!({"type": "object"}),
236    }
237}
238
239fn table_to_schema(table: &TableDefinition) -> Value {
240    let mut properties = Map::new();
241    let mut required = Vec::new();
242
243    for col in &table.columns {
244        let mut prop = column_to_openapi_type(&col.column_type);
245        if col.nullable {
246            if let Some(obj) = prop.as_object_mut() {
247                obj.insert("nullable".into(), json!(true));
248            }
249        }
250        properties.insert(col.name.clone(), prop);
251        if !col.nullable {
252            required.push(json!(col.name));
253        }
254    }
255
256    json!({
257        "type": "object",
258        "properties": Value::Object(properties),
259        "required": required
260    })
261}
262
263fn table_to_create_schema(table: &TableDefinition) -> Value {
264    let mut properties = Map::new();
265    let mut required = Vec::new();
266
267    for col in &table.columns {
268        if col.auto_generate {
269            continue;
270        }
271        let mut prop = column_to_openapi_type(&col.column_type);
272        if col.nullable {
273            if let Some(obj) = prop.as_object_mut() {
274                obj.insert("nullable".into(), json!(true));
275            }
276        }
277        properties.insert(col.name.clone(), prop);
278        if !col.nullable && col.default_value.is_none() {
279            required.push(json!(col.name));
280        }
281    }
282
283    json!({
284        "type": "object",
285        "properties": Value::Object(properties),
286        "required": required
287    })
288}
289
290fn list_parameters(table: &TableDefinition) -> Vec<Value> {
291    let mut params = vec![
292        json!({"name": "page", "in": "query", "schema": {"type": "integer"}, "description": "Page number (default: 1)"}),
293        json!({"name": "per_page", "in": "query", "schema": {"type": "integer"}, "description": "Items per page (default: 50, max: 200)"}),
294        json!({"name": "sort", "in": "query", "schema": {"type": "string"}, "description": "Sort by column (prefix - for DESC)"}),
295        json!({"name": "filter", "in": "query", "schema": {"type": "string"}, "description": "JSON filter array"}),
296    ];
297
298    // Add query params for each filterable column
299    for col in &table.columns {
300        params.push(json!({
301            "name": col.name,
302            "in": "query",
303            "schema": column_to_openapi_type(&col.column_type),
304            "description": format!("Filter by {}", col.name)
305        }));
306    }
307
308    params
309}
310
311fn to_pascal_case(s: &str) -> String {
312    s.split('_')
313        .map(|word| {
314            let mut chars = word.chars();
315            match chars.next() {
316                Some(c) => c.to_uppercase().to_string() + &chars.as_str().to_lowercase(),
317                None => String::new(),
318            }
319        })
320        .collect()
321}
322
323fn singular(s: &str) -> String {
324    if s.ends_with('s') && s.len() > 1 {
325        s[..s.len() - 1].to_string()
326    } else {
327        s.to_string()
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334    use crate::{
335        ColumnDefinition, ColumnType, CustomRouteDefinition, ServiceManifest, ServiceMode,
336        TableDefinition,
337    };
338
339    fn test_manifest() -> ServiceManifest {
340        ServiceManifest {
341            name: "test-service".into(),
342            version: Some("1.0.0".into()),
343            tables: vec![TableDefinition {
344                name: "todos".into(),
345                columns: vec![
346                    ColumnDefinition {
347                        name: "id".into(),
348                        column_type: ColumnType::Uuid,
349                        primary_key: true,
350                        nullable: false,
351                        auto_generate: true,
352                        default_value: None,
353                        references: None,
354                        on_delete: None,
355                        unique: false,
356                        validations: vec![],
357                    },
358                    ColumnDefinition {
359                        name: "title".into(),
360                        column_type: ColumnType::Text,
361                        primary_key: false,
362                        nullable: false,
363                        auto_generate: false,
364                        default_value: None,
365                        references: None,
366                        on_delete: None,
367                        unique: false,
368                        validations: vec![],
369                    },
370                    ColumnDefinition {
371                        name: "done".into(),
372                        column_type: ColumnType::Boolean,
373                        primary_key: false,
374                        nullable: false,
375                        auto_generate: false,
376                        default_value: Some("false".into()),
377                        references: None,
378                        on_delete: None,
379                        unique: false,
380                        validations: vec![],
381                    },
382                ],
383                indexes: vec![],
384                soft_delete: false,
385                owner_field: None,
386                auth_required: false,
387                permission_area: None,
388                hooks: None,
389            }],
390            cells: vec![],
391            events: vec![],
392            subscriptions: vec![],
393            custom_routes: vec![],
394            mode: ServiceMode::Crud,
395            authorization: None,
396        }
397    }
398
399    #[test]
400    fn test_generates_valid_openapi() {
401        let manifest = test_manifest();
402        let spec = generate_openapi(&manifest, "test-tenant");
403
404        assert_eq!(spec["openapi"], "3.1.0");
405        assert_eq!(spec["info"]["title"], "test-service");
406        assert_eq!(spec["info"]["version"], "1.0.0");
407    }
408
409    #[test]
410    fn test_generates_crud_paths() {
411        let manifest = test_manifest();
412        let spec = generate_openapi(&manifest, "acme");
413
414        let paths = spec["paths"].as_object().unwrap();
415        assert!(paths.contains_key("/svc/acme/test-service/todos"));
416        assert!(paths.contains_key("/svc/acme/test-service/todos/:id"));
417    }
418
419    #[test]
420    fn test_generates_schemas() {
421        let manifest = test_manifest();
422        let spec = generate_openapi(&manifest, "acme");
423
424        let schemas = spec["components"]["schemas"].as_object().unwrap();
425        assert!(schemas.contains_key("Todos"));
426        assert!(schemas.contains_key("TodosCreate"));
427        assert!(schemas.contains_key("Pagination"));
428    }
429
430    #[test]
431    fn test_create_schema_excludes_auto_generated() {
432        let manifest = test_manifest();
433        let spec = generate_openapi(&manifest, "acme");
434
435        let create_props = spec["components"]["schemas"]["TodosCreate"]["properties"]
436            .as_object()
437            .unwrap();
438        // "id" is auto_generate=true, should be excluded from create schema
439        assert!(!create_props.contains_key("id"));
440        assert!(create_props.contains_key("title"));
441    }
442
443    #[test]
444    fn test_custom_routes() {
445        let mut manifest = test_manifest();
446        manifest.custom_routes.push(CustomRouteDefinition {
447            method: "POST".into(),
448            path: "/complete/:id".into(),
449            handler: "complete_todo".into(),
450        });
451
452        let spec = generate_openapi(&manifest, "acme");
453        let paths = spec["paths"].as_object().unwrap();
454        assert!(paths.contains_key("/svc/acme/test-service/_fn/complete_todo"));
455    }
456
457    #[test]
458    fn test_default_version() {
459        let mut manifest = test_manifest();
460        manifest.version = None;
461        let spec = generate_openapi(&manifest, "acme");
462        assert_eq!(spec["info"]["version"], "0.1.0");
463    }
464
465    #[test]
466    fn test_local_slug() {
467        let manifest = test_manifest();
468        let spec = generate_openapi(&manifest, "local");
469
470        let paths = spec["paths"].as_object().unwrap();
471        assert!(paths.contains_key("/svc/local/test-service/todos"));
472    }
473}