Skip to content

Commit e9ee1b8

Browse files
authored
Merge pull request #268 from labthings/handle-nullables
Handle fields that allow `null` values properly. This merge fixes a longstanding issue that was causing invalid thing descriptions to be created if any fields had `allow_none` set to `True`.
2 parents 52695d5 + 49ea82d commit e9ee1b8

File tree

7 files changed

+107
-32
lines changed

7 files changed

+107
-32
lines changed

Diff for: src/labthings/apispec/plugins.py

+7-6
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

Diff for: src/labthings/apispec/utilities.py

+17-8
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(

Diff for: src/labthings/json/marshmallow_jsonschema/README

+18
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,22 @@ This submodule is a modified version of fuhrysteve/marshmallow-jsonschema, with
33
This will convert any Marshmallow schema into a JSON schema, without any schema references or additional properties.
44
It will create a basic, inline schema only.
55

6+
It has also been modified (with the addition of `.base.convert_type_list_to_oneof`) to avoid returning list values for
7+
the type of a schema (this is valid JSON schema, but is not permitted in Thing Description syntax). Instead, we expand
8+
the list, by creating a copy of the schema for each type, and combining them using `oneOf`. This means that
9+
`fields.String(allow_none=True)`, which would previously be rendered as:
10+
```json
11+
{"type": ["string", "null"]}
12+
```
13+
will be dumped as
14+
```json
15+
{
16+
"oneOf": [
17+
{"type": "string"},
18+
{"type": "null"}
19+
]
20+
}
21+
```
22+
This is also valid JSONSchema, though clearly less elegant. However, it's required by the thing description.
23+
624
https://github.com/fuhrysteve/marshmallow-jsonschema

Diff for: src/labthings/json/marshmallow_jsonschema/base.py

+25
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import datetime
22
import decimal
33
import uuid
4+
from copy import deepcopy
45
from inspect import isclass
56

67
from marshmallow import Schema, fields, missing, validate
@@ -68,6 +69,29 @@
6869
}
6970

7071

72+
def convert_type_list_to_oneof(schema):
73+
"""Avoid setting `type` to a list, using `oneOf`
74+
75+
JSONSchema allows datatypes to be specified as lists, which
76+
means the type may be any of the specified values. This is
77+
not permitted by W3C Thing Description. This function will
78+
use the oneOf mechanism in JSONSchema to convert schemas
79+
that use a list of types into a series of schemas each with
80+
one type. Those schemas are then nested together using
81+
OneOf.
82+
83+
Schemas that do not have a type that is a list are returned
84+
unmodified.
85+
"""
86+
if "type" not in schema or type(schema["type"]) != list:
87+
return schema
88+
subschemas = []
89+
for t in schema["type"]:
90+
subschemas.append(deepcopy(schema))
91+
subschemas[-1]["type"] = t
92+
return {"oneOf": subschemas}
93+
94+
7195
class JSONSchema(Schema):
7296
"""Converts to JSONSchema as defined by http://json-schema.org/."""
7397

@@ -202,6 +226,7 @@ def _get_schema_for_field(self, obj, field):
202226
)
203227
if base_class is not None and base_class in FIELD_VALIDATORS:
204228
schema = FIELD_VALIDATORS[base_class](schema, field, validator, obj)
229+
schema = convert_type_list_to_oneof(schema)
205230
return schema
206231

207232
def _from_nested_schema(self, _, field):

Diff for: tests/conftest.py

+11
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

Diff for: tests/test_json_marshmallow_jsonschema.py

+9-4
Original file line numberDiff line numberDiff line change
@@ -289,18 +289,23 @@ class TestSchema(Schema):
289289

290290

291291
def test_allow_none():
292-
"""A field with allow_none set to True should have type null as additional."""
292+
"""A field with allow_none set to True should be able to be null.
293+
294+
Note that this has been modified from JSONSchema behaviour: in
295+
JSONSchema, "type" should be set to `["string", "null"]`. This
296+
is not allowed in Thing Descriptions, so instead we need to use
297+
`oneOf`. Our modified JSON schema library does this."""
293298

294299
class TestSchema(Schema):
295300
id = fields.Integer(required=True)
296-
readonly_fld = fields.String(allow_none=True)
301+
nullable_fld = fields.String(allow_none=True)
297302

298303
schema = TestSchema()
299304

300305
dumped = validate_and_dump(schema)
301306

302-
assert dumped["properties"]["readonly_fld"] == {
303-
"type": ["string", "null"],
307+
assert dumped["properties"]["nullable_fld"] == {
308+
"oneOf": [{"type": "string"}, {"type": "null"}],
304309
}
305310

306311

Diff for: tests/test_openapi.py

+20-14
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)