openapi_generator/
main.rs

1//! Generate an openapi.yaml file from the Zebra RPC methods
2
3use std::{error::Error, fs::File, io::Write};
4
5use indexmap::IndexMap;
6use quote::ToTokens;
7use rand::{distributions::Alphanumeric, thread_rng, Rng};
8use serde::Serialize;
9use syn::LitStr;
10
11use zebra_rpc::methods::{trees::GetTreestate, *};
12
13use types::{get_mining_info, submit_block, subsidy, validate_address, z_validate_address};
14
15// The API server
16const SERVER: &str = "http://localhost:8232";
17
18// The API methods
19#[derive(Serialize, Debug)]
20struct Methods {
21    paths: IndexMap<String, IndexMap<String, MethodConfig>>,
22}
23
24// The configuration for each method
25#[derive(Serialize, Clone, Debug)]
26struct MethodConfig {
27    tags: Vec<String>,
28    description: String,
29    #[serde(rename = "requestBody")]
30    request_body: RequestBody,
31    responses: IndexMap<String, Response>,
32}
33
34// The request body
35#[derive(Serialize, Clone, Debug)]
36struct RequestBody {
37    required: bool,
38    content: Content,
39}
40
41// The content of the request body
42#[derive(Serialize, Clone, Debug)]
43struct Content {
44    #[serde(rename = "application/json")]
45    application_json: Application,
46}
47
48// The application of the request body
49#[derive(Serialize, Clone, Debug)]
50struct Application {
51    schema: Schema,
52}
53
54// The schema of the request body
55#[derive(Serialize, Clone, Debug)]
56struct Schema {
57    #[serde(rename = "type")]
58    type_: String,
59    properties: IndexMap<String, Property>,
60}
61
62// The properties of the request body
63#[derive(Serialize, Clone, Debug)]
64struct Property {
65    #[serde(rename = "type")]
66    type_: String,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    items: Option<ArrayItems>,
69    default: String,
70}
71
72// The response
73#[derive(Serialize, Clone, Debug)]
74struct Response {
75    description: String,
76    content: Content,
77}
78
79// The array items
80#[derive(Serialize, Clone, Debug)]
81struct ArrayItems {}
82
83fn main() -> Result<(), Box<dyn Error>> {
84    let current_path = env!("CARGO_MANIFEST_DIR");
85
86    // Define the paths to the Zebra RPC methods
87    let paths = vec![(
88        format!("{}/../zebra-rpc/src/methods.rs", current_path),
89        "Rpc",
90    )];
91
92    // Create an indexmap to store the method names and configuration
93    let mut methods = IndexMap::new();
94
95    for zebra_rpc_methods_path in paths {
96        // Read the source code from the file
97        let source_code = std::fs::read_to_string(zebra_rpc_methods_path.0)?;
98
99        // Parse the source code into a syn AST
100        let syn_file = syn::parse_file(&source_code)?;
101
102        // Create an indexmap to store the methods configuration
103        let mut methods_config = IndexMap::new();
104
105        // Iterate over items in the file looking for traits
106        for item in &syn_file.items {
107            if let syn::Item::Trait(trait_item) = item {
108                // Check if this trait is the one we're interested in
109                if trait_item.ident == zebra_rpc_methods_path.1 {
110                    // Iterate over the trait items looking for methods
111                    for trait_item in &trait_item.items {
112                        // Extract method name
113                        let method_name = method_name(trait_item)?;
114
115                        // Extract method documentation and description
116                        let (method_doc, mut description) = method_doc(trait_item)?;
117
118                        // Request type. TODO: All methods are POST so we just hardcode it
119                        let request_type = "post".to_string();
120
121                        // Tags. TODO: We are assuming 1 tag per call for now
122                        let tags = tags(&method_doc)?;
123
124                        // Parameters
125                        let mut parameters_example = "[]".to_string();
126                        if let Ok((params_description, params_example)) = get_params(&method_doc) {
127                            // Add parameters to method description:
128                            description =
129                                add_params_to_description(&description, &params_description);
130                            // The Zebra API uses a `params` array to pass arguments to the RPC methods,
131                            // so we need to add this to the OpenAPI spec instead of `parameters`
132                            parameters_example = params_example;
133                        }
134
135                        // Create the request body
136                        let request_body = create_request_body(&method_name, &parameters_example);
137
138                        // Check if we have parameters
139                        let mut have_parameters = true;
140                        if parameters_example == "[]" {
141                            have_parameters = false;
142                        }
143
144                        // Create the responses
145                        let responses = create_responses(&method_name, have_parameters)?;
146
147                        // Add the method configuration to the indexmap
148                        methods_config.insert(
149                            request_type,
150                            MethodConfig {
151                                tags,
152                                description,
153                                request_body,
154                                responses,
155                            },
156                        );
157
158                        // Add the method name and configuration to the indexmap
159                        methods.insert(format!("/{}", method_name), methods_config.clone());
160                    }
161                }
162            }
163        }
164    }
165
166    // Create a struct to hold all the methods
167    let all_methods = Methods { paths: methods };
168
169    // Add openapi header and write to file
170    let yml_string = serde_yml::to_string(&all_methods)?;
171    let mut w = File::create("openapi.yaml")?;
172    w.write_all(format!("{}{}", create_yaml(), yml_string).as_bytes())?;
173
174    Ok(())
175}
176
177// Create the openapi.yaml header
178fn create_yaml() -> String {
179    format!("openapi: 3.0.3
180info:
181    title: Swagger Zebra API - OpenAPI 3.0
182    version: 0.0.1
183    description: |-
184        This is the Zebra API. It is a JSON-RPC 2.0 API that allows you to interact with the Zebra node.
185
186        Useful links:
187        - [The Zebra repository](https://github.com/ZcashFoundation/zebra)
188        - [The latests API spec](https://github.com/ZcashFoundation/zebra/blob/main/openapi.yaml)
189servers:
190  - url: {}
191", SERVER)
192}
193
194// Extract the method name from the trait item
195fn method_name(trait_item: &syn::TraitItem) -> Result<String, Box<dyn Error>> {
196    let mut method_name = "".to_string();
197    if let syn::TraitItem::Fn(method) = trait_item {
198        method_name = method.sig.ident.to_string();
199
200        // Refine name if needed
201        method.attrs.iter().for_each(|attr| {
202            if attr.path().is_ident("rpc") {
203                let _ = attr.parse_nested_meta(|meta| {
204                    method_name = meta.value()?.parse::<LitStr>()?.value();
205                    Ok(())
206                });
207            }
208        });
209    }
210    Ok(method_name)
211}
212
213// Return the method docs array and the description of the method
214fn method_doc(method: &syn::TraitItem) -> Result<(Vec<String>, String), Box<dyn Error>> {
215    let mut method_doc = vec![];
216    if let syn::TraitItem::Fn(method) = method {
217        // Filter only doc attributes
218        let doc_attrs: Vec<_> = method
219            .attrs
220            .iter()
221            .filter(|attr| attr.path().is_ident("doc"))
222            .collect();
223
224        // If no doc attributes found, return an error
225        if doc_attrs.is_empty() {
226            return Err("No documentation attribute found for the method".into());
227        }
228
229        method.attrs.iter().for_each(|attr| {
230            if attr.path().is_ident("doc") {
231                method_doc.push(attr.to_token_stream().to_string());
232            }
233        });
234    }
235
236    // Extract the description from the first line of documentation
237    let description = match method_doc[0].split_once('"') {
238        Some((_, desc)) => desc.trim().to_string().replace('\'', "''"),
239        None => return Err("Description not found in method documentation".into()),
240    };
241
242    Ok((method_doc, description.trim_end_matches("\"]").to_string()))
243}
244
245// Extract the tags from the method documentation. TODO: Assuming 1 tag per method for now
246fn tags(method_doc: &[String]) -> Result<Vec<String>, Box<dyn Error>> {
247    // Find the line containing tags information
248    let tags_line = method_doc
249        .iter()
250        .find(|line| line.contains("tags:"))
251        .ok_or("Tags not found in method documentation")?;
252
253    // Extract tags from the tags line
254    let mut tags = Vec::new();
255    let tags_str = tags_line
256        .split(':')
257        .nth(1)
258        .ok_or("Invalid tags line")?
259        .trim();
260
261    // Split the tags string into individual tags
262    for tag in tags_str.split(',') {
263        let trimmed_tag = tag.trim_matches(|c: char| !c.is_alphanumeric());
264        if !trimmed_tag.is_empty() {
265            tags.push(trimmed_tag.to_string());
266        }
267    }
268
269    Ok(tags)
270}
271
272// Extract the parameters from the method documentation
273fn get_params(method_doc: &[String]) -> Result<(String, String), Box<dyn Error>> {
274    // Find the start and end index of the parameters
275    let params_start_index = method_doc
276        .iter()
277        .enumerate()
278        .find(|(_, line)| line.contains("# Parameters"));
279    let notes_start_index = method_doc
280        .iter()
281        .enumerate()
282        .find(|(_, line)| line.contains("# Notes"));
283
284    // If start and end indices of parameters are found, extract them
285    if let (Some((params_index, _)), Some((notes_index, _))) =
286        (params_start_index, notes_start_index)
287    {
288        let params = &method_doc[params_index + 2..notes_index - 1];
289
290        // Initialize variables to store parameter descriptions and examples
291        let mut param_descriptions = Vec::new();
292        let mut param_examples = Vec::new();
293
294        // Iterate over the parameters and extract information
295        for param_line in params {
296            // Check if the line starts with the expected format
297            if param_line.trim().starts_with("# [doc = \" -") {
298                // Extract parameter name and description
299                if let Some((name, description)) = extract_param_info(param_line) {
300                    param_descriptions.push(format!("- `{}` - {}", name, description));
301
302                    // Extract parameter example if available
303                    if let Some(example) = extract_param_example(param_line) {
304                        param_examples.push(example);
305                    }
306                }
307            }
308        }
309
310        // Format parameters and examples
311        let params_formatted = format!("[{}]", param_examples.join(", "));
312        let params_description = param_descriptions.join("\n");
313
314        return Ok((params_description, params_formatted));
315    }
316
317    Err("No parameters found".into())
318}
319
320// Extract parameter name and description
321fn extract_param_info(param_line: &str) -> Option<(String, String)> {
322    let start_idx = param_line.find('`')?;
323    let end_idx = param_line.rfind('`')?;
324    let name = param_line[start_idx + 1..end_idx].trim().to_string();
325
326    let description_starts = param_line.find(") ")?;
327    let description_ends = param_line.rfind("\"]")?;
328    let description = param_line[description_starts + 2..description_ends]
329        .trim()
330        .to_string();
331
332    Some((name, description))
333}
334
335// Extract parameter example if available
336fn extract_param_example(param_line: &str) -> Option<String> {
337    if let Some(example_start) = param_line.find("example=") {
338        let example_ends = param_line.rfind(')')?;
339        let example = param_line[example_start + 8..example_ends].trim();
340        Some(example.to_string())
341    } else {
342        None
343    }
344}
345
346// Create the request body
347fn create_request_body(method_name: &str, parameters_example: &str) -> RequestBody {
348    // Add the method name to the request body
349    let method_name_prop = Property {
350        type_: "string".to_string(),
351        items: None,
352        default: method_name.to_string(),
353    };
354
355    // Add random string is used to identify the requests done by the client
356    let rand_string: String = thread_rng()
357        .sample_iter(&Alphanumeric)
358        .take(10)
359        .map(char::from)
360        .collect();
361    let request_id_prop = Property {
362        type_: "string".to_string(),
363        items: None,
364        default: rand_string,
365    };
366
367    // Create the schema and add the first 2 properties
368    let mut schema = IndexMap::new();
369    schema.insert("method".to_string(), method_name_prop);
370    schema.insert("id".to_string(), request_id_prop);
371
372    // Add the parameters with the extracted examples
373    let default = parameters_example.replace('\\', "");
374    schema.insert(
375        "params".to_string(),
376        Property {
377            type_: "array".to_string(),
378            items: Some(ArrayItems {}),
379            default,
380        },
381    );
382
383    // Create the request body
384    let content = Content {
385        application_json: Application {
386            schema: Schema {
387                type_: "object".to_string(),
388                properties: schema,
389            },
390        },
391    };
392
393    RequestBody {
394        required: true,
395        content,
396    }
397}
398
399// Create the responses
400fn create_responses(
401    method_name: &str,
402    have_parameters: bool,
403) -> Result<IndexMap<String, Response>, Box<dyn Error>> {
404    let mut responses = IndexMap::new();
405
406    let properties = get_default_properties(method_name)?;
407
408    let res_ok = Response {
409        description: "OK".to_string(),
410        content: Content {
411            application_json: Application {
412                schema: Schema {
413                    type_: "object".to_string(),
414                    properties,
415                },
416            },
417        },
418    };
419    responses.insert("200".to_string(), res_ok);
420
421    let mut properties = IndexMap::new();
422    if have_parameters {
423        properties.insert(
424            "error".to_string(),
425            Property {
426                type_: "string".to_string(),
427                items: None,
428                default: "Invalid parameters".to_string(),
429            },
430        );
431        let res_bad_request = Response {
432            description: "Bad request".to_string(),
433            content: Content {
434                application_json: Application {
435                    schema: Schema {
436                        type_: "object".to_string(),
437                        properties,
438                    },
439                },
440            },
441        };
442        responses.insert("400".to_string(), res_bad_request);
443    }
444
445    Ok(responses)
446}
447
448// Add the parameters to the method description
449fn add_params_to_description(description: &str, params_description: &str) -> String {
450    let mut new_description = description.to_string();
451    new_description.push_str("\n\n**Request body `params` arguments:**\n\n");
452    new_description.push_str(params_description);
453    new_description
454}
455
456fn default_property<T: serde::Serialize>(
457    type_: &str,
458    items: Option<ArrayItems>,
459    default_value: T,
460) -> Result<Property, Box<dyn Error>> {
461    Ok(Property {
462        type_: type_.to_string(),
463        items,
464        default: serde_json::to_string(&default_value)?,
465    })
466}
467
468// Get requests examples by using defaults from the Zebra RPC methods
469// TODO: Make this function more concise/readable (https://github.com/ZcashFoundation/zebra/pull/8616#discussion_r1643193949)
470fn get_default_properties(method_name: &str) -> Result<IndexMap<String, Property>, Box<dyn Error>> {
471    let type_ = "object";
472    let items = None;
473    let mut props = IndexMap::new();
474
475    // TODO: An entry has to be added here manually for each new RPC method introduced, can we automate?
476    let default_result = match method_name {
477        // mining
478        // TODO: missing `getblocktemplate`. It's a complex method that requires a lot of parameters.
479        "getnetworkhashps" => default_property(type_, items.clone(), u64::default())?,
480        "getblocksubsidy" => {
481            default_property(type_, items.clone(), subsidy::BlockSubsidy::default())?
482        }
483        "getmininginfo" => {
484            default_property(type_, items.clone(), get_mining_info::Response::default())?
485        }
486        "getnetworksolps" => default_property(type_, items.clone(), u64::default())?,
487        "submitblock" => default_property(type_, items.clone(), submit_block::Response::default())?,
488        // util
489        "validateaddress" => {
490            default_property(type_, items.clone(), validate_address::Response::default())?
491        }
492        "z_validateaddress" => default_property(
493            type_,
494            items.clone(),
495            z_validate_address::Response::default(),
496        )?,
497        // address
498        "getaddressbalance" => default_property(type_, items.clone(), AddressBalance::default())?,
499        "getaddressutxos" => default_property(type_, items.clone(), GetAddressUtxos::default())?,
500        "getaddresstxids" => default_property(type_, items.clone(), Vec::<String>::default())?,
501        // network
502        "getpeerinfo" => {
503            default_property(type_, items.clone(), types::peer_info::PeerInfo::default())?
504        }
505        // blockchain
506        "getdifficulty" => default_property(type_, items.clone(), f64::default())?,
507        "getblockchaininfo" => {
508            default_property(type_, items.clone(), GetBlockChainInfo::default())?
509        }
510        "getrawmempool" => default_property(type_, items.clone(), Vec::<String>::default())?,
511        "getblockhash" => default_property(type_, items.clone(), GetBlockHash::default())?,
512        "z_getsubtreesbyindex" => {
513            default_property(type_, items.clone(), trees::GetSubtrees::default())?
514        }
515        "z_gettreestate" => default_property(type_, items.clone(), GetTreestate::default())?,
516        "getblockcount" => default_property(type_, items.clone(), u32::default())?,
517        "getbestblockhash" => default_property(type_, items.clone(), GetBlockHash::default())?,
518        "getblock" => default_property(type_, items.clone(), GetBlock::default())?,
519        // wallet
520        "z_listunifiedreceivers" => default_property(
521            type_,
522            items.clone(),
523            types::unified_address::Response::default(),
524        )?,
525        // control
526        "getinfo" => default_property(type_, items.clone(), GetInfo::default())?,
527        "stop" => default_property(type_, items.clone(), ())?,
528        // transaction
529        "sendrawtransaction" => {
530            default_property(type_, items.clone(), SentTransactionHash::default())?
531        }
532        "getrawtransaction" => {
533            default_property(type_, items.clone(), GetRawTransaction::default())?
534        }
535        // default
536        _ => Property {
537            type_: type_.to_string(),
538            items: None,
539            default: "{}".to_string(),
540        },
541    };
542
543    props.insert("result".to_string(), default_result);
544    Ok(props)
545}