Skip to main content

spikard_cli/codegen/openrpc/
spec_parser.rs

1//! `OpenRPC` 1.3.2 specification parsing and extraction.
2//!
3//! This module handles parsing `OpenRPC` 1.3.2 specs and extracting structured data
4//! for code generation, including methods, parameters, results, and errors.
5
6use anyhow::{Context, Result};
7use heck::ToPascalCase;
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10use std::collections::HashMap;
11use std::fs;
12use std::path::Path;
13
14/// Complete `OpenRPC` 1.3.2 specification
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct OpenRpcSpec {
17    /// `OpenRPC` version (should be "1.3.2")
18    pub openrpc: String,
19    /// API metadata
20    pub info: OpenRpcInfo,
21    /// JSON-RPC methods
22    pub methods: Vec<OpenRpcMethod>,
23    /// Server information (optional)
24    #[serde(default)]
25    pub servers: Vec<OpenRpcServer>,
26    /// Reusable components (optional)
27    #[serde(default)]
28    pub components: OpenRpcComponents,
29}
30
31/// API metadata
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct OpenRpcInfo {
34    pub title: String,
35    pub version: String,
36    #[serde(default)]
37    pub description: Option<String>,
38    #[serde(default)]
39    pub contact: Option<OpenRpcContact>,
40    #[serde(default)]
41    pub license: Option<OpenRpcLicense>,
42}
43
44/// Contact information
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct OpenRpcContact {
47    #[serde(default)]
48    pub name: Option<String>,
49    #[serde(default)]
50    pub email: Option<String>,
51    #[serde(default)]
52    pub url: Option<String>,
53}
54
55/// License information
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct OpenRpcLicense {
58    pub name: String,
59    #[serde(default)]
60    pub url: Option<String>,
61}
62
63/// Server information
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct OpenRpcServer {
66    pub name: String,
67    pub url: String,
68    #[serde(default)]
69    pub description: Option<String>,
70}
71
72/// JSON-RPC method definition
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct OpenRpcMethod {
75    /// Method name (e.g., "user.getById")
76    pub name: String,
77    /// Short description
78    #[serde(default)]
79    pub summary: Option<String>,
80    /// Longer description
81    #[serde(default)]
82    pub description: Option<String>,
83    /// Method parameters
84    #[serde(default)]
85    pub params: Vec<OpenRpcParam>,
86    /// Method result definition
87    pub result: OpenRpcResult,
88    /// Method errors
89    #[serde(default)]
90    pub errors: Vec<OpenRpcError>,
91    /// Example calls
92    #[serde(default)]
93    pub examples: Vec<OpenRpcExample>,
94    /// Tags for organization (optional)
95    #[serde(default)]
96    pub tags: Vec<OpenRpcTag>,
97}
98
99/// Tag for organizing methods
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct OpenRpcTag {
102    pub name: String,
103    #[serde(default)]
104    pub description: Option<String>,
105}
106
107/// Method parameter
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct OpenRpcParam {
110    pub name: String,
111    #[serde(default)]
112    pub description: Option<String>,
113    /// Whether parameter is required
114    #[serde(default)]
115    pub required: bool,
116    /// JSON Schema for parameter
117    pub schema: Value,
118}
119
120/// Method result definition
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct OpenRpcResult {
123    pub name: String,
124    #[serde(default)]
125    pub description: Option<String>,
126    /// JSON Schema for result
127    pub schema: Value,
128}
129
130/// Error definition
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct OpenRpcError {
133    /// Error code
134    pub code: i32,
135    /// Error message
136    pub message: String,
137    /// Error data schema (optional)
138    #[serde(default)]
139    pub data: Option<Value>,
140}
141
142/// Example call
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct OpenRpcExample {
145    pub name: String,
146    #[serde(default)]
147    pub description: Option<String>,
148    /// Example parameters
149    pub params: Vec<OpenRpcExampleParam>,
150    /// Example result
151    pub result: OpenRpcExampleResult,
152}
153
154/// Example parameter value
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct OpenRpcExampleParam {
157    pub name: String,
158    pub value: Value,
159}
160
161/// Example result value
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct OpenRpcExampleResult {
164    pub name: String,
165    pub value: Value,
166}
167
168/// Reusable components
169#[derive(Debug, Clone, Default, Serialize, Deserialize)]
170pub struct OpenRpcComponents {
171    /// Reusable schemas
172    #[serde(default)]
173    pub schemas: HashMap<String, Value>,
174}
175
176/// Parse an `OpenRPC` 1.3.2 specification file
177///
178/// Supports both JSON and YAML formats
179pub fn parse_openrpc_schema(path: &Path) -> Result<OpenRpcSpec> {
180    let content =
181        fs::read_to_string(path).with_context(|| format!("Failed to read OpenRPC file: {}", path.display()))?;
182
183    let spec: OpenRpcSpec = if path.extension().and_then(|s| s.to_str()) == Some("json") {
184        serde_json::from_str(&content)
185            .with_context(|| format!("Failed to parse OpenRPC JSON from {}", path.display()))?
186    } else {
187        serde_saphyr::from_str(&content)
188            .with_context(|| format!("Failed to parse OpenRPC YAML from {}", path.display()))?
189    };
190
191    if !spec.openrpc.starts_with("1.3") {
192        anyhow::bail!("Unsupported OpenRPC version: {}. Expected 1.3.x", spec.openrpc);
193    }
194
195    Ok(spec)
196}
197
198/// Extract the component schema name from a `$ref`, if present.
199pub fn schema_ref_name(schema: &Value) -> Option<&str> {
200    schema
201        .get("$ref")
202        .and_then(Value::as_str)
203        .and_then(|reference| reference.split('/').next_back())
204}
205
206/// Resolve a component schema reference to its concrete schema definition.
207///
208/// Unknown or invalid references are returned unchanged so generators can
209/// degrade gracefully instead of failing mid-generation.
210pub fn resolve_schema<'a>(spec: &'a OpenRpcSpec, schema: &'a Value) -> &'a Value {
211    let mut current = schema;
212    let mut depth = 0usize;
213
214    while let Some(reference) = current.get("$ref").and_then(Value::as_str) {
215        let Some(name) = reference.split('/').next_back() else {
216            break;
217        };
218        let Some(next) = spec.components.schemas.get(name) else {
219            break;
220        };
221
222        current = next;
223        depth += 1;
224
225        if depth >= 32 {
226            break;
227        }
228    }
229
230    current
231}
232
233/// Extract all methods from spec
234pub fn extract_methods(spec: &OpenRpcSpec) -> Vec<&OpenRpcMethod> {
235    spec.methods.iter().collect()
236}
237
238/// Get params class name from method name
239pub fn get_method_params_class_name(method_name: &str) -> String {
240    format!("{}Params", method_name.replace(['.', '-', '_'], " ").to_pascal_case())
241}
242
243/// Get result class name from method name
244pub fn get_result_class_name(method_name: &str) -> String {
245    format!("{}Result", method_name.replace(['.', '-', '_'], " ").to_pascal_case())
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use tempfile::tempdir;
252
253    #[test]
254    fn test_get_method_params_class_name() {
255        assert_eq!(get_method_params_class_name("user.getById"), "UserGetByIdParams");
256        assert_eq!(get_method_params_class_name("complex_method"), "ComplexMethodParams");
257        assert_eq!(get_method_params_class_name("user-create"), "UserCreateParams");
258    }
259
260    #[test]
261    fn test_get_result_class_name() {
262        assert_eq!(get_result_class_name("user.getById"), "UserGetByIdResult");
263        assert_eq!(get_result_class_name("complex_method"), "ComplexMethodResult");
264        assert_eq!(get_result_class_name("user-create"), "UserCreateResult");
265    }
266
267    #[test]
268    fn test_parse_openrpc_schema_rejects_unsupported_version() {
269        let dir = tempdir().unwrap();
270        let path = dir.path().join("api.yaml");
271        std::fs::write(
272            &path,
273            r#"
274openrpc: "2.0.0"
275info:
276  title: Demo
277  version: "1.0.0"
278methods: []
279"#,
280        )
281        .unwrap();
282
283        let err = parse_openrpc_schema(&path).unwrap_err();
284        assert!(err.to_string().contains("Unsupported OpenRPC version"), "{err}");
285    }
286
287    #[test]
288    fn test_parse_openrpc_schema_supports_yaml() {
289        let dir = tempdir().unwrap();
290        let path = dir.path().join("api.yaml");
291        std::fs::write(
292            &path,
293            r#"
294openrpc: "1.3.2"
295info:
296  title: Demo
297  version: "1.0.0"
298methods:
299  - name: demo.ping
300    params:
301      - name: value
302        required: true
303        schema:
304          type: string
305    result:
306      name: result
307      schema:
308        type: string
309"#,
310        )
311        .unwrap();
312
313        let spec = parse_openrpc_schema(&path).unwrap();
314        assert_eq!(spec.openrpc, "1.3.2");
315        assert_eq!(spec.methods.len(), 1);
316        assert_eq!(spec.methods[0].name, "demo.ping");
317    }
318
319    #[test]
320    fn test_extract_methods_returns_all_methods() {
321        let dir = tempdir().unwrap();
322        let path = dir.path().join("api.yaml");
323        std::fs::write(
324            &path,
325            r#"
326openrpc: "1.3.2"
327info:
328  title: Demo
329  version: "1.0.0"
330methods:
331  - name: demo.a
332    result:
333      name: result
334      schema:
335        type: string
336  - name: demo.b
337    result:
338      name: result
339      schema:
340        type: string
341"#,
342        )
343        .unwrap();
344
345        let spec = parse_openrpc_schema(&path).unwrap();
346        let methods = extract_methods(&spec);
347        assert_eq!(methods.len(), 2);
348        assert_eq!(methods[0].name, "demo.a");
349        assert_eq!(methods[1].name, "demo.b");
350    }
351}