2021-01-05 20:43:39 +00:00
|
|
|
import flask; from flask import request
|
2017-05-02 04:23:16 +00:00
|
|
|
import functools
|
2021-01-05 20:43:39 +00:00
|
|
|
import time
|
|
|
|
|
|
|
|
from voussoirkit import passwordy
|
2017-05-02 04:23:16 +00:00
|
|
|
|
2018-01-10 05:21:15 +00:00
|
|
|
import etiquette
|
|
|
|
|
2017-12-16 20:25:01 +00:00
|
|
|
from . import jsonify
|
2017-05-02 04:23:16 +00:00
|
|
|
|
2021-01-05 20:43:39 +00:00
|
|
|
def cached_endpoint(max_age):
|
|
|
|
'''
|
|
|
|
The cached_endpoint decorator can be used on slow endpoints that don't need
|
|
|
|
to be constantly updated or endpoints that produce large, static responses.
|
|
|
|
|
|
|
|
WARNING: The return value of the endpoint is shared with all users.
|
|
|
|
You should never use this cache on an endpoint that provides private
|
|
|
|
or personalized data, and you should not try to pass other headers through
|
|
|
|
the response.
|
|
|
|
|
|
|
|
When the function is run, its return value is stored and a random etag is
|
|
|
|
generated so that subsequent runs can respond with 304. This way, large
|
|
|
|
response bodies do not need to be transmitted often.
|
|
|
|
|
|
|
|
Given a nonzero max_age, the endpoint will only be run once per max_age
|
|
|
|
seconds on a global basis (not per-user). This way, you can prevent a slow
|
|
|
|
function from being run very often. In-between requests will just receive
|
|
|
|
the previous return value (still using 200 or 304 as appropriate for the
|
|
|
|
client's provided etag).
|
|
|
|
|
|
|
|
An example use case would be large-sized data dumps that don't need to be
|
|
|
|
precisely up to date every time.
|
|
|
|
'''
|
|
|
|
state = {
|
|
|
|
'max_age': max_age,
|
|
|
|
'stored_value': None,
|
|
|
|
'stored_etag': None,
|
|
|
|
'headers': {'ETag': None, 'Cache-Control': f'max-age={max_age}'},
|
|
|
|
'last_run': 0,
|
|
|
|
}
|
|
|
|
|
|
|
|
def wrapper(function):
|
|
|
|
@functools.wraps(function)
|
|
|
|
def wrapped(*args, **kwargs):
|
|
|
|
if (not state['max_age']) or (time.time() - state['last_run'] > state['max_age']):
|
|
|
|
value = function(*args, **kwargs)
|
|
|
|
if isinstance(value, flask.Response):
|
|
|
|
if value.headers.get('Content-Type'):
|
|
|
|
state['headers']['Content-Type'] = value.headers.get('Content-Type')
|
|
|
|
value = value.response
|
|
|
|
if value != state['stored_value']:
|
|
|
|
state['stored_value'] = value
|
|
|
|
state['stored_etag'] = passwordy.random_hex(20)
|
|
|
|
state['headers']['ETag'] = state['stored_etag']
|
|
|
|
state['last_run'] = time.time()
|
|
|
|
else:
|
|
|
|
value = state['stored_value']
|
|
|
|
|
|
|
|
client_etag = request.headers.get('If-None-Match', None)
|
|
|
|
if client_etag == state['stored_etag']:
|
|
|
|
response = flask.Response(status=304, headers=state['headers'])
|
|
|
|
else:
|
|
|
|
response = flask.Response(value, status=200, headers=state['headers'])
|
|
|
|
|
|
|
|
return response
|
|
|
|
return wrapped
|
|
|
|
return wrapper
|
|
|
|
|
2018-01-10 05:21:15 +00:00
|
|
|
def catch_etiquette_exception(function):
|
|
|
|
'''
|
|
|
|
If an EtiquetteException is raised, automatically catch it and convert it
|
2018-09-23 21:57:25 +00:00
|
|
|
into a json response so that the user isn't receiving error 500.
|
2018-01-10 05:21:15 +00:00
|
|
|
'''
|
|
|
|
@functools.wraps(function)
|
|
|
|
def wrapped(*args, **kwargs):
|
|
|
|
try:
|
|
|
|
return function(*args, **kwargs)
|
2020-09-19 10:08:45 +00:00
|
|
|
except etiquette.exceptions.EtiquetteException as exc:
|
|
|
|
if isinstance(exc, etiquette.exceptions.NoSuch):
|
2018-01-10 05:21:15 +00:00
|
|
|
status = 404
|
|
|
|
else:
|
|
|
|
status = 400
|
2021-01-01 20:56:05 +00:00
|
|
|
response = exc.jsonify()
|
2018-01-10 05:21:15 +00:00
|
|
|
response = jsonify.make_json_response(response, status=status)
|
|
|
|
flask.abort(response)
|
|
|
|
return wrapped
|
|
|
|
|
2017-05-02 04:23:16 +00:00
|
|
|
def required_fields(fields, forbid_whitespace=False):
|
|
|
|
'''
|
|
|
|
Declare that the endpoint requires certain POST body fields. Without them,
|
|
|
|
we respond with 400 and a message.
|
|
|
|
|
|
|
|
forbid_whitespace:
|
|
|
|
If True, then providing the field is not good enough. It must also
|
|
|
|
contain at least some non-whitespace characters.
|
|
|
|
'''
|
|
|
|
def wrapper(function):
|
|
|
|
@functools.wraps(function)
|
|
|
|
def wrapped(*args, **kwargs):
|
|
|
|
for requirement in fields:
|
|
|
|
missing = (
|
|
|
|
requirement not in request.form or
|
|
|
|
(forbid_whitespace and request.form[requirement].strip() == '')
|
|
|
|
)
|
|
|
|
if missing:
|
|
|
|
response = {
|
|
|
|
'error_type': 'MISSING_FIELDS',
|
|
|
|
'error_message': 'Required fields: %s' % ', '.join(fields),
|
|
|
|
}
|
|
|
|
response = jsonify.make_json_response(response, status=400)
|
|
|
|
return response
|
|
|
|
|
|
|
|
return function(*args, **kwargs)
|
|
|
|
return wrapped
|
|
|
|
return wrapper
|