Skip to content

Commit

Permalink
More encoders (#302)
Browse files Browse the repository at this point in the history
* Update black
* Update mypy
* Add documentation about the encoders
  • Loading branch information
tarsil committed May 3, 2024
1 parent b4df3b4 commit 31bb584
Show file tree
Hide file tree
Showing 10 changed files with 320 additions and 0 deletions.
8 changes: 8 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ repos:
hooks:
- id: ruff
args: ["--fix", "--line-length=99"]
<<<<<<< HEAD
=======
- repo: https://github.com/psf/black
rev: 24.4.1
hooks:
- id: black
args: ["--line-length=99"]
>>>>>>> 932521b (More encoders (#302))
ci:
autofix_commit_msg: 🎨 [pre-commit.ci] Auto format from pre-commit.com hooks
autoupdate_commit_msg: ⬆ [pre-commit.ci] pre-commit autoupdate
171 changes: 171 additions & 0 deletions docs/encoders.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
# Encoders

Esmerald being built on top of Lilya, brings another level of flexibility, the **encoders**.

Pretty much like Lilya, an Encoder is what allows a specific type of object to be understood,
encoded and serialized by Esmerald without breaking the application.

An example of default existing encoders in Esmerald would be the support for **Pydantic** and **MsgSpec**.

!!! Warning
The encoders came to Esmerald after the version **3.1.2**. If you are using a version prior
to that, this won't be available.

## Benefits of encoders

The greatest benefit of supporting the encoders is that you don't need to rely on a specific framework
to support a specific library for you to use.

With Esmerald `Encoder` you can design it yourself and simply add it to Esmerald to be used making it
**future proof** and extremely dynamic.

## How to use it

To take advantage of the Encoders **you must subclass the Encoder from Esmerald and implement three mandatory functions**.

```python
from esmerald.encoders import Encoder
```

When subclassing the `Encoder`, the mandatory functions are:

* [`is_type()`](#is_type)
* [`serialize()`](#serialize)
* [`encode()`](#encode)

Esmerald extends the native functionality of Lilya regarding the encoders and adds some extra flavours to it.

The reasoning behind it its because Esmerald internally manages signatures and data validations that are
unique to Esmerald.

### is_type

This function might sound confusing but it is in fact something simple. This function is used to check
if the object of type X is an instance or a subclass of that same type.

!!! Danger
Here is where it is different from Lilya. With Lilya you can use the `__type__` as well but
**not in Esmerald. In Esmerald you must implement the `is_type` function.

#### Example

This is what currently Esmerald is doing for Pydantic and MsgSpec.

```python
{!> ../docs_src/encoders/is_type.py !}
```

As you can see, this is how we check and verify if an object of type `BaseModel` and `Struct` are
properly validated by Esmerald.

### serialize

This function is what tells Esmerald how to serialize the given object type into a JSON readable
format.

Quite simple and intuitive.

#### Example

```python
{!> ../docs_src/encoders/serialize.py !}
```

### encode

Finally, this functionality is what converts a given piece of data (JSON usually) into an object
of the type of the Encoder.

For example, a dictionary into Pydantic models or MsgSpec Structs.

#### Example

```python
{!> ../docs_src/encoders/encode.py !}
```

### The flexibility

As you can see, there are many ways of you building your encoders. Esmerald internally already brings
two of them out of the box but you are free to build your own [custom encoder](#custom-encoders) and
apply your own logic and validations.

You have 100% the power and control over any validator you would love to have in your Esmerald application.

### Custom Encoders

Well, this is where it becomes interesting. What if you actually want to build an Encoder that is not
currently supported by Esmerald natively, for example, the library `attrs`?

It is in fact very simple as well, following the previous steps and explanations, it would look
like this:

```python
{!> ../docs_src/encoders/custom.py !}
```

Do you see any differences compared to `Pydantic` and `MsgSpec`?

Well, the `is_type` does not check for an `isinstance` or `is_class_and_subclass` and the reason
for that its because when using `attrs` there is not specific object of type X like we have in others,
in fact, the `attrs` uses decorators for it and by default provides a `has()` function that is used
to check the `attrs` object types, so we can simply use it.

Every library has its own ways, object types and everything in between to check and
**this is the reason why the `is_type` exists, to make sure you have the control over the way the typing is checked**.

Now imagine what you can do with any other library at your choice.

### Register the Encoder

Well, building the encoders is good fun but it does nothing to Esmerald unless you make it aware those
in fact exist and should be used.

There are different ways of registering the encoders.

* Via [settings](#via-settings)
* Via [instance](#via-instance)

Esmerald also provides a function to register anywhere in your application but **it is not recommended**
to use it without understanding the ramifications, mostly if you have handlers relying on a given
object type that needs the encoder to be available before assembling the routing system.

```python
from esmerald.encoders import register_esmerald_encoder
```

#### Via Settings

Like everything in Esmerald, you can use the settings for basically everything in your application.

Let us use the example of the [custom encoder](#custom-encoders) `AttrsEncoder`.

```python
{!> ../docs_src/encoders/via_settings.py !}
```

#### Via Instance

Classic approach and also available in any Esmerald or ChildEsmerald instance.

```python
{!> ../docs_src/encoders/via_instance.py !}
```

#### Adding an encoder via app instance function

This is also available in any Esmerald and ChildEsmerald application. If you would like to add
an encoder after instantiation you can do it but again, **it is not recommended**
to use it without understanding the ramifications, mostly if you have handlers relying on a given
object type that needs the encoder to be available before assembling the routing system.

```python
{!> ../docs_src/encoders/via_func.py !}
```

### Notes

Having this level of flexibility is great in any application and Esmerald makes it easy for you but
it is also important to understand that this level of control also comes with risks, meaning, when
you build an encoder, make sure you test all the cases possible and more importantly, you implement
**all the functions** mentioned above or else your application will break.
27 changes: 27 additions & 0 deletions docs_src/encoders/custom.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from __future__ import annotations

from typing import Any

from attrs import asdict, define, field, has

from esmerald.encoders import Encoder


class AttrsEncoder(Encoder):

def is_type(self, value: Any) -> bool:
return has(value)

def serialize(self, obj: Any) -> Any:
return asdict(obj)

def encode(self, annotation: Any, value: Any) -> Any:
return annotation(**value)


# The way an `attr` object is defined
@define
class AttrItem:
name: str = field()
age: int = field()
email: str
36 changes: 36 additions & 0 deletions docs_src/encoders/encode.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from __future__ import annotations

from typing import Any

import msgspec
from msgspec import Struct
from pydantic import BaseModel

from esmerald.encoders import Encoder
from lilya._utils import is_class_and_subclass


class MsgSpecEncoder(Encoder):

def is_type(self, value: Any) -> bool:
return isinstance(value, Struct) or is_class_and_subclass(value, Struct)

def serialize(self, obj: Any) -> Any:
return msgspec.json.decode(msgspec.json.encode(obj))

def encode(self, annotation: Any, value: Any) -> Any:
return msgspec.json.decode(msgspec.json.encode(value), type=annotation)


class PydanticEncoder(Encoder):

def is_type(self, value: Any) -> bool:
return isinstance(value, BaseModel) or is_class_and_subclass(value, BaseModel)

def serialize(self, obj: BaseModel) -> dict[str, Any]:
return obj.model_dump()

def encode(self, annotation: Any, value: Any) -> Any:
if isinstance(value, BaseModel):
return value
return annotation(**value)
21 changes: 21 additions & 0 deletions docs_src/encoders/is_type.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from __future__ import annotations

from typing import Any

from msgspec import Struct
from pydantic import BaseModel

from esmerald.encoders import Encoder
from lilya._utils import is_class_and_subclass


class MsgSpecEncoder(Encoder):

def is_type(self, value: Any) -> bool:
return isinstance(value, Struct) or is_class_and_subclass(value, Struct)


class PydanticEncoder(Encoder):

def is_type(self, value: Any) -> bool:
return isinstance(value, BaseModel) or is_class_and_subclass(value, BaseModel)
28 changes: 28 additions & 0 deletions docs_src/encoders/serialize.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from __future__ import annotations

from typing import Any

import msgspec
from msgspec import Struct
from pydantic import BaseModel

from esmerald.encoders import Encoder
from lilya._utils import is_class_and_subclass


class MsgSpecEncoder(Encoder):

def is_type(self, value: Any) -> bool:
return isinstance(value, Struct) or is_class_and_subclass(value, Struct)

def serialize(self, obj: Any) -> Any:
return msgspec.json.decode(msgspec.json.encode(obj))


class PydanticEncoder(Encoder):

def is_type(self, value: Any) -> bool:
return isinstance(value, BaseModel) or is_class_and_subclass(value, BaseModel)

def serialize(self, obj: BaseModel) -> dict[str, Any]:
return obj.model_dump()
8 changes: 8 additions & 0 deletions docs_src/encoders/via_func.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from myapp.encoders import AttrsEncoder

from esmerald import Esmerald

app = Esmerald(
routes=[...],
)
app.register_encoder(AttrsEncoder)
8 changes: 8 additions & 0 deletions docs_src/encoders/via_instance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from myapp.encoders import AttrsEncoder

from esmerald import Esmerald

app = Esmerald(
routes=[...],
encoders=[AttrsEncoder],
)
12 changes: 12 additions & 0 deletions docs_src/encoders/via_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from typing import List, Union

from myapp.encoders import AttrsEncoder

from esmerald import EsmeraldAPISettings
from esmerald.encoders import Encoder


class AppSettings(EsmeraldAPISettings):
@property
def encoders(self) -> Union[List[Encoder], None]:
return [AttrsEncoder]
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ nav:
- Request: "requests.md"
- Context: "context.md"
- Responses: "responses.md"
- Encoders: "encoders.md"
- MsgSpec: "msgspec.md"
- Background Tasks: "background-tasks.md"
- Lifespan Events: "lifespan-events.md"
Expand Down

0 comments on commit 31bb584

Please sign in to comment.