Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rework ETag: remove "ETag computed with API response data using another schema", allow decorating MethodView #394

Merged
merged 8 commits into from Oct 16, 2022
74 changes: 7 additions & 67 deletions docs/etag.rst
Expand Up @@ -35,30 +35,28 @@ The :class:`Schema <marshmallow.Schema>` must be provided explicitly, even
though it is the same as the response schema.

.. code-block:: python
:emphasize-lines: 28,37
:emphasize-lines: 2,15,26,34

@blp.route("/")
@blp.etag
class Pet(MethodView):
@blp.etag
@blp.response(200, PetSchema(many=True))
def get(self):
return Pet.get()

@blp.etag
@blp.arguments(PetSchema)
@blp.response(201, PetSchema)
def post(self, new_data):
return Pet.create(**new_data)


@blp.route("/<pet_id>")
@blp.etag
class PetById(MethodView):
@blp.etag
@blp.response(200, PetSchema)
def get(self, pet_id):
return Pet.get_by_id(pet_id)

@blp.etag
@blp.arguments(PetSchema)
@blp.response(200, PetSchema)
def put(self, update_data, pet_id):
Expand All @@ -68,67 +66,13 @@ though it is the same as the response schema.
pet.update(update_data)
return pet

@blp.etag
@blp.response(204)
def delete(self, pet_id):
pet = Pet.get_by_id(pet_id)
# Check ETag is a manual action and schema must be provided
blp.check_etag(pet, PetSchema)
Pet.delete(pet_id)

ETag Computed with API Response Data Using Another Schema
---------------------------------------------------------

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 should be provided to
:meth:`Blueprint.etag <Blueprint.etag>`. Then, it does not need to be passed to
:meth:`check_etag <Blueprint.check_etag>`.

.. code-block:: python
:emphasize-lines: 3,8,17,22,28,32,37

@blp.route("/")
class Pet(MethodView):
@blp.etag(PetEtagSchema(many=True))
@blp.response(200, PetSchema(many=True))
def get(self):
return Pet.get()

@blp.etag(PetEtagSchema)
@blp.arguments(PetSchema)
@blp.response(201, PetSchema)
def post(self, new_pet):
return Pet.create(**new_data)


@blp.route("/<int:pet_id>")
class PetById(MethodView):
@blp.etag(PetEtagSchema)
@blp.response(200, PetSchema)
def get(self, pet_id):
return Pet.get_by_id(pet_id)

@blp.etag(PetEtagSchema)
@blp.arguments(PetSchema)
@blp.response(200, 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
blp.check_etag(pet)
pet.update(update_data)
return pet

@blp.etag(PetEtagSchema)
@blp.response(204)
def delete(self, pet_id):
pet = self._get_pet(pet_id)
# Check ETag is a manual action, ETag schema is used
blp.check_etag(pet)
Pet.delete(pet_id)

ETag Computed on Arbitrary Data
-------------------------------

Expand All @@ -137,23 +81,21 @@ The ETag can also be computed from arbitrary data by calling

The example below illustrates this with no ETag schema, but it is also possible
to pass an ETag schema to :meth:`set_etag <Blueprint.set_etag>` and
:meth:`check_etag <Blueprint.check_etag>` or equivalently to
:meth:`Blueprint.etag <Blueprint.etag>`.
:meth:`check_etag <Blueprint.check_etag>`.

.. code-block:: python
:emphasize-lines: 3,8,11,16,22,26,29,35,38,41,46
:emphasize-lines: 2,8,15,20,25,33,36,43

@blp.route("/")
@blp.etag
class Pet(MethodView):
@blp.etag
@blp.response(200, PetSchema(many=True))
def get(self):
pets = Pet.get()
# Compute ETag using arbitrary data
blp.set_etag([pet.update_time for pet in pets])
return pets

@blp.etag
@blp.arguments(PetSchema)
@blp.response(201, PetSchema)
def post(self, new_data):
Expand All @@ -163,15 +105,14 @@ to pass an ETag schema to :meth:`set_etag <Blueprint.set_etag>` and


@blp.route("/<pet_id>")
@blp.etag
class PetById(MethodView):
@blp.etag
@blp.response(200, PetSchema)
def get(self, pet_id):
# Compute ETag using arbitrary data
blp.set_etag(new_data["update_time"])
return Pet.get_by_id(pet_id)

@blp.etag
@blp.arguments(PetSchema)
@blp.response(200, PetSchema)
def put(self, update_data, pet_id):
Expand All @@ -183,7 +124,6 @@ to pass an ETag schema to :meth:`set_etag <Blueprint.set_etag>` and
blp.set_etag(new_data["update_time"])
return pet

@blp.etag
@blp.response(204)
def delete(self, pet_id):
pet = Pet.get_by_id(pet_id)
Expand Down
14 changes: 14 additions & 0 deletions flask_smorest/blueprint.py
Expand Up @@ -305,3 +305,17 @@ def wrapper(*f_args, **f_kwargs):
return wrapper

return decorator

def _decorate_view_func_or_method_view(self, decorator, obj):
"""Apply decorator to view func or MethodView HTTP methods"""

# Decorating a MethodView decorates all HTTP methods
if isinstance(obj, type(MethodView)):
for method in self.HTTP_METHODS:
if method in obj.methods:
method_l = method.lower()
func = getattr(obj, method_l)
setattr(obj, method_l, decorator(func))
return obj

return decorator(obj)
75 changes: 21 additions & 54 deletions flask_smorest/etag.py
Expand Up @@ -8,11 +8,10 @@

import hashlib

from marshmallow import Schema
from flask import request, current_app

from .exceptions import PreconditionRequired, PreconditionFailed, NotModified
from .utils import deepupdate, get_appcontext
from .utils import deepupdate, resolve_schema_instance, get_appcontext


IF_NONE_MATCH_HEADER = {
Expand Down Expand Up @@ -56,38 +55,19 @@ class EtagMixin:
# 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 <marshmallow.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(...):
...
def etag(self, obj):
"""Decorator adding ETag management to the endpoint

The ``etag`` decorator expects the decorated view function to return a
``Response`` object. It is the case if it is decorated with the
``response`` decorator.

The ``etag`` decorator may be used to decorate a
:class:`MethodView <flask.views.MethodView>`. In this case, it applies
to all HTTP methods in the ``MethodView``.

See :doc:`ETag <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)
Expand All @@ -98,8 +78,6 @@ def wrapper(*args, **kwargs):
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)
Expand All @@ -108,7 +86,7 @@ def wrapper(*args, **kwargs):
# Verify check_etag was called in resource code if needed
self._verify_check_etag()
# Add etag value to response
self._set_etag_in_response(resp, etag_schema)
self._set_etag_in_response(resp)

return resp

Expand All @@ -119,30 +97,21 @@ def wrapper(*args, **kwargs):

return wrapper

if view_func:
return decorator(view_func)
return decorator
return self._decorate_view_func_or_method_view(decorator, obj)

@staticmethod
def _generate_etag(etag_data, etag_schema=None, extra_data=None):
def _generate_etag(etag_data, extra_data=None):
"""Generate an ETag from data

etag_data: Data to use to compute ETag (must be json serializable)
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 extra_data:
raw_data = (raw_data, extra_data)
data = json.dumps(raw_data, sort_keys=True)
etag_data = (etag_data, extra_data)
data = json.dumps(etag_data, sort_keys=True)
return hashlib.sha1(bytes(data, "utf-8")).hexdigest()

def _check_precondition(self):
Expand Down Expand Up @@ -174,8 +143,9 @@ def check_etag(self, etag_data, etag_schema=None):
if request.method not in self.METHODS_NEEDING_CHECK_ETAG:
warnings.warn(f"ETag cannot be checked on {request.method} request.")
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 etag_schema is not None:
etag_data = resolve_schema_instance(etag_schema).dump(etag_data)
new_etag = self._generate_etag(etag_data)
_get_etag_ctx()["etag_checked"] = True
if new_etag not in request.if_match:
raise PreconditionFailed
Expand Down Expand Up @@ -223,13 +193,14 @@ def set_etag(self, etag_data, etag_schema=None):
if request.method not in self.METHODS_ALLOWING_SET_ETAG:
warnings.warn(f"ETag cannot be set on {request.method} request.")
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 etag_schema is not None:
etag_data = resolve_schema_instance(etag_schema).dump(etag_data)
new_etag = self._generate_etag(etag_data)
self._check_not_modified(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_schema):
def _set_etag_in_response(self, response):
"""Set ETag in response object

Called automatically.
Expand All @@ -241,17 +212,13 @@ def _set_etag_in_response(self, response, etag_schema):
new_etag = _get_etag_ctx().get("etag")
# If no ETag data was manually provided, use response content
if new_etag is None:
# If etag_schema is provided, use raw result rather than
# the 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"
]
etag_data = get_appcontext()["result_dump"]
extra_data = tuple(
(k, v)
for k, v in response.headers
if k in self.ETAG_INCLUDE_HEADERS
)
new_etag = self._generate_etag(etag_data, etag_schema, extra_data)
new_etag = self._generate_etag(etag_data, extra_data)
self._check_not_modified(new_etag)
response.set_etag(new_etag)

Expand Down
8 changes: 3 additions & 5 deletions flask_smorest/response.py
Expand Up @@ -11,6 +11,7 @@
from .utils import (
deepupdate,
remove_none,
resolve_schema_instance,
get_appcontext,
prepare_response,
unpack_tuple_response,
Expand Down Expand Up @@ -63,8 +64,7 @@ class or instance or reference or dict.

See :doc:`Response <response>`.
"""
if isinstance(schema, type):
schema = schema()
schema = resolve_schema_instance(schema)

# Document response (schema, description,...) in the API doc
doc_schema = self._make_doc_response_schema(schema)
Expand Down Expand Up @@ -105,7 +105,6 @@ def wrapper(*args, **kwargs):

# Store result in appcontext (may be used for ETag computation)
appcontext = get_appcontext()
appcontext["result_raw"] = result_raw
appcontext["result_dump"] = result_dump

# Build response
Expand Down Expand Up @@ -172,8 +171,7 @@ class or instance or reference or dict.
resp_doc = response
# Otherwise, build response description
else:
if isinstance(schema, type):
schema = schema()
schema = resolve_schema_instance(schema)

# Document response (schema, description,...) in the API doc
doc_schema = self._make_doc_response_schema(schema)
Expand Down
9 changes: 9 additions & 0 deletions flask_smorest/utils.py
Expand Up @@ -28,6 +28,15 @@ def remove_none(mapping):
return {k: v for k, v in mapping.items() if v is not None}


def resolve_schema_instance(schema):
"""Return schema instance for given schema (instance or class).

:param type|Schema schema: marshmallow.Schema instance or class
:return: schema instance of given schema
"""
return schema() if isinstance(schema, type) else schema


def get_appcontext():
"""Get extension section in flask g"""
return g.setdefault("_flask_smorest", {})
Expand Down