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

Standalone validation (T30324) #167

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
12 changes: 12 additions & 0 deletions binder/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,3 +235,15 @@ def __add__(self, other):
else:
errors[model] = other.errors[model]
return BinderValidationError(errors)


class BinderSkipSave(BinderException):
"""Used to abort the database transaction when validation was successfull.
Validation is possible when saving (post, put, multi-put) or deleting models."""

http_code = 200
code = 'SkipSave'

def __init__(self):
super().__init__()
self.fields['message'] = 'No validation errors were encountered.'
46 changes: 39 additions & 7 deletions binder/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from django.views.generic import View
from django.core.exceptions import ObjectDoesNotExist, FieldError, ValidationError, FieldDoesNotExist
from django.http import HttpResponse, StreamingHttpResponse, HttpResponseForbidden
from django.http.request import RawPostDataException
from django.http.request import RawPostDataException, QueryDict
from django.db import models, connections
from django.db.models import Q, F
from django.db.models.lookups import Transform
Expand All @@ -27,7 +27,11 @@
from django.db.models.fields.reverse_related import ForeignObjectRel


from .exceptions import BinderException, BinderFieldTypeError, BinderFileSizeExceeded, BinderForbidden, BinderImageError, BinderImageSizeExceeded, BinderInvalidField, BinderIsDeleted, BinderIsNotDeleted, BinderMethodNotAllowed, BinderNotAuthenticated, BinderNotFound, BinderReadOnlyFieldError, BinderRequestError, BinderValidationError, BinderFileTypeIncorrect, BinderInvalidURI
from .exceptions import (
BinderException, BinderFieldTypeError, BinderFileSizeExceeded, BinderForbidden, BinderImageError, BinderImageSizeExceeded,
BinderInvalidField, BinderIsDeleted, BinderIsNotDeleted, BinderMethodNotAllowed, BinderNotAuthenticated, BinderNotFound,
BinderReadOnlyFieldError, BinderRequestError, BinderValidationError, BinderFileTypeIncorrect, BinderInvalidURI, BinderSkipSave
)
from . import history
from .orderable_agg import OrderableArrayAgg, GroupConcat, StringAgg
from .models import FieldFilter, BinderModel, ContextAnnotation, OptionalAnnotation, BinderFileField
Expand Down Expand Up @@ -265,6 +269,9 @@ class ModelView(View):
# NOTE: custom _store__foo() methods will still be called for unupdatable fields.
unupdatable_fields = []

# Allow validation without saving.
allow_standalone_validation = False

# Fields to use for ?search=foo. Empty tuple for disabled search.
# NOTE: only string fields and 'id' are supported.
# id is hardcoded to be treated as an integer.
Expand Down Expand Up @@ -371,6 +378,10 @@ def dispatch(self, request, *args, **kwargs):

response = None
try:
# only allow standalone validation if you know what you are doing
if 'validate' in request.GET and request.GET['validate'] == 'true' and not self.allow_standalone_validation:
raise BinderRequestError('Standalone validation not enabled. You must enable this feature explicitly.')

#### START TRANSACTION
with ExitStack() as stack, history.atomic(source='http', user=request.user, uuid=request.request_id):
transaction_dbs = ['default']
Expand Down Expand Up @@ -1365,6 +1376,18 @@ def binder_validation_error(self, obj, validation_error, pk=None):
})



def _abort_when_standalone_validation(self, request):
"""Raise a `BinderSkipSave` exception when this is a validation request."""
if 'validate' in request.GET and request.GET['validate'] == 'true':
if self.allow_standalone_validation:
params = QueryDict(request.body)
raise BinderSkipSave
else:
raise BinderRequestError('Standalone validation not enabled. You must enable this feature explicitly.')
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add in the error a small description on how to enable this standalone_validation? e.g. add allow_standalone_validation = True on the model Foo

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See my latest commit




# Deserialize JSON to Django Model objects.
# obj: Model object to update (for PUT), newly created object (for POST)
# values: Python dict of {field name: value} (parsed JSON)
Expand Down Expand Up @@ -2073,21 +2096,23 @@ def _multi_put_deletions(self, deletions, new_id_map, request):


def multi_put(self, request):
logger.info('ACTIVATING THE MULTI-PUT!!!1!')
logger.info('ACTIVATING THE MULTI-PUT!!!!!')

# Hack to communicate to _store() that we're not interested in
# the new data (for perf reasons).
request._is_multi_put = True

data, deletions = self._multi_put_parse_request(request)
objects = self._multi_put_collect_objects(data)
objects, overrides = self._multi_put_override_superclass(objects)
objects, overrides = self._multi_put_override_superclass(objects) # model inheritance
objects = self._multi_put_convert_backref_to_forwardref(objects)
dependencies = self._multi_put_calculate_dependencies(objects)
ordered_objects = self._multi_put_order_dependencies(dependencies)
new_id_map = self._multi_put_save_objects(ordered_objects, objects, request)
self._multi_put_id_map_add_overrides(new_id_map, overrides)
new_id_map = self._multi_put_deletions(deletions, new_id_map, request)
new_id_map = self._multi_put_save_objects(ordered_objects, objects, request) # may raise validation errors
self._multi_put_id_map_add_overrides(new_id_map, overrides) # model inheritance
new_id_map = self._multi_put_deletions(deletions, new_id_map, request) # may raise validation errors

self._abort_when_standalone_validation(request)

output = defaultdict(list)
for (model, oid), nid in new_id_map.items():
Expand Down Expand Up @@ -2123,6 +2148,8 @@ def put(self, request, pk=None):

data = self._store(obj, values, request)

self._abort_when_standalone_validation(request)

new = dict(data)
new.pop('_meta', None)

Expand Down Expand Up @@ -2153,6 +2180,8 @@ def post(self, request, pk=None):

data = self._store(self.model(), values, request)

self._abort_when_standalone_validation(request)

new = dict(data)
new.pop('_meta', None)

Expand Down Expand Up @@ -2190,6 +2219,9 @@ def delete(self, request, pk=None, undelete=False, skip_body_check=False):
raise BinderNotFound()

self.delete_obj(obj, undelete, request)

self._abort_when_standalone_validation(request)

logger.info('{}DELETEd {} #{}'.format('UN' if undelete else '', self._model_name(), pk))

return HttpResponse(status=204) # No content
Expand Down
11 changes: 10 additions & 1 deletion docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ Ordering is a simple matter of enumerating the fields in the `order_by` query pa
The default sort order is ascending. If you want to sort in descending order, simply prefix the attribute name with a minus sign. This honors the scoping, so `api/animal?order_by=-name,id` will sort by `name` in descending order and by `id` in ascending order.


### Saving a model
### Saving or updating a model

Creating a new model is possible with `POST api/animal/`, and updating a model with `PUT api/animal/`. Both requests accept a JSON body, like this:

Expand Down Expand Up @@ -161,6 +161,15 @@ If this request succeeds, you'll get back a mapping of the fake ids and the real

It is also possible to update existing models with multi PUT. If you use a "real" id instead of a fake one, the model will be updated instead of created.


#### Standalone Validation (without saving models)

Sometimes you want to validate the model that you are going to save without actually saving it. This is useful, for example, when you want to inform the user of validation errors on the frontend, without having to implement the validation logic again. You may check for validation errors by sending a `POST`, `PUT` or `PATCH` request with an additional query parameter `validate`.

Currently this is implemented by raising an `BinderValidateOnly` exception, which makes sure that the atomic database transaction is aborted. Ideally, you would only want to call the validation logic on the models, so only calling validation for fields and validation for model (`clean()`). But for now, we do it this way, at the cost of a performance penalty.

It is important to realize that in this way, the normal `save()` function is called on a model, so it is possible that possible side effects are triggered, when these are implemented directly in `save()`, as opposed to in a signal method, which would be preferable. In other words, we cannot guarantee that the request will be idempotent. Therefore, the validation only feature is disabled by default and must be enabled by setting `allow_standalone_validation=True` on the view.

### Uploading files

To upload a file, you have to add it to the `file_fields` of the `ModelView`:
Expand Down
228 changes: 228 additions & 0 deletions tests/test_model_validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
from re import I
from tests.testapp.models import contact_person
from tests.testapp.models.contact_person import ContactPerson
from django.test import TestCase, Client

import json
from binder.json import jsonloads
from django.contrib.auth.models import User
from .testapp.models import Animal, Caretaker, ContactPerson


class TestModelValidation(TestCase):
"""
Test the validate-only functionality.

We check that the validation is executed as normal, but that the models
are not created when the validate query paramter is set to true.

We check validation for:
- post
- put
- multi-put
- delete
"""


def setUp(self):
super().setUp()
u = User(username='testuser', is_active=True, is_superuser=True)
u.set_password('test')
u.save()
self.client = Client()
r = self.client.login(username='testuser', password='test')
self.assertTrue(r)

# some update payload
self.model_data_with_error = {
'name': 'very_special_forbidden_contact_person_name', # see `contact_person.py`
}
self.model_data = {
'name': 'Scooooooby',
}


### helpers ###


def assert_validation_error(self, response, person_id=None):
if person_id is None:
person_id = 'null' # for post

self.assertEqual(response.status_code, 400)

returned_data = jsonloads(response.content)

# check that there were validation errors
self.assertEqual(returned_data.get('code'), 'ValidationError')

# check that the validation error is present
validation_error = returned_data.get('errors').get('contact_person').get(str(person_id)).get('__all__')[0]
self.assertEqual(validation_error.get('code'), 'invalid')
self.assertEqual(validation_error.get('message'), 'Very special validation check that we need in `tests.M2MStoreErrorsTest`.')


def assert_multi_put_validation_error(self, response):
self.assertEqual(response.status_code, 400)

returned_data = jsonloads(response.content)

# check that there were validation errors
self.assertEqual(returned_data.get('code'), 'ValidationError')

# check that all (two) the validation errors are present
for error in returned_data.get('errors').get('contact_person').values():
validation_error = error.get('__all__')[0]
self.assertEqual(validation_error.get('code'), 'invalid')
self.assertEqual(validation_error.get('message'), 'Very special validation check that we need in `tests.M2MStoreErrorsTest`.')


### tests ###


def assert_no_validation_error(self, response):
self.assertEqual(response.status_code, 200)

# check that the validation was successful
returned_data = jsonloads(response.content)
self.assertEqual(returned_data.get('code'), 'SkipSave')
self.assertEqual(returned_data.get('message'), 'No validation errors were encountered.')


def test_validate_on_post(self):
self.assertEqual(0, ContactPerson.objects.count())

# trigger a validation error
response = self.client.post('/contact_person/?validate=true', data=json.dumps(self.model_data_with_error), content_type='application/json')
self.assert_validation_error(response)
self.assertEqual(0, ContactPerson.objects.count())

# now without validation errors
response = self.client.post('/contact_person/?validate=true', data=json.dumps(self.model_data), content_type='application/json')
self.assert_no_validation_error(response)
self.assertEqual(0, ContactPerson.objects.count())

# now for real
response = self.client.post('/contact_person/', data=json.dumps(self.model_data), content_type='application/json')
self.assertEqual(response.status_code, 200)
self.assertEqual('Scooooooby', ContactPerson.objects.first().name)


def test_validate_on_put(self):
person_id = ContactPerson.objects.create(name='Scooby Doo').id
self.assertEqual('Scooby Doo', ContactPerson.objects.first().name)

# trigger a validation error
response = self.client.put(f'/contact_person/{person_id}/?validate=true', data=json.dumps(self.model_data_with_error), content_type='application/json')
self.assert_validation_error(response, person_id)
self.assertEqual('Scooby Doo', ContactPerson.objects.first().name)

# now without validation errors
response = self.client.put(f'/contact_person/{person_id}/?validate=true', data=json.dumps(self.model_data), content_type='application/json')
self.assert_no_validation_error(response)
self.assertEqual('Scooby Doo', ContactPerson.objects.first().name)

# now for real
response = self.client.put(f'/contact_person/{person_id}/', data=json.dumps(self.model_data), content_type='application/json')
self.assertEqual(response.status_code, 200)
self.assertEqual('Scooooooby', ContactPerson.objects.first().name)


def test_validate_on_multiput(self):
person_1_id = ContactPerson.objects.create(name='Scooby Doo 1').id
person_2_id = ContactPerson.objects.create(name='Scooby Doo 2').id

multi_put_data = {'data': [
{
'id': person_1_id,
'name': 'New Scooby',
},
{
'id': person_2_id,
'name': 'New Doo'
}
]}

multi_put_data_with_error = {'data': [
{
'id': person_1_id,
'name': 'very_special_forbidden_contact_person_name',
},
{
'id': person_2_id,
'name': 'very_special_forbidden_contact_person_name'
}
]}

# trigger a validation error
response = self.client.put(f'/contact_person/?validate=true', data=json.dumps(multi_put_data_with_error), content_type='application/json')
self.assert_multi_put_validation_error(response)
self.assertEqual('Scooby Doo 1', ContactPerson.objects.get(id=person_1_id).name)
self.assertEqual('Scooby Doo 2', ContactPerson.objects.get(id=person_2_id).name)


# now without validation error
response = self.client.put(f'/contact_person/?validate=true', data=json.dumps(multi_put_data), content_type='application/json')
self.assert_no_validation_error(response)
self.assertEqual('Scooby Doo 1', ContactPerson.objects.get(id=person_1_id).name)
self.assertEqual('Scooby Doo 2', ContactPerson.objects.get(id=person_2_id).name)

# now for real
response = self.client.put(f'/contact_person/', data=json.dumps(multi_put_data), content_type='application/json')
self.assertEqual(response.status_code, 200)
self.assertEqual('New Scooby', ContactPerson.objects.get(id=person_1_id).name)
self.assertEqual('New Doo', ContactPerson.objects.get(id=person_2_id).name)


def test_validate_on_delete(self):
'''Check if deletion is cancelled when we only attempt to validate
the delete operation. This test only covers validation of the
on_delete=PROTECT constraint of a fk.'''

def is_deleted(obj):
'''Whether the obj was soft-deleted, so when the 'deleted'
attribute is not present, or when it is True.'''

try:
obj.refresh_from_db()
except obj.DoesNotExist:
return True # hard-deleted
return animal.__dict__.get('deleted') or False


# animal has a fk to caretaker with on_delete=PROTECT
caretaker = Caretaker.objects.create(name='Connie Care')
animal = Animal.objects.create(name='Pony', caretaker=caretaker)


### with validation error

response = self.client.delete(f'/caretaker/{caretaker.id}/?validate=true')
# assert validation error
# and check that it was about the PROTECTED constraint
self.assertEqual(response.status_code, 400)
returned_data = jsonloads(response.content)
self.assertEqual(returned_data.get('code'), 'ValidationError')
self.assertEqual(returned_data.get('errors').get('caretaker').get(str(caretaker.id)).get('id')[0].get('code'), 'protected')

self.assertFalse(is_deleted(caretaker))


### without validation error

# now we delete the animal to make sure that deletion is possible
# note that soft-deleting will of course not remove the validation error
animal.delete()

# now no validation error should be trown
response = self.client.delete(f'/caretaker/{caretaker.id}/?validate=true')
print(response.content)
self.assert_no_validation_error(response)

self.assertFalse(is_deleted(caretaker))


### now for real

response = self.client.delete(f'/caretaker/{caretaker.id}/')
self.assertTrue(is_deleted(caretaker))
2 changes: 1 addition & 1 deletion tests/testapp/models/animal.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class Animal(LoadedValuesMixin, BinderModel):
name = models.TextField(max_length=64)
zoo = models.ForeignKey('Zoo', on_delete=models.CASCADE, related_name='animals', blank=True, null=True)
zoo_of_birth = models.ForeignKey('Zoo', on_delete=models.CASCADE, related_name='+', blank=True, null=True) # might've been born outside captivity
caretaker = models.ForeignKey('Caretaker', on_delete=models.PROTECT, related_name='animals', blank=True, null=True)
caretaker = models.ForeignKey('Caretaker', on_delete=models.PROTECT, related_name='animals', blank=True, null=True) # we use the fact that this one is PROTECT in `test_model_validation.py`
deleted = models.BooleanField(default=False) # Softdelete

def __str__(self):
Expand Down
Loading