use crate::ir::{ApiSpec, Endpoint, HttpMethod, Parameter, ParameterLocation, RsType};
use super::{Config, ClientMode};
use anyhow::Result;
use heck::{ToLowerCamelCase, ToPascalCase};
pub fn generate(spec: &ApiSpec, config: &Config) -> Result<String> {
let mut output = String::new();
if config.client_mode == ClientMode::None {
return Ok("".to_string());
}
output.push_str("// SPDX-License-Identifier: PMPL-1.0-or-later\n");
output.push_str("// Generated by rescript-openapi - DO NOT EDIT\n");
output.push_str(&format!("// Source: {} v{}\n\n", spec.title, spec.version));
output.push_str("open RescriptCore\n");
if !config.unified_module {
output.push_str(&format!("open {}Types\n", config.module_prefix));
output.push_str(&format!("open {}Schema\n\n", config.module_prefix));
} else {
output.push_str("module S = RescriptSchema.S\n\n");
}
output.push_str(r#"/** API error type */
type apiError =
| HttpError({status: int, message: string, body: option<Js.Json.t>})
| ParseError({message: string, body: option<Js.Json.t>})
| HttpClientError({message: string})
/** HTTP method (polymorphic variant for Fetch API) */
type httpMethod = [#GET | #POST | #PUT | #PATCH | #DELETE | #HEAD | #OPTIONS]
/** HTTP request configuration */
type httpRequest = {
method: httpMethod,
url: string,
headers: Dict.t<string>,
body: option<Js.Json.t>,
}
/** HTTP client module signature - implement this to use any HTTP library */
module type HttpClient = {
let request: httpRequest => promise<result<Js.Json.t, apiError>>
}
"#);
if config.client_mode == ClientMode::Full {
output.push_str(r#"
/** Default fetch-based HTTP client using @glennsl/rescript-fetch */
module FetchClient: HttpClient = {
open Fetch
let request = async (req: httpRequest): result<Js.Json.t, apiError> => {
try {
let init: Request.init = {
method: (req.method :> Fetch.method),
headers: Headers.fromObject(req.headers->Obj.magic),
}
let init = switch req.body {
| Some(b) => {...init, body: b->JSON.stringify->Body.string}
| None => init
}
let response = await fetch(req.url, init)
if response->Response.ok {
let json = await response->Response.json
Ok(json)
} else {
let status = response->Response.status
let message = response->Response.statusText
let body = try {
Some(await response->Response.json)
} catch {
| _ => None
}
Error(HttpError({status, message, body}))
}
} catch {
| Exn.Error(e) => Error(HttpClientError({
message: Exn.message(e)->Option.getOr("Network error"),
}))
}
}
}
"#);
}
output.push_str(r#"
/** Authentication configuration */
type authConfig =
| NoAuth
| BearerToken(string)
| ApiKey({key: string, headerName: string})
/** Client configuration */
type config = {
baseUrl: string,
headers: Dict.t<string>,
auth: authConfig,
}
/** Create client configuration with optional authentication
*
* Bearer token auth:
* ```rescript
* let config = makeConfig(
* ~baseUrl="https://api.example.com",
* ~bearerToken="my-jwt-token",
* ()
* )
* ```
*
* API key auth:
* ```rescript
* let config = makeConfig(
* ~baseUrl="https://api.example.com",
* ~apiKey="my-api-key",
* ~apiKeyHeader="X-API-Key",
* ()
* )
* ```
*/
let makeConfig = (
~baseUrl: string,
~headers=Dict.make(),
~bearerToken: option<string>=?,
~apiKey: option<string>=?,
~apiKeyHeader: string="X-API-Key",
()
): config => {
let auth = switch (bearerToken, apiKey) {
| (Some(token), _) => BearerToken(token)
| (_, Some(key)) => ApiKey({key, headerName: apiKeyHeader})
| (None, None) => NoAuth
}
{
baseUrl,
headers,
auth,
}
}
/** Apply authentication headers to a headers dict */
let applyAuth = (headers: Dict.t<string>, auth: authConfig): unit => {
switch auth {
| NoAuth => ()
| BearerToken(token) => headers->Dict.set("Authorization", `Bearer ${token}`)
| ApiKey({key, headerName}) => headers->Dict.set(headerName, key)
}
}
/** Build URL with query parameters */
let buildUrl = (baseUrl: string, path: string, query: Dict.t<string>): string => {
let url = baseUrl ++ path
let params = query
->Dict.toArray
->Array.map(((k, v)) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
->Array.join("&")
if params->String.length > 0 {
url ++ "?" ++ params
} else {
url
}
}
/** API client functor - provide your own HttpClient implementation */
module Make = (Http: HttpClient) => {
"#);
for endpoint in &spec.endpoints {
output.push_str(&generate_endpoint(endpoint));
output.push('\n');
}
output.push_str("}\n\n");
if config.client_mode == ClientMode::Full {
output.push_str("/** Default client using fetch */\n");
output.push_str("module Client = Make(FetchClient)\n\n");
output.push_str("/** Operation aliases for convenience */\n");
output.push_str("module Aliases = {\n");
for endpoint in &spec.endpoints {
let alias = generate_path_alias(&endpoint.path, &endpoint.method);
if alias != endpoint.operation_id {
output.push_str(&format!(" let {} = Client.{}\n", alias, endpoint.operation_id));
}
}
output.push_str("}\n");
}
Ok(output)
}
fn generate_endpoint(endpoint: &Endpoint) -> String {
let mut output = String::new();
if let Some(doc) = &endpoint.doc {
output.push_str(&format!(" /** {} */\n", doc));
}
let fn_name = &endpoint.operation_id;
let path_params: Vec<_> = endpoint.parameters.iter()
.filter(|p| matches!(p.location, ParameterLocation::Path))
.collect();
let query_params: Vec<_> = endpoint.parameters.iter()
.filter(|p| matches!(p.location, ParameterLocation::Query))
.collect();
let header_params: Vec<_> = endpoint.parameters.iter()
.filter(|p| matches!(p.location, ParameterLocation::Header))
.collect();
let mut params = vec!["config: config".to_string()];
for p in &path_params {
params.push(format!("~{}: {}", p.name, p.ty.to_rescript()));
}
if let Some(body) = &endpoint.request_body {
params.push(format!("~body: {}", body.ty.to_rescript()));
}
for p in &query_params {
if p.required {
params.push(format!("~{}: {}", p.name, p.ty.to_rescript()));
} else {
params.push(format!("~{}=?", p.name));
}
}
for p in &header_params {
if p.required {
params.push(format!("~{}: {}", p.name, p.ty.to_rescript()));
} else {
params.push(format!("~{}=?", p.name));
}
}
let success_response = endpoint.responses.iter()
.find(|r| r.status >= 200 && r.status < 300);
let return_type = success_response
.and_then(|r| r.ty.as_ref())
.map(|t| t.to_rescript())
.unwrap_or_else(|| "unit".to_string());
output.push_str(&format!(
" let {} = async ({}, ()): result<{}, apiError> => {{\n",
fn_name,
params.join(", "),
return_type
));
let path = build_path(&endpoint.path, &path_params);
output.push_str(&format!(" let path = {}\n", path));
output.push_str(" let query = Dict.make()\n");
for p in &query_params {
if p.required {
output.push_str(&format!(
" query->Dict.set(\"{}\", {}->String.make)\n",
p.name, p.name
));
} else {
output.push_str(&format!(
" switch {} {{ | Some(v) => query->Dict.set(\"{}\", v->String.make) | None => () }}\n",
p.name, p.name
));
}
}
output.push_str(" let headers = Dict.fromArray(config.headers->Dict.toArray)\n");
output.push_str(" headers->Dict.set(\"Content-Type\", \"application/json\")\n");
output.push_str(" applyAuth(headers, config.auth)\n");
for p in &header_params {
if p.required {
output.push_str(&format!(
" headers->Dict.set(\"{}\", {})\n",
p.name, p.name
));
} else {
output.push_str(&format!(
" switch {} {{ | Some(v) => headers->Dict.set(\"{}\", v) | None => () }}\n",
p.name, p.name
));
}
}
let body_expr = if let Some(body) = &endpoint.request_body {
match &body.ty {
RsType::Named(type_name) => {
format!("Some(S.reverseConvertToJsonOrThrow(body, {}Schema))", type_name.to_lower_camel_case())
}
_ => "Some(body->Obj.magic)".to_string()
}
} else {
"None".to_string()
};
let method = match endpoint.method {
HttpMethod::Get => "#GET",
HttpMethod::Post => "#POST",
HttpMethod::Put => "#PUT",
HttpMethod::Patch => "#PATCH",
HttpMethod::Delete => "#DELETE",
HttpMethod::Head => "#HEAD",
HttpMethod::Options => "#OPTIONS",
};
output.push_str(&format!(r#"
let req: httpRequest = {{
method: {},
url: buildUrl(config.baseUrl, path, query),
headers,
body: {},
}}
switch await Http.request(req) {{
"#, method, body_expr));
if let Some(response) = success_response {
if let Some(ty) = &response.ty {
if let RsType::Named(type_name) = ty {
output.push_str(&format!(
" | 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",
type_name.to_lower_camel_case()
));
} else {
output.push_str(" | Ok(json) => Ok(json->Obj.magic)\n");
}
} else {
output.push_str(" | Ok(_) => Ok()\n");
}
} else {
output.push_str(" | Ok(json) => Ok(json->Obj.magic)\n");
}
output.push_str(" | Error(e) => Error(e)\n");
output.push_str(" }\n");
output.push_str(" }\n");
output
}
fn build_path(path: &str, path_params: &[&Parameter]) -> String {
if path_params.is_empty() {
return format!("\"{}\"", path);
}
let mut template = path.to_string();
for param in path_params {
let param_expr = match ¶m.ty {
RsType::String => param.name.clone(),
RsType::Int => format!("{}->Int.toString", param.name),
RsType::Float => format!("{}->Float.toString", param.name),
RsType::Bool => format!("{}->Bool.toString", param.name),
_ => format!("{}->String.make", param.name),
};
let placeholder = format!("{{{}}}", param.name);
template = template.replace(&placeholder, &format!("${{{}}}", param_expr));
let placeholder_colon = format!(":{}", param.name);
template = template.replace(&placeholder_colon, &format!("${{{}}}", param_expr));
}
format!("`{}`", template)
}
fn generate_path_alias(path: &str, method: &HttpMethod) -> String {
let method_prefix = match method {
HttpMethod::Get => "get",
HttpMethod::Post => "create",
HttpMethod::Put => "update",
HttpMethod::Patch => "patch",
HttpMethod::Delete => "delete",
HttpMethod::Head => "head",
HttpMethod::Options => "options",
};
let path_parts: Vec<_> = path
.split('/')
.filter(|s| !s.is_empty() && !s.starts_with('{'))
.collect();
let path_name = path_parts
.iter()
.map(|s| s.to_pascal_case())
.collect::<Vec<_>>()
.join("");
format!("{}{}", method_prefix, path_name)
}