599 lines
19 KiB
Rust
599 lines
19 KiB
Rust
use heck::{ToSnakeCase, ToUpperCamelCase};
|
|
use indexmap::{map::Entry, IndexMap};
|
|
use proc_macro2::TokenStream;
|
|
use quote::{format_ident, quote, ToTokens};
|
|
use syn::Ident;
|
|
|
|
use crate::openapi::r#type::OpenApiType;
|
|
|
|
use super::{r#enum::Enum, ResolvedSchema, WarningReporter};
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum PrimitiveType {
|
|
Bool,
|
|
I32,
|
|
I64,
|
|
String,
|
|
Float,
|
|
DateTime,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum PropertyType {
|
|
Primitive(PrimitiveType),
|
|
Ref(String),
|
|
Enum(Enum),
|
|
Nested(Box<Object>),
|
|
Array(Box<PropertyType>),
|
|
Any,
|
|
}
|
|
|
|
impl PropertyType {
|
|
pub fn codegen(
|
|
&self,
|
|
namespace: &mut ObjectNamespace,
|
|
resolved: &ResolvedSchema,
|
|
) -> Option<TokenStream> {
|
|
match self {
|
|
Self::Primitive(PrimitiveType::Bool) => Some(format_ident!("bool").into_token_stream()),
|
|
Self::Primitive(PrimitiveType::I32) => Some(format_ident!("i32").into_token_stream()),
|
|
Self::Primitive(PrimitiveType::I64) => Some(format_ident!("i64").into_token_stream()),
|
|
Self::Primitive(PrimitiveType::String) => {
|
|
Some(format_ident!("String").into_token_stream())
|
|
}
|
|
Self::Primitive(PrimitiveType::DateTime) => {
|
|
Some(quote! { chrono::DateTime<chrono::Utc> })
|
|
}
|
|
Self::Primitive(PrimitiveType::Float) => Some(format_ident!("f64").into_token_stream()),
|
|
Self::Ref(path) => {
|
|
let name = path.strip_prefix("#/components/schemas/")?;
|
|
let name = format_ident!("{name}");
|
|
|
|
Some(quote! { crate::models::#name })
|
|
}
|
|
Self::Enum(r#enum) => {
|
|
let code = r#enum.codegen(resolved)?;
|
|
namespace.push_element(code);
|
|
|
|
let ns = namespace.get_ident();
|
|
let name = format_ident!("{}", r#enum.name);
|
|
|
|
Some(quote! {
|
|
#ns::#name
|
|
})
|
|
}
|
|
Self::Array(array) => {
|
|
let inner_ty = array.codegen(namespace, resolved)?;
|
|
|
|
Some(quote! {
|
|
Vec<#inner_ty>
|
|
})
|
|
}
|
|
Self::Nested(nested) => {
|
|
let code = nested.codegen(resolved)?;
|
|
namespace.push_element(code);
|
|
|
|
let ns = namespace.get_ident();
|
|
let name = format_ident!("{}", nested.name);
|
|
|
|
Some(quote! {
|
|
#ns::#name
|
|
})
|
|
}
|
|
Self::Any => Some(quote! {
|
|
serde_json::Value
|
|
}),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Property {
|
|
pub field_name: String,
|
|
pub name: String,
|
|
pub description: Option<String>,
|
|
pub required: bool,
|
|
pub nullable: bool,
|
|
pub r#type: PropertyType,
|
|
pub deprecated: bool,
|
|
}
|
|
|
|
impl Property {
|
|
pub fn from_schema(
|
|
name: &str,
|
|
required: bool,
|
|
schema: &OpenApiType,
|
|
schemas: &IndexMap<&str, OpenApiType>,
|
|
warnings: WarningReporter,
|
|
) -> Option<Self> {
|
|
let name = name.to_owned();
|
|
let field_name = name.to_snake_case();
|
|
let description = schema.description.as_deref().map(ToOwned::to_owned);
|
|
|
|
match schema {
|
|
OpenApiType {
|
|
r#enum: Some(_), ..
|
|
} => {
|
|
let Some(r#enum) = Enum::from_schema(
|
|
&name.clone().to_upper_camel_case(),
|
|
schema,
|
|
warnings.clone(),
|
|
) else {
|
|
warnings.push("Failed to create enum");
|
|
return None;
|
|
};
|
|
Some(Self {
|
|
r#type: PropertyType::Enum(r#enum),
|
|
name,
|
|
field_name,
|
|
description,
|
|
required,
|
|
deprecated: schema.deprecated,
|
|
nullable: false,
|
|
})
|
|
}
|
|
OpenApiType {
|
|
one_of: Some(types),
|
|
..
|
|
} => match types.as_slice() {
|
|
[left, OpenApiType {
|
|
r#type: Some("null"),
|
|
..
|
|
}] => {
|
|
let mut inner = Self::from_schema(&name, required, left, schemas, warnings)?;
|
|
inner.nullable = true;
|
|
Some(inner)
|
|
}
|
|
[left @ .., OpenApiType {
|
|
r#type: Some("null"),
|
|
..
|
|
}] => {
|
|
let rest = OpenApiType {
|
|
one_of: Some(left.to_owned()),
|
|
..schema.clone()
|
|
};
|
|
let mut inner = Self::from_schema(&name, required, &rest, schemas, warnings)?;
|
|
inner.nullable = true;
|
|
Some(inner)
|
|
}
|
|
cases => {
|
|
let Some(r#enum) =
|
|
Enum::from_one_of(&name.to_upper_camel_case(), cases, warnings.clone())
|
|
else {
|
|
warnings.push("Failed to create oneOf enum");
|
|
return None;
|
|
};
|
|
|
|
Some(Self {
|
|
name,
|
|
field_name,
|
|
description,
|
|
required,
|
|
nullable: false,
|
|
deprecated: schema.deprecated,
|
|
r#type: PropertyType::Enum(r#enum),
|
|
})
|
|
}
|
|
},
|
|
OpenApiType {
|
|
all_of: Some(types),
|
|
..
|
|
} => {
|
|
let obj_name = name.to_upper_camel_case();
|
|
let composite =
|
|
Object::from_all_of(&obj_name, types, schemas, warnings.child(&obj_name));
|
|
Some(Self {
|
|
name,
|
|
field_name,
|
|
description,
|
|
required,
|
|
nullable: false,
|
|
deprecated: schema.deprecated,
|
|
r#type: PropertyType::Nested(Box::new(composite)),
|
|
})
|
|
}
|
|
OpenApiType {
|
|
r#type: Some("object"),
|
|
properties: None,
|
|
..
|
|
} => Some(Self {
|
|
field_name,
|
|
name,
|
|
description,
|
|
required,
|
|
nullable: false,
|
|
r#type: PropertyType::Any,
|
|
deprecated: schema.deprecated,
|
|
}),
|
|
OpenApiType {
|
|
r#type: Some("object"),
|
|
..
|
|
} => {
|
|
let obj_name = name.to_upper_camel_case();
|
|
Some(Self {
|
|
r#type: PropertyType::Nested(Box::new(Object::from_schema_object(
|
|
&obj_name,
|
|
schema,
|
|
schemas,
|
|
warnings.child(&obj_name),
|
|
))),
|
|
name,
|
|
field_name,
|
|
description,
|
|
required,
|
|
deprecated: schema.deprecated,
|
|
nullable: false,
|
|
})
|
|
}
|
|
OpenApiType {
|
|
ref_path: Some(path),
|
|
..
|
|
} => Some(Self {
|
|
name,
|
|
field_name,
|
|
description,
|
|
r#type: PropertyType::Ref((*path).to_owned()),
|
|
required,
|
|
deprecated: schema.deprecated,
|
|
nullable: false,
|
|
}),
|
|
OpenApiType {
|
|
r#type: Some("array"),
|
|
items: Some(items),
|
|
..
|
|
} => {
|
|
let inner = Self::from_schema(&name, required, items, schemas, warnings)?;
|
|
|
|
Some(Self {
|
|
name,
|
|
field_name,
|
|
description,
|
|
required,
|
|
nullable: false,
|
|
deprecated: schema.deprecated,
|
|
r#type: PropertyType::Array(Box::new(inner.r#type)),
|
|
})
|
|
}
|
|
OpenApiType {
|
|
r#type: Some(_), ..
|
|
} => {
|
|
let prim = match (schema.r#type, schema.format) {
|
|
(Some("integer"), Some("int32")) => PrimitiveType::I32,
|
|
(Some("integer"), Some("int64")) => PrimitiveType::I64,
|
|
(Some("number"), /* Some("float") */ _) | (_, Some("float")) => {
|
|
PrimitiveType::Float
|
|
}
|
|
(Some("string"), None) => PrimitiveType::String,
|
|
(Some("string"), Some("date")) => PrimitiveType::DateTime,
|
|
(Some("boolean"), None) => PrimitiveType::Bool,
|
|
_ => return None,
|
|
};
|
|
|
|
Some(Self {
|
|
name,
|
|
field_name,
|
|
description,
|
|
required,
|
|
nullable: false,
|
|
deprecated: schema.deprecated,
|
|
r#type: PropertyType::Primitive(prim),
|
|
})
|
|
}
|
|
_ => {
|
|
warnings.push("Could not resolve property type");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn codegen(
|
|
&self,
|
|
namespace: &mut ObjectNamespace,
|
|
resolved: &ResolvedSchema,
|
|
) -> Option<TokenStream> {
|
|
let desc = self.description.as_ref().map(|d| quote! { #[doc = #d]});
|
|
|
|
let name = &self.name;
|
|
let (name, serde_attr) = match name.as_str() {
|
|
// https://doc.rust-lang.org/reference/keywords.html#r-lex.keywords
|
|
"as" => (format_ident!("r#as"), None),
|
|
"break" => (format_ident!("r#break"), None),
|
|
"const" => (format_ident!("r#const"), None),
|
|
"continue" => (format_ident!("r#continue"), None),
|
|
"crate" => (format_ident!("r#crate"), None),
|
|
"else" => (format_ident!("r#else"), None),
|
|
"enum" => (format_ident!("r#enum"), None),
|
|
"extern" => (format_ident!("r#extern"), None),
|
|
"false" => (format_ident!("r#false"), None),
|
|
"fn" => (format_ident!("r#fn"), None),
|
|
"for" => (format_ident!("r#for"), None),
|
|
"if" => (format_ident!("r#if"), None),
|
|
"impl" => (format_ident!("r#impl"), None),
|
|
"in" => (format_ident!("r#in"), None),
|
|
"let" => (format_ident!("r#let"), None),
|
|
"loop" => (format_ident!("r#loop"), None),
|
|
"match" => (format_ident!("r#match"), None),
|
|
"mod" => (format_ident!("r#mod"), None),
|
|
"move" => (format_ident!("r#move"), None),
|
|
"mut" => (format_ident!("r#mut"), None),
|
|
"pub" => (format_ident!("r#pub"), None),
|
|
"ref" => (format_ident!("r#ref"), None),
|
|
"return" => (format_ident!("r#return"), None),
|
|
"self" => (format_ident!("r#self"), None),
|
|
"Self" => (format_ident!("r#Self"), None),
|
|
"static" => (format_ident!("r#static"), None),
|
|
"struct" => (format_ident!("r#struct"), None),
|
|
"super" => (format_ident!("r#super"), None),
|
|
"trait" => (format_ident!("r#trait"), None),
|
|
"true" => (format_ident!("r#true"), None),
|
|
"type" => (format_ident!("r#type"), None),
|
|
"unsafe" => (format_ident!("r#unsafe"), None),
|
|
"use" => (format_ident!("r#use"), None),
|
|
"where" => (format_ident!("r#where"), None),
|
|
"while" => (format_ident!("r#while"), None),
|
|
"async" => (format_ident!("r#async"), None),
|
|
"await" => (format_ident!("r#await"), None),
|
|
"dyn" => (format_ident!("r#dyn"), None),
|
|
"abstract" => (format_ident!("r#abstract"), None),
|
|
"become" => (format_ident!("r#become"), None),
|
|
"box" => (format_ident!("r#box"), None),
|
|
"do" => (format_ident!("r#do"), None),
|
|
"final" => (format_ident!("r#final"), None),
|
|
"macro" => (format_ident!("r#macro"), None),
|
|
"override" => (format_ident!("r#override"), None),
|
|
"priv" => (format_ident!("r#priv"), None),
|
|
"typeof" => (format_ident!("r#typeof"), None),
|
|
"unsized" => (format_ident!("r#unsized"), None),
|
|
"virtual" => (format_ident!("r#virtual"), None),
|
|
"yield" => (format_ident!("r#yield"), None),
|
|
"try" => (format_ident!("r#try"), None),
|
|
"gen" => (format_ident!("r#gen"), None),
|
|
|
|
name if name != self.field_name => (
|
|
format_ident!("{}", self.field_name),
|
|
Some(quote! { #[serde(rename = #name)]}),
|
|
),
|
|
_ => (format_ident!("{}", self.field_name), None),
|
|
};
|
|
|
|
let ty_inner = self.r#type.codegen(namespace, resolved)?;
|
|
|
|
let ty = if !self.required || self.nullable {
|
|
quote! { Option<#ty_inner> }
|
|
} else {
|
|
ty_inner
|
|
};
|
|
|
|
let deprecated = self.deprecated.then(|| {
|
|
let note = self.description.as_ref().map(|d| quote! { note = #d });
|
|
|
|
quote! {
|
|
#[deprecated(#note)]
|
|
}
|
|
});
|
|
|
|
Some(quote! {
|
|
#desc
|
|
#deprecated
|
|
#serde_attr
|
|
pub #name: #ty
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
|
pub struct Object {
|
|
pub name: String,
|
|
pub description: Option<String>,
|
|
pub properties: IndexMap<String, Property>,
|
|
}
|
|
|
|
impl Object {
|
|
pub fn from_schema_object(
|
|
name: &str,
|
|
schema: &OpenApiType,
|
|
schemas: &IndexMap<&str, OpenApiType>,
|
|
warnings: WarningReporter,
|
|
) -> Self {
|
|
let mut result = Object {
|
|
name: name.to_owned(),
|
|
description: schema.description.as_deref().map(ToOwned::to_owned),
|
|
..Default::default()
|
|
};
|
|
|
|
let Some(props) = &schema.properties else {
|
|
warnings.push("Missing properties");
|
|
return result;
|
|
};
|
|
|
|
let required = schema.required.clone().unwrap_or_default();
|
|
|
|
for (prop_name, prop) in props {
|
|
let Some(prop) = Property::from_schema(
|
|
prop_name,
|
|
required.contains(prop_name),
|
|
prop,
|
|
schemas,
|
|
warnings.child(prop_name),
|
|
) else {
|
|
continue;
|
|
};
|
|
|
|
let field_name = prop.field_name.clone();
|
|
|
|
let entry = result.properties.entry(field_name.clone());
|
|
if let Entry::Occupied(mut entry) = entry {
|
|
let other_name = entry.get().name.clone();
|
|
warnings.push(format!(
|
|
"Property name collision: {other_name} and {field_name}"
|
|
));
|
|
// deprioritise kebab and camelcase
|
|
if other_name.contains('-')
|
|
|| other_name
|
|
.chars()
|
|
.filter(|c| c.is_alphabetic())
|
|
.any(|c| c.is_ascii_uppercase())
|
|
{
|
|
entry.insert(prop);
|
|
}
|
|
} else {
|
|
entry.insert_entry(prop);
|
|
}
|
|
}
|
|
|
|
result
|
|
}
|
|
|
|
pub fn from_all_of(
|
|
name: &str,
|
|
types: &[OpenApiType],
|
|
schemas: &IndexMap<&str, OpenApiType>,
|
|
warnings: WarningReporter,
|
|
) -> Self {
|
|
let mut result = Self {
|
|
name: name.to_owned(),
|
|
..Default::default()
|
|
};
|
|
|
|
for r#type in types {
|
|
let r#type = if let OpenApiType {
|
|
ref_path: Some(ref_path),
|
|
..
|
|
} = r#type
|
|
{
|
|
let Some(name) = ref_path.strip_prefix("#/components/schemas/") else {
|
|
warnings.push(format!("Malformed ref {ref_path}"));
|
|
continue;
|
|
};
|
|
let Some(schema) = schemas.get(name) else {
|
|
warnings.push(format!("Missing schema for ref {name}"));
|
|
continue;
|
|
};
|
|
schema
|
|
} else {
|
|
r#type
|
|
};
|
|
let obj = if let Some(types) = &r#type.all_of {
|
|
Self::from_all_of(name, types, schemas, warnings.child("variant"))
|
|
} else {
|
|
Self::from_schema_object(name, r#type, schemas, warnings.child("variant"))
|
|
};
|
|
|
|
result.description = result.description.or(obj.description);
|
|
result.properties.extend(obj.properties);
|
|
}
|
|
|
|
result
|
|
}
|
|
|
|
pub fn codegen(&self, resolved: &ResolvedSchema) -> Option<TokenStream> {
|
|
let doc = self.description.as_ref().map(|d| {
|
|
quote! {
|
|
#[doc = #d]
|
|
}
|
|
});
|
|
|
|
let mut namespace = ObjectNamespace {
|
|
object: self,
|
|
ident: None,
|
|
elements: Vec::default(),
|
|
};
|
|
|
|
let mut props = Vec::with_capacity(self.properties.len());
|
|
for (_, prop) in &self.properties {
|
|
props.push(prop.codegen(&mut namespace, resolved)?);
|
|
}
|
|
|
|
let name = format_ident!("{}", self.name);
|
|
let ns = namespace.codegen();
|
|
|
|
Some(quote! {
|
|
#ns
|
|
|
|
#doc
|
|
#[derive(Debug, Clone, PartialEq, serde::Deserialize)]
|
|
pub struct #name {
|
|
#(#props),*
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
pub struct ObjectNamespace<'o> {
|
|
object: &'o Object,
|
|
ident: Option<Ident>,
|
|
elements: Vec<TokenStream>,
|
|
}
|
|
|
|
impl ObjectNamespace<'_> {
|
|
pub fn get_ident(&mut self) -> Ident {
|
|
self.ident
|
|
.get_or_insert_with(|| {
|
|
let name = self.object.name.to_snake_case();
|
|
format_ident!("{name}")
|
|
})
|
|
.clone()
|
|
}
|
|
|
|
pub fn push_element(&mut self, el: TokenStream) {
|
|
self.elements.push(el);
|
|
}
|
|
|
|
pub fn codegen(mut self) -> Option<TokenStream> {
|
|
if self.elements.is_empty() {
|
|
None
|
|
} else {
|
|
let ident = self.get_ident();
|
|
let elements = self.elements;
|
|
Some(quote! {
|
|
pub mod #ident {
|
|
#(#elements)*
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod test {
|
|
use super::*;
|
|
|
|
use crate::openapi::schema::test::get_schema;
|
|
|
|
#[test]
|
|
fn resolve_objects() {
|
|
let schema = get_schema();
|
|
|
|
let mut objects = 0;
|
|
let mut unresolved = vec![];
|
|
|
|
for (name, desc) in &schema.components.schemas {
|
|
if desc.r#type == Some("object") {
|
|
objects += 1;
|
|
let reporter = WarningReporter::new();
|
|
Object::from_schema_object(
|
|
name,
|
|
desc,
|
|
&schema.components.schemas,
|
|
reporter.clone(),
|
|
);
|
|
if !reporter.is_empty() {
|
|
unresolved.push(name);
|
|
}
|
|
}
|
|
}
|
|
|
|
if !unresolved.is_empty() {
|
|
panic!(
|
|
"Failed to resolve {}/{} objects. Could not resolve [{}]",
|
|
unresolved.len(),
|
|
objects,
|
|
unresolved
|
|
.into_iter()
|
|
.map(|u| format!("`{u}`"))
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
)
|
|
}
|
|
}
|
|
}
|