Skip to content

Commit 23bec5e

Browse files
committed
serializers: redesigned validation and star-like writable fields
* remove `source="*"` handling * move instance/representation manipulation responsibility to field objects to support nested objects and multiple key access pattern * update docs * fix #44 (writable star-like fields) * fix #43 (broken resource manipulation on star-like fields) * fix #42 (wrong field descriptions on validation errors) * redesign resource/field validation process
1 parent d7679ac commit 23bec5e

File tree

7 files changed

+306
-271
lines changed

7 files changed

+306
-271
lines changed

docs/guide/serializers.rst

Lines changed: 130 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -110,25 +110,21 @@ All field classes accept this set of arguments:
110110
intead of relying on param labels.*
111111

112112
* **source** *(str, optional):* name of internal object key/attribute
113-
that will be passed to field's on ``.to_representation(value)`` call.
114-
Special ``'*'`` value is allowed that will pass whole object to
115-
field when making representation. If not set then default source will
116-
be a field name used as a serializer's attribute.
113+
that will be passed to field's on ``.to_representation(value)`` call. If not
114+
set then default source is a field name used as a serializer's attribute.
117115

118116
* **validators** *(list, optional):* list of validator callables.
119117

120118
* **many** *(bool, optional)* set to True if field is in fact a list
121119
of given type objects
122120

123121

124-
.. note::
125-
126-
``source='*'`` is in fact a dirty workaround and will not work well
127-
on validation when new object instances needs to be created/updated
128-
using POST/PUT requests. This works quite well with simple retrieve/list
129-
type resources but in more sophisticated cases it is better to use
130-
custom object properties as sources to encapsulate such fields.
122+
.. versionchanged:: 1.0.0
131123

124+
Fields no no longer have special case treatment for ``source='*'`` argument.
125+
If you want to access multiple object keys and values within single
126+
serializer field please refer to :ref:`guide-field-attribute-access` section
127+
of this document.
132128

133129
.. _field-validation:
134130

@@ -143,9 +139,9 @@ in order to provide correct HTTP responses each validator shoud raise
143139
.. note::
144140

145141
Concept of validation for fields is understood here as a process of checking
146-
if data of valid type (successfully parsed/processed by
147-
``.from_representation`` handler) does meet some other constraints
148-
(lenght, bounds, unique, etc).
142+
if data of valid type (i.e. data that was successfully parsed/processed by
143+
``.from_representation()`` handler) does meet some other constraints
144+
(lenght, bounds, uniquess, etc).
149145

150146

151147
Example of simple validator usage:
@@ -174,31 +170,53 @@ Resource validation
174170
~~~~~~~~~~~~~~~~~~~
175171

176172
In most cases field level validation is all that you need but sometimes you
177-
need to perfom obejct level validation that needs to access multiple fields
178-
that are already deserialized and validated. Suggested way to do this in
179-
graceful is to override serializer's ``.validate()`` method and raise
180-
:class:`graceful.errors.ValidationError` when your validation fails. This
181-
exception will be then automatically translated to HTTP Bad Request response
182-
on resource-level handlers. Here is example:
173+
need to perfom validation on whole resource representation or deserialized
174+
object. It is possible to access multiple fields that were already deserialized
175+
and pre-validated directly from serializer class.
176+
177+
You can provide your own object-level serialization handler using serializer's
178+
``validate()`` method. This method accepts two arguments:
179+
180+
* **object_dict** *(dict):* it is deserialized object dictionary that already
181+
passed validation. Field sources instead of their representation names are
182+
used as its keys.
183+
184+
* **partial** *(bool):* it is set to ``True`` only on partial object updates
185+
(e.g. on ``PATCH`` requests). If you plan to support partial resource
186+
modification you should check this field and verify if you object has
187+
all the existing keys.
188+
189+
If your validation fails you should raise the
190+
:class:`graceful.errors.ValidationError` exception. Following is the example
191+
of resource serializer with custom object-level validation:
183192

184193

185194
.. code-block:: python
186195
187196
class DrinkSerializer():
188-
alcohol = StringField("main ingredient", required=True)
189-
mixed_with = StringField("what makes it tasty", required=True)
197+
alcohol = StringField("main ingredient")
198+
mixed_with = StringField("what makes it tasty")
199+
200+
def validate(self, object_dict, partial):
201+
# note: always make sure to call super `validate_object()`
202+
# to make sure that per-field validation is enabled.
190203
191-
def validate(self, object_dict, partial=False):
192-
# note: always make sure to call super `validate()`
193-
# so whole validation of fields works as expected
194-
super().validate(object_dict, partial)
204+
if partial and any([
205+
'alcohol' in object_dict,
206+
'mixed_with' in object_dict,
207+
]):
208+
raise ValidationError(
209+
"bartender refused to change ingredients"
210+
)
195211
196212
# here is a place for your own validation
197213
if (
198214
object_dict['alcohol'] == 'whisky' and
199215
object_dict['mixed_with'] == 'cola'
200216
):
201-
raise ValidationError("bartender refused!')
217+
raise ValidationError(
218+
"bartender refused to mix whisky with cola!"
219+
)
202220
203221
204222
Custom fields
@@ -228,3 +246,88 @@ as a serialized JSON string that we would like to (de)serialize:
228246
def to_representation(data):
229247
return json.loads(data)
230248
249+
.. _guide-field-attribute-access:
250+
251+
252+
Accessing multiple fields at once
253+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
254+
255+
Sometimes you need to access multiple fields of internal object instance at
256+
once in order to properly represent data in your API. This is very common when
257+
interacting with legacy services/components that cannot be changed or when
258+
your storage engine simply does not allow to store nested or structured objects.
259+
260+
Serializers generally work on per-field basis and allow only to translate field
261+
names between representation and application internal objects. In order to
262+
manipulate multiple representation or internal object instance keys within the
263+
single field you need to create custom field class and override one or more
264+
of following methods:
265+
266+
* ``read_instance(self, instance, key_or_attribute)``: read value from the
267+
object instance before serialization. The return value will be later passed
268+
as an argument to ``to_representation()`` method. The ``key_or_attribute``
269+
argument is field's name or source (if ``source`` explicitly specified).
270+
Base implementation defaults to dictionary key lookup or object attribute
271+
lookup.
272+
* ``read_representation(self, representation, key_or_attribute)``: read value
273+
from the object instance before deserialization. The return value will be
274+
later passed as an argument to ``from_representation()`` method. The
275+
``key_or_attribute`` argument the field's name. Base implementation defaults
276+
to dictionary key lookup or object attribute lookup.
277+
* ``update_instance(self, instance, key_or_attribute, value)``: update the
278+
content of object instance after deserialization. The ``value`` argument is
279+
the return value of ``from_representation()`` method. The
280+
``key_or_attribute`` argument the field's name or source (if ``source``
281+
explicitly specified). Base implementation defaults to dictionary key
282+
assignment or object attribute assignment.
283+
* ``update_representation(self, representation, key_or_attribute, value)``:
284+
update the content of representation instance after serialization.
285+
The ``value`` argument is the return value of ``to_representation()`` method.
286+
The ``key_or_attribute`` argument the field's name. Base implementation
287+
defaults to dictionary key assignment or object attribute assignment.
288+
289+
To better explain how to use these methods let's assume that due to some
290+
storage backend constraints we cannot save nested dictionaries. All of fields
291+
of some nested object will have to be stored under separate keys but we still
292+
want to present this to the user as separate nested dictionary. And of course
293+
we want to support both writes and saves.
294+
295+
.. code-block:: python
296+
297+
class OwnerField(RawField):
298+
def from_representation(self, data):
299+
if not isinstance(data, dict):
300+
raise ValueError("expected object")
301+
302+
return {
303+
'owner_name': data.get('name'),
304+
'owner_age': data.get('age'),
305+
}
306+
307+
def to_representation(self, value):
308+
return {
309+
'age': value.get('owner_age'),
310+
'name': value.get('owner_name'),
311+
}
312+
313+
def validate(self, value):
314+
print(value)
315+
if 'owner_age' not in value or not isinstance(value['owner_age'], int):
316+
raise ValidationError("invalid owner age")
317+
318+
if 'owner_name' not in value:
319+
raise ValidationError("invalid owner name")
320+
321+
def update_instance(self, instance, attribute_or_key, value):
322+
# we assume that instance is always a dictionary so we can
323+
# use the .update() method
324+
instance.update(value)
325+
326+
def read_instance(self, instance, attribute_or_key):
327+
# .to_representation() method requires acces to whole object
328+
# dictionary so we have to return whole object.
329+
return instance
330+
331+
332+
Similar approach may be used to flatten nested objects into more compact
333+
representations.

src/graceful/errors.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ def _get_description(self):
3434
"forbidden: {}".format(self.forbidden)
3535
if self.forbidden else ""
3636
),
37-
"invalid: {}:".format(self.invalid) if self.invalid else "",
37+
"invalid: {}".format(self.invalid) if self.invalid else "",
3838
(
3939
"failed to parse: {}".format(self.failed)
4040
if self.failed else ""

src/graceful/fields.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import inspect
2+
from collections import Mapping, MutableMapping
23

34
from graceful.validators import min_validator, max_validator
45

@@ -60,6 +61,14 @@ def from_representation(self, data):
6061
def to_representation(self, value):
6162
return ["True", "False"][value]
6263
64+
.. versionchanged:: 1.0.0
65+
Field instances no longer support ``source="*"`` to access whole object
66+
for the purpose of representation serialization/deserialization. If you
67+
want to access multiple fields of object instance and/or its
68+
representation you must override ``read_*`` and ``update_*`` methods in
69+
your custom field classes (see :ref:`guide-field-attribute-access`
70+
section in documentation).
71+
6372
"""
6473

6574
#: Two-tuple ``(label, url)`` pointing to represented type specification
@@ -172,6 +181,66 @@ def validate(self, value):
172181
for validator in self.validators:
173182
validator(value)
174183

184+
def update_instance(self, instance, attribute_or_key, value):
185+
"""Update object instance after deserialization.
186+
187+
Args:
188+
instance (object): dictionary or object after serialization.
189+
attribute_or_key (str): field's name or source (if ``source``
190+
explicitly specified).
191+
value (object): return value from ``from_representation`` method.
192+
"""
193+
if isinstance(instance, MutableMapping):
194+
instance[attribute_or_key] = value
195+
else:
196+
setattr(instance, attribute_or_key, value)
197+
198+
def read_instance(self, instance, attribute_or_key):
199+
"""Read value from the object instance before serialization.
200+
201+
Args:
202+
instance (object): dictionary or object before serialization.
203+
attribute_or_key (str): field's name or source (if ``source``
204+
explicitly specified).
205+
206+
Returns:
207+
The value that will be later passed as an argument to
208+
``to_representation()`` method.
209+
"""
210+
if isinstance(instance, Mapping):
211+
return instance.get(attribute_or_key, None)
212+
213+
return getattr(instance, attribute_or_key, None)
214+
215+
def update_representation(self, representation, attribute_or_key, value):
216+
"""Update representation after field serialization.
217+
218+
Args:
219+
instance (object): representation object.
220+
attribute_or_key (str): field's name.
221+
value (object): return value from ``to_representation`` method.
222+
"""
223+
if isinstance(representation, MutableMapping):
224+
representation[attribute_or_key] = value
225+
else:
226+
setattr(representation, attribute_or_key, value)
227+
228+
def read_representation(self, representation, attribute_or_key):
229+
"""Read value from the representation before deserialization.
230+
231+
Args:
232+
instance (object): dictionary or object before deserialization.
233+
attribute_or_key (str): field's name.
234+
235+
Returns:
236+
The value that will be later passed as an argument to
237+
``from_representation()`` method.
238+
"""
239+
if isinstance(representation, Mapping):
240+
return representation.get(attribute_or_key, None)
241+
242+
return getattr(representation, attribute_or_key, None)
243+
175244

176245
class RawField(BaseField):
177246
"""Represents raw field subtype.

src/graceful/resources/base.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -424,11 +424,11 @@ def require_validated(self, req, partial=False, bulk=False):
424424

425425
try:
426426
for representation in representations:
427-
object_dict = self.serializer.from_representation(
428-
representation
427+
object_dicts.append(
428+
self.serializer.from_representation(
429+
representation, partial
430+
)
429431
)
430-
self.serializer.validate(object_dict, partial)
431-
object_dicts.append(object_dict)
432432

433433
except DeserializationError as err:
434434
# when working on Resource we know that we can finally raise

0 commit comments

Comments
 (0)