Skip to main content

rescript_openapi/codegen/
client.rs

1// SPDX-License-Identifier: PMPL-1.0-or-later
2// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell
3
4//! HTTP client generation with pluggable HTTP backend
5
6use crate::ir::{ApiSpec, Endpoint, HttpMethod, Parameter, ParameterLocation, RsType};
7use super::{Config, ClientMode};
8use anyhow::Result;
9use heck::{ToLowerCamelCase, ToPascalCase};
10
11pub fn generate(spec: &ApiSpec, config: &Config) -> Result<String> {
12    let mut output = String::new();
13
14    if config.client_mode == ClientMode::None {
15        return Ok("".to_string());
16    }
17
18    // Header
19    output.push_str("// SPDX-License-Identifier: PMPL-1.0-or-later\n");
20    output.push_str("// Generated by rescript-openapi - DO NOT EDIT\n");
21    output.push_str(&format!("// Source: {} v{}\n\n", spec.title, spec.version));
22
23    // Import core library and types
24    output.push_str("open RescriptCore\n");
25    if !config.unified_module {
26        output.push_str(&format!("open {}Types\n", config.module_prefix));
27        output.push_str(&format!("open {}Schema\n\n", config.module_prefix));
28    } else {
29        output.push_str("module S = RescriptSchema.S\n\n");
30    }
31
32    // HTTP abstraction layer
33    output.push_str(r#"/** API error type */
34type apiError =
35  | HttpError({status: int, message: string, body: option<Js.Json.t>})
36  | ParseError({message: string, body: option<Js.Json.t>})
37  | HttpClientError({message: string})
38
39/** HTTP method (polymorphic variant for Fetch API) */
40type httpMethod = [#GET | #POST | #PUT | #PATCH | #DELETE | #HEAD | #OPTIONS]
41
42/** HTTP request configuration */
43type httpRequest = {
44  method: httpMethod,
45  url: string,
46  headers: Dict.t<string>,
47  body: option<Js.Json.t>,
48}
49
50/** HTTP client module signature - implement this to use any HTTP library */
51module type HttpClient = {
52  let request: httpRequest => promise<result<Js.Json.t, apiError>>
53}
54"#);
55
56    if config.client_mode == ClientMode::Full {
57        output.push_str(r#"
58/** Default fetch-based HTTP client using @glennsl/rescript-fetch */
59module FetchClient: HttpClient = {
60  open Fetch
61
62  let request = async (req: httpRequest): result<Js.Json.t, apiError> => {
63    try {
64      let init: Request.init = {
65        method: (req.method :> Fetch.method),
66        headers: Headers.fromObject(req.headers->Obj.magic),
67      }
68      let init = switch req.body {
69      | Some(b) => {...init, body: b->JSON.stringify->Body.string}
70      | None => init
71      }
72      let response = await fetch(req.url, init)
73
74      if response->Response.ok {
75        let json = await response->Response.json
76        Ok(json)
77      } else {
78        let status = response->Response.status
79        let message = response->Response.statusText
80        let body = try {
81          Some(await response->Response.json)
82        } catch {
83        | _ => None
84        }
85        Error(HttpError({status, message, body}))
86      }
87    } catch {
88    | Exn.Error(e) => Error(HttpClientError({
89        message: Exn.message(e)->Option.getOr("Network error"),
90      }))
91    }
92  }
93}
94"#);
95    }
96
97    output.push_str(r#"
98/** Authentication configuration */
99type authConfig =
100  | NoAuth
101  | BearerToken(string)
102  | ApiKey({key: string, headerName: string})
103
104/** Client configuration */
105type config = {
106  baseUrl: string,
107  headers: Dict.t<string>,
108  auth: authConfig,
109}
110
111/** Create client configuration with optional authentication
112 *
113 * Bearer token auth:
114 * ```rescript
115 * let config = makeConfig(
116 *   ~baseUrl="https://api.example.com",
117 *   ~bearerToken="my-jwt-token",
118 *   ()
119 * )
120 * ```
121 *
122 * API key auth:
123 * ```rescript
124 * let config = makeConfig(
125 *   ~baseUrl="https://api.example.com",
126 *   ~apiKey="my-api-key",
127 *   ~apiKeyHeader="X-API-Key",
128 *   ()
129 * )
130 * ```
131 */
132let makeConfig = (
133  ~baseUrl: string,
134  ~headers=Dict.make(),
135  ~bearerToken: option<string>=?,
136  ~apiKey: option<string>=?,
137  ~apiKeyHeader: string="X-API-Key",
138  ()
139): config => {
140  let auth = switch (bearerToken, apiKey) {
141  | (Some(token), _) => BearerToken(token)
142  | (_, Some(key)) => ApiKey({key, headerName: apiKeyHeader})
143  | (None, None) => NoAuth
144  }
145  {
146    baseUrl,
147    headers,
148    auth,
149  }
150}
151
152/** Apply authentication headers to a headers dict */
153let applyAuth = (headers: Dict.t<string>, auth: authConfig): unit => {
154  switch auth {
155  | NoAuth => ()
156  | BearerToken(token) => headers->Dict.set("Authorization", `Bearer ${token}`)
157  | ApiKey({key, headerName}) => headers->Dict.set(headerName, key)
158  }
159}
160
161/** Build URL with query parameters */
162let buildUrl = (baseUrl: string, path: string, query: Dict.t<string>): string => {
163  let url = baseUrl ++ path
164  let params = query
165    ->Dict.toArray
166    ->Array.map(((k, v)) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
167    ->Array.join("&")
168
169  if params->String.length > 0 {
170    url ++ "?" ++ params
171  } else {
172    url
173  }
174}
175
176/** API client functor - provide your own HttpClient implementation */
177module Make = (Http: HttpClient) => {
178"#);
179
180    // Generate endpoint functions inside the functor
181    for endpoint in &spec.endpoints {
182        output.push_str(&generate_endpoint(endpoint));
183        output.push('\n');
184    }
185
186    output.push_str("}\n\n");
187
188    if config.client_mode == ClientMode::Full {
189        // Default client using FetchClient
190        output.push_str("/** Default client using fetch */\n");
191        output.push_str("module Client = Make(FetchClient)\n\n");
192
193        // Generate aliases map (operationId -> path-based name)
194        output.push_str("/** Operation aliases for convenience */\n");
195        output.push_str("module Aliases = {\n");
196        for endpoint in &spec.endpoints {
197            let alias = generate_path_alias(&endpoint.path, &endpoint.method);
198            if alias != endpoint.operation_id {
199                output.push_str(&format!("  let {} = Client.{}\n", alias, endpoint.operation_id));
200            }
201        }
202        output.push_str("}\n");
203    }
204
205    Ok(output)
206}
207
208fn generate_endpoint(endpoint: &Endpoint) -> String {
209    let mut output = String::new();
210
211    // Documentation
212    if let Some(doc) = &endpoint.doc {
213        output.push_str(&format!("  /** {} */\n", doc));
214    }
215
216    let fn_name = &endpoint.operation_id;
217
218    // Collect parameters by location
219    let path_params: Vec<_> = endpoint.parameters.iter()
220        .filter(|p| matches!(p.location, ParameterLocation::Path))
221        .collect();
222    let query_params: Vec<_> = endpoint.parameters.iter()
223        .filter(|p| matches!(p.location, ParameterLocation::Query))
224        .collect();
225    let header_params: Vec<_> = endpoint.parameters.iter()
226        .filter(|p| matches!(p.location, ParameterLocation::Header))
227        .collect();
228
229    // Build parameter list
230    let mut params = vec!["config: config".to_string()];
231
232    for p in &path_params {
233        params.push(format!("~{}: {}", p.name, p.ty.to_rescript()));
234    }
235
236    if let Some(body) = &endpoint.request_body {
237        params.push(format!("~body: {}", body.ty.to_rescript()));
238    }
239
240    // Optional query parameters
241    for p in &query_params {
242        if p.required {
243            params.push(format!("~{}: {}", p.name, p.ty.to_rescript()));
244        } else {
245            params.push(format!("~{}=?", p.name));
246        }
247    }
248
249    // Optional header parameters
250    for p in &header_params {
251        if p.required {
252            params.push(format!("~{}: {}", p.name, p.ty.to_rescript()));
253        } else {
254            params.push(format!("~{}=?", p.name));
255        }
256    }
257
258    // Determine return type
259    let success_response = endpoint.responses.iter()
260        .find(|r| r.status >= 200 && r.status < 300);
261
262    let return_type = success_response
263        .and_then(|r| r.ty.as_ref())
264        .map(|t| t.to_rescript())
265        .unwrap_or_else(|| "unit".to_string());
266
267    output.push_str(&format!(
268        "  let {} = async ({}, ()): result<{}, apiError> => {{\n",
269        fn_name,
270        params.join(", "),
271        return_type
272    ));
273
274    // Build path with interpolation
275    let path = build_path(&endpoint.path, &path_params);
276    output.push_str(&format!("    let path = {}\n", path));
277
278    // Build query dict
279    output.push_str("    let query = Dict.make()\n");
280    for p in &query_params {
281        if p.required {
282            output.push_str(&format!(
283                "    query->Dict.set(\"{}\", {}->String.make)\n",
284                p.name, p.name
285            ));
286        } else {
287            output.push_str(&format!(
288                "    switch {} {{ | Some(v) => query->Dict.set(\"{}\", v->String.make) | None => () }}\n",
289                p.name, p.name
290            ));
291        }
292    }
293
294    // Build headers dict and apply authentication
295    output.push_str("    let headers = Dict.fromArray(config.headers->Dict.toArray)\n");
296    output.push_str("    headers->Dict.set(\"Content-Type\", \"application/json\")\n");
297    output.push_str("    applyAuth(headers, config.auth)\n");
298
299    for p in &header_params {
300        if p.required {
301            output.push_str(&format!(
302                "    headers->Dict.set(\"{}\", {})\n",
303                p.name, p.name
304            ));
305        } else {
306            output.push_str(&format!(
307                "    switch {} {{ | Some(v) => headers->Dict.set(\"{}\", v) | None => () }}\n",
308                p.name, p.name
309            ));
310        }
311    }
312
313    // Build request body
314    let body_expr = if let Some(body) = &endpoint.request_body {
315        match &body.ty {
316            RsType::Named(type_name) => {
317                format!("Some(S.reverseConvertToJsonOrThrow(body, {}Schema))", type_name.to_lower_camel_case())
318            }
319            _ => "Some(body->Obj.magic)".to_string()
320        }
321    } else {
322        "None".to_string()
323    };
324
325    // Make request (polymorphic variant for Fetch API)
326    let method = match endpoint.method {
327        HttpMethod::Get => "#GET",
328        HttpMethod::Post => "#POST",
329        HttpMethod::Put => "#PUT",
330        HttpMethod::Patch => "#PATCH",
331        HttpMethod::Delete => "#DELETE",
332        HttpMethod::Head => "#HEAD",
333        HttpMethod::Options => "#OPTIONS",
334    };
335
336    output.push_str(&format!(r#"
337    let req: httpRequest = {{
338      method: {},
339      url: buildUrl(config.baseUrl, path, query),
340      headers,
341      body: {},
342    }}
343
344    switch await Http.request(req) {{
345"#, method, body_expr));
346
347    // Parse response
348    if let Some(response) = success_response {
349        if let Some(ty) = &response.ty {
350            if let RsType::Named(type_name) = ty {
351                output.push_str(&format!(
352                    "    | Ok(json) => try {{\n      Ok(S.parseJsonOrThrow(json, {}Schema))\n    }} catch {{\n    | Exn.Error(e) => Error(ParseError({{message: Exn.message(e)->Option.getOr(\"Parse error\"), body: Some(json)}}))\n    }}\n",
353                    type_name.to_lower_camel_case()
354                ));
355            } else {
356                output.push_str("    | Ok(json) => Ok(json->Obj.magic)\n");
357            }
358        } else {
359            output.push_str("    | Ok(_) => Ok()\n");
360        }
361    } else {
362        output.push_str("    | Ok(json) => Ok(json->Obj.magic)\n");
363    }
364
365    output.push_str("    | Error(e) => Error(e)\n");
366    output.push_str("    }\n");
367    output.push_str("  }\n");
368
369    output
370}
371
372fn build_path(path: &str, path_params: &[&Parameter]) -> String {
373    if path_params.is_empty() {
374        return format!("\"{}\"", path);
375    }
376
377    // Use template literal for path interpolation
378    let mut template = path.to_string();
379
380    for param in path_params {
381        // Convert param to string based on type
382        let param_expr = match &param.ty {
383            RsType::String => param.name.clone(),
384            RsType::Int => format!("{}->Int.toString", param.name),
385            RsType::Float => format!("{}->Float.toString", param.name),
386            RsType::Bool => format!("{}->Bool.toString", param.name),
387            _ => format!("{}->String.make", param.name),
388        };
389
390        // Handle {param} style
391        let placeholder = format!("{{{}}}", param.name);
392        template = template.replace(&placeholder, &format!("${{{}}}", param_expr));
393
394        // Handle :param style
395        let placeholder_colon = format!(":{}", param.name);
396        template = template.replace(&placeholder_colon, &format!("${{{}}}", param_expr));
397    }
398
399    format!("`{}`", template)
400}
401
402fn generate_path_alias(path: &str, method: &HttpMethod) -> String {
403    let method_prefix = match method {
404        HttpMethod::Get => "get",
405        HttpMethod::Post => "create",
406        HttpMethod::Put => "update",
407        HttpMethod::Patch => "patch",
408        HttpMethod::Delete => "delete",
409        HttpMethod::Head => "head",
410        HttpMethod::Options => "options",
411    };
412
413    // /users/{id}/posts -> UsersIdPosts -> getUsersIdPosts
414    let path_parts: Vec<_> = path
415        .split('/')
416        .filter(|s| !s.is_empty() && !s.starts_with('{'))
417        .collect();
418
419    let path_name = path_parts
420        .iter()
421        .map(|s| s.to_pascal_case())
422        .collect::<Vec<_>>()
423        .join("");
424
425    format!("{}{}", method_prefix, path_name)
426}