Skip to content

_unstable_ref_variants and naming #509

@bjchambers

Description

@bjchambers

I was having trouble getting named variants in code generated from my schema, and found the _unstable_ref_variants which fixed it (in combination with a transform I added to add a discriminator for every enum (see below). But, it I'm somewhat concerned that it uses the variant for the name -- it seems like this should be an option, or it should default to the Enum + Variant as a name. The rationale being it could be common to have an Error and Success variant in a variety of enums. If the name is just the variant, then we get a collision and an invalid schema.

enum A {
  MyCase { ... }
}
enum B {
  MyCase { ... }
}

It is still ok for serde to use MyCase as the tag in both cases, but the generated schema needs a unique name like A/MyCase or AMyCase.

In terms of adding the discriminators, I'm using something like the following right now:

/// A schemars [`Transform`](schemars::transform::Transform) that adds an OpenAPI
/// `discriminator` object to `oneOf` schemas generated from `#[serde(tag = "...")]` enums.
///
/// # Usage
///
/// ```ignore
/// #[derive(schemars::JsonSchema, serde::Serialize, serde::Deserialize)]
/// #[serde(tag = "type", rename_all = "camelCase")]
/// #[schemars(transform = AddDiscriminator::new("type"))]
/// enum MyEnum {
///     #[schemars(title = "VariantA")]
///     VariantA,
///     #[schemars(title = "VariantB")]
///     VariantB { value: String },
/// }
/// ```
pub struct AddDiscriminator {
    property_name: String,
}

impl AddDiscriminator {
    /// Create a new `AddDiscriminator` transform for the given tag property name.
    ///
    /// The `property_name` should match the `tag` value in `#[serde(tag = "...")]`.
    pub fn new(property_name: impl Into<String>) -> Self {
        Self {
            property_name: property_name.into(),
        }
    }

    /// Extract the discriminator tag's `const` value from a oneOf variant schema.
    ///
    /// Handles both inline schemas (direct `properties`) and `allOf` patterns
    /// (where a `$ref` is combined with discriminator properties).
    fn find_tag_const<'a>(variant: &'a Value, tag_property: &str) -> Option<&'a str> {
        // Check direct properties (inline variant)
        if let Some(val) = variant
            .get("properties")
            .and_then(|p| p.get(tag_property))
            .and_then(|p| p.get("const"))
            .and_then(|c| c.as_str())
        {
            return Some(val);
        }

        // Check allOf items
        if let Some(all_of) = variant.get("allOf").and_then(|v| v.as_array()) {
            for item in all_of {
                if let Some(val) = item
                    .get("properties")
                    .and_then(|p| p.get(tag_property))
                    .and_then(|p| p.get("const"))
                    .and_then(|c| c.as_str())
                {
                    return Some(val);
                }
            }
        }

        None
    }

    /// Extract a `$ref` path from a oneOf variant schema.
    fn find_ref(variant: &Value) -> Option<&str> {
        if let Some(ref_str) = variant.get("$ref").and_then(|r| r.as_str()) {
            return Some(ref_str);
        }

        if let Some(all_of) = variant.get("allOf").and_then(|v| v.as_array()) {
            for item in all_of {
                if let Some(ref_str) = item.get("$ref").and_then(|r| r.as_str()) {
                    return Some(ref_str);
                }
            }
        }

        None
    }
}

impl schemars::transform::Transform for AddDiscriminator {
    fn transform(&mut self, schema: &mut schemars::Schema) {
        let Some(obj) = schema.as_object_mut() else {
            return;
        };
        let Some(one_of) = obj.get("oneOf").and_then(|v| v.as_array()) else {
            return;
        };

        // Build mapping from discriminator values to $ref paths
        let mut mapping = Map::new();
        for variant in one_of {
            if let Some(tag_value) = Self::find_tag_const(variant, &self.property_name)
                && let Some(ref_path) = Self::find_ref(variant)
            {
                mapping.insert(tag_value.to_string(), Value::String(ref_path.to_string()));
            }
        }

        // Build the discriminator object
        let mut discriminator = Map::new();
        discriminator.insert(
            "propertyName".to_string(),
            Value::String(self.property_name.clone()),
        );
        if !mapping.is_empty() {
            discriminator.insert("mapping".to_string(), Value::Object(mapping));
        }

        obj.insert("discriminator".to_string(), Value::Object(discriminator));
    }
}

(with some tests to make sure it works as expected). I guess I could modify my transform to also rename all of the extracted variants, but that seems a bit excessive. I'm wondering if there is a case for going the other way -- supporting add the discriminator as an option #[schemars(discriminator)] and adding a #[schemars(variant_def = "name")] option to variants (and/or defaulting to including the enum name in the variant definition name).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions