Coverage for portality/api/common.py: 92%
160 statements
« prev ^ index » next coverage.py v6.4.2, created at 2022-07-22 15:59 +0100
« prev ^ index » next coverage.py v6.4.2, created at 2022-07-22 15:59 +0100
1#~~API:Feature~~
2import json, uuid
3from portality.core import app
4from flask import request
5from copy import deepcopy
6from link_header import LinkHeader, Link
8LINK_HEADERS = ['next', 'prev', 'last']
9TOTAL_RESULTS_COUNT = ['total']
11ERROR_TEMPLATE = {"status": {"type": "string"}, "error": {"type": "string"}}
12CREATED_TEMPLATE = {"status": {"type": "string"}, "id": {"type": "string"}, "location": {"type": "string"}}
15class Api(object):
16 # ~~->Swagger:Feature~~
17 # ~~->API:Documentation~~
18 SWAG_TEMPLATE = {
19 "description" : "",
20 "responses": {},
21 "parameters": [],
22 "tags": []
23 }
24 R200 = {"schema": {}, "description": "A successful request/response"}
25 R201 = {"schema": {"properties": CREATED_TEMPLATE}, "description": "Resource created successfully, response "
26 "contains the new resource ID and location."}
27 R201_BULK = {"schema": {"items": {"properties" : CREATED_TEMPLATE, "type" : "object"}, "type" : "array"},
28 "description": "Resources created successfully, response contains the new resource IDs "
29 "and locations."}
30 R204 = {"description": "OK (Request succeeded), No Content"}
31 R400 = {"schema": {"properties": ERROR_TEMPLATE}, "description": "Bad Request. Your request body was missing a "
32 "required field, or the data in one of the "
33 "fields did not match the schema above (e.g. "
34 "string of latin letters in an integer field). "
35 "In the Bulk API it may mean that one of the "
36 "records in the bulk operation failed. See the "
37 "\"error\" part of the response for details."}
38 R401 = {"schema": {"properties": ERROR_TEMPLATE}, "description": "Access to this route/resource requires "
39 "authentication, but you did not provide any "
40 "credentials."}
41 R403 = {"schema": {"properties": ERROR_TEMPLATE}, "description": "Access to this route/resource requires "
42 "authentication, and you provided the wrong "
43 "credentials. This includes situations where you "
44 "are authenticated successfully via your API "
45 "key, but you are not the owner of a specific "
46 "resource and are therefore barred from "
47 "updating/deleting it."}
48 R404 = {"schema": {"properties": ERROR_TEMPLATE}, "description": "Resource not found"}
49 R409 = {"schema": {"properties": ERROR_TEMPLATE}, "description": "This resource or one it depends on is currently "
50 "locked for editing by another user, and you may "
51 "not submit changes to it at this time"}
52 R500 = {"schema": {"properties": ERROR_TEMPLATE}, "description": "Unable to retrieve the recource. This record "
53 "contains bad data"}
55 SWAG_API_KEY_REQ_PARAM = {
56 "description": "<div class=\"search-query-docs\">Go to 'MY ACCOUNT' and 'Settings' to find your API key. If there is no key, click 'Generate a new API key'. If you do not see that button, contact us.",
57 "required": True,
58 "type": "string",
59 "name": "api_key",
60 "in": "query"
61 }
63 @classmethod
64 @property
65 def SWAG_TAG(cls):
66 raise RuntimeError('You must override this class constant in every subclass.')
68 @classmethod
69 def _add_swag_tag(cls, template):
70 template['tags'].append(cls.SWAG_TAG)
71 return template
73 @classmethod
74 def _add_api_key(cls, template, optional=False):
75 # ~~->APIKey:Feature~~
76 api_key_param = deepcopy(cls.SWAG_API_KEY_REQ_PARAM)
77 if optional:
78 api_key_param['required'] = False
79 api_key_param['description'] = "<div class=\"search-query-docs\"><em>Note this parameter is optional for " \
80 "this route - you could, but don't have to supply a key. Doing so grants " \
81 "you access to records of yours that are not public, in addition to all " \
82 "public records.</em> Go to 'MY ACCOUNT' and 'Settings' to find your API key. If there is no key, click 'Generate a new API key'. If you do not see that button, contact us."
83 template["parameters"].insert(0, api_key_param)
84 return template
86 @classmethod
87 def _build_swag_response(cls, template, api_key_optional_override=None, api_key_override=None):
88 """
89 Construct the swagger response structure upon a template
90 :param template
91 :param api_key_optional_override: override the class-level value of API_KEY_OPTIONAL
92 :return: an updated template
93 """
94 template = deepcopy(template)
95 cls._add_swag_tag(template)
96 if api_key_override is not False:
97 if api_key_optional_override is not None:
98 cls._add_api_key(template, optional=api_key_optional_override)
99 elif hasattr(cls, 'API_KEY_OPTIONAL'):
100 cls._add_api_key(template, optional=cls.API_KEY_OPTIONAL)
102 return template
105class Api400Error(Exception):
106 pass
109class Api401Error(Exception):
110 pass
113class Api403Error(Exception):
114 pass
117class Api404Error(Exception):
118 pass
121class Api409Error(Exception):
122 """
123 API error to throw if a resource being edited is locked
124 """
125 pass
128class Api500Error(Exception):
129 pass
132class DataObjectJsonEncoder(json.JSONEncoder):
133 def default(self, o):
134 return o.data
137class ModelJsonEncoder(json.JSONEncoder):
138 def default(self, o):
139 return o.data
142def created(obj, location):
143 app.logger.info("Sending 201 Created: {x}".format(x=location))
144 t = deepcopy(CREATED_TEMPLATE)
145 t['status'] = "created"
146 t['id'] = obj.id
147 t['location'] = location
148 resp = respond(json.dumps(t), 201)
149 resp.headers["Location"] = location
150 resp.status_code = 201
151 return resp
154def bulk_created(ids_and_locations):
155 app.logger.info("Sending 201 Created for bulk request")
156 out = []
157 for id, loc in ids_and_locations:
158 t = deepcopy(CREATED_TEMPLATE)
159 t['status'] = "created"
160 t['id'] = id
161 t['location'] = loc
162 out.append(t)
164 resp = respond(json.dumps(out), 201)
165 resp.status_code = 201
166 return resp
169def no_content():
170 return respond("", 204)
173def jsonify_data_object(do):
174 data = json.dumps(do, cls=DataObjectJsonEncoder)
175 return respond(data, 200)
178def jsonify_models(models):
179 data = json.dumps(models, cls=ModelJsonEncoder)
181 metadata = {}
182 for k in LINK_HEADERS + TOTAL_RESULTS_COUNT:
183 if k in models.data:
184 metadata[k] = models.data[k]
186 return respond(data, 200, metadata=metadata)
189def generate_link_headers(metadata):
190 """
191 Generate Link: HTTP headers for API navigation.
193 :param metadata: Dictionary with none, some or all of the
194 keys 'next', 'prev' and 'last' defined. The values are the
195 corresponding pre-generated links.
196 """
197 link_metadata = {k: v for k, v in metadata.items() if k in LINK_HEADERS}
199 links = []
200 for k, v in link_metadata.items():
201 links.append(Link(v, rel=k)) # e.g. Link("https://example.com/foo", rel="next")
203 return str(LinkHeader(links)) # RFC compliant headers e.g.
204 # <https://example.com/foo>; rel=next, <https://example.com/bar>; rel=last
207def respond(data, status, metadata=None):
208 # avoid subtle bugs, don't use mutable objects as default vals in Python
209 # https://pythonconquerstheuniverse.wordpress.com/category/python-gotchas/
210 if metadata is None:
211 metadata = {}
213 headers = {'Access-Control-Allow-Origin': '*'}
214 link = generate_link_headers(metadata)
215 if link:
216 headers['Link'] = link
218 if 'total' in metadata:
219 headers['X-Total-Count'] = metadata['total']
221 callback = request.args.get('callback', False)
222 if callback:
223 content = str(callback) + '(' + str(data) + ')'
224 return app.response_class(content, status, headers, mimetype='application/javascript')
225 else:
226 return app.response_class(data, status, headers, mimetype='application/json')
229@app.errorhandler(Api400Error)
230def bad_request(error):
231 magic = uuid.uuid1()
232 app.logger.info("Sending 400 Bad Request from client: {x} (ref: {y})".format(x=str(error), y=magic))
233 t = deepcopy(ERROR_TEMPLATE)
234 t['status'] = 'bad_request'
235 t['error'] = str(error) + " (ref: {y})".format(y=magic)
236 return respond(json.dumps(t), 400)
239@app.errorhandler(Api404Error)
240def not_found(error):
241 magic = uuid.uuid1()
242 app.logger.info("Sending 404 Not Found from client: {x} (ref: {y})".format(x=str(error), y=magic))
243 t = deepcopy(ERROR_TEMPLATE)
244 t['status'] = 'not_found'
245 t['error'] = str(error) + " (ref: {y})".format(y=magic)
246 return respond(json.dumps(t), 404)
249@app.errorhandler(Api401Error)
250def unauthorised(error):
251 magic = uuid.uuid1()
252 app.logger.info("Sending 401 Unauthorised from client: {x} (ref: {y})".format(x=str(error), y=magic))
253 t = deepcopy(ERROR_TEMPLATE)
254 t['status'] = 'unauthorised'
255 t['error'] = str(error) + " (ref: {y})".format(y=magic)
256 return respond(json.dumps(t), 401)
259@app.errorhandler(Api403Error)
260def forbidden(error):
261 magic = uuid.uuid1()
262 app.logger.info("Sending 403 Forbidden from client: {x} (ref: {y})".format(x=str(error), y=magic))
263 t = deepcopy(ERROR_TEMPLATE)
264 t['status'] = 'forbidden'
265 t['error'] = str(error) + " (ref: {y})".format(y=magic)
266 return respond(json.dumps(t), 403)
269@app.errorhandler(Api500Error)
270def bad_request(error):
271 magic = uuid.uuid1()
272 app.logger.info("Sending 500 Bad Request from client: {x} (ref: {y})".format(x=str(error), y=magic))
273 t = deepcopy(ERROR_TEMPLATE)
274 t['status'] = 'Unable to retrieve the recource.'
275 t['error'] = str(error) + " (ref: {y})".format(y=magic)
276 return respond(json.dumps(t), 500)