Skip to main content

shaperail_codegen/
typescript.rs

1use std::collections::BTreeMap;
2
3/// Generate TypeScript type definitions from an OpenAPI 3.1 spec.
4///
5/// Reads the `components.schemas` section and produces one interface per schema.
6/// This generates types that match what `openapi-typescript` would produce from
7/// the same spec — the spec JSON is also written alongside so users can run
8/// `npx openapi-typescript openapi.json -o types.ts` if they prefer.
9pub fn generate_from_spec(spec: &serde_json::Value) -> BTreeMap<String, String> {
10    let mut files: BTreeMap<String, String> = BTreeMap::new();
11
12    let schemas = match spec
13        .get("components")
14        .and_then(|c| c.get("schemas"))
15        .and_then(|s| s.as_object())
16    {
17        Some(s) => s,
18        None => return files,
19    };
20
21    let mut all_interfaces = Vec::new();
22
23    // Sort schema names for deterministic output
24    let mut schema_names: Vec<&String> = schemas.keys().collect();
25    schema_names.sort();
26
27    for name in &schema_names {
28        let schema = &schemas[*name];
29        if let Some(interface) = schema_to_interface(name, schema) {
30            all_interfaces.push(interface);
31        }
32    }
33
34    // Generate per-resource files by grouping schemas
35    // Collect resource names from paths tags
36    let mut resource_schemas: BTreeMap<String, Vec<String>> = BTreeMap::new();
37
38    if let Some(paths) = spec.get("paths").and_then(|p| p.as_object()) {
39        for (_path, methods) in paths {
40            if let Some(methods_obj) = methods.as_object() {
41                for (_method, operation) in methods_obj {
42                    if let Some(tags) = operation.get("tags").and_then(|t| t.as_array()) {
43                        if let Some(tag) = tags.first().and_then(|t| t.as_str()) {
44                            let pascal = to_pascal_case(tag);
45                            // Add the main schema and any input schemas
46                            for schema_name in &schema_names {
47                                if schema_name.starts_with(&pascal) {
48                                    resource_schemas
49                                        .entry(tag.to_string())
50                                        .or_default()
51                                        .push((*schema_name).clone());
52                                }
53                            }
54                        }
55                    }
56                }
57            }
58        }
59    }
60
61    // Deduplicate schema lists
62    for schemas_list in resource_schemas.values_mut() {
63        schemas_list.sort();
64        schemas_list.dedup();
65    }
66
67    // Generate per-resource .ts files
68    for (resource_name, schema_list) in &resource_schemas {
69        let mut content = String::new();
70        for schema_name in schema_list {
71            if let Some(schema) = schemas.get(schema_name) {
72                if let Some(interface) = schema_to_interface(schema_name, schema) {
73                    content.push_str(&interface);
74                    content.push('\n');
75                }
76            }
77        }
78        if !content.is_empty() {
79            files.insert(format!("{resource_name}.ts"), content);
80        }
81    }
82
83    // Generate index.ts that re-exports everything
84    let index: String = resource_schemas
85        .keys()
86        .map(|r| format!("export * from './{r}';"))
87        .collect::<Vec<_>>()
88        .join("\n");
89    if !index.is_empty() {
90        files.insert("index.ts".to_string(), format!("{index}\n"));
91    }
92
93    files
94}
95
96fn schema_to_interface(name: &str, schema: &serde_json::Value) -> Option<String> {
97    let properties = schema.get("properties")?.as_object()?;
98    let required_fields: Vec<&str> = schema
99        .get("required")
100        .and_then(|r| r.as_array())
101        .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
102        .unwrap_or_default();
103
104    let mut fields = Vec::new();
105
106    // Sort property names for deterministic output
107    let mut prop_names: Vec<&String> = properties.keys().collect();
108    prop_names.sort();
109
110    for prop_name in prop_names {
111        let prop = &properties[prop_name];
112        let ts_type = openapi_type_to_ts(prop);
113        let optional = if required_fields.contains(&prop_name.as_str()) {
114            ""
115        } else {
116            "?"
117        };
118        fields.push(format!("  {prop_name}{optional}: {ts_type};"));
119    }
120
121    Some(format!(
122        "export interface {name} {{\n{}\n}}\n",
123        fields.join("\n")
124    ))
125}
126
127fn openapi_type_to_ts(schema: &serde_json::Value) -> &'static str {
128    // Check for $ref — treat as unknown
129    if schema.get("$ref").is_some() {
130        return "unknown";
131    }
132
133    let type_val = schema.get("type").and_then(|t| t.as_str());
134    let format_val = schema.get("format").and_then(|f| f.as_str());
135
136    match (type_val, format_val) {
137        (Some("string"), _) => "string",
138        (Some("integer"), _) | (Some("number"), _) => "number",
139        (Some("boolean"), _) => "boolean",
140        (Some("array"), _) => "unknown[]",
141        (Some("object"), _) => "Record<string, unknown>",
142        _ => "unknown",
143    }
144}
145
146fn to_pascal_case(s: &str) -> String {
147    s.split('_')
148        .map(|word| {
149            let mut chars = word.chars();
150            match chars.next() {
151                None => String::new(),
152                Some(c) => {
153                    let upper: String = c.to_uppercase().collect();
154                    upper + &chars.as_str().to_lowercase()
155                }
156            }
157        })
158        .collect()
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use indexmap::IndexMap;
165    use shaperail_core::{
166        AuthRule, CacheSpec, EndpointSpec, FieldSchema, FieldType, HttpMethod, PaginationStyle,
167        ResourceDefinition,
168    };
169
170    fn test_config() -> shaperail_core::ProjectConfig {
171        shaperail_core::ProjectConfig {
172            project: "test-api".to_string(),
173            port: 3000,
174            workers: shaperail_core::WorkerCount::Auto,
175            database: None,
176            databases: None,
177            cache: None,
178            auth: None,
179            storage: None,
180            logging: None,
181            events: None,
182            protocols: vec!["rest".to_string()],
183            graphql: None,
184            grpc: None,
185        }
186    }
187
188    fn sample_resource() -> ResourceDefinition {
189        let mut schema = IndexMap::new();
190        schema.insert(
191            "id".to_string(),
192            FieldSchema {
193                field_type: FieldType::Uuid,
194                primary: true,
195                generated: true,
196                required: false,
197                unique: false,
198                nullable: false,
199                reference: None,
200                min: None,
201                max: None,
202                format: None,
203                values: None,
204                default: None,
205                sensitive: false,
206                search: false,
207                items: None,
208            },
209        );
210        schema.insert(
211            "name".to_string(),
212            FieldSchema {
213                field_type: FieldType::String,
214                primary: false,
215                generated: false,
216                required: true,
217                unique: false,
218                nullable: false,
219                reference: None,
220                min: None,
221                max: None,
222                format: None,
223                values: None,
224                default: None,
225                sensitive: false,
226                search: false,
227                items: None,
228            },
229        );
230        schema.insert(
231            "active".to_string(),
232            FieldSchema {
233                field_type: FieldType::Boolean,
234                primary: false,
235                generated: false,
236                required: false,
237                unique: false,
238                nullable: true,
239                reference: None,
240                min: None,
241                max: None,
242                format: None,
243                values: None,
244                default: None,
245                sensitive: false,
246                search: false,
247                items: None,
248            },
249        );
250
251        let mut endpoints = IndexMap::new();
252        endpoints.insert(
253            "list".to_string(),
254            EndpointSpec {
255                method: HttpMethod::Get,
256                path: "/items".to_string(),
257                auth: Some(AuthRule::Roles(vec!["member".to_string()])),
258                input: None,
259                filters: None,
260                search: None,
261                pagination: Some(PaginationStyle::Cursor),
262                sort: None,
263                cache: Some(CacheSpec {
264                    ttl: 60,
265                    invalidate_on: None,
266                }),
267                controller: None,
268                events: None,
269                jobs: None,
270                upload: None,
271                soft_delete: false,
272            },
273        );
274        endpoints.insert(
275            "create".to_string(),
276            EndpointSpec {
277                method: HttpMethod::Post,
278                path: "/items".to_string(),
279                auth: Some(AuthRule::Roles(vec!["admin".to_string()])),
280                input: Some(vec!["name".to_string(), "active".to_string()]),
281                filters: None,
282                search: None,
283                pagination: None,
284                sort: None,
285                cache: None,
286                controller: None,
287                events: None,
288                jobs: None,
289                upload: None,
290                soft_delete: false,
291            },
292        );
293
294        ResourceDefinition {
295            resource: "items".to_string(),
296            version: 1,
297            db: None,
298            schema,
299            endpoints: Some(endpoints),
300            relations: None,
301            indexes: None,
302        }
303    }
304
305    #[test]
306    fn generates_ts_from_openapi_spec() {
307        let config = test_config();
308        let resources = vec![sample_resource()];
309        let spec = crate::openapi::generate(&config, &resources);
310        let files = generate_from_spec(&spec);
311
312        assert!(files.contains_key("items.ts"), "items.ts generated");
313        assert!(files.contains_key("index.ts"), "index.ts generated");
314    }
315
316    #[test]
317    fn ts_contains_interfaces() {
318        let config = test_config();
319        let resources = vec![sample_resource()];
320        let spec = crate::openapi::generate(&config, &resources);
321        let files = generate_from_spec(&spec);
322
323        let items_ts = &files["items.ts"];
324        assert!(
325            items_ts.contains("export interface Items"),
326            "main interface"
327        );
328        assert!(
329            items_ts.contains("export interface ItemsCreateInput"),
330            "input interface"
331        );
332    }
333
334    #[test]
335    fn ts_field_types_correct() {
336        let config = test_config();
337        let resources = vec![sample_resource()];
338        let spec = crate::openapi::generate(&config, &resources);
339        let files = generate_from_spec(&spec);
340
341        let items_ts = &files["items.ts"];
342        assert!(items_ts.contains("id?: string;"), "uuid → optional string");
343        assert!(items_ts.contains("name: string;"), "required string");
344        assert!(
345            items_ts.contains("active?: boolean;"),
346            "nullable boolean optional"
347        );
348    }
349
350    #[test]
351    fn ts_index_reexports() {
352        let config = test_config();
353        let resources = vec![sample_resource()];
354        let spec = crate::openapi::generate(&config, &resources);
355        let files = generate_from_spec(&spec);
356
357        let index = &files["index.ts"];
358        assert!(index.contains("export * from './items';"));
359    }
360
361    #[test]
362    fn deterministic_ts_output() {
363        let config = test_config();
364        let resources = vec![sample_resource()];
365        let spec = crate::openapi::generate(&config, &resources);
366
367        let files1 = generate_from_spec(&spec);
368        let files2 = generate_from_spec(&spec);
369
370        assert_eq!(files1, files2, "TS SDK output must be deterministic");
371    }
372}