Customization guide =================== Basics ------ It can be useful to know how it all works before writing your custom extensions. Once a :py:class:`~kaiju_models.bases.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 :py:meth:`~kaiju_models.bases.Model.decode` or :py:meth:`~kaiju_models.bases.Model.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 :ref:`fields-api` for detail on standard field types. To write a custom field you should import the :py:class:`~kaiju_models.bases.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. .. code-block:: python 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. .. code-block:: python 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: .. code-block:: python # 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. .. code-block:: python 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 :py:class:`~kaiju_models.bases.ModelValidationError`. You can use validation functions to normalize values too. .. code-block:: python 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 :py:class:`~kaiju_models.fields.ListField` source code. .. code-block:: python # 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, :py:class:`~kaiju_models.fields.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: .. code-block:: python 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.