chibisov / drf-extensions

DRF-extensions is a collection of custom extensions for Django REST Framework
http://chibisov.github.io/drf-extensions/docs
MIT License
1.47k stars 208 forks source link

Optimistic concurrency control using ETags #171

Closed pkainz closed 7 years ago

pkainz commented 7 years ago

API resources: Optimistic concurrency control using ETags

The Problem

As @mbox already mentioned, the default implementation in drf-extensions does not allow a correct optimistic locking using ETags and conditional requests. The ETag ties each request to a particular HTTP method, view, and response. While this works as expected for server-side caching (correctly returns status 304, and 200), it does not work to identify the requested resources when retrieving an object, altering it and putting it back to the server:

# Retrieve Request
GET /books/1/ HTTP/1.1
Accept: "application/json"

# Response
HTTP/1.1 200 OK
Content-Type: "application/json"
ETag: "591fa57af468db45942e5bf8ebe59378"

{'issn': '9780345531988', 'name': 'The Summons', 'id': 1, 'author': 'Stephen King'}

# Update Request
PUT /books/1/ HTTP/1.1
Accept: "application/json"
If-Match: "591fa57af468db45942e5bf8ebe59378"

{'issn': '9780345531988', 'name': 'The Summons', 'id': 1, 'author': 'John Grisham'}

# Response
HTTP/1.1 412 PRECONDITION FAILED            # <- THIS SHOULD BE '200 OK'!
Content-Type: "application/json"
ETag: "<another_value_that_hashed_the_PUT_view>"

Related discussion in the DRF issues: https://github.com/tomchristie/django-rest-framework/issues/32

Proposed Changes

I have implemented the following changes/additions to enable optimistic locking using conditional requests using ETag, If-Match, If-None-Match HTTP headers in DRF-extensions. It is based on the requirement that we need an ETag value to represent the current semantic version of individual model instances rather than using it as caching function for a dynamic page.

The new features have been tested in unit tests where applicable and there exists a new functional test app in tests_app/tests/functional/concurrency. It tests a standard APIClient from the rest_framework to query Book models.

Additional Changes

Some other changes have been performed to ensure that all tests pass in django 1.10 and 1.9, DRF 3.5.3:

I documented the source code. The usage of the new mixins and decorators is identical to the current implementation that exists for @etag.

auvipy commented 7 years ago

I really appreciate your pr. allow me to review it

pkainz commented 7 years ago

@auvipy sure, go ahead. i will work on some docs and include release notes.

pkainz commented 7 years ago

@auvipy I ran into some issues when using @api_etag with the DefaultAPIKeyConstructor. The constructor was malfunctioning and disallowed conditional updates, once the Etag was computed for the first call of the view. This locked us out and always returned 304 responses. Hence, I dropped the class and cleaned the code. Further, I fixed some issues with my unit/integration tests to pass with CI.

Docs in the development version has also been updated according to the guidelines.

kevin-brown commented 7 years ago

I'm definitely missing something when looking over the changes for this pull request. The follow changes make sense.

All of these should in one way or another fix the current issue that we have with ETags, where they aren't being generated properly. But what I can't figure out is why there is a whole new set of settings, mixins, and views being added on top of these changes? They allow for the new changes to be rolled out quickly, but they don't add much on top of the existing ETag settings, mixins, and views as it appears that you could realistically swap them both without much issue.

Wouldn't it make more sense to recommend that these new key bits and constructors are used within the documentation, and eventually (when a version change allows it) make these the default?

pkainz commented 7 years ago

But what I can't figure out is why there is a whole new set of settings, mixins, and views being added on top of these changes?

The main idea was not to break any existing functionality in the package and provide the new behaviour out of the box in drf-extensions. We do not necessarily need the new settings/mixins etc., but it reduces configuration effort in individual views/viewsets if one wants to make use of both the existing and the new functionality.

You can as well use the following code that has the same effect as the APIETAGMixin, but it overrides for all views inheriting the ETAGMixin. In settings.py:

REST_FRAMEWORK_EXTENSIONS = {
    'DEFAULT_OBJECT_ETAG_FUNC': 'rest_framework_extensions.utils.default_api_object_etag_func',
    'DEFAULT_LIST_ETAG_FUNC': 'rest_framework_extensions.utils.default_api_list_etag_func',
}

In views.py:

from rest_framework import viewsets
from rest_framework_extensions.etag.mixins import ETAGMixin

from my_app.models import Model
from my_app.serializer import ModelSerializer

class SomeView(ETAGMixin, viewset.ModelViewSet):
    queryset = Model.objects.all()
    serializer = ModelSerializer

The alternative option is to keep the default as is and define it on a per-view(set) basis:

from rest_framework import viewsets
from rest_framework_extensions.etag.mixins import ETAGMixin
from rest_framework_extensions.utils import (default_api_object_etag_func, default_api_list_etag_func)

from my_app.models import Model
from my_app.serializer import ModelSerializer

class SomeView(ETAGMixin, viewset.ModelViewSet):
    object_etag_func = default_api_object_etag_func
    list_etag_func = default_api_list_etag_func

    queryset = Model.objects.all()
    serializer = ModelSerializer

Whichever you prefer works without the APIETAGMixin class, even without the APIETAGProcessor, but you would have to make sure that the etag_func parameter is passed to it. Any ideas?

pkainz commented 7 years ago

Wouldn't it make more sense to recommend that these new key bits and constructors are used within the documentation, and eventually (when a version change allows it) make these the default?

We could do that by adding example configurations (for instance as in https://github.com/chibisov/drf-extensions/pull/171#issuecomment-270013371) on how to use the new KeyBits and KeyConstructors for the optimistic locking use case, including a hint that manual method decoration using @etag in a view without the etag_func argument will not work for optimistic locking. In my opinion the current solution is cleaner and does not contain this pitfall. But I am open to suggestions :)

pkainz commented 7 years ago

@auvipy @kevin-brown What's your opinion on how to proceed with this PR?

auvipy commented 7 years ago

I would rather interested to get it in ASAP. but still willing to see others opinion before get merged. I would request you to be patient. and thanks a lot for all this work.

auvipy commented 7 years ago

@pkainz do you think anything could be done for better in this patch? otherwise i will merge it and wait for new pr's

pkainz commented 7 years ago

Yes, I have some additions. Will commit soon.

pkainz commented 7 years ago

@auvipy I added a new commit that adds support for mandatory conditional requests, and returns 428 PRECONDITION REQUIRED if the header is not present in the request. As default, we use If-Match headers for conditional unsafe operations (except POST). I updated the docs accordingly.

kevin-brown commented 7 years ago

Hi, back again to ask more questions.

Is there any reason why the precondition checks were wrapped in with etags instead of being broken out as their own decorator? The docs allude to additional use cases (which do exist, like If-Range and X-BULK-OPERATION) but it requires using etags in order to use it with those use cases.

auvipy commented 7 years ago

@pkainz could you address the issues raised by @kevin-brown

pkainz commented 7 years ago

Fair point, @kevin-brown. No there is not, I simply did not go through all other use cases that may require headers. A dedicated decorator, e.g. @precondition_required, seems reasonable, which can then neatly be mixed into any existing functionality. @auvipy, can you list the operations in drf-extensions that require conditional headers?

pkainz commented 7 years ago

Following the input of @kevin-brown, I removed the APIETAGProcessor and re-used the existing ETAGProcessor functionality to avoid confusion. There is now a generic decorator function @precondition_required for view methods that checks for a set of headers for each HTTP verb. I added an out-of-the-box working configuration for optimistic concurrency control using a mixin for viewsets/views, that uses the new KeyBits and KeyConstructors to construct an ETag from a resource's attribute values. It returns 428 PRECONDITION REQUIRED, if the required headers are not present. See the updated docs for details.

The principal functionality for checking custom headers is now provided. However, I will not integrate this new pattern into the existing bulk-operations. Maybe someone else will submit a PR for this refactoring. @auvipy, could you merge that PR?

auvipy commented 7 years ago

@kevin-brown could you provide a final feedback as it seems he addressed some of your suggestions?

@pkainz Could you please open one/more relevant issues describing the possible refactors?

auvipy commented 5 years ago

going to reinvestigate this again