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.add_filter("escape_jsdoc", escape_jsdoc);
17    env.add_template("client.ts.j2", include_str!("../../templates/client.ts.j2"))
18        .expect("template should be valid");
19    let tmpl = env.get_template("client.ts.j2").unwrap();
20
21    let imported_types = collect_imported_types(ir);
22    let operations: Vec<minijinja::Value> = ir
23        .operations
24        .iter()
25        .flat_map(build_operation_contexts)
26        .collect();
27
28    tmpl.render(context! {
29        title => ir.info.title.clone(),
30        imported_types => imported_types,
31        operations => operations,
32        no_jsdoc => _no_jsdoc,
33    })
34    .expect("render should succeed")
35}
36
37fn build_operation_contexts(op: &IrOperation) -> Vec<minijinja::Value> {
38    let mut results = Vec::new();
39
40    match &op.return_type {
41        IrReturnType::Standard(resp) => {
42            results.push(build_standard_op(op, &ir_type_to_ts(&resp.response_type)));
43        }
44        IrReturnType::Void => {
45            results.push(build_void_op(op));
46        }
47        IrReturnType::Sse(sse) => {
48            let return_type = if let Some(ref name) = sse.event_type_name {
49                name.clone()
50            } else {
51                ir_type_to_ts(&sse.event_type)
52            };
53            let sse_name = if sse.also_has_json {
54                format!("{}Stream", op.name.camel_case)
55            } else {
56                op.name.camel_case.clone()
57            };
58            results.push(build_sse_op(op, &return_type, &sse_name));
59
60            if let Some(ref json_resp) = sse.json_response {
61                results.push(build_standard_op(
62                    op,
63                    &ir_type_to_ts(&json_resp.response_type),
64                ));
65            }
66        }
67    }
68
69    results
70}
71
72fn build_standard_op(op: &IrOperation, return_type: &str) -> minijinja::Value {
73    let (params_sig, path_params, query_params_obj, has_body, has_path_params, has_query_params) =
74        build_params(op);
75
76    context! {
77        kind => "standard",
78        method_name => op.name.camel_case.clone(),
79        http_method => op.method.as_str(),
80        path => op.path.clone(),
81        params_signature => params_sig,
82        return_type => return_type,
83        path_params => path_params,
84        query_params_obj => query_params_obj,
85        has_body => has_body,
86        has_path_params => has_path_params,
87        has_query_params => has_query_params,
88        summary => op.summary.clone(),
89        description => op.description.clone(),
90        deprecated => op.deprecated,
91    }
92}
93
94fn build_void_op(op: &IrOperation) -> minijinja::Value {
95    let (params_sig, path_params, query_params_obj, has_body, has_path_params, has_query_params) =
96        build_params(op);
97
98    context! {
99        kind => "void",
100        method_name => op.name.camel_case.clone(),
101        http_method => op.method.as_str(),
102        path => op.path.clone(),
103        params_signature => params_sig,
104        return_type => "void",
105        path_params => path_params,
106        query_params_obj => query_params_obj,
107        has_body => has_body,
108        has_path_params => has_path_params,
109        has_query_params => has_query_params,
110        summary => op.summary.clone(),
111        description => op.description.clone(),
112        deprecated => op.deprecated,
113    }
114}
115
116fn build_sse_op(op: &IrOperation, return_type: &str, method_name: &str) -> minijinja::Value {
117    let (mut parts, path_params, _query, has_body, has_path_params, _has_query) =
118        build_params_raw(op);
119
120    // For SSE, use SSEOptions instead of RequestOptions
121    if let Some(last) = parts.last_mut()
122        && last.starts_with("options?")
123    {
124        *last = "options?: SSEOptions".to_string();
125    }
126    let params_sig = parts.join(", ");
127
128    context! {
129        kind => "sse",
130        method_name => method_name,
131        http_method => op.method.as_str(),
132        path => op.path.clone(),
133        params_signature => params_sig,
134        return_type => return_type,
135        path_params => path_params,
136        has_body => has_body,
137        has_path_params => has_path_params,
138        summary => op.summary.clone(),
139        description => op.description.clone(),
140        deprecated => op.deprecated,
141    }
142}
143
144fn build_params(op: &IrOperation) -> (String, Vec<minijinja::Value>, String, bool, bool, bool) {
145    let (parts, path_params, query_params_obj, has_body, has_path_params, has_query_params) =
146        build_params_raw(op);
147    (
148        parts.join(", "),
149        path_params,
150        query_params_obj,
151        has_body,
152        has_path_params,
153        has_query_params,
154    )
155}
156
157fn build_params_raw(
158    op: &IrOperation,
159) -> (Vec<String>, Vec<minijinja::Value>, String, bool, bool, bool) {
160    let mut parts = Vec::new();
161    let mut path_params = Vec::new();
162    let mut query_parts = Vec::new();
163
164    for param in &op.parameters {
165        if param.location == IrParameterLocation::Path {
166            let ts_type = ir_type_to_ts(&param.param_type);
167            parts.push(format!("{}: {}", param.name.camel_case, ts_type));
168            path_params.push(context! {
169                name => param.name.camel_case.clone(),
170                original_name => param.original_name.clone(),
171            });
172        }
173    }
174
175    for param in &op.parameters {
176        if param.location == IrParameterLocation::Query {
177            let ts_type = ir_type_to_ts(&param.param_type);
178            if param.required {
179                parts.push(format!("{}: {}", param.name.camel_case, ts_type));
180            } else {
181                parts.push(format!("{}?: {}", param.name.camel_case, ts_type));
182            }
183            query_parts.push(format!(
184                "{}: String({})",
185                param.original_name, param.name.camel_case
186            ));
187        }
188    }
189
190    let has_body = op.request_body.is_some();
191    if let Some(ref body) = op.request_body {
192        let ts_type = ir_type_to_ts(&body.body_type);
193        if body.required {
194            parts.push(format!("body: {ts_type}"));
195        } else {
196            parts.push(format!("body?: {ts_type}"));
197        }
198    }
199
200    parts.push("options?: RequestOptions".to_string());
201
202    let has_path_params = !path_params.is_empty();
203    let has_query_params = !query_parts.is_empty();
204    let query_params_obj = query_parts.join(", ");
205
206    (
207        parts,
208        path_params,
209        query_params_obj,
210        has_body,
211        has_path_params,
212        has_query_params,
213    )
214}
215
216fn collect_imported_types(ir: &IrSpec) -> Vec<String> {
217    let mut types = HashSet::new();
218
219    for op in &ir.operations {
220        collect_types_from_return(&op.return_type, &mut types);
221
222        if let Some(ref body) = op.request_body {
223            collect_types_from_ir_type(&body.body_type, &mut types);
224        }
225
226        for param in &op.parameters {
227            collect_types_from_ir_type(&param.param_type, &mut types);
228        }
229    }
230
231    let mut sorted: Vec<String> = types.into_iter().collect();
232    sorted.sort();
233    sorted
234}
235
236fn collect_types_from_return(ret: &IrReturnType, types: &mut HashSet<String>) {
237    match ret {
238        IrReturnType::Standard(resp) => {
239            collect_types_from_ir_type(&resp.response_type, types);
240        }
241        IrReturnType::Sse(sse) => {
242            if let Some(ref name) = sse.event_type_name {
243                types.insert(name.clone());
244            } else {
245                collect_types_from_ir_type(&sse.event_type, types);
246            }
247            for v in &sse.variants {
248                collect_types_from_ir_type(v, types);
249            }
250            if let Some(ref json) = sse.json_response {
251                collect_types_from_ir_type(&json.response_type, types);
252            }
253        }
254        IrReturnType::Void => {}
255    }
256}
257
258fn collect_types_from_ir_type(ir_type: &IrType, types: &mut HashSet<String>) {
259    match ir_type {
260        IrType::Ref(name) => {
261            types.insert(name.clone());
262        }
263        IrType::Array(inner) | IrType::Map(inner) => collect_types_from_ir_type(inner, types),
264        IrType::Union(variants) => {
265            for v in variants {
266                collect_types_from_ir_type(v, types);
267            }
268        }
269        IrType::Object(fields) => {
270            for (_, ty, _) in fields {
271                collect_types_from_ir_type(ty, types);
272            }
273        }
274        _ => {}
275    }
276}