Customization guide

Basics

It can be useful to know how it all works before writing your custom extensions.

Once a Model subclass is declared and created in the code, it automatically generates a msgspec.Struct type based on the field data in the Fields class. Basically, it goes across all the fields there and gathers name, field’s __base_type__ and default values and defines a structure type from them. The structure itself goes to the model’s __base_type__ class attribute. The code also creates JSON and Msgpack converters for this structure type and stores them at __decode__ and __decode_msgpack__ class attributes.

Once a model instance receives data in either one of its decode() or get_struct() methods, it uses the model’s structure class stored at __base_type__ to validate and load data to a struct object.

The important note here is that python types, model fields and constraints are evaluated on model class initialization and that python types (annotations) you bind to your fields should be compatible with msgspec type specification.

Fields

Note

Before writing custom stuff make sure there’s no default one for your task. See fields - model field types for detail on standard field types.

To write a custom field you should import the Field class and inherit from it. You have to set __base_type__ to a base Python type to be bound to this field and __ui_type__ with a string which should be displayed as type in the field UI schema.

from uuid import UUID
from kaiju_models import Field

class UUIDField(Field[UUID])
    __base_type__ = UUID
    __ui_type__ = 'uuid'

Suppose you want to add some custom field settings (attributes) and also display them in the UI schema. The best way is to create a dataclass from your field and also create a get_schema method returning this attribute value as well.

from dataclasses import dataclass

@dataclass(slots=True)
class UUIDField(Field[UUID])
    __base_type__ = UUID
    __ui_type__ = 'uuid'

    version: int = 4
    """UUID version number."""

def get_schema(self):
    return {**super().get_schema(), "version": self.version}

In case you wanted to validate the UUID against the version on the client side, you should probably move this attribute from the schema root too the validation section instead:

# better

@dataclass(slots=True)
class UUIDField(Field[UUID])
    __base_type__ = UUID
    __ui_type__ = 'uuid'

    version: int = 4
    """UUID version number."""

def _validation_schema(self):
    return {"version": self.version}

Post-validation

Suppose you also want to check for a proper UUID version on backend. There’s a problem! Standard type conversion doesn’t care for the UUID version and it won’t raise any error!!! There’s a way to counter that by creating a custom post-validation method for the field by declaring a validate function which accepts an attribute value, so the final example would be like this.

from kaiju_models import ModelValidationError, ErrorData, ErrorCode

@dataclass(slots=True)
class UUIDField(Field[UUID])
    __base_type__ = UUID
    __ui_type__ = 'uuid'

    version: int = 4
    """UUID version number."""

def validate(self, value: UUID, /) -> UUID:
    if value.version != self.version:
        raise ModelValidationError(
            'Invalid UUID version',
            data=ErrorData(code=ErrorCode.INVALID_FORMAT))
    return value

def _validation_schema(self):
    return {"version": self.version}

Note that a validate function receives a value after it has been decoded, so you can be sure that it will be of a proper type (or None if the value is allowed to be optional).

The validate function must also return a value of the same type after it has been validated or raise a ModelValidationError.

You can use validation functions to normalize values too.

from kaiju_models import StringField

class CapitalizedString(StringField[str])
    __base_type__ = str
    __ui_type__ = 'string'

def validate(self, value: str, /) -> str:
    return value.capitalize()

Although custom validator may be tempting, keep in mind that they may slow down data conversion. Try to stick to standard constraints and types whenever possible.

More complex types

Models API uses msgspec types for data conversion and validation with a variety of supported types. Sometimes a more complex type behavior than just a simple type hint is required. For example, take a look at the standard ListField source code.

# part of source code skipped

class ListField(Field[list]):

    __base_type__ = list

    def __init__(self, model: Field, *args, min_length: int = None, max_length: int = None, **kws: Any) -> None:
        self.model = model
        self.min_length = min_length
        self.max_length = max_length
        super().__init__(*args, default=[], **kws)

    def _get_annotation(self) -> type:
        return Annotated[self.__base_type__[self.model.__annotation__], self._get_annotation_meta()]

    def _get_annotation_meta(self):
        return Meta(
            min_length=self.min_length,
            max_length=self.max_length,
            title=self.title,
            description=self.description)

As you can see, ListField uses msgspec meta to define length constraints in a customized _get_annotation_meta() method. But that’s not all.

It also uses custom _get_annotation() to create an annotation for not only a list but for a list of items of certain type depending on which model was passed to the list. On practice the result would look like this:

class Item(Model['Item.Fields']):
    class Fields(Model.Fields):
        id: str = StringField()
        quantity: int = IntegerField()

class Cart(Model['Cart.Fields']):
    class Fields(Model.Fields):
        items: list[Item] = ListField(Item(), min_length=1, max_length=10)

The API will automatically create an annotation and constraint for a list of items and add it to the structure.