spikard_cli/codegen/openrpc/
spec_parser.rs1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct OpenRpcSpec {
17 pub openrpc: String,
19 pub info: OpenRpcInfo,
21 pub methods: Vec<OpenRpcMethod>,
23 #[serde(default)]
25 pub servers: Vec<OpenRpcServer>,
26 #[serde(default)]
28 pub components: OpenRpcComponents,
29}
30
31#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct OpenRpcLicense {
58 pub name: String,
59 #[serde(default)]
60 pub url: Option<String>,
61}
62
63#[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#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct OpenRpcMethod {
75 pub name: String,
77 #[serde(default)]
79 pub summary: Option<String>,
80 #[serde(default)]
82 pub description: Option<String>,
83 #[serde(default)]
85 pub params: Vec<OpenRpcParam>,
86 pub result: OpenRpcResult,
88 #[serde(default)]
90 pub errors: Vec<OpenRpcError>,
91 #[serde(default)]
93 pub examples: Vec<OpenRpcExample>,
94 #[serde(default)]
96 pub tags: Vec<OpenRpcTag>,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct OpenRpcTag {
102 pub name: String,
103 #[serde(default)]
104 pub description: Option<String>,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct OpenRpcParam {
110 pub name: String,
111 #[serde(default)]
112 pub description: Option<String>,
113 #[serde(default)]
115 pub required: bool,
116 pub schema: Value,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct OpenRpcResult {
123 pub name: String,
124 #[serde(default)]
125 pub description: Option<String>,
126 pub schema: Value,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct OpenRpcError {
133 pub code: i32,
135 pub message: String,
137 #[serde(default)]
139 pub data: Option<Value>,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct OpenRpcExample {
145 pub name: String,
146 #[serde(default)]
147 pub description: Option<String>,
148 pub params: Vec<OpenRpcExampleParam>,
150 pub result: OpenRpcExampleResult,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct OpenRpcExampleParam {
157 pub name: String,
158 pub value: Value,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct OpenRpcExampleResult {
164 pub name: String,
165 pub value: Value,
166}
167
168#[derive(Debug, Clone, Default, Serialize, Deserialize)]
170pub struct OpenRpcComponents {
171 #[serde(default)]
173 pub schemas: HashMap<String, Value>,
174}
175
176pub 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
198pub 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
206pub 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
233pub fn extract_methods(spec: &OpenRpcSpec) -> Vec<&OpenRpcMethod> {
235 spec.methods.iter().collect()
236}
237
238pub fn get_method_params_class_name(method_name: &str) -> String {
240 format!("{}Params", method_name.replace(['.', '-', '_'], " ").to_pascal_case())
241}
242
243pub 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}