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 build_standard_op(op: &IrOperation, return_type: &str) -> minijinja::Value {
115    let (params_sig, path_params, query_params_obj, has_body, has_path_params, has_query_params) =
116        build_params(op);
117
118    context! {
119        kind => "standard",
120        method_name => op.name.camel_case.clone(),
121        http_method => op.method.as_str(),
122        path => op.path.clone(),
123        params_signature => params_sig,
124        return_type => return_type,
125        path_params => path_params,
126        query_params_obj => query_params_obj,
127        has_body => has_body,
128        has_path_params => has_path_params,
129        has_query_params => has_query_params,
130        summary => op.summary.clone(),
131        description => op.description.clone(),
132        deprecated => op.deprecated,
133    }
134}
135
136fn build_void_op(op: &IrOperation) -> minijinja::Value {
137    let (params_sig, path_params, query_params_obj, has_body, has_path_params, has_query_params) =
138        build_params(op);
139
140    context! {
141        kind => "void",
142        method_name => op.name.camel_case.clone(),
143        http_method => op.method.as_str(),
144        path => op.path.clone(),
145        params_signature => params_sig,
146        return_type => "void",
147        path_params => path_params,
148        query_params_obj => query_params_obj,
149        has_body => has_body,
150        has_path_params => has_path_params,
151        has_query_params => has_query_params,
152        summary => op.summary.clone(),
153        description => op.description.clone(),
154        deprecated => op.deprecated,
155    }
156}
157
158fn build_sse_op(op: &IrOperation, return_type: &str, method_name: &str) -> minijinja::Value {
159    let (mut parts, path_params, query_params_obj, has_body, has_path_params, has_query_params) =
160        build_params_raw(op);
161
162    // For SSE, use SSEOptions instead of RequestOptions
163    if let Some(last) = parts.last_mut()
164        && last.starts_with("options?")
165    {
166        *last = "options?: SSEOptions".to_string();
167    }
168    let params_sig = parts.join(", ");
169
170    context! {
171        kind => "sse",
172        method_name => method_name,
173        http_method => op.method.as_str(),
174        path => op.path.clone(),
175        params_signature => params_sig,
176        return_type => return_type,
177        path_params => path_params,
178        query_params_obj => query_params_obj,
179        has_body => has_body,
180        has_path_params => has_path_params,
181        has_query_params => has_query_params,
182        summary => op.summary.clone(),
183        description => op.description.clone(),
184        deprecated => op.deprecated,
185    }
186}
187
188fn build_params(op: &IrOperation) -> (String, Vec<minijinja::Value>, String, bool, bool, bool) {
189    let (parts, path_params, query_params_obj, has_body, has_path_params, has_query_params) =
190        build_params_raw(op);
191    (
192        parts.join(", "),
193        path_params,
194        query_params_obj,
195        has_body,
196        has_path_params,
197        has_query_params,
198    )
199}
200
201fn build_params_raw(
202    op: &IrOperation,
203) -> (Vec<String>, Vec<minijinja::Value>, String, bool, bool, bool) {
204    let mut parts = Vec::new();
205    let mut path_params = Vec::new();
206    let mut query_parts = Vec::new();
207
208    for param in &op.parameters {
209        if param.location == IrParameterLocation::Path {
210            let ts_type = ir_type_to_ts(&param.param_type);
211            parts.push(format!("{}: {}", param.name.camel_case, ts_type));
212            path_params.push(context! {
213                name => param.name.camel_case.clone(),
214                original_name => param.original_name.clone(),
215            });
216        }
217    }
218
219    for param in &op.parameters {
220        if param.location == IrParameterLocation::Query {
221            let ts_type = ir_type_to_ts(&param.param_type);
222            if param.required {
223                parts.push(format!("{}: {}", param.name.camel_case, ts_type));
224            } else {
225                parts.push(format!("{}?: {}", param.name.camel_case, ts_type));
226            }
227            query_parts.push(format!(
228                "{}: String({})",
229                param.original_name, param.name.camel_case
230            ));
231        }
232    }
233
234    let has_body = op.request_body.is_some();
235    if let Some(ref body) = op.request_body {
236        let ts_type = ir_type_to_ts(&body.body_type);
237        if body.required {
238            parts.push(format!("body: {ts_type}"));
239        } else {
240            parts.push(format!("body?: {ts_type}"));
241        }
242    }
243
244    parts.push("options?: RequestOptions".to_string());
245
246    let has_path_params = !path_params.is_empty();
247    let has_query_params = !query_parts.is_empty();
248    let query_params_obj = query_parts.join(", ");
249
250    (
251        parts,
252        path_params,
253        query_params_obj,
254        has_body,
255        has_path_params,
256        has_query_params,
257    )
258}
259
260fn collect_imported_types<'a>(ops: impl Iterator<Item = &'a IrOperation>) -> Vec<String> {
261    let mut types = HashSet::new();
262
263    for op in ops {
264        collect_types_from_return(&op.return_type, &mut types);
265
266        if let Some(ref body) = op.request_body {
267            collect_types_from_ir_type(&body.body_type, &mut types);
268        }
269
270        for param in &op.parameters {
271            collect_types_from_ir_type(&param.param_type, &mut types);
272        }
273    }
274
275    let mut sorted: Vec<String> = types.into_iter().collect();
276    sorted.sort();
277    sorted
278}
279
280fn collect_types_from_return(ret: &IrReturnType, types: &mut HashSet<String>) {
281    match ret {
282        IrReturnType::Standard(resp) => {
283            collect_types_from_ir_type(&resp.response_type, types);
284        }
285        IrReturnType::Sse(sse) => {
286            if let Some(ref name) = sse.event_type_name {
287                types.insert(name.clone());
288            } else {
289                collect_types_from_ir_type(&sse.event_type, types);
290            }
291            // SSE variant types are only used in type definitions (types.ts),
292            // not in client method signatures — skip them here.
293            if let Some(ref json) = sse.json_response {
294                collect_types_from_ir_type(&json.response_type, types);
295            }
296        }
297        IrReturnType::Void => {}
298    }
299}
300
301fn collect_types_from_ir_type(ir_type: &IrType, types: &mut HashSet<String>) {
302    match ir_type {
303        IrType::Ref(name) => {
304            types.insert(name.clone());
305        }
306        IrType::Array(inner) | IrType::Map(inner) => collect_types_from_ir_type(inner, types),
307        IrType::Union(variants) => {
308            for v in variants {
309                collect_types_from_ir_type(v, types);
310            }
311        }
312        IrType::Object(fields) => {
313            for (_, ty, _) in fields {
314                collect_types_from_ir_type(ty, types);
315            }
316        }
317        _ => {}
318    }
319}