Usage¶
Schemas¶
Schemas are defined by subclassing Schema
and defining fields as class properties.
import datetime
from ciri import fields, Schema, ValidationError
class User(Schema):
name = fields.String(required=True)
type_ = fields.String(name='type', required=True)
created = fields.Date()
user = User(name='Eric', type_='dev', created=datetime.date(2018, 1, 1))
user.serialize()
# {'name': Eric', 'type': 'dev', 'created': '2018-01-01'}
Subclassing¶
Schemas can be subclassed as you would normal objects.
class Person(Schema):
name = fields.String()
age = fields.Integer()
class Parent(Person):
# inherits name, age
children = fields.List(Person())
class GrandParent(Parent):
# inherits name, age, children
grandchildren = fields.List(Person())
Subclasses can override existing fields or remove them altogether:
class Contact(Schema)
id = fields.Integer(required=True)
name = fields.String(required=True)
mobile = fields.String()
emergency_contact_id = fields.Integer()
class EmergencyContact(Schema)
mobile = fields.String(required=True)
emergency_contact_id = None
Behavior¶
All schemas have a SchemaOptions
object which sets the default behavior. You can
configure your own defaults by setting the __schema_options__ property in your schema definition.
Note
Schema options are not inherited by subclasses by design. This allows schema composition to be defined by the instanced schema and field behavior. For this reason, it’s recommended to create your own subclass of Schema if you want to modify all schemas behavior. Alternatively, you can override the options as needed as shown below.
from ciri import fields, Schema, SchemaOptions
class Person(Schema):
__schema_options__ = SchemaOptions(allow_none=True)
name = fields.String()
person = Person().serialize({}) # {'name': None}
Validation¶
Validate data using the validate()
method:
class Person(Schema):
name = fields.String(required=True)
person = Person().serialize({}) # raises ValidationError
Raw validation errors are stored on the _raw_errors property. The schema error handler will also compile error output to be used in your application and can be accessed through the errors property. Check out the Errors section for more details on what to do with validation errors.
Serialization¶
Data is serialized using the serialize()
method:
class Person(Schema):
name = fields.String(required=True)
person = Person().serialize({'name': 'Harry'}) # {'name': 'Harry'}
You can also serialize a schema instance by passing no data to the serailize method:
person = Person(name='Harry').serialize() # {'name': Harry}
By default, validate()
is called serialization. You can skip validation
(if you have already validated elsewhere for example) by passing skip_validation to the
serialize method:
person = Person(name=123).serialize(skip_validation=True) # {'name': 123}
All serialization requires validation to correctly serialize, but if you are confident the data being serialized is already valid, you can save time by skipping validation. This is useful if you are serializing database output or other known values.
Deserialization¶
Data is deserialized using the deserialize()
method. It behaves the same
way as the serialization method and has the same validation caveats. Check out the Serialization
section for more info.
class Person(Schema):
name = fields.String(required=True)
person = Person().deserialize({'name': 'Harry'})
person.name # Harry
Encoding¶
Data is encoded using the (you guessed it) encode()
method. By default, the
validate()
and serialize()
methods will be called and
the resulting serialized data will be passed to the encoder. You can skip validation and serialization
using the skip_validation and skip_serialization keyword args.
The default encoder class is JSONEncoder
but can be set in the schema options.
class Person(Schema):
name = fields.String()
active = fields.Boolean(default=False)
person = Person(name=Harry).encode() # '{"name": "Harry", "active": false}'
Errors¶
Things go wrong. It’s important to know why they went wrong. When a field is invalid,
a FieldValidationError
is raised. The offending FieldError
passed is then set on on the schemas _raw_errors dict under the key the field was defined as
on the schema (not the serialized output name). More often than not, you won’t need to care about the
_raw_errors but it can be useful for testing and debugging.
By contrast, the errors property is very useful for error reporting. It holds the formatted error output. The default error handler outputs errors that are structured like so:
{'field_key': {'msg': 'Error Description'}}
So in effect:
from ciri import fields, Schema, ValidationError
class Person(Schema):
name = fields.String(required=True)
age = fields.Integer(required=True)
born = fields.Date(required=True)
try:
person = Person(name=Harry, age="42").serialize()
except ValidationError:
person.errors # {'age': {'msg': 'Field is not a valid Integer'}, 'born': {'msg': 'Required Field'}}
Nested Errors¶
Sequence and Mapping fields such as List
and Schema
can
contain multiple errors. The default error handler will nest these errors under the errors key.
Here is an example of nested errors:
class Person(Schema):
name = fields.String(required=True)
age = fields.Integer(required=True)
born = fields.Date(required=True)
class Sibling(Person):
mother = fields.Schema(Person, required=True)
siblings = fields.List(Person(), required=True)
mother = Person(name='Karen', age="73", born='1937-06-17')
brother = Person(name='Joe', age=45, born='1965-10-11')
child = Sibling(name='Harry', age=42, mother=mother, siblings=[brother, 'sue'])
try:
child.serialize()
except Exception as e:
print(child.errors)
# {'born': {'msg': 'Required Field'},
# 'siblings': {'msg': 'Invalid Item(s)', 'errors': {'1': {'msg': 'Field is not a valid Mapping'}}},
# 'mother': {'msg': 'Invalid Schema', 'errors': {'age': {'msg': 'Field is not a valid Integer'}}}}
Note
Sequence fields will use the sequence index (coerced with str
) as the error key.
Composition¶
Schemas can be composed using the __schema_include__ attribute. You can alternatively provide the compose attribute of the schema Meta class, which sets the schema include property for you.
Normal mixins work as well, and so does a dict of fields.
class A(Schema):
a = fields.String()
class B(Schema):
b = fields.String()
class C(Schema):
c = fields.String()
class AB(A, B):
pass
class ABC(Schema):
class Meta:
compose = [AB, C]
class ABCD(ABC):
__schema_include__ = [{'d': fields.String()}]
Schema composition can be very handy when you have many overlapping fields between schemas, but not all fields apply. For example, let’s define some schemas for some imaginary API responses:
Polymorphic Schemas¶
Polymorphic schemas let you define a set of derived schema variations. In other words, you can define a base schema that can be mapped to another schema using an identifier that resides on the schema itself.
Let’s examine a small but practical use case for using polymorphism; versioning. Take this basic user schema as an example:
from ciri import PolySchema
class AppUser(PolySchema):
username = fields.String(required=True)
email = fields.String(required=True)
version = fields.String(default='v1')
__poly_on__ = version # define the field which will determine the subclass mapping
The __poly_on__ attribute defines which schema field will be used to map subclasses. Notice that we’ve also got the username and email fields. We can now use the AppUser schema to define further variations.
class AppUserV1(AppUser):
__poly_id__ = 'v1' # define the polymorphic identifier
# add additional fields
first_name = fields.String(required=True)
last_name = fields.String()
twitter_url = fields.String(allow_none=True)
class AppUserV2(AppUserV1):
__poly_id__ = 'v2' # define the polymorphic identifier
# add another field
roles = fields.List(fields.String())
# modify another field
last_name = fields.String(required=True)
Above we’ve used the __poly_id__ attribute to define a v1 and v2 schema mapping. Notice how you can continue to subclass the original
PolySchema
. Now let’s see what makes these mappings so handy.
user_data = {
'username': 'magic_is_cool',
'email': 'hpotter@hogwarts.example.com',
'first_name': 'Harry',
'last_name': 'Potter',
'roles': ['student', 'wizard']
}
v1_user = AppUser(version='v1', **user_data)
# <AppUser object at 0x7f4441245e48>
v2_user = AppUser(version='v2', **user_data)
# <AppUser object at 0x7fd127dfae48>
v1_user_again = AppUser.polymorph(version='v1', **user_data)
# <AppUserV1 object at 0x7f64597520b8>
v2_user_again = AppUser().deserialize(v2_user.serialize())
# <AppUserV2 object at 0x7fd127dfaf28>
Notice that each of these interactions utilize the same base polymorphic schema class, but will return different schema instances depending on the usage. This is important to know when performing checks like isinstance, but for most other intents and purposes, they can be considered functionally equivalent.
Here’s a bit of code that may make that more clear:
print(isinstance(v1_user, AppUserV1))
# False
print(isinstance(v1_user_again, AppUserV1))
# True
print(v1_user.serialize() == v1_user_again.serialize())
# True