Skip to content

Commit 9fa713e

Browse files
author
Joel Collins
committed
Changed views to be more opinionated
1 parent cb0df6f commit 9fa713e

File tree

1 file changed

+144
-61
lines changed

1 file changed

+144
-61
lines changed

src/labthings/view/__init__.py

Lines changed: 144 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from flask.views import MethodView
1+
from flask.views import MethodView, http_method_funcs
22
from flask import request
33
from werkzeug.wrappers import Response as ResponseBase
44
from werkzeug.exceptions import BadRequest
@@ -8,12 +8,13 @@
88
from .args import use_args
99
from .marshalling import marshal_with
1010

11-
from ..utilities import unpack
11+
from ..utilities import unpack, get_docstring, get_summary
1212
from ..representations import DEFAULT_REPRESENTATIONS
1313
from ..find import current_labthing
1414
from ..event import PropertyStatusEvent
1515
from ..schema import Schema, ActionSchema, build_action_schema
1616
from ..tasks import taskify
17+
from ..deque import Deque, resize_deque
1718

1819
from gevent.timeout import Timeout
1920

@@ -28,31 +29,15 @@ class View(MethodView):
2829
get(), put(), post(), and delete(), corresponding to HTTP methods.
2930
3031
These functions will allow for automated documentation generation.
31-
32-
Unlike MethodView, a LabThings View is opinionated, in that unless
33-
explicitally returning a Response object, all requests with be marshaled
34-
with the same schema, and all request arguments will be parsed with the same
35-
args schema
3632
"""
3733

3834
endpoint = None
39-
40-
schema: Schema = None
41-
args: dict = None
4235
semtype: str = None
36+
4337
tags: list = [] # Custom tags the user can add
4438
_cls_tags = set() # Class tags that shouldn't be removed
4539
title: None
4640

47-
# Default input content_type
48-
content_type = "application/json"
49-
# Custom responses dictionary
50-
responses: dict = {}
51-
# Methods for which to read arguments
52-
arg_methods = ("POST", "PUT", "PATCH")
53-
# Methods for which to marshal responses
54-
marshal_methods = ("GET", "PUT", "POST", "PATCH")
55-
5641
def __init__(self, *args, **kwargs):
5742
MethodView.__init__(self, *args, **kwargs)
5843

@@ -61,18 +46,23 @@ def __init__(self, *args, **kwargs):
6146
self.representations = OrderedDict(DEFAULT_REPRESENTATIONS)
6247

6348
@classmethod
64-
def get_responses(cls):
65-
r = {200: {"schema": cls.schema, "content_type": "application/json",}}
66-
r.update(cls.responses)
67-
return r
68-
69-
@classmethod
70-
def get_schema(cls):
71-
return cls.schema
72-
73-
@classmethod
74-
def get_args(cls):
75-
return cls.args
49+
def get_apispec(cls):
50+
d = {}
51+
52+
for method in http_method_funcs:
53+
if hasattr(cls, method):
54+
d[method] = {
55+
"description": getattr(cls, "description", None)
56+
or get_docstring(cls),
57+
"summary": getattr(cls, "summary", None) or get_summary(cls),
58+
"tags": list(cls.get_tags()),
59+
}
60+
61+
# Enable custom responses from all methods
62+
if getattr(cls, "responses", None):
63+
for method in d.keys():
64+
d[method]["responses"] = getattr(cls, "responses")
65+
return d
7666

7767
@classmethod
7868
def get_tags(cls):
@@ -98,14 +88,6 @@ def dispatch_request(self, *args, **kwargs):
9888
if meth is None and request.method == "HEAD":
9989
meth = getattr(self, "get", None)
10090

101-
# Inject request arguments if an args schema is defined
102-
if request.method in self.arg_methods and self.get_args():
103-
meth = use_args(self.get_args())(meth)
104-
105-
# Marhal response if a response schema is defined
106-
if request.method in self.marshal_methods and self.get_schema():
107-
meth = marshal_with(self.get_schema())(meth)
108-
10991
# Flask should ensure this is assersion never fails
11092
assert meth is not None, f"Unimplemented method {request.method!r}"
11193

@@ -133,22 +115,57 @@ def represent_response(self, response):
133115

134116

135117
class ActionView(View):
136-
_cls_tags = {"actions"}
118+
119+
# Data formatting
120+
schema: Schema = None
121+
args: dict = None
122+
semtype: str = None
123+
124+
# Spec overrides
125+
responses = {} # Custom responses for invokeaction
126+
127+
# Spec parameters
137128
safe: bool = False
138129
idempotent: bool = False
139130

131+
# Internal
132+
_cls_tags = {"actions"}
133+
_deque = Deque() # Action queue
134+
140135
@classmethod
141-
def get_responses(cls):
142-
"""Build an output schema that includes the Action wrapper object"""
143-
r = {
144-
201: {
145-
"schema": build_action_schema(cls.schema, cls.args)(),
146-
"content_type": "application/json",
147-
"description": "Action started",
148-
}
136+
def get_apispec(cls):
137+
d = {
138+
"post": {
139+
"description": getattr(cls, "description", None) or get_docstring(cls),
140+
"summary": getattr(cls, "summary", None) or get_summary(cls),
141+
"tags": list(cls.get_tags()),
142+
"requestBody": {"content": {"application/json": cls.schema}},
143+
"responses": {
144+
# Our POST 201 will usually be application/json
145+
201: {
146+
"schema": build_action_schema(cls.schema, cls.args)(),
147+
"content_type": "application/json",
148+
"description": "Action started",
149+
}
150+
},
151+
},
152+
"get": {
153+
"description": "Action queue",
154+
"summary": "Action queue",
155+
"tags": list(cls.get_tags()),
156+
"responses": {
157+
# Our GET 200 will usually be application/json
158+
200: {
159+
"schema": build_action_schema(cls.schema, cls.args)(many=True),
160+
"content_type": "application/json",
161+
"description": "Action started",
162+
}
163+
},
164+
},
149165
}
150-
r.update(cls.responses)
151-
return r
166+
# Enable custom responses from POST
167+
d["post"]["responses"].update(cls.responses)
168+
return d
152169

153170
def dispatch_request(self, *args, **kwargs):
154171
meth = getattr(self, request.method.lower(), None)
@@ -158,12 +175,12 @@ def dispatch_request(self, *args, **kwargs):
158175
return View.dispatch_request(self, *args, **kwargs)
159176

160177
# Inject request arguments if an args schema is defined
161-
if self.get_args():
162-
meth = use_args(self.get_args())(meth)
178+
if self.args:
179+
meth = use_args(self.args)(meth)
163180

164-
# Marhal response if a response schema is defines
165-
if self.get_schema():
166-
meth = marshal_with(self.get_schema())(meth)
181+
# Marhal response if a response schema is defined
182+
if self.schema:
183+
meth = marshal_with(self.schema)(meth)
167184

168185
# Make a task out of the views `post` method
169186
task = taskify(meth)(*args, **kwargs)
@@ -181,27 +198,93 @@ def dispatch_request(self, *args, **kwargs):
181198
except Timeout:
182199
pass
183200

201+
# Log the action to the view's deque
202+
self._deque.append(task)
203+
184204
# If the action returns quickly, and returns a valid Response, return it as-is
185205
if task.output and isinstance(task.output, ResponseBase):
186-
return self.represent_response(task.output)
206+
return self.represent_response(task.output, 200)
187207

188208
return self.represent_response((ActionSchema().dump(task), 201))
189209

190210

191211
class PropertyView(View):
212+
schema: Schema = None
213+
semtype: str = None
214+
215+
# Spec overrides
216+
responses = {} # Custom responses for invokeaction
217+
192218
_cls_tags = {"properties"}
193219

194220
@classmethod
195-
def get_args(cls):
196-
"""Use the output schema for arguments, on Properties"""
197-
return cls.schema
221+
def get_apispec(cls):
222+
d = {}
223+
224+
# writeproperty methods
225+
for method in ("put", "post"):
226+
if hasattr(cls, method):
227+
d[method] = {
228+
"description": getattr(cls, "description", None)
229+
or get_docstring(cls),
230+
"summary": getattr(cls, "summary", None) or get_summary(cls),
231+
"tags": list(cls.get_tags()),
232+
"requestBody": {
233+
"content": {"application/json": {"schema": cls.schema}}
234+
},
235+
"responses": {
236+
200: {
237+
"schema": cls.schema,
238+
"content_type": "application/json",
239+
"description": "Write property",
240+
}
241+
},
242+
}
243+
244+
if hasattr(cls, "get"):
245+
d["get"] = {
246+
"description": getattr(cls, "description", None) or get_docstring(cls),
247+
"summary": getattr(cls, "summary", None) or get_summary(cls),
248+
"tags": list(cls.get_tags()),
249+
"responses": {
250+
200: {
251+
"schema": cls.schema,
252+
"content_type": "application/json",
253+
"description": "Read property",
254+
}
255+
},
256+
}
257+
258+
# Enable custom responses from all methods
259+
for method in d.keys():
260+
d[method]["responses"].update(cls.responses)
261+
return d
198262

199263
def dispatch_request(self, *args, **kwargs):
264+
meth = getattr(self, request.method.lower(), None)
265+
266+
# Flask should ensure this is assersion never fails
267+
assert meth is not None, f"Unimplemented method {request.method!r}"
268+
269+
# If the request method is HEAD and we don't have a handler for it
270+
# retry with GET.
271+
if meth is None and request.method == "HEAD":
272+
meth = getattr(self, "get", None)
273+
274+
# POST and PUT methods can be used to write properties
275+
# In all other cases, ignore arguments
276+
if request.method in ("PUT", "POST") and self.schema:
277+
meth = use_args(self.schema)(meth)
278+
279+
# All methods should serialise properties
280+
if self.schema:
281+
meth = marshal_with(self.schema)(meth)
282+
200283
# Generate basic response
201-
resp = View.dispatch_request(self, *args, **kwargs)
284+
resp = self.represent_response(meth(*args, **kwargs))
202285

203286
# Emit property event
204-
if request.method in ("POST", "PUT", "DELETE", "PATCH"):
287+
if request.method in ("POST", "PUT"):
205288
property_value = self.get_value()
206289
property_name = getattr(self, "endpoint", None) or getattr(
207290
self, "__name__", "unknown"

0 commit comments

Comments
 (0)