Add support to externally tagged unions (#1043)

* chore: add track_caller to test asserts

* support externally-tagged unions

* new line

* add derive error when discriminator_name and externally tagger are both specified

* refactor: bind pattern variable
This commit is contained in:
Luca Iachini
2025-06-06 15:16:22 +02:00
committed by GitHub
parent ed2c5ea84d
commit c43ce04381
3 changed files with 391 additions and 112 deletions

View File

@@ -40,6 +40,8 @@ struct UnionArgs {
#[darling(default)]
discriminator_name: Option<String>,
#[darling(default)]
externally_tagged: bool,
#[darling(default)]
external_docs: Option<ExternalDocument>,
#[darling(default)]
rename_all: Option<RenameRule>,
@@ -55,11 +57,18 @@ pub(crate) fn generate(args: DeriveInput) -> GeneratorResult<TokenStream> {
let description = optional_literal(&description);
let discriminator_name = &args.discriminator_name;
let e = match &args.data {
Data::Enum(e) => e,
_ => return Err(Error::new_spanned(ident, "AnyOf can only be applied to an enum.").into()),
let Data::Enum(e) = &args.data else {
return Err(Error::new_spanned(ident, "AnyOf can only be applied to an enum.").into());
};
if discriminator_name.is_some() && args.externally_tagged {
return Err(Error::new_spanned(
ident,
"Discriminator name cannot be used with externally tagged unions.",
)
.into());
}
let mut types = Vec::new();
let mut from_json = Vec::new();
let mut to_json = Vec::new();
@@ -76,122 +85,157 @@ pub(crate) fn generate(args: DeriveInput) -> GeneratorResult<TokenStream> {
for variant in e {
let item_ident = &variant.ident;
match variant.fields.len() {
1 => {
let object_ty = &variant.fields.fields[0];
let schema_name = quote! {
::std::format!("{}_{}", <Self as #crate_name::types::Type>::name(), <#object_ty as #crate_name::types::Type>::name())
};
let mapping_name = match &variant.mapping {
Some(mapping) => quote!(::std::string::ToString::to_string(#mapping)),
None => {
let name = apply_rename_rule_variant(
args.rename_all,
item_ident.unraw().to_string(),
);
quote!(::std::string::ToString::to_string(#name))
if variant.fields.len() != 1 {
return Err(Error::new_spanned(&variant.ident, "Incorrect oneof definition.").into());
}
let object_ty = &variant.fields.fields[0];
let schema_name = quote! {
::std::format!("{}_{}", <Self as #crate_name::types::Type>::name(), <#object_ty as #crate_name::types::Type>::name())
};
let mapping_name = match &variant.mapping {
Some(mapping) => mapping.clone(),
None => apply_rename_rule_variant(args.rename_all, item_ident.unraw().to_string()),
};
types.push(object_ty);
if args.externally_tagged {
from_json.push(quote! {
if let value @ ::std::option::Option::Some(_) = value.as_object().and_then(|obj| obj.get(#mapping_name)).cloned() {
return <#object_ty as #crate_name::types::ParseFromJSON>::parse_from_json(value)
.map(Self::#item_ident)
.map_err(#crate_name::types::ParseError::propagate);
}
});
} else if discriminator_name.is_some() {
from_json.push(quote! {
if ::std::matches!(discriminator_name, ::std::option::Option::Some(discriminator_name) if discriminator_name == #mapping_name) {
return <#object_ty as #crate_name::types::ParseFromJSON>::parse_from_json(::std::option::Option::Some(value))
.map(Self::#item_ident)
.map_err(#crate_name::types::ParseError::propagate);
}
});
} else if !args.one_of {
// any of
from_json.push(quote! {
if let ::std::result::Result::Ok(obj) = <#object_ty as #crate_name::types::ParseFromJSON>::parse_from_json(::std::option::Option::Some(::std::clone::Clone::clone(&value)))
.map(Self::#item_ident) {
return ::std::result::Result::Ok(obj);
}
});
} else {
// one of
from_json.push(quote! {
if let ::std::result::Result::Ok(obj) = <#object_ty as #crate_name::types::ParseFromJSON>::parse_from_json(::std::option::Option::Some(::std::clone::Clone::clone(&value)))
.map(Self::#item_ident) {
if res_obj.is_some() {
return ::std::result::Result::Err(#crate_name::types::ParseError::expected_type(value));
}
};
types.push(object_ty);
res_obj = Some(obj);
}
});
}
if discriminator_name.is_some() {
from_json.push(quote! {
if ::std::matches!(discriminator_name, ::std::option::Option::Some(discriminator_name) if discriminator_name == &#mapping_name) {
return <#object_ty as #crate_name::types::ParseFromJSON>::parse_from_json(::std::option::Option::Some(value))
.map(Self::#item_ident)
.map_err(#crate_name::types::ParseError::propagate);
}
});
} else if !args.one_of {
// any of
from_json.push(quote! {
if let ::std::result::Result::Ok(obj) = <#object_ty as #crate_name::types::ParseFromJSON>::parse_from_json(::std::option::Option::Some(::std::clone::Clone::clone(&value)))
.map(Self::#item_ident) {
return ::std::result::Result::Ok(obj);
}
});
} else {
// one of
from_json.push(quote! {
if let ::std::result::Result::Ok(obj) = <#object_ty as #crate_name::types::ParseFromJSON>::parse_from_json(::std::option::Option::Some(::std::clone::Clone::clone(&value)))
.map(Self::#item_ident) {
if res_obj.is_some() {
return ::std::result::Result::Err(#crate_name::types::ParseError::expected_type(value));
}
res_obj = Some(obj);
}
});
if args.externally_tagged {
to_json.push(quote! {
Self::#item_ident(obj) => {
let value = <#object_ty as #crate_name::types::ToJSON>::to_json(obj);
let mut wrapped = #crate_name::__private::serde_json::Map::new();
wrapped.insert(::std::convert::Into::into(#mapping_name), ::std::option::Option::unwrap_or_default(value));
::std::option::Option::Some(#crate_name::__private::serde_json::Value::Object(wrapped))
}
});
} else if let Some(discriminator_name) = &discriminator_name {
to_json.push(quote! {
Self::#item_ident(obj) => {
let mut value = <#object_ty as #crate_name::types::ToJSON>::to_json(obj);
if let ::std::option::Option::Some(obj) = value.as_mut().and_then(|value| value.as_object_mut()) {
obj.insert(::std::convert::Into::into(#discriminator_name), ::std::convert::Into::into(#mapping_name));
}
value
}
});
} else {
to_json.push(quote! {
Self::#item_ident(obj) => <#object_ty as #crate_name::types::ToJSON>::to_json(obj)
});
}
mapping.push(quote! {
(::std::string::ToString::to_string(#mapping_name), ::std::format!("#/components/schemas/{}", #schema_name))
});
if args.externally_tagged {
create_schemas.push(quote! {
{
fn __check_is_object_type<T: #crate_name::types::IsObjectType>() {}
__check_is_object_type::<#object_ty>();
}
if let Some(discriminator_name) = &discriminator_name {
to_json.push(quote! {
Self::#item_ident(obj) => {
let mut value = <#object_ty as #crate_name::types::ToJSON>::to_json(obj);
if let ::std::option::Option::Some(obj) = value.as_mut().and_then(|value| value.as_object_mut()) {
obj.insert(::std::convert::Into::into(#discriminator_name), ::std::convert::Into::into(#mapping_name));
}
value
}
});
} else {
to_json.push(quote! {
Self::#item_ident(obj) => <#object_ty as #crate_name::types::ToJSON>::to_json(obj)
});
}
mapping.push(quote! {
(#mapping_name, ::std::format!("#/components/schemas/{}", #schema_name))
});
if let Some(discriminator_name) = &args.discriminator_name {
create_schemas.push(quote! {
{
fn __check_is_object_type<T: #crate_name::types::IsObjectType>() {}
__check_is_object_type::<#object_ty>();
}
let schema = #crate_name::registry::MetaSchema {
description: #description,
all_of: ::std::vec![
#crate_name::registry::MetaSchemaRef::Inline(::std::boxed::Box::new(#crate_name::registry::MetaSchema {
required: #required,
properties: ::std::vec![
(
#discriminator_name,
#crate_name::registry::MetaSchemaRef::Inline(::std::boxed::Box::new(
#crate_name::registry::MetaSchema {
ty: "string",
enum_items: ::std::vec![::std::convert::Into::into(#mapping_name)],
example: ::std::option::Option::Some(::std::convert::Into::into(#mapping_name)),
..#crate_name::registry::MetaSchema::ANY
}
)),
)
],
..#crate_name::registry::MetaSchema::new("object")
})),
<#object_ty as #crate_name::types::Type>::schema_ref(),
let schema = #crate_name::registry::MetaSchema {
description: #description,
all_of: ::std::vec![
#crate_name::registry::MetaSchemaRef::Inline(::std::boxed::Box::new(#crate_name::registry::MetaSchema {
required: ::std::vec![#mapping_name],
properties: ::std::vec![
(
#mapping_name,
<#object_ty as #crate_name::types::Type>::schema_ref(),
)
],
..#crate_name::registry::MetaSchema::ANY
};
..#crate_name::registry::MetaSchema::new("object")
})),
],
..#crate_name::registry::MetaSchema::ANY
};
registry.schemas.insert(#schema_name, schema);
});
registry.schemas.insert(#schema_name, schema);
});
schemas.push(quote! {
#crate_name::registry::MetaSchemaRef::Reference(#schema_name)
});
} else {
schemas.push(quote! {
<#object_ty as #crate_name::types::Type>::schema_ref()
});
schemas.push(quote! {
#crate_name::registry::MetaSchemaRef::Reference(#schema_name)
});
} else if let Some(discriminator_name) = &args.discriminator_name {
create_schemas.push(quote! {
{
fn __check_is_object_type<T: #crate_name::types::IsObjectType>() {}
__check_is_object_type::<#object_ty>();
}
}
_ => {
return Err(
Error::new_spanned(&variant.ident, "Incorrect oneof definition.").into(),
);
}
let schema = #crate_name::registry::MetaSchema {
description: #description,
all_of: ::std::vec![
#crate_name::registry::MetaSchemaRef::Inline(::std::boxed::Box::new(#crate_name::registry::MetaSchema {
required: #required,
properties: ::std::vec![
(
#discriminator_name,
#crate_name::registry::MetaSchemaRef::Inline(::std::boxed::Box::new(
#crate_name::registry::MetaSchema {
ty: "string",
enum_items: ::std::vec![::std::convert::Into::into(#mapping_name)],
example: ::std::option::Option::Some(::std::convert::Into::into(#mapping_name)),
..#crate_name::registry::MetaSchema::ANY
}
)),
)
],
..#crate_name::registry::MetaSchema::new("object")
})),
<#object_ty as #crate_name::types::Type>::schema_ref(),
],
..#crate_name::registry::MetaSchema::ANY
};
registry.schemas.insert(#schema_name, schema);
});
schemas.push(quote! {
#crate_name::registry::MetaSchemaRef::Reference(#schema_name)
});
} else {
schemas.push(quote! {
<#object_ty as #crate_name::types::Type>::schema_ref()
});
}
}
@@ -211,6 +255,10 @@ pub(crate) fn generate(args: DeriveInput) -> GeneratorResult<TokenStream> {
#(#from_json)*
::std::result::Result::Err(#crate_name::types::ParseError::expected_type(value))
},
None if args.externally_tagged => quote! {
#(#from_json)*
::std::result::Result::Err(#crate_name::types::ParseError::expected_type(value))
},
// anyof
None if !args.one_of => quote! {
#(#from_json)*

View File

@@ -5,6 +5,7 @@ Define an OpenAPI discriminator object.
| Attribute | Description | Type | Optional |
|--------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------|----------|
| discriminator_name | The name of the property in the payload that will hold the discriminator value. | string | Y |
| externally_tagged | Represent the union using the **externally tagged** format. The variant will be wrapped in an object where the key is the variant name. See [Serde enum representations](https://serde.rs/enum-representations.html#externally-tagged). | bool | Y |
| one_of | Validates the value against exactly one of the subschemas | bool | Y |
| external_docs | Specify a external resource for extended documentation | string | Y |
| rename_all | Rename all the mapping name according to the given case convention. The possible values are "lowercase", "UPPERCASE", "PascalCase", "camelCase", "snake_case", "SCREAMING_SNAKE_CASE", "kebab-case", "SCREAMING-KEBAB-CASE". | string | Y |

View File

@@ -614,3 +614,233 @@ fn rename_all() {
}))
);
}
#[test]
fn with_externally_tagged() {
#[derive(Object, Debug, PartialEq)]
struct A {
v1: i32,
v2: String,
}
#[derive(Object, Debug, PartialEq)]
struct B {
v3: bool,
}
#[derive(Union, Debug, PartialEq)]
#[oai(externally_tagged)]
enum MyObj {
A(A),
B(B),
}
let schema = get_meta::<MyObj>();
assert_eq!(
schema,
MetaSchema {
rust_typename: Some("union::with_externally_tagged::MyObj"),
ty: "object",
any_of: vec![
MetaSchemaRef::Reference("MyObj_A".to_string()),
MetaSchemaRef::Reference("MyObj_B".to_string()),
],
..MetaSchema::ANY
}
);
let schema_myobj_a = get_meta_by_name::<MyObj>("MyObj_A");
assert_eq!(
schema_myobj_a,
MetaSchema {
all_of: vec![MetaSchemaRef::Inline(Box::new(MetaSchema {
required: vec!["A"],
properties: vec![("A", MetaSchemaRef::Reference("A".to_string()),)],
..MetaSchema::new("object")
})),],
..MetaSchema::ANY
}
);
let schema_myobj_b = get_meta_by_name::<MyObj>("MyObj_B");
assert_eq!(
schema_myobj_b,
MetaSchema {
all_of: vec![MetaSchemaRef::Inline(Box::new(MetaSchema {
required: vec!["B"],
properties: vec![("B", MetaSchemaRef::Reference("B".to_string()),)],
..MetaSchema::new("object")
})),],
..MetaSchema::ANY
}
);
let mut registry = Registry::new();
MyObj::register(&mut registry);
assert!(registry.schemas.contains_key("A"));
assert!(registry.schemas.contains_key("B"));
assert_eq!(
MyObj::parse_from_json(Some(json!({
"A":{
"v1": 100,
"v2": "hello",
}
})))
.unwrap(),
MyObj::A(A {
v1: 100,
v2: "hello".to_string()
})
);
assert_eq!(
MyObj::A(A {
v1: 100,
v2: "hello".to_string()
})
.to_json(),
Some(json!({
"A":{
"v1": 100,
"v2": "hello",
}
}))
);
assert_eq!(
MyObj::parse_from_json(Some(json!({
"B": {
"v3": true,
}
})))
.unwrap(),
MyObj::B(B { v3: true })
);
assert_eq!(
MyObj::B(B { v3: true }).to_json(),
Some(json!({
"B": {
"v3": true,
}
}))
);
}
#[test]
fn with_externally_tagged_mapping() {
#[derive(Object, Debug, PartialEq)]
struct A {
v1: i32,
v2: String,
}
#[derive(Object, Debug, PartialEq)]
struct B {
v3: bool,
}
#[derive(Union, Debug, PartialEq)]
#[oai(externally_tagged)]
enum MyObj {
#[oai(mapping = "c")]
A(A),
#[oai(mapping = "d")]
B(B),
}
let schema = get_meta::<MyObj>();
assert_eq!(
schema,
MetaSchema {
rust_typename: Some("union::with_externally_tagged_mapping::MyObj"),
ty: "object",
any_of: vec![
MetaSchemaRef::Reference("MyObj_A".to_string()),
MetaSchemaRef::Reference("MyObj_B".to_string()),
],
..MetaSchema::ANY
}
);
let schema_myobj_a = get_meta_by_name::<MyObj>("MyObj_A");
assert_eq!(
schema_myobj_a,
MetaSchema {
all_of: vec![MetaSchemaRef::Inline(Box::new(MetaSchema {
required: vec!["c"],
properties: vec![("c", MetaSchemaRef::Reference("A".to_string()),)],
..MetaSchema::new("object")
})),],
..MetaSchema::ANY
}
);
let schema_myobj_b = get_meta_by_name::<MyObj>("MyObj_B");
assert_eq!(
schema_myobj_b,
MetaSchema {
all_of: vec![MetaSchemaRef::Inline(Box::new(MetaSchema {
required: vec!["d"],
properties: vec![("d", MetaSchemaRef::Reference("B".to_string()),)],
..MetaSchema::new("object")
})),],
..MetaSchema::ANY
}
);
let mut registry = Registry::new();
MyObj::register(&mut registry);
assert!(registry.schemas.contains_key("A"));
assert!(registry.schemas.contains_key("B"));
assert_eq!(
MyObj::parse_from_json(Some(json!({
"c":{
"v1": 100,
"v2": "hello",
}
})))
.unwrap(),
MyObj::A(A {
v1: 100,
v2: "hello".to_string()
})
);
assert_eq!(
MyObj::A(A {
v1: 100,
v2: "hello".to_string()
})
.to_json(),
Some(json!({
"c":{
"v1": 100,
"v2": "hello",
}
}))
);
assert_eq!(
MyObj::parse_from_json(Some(json!({
"d": {
"v3": true,
}
})))
.unwrap(),
MyObj::B(B { v3: true })
);
assert_eq!(
MyObj::B(B { v3: true }).to_json(),
Some(json!({
"d": {
"v3": true,
}
}))
);
}