Repository: GREsau/schemars (rust)

Issue:
Support individually untagged enum values
Serde allows to add `#[serde(untagged)]` to individual enum variants.

See here for an example of the working serde code: https://play.rust-lang.org/?version=stable&mode=debug&edition=2024&gist=75fc42b60b77cca8eeeb8fab53a3f78c

If I add schemars to this, the generated schema is wrong:

```rust
#![allow(dead_code)]

use serde_derive::{Serialize, Deserialize};
use schemars::{schema_for, JsonSchema};

#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "type")]
enum StructuralSubElement {
    Title,
    Subtitle,
    Decoration,
    Docinfo,
    #[serde(untagged)]
    SubStructure(SubStructure),
}

#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "type")]
enum SubStructure {
    Topic, Sidebar, Transition, Section, BodyElement
}

fn main() {
	let schema = schema_for!(StructuralSubElement);
	println!("{}", serde_json::to_string_pretty(&schema).unwrap());
}
```

The resulting schema has this wrong `"const": "SubStructure"` constraint:

```json
{
	"StructuralSubElement": {
		"oneOf": [
			...,
			{
				"type": "object",
				"properties": {
					"type": { "type": "string", "const": "SubStructure" }
				},
				"$ref": "#/$defs/SubStructure",
				"required": ["type"]
			}
		]
	}
}
```

Maybe related: #311

Pull request:
Support untagged variants of tagged enums
Fixes #388, supersedes #389

This PR adds support for variant-level `untagged` attribute - see https://serde.rs/variant-attrs.html#untagged

Patch:
diff --git a/docs/_includes/attributes.md b/docs/_includes/attributes.md
index 406ae80..41c5721 100644
--- a/docs/_includes/attributes.md
+++ b/docs/_includes/attributes.md
@@ -90,7 +90,9 @@ Serde docs: [container](https://serde.rs/container-attrs.html#rename_all)
 
 Set on an enum to generate the schema for the [internally tagged](https://serde.rs/enum-representations.html#internally-tagged), [adjacently tagged](https://serde.rs/enum-representations.html#adjacently-tagged), or [untagged](https://serde.rs/enum-representations.html#untagged) representation of this enum.
 
-Serde docs: [`tag`](https://serde.rs/container-attrs.html#tag) / [`tag`+`content`](https://serde.rs/container-attrs.html#tag--content) / [`untagged`](https://serde.rs/container-attrs.html#untagged)
+`#[serde(untagged)]`/`#[schemars(untagged)]` can also be set on an individual variant of a tagged enum to treat just that variant as untagged.
+
+Serde docs: [`tag`](https://serde.rs/container-attrs.html#tag) / [`tag`+`content`](https://serde.rs/container-attrs.html#tag--content) / [`untagged` (enum)](https://serde.rs/container-attrs.html#untagged) / [`untagged` (variant)](https://serde.rs/variant-attrs.html#untagged)
 
 <h3 id="default">
 
diff --git a/schemars/examples/serde_attrs.rs b/schemars/examples/serde_attrs.rs
index b8d0fb4..2586a94 100644
--- a/schemars/examples/serde_attrs.rs
+++ b/schemars/examples/serde_attrs.rs
@@ -12,13 +12,20 @@ pub struct MyStruct {
 }
 
 #[derive(Deserialize, Serialize, JsonSchema)]
-#[serde(untagged)]
 pub enum MyEnum {
     StringNewType(String),
-    StructVariant { floats: Vec<f32> },
+    Unit1,
+    #[serde(untagged)]
+    StructVariant {
+        floats: Vec<f32>,
+    },
+    #[serde(untagged)]
+    Unit2,
 }
 
 fn main() {
     let schema = schema_for!(MyStruct);
     println!("{}", serde_json::to_string_pretty(&schema).unwrap());
+
+    // println!("{}", serde_json::to_string_pretty(&MyEnum::Unit1).unwrap());
 }
diff --git a/schemars_derive/src/schema_exprs.rs b/schemars_derive/src/schema_exprs.rs
index 166a65d..8f0c187 100644
--- a/schemars_derive/src/schema_exprs.rs
+++ b/schemars_derive/src/schema_exprs.rs
@@ -207,7 +207,7 @@ fn expr_for_external_tagged_enum<'a>(
     deny_unknown_fields: bool,
 ) -> SchemaExpr {
     let (unit_variants, complex_variants): (Vec<_>, Vec<_>) =
-        variants.partition(|v| v.is_unit() && v.attrs.is_default());
+        variants.partition(|v| v.is_unit() && v.attrs.is_default() && !v.serde_attrs.untagged());
     let add_unit_names = unit_variants.iter().map(|v| {
         let name = v.name();
         v.with_contract_check(quote! {
@@ -238,6 +238,13 @@ fn expr_for_external_tagged_enum<'a>(
     }
 
     schemas.extend(complex_variants.into_iter().map(|variant| {
+        if variant.serde_attrs.untagged() {
+            return (
+                Some(variant),
+                expr_for_untagged_enum_variant(variant, deny_unknown_fields),
+            );
+        }
+
         let name = variant.name();
 
         let mut schema_expr =
@@ -267,6 +274,9 @@ fn expr_for_internal_tagged_enum<'a>(
 ) -> SchemaExpr {
     let variant_schemas = variants
         .map(|variant| {
+            if variant.serde_attrs.untagged() {
+                return (Some(variant), expr_for_untagged_enum_variant(variant, deny_unknown_fields))
+            }
 
             let mut schema_expr = expr_for_internal_tagged_enum_variant(variant, deny_unknown_fields);
 
@@ -311,6 +321,13 @@ fn expr_for_adjacent_tagged_enum<'a>(
 ) -> SchemaExpr {
     let schemas = variants
         .map(|variant| {
+            if variant.serde_attrs.untagged() {
+                return (
+                    Some(variant),
+                    expr_for_untagged_enum_variant(variant, deny_unknown_fields),
+                );
+            }
+
             let content_schema = if variant.is_unit() && variant.attrs.with.is_none() {
                 None
             } else {
@@ -368,7 +385,17 @@ fn expr_for_adjacent_tagged_enum<'a>(
 
 /// Callers must determine if all subschemas are mutually exclusive. The current behaviour is to
 /// assume that variants are mutually exclusive except for untagged enums.
-fn variant_subschemas(unique: bool, schemas: Vec<(Option<&Variant>, SchemaExpr)>) -> SchemaExpr {
+fn variant_subschemas(
+    mut unique: bool,
+    schemas: Vec<(Option<&Variant>, SchemaExpr)>,
+) -> SchemaExpr {
+    if schemas
+        .iter()
+        .any(|(v, _)| v.is_some_and(|v| v.serde_attrs.untagged()))
+    {
+        unique = false;
+    }
+
     let keyword = if unique { "oneOf" } else { "anyOf" };
     let add_schemas = schemas.into_iter().map(|(v, s)| {
         let add = quote! {


Test patch:
diff --git a/schemars/tests/integration/enums_untagged_variant.rs b/schemars/tests/integration/enums_untagged_variant.rs
new file mode 100644
index 0000000..c86bf7f
--- /dev/null
+++ b/schemars/tests/integration/enums_untagged_variant.rs
@@ -0,0 +1,198 @@
+use crate::prelude::*;
+use std::collections::BTreeMap;
+
+#[derive(JsonSchema, Deserialize, Serialize)]
+struct UnitStruct;
+
+#[derive(JsonSchema, Deserialize, Serialize, Default)]
+struct Struct {
+    foo: i32,
+    bar: bool,
+}
+
+#[derive(JsonSchema, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+enum External {
+    TaggedUnitOne,
+    TaggedStruct {
+        baz: i32,
+        foobar: bool,
+    },
+    #[serde(untagged)]
+    UnitOne,
+    #[serde(untagged)]
+    UnitStructNewType(UnitStruct),
+    #[serde(untagged)]
+    StructNewType(Struct),
+    #[serde(untagged)]
+    Struct {
+        baz: i32,
+        foobar: bool,
+    },
+    #[serde(untagged)]
+    Tuple(i32, bool),
+    #[serde(untagged)]
+    StringMap(BTreeMap<String, String>),
+}
+
+impl External {
+    fn values() -> impl IntoIterator<Item = Self> {
+        [
+            Self::TaggedUnitOne,
+            Self::TaggedStruct {
+                baz: 123,
+                foobar: true,
+            },
+            Self::UnitOne,
+            Self::StringMap(
+                [("hello".to_owned(), "world".to_owned())]
+                    .into_iter()
+                    .collect(),
+            ),
+            Self::UnitStructNewType(UnitStruct),
+            Self::StructNewType(Struct {
+                foo: 123,
+                bar: true,
+            }),
+            Self::Struct {
+                baz: 123,
+                foobar: true,
+            },
+            Self::Tuple(456, false),
+        ]
+    }
+}
+
+#[derive(JsonSchema, Deserialize, Serialize)]
+#[serde(tag = "tag")]
+enum Internal {
+    TaggedUnitOne,
+    TaggedStruct {
+        baz: i32,
+        foobar: bool,
+    },
+    #[serde(untagged)]
+    UnitOne,
+    #[serde(untagged)]
+    UnitStructNewType(UnitStruct),
+    #[serde(untagged)]
+    StructNewType(Struct),
+    #[serde(untagged)]
+    Struct {
+        baz: i32,
+        foobar: bool,
+    },
+    // Internally-tagged enums don't support tuple variants
+    // #[serde(untagged)]
+    // Tuple(i32, bool),
+    #[serde(untagged)]
+    StringMap(BTreeMap<String, String>),
+}
+
+impl Internal {
+    fn values() -> impl IntoIterator<Item = Self> {
+        [
+            Self::TaggedUnitOne,
+            Self::TaggedStruct {
+                baz: 123,
+                foobar: true,
+            },
+            Self::UnitOne,
+            Self::StringMap(
+                [("hello".to_owned(), "world".to_owned())]
+                    .into_iter()
+                    .collect(),
+            ),
+            Self::UnitStructNewType(UnitStruct),
+            Self::StructNewType(Struct {
+                foo: 123,
+                bar: true,
+            }),
+            Self::Struct {
+                baz: 123,
+                foobar: true,
+            },
+            // Self::Tuple(456, false),
+        ]
+    }
+}
+
+#[derive(JsonSchema, Deserialize, Serialize)]
+#[serde(tag = "tag", content = "content")]
+enum Adjacent {
+    TaggedUnitOne,
+    TaggedStruct {
+        baz: i32,
+        foobar: bool,
+    },
+    #[serde(untagged)]
+    UnitOne,
+    #[serde(untagged)]
+    UnitStructNewType(UnitStruct),
+    #[serde(untagged)]
+    StructNewType(Struct),
+    #[serde(untagged)]
+    Struct {
+        baz: i32,
+        foobar: bool,
+    },
+    #[serde(untagged)]
+    Tuple(i32, bool),
+    #[serde(untagged)]
+    StringMap(BTreeMap<String, String>),
+}
+
+impl Adjacent {
+    fn values() -> impl IntoIterator<Item = Self> {
+        [
+            Self::TaggedUnitOne,
+            Self::TaggedStruct {
+                baz: 123,
+                foobar: true,
+            },
+            Self::UnitOne,
+            Self::StringMap(
+                [("hello".to_owned(), "world".to_owned())]
+                    .into_iter()
+                    .collect(),
+            ),
+            Self::UnitStructNewType(UnitStruct),
+            Self::StructNewType(Struct {
+                foo: 123,
+                bar: true,
+            }),
+            Self::Struct {
+                baz: 123,
+                foobar: true,
+            },
+            Self::Tuple(456, false),
+        ]
+    }
+}
+
+#[test]
+fn externally_tagged_enum() {
+    test!(External)
+        .assert_snapshot()
+        .assert_allows_ser_roundtrip(External::values())
+        .assert_matches_de_roundtrip(arbitrary_values());
+}
+
+#[test]
+fn internally_tagged_enum() {
+    test!(Internal)
+        .assert_snapshot()
+        .assert_allows_ser_roundtrip(Internal::values())
+        .assert_matches_de_roundtrip(arbitrary_values_except(
+            Value::is_array,
+            "internally tagged enums can technically be deserialized from sequences, but that's not intended to be used via JSON, so schemars ignores it",
+        ));
+}
+
+#[test]
+fn adjacently_tagged_enum() {
+    test!(Adjacent)
+        .assert_snapshot()
+        .assert_allows_ser_roundtrip(Adjacent::values())
+        .assert_matches_de_roundtrip(arbitrary_values());
+}
diff --git a/schemars/tests/integration/main.rs b/schemars/tests/integration/main.rs
index 0cbacb9..27b36ca 100644
--- a/schemars/tests/integration/main.rs
+++ b/schemars/tests/integration/main.rs
@@ -20,6 +20,7 @@ mod enum_repr;
 mod enums;
 mod enums_deny_unknown_fields;
 mod enums_flattened;
+mod enums_untagged_variant;
 mod examples;
 mod extend;
 mod flatten;
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
new file mode 100644
index 0000000..1b31c01
--- /dev/null
+++ b/schemars/tests/integration/snapshots/schemars/tests/integration/enums_untagged_variant.rs~adjacently_tagged_enum.json
@@ -0,0 +1,113 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "title": "Adjacent",
+  "anyOf": [
+    {
+      "type": "object",
+      "properties": {
+        "tag": {
+          "type": "string",
+          "const": "TaggedUnitOne"
+        }
+      },
+      "required": [
+        "tag"
+      ]
+    },
+    {
+      "type": "object",
+      "properties": {
+        "tag": {
+          "type": "string",
+          "const": "TaggedStruct"
+        },
+        "content": {
+          "type": "object",
+          "properties": {
+            "baz": {
+              "type": "integer",
+              "format": "int32"
+            },
+            "foobar": {
+              "type": "boolean"
+            }
+          },
+          "required": [
+            "baz",
+            "foobar"
+          ]
+        }
+      },
+      "required": [
+        "tag",
+        "content"
+      ]
+    },
+    {
+      "type": "null"
+    },
+    {
+      "$ref": "#/$defs/UnitStruct"
+    },
+    {
+      "$ref": "#/$defs/Struct"
+    },
+    {
+      "type": "object",
+      "properties": {
+        "baz": {
+          "type": "integer",
+          "format": "int32"
+        },
+        "foobar": {
+          "type": "boolean"
+        }
+      },
+      "required": [
+        "baz",
+        "foobar"
+      ]
+    },
+    {
+      "type": "array",
+      "prefixItems": [
+        {
+          "type": "integer",
+          "format": "int32"
+        },
+        {
+          "type": "boolean"
+        }
+      ],
+      "minItems": 2,
+      "maxItems": 2
+    },
+    {
+      "type": "object",
+      "additionalProperties": {
+        "type": "string"
+      }
+    }
+  ],
+  "$defs": {
+    "UnitStruct": {
+      "type": "null"
+    },
+    "Struct": {
+      "type": "object",
+      "properties": {
+        "foo": {
+          "type": "integer",
+          "format": "int32"
+        },
+        "bar": {
+          "type": "boolean"
+        }
+      },
+      "required": [
+        "foo",
+        "bar"
+      ]
+    }
+  }
+}
\ No newline at end of file
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
new file mode 100644
index 0000000..47c1272
--- /dev/null
+++ b/schemars/tests/integration/snapshots/schemars/tests/integration/enums_untagged_variant.rs~externally_tagged_enum.json
@@ -0,0 +1,103 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "title": "External",
+  "anyOf": [
+    {
+      "type": "string",
+      "enum": [
+        "taggedUnitOne"
+      ]
+    },
+    {
+      "type": "object",
+      "properties": {
+        "taggedStruct": {
+          "type": "object",
+          "properties": {
+            "baz": {
+              "type": "integer",
+              "format": "int32"
+            },
+            "foobar": {
+              "type": "boolean"
+            }
+          },
+          "required": [
+            "baz",
+            "foobar"
+          ]
+        }
+      },
+      "required": [
+        "taggedStruct"
+      ],
+      "additionalProperties": false
+    },
+    {
+      "type": "null"
+    },
+    {
+      "$ref": "#/$defs/UnitStruct"
+    },
+    {
+      "$ref": "#/$defs/Struct"
+    },
+    {
+      "type": "object",
+      "properties": {
+        "baz": {
+          "type": "integer",
+          "format": "int32"
+        },
+        "foobar": {
+          "type": "boolean"
+        }
+      },
+      "required": [
+        "baz",
+        "foobar"
+      ]
+    },
+    {
+      "type": "array",
+      "prefixItems": [
+        {
+          "type": "integer",
+          "format": "int32"
+        },
+        {
+          "type": "boolean"
+        }
+      ],
+      "minItems": 2,
+      "maxItems": 2
+    },
+    {
+      "type": "object",
+      "additionalProperties": {
+        "type": "string"
+      }
+    }
+  ],
+  "$defs": {
+    "UnitStruct": {
+      "type": "null"
+    },
+    "Struct": {
+      "type": "object",
+      "properties": {
+        "foo": {
+          "type": "integer",
+          "format": "int32"
+        },
+        "bar": {
+          "type": "boolean"
+        }
+      },
+      "required": [
+        "foo",
+        "bar"
+      ]
+    }
+  }
+}
\ No newline at end of file
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
new file mode 100644
index 0000000..5733d75
--- /dev/null
+++ b/schemars/tests/integration/snapshots/schemars/tests/integration/enums_untagged_variant.rs~internally_tagged_enum.json
@@ -0,0 +1,91 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "title": "Internal",
+  "anyOf": [
+    {
+      "type": "object",
+      "properties": {
+        "tag": {
+          "type": "string",
+          "const": "TaggedUnitOne"
+        }
+      },
+      "required": [
+        "tag"
+      ]
+    },
+    {
+      "type": "object",
+      "properties": {
+        "baz": {
+          "type": "integer",
+          "format": "int32"
+        },
+        "foobar": {
+          "type": "boolean"
+        },
+        "tag": {
+          "type": "string",
+          "const": "TaggedStruct"
+        }
+      },
+      "required": [
+        "tag",
+        "baz",
+        "foobar"
+      ]
+    },
+    {
+      "type": "null"
+    },
+    {
+      "$ref": "#/$defs/UnitStruct"
+    },
+    {
+      "$ref": "#/$defs/Struct"
+    },
+    {
+      "type": "object",
+      "properties": {
+        "baz": {
+          "type": "integer",
+          "format": "int32"
+        },
+        "foobar": {
+          "type": "boolean"
+        }
+      },
+      "required": [
+        "baz",
+        "foobar"
+      ]
+    },
+    {
+      "type": "object",
+      "additionalProperties": {
+        "type": "string"
+      }
+    }
+  ],
+  "$defs": {
+    "UnitStruct": {
+      "type": "null"
+    },
+    "Struct": {
+      "type": "object",
+      "properties": {
+        "foo": {
+          "type": "integer",
+          "format": "int32"
+        },
+        "bar": {
+          "type": "boolean"
+        }
+      },
+      "required": [
+        "foo",
+        "bar"
+      ]
+    }
+  }
+}
\ No newline at end of file