use shaperail_core::{EndpointSpec, HttpMethod, ResourceDefinition};
pub fn generate_service_client(service_name: &str, resources: &[ResourceDefinition]) -> String {
let mod_name = service_name.replace('-', "_");
let mut out = String::new();
out.push_str(&format!(
"//! Auto-generated typed client for service `{service_name}`.\n"
));
out.push_str("//! DO NOT EDIT — regenerated by `shaperail generate`.\n\n");
out.push_str("use serde::{{Deserialize, Serialize}};\n\n");
out.push_str(&format!(
"/// Typed HTTP client for the `{service_name}` service.\n"
));
out.push_str("#[derive(Debug, Clone)]\n");
out.push_str(&format!(
"pub struct {client_type} {{\n",
client_type = client_type_name(&mod_name)
));
out.push_str(" base_url: String,\n");
out.push_str(" client: reqwest::Client,\n");
out.push_str(" auth_token: Option<String>,\n");
out.push_str("}\n\n");
out.push_str(&format!(
"impl {client_type} {{\n",
client_type = client_type_name(&mod_name)
));
out.push_str(" /// Create a new client pointing at the given base URL.\n");
out.push_str(" pub fn new(base_url: impl Into<String>) -> Self {\n");
out.push_str(" Self {\n");
out.push_str(" base_url: base_url.into(),\n");
out.push_str(" client: reqwest::Client::builder()\n");
out.push_str(" .timeout(std::time::Duration::from_secs(10))\n");
out.push_str(" .build()\n");
out.push_str(" .unwrap_or_default(),\n");
out.push_str(" auth_token: None,\n");
out.push_str(" }\n");
out.push_str(" }\n\n");
out.push_str(" /// Set the Bearer token for authenticated requests.\n");
out.push_str(" pub fn with_auth(mut self, token: impl Into<String>) -> Self {\n");
out.push_str(" self.auth_token = Some(token.into());\n");
out.push_str(" self\n");
out.push_str(" }\n\n");
for resource in resources {
let endpoints = match &resource.endpoints {
Some(ep) => ep,
None => continue,
};
for (ep_name, endpoint) in endpoints {
let method_name = format!("{}_{}", resource.resource, ep_name);
let method_code = generate_endpoint_method(resource, ep_name, endpoint);
out.push_str(&method_code);
let _ = method_name; }
}
out.push_str("}\n\n");
for resource in resources {
out.push_str(&generate_resource_types(resource));
}
out
}
fn client_type_name(mod_name: &str) -> String {
let mut result = String::new();
let mut capitalize_next = true;
for ch in mod_name.chars() {
if ch == '_' {
capitalize_next = true;
} else if capitalize_next {
result.push(ch.to_uppercase().next().unwrap_or(ch));
capitalize_next = false;
} else {
result.push(ch);
}
}
result.push_str("Client");
result
}
fn generate_endpoint_method(
resource: &ResourceDefinition,
ep_name: &str,
endpoint: &EndpointSpec,
) -> String {
let method_name = format!("{}_{}", resource.resource, ep_name);
let resource_type = pascal_case(&resource.resource);
let version = resource.version;
let has_id_param = endpoint.path().contains(":id");
let has_input = endpoint.input.is_some() && !endpoint.input.as_ref().is_none_or(Vec::is_empty);
let mut out = String::new();
out.push_str(&format!(
" /// {method} {path}\n",
method = endpoint.method(),
path = endpoint.path()
));
out.push_str(&format!(" pub async fn {method_name}(\n"));
out.push_str(" &self,\n");
if has_id_param {
out.push_str(" id: &str,\n");
}
if has_input {
out.push_str(&format!(" input: &{resource_type}Input,\n"));
}
out.push_str(&format!(
" ) -> Result<{return_type}, ClientError> {{\n",
return_type = match *endpoint.method() {
HttpMethod::Delete => "()".to_string(),
HttpMethod::Get if ep_name == "list" => format!("Vec<{resource_type}>"),
_ => resource_type.clone(),
}
));
let versioned_path = format!("/v{version}{}", endpoint.path());
if has_id_param {
let path_with_placeholder = versioned_path.replace(":id", "{}");
out.push_str(" let url = format!(\"{}");
out.push_str(&path_with_placeholder);
out.push_str("\", self.base_url, id);\n");
} else {
out.push_str(" let url = format!(\"{}");
out.push_str(&versioned_path);
out.push_str("\", self.base_url);\n");
}
let http_method = match *endpoint.method() {
HttpMethod::Get => "get",
HttpMethod::Post => "post",
HttpMethod::Patch => "patch",
HttpMethod::Put => "put",
HttpMethod::Delete => "delete",
};
out.push_str(&format!(
" let mut req = self.client.{http_method}(&url);\n"
));
out.push_str(" if let Some(ref token) = self.auth_token {\n");
out.push_str(" req = req.bearer_auth(token);\n");
out.push_str(" }\n");
if has_input {
out.push_str(" req = req.json(input);\n");
}
out.push_str(" let resp = req.send().await.map_err(ClientError::Request)?;\n");
out.push_str(" if !resp.status().is_success() {\n");
out.push_str(" let status = resp.status().as_u16();\n");
out.push_str(" let body = resp.text().await.unwrap_or_default();\n");
out.push_str(" return Err(ClientError::Api { status, body });\n");
out.push_str(" }\n");
match *endpoint.method() {
HttpMethod::Delete => {
out.push_str(" Ok(())\n");
}
_ => {
out.push_str(" let body = resp.json().await.map_err(ClientError::Request)?;\n");
out.push_str(" Ok(body)\n");
}
}
out.push_str(" }\n\n");
out
}
fn generate_resource_types(resource: &ResourceDefinition) -> String {
let type_name = pascal_case(&resource.resource);
let mut out = String::new();
out.push_str(&format!("/// Record type for `{}`.\n", resource.resource));
out.push_str("#[derive(Debug, Clone, Serialize, Deserialize)]\n");
out.push_str(&format!("pub struct {type_name} {{\n"));
for (field_name, _field_schema) in &resource.schema {
out.push_str(&format!(" pub {field_name}: serde_json::Value,\n"));
}
out.push_str("}\n\n");
let mut input_fields = std::collections::HashSet::new();
if let Some(endpoints) = &resource.endpoints {
for (_, ep) in endpoints {
if let Some(ref inputs) = ep.input {
for f in inputs {
input_fields.insert(f.clone());
}
}
}
}
if !input_fields.is_empty() {
out.push_str(&format!("/// Input type for `{}`.\n", resource.resource));
out.push_str("#[derive(Debug, Clone, Serialize, Deserialize)]\n");
out.push_str(&format!("pub struct {type_name}Input {{\n"));
for field_name in &input_fields {
out.push_str(" #[serde(skip_serializing_if = \"Option::is_none\")]\n");
out.push_str(&format!(
" pub {field_name}: Option<serde_json::Value>,\n"
));
}
out.push_str("}\n\n");
}
out
}
fn pascal_case(s: &str) -> String {
let mut result = String::new();
let mut capitalize_next = true;
for ch in s.chars() {
if ch == '_' || ch == '-' {
capitalize_next = true;
} else if capitalize_next {
result.push(ch.to_uppercase().next().unwrap_or(ch));
capitalize_next = false;
} else {
result.push(ch);
}
}
result
}
pub const CLIENT_ERROR_TYPE: &str = r#"/// Error from an inter-service client call.
#[derive(Debug)]
pub enum ClientError {
/// HTTP request failed (network, timeout, etc).
Request(reqwest::Error),
/// Remote service returned an error status.
Api { status: u16, body: String },
}
impl std::fmt::Display for ClientError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Request(e) => write!(f, "request error: {e}"),
Self::Api { status, body } => write!(f, "API error {status}: {body}"),
}
}
}
impl std::error::Error for ClientError {}
"#;
pub fn generate_client_module(service_name: &str, resources: &[ResourceDefinition]) -> String {
let mut out = String::new();
out.push_str(CLIENT_ERROR_TYPE);
out.push('\n');
out.push_str(&generate_service_client(service_name, resources));
out
}
#[cfg(test)]
mod tests {
use super::*;
fn make_resource() -> ResourceDefinition {
let yaml = r#"
resource: users
version: 1
schema:
id: { type: uuid, primary: true, generated: true }
name: { type: string, required: true }
endpoints:
list:
method: GET
path: /users
create:
method: POST
path: /users
input: [name]
get:
method: GET
path: /users/:id
delete:
method: DELETE
path: /users/:id
"#;
crate::parser::parse_resource(yaml).unwrap()
}
#[test]
fn client_type_name_conversion() {
assert_eq!(client_type_name("users_api"), "UsersApiClient");
assert_eq!(client_type_name("orders"), "OrdersClient");
}
#[test]
fn pascal_case_conversion() {
assert_eq!(pascal_case("users"), "Users");
assert_eq!(pascal_case("order_items"), "OrderItems");
assert_eq!(pascal_case("my-service"), "MyService");
}
#[test]
fn generate_client_contains_struct() {
let resource = make_resource();
let code = generate_service_client("users-api", &[resource]);
assert!(code.contains("pub struct UsersApiClient"));
assert!(code.contains("pub fn new("));
assert!(code.contains("pub fn with_auth("));
}
#[test]
fn generate_client_contains_methods() {
let resource = make_resource();
let code = generate_service_client("users-api", &[resource]);
assert!(code.contains("pub async fn users_list("));
assert!(code.contains("pub async fn users_create("));
assert!(code.contains("pub async fn users_get("));
assert!(code.contains("pub async fn users_delete("));
}
#[test]
fn generate_client_contains_types() {
let resource = make_resource();
let code = generate_service_client("users-api", &[resource]);
assert!(code.contains("pub struct Users {"));
assert!(code.contains("pub struct UsersInput {"));
}
#[test]
fn generate_client_module_includes_error_type() {
let resource = make_resource();
let code = generate_client_module("users-api", &[resource]);
assert!(code.contains("pub enum ClientError"));
assert!(code.contains("pub struct UsersApiClient"));
}
#[test]
fn generate_client_empty_resources() {
let code = generate_service_client("empty-svc", &[]);
assert!(code.contains("pub struct EmptySvcClient"));
assert!(!code.contains("pub async fn"));
}
}