From 5220bdfe7f0b2c4789a7b51587994207cb163f6b Mon Sep 17 00:00:00 2001 From: Saif Modan <185626776+saifmodan2006@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:34:54 +0530 Subject: [PATCH] fix(marshmallow): serialize OneOf and Equal validator choices to OpenAPI enum (fixes #679) --- .../ext/marshmallow/field_converter.py | 40 ++++++++++++---- tests/test_ext_marshmallow_field.py | 46 +++++++++++++++++++ 2 files changed, 77 insertions(+), 9 deletions(-) diff --git a/src/apispec/ext/marshmallow/field_converter.py b/src/apispec/ext/marshmallow/field_converter.py index 6fd76a63..a191b33d 100644 --- a/src/apispec/ext/marshmallow/field_converter.py +++ b/src/apispec/ext/marshmallow/field_converter.py @@ -232,6 +232,21 @@ def field2default( ret["default"] = default return ret + def _serialize_choice( + self, field: marshmallow.fields.Field, choice: typing.Any + ) -> typing.Any: + if choice is None: + return None + target_field = ( + field.inner if isinstance(field, marshmallow.fields.List) else field + ) + try: + val = target_field._serialize(choice, None, None) + hash(val) + return val + except Exception: + return choice + def field2choices( self, field: marshmallow.fields.Field, **kwargs: typing.Any ) -> dict: @@ -245,9 +260,15 @@ def field2choices( choices = [] for validator in field.validators: if hasattr(validator, "choices"): - choices.append(OrderedSet(validator.choices)) + choices.append( + OrderedSet( + self._serialize_choice(field, c) for c in validator.choices + ) + ) elif hasattr(validator, "comparable"): - choices.append(OrderedSet([validator.comparable])) + choices.append( + OrderedSet([self._serialize_choice(field, validator.comparable)]) + ) if choices: attributes["enum"] = list(functools.reduce(operator.and_, choices)) @@ -550,13 +571,14 @@ def enum2properties(self, field, **kwargs: typing.Any) -> dict: ret = {} if isinstance(field, marshmallow.fields.Enum): ret = self.field2property(field.field) - if field.by_value is False: - choices = (m for m in field.enum.__members__) - else: - choices = (m.value for m in field.enum) - ret["enum"] = [field.field._serialize(v, None, None) for v in choices] - if field.allow_none and None not in ret["enum"]: - ret["enum"].append(None) + if "enum" not in kwargs.get("ret", {}): + if field.by_value is False: + choices = (m for m in field.enum.__members__) + else: + choices = (m.value for m in field.enum) + ret["enum"] = [field.field._serialize(v, None, None) for v in choices] + if field.allow_none and None not in ret["enum"]: + ret["enum"].append(None) return ret def datetime2properties(self, field, **kwargs: typing.Any) -> dict: diff --git a/tests/test_ext_marshmallow_field.py b/tests/test_ext_marshmallow_field.py index eddb89e9..12827190 100644 --- a/tests/test_ext_marshmallow_field.py +++ b/tests/test_ext_marshmallow_field.py @@ -1,6 +1,7 @@ import datetime as dt import importlib.metadata import re +import uuid from enum import Enum import pytest @@ -19,6 +20,51 @@ def test_field2choices_preserving_order(openapi): assert openapi.field2choices(field) == {"enum": choices} +def test_field2choices_serializes_choices(openapi): + date_choices = [dt.date(2023, 1, 1), dt.date(2023, 1, 2)] + field = fields.Date(validate=validate.OneOf(date_choices)) + assert openapi.field2choices(field) == {"enum": ["2023-01-01", "2023-01-02"]} + + dtime_choices = [dt.datetime(2023, 1, 1, 12, 0)] + field = fields.DateTime(validate=validate.OneOf(dtime_choices)) + assert openapi.field2choices(field) == {"enum": ["2023-01-01T12:00:00"]} + + u = uuid.uuid4() + field = fields.UUID(validate=validate.OneOf([u])) + assert openapi.field2choices(field) == {"enum": [str(u)]} + + field = fields.Date(validate=validate.Equal(dt.date(2023, 1, 1))) + assert openapi.field2choices(field) == {"enum": ["2023-01-01"]} + + +def test_field2choices_with_labels(openapi): + field = fields.Integer(validate=validate.OneOf([1, 2], labels=["one", "two"])) + assert openapi.field2choices(field) == {"enum": [1, 2]} + + +def test_enum_field_with_oneof_validator(spec_fixture): + class MyEnum(Enum): + one = 1 + two = 2 + three = 3 + + field = fields.Enum(MyEnum, validate=validate.OneOf([MyEnum.one, MyEnum.two])) + ret = spec_fixture.openapi.field2property(field) + assert ret["type"] == "string" + assert ret["enum"] == ["one", "two"] + + field_val = fields.Enum( + MyEnum, by_value=True, validate=validate.OneOf([MyEnum.one, MyEnum.two]) + ) + ret_val = spec_fixture.openapi.field2property(field_val) + assert ret_val["enum"] == [1, 2] + + field_eq = fields.Enum(MyEnum, validate=validate.Equal(MyEnum.two)) + ret_eq = spec_fixture.openapi.field2property(field_eq) + assert ret_eq["type"] == "string" + assert ret_eq["enum"] == ["two"] + + @pytest.mark.parametrize( ("FieldClass", "jsontype"), [