Move cached_endpoint, required_fields to flasktools.

This commit is contained in:
voussoir 2021-09-03 12:45:07 -07:00
parent e883409daf
commit d4025e865b
No known key found for this signature in database
GPG key ID: 5F7554F8C26DACCB
6 changed files with 27 additions and 143 deletions

View file

@ -1,93 +1,11 @@
import flask; from flask import request import flask; from flask import request
import functools import functools
import time
import werkzeug.datastructures import werkzeug.datastructures
from voussoirkit import dotdict
from voussoirkit import flasktools from voussoirkit import flasktools
from voussoirkit import passwordy
from voussoirkit import sentinel
import etiquette import etiquette
NOT_CACHED = sentinel.Sentinel('not cached', truthyness=False)
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).
With max_age=0, the function will be run every time to check if the value
has changed, but if it hasn't changed then we can still send a 304 response,
saving bandwidth.
An example use case would be large-sized data dumps that don't need to be
precisely up to date every time.
'''
if max_age < 0:
raise ValueError(f'max_age should be positive, not {max_age}.')
state = dotdict.DotDict({
'max_age': max_age,
'stored_value': NOT_CACHED,
'stored_etag': None,
'headers': {'ETag': None, 'Cache-Control': f'max-age={max_age}'},
'last_run': 0,
})
def wrapper(function):
def get_value(*args, **kwargs):
can_bail = (
state.stored_value is not NOT_CACHED and
state.max_age != 0 and
(time.time() - state.last_run) < state.max_age
)
if can_bail:
return state.stored_value
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()
return value
@functools.wraps(function)
def wrapped(*args, **kwargs):
value = get_value(*args, **kwargs)
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
def catch_etiquette_exception(function): def catch_etiquette_exception(function):
''' '''
If an EtiquetteException is raised, automatically catch it and convert it If an EtiquetteException is raised, automatically catch it and convert it
@ -128,32 +46,3 @@ def give_theme_cookie(function):
return response return response
return wrapped return wrapped
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 = flasktools.make_json_response(response, status=400)
return response
return function(*args, **kwargs)
return wrapped
return wrapper

View file

@ -9,7 +9,6 @@ from voussoirkit import stringtools
import etiquette import etiquette
from .. import common from .. import common
from .. import decorators
site = common.site site = common.site
session_manager = common.session_manager session_manager = common.session_manager
@ -57,7 +56,7 @@ def get_album_zip(album_id):
return flask.Response(streamed_zip, headers=outgoing_headers) return flask.Response(streamed_zip, headers=outgoing_headers)
@site.route('/album/<album_id>/add_child', methods=['POST']) @site.route('/album/<album_id>/add_child', methods=['POST'])
@decorators.required_fields(['child_id'], forbid_whitespace=True) @flasktools.required_fields(['child_id'], forbid_whitespace=True)
def post_album_add_child(album_id): def post_album_add_child(album_id):
album = common.P_album(album_id, response_type='json') album = common.P_album(album_id, response_type='json')
@ -69,7 +68,7 @@ def post_album_add_child(album_id):
return flasktools.make_json_response(response) return flasktools.make_json_response(response)
@site.route('/album/<album_id>/remove_child', methods=['POST']) @site.route('/album/<album_id>/remove_child', methods=['POST'])
@decorators.required_fields(['child_id'], forbid_whitespace=True) @flasktools.required_fields(['child_id'], forbid_whitespace=True)
def post_album_remove_child(album_id): def post_album_remove_child(album_id):
album = common.P_album(album_id, response_type='json') album = common.P_album(album_id, response_type='json')
@ -98,7 +97,7 @@ def post_album_refresh_directories(album_id):
return flasktools.make_json_response({}) return flasktools.make_json_response({})
@site.route('/album/<album_id>/set_thumbnail_photo', methods=['POST']) @site.route('/album/<album_id>/set_thumbnail_photo', methods=['POST'])
@decorators.required_fields(['photo_id'], forbid_whitespace=True) @flasktools.required_fields(['photo_id'], forbid_whitespace=True)
def post_album_set_thumbnail_photo(album_id): def post_album_set_thumbnail_photo(album_id):
album = common.P_album(album_id, response_type='json') album = common.P_album(album_id, response_type='json')
photo = common.P_photo(request.form['photo_id'], response_type='json') photo = common.P_photo(request.form['photo_id'], response_type='json')
@ -109,7 +108,7 @@ def post_album_set_thumbnail_photo(album_id):
# Album photo operations ########################################################################### # Album photo operations ###########################################################################
@site.route('/album/<album_id>/add_photo', methods=['POST']) @site.route('/album/<album_id>/add_photo', methods=['POST'])
@decorators.required_fields(['photo_id'], forbid_whitespace=True) @flasktools.required_fields(['photo_id'], forbid_whitespace=True)
def post_album_add_photo(album_id): def post_album_add_photo(album_id):
''' '''
Add a photo or photos to this album. Add a photo or photos to this album.
@ -123,7 +122,7 @@ def post_album_add_photo(album_id):
return flasktools.make_json_response(response) return flasktools.make_json_response(response)
@site.route('/album/<album_id>/remove_photo', methods=['POST']) @site.route('/album/<album_id>/remove_photo', methods=['POST'])
@decorators.required_fields(['photo_id'], forbid_whitespace=True) @flasktools.required_fields(['photo_id'], forbid_whitespace=True)
def post_album_remove_photo(album_id): def post_album_remove_photo(album_id):
''' '''
Remove a photo or photos from this album. Remove a photo or photos from this album.
@ -195,7 +194,7 @@ def post_album_show_in_folder(album_id):
# Album listings ################################################################################### # Album listings ###################################################################################
@site.route('/all_albums.json') @site.route('/all_albums.json')
@decorators.cached_endpoint(max_age=15) @flasktools.cached_endpoint(max_age=15)
def get_all_album_names(): def get_all_album_names():
all_albums = {album.id: album.display_name for album in common.P.get_albums()} all_albums = {album.id: album.display_name for album in common.P.get_albums()}
response = {'albums': all_albums} response = {'albums': all_albums}

View file

@ -5,7 +5,6 @@ from voussoirkit import flasktools
import etiquette import etiquette
from .. import common from .. import common
from .. import decorators
site = common.site site = common.site
session_manager = common.session_manager session_manager = common.session_manager
@ -45,7 +44,7 @@ def get_bookmarks_json():
# Bookmark create and delete ####################################################################### # Bookmark create and delete #######################################################################
@site.route('/bookmarks/create_bookmark', methods=['POST']) @site.route('/bookmarks/create_bookmark', methods=['POST'])
@decorators.required_fields(['url'], forbid_whitespace=True) @flasktools.required_fields(['url'], forbid_whitespace=True)
def post_bookmark_create(): def post_bookmark_create():
url = request.form['url'] url = request.form['url']
title = request.form.get('title', None) title = request.form.get('title', None)

View file

@ -10,7 +10,6 @@ from voussoirkit import stringtools
import etiquette import etiquette
from .. import common from .. import common
from .. import decorators
from .. import helpers from .. import helpers
site = common.site site = common.site
@ -98,7 +97,7 @@ def post_photo_add_remove_tag_core(photo_ids, tagname, add_or_remove):
return flasktools.make_json_response(response) return flasktools.make_json_response(response)
@site.route('/photo/<photo_id>/add_tag', methods=['POST']) @site.route('/photo/<photo_id>/add_tag', methods=['POST'])
@decorators.required_fields(['tagname'], forbid_whitespace=True) @flasktools.required_fields(['tagname'], forbid_whitespace=True)
def post_photo_add_tag(photo_id): def post_photo_add_tag(photo_id):
''' '''
Add a tag to this photo. Add a tag to this photo.
@ -111,7 +110,7 @@ def post_photo_add_tag(photo_id):
return response return response
@site.route('/photo/<photo_id>/copy_tags', methods=['POST']) @site.route('/photo/<photo_id>/copy_tags', methods=['POST'])
@decorators.required_fields(['other_photo'], forbid_whitespace=True) @flasktools.required_fields(['other_photo'], forbid_whitespace=True)
def post_photo_copy_tags(photo_id): def post_photo_copy_tags(photo_id):
''' '''
Copy the tags from another photo. Copy the tags from another photo.
@ -123,7 +122,7 @@ def post_photo_copy_tags(photo_id):
return flasktools.make_json_response([tag.jsonify(minimal=True) for tag in photo.get_tags()]) return flasktools.make_json_response([tag.jsonify(minimal=True) for tag in photo.get_tags()])
@site.route('/photo/<photo_id>/remove_tag', methods=['POST']) @site.route('/photo/<photo_id>/remove_tag', methods=['POST'])
@decorators.required_fields(['tagname'], forbid_whitespace=True) @flasktools.required_fields(['tagname'], forbid_whitespace=True)
def post_photo_remove_tag(photo_id): def post_photo_remove_tag(photo_id):
''' '''
Remove a tag from this photo. Remove a tag from this photo.
@ -136,7 +135,7 @@ def post_photo_remove_tag(photo_id):
return response return response
@site.route('/batch/photos/add_tag', methods=['POST']) @site.route('/batch/photos/add_tag', methods=['POST'])
@decorators.required_fields(['photo_ids', 'tagname'], forbid_whitespace=True) @flasktools.required_fields(['photo_ids', 'tagname'], forbid_whitespace=True)
def post_batch_photos_add_tag(): def post_batch_photos_add_tag():
response = post_photo_add_remove_tag_core( response = post_photo_add_remove_tag_core(
photo_ids=request.form['photo_ids'], photo_ids=request.form['photo_ids'],
@ -146,7 +145,7 @@ def post_batch_photos_add_tag():
return response return response
@site.route('/batch/photos/remove_tag', methods=['POST']) @site.route('/batch/photos/remove_tag', methods=['POST'])
@decorators.required_fields(['photo_ids', 'tagname'], forbid_whitespace=True) @flasktools.required_fields(['photo_ids', 'tagname'], forbid_whitespace=True)
def post_batch_photos_remove_tag(): def post_batch_photos_remove_tag():
response = post_photo_add_remove_tag_core( response = post_photo_add_remove_tag_core(
photo_ids=request.form['photo_ids'], photo_ids=request.form['photo_ids'],
@ -194,7 +193,7 @@ def post_photo_refresh_metadata(photo_id):
return response return response
@site.route('/batch/photos/refresh_metadata', methods=['POST']) @site.route('/batch/photos/refresh_metadata', methods=['POST'])
@decorators.required_fields(['photo_ids'], forbid_whitespace=True) @flasktools.required_fields(['photo_ids'], forbid_whitespace=True)
def post_batch_photos_refresh_metadata(): def post_batch_photos_refresh_metadata():
response = post_photo_refresh_metadata_core(photo_ids=request.form['photo_ids']) response = post_photo_refresh_metadata_core(photo_ids=request.form['photo_ids'])
return response return response
@ -238,14 +237,14 @@ def post_photo_show_in_folder(photo_id):
flask.abort(501) flask.abort(501)
@site.route('/batch/photos/set_searchhidden', methods=['POST']) @site.route('/batch/photos/set_searchhidden', methods=['POST'])
@decorators.required_fields(['photo_ids'], forbid_whitespace=True) @flasktools.required_fields(['photo_ids'], forbid_whitespace=True)
def post_batch_photos_set_searchhidden(): def post_batch_photos_set_searchhidden():
photo_ids = request.form['photo_ids'] photo_ids = request.form['photo_ids']
response = post_batch_photos_searchhidden_core(photo_ids=photo_ids, searchhidden=True) response = post_batch_photos_searchhidden_core(photo_ids=photo_ids, searchhidden=True)
return response return response
@site.route('/batch/photos/unset_searchhidden', methods=['POST']) @site.route('/batch/photos/unset_searchhidden', methods=['POST'])
@decorators.required_fields(['photo_ids'], forbid_whitespace=True) @flasktools.required_fields(['photo_ids'], forbid_whitespace=True)
def post_batch_photos_unset_searchhidden(): def post_batch_photos_unset_searchhidden():
photo_ids = request.form['photo_ids'] photo_ids = request.form['photo_ids']
response = post_batch_photos_searchhidden_core(photo_ids=photo_ids, searchhidden=False) response = post_batch_photos_searchhidden_core(photo_ids=photo_ids, searchhidden=False)
@ -258,7 +257,7 @@ def get_clipboard_page():
return common.render_template(request, 'clipboard.html') return common.render_template(request, 'clipboard.html')
@site.route('/batch/photos', methods=['POST']) @site.route('/batch/photos', methods=['POST'])
@decorators.required_fields(['photo_ids'], forbid_whitespace=True) @flasktools.required_fields(['photo_ids'], forbid_whitespace=True)
def post_batch_photos(): def post_batch_photos():
''' '''
Return a list of photo.jsonify() for each requested photo id. Return a list of photo.jsonify() for each requested photo id.
@ -273,7 +272,7 @@ def post_batch_photos():
return response return response
@site.route('/batch/photos/photo_card', methods=['POST']) @site.route('/batch/photos/photo_card', methods=['POST'])
@decorators.required_fields(['photo_ids'], forbid_whitespace=True) @flasktools.required_fields(['photo_ids'], forbid_whitespace=True)
def post_batch_photos_photo_cards(): def post_batch_photos_photo_cards():
photo_ids = request.form['photo_ids'] photo_ids = request.form['photo_ids']
@ -328,7 +327,7 @@ def get_batch_photos_download_zip(zip_token):
return flask.Response(streamed_zip, headers=outgoing_headers) return flask.Response(streamed_zip, headers=outgoing_headers)
@site.route('/batch/photos/download_zip', methods=['POST']) @site.route('/batch/photos/download_zip', methods=['POST'])
@decorators.required_fields(['photo_ids'], forbid_whitespace=True) @flasktools.required_fields(['photo_ids'], forbid_whitespace=True)
def post_batch_photos_download_zip(): def post_batch_photos_download_zip():
''' '''
Initiating file downloads via POST requests is a bit clunky and unreliable, Initiating file downloads via POST requests is a bit clunky and unreliable,

View file

@ -5,7 +5,6 @@ from voussoirkit import flasktools
import etiquette import etiquette
from .. import common from .. import common
from .. import decorators
site = common.site site = common.site
session_manager = common.session_manager session_manager = common.session_manager
@ -57,7 +56,7 @@ def post_tag_edit(tagname):
return response return response
@site.route('/tag/<tagname>/add_child', methods=['POST']) @site.route('/tag/<tagname>/add_child', methods=['POST'])
@decorators.required_fields(['child_name'], forbid_whitespace=True) @flasktools.required_fields(['child_name'], forbid_whitespace=True)
def post_tag_add_child(tagname): def post_tag_add_child(tagname):
parent = common.P_tag(tagname, response_type='json') parent = common.P_tag(tagname, response_type='json')
child = common.P_tag(request.form['child_name'], response_type='json') child = common.P_tag(request.form['child_name'], response_type='json')
@ -66,7 +65,7 @@ def post_tag_add_child(tagname):
return flasktools.make_json_response(response) return flasktools.make_json_response(response)
@site.route('/tag/<tagname>/add_synonym', methods=['POST']) @site.route('/tag/<tagname>/add_synonym', methods=['POST'])
@decorators.required_fields(['syn_name'], forbid_whitespace=True) @flasktools.required_fields(['syn_name'], forbid_whitespace=True)
def post_tag_add_synonym(tagname): def post_tag_add_synonym(tagname):
syn_name = request.form['syn_name'] syn_name = request.form['syn_name']
@ -77,7 +76,7 @@ def post_tag_add_synonym(tagname):
return flasktools.make_json_response(response) return flasktools.make_json_response(response)
@site.route('/tag/<tagname>/remove_child', methods=['POST']) @site.route('/tag/<tagname>/remove_child', methods=['POST'])
@decorators.required_fields(['child_name'], forbid_whitespace=True) @flasktools.required_fields(['child_name'], forbid_whitespace=True)
def post_tag_remove_child(tagname): def post_tag_remove_child(tagname):
parent = common.P_tag(tagname, response_type='json') parent = common.P_tag(tagname, response_type='json')
child = common.P_tag(request.form['child_name'], response_type='json') child = common.P_tag(request.form['child_name'], response_type='json')
@ -86,7 +85,7 @@ def post_tag_remove_child(tagname):
return flasktools.make_json_response(response) return flasktools.make_json_response(response)
@site.route('/tag/<tagname>/remove_synonym', methods=['POST']) @site.route('/tag/<tagname>/remove_synonym', methods=['POST'])
@decorators.required_fields(['syn_name'], forbid_whitespace=True) @flasktools.required_fields(['syn_name'], forbid_whitespace=True)
def post_tag_remove_synonym(tagname): def post_tag_remove_synonym(tagname):
syn_name = request.form['syn_name'] syn_name = request.form['syn_name']
@ -99,7 +98,7 @@ def post_tag_remove_synonym(tagname):
# Tag listings ##################################################################################### # Tag listings #####################################################################################
@site.route('/all_tags.json') @site.route('/all_tags.json')
@decorators.cached_endpoint(max_age=15) @flasktools.cached_endpoint(max_age=15)
def get_all_tag_names(): def get_all_tag_names():
all_tags = list(common.P.get_all_tag_names()) all_tags = list(common.P.get_all_tag_names())
all_synonyms = common.P.get_all_synonyms() all_synonyms = common.P.get_all_synonyms()
@ -158,7 +157,7 @@ def get_tags_json():
# Tag create and delete ############################################################################ # Tag create and delete ############################################################################
@site.route('/tags/create_tag', methods=['POST']) @site.route('/tags/create_tag', methods=['POST'])
@decorators.required_fields(['name'], forbid_whitespace=True) @flasktools.required_fields(['name'], forbid_whitespace=True)
def post_tag_create(): def post_tag_create():
name = request.form['name'] name = request.form['name']
description = request.form.get('description', None) description = request.form.get('description', None)
@ -168,7 +167,7 @@ def post_tag_create():
return flasktools.make_json_response(response) return flasktools.make_json_response(response)
@site.route('/tags/easybake', methods=['POST']) @site.route('/tags/easybake', methods=['POST'])
@decorators.required_fields(['easybake_string'], forbid_whitespace=True) @flasktools.required_fields(['easybake_string'], forbid_whitespace=True)
def post_tag_easybake(): def post_tag_easybake():
easybake_string = request.form['easybake_string'] easybake_string = request.form['easybake_string']

View file

@ -5,7 +5,6 @@ from voussoirkit import flasktools
import etiquette import etiquette
from .. import common from .. import common
from .. import decorators
from .. import sessions from .. import sessions
site = common.site site = common.site
@ -67,7 +66,7 @@ def get_login():
return response return response
@site.route('/login', methods=['POST']) @site.route('/login', methods=['POST'])
@decorators.required_fields(['username', 'password']) @flasktools.required_fields(['username', 'password'])
def post_login(): def post_login():
session = session_manager.get(request) session = session_manager.get(request)
if session.user: if session.user:
@ -107,7 +106,7 @@ def get_register():
return flask.redirect('/login') return flask.redirect('/login')
@site.route('/register', methods=['POST']) @site.route('/register', methods=['POST'])
@decorators.required_fields(['username', 'password_1', 'password_2']) @flasktools.required_fields(['username', 'password_1', 'password_2'])
def post_register(): def post_register():
session = session_manager.get(request) session = session_manager.get(request)
if session.user: if session.user: