From 60f9a96f3867e14adbba7f4829aa68ff90800271 Mon Sep 17 00:00:00 2001 From: nomi3 Date: Tue, 17 Jun 2025 18:14:43 +0900 Subject: [PATCH 1/3] feat: support array-root requestBody via list_as_array flag --- AUTHORS.rst | 1 + src/apispec/ext/marshmallow/__init__.py | 3 ++ src/apispec/ext/marshmallow/openapi.py | 26 +++++++++++++++ tests/test_array_requestbody.py | 42 +++++++++++++++++++++++++ 4 files changed, 72 insertions(+) create mode 100644 tests/test_array_requestbody.py diff --git a/AUTHORS.rst b/AUTHORS.rst index 22ce7b30..32070583 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -87,3 +87,4 @@ Contributors (chronological) - Robert Shepley `@ShepleySound `_ - Robin `@allrob23 `_ - Xingang Zhang `@0x0400 `_ +- nomi3 `@nomi3 `_ \ No newline at end of file diff --git a/src/apispec/ext/marshmallow/__init__.py b/src/apispec/ext/marshmallow/__init__.py index d4f1a542..0f514aa1 100644 --- a/src/apispec/ext/marshmallow/__init__.py +++ b/src/apispec/ext/marshmallow/__init__.py @@ -120,6 +120,7 @@ def schema_name_resolver(schema): def __init__( self, schema_name_resolver: typing.Callable[[type[Schema]], str] | None = None, + list_as_array: bool = False, ) -> None: super().__init__() self.schema_name_resolver = schema_name_resolver or resolver @@ -127,6 +128,7 @@ def __init__( self.openapi_version: Version | None = None self.converter: OpenAPIConverter | None = None self.resolver: SchemaResolver | None = None + self.list_as_array = list_as_array def init_spec(self, spec: APISpec) -> None: super().init_spec(spec) @@ -136,6 +138,7 @@ def init_spec(self, spec: APISpec) -> None: openapi_version=spec.openapi_version, schema_name_resolver=self.schema_name_resolver, spec=spec, + list_as_array=self.list_as_array, ) self.resolver = self.Resolver( openapi_version=spec.openapi_version, converter=self.converter diff --git a/src/apispec/ext/marshmallow/openapi.py b/src/apispec/ext/marshmallow/openapi.py index 00b47d5c..536bdd69 100644 --- a/src/apispec/ext/marshmallow/openapi.py +++ b/src/apispec/ext/marshmallow/openapi.py @@ -56,6 +56,7 @@ def __init__( openapi_version: Version | str, schema_name_resolver, spec: APISpec, + list_as_array: bool = False, ) -> None: self.openapi_version = ( Version(openapi_version) @@ -66,6 +67,7 @@ def __init__( self.spec = spec self.init_attribute_functions() self.init_parameter_attribute_functions() + self.list_as_array = list_as_array # Schema references self.refs: dict = {} @@ -108,6 +110,30 @@ def resolve_nested_schema(self, schema): :param schema: schema to add to the spec """ + if isinstance(schema, marshmallow.fields.List) and getattr( + self, "list_as_array", False + ): + return { + "type": "array", + "items": self.resolve_nested_schema(schema.inner), + } + + if getattr(self, "list_as_array", False): + if isinstance(schema, marshmallow.Schema): + field_dict = schema.fields + elif isinstance(schema, type) and issubclass(schema, marshmallow.Schema): + field_dict = schema._declared_fields + else: + field_dict = None + + if field_dict is not None and len(field_dict) == 1: + fld = next(iter(field_dict.values())) + if isinstance(fld, marshmallow.fields.List) and fld.data_key is None: + return { + "type": "array", + "items": self.resolve_nested_schema(fld.inner), + } + try: schema_instance = resolve_schema_instance(schema) # If schema is a string and is not found in registry, diff --git a/tests/test_array_requestbody.py b/tests/test_array_requestbody.py new file mode 100644 index 00000000..f5cf20bc --- /dev/null +++ b/tests/test_array_requestbody.py @@ -0,0 +1,42 @@ +from marshmallow import Schema, fields +from apispec import APISpec +from apispec.ext.marshmallow import MarshmallowPlugin + +class UUIDListSchema(Schema): + uuids = fields.List(fields.UUID(), data_key=None) + +def _build_schema(list_as_array: bool): + plugin = MarshmallowPlugin(list_as_array=list_as_array) + spec = APISpec( + title="Test", + version="1.0.0", + openapi_version="3.0.2", + plugins=[plugin], + ) + spec.path( + path="/list", + operations={ + "post": { + "requestBody": { + "content": {"application/json": {"schema": UUIDListSchema()}} + }, + "responses": {"200": {}}, + } + }, + ) + schema = spec.to_dict()["paths"]["/list"]["post"]["requestBody"]["content"]["application/json"]["schema"] + + if "$ref" in schema: + ref_name = schema["$ref"].split("/")[-1] + schema = spec.to_dict()["components"]["schemas"][ref_name] + + return schema + +def test_default_is_object(): + schema = _build_schema(list_as_array=False) + assert schema["type"] == "object" + +def test_list_as_array_flag(): + schema = _build_schema(list_as_array=True) + assert schema["type"] == "array" + assert "items" in schema From 4582cc497be98e636d95b581f23900a6f22911a3 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 17 Jun 2025 09:25:25 +0000 Subject: [PATCH 2/3] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_array_requestbody.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/test_array_requestbody.py b/tests/test_array_requestbody.py index f5cf20bc..e7fbf186 100644 --- a/tests/test_array_requestbody.py +++ b/tests/test_array_requestbody.py @@ -1,10 +1,13 @@ from marshmallow import Schema, fields + from apispec import APISpec from apispec.ext.marshmallow import MarshmallowPlugin + class UUIDListSchema(Schema): uuids = fields.List(fields.UUID(), data_key=None) + def _build_schema(list_as_array: bool): plugin = MarshmallowPlugin(list_as_array=list_as_array) spec = APISpec( @@ -24,7 +27,9 @@ def _build_schema(list_as_array: bool): } }, ) - schema = spec.to_dict()["paths"]["/list"]["post"]["requestBody"]["content"]["application/json"]["schema"] + schema = spec.to_dict()["paths"]["/list"]["post"]["requestBody"]["content"][ + "application/json" + ]["schema"] if "$ref" in schema: ref_name = schema["$ref"].split("/")[-1] @@ -32,10 +37,12 @@ def _build_schema(list_as_array: bool): return schema + def test_default_is_object(): schema = _build_schema(list_as_array=False) assert schema["type"] == "object" + def test_list_as_array_flag(): schema = _build_schema(list_as_array=True) assert schema["type"] == "array" From 71d9b70d02c121d702233f09ab7cc805ac83319b Mon Sep 17 00:00:00 2001 From: nomi3 Date: Fri, 25 Jul 2025 23:53:02 +0900 Subject: [PATCH 3/3] refactor: simplify condition for list_as_array flag in OpenAPIConverter --- src/apispec/ext/marshmallow/openapi.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/apispec/ext/marshmallow/openapi.py b/src/apispec/ext/marshmallow/openapi.py index 536bdd69..080e56d0 100644 --- a/src/apispec/ext/marshmallow/openapi.py +++ b/src/apispec/ext/marshmallow/openapi.py @@ -110,15 +110,13 @@ def resolve_nested_schema(self, schema): :param schema: schema to add to the spec """ - if isinstance(schema, marshmallow.fields.List) and getattr( - self, "list_as_array", False - ): + if isinstance(schema, marshmallow.fields.List) and self.list_as_array: return { "type": "array", "items": self.resolve_nested_schema(schema.inner), } - if getattr(self, "list_as_array", False): + if self.list_as_array: if isinstance(schema, marshmallow.Schema): field_dict = schema.fields elif isinstance(schema, type) and issubclass(schema, marshmallow.Schema):