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