shaperail-codegen 0.12.0

YAML parser, validator, and code generator for Shaperail
Documentation
use shaperail_core::{EndpointSpec, HttpMethod, ResourceDefinition};

/// Generate a typed inter-service client module for a set of resources.
///
/// The generated client provides type-safe methods for calling endpoints
/// on a remote service, using the resource definitions as the contract.
/// Type mismatches between services become compile errors.
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");

    // Generate the client struct
    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");

    // Generate constructor
    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");

    // Generate typed methods per resource endpoint
    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; // used in generate_endpoint_method
        }
    }

    out.push_str("}\n\n");

    // Generate request/response types per resource
    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();

    // Method signature
    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(),
        }
    ));

    // Build URL — generate a Rust format!() call as source code
    let versioned_path = format!("/v{version}{}", endpoint.path());
    if has_id_param {
        // Output: let url = format!("{}/v1/users/{}", self.base_url, id);
        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 {
        // Output: let url = format!("{}/v1/users", self.base_url);
        out.push_str("        let url = format!(\"{}");
        out.push_str(&versioned_path);
        out.push_str("\", self.base_url);\n");
    }

    // Build request
    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");
    }

    // Send and parse
    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();

    // Main record type
    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");

    // Input type (fields that appear in any endpoint's input list)
    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
}

/// Error type for inter-service client calls.
/// This is included in generated code as a string constant.
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 {}
"#;

/// Generate a complete inter-service client module file.
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"));
        // No methods generated
        assert!(!code.contains("pub async fn"));
    }
}