Components#
django-modern-rest utilizes component approach to parse all
the unstructured things like headers, body, and cookies
into a strongly typed and validated model.
To use a component, you can just add it as a parameter
to your endpoint method inside a Controller.
How does it work?
In import time, when controller is first created, we iterate over all existing endpoints in this class
For each endpoint we fetch method annotations and find all
ComponentParserobjects, they will be treated as component parsersNext, we create a request parsing model during the import time, with all combined fields to be parsed later
In runtime, when request is received, we provide the needed data for this single parsing model
If everything is ok, we call the needed endpoint with the correct data
If there’s a parsing error we raise
RequestSerializationErrorand return a beautiful error message for the user
You can use existing ones or create your own.
Note
All existing components should only be inherited for parsing. If you want to change the implementation details of a component – create a new one from scratch.
You can still delegate parts of the work to existing ones.
What is inside a component?#
All components consist of two parts:
The first one is a
ComponentParsersubclass, which knows how to provide the required data for itself, build OpenAPI schemas and etc. For example:QueryComponentThe second part is a
typing.Annotatedbased annotation that has a component parser instance as metadata. These annotations will be used by the end users. For example,Query
Note
Type aliases have first-class support:
UserBody: TypeAlias = Body[User] and type UserBody = Body[User]
both work as component annotations, including aliases of aliases
and subscripted generic aliases like type Payload[ModelT] = Body[ModelT].
Default values#
Component parameters can have default values, just like any other
Python parameters. When a request has no data for such a component,
the endpoint receives its default value.
Defaults are real Python defaults: they are passed as-is,
without any copies. Regular parameters with defaults,
like help_text: str = 'default', can be mixed with components.
1from typing import Final
2
3import msgspec
4
5from dmr import Body, Controller, Query
6from dmr.plugins.msgspec import MsgspecSerializer
7
8
9class ProductFilters(msgspec.Struct):
10 category: str
11
12
13# Defaults are passed as-is, so structs used as defaults must be frozen:
14class Pagination(msgspec.Struct, frozen=True):
15 page: int = 1
16
17
18DEFAULT_PAGINATION: Final = Pagination()
19
20
21class ProductController(Controller[MsgspecSerializer]):
22 def post(
23 self,
24 # Correct: `None` is inside the component annotation
25 parsed_body: Body[ProductFilters | None] = None,
26 # Defaults can be of the same type as the model:
27 parsed_query: Query[Pagination] = DEFAULT_PAGINATION,
28 ) -> dict[str, str | int]:
29 return {
30 'category': 'all' if parsed_body is None else parsed_body.category,
31 'page': parsed_query.page,
32 }
33
Run result
$ curl http://127.0.0.1:8000/api/products/ -X POST
{"category":"all","page":1}
$ curl 'http://127.0.0.1:8000/api/products/?page=2' -X POST -d '{"category": "cars"}' -H 'Content-Type: application/json'
{"category":"cars","page":2}
OpenAPI Schema
Preview openapi.json
{
"components": {
"schemas": {
"ErrorDetail": {
"description": "Base schema for error details description.",
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "integer"
},
{
"type": "string"
}
]
},
"type": "array"
},
"msg": {
"type": "string"
},
"type": {
"type": "string"
}
},
"required": [
"msg"
],
"title": "ErrorDetail",
"type": "object"
},
"ErrorModel": {
"description": "Default error response schema.\n\nCan be customized.\nSee :ref:`customizing-error-messages` for more details.",
"properties": {
"detail": {
"items": {
"$ref": "#/components/schemas/ErrorDetail"
},
"type": "array"
}
},
"required": [
"detail"
],
"title": "ErrorModel",
"type": "object"
},
"ProductFilters": {
"properties": {
"category": {
"type": "string"
}
},
"required": [
"category"
],
"title": "ProductFilters",
"type": "object"
}
}
},
"info": {
"title": "Django Modern Rest",
"version": "0.1.0"
},
"openapi": "3.2.0",
"paths": {
"/api/productcontroller/": {
"post": {
"operationId": "postProductControllerApiProductcontroller",
"parameters": [
{
"in": "query",
"name": "page",
"schema": {
"default": 1,
"type": "integer"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/ProductFilters"
},
{
"type": "null"
}
]
}
}
}
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"additionalProperties": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"type": "object"
}
}
},
"description": "Created"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorModel"
}
}
},
"description": "Raised when request components cannot be parsed"
},
"406": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorModel"
}
}
},
"description": "Raised when provided `Accept` header cannot be satisfied"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorModel"
}
}
},
"description": "Raised when returned response does not match the response schema"
}
}
}
}
}
}
1from typing import Final
2
3import pydantic
4
5from dmr import Body, Controller, Query
6from dmr.plugins.pydantic import PydanticSerializer
7
8
9class ProductFilters(pydantic.BaseModel):
10 category: str
11
12
13class Pagination(pydantic.BaseModel):
14 # Defaults are passed as-is, so models used as defaults must be frozen:
15 model_config = pydantic.ConfigDict(frozen=True)
16
17 page: int = 1
18
19
20DEFAULT_PAGINATION: Final = Pagination()
21
22
23class ProductController(Controller[PydanticSerializer]):
24 def post(
25 self,
26 # Correct: `None` is inside the component annotation
27 parsed_body: Body[ProductFilters | None] = None,
28 # Defaults can be of the same type as the model:
29 parsed_query: Query[Pagination] = DEFAULT_PAGINATION,
30 ) -> dict[str, str | int]:
31 return {
32 'category': 'all' if parsed_body is None else parsed_body.category,
33 'page': parsed_query.page,
34 }
35
Run result
$ curl http://127.0.0.1:8000/api/products/ -X POST
{"category":"all","page":1}
$ curl 'http://127.0.0.1:8000/api/products/?page=2' -X POST -d '{"category": "cars"}' -H 'Content-Type: application/json'
{"category":"cars","page":2}
OpenAPI Schema
Preview openapi.json
{
"components": {
"schemas": {
"ErrorDetail": {
"description": "Base schema for error details description.",
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "integer"
},
{
"type": "string"
}
]
},
"title": "Loc",
"type": "array"
},
"msg": {
"title": "Msg",
"type": "string"
},
"type": {
"title": "Type",
"type": "string"
}
},
"required": [
"msg"
],
"title": "ErrorDetail",
"type": "object"
},
"ErrorModel": {
"description": "Default error response schema.\n\nCan be customized.\nSee :ref:`customizing-error-messages` for more details.",
"properties": {
"detail": {
"items": {
"$ref": "#/components/schemas/ErrorDetail"
},
"title": "Detail",
"type": "array"
}
},
"required": [
"detail"
],
"title": "ErrorModel",
"type": "object"
},
"ProductFilters": {
"properties": {
"category": {
"title": "Category",
"type": "string"
}
},
"required": [
"category"
],
"title": "ProductFilters",
"type": "object"
}
}
},
"info": {
"title": "Django Modern Rest",
"version": "0.1.0"
},
"openapi": "3.2.0",
"paths": {
"/api/productcontroller/": {
"post": {
"operationId": "postProductControllerApiProductcontroller",
"parameters": [
{
"in": "query",
"name": "page",
"schema": {
"default": 1,
"title": "Page",
"type": "integer"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/ProductFilters"
},
{
"type": "null"
}
]
}
}
}
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"additionalProperties": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"type": "object"
}
}
},
"description": "Created"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorModel"
}
}
},
"description": "Raised when request components cannot be parsed"
},
"406": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorModel"
}
}
},
"description": "Raised when provided `Accept` header cannot be satisfied"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorModel"
}
}
},
"description": "Raised when returned response does not match the response schema"
}
}
}
}
}
}
What counts as “no data” for each component?
Bodyhas no data when the request body is empty, formultipart/form-datarequests: when there are no fields and no filesQueryhas no data when there are no query parametersHeadershas no data when there are no headers, which is practically never the case for real requestsCookieshas no data when there are no cookiesPathhas no data when the url pattern has no parametersFileMetadatahas no data when no files were uploaded
Custom components receive the default value as the default parameter
of provide_context_data()
and decide when to return it.
When the request has some data, it is always validated against the model,
so Body[Model | None] = None still returns an error
for invalid bodies, it does not silently fall back to None.
Note
Defaults are passed to the model that parses the whole request,
so the rules of the serializer’s model type apply:
dataclasses.dataclass() for pydantic
and msgspec.Struct for msgspec reject mutable defaults
like [] and non-frozen model instances during the import time.
Use None or frozen models as defaults.
Warning
The | None part must be inside the component annotation,
so it must be Body[Model | None] = None
and not Body[Model] | None = None.
The second form hides the component behind a union,
so it would not be parsed at all.
We raise UnsolvableAnnotationsError
during the import time for any annotation that hides a component
inside other types, like Body[Model] | None or list[Body[Model]].
Defaults are also reflected in the OpenAPI schema:
a Body with a default is documented
with required: false, and parameters of other components
with defaults are documented as not required.
Path parameters are always required by the OpenAPI specification.
Browse components#
Parsing query parameters.
Parsing header parameters.
Parsing cookie parameters.
Parsing path parameters.
Parsing request body.
Uploading files.
API Reference#
- class dmr.components.ComponentParser[source]#
Base abstract provider for request components.
- conditional_types(model: TypeForm[Any], model_meta: tuple[Any, ...]) Mapping[str, Any][source]#
Provide conditional parsing types based on content type.
Some components parser might define different input models based on the request’s content type.
This method must return a mapping of content_type to the model. If this component support this.
- context_name: ClassVar[str]#
All subtypes must provide a unique name that will be used to parse context.
We use a single context for all parsing, this component will live under a dict field with this name.
- abstractmethod get_schema(model: TypeForm[Any], model_meta: tuple[Any, ...], metadata: EndpointMetadata, controller_cls: type[Controller[BaseSerializer]], context: OpenAPIContext) list[Parameter | Reference] | RequestBody[source]#
Generate OpenAPI spec for component.
Changed in version 0.16.0: serializer parameter was changed to be controller_cls.
- abstractmethod provide_context_data(endpoint: Endpoint, controller: Controller[BaseSerializer], *, field_model: TypeForm[Any], default: Any = EMPTY) Any | tuple[Any, ...][source]#
Return unstructured raw values for
serializer.from_python().It must return the same number of elements that has type vars. Basically, each type var is a model. Each element in a tuple is the corresponding data for that model.
When this method returns not a tuple and there’s only one type variable, it also works.
default is the default value of the endpoint parameter, like
parsed_body: Body[Model | None] = None. When it is notEMPTYand the request has no data for this component, return default as-is.Changed in version 0.16.0: Added default parameter.
- provide_response_specs(metadata: EndpointMetadata, controller_cls: type[Controller[BaseSerializer]], existing_responses: Mapping[HTTPStatus, ResponseSpec]) list[ResponseSpec][source]#
Return a list of extra responses that this component produces.
For example, when parsing something, we always have an option to fail a parsing, if some request does not fit our model.
- validate(controller_cls: type[Controller[BaseSerializer]], metadata: EndpointMetadata) None[source]#
Validates that the component is correctly defined.
By default does nothing. Runs in import time.