Skip to main content

oag_node_client/emitters/
client.rs

1use 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
8/// Escape `*/` sequences that would prematurely close JSDoc comment blocks.
9fn escape_jsdoc(value: String) -> String {
10    value.replace("*/", "*\\/")
11}
12
13/// Emit `client.ts` — the API client class with REST and SSE methods.
14pub 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    // Build and deduplicate operations, tracking which source ops survived.
23    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    // Only collect types from operations that contributed surviving methods.
55    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    // For SSE, use SSEOptions instead of RequestOptions
174    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(&param.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(&param.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            // SSE variant types are only used in type definitions (types.ts),
334            // not in client method signatures — skip them here.
335            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}