Skip to content

Commit f23d200

Browse files
committed
feat(marshmallow): support Tuple field in OpenAPI 3.1+ (closes #399)
1 parent 0b66a26 commit f23d200

6 files changed

Lines changed: 268 additions & 4 deletions

File tree

‎src/apispec/core.py‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -350,9 +350,18 @@ def _resolve_refs_in_schema(self, schema: dict) -> None:
350350
"schema", schema["properties"][key]
351351
)
352352
self._resolve_refs_in_schema(schema["properties"][key])
353-
if "items" in schema:
353+
if "items" in schema and isinstance(schema["items"], (dict, str)):
354354
schema["items"] = self.get_ref("schema", schema["items"])
355-
self._resolve_refs_in_schema(schema["items"])
355+
if isinstance(schema["items"], dict):
356+
self._resolve_refs_in_schema(schema["items"])
357+
if "prefixItems" in schema:
358+
schema["prefixItems"] = [
359+
self.get_ref("schema", s) if isinstance(s, (dict, str)) else s
360+
for s in schema["prefixItems"]
361+
]
362+
for sch in schema["prefixItems"]:
363+
if isinstance(sch, dict):
364+
self._resolve_refs_in_schema(sch)
356365
for key in ("allOf", "oneOf", "anyOf"):
357366
if key in schema:
358367
schema[key] = [self.get_ref("schema", s) for s in schema[key]]

‎src/apispec/ext/marshmallow/field_converter.py‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
from marshmallow.orderedset import OrderedSet
2020
from packaging.version import Version
2121

22+
from apispec.exceptions import APISpecError
23+
2224
# marshmallow field => (JSON Schema type, format)
2325
DEFAULT_FIELD_MAPPING: dict[type, tuple[str | None, str | None]] = {
2426
marshmallow.fields.Integer: ("integer", None),
@@ -38,6 +40,7 @@
3840
marshmallow.fields.Field: (None, None),
3941
marshmallow.fields.Raw: (None, None),
4042
marshmallow.fields.List: ("array", None),
43+
marshmallow.fields.Tuple: ("array", None),
4144
marshmallow.fields.IP: ("string", "ip"),
4245
marshmallow.fields.IPv4: ("string", "ipv4"),
4346
marshmallow.fields.IPv6: ("string", "ipv6"),
@@ -69,6 +72,7 @@
6972
"enum",
7073
"type",
7174
"items",
75+
"prefixItems",
7276
"allOf",
7377
"oneOf",
7478
"anyOf",
@@ -112,6 +116,7 @@ def init_attribute_functions(self):
112116
self.nested2properties,
113117
self.pluck2properties,
114118
self.list2properties,
119+
self.tuple2properties,
115120
self.dict2properties,
116121
self.timedelta2properties,
117122
self.datetime2properties,
@@ -365,7 +370,12 @@ def field2length(
365370
]
366371

367372
is_array = isinstance(
368-
field, (marshmallow.fields.Nested, marshmallow.fields.List)
373+
field,
374+
(
375+
marshmallow.fields.Nested,
376+
marshmallow.fields.List,
377+
marshmallow.fields.Tuple,
378+
),
369379
)
370380
min_attr = "minItems" if is_array else "minLength"
371381
max_attr = "maxItems" if is_array else "maxLength"
@@ -503,6 +513,27 @@ def list2properties(self, field, **kwargs: typing.Any) -> dict:
503513
ret["items"] = self.field2property(field.inner)
504514
return ret
505515

516+
def tuple2properties(
517+
self, field: marshmallow.fields.Field, **kwargs: typing.Any
518+
) -> dict:
519+
"""Return a dictionary of properties from :class:`Tuple <marshmallow.fields.Tuple>` fields.
520+
521+
Will provide `prefixItems` and `items: False` properties based on the field's `tuple_fields` attribute.
522+
523+
:param Field field: A marshmallow field.
524+
:rtype: dict
525+
"""
526+
ret: dict[str, typing.Any] = {}
527+
if isinstance(field, marshmallow.fields.Tuple):
528+
if (self.openapi_version.major, self.openapi_version.minor) < (3, 1):
529+
raise APISpecError(
530+
f"Tuple field is only supported in OpenAPI 3.1.0 and above. "
531+
f"Current OpenAPI version is {self.openapi_version}."
532+
)
533+
ret["prefixItems"] = [self.field2property(f) for f in field.tuple_fields]
534+
ret["items"] = False
535+
return ret
536+
506537
def dict2properties(self, field, **kwargs: typing.Any) -> dict:
507538
"""Return a dictionary of properties from :class:`Dict <marshmallow.fields.Dict>` fields.
508539

‎src/apispec/ext/marshmallow/schema_resolver.py‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,8 +282,17 @@ def resolve_schema_dict(self, schema):
282282
:param string|Schema|dict schema: the schema to resolve.
283283
"""
284284
if isinstance(schema, dict):
285-
if schema.get("type") == "array" and "items" in schema:
285+
if (
286+
schema.get("type") == "array"
287+
and "items" in schema
288+
and not isinstance(schema["items"], bool)
289+
):
286290
schema["items"] = self.resolve_schema_dict(schema["items"])
291+
if "prefixItems" in schema:
292+
schema["prefixItems"] = [
293+
self.resolve_schema_dict(s) if not isinstance(s, bool) else s
294+
for s in schema["prefixItems"]
295+
]
287296
if schema.get("type") == "object" and "properties" in schema:
288297
schema["properties"] = {
289298
k: self.resolve_schema_dict(v)

‎tests/test_core.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,23 @@ def test_components_resolve_refs_in_schema(self, spec):
404404
spec.components.schema("refs_schema", copy.deepcopy(self.REFS_SCHEMA))
405405
self.assert_schema_refs(spec, get_schemas(spec)["refs_schema"])
406406

407+
@pytest.mark.parametrize("spec", ("3.1.0",), indirect=True)
408+
def test_components_resolve_refs_in_schema_openapi_3_1_tuple(self, spec):
409+
spec.components.schema(
410+
"tuple_schema",
411+
{
412+
"type": "array",
413+
"prefixItems": ["NestedSchema", {"type": "string"}],
414+
"items": False,
415+
},
416+
)
417+
schema = get_schemas(spec)["tuple_schema"]
418+
assert schema["items"] is False
419+
assert schema["prefixItems"] == [
420+
build_ref(spec, "schema", "NestedSchema"),
421+
{"type": "string"},
422+
]
423+
407424
def test_components_resolve_response_schema(self, spec):
408425
schema = {"schema": "PetSchema"}
409426
if spec.openapi_version.major >= 3:

‎tests/test_ext_marshmallow.py‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@
77
DateTime,
88
Dict,
99
Field,
10+
Float,
1011
Int,
1112
List,
1213
Nested,
1314
String,
1415
TimeDelta,
16+
Tuple,
1517
)
1618
from packaging.version import Version
1719

@@ -36,6 +38,7 @@
3638
get_paths,
3739
get_responses,
3840
get_schemas,
41+
validate_spec,
3942
)
4043

4144
MA_VERSION = Version(importlib.metadata.version("marshmallow"))
@@ -1369,3 +1372,94 @@ class SchemaWithTimeDelta(Schema):
13691372
get_schemas(spec)["SchemaWithTimeDelta"]["properties"]["day"]["x-unit"]
13701373
== "days"
13711374
)
1375+
1376+
1377+
class TestTuple:
1378+
@pytest.mark.parametrize("openapi_version", ("2.0", "3.0.0"))
1379+
def test_tuple_unsupported_openapi_versions(self, openapi_version):
1380+
class SchemaWithTuple(Schema):
1381+
point = Tuple([Float(), Float()])
1382+
1383+
spec = APISpec(
1384+
title="Tuple Test",
1385+
version="0.1",
1386+
openapi_version=openapi_version,
1387+
plugins=(MarshmallowPlugin(),),
1388+
)
1389+
with pytest.raises(
1390+
APISpecError,
1391+
match="Tuple field is only supported in OpenAPI 3.1.0 and above.",
1392+
):
1393+
spec.components.schema("SchemaWithTuple", schema=SchemaWithTuple)
1394+
1395+
def test_tuple_in_schema_openapi_3_1(self):
1396+
class SchemaWithTuple(Schema):
1397+
point = Tuple([Float(), Float()])
1398+
1399+
spec = APISpec(
1400+
title="Tuple Test",
1401+
version="0.1",
1402+
openapi_version="3.1.0",
1403+
plugins=(MarshmallowPlugin(),),
1404+
)
1405+
spec.components.schema("SchemaWithTuple", schema=SchemaWithTuple)
1406+
1407+
result = get_schemas(spec)["SchemaWithTuple"]["properties"]["point"]
1408+
assert result == {
1409+
"type": "array",
1410+
"prefixItems": [{"type": "number"}, {"type": "number"}],
1411+
"items": False,
1412+
}
1413+
assert validate_spec(spec)
1414+
1415+
def test_tuple_with_nested_schema_openapi_3_1(self):
1416+
class SchemaWithNestedTuple(Schema):
1417+
pair = Tuple([Nested(PetSchema), String()])
1418+
1419+
spec = APISpec(
1420+
title="Nested Tuple Test",
1421+
version="0.1",
1422+
openapi_version="3.1.0",
1423+
plugins=(MarshmallowPlugin(),),
1424+
)
1425+
spec.components.schema("SchemaWithNestedTuple", schema=SchemaWithNestedTuple)
1426+
1427+
assert len(get_schemas(spec)) == 2
1428+
result = get_schemas(spec)["SchemaWithNestedTuple"]["properties"]["pair"]
1429+
assert result == {
1430+
"type": "array",
1431+
"prefixItems": [build_ref(spec, "schema", "Pet"), {"type": "string"}],
1432+
"items": False,
1433+
}
1434+
assert validate_spec(spec)
1435+
1436+
def test_tuple_in_operation_openapi_3_1(self):
1437+
class CoordSchema(Schema):
1438+
coords = Tuple([Int(), String()])
1439+
1440+
spec = APISpec(
1441+
title="Operation Tuple Test",
1442+
version="0.1",
1443+
openapi_version="3.1.0",
1444+
plugins=(MarshmallowPlugin(),),
1445+
)
1446+
spec.path(
1447+
"/coords",
1448+
operations={
1449+
"get": {
1450+
"responses": {
1451+
"200": {
1452+
"description": "Coordinates response",
1453+
"content": {"application/json": {"schema": CoordSchema}},
1454+
}
1455+
}
1456+
}
1457+
},
1458+
)
1459+
schema = get_schemas(spec)["Coord"]
1460+
assert schema["properties"]["coords"] == {
1461+
"type": "array",
1462+
"prefixItems": [{"type": "integer"}, {"type": "string"}],
1463+
"items": False,
1464+
}
1465+
assert validate_spec(spec)

‎tests/test_ext_marshmallow_field.py‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
from marshmallow import Schema, fields, validate
88
from packaging.version import Version
99

10+
from apispec.exceptions import APISpecError
11+
1012
from .schemas import CategorySchema, CustomIntegerField, CustomList, CustomStringField
1113
from .utils import build_ref, get_schemas
1214

@@ -779,3 +781,105 @@ def test_field2property_examples(spec_fixture):
779781
assert res == {
780782
"examples": ["foo", "bar"],
781783
}
784+
785+
786+
class TestField2PropertyTuple:
787+
@pytest.mark.parametrize("spec_fixture", ("2.0", "3.0.0"), indirect=True)
788+
def test_tuple_unsupported_openapi_versions(self, spec_fixture):
789+
field = fields.Tuple([fields.Integer(), fields.String()])
790+
with pytest.raises(
791+
APISpecError,
792+
match="Tuple field is only supported in OpenAPI 3.1.0 and above.",
793+
):
794+
spec_fixture.openapi.field2property(field)
795+
796+
@pytest.mark.parametrize("spec_fixture", ("3.1.0",), indirect=True)
797+
def test_tuple_simple(self, spec_fixture):
798+
field = fields.Tuple([fields.Integer(), fields.String()])
799+
res = spec_fixture.openapi.field2property(field)
800+
assert res == {
801+
"type": "array",
802+
"prefixItems": [{"type": "integer"}, {"type": "string"}],
803+
"items": False,
804+
}
805+
806+
@pytest.mark.parametrize("spec_fixture", ("3.1.0",), indirect=True)
807+
def test_tuple_empty(self, spec_fixture):
808+
field = fields.Tuple([])
809+
res = spec_fixture.openapi.field2property(field)
810+
assert res == {
811+
"type": "array",
812+
"prefixItems": [],
813+
"items": False,
814+
}
815+
816+
@pytest.mark.parametrize("spec_fixture", ("3.1.0",), indirect=True)
817+
def test_tuple_nullable(self, spec_fixture):
818+
field = fields.Tuple([fields.Integer()], allow_none=True)
819+
res = spec_fixture.openapi.field2property(field)
820+
assert res == {
821+
"type": ["array", "null"],
822+
"prefixItems": [{"type": "integer"}],
823+
"items": False,
824+
}
825+
826+
@pytest.mark.parametrize("spec_fixture", ("3.1.0",), indirect=True)
827+
def test_tuple_length_validator(self, spec_fixture):
828+
field = fields.Tuple(
829+
[fields.Integer(), fields.String()],
830+
validate=validate.Length(equal=2),
831+
)
832+
res = spec_fixture.openapi.field2property(field)
833+
assert res == {
834+
"type": "array",
835+
"prefixItems": [{"type": "integer"}, {"type": "string"}],
836+
"items": False,
837+
"minItems": 2,
838+
"maxItems": 2,
839+
}
840+
841+
@pytest.mark.parametrize("spec_fixture", ("3.1.0",), indirect=True)
842+
def test_tuple_dump_only_and_load_only(self, spec_fixture):
843+
dump_field = fields.Tuple([fields.Integer()], dump_only=True)
844+
assert spec_fixture.openapi.field2property(dump_field) == {
845+
"type": "array",
846+
"prefixItems": [{"type": "integer"}],
847+
"items": False,
848+
"readOnly": True,
849+
}
850+
851+
load_field = fields.Tuple([fields.Integer()], load_only=True)
852+
assert spec_fixture.openapi.field2property(load_field) == {
853+
"type": "array",
854+
"prefixItems": [{"type": "integer"}],
855+
"items": False,
856+
"writeOnly": True,
857+
}
858+
859+
@pytest.mark.parametrize("spec_fixture", ("3.1.0",), indirect=True)
860+
def test_tuple_metadata(self, spec_fixture):
861+
field = fields.Tuple(
862+
[fields.Integer()],
863+
metadata={"description": "Tuple of coordinates"},
864+
)
865+
assert spec_fixture.openapi.field2property(field) == {
866+
"type": "array",
867+
"prefixItems": [{"type": "integer"}],
868+
"items": False,
869+
"description": "Tuple of coordinates",
870+
}
871+
872+
@pytest.mark.parametrize("spec_fixture", ("3.1.0",), indirect=True)
873+
def test_tuple_nested(self, spec_fixture):
874+
field = fields.Tuple([fields.Tuple([fields.Integer()])])
875+
assert spec_fixture.openapi.field2property(field) == {
876+
"type": "array",
877+
"prefixItems": [
878+
{
879+
"type": "array",
880+
"prefixItems": [{"type": "integer"}],
881+
"items": False,
882+
}
883+
],
884+
"items": False,
885+
}

0 commit comments

Comments
 (0)