Repository: GREsau/schemars
Pull request #413 (2025-05-31 15:48:23)

Set `title` to variant name for untagged enums/variants
Addresses #102 

This applies both to untagged enums, and to individual untagged variants of tagged enums


Patch:
diff --git a/schemars/src/_private/mod.rs b/schemars/src/_private/mod.rs
index 439f34c..f95550d 100644
--- a/schemars/src/_private/mod.rs
+++ b/schemars/src/_private/mod.rs
@@ -145,7 +145,7 @@ pub fn apply_internal_enum_variant_tag(
     deny_unknown_fields: bool,
 ) {
     let obj = schema.ensure_object();
-    let is_unit = obj.get("type").and_then(|t| t.as_str()) == Some("null");
+    let is_unit = obj.get("type").and_then(Value::as_str) == Some("null");
 
     obj.insert("type".to_owned(), "object".into());
 
@@ -202,10 +202,6 @@ pub fn insert_object_property(
     }
 }
 
-pub fn insert_metadata_property(schema: &mut Schema, key: &str, value: impl Into<Value>) {
-    schema.ensure_object().insert(key.to_owned(), value.into());
-}
-
 pub fn insert_metadata_property_if_nonempty(
     schema: &mut Schema,
     key: &str,
@@ -213,7 +209,7 @@ pub fn insert_metadata_property_if_nonempty(
 ) {
     let value: String = value.into();
     if !value.is_empty() {
-        insert_metadata_property(schema, key, value);
+        schema.insert(key.to_owned(), value.into());
     }
 }
 
@@ -224,7 +220,7 @@ pub fn insert_validation_property(
     value: impl Into<Value>,
 ) {
     if schema.has_type(required_type) || (required_type == "number" && schema.has_type("integer")) {
-        schema.ensure_object().insert(key.to_owned(), value.into());
+        schema.insert(key.to_owned(), value.into());
     }
 }
 
diff --git a/schemars_derive/src/ast/mod.rs b/schemars_derive/src/ast/mod.rs
index c098959..5354ebd 100644
--- a/schemars_derive/src/ast/mod.rs
+++ b/schemars_derive/src/ast/mod.rs
@@ -96,12 +96,12 @@ impl Field<'_> {
 
         if self.serde_attrs.skip_deserializing() {
             mutators.push(quote! {
-                schemars::_private::insert_metadata_property(&mut #SCHEMA, "readOnly", true);
+                #SCHEMA.insert("readOnly".to_owned(), true.into());
             });
         }
         if self.serde_attrs.skip_serializing() {
             mutators.push(quote! {
-                schemars::_private::insert_metadata_property(&mut #SCHEMA, "writeOnly", true);
+                #SCHEMA.insert("writeOnly".to_owned(), true.into());
             });
         }
     }
diff --git a/schemars_derive/src/attr/mod.rs b/schemars_derive/src/attr/mod.rs
index cf70ea9..e510d93 100644
--- a/schemars_derive/src/attr/mod.rs
+++ b/schemars_derive/src/attr/mod.rs
@@ -191,7 +191,7 @@ impl CommonAttrs {
 
         if self.deprecated {
             mutators.push(quote! {
-                schemars::_private::insert_metadata_property(&mut #SCHEMA, "deprecated", true);
+                #SCHEMA.insert("deprecated".to_owned(), true.into());
             });
         }
 
@@ -202,13 +202,13 @@ impl CommonAttrs {
                 }
             });
             mutators.push(quote! {
-                schemars::_private::insert_metadata_property(&mut #SCHEMA, "examples", schemars::_private::serde_json::Value::Array([#(#examples),*].into_iter().flatten().collect()));
+                #SCHEMA.insert("examples".to_owned(), schemars::_private::serde_json::Value::Array([#(#examples),*].into_iter().flatten().collect()));
             });
         }
 
         for (k, v) in &self.extensions {
             mutators.push(quote! {
-                schemars::_private::insert_metadata_property(&mut #SCHEMA, #k, schemars::_private::serde_json::json!(#v));
+                #SCHEMA.insert(#k.to_owned(), schemars::_private::serde_json::json!(#v));
             });
         }
 
diff --git a/schemars_derive/src/schema_exprs.rs b/schemars_derive/src/schema_exprs.rs
index 8f0c187..c53db03 100644
--- a/schemars_derive/src/schema_exprs.rs
+++ b/schemars_derive/src/schema_exprs.rs
@@ -241,7 +241,7 @@ fn expr_for_external_tagged_enum<'a>(
         if variant.serde_attrs.untagged() {
             return (
                 Some(variant),
-                expr_for_untagged_enum_variant(variant, deny_unknown_fields),
+                expr_for_untagged_enum_variant(variant, deny_unknown_fields, true),
             );
         }
 
@@ -253,7 +253,8 @@ fn expr_for_external_tagged_enum<'a>(
                     schemars::_private::new_unit_enum_variant(#name)
                 }
             } else {
-                let sub_schema = expr_for_untagged_enum_variant(variant, deny_unknown_fields);
+                let sub_schema =
+                    expr_for_untagged_enum_variant(variant, deny_unknown_fields, false);
                 quote! {
                     schemars::_private::new_externally_tagged_enum_variant(#name, #sub_schema)
                 }
@@ -275,7 +276,7 @@ fn expr_for_internal_tagged_enum<'a>(
     let variant_schemas = variants
         .map(|variant| {
             if variant.serde_attrs.untagged() {
-                return (Some(variant), expr_for_untagged_enum_variant(variant, deny_unknown_fields))
+                return (Some(variant), expr_for_untagged_enum_variant(variant, deny_unknown_fields, true))
             }
 
             let mut schema_expr = expr_for_internal_tagged_enum_variant(variant, deny_unknown_fields);
@@ -300,9 +301,7 @@ fn expr_for_untagged_enum<'a>(
 ) -> SchemaExpr {
     let schemas = variants
         .map(|variant| {
-            let mut schema_expr = expr_for_untagged_enum_variant(variant, deny_unknown_fields);
-
-            variant.add_mutators(&mut schema_expr.mutators);
+            let schema_expr = expr_for_untagged_enum_variant(variant, deny_unknown_fields, true);
 
             (Some(variant), schema_expr)
         })
@@ -324,14 +323,18 @@ fn expr_for_adjacent_tagged_enum<'a>(
             if variant.serde_attrs.untagged() {
                 return (
                     Some(variant),
-                    expr_for_untagged_enum_variant(variant, deny_unknown_fields),
+                    expr_for_untagged_enum_variant(variant, deny_unknown_fields, true),
                 );
             }
 
             let content_schema = if variant.is_unit() && variant.attrs.with.is_none() {
                 None
             } else {
-                Some(expr_for_untagged_enum_variant(variant, deny_unknown_fields))
+                Some(expr_for_untagged_enum_variant(
+                    variant,
+                    deny_unknown_fields,
+                    false,
+                ))
             };
 
             let (add_content_to_props, add_content_to_required) = content_schema
@@ -421,24 +424,45 @@ fn variant_subschemas(
     .into()
 }
 
-fn expr_for_untagged_enum_variant(variant: &Variant, deny_unknown_fields: bool) -> SchemaExpr {
-    if let Some(with_attr) = &variant.attrs.with {
+// This function is also used for tagged variants, in which case the resulting SchemaExpr will be
+// embedded or mutated to include the tag. `is_actually_untagged` will be true if the enum or the
+// variant has the `untagged` attribute.
+fn expr_for_untagged_enum_variant(
+    variant: &Variant,
+    deny_unknown_fields: bool,
+    is_actually_untagged: bool,
+) -> SchemaExpr {
+    let mut schema_expr = if let Some(with_attr) = &variant.attrs.with {
         let (ty, type_def) = type_for_schema(with_attr);
         let mut schema_expr = SchemaExpr::from(quote_spanned! {variant.original.span()=>
             #GENERATOR.subschema_for::<#ty>()
         });
 
         schema_expr.definitions.extend(type_def);
+        schema_expr
+    } else {
+        match variant.style {
+            Style::Unit => expr_for_unit_struct(),
+            Style::Newtype => expr_for_field(&variant.fields[0], false),
+            Style::Tuple => expr_for_tuple_struct(&variant.fields),
+            Style::Struct => {
+                expr_for_struct(&variant.fields, &SerdeDefault::None, deny_unknown_fields)
+            }
+        }
+    };
 
-        return schema_expr;
-    }
+    if is_actually_untagged {
+        if variant.attrs.common.title.is_none() {
+            let title = variant.name();
+            schema_expr.mutators.push(quote! {
+                #SCHEMA.insert("title".to_owned(), #title.into());
+            });
+        }
 
-    match variant.style {
-        Style::Unit => expr_for_unit_struct(),
-        Style::Newtype => expr_for_field(&variant.fields[0], false),
-        Style::Tuple => expr_for_tuple_struct(&variant.fields),
-        Style::Struct => expr_for_struct(&variant.fields, &SerdeDefault::None, deny_unknown_fields),
+        variant.add_mutators(&mut schema_expr.mutators);
     }
+
+    schema_expr
 }
 
 fn expr_for_internal_tagged_enum_variant(
@@ -563,7 +587,7 @@ fn expr_for_struct(
                 if let Some(default) = field_default_expr(field, set_container_default.is_some()) {
                     schema_expr.mutators.push(quote! {
                         #default.and_then(|d| schemars::_schemars_maybe_to_value!(d))
-                            .map(|d| schemars::_private::insert_metadata_property(&mut #SCHEMA, "default", d));
+                            .map(|d| #SCHEMA.insert("default".to_owned(), d));
                     })
                 }
 


Test patch:
diff --git a/schemars/tests/integration/snapshots/schemars/tests/integration/contract.rs~untagged_enum.de.json b/schemars/tests/integration/snapshots/schemars/tests/integration/contract.rs~untagged_enum.de.json
index aa92e7e..2124027 100644
--- a/schemars/tests/integration/snapshots/schemars/tests/integration/contract.rs~untagged_enum.de.json
+++ b/schemars/tests/integration/snapshots/schemars/tests/integration/contract.rs~untagged_enum.de.json
@@ -3,9 +3,11 @@
   "title": "UntaggedEnum",
   "anyOf": [
     {
+      "title": "WriteOnlyUnit",
       "type": "null"
     },
     {
+      "title": "WriteOnlyStruct",
       "type": "object",
       "properties": {
         "i": {
@@ -18,9 +20,11 @@
       ]
     },
     {
+      "title": "de_renamed_unit",
       "type": "null"
     },
     {
+      "title": "de_renamed_struct",
       "type": "object",
       "properties": {
         "b": {
diff --git a/schemars/tests/integration/snapshots/schemars/tests/integration/contract.rs~untagged_enum.ser.json b/schemars/tests/integration/snapshots/schemars/tests/integration/contract.rs~untagged_enum.ser.json
index 31a5b89..da6e96f 100644
--- a/schemars/tests/integration/snapshots/schemars/tests/integration/contract.rs~untagged_enum.ser.json
+++ b/schemars/tests/integration/snapshots/schemars/tests/integration/contract.rs~untagged_enum.ser.json
@@ -3,9 +3,11 @@
   "title": "UntaggedEnum",
   "anyOf": [
     {
+      "title": "READ-ONLY-UNIT",
       "type": "null"
     },
     {
+      "title": "READ-ONLY-STRUCT",
       "type": "object",
       "properties": {
         "S": {
@@ -17,9 +19,11 @@
       ]
     },
     {
+      "title": "ser_renamed_unit",
       "type": "null"
     },
     {
+      "title": "ser_renamed_struct",
       "type": "object",
       "properties": {
         "B": {
diff --git a/schemars/tests/integration/snapshots/schemars/tests/integration/enums.rs~untagged_enum.json b/schemars/tests/integration/snapshots/schemars/tests/integration/enums.rs~untagged_enum.json
index e744d55..51a0b4c 100644
--- a/schemars/tests/integration/snapshots/schemars/tests/integration/enums.rs~untagged_enum.json
+++ b/schemars/tests/integration/snapshots/schemars/tests/integration/enums.rs~untagged_enum.json
@@ -3,21 +3,26 @@
   "title": "Untagged",
   "anyOf": [
     {
+      "title": "UnitOne",
       "type": "null"
     },
     {
+      "title": "StringMap",
       "type": "object",
       "additionalProperties": {
         "type": "string"
       }
     },
     {
+      "title": "UnitStructNewType",
       "$ref": "#/$defs/UnitStruct"
     },
     {
+      "title": "StructNewType",
       "$ref": "#/$defs/Struct"
     },
     {
+      "title": "Struct",
       "type": "object",
       "properties": {
         "foo": {
@@ -34,6 +39,7 @@
       ]
     },
     {
+      "title": "Tuple",
       "type": "array",
       "prefixItems": [
         {
@@ -48,14 +54,17 @@
       "maxItems": 2
     },
     {
+      "title": "UnitTwo",
       "type": "null"
     },
     {
+      "title": "UnitAsInt",
       "type": "integer",
       "format": "uint64",
       "minimum": 0
     },
     {
+      "title": "TupleAsStr",
       "type": "string",
       "pattern": "^\\d+ (true|false)$"
     }
diff --git a/schemars/tests/integration/snapshots/schemars/tests/integration/enums_deny_unknown_fields.rs~untagged_enum.json b/schemars/tests/integration/snapshots/schemars/tests/integration/enums_deny_unknown_fields.rs~untagged_enum.json
index 1e6a367..f9f409f 100644
--- a/schemars/tests/integration/snapshots/schemars/tests/integration/enums_deny_unknown_fields.rs~untagged_enum.json
+++ b/schemars/tests/integration/snapshots/schemars/tests/integration/enums_deny_unknown_fields.rs~untagged_enum.json
@@ -3,21 +3,26 @@
   "title": "Untagged",
   "anyOf": [
     {
+      "title": "Unit",
       "type": "null"
     },
     {
+      "title": "StringMap",
       "type": "object",
       "additionalProperties": {
         "type": "string"
       }
     },
     {
+      "title": "StructNewType",
       "$ref": "#/$defs/Struct"
     },
     {
+      "title": "StructDenyUnknownFieldsNewType",
       "$ref": "#/$defs/StructDenyUnknownFields"
     },
     {
+      "title": "Struct",
       "type": "object",
       "properties": {
         "foo": {
diff --git a/schemars/tests/integration/snapshots/schemars/tests/integration/enums_untagged_variant.rs~adjacently_tagged_enum.json b/schemars/tests/integration/snapshots/schemars/tests/integration/enums_untagged_variant.rs~adjacently_tagged_enum.json
index 1b31c01..8f04e21 100644
--- a/schemars/tests/integration/snapshots/schemars/tests/integration/enums_untagged_variant.rs~adjacently_tagged_enum.json
+++ b/schemars/tests/integration/snapshots/schemars/tests/integration/enums_untagged_variant.rs~adjacently_tagged_enum.json
@@ -44,15 +44,19 @@
       ]
     },
     {
+      "title": "UnitOne",
       "type": "null"
     },
     {
+      "title": "UnitStructNewType",
       "$ref": "#/$defs/UnitStruct"
     },
     {
+      "title": "StructNewType",
       "$ref": "#/$defs/Struct"
     },
     {
+      "title": "Struct",
       "type": "object",
       "properties": {
         "baz": {
@@ -69,6 +73,7 @@
       ]
     },
     {
+      "title": "Tuple",
       "type": "array",
       "prefixItems": [
         {
@@ -83,6 +88,7 @@
       "maxItems": 2
     },
     {
+      "title": "StringMap",
       "type": "object",
       "additionalProperties": {
         "type": "string"
diff --git a/schemars/tests/integration/snapshots/schemars/tests/integration/enums_untagged_variant.rs~externally_tagged_enum.json b/schemars/tests/integration/snapshots/schemars/tests/integration/enums_untagged_variant.rs~externally_tagged_enum.json
index 47c1272..9213fa5 100644
--- a/schemars/tests/integration/snapshots/schemars/tests/integration/enums_untagged_variant.rs~externally_tagged_enum.json
+++ b/schemars/tests/integration/snapshots/schemars/tests/integration/enums_untagged_variant.rs~externally_tagged_enum.json
@@ -34,15 +34,19 @@
       "additionalProperties": false
     },
     {
+      "title": "unitOne",
       "type": "null"
     },
     {
+      "title": "unitStructNewType",
       "$ref": "#/$defs/UnitStruct"
     },
     {
+      "title": "structNewType",
       "$ref": "#/$defs/Struct"
     },
     {
+      "title": "struct",
       "type": "object",
       "properties": {
         "baz": {
@@ -59,6 +63,7 @@
       ]
     },
     {
+      "title": "tuple",
       "type": "array",
       "prefixItems": [
         {
@@ -73,6 +78,7 @@
       "maxItems": 2
     },
     {
+      "title": "stringMap",
       "type": "object",
       "additionalProperties": {
         "type": "string"
diff --git a/schemars/tests/integration/snapshots/schemars/tests/integration/enums_untagged_variant.rs~internally_tagged_enum.json b/schemars/tests/integration/snapshots/schemars/tests/integration/enums_untagged_variant.rs~internally_tagged_enum.json
index 5733d75..4681eb8 100644
--- a/schemars/tests/integration/snapshots/schemars/tests/integration/enums_untagged_variant.rs~internally_tagged_enum.json
+++ b/schemars/tests/integration/snapshots/schemars/tests/integration/enums_untagged_variant.rs~internally_tagged_enum.json
@@ -36,15 +36,19 @@
       ]
     },
     {
+      "title": "UnitOne",
       "type": "null"
     },
     {
+      "title": "UnitStructNewType",
       "$ref": "#/$defs/UnitStruct"
     },
     {
+      "title": "StructNewType",
       "$ref": "#/$defs/Struct"
     },
     {
+      "title": "Struct",
       "type": "object",
       "properties": {
         "baz": {
@@ -61,6 +65,7 @@
       ]
     },
     {
+      "title": "StringMap",
       "type": "object",
       "additionalProperties": {
         "type": "string"
diff --git a/schemars/tests/integration/snapshots/schemars/tests/integration/extend.rs~extend_untagged_enum.json b/schemars/tests/integration/snapshots/schemars/tests/integration/extend.rs~extend_untagged_enum.json
index 8a0df44..450a75b 100644
--- a/schemars/tests/integration/snapshots/schemars/tests/integration/extend.rs~extend_untagged_enum.json
+++ b/schemars/tests/integration/snapshots/schemars/tests/integration/extend.rs~extend_untagged_enum.json
@@ -3,13 +3,16 @@
   "title": "UntaggedEnum",
   "anyOf": [
     {
+      "title": "Unit",
       "type": "null",
       "foo": "bar"
     },
     {
+      "title": "NewType",
       "foo": "bar"
     },
     {
+      "title": "Tuple",
       "type": "array",
       "prefixItems": [
         {
@@ -25,6 +28,7 @@
       "foo": "bar"
     },
     {
+      "title": "Struct",
       "type": "object",
       "properties": {
         "i": {
diff --git a/schemars/tests/integration/snapshots/schemars/tests/integration/remote_derive.rs~type_param.json b/schemars/tests/integration/snapshots/schemars/tests/integration/remote_derive.rs~type_param.json
index 2a21dc4..32f0a33 100644
--- a/schemars/tests/integration/snapshots/schemars/tests/integration/remote_derive.rs~type_param.json
+++ b/schemars/tests/integration/snapshots/schemars/tests/integration/remote_derive.rs~type_param.json
@@ -18,12 +18,14 @@
     "Or_for_uint8_and_boolean": {
       "anyOf": [
         {
+          "title": "A",
           "type": "integer",
           "format": "uint8",
           "minimum": 0,
           "maximum": 255
         },
         {
+          "title": "B",
           "type": "boolean"
         }
       ]
@@ -31,9 +33,11 @@
     "Or_for_null_and_string": {
       "anyOf": [
         {
+          "title": "A",
           "type": "null"
         },
         {
+          "title": "B",
           "type": "string"
         }
       ]