Integrate Pydantic with Django and Django REST Framework
Python has always been a dynamically typed language, which means you don’t have to specify data types for variables and function return values. PEP 484 introduced type hints — a way to make Python feel statically typed.
While type hints can help structure your projects better, they are just that — hints — and by default do not affect the runtime. However, there is a way to force type checks on runtime, and we’ll explore it today, after dialing in on some basics.
The function below takes and returns a string and is annotated as follows:
def username(name: str) -> str:
return 'Hello' + name
In the function username
, the argument name
is expected to be of type str
and the return type str
. Subtypes are accepted as arguments
.
In this article we’ll explore how to use Pydantic with Django and Django REST Framework, and how to force type checks on runtime.
Pydantic
Pydantic is a Python package for data validation and settings management that's based on Python type hints. It enforces type hints at runtime, provides user-friendly errors, allows custom data types, and works well with many popular IDEs. It's extremely fast and easy to use as well!
Let's look at an example:
from pydantic import BaseModel
class User(BaseModel):
id: int
name = str
external_data = {
'id': '1',
'name': 'John Doe',
}
user = User(**external_data)
Here we’re creating a User
model that has an id
field of type int
and a name
field of type str
.
print(user.id)
>>> 1
print(user.name)
>>> John Doe
To learn more about Pydantic, be sure to read the Overview page from the official docs.
Pydantic with Django
When coupled with Django, we can use Pydantic to ensure that only data that matches the defined schemas
are used in our application.
That's why we will use Pyngo an open source Python package help to integrate Pydantic with Django, and Django REST Framework, and give us multiple parameters to use with OpenAPI.
Getting Started
To get started, we need to install Pyngo, as we know it's based on Pydantic
, Django
, and typing_extensions
.
just run the following command:
$ pip install pyngo
Features
- Using
Pydantic
to Build your Models inDjango
Project. - Using
OpenAPI
utilities to buildparams
from a basic model. - Using
QueryDictModel
to buildPydantic
models from aQueryDict
object. - Propagate any errors from
Pydantic
inDjango Rest Framework
.
OpenAPI parameters
pyngo.openapi_params()
Build a List of ParameterDict
describing the fields of a pydantic.BaseModel.
By setting the fields according to the dictionary we have it before in ParameterDict
class.
Mostly this dictionary representing the fixed fields of an OpenAPI parameter object.
As variables we can use the following fields:
name (str)
– The name of the parameter. Parameter names are case sensitive.in (Literal["query", "header", "path", "cookie"])
– The location of the parameter.description (str)
– A brief description of the parameter. This could contain examples of use.required (bool)
– Determines whether or not this parameter is required or optional.deprecated (bool)
– Determines whether or not the parameter is in the process of being removed from the specification.allowEmptyValue (bool)
– Determines whether or not the parameter value can be omitted (null
or an empty string).
openapi_params()
pyngo.openapi_params()
can build params from a basic model:
from pydantic import BaseModel
from pyngo import openapi_params
class Model(BaseModel):
id: int
print(openapi_params(Model))
we will see that the response in json
format, and id
will be the name of the parameter, this is what we got in a basic model:
[
{
'name': 'id',
'in': 'query',
'description': '',
'required': True,
'deprecated': False,
'allowEmptyValue': False
}
]
Let's try to extand using pyngo.ParameterDict.required
is set according to the type of the variable:
from typing import Optional
from pydantic import BaseModel
from pyngo import openapi_params
class Model(BaseModel):
required_param: int
optional_param: Optional[int]
print(openapi_params(Model))
same result but we will see the changes in the required
field:
[
{
'allowEmptyValue': False,
'deprecated': False,
'description': '',
'in': 'query',
'name': 'required_param',
'required': True
},
{
'allowEmptyValue': False,
'deprecated': False,
'description': '',
'in': 'query',
'name': 'optional_param',
'required': False
}
]
We can also setup the other fields of the ParameterDict
class:
from pydantic import BaseModel, Field
from pyngo import openapi_params
class WithDescription(BaseModel):
described_param: str = Field(
description="Hello World Use Me!"
)
class InPath(BaseModel):
path_param: str = Field(location="path")
class WithDeprecated(BaseModel):
deprecated_field: bool = Field(deprecated=True)
class WithNoAllowEmpty(BaseModel):
can_be_empty: bool = Field(allowEmptyValue=False)
print(openapi_params(WithDescription)[0]["description"])
print(openapi_params(InPath)[0]["in"])
print(openapi_params(WithDeprecated)[0]["deprecated"])
print(openapi_params(WithNoAllowEmpty)[0]["allowEmptyValue"])
and we will see the response:
>>> 'Hello World Use Me!'
>>> 'path'
>>> 'True'
>>> 'False'
If we hardcoded the ParameterDict
class, this can raise multiple errors:
ValueError
– If any of the fields are complex types.ValueError
– If location is not a value supported byParameterDict.in
.ValueError
– If you try to set required toFalse
on a field whose location is"path"
.ValueError
– If you try to setallowEmptyValue
on a field whose location is not"query"
.
I know most of what we see right now need to get deep somehow in OpenAPI, but we this is the simple way to build a ParameterDict
class.
Django QueryDictModel
In an HttpRequest
object, the GET
and POST
attributes are instances of django.http.QueryDict
, a dictionary-like class customized to deal with multiple values for the same key. This is necessary because some HTML form elements, notably <select multiple>
, pass multiple values for the same key.
The QueryDicts
at request.POST
and request.GET
will be immutable when accessed in a normal request/response
cycle. To get a mutable version you need to use QueryDict.copy()
.
This is how the official Django documentation explain it, that's why pyngo
come with idea of pyngo.querydict_to_dict()
and pyngo.QueryDictModel
are conveniences for building a pydantic.BaseModel
from a django.QueryDict
.
let's see how it works, we will use the following model:
from django.http import QueryDict
class Model(QueryDictModel):
id: int
name: str
Model.parse_obj(QueryDict("id=12&name=yezz"))
the result is:
>>> Model(id=12, name='yezz')
Convert a Django.http.QueryDict
to a dict under the constraints introduced on the types of fields of a pydantic.BaseModel
.
Let's took a look at the pyngo.querydict_to_dict()
and pyngo.QueryDictModel
function:
Note: Don't forget to Setup the Django Project.
from typing import List
from django.http import QueryDict
from pydantic import BaseModel
from pyngo import QueryDictModel, querydict_to_dict
class Model(BaseModel):
single_param: int
list_param: List[str]
class QueryModel(QueryDictModel):
single_param: int
list_param: List[str]
query_dict = QueryDict("single_param=20&list_param=Life")
print(Model.parse_obj(querydict_to_dict(query_dict, Model)))
print(QueryModel.parse_obj(query_dict))
as a result we will see:
>>> Model(single_param=20, list_param=['Life'])
>>> QueryModel(single_param=20, list_param=['Life'])
This is a very simple way to build a pydantic.BaseModel
from a django.http.QueryDict
.
ValidationError in Django REST Framework
Extracting the arguments from a pydantic.ValidationError
and convert them to a dictionary whose format matches those used by the details for an error in Django Rest Framework
, this work is done by creating this function pyngo.drf_error_details
and calling it on the pydantic.ValidationError
:
pyngo.drf_error_details()
will propagate any errors from Pydantic:
from pydantic import BaseModel, ValidationError
from pyngo import drf_error_details
class Model(BaseModel):
id: int
name: str
data = {"id": "Cat"}
try:
Model.parse_obj(data)
except ValidationError as e:
print(drf_error_details(e))
As a result we will see:
{
'name': ['field required'],
'id': ['value is not a valid integer']
}
- Let's see the Errors descend into nested fields:
from typing import List
from pydantic import BaseModel, ValidationError
from pyngo import drf_error_details
class Framework(BaseModel):
id: int
class Language(BaseModel):
framework: List[Framework]
data = {"Framework": [{"id": "not_a_number"}]}
try:
Framework.parse_obj(data)
except ValidationError as e:
print(drf_error_details(e))
We will see:
{
'framework': {
'0': {
'id': ['value is not a valid integer']
},
'1': {
'id': ['field required']
}
}
}
To Integrate Pydantic with Django or Django Rest Framework, try to see some articles or pre-built packages like Djantic or Django-Ninja a web framework for building APIs with Django and Python 3.6+ type hints inspired by FastAPI.
In this article we just take a deep look into my pre-built package thats could help you integrate pydantic with Django and Django Rest Framework Natively.