Params/Response
Param Types¶
Param Types is the set of objects to declare request parameter of a specific type through type hints,
There are Path
, Query
, Cookie
,Header
, Body
, File
, Form
.
Tip
If you don't know the types of "request parameters", visit HTTP Requests
Sensei's Param Types are inherited from FieldInfo
, produced by the Field
function. You can apply the same approaches
that was described for Field Types.
Example
Here is a function that creates a user and returns a JWT token, corresponding to the user.
@router.post('/users')
def create_user(
id: Annotated[int, Body(ge=0)],
username: Annotated[str, Body(pattern=r'^\w+$')],
email: Annotated[EmailStr, Body()],
age: Annotated[int, Body(ge=14)] = 18,
is_active: Annotated[bool, Body()] = True,
) -> str:
pass
Let's create some models and show examples, using them.
class User:
email: EmailStr
id: PositiveInt
first_name: str
last_name: str
avatar: AnyHttpUrl
class Post:
id: PositiveInt
title: str
content: str
created_at: datetime.datetime
Path¶
This endpoint retrieves a post by its ID from the URL path.
@router.get('/posts/{id_}')
def get_post(cls, id_: Annotated[int, Path()]) -> Post:
pass
When using the Path
parameter, a placeholder name must match the argument name.
Tip
If you are sure that you will not write validations, you can omit the Path
declaration.
@router.get('/posts/{id_}')
def get_post(cls, id_: int) -> Post:
pass
Query¶
This uses a query parameter to search for users based on a string.
@router.get('/search')
def search_users(cls, query: Annotated[str, Query()] = "") -> list[User]:
pass
Tip
If you use a method, that usually includes query parameters, you can omit the Query
declaration.
@router.get('/search')
def search_users(cls, query: str = "") -> list[User]:
pass
Methods that usually include query parameters:
- GET
- DELETE
- HEAD
- OPTIONS
Body¶
This accepts a User
object in the request body for user creation.
@router.post('/create_user')
def create_user(cls, user: Annotated[User, Body()]) -> User:
pass
Body
parameters have arguments that other parameters don't have:
MIME-type¶
If the content of your request body is not default (e.g JSON), you can change its media type:
Example
@router.post('/create_user')
def create_user(cls, user: Annotated[User, Body(media_type='application/xml')]) -> User:
pass
Embed/Non-embed¶
The embed
parameter in Sensei's Body()
function determines how the request body should be formatted when passed.
When embed
is set to True
(which is the default), the request body expects the object to be wrapped inside a
dictionary with a specific key.
When embed
is set to False
, the object itself is expected to be the body of the request
without being wrapped in a dictionary.
Here are examples of both scenarios:
Embed (default)¶
When embed=True
(default), the User
is wrapped inside a key (e.g., "user"), so the body needs to provide a
key-value pair where the value is the actual User
.
@router.post('/create_user')
def create_user(user: Annotated[User, Body(embed=True)]) -> User:
pass
This will produce the following request:
{
"user": {
"name": "John Doe",
"email": "[email protected]"
}
}
In this case, the body contains a dictionary with the key "user"
, and the value is the serialized User
object.
Non-embed¶
When embed=False
, the User
is expected to be the body itself, like this:
@router.post('/create_user')
def create_user(user: Annotated[User, Body(embed=False)]) -> User:
pass
This will produce the following request:
{
"name": "John Doe",
"email": "[email protected]"
}
Here, the body directly contains the serialized User
object.
Tip
If you use a method, that usually includes the request body, you can omit the Body
declaration.
@router.post('/create_user')
def create_user(cls, user: User) -> User:
pass
Methods that usually include request body:
- POST
- PUT
- PATCH
Form¶
Here, username
and password
are captured from a form submission.
@router.post('/login')
def login_user(cls, username: Annotated[str, Form()], password: Annotated[str, Form()]) -> str:
pass
Technical Details
Form
is inherited from Body
. You can use the embed
argument, but can't use media_type
, because Form
has preset
media_type='application/x-www-form-urlencoded'
.
You can achieve the same functionality if use Body(media_type='application/x-www-form-urlencoded')
instead of Form
.
File¶
This accepts an image file from the request as a File
.
@router.post('/upload')
def upload_image(cls, image: Annotated[bytes, File()]) -> str:
pass
File
is inherited from Form
, consequently there is embed
argument like in Body
.
Warning
Despite File
being inherited from Form
, which is inherited from Body
, you cannot achieve the same functionality,
if using Body(media_type='multipart/form-data')
instead of File
. It's because File
parameters are serialized differently.
Let's assume you use Body(media_type='multipart/form-data')
instead of File
. If you try to pass the content of binary
file with non-UTF8 characters, Sensei cannot finish serialization and will throw the UnicodeDecodeError
.
UnicodeDecodeError
@router.post('/upload')
def upload_image(image: Annotated[bytes, Body(media_type='multipart/form-data')]) -> str:
pass
with open('/path/to/image', 'rb') as f:
image = f.read()
print(upload_image(image)) # <--- UnicodeDecodeError
Header¶
This function downloads a file while authenticating the user with a token from the headers:
@router.get('/download')
def download_file(cls, x_token: Annotated[str, Header()]) -> bytes:
pass
Info
When your endpoint relies on custom headers, that are not supported by default in HTTP, you should call it with the prefix X-
.
For instance, we want to define a header containing the request signature, that is encoded metadata, such as current time,
your username, etc. You should call it like this:
X-Signature
Cookie:¶
This requires session_id
from the browser cookies for user verification.
@router.get('/verify')
def verify_user(cls, session_id: Annotated[str, Cookie()]) -> bool:
pass
Response Types¶
Response type is the return type of routed function(method). There is a category of response types, that doesn't require Preparers/Finalizers, that we will learn in the future. That means these types are handled automatically. This category includes:
None
¶
If this type is present or a function has no return type, None
is returned.
Example
You can use None
when a function doesn't return a relevant response or doesn't return it at all.
For instance, endpoints with the DELETE
method often don't return the response.
@router.delete('/users')
def delete_user() -> None:
pass
str
¶
str
response type refers to the text representation of the response. If this type is present, the text
attribute
of the Response
object is returned.
Example
When you need to get the html
code of a page, you can use str
.
@router.get('/index.html')
def get_page() -> str:
pass
get_page()
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Main page</title>
</head>
<body>
<h1>Hello, World!</h1>
<a href="https://example.com">Visit</a>
</body>
</html>
bytes
¶
bytes
response type refers to the raw binary representation of the response. If this type is present, the content
attribute
of the Response
object is returned.
Example
When you need to query the binary file, you can use bytes
.
from PIL import Image
from io import BytesIO
@router.get('/ai_image')
def generate_image(style: str, resolution: Resolution) -> bytes:
pass
image_bytes = generate_image("cartoon", Resolution(1200, 400))
image = Image.open(BytesIO(image_bytes))
dict
¶
dict
response type refers to the JSON representation of the response.
In particular, it refers to JSON as a dict
. Don't confuse with list[dict]
.
If this type is present and the method is not OPTIONS or HEAD, the result of the json()
method of the Response
object is returned.
Otherwise, if the method is OPTIONS or HEAD, the attribute headers
of the Response
object is returned. This is the only response
type that can be used for automatic header extraction.
Instead of dict
you can provide dict[KT, VT]
, where KT
is the key type and VT
is the value type.
Tip
Use the dict
response type only when you want to get headers,
using 'HEAD' or 'OPTIONS' method, or response validation is redundant or useless
In this example, adding validations for the version
and available
fields could be redundant. Especially, if this function
is used as a helper.
@router.get('/status')
def get_status() -> dict[str, Union[str, bool]]: pass
get_status()
{
"version": "1.0.0",
"available": true
}
Or if this endpoint provides useful headers, we can get it using the dict
response type.
@router.head('/status')
def get_status() -> dict[str, Any]: pass
get_status()
list[dict]
¶
list[dict]
response type also refers to the JSON representation of the response, but it refers to JSON as a list[dict]
.
If this type is present, the result of the json()
method of the Response
object is returned.
Instead of list[dict]
you can provide list[dict[KT, VT]]
, where KT
is the key type and VT
is the value type.
Tip
Most probably, you will not find a situation, where JSON is represented as a list
.
But a list can be wrapped in a field, such as "data".
You can use this type in combination with __finalize_json__
hook, that we will cover in
Preparers/Finalizers.
This response type also should be used when response validation is redundant or useless.
In this example, if we need this model only as output in this function and don't need the same model as input in others,
you can use list[dict]
@router.get('/users')
def get_users() -> list[dict[str, Union[str, int]]]:
pass
<BaseModel>
¶
<BaseModel>
response type refers to unpacking JSON representation of the response to the constructor of a
subclass of pydantic.BaseModel
. This means:
1) If the response is represented as a dictionary, like that:
{"name": "alex", "id": 1, "city": "Manchester"}
2) You can make this model:
class User(APIModel):
id: NonNegativeInt
name: str
city: str
3) And unpack {"name": "alex", "id": 1, "city": "Manchester"}
to User
. As a result, you will have a User
object.
User(id=1, name='alex', city='Manchester')
The algorithm above is what Sensei does when you provide a <BaseModel>
response type.
Info
Here <BaseModel>
is a placeholder, that should be substituted by the class name of the model, inherited from BaseModel
.
This response type is better than dict
because validations of BaseModel
are
performed. But unlike dict
, this type can't be used for automatic header extraction.
Example
Here is example was shown in First Steps
class Pokemon(APIModel):
name: str
id: int
height: int
weight: int
@router.get('/pokemon/{name}')
def get_pokemon(name: Annotated[str, Path(max_length=300)]) -> Pokemon:
pass
list[<BaseModel>]
¶
list[BaseModel]
response type refers to sequential unpacking JSON representation of the response to the constructor of a
subclass of pydantic.BaseModel
. That means:
1) If response is represented as a list of dictionaries of the same structure, like that:
[{"name": "alex", "id": 1, "city": "Manchester"}, {"name": "bob", "id": 2, "city": "London"}, ...]
2) You can make this model:
class User(APIModel):
id: NonNegativeInt
name: str
city: str
3) And unpack {"name": "alex", "id": 1, "city": "Manchester"}
to User
, {"name": "bob", "id": 2, "city": "London"}
to User
, etc. As a result, you will have a list of User
objects.
[User(id=1, name='alex', city='Manchester'), User(id=2, name='bob', city='London'), ...]
The algorithm above is what Sensei does when you provide a list[<BaseModel>]
response type.
Info
Here <BaseModel>
is a placeholder, that should be substituted by the class name of the model, inherited from BaseModel
.
This response type is better than list[dict]
, because validations of BaseModel
are
performed.
Tip
Most probably, you will not find a situation, where JSON is represented as a list
.
But a list can be wrapped in a field, such as "data".
You can use this type in combination with __finalize_json__
hook, that we will cover in
Preparers/Finalizers.
@router.get('/users')
def get_users() -> list[User]:
pass
Self
¶
Self
response type is used only in routed methods,
specifically in class method (@classmethod
) and instance methods (self
).
To use Self
, you need to import it.
In python 3.9
from typing_extensions import Self
In python >=3.10
from typing import Self
Let's explore two use cases:
Class Method¶
Self
in class method refers to the same as <BaseModel>
.
The current class in which the method is declared is taken as the model.
Example
class User(APIModel):
@classmethod
@router.get('/users/{id_}')
def get(cls, id_: Annotated[NonNegativeInt, Path()]) -> Self:
pass
Instance Method¶
Self
in instance method refers to the current object from which the method was called. This object is returned.
Example
You can use it in PUT
and PATCH
methods that update data related to the current model. It's a common approach,
to return the object for which the update was called.
In this example, we use Preparers/Finalizers
class User(APIModel):
...
@router.patch('/users/{id_}')
def update(
self,
name: str,
job: str
) -> Self:
pass
@update.prepare
def _update_in(self, args: Args) -> Args:
args.url = format_str(args.url, {'id_': self.id})
return args
@update.finalize()
def _update_out(self, response: Response) -> Self:
json_ = response.json()
self.first_name = json_['name']
return self
Forward Reference¶
In Python 3.9 Self
is not included in the typing
module, but is included in typing_extensions
.
Because of this, IDEs often cannot understand this return type in Python 3.9.
You can achieve the same functionality using Forward References.
Info
Forward reference is a way to resolve the issue, when a type hint contains names that have not been defined yet. It is a string literal, that can express a definition, to be resolved later.
For example, the following code, namely constructor definition, does not work:
class Tree:
def __init__(self, left: Tree, right: Tree):
self.left = left
self.right = right
To address this, we write:
class Tree:
def __init__(self, left: 'Tree', right: 'Tree'):
self.left = left
self.right = right
For instance, this code:
class User(APIModel):
...
@classmethod
@router.get('/users/{id_}')
def get(cls, id_: Annotated[NonNegativeInt, Path()]) -> Self:
pass
Can also be written as:
class User(APIModel):
...
@classmethod
@router.get('/users/{id_}')
def get(cls, id_: Annotated[NonNegativeInt, Path()]) -> "User":
pass
Warning
Forward Reference can only be used in routed methods (not routed functions). In the following example, Sensei cannot understand the response type.
class User(APIModel):
email: EmailStr
id: PositiveInt
first_name: str
last_name: str
avatar: AnyHttpUrl
@router.get('/users')
def list(
cls,
page: Annotated[int, Query()] = 1,
per_page: Annotated[int, Query(le=7)] = 3
) -> list["User"]:
pass
@router.get('/users/{id_}')
def get(cls, id_: Annotated[int, Path(alias='id')]) -> "User":
pass
list[Self]
¶
list[Self]
it's a mix of list[<BaseModel>]
and Self
. It refers to sequential unpacking JSON representation
of the response to the constructor of a current BaseModel
class from which the method was called.
But unlike Self
, it can be used only in class methods.
You can use Forward References as well as for
Self
Example
This code
@router.get('/users')
def get_users() -> list[User]:
pass
Can be rewritten as
class User(APIModel)
...
@router.get('/users')
def list() -> list[Self]:
pass
Or as
class User(APIModel)
...
@router.get('/users')
def list() -> list["User"]:
pass
Recap¶
In Sensei, defining Param Types and Response Types enables structured request and response handling, promoting flexibility, code clarity, and effective validation. Here's a brief overview:
-
Param Types are used to specify the origin of parameters in HTTP requests (
Path
,Query
,Cookie
,Header
,Body
,File
,Form
). They provide a consistent way to validate requests:Path
is for URL path params,Query
is for URL queries, andBody
is for JSON data in requests.File
andForm
types cater to form data, withFile
designed for binary files.Header
andCookie
handle HTTP headers and cookies, respectively.
-
Response Types define the structure of responses and support automated JSON parsing and response validation:
- Basic types like
str
,bytes
,None
, anddict
provide flexible output for text, binary data, or basic JSON. - Structured response types using
BaseModel
ensure data validation, withSelf
andlist[Self]
enhancing functionality in OOP-style.
- Basic types like
Using these types, Sensei promotes readability and robustness in handling HTTP request/response patterns.
Additionally, automated validations streamline error handling, while options like Self
and list[Self]
provide scalability for complex models and responses, keeping code DRY and maintainable.