Tinkoff Python
Лекция 4
Проектирование API. REST
Афонасьев Евгений
Серверный рендеринг
![](https://s3.amazonaws.com/media-p.slid.es/uploads/995652/images/5946021/pasted-from-clipboard.png)
Редко используется в крупных проектах
Обычно только во внутренних системах, например в административных панелях
![](https://s3.amazonaws.com/media-p.slid.es/uploads/995652/images/5945997/pasted-from-clipboard.png)
Почему?
- Интерфейс требует перезагрузки страницы и запросов на сервер на каждое действие
- ограниченность в разработке интерактивных интерфейсов
- нельзя написать нормальное мобильное приложение
- неудобно работать с ajax
Чтобы работать одновременно и с мобильными приложениями и с браузером, сервер должен оперировать данными, а не представлением
![](https://s3.amazonaws.com/media-p.slid.es/uploads/995652/images/5946010/pasted-from-clipboard.png)
Эндпоинты API
- GET /api/v1/get_user
- GET /api/v1/get_user_list
- POST /api/v1/create_user
Грамотно написанное API позволяет использовать себя для разных платформ
Фронты разные, бэк один
![](https://s3.amazonaws.com/media-p.slid.es/uploads/995652/images/5946005/pasted-from-clipboard.png)
Server side rendering обрел второе дыхание на фронте
Форматы передачи данных
![](https://s3.amazonaws.com/media-p.slid.es/uploads/995652/images/5946013/pasted-from-clipboard.png)
Content-type header
https://developer.mozilla.org/ru/docs/Web/HTTP/Заголовки/Content-Type
Accept header
https://developer.mozilla.org/ru/docs/Web/HTTP/Заголовки/Accept
Text
![](https://s3.amazonaws.com/media-p.slid.es/uploads/995652/images/5945966/pasted-from-clipboard.png)
Text
@app.route('/user/<user_id>/name')
def user_name(user_id):
return 'name'
@app.route('/user/<user_id>/desc')
def user_desc(user_id):
return 'long text about user...'
csv
@app.route('/users')
def users():
return (
'user_id;name;surname;money\n'
'user1;igor;volkov;13;2000\n'
'user2;vanya;batkov;83;8000\n'
)
xml
@app.route('/data')
def data():
return """
<note>
<to>Tove</to>
<from>Jani</from>
<heading>Reminder</heading>
<body>Don't forget me this weekend!</body>
</note>
"""
xml
Когда-то был самым распространенным форматом
![](https://s3.amazonaws.com/media-p.slid.es/uploads/995652/images/5945889/pasted-from-clipboard.png)
json
![](https://s3.amazonaws.com/media-p.slid.es/uploads/995652/images/5945959/pasted-from-clipboard.png)
json
import json
@app.route('/data')
def data():
return json.dumps({'id': 1, 'name': 'name'})
Обычно работа с json встроена во фрэймворки
json
from flask import jsonify
@app.route('/_get_current_user')
def get_current_user():
return jsonify(
username=g.user.username,
email=g.user.email,
id=g.user.id,
)
json
- Современный стандарт
- Человеко-читаемые данные
- Компактнее чем xml
- Будет достаточен в 99% случаев
ujson etc.
import ujson
@app.route('/data')
def data():
return ujson.dumps({'id': 1, 'name': 'name'})
Работает быстрее встроенного json модуля
![](https://s3.amazonaws.com/media-p.slid.es/uploads/995652/images/5945858/pasted-from-clipboard.png)
ujson
try:
import ujson as json
except ImportError:
import json
Часто является опциональной зависимостью для библиотек (но его поведение не на 100% совпадает со стандартным модулем!)
Бинарные форматы
![](https://s3.amazonaws.com/media-p.slid.es/uploads/995652/images/5945866/pasted-from-clipboard.png)
msgpack
![](https://s3.amazonaws.com/media-p.slid.es/uploads/995652/images/5945766/pasted-from-clipboard.png)
msgpack
import msgpack
@app.route('/data')
def data():
user = {'id': 1, 'name': 'name'}
return msgpack.packb(user, use_bin_type=True)
Бинарные форматы
+ Объем передаваемых данных значительно меньше
- Больше нагрузка на CPU (обычно не критично)
- Все клиенты должны уметь распаковывать данные из используемого формата
- Нельзя понять, что лежит в сообщении, без декодирования
Бинарные форматы со схемами
![](https://s3.amazonaws.com/media-p.slid.es/uploads/995652/images/5945882/pasted-from-clipboard.png)
Protobuf
message Car {
required string model = 1;
enum BodyType {
sedan = 0;
hatchback = 1;
SUV = 2;
}
required BodyType type = 2 [default = sedan];
optional string color = 3;
required int32 year = 4;
message Owner {
required string name = 1;
required string lastName = 2;
required int64 driverLicense = 3;
}
repeated Owner previousOwner = 5;
}
![](https://s3.amazonaws.com/media-p.slid.es/uploads/995652/images/5945810/pasted-from-clipboard.png)
Бинарные форматы со схемами
+ Объем передаваемых данных значительно меньше
+ Встроенная валидация данных по схеме
+ Обычно позволяет изменять схему с сохранением обратной совместимости (версионирование)
Бинарные форматы со схемами
- Больше нагрузка на CPU
- Нельзя понять, что лежит в сообщении, без декодирования
- Нужно передавать схемы всем системам, которые должны будут работать с данными!
Шаблоны построения API
![](https://s3.amazonaws.com/media-p.slid.es/uploads/995652/images/5946156/pasted-from-clipboard.png)
Велосипеды
![](https://s3.amazonaws.com/media-p.slid.es/uploads/995652/images/5946174/pasted-from-clipboard.png)
Велосипеды
@app.route('/get_users')
def get_users():
return json.dumps([
{'id': 1, 'name': 'name'},
{'id': 2, 'name': 'name'},
])
@app.route('/user/<user_id>')
def get_user(user_id):
user = db.get_user(user_id)
return json.dumps(user)
@app.route('/create_user')
def create_user(user_id):
user = db.create_user(request.data)
return 'ok'
Плюсы/минусы
+ Легко начать
- Сложно объяснить пользователям как использовать апи
- Нет готовых стандартных инструментов
![](https://s3.amazonaws.com/media-p.slid.es/uploads/995652/images/5946182/pasted-from-clipboard.png)
Все запросы попадают на один http эндпоинт
POST /api/rpc
Оперирует методами
User.create_user
User.add_friend
Можно написать что-то свое
@app.route('/api', methods=['POST'])
def handle_request():
content = request.json
if content['method'] == 'User.add_friend':
User.add_friend(**content['kwargs'])
if content['method'] == 'User.get_friends':
friends = User.get_friends(**content['kwargs'])
return friends, 200
return 200
![](https://s3.amazonaws.com/media-p.slid.es/uploads/995652/images/5946977/pasted-from-clipboard.png)
XML-RPC - устарел
![](https://s3.amazonaws.com/media-p.slid.es/uploads/995652/images/5945889/pasted-from-clipboard.png)
JSON RPC
# normal request
--> {
"jsonrpc": "2.0",
"method": "subtract",
"params": {"subtrahend": 23},
"id": 3
}
<-- {
"jsonrpc": "2.0",
"result": 19,
"id": 3
}
# error
--> []
<-- {
"jsonrpc": "2.0",
"error": {"code": -32, "message": "Invalid Request"},
"id": null
}
JSON RPC
from flask import Flask, request, Response
from jsonrpcserver import method, dispatch
app = Flask(__name__)
@method
def ping():
return "pong"
@app.route("/", methods=["POST"])
def index():
req = request.get_data().decode()
response = dispatch(req)
return Response(
str(response),
response.http_status,
mimetype="application/json",
)
if __name__ == "__main__":
app.run()
Какие методы мы бы написали для todo приложения?
UserBucket.get_items(user_id)
UserBucket.add_item(user_id, item_id)
UserBucket.buy(user_id)
Item.get_info(item_id)
Плюсы
Легко проектировать api, методы из кода напрямую ложатся на методы RPC
Плюсы
Можно использовать в качестве транспорта не только http (ws) без больших изменений в коде
Плюсы
Хорошо подходит для внутренних api внутри одной системы для взаимодействия между сервисами
Минусы
Описывать и документировать API все еще сложно, нужно пояснять смысл методов и придумывать им понятные названия
gRPC
![](https://s3.amazonaws.com/media-p.slid.es/uploads/995652/images/5946391/pasted-from-clipboard.png)
gRPC
Экосистема - свои сервера, свои клиенты, свой формат сериализации.
Кодогенерация сервера и клиента.
![](https://s3.amazonaws.com/media-p.slid.es/uploads/995652/images/5946352/pasted-from-clipboard.png)
Representational State Transfer
Оперирует ресурсами
users, pages, operations, actions
Ресурсом может быть как одна сущность, так и список сущностей
/users
/users?older=30
/users/<user_id>
Каждый ресурс имеет свой уникальный адрес
/users/<user_id>/friends/<friend_id>
Операции кодируются HTTP методами
GET, POST, DELETE (PUT, PATCH)
Ошибки задаются http статусами
400, 401, 403 etc.
GET
GET /users - список пользователей
GET /users/<user_id> - конкретный пользователь
GET /users?page=5 - страница списка пользователей
GET
Безопасный метод, никогда не должен приводить к изменениям данных на бэке!
POST
POST /users {} - создание пользователя
POST /users/<user_id>/friends {} - добавление друга
POST
Каждый вызов создает новую сущность!
POST
Ожидает код 201
DELETE
DELETE /users/<user_id> - удаление пользователя
DELETE
Ожидает код 204 или 404
PUT
PUT /users/<user_id> {} - перетирание данных новыми
PUT
Нужно передать все поля сущности!
PATCH
PUT /users/<user_id> {} - обновление данных
PATCH
Можно передать только часть данных
@app.route('/api', methods=['POST'])
def handle_request():
content = request.json
if content['method'] == 'User.add_friend':
User.add_friend(**content['kwargs'])
if content['method'] == 'User.get_friends':
return User.get_friends(**content['kwargs'])
return 200
Добавление друга на RPC
@app.route(
'/api/users/<user_id>/friends',
methods=['GET', 'POST']
)
def handle_request(user_id):
if request.method == 'POST':
User.add_friend(user_id, friend=request.json)
return friend, 201 # Created
if request.method == 'GET':
friends = User.get_friends(user_id)
return friends, 200 # Ok
return 405 # Method not allowed
Добавление друга на REST
Какими ресурсами мы бы оперировали в todo приложении?
GET /users/<user_id>/tasks
POST /users/<user_id>/tasks
DELETE /users/<user_id>/tasks/<task_id>
GET /users/<user_id>/tasks/<task_id>
PUT /users/<user_id>/tasks/<task_id>
Retry
Важно понимать какие запросы можно повторять при ошибках!
Retry
Повторять можно 5** коды и ошибки соединения
Идемпотентность
Свойство объекта или операции при повторном применении операции к объекту давать тот же результат, что и при первом.
Идемпотентность
В контексте веб обычно подразумевается, что на один и тот же запрос будет одно и то же действие на сервере, и такой запрос можно безопасно повторять
POST не идемпотентен!!!
Идемпотентность
Идемпотетности можно достигнуть, добавив ко всем запросам ключ идемпотентности с дальнейшем валидацией на стороне сервера
Stateless
Это протокол передачи данных, который относит каждый запрос к независимой транзакции, которая не связана с предыдущим запросом
Stateless
Такой подход позволяет строить хорошо масштабируемые/надежные системы
Плюсы
Удобно документировать апи, методы и ошибки HTTP достаточно понятны и очевидны.
Плюсы
Хорошо подходит для публичных API для сторонних разработчиков
Плюсы
Хорошо поддается дополнительной обработке на промежуточных узлах и кэшированию
Минусы
Сложнее проектировать api, нужно придумывать как описать свою систему в виде набора ресурсов
Минусы
Нет единого стандарта (как передавать ошибки, в каком формате передавать данные)
Перерыв?
Pagination
Limit, offset
GET /users?limit=100&offset=200
GET /users?size=100&page=200
OFFSET на больших значениях в SQL СУБД медленный!
Keyset
GET /users?id=20&limit=200
GET /users?ts=1635346346&page=200
Данные должны быть отсортированы по выбранному ключу
Формат ответа
{
"page": 10,
"count": 1000,
"result": [
{
"user_id": 1
},
...
]
}
Формат ответа
[
{
"user_id": 1
},
...
]
# служебные данные в http headers
![](https://s3.amazonaws.com/media-p.slid.es/uploads/995652/images/7126049/pasted-from-clipboard.png)
OpenAPI
![](https://s3.amazonaws.com/media-p.slid.es/uploads/995652/images/5946826/pasted-from-clipboard.png)
http://editor.swagger.io/
![](https://s3.amazonaws.com/media-p.slid.es/uploads/995652/images/7131444/pasted-from-clipboard.png)
Можно генерировать спеку из кода (flask-rest-plus)
Можно часть кода вынести в спеку (conexion)
Можно генерировать клиентские библиотеки по спеке
Аутентификация
![](https://s3.amazonaws.com/media-p.slid.es/uploads/995652/images/5946477/pasted-from-clipboard.png)
только HTTPS!
![](https://s3.amazonaws.com/media-p.slid.es/uploads/995652/images/5946914/pasted-from-clipboard.png)
Аутентификация через заголовки Basic Auth
![](https://s3.amazonaws.com/media-p.slid.es/uploads/995652/images/5946470/pasted-from-clipboard.png)
Проблемы
- Нужно везде таскать логин пароль в открытом виде (BASE64)
- Нужно хранить логин/пароль на стороне клиента
- Смена пароля потребует повторной аутентификации во всех системах
Mypy
http://mypy-lang.org/
Инструмент статического анализа типов в python коде
![](https://s3.amazonaws.com/media-p.slid.es/uploads/995652/images/7131471/pasted-from-clipboard.png)
Ловит ошибки, даже если аннотаций нет
Стандартная библиотека и многие сторонние уже аннотированы
Type hints
def greeting(name: str) -> str:
return 'Hello ' + name
Type hints
user: User = get_user(...)
Type hints
class Container:
users: List[User]
...
Python не как не проверяет!
In [1]: def x(i: int) -> int:
...: pass
...:
In [2]: x.__annotations__
Out[2]: {'i': int, 'return': int}
Документация
Автодополнение
![](https://s3.amazonaws.com/media-p.slid.es/uploads/995652/images/7131514/pasted-from-clipboard.png)
int, str, bytes, float, bool
Union (один из)
Union[int, str, float]
Optional (или None)
from typing import Optional
def greeting(name: str) -> Optional[str]:
if name:
return 'Hello ' + name
else:
return None
List[int]
Dict[str, int]
NoReturn
from typing import NoReturn
def inf_loop() -> NoReturn:
while True:
pass
user = cast(User, user)
Aliases
from typing import List, Union
Vector = List[Union[int, float]]
def scale(scalar: float, vector: Vector) -> Vector:
return [scalar * num for num in vector]
Callable
from typing import Callable
def feeder(get_next_item: Callable[[], str]) -> None:
# Body
Generic
from typing import Sequence, TypeVar
# Declare type variable
T = TypeVar('T')
# Generic function
def first(l: Sequence[T]) -> T:
return l[0]
from typing import Any
Type
class User: ...
class BasicUser(User): ...
class ProUser(User): ...
class TeamUser(User): ...
# Accepts User, BasicUser, ProUser, TeamUser, ...
def make_new_user(user_class: Type[User]) -> User:
# ...
return user_class()
Generator
# Generator[YieldType, SendType, ReturnType]
def echo_round() -> Generator[int, float, str]:
sent = yield 0
while sent >= 0:
sent = yield round(sent)
return 'Done'
def infinite_stream(
start: int
) -> Generator[int, None, None]:
while True:
yield start
start += 1
Generator
Iterator
def infinite_stream(
start: int
) -> Iterator[int]:
while True:
yield start
start += 1
NamedTuple
class Employee(NamedTuple):
name: str
id: int = 3
employee = Employee('Guido')
assert employee.id == 3
TYPE_CHECKING
if TYPE_CHECKING:
import expensive_mod
def fun(arg: 'expensive_mod.SomeType') -> None:
pass
Валидация
Никогда нельзя доверять входным данным!
Pydantic
https://pydantic-docs.helpmanual.io
from datetime import datetime
from typing import List
from pydantic import BaseModel
class User(BaseModel):
id: int
name: str
signup_ts: datetime
friends: List[int]
Pydantic
external_data = {
'id': '123',
'signup_ts': '2019-06-01 12:22',
'friends': [1, 2, '3']
}
user = User(**external_data)
print(user.id)
print(repr(user.signup_ts))
print(user.friends)
print(user.dict())
Pydantic
from pydantic import ValidationError
try:
User(
signup_ts='broken',
friends=[1, 2, 'not number'],
)
except ValidationError as e:
print(e.json())
class Friend(BaseModel):
id: int
name: str
class User(BaseModel):
id: int
name: str
signup_ts: datetime
friends: List[Friend]
Модели можно вкладывать друг в друга
User.parse_raw(json_str)
User(...).json()
Зачем?
Простой, читаемый код
Автодополнение
Домашняя работа
Сервис кино отзывов
Вопросы?
Tinkoff Python 2020 - 4
By Afonasev Evgeniy
Tinkoff Python 2020 - 4
- 573