Skip to content

Commit ea56731

Browse files
authored
Merge pull request #249 from labthings/improve-openapi
More openapi improvements See the commit messages - but this builds on the last merge form this branch, adding unit tests for all OpenAPI code that I've added, strengthening unit testing of the Thing Description (to make sure I've not broken it) and fixing numerous small bugs and inconsistencies.
2 parents 247920e + 54d5df9 commit ea56731

23 files changed

+850
-269
lines changed

poetry.lock

Lines changed: 232 additions & 94 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ python = "^3.6"
1717
Flask = "^1.1.1"
1818
marshmallow = "^3.4.0"
1919
webargs = ">=6,<9"
20-
apispec = {version = ">=3.2,<5.0", extras = ["yaml"]}
20+
apispec = {version = ">=3.2,<5.0", extras = ["yaml", "validation"]}
2121
flask-cors = "^3.0.8"
2222
zeroconf = ">=0.24.5,<0.34.0"
2323
apispec_webframeworks = "^0.5.2"

src/labthings/apispec/plugins.py

Lines changed: 90 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,17 @@
11
import re
2+
from copy import deepcopy
23

34
from apispec import BasePlugin
4-
5-
from apispec.ext.marshmallow import (
6-
MarshmallowPlugin as _MarshmallowPlugin,
7-
)
5+
from apispec.ext.marshmallow import MarshmallowPlugin as _MarshmallowPlugin
86
from apispec.ext.marshmallow import OpenAPIConverter
97
from flask.views import http_method_funcs
108

119
from .. import fields
1210
from ..json.schemas import schema_to_json
13-
from ..schema import EventSchema, build_action_schema
11+
from ..schema import ActionSchema, EventSchema
1412
from ..utilities import get_docstring, get_summary, merge
1513
from ..views import ActionView, EventView, PropertyView, View
14+
from .utilities import ensure_schema, get_marshmallow_plugin
1615

1716

1817
class ExtendedOpenAPIConverter(OpenAPIConverter):
@@ -54,6 +53,12 @@ class MarshmallowPlugin(_MarshmallowPlugin):
5453
class FlaskLabThingsPlugin(BasePlugin):
5554
"""APIspec plugin for Flask LabThings"""
5655

56+
spec = None
57+
58+
def init_spec(self, spec):
59+
self.spec = spec
60+
return super().init_spec(spec)
61+
5762
@classmethod
5863
def spec_for_interaction(cls, interaction):
5964
d = {}
@@ -62,14 +67,18 @@ def spec_for_interaction(cls, interaction):
6267
if hasattr(interaction, method):
6368
prop = getattr(interaction, method)
6469
d[method] = {
65-
"description": getattr(prop, "description", None)
66-
or get_docstring(prop, remove_newlines=False)
67-
or getattr(interaction, "description", None)
68-
or get_docstring(interaction, remove_newlines=False),
69-
"summary": getattr(prop, "summary", None)
70-
or get_summary(prop)
71-
or getattr(interaction, "summary", None)
72-
or get_summary(interaction),
70+
"description": (
71+
getattr(prop, "description", None)
72+
or get_docstring(prop, remove_newlines=False)
73+
or getattr(interaction, "description", None)
74+
or get_docstring(interaction, remove_newlines=False)
75+
),
76+
"summary": (
77+
getattr(prop, "summary", None)
78+
or get_summary(prop)
79+
or getattr(interaction, "summary", None)
80+
or get_summary(interaction)
81+
),
7382
"tags": list(interaction.get_tags()),
7483
"responses": {
7584
"5XX": {
@@ -87,12 +96,25 @@ def spec_for_interaction(cls, interaction):
8796
},
8897
}
8998
},
99+
"parameters": [],
90100
}
101+
# Allow custom responses from the class, overridden by the method
102+
d[method]["responses"].update(
103+
deepcopy(getattr(interaction, "responses", {}))
104+
)
105+
d[method]["responses"].update(deepcopy(getattr(prop, "responses", {})))
106+
# Allow custom parameters from the class & method
107+
d[method]["parameters"].extend(
108+
deepcopy(getattr(interaction, "parameters", {}))
109+
)
110+
d[method]["parameters"].extend(
111+
deepcopy(getattr(prop, "parameters", {}))
112+
)
91113
return d
92114

93115
@classmethod
94116
def spec_for_property(cls, prop):
95-
class_json_schema = schema_to_json(prop.schema) if prop.schema else None
117+
class_schema = ensure_schema(prop.schema) or {}
96118

97119
d = cls.spec_for_interaction(prop)
98120

@@ -103,22 +125,12 @@ def spec_for_property(cls, prop):
103125
d.get(method, {}),
104126
{
105127
"requestBody": {
106-
"content": {
107-
prop.content_type: (
108-
{"schema": class_json_schema}
109-
if class_json_schema
110-
else {}
111-
)
112-
}
128+
"content": {prop.content_type: {"schema": class_schema}}
113129
},
114130
"responses": {
115131
200: {
116132
"content": {
117-
prop.content_type: (
118-
{"schema": class_json_schema}
119-
if class_json_schema
120-
else {}
121-
)
133+
prop.content_type: {"schema": class_schema}
122134
},
123135
"description": "Write property",
124136
}
@@ -133,36 +145,57 @@ def spec_for_property(cls, prop):
133145
{
134146
"responses": {
135147
200: {
136-
"content": {
137-
prop.content_type: (
138-
{"schema": class_json_schema}
139-
if class_json_schema
140-
else {}
141-
)
142-
},
148+
"content": {prop.content_type: {"schema": class_schema}},
143149
"description": "Read property",
144150
}
145151
},
146152
},
147153
)
148154

149-
# Enable custom responses from all methods
150-
for method in d.keys():
151-
d[method]["responses"].update(prop.responses)
152-
153155
return d
154156

155-
@classmethod
156-
def spec_for_action(cls, action):
157-
class_args = schema_to_json(action.args)
158-
action_json_schema = schema_to_json(
159-
build_action_schema(action.schema, action.args)()
160-
)
161-
queue_json_schema = schema_to_json(
162-
build_action_schema(action.schema, action.args)(many=True)
157+
def spec_for_action(self, action):
158+
action_input = ensure_schema(action.args, name=f"{action.__name__}InputSchema")
159+
action_output = ensure_schema(
160+
action.schema, name=f"{action.__name__}OutputSchema"
163161
)
162+
# We combine input/output parameters with ActionSchema using an
163+
# allOf directive, so we don't end up duplicating the schema
164+
# for every action.
165+
if action_output or action_input:
166+
# It would be neater to combine the schemas in OpenAPI with allOf
167+
# I think the code below does it - but I'm not yet convinced it is working
168+
# TODO: add tests to validate this
169+
plugin = get_marshmallow_plugin(self.spec)
170+
action_input_dict = (
171+
plugin.resolver.resolve_schema_dict(action_input)
172+
if action_input
173+
else {}
174+
)
175+
action_output_dict = (
176+
plugin.resolver.resolve_schema_dict(action_output)
177+
if action_output
178+
else {}
179+
)
180+
action_schema = {
181+
"allOf": [
182+
plugin.resolver.resolve_schema_dict(ActionSchema),
183+
{
184+
"type": "object",
185+
"properties": {
186+
"input": action_input_dict,
187+
"output": action_output_dict,
188+
},
189+
},
190+
]
191+
}
192+
# The line below builds an ActionSchema subclass. This works and
193+
# is valid, but results in ActionSchema being duplicated many times...
194+
# action_schema = build_action_schema(action_output, action_input)
195+
else:
196+
action_schema = ActionSchema
164197

165-
d = cls.spec_for_interaction(action)
198+
d = self.spec_for_interaction(action)
166199

167200
# Add in Action spec
168201
d = merge(
@@ -172,7 +205,7 @@ def spec_for_action(cls, action):
172205
"requestBody": {
173206
"content": {
174207
action.content_type: (
175-
{"schema": class_args} if class_args else {}
208+
{"schema": action_input} if action_input else {}
176209
)
177210
}
178211
},
@@ -181,25 +214,14 @@ def spec_for_action(cls, action):
181214
# 200 responses with cls.responses = {200: {...}}
182215
200: {
183216
"description": "Action completed immediately",
184-
# Allow customising 200 (immediate response) content type
185-
"content": {
186-
action.response_content_type: (
187-
{"schema": action_json_schema}
188-
if action_json_schema
189-
else {}
190-
)
191-
},
217+
# Allow customising 200 (immediate response) content type?
218+
# TODO: I'm not convinced it's still possible to customise this.
219+
"content": {"application/json": {"schema": action_schema}},
192220
},
193221
201: {
194222
"description": "Action started",
195223
# Our POST 201 MUST be application/json
196-
"content": {
197-
"application/json": (
198-
{"schema": action_json_schema}
199-
if action_json_schema
200-
else {}
201-
)
202-
},
224+
"content": {"application/json": {"schema": action_schema}},
203225
},
204226
},
205227
},
@@ -210,18 +232,19 @@ def spec_for_action(cls, action):
210232
"description": "Action queue",
211233
"content": {
212234
"application/json": (
213-
{"schema": queue_json_schema}
214-
if queue_json_schema
215-
else {}
235+
{
236+
"schema": {
237+
"type": "array",
238+
"items": action_schema,
239+
}
240+
}
216241
)
217242
},
218243
}
219244
},
220245
},
221246
},
222247
)
223-
# Enable custom responses from POST
224-
d["post"]["responses"].update(action.responses)
225248
return d
226249

227250
@classmethod

src/labthings/apispec/utilities.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
from inspect import isclass
2+
from typing import Dict, Type, Union, cast
3+
4+
from apispec.ext.marshmallow import MarshmallowPlugin
5+
from apispec.ext.marshmallow.field_converter import FieldConverterMixin
6+
from marshmallow import Schema
7+
8+
from .. import fields
9+
10+
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)
16+
17+
18+
def ensure_schema(
19+
schema: Union[
20+
fields.Field,
21+
Type[fields.Field],
22+
Schema,
23+
Type[Schema],
24+
Dict[str, Union[fields.Field, type]],
25+
],
26+
name: str = "GeneratedFromDict",
27+
) -> Union[dict, Schema]:
28+
"""Create a Schema object, or OpenAPI dictionary, given a Field, Schema, or Dict.
29+
30+
The output from this function should be suitable to include in a dictionary
31+
that is passed to APISpec. Fields won't get processed by the Marshmallow
32+
plugin, and can't be converted to Schemas without adding a field name, so
33+
we convert them directly to the dictionary representation.
34+
35+
Other Schemas are returned as Marshmallow Schema instances, which will be
36+
converted to references by the plugin.
37+
"""
38+
if schema is None:
39+
return None
40+
if isinstance(schema, fields.Field):
41+
return field2property(schema)
42+
elif isinstance(schema, dict):
43+
return Schema.from_dict(schema, name=name)()
44+
elif isinstance(schema, Schema):
45+
return schema
46+
if isclass(schema):
47+
schema = cast(Type, schema)
48+
if issubclass(schema, fields.Field):
49+
return field2property(schema())
50+
elif issubclass(schema, Schema):
51+
return schema()
52+
raise TypeError(
53+
f"Invalid schema type {type(schema)}. Must be a Schema or Mapping/dict"
54+
)
55+
56+
57+
def get_marshmallow_plugin(spec):
58+
"""Extract the marshmallow plugin object from an APISpec"""
59+
for p in spec.plugins:
60+
if isinstance(p, MarshmallowPlugin):
61+
return p
62+
raise AttributeError("The APISpec does not seem to have a Marshmallow plugin.")

0 commit comments

Comments
 (0)