first attempt at adding openapi generation from existing docs

This commit is contained in:
Yeastplume 2024-12-16 13:56:13 +00:00
parent b93d88b58c
commit 3bf6715526
7 changed files with 1026 additions and 0 deletions

2
Cargo.lock generated
View file

@ -954,6 +954,8 @@ dependencies = [
"serde",
"serde_derive",
"serde_json",
"serde_yaml",
"syn 2.0.75",
"term",
"thiserror",
]

View file

@ -33,6 +33,8 @@ futures = "0.3.19"
serde_json = "1"
log = "0.4"
term = "0.6"
serde_yaml = "0.9"
syn = { version = "2.0", features = ["full", "parsing"] }
grin_api = { path = "./api", version = "5.4.0-alpha.0" }
grin_config = { path = "./config", version = "5.4.0-alpha.0" }

98
api/src/macros.rs Normal file
View file

@ -0,0 +1,98 @@
// Copyright 2021 The Grin Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/// Attribute macro for documenting API endpoints
///
/// Example usage:
/// ```rust
/// #[api_endpoint]
/// #[endpoint(
/// path = "/v1/status",
/// method = "GET",
/// summary = "Get node status",
/// description = "Returns the current status of the node"
/// )]
/// pub struct StatusHandler {
/// pub chain: Weak<Chain>,
/// }
/// ```
#[macro_export]
macro_rules! api_endpoint {
(
$(#[endpoint(
path = $path:expr,
method = $method:expr,
summary = $summary:expr,
description = $description:expr
$(, params = [$($param:expr),*])?
$(, response = $response:ty)?
)])*
pub struct $name:ident {
$($field:ident: $type:ty),* $(,)?
}
) => {
pub struct $name {
$($field: $type),*
}
impl ApiEndpoint for $name {
fn get_endpoint_spec() -> EndpointSpec {
EndpointSpec {
path: $path.to_string(),
method: $method.to_string(),
summary: $summary.to_string(),
description: $description.to_string(),
params: vec![$($($param.into()),*)?],
response: None $(Some(stringify!($response).to_string()))?
}
}
}
};
}
/// Trait for types that represent API endpoints
pub trait ApiEndpoint {
fn get_endpoint_spec() -> EndpointSpec;
}
/// Represents an OpenAPI endpoint specification
pub struct EndpointSpec {
pub path: String,
pub method: String,
pub summary: String,
pub description: String,
pub params: Vec<ParamSpec>,
pub response: Option<String>,
}
/// Represents an OpenAPI parameter specification
pub struct ParamSpec {
pub name: String,
pub description: String,
pub required: bool,
pub schema_type: String,
pub location: String,
}
impl From<(&str, &str, bool, &str, &str)> for ParamSpec {
fn from(tuple: (&str, &str, bool, &str, &str)) -> Self {
ParamSpec {
name: tuple.0.to_string(),
description: tuple.1.to_string(),
required: tuple.2,
schema_type: tuple.3.to_string(),
location: tuple.4.to_string(),
}
}
}

View file

@ -22,6 +22,7 @@ extern crate log;
use crate::config::config::SERVER_CONFIG_FILE_NAME;
use crate::core::global;
use crate::tools::check_seeds;
use crate::tools::openapi;
use crate::util::init_logger;
use clap::App;
use futures::channel::oneshot;
@ -222,6 +223,25 @@ fn real_main() -> i32 {
0
}
// openapi command
("openapi", Some(args)) => {
let output = args.value_of("output").unwrap();
let format = args.value_of("format").unwrap();
match openapi::generate_openapi_spec(output, format) {
Ok(_) => {
println!(
"Successfully generated OpenAPI documentation at: {}",
output
);
0
}
Err(e) => {
error!("Failed to generate OpenAPI documentation: {}", e);
1
}
}
}
// If nothing is specified, try to just use the config file instead
// this could possibly become the way to configure most things
// with most command line options being phased out

View file

@ -103,3 +103,18 @@ subcommands:
help: Output file to write the results to
long: output
takes_value: true
- openapi:
about: Generate OpenAPI documentation from JSON-RPC endpoints
args:
- output:
help: Output file path
short: o
long: output
takes_value: true
required: true
- format:
help: Output format (json or yaml)
short: f
long: format
takes_value: true
default_value: json

View file

@ -16,3 +16,5 @@
mod seedcheck;
pub use seedcheck::check_seeds;
pub mod openapi;

887
src/bin/tools/openapi.rs Normal file
View file

@ -0,0 +1,887 @@
// Copyright 2024 The Grin Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use serde::{Deserialize, Serialize};
use serde_json;
use serde_yaml;
use std::collections::HashMap;
use std::fs::File;
use std::io::Write;
use std::path::Path;
use syn;
#[allow(non_snake_case)]
#[derive(Serialize, Deserialize)]
pub struct OpenApiSpec {
openapi: String,
info: Info,
paths: HashMap<String, PathItem>,
components: Components,
}
#[derive(Serialize, Deserialize)]
pub struct Info {
title: String,
version: String,
description: Option<String>,
}
#[derive(Serialize, Deserialize)]
pub struct Components {
schemas: HashMap<String, Schema>,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct Schema {
#[serde(rename = "type")]
type_: String,
#[serde(skip_serializing_if = "Option::is_none")]
properties: Option<HashMap<String, Schema>>,
#[serde(skip_serializing_if = "Option::is_none")]
items: Option<Box<Schema>>,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
format: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
example: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
oneOf: Option<Vec<Schema>>,
#[serde(skip_serializing_if = "Option::is_none")]
required: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
additionalProperties: Option<bool>,
#[serde(rename = "enum", skip_serializing_if = "Option::is_none")]
enum_values_renamed: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
summary: Option<String>,
}
#[derive(Serialize, Deserialize)]
pub struct PathItem {
post: Operation,
}
#[allow(non_snake_case)]
#[derive(Serialize, Deserialize)]
pub struct Operation {
summary: String,
description: Option<String>,
requestBody: RequestBody,
responses: HashMap<String, Response>,
tags: Vec<String>,
}
#[derive(Serialize, Deserialize)]
pub struct RequestBody {
description: String,
content: HashMap<String, MediaType>,
required: bool,
}
#[derive(Serialize, Deserialize)]
pub struct Response {
description: String,
content: Option<HashMap<String, MediaType>>,
}
#[derive(Serialize, Deserialize)]
pub struct MediaType {
schema: Schema,
}
fn type_to_schema(ty: &syn::Type) -> Schema {
match ty {
syn::Type::Path(type_path) => {
let path = &type_path.path;
let last_segment = path.segments.last().unwrap();
let type_name = last_segment.ident.to_string();
match type_name.as_str() {
"String" | "str" => Schema {
type_: "string".to_string(),
properties: None,
items: None,
description: None,
format: None,
example: None,
oneOf: None,
required: None,
additionalProperties: None,
enum_values_renamed: None,
title: None,
summary: None,
},
"bool" => Schema {
type_: "boolean".to_string(),
properties: None,
items: None,
description: None,
format: None,
example: None,
oneOf: None,
required: None,
additionalProperties: None,
enum_values_renamed: None,
title: None,
summary: None,
},
"i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" => Schema {
type_: "integer".to_string(),
properties: None,
items: None,
description: None,
format: Some(type_name.to_string()),
example: None,
oneOf: None,
required: None,
additionalProperties: None,
enum_values_renamed: None,
title: None,
summary: None,
},
"f32" | "f64" => Schema {
type_: "number".to_string(),
properties: None,
items: None,
description: None,
format: Some(type_name.to_string()),
example: None,
oneOf: None,
required: None,
additionalProperties: None,
enum_values_renamed: None,
title: None,
summary: None,
},
"Vec" => {
if let syn::PathArguments::AngleBracketed(args) = &last_segment.arguments {
if let Some(syn::GenericArgument::Type(inner_type)) = args.args.first() {
Schema {
type_: "array".to_string(),
properties: None,
items: Some(Box::new(type_to_schema(inner_type))),
description: None,
format: None,
example: None,
oneOf: None,
required: None,
additionalProperties: None,
enum_values_renamed: None,
title: None,
summary: None,
}
} else {
default_schema()
}
} else {
default_schema()
}
}
"Option" => {
if let syn::PathArguments::AngleBracketed(args) = &last_segment.arguments {
if let Some(syn::GenericArgument::Type(inner_type)) = args.args.first() {
type_to_schema(inner_type) // For Option, we just use the inner type's schema
} else {
default_schema()
}
} else {
default_schema()
}
}
"Result" => {
if let syn::PathArguments::AngleBracketed(args) = &last_segment.arguments {
if let Some(syn::GenericArgument::Type(ok_type)) = args.args.first() {
type_to_schema(ok_type) // For Result, we use the Ok type's schema
} else {
default_schema()
}
} else {
default_schema()
}
}
"HashMap" => Schema {
type_: "object".to_string(),
properties: None,
items: None,
description: None,
format: None,
example: None,
oneOf: None,
required: None,
additionalProperties: Some(true),
enum_values_renamed: None,
title: None,
summary: None,
},
_ => {
// For custom types, create an object schema
Schema {
type_: "object".to_string(),
properties: Some(HashMap::new()),
items: None,
description: Some(format!("Custom type: {}", type_name)),
format: None,
example: None,
oneOf: None,
required: None,
additionalProperties: Some(true),
enum_values_renamed: None,
title: None,
summary: None,
}
}
}
}
_ => default_schema(),
}
}
fn default_schema() -> Schema {
Schema {
type_: "object".to_string(),
properties: None,
items: None,
description: None,
format: None,
example: None,
oneOf: None,
required: None,
additionalProperties: None,
enum_values_renamed: None,
title: None,
summary: None,
}
}
fn parse_doc_comment(
attrs: &[syn::Attribute],
) -> (Option<String>, Option<String>, Vec<(String, String)>) {
let mut doc_lines = Vec::new();
for attr in attrs {
if attr.path().is_ident("doc") {
if let Ok(doc) = attr.parse_args::<syn::LitStr>() {
let line = doc.value().trim().to_string();
if !line.is_empty() {
doc_lines.push(line);
}
}
}
}
if doc_lines.is_empty() {
return (None, None, Vec::new());
}
// Extract summary (first line) and description
let mut summary = None;
let mut description = Vec::new();
let mut params = Vec::new();
let mut in_params = false;
let mut in_returns = false;
let mut current_section = Vec::new();
for line in doc_lines {
if line.starts_with("# Arguments") {
// Add accumulated lines to description if we're not already in a section
if !in_params && !in_returns && !current_section.is_empty() {
description.extend(current_section.drain(..));
}
in_params = true;
in_returns = false;
current_section.clear();
continue;
} else if line.starts_with("# Returns") {
// Add accumulated lines to description if we're not already in a section
if !in_params && !in_returns && !current_section.is_empty() {
description.extend(current_section.drain(..));
}
in_params = false;
in_returns = true;
current_section.clear();
continue;
}
if summary.is_none() && !line.starts_with('#') {
summary = Some(line.clone());
continue;
}
if in_params {
if let Some(param_doc) = line.strip_prefix("* `") {
if let Some((param_name, param_desc)) = param_doc.split_once("` - ") {
params.push((param_name.to_string(), param_desc.to_string()));
}
}
} else if !in_returns {
// Only add to current section if it's not a section header
if !line.starts_with('#') {
current_section.push(line);
}
}
}
// Add any remaining lines to description
if !current_section.is_empty() {
description.extend(current_section);
}
(
summary,
if description.is_empty() {
None
} else {
Some(description.join("\n"))
},
params,
)
}
fn create_method_schema(
method_name: &str,
doc_summary: Option<String>,
doc_description: Option<String>,
params_schema: HashMap<String, Schema>,
param_descriptions: Vec<(String, String)>,
return_schema: Schema,
) -> Schema {
let mut properties = HashMap::new();
// Add standard JSON-RPC fields with version as enum
properties.insert(
"jsonrpc".to_string(),
Schema {
type_: "string".to_string(),
properties: None,
items: None,
description: Some("JSON-RPC version".to_string()),
format: None,
example: None,
oneOf: None,
required: None,
additionalProperties: None,
enum_values_renamed: Some(vec!["2.0".to_string()]),
title: None,
summary: None,
},
);
// Method field should use enum instead of example
properties.insert(
"method".to_string(),
Schema {
type_: "string".to_string(),
properties: None,
items: None,
description: doc_summary.clone(),
format: None,
example: None,
oneOf: None,
required: None,
additionalProperties: None,
enum_values_renamed: Some(vec![method_name.to_string()]),
title: None,
summary: None,
},
);
// Create params schema with descriptions
let mut params_with_desc = params_schema.clone();
for (param_name, param_desc) in param_descriptions {
if let Some(param_schema) = params_with_desc.get_mut(&param_name) {
param_schema.description = Some(param_desc);
}
}
// Only add params if we have any
let params_schema = if params_with_desc.is_empty() {
Schema {
type_: "object".to_string(),
properties: Some(HashMap::new()),
items: None,
description: Some("Method parameters".to_string()),
format: None,
example: Some(serde_json::json!({})),
oneOf: None,
required: None,
additionalProperties: Some(false),
enum_values_renamed: None,
title: None,
summary: None,
}
} else {
Schema {
type_: "object".to_string(),
properties: Some(params_with_desc),
items: None,
description: Some("Method parameters".to_string()),
format: None,
example: None,
oneOf: None,
required: Some(params_schema.keys().map(|k| k.to_string()).collect()),
additionalProperties: Some(false),
enum_values_renamed: None,
title: None,
summary: None,
}
};
properties.insert("params".to_string(), params_schema);
properties.insert(
"id".to_string(),
Schema {
type_: "string".to_string(),
properties: None,
items: None,
description: Some("Request ID".to_string()),
format: None,
example: Some(serde_json::json!(1)),
oneOf: None,
required: None,
additionalProperties: None,
enum_values_renamed: None,
title: None,
summary: None,
},
);
Schema {
type_: "object".to_string(),
properties: Some(properties),
items: None,
description: doc_description,
format: None,
example: None,
oneOf: None,
required: Some(vec![
"jsonrpc".to_string(),
"method".to_string(),
"params".to_string(),
"id".to_string(),
]),
additionalProperties: Some(false),
enum_values_renamed: None,
title: Some(method_name.to_string()),
summary: doc_summary,
}
}
fn create_rpc_endpoint(
spec: &mut OpenApiSpec,
base_path: &str,
methods: Vec<(
String,
Option<String>,
Option<String>,
HashMap<String, Schema>,
Vec<(String, String)>,
Schema,
)>,
) {
let method_schemas: Vec<Schema> = methods
.into_iter()
.map(
|(name, summary, description, params, param_descriptions, ret)| {
let mut schema = create_method_schema(
&name,
summary.clone(),
description.clone(),
params,
param_descriptions,
ret,
);
// Add method description to the schema title and description
if let Some(desc) = summary {
schema.title = Some(format!("{} - {}", name, desc));
} else {
schema.title = Some(name.clone());
}
// Add full description if available
if let Some(desc) = description {
schema.description = Some(desc);
}
schema
},
)
.collect();
let operation = Operation {
summary: format!("JSON-RPC endpoint for {}", &base_path[4..]),
description: Some("JSON-RPC 2.0 endpoint".to_string()),
requestBody: RequestBody {
description: "JSON-RPC request".to_string(),
content: {
let mut content = HashMap::new();
content.insert(
"application/json".to_string(),
MediaType {
schema: Schema {
type_: "object".to_string(),
properties: None,
items: None,
description: None,
format: None,
example: None,
oneOf: Some(method_schemas),
required: None,
additionalProperties: None,
enum_values_renamed: None,
title: None,
summary: None,
},
},
);
content
},
required: true,
},
responses: {
let mut responses = HashMap::new();
responses.insert(
"200".to_string(),
Response {
description: "Successful response".to_string(),
content: Some({
let mut content = HashMap::new();
content.insert(
"application/json".to_string(),
MediaType {
schema: Schema {
type_: "object".to_string(),
properties: Some({
let mut props = HashMap::new();
props.insert(
"jsonrpc".to_string(),
Schema {
type_: "string".to_string(),
properties: None,
items: None,
description: Some("JSON-RPC version".to_string()),
format: None,
example: None,
oneOf: None,
required: None,
additionalProperties: None,
enum_values_renamed: Some(vec!["2.0".to_string()]),
title: None,
summary: None,
},
);
props.insert(
"result".to_string(),
Schema {
type_: "object".to_string(),
properties: None,
items: None,
description: Some("Method result".to_string()),
format: None,
example: None,
oneOf: None,
required: None,
additionalProperties: Some(true),
enum_values_renamed: None,
title: None,
summary: None,
},
);
props.insert(
"id".to_string(),
Schema {
type_: "string".to_string(),
properties: None,
items: None,
description: Some("Request ID".to_string()),
format: None,
example: Some(serde_json::json!(1)),
oneOf: None,
required: None,
additionalProperties: None,
enum_values_renamed: None,
title: None,
summary: None,
},
);
props
}),
items: None,
description: None,
format: None,
example: None,
oneOf: None,
required: Some(vec![
"jsonrpc".to_string(),
"result".to_string(),
"id".to_string(),
]),
additionalProperties: Some(false),
enum_values_renamed: None,
title: None,
summary: None,
},
},
);
content
}),
},
);
responses.insert(
"400".to_string(),
Response {
description: "Invalid request".to_string(),
content: Some({
let mut content = HashMap::new();
content.insert(
"application/json".to_string(),
MediaType {
schema: Schema {
type_: "object".to_string(),
properties: Some({
let mut props = HashMap::new();
props.insert(
"jsonrpc".to_string(),
Schema {
type_: "string".to_string(),
properties: None,
items: None,
description: Some("JSON-RPC version".to_string()),
format: None,
example: None,
oneOf: None,
required: None,
additionalProperties: None,
enum_values_renamed: Some(vec!["2.0".to_string()]),
title: None,
summary: None,
},
);
props.insert(
"error".to_string(),
Schema {
type_: "object".to_string(),
properties: Some({
let mut error_props = HashMap::new();
error_props.insert(
"code".to_string(),
Schema {
type_: "integer".to_string(),
properties: None,
items: None,
description: Some(
"Error code".to_string(),
),
format: None,
example: None,
oneOf: None,
required: None,
additionalProperties: None,
enum_values_renamed: None,
title: None,
summary: None,
},
);
error_props.insert(
"message".to_string(),
Schema {
type_: "string".to_string(),
properties: None,
items: None,
description: Some(
"Error message".to_string(),
),
format: None,
example: None,
oneOf: None,
required: None,
additionalProperties: None,
enum_values_renamed: None,
title: None,
summary: None,
},
);
error_props
}),
items: None,
description: Some("Error details".to_string()),
format: None,
example: None,
oneOf: None,
required: Some(vec![
"code".to_string(),
"message".to_string(),
]),
additionalProperties: Some(false),
enum_values_renamed: None,
title: None,
summary: None,
},
);
props.insert(
"id".to_string(),
Schema {
type_: "string".to_string(),
properties: None,
items: None,
description: Some("Request ID".to_string()),
format: None,
example: Some(serde_json::json!(1)),
oneOf: None,
required: None,
additionalProperties: None,
enum_values_renamed: None,
title: None,
summary: None,
},
);
props
}),
items: None,
description: None,
format: None,
example: None,
oneOf: None,
required: Some(vec![
"jsonrpc".to_string(),
"error".to_string(),
"id".to_string(),
]),
additionalProperties: Some(false),
enum_values_renamed: None,
title: None,
summary: None,
},
},
);
content
}),
},
);
responses
},
tags: vec![base_path[4..].to_string()], // Remove /v2/ prefix for tag
};
spec.paths
.insert(base_path.to_string(), PathItem { post: operation });
}
fn collect_methods(
source: &str,
trait_name: &str,
) -> Vec<(
String,
Option<String>,
Option<String>,
HashMap<String, Schema>,
Vec<(String, String)>,
Schema,
)> {
let mut methods = Vec::new();
let file = syn::parse_str::<syn::File>(source).unwrap();
for item in file.items {
if let syn::Item::Trait(item_trait) = item {
if item_trait.ident == trait_name {
for item in item_trait.items {
if let syn::TraitItem::Fn(method) = item {
let method_name = method.sig.ident.to_string();
let (doc_summary, doc_description, param_descriptions) =
parse_doc_comment(&method.attrs);
let mut params_schema = HashMap::new();
for param in &method.sig.inputs {
if let syn::FnArg::Typed(pat_type) = param {
if let syn::Pat::Ident(pat_ident) = &*pat_type.pat {
let param_name = pat_ident.ident.to_string();
if param_name != "self" {
params_schema
.insert(param_name, type_to_schema(&pat_type.ty));
}
}
}
}
let mut return_schema = default_schema();
if let syn::ReturnType::Type(_, ty) = &method.sig.output {
return_schema = type_to_schema(ty);
}
methods.push((
method_name,
doc_summary,
doc_description,
params_schema,
param_descriptions,
return_schema,
));
}
}
}
}
}
methods
}
impl Default for Info {
fn default() -> Self {
Info {
title: "Grin Node API".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
description: Some("Grin Node JSON-RPC API".to_string()),
}
}
}
/// Generate OpenAPI specification from JSON-RPC endpoints
pub fn generate_openapi_spec(
output_path: &str,
format: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let mut spec = OpenApiSpec {
openapi: "3.0.0".to_string(),
info: Info::default(),
paths: HashMap::new(),
components: Components {
schemas: HashMap::new(),
},
};
// Add Foreign API endpoints
let foreign_methods = collect_methods(
include_str!("../../../api/src/foreign_rpc.rs"),
"ForeignRpc",
);
create_rpc_endpoint(&mut spec, "/v2/foreign", foreign_methods);
// Add Owner API endpoints
let owner_methods = collect_methods(include_str!("../../../api/src/owner_rpc.rs"), "OwnerRpc");
create_rpc_endpoint(&mut spec, "/v2/owner", owner_methods);
// Write spec to file
let output = match format {
"yaml" => serde_yaml::to_string(&spec)?,
"json" => serde_json::to_string_pretty(&spec)?,
_ => return Err("Unsupported format. Use 'json' or 'yaml'.".into()),
};
let mut file = File::create(Path::new(output_path))?;
file.write_all(output.as_bytes())?;
Ok(())
}