1use std::collections::BTreeMap;
2
3pub 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 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 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 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 for schemas_list in resource_schemas.values_mut() {
63 schemas_list.sort();
64 schemas_list.dedup();
65 }
66
67 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 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 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 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 transient: false,
209 },
210 );
211 schema.insert(
212 "name".to_string(),
213 FieldSchema {
214 field_type: FieldType::String,
215 primary: false,
216 generated: false,
217 required: true,
218 unique: false,
219 nullable: false,
220 reference: None,
221 min: None,
222 max: None,
223 format: None,
224 values: None,
225 default: None,
226 sensitive: false,
227 search: false,
228 items: None,
229 transient: false,
230 },
231 );
232 schema.insert(
233 "active".to_string(),
234 FieldSchema {
235 field_type: FieldType::Boolean,
236 primary: false,
237 generated: false,
238 required: false,
239 unique: false,
240 nullable: true,
241 reference: None,
242 min: None,
243 max: None,
244 format: None,
245 values: None,
246 default: None,
247 sensitive: false,
248 search: false,
249 items: None,
250 transient: false,
251 },
252 );
253
254 let mut endpoints = IndexMap::new();
255 endpoints.insert(
256 "list".to_string(),
257 EndpointSpec {
258 method: Some(HttpMethod::Get),
259 path: Some("/items".to_string()),
260 auth: Some(AuthRule::Roles(vec!["member".to_string()])),
261 pagination: Some(PaginationStyle::Cursor),
262 cache: Some(CacheSpec {
263 ttl: 60,
264 invalidate_on: None,
265 }),
266 ..Default::default()
267 },
268 );
269 endpoints.insert(
270 "create".to_string(),
271 EndpointSpec {
272 method: Some(HttpMethod::Post),
273 path: Some("/items".to_string()),
274 auth: Some(AuthRule::Roles(vec!["admin".to_string()])),
275 input: Some(vec!["name".to_string(), "active".to_string()]),
276 ..Default::default()
277 },
278 );
279
280 ResourceDefinition {
281 resource: "items".to_string(),
282 version: 1,
283 db: None,
284 tenant_key: None,
285 schema,
286 endpoints: Some(endpoints),
287 relations: None,
288 indexes: None,
289 }
290 }
291
292 #[test]
293 fn generates_ts_from_openapi_spec() {
294 let config = test_config();
295 let resources = vec![sample_resource()];
296 let spec = crate::openapi::generate(&config, &resources);
297 let files = generate_from_spec(&spec);
298
299 assert!(files.contains_key("items.ts"), "items.ts generated");
300 assert!(files.contains_key("index.ts"), "index.ts generated");
301 }
302
303 #[test]
304 fn ts_contains_interfaces() {
305 let config = test_config();
306 let resources = vec![sample_resource()];
307 let spec = crate::openapi::generate(&config, &resources);
308 let files = generate_from_spec(&spec);
309
310 let items_ts = &files["items.ts"];
311 assert!(
312 items_ts.contains("export interface Items"),
313 "main interface"
314 );
315 assert!(
316 items_ts.contains("export interface ItemsCreateInput"),
317 "input interface"
318 );
319 }
320
321 #[test]
322 fn ts_field_types_correct() {
323 let config = test_config();
324 let resources = vec![sample_resource()];
325 let spec = crate::openapi::generate(&config, &resources);
326 let files = generate_from_spec(&spec);
327
328 let items_ts = &files["items.ts"];
329 assert!(items_ts.contains("id?: string;"), "uuid → optional string");
330 assert!(items_ts.contains("name: string;"), "required string");
331 assert!(
332 items_ts.contains("active?: boolean;"),
333 "nullable boolean optional"
334 );
335 }
336
337 #[test]
338 fn ts_index_reexports() {
339 let config = test_config();
340 let resources = vec![sample_resource()];
341 let spec = crate::openapi::generate(&config, &resources);
342 let files = generate_from_spec(&spec);
343
344 let index = &files["index.ts"];
345 assert!(index.contains("export * from './items';"));
346 }
347
348 #[test]
349 fn sensitive_field_omitted_from_ts_response_interface() {
350 let config = test_config();
351 let mut resource = sample_resource();
352 resource.schema.get_mut("active").unwrap().sensitive = true;
353 let spec = crate::openapi::generate(&config, &[resource]);
354 let files = generate_from_spec(&spec);
355
356 let items_ts = &files["items.ts"];
357
358 let response_block = items_ts
360 .split("export interface Items {")
361 .nth(1)
362 .and_then(|s| s.split('}').next())
363 .expect("Items interface body");
364 assert!(
365 !response_block.contains("active"),
366 "sensitive `active` must be absent from Items response interface, got: {response_block:?}"
367 );
368
369 let input_block = items_ts
371 .split("export interface ItemsCreateInput {")
372 .nth(1)
373 .and_then(|s| s.split('}').next())
374 .expect("ItemsCreateInput interface body");
375 assert!(
376 input_block.contains("active"),
377 "sensitive fields declared in input: must remain in request interface, got: {input_block:?}"
378 );
379 }
380
381 #[test]
382 fn deterministic_ts_output() {
383 let config = test_config();
384 let resources = vec![sample_resource()];
385 let spec = crate::openapi::generate(&config, &resources);
386
387 let files1 = generate_from_spec(&spec);
388 let files2 = generate_from_spec(&spec);
389
390 assert_eq!(files1, files2, "TS SDK output must be deterministic");
391 }
392}