diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a8e70da3..11d849cb 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,17 +1,30 @@ Changelog --------- +0.11.0 (2018-11-09) ++++++++++++++++++++ + +Features: + +- *Backwards-incompatible*: Rework of the ETag feature. It is now accesible + using dedicated ``Blueprint.etag`` decorator. ``check_etag`` and ``set_etag`` + are methods of ``Blueprint`` and ``etag.INCLUDE_HEADERS`` is replaced with + ``Blueprint.ETAG_INCLUDE_HEADERS``. It is enabled by default (only on views + decorated with ``Blueprint.etag``) and disabled with ``ETAG_DISABLED`` + application configuration parameter. ``is_etag_enabled`` is now private. + 0.10.0 (2018-10-24) +++++++++++++++++++ Features: - *Backwards-incompatible*: Don't prefix all routes in the spec with - `APPLICATION_ROOT`. If using OpenAPI v2, set `APPLICATION_ROOT` as - `basePath`. If using OpenAPI v3, the user should specify `servers` manually. -- *Backwards-incompatible*: In testing and debug modes, `verify_check_etag` not - only logs a warning but also raises `CheckEtagNotCalledError` if `check_etag` - is not called in a resource that needs it. + ``APPLICATION_ROOT``. If using OpenAPI v2, set ``APPLICATION_ROOT`` as + ``basePath``. If using OpenAPI v3, the user should specify ``servers`` + manually. +- *Backwards-incompatible*: In testing and debug modes, ``verify_check_etag`` + not only logs a warning but also raises ``CheckEtagNotCalledError`` if + ``check_etag`` is not called in a resource that needs it. 0.9.2 (2018-10-16) ++++++++++++++++++ diff --git a/docs/api_reference.rst b/docs/api_reference.rst index fd674728..f472e8ca 100644 --- a/docs/api_reference.rst +++ b/docs/api_reference.rst @@ -22,6 +22,9 @@ Blueprint .. automethod:: arguments .. automethod:: response .. automethod:: paginate + .. automethod:: etag + .. automethod:: check_etag + .. automethod:: set_etag Pagination ========== @@ -30,11 +33,3 @@ Pagination :members: .. autoclass:: flask_rest_api.pagination.PaginationParameters :members: - -ETag -==== - -.. autofunction:: flask_rest_api.etag.is_etag_enabled -.. autofunction:: flask_rest_api.etag.is_etag_enabled_for_request -.. autofunction:: flask_rest_api.etag.check_etag -.. autofunction:: flask_rest_api.etag.set_etag diff --git a/docs/etag.rst b/docs/etag.rst index 76932cb2..7a6cba4d 100644 --- a/docs/etag.rst +++ b/docs/etag.rst @@ -14,9 +14,9 @@ The first case is mostly useful to limit the bandwidth usage, the latter addresses the case where two clients update a resource at the same time (known as the "*lost update problem*"). -The ETag featured is enabled with the `ETAG_ENABLED` application parameter. It -can be disabled function-wise by passing `disable_etag=False` to the -:meth:`Blueprint.response ` decorator. +The ETag featured is available through the +:meth:`Blueprint.etag ` decorator. It can be disabled globally +with the `ETAG_DISABLED` application parameter. `flask-rest-api` provides helpers to compute ETag, but ultimately, only the developer knows what data is relevant to use as ETag source, so there can be @@ -29,23 +29,23 @@ The simplest case is when the ETag is computed using returned data, using the :class:`Schema ` that serializes the data. In this case, almost eveything is automatic. Only the call to -:meth:`check_etag ` is manual. +:meth:`Blueprint.check_etag ` is manual. The :class:`Schema ` must be provided explicitly, even though it is the same as the response schema. .. code-block:: python - :emphasize-lines: 27,35 - - from flask_rest_api import check_etag + :emphasize-lines: 29,38 @blp.route('/') class Pet(MethodView): + @blp.etag @blp.response(PetSchema(many=True)) def get(self): return Pet.get() + @blp.etag @blp.arguments(PetSchema) @blp.response(PetSchema) def post(self, new_data): @@ -54,24 +54,27 @@ though it is the same as the response schema. @blp.route('/') class PetById(MethodView): + @blp.etag @blp.response(PetSchema) def get(self, pet_id): return Pet.get_by_id(pet_id) + @blp.etag @blp.arguments(PetSchema) @blp.response(PetSchema) def put(self, update_data, pet_id): pet = Pet.get_by_id(pet_id) # Check ETag is a manual action and schema must be provided - check_etag(pet, PetSchema) + blp.check_etag(pet, PetSchema) pet.update(update_data) return pet + @blp.etag @blp.response(code=204) def delete(self, pet_id): pet = Pet.get_by_id(pet_id) # Check ETag is a manual action and schema must be provided - check_etag(pet, PetSchema) + blp.check_etag(pet, PetSchema) Pet.delete(pet_id) ETag Computed with API Response Data Using Another Schema @@ -81,122 +84,132 @@ Sometimes, it is not possible to use the data returned by the view function as ETag data because it contains extra information that is irrelevant, like HATEOAS information, for instance. -In this case, a specific ETag schema can be provided as ``etag_schema`` keyword -argument to :meth:`Blueprint.response `. Then, it does not -need to be passed to :meth:`check_etag `. +In this case, a specific ETag schema should be provided to +:meth:`Blueprint.etag `. Then, it does not need to be passed to +:meth:`check_etag `. .. code-block:: python - :emphasize-lines: 7,12,19,24,28,32,36 - - from flask_rest_api import check_etag + :emphasize-lines: 4,9,18,23,29,33,38 @blp.route('/') class Pet(MethodView): - @blp.response( - PetSchema(many=True), etag_schema=PetEtagSchema(many=True)) + @blp.etag(PetEtagSchema(many=True)) + @blp.response(PetSchema(many=True)) def get(self): return Pet.get() + @blp.etag(PetEtagSchema) @blp.arguments(PetSchema) - @blp.response(PetSchema, etag_schema=PetEtagSchema) + @blp.response(PetSchema) def post(self, new_pet): return Pet.create(**new_data) @blp.route('/') class PetById(MethodView): - @blp.response(PetSchema, etag_schema=PetEtagSchema) + @blp.etag(PetEtagSchema) + @blp.response(PetSchema) def get(self, pet_id): return Pet.get_by_id(pet_id) + @blp.etag(PetEtagSchema) @blp.arguments(PetSchema) - @blp.response(PetSchema, etag_schema=PetEtagSchema) + @blp.response(PetSchema) def put(self, new_pet, pet_id): pet = Pet.get_by_id(pet_id) # Check ETag is a manual action and schema must be provided - check_etag(pet) + blp.check_etag(pet) pet.update(update_data) return pet - @blp.response(code=204, etag_schema=PetEtagSchema) + @blp.etag(PetEtagSchema) + @blp.response(code=204) def delete(self, pet_id): pet = self._get_pet(pet_id) # Check ETag is a manual action, ETag schema is used - check_etag(pet) + blp.check_etag(pet) Pet.delete(pet_id) ETag Computed on Arbitrary Data ------------------------------- The ETag can also be computed from arbitrary data by calling -:meth:`set_etag ` manually. +:meth:`Blueprint.set_etag ` manually. The example below illustrates this with no ETag schema, but it is also possible -to pass an ETag schema to :meth:`set_etag ` and -:meth:`check_etag ` or equivalently to -:meth:`Blueprint.response `. +to pass an ETag schema to :meth:`set_etag ` and +:meth:`check_etag ` or equivalently to +:meth:`Blueprint.etag `. .. code-block:: python - :emphasize-lines: 10,17,26,34,37,44 - - from flask_rest_api import check_etag, set_etag + :emphasize-lines: 4,9,12,17,23,27,30,36,39,42,47 @blp.route('/') class Pet(MethodView): + @blp.etag @blp.response(PetSchema(many=True)) def get(self): pets = Pet.get() # Compute ETag using arbitrary data - set_etag([pet.update_time for pet in pets]) + blp.set_etag([pet.update_time for pet in pets]) return pets + @blp.etag @blp.arguments(PetSchema) @blp.response(PetSchema) def post(self, new_data): # Compute ETag using arbitrary data - set_etag(new_data['update_time']) + blp.set_etag(new_data['update_time']) return Pet.create(**new_data) @blp.route('/') class PetById(MethodView): + @blp.etag @blp.response(PetSchema) def get(self, pet_id): # Compute ETag using arbitrary data - set_etag(new_data['update_time']) + blp.set_etag(new_data['update_time']) return Pet.get_by_id(pet_id) + @blp.etag @blp.arguments(PetSchema) @blp.response(PetSchema) def put(self, update_data, pet_id): pet = Pet.get_by_id(pet_id) # Check ETag is a manual action - check_etag(pet, ['update_time']) + blp.check_etag(pet, ['update_time']) pet.update(update_data) # Compute ETag using arbitrary data - set_etag(new_data['update_time']) + blp.set_etag(new_data['update_time']) return pet + @blp.etag @blp.response(code=204) def delete(self, pet_id): pet = Pet.get_by_id(pet_id) # Check ETag is a manual action - check_etag(pet, ['update_time']) + blp.check_etag(pet, ['update_time']) Pet.delete(pet_id) +ETag not checked warning +------------------------ + +It is up to the developer to call +:meth:`Blueprint.check_etag ` in the view function. It +can't be automatic. + +If ETag is enabled and :meth:`check_etag ` is not called, +a warning is logged at runtime. When in `DEBUG` or `TESTING` mode, an exception +is raised. + Include Headers Content in ETag ------------------------------- When ETag is computed with response data, that data may contain headers. It is up to the developer to decide whether this data should be part of the ETag. -By default, only pagination data is included in the ETag computation. The list -of headers to include is defined as: - -.. code-block:: python - - INCLUDE_HEADERS = ['X-Pagination'] - -It can be changed globally by mutating ``flask_rest_api.etag.INCLUDE_HEADERS``. +By default, only pagination header is included in the ETag computation. This +can be changed by customizing `Blueprint.ETAG_INCLUDE_HEADERS`. diff --git a/flask_rest_api/__init__.py b/flask_rest_api/__init__.py index 82e5a14b..7a73f11b 100644 --- a/flask_rest_api/__init__.py +++ b/flask_rest_api/__init__.py @@ -4,7 +4,6 @@ from .spec import APISpec, DocBlueprintMixin from .blueprint import Blueprint # noqa -from .etag import is_etag_enabled, check_etag, set_etag # noqa from .pagination import Page # noqa from .error_handler import ErrorHandlerMixin from .compat import APISPEC_VERSION_MAJOR diff --git a/flask_rest_api/blueprint.py b/flask_rest_api/blueprint.py index 4b9fd01d..7201a514 100644 --- a/flask_rest_api/blueprint.py +++ b/flask_rest_api/blueprint.py @@ -36,6 +36,7 @@ from .arguments import ArgumentsMixin from .response import ResponseMixin from .pagination import PaginationMixin +from .etag import EtagMixin from .exceptions import EndpointMethodDocAlreadyRegistedError from .compat import APISPEC_VERSION_MAJOR @@ -46,7 +47,8 @@ class Blueprint( - FlaskBlueprint, ArgumentsMixin, ResponseMixin, PaginationMixin): + FlaskBlueprint, + ArgumentsMixin, ResponseMixin, PaginationMixin, EtagMixin): """Blueprint that registers info in API documentation""" def __init__(self, *args, **kwargs): diff --git a/flask_rest_api/etag.py b/flask_rest_api/etag.py index e917f4a0..49ff47c4 100644 --- a/flask_rest_api/etag.py +++ b/flask_rest_api/etag.py @@ -1,7 +1,10 @@ """ETag feature""" +from functools import wraps + import hashlib +from marshmallow import Schema from flask import request, current_app, json from .exceptions import ( @@ -11,16 +14,9 @@ from .compat import MARSHMALLOW_VERSION_MAJOR -METHODS_NEEDING_CHECK_ETAG = ['PUT', 'PATCH', 'DELETE'] -METHODS_ALLOWING_SET_ETAG = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH'] - -# Can be mutated to specify which headers to use for ETag computation -INCLUDE_HEADERS = ['X-Pagination'] - - -def is_etag_enabled(app): +def _is_etag_enabled(): """Return True if ETag feature enabled application-wise""" - return app.config.get('ETAG_ENABLED', False) + return not current_app.config.get('ETAG_DISABLED', False) def _get_etag_ctx(): @@ -28,150 +24,199 @@ def _get_etag_ctx(): return get_appcontext()['etag'] -def disable_etag_for_request(): - _get_etag_ctx()['disabled'] = True - - -def is_etag_enabled_for_request(): - """Return True if ETag feature enabled for this request - - It is enabled if - - the feature is enabled application-wise and - - it is not disabled for this route - """ - return (is_etag_enabled(current_app) and - not _get_etag_ctx().get('disabled', False)) - - -def set_etag_schema(etag_schema): - _get_etag_ctx()['etag_schema'] = etag_schema - - -def _get_etag_schema(): - return _get_etag_ctx().get('etag_schema') - - -def _generate_etag(etag_data, etag_schema=None, extra_data=None): - """Generate an ETag from data - - etag_data: Data to use to compute ETag - etag_schema: Schema to dump data with before hashing - extra_data: Extra data to add before hashing - - Typically, extra_data is used to add pagination metadata to the hash. It is - not dumped through the Schema. - """ - if etag_schema is None: - raw_data = etag_data - else: - if isinstance(etag_schema, type): - etag_schema = etag_schema() - raw_data = etag_schema.dump(etag_data) - if MARSHMALLOW_VERSION_MAJOR < 3: - raw_data = raw_data[0] - if extra_data: - raw_data = (raw_data, extra_data) - # flask's json.dumps is needed here - # as vanilla json.dumps chokes on lazy_strings - data = json.dumps(raw_data, sort_keys=True) - return hashlib.sha1(bytes(data, 'utf-8')).hexdigest() - - -def check_precondition(): - """Check If-Match header is there - - Raise 428 if If-Match header missing - - Called automatically for PUT, PATCH and DELETE methods - """ - # TODO: other methods? - # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Match - if (is_etag_enabled_for_request() and - request.method in METHODS_NEEDING_CHECK_ETAG and - not request.if_match): - raise PreconditionRequired - - -def check_etag(etag_data, etag_schema=None): - """Compare If-Match header with computed ETag - - Raise 412 if If-Match-Header does not match. - - Must be called from resource code to check ETag. - - Unfortunately, there is no way to call it automatically. It is the - developer's responsability to do it. However, a warning is logged at - runtime if this function was not called. - """ - if is_etag_enabled_for_request(): - etag_schema = etag_schema or _get_etag_schema() - new_etag = _generate_etag(etag_data, etag_schema) - _get_etag_ctx()['etag_checked'] = True - if new_etag not in request.if_match: - raise PreconditionFailed - - -def verify_check_etag(): - """Verify check_etag was called in resource code - - Log a warning if ETag is enabled but check_etag was not called in - resource code in a PUT, PATCH or DELETE method. - - Raise CheckEtagNotCalledError when in debug or testing mode. - - This is called automatically. It is meant to warn the developer about an - issue in his ETag management. - """ - if (is_etag_enabled_for_request() and - request.method in METHODS_NEEDING_CHECK_ETAG): - if not _get_etag_ctx().get('etag_checked'): - message = ( - 'ETag enabled but not checked in endpoint {} on {} request.' - .format(request.endpoint, request.method)) - app = current_app - app.logger.warning(message) - if app.debug or app.testing: - raise CheckEtagNotCalledError(message) - - -def set_etag(etag_data, etag_schema=None): - """Set ETag for this response - - Raise 304 if ETag identical to If-None-Match header - - Can be called from resource code. If not called, ETag will be computed by - default from response data before sending response. - - Logs a warning if called in a method other than GET, HEAD, POST, PUT, PATCH - """ - if request.method not in METHODS_ALLOWING_SET_ETAG: - current_app.logger.warning( - 'ETag cannot be set on {} request.'.format(request.method)) - if is_etag_enabled_for_request(): - etag_schema = etag_schema or _get_etag_schema() - new_etag = _generate_etag(etag_data, etag_schema) - if new_etag in request.if_none_match: - raise NotModified - # Store ETag in AppContext to add it the the response headers later on - _get_etag_ctx()['etag'] = new_etag - - -def set_etag_in_response(response, etag_data, etag_schema): - """Set ETag in response object - - Called automatically. - - If no ETag data was computed using set_etag, it is computed here from - response data. - """ - if (is_etag_enabled_for_request() and - request.method in METHODS_ALLOWING_SET_ETAG): - new_etag = _get_etag_ctx().get('etag') - # If no ETag data was manually provided, use response content - if new_etag is None: - headers = (response.headers.get(h) for h in INCLUDE_HEADERS) - extra_data = tuple(h for h in headers if h is not None) - new_etag = _generate_etag(etag_data, etag_schema, extra_data) +class EtagMixin: + """Extend Blueprint to add ETag handling""" + + METHODS_NEEDING_CHECK_ETAG = ['PUT', 'PATCH', 'DELETE'] + METHODS_ALLOWING_SET_ETAG = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH'] + + # Headers to include in ETag computation + ETAG_INCLUDE_HEADERS = ['X-Pagination'] + + def etag(self, etag_schema=None): + """Decorator generating an endpoint response + + :param etag_schema: :class:`Schema ` class + or instance. If not None, will be used to serialize etag data. + + Can be used as either a decorator or a decorator factory: + + Example: :: + + @blp.etag + def view_func(...): + ... + + @blp.etag(EtagSchema) + def view_func(...): + ... + + See :doc:`ETag `. + """ + if etag_schema is None or isinstance(etag_schema, (type, Schema)): + # Factory: @etag(), @etag(EtagSchema) or @etag(EtagSchema()) + view_func = None + if isinstance(etag_schema, type): + etag_schema = etag_schema() + else: + # Decorator: @etag + view_func, etag_schema = etag_schema, None + + def decorator(func): + + @wraps(func) + def wrapper(*args, **kwargs): + + etag_enabled = _is_etag_enabled() + + if etag_enabled: + # Check etag precondition + self._check_precondition() + # Store etag_schema in AppContext + _get_etag_ctx()['etag_schema'] = etag_schema + + # Execute decorated function + resp = func(*args, **kwargs) + + if etag_enabled: + # Verify check_etag was called in resource code if needed + self._verify_check_etag() + # Add etag value to response + # Pass data to use as ETag data if set_etag was not called + # If etag_schema is provided, pass raw result rather than + # dump, as the dump needs to be done using etag_schema + etag_data = get_appcontext()[ + 'result_dump' if etag_schema is None else 'result_raw' + ] + self._set_etag_in_response(resp, etag_data, etag_schema) + + return resp + + return wrapper + + if view_func: + return decorator(view_func) + return decorator + + @staticmethod + def _generate_etag(etag_data, etag_schema=None, extra_data=None): + """Generate an ETag from data + + etag_data: Data to use to compute ETag + etag_schema: Schema to dump data with before hashing + extra_data: Extra data to add before hashing + + Typically, extra_data is used to add pagination metadata to the hash. + It is not dumped through the Schema. + """ + if etag_schema is None: + raw_data = etag_data + else: + if isinstance(etag_schema, type): + etag_schema = etag_schema() + raw_data = etag_schema.dump(etag_data) + if MARSHMALLOW_VERSION_MAJOR < 3: + raw_data = raw_data[0] + if extra_data: + raw_data = (raw_data, extra_data) + # flask's json.dumps is needed here + # as vanilla json.dumps chokes on lazy_strings + data = json.dumps(raw_data, sort_keys=True) + return hashlib.sha1(bytes(data, 'utf-8')).hexdigest() + + def _check_precondition(self): + """Check If-Match header is there + + Raise 428 if If-Match header missing + + Called automatically for PUT, PATCH and DELETE methods + """ + # TODO: other methods? + # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Match + if ( + request.method in self.METHODS_NEEDING_CHECK_ETAG and + not request.if_match + ): + raise PreconditionRequired + + def check_etag(self, etag_data, etag_schema=None): + """Compare If-Match header with computed ETag + + Raise 412 if If-Match-Header does not match. + + Must be called from resource code to check ETag. + + Unfortunately, there is no way to call it automatically. It is the + developer's responsability to do it. However, a warning is logged at + runtime if this function was not called. + """ + if _is_etag_enabled(): + etag_schema = etag_schema or _get_etag_ctx().get('etag_schema') + new_etag = self._generate_etag(etag_data, etag_schema) + _get_etag_ctx()['etag_checked'] = True + if new_etag not in request.if_match: + raise PreconditionFailed + + def _verify_check_etag(self): + """Verify check_etag was called in resource code + + Log a warning if ETag is enabled but check_etag was not called in + resource code in a PUT, PATCH or DELETE method. + + Raise CheckEtagNotCalledError when in debug or testing mode. + + This is called automatically. It is meant to warn the developer about + an issue in his ETag management. + """ + if request.method in self.METHODS_NEEDING_CHECK_ETAG: + if not _get_etag_ctx().get('etag_checked'): + message = ( + 'ETag not checked in endpoint {} on {} request.' + .format(request.endpoint, request.method)) + app = current_app + app.logger.warning(message) + if app.debug or app.testing: + raise CheckEtagNotCalledError(message) + + def set_etag(self, etag_data, etag_schema=None): + """Set ETag for this response + + Raise 304 if ETag identical to If-None-Match header + + Can be called from resource code. If not called, ETag will be computed + by default from response data before sending response. + + Logs a warning if called in a method other than one of + GET, HEAD, POST, PUT, PATCH + """ + if request.method not in self.METHODS_ALLOWING_SET_ETAG: + current_app.logger.warning( + 'ETag cannot be set on {} request.'.format(request.method)) + if _is_etag_enabled(): + etag_schema = etag_schema or _get_etag_ctx().get('etag_schema') + new_etag = self._generate_etag(etag_data, etag_schema) if new_etag in request.if_none_match: raise NotModified - response.set_etag(new_etag) + # Store ETag in AppContext to add it to response headers later on + _get_etag_ctx()['etag'] = new_etag + + def _set_etag_in_response(self, response, etag_data, etag_schema): + """Set ETag in response object + + Called automatically. + + If no ETag data was computed using set_etag, it is computed here from + response data. + """ + if request.method in self.METHODS_ALLOWING_SET_ETAG: + new_etag = _get_etag_ctx().get('etag') + # If no ETag data was manually provided, use response content + if new_etag is None: + headers = ( + response.headers.get(h) for h in self.ETAG_INCLUDE_HEADERS) + extra_data = tuple(h for h in headers if h is not None) + new_etag = self._generate_etag( + etag_data, etag_schema, extra_data) + if new_etag in request.if_none_match: + raise NotModified + response.set_etag(new_etag) diff --git a/flask_rest_api/response.py b/flask_rest_api/response.py index f3ad0af2..9eaa1a4f 100644 --- a/flask_rest_api/response.py +++ b/flask_rest_api/response.py @@ -4,9 +4,6 @@ from flask import jsonify -from .etag import ( - disable_etag_for_request, check_precondition, verify_check_etag, - set_etag_schema, set_etag_in_response) from .utils import deepupdate, get_appcontext from .compat import MARSHMALLOW_VERSION_MAJOR @@ -14,25 +11,18 @@ class ResponseMixin: """Extend Blueprint to add response handling""" - def response(self, schema=None, *, code=200, description='', - etag_schema=None, disable_etag=False): + def response(self, schema=None, *, code=200, description=''): """Decorator generating an endpoint response :param schema: :class:`Schema ` class or instance. If not None, will be used to serialize response data. :param int code: HTTP status code (default: 200). :param str descripton: Description of the response. - :param etag_schema: :class:`Schema ` class - or instance. If not None, will be used to serialize etag data. - :param bool disable_etag: Disable ETag feature locally even if enabled - globally. See :doc:`Response `. """ if isinstance(schema, type): schema = schema() - if isinstance(etag_schema, type): - etag_schema = etag_schema() def decorator(func): @@ -46,41 +36,26 @@ def decorator(func): @wraps(func) def wrapper(*args, **kwargs): - if disable_etag: - disable_etag_for_request() - - # Check etag precondition - check_precondition() - - # Store etag_schema in AppContext - set_etag_schema(etag_schema) - # Execute decorated function - result = func(*args, **kwargs) - - # Verify that check_etag was called in resource code if needed - verify_check_etag() + result_raw = func(*args, **kwargs) # Dump result with schema if specified if schema is None: - result_dump = result + result_dump = result_raw else: - result_dump = schema.dump(result) + result_dump = schema.dump(result_raw) if MARSHMALLOW_VERSION_MAJOR < 3: result_dump = result_dump[0] + # Store result in appcontext (may be used for ETag computation) + get_appcontext()['result_raw'] = result_raw + get_appcontext()['result_dump'] = result_dump + # Build response resp = jsonify(self._prepare_response_content(result_dump)) resp.headers.extend(get_appcontext()['headers']) resp.status_code = code - # Add etag value to response - # Pass data to use as ETag data if set_etag was not called - # If etag_schema is provided, pass raw data rather than dump, - # as the dump needs to be done using etag_schema - etag_data = result_dump if etag_schema is None else result - set_etag_in_response(resp, etag_data, etag_schema) - return resp return wrapper diff --git a/tests/test_etag.py b/tests/test_etag.py index 380df8a7..3f047396 100644 --- a/tests/test_etag.py +++ b/tests/test_etag.py @@ -7,14 +7,11 @@ import pytest -from flask import current_app, Response +from flask import Response from flask.views import MethodView -from flask_rest_api import Api, Blueprint, abort, check_etag, set_etag -from flask_rest_api.etag import ( - _generate_etag, is_etag_enabled, - disable_etag_for_request, is_etag_enabled_for_request, _get_etag_ctx, - check_precondition, set_etag_in_response, verify_check_etag) +from flask_rest_api import Api, Blueprint, abort +from flask_rest_api.etag import _get_etag_ctx from flask_rest_api.exceptions import ( CheckEtagNotCalledError, NotModified, PreconditionRequired, PreconditionFailed) @@ -22,15 +19,9 @@ from flask_rest_api.compat import MARSHMALLOW_VERSION_MAJOR from .mocks import ItemNotFound -from .conftest import AppConfig from .utils import NoLoggingContext -class AppConfigEtagEnabled(AppConfig): - """Basic config with ETag feature enabled""" - ETAG_ENABLED = True - - @pytest.fixture(params=[True, False]) def app_with_etag(request, collection, schemas, app): """Return a basic API sample with ETag""" @@ -44,13 +35,15 @@ def app_with_etag(request, collection, schemas, app): @blp.route('/') class Resource(MethodView): + @blp.etag(DocEtagSchema(many=True)) @blp.response( - DocSchema(many=True), etag_schema=DocEtagSchema(many=True)) + DocSchema(many=True)) def get(self): return collection.items + @blp.etag(DocEtagSchema) @blp.arguments(DocSchema) - @blp.response(DocSchema, code=201, etag_schema=DocEtagSchema) + @blp.response(DocSchema, code=201) def post(self, new_item): return collection.post(new_item) @@ -63,33 +56,37 @@ def _get_item(self, item_id): except ItemNotFound: abort(404) - @blp.response(DocSchema, etag_schema=DocEtagSchema) + @blp.etag(DocEtagSchema) + @blp.response(DocSchema) def get(self, item_id): return self._get_item(item_id) + @blp.etag(DocEtagSchema) @blp.arguments(DocSchema) - @blp.response(DocSchema, etag_schema=DocEtagSchema) + @blp.response(DocSchema) def put(self, new_item, item_id): item = self._get_item(item_id) - check_etag(item, DocEtagSchema) + blp.check_etag(item, DocEtagSchema) return collection.put(item_id, new_item) - @blp.response(code=204, etag_schema=DocEtagSchema) + @blp.etag(DocEtagSchema) + @blp.response(code=204) def delete(self, item_id): item = self._get_item(item_id) - check_etag(item, DocEtagSchema) + blp.check_etag(item, DocEtagSchema) del collection.items[collection.items.index(item)] else: @blp.route('/') - @blp.response( - DocSchema(many=True), etag_schema=DocEtagSchema(many=True)) + @blp.etag(DocEtagSchema(many=True)) + @blp.response(DocSchema(many=True)) def get_resources(): return collection.items @blp.route('/', methods=('POST',)) + @blp.etag(DocEtagSchema) @blp.arguments(DocSchema) - @blp.response(DocSchema, code=201, etag_schema=DocEtagSchema) + @blp.response(DocSchema, code=201) def post_resource(new_item): return collection.post(new_item) @@ -100,26 +97,26 @@ def _get_item(item_id): abort(404) @blp.route('/') - @blp.response( - DocSchema, etag_schema=DocEtagSchema) + @blp.etag(DocEtagSchema) + @blp.response(DocSchema) def get_resource(item_id): return _get_item(item_id) @blp.route('/', methods=('PUT',)) + @blp.etag(DocEtagSchema) @blp.arguments(DocSchema) - @blp.response( - DocSchema, etag_schema=DocEtagSchema) + @blp.response(DocSchema) def put_resource(new_item, item_id): item = _get_item(item_id) - check_etag(item) + blp.check_etag(item) return collection.put(item_id, new_item) @blp.route('/', methods=('DELETE',)) - @blp.response( - code=204, etag_schema=DocEtagSchema) + @blp.etag(DocEtagSchema) + @blp.response(code=204) def delete_resource(item_id): item = _get_item(item_id) - check_etag(item) + blp.check_etag(item) del collection.items[collection.items.index(item)] api = Api(app) @@ -139,12 +136,14 @@ def test_etag_is_deterministic(self): OrderedDict instances that are equivalent to the same dictionary. """ + blp = Blueprint('test', __name__) + data = OrderedDict([ ('a', 1), ('b', 2), ('c', OrderedDict([('a', 1), ('b', 2)])) ]) - etag = _generate_etag(data) + etag = blp._generate_etag(data) data_copies = [ OrderedDict([ @@ -169,11 +168,12 @@ def test_etag_is_deterministic(self): ]), ] - data_copies_etag = [_generate_etag(d) for d in data_copies] + data_copies_etag = [blp._generate_etag(d) for d in data_copies] assert all(e == etag for e in data_copies_etag) @pytest.mark.parametrize('extra_data', [None, {}, {'answer': 42}]) def test_etag_generate_etag(self, schemas, extra_data): + blp = Blueprint('test', __name__) etag_schema = schemas.DocEtagSchema item = {'item_id': 1, 'db_field': 0} item_schema_dump = etag_schema().dump(item) @@ -186,243 +186,179 @@ def test_etag_generate_etag(self, schemas, extra_data): data = (item, extra_data) data_dump = (item_schema_dump, extra_data) - etag = _generate_etag(item, extra_data=extra_data) + etag = blp._generate_etag(item, extra_data=extra_data) assert etag == hashlib.sha1( bytes(json.dumps(data, sort_keys=True), 'utf-8') ).hexdigest() - etag = _generate_etag(item, etag_schema, extra_data=extra_data) + etag = blp._generate_etag(item, etag_schema, extra_data=extra_data) assert etag == hashlib.sha1( bytes(json.dumps(data_dump, sort_keys=True), 'utf-8') ).hexdigest() - etag = _generate_etag(item, etag_schema(), extra_data=extra_data) + etag = blp._generate_etag(item, etag_schema(), extra_data=extra_data) assert etag == hashlib.sha1( bytes(json.dumps(data_dump, sort_keys=True), 'utf-8') ).hexdigest() - @pytest.mark.parametrize( - 'app', [AppConfig, AppConfigEtagEnabled], indirect=True) - def test_etag_is_etag_enabled_for_request(self, app): - - with app.test_request_context('/'): - assert ( - is_etag_enabled_for_request() == is_etag_enabled(current_app)) - disable_etag_for_request() - assert not is_etag_enabled_for_request() - - @pytest.mark.parametrize( - 'app', [AppConfig, AppConfigEtagEnabled], indirect=True) @pytest.mark.parametrize('method', HTTP_METHODS) def test_etag_check_precondition(self, app, method): + blp = Blueprint('test', __name__) with app.test_request_context('/', method=method): - if method in ['PUT', 'PATCH', 'DELETE'] and is_etag_enabled(app): + if method in ['PUT', 'PATCH', 'DELETE']: with pytest.raises(PreconditionRequired): - check_precondition() + blp._check_precondition() else: - check_precondition() - disable_etag_for_request() - check_precondition() - - @pytest.mark.parametrize( - 'app', [AppConfig, AppConfigEtagEnabled], indirect=True) - def test_etag_check_etag(self, app, schemas): + blp._check_precondition() + @pytest.mark.parametrize('etag_disabled', (True, False)) + def test_etag_check_etag(self, app, schemas, etag_disabled): + app.config['ETAG_DISABLED'] = etag_disabled + blp = Blueprint('test', __name__) etag_schema = schemas.DocEtagSchema old_item = {'item_id': 1, 'db_field': 0} new_item = {'item_id': 1, 'db_field': 1} - - old_etag = _generate_etag(old_item) - old_etag_with_schema = _generate_etag(old_item, etag_schema) + old_etag = blp._generate_etag(old_item) + old_etag_with_schema = blp._generate_etag(old_item, etag_schema) with app.test_request_context('/', headers={'If-Match': old_etag}): - check_etag(old_item) - if is_etag_enabled(app): + blp.check_etag(old_item) + if not etag_disabled: with pytest.raises(PreconditionFailed): - check_etag(new_item) + blp.check_etag(new_item) else: - check_etag(new_item) - disable_etag_for_request() - check_etag(old_item) - check_etag(new_item) + blp.check_etag(new_item) with app.test_request_context( '/', headers={'If-Match': old_etag_with_schema}): - check_etag(old_item, etag_schema) - if is_etag_enabled(app): + blp.check_etag(old_item, etag_schema) + if not etag_disabled: with pytest.raises(PreconditionFailed): - check_etag(new_item, etag_schema) + blp.check_etag(new_item, etag_schema) else: - check_etag(new_item) - disable_etag_for_request() - check_etag(old_item) - check_etag(new_item) + blp.check_etag(new_item) - @pytest.mark.parametrize( - 'app', [AppConfig, AppConfigEtagEnabled], indirect=True) @pytest.mark.parametrize('method', HTTP_METHODS) def test_etag_verify_check_etag_warning(self, app, method): - + blp = Blueprint('test', __name__) old_item = {'item_id': 1, 'db_field': 0} - old_etag = _generate_etag(old_item) + old_etag = blp._generate_etag(old_item) with mock.patch.object(app.logger, 'warning') as mock_warning: with app.test_request_context('/', method=method, headers={'If-Match': old_etag}): - verify_check_etag() - if (is_etag_enabled(app) and - method in ['PUT', 'PATCH', 'DELETE']): + blp._verify_check_etag() + if method in ['PUT', 'PATCH', 'DELETE']: assert mock_warning.called mock_warning.reset_mock() else: assert not mock_warning.called - check_etag(old_item) - verify_check_etag() - assert not mock_warning.called - disable_etag_for_request() - verify_check_etag() - assert not mock_warning.called - check_etag(old_item) - verify_check_etag() + blp.check_etag(old_item) + blp._verify_check_etag() assert not mock_warning.called - @pytest.mark.parametrize( - 'app', [AppConfig, AppConfigEtagEnabled], indirect=True) @pytest.mark.parametrize('method', HTTP_METHODS) @pytest.mark.parametrize('debug', (True, False)) @pytest.mark.parametrize('testing', (True, False)) def test_etag_verify_check_etag_exception( self, app, method, debug, testing): - app.config['DEBUG'] = debug app.config['TESTING'] = testing + blp = Blueprint('test', __name__) with NoLoggingContext(app): with app.test_request_context('/', method=method): - if ( - (debug or testing) - and is_etag_enabled(app) - and method in ['PUT', 'PATCH', 'DELETE'] - ): + if (debug or testing) and method in ['PUT', 'PATCH', 'DELETE']: with pytest.raises( CheckEtagNotCalledError, - match='ETag enabled but not checked in endpoint' + match='ETag not checked in endpoint' ): - verify_check_etag() + blp._verify_check_etag() else: - verify_check_etag() - - @pytest.mark.parametrize( - 'app', [AppConfig, AppConfigEtagEnabled], indirect=True) - def test_etag_set_etag(self, app, schemas): + blp._verify_check_etag() + @pytest.mark.parametrize('etag_disabled', (True, False)) + def test_etag_set_etag(self, app, schemas, etag_disabled): + app.config['ETAG_DISABLED'] = etag_disabled + blp = Blueprint('test', __name__) etag_schema = schemas.DocEtagSchema item = {'item_id': 1, 'db_field': 0} - - etag = _generate_etag(item) - etag_with_schema = _generate_etag(item, etag_schema) + etag = blp._generate_etag(item) + etag_with_schema = blp._generate_etag(item, etag_schema) with app.test_request_context('/'): - set_etag(item) - if is_etag_enabled(app): + blp.set_etag(item) + if not etag_disabled: assert _get_etag_ctx()['etag'] == etag del _get_etag_ctx()['etag'] else: assert 'etag' not in _get_etag_ctx() - disable_etag_for_request() - set_etag(item) - assert 'etag' not in _get_etag_ctx() with app.test_request_context( '/', headers={'If-None-Match': etag}): - if is_etag_enabled(app): + if not etag_disabled: with pytest.raises(NotModified): - set_etag(item) + blp.set_etag(item) else: - set_etag(item) + blp.set_etag(item) assert 'etag' not in _get_etag_ctx() - disable_etag_for_request() - set_etag(item) - assert 'etag' not in _get_etag_ctx() with app.test_request_context( '/', headers={'If-None-Match': etag_with_schema}): - if is_etag_enabled(app): + if not etag_disabled: with pytest.raises(NotModified): - set_etag(item, etag_schema) + blp.set_etag(item, etag_schema) else: - set_etag(item, etag_schema) + blp.set_etag(item, etag_schema) assert 'etag' not in _get_etag_ctx() - disable_etag_for_request() - set_etag(item, etag_schema) - assert 'etag' not in _get_etag_ctx() with app.test_request_context( '/', headers={'If-None-Match': 'dummy'}): - if is_etag_enabled(app): - set_etag(item) + if not etag_disabled: + blp.set_etag(item) assert _get_etag_ctx()['etag'] == etag del _get_etag_ctx()['etag'] - set_etag(item, etag_schema) + blp.set_etag(item, etag_schema) assert _get_etag_ctx()['etag'] == etag_with_schema del _get_etag_ctx()['etag'] else: - set_etag(item) + blp.set_etag(item) assert 'etag' not in _get_etag_ctx() - set_etag(item, etag_schema) + blp.set_etag(item, etag_schema) assert 'etag' not in _get_etag_ctx() - disable_etag_for_request() - set_etag(item) - assert 'etag' not in _get_etag_ctx() - set_etag(item, etag_schema) - assert 'etag' not in _get_etag_ctx() - - @pytest.mark.parametrize( - 'app', [AppConfig, AppConfigEtagEnabled], indirect=True) + + @pytest.mark.parametrize('etag_disabled', (True, False)) @pytest.mark.parametrize('method', HTTP_METHODS) - def test_set_etag_method_not_allowed_warning(self, app, method): + def test_set_etag_method_not_allowed_warning( + self, app, method, etag_disabled): + app.config['ETAG_DISABLED'] = etag_disabled + blp = Blueprint('test', __name__) with mock.patch.object(app.logger, 'warning') as mock_warning: with app.test_request_context('/', method=method): - set_etag(None) + blp.set_etag(None) if method in ['GET', 'HEAD', 'POST', 'PUT', 'PATCH']: assert not mock_warning.called else: assert mock_warning.called - @pytest.mark.parametrize( - 'app', [AppConfig, AppConfigEtagEnabled], indirect=True) @pytest.mark.parametrize('paginate', (True, False)) def test_etag_set_etag_in_response(self, app, schemas, paginate): - + blp = Blueprint('test', __name__) etag_schema = schemas.DocEtagSchema item = {'item_id': 1, 'db_field': 0} extra_data = ('Dummy pagination header', ) if paginate else tuple() - etag = _generate_etag(item, extra_data=extra_data) - etag_with_schema = _generate_etag( + etag = blp._generate_etag(item, extra_data=extra_data) + etag_with_schema = blp._generate_etag( item, etag_schema, extra_data=extra_data) with app.test_request_context('/'): resp = Response() if extra_data: resp.headers['X-Pagination'] = 'Dummy pagination header' - if is_etag_enabled(app): - set_etag_in_response(resp, item, None) - assert resp.get_etag() == (etag, False) - set_etag_in_response(resp, item, etag_schema) - assert resp.get_etag() == (etag_with_schema, False) - else: - set_etag_in_response(resp, item, None) - assert resp.get_etag() == (None, None) - set_etag_in_response(resp, item, etag_schema) - assert resp.get_etag() == (None, None) - disable_etag_for_request() - resp = Response() - set_etag_in_response(resp, item, None) - assert resp.get_etag() == (None, None) - set_etag_in_response(resp, item, etag_schema) - assert resp.get_etag() == (None, None) + blp._set_etag_in_response(resp, item, None) + assert resp.get_etag() == (etag, False) + blp._set_etag_in_response(resp, item, etag_schema) + assert resp.get_etag() == (etag_with_schema, False) - @pytest.mark.parametrize('app', [AppConfigEtagEnabled], indirect=True) def test_etag_operations_etag_enabled(self, app_with_etag): client = app_with_etag.test_client() - assert is_etag_enabled(app_with_etag) # GET without ETag: OK response = client.get('/test/') @@ -521,8 +457,8 @@ def test_etag_operations_etag_enabled(self, app_with_etag): def test_etag_operations_etag_disabled(self, app_with_etag): + app_with_etag.config['ETAG_DISABLED'] = True client = app_with_etag.test_client() - assert not is_etag_enabled(app_with_etag) # GET without ETag: OK response = client.get('/test/') diff --git a/tests/test_examples.py b/tests/test_examples.py index 7b790f05..af564798 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -7,17 +7,11 @@ from flask.views import MethodView -from flask_rest_api import Api, Blueprint, abort, check_etag, set_etag, Page +from flask_rest_api import Api, Blueprint, abort, Page -from .conftest import AppConfig from .mocks import ItemNotFound -class AppConfigFullExample(AppConfig): - """Basic config with ETag feature enabled""" - ETAG_ENABLED = True - - def implicit_data_and_schema_etag_blueprint(collection, schemas): """Blueprint with implicit data and schema ETag computation @@ -32,11 +26,13 @@ def implicit_data_and_schema_etag_blueprint(collection, schemas): @blp.route('/') class Resource(MethodView): + @blp.etag @blp.response(DocSchema(many=True)) @blp.paginate(Page) def get(self): return collection.items + @blp.etag @blp.arguments(DocSchema) @blp.response(DocSchema) def post(self, new_item): @@ -51,23 +47,26 @@ def _get_item(self, item_id): except ItemNotFound: abort(404) + @blp.etag @blp.response(DocSchema) def get(self, item_id): return self._get_item(item_id) + @blp.etag @blp.arguments(DocSchema) @blp.response(DocSchema) def put(self, new_item, item_id): item = self._get_item(item_id) # Check ETag is a manual action and schema must be provided - check_etag(item, DocSchema) + blp.check_etag(item, DocSchema) return collection.put(item_id, new_item) + @blp.etag @blp.response(code=204) def delete(self, item_id): item = self._get_item(item_id) # Check ETag is a manual action and schema must be provided - check_etag(item, DocSchema) + blp.check_etag(item, DocSchema) collection.delete(item_id) return blp @@ -87,9 +86,8 @@ def implicit_data_explicit_schema_etag_blueprint(collection, schemas): @blp.route('/') class Resource(MethodView): - @blp.response( - DocSchema(many=True), etag_schema=DocEtagSchema(many=True) - ) + @blp.etag(DocEtagSchema(many=True)) + @blp.response(DocSchema(many=True)) @blp.paginate() def get(self, pagination_parameters): pagination_parameters.item_count = len(collection.items) @@ -98,8 +96,9 @@ def get(self, pagination_parameters): pagination_parameters.last_item + 1 ] + @blp.etag(DocEtagSchema) @blp.arguments(DocSchema) - @blp.response(DocSchema, etag_schema=DocEtagSchema) + @blp.response(DocSchema) def post(self, new_item): return collection.post(new_item) @@ -112,25 +111,28 @@ def _get_item(self, item_id): except ItemNotFound: abort(404) - @blp.response(DocSchema, etag_schema=DocEtagSchema) + @blp.etag(DocEtagSchema) + @blp.response(DocSchema) def get(self, item_id): item = self._get_item(item_id) return item + @blp.etag(DocEtagSchema) @blp.arguments(DocSchema) - @blp.response(DocSchema, etag_schema=DocEtagSchema) + @blp.response(DocSchema) def put(self, new_item, item_id): item = self._get_item(item_id) # Check ETag is a manual action, ETag schema is used - check_etag(item) + blp.check_etag(item) new_item = collection.put(item_id, new_item) return new_item - @blp.response(code=204, etag_schema=DocEtagSchema) + @blp.etag(DocEtagSchema) + @blp.response(code=204) def delete(self, item_id): item = self._get_item(item_id) # Check ETag is a manual action, ETag schema is used - check_etag(item) + blp.check_etag(item) collection.delete(item_id) return blp @@ -151,6 +153,7 @@ def explicit_data_no_schema_etag_blueprint(collection, schemas): @blp.route('/') class Resource(MethodView): + @blp.etag @blp.response(DocSchema(many=True)) @blp.paginate() def get(self, pagination_parameters): @@ -162,11 +165,12 @@ def get(self, pagination_parameters): pagination_parameters.last_item + 1 ] + @blp.etag @blp.arguments(DocSchema) @blp.response(DocSchema) def post(self, new_item): # Compute ETag using arbitrary data and no schema - set_etag(new_item['db_field']) + blp.set_etag(new_item['db_field']) return collection.post(new_item) @blp.route('/') @@ -178,29 +182,32 @@ def _get_item(self, item_id): except ItemNotFound: abort(404) + @blp.etag @blp.response(DocSchema) def get(self, item_id): item = self._get_item(item_id) # Compute ETag using arbitrary data and no schema - set_etag(item['db_field']) + blp.set_etag(item['db_field']) return item + @blp.etag @blp.arguments(DocSchema) @blp.response(DocSchema) def put(self, new_item, item_id): item = self._get_item(item_id) # Check ETag is a manual action, no shema used - check_etag(item['db_field']) + blp.check_etag(item['db_field']) new_item = collection.put(item_id, new_item) # Compute ETag using arbitrary data and no schema - set_etag(new_item['db_field']) + blp.set_etag(new_item['db_field']) return new_item + @blp.etag @blp.response(code=204) def delete(self, item_id): item = self._get_item(item_id) # Check ETag is a manual action, no shema used - check_etag(item['db_field']) + blp.check_etag(item['db_field']) collection.delete(item_id) return blp @@ -218,7 +225,6 @@ def blueprint_fixture(request, collection, schemas): class TestFullExample(): - @pytest.mark.parametrize('app', [AppConfigFullExample], indirect=True) def test_examples(self, app, blueprint_fixture, schemas): blueprint, bp_schema = blueprint_fixture @@ -301,7 +307,7 @@ def assert_counters( # PUT without ETag: Precondition required error item_1_data['field'] = 1 - with assert_counters(1, 0, 0, 0): + with assert_counters(0, 0, 0, 0): response = client.put( '/test/{}'.format(item_1_id), data=json.dumps(item_1_data),