use anyhow::{Context, Result};
use heck::ToPascalCase;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::fs;
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenRpcSpec {
pub openrpc: String,
pub info: OpenRpcInfo,
pub methods: Vec<OpenRpcMethod>,
#[serde(default)]
pub servers: Vec<OpenRpcServer>,
#[serde(default)]
pub components: OpenRpcComponents,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenRpcInfo {
pub title: String,
pub version: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub contact: Option<OpenRpcContact>,
#[serde(default)]
pub license: Option<OpenRpcLicense>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenRpcContact {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub email: Option<String>,
#[serde(default)]
pub url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenRpcLicense {
pub name: String,
#[serde(default)]
pub url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenRpcServer {
pub name: String,
pub url: String,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenRpcMethod {
pub name: String,
#[serde(default)]
pub summary: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub params: Vec<OpenRpcParam>,
pub result: OpenRpcResult,
#[serde(default)]
pub errors: Vec<OpenRpcError>,
#[serde(default)]
pub examples: Vec<OpenRpcExample>,
#[serde(default)]
pub tags: Vec<OpenRpcTag>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenRpcTag {
pub name: String,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenRpcParam {
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub required: bool,
pub schema: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenRpcResult {
pub name: String,
#[serde(default)]
pub description: Option<String>,
pub schema: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenRpcError {
pub code: i32,
pub message: String,
#[serde(default)]
pub data: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenRpcExample {
pub name: String,
#[serde(default)]
pub description: Option<String>,
pub params: Vec<OpenRpcExampleParam>,
pub result: OpenRpcExampleResult,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenRpcExampleParam {
pub name: String,
pub value: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenRpcExampleResult {
pub name: String,
pub value: Value,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct OpenRpcComponents {
#[serde(default)]
pub schemas: HashMap<String, Value>,
}
pub fn parse_openrpc_schema(path: &Path) -> Result<OpenRpcSpec> {
let content =
fs::read_to_string(path).with_context(|| format!("Failed to read OpenRPC file: {}", path.display()))?;
let spec: OpenRpcSpec = if path.extension().and_then(|s| s.to_str()) == Some("json") {
serde_json::from_str(&content)
.with_context(|| format!("Failed to parse OpenRPC JSON from {}", path.display()))?
} else {
serde_saphyr::from_str(&content)
.with_context(|| format!("Failed to parse OpenRPC YAML from {}", path.display()))?
};
if !spec.openrpc.starts_with("1.3") {
anyhow::bail!("Unsupported OpenRPC version: {}. Expected 1.3.x", spec.openrpc);
}
Ok(spec)
}
pub fn schema_ref_name(schema: &Value) -> Option<&str> {
schema
.get("$ref")
.and_then(Value::as_str)
.and_then(|reference| reference.split('/').next_back())
}
pub fn resolve_schema<'a>(spec: &'a OpenRpcSpec, schema: &'a Value) -> &'a Value {
let mut current = schema;
let mut depth = 0usize;
while let Some(reference) = current.get("$ref").and_then(Value::as_str) {
let Some(name) = reference.split('/').next_back() else {
break;
};
let Some(next) = spec.components.schemas.get(name) else {
break;
};
current = next;
depth += 1;
if depth >= 32 {
break;
}
}
current
}
pub fn extract_methods(spec: &OpenRpcSpec) -> Vec<&OpenRpcMethod> {
spec.methods.iter().collect()
}
pub fn get_method_params_class_name(method_name: &str) -> String {
format!("{}Params", method_name.replace(['.', '-', '_'], " ").to_pascal_case())
}
pub fn get_result_class_name(method_name: &str) -> String {
format!("{}Result", method_name.replace(['.', '-', '_'], " ").to_pascal_case())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_get_method_params_class_name() {
assert_eq!(get_method_params_class_name("user.getById"), "UserGetByIdParams");
assert_eq!(get_method_params_class_name("complex_method"), "ComplexMethodParams");
assert_eq!(get_method_params_class_name("user-create"), "UserCreateParams");
}
#[test]
fn test_get_result_class_name() {
assert_eq!(get_result_class_name("user.getById"), "UserGetByIdResult");
assert_eq!(get_result_class_name("complex_method"), "ComplexMethodResult");
assert_eq!(get_result_class_name("user-create"), "UserCreateResult");
}
#[test]
fn test_parse_openrpc_schema_rejects_unsupported_version() {
let dir = tempdir().unwrap();
let path = dir.path().join("api.yaml");
std::fs::write(
&path,
r#"
openrpc: "2.0.0"
info:
title: Demo
version: "1.0.0"
methods: []
"#,
)
.unwrap();
let err = parse_openrpc_schema(&path).unwrap_err();
assert!(err.to_string().contains("Unsupported OpenRPC version"), "{err}");
}
#[test]
fn test_parse_openrpc_schema_supports_yaml() {
let dir = tempdir().unwrap();
let path = dir.path().join("api.yaml");
std::fs::write(
&path,
r#"
openrpc: "1.3.2"
info:
title: Demo
version: "1.0.0"
methods:
- name: demo.ping
params:
- name: value
required: true
schema:
type: string
result:
name: result
schema:
type: string
"#,
)
.unwrap();
let spec = parse_openrpc_schema(&path).unwrap();
assert_eq!(spec.openrpc, "1.3.2");
assert_eq!(spec.methods.len(), 1);
assert_eq!(spec.methods[0].name, "demo.ping");
}
#[test]
fn test_extract_methods_returns_all_methods() {
let dir = tempdir().unwrap();
let path = dir.path().join("api.yaml");
std::fs::write(
&path,
r#"
openrpc: "1.3.2"
info:
title: Demo
version: "1.0.0"
methods:
- name: demo.a
result:
name: result
schema:
type: string
- name: demo.b
result:
name: result
schema:
type: string
"#,
)
.unwrap();
let spec = parse_openrpc_schema(&path).unwrap();
let methods = extract_methods(&spec);
assert_eq!(methods.len(), 2);
assert_eq!(methods[0].name, "demo.a");
assert_eq!(methods[1].name, "demo.b");
}
}