1pub mod bundled;
2pub mod context;
3pub mod pack;
4pub mod resolve;
5pub mod type_map;
6
7use minijinja::Environment;
8
9use crate::config::{GeneratorConfig, OutputLayout, SplitBy};
10use crate::ir::{IrSpec, group_operations};
11use crate::{GenerateOutput, GeneratedFile, GeneratorError, normalize_generated};
12
13use self::pack::{FilterConfig, TemplatePack};
14
15pub fn generate(
20 ir: &IrSpec,
21 config: &GeneratorConfig,
22 pack: &TemplatePack,
23) -> Result<GenerateOutput, GeneratorError> {
24 let tm = &pack.manifest.type_map;
25 let field_casing = &pack.manifest.pack.field_casing;
26 let operation_casing = &pack.manifest.pack.operation_casing;
27
28 let ctx = context::build_context(ir, tm, field_casing, operation_casing);
30
31 let mut env = Environment::new();
33 env.set_trim_blocks(true);
34
35 for (name, content) in &pack.templates {
37 env.add_template(name, content)
38 .map_err(|e| GeneratorError::Render(format!("template '{name}': {e}")))?;
39 }
40
41 for (name, filter_config) in &pack.manifest.filters {
43 register_filter(&mut env, name, filter_config);
44 }
45
46 let scaffold_ctx = config
48 .scaffold
49 .as_ref()
50 .map(minijinja::Value::from_serialize)
51 .unwrap_or(minijinja::Value::UNDEFINED);
52
53 let source_dir = &config.source_dir;
54 let no_jsdoc = config.no_jsdoc.unwrap_or(false);
55
56 let config_ctx = minijinja::context! {
57 scaffold => scaffold_ctx,
58 source_dir => source_dir,
59 no_jsdoc => no_jsdoc,
60 base_url => config.base_url.clone(),
61 };
62
63 let render_ctx = minijinja::context! {
65 ..ctx,
66 ..config_ctx,
67 };
68
69 let mut source_files = match config.layout {
70 OutputLayout::Bundled => render_bundled(&env, pack, &render_ctx, source_dir)?,
71 OutputLayout::Modular => render_modular(&env, pack, &render_ctx, source_dir)?,
72 OutputLayout::Split => {
73 let split_by = config.split_by.unwrap_or(SplitBy::Tag);
74 render_split(&env, pack, &render_ctx, source_dir, ir, no_jsdoc, split_by)?
75 }
76 };
77
78 let mut scaffold_files = render_scaffold(&env, pack, &render_ctx, source_dir)?;
79
80 for file in &mut source_files {
82 file.content = normalize_generated(&file.content);
83 }
84 for file in &mut scaffold_files {
85 file.content = normalize_generated(&file.content);
86 }
87
88 Ok(GenerateOutput {
89 source_files,
90 scaffold_files,
91 })
92}
93
94fn register_filter(env: &mut Environment, name: &str, config: &FilterConfig) {
95 let replace = config.replace.clone();
96 let with = config.with.clone();
97 env.add_filter(name.to_string(), move |value: String| -> String {
98 value.replace(&replace, &with)
99 });
100}
101
102fn render_modular(
103 env: &Environment,
104 pack: &TemplatePack,
105 ctx: &minijinja::Value,
106 source_dir: &str,
107) -> Result<Vec<GeneratedFile>, GeneratorError> {
108 let layout =
109 pack.manifest.layouts.modular.as_ref().ok_or_else(|| {
110 GeneratorError::Other("pack has no modular layout defined".to_string())
111 })?;
112
113 let mut files = Vec::new();
114 for file_def in &layout.files {
115 let path = file_def.path.replace("{source_dir}", source_dir);
116 let path = normalize_path(&path);
117
118 if let Some(ref when) = file_def.when
120 && !eval_condition(env, when, ctx)
121 {
122 continue;
123 }
124
125 let content = if env.get_template(&file_def.template).is_ok() {
127 let tmpl = env.get_template(&file_def.template).unwrap();
128 tmpl.render(ctx)
129 .map_err(|e| GeneratorError::Render(format!("{}: {e}", file_def.template)))?
130 } else {
131 pack.templates
133 .get(&file_def.template)
134 .cloned()
135 .unwrap_or_default()
136 };
137
138 files.push(GeneratedFile { path, content });
139 }
140
141 Ok(files)
142}
143
144fn render_bundled(
145 env: &Environment,
146 pack: &TemplatePack,
147 ctx: &minijinja::Value,
148 source_dir: &str,
149) -> Result<Vec<GeneratedFile>, GeneratorError> {
150 let layout =
151 pack.manifest.layouts.bundled.as_ref().ok_or_else(|| {
152 GeneratorError::Other("pack has no bundled layout defined".to_string())
153 })?;
154
155 let mut rendered_sections = Vec::new();
156 for section in &layout.sections {
157 let tmpl = env
158 .get_template(§ion.template)
159 .map_err(|e| GeneratorError::Render(format!("{}: {e}", section.template)))?;
160 let content = tmpl
161 .render(ctx)
162 .map_err(|e| GeneratorError::Render(format!("{}: {e}", section.template)))?;
163 rendered_sections.push((section.label.as_str(), content));
164 }
165
166 let bundled_content = bundled::bundle_sections(layout, rendered_sections);
167 let path = layout.output_path.replace("{source_dir}", source_dir);
168 let path = normalize_path(&path);
169
170 Ok(vec![GeneratedFile {
171 path,
172 content: bundled_content,
173 }])
174}
175
176fn render_split(
177 env: &Environment,
178 pack: &TemplatePack,
179 ctx: &minijinja::Value,
180 source_dir: &str,
181 ir: &IrSpec,
182 _no_jsdoc: bool,
183 split_by: SplitBy,
184) -> Result<Vec<GeneratedFile>, GeneratorError> {
185 let layout = pack
186 .manifest
187 .layouts
188 .split
189 .as_ref()
190 .ok_or_else(|| GeneratorError::Other("pack has no split layout defined".to_string()))?;
191
192 let mut files = Vec::new();
193
194 for file_def in &layout.shared_files {
196 let path = file_def.path.replace("{source_dir}", source_dir);
197 let path = normalize_path(&path);
198 let tmpl = env
199 .get_template(&file_def.template)
200 .map_err(|e| GeneratorError::Render(format!("{}: {e}", file_def.template)))?;
201 let content = tmpl
202 .render(ctx)
203 .map_err(|e| GeneratorError::Render(format!("{}: {e}", file_def.template)))?;
204 files.push(GeneratedFile { path, content });
205 }
206
207 let groups = group_operations(ir, split_by);
212 let mut group_names = Vec::new();
213
214 for group in &groups {
215 let group_file_name =
216 normalize_path(&format!("{}/{}.ts", source_dir, group.name.snake_case));
217
218 let op_names: Vec<&str> = group
220 .operation_indices
221 .iter()
222 .map(|&i| ir.operations[i].name.camel_case.as_str())
223 .collect();
224
225 let mut lines = Vec::new();
226 lines.push("// Auto-generated by oag — do not edit".to_string());
227 lines.push(format!("// Operations group: {}", group.name.original));
228 lines.push(String::new());
229 lines.push("// This group contains the following operations:".to_string());
230 for name in &op_names {
231 lines.push(format!("// - {name}"));
232 }
233 lines.push(String::new());
234 lines.push("// Import the client and call the relevant methods:".to_string());
235 lines.push("// import { ApiClient } from \"./client\";".to_string());
236 lines.push(String::new());
237 lines.push("export { ApiClient } from \"./client\";".to_string());
238 lines.push("export * from \"./types\";".to_string());
239
240 group_names.push(group.name.snake_case.clone());
241 files.push(GeneratedFile {
242 path: group_file_name,
243 content: lines.join("\n") + "\n",
244 });
245 }
246
247 let mut index_lines = vec![
249 "// Auto-generated by oag — do not edit".to_string(),
250 "export * from \"./types\";".to_string(),
251 "export * from \"./guards\";".to_string(),
252 "export { ApiClient, type ClientConfig, type RequestOptions } from \"./client\";"
253 .to_string(),
254 "export { streamSse, SSEError, type SSEOptions } from \"./sse\";".to_string(),
255 ];
256 for name in &group_names {
257 index_lines.push(format!("export * from \"./{name}\";"));
258 }
259 let index_path = normalize_path(&format!("{}/index.ts", source_dir));
260 files.push(GeneratedFile {
261 path: index_path,
262 content: index_lines.join("\n") + "\n",
263 });
264
265 Ok(files)
266}
267
268fn render_scaffold(
269 env: &Environment,
270 pack: &TemplatePack,
271 ctx: &minijinja::Value,
272 source_dir: &str,
273) -> Result<Vec<GeneratedFile>, GeneratorError> {
274 let has_scaffold = ctx
276 .get_attr("scaffold")
277 .ok()
278 .is_some_and(|v| !v.is_undefined());
279 if !has_scaffold {
280 return Ok(Vec::new());
281 }
282
283 let mut files = Vec::new();
284
285 for file_def in &pack.manifest.scaffold.files {
287 if let Some(ref when) = file_def.when
288 && !eval_condition(env, when, ctx)
289 {
290 continue;
291 }
292 let path = file_def.path.replace("{source_dir}", source_dir);
293 let path = normalize_path(&path);
294
295 let content = if let Ok(tmpl) = env.get_template(&file_def.template) {
296 tmpl.render(ctx)
297 .map_err(|e| GeneratorError::Render(format!("{}: {e}", file_def.template)))?
298 } else {
299 pack.templates
300 .get(&file_def.template)
301 .cloned()
302 .unwrap_or_default()
303 };
304
305 files.push(GeneratedFile { path, content });
306 }
307
308 for file_def in &pack.manifest.scaffold.test_files {
310 if let Some(ref when) = file_def.when
311 && !eval_condition(env, when, ctx)
312 {
313 continue;
314 }
315 let path = file_def.path.replace("{source_dir}", source_dir);
316 let path = normalize_path(&path);
317
318 let content = if let Ok(tmpl) = env.get_template(&file_def.template) {
319 tmpl.render(ctx)
320 .map_err(|e| GeneratorError::Render(format!("{}: {e}", file_def.template)))?
321 } else {
322 pack.templates
323 .get(&file_def.template)
324 .cloned()
325 .unwrap_or_default()
326 };
327
328 files.push(GeneratedFile { path, content });
329 }
330
331 Ok(files)
332}
333
334fn eval_condition(env: &Environment, condition: &str, ctx: &minijinja::Value) -> bool {
336 let expr_template = format!("{{% if {condition} %}}true{{% endif %}}");
338 let mut temp_env = env.clone();
339 if temp_env
340 .add_template("__condition__", &expr_template)
341 .is_err()
342 {
343 return false;
344 }
345 let Ok(tmpl) = temp_env.get_template("__condition__") else {
346 return false;
347 };
348 tmpl.render(ctx).ok().is_some_and(|s| s.trim() == "true")
349}
350
351fn normalize_path(path: &str) -> String {
353 let path = path.replace("//", "/");
354 let path = path.strip_prefix("./").unwrap_or(&path);
355 if let Some(stripped) = path.strip_prefix('/') {
356 stripped.to_string()
357 } else {
358 path.to_string()
359 }
360}