oag_node_client/emitters/
client.rs1use std::collections::HashSet;
2
3use minijinja::{Environment, context};
4use oag_core::ir::{IrOperation, IrParameterLocation, IrReturnType, IrSpec, IrType};
5
6use crate::type_mapper::ir_type_to_ts;
7
8fn escape_jsdoc(value: String) -> String {
10 value.replace("*/", "*\\/")
11}
12
13pub fn emit_client(ir: &IrSpec, _no_jsdoc: bool) -> String {
15 let mut env = Environment::new();
16 env.set_trim_blocks(true);
17 env.add_filter("escape_jsdoc", escape_jsdoc);
18 env.add_template("client.ts.j2", include_str!("../../templates/client.ts.j2"))
19 .expect("template should be valid");
20 let tmpl = env.get_template("client.ts.j2").unwrap();
21
22 let mut seen_methods = HashSet::new();
24 let mut used_op_indices = HashSet::new();
25 let operations: Vec<minijinja::Value> = ir
26 .operations
27 .iter()
28 .enumerate()
29 .flat_map(|(idx, op)| {
30 build_operation_contexts(op)
31 .into_iter()
32 .map(move |ctx| (idx, ctx))
33 })
34 .filter(|(idx, ctx)| {
35 let name = ctx
36 .get_attr("method_name")
37 .ok()
38 .and_then(|v| v.as_str().map(String::from));
39 match name {
40 Some(n) => {
41 if seen_methods.insert(n) {
42 used_op_indices.insert(*idx);
43 true
44 } else {
45 false
46 }
47 }
48 None => true,
49 }
50 })
51 .map(|(_, ctx)| ctx)
52 .collect();
53
54 let imported_types = collect_imported_types(
56 ir.operations
57 .iter()
58 .enumerate()
59 .filter(|(i, _)| used_op_indices.contains(i))
60 .map(|(_, op)| op),
61 );
62
63 let has_sse = operations.iter().any(|op| {
64 op.get_attr("kind")
65 .ok()
66 .is_some_and(|v| v.as_str() == Some("sse"))
67 });
68
69 tmpl.render(context! {
70 title => ir.info.title.clone(),
71 imported_types => imported_types,
72 operations => operations,
73 has_sse => has_sse,
74 no_jsdoc => _no_jsdoc,
75 })
76 .expect("render should succeed")
77}
78
79fn build_operation_contexts(op: &IrOperation) -> Vec<minijinja::Value> {
80 let mut results = Vec::new();
81
82 match &op.return_type {
83 IrReturnType::Standard(resp) => {
84 results.push(build_standard_op(op, &ir_type_to_ts(&resp.response_type)));
85 }
86 IrReturnType::Void => {
87 results.push(build_void_op(op));
88 }
89 IrReturnType::Sse(sse) => {
90 let return_type = if let Some(ref name) = sse.event_type_name {
91 name.clone()
92 } else {
93 ir_type_to_ts(&sse.event_type)
94 };
95 let sse_name = if sse.also_has_json {
96 format!("{}Stream", op.name.camel_case)
97 } else {
98 op.name.camel_case.clone()
99 };
100 results.push(build_sse_op(op, &return_type, &sse_name));
101
102 if let Some(ref json_resp) = sse.json_response {
103 results.push(build_standard_op(
104 op,
105 &ir_type_to_ts(&json_resp.response_type),
106 ));
107 }
108 }
109 }
110
111 results
112}
113
114fn is_multipart_op(op: &IrOperation) -> bool {
115 op.request_body
116 .as_ref()
117 .is_some_and(|b| b.content_type == "multipart/form-data")
118}
119
120fn build_standard_op(op: &IrOperation, return_type: &str) -> minijinja::Value {
121 let result = build_params(op);
122
123 context! {
124 kind => "standard",
125 method_name => op.name.camel_case.clone(),
126 http_method => op.method.as_str(),
127 path => op.path.clone(),
128 params_signature => result.parts.join(", "),
129 return_type => return_type,
130 path_params => result.path_params,
131 query_params_obj => result.query_params_obj,
132 header_params_obj => result.header_params_obj,
133 has_body => result.has_body,
134 body_content_type => result.body_content_type.clone(),
135 is_multipart => is_multipart_op(op),
136 has_path_params => result.has_path_params,
137 has_query_params => result.has_query_params,
138 has_header_params => result.has_header_params,
139 summary => op.summary.clone(),
140 description => op.description.clone(),
141 deprecated => op.deprecated,
142 }
143}
144
145fn build_void_op(op: &IrOperation) -> minijinja::Value {
146 let result = build_params(op);
147
148 context! {
149 kind => "void",
150 method_name => op.name.camel_case.clone(),
151 http_method => op.method.as_str(),
152 path => op.path.clone(),
153 params_signature => result.parts.join(", "),
154 return_type => "void",
155 path_params => result.path_params,
156 query_params_obj => result.query_params_obj,
157 header_params_obj => result.header_params_obj,
158 has_body => result.has_body,
159 body_content_type => result.body_content_type.clone(),
160 is_multipart => is_multipart_op(op),
161 has_path_params => result.has_path_params,
162 has_query_params => result.has_query_params,
163 has_header_params => result.has_header_params,
164 summary => op.summary.clone(),
165 description => op.description.clone(),
166 deprecated => op.deprecated,
167 }
168}
169
170fn build_sse_op(op: &IrOperation, return_type: &str, method_name: &str) -> minijinja::Value {
171 let mut result = build_params_raw(op);
172
173 if let Some(last) = result.parts.last_mut()
175 && last.starts_with("options?")
176 {
177 *last = "options?: SSEOptions".to_string();
178 }
179 let params_sig = result.parts.join(", ");
180
181 context! {
182 kind => "sse",
183 method_name => method_name,
184 http_method => op.method.as_str(),
185 path => op.path.clone(),
186 params_signature => params_sig,
187 return_type => return_type,
188 path_params => result.path_params,
189 query_params_obj => result.query_params_obj,
190 header_params_obj => result.header_params_obj,
191 has_body => result.has_body,
192 body_content_type => result.body_content_type.clone(),
193 is_multipart => is_multipart_op(op),
194 has_path_params => result.has_path_params,
195 has_query_params => result.has_query_params,
196 has_header_params => result.has_header_params,
197 summary => op.summary.clone(),
198 description => op.description.clone(),
199 deprecated => op.deprecated,
200 }
201}
202
203struct ParamsResult {
204 parts: Vec<String>,
205 path_params: Vec<minijinja::Value>,
206 query_params_obj: String,
207 header_params_obj: String,
208 has_body: bool,
209 body_content_type: String,
210 has_path_params: bool,
211 has_query_params: bool,
212 has_header_params: bool,
213}
214
215fn build_params(op: &IrOperation) -> ParamsResult {
216 build_params_raw(op)
217}
218
219fn build_params_raw(op: &IrOperation) -> ParamsResult {
220 let mut required_parts = Vec::new();
221 let mut optional_parts = Vec::new();
222 let mut path_params = Vec::new();
223 let mut query_parts = Vec::new();
224 let mut header_parts = Vec::new();
225
226 for param in &op.parameters {
227 let ts_type = ir_type_to_ts(¶m.param_type);
228 match param.location {
229 IrParameterLocation::Path => {
230 required_parts.push(format!("{}: {}", param.name.camel_case, ts_type));
231 path_params.push(context! {
232 name => param.name.camel_case.clone(),
233 original_name => param.original_name.clone(),
234 });
235 }
236 IrParameterLocation::Query => {
237 if param.required {
238 required_parts.push(format!("{}: {}", param.name.camel_case, ts_type));
239 } else {
240 optional_parts.push(format!("{}?: {}", param.name.camel_case, ts_type));
241 }
242 query_parts.push(format!(
243 "\"{}\": {}",
244 param.original_name, param.name.camel_case
245 ));
246 }
247 IrParameterLocation::Header => {
248 if param.required {
249 required_parts.push(format!("{}: {}", param.name.camel_case, ts_type));
250 } else {
251 optional_parts.push(format!("{}?: {}", param.name.camel_case, ts_type));
252 }
253 header_parts.push(format!(
254 "\"{}\": {}",
255 param.original_name, param.name.camel_case
256 ));
257 }
258 _ => {}
259 }
260 }
261
262 let has_body = op.request_body.is_some();
263 let body_content_type = op
264 .request_body
265 .as_ref()
266 .map(|b| b.content_type.clone())
267 .unwrap_or_else(|| "application/json".to_string());
268
269 if let Some(ref body) = op.request_body {
270 let ts_type = ir_type_to_ts(&body.body_type);
271 if body.required {
272 required_parts.push(format!("body: {ts_type}"));
273 } else {
274 optional_parts.push(format!("body?: {ts_type}"));
275 }
276 }
277
278 optional_parts.push("options?: RequestOptions".to_string());
279
280 let mut parts = required_parts;
281 parts.extend(optional_parts);
282
283 let has_path_params = !path_params.is_empty();
284 let has_query_params = !query_parts.is_empty();
285 let has_header_params = !header_parts.is_empty();
286 let query_params_obj = query_parts.join(", ");
287 let header_params_obj = header_parts.join(", ");
288
289 ParamsResult {
290 parts,
291 path_params,
292 query_params_obj,
293 header_params_obj,
294 has_body,
295 body_content_type,
296 has_path_params,
297 has_query_params,
298 has_header_params,
299 }
300}
301
302fn collect_imported_types<'a>(ops: impl Iterator<Item = &'a IrOperation>) -> Vec<String> {
303 let mut types = HashSet::new();
304
305 for op in ops {
306 collect_types_from_return(&op.return_type, &mut types);
307
308 if let Some(ref body) = op.request_body {
309 collect_types_from_ir_type(&body.body_type, &mut types);
310 }
311
312 for param in &op.parameters {
313 collect_types_from_ir_type(¶m.param_type, &mut types);
314 }
315 }
316
317 let mut sorted: Vec<String> = types.into_iter().collect();
318 sorted.sort();
319 sorted
320}
321
322fn collect_types_from_return(ret: &IrReturnType, types: &mut HashSet<String>) {
323 match ret {
324 IrReturnType::Standard(resp) => {
325 collect_types_from_ir_type(&resp.response_type, types);
326 }
327 IrReturnType::Sse(sse) => {
328 if let Some(ref name) = sse.event_type_name {
329 types.insert(name.clone());
330 } else {
331 collect_types_from_ir_type(&sse.event_type, types);
332 }
333 if let Some(ref json) = sse.json_response {
336 collect_types_from_ir_type(&json.response_type, types);
337 }
338 }
339 IrReturnType::Void => {}
340 }
341}
342
343fn collect_types_from_ir_type(ir_type: &IrType, types: &mut HashSet<String>) {
344 match ir_type {
345 IrType::Ref(name) => {
346 types.insert(name.clone());
347 }
348 IrType::Array(inner) | IrType::Map(inner) => collect_types_from_ir_type(inner, types),
349 IrType::Union(variants) | IrType::Intersection(variants) => {
350 for v in variants {
351 collect_types_from_ir_type(v, types);
352 }
353 }
354 IrType::Object(fields) => {
355 for (_, ty, _) in fields {
356 collect_types_from_ir_type(ty, types);
357 }
358 }
359 _ => {}
360 }
361}