Skip to content

Commit dc696dd

Browse files
committed
Fix field2property for nullable values
Using the `field2property` method of the `FieldConverterMixin` from `apispec.ext.marshmallow` is not reliable, as some methods (specifically those dealing with nullable fields) depend on being linked to an `APISpec` object. I've changed our code so we now use the initialised converter object from our current spec - it adds an argument, but makes everything more robust.
1 parent 3174367 commit dc696dd

File tree

4 files changed

+55
-28
lines changed

4 files changed

+55
-28
lines changed

src/labthings/apispec/plugins.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -112,11 +112,10 @@ def spec_for_interaction(cls, interaction):
112112
)
113113
return d
114114

115-
@classmethod
116-
def spec_for_property(cls, prop):
117-
class_schema = ensure_schema(prop.schema) or {}
115+
def spec_for_property(self, prop):
116+
class_schema = ensure_schema(self.spec, prop.schema) or {}
118117

119-
d = cls.spec_for_interaction(prop)
118+
d = self.spec_for_interaction(prop)
120119

121120
# Add in writeproperty methods
122121
for method in ("put", "post"):
@@ -155,9 +154,11 @@ def spec_for_property(cls, prop):
155154
return d
156155

157156
def spec_for_action(self, action):
158-
action_input = ensure_schema(action.args, name=f"{action.__name__}InputSchema")
157+
action_input = ensure_schema(
158+
self.spec, action.args, name=f"{action.__name__}InputSchema"
159+
)
159160
action_output = ensure_schema(
160-
action.schema, name=f"{action.__name__}OutputSchema"
161+
self.spec, action.schema, name=f"{action.__name__}OutputSchema"
161162
)
162163
# We combine input/output parameters with ActionSchema using an
163164
# allOf directive, so we don't end up duplicating the schema

src/labthings/apispec/utilities.py

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,27 @@
11
from inspect import isclass
22
from typing import Dict, Type, Union, cast
33

4+
from apispec import APISpec
45
from apispec.ext.marshmallow import MarshmallowPlugin
5-
from apispec.ext.marshmallow.field_converter import FieldConverterMixin
66
from marshmallow import Schema
77

88
from .. import fields
99

1010

11-
def field2property(field):
12-
"""Convert a marshmallow Field to OpenAPI dictionary"""
13-
converter = FieldConverterMixin()
14-
converter.init_attribute_functions()
15-
return converter.field2property(field)
11+
def field2property(spec: APISpec, field: fields.Field):
12+
"""Convert a marshmallow Field to OpenAPI dictionary
13+
14+
We require an initialised APISpec object to use its
15+
converter function - in particular, this will depend
16+
on the OpenAPI version defined in `spec`. We also rely
17+
on the spec having a `MarshmallowPlugin` attached.
18+
"""
19+
plugin = get_marshmallow_plugin(spec)
20+
return plugin.converter.field2property(field)
1621

1722

1823
def ensure_schema(
24+
spec: APISpec,
1925
schema: Union[
2026
fields.Field,
2127
Type[fields.Field],
@@ -34,19 +40,22 @@ def ensure_schema(
3440
3541
Other Schemas are returned as Marshmallow Schema instances, which will be
3642
converted to references by the plugin.
43+
44+
The first argument must be an initialised APISpec object, as the conversion
45+
of single fields to dictionaries is version-dependent.
3746
"""
3847
if schema is None:
3948
return None
4049
if isinstance(schema, fields.Field):
41-
return field2property(schema)
50+
return field2property(spec, schema)
4251
elif isinstance(schema, dict):
4352
return Schema.from_dict(schema, name=name)()
4453
elif isinstance(schema, Schema):
4554
return schema
4655
if isclass(schema):
4756
schema = cast(Type, schema)
4857
if issubclass(schema, fields.Field):
49-
return field2property(schema())
58+
return field2property(spec, schema())
5059
elif issubclass(schema, Schema):
5160
return schema()
5261
raise TypeError(

tests/conftest.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,17 @@ def post(self, args):
213213

214214
thing.add_view(TestFieldProperty, "/TestFieldProperty")
215215

216+
class TestNullableFieldProperty(PropertyView):
217+
schema = fields.Integer(allow_none=True)
218+
219+
def get(self):
220+
return "one"
221+
222+
def post(self, args):
223+
pass
224+
225+
thing.add_view(TestNullableFieldProperty, "/TestNullableFieldProperty")
226+
216227
class FailAction(ActionView):
217228
wait_for = 0.1
218229

tests/test_openapi.py

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -54,43 +54,49 @@ def post(self):
5454
assert original_input_schema != modified_input_schema
5555

5656

57-
def test_ensure_schema_field_instance():
58-
ret = utilities.ensure_schema(fields.Integer())
57+
def test_ensure_schema_field_instance(spec):
58+
ret = utilities.ensure_schema(spec, fields.Integer())
5959
assert ret == {"type": "integer"}
6060

6161

62-
def test_ensure_schema_field_class():
63-
ret = utilities.ensure_schema(fields.Integer)
62+
def test_ensure_schema_nullable_field_instance(spec):
63+
ret = utilities.ensure_schema(spec, fields.Integer(allow_none=True))
64+
assert ret == {"type": "integer", "nullable": True}
65+
66+
67+
def test_ensure_schema_field_class(spec):
68+
ret = utilities.ensure_schema(spec, fields.Integer)
6469
assert ret == {"type": "integer"}
6570

6671

67-
def test_ensure_schema_class():
68-
ret = utilities.ensure_schema(LogRecordSchema)
72+
def test_ensure_schema_class(spec):
73+
ret = utilities.ensure_schema(spec, LogRecordSchema)
6974
assert isinstance(ret, Schema)
7075

7176

72-
def test_ensure_schema_instance():
73-
ret = utilities.ensure_schema(LogRecordSchema())
77+
def test_ensure_schema_instance(spec):
78+
ret = utilities.ensure_schema(spec, LogRecordSchema())
7479
assert isinstance(ret, Schema)
7580

7681

77-
def test_ensure_schema_dict():
82+
def test_ensure_schema_dict(spec):
7883
ret = utilities.ensure_schema(
84+
spec,
7985
{
8086
"count": fields.Integer(),
8187
"name": fields.String(),
82-
}
88+
},
8389
)
8490
assert isinstance(ret, Schema)
8591

8692

87-
def test_ensure_schema_none():
88-
assert utilities.ensure_schema(None) is None
93+
def test_ensure_schema_none(spec):
94+
assert utilities.ensure_schema(spec, None) is None
8995

9096

91-
def test_ensure_schema_error():
97+
def test_ensure_schema_error(spec):
9298
with pytest.raises(TypeError):
93-
utilities.ensure_schema(Exception)
99+
utilities.ensure_schema(spec, Exception)
94100

95101

96102
def test_get_marshmallow_plugin(spec):

0 commit comments

Comments
 (0)