Fast API
Fast API
МИНИН
РАЗРАБОТКА ПРОГРАММНЫХ
ИНТЕРФЕЙСОВ ВЕБ-ПРИЛОЖЕНИЙ
С ИСПОЛЬЗОВАНИЕМ
ФРЕЙМВОРКА FastAPI
Тамбов
Издательский центр ФГБОУ ВО «ТГТУ»
2024
0
Министерство науки и высшего образования Российской Федерации
Федеральное государственное бюджетное образовательное
учреждение высшего образования
«Тамбовский государственный технический университет»
А. И. ЕЛИСЕЕВ, Ю. В. МИНИН
РАЗРАБОТКА ПРОГРАММНЫХ
ИНТЕРФЕЙСОВ ВЕБ-ПРИЛОЖЕНИЙ
С ИСПОЛЬЗОВАНИЕМ
ФРЕЙМВОРКА FastAPI
Утверждено Ученым советом университета
в качестве учебного пособия для студентов 2, 3 курсов,
обучающихся по направлению подготовки 09.03.02
«Информационные системы и технологии»
Тамбов
Издательский центр ФГБОУ ВО «ТГТУ»
2024
1
УДК 004(075.8)
ББК з973.43я73
Е51
Рецензенты:
Кандидат технических наук, доцент, доцент Института новых технологий
и искусственного интеллекта ФГБОУ ВО «ТГУ им. Г. Р. Державина»
И. А. Зауголков
Кандидат технических наук, доцент кафедры
«Мехатроника и технологические измерения» ФГБОУ ВО «ТГТУ»
А. С. Егоров
Елисеев, А. И.
Е51 Разработка программных интерфейсов веб-приложений с исполь-
зованием фреймворка FastAPI [Электронный ресурс] : учебное посо-
бие / А. И. Елисеев, Ю. В. Минин. – Тамбов : Издательский центр
ФГБОУ ВО «ТГТУ», 2024. – 1 электрон. опт. диск (CD-ROM). –
Системные требования : ПК не ниже класса Pentium II ; CD-ROM-
дисковод ; 1,5 Mb ; RAM ; Windows 95/98/XP ; мышь. – Загл. с
экрана.
ISBN 978-5-8265-2821-1
Представляет собой руководство по использованию фреймворка FastAPI,
охватывающее основные концепции и практические примеры для создания
веб-приложений на Python. Рассмотрены ключевые аспекты работы
с FastAPI, включая маршрутизацию, обработку запросов и ответов, валидацию
данных, аутентификацию, а также интеграцию с базами данных и асинхронное
программирование.
Предназначено для студентов 2, 3 курсов, обучающихся по направлению
подготовки 09.03.02 «Информационные системы и технологии».
УДК 004(075.8)
ББК з973.43я73
2
ВВЕДЕНИЕ
3
ВЕБ-ФРЕЙМВОРКИ НА Python
4
В FastAPI используются:
подсказки типов Python;
пакет Starlette, включая поддержку асинхронности;
пакет Pydantic для определения и проверки данных;
возможности интеграции, позволяющие использовать и расши-
рять возможности фреймворка.
Необходимые пакеты для работы с FastAPI:
фреймворк FastAPI – poetry add fastapi;
add httpx.
ВВЕДЕНИЕ В FastAPI
Первое приложение
Первый пример:
# main.py
from fastapi import FastAPI
app = FastAPI()
@app.get("/hi")
def greet():
return "Hello World!"
5
def greet() представляет собой функцию пути – основную точку кон-
такта с HTTP-запросами и ответами.
Запуск приложения с помощью командной строки:
app = FastAPI()
@app.get("/hi")
def greet():
return "Hello World!"
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", reload=True)
http 127.0.0.1:8000/hi
HTTP/1.1 200 OK
content-length: 15
6
content-type: application/json
date: Thu, 30 Jun 2024 07:38:27 GMT
server: uvicorn
"Hello World!"
http -v 127.0.0.1:8000/hi
HTTP/1.1 200 OK
content-length: 15
content-type: application/json
date: Thu, 30 Jun 2024 08:05:06 GMT
server: uvicorn
"Hello World!"
7
Использование пути:
app = FastAPI()
@app.get("/hi/{who}")
def greet(who):
return f"Hello {who}!"
http 127.0.0.1:8000/hi/user
HTTP/1.1 200 OK
content-length: 13
content-type: application/json
date: Thu, 30 Jun 2024 08:09:02 GMT
server: uvicorn
"Hello user!"
@app.get("/hi")
def greet(who):
return f"Hello {who}!"
http -b 127.0.0.1:8000/hi?who=user
"Hello user!"
8
Или так:
"Hello user!"
{
"who": "user"
}
HTTP/1.1 200 OK
content-length: 13
content-type: application/json
date: Thu, 30 Jun 2024 08:37:00 GMT
server: uvicorn
"Hello user!"
9
Параметры запроса
Параметры запроса (Request Parameters) – специальные параметры
в функции, декорированной операциями пути, которые позволяют полу-
чать данные из запроса.
Параметры запроса разрешаются путем внедрения зависимости.
FastAPI любой запрос разложит по следующим объектам:
Header – HTTP-заголовки;
Path – URL-адрес;
Query – параметры запроса (после символа ? в конце URL);
Body – тело HTTP-сообщения.
В приведенном ниже запросе есть строки запроса skip и limit:
http://127.0.0.1:8000/items/?skip=0&limit=103
10
Connection: keep-alive
Host: 127.0.0.1:8000
User-Agent: HTTPie/3.2.2
HTTP/1.1 200 OK
content-length: 22
content-type: application/json
date: Mon, 12 Aug 2024 18:27:14 GMT
server: uvicorn
{
"limit": 10,
"skip": 10
}
http://127.0.0.1/users/1
Реализация:
Тестирование:
http -v 127.0.0.1:8000/users/1
HTTP/1.1 200 OK
content-length: 8
11
content-type: application/json
date: Mon, 12 Aug 2024 18:23:20 GMT
server: uvicorn
{
"id": 1
}
@app.get("/secure-data")
async def read_secure_data(api_key: str = Header(...)):
if not re.match(REGEX_API_KEY, api_key):
raise HTTPException(status_code=400, detail="Invalid API Key
format")
return {"message": "Access granted", "api_key": api_key}
Тестирование:
http http://127.0.0.1:8000/secure-data
api-key:1234567890abcdef1234567890abcdef
12
User-Agent: HTTPie/3.2.2
api-key: 1234567890abcdef1234567890abcdef
HTTP/1.1 200 OK
content-length: 73
content-type: application/json
date: Sun, 08 Sep 2024 16:54:44 GMT
server: uvicorn
{
"api_key": "1234567890abcdef1234567890abcdef",
"message": "Access granted"
}
document.cookie='country=Russia'
13
В примере ниже показана конечная точка, которая получает поле
тела запроса с названием content в формате обычного текста:
Тестирование:
Message
HTTP/1.1 200 OK
content-length: 23
content-type: application/json
date: Mon, 12 Aug 2024 18:34:10 GMT
server: uvicorn
{
"Content": "Message\n"
}
14
Заголовок Content-Type в HTTP-запросе имеет значение application/x-
www-form-urlencoded, если используются параметры формы, или
multipart/form-data, если форма используется для загрузки файлов.
В примере ниже мы используем форму для отправки имени пользо-
вателя и пароля на сервер:
Тестирование:
user=admin&password=secret
HTTP/1.1 200 OK
content-length: 36
content-type: application/json
date: Mon, 12 Aug 2024 18:37:57 GMT
server: uvicorn
{
"password": "secret",
"user": "admin"
}
15
Загрузка файлов – одна из тех задач, которые сложно реализовать
в API. К счастью, в FastAPI эта задача решается несложно:
Тестирование:
+-----------------------------------------+
| NOTE: binary data not shown in terminal |
+-----------------------------------------+
HTTP/1.1 200 OK
content-length: 57
content-type: application/json
date: Mon, 12 Aug 2024 18:41:41 GMT
server: uvicorn
{
"Photo": "bdb81b15-576e-4993-9308-6c22ff9ac248-file.png"
}
16
функции зависимости, которые будут обрабатывать и объединять их осо-
бым образом, например, для пагинации или аутентификации.
Какой вариант выбрать? При передаче аргументов в URL стандарт-
ной практикой стало следование рекомендациям стиля REST. Строки
запросов обычно применяются для предоставления дополнительных аргу-
ментов, таких как пагинация. Тело запроса обычно используется для
больших объемов, вводимых данных, например, целых или частичных
моделей.
HTTP-ответы
По умолчанию FastAPI преобразует все, что возвращаетcя из функ-
ции конечной точки, в формат JSON. Поэтому HTTP-ответ содержит стро-
ку заголовка Content-type: application/json.
Типы ответов (классы из модуля fastapi.responses):
JSONRespons (по умолчанию);
HTMLResponse;
PlainTextResponse;
RedirectResponse;
FileResponse;
StreamingResponse.
Для других форматов вывода можно использовать общий класс
Response, требующий следующие параметры:
content – строка или байт;
media_type – строка MIME-типа;
status_code – целочисленный код состояния HTTP;
headers – словарь строк.
Код состояния
По умолчанию FastAPI возвращает код состояния 200. Исключения
вызывают коды группы 4xx.
17
Указание кода состояния:
@app.get("/happy")
def happy(status_code=200):
return ":)"
@app.get("/happy")
def happy(status_code=status.HTTP_200_OK):
return ":)"
Заголовки
Можно вводить заголовки HTTP-ответов (не нужно возвращать
сообщения response):
@app.get("/header/{name}/{value}")
def header(name, value, response:Response):
response.headers[name] = value
return "normal body"
$ http 127.0.0.1:8000/header/marco/polo
HTTP/1.1 200 OK
content-length: 13
content-type: application/json
date: Wed, 31 May 2023 17:47:38 GMT
marco: polo
server: uvicorn
"normal body"
Автоматизированная документация
FastAPI генерирует спецификацию OpenAPI из кода и включает эту
страницу для отображения и тестирования всех конечных точек.
18
Документация в виде Swagger UI доступна по адресу
http://127.0.0.1:8000/docs.
Есть второй вариант – ReDoc, который доступен по адресу
http://127.0.0.1:8000/redoc.
АСИНХРОННОСТЬ
19
Пример асинхронной точки доступа:
app = FastAPI()
@app.get("/hi")
async def greet():
await asyncio.sleep(1)
return "Hello World!"
ВАЛИДАЦИЯ ДАННЫХ
Подсказки типов
Существует один синтаксис для переменных и другой – для возвра-
щаемых значений функций. Подсказки типа переменной могут включать
только тип:
name: type
20
Или также инициализировать переменную значением:
Тип может быть одним из стандартных простых типов, таких как int
или str, или коллекцией, такой как tuple, list или dict:
21
Или, если быть более точными:
$ python
...
>>> thing0
Traceback (most recent call last):
File "<stdin>", line 1, in <module> NameError: name thing0 is not
defined
>>> thing0: str
$ python
...
>>> thing1: str = "yeti"
>>> thing1 = 47
Проверка:
mypy stuff.py
stuff.py:2: error: Incompatible types in assignment (expression has
type "int", variable has type "str") Found 1 error in 1 file (checked
1 source file)
22
Пример:
Структуры данных
Структуры данных в Python (помимо базовых int, string и подоб-
ных им):
tuple – кортеж, неизменяемая последовательность объектов;
list – список, изменяемая последовательность объектов;
set – множество, изменяемые уникальные объекты;
dict – словарь, пары изменяемых объектов «ключ – значение»
(ключ должен быть неизменяемого типа).
Кортежи и списки позволяют обращаться к переменной по индексу:
TITLE = 0
DIRECTOR = 1
RELEASE_DATE = 2
DURATION = 3
GENRE = 4
RATING = 5
23
Словари выглядят немного лучше, предоставляя доступ по ключам:
dict_movie = {
"title": "Inception",
"director": "Christopher Nolan",
"release_date": "2010-07-16",
"duration": 148,
"genre": "Science Fiction",
"rating": 8.8
}
print("Title is", dict_movie["title"])
Датаклассы
Использование стандартного класса:
class MovieClass:
def __init__(self,
title: str,
director: str,
release_date: str,
duration: int,
genre: str,
rating: float):
self.title = title
self.director = director
self.release_date = release_date
self.duration = duration
self.genre = genre
self.rating = rating
24
class_movie = MovieClass("Inception", "Christopher Nolan", "2010-07-
16", 148, "Science Fiction", 8.8)
@dataclass
class MovieDataClass:
title: str
director: str
release_date: str
duration: int
genre: str
rating: float
Задачи валидации
При работе с данными, обычно словарями, нужно проверять абсо-
лютно все:
Ключ обязателен?
Если ключ отсутствует, есть ли значение по умолчанию?
Существует ли ключ?
Если да, то относится ли значение ключа к правильному типу?
Если да, то находится ли значение в нужном диапазоне или соот-
ветствует ли оно шаблону?
Три решения отвечают хотя бы некоторым из этих требований:
Dataclasse – часть стандартного языка Python.
attrs – сторонний пакет, но содержит супернабор классов данных.
Pydantic – тоже сторонний продукт, но интегрированный
в FastAPI.
25
БИБЛИОТЕКА Pydantic
Создадим временную базу данных внутри приложения, а также
два маршрута:
todo_list = []
@app.post("/todo")
async def add_todo(todo: dict) -> dict:
todo_list.append(todo)
return {"message": "Todo added successfully"}
@app.get("/todo")
async def retrieve_todos() -> dict:
return {"todos": todo_list}
В примере POST-запрос отправляет данные в следующем формате:
{
"id": id,
"item": item
}
Пустой словарь может быть отправлен без возврата какой-либо
ошибки. Пользователь может отправить запрос с телом, отличным от пока-
занного ранее.
Создание модели с требуемой структурой тела запроса и назначение
ее в качестве типа телу запроса гарантирует, что будут переданы только
поля данных, присутствующие в модели.
Используем класс BaseModel из Pydantic:
from pydantic import BaseModel
class Todo(BaseModel):
id: int
item: str
Далее заменим тип переменной тела запроса с dict на Todo:
todo_list = []
@todo_router.post("/todo")
async def add_todo(todo: Todo) -> dict:
todo_list.append(todo)
return {"message": "Todo added successfully"}
@todo_router.get("/todo")
async def retrieve_todos() -> dict:
return {"todos": todo_list}
26
Проверим новый валидатор тела запроса, отправив пустой словарь
в качестве тела запроса:
{
"detail": [
{
"input": {},
"loc": ["body", "id"],
"msg": "Field required",
"type": "missing"
},
{
"input": {},
"loc": ["body", "item"],
"msg": "Field required",
"type": "missing"
}
]
}
Вложенные модели
В Pydantic модели также могут быть вложенными, например:
class Item(BaseModel):
desc: str
status: str
class Todo(BaseModel):
id: int
item: Item
{
"id": 1,
"item": {
"desc": "description",
"status": "completed"
}
}
27
Модели ответов
Добавим новый маршрут:
todo_list = []
@app.get("/todo")
async def retrieve_todo() -> dict:
return {
"todos": todo_list
}
class TodoItem(BaseModel):
item: str
class TodoItems(BaseModel):
todos: list[TodoItem]
todo_list = []
@app.get("/todo", response_model=TodoItems)
async def retrieve_todo() -> dict:
return {
"todos": todo_list
}
Проверка значений
Некоторые ограничения могут быть наложены на валидируемое зна-
чение. Целочисленное значение (conint) или число с плавающей точкой
(confloat):
gt – больше чем;
lt – меньше чем;
ge – больше или равно;
le – меньше или равно;
multiple_of – целое число, кратное значению.
28
Строковое (constr) значение:
min_length – минимальная длина в символах (не в байтах);
max_length – максимальная длина в символах;
to_upper – преобразование в прописные буквы;
to_lower – преобразование в строчные буквы;
regex – сопоставление с регулярным выражением Python.
Кортеж, список или множество:
min_items – минимальное количество элементов;
max_items – максимальное количество элементов.
Пример:
class Movie(BaseModel):
title: constr(min_length=1)
director: str
release_date: str
duration: conint(gt=0) # Продолжительность должна быть
положительным целым числом
genre: str
rating: confloat(ge=0, le=10) # Рейтинг должен быть в диапазоне
от 0 до 10
bad_movie = Movie(
duration=-120, # Некорректная продолжительность
rating=15.0 # Некорректный рейтинг
)
29
Альтернативный вариант – спецификация Field из библиотеки
Pydantic:
class Movie(BaseModel):
title: str = Field(..., min_length=1, description="Title
of the movie")
director: str = Field(..., description="Director of the movie")
release_date: str = Field(..., description="Release date of the
movie in YYYY-MM-DD format")
duration: int = Field(..., gt=0, description="Duration
of the movie in minutes, must be a positive integer")
genre: str = Field(..., description="Genre of the movie")
rating: float = Field(..., ge=0, le=10, description="Rating
of the movie, must be between 0 and 10")
Обработка ошибок
Запросы могут возвращать ответы, сообщающие об ошибках, причем
эти ответы могут не содержать достаточно информации о причине сбоя.
Ошибки могут быть вызваны попыткой доступа к несуществующим ресур-
сам, защищенным страницам без достаточных прав доступа и даже ошиб-
ками сервера.
Ошибки в FastAPI обрабатываются путем создания исключения
с помощью класса HTTPException. Класс HTTPException принимает
три аргумента:
status_code: код состояния, который будет возвращен для данного
сбоя.
detail: сопроводительное сообщение, которое будет отправлено
клиенту.
headers: необязательный параметр для ответов, требующих заго-
ловков.
30
Мы можем объявить код статуса HTTP, чтобы отменить стандартный
код статуса для успешных операций POST, добавив аргумент status_code
в функцию декоратора:
@app.post("/todo", status_code=201)
async def add_todo(todo: Todo) -> dict:
todo_list.append(todo)
return {
"message": "Todo added successfully."
}
Пример c HTTPException:
@app.get("/todo/{todo_id}")
async def get_single_todo(todo_id: int = Path(..., title="The ID
of the todo.")) -> dict:
for todo in todo_list:
if todo.id == todo_id:
return {
"todo": todo
}
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Todo with supplied ID doesn't exist",
)
ЗАВИСИМОСТИ В FastAPI
31
Пример:
app = FastAPI()
# функция зависимости
def user_dep(name: str = Body(embed=True), password: str =
Body(emded=True)):
return {"name": name, "valid": True}
@app.post("/user")
def get_user(user: dict = Depends(user_dep)) -> dict:
return user
32
Если функция зависимости проверяет что-то и не возвращает ника-
ких значений, можно определить зависимость в декораторе пути:
@app.method(url, dependencies=[Depends(depfunc)])
# функция зависимости
def user_dep(name: str = Body(embed=True), password: str =
Body(emded=True)):
if not name:
raise
@app.post("/user", dependencies=[Depends(user_dep)])
def get_user() -> bool:
return True
def depfunc1():
pass
def depfunc2():
pass
@app.get("/main")
def get_main():
pass
33
ШАБЛОНИЗАЦИЯ
Фильтры
Несмотря на схожесть синтаксиса Python и Jinja, такие модификации,
как объединение строк, изменение регистра и так далее, не могут быть
выполнены с помощью Python в Jinja.
Для выполнения таких модификаций в Jinja есть фильтры.
Фильтр отделяется от переменной символом пайпа (|) и может
содержать необязательные аргументы в круглых скобках:
{{ variable | filter_name(*args) }}
34
Если аргументов нет, то определение выглядит следующим образом:
{{ variable | filter_name }}
{{ 3.142 | int }}
> 3
{{ 31 | float }}
> 31.0
35
Использование операторов if в Jinja аналогично их использованию
в Python, операторы if используются в управляющих блоках {% %}:
Макросы
Макрос в Jinja – это функция, возвращающая HTML-строку. Основ-
ная цель использования макросов – избежать повторения кода и вместо
этого использовать один вызов функции.
Например, макрос для создания поля ввода:
{{ input('item') }}
36
В результате получим следующее:
<div class="form">
<input type="text" name="item" value="" size="20">
</div>
@app.get("/todo", response_model=TodoItems)
async def retrieve_todo(request: Request):
return templates.TemplateResponse("todo.html",
{ "request": request,
"todos": todo_list
})
<html>
<head>
</head>
<body>
{% for todo in todos %}
<li class="list-group-item">
{{ loop.index }}. <a href= "/todo/{{loop.index}}">
{{ todo.item }} </a>
</li>
{% endfor %}
</body>
</html>
37
АРХИТЕКТУРА ПРИЛОЖЕНИЯ
src/
main.py
database/
__init__.py
base.py
connection.py
routes/
__init__.py
events.py
users.py
models/
__init__.py
events.py
users.py
Каталог routes:
events.py: будет обрабатывать маршруты для создания, обновле-
ния и удаления событий.
users.py: будет обрабатывать маршруты для регистрации и входа
пользователей.
Каталог models:
events.py: будет содержать определение моделей для операций
с событиями.
users.py: будет содержать определение моделей для операций
с пользователями.
Реализация моделей
Определим модель Event в файле models/events.py:
class Event(BaseModel):
id: int
38
title: str
date: datetime
description: str
tags: list[str]
location: str
class Config:
schema_extra = {
"example": {
"title": "Event title",
"date": "2024-08-28T14:38:04",
"description": "Event description",
"tags": ["tag1", "tag2"],
"location": "online"
}
}
class User(BaseModel):
email: EmailStr
password: str
class Config:
schema_extra = {
"example": {
"email": "[email protected]",
"password": "secret"
}
}
39
Реализация маршрутов User
Начнем с определения основного маршрута регистрации в файлe
routes/users.py:
user_router = APIRouter(
tags=["User"],
)
users = {}
@user_router.post("/signup")
async def sign_user_up(data: User) -> dict:
if data.email in users:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="User with supplied username exists"
)
users[data.email] = data
return {
"message": "User successfully registered!"
}
@user_router.post("/signin")
async def sign_user_in(user: User) -> dict:
if user.email not in users:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User does not exist"
)
if users[user.email].password != user.password:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Wrong credential passed"
)
return {
"message": "User signed in successfully"
}
40
Первым делом проверяется, существует ли пользователь в базе,
и если он не существует, то возбуждается исключение. Если пользователь
существует, приложение переходит к проверке совпадения паролей, после
чего возвращает сообщение об успехе или исключение.
Теперь, когда мы определили маршруты для пользовательских опе-
раций, зарегистрируем их в файле main.py и запустим приложение.
import uvicorn
app = FastAPI()
# Регистрация маршрутов
app.include_router(user_router, prefix="/user")
if __name__ == '__main__':
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
HTTP/1.1 200 OK
content-length: 43
content-type: application/json
date: Tue, 13 Aug 2024 15:14:03 GMT
server: uvicorn
{
"message": "User successfully registered!"
}
HTTP/1.1 200 OK
content-length: 41
content-type: application/json
date: Tue, 13 Aug 2024 15:15:59 GMT
server: uvicorn
{
"message": "User signed in successfully"
}
41
Реализация маршрутов Event
Начнем с импорта зависимостей и определения маршрутизатора
событий:
event_router = APIRouter(
tags=["Events"]
)
events = []
@event_router.get("/", response_model=list[Event])
async def retrieve_all_events() -> list[Event]:
return events
@event_router.get("/{id}", response_model=Event)
async def retrieve_event(id: int) -> Event:
for event in events:
if event.id == id:
return event
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Event with supplied ID does not exist"
)
@event_router.post("/new")
async def create_event(body: Event = Body(...)) -> dict:
events.append(body)
return {
"message": "Event created successfully"
}
@event_router.delete("/delete/{id}")
async def delete_event(id: int) -> dict:
for event in events:
if event.id == id:
events.remove(event)
42
return {
"message": "Event deleted successfully"
}
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Event with supplied ID does not exist"
)
app = FastAPI()
# Регистрация маршрутов
app.include_router(user_router, prefix="/user")
app.include_router(event_router, prefix="/event")
if __name__ == "__main__":
uvicorn.run("main:app", host="0.0.0.0", port=8080,
reload=True)
http http://127.0.0.1:8000/event/
HTTP/1.1 200 OK
content-length: 2
content-type: application/json
date: Tue, 13 Aug 2024 15:25:38 GMT
server: uvicorn
[]
HTTP/1.1 200 OK
content-length: 40
content-type: application/json
date: Tue, 13 Aug 2024 15:30:53 GMT
server: uvicorn
43
{
"message": "Event created successfully"
}
http http://127.0.0.1:8000/event/1
HTTP/1.1 200 OK
content-length: 286
content-type: application/json
date: Tue, 13 Aug 2024 15:35:46 GMT
server: uvicorn
{
"description": "Event description",
"id": 1,
"date": "2024-08-28T14:38:04",
"location": "online",
"tags": [
"tag1",
"tag2"
],
"title": "Event title"
}
HTTP/1.1 200 OK
content-length: 40
content-type: application/json
date: Tue, 13 Aug 2024 15:37:35 GMT
server: uvicorn
{
"message": "Event deleted successfully"
}
{
"detail": "Event with supplied ID does not exist"
}
44
ПОДКЛЮЧЕНИЕ БАЗЫ ДАННЫХ
postgresql+asyncpg://username:password@server:5432/dbname .
DATABASE_URL = "post-
gresql+asyncpg://username:[email protected]:5432/app_db"
engine = create_async_engine(DATABASE_URL, echo=True, future=True)
45
Добавим контекстный менеджер, который будет создавать асин-
хронную сессию для работы с базой данных.
class Base(DeclarativeBase):
pass
class Event(Base):
__tablename__ = "events"
class EventRequest(BaseModel):
title: str
date: datetime
description: str
tags: list[str]
location: str
class EventCreate(EventRequest):
pass
46
Добавим еще один класс Pydantic, который будет использоваться при
операциях обновления:
class EventUpdate(EventCreate):
title: str | None = None
date: datetime | None = None
description: str | None = None
tags: list[str] | None = None
location: str | None = None
import uvicorn
app = FastAPI()
app.include_router(user_router, prefix="/user")
app.include_router(event_router, prefix="/event")
@app.on_event("startup")
async def on_startup():
await init_db()
@app.get("/")
async def home():
return RedirectResponse(url="/event/")
if __name__ == '__main__':
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
47
В файле routes/events.py обновим импорт для включения классов
моделей событий, а также функции get_session():
@event_router.post("/new")
async def create_event(
new_event: EventCreate, session: AsyncSession = De-
pends(get_session)) -> dict:
event = Event(**new_event.model_dump()) # распаковки словаря
в аргументы конструктора
session.add(event)
await session.commit()
await session.refresh(event)
return {"message": "Event created successfully"}
@event_router.get("/", response_model=list[EventRequest])
async def retrieve_all_events(
session: AsyncSession = Depends(get_session),) -> list[Event]:
statement = select(Event)
result = await session.execute(statement)
events = result.scalars().all()
return list(events)
@event_router.get("/{id}", response_model=EventRequest)
async def retrieve_event(
id: int, session: AsyncSession = Depends(get_session)) -> Event |
dict:
event = await session.get(Event, id)
if event:
return event
48
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Event with supplied ID does not exist",
)
@event_router.patch("/edit/{id}", response_model=EventCreate)
async def update_event(id: int, new_data: EventUpdate, session:
AsyncSession = Depends(get_session)) -> Event | dict:
@event_router.delete("/delete/{id}")
async def delete_event(id: int, session: AsyncSession =
Depends(get_session)) -> dict:
event = await session.get(Event, id)
if event:
await session.delete(event)
await session.commit()
return {"message": "Event deleted successfully"}
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Event with supplied ID does not exist",
)
49
АУТЕНТИФИКАЦИЯ
Внедрение зависимостей
В FastAPI зависимость может быть определена как функция
или класс.
Созданная зависимость дает доступ к ее базовым атрибутам или
методам, устраняя необходимость создавать эти объекты в наследующих
их функциях.
50
Внедрение зависимости помогает сократить повторение кода в неко-
торых случаях, например, при аутентификации и авторизации:
@router.get("/user/me")
async get_user_details(user: User = Depends(get_user)):
return user
OAuth2 Flow
Будем использовать схему аутентификации OAuth2, которая требует,
чтобы клиент отправил имя пользователя и пароль в качестве данных фор-
мы. Имя пользователя в нашем случае – это адрес электронной почты,
используемый при создании учетной записи.
Когда данные формы отправляются клиентом на сервер, в качестве
ответа отправляется токен доступа (access token), который представляет
собой подписанный JWT-токен. Перед созданием токена доступа
для дальнейшей авторизации выполняется проверка подлинности отправ-
ленных на сервер учетных данных.
Для авторизации аутентифицированного пользователя в JWT добав-
ляется префикс Bearer при отправке данных в заголовке для авторизации
действия на сервере.
В каталоге auth создадим четыре файла:
jwt_handler.py: этот файл будет содержать функции, необходи-
мые для кодирования и декодирования JWT-токенов.
51
authenticate.py: этот файл будет содержать зависимость
authenticate, которая будет внедряться в маршруты для обеспечения
аутентификации и авторизации.
hash_password.py: этот файл будет содержать функции, которые
будут использоваться для хеширования пароля пользователя при регистра-
ции и сравнения паролей при входе в систему.
__init__.py: этот файл указывает на содержимое каталога как
модуля.
Хеширование паролей
Раньше мы хранили пароли пользователей в виде обычного текста.
Это крайне небезопасная и запрещенная практика при создании API.
Пароли должны быть зашифрованы или хешированы с помощью соответ-
ствующих библиотек.
Мы будем хешировать пароли пользователей с помощью алгоритма
bcrypt. Для этого установим пакет passlib:
class HashPassword:
def create_hash(self, password: str):
return pwd_context.hash(password)
class HashPassword:
52
def create_hash(self, password: str):
return pwd_context.hash(password)
class User(Base):
__tablename__ = "users"
class SignInUser(BaseModel):
email: EmailStr
password: str
class Config:
schema_extra = {
"example": {
"email": "[email protected]",
"password": "secret",
}
}
class SignUpUser(SignInUser):
pass
53
Обновим маршрут регистрации пользователей в routes/users.py,
добавив хеширование пароля:
hash_password = HashPassword()
@user_router.post("/signup")
async def sign_user_up(
data: SignUpUser, session: AsyncSession = Depends(get_session)) -
> dict:
statement = select(User).where(User.email == data.email)
result = await session.execute(statement)
user = result.scalar()
if user:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="User with supplied email exists",
)
user = User(**data.model_dump())
hashed_password = hash_password.create_hash(data.password)
user.password = hashed_password
session.add(user)
await session.commit()
await session.refresh(user)
54
Для работы с токеном используем пакет pyjwt:
# jwt_handler.py
import time
import jwt
55
expired!"
)
return data
except InvalidTokenError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid
token"
)
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/user/signin")
Обновление маршрутов
В файле routes/users.py добавим импорт:
56
Мы импортировали класс OAuth2PasswordRequestForm из модуля
fastapi.security FastAPI, который будет внедрен в маршрут входа для полу-
чения отправленных учетных данных.
Обновим функцию маршрута sign_user_in():
class TokenResponse(BaseModel):
access_token: str
token_type: str
@user_router.post("/signin", response_model=TokenResponse)
57
@event_router.post("/new")
async def create_event(
data: EventCreate,
user: str = Depends(authenticate),
session: AsyncSession = Depends(get_session),
) -> dict:
...
@event_router.patch("/edit/{id}", response_model=EventCreate)
async def update_event(
id: int,
data: EventUpdate,
user: str = Depends(authenticate),
session: AsyncSession = Depends(get_session),
) -> Event | dict:
...
@event_router.delete("/delete/{id}")
async def delete_event(
id: int,
user: str = Depends(authenticate),
session: AsyncSession = Depends(get_session),
) -> dict:
...
58
Протестируем маршруты:
http -v --form http://127.0.0.1:8000/user/signin
username="[email protected]" password="Str0ngPassw0rd"
username=user3%40server.com&password=Str0ngPassw0rd
HTTP/1.1 200 OK
content-length: 196
content-type: application/json
date: Mon, 19 Aug 2024 09:14:59 GMT
server: uvicorn
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer"
}
В SwaggerUI можно нажать кнопку Authorize, и отобразится модаль-
ное окно входа. Ввод учетных данных и пароля приведет к результату,
показанному на рис. 2.
59
Рис. 3. Создание события в SwaggerUI
HTTP/1.1 200 OK
content-length: 40
content-type: application/json
date: Mon, 19 Aug 2024 11:12:44 GMT
server: uvicorn
{
"message": "Event created successfully"
}
60
HTTP/1.1 401 Unauthorized
content-length: 30
content-type: application/json
date: Mon, 19 Aug 2024 11:15:02 GMT
server: uvicorn
www-authenticate: Bearer
{
"detail": "Not authenticated"
}
class Event(Base):
__tablename__ = "events"
61
Если создается новое событие, то оно сохраняется с адресом элек-
тронной почты создателя:
HTTP/1.1 200 OK
content-length: 40
content-type: application/json
date: Mon, 19 Aug 2024 18:46:53 GMT
server: uvicorn
{
"message": "Event created successfully"
}
62
Обновим маршрут DELETE:
async def delete_event(
id: int,
user: str = Depends(authenticate),
session: AsyncSession = Depends(get_session),) -> dict:
event = await session.get(Event, id)
if event:
if event.creator != user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Operation not allowed"
)
...
Проверка:
http DELETE http://127.0.0.1:8000/event/delete/1 -A bearer -
a eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
CORS
63
Совместное использование ресурсов разными источниками (Cross-
Origin Resource Sharing, CORS) предполагает связь между другими доверен-
ными серверами и вашим сайтом. Если на сайте весь код фронтенда и бэкен-
да находится в одном месте, то проблем не возникнет. Но в наши дни часто
встречается ситуация, когда фронтенд на JavaScript общается с бэкендом,
написанным на чем-то вроде FastAPI. Эти серверы не будут иметь одинако-
вого происхождения, так как у них будут отличаться следующие параметры:
протокол – HTTP или HTTPS;
домен – интернет-домен, например yandex.ru или 127.0.0.1;
порт – числовой TCP/IP-порт в этом домене, например 80, 443
или 8000.
Как бэкенд может отличить надежный фронтенд от приложения
злоумышленника? Это задача CORS–технологии, определяющей, чему
доверяет бэкенд.
Наиболее известными способами ограничить достук к бэкенду явля-
ются:
заголовки запросов Origin;
HTTP-методы;
HTTP-заголовки;
тайм-аут кэша CORS.
В примере показано, как разрешить только один фронтенд-сервер
(с доменом https://site.com), а также любые HTTP-заголовки и методы:
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["http://site.com",],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
64
МИГРАЦИИ
65
Затем надо найти строку:
target_metadata = None
target_metadata = Event.metadata
target_metadata = User.metadata
config.set_main_option("sqlalchemy.url", en-
gine.url.render_as_string(hide_password=False))
import sys
sys.path.append('./src')
file_template = %%(year)d-%%(month).2d-%%(day).2d_%%(slug)s
66
В результате создания миграции в каталоге migrations появится файл
с кодом начальной миграции:
БИБЛИОТЕКА Pytest
67
рования unittest, библиотека Pytest имеет более простой синтаксис и более
предпочтительна для тестирования приложений.
Установка:
68
Создадим конфигурационный файл pytest.ini и добавим в него сле-
дующий код:
[pytest]
asyncio_mode = auto
pythonpath = src
addopts = -p no:warnings
import asyncio
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
DATABASE_URL = "post-
gresql+asyncpg://username:[email protected]:5432/test_db"
@asynccontextmanager
async def override_get_session():
async with AsyncSession(engine) as session:
yield session
69
Создадим функцию для инициализации базы данных:
@pytest_asyncio.fixture(scope="session", autouse=True)
async def initialize_database():
await init_db()
yield
@pytest_asyncio.fixture(scope="function")
def event_loop():
policy = asyncio.get_event_loop_policy()
loop = policy.new_event_loop()
yield loop
loop.close()
@pytest_asyncio.fixture(scope="function")
async def test_session():
session_maker.configure(bind=engine)
async with session_maker() as session:
try:
yield session
finally:
await session.close()
70
Добавим фикстуру для создания клиента HTTPX:
@pytest_asyncio.fixture(scope="function")
async def client(test_session):
app.dependency_overrides[get_session] = lambda: test_session
async with AsyncClient(
transport=ASGITransport(app), base_url="http://localhost"
) as client:
yield client
app.dependency_overrides.clear()
import pytest
import httpx
payload = {
"email": "[email protected]",
"password": "testpassword",
}
71
Теперь инициируем сам запрос:
Запустим тест:
pytest tests/test_login.py
============================ test session starts
============================
...
collected 1 item
tests/test_login.py .
[100%]
@pytest.mark.asyncio
async def test_sign_user_in(client: httpx.AsyncClient) -> None:
headers = {
"accept": "application/json",
"Content-Type": "application/x-www-form-urlencoded",
}
72
Повторим запуск теста:
pytest tests/test_login.py
============================ test session starts ============================
...
collected 2 items
tests/test_login.py .. [100%]
import httpx
import pytest
from auth.jwt_handler import create_access_token
from sqlalchemy.ext.asyncio import AsyncSession
from models.events import Event
from typing import AsyncGenerator
from datetime import datetime
@pytest.fixture
async def access_token() -> str:
return create_access_token("[email protected]")
@pytest.fixture
async def mock_event(test_session: AsyncSession) -> AsyncGenera-
tor[Event, None]:
new_event = Event(
creator="[email protected]",
title="Event title",
date=datetime(2024, 8, 28, 14, 38, 4),
description="Event description",
tags=["tag1", "tag2"],
location="Event location",
73
)
test_session.add(new_event)
await test_session.commit()
await test_session.refresh(new_event)
yield new_event
await test_session.rollback()
@pytest.mark.asyncio
async def test_get_events(
client: httpx.AsyncClient, mock_event: Event, access_token: str
) -> None:
headers = {"Authorization": f"Bearer {access_token}"}
response = await client.get("/event/", headers=headers)
assert response.status_code == 200
assert response.json()[0]["title"] == mock_event.title
@pytest.mark.asyncio
async def test_get_event(
client: httpx.AsyncClient, mock_event: Event, access_token: str
) -> None:
headers = {"Authorization": f"Bearer {access_token}"}
response = await client.get("/event/1", headers=headers)
assert response.status_code == 200
assert response.json()["title"] == mock_event.title
@pytest.mark.asyncio
async def test_post_event(client: httpx.AsyncClient, access_token:
str) -> None:
payload = {
"title": "Event title",
"date": "2024-08-28T14:38:04",
"description": "Event description",
"tags": ["tag1", "tag2"],
"location": "Event location",
74
}
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {access_token}",
}
test_response = {"message": "Event created successfully"}
response = await client.post("/event/new", json=payload,
headers=headers)
assert response.status_code == 200
assert response.json() == test_response
@pytest.mark.asyncio
async def test_update_event(
client: httpx.AsyncClient, mock_event: Event, access_token: str
) -> None:
test_payload = {"title": "Updated event title"}
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {access_token}",
}
response = await client.patch("/event/edit/1", json=test_payload,
headers=headers)
assert response.status_code == 200
assert response.json()["title"] == test_payload["title"]
@pytest.mark.asyncio
async def test_delete_event(
client: httpx.AsyncClient, mock_event: Event, access_token: str
) -> None:
test_response = {"message": "Event deleted successfully"}
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {access_token}",
}
response = await client.delete("/event/delete/1",
headers=headers)
assert response.status_code == 200
assert response.json() == test_response
75
Чтобы убедиться, что событие действительно было удалено, добавим
последний тест:
@pytest.mark.asyncio
async def test_get_event_again(
client: httpx.AsyncClient, mock_event: Event, access_token: str
) -> None:
headers = {"Authorization": f"Bearer {access_token}"}
response = await client.get("/event/1", headers=headers)
assert response.status_code == 404
pytest
tests/test_login.py .. [ 25%]
tests/test_routes.py ...... [100%]
76
ЗАКЛЮЧЕНИЕ
77
ОГЛАВЛЕНИЕ
ВВЕДЕНИЕ ………………………………………………………………. 3
HTTP-ответы ………………………………………………………….. 17
Заголовки ……………………………………………………………… 18
АСИНХРОННОСТЬ ……………………………………………………... 19
Датаклассы ……………………………………………………………. 24
78
ЗАВИСИМОСТИ В FastAPI ………………………………………….. 31
ШАБЛОНИЗАЦИЯ ……………………………………………………… 34
Фильтры ………………………………………………………………. 34
Макросы ………………………………………………………………. 36
АУТЕНТИФИКАЦИЯ …………………………………………………… 50
CORS ……………………………………………………………………… 63
МИГРАЦИИ ……………………………………………………………… 65
ЗАКЛЮЧЕНИЕ …………………………………………………………... 77
79
Учебное электронное издание
Учебное пособие
Редактирование Е. С. М о р д а с о в о й
Графический и мультимедийный дизайнер Т. Ю. З о т о в а
Обложка, упаковка, тиражирование Т . Ю . З о т о в о й
80