First Steps
First Request¶
Do you want to see the simplest and most robust HTTP Request? He's already here!
from typing import Annotated
from sensei import Router, Path, APIModel
router = Router('https://pokeapi.co/api/v2/')
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
pokemon = get_pokemon(name="pikachu")
print(pokemon)
Pokemon(name='pikachu' id=25 height=4 weight=60)
Tip
If you don't know what are "request", "response" "route", "endpoint", "methods" and other HTTP terms, visit HTTP Requests
Let's take this request step by step.
Step 1: Importing Dependencies¶
from typing import Annotated
from sensei import Router, Path, APIModel
Annotated
is imported from Python's typing
module, allowing us to add metadata (like
Param Types) to variables.
Router
, Path
, and APIModel
are imported from the Sensei framework.
Router
helps manage API endpoints and routing.Path
specifies path (route) parameters and defines the argument's validation.APIModel
is used to define models for structuring API responses.
Step 2: Creating a Router¶
router = Router('https://pokeapi.co/api/v2/')
A Router
instance is created, with the base URL of the PokéAPI (https://pokeapi.co/api/v2/
). This will be the
root path for all subsequent API calls.
Step 3: Defining the Pokemon
Model¶
class Pokemon(APIModel):
name: str
id: int
height: int
weight: int
Pokemon
is a class that inherits from APIModel
. This class represents the structure of a Pokémon's data.
The attributes are:
name
: a stringid
: an integerheight
: an integerweight
: an integer
Technical Details
APIModel
inherits from pydantic.BaseModel
. You can apply the principles
of pydantic
for models in Sensei
Step 4: Defining the Routed Function¶
@router.get('/pokemon/{name}')
def get_pokemon(name: Annotated[str, Path(max_length=300)]) -> Pokemon:
pass
This function is decorated with @router.get
, defining it as a GET request for the /pokemon/{name}
endpoint.
Info
Decorator is a design pattern that allows you to modify or extend the behavior of functions or methods without changing their code directly.
In Python, they are applied using the @decorator
syntax just above a
function definition. In most cases, the decorator is some function, taking function and returning modified(decorated)
function
The {name}
is a path parameter, dynamically inserted into the API call.
name
: is an argument annotated as a string withPath
, meaning it is a required URL path parameter. Also, inPath
we defined validation rules, namely checking whether the length of the name is less or equal then 300.Path
is placed inAnnotated
object as metadata.
Routed Function¶
Instead of @router.get
, you can use the other operations (HTTP methods):
@router.post
@router.put
@router.delete
@router.patch
@router.head
@router.options
These decorators are called route decorators and the result of these decorators is called routed function (method). Sometimes, if it is clear from the context that we are talking about a routed function, we will shorten this name to a route.
Warning
Routed functions must not have the body.
This code will not be executed. Instead of the body, place the pass
keyword.
@router.get('/pokemon/{name}')
def get_pokemon(name: Annotated[str, Path(max_length=300)]) -> Pokemon:
pass
The function body is omitted because Sensei automatically handles the API call and response mapping.
In this case, the function returns a Pokemon
object, ensuring that the data fetched from the API matches the
Pokemon
model. The return type we will also
call response type.
Info
This "omitting" pattern is called Interface-driven function or Signature-driven function, because we do not write any code achieving the result. We dedicate this responsibility to some tool, parsing the function's interface(signature). According to it, the tool handles a call automatically. This pattern is the key to how Sensei achieves declarative and metaprogramming code styles.
Step 5: Making the API Call¶
pokemon = get_pokemon(name="pikachu")
print(pokemon)
Let's consider the algorithm of the call:
1) Calling the get_pokemon
function with the argument name="pikachu"
.
2) Collecting the function's return type and arguments' names, types and values; Param Types,
such as Path
, Query
, Body
, Cookie
, Header
, File
, and Form
, including argument validations placed inside
them.
3) Validating input args. In this case name="pikachu"
4) Sending HTTP request.
5) Validating and mapping response, according to the return type
Async Request¶
If you want to make an asynchronous request, the code will be almost the same:
import asyncio
from typing import Annotated
from sensei import Router, Path, APIModel
router = Router('https://pokeapi.co/api/v2/')
class Pokemon(APIModel):
name: str
id: int
height: int
weight: int
@router.get('/pokemon/{name}')
async def get_pokemon(name: Annotated[str, Path(max_length=300)]) -> Pokemon:
pass
async def main() -> None:
pokemon = await get_pokemon(name="pikachu")
print(pokemon)
asyncio.run(main())
Only add async
before def
!
Tip
If you don't know what are "concurrency", "parallelism" and what does do "async/await" in Python, visit Concurrency/Parallelism
API Model¶
As mentioned earlier, APIModel
models use the same principles as BaseModel
models from
pydantic
. You can explore its documentation, to learn info for advanced users.
To make request with non-scalar input/output values, you definitely need to create models.
Let’s go over the basics!
Creating¶
from sensei import APIModel
from pydantic import EmailStr, NonNegativeInt
class User(APIModel):
id: NonNegativeInt
username: str
email: EmailStr
age: int = 18
is_active: bool = True
Models created using APIModel
have validation.
This means if you pass a value not matching corresponding field constraints, ValidationError
will be thrown.
ValidationError
User(id=-1, username="user", email="randomstr", age=18.01, is_active=-1)
4 validation errors for User id Input should be greater than or equal to 0 [type=greater_than_equal, input_value=-1, input_type=int] For further information visit https://errors.pydantic.dev/2.9/v/greater_than_equal email value is not a valid email address: An email address must have an @-sign. [type=value_error, input_value='randomstr', input_type=str] age Input should be a valid integer, got a number with a fractional part [type=int_from_float, input_value=18.01, input_type=float] For further information visit https://errors.pydantic.dev/2.9/v/int_from_float is_active Input should be a valid boolean, unable to interpret input [type=bool_parsing, input_value=-1, input_type=int] For further information visit https://errors.pydantic.dev/2.9/v/bool_parsing
In this User
model, the fields are:
id
: an integer for the user ID.NonNegativeInt
ensures that the value >= 0.username
: a string for the username.email
: validated by theEmailStr
type frompydantic
, ensuring a valid email.age
: an integer defaulting to 18.is_active
: a boolean defaulting to True.
Tip
Value validation is not force-type validation.
For example, if you define a model with id
, due_date
, and priority
fields of types int
, bool
, and datetime
respectively,
you can pass:
- numerical string as
id
- ISO-8601, UTC or strings of the other date formats as
due_date
'yes'/'no'
,'on'/'off'
,'true'/'false'
,1/0
etc. aspriority
from sensei import APIModel
from datetime import datetime
class Task(APIModel):
id: int
due_date: datetime
priority: bool
task = Task(due_date='2024-10-15T15:30:00', id="1", priority="yes")
print(task)
The result will be
Task(id=1, due_date=datetime.datetime(2024, 10, 15, 15, 30), priority=True)
Validating¶
Constrained Types¶
In addition to built-in validation like EmailStr
or NonNegativeInt
,
you can enforce more specific constraints in your models using Constrained types, like constr
and conint
.
constr
: Enforces restrictions on a string field, like setting a minimum or maximum length, or allowing only certain characters.conint
: Enforces restrictions on an integer field, such as minimum and maximum value.
For instance, in the User
model, you can add constraints for the username
, id
, and age
fields. Here's an updated
version
of the User
model:
from sensei import APIModel
from pydantic import EmailStr, conint, constr
class User(APIModel):
id: conint(ge=0) # (1)!
username: constr(pattern=r'^\w+$') # (2)!
email: EmailStr
age: conint(ge=14) = 18 # (3)!
is_active: bool = True
- Ensures id is a non-negative integer
- Allows only alphanumeric characters and underscores
- Ensures the age is 14 or older
Info
There are also many constrained types called by pattern con<type>
:
conset
conbytes
conlist
- etc...
Field Types¶
The same validation rules can also be applied using Field
. There are two approaches to use it.
Technical Details
Sensei's Param Types, such as Path
, Query
, Cookie
, Header
,
Body
, File
, and Form
are inherited from
FieldInfo
,
produced by the Field
function. All approaches that will be described are applied
to param types.
Annotated¶
Here’s how you can rewrite the same User
model using Annotated
:
from typing import Annotated
from sensei import APIModel
from pydantic import EmailStr, Field
class User(APIModel):
id: Annotated[int, Field(ge=0)] # (1)!
username: Annotated[str, Field(pattern=r'^\w+$')] # (2)!
email: EmailStr
age: Annotated[int, Field(18, ge=14)] # (3)!
is_active: bool = True
- Same as
conint(ge=0)
- Same as
constr(pattern=r'^\w+$')
- Same as
conint(ge=14)
Setting the default value of age can be achieved in different ways:
-
Passing default value as an argument to
Field
age: Annotated[int, Field(18, ge=14)]
-
Or a familiar way
age: Annotated[int, Field(ge=14)] = 18
Directly¶
For simplicity, you can use Field()
directly, without Annotated
.
Here’s how you can rewrite the User
model:
from sensei import APIModel
from pydantic import EmailStr, Field
class User(APIModel):
id: int = Field(ge=0) # (1)!
username: str = Field(regex=r'^\w+$') # (2)!
email: EmailStr
age: int = Field(18, ge=14) # (3)!
is_active: bool = True
- Same as
conint(ge=0)
- Same as
constr(pattern=r'^\w+$')
- Same as
conint(ge=14)
Here setting the default value of age can be achieved in only one way.
age: int = Field(18, ge=14)
Info
If you use Field Types simultaneously with Constrained Types, you should know about validation priority.
-
When using
Field
directly, Constrained Type takes precedence overField
.username: constr(min_length=5) = Field(min_length=10)
-
When use
Field
+Annotated
,Field
take precedence over Constrained Type.username: Annotated[constr(min_length=5), Field(min_length=10)]
Validators¶
In addition to using Constrained Types and Fields for validation, you can also define custom validation logic in your model using validators. Validators allow you to apply more complex validation rules that cannot be easily expressed using the built-in types or field constraints.
Here's an example of how you can validate the email field:
import re
from sensei import APIModel
from typing import Annotated
from pydantic import Field, field_validator
class User(APIModel):
id: Annotated[int, Field(ge=0)]
username: Annotated[str, Field(pattern=r'^\w+$')]
email: str
age: Annotated[int, Field(18, ge=14)]
is_active: bool = True
@field_validator('email')
def validate_email(cls, value):
pattern = r'^[\w\.-]+@[a-zA-Z0-9-]+\.[a-zA-Z]{2,}$'
if not re.match(pattern, value):
raise ValueError(f"Email '{value}' does not match the required pattern.")
return value
Validator is defined through the @field_validator
decorator from pydantic
.
You can pass one or more field names to @field_validator
, to determine what fields will use this validator, or '*'
to apply
validator for every field. The decorator is applied to a class method,
which takes the value of a field as an argument and returns the validated (or transformed) value. This method can also
raise a ValueError
or TypeError
to signal a validation failure.
Tip
In situations, when you use @field_validator
along with other validators, like built-in Field
, you may need to
set the execution order of your custom validator. There is mode
argument, to customize it.
mode="before"
- validator is executed before thepydantic
internal parsing.mode="after"
- validator is executed after thepydantic
internal parsing.mode="plain"
- validator terminates other validators, no further validators are calledmode="wrap"
- validator can run code before thepydantic
internal parsing and after.
Here is an example of how to use comma-separated tags, in result will be represented as list[str]
from sensei import APIModel
from typing import Annotated
from pydantic import Field, field_validator
class Video(APIModel):
tags: Annotated[list[str], Field(min_length=1)]
...
@field_validator('tags', mode="before")
def _split_str(cls, value):
if isinstance(value, str):
return value.split(',')
else:
return value
print(Video(tags='trend,unreal,good'))
Video(tags=['trend', 'unreal', 'good'])
Serializing¶
To serialize a model, you can use the model_dump
method.
user = User(id=1, username="johndoe", email="[email protected]", age=30)
user_data = user.model_dump(mode="json")
print(user_data)
{
"id": 1,
"username": "johndoe",
"email": "[email protected]",
"age": 30,
"is_active": True
}
Tip
If you use a model in another request or want to write it to a file, you should serialize it with mode="json"
.
user_data = user.model_dump(mode="json")
For example, if you have some field with datetime
object, this mode evaluates this time as a string.
from sensei import APIModel
from datetime import datetime
class Task(APIModel):
due_date: datetime
...
task = Task(due_date=datetime(2024, 10, 15, 15, 30)).model_dump(mode="json")
print(task)
{
"date": '2024-10-15T15:30:00',
...
}
Routed Methods¶
You can create a model that performs both validation and making requests. This OOP Style is called Routed Model (we will explore it later).
from typing import Annotated
from typing_extensions import Self
from sensei import APIModel, Router, Path
from pydantic import NonNegativeInt, EmailStr
router = Router('https://api.example.com/')
class User(APIModel):
id: NonNegativeInt
username: str
email: EmailStr
@classmethod
@router.get('/users/{id_}')
def get(cls, id_: Annotated[NonNegativeInt, Path()]) -> Self:
pass # (1)!
- This is called routed method
The algorithm is the following:
-
Create a
Router
instancerouter = Router('https://api.example.com/')
-
Define endpoints through routed methods
@classmethod @router.get('/users/{id_}') def get(cls, id_: Annotated[NonNegativeInt, Path()]) -> Self: pass
As a result, User.get
returns User
object:
user = User.get(1)
print(user.email)
Routed Model is a good practice to organize endpoints in one class when they are related to one model.
Warning
You must not decorate a method as routed in a class not inherited from APIModel
.
This makes it impossible to use Preparers/Finalizers, which we will learn in
the future, and can lead to issues in the other situations.
For instance, there is a common error to use BaseModel
for the same purpose as APIModel
:
from pydantic import BaseModel
from typing import Annotated
from typing_extensions import Self
from sensei import Router, Path
from pydantic import NonNegativeInt
router = Router('https://api.example.com/')
class User(BaseModel):
@classmethod
@router.get('/users/{id_}')
def get(cls, id_: Annotated[NonNegativeInt, Path()]) -> Self:
pass
Recap¶
Sensei abstracts away much of the manual work, letting developers focus on function signatures while the framework handles the API logic and data validation.
The example of First Request demonstrates a simple and robust HTTP request using the Sensei framework. Here's the key breakdown of the process:
1. Importing Dependencies:¶
Router
manages API endpoints and routing.Path
specifies and validates route parameters.APIModel
defines models for structuring API responses (similar topydantic.BaseModel
).
2. Creating the Router:¶
The Router
is initialized with the base URL of the PokéAPI. All subsequent requests will use this as the base path.
3. Defining the Model:¶
The Pokemon
class represents the data structure for a Pokémon, with fields like name
, id
, height
, and weight
.
It inherits from APIModel
, which provides validation and serialization.
4. Creating the Endpoint:¶
The get_pokemon
function is a routed function
decorated with @router.get
, defining a GET request for/pokemon/{name}
.
This uses Annotated
to ensure that name
is a string and adheres to the validation rule (max length of 300).
5. Making the Request:¶
By calling get_pokemon(name="pikachu")
, Sensei automatically handles validation, makes the HTTP request,
and maps the API response into the Pokemon
model. The code omits the function body since Sensei handles calls through
the function's signature.