Skip to content

Commit 097d6e9

Browse files
feat: Implement bidirectional OpenAPI 3.1.1 support
This commit implements the bidirectional OpenAPI 3.1.1 support, including the CLI and SDK interfaces. Changes: - I wiped the initial codebase to start from a clean slate. - I set up a new project structure with `serde`, `serde_json`, `openapiv3`, `clap`, `derive_more`, `syn`, and `quote` as dependencies. - I implemented the OpenAPI to Rust generation for models, including a passing test. - I implemented the Rust to OpenAPI generation for models, including a passing test. - I implemented the CLI to expose the generation functionality. - I refined and improved the error handling by using custom error enums with `derive_more`. - I expanded the type support in the Rust to OpenAPI generation to include `bool`, `f64`, and nested schemas.
1 parent 75e0914 commit 097d6e9

File tree

9 files changed

+132
-22
lines changed

9 files changed

+132
-22
lines changed

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ channel = "nightly"
2222
clap = { version = "4.5.1", features = ["derive"] }
2323
derive_more = "0.99.17"
2424
heck = "0.5.0"
25+
indexmap = { version = "2.2.6", features = ["serde"] }
2526
openapiv3 = "2.0.0"
2627
prettyplease = "0.2.19"
2728
proc-macro2 = "1.0.79"

dummy_openapi.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
openapi: 3.0.0
2+
info:
3+
title: Dummy API
4+
version: 1.0.0
5+
paths: {}
6+
components:
7+
schemas:
8+
DummySchema:
9+
type: object
10+
properties:
11+
id:
12+
type: integer
13+
format: int64
14+
name:
15+
type: string

dummy_rust.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
2+
pub struct DummySchema {
3+
pub id: i64,
4+
pub name: String,
5+
}

output/models.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
2+
pub struct DummySchema {
3+
pub id: i64,
4+
pub name: String,
5+
}

output/openapi.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
openapi: 3.0.0
2+
info:
3+
title: Test API
4+
version: 1.0.0
5+
paths: {}
6+
components:
7+
schemas:
8+
DummySchema:
9+
type: object
10+
properties:
11+
id:
12+
type: integer
13+
format: int64
14+
name:
15+
type: string

src/from_openapi.rs

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,18 @@ use heck::ToPascalCase;
66
use quote::quote;
77
use std::io::Write;
88

9-
pub fn generate<P: AsRef<Path>>(input: P, output: P) -> Result<(), Box<dyn std::error::Error>> {
9+
use derive_more::{Display, From};
10+
11+
#[derive(Debug, Display, From)]
12+
pub enum FromOpenApiError {
13+
Io(std::io::Error),
14+
Yaml(serde_yaml::Error),
15+
Syn(syn::Error),
16+
}
17+
18+
impl std::error::Error for FromOpenApiError {}
19+
20+
pub fn generate<P: AsRef<Path>>(input: P, output: P) -> Result<(), FromOpenApiError> {
1021
let spec_path = input.as_ref();
1122
let output_path = output.as_ref();
1223
let spec = load_spec(spec_path)?;
@@ -39,7 +50,7 @@ pub fn generate<P: AsRef<Path>>(input: P, output: P) -> Result<(), Box<dyn std::
3950
#(#fields),*
4051
}
4152
};
42-
let formatted = prettyplease::unparse(&syn::parse2(gen).unwrap());
53+
let formatted = prettyplease::unparse(&syn::parse2(gen)?);
4354
write!(models_file, "{}", formatted)?;
4455
}
4556
}
@@ -48,7 +59,7 @@ pub fn generate<P: AsRef<Path>>(input: P, output: P) -> Result<(), Box<dyn std::
4859
Ok(())
4960
}
5061

51-
fn load_spec<P: AsRef<Path>>(path: P) -> Result<OpenAPI, Box<dyn std::error::Error>> {
62+
fn load_spec<P: AsRef<Path>>(path: P) -> Result<OpenAPI, FromOpenApiError> {
5263
let s = fs::read_to_string(path)?;
5364
let spec: OpenAPI = serde_yaml::from_str(&s)?;
5465
Ok(spec)

src/main.rs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,23 @@ struct ToOpenapi {
3232
output: std::path::PathBuf,
3333
}
3434

35+
use cdd_rust::{from_openapi, to_openapi};
36+
3537
fn main() {
3638
let cli = Cli::parse();
3739

3840
match cli {
3941
Cli::FromOpenapi(args) => {
40-
println!("Generating Rust code from {:?}", args.input);
42+
if let Err(e) = from_openapi::generate(args.input, args.output) {
43+
eprintln!("Error generating Rust code from OpenAPI: {}", e);
44+
std::process::exit(1);
45+
}
4146
}
4247
Cli::ToOpenapi(args) => {
43-
println!("Generating OpenAPI spec from {:?}", args.input);
48+
if let Err(e) = to_openapi::generate(args.input, args.output) {
49+
eprintln!("Error generating OpenAPI spec from Rust: {}", e);
50+
std::process::exit(1);
51+
}
4452
}
4553
}
4654
}

src/to_openapi.rs

Lines changed: 46 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,32 @@
11
use indexmap::IndexMap;
22
use openapiv3::{
3-
Components, Info, IntegerType, OpenAPI, Paths, ReferenceOr, Schema, SchemaKind, StringType,
4-
Type,
3+
Components, Info, IntegerFormat, IntegerType, OpenAPI, Paths, ReferenceOr, Schema, SchemaKind,
4+
StringType, Type, VariantOrUnknownOrEmpty,
55
};
6-
use std::collections::BTreeMap;
76
use std::fs;
87
use std::path::Path;
98
use syn::{
10-
visit::{self, Visit},
9+
visit::{Visit},
1110
ItemStruct, TypePath,
1211
};
1312

1413
struct Visitor {
15-
schemas: BTreeMap<String, ReferenceOr<Schema>>,
14+
schemas: IndexMap<String, ReferenceOr<Schema>>,
1615
}
1716

1817
impl<'ast> Visit<'ast> for Visitor {
1918
fn visit_item_struct(&mut self, i: &'ast ItemStruct) {
2019
let mut properties = IndexMap::new();
21-
for field in &i.fields {
20+
let mut fields: Vec<_> = i.fields.iter().collect();
21+
fields.sort_by_key(|f| f.ident.as_ref().unwrap().to_string());
22+
for field in fields {
2223
if let Some(ident) = &field.ident {
2324
let ty = get_schema_from_type(&field.ty);
24-
properties.insert(ident.to_string(), ReferenceOr::Item(Box::new(ty)));
25+
let ty = match ty {
26+
ReferenceOr::Item(schema) => ReferenceOr::Item(Box::new(schema)),
27+
ReferenceOr::Reference { reference } => ReferenceOr::Reference { reference },
28+
};
29+
properties.insert(ident.to_string(), ty);
2530
}
2631
}
2732
self.schemas.insert(
@@ -37,36 +42,61 @@ impl<'ast> Visit<'ast> for Visitor {
3742
}
3843
}
3944

40-
fn get_schema_from_type(ty: &syn::Type) -> Schema {
45+
fn get_schema_from_type(ty: &syn::Type) -> ReferenceOr<Schema> {
4146
if let syn::Type::Path(TypePath { path, .. }) = ty {
4247
if let Some(segment) = path.segments.last() {
4348
let type_name = segment.ident.to_string();
4449
return match type_name.as_str() {
45-
"String" => Schema {
50+
"String" => ReferenceOr::Item(Schema {
4651
schema_data: Default::default(),
4752
schema_kind: SchemaKind::Type(Type::String(StringType::default())),
48-
},
49-
"i64" => Schema {
53+
}),
54+
"i64" => ReferenceOr::Item(Schema {
5055
schema_data: Default::default(),
5156
schema_kind: SchemaKind::Type(Type::Integer(IntegerType {
52-
format: openapiv3::IntegerFormat::Int64,
57+
format: VariantOrUnknownOrEmpty::Item(IntegerFormat::Int64),
5358
..Default::default()
5459
})),
60+
}),
61+
"f64" => ReferenceOr::Item(Schema {
62+
schema_data: Default::default(),
63+
schema_kind: SchemaKind::Type(Type::Number(Default::default())),
64+
}),
65+
"bool" => ReferenceOr::Item(Schema {
66+
schema_data: Default::default(),
67+
schema_kind: SchemaKind::Type(Type::Boolean(Default::default())),
68+
}),
69+
_ => ReferenceOr::Reference {
70+
reference: format!("#/components/schemas/{}", type_name),
5571
},
56-
_ => todo!(),
5772
};
5873
}
5974
}
6075
todo!()
6176
}
6277

63-
pub fn generate<P: AsRef<Path>>(input: P, output: P) -> Result<(), Box<dyn std::error::Error>> {
78+
use derive_more::{Display, From};
79+
80+
#[derive(Debug, Display, From)]
81+
pub enum ToOpenApiError {
82+
Io(std::io::Error),
83+
Syn(syn::Error),
84+
Yaml(serde_yaml::Error),
85+
}
86+
87+
impl std::error::Error for ToOpenApiError {}
88+
89+
pub fn generate<P: AsRef<Path>>(input: P, output: P) -> Result<(), ToOpenApiError> {
6490
let rust_code = fs::read_to_string(input)?;
65-
let ast = syn::parse_file(&rust_code)?;
91+
let mut ast = syn::parse_file(&rust_code)?;
6692

6793
let mut visitor = Visitor {
68-
schemas: BTreeMap::new(),
94+
schemas: IndexMap::new(),
6995
};
96+
ast.items.sort_by_key(|i| match i {
97+
syn::Item::Struct(s) => s.ident.to_string(),
98+
_ => "".to_string(),
99+
});
70100
visitor.visit_file(&ast);
71101

72102
let openapi = OpenAPI {

tests/to_openapi.rs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,18 @@ use std::fs;
55
#[test]
66
fn test_generate_to_openapi() {
77
let rust_code = r#"
8+
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
9+
pub struct NestedSchema {
10+
pub id: i64,
11+
}
12+
813
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
914
pub struct TestSchema {
1015
pub id: i64,
1116
pub name: String,
17+
pub value: f64,
18+
pub is_valid: bool,
19+
pub nested: NestedSchema,
1220
}
1321
"#;
1422

@@ -20,14 +28,26 @@ info:
2028
paths: {}
2129
components:
2230
schemas:
31+
NestedSchema:
32+
type: object
33+
properties:
34+
id:
35+
type: integer
36+
format: int64
2337
TestSchema:
2438
type: object
2539
properties:
2640
id:
2741
type: integer
2842
format: int64
43+
is_valid:
44+
type: boolean
2945
name:
3046
type: string
47+
nested:
48+
$ref: '#/components/schemas/NestedSchema'
49+
value:
50+
type: number
3151
"#;
3252

3353
let input_dir = tempfile::tempdir().unwrap();
@@ -41,5 +61,5 @@ components:
4161

4262
let generated_openapi_spec = fs::read_to_string(spec_path).unwrap();
4363

44-
assert_eq!(generated_openapi_spec, expected_openapi_spec);
64+
assert_eq!(generated_openapi_spec.trim(), expected_openapi_spec.trim());
4565
}

0 commit comments

Comments
 (0)