Skip to content

Commit

Permalink
added manual field specification
Browse files Browse the repository at this point in the history
  • Loading branch information
georgebv committed Feb 14, 2024
1 parent 3c9bab2 commit 625251f
Show file tree
Hide file tree
Showing 3 changed files with 150 additions and 21 deletions.
69 changes: 48 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@
- [General](#general)
- [Existing Models](#existing-models)
- [Nested Models](#nested-models)
- [Roadmap](#roadmap)
- [Manual Serializer Configuration](#manual-serializer-configuration)
- [Per-Field Configuration](#per-field-configuration)
- [Custom Serializer](#custom-serializer)

# Introduction

Expand All @@ -36,8 +38,7 @@ on top of [Django](https://www.djangoproject.com/) used to write REST APIs.
If you develop DRF APIs and rely on pydantic for data validation/(de)serialization ,
then `drf-pydantic` is for you 😍.

> ℹ️ **INFO**<br>
> `drf_pydantic` supports `pydantic` v2. Due to breaking API changes in `pydantic`
> ℹ️ **INFO**<br> > `drf_pydantic` supports `pydantic` v2. Due to breaking API changes in `pydantic`
> v2 support for `pydantic` v1 is available only in `drf_pydantic` 1.\*.\*.
## Performance
Expand Down Expand Up @@ -65,8 +66,8 @@ models:
from drf_pydantic import BaseModel

class MyModel(BaseModel):
name: str
addresses: list[str]
name: str
addresses: list[str]
```

`MyModel.drf_serializer` would be equvalent to the following DRF Serializer class:
Expand All @@ -75,10 +76,10 @@ class MyModel(BaseModel):
class MyModelSerializer:
name = CharField(allow_null=False, required=True)
addresses = ListField(
allow_empty=True,
allow_null=False,
child=CharField(allow_null=False),
required=True,
allow_empty=True,
allow_null=False,
child=CharField(allow_null=False),
required=True,
)
```

Expand Down Expand Up @@ -106,10 +107,10 @@ Your existing pydantic models:
from pydantic import BaseModel

class Pet(BaseModel):
name: str
name: str

class Dog(Pet):
breed: str
breed: str
```

Update your `Dog` model and get serializer via the `drf_serializer`:
Expand All @@ -119,10 +120,10 @@ from drf_pydantic import BaseModel as DRFBaseModel
from pydantic import BaseModel

class Pet(BaseModel):
name: str
name: str

class Dog(DRFBaseModel, Pet):
breed: str
breed: str

Dog.drf_serializer
```
Expand All @@ -142,20 +143,46 @@ from drf_pydantic import BaseModel as DRFBaseModel
from pydantic import BaseModel

class Apartment(BaseModel):
floor: int
tenant: str
floor: int
tenant: str

class Building(BaseModel):
address: str
aparments: list[Apartment]
address: str
aparments: list[Apartment]

class Block(DRFBaseModel):
buildings: list[Buildind]
buildings: list[Buildind]

Block.drf_serializer
```

# Roadmap
## Manual Serializer Configuration

If `drf_pydantic` does not generate the serializer you need, you can either granularly
configure which DRF serializer fields to use for each pydantic field, or you can
create a custom serializer for the model altogether.

> ⚠️ **WARNING**<br>
> When manually configuring the serializer you are responsible for setting all
> properties of the fields (e.g., `allow_null`, `required`, `default`, etc.).
> `drf_pydantic` does not perform any introspection for fields that are manually
> configured or for any fields if a custom serializer is used.
### Per-Field Configuration

- Add support for custom field types (both for pydantic and DRF)
- Add option to create custom serializer for complex models
```python
from typing import Annotated

from drf_pydantic import BaseModel
from rest_framework.serializers import IntegerField

class Person(BaseModel):
name: str
age: Annotated[float, IntegerField(min_value=0, max_value=100)]
```

### Custom Serializer

```python
# TODO
```
13 changes: 13 additions & 0 deletions src/drf_pydantic/parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,19 @@ def _convert_field(field: pydantic.fields.FieldInfo) -> serializers.Field:
Django REST framework serializer Field instance.
"""
# Check if DRF field was explicitly set
manual_drf_fields: list[serializers.Field] = []
for item in field.metadata:
if isinstance(item, serializers.Field):
manual_drf_fields.append(item)
if len(manual_drf_fields) == 1:
return manual_drf_fields[0]
if len(manual_drf_fields) > 1:
raise FieldConversionError(
"Field has multiple conflicting DRF serializer fields. "
"Only one DRF serializer field can be provided per field."
)

assert field.annotation is not None
drf_field_kwargs: dict[str, typing.Any] = {
"required": field.is_required(),
Expand Down
89 changes: 89 additions & 0 deletions tests/test_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,19 @@ class Person(BaseModel):
assert isinstance(field, serializers.CharField)
assert field.allow_null is True

def test_optional_type_with_annotation(self):
class Person(BaseModel):
name: typing.Annotated[
typing.Optional[str],
pydantic.StringConstraints(min_length=3),
]

serializer = Person.drf_serializer()

field: serializers.Field = serializer.fields["name"]
assert isinstance(field, serializers.CharField)
assert field.allow_null is True

def test_union_field_error(self):
with pytest.raises(ModelConversionError) as exc_info:

Expand Down Expand Up @@ -586,3 +599,79 @@ class Person(BaseModel):
assert serializer.fields["field_5"].allow_null is True
assert serializer.fields["field_6"].allow_null is True
assert serializer.fields["field_7"].allow_null is True


class TestManualFields:
def test_same_type(self):
class Person(BaseModel):
age: typing.Annotated[int, serializers.IntegerField()]

serializer = Person.drf_serializer()

assert isinstance(serializer.fields["age"], serializers.IntegerField)
assert serializer.fields["age"].required is True

def test_different_type(self):
class Person(BaseModel):
age: typing.Annotated[int, serializers.CharField()]

serializer = Person.drf_serializer()

assert isinstance(serializer.fields["age"], serializers.CharField)
assert serializer.fields["age"].required is True
assert serializer.fields["age"].allow_null is False

def test_same_type_optional(self):
class Person(BaseModel):
age: typing.Annotated[
typing.Optional[int],
serializers.IntegerField(),
]

serializer = Person.drf_serializer()

assert isinstance(serializer.fields["age"], serializers.IntegerField)
assert serializer.fields["age"].required is True
assert serializer.fields["age"].allow_null is False

def test_different_type_optional(self):
class Person(BaseModel):
age: typing.Annotated[
typing.Optional[int],
serializers.CharField(),
]

serializer = Person.drf_serializer()

assert isinstance(serializer.fields["age"], serializers.CharField)
assert serializer.fields["age"].required is True
assert serializer.fields["age"].allow_null is False

def test_required_override(self):
class Person(BaseModel):
age: typing.Annotated[
int,
serializers.IntegerField(required=False),
]

serializer = Person.drf_serializer()

assert isinstance(serializer.fields["age"], serializers.IntegerField)
assert serializer.fields["age"].required is False

def test_multiple_field_error(self):
with pytest.raises(ModelConversionError) as exc_info:

class Person(BaseModel):
age: typing.Annotated[
int,
serializers.IntegerField(),
serializers.CharField(),
]

Person.drf_serializer()

assert "Error when converting model: Person" in str(exc_info.value)
assert "Field has multiple conflicting DRF serializer fields" in str(
exc_info.value
)

0 comments on commit 625251f

Please sign in to comment.