use anyhow::Result;
use heck::{ToPascalCase, ToSnakeCase};
use serde_json::Value;
use crate::codegen::openrpc::spec_parser::{
OpenRpcMethod, OpenRpcSpec, get_method_params_class_name, get_result_class_name, resolve_schema, schema_ref_name,
};
use super::OpenRpcGenerator;
pub struct RustOpenRpcGenerator;
impl OpenRpcGenerator for RustOpenRpcGenerator {
fn generate_handler_app(&self, spec: &OpenRpcSpec) -> Result<String> {
let mut code = String::new();
code.push_str("//! JSON-RPC 2.0 handlers generated from OpenRPC specification.\n");
code.push_str("//!\n");
code.push_str("//! Generated by Spikard CLI. Integrate `register_jsonrpc_route` into an\n");
code.push_str("//! existing `spikard::App` instead of copying this into a standalone main.\n\n");
code.push_str("use axum::body::Body;\n");
code.push_str("use axum::http::{Response, StatusCode};\n");
code.push_str("use schemars::JsonSchema;\n");
code.push_str("use serde::{Deserialize, Serialize};\n");
code.push_str("use serde_json::Value;\n");
code.push_str("use spikard::{App, AppError, HandlerResult, RequestContext, post};\n\n");
code.push_str("#[derive(Debug, Clone, Deserialize, JsonSchema)]\n");
code.push_str("pub struct JsonRpcRequest {\n");
code.push_str(" pub jsonrpc: String,\n");
code.push_str(" pub method: String,\n");
code.push_str(" #[serde(default)]\n");
code.push_str(" pub params: Option<Value>,\n");
code.push_str(" #[serde(default)]\n");
code.push_str(" pub id: Option<Value>,\n");
code.push_str("}\n\n");
code.push_str("#[derive(Debug, Clone, Serialize, JsonSchema)]\n");
code.push_str("pub struct JsonRpcErrorObject {\n");
code.push_str(" pub code: i32,\n");
code.push_str(" pub message: String,\n");
code.push_str(" #[serde(skip_serializing_if = \"Option::is_none\")]\n");
code.push_str(" pub data: Option<Value>,\n");
code.push_str("}\n\n");
code.push_str("#[derive(Debug, Clone, Serialize, JsonSchema)]\n");
code.push_str("pub struct JsonRpcResponse {\n");
code.push_str(" pub jsonrpc: &'static str,\n");
code.push_str(" #[serde(skip_serializing_if = \"Option::is_none\")]\n");
code.push_str(" pub result: Option<Value>,\n");
code.push_str(" #[serde(skip_serializing_if = \"Option::is_none\")]\n");
code.push_str(" pub error: Option<JsonRpcErrorObject>,\n");
code.push_str(" #[serde(skip_serializing_if = \"Option::is_none\")]\n");
code.push_str(" pub id: Option<Value>,\n");
code.push_str("}\n\n");
code.push_str("impl JsonRpcResponse {\n");
code.push_str(" fn success(result: Value, id: Option<Value>) -> Self {\n");
code.push_str(" Self {\n");
code.push_str(" jsonrpc: \"2.0\",\n");
code.push_str(" result: Some(result),\n");
code.push_str(" error: None,\n");
code.push_str(" id,\n");
code.push_str(" }\n");
code.push_str(" }\n\n");
code.push_str(
" fn error(code: i32, message: impl Into<String>, data: Option<Value>, id: Option<Value>) -> Self {\n",
);
code.push_str(" Self {\n");
code.push_str(" jsonrpc: \"2.0\",\n");
code.push_str(" result: None,\n");
code.push_str(" error: Some(JsonRpcErrorObject {\n");
code.push_str(" code,\n");
code.push_str(" message: message.into(),\n");
code.push_str(" data,\n");
code.push_str(" }),\n");
code.push_str(" id,\n");
code.push_str(" }\n");
code.push_str(" }\n");
code.push_str("}\n\n");
for (name, schema) in &spec.components.schemas {
generate_named_schema(&mut code, spec, &name.to_pascal_case(), schema)?;
}
for method in &spec.methods {
generate_rust_dtos(&mut code, spec, method)?;
}
for method in &spec.methods {
generate_rust_handler(&mut code, method)?;
}
code.push_str("pub async fn handle_jsonrpc_call(\n");
code.push_str(" method_name: &str,\n");
code.push_str(" params: Option<Value>,\n");
code.push_str(" request_id: Option<Value>,\n");
code.push_str(") -> JsonRpcResponse {\n");
code.push_str(" match method_name {\n");
for method in &spec.methods {
let params_class = get_method_params_class_name(&method.name);
let result_class = get_result_class_name(&method.name);
let handler_name = handler_name_for_method(&method.name);
code.push_str(&format!(" \"{}\" => {{\n", method.name));
code.push_str(" let raw_params = params.unwrap_or_else(|| serde_json::json!({}));\n");
code.push_str(&format!(
" let parsed_params: {params_class} = match serde_json::from_value(raw_params) {{\n"
));
code.push_str(" Ok(params) => params,\n");
code.push_str(" Err(err) => {\n");
code.push_str(
" return JsonRpcResponse::error(-32602, \"Invalid params\", Some(serde_json::json!({ \"details\": err.to_string() })), request_id);\n",
);
code.push_str(" }\n");
code.push_str(" };\n");
code.push_str(&format!(
" let result: {result_class} = match {handler_name}(parsed_params).await {{\n"
));
code.push_str(" Ok(result) => result,\n");
code.push_str(" Err(err) => {\n");
code.push_str(
" return JsonRpcResponse::error(-32603, \"Internal error\", Some(serde_json::json!({ \"details\": err })), request_id);\n",
);
code.push_str(" }\n");
code.push_str(" };\n");
code.push_str(" match serde_json::to_value(result) {\n");
code.push_str(" Ok(value) => JsonRpcResponse::success(value, request_id),\n");
code.push_str(
" Err(err) => JsonRpcResponse::error(-32603, \"Internal error\", Some(serde_json::json!({ \"details\": err.to_string() })), request_id),\n",
);
code.push_str(" }\n");
code.push_str(" }\n");
}
code.push_str(" _ => JsonRpcResponse::error(-32601, \"Method not found\", None, request_id),\n");
code.push_str(" }\n");
code.push_str("}\n\n");
code.push_str("pub async fn jsonrpc_handler(ctx: RequestContext) -> HandlerResult {\n");
code.push_str(" let request: JsonRpcRequest = serde_json::from_value(ctx.body_value().clone())\n");
code.push_str(
" .map_err(|err| (StatusCode::BAD_REQUEST, format!(\"Invalid JSON-RPC request: {err}\")))?;\n",
);
code.push_str(" let response = handle_jsonrpc_call(&request.method, request.params, request.id).await;\n");
code.push_str(" json_response(StatusCode::OK, &response)\n");
code.push_str("}\n\n");
code.push_str("pub fn register_jsonrpc_route(app: &mut App, path: &str) -> Result<(), AppError> {\n");
code.push_str(" app.route(post(path).handler_name(\"jsonrpc\"), jsonrpc_handler)?;\n");
code.push_str(" Ok(())\n");
code.push_str("}\n\n");
code.push_str("fn json_response<T: Serialize>(status: StatusCode, value: &T) -> HandlerResult {\n");
code.push_str(" let body = serde_json::to_vec(value)\n");
code.push_str(" .map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?;\n");
code.push_str(" Response::builder()\n");
code.push_str(" .status(status)\n");
code.push_str(" .header(\"content-type\", \"application/json\")\n");
code.push_str(" .body(Body::from(body))\n");
code.push_str(" .map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))\n");
code.push_str("}\n");
Ok(code)
}
fn language_name(&self) -> &'static str {
"rust"
}
}
fn generate_named_schema(code: &mut String, spec: &OpenRpcSpec, type_name: &str, schema: &Value) -> Result<()> {
let resolved = resolve_schema(spec, schema);
emit_nested_object_types(code, spec, type_name, resolved)?;
code.push_str(&format!("/// Schema `{type_name}`.\n"));
code.push_str("#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]\n");
if let Some(properties) = resolved.get("properties").and_then(Value::as_object) {
code.push_str(&format!("pub struct {type_name} {{\n"));
let required_fields = required_field_names(resolved);
for (field_name, field_schema) in properties {
let rust_name = rust_identifier(field_name);
let required = required_fields.iter().any(|required_name| required_name == field_name);
let field_type =
json_schema_to_rust_type_in_context(spec, field_schema, required, Some(type_name), Some(field_name));
if rust_name != *field_name {
code.push_str(&format!(" #[serde(rename = \"{}\")]\n", field_name));
}
if !required {
code.push_str(" #[serde(default, skip_serializing_if = \"Option::is_none\")]\n");
}
code.push_str(&format!(" pub {rust_name}: {field_type},\n"));
}
code.push_str("}\n\n");
} else {
code.push_str(&format!(
"pub type {type_name} = {};\n\n",
json_schema_to_rust_type(spec, resolved, true)
));
}
Ok(())
}
fn generate_rust_dtos(code: &mut String, spec: &OpenRpcSpec, method: &OpenRpcMethod) -> Result<()> {
let params_class = get_method_params_class_name(&method.name);
let result_class = get_result_class_name(&method.name);
for param in &method.params {
emit_inline_field_types(code, spec, ¶ms_class, ¶m.name, ¶m.schema)?;
}
code.push_str(&format!("/// Parameters for `{}`.\n", method.name));
code.push_str("#[derive(Debug, Clone, Deserialize, JsonSchema)]\n");
code.push_str(&format!("pub struct {params_class} {{\n"));
if method.params.is_empty() {
code.push_str("}\n\n");
} else {
for param in &method.params {
let field_name = rust_identifier(¶m.name);
let field_type = json_schema_to_rust_type_in_context(
spec,
¶m.schema,
param.required,
Some(¶ms_class),
Some(¶m.name),
);
if field_name != param.name {
code.push_str(&format!(" #[serde(rename = \"{}\")]\n", param.name));
}
if !param.required {
code.push_str(" #[serde(default, skip_serializing_if = \"Option::is_none\")]\n");
}
if let Some(description) = ¶m.description {
code.push_str(&doc_comment(description, 4));
}
code.push_str(&format!(" pub {field_name}: {field_type},\n"));
}
code.push_str("}\n\n");
}
let resolved_result = resolve_schema(spec, &method.result.schema);
emit_nested_object_types(code, spec, &result_class, resolved_result)?;
code.push_str(&format!("/// Result payload for `{}`.\n", method.name));
if let Some(ref_name) = schema_ref_name(&method.result.schema) {
code.push_str(&format!("pub type {result_class} = {};\n\n", ref_name.to_pascal_case()));
} else if let Some(properties) = resolved_result.get("properties").and_then(Value::as_object) {
code.push_str("#[derive(Debug, Clone, Serialize, JsonSchema)]\n");
code.push_str(&format!("pub struct {result_class} {{\n"));
let required_fields = required_field_names(resolved_result);
for (field_name, field_schema) in properties {
let rust_name = rust_identifier(field_name);
let required = required_fields.iter().any(|required_name| required_name == field_name);
let field_type = json_schema_to_rust_type_in_context(
spec,
field_schema,
required,
Some(&result_class),
Some(field_name),
);
if rust_name != *field_name {
code.push_str(&format!(" #[serde(rename = \"{}\")]\n", field_name));
}
if !required {
code.push_str(" #[serde(default, skip_serializing_if = \"Option::is_none\")]\n");
}
code.push_str(&format!(" pub {rust_name}: {field_type},\n"));
}
code.push_str("}\n\n");
} else {
code.push_str(&format!(
"pub type {result_class} = {};\n\n",
json_schema_to_rust_type(spec, resolved_result, true)
));
}
Ok(())
}
fn emit_nested_object_types(code: &mut String, spec: &OpenRpcSpec, parent_name: &str, schema: &Value) -> Result<()> {
let resolved = resolve_schema(spec, schema);
if let Some(properties) = resolved.get("properties").and_then(Value::as_object) {
for (field_name, field_schema) in properties {
emit_inline_field_types(code, spec, parent_name, field_name, field_schema)?;
}
}
Ok(())
}
fn emit_inline_field_types(
code: &mut String,
spec: &OpenRpcSpec,
parent_name: &str,
field_name: &str,
schema: &Value,
) -> Result<()> {
if schema_ref_name(schema).is_some() {
return Ok(());
}
let resolved = resolve_schema(spec, schema);
if schema_has_named_object_shape(resolved) {
let nested_name = format!("{parent_name}{}", field_name.to_pascal_case());
generate_named_schema(code, spec, &nested_name, resolved)?;
return Ok(());
}
if let Some(items) = resolved.get("items") {
let resolved_items = resolve_schema(spec, items);
if schema_ref_name(items).is_none() && schema_has_named_object_shape(resolved_items) {
let nested_name = format!("{parent_name}{}Item", field_name.to_pascal_case());
generate_named_schema(code, spec, &nested_name, resolved_items)?;
}
}
Ok(())
}
fn schema_has_named_object_shape(schema: &Value) -> bool {
schema
.get("type")
.and_then(Value::as_str)
.is_some_and(|schema_type| schema_type == "object")
&& schema
.get("properties")
.and_then(Value::as_object)
.is_some_and(|properties| !properties.is_empty())
}
fn generate_rust_handler(code: &mut String, method: &OpenRpcMethod) -> Result<()> {
let handler_name = handler_name_for_method(&method.name);
let params_class = get_method_params_class_name(&method.name);
let result_class = get_result_class_name(&method.name);
if let Some(summary) = &method.summary {
code.push_str(&doc_comment(summary, 0));
}
if let Some(description) = &method.description {
code.push_str(&doc_comment(description, 0));
}
code.push_str(&format!(
"pub async fn {handler_name}(_params: {params_class}) -> Result<{result_class}, String> {{\n"
));
code.push_str(" Err(\"Implement JSON-RPC method logic\".to_string())\n");
code.push_str("}\n\n");
Ok(())
}
fn handler_name_for_method(method_name: &str) -> String {
format!("handle_{}", rust_identifier(&method_name.replace(['.', '-'], "_")))
}
fn rust_identifier(name: &str) -> String {
let candidate = name.to_snake_case();
match candidate.as_str() {
"type" => "type_".to_string(),
"match" => "match_".to_string(),
"loop" => "loop_".to_string(),
"self" => "self_".to_string(),
_ => candidate,
}
}
fn json_schema_to_rust_type(spec: &OpenRpcSpec, schema: &Value, required: bool) -> String {
json_schema_to_rust_type_in_context(spec, schema, required, None, None)
}
fn json_schema_to_rust_type_in_context(
spec: &OpenRpcSpec,
schema: &Value,
required: bool,
parent_name: Option<&str>,
field_name: Option<&str>,
) -> String {
if let Some(ref_name) = schema_ref_name(schema) {
let base_type = ref_name.to_pascal_case();
return if required {
base_type
} else {
format!("Option<{base_type}>")
};
}
let resolved = resolve_schema(spec, schema);
let base_type = match resolved.get("type").and_then(Value::as_str) {
Some("string") => match resolved.get("format").and_then(Value::as_str) {
Some("uuid") => "uuid::Uuid".to_string(),
Some("date-time") => "chrono::DateTime<chrono::Utc>".to_string(),
Some("date") => "chrono::NaiveDate".to_string(),
_ => "String".to_string(),
},
Some("integer") => "i64".to_string(),
Some("number") => "f64".to_string(),
Some("boolean") => "bool".to_string(),
Some("array") => {
let item_type = resolved
.get("items")
.map(|items| {
if let (Some(parent_name), Some(field_name)) = (parent_name, field_name) {
if schema_ref_name(items).is_none()
&& schema_has_named_object_shape(resolve_schema(spec, items))
{
return format!("{parent_name}{}Item", field_name.to_pascal_case());
}
}
json_schema_to_rust_type_in_context(spec, items, true, None, None)
})
.unwrap_or_else(|| "Value".to_string());
format!("Vec<{item_type}>")
}
Some("object") => {
if let (Some(parent_name), Some(field_name)) = (parent_name, field_name)
&& schema_has_named_object_shape(resolved)
{
format!("{parent_name}{}", field_name.to_pascal_case())
} else {
"Value".to_string()
}
}
_ if resolved.get("enum").is_some() => "String".to_string(),
_ => "Value".to_string(),
};
if required {
base_type
} else {
format!("Option<{base_type}>")
}
}
fn required_field_names(schema: &Value) -> Vec<String> {
schema
.get("required")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|value| value.as_str().map(str::to_string))
.collect()
}
fn doc_comment(text: &str, indent: usize) -> String {
let prefix = " ".repeat(indent);
let mut output = String::new();
for line in text.lines().map(str::trim).filter(|line| !line.is_empty()) {
output.push_str(&format!("{prefix}/// {line}\n"));
}
output
}
#[cfg(test)]
mod tests {
use super::*;
use crate::codegen::openrpc::spec_parser::{OpenRpcInfo, OpenRpcParam, OpenRpcResult, OpenRpcSpec};
use serde_json::json;
fn sample_spec() -> OpenRpcSpec {
OpenRpcSpec {
openrpc: "1.3.2".to_string(),
info: OpenRpcInfo {
title: "User API".to_string(),
version: "1.0.0".to_string(),
description: None,
contact: None,
license: None,
},
methods: vec![OpenRpcMethod {
name: "user.get".to_string(),
summary: Some("Fetch a user".to_string()),
description: Some("Returns a single user by identifier.".to_string()),
params: vec![OpenRpcParam {
name: "userId".to_string(),
description: Some("Unique user identifier".to_string()),
required: true,
schema: json!({ "type": "string" }),
}],
result: OpenRpcResult {
name: "user".to_string(),
description: Some("User payload".to_string()),
schema: json!({
"type": "object",
"properties": {
"id": { "type": "string" },
"active": { "type": "boolean" }
},
"required": ["id"]
}),
},
errors: vec![],
examples: vec![],
tags: vec![],
}],
servers: vec![],
components: Default::default(),
}
}
#[test]
fn rust_generator_emits_spikard_router_helpers() {
let generator = RustOpenRpcGenerator;
let output = generator.generate_handler_app(&sample_spec()).unwrap();
assert!(output.contains("pub async fn handle_jsonrpc_call"));
assert!(output.contains("pub async fn jsonrpc_handler"));
assert!(output.contains("pub fn register_jsonrpc_route"));
assert!(output.contains("post(path).handler_name(\"jsonrpc\")"));
}
#[test]
fn rust_generator_emits_typed_dtos() {
let generator = RustOpenRpcGenerator;
let output = generator.generate_handler_app(&sample_spec()).unwrap();
assert!(output.contains("pub struct UserGetParams"));
assert!(output.contains("pub user_id: String"));
assert!(output.contains("pub struct UserGetResult"));
assert!(output.contains("pub active: Option<bool>"));
}
#[test]
fn rust_generator_uses_snake_case_handler_names() {
let generator = RustOpenRpcGenerator;
let output = generator.generate_handler_app(&sample_spec()).unwrap();
assert!(output.contains("pub async fn handle_user_get"));
}
}