rescript_openapi/codegen/
client.rs1use 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 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 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 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 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 output.push_str("/** Default client using fetch */\n");
191 output.push_str("module Client = Make(FetchClient)\n\n");
192
193 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 if let Some(doc) = &endpoint.doc {
213 output.push_str(&format!(" /** {} */\n", doc));
214 }
215
216 let fn_name = &endpoint.operation_id;
217
218 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 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 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 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 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 let path = build_path(&endpoint.path, &path_params);
276 output.push_str(&format!(" let path = {}\n", path));
277
278 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 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 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 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 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 let mut template = path.to_string();
379
380 for param in path_params {
381 let param_expr = match ¶m.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 let placeholder = format!("{{{}}}", param.name);
392 template = template.replace(&placeholder, &format!("${{{}}}", param_expr));
393
394 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 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}