Skip to content

Commit

Permalink
Model Serializer #63
Browse files Browse the repository at this point in the history
  • Loading branch information
AliRn76 authored Jan 23, 2024
2 parents e3f654f + 84139e6 commit 9c4ba74
Show file tree
Hide file tree
Showing 8 changed files with 321 additions and 3 deletions.
3 changes: 3 additions & 0 deletions docs/docs/release_notes.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
### 3.7.0
- Add `ModelSerializer`

### 3.6.0
- Use `observable` pattern for loading database middleware and inheritance of the `Query` class
- Remove `IDType` from the `Model`
Expand Down
72 changes: 72 additions & 0 deletions docs/docs/serializer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
You can write your `serializer` in 2 style:


## Style 1 (Pydantic)
Write a normal `pydantic` class and use it as serializer:

```python
from pydantic import BaseModel
from pydantic import Field

from panther.app import API
from panther.request import Request
from panther.response import Response


class UserSerializer(BaseModel):
username: str
password: str
first_name: str = Field(default='', min_length=2)
last_name: str = Field(default='', min_length=4)


@API(input_model=UserSerializer)
async def serializer_example(request: Request):
return Response(data=request.validated_data)
```

## Style 2 (Model Serializer)
Use panther `ModelSerializer` to write your serializer which will use your `model` fields as its fields, and you can say which fields are `required`

```python
from pydantic import Field

from panther import status
from panther.app import API
from panther.db import Model
from panther.request import Request
from panther.response import Response
from panther.serializer import ModelSerializer


class User(Model):
username: str
password: str
first_name: str = Field(default='', min_length=2)
last_name: str = Field(default='', min_length=4)


class UserModelSerializer(metaclass=ModelSerializer, model=User):
fields = ['username', 'first_name', 'last_name']
required_fields = ['first_name']


@API(input_model=UserModelSerializer)
async def model_serializer_example(request: Request):
return Response(data=request.validated_data, status_code=status.HTTP_202_ACCEPTED)
```

### Notes:
1. In the example above `UserModelSerializer` only accepts the values of `fields` attribute

2. In default the `UserModelSerializer.fields` are same as `User.fields` but you can change their default and make them required with `required_fields` attribute

3. If you want uses `required_fields` you have to put them in `fields` too.

4. `fields` attribute is `required` when you are using `ModelSerializer` as `metaclass`

5. `model=` is required when you are using `ModelSerializer` as `metaclass`

6. You have to use `ModelSerializer` as `metaclass` (not as a parent)

7. Panther is going to create a `pydantic` model as your `UserModelSerializer` in the startup
1 change: 1 addition & 0 deletions docs/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ nav:
- Working With Database: 'working_with_db.md'
- Panther ODM: 'panther_odm.md'
- Configs: 'configs.md'
- Serializer: 'serializer.md'
- WebSocket: 'websocket.md'
- Monitoring: 'monitoring.md'
- Log Queries: 'log_queries.md'
Expand Down
7 changes: 5 additions & 2 deletions example/app/serializers.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from app.models import User
from pydantic import BaseModel, constr

from panther.file_handler import File, Image
from panther.serializer import ModelSerializer


class UserInputSerializer(BaseModel):
Expand All @@ -12,8 +14,9 @@ class UserOutputSerializer(BaseModel):
username: str


class UserUpdateSerializer(BaseModel):
username: str
class UserUpdateSerializer(metaclass=ModelSerializer, model=User):
fields = ['username']
required_fields = ['username']


class FileSerializer(BaseModel):
Expand Down
32 changes: 32 additions & 0 deletions example/model_serializer_example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from pydantic import Field

from panther import status, Panther
from panther.app import API
from panther.db import Model
from panther.request import Request
from panther.response import Response
from panther.serializer import ModelSerializer


class User(Model):
username: str
password: str
first_name: str = Field(default='', min_length=2)
last_name: str = Field(default='', min_length=4)


class UserSerializer(metaclass=ModelSerializer, model=User):
fields = ['username', 'first_name', 'last_name']
# required_fields = ['first_name']


@API(input_model=UserSerializer)
async def model_serializer_example(request: Request):
return Response(data=request.validated_data, status_code=status.HTTP_202_ACCEPTED)


url_routing = {
'': model_serializer_example,
}

app = Panther(__name__, configs=__name__, urls=url_routing)
2 changes: 1 addition & 1 deletion panther/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from panther.main import Panther # noqa: F401

__version__ = '3.6.0'
__version__ = '3.7.0'


def version():
Expand Down
34 changes: 34 additions & 0 deletions panther/serializer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from pydantic import create_model
from pydantic_core._pydantic_core import PydanticUndefined


class ModelSerializer:
def __new__(cls, *args, **kwargs):
if len(args) == 0:
msg = f"you should not inherit the 'ModelSerializer', you should use it as 'metaclass' -> {cls.__name__}"
raise TypeError(msg)
model_name = args[0]
if 'model' not in kwargs:
msg = f"'model' required while using 'ModelSerializer' metaclass -> {model_name}"
raise AttributeError(msg)

model_fields = kwargs['model'].model_fields
field_definitions = {}
if 'fields' not in args[2]:
msg = f"'fields' required while using 'ModelSerializer' metaclass. -> {model_name}"
raise AttributeError(msg) from None
for field_name in args[2]['fields']:
if field_name not in model_fields:
msg = f"'{field_name}' is not in '{kwargs['model'].__name__}' -> {model_name}"
raise AttributeError(msg) from None

field_definitions[field_name] = (model_fields[field_name].annotation, model_fields[field_name])
for required in args[2].get('required_fields', []):
if required not in field_definitions:
msg = f"'{required}' is in 'required_fields' but not in 'fields' -> {model_name}"
raise AttributeError(msg) from None
field_definitions[required][1].default = PydanticUndefined
return create_model(
__model_name=model_name,
**field_definitions
)
173 changes: 173 additions & 0 deletions tests/test_model_serializer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import asyncio
from pathlib import Path
from unittest import TestCase

from pydantic import Field

from panther import Panther
from panther.app import API
from panther.configs import config
from panther.db import Model
from panther.request import Request
from panther.serializer import ModelSerializer
from panther.test import APIClient


class Book(Model):
name: str
author: str = Field('default_author')
pages_count: int = Field(0)


class NotRequiredFieldsSerializer(metaclass=ModelSerializer, model=Book):
fields = ['author', 'pages_count']


class RequiredFieldsSerializer(metaclass=ModelSerializer, model=Book):
fields = ['name', 'author', 'pages_count']


class OnlyRequiredFieldsSerializer(metaclass=ModelSerializer, model=Book):
fields = ['name', 'author', 'pages_count']
required_fields = ['author', 'pages_count']


@API(input_model=NotRequiredFieldsSerializer)
async def not_required(request: Request):
return request.validated_data


@API(input_model=RequiredFieldsSerializer)
async def required(request: Request):
return request.validated_data


@API(input_model=OnlyRequiredFieldsSerializer)
async def only_required(request: Request):
return request.validated_data


urls = {
'not-required': not_required,
'required': required,
'only-required': only_required,
}


class TestModelSerializer(TestCase):
DB_PATH = 'test.pdb'

@classmethod
def setUpClass(cls) -> None:
global MIDDLEWARES
MIDDLEWARES = [
('panther.middlewares.db.DatabaseMiddleware', {'url': f'pantherdb://{cls.DB_PATH}'}),
]
app = Panther(__name__, configs=__name__, urls=urls)
cls.client = APIClient(app=app)

def tearDown(self) -> None:
Path(self.DB_PATH).unlink(missing_ok=True)

def test_not_required_fields_empty_response(self):
payload = {}
res = self.client.post('not-required', payload=payload)
assert res.status_code == 200
assert res.data == {'author': 'default_author', 'pages_count': 0}

def test_not_required_fields_full_response(self):
payload = {
'author': 'ali',
'pages_count': '12'
}
res = self.client.post('not-required', payload=payload)
assert res.status_code == 200
assert res.data == {'author': 'ali', 'pages_count': 12}

def test_required_fields_error(self):
payload = {}
res = self.client.post('required', payload=payload)
assert res.status_code == 400
assert res.data == {'name': 'Field required'}

def test_required_fields_success(self):
payload = {
'name': 'how to code',
'author': 'ali',
'pages_count': '12'
}
res = self.client.post('required', payload=payload)
assert res.status_code == 200
assert res.data == {'name': 'how to code', 'author': 'ali', 'pages_count': 12}

def test_only_required_fields_error(self):
payload = {}
res = self.client.post('only-required', payload=payload)
assert res.status_code == 400
assert res.data == {'name': 'Field required', 'author': 'Field required', 'pages_count': 'Field required'}

def test_only_required_fields_success(self):
payload = {
'name': 'how to code',
'author': 'ali',
'pages_count': '12'
}
res = self.client.post('only-required', payload=payload)
assert res.status_code == 200
assert res.data == {'name': 'how to code', 'author': 'ali', 'pages_count': 12}

def test_define_class_without_fields(self):
try:
class Serializer1(metaclass=ModelSerializer, model=Book):
pass
except Exception as e:
assert isinstance(e, AttributeError)
assert e.args[0] == "'fields' required while using 'ModelSerializer' metaclass. -> Serializer1"
else:
assert False

def test_define_class_with_invalid_fields(self):
try:
class Serializer2(metaclass=ModelSerializer, model=Book):
fields = ['ok', 'no']
except Exception as e:
assert isinstance(e, AttributeError)
assert e.args[0] == "'ok' is not in 'Book' -> Serializer2"
else:
assert False

def test_define_class_with_invalid_required_fields(self):
try:
class Serializer3(metaclass=ModelSerializer, model=Book):
fields = ['name', 'author']
required_fields = ['pages_count']
except Exception as e:
assert isinstance(e, AttributeError)
assert e.args[0] == "'pages_count' is in 'required_fields' but not in 'fields' -> Serializer3"
else:
assert False

def test_define_class_without_model(self):
try:
class Serializer4(metaclass=ModelSerializer):
fields = ['name', 'author']
required_fields = ['pages_count']
except Exception as e:
assert isinstance(e, AttributeError)
assert e.args[0] == "'model' required while using 'ModelSerializer' metaclass -> Serializer4"
else:
assert False

def test_define_class_without_metaclass(self):
class Serializer5(ModelSerializer):
fields = ['name', 'author']
required_fields = ['pages_count']

try:
Serializer5(name='alice', author='bob')
except Exception as e:
assert isinstance(e, TypeError)
assert e.args[0] == ("you should not inherit the 'ModelSerializer', "
"you should use it as 'metaclass' -> Serializer5")
else:
assert False

0 comments on commit 9c4ba74

Please sign in to comment.