/
_api.py
executable file
·549 lines (452 loc) · 23.5 KB
/
_api.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
"""
flask_restful_swagger2 API subclass
"""
import traceback
import copy
import logging
import werkzeug
from flask_restful import abort
from flask_restful_swagger_2 import Api as FRSApiBase
from flask_restful_swagger_2.swagger import create_swagger_endpoint, add_parameters
from flask_restful_swagger_2 import validate_definitions_object, parse_method_doc
from flask_restful_swagger_2 import validate_path_item_object
from flask_restful_swagger_2 import extract_swagger_path, Extractor
import safrs
# Import here in order to avoid circular dependencies, (todo: fix)
from .swagger_doc import swagger_doc, swagger_method_doc, default_paging_parameters
from .swagger_doc import parse_object_doc, swagger_relationship_doc, get_http_methods
from .errors import ValidationError, GenericError, NotFoundError
from .config import get_config
from .jsonapi import SAFRSRestAPI, SAFRSRestMethodAPI, SAFRSRestRelationshipAPI
from flask_restful.representations.json import output_json
from flask_restful.utils import OrderedDict
from functools import wraps
HTTP_METHODS = ["GET", "PUT", "POST", "DELETE", "PATCH"]
DEFAULT_REPRESENTATIONS = [("application/vnd.api+json", output_json)]
# pylint: disable=protected-access,invalid-name,line-too-long,logging-format-interpolation,fixme,too-many-branches
class Api(FRSApiBase):
"""
Subclass of the flask_restful_swagger API class where we add the expose_object method
this method creates an API endpoint for the SAFRSBase object and corresponding swagger
documentation
"""
_operation_ids = {}
def __init__(self, *args, **kwargs):
"""
http://jsonapi.org/format/#content-negotiation-servers
Servers MUST send all JSON:API data in response documents with
the header Content-Type: application/vnd.api+json without any media type parameters.
Servers MUST respond with a 415 Unsupported Media Type status code if
a request specifies the header Content-Type: application/vnd.api+json with any media type parameters.
Servers MUST respond with a 406 Not Acceptable status code if
a request’s Accept header contains the JSON:API media type and
all instances of that media type are modified with media type parameters.
"""
custom_swagger = kwargs.pop("custom_swagger", {})
kwargs["default_mediatype"] = "application/vnd.api+json"
super(Api, self).__init__(*args, **kwargs)
swagger_doc = self.get_swagger_doc()
safrs.dict_merge(swagger_doc, custom_swagger)
self.representations = OrderedDict(DEFAULT_REPRESENTATIONS)
def expose_object(self, safrs_object, url_prefix="", **properties):
"""
This methods creates the API url endpoints for the SAFRObjects
:param safrs_object: SAFSBase subclass that we would like to expose
"""
"""
creates a class of the form
@api_decorator
class Class_API(SAFRSRestAPI):
SAFRSObject = safrs_object
add the class as an api resource to /SAFRSObject and /SAFRSObject/{id}
tablename: safrs_object.__tablename__, e.g. "Users"
classname: safrs_object.__name__, e.g. "User"
"""
self.safrs_object = safrs_object
safrs_object.url_prefix = url_prefix
api_class_name = "{}_API".format(safrs_object._s_type)
# tags indicate where in the swagger hierarchy the endpoint will be shown
tags = [safrs_object._s_type]
# Expose the methods first
self.expose_methods(url_prefix, tags=tags)
RESOURCE_URL_FMT = get_config("RESOURCE_URL_FMT")
url = RESOURCE_URL_FMT.format(url_prefix, safrs_object._s_type)
endpoint = safrs_object.get_endpoint()
properties["SAFRSObject"] = safrs_object
swagger_decorator = swagger_doc(safrs_object)
# Create the class and decorate it
api_class = api_decorator(type(api_class_name, (SAFRSRestAPI,), properties), swagger_decorator)
# Expose the collection
safrs.log.info("Exposing %s on %s, endpoint: %s", safrs_object._s_type, url, endpoint)
resource = self.add_resource(api_class, url, endpoint=endpoint, methods=["GET", "POST"])
INSTANCE_URL_FMT = get_config("INSTANCE_URL_FMT")
url = INSTANCE_URL_FMT.format(url_prefix, safrs_object._s_type, safrs_object.__name__)
endpoint = safrs_object.get_endpoint(type="instance")
# Expose the instances
self.add_resource(api_class, url, endpoint=endpoint)
safrs.log.info("Exposing {} instances on {}, endpoint: {}".format(safrs_object._s_type, url, endpoint))
object_doc = parse_object_doc(safrs_object)
object_doc["name"] = safrs_object._s_type
self._swagger_object["tags"].append(object_doc)
relationships = safrs_object.__mapper__.relationships
for relationship in relationships:
self.expose_relationship(relationship, url, tags=tags)
def expose_methods(self, url_prefix, tags):
"""
Expose the safrs "documented_api_method" decorated methods
:param url_prefix: api url prefix
:param tags: swagger tags
:return: None
"""
safrs_object = self.safrs_object
api_methods = safrs_object._s_get_jsonapi_rpc_methods()
for api_method in api_methods:
method_name = api_method.__name__
api_method_class_name = "method_{}_{}".format(safrs_object.__tablename__, method_name)
if (
isinstance(safrs_object.__dict__.get(method_name, None), (classmethod, staticmethod))
or getattr(api_method, "__self__", None) is safrs_object
):
# method is a classmethod or static method, make it available at the class level
CLASSMETHOD_URL_FMT = get_config("CLASSMETHOD_URL_FMT")
url = CLASSMETHOD_URL_FMT.format(url_prefix, safrs_object.__tablename__, method_name)
else:
# expose the method at the instance level
INSTANCEMETHOD_URL_FMT = get_config("INSTANCEMETHOD_URL_FMT")
url = INSTANCEMETHOD_URL_FMT.format(
url_prefix, safrs_object.__tablename__, safrs_object.object_id, method_name
)
ENDPOINT_FMT = get_config("ENDPOINT_FMT")
endpoint = ENDPOINT_FMT.format(url_prefix, safrs_object.__tablename__ + "." + method_name)
swagger_decorator = swagger_method_doc(safrs_object, method_name, tags)
properties = {"SAFRSObject": safrs_object, "method_name": method_name}
api_class = api_decorator(type(api_method_class_name, (SAFRSRestMethodAPI,), properties), swagger_decorator)
meth_name = safrs_object.__tablename__ + "." + api_method.__name__
safrs.log.info("Exposing method {} on {}, endpoint: {}".format(meth_name, url, endpoint))
self.add_resource(api_class, url, endpoint=endpoint, methods=get_http_methods(api_method), jsonapi_rpc=True)
def expose_relationship(self, relationship, url_prefix, tags):
"""
Expose a relationship tp the REST API:
A relationship consists of a parent and a child class
creates a class of the form
@api_decorator
class Parent_X_Child_API(SAFRSRestAPI):
SAFRSObject = safrs_object
add the class as an api resource to /SAFRSObject and /SAFRSObject/{id}
:param relationship: relationship
:param url_prefix: api url prefix
:param tags: swagger tags
:return: None
"""
API_CLASSNAME_FMT = "{}_X_{}_API"
properties = {}
safrs_object = relationship.mapper.class_
rel_name = relationship.key
parent_class = relationship.parent.class_
parent_name = parent_class.__name__
# Name of the endpoint class
RELATIONSHIP_URL_FMT = get_config("RELATIONSHIP_URL_FMT")
api_class_name = API_CLASSNAME_FMT.format(parent_name, rel_name)
url = RELATIONSHIP_URL_FMT.format(url_prefix, rel_name)
ENDPOINT_FMT = get_config("ENDPOINT_FMT")
endpoint = ENDPOINT_FMT.format(url_prefix, rel_name)
# Relationship object
decorators = getattr(parent_class, "custom_decorators", []) + getattr(parent_class, "decorators", [])
rel_object = type(
rel_name,
(SAFRSRelationshipObject,),
{
"relationship": relationship,
# Merge the relationship decorators from the classes
# This makes things really complicated!!!
# TODO: simplify this by creating a proper superclass
"custom_decorators": decorators,
},
)
properties["SAFRSObject"] = rel_object
swagger_decorator = swagger_relationship_doc(rel_object, tags)
api_class = api_decorator(type(api_class_name, (SAFRSRestRelationshipAPI,), properties), swagger_decorator)
# Expose the relationship for the parent class:
# GET requests to this endpoint retrieve all item ids
safrs.log.info("Exposing relationship {} on {}, endpoint: {}".format(rel_name, url, endpoint))
self.add_resource(api_class, url, endpoint=endpoint, methods=["GET", "POST", "PATCH"])
#
try:
child_object_id = safrs_object.object_id
except Exception as exc:
safrs.log.exception(exc)
safrs.log.error("No object id for {}".format(safrs_object))
child_object_id = safrs_object.__name__
if safrs_object == parent_class:
# Avoid having duplicate argument ids in the url:
# append a 2 in case of a self-referencing relationship
# todo : test again
child_object_id += "2"
# Expose the relationship for <string:ChildId>, this lets us
# query and delete the class relationship properties for a given
# child id
url = (RELATIONSHIP_URL_FMT + "/<string:{}>").format(url_prefix, rel_name, child_object_id)
endpoint = "{}api.{}Id".format(url_prefix, rel_name)
safrs.log.info("Exposing {} relationship {} on {}, endpoint: {}".format(parent_name, rel_name, url, endpoint))
self.add_resource(
api_class, url, relationship=rel_object.relationship, endpoint=endpoint, methods=["GET", "DELETE"]
)
def add_resource(self, resource, *urls, **kwargs):
"""
This method is partly copied from flask_restful_swagger_2/__init__.py
I changed it because we don't need path id examples when
there's no {id} in the path. We filter out the unwanted parameters
"""
relationship = kwargs.pop("relationship", False) # relationship object
SAFRS_INSTANCE_SUFFIX = get_config("OBJECT_ID_SUFFIX") + "}"
path_item = {}
definitions = {}
resource_methods = kwargs.get("methods", HTTP_METHODS)
kwargs.pop("safrs_object", None)
is_jsonapi_rpc = kwargs.pop("jsonapi_rpc", False) # check if the exposed method is a jsonapi_rpc method
for method in [m.lower() for m in resource.methods]:
if not method.upper() in resource_methods:
continue
f = getattr(resource, method, None)
if not f:
continue
operation = getattr(f, "__swagger_operation_object", None)
if operation:
# operation, definitions_ = self._extract_schemas(operation)
operation, definitions_ = Extractor.extract(operation)
path_item[method] = operation
definitions.update(definitions_)
summary = parse_method_doc(f, operation)
if summary:
operation["summary"] = summary.split("<br/>")[0]
validate_definitions_object(definitions)
self._swagger_object["definitions"].update(definitions)
if path_item:
validate_path_item_object(path_item)
for url in urls:
if not url.startswith("/"):
raise ValidationError("paths must start with a /")
swagger_url = extract_swagger_path(url)
for method in [m.lower() for m in resource.methods]:
if method == "post" and swagger_url.strip("/").endswith(SAFRS_INSTANCE_SUFFIX):
# POSTing to an instance isn't jsonapi-compliant (https://jsonapi.org/format/#crud-creating-client-ids)
# "A server MUST return 403 Forbidden in response to an
# unsupported request to create a resource with a client-generated ID"
# the method has already been added before, remove it & continue
path_item.pop(method, None)
continue
method_doc = copy.deepcopy(path_item.get(method))
if not method_doc:
continue
filtered_parameters = []
for parameter in method_doc.get("parameters", []):
object_id = "{%s}" % parameter.get("name")
if method == "get" and relationship:
default_include = ",".join(
[rel.key for rel in relationship.mapper.class_.__mapper__.relationships]
)
param = {
"default": default_include,
"type": "string",
"name": "include",
"in": "query",
"format": "string",
"required": False,
"description": "Related relationships to include (csv)",
}
if param not in filtered_parameters:
filtered_parameters.append(param)
if method == "get" and not swagger_url.endswith(SAFRS_INSTANCE_SUFFIX) and not is_jsonapi_rpc:
# limit parameter specifies the number of items to return
for param in default_paging_parameters():
if param not in filtered_parameters:
filtered_parameters.append(param)
default_include = ",".join([rel.key for rel in self.safrs_object.__mapper__.relationships])
param = {
"default": default_include,
"type": "string",
"name": "include",
"in": "query",
"format": "string",
"required": False,
"description": "Related relationships to include (csv)",
}
if param not in filtered_parameters:
filtered_parameters.append(param)
param = {
"default": ",".join(self.safrs_object._s_jsonapi_attrs),
"type": "string",
"name": "fields[{}]".format(self.safrs_object._s_type),
"in": "query",
"format": "string",
"required": False,
"description": "Related relationships to include (csv)",
}
if param not in filtered_parameters:
filtered_parameters.append(param)
param = {
"default": ",".join(self.safrs_object._s_jsonapi_attrs),
"type": "string",
"name": "sort",
"in": "query",
"format": "string",
"required": False,
"description": "Sort order",
}
if param not in filtered_parameters:
filtered_parameters.append(param)
for column_name in self.safrs_object._s_column_names:
param = {
"default": "",
"type": "string",
"name": "filter[{}]".format(column_name),
"in": "query",
"format": "string",
"required": False,
"description": "{} attribute filter (csv)".format(column_name),
}
if param not in filtered_parameters:
filtered_parameters.append(param)
param = {
"default": "",
"type": "string",
"name": "filter",
"in": "query",
"format": "string",
"required": False,
"description": "Custom filter",
}
if param not in filtered_parameters:
filtered_parameters.append(param)
if not (parameter.get("in") == "path" and not object_id in swagger_url):
# Only if a path param is in path url then we add the param
filtered_parameters.append(parameter)
method_doc["parameters"] = filtered_parameters
method_doc["operationId"] = self.get_operation_id(path_item.get(method).get("summary", ""))
path_item[method] = method_doc
if method == "get" and not swagger_url.endswith(SAFRS_INSTANCE_SUFFIX):
# If no {id} was provided, we return a list of all the objects
# pylint: disable=invalid-formatstring
try:
method_doc["description"] += " list (See GET /{{} for details)".format(
SAFRS_INSTANCE_SUFFIX
)
method_doc["responses"]["200"]["schema"] = ""
except:
pass
self._swagger_object["paths"][swagger_url] = path_item
super(FRSApiBase, self).add_resource(resource, *urls, **kwargs)
@classmethod
def get_operation_id(cls, summary):
"""
get_operation_id
"""
summary = "".join(c for c in summary if c.isalnum())
if summary not in cls._operation_ids:
cls._operation_ids[summary] = 0
else:
cls._operation_ids[summary] += 1
return "{}_{}".format(summary, cls._operation_ids[summary])
def api_decorator(cls, swagger_decorator):
"""
Decorator for the API views:
- add swagger documentation ( swagger_decorator )
- add cors
- add generic exception handling
We couldn't use inheritance because the rest method decorator
references the cls.SAFRSObject which isn't known
"""
cors_domain = get_config("cors_domain")
cls.http_methods = (
{}
) # holds overridden http methods, note: cls also has the "methods" set, but it's not related to this
for method_name in ["get", "post", "delete", "patch", "put", "options"]: # HTTP methods
method = getattr(cls, method_name, None)
if not method:
continue
decorated_method = method
# if the SAFRSObject has a custom http method decorator, use it
# e.g. SAFRSObject.get
custom_method = getattr(cls.SAFRSObject, method_name, None)
if custom_method and callable(custom_method):
decorated_method = custom_method
# keep the default method as parent_<method_name>, e.g. parent_get
parent_method = getattr(cls, method_name)
cls.http_methods[method_name] = lambda *args, **kwargs: parent_method(*args, **kwargs)
# Apply custom decorators, specified as class variable list
try:
# Add swagger documentation
decorated_method = swagger_decorator(decorated_method)
except RecursionError:
# Got this error when exposing WP DB, TODO: investigate where it comes from
safrs.log.error(
"Failed to generate documentation for {} {} (Recursion Error)".format(cls, decorated_method)
)
# pylint: disable=broad-except
except Exception as exc:
safrs.log.exception(exc)
safrs.log.error("Failed to generate documentation for {}".format(decorated_method))
# Add cors
if cors_domain is not None:
decorated_method = cors.crossdomain(origin=cors_domain)(decorated_method)
# Add exception handling
decorated_method = http_method_decorator(decorated_method)
setattr(decorated_method, "SAFRSObject", cls.SAFRSObject)
for custom_decorator in getattr(cls.SAFRSObject, "custom_decorators", []):
decorated_method = custom_decorator(decorated_method)
setattr(cls, method_name, decorated_method)
return cls
def http_method_decorator(fun):
"""
Decorator for the REST methods
- commit the database
- convert all exceptions to a JSON serializable GenericError
This method will be called for all requests
"""
@wraps(fun)
def method_wrapper(*args, **kwargs):
try:
result = fun(*args, **kwargs)
safrs.DB.session.commit()
return result
except (ValidationError, GenericError, NotFoundError) as exc:
safrs.log.exception(exc)
status_code = getattr(exc, "status_code")
message = exc.message
except werkzeug.exceptions.NotFound:
status_code = 404
message = "Not Found"
except Exception as exc:
status_code = getattr(exc, "status_code", 500)
traceback.print_exc()
if safrs.log.getEffectiveLevel() > logging.DEBUG:
message = "Unknown Error"
else:
message = str(exc)
safrs.DB.session.rollback()
errors = dict(detail=message)
abort(status_code, errors=[errors])
return method_wrapper
class SAFRSRelationshipObject:
"""
Relationship object
"""
__tablename__ = "tabname"
__name__ = "name"
@classmethod
def get_swagger_doc(cls, http_method):
"""
Create a swagger api model based on the sqlalchemy schema
if an instance exists in the DB, the first entry is used as example
"""
body = {}
responses = {}
object_name = cls.__name__
object_model = {}
responses = {"200": {"description": "{} object".format(object_name), "schema": object_model}}
if http_method == "post":
responses = {"200": {"description": "Success"}}
if http_method == "get":
responses = {"200": {"description": "Success"}}
# responses['200']['schema'] = {'$ref': '#/definitions/{}'.format(object_model.__name__)}
return body, responses