Skip to content

Commit 207c68f

Browse files
committed
feat(utoipa-gen): add title_variants attribute for mixed enums
Adds an enum-level attribute to generate the `title` of its variants. By default it is the concatenation of the enum's and the variant's name. If the enum has an explicit `title` set, it is used as prefix instead. All variant titles can be overridden using the field-level `title` attribute.
1 parent a024aca commit 207c68f

9 files changed

Lines changed: 196 additions & 5 deletions

utoipa-gen/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog - utoipa-gen
22

3+
## Unreleased
4+
5+
### Added
6+
7+
* Add support for `#[schema(title_variants)]` on mixed enums (https://github.com/juhaku/utoipa/pull/1511)
8+
39
## 5.4.0 - Jun 16 2025
410

511
### Added

utoipa-gen/src/component/features.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ pub enum Feature {
8484
WriteOnly(attributes::WriteOnly),
8585
ReadOnly(attributes::ReadOnly),
8686
Title(attributes::Title),
87+
TitleVariants(attributes::TitleVariants),
8788
Nullable(attributes::Nullable),
8889
Rename(attributes::Rename),
8990
RenameAll(attributes::RenameAll),
@@ -179,6 +180,7 @@ impl ToTokensDiagnostics for Feature {
179180
Feature::WriteOnly(write_only) => quote! { .write_only(Some(#write_only)) },
180181
Feature::ReadOnly(read_only) => quote! { .read_only(Some(#read_only)) },
181182
Feature::Title(title) => quote! { .title(Some(#title)) },
183+
Feature::TitleVariants(_) => return Err(Diagnostics::new("TitleVariants does not support `ToTokens`")),
182184
Feature::Nullable(_nullable) => return Err(Diagnostics::new("Nullable does not support `ToTokens`")),
183185
Feature::Rename(rename) => rename.to_token_stream(),
184186
Feature::Style(style) => quote! { .style(Some(#style)) },
@@ -274,6 +276,7 @@ impl Display for Feature {
274276
Feature::WriteOnly(write_only) => write_only.fmt(f),
275277
Feature::ReadOnly(read_only) => read_only.fmt(f),
276278
Feature::Title(title) => title.fmt(f),
279+
Feature::TitleVariants(title_variants) => title_variants.fmt(f),
277280
Feature::Nullable(nullable) => nullable.fmt(f),
278281
Feature::Rename(rename) => rename.fmt(f),
279282
Feature::Style(style) => style.fmt(f),
@@ -324,6 +327,7 @@ impl Validatable for Feature {
324327
Feature::WriteOnly(write_only) => write_only.is_validatable(),
325328
Feature::ReadOnly(read_only) => read_only.is_validatable(),
326329
Feature::Title(title) => title.is_validatable(),
330+
Feature::TitleVariants(title_variants) => title_variants.is_validatable(),
327331
Feature::Nullable(nullable) => nullable.is_validatable(),
328332
Feature::Rename(rename) => rename.is_validatable(),
329333
Feature::Style(style) => style.is_validatable(),
@@ -388,6 +392,7 @@ is_validatable! {
388392
attributes::WriteOnly,
389393
attributes::ReadOnly,
390394
attributes::Title,
395+
attributes::TitleVariants,
391396
attributes::Nullable,
392397
attributes::Rename,
393398
attributes::RenameAll,
@@ -623,6 +628,7 @@ impl_feature_into_inner! {
623628
attributes::WriteOnly,
624629
attributes::ReadOnly,
625630
attributes::Title,
631+
attributes::TitleVariants,
626632
attributes::Nullable,
627633
attributes::Rename,
628634
attributes::RenameAll,

utoipa-gen/src/component/features/attributes.rs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,27 @@ impl From<Examples> for Feature {
121121
}
122122
}
123123

124+
impl_feature! {
125+
#[derive(Clone)]
126+
#[cfg_attr(feature = "debug", derive(Debug))]
127+
pub struct TitleVariants;
128+
}
129+
130+
impl Parse for TitleVariants {
131+
fn parse(_: ParseStream, _: Ident) -> syn::Result<Self>
132+
where
133+
Self: std::marker::Sized,
134+
{
135+
Ok(Self)
136+
}
137+
}
138+
139+
impl From<TitleVariants> for Feature {
140+
fn from(value: TitleVariants) -> Self {
141+
Feature::TitleVariants(value)
142+
}
143+
}
144+
124145
impl_feature! {"xml" =>
125146
#[derive(Default, Clone)]
126147
#[cfg_attr(feature = "debug", derive(Debug))]
@@ -258,7 +279,7 @@ impl From<ReadOnly> for Feature {
258279
impl_feature! {
259280
#[derive(Clone)]
260281
#[cfg_attr(feature = "debug", derive(Debug))]
261-
pub struct Title(String);
282+
pub struct Title(pub String);
262283
}
263284

264285
impl Parse for Title {

utoipa-gen/src/component/schema/enums.rs

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use crate::{
99
features::{
1010
attributes::{
1111
Deprecated, Description, Discriminator, Example, Examples, NoRecursion, Rename,
12-
RenameAll, Title,
12+
RenameAll, Title, TitleVariants,
1313
},
1414
parse_features, pop_feature, Feature, IntoInner, IsInline, ToTokensExt,
1515
},
@@ -255,6 +255,15 @@ impl<'p> MixedEnum<'p> {
255255
let rename_all = pop_feature!(features => Feature::RenameAll(_) as Option<RenameAll>);
256256
let description = pop_feature!(features => Feature::Description(_) as Option<Description>);
257257
let discriminator = pop_feature!(features => Feature::Discriminator(_));
258+
let title_variants =
259+
pop_feature!(features => Feature::TitleVariants(_) as Option<TitleVariants>);
260+
let variants_title_prefix = features
261+
.iter()
262+
.find_map(|feature| match feature {
263+
Feature::Title(Title(title)) => Some(title.clone()),
264+
_ => None,
265+
})
266+
.unwrap_or_else(|| root.ident.to_string());
258267

259268
let variants = variants
260269
.iter()
@@ -268,7 +277,7 @@ impl<'p> MixedEnum<'p> {
268277
if variant_rules.skip {
269278
None
270279
} else {
271-
let variant_features = match &variant.fields {
280+
let mut variant_features = match &variant.fields {
272281
Fields::Named(_) => {
273282
match variant
274283
.attrs
@@ -306,6 +315,16 @@ impl<'p> MixedEnum<'p> {
306315
}
307316
};
308317

318+
if title_variants.is_some()
319+
&& !variant_features
320+
.iter()
321+
.any(|f| matches!(f, Feature::Title(_)))
322+
{
323+
let variant_name = variant.ident.to_string();
324+
let title = format!("{variants_title_prefix}{variant_name}");
325+
variant_features.push(Feature::Title(Title(title)));
326+
}
327+
309328
Some(Ok((variant, variant_rules, variant_features)))
310329
}
311330
})

utoipa-gen/src/component/schema/features.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ use crate::{
88
attributes::{
99
AdditionalProperties, As, Bound, ContentEncoding, ContentMediaType, Deprecated,
1010
Description, Discriminator, Example, Examples, Format, Ignore, Inline, NoRecursion,
11-
Nullable, ReadOnly, Rename, RenameAll, Required, SchemaWith, Title, ValueType,
12-
WriteOnly, XmlAttr,
11+
Nullable, ReadOnly, Rename, RenameAll, Required, SchemaWith, Title, TitleVariants,
12+
ValueType, WriteOnly, XmlAttr,
1313
},
1414
impl_into_inner, impl_merge, parse_features,
1515
validation::{
@@ -101,6 +101,7 @@ impl Parse for MixedEnumFeatures {
101101
Examples,
102102
crate::component::features::attributes::Default,
103103
Title,
104+
TitleVariants,
104105
RenameAll,
105106
As,
106107
Deprecated,

utoipa-gen/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,9 @@ static CONFIG: once_cell::sync::Lazy<utoipa_config::Config> =
318318
/// * `title = ...` Literal string value. Can be used to define title for enum in OpenAPI
319319
/// document. Some OpenAPI code generation libraries also use this field as a name for the
320320
/// enum.
321+
/// * `title_variants` Can be used to generate titles for enum variants by concatenating the enum
322+
/// title with the variant name. Variant titles can be overridden individually by annotating
323+
/// them with the `#[schema(title = "...")]` attribute.
321324
/// * `rename_all = ...` Supports same syntax as _serde_ _`rename_all`_ attribute. Will rename all
322325
/// variants of the enum accordingly. If both _serde_ `rename_all` and _schema_ _`rename_all`_
323326
/// are defined __serde__ will take precedence.

utoipa-gen/tests/schema_derive_test.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -968,6 +968,44 @@ fn derive_mixed_enum_title() {
968968
assert_json_snapshot!(value);
969969
}
970970

971+
#[test]
972+
fn derive_mixed_enum_title_variants() {
973+
#[derive(ToSchema)]
974+
struct Foo;
975+
976+
let value: Value = api_doc! {
977+
#[schema(title_variants)]
978+
enum EnumTitleVariants {
979+
UnitValue,
980+
NamedFields {
981+
id: &'static str,
982+
},
983+
UnnamedFields(Foo),
984+
#[schema(title = "Overridden")]
985+
OverriddenTitle,
986+
}
987+
};
988+
989+
assert_json_snapshot!(value);
990+
}
991+
992+
#[test]
993+
fn derive_mixed_enum_title_variants_enum_title() {
994+
let value: Value = api_doc! {
995+
#[schema(title = "CustomTitle", title_variants)]
996+
enum EnumTitleVariants {
997+
UnitValue,
998+
NamedFields {
999+
id: &'static str,
1000+
},
1001+
#[schema(title = "Overridden")]
1002+
OverriddenTitle,
1003+
}
1004+
};
1005+
1006+
assert_json_snapshot!(value);
1007+
}
1008+
9711009
#[test]
9721010
fn derive_mixed_enum_example() {
9731011
#[derive(Serialize, ToSchema)]
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
---
2+
source: utoipa-gen/tests/schema_derive_test.rs
3+
expression: value
4+
---
5+
{
6+
"oneOf": [
7+
{
8+
"enum": [
9+
"UnitValue"
10+
],
11+
"title": "EnumTitleVariantsUnitValue",
12+
"type": "string"
13+
},
14+
{
15+
"properties": {
16+
"NamedFields": {
17+
"properties": {
18+
"id": {
19+
"type": "string"
20+
}
21+
},
22+
"required": [
23+
"id"
24+
],
25+
"type": "object"
26+
}
27+
},
28+
"required": [
29+
"NamedFields"
30+
],
31+
"title": "EnumTitleVariantsNamedFields",
32+
"type": "object"
33+
},
34+
{
35+
"properties": {
36+
"UnnamedFields": {
37+
"$ref": "#/components/schemas/Foo"
38+
}
39+
},
40+
"required": [
41+
"UnnamedFields"
42+
],
43+
"title": "EnumTitleVariantsUnnamedFields",
44+
"type": "object"
45+
},
46+
{
47+
"enum": [
48+
"OverriddenTitle"
49+
],
50+
"title": "Overridden",
51+
"type": "string"
52+
}
53+
]
54+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
source: utoipa-gen/tests/schema_derive_test.rs
3+
expression: value
4+
---
5+
{
6+
"oneOf": [
7+
{
8+
"enum": [
9+
"UnitValue"
10+
],
11+
"title": "CustomTitleUnitValue",
12+
"type": "string"
13+
},
14+
{
15+
"properties": {
16+
"NamedFields": {
17+
"properties": {
18+
"id": {
19+
"type": "string"
20+
}
21+
},
22+
"required": [
23+
"id"
24+
],
25+
"type": "object"
26+
}
27+
},
28+
"required": [
29+
"NamedFields"
30+
],
31+
"title": "CustomTitleNamedFields",
32+
"type": "object"
33+
},
34+
{
35+
"enum": [
36+
"OverriddenTitle"
37+
],
38+
"title": "Overridden",
39+
"type": "string"
40+
}
41+
],
42+
"title": "CustomTitle"
43+
}

0 commit comments

Comments
 (0)