Tinkoff Python

Лекция 4

Проектирование API. REST

Афонасьев Евгений

Серверный рендеринг

Редко используется в крупных проектах

Обычно только во внутренних системах, например в административных панелях

Почему?

  • Интерфейс требует перезагрузки страницы и запросов на сервер на каждое действие
  • ограниченность в разработке интерактивных интерфейсов
  • нельзя написать нормальное мобильное приложение
  • неудобно работать с ajax

Чтобы работать одновременно и с мобильными приложениями и с браузером, сервер должен оперировать данными, а не представлением

Эндпоинты API

  • GET /api/v1/get_user
  • GET /api/v1/get_user_list
  • POST /api/v1/create_user

Грамотно написанное API позволяет использовать себя для разных платформ

Фронты разные, бэк один

Server side rendering обрел второе дыхание на фронте

Форматы передачи данных

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

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

Когда-то был самым распространенным форматом

json

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 модуля

ujson

try:
    import ujson as json
except ImportError:
    import json

Часто является опциональной зависимостью для библиотек (но его поведение не на 100% совпадает со стандартным модулем!)

Бинарные форматы

msgpack

msgpack

import msgpack

@app.route('/data')
def data():
    user = {'id': 1, 'name': 'name'}
    return msgpack.packb(user, use_bin_type=True)

Бинарные форматы

+ Объем передаваемых данных значительно меньше

- Больше нагрузка на CPU (обычно не критично)

- Все клиенты должны уметь распаковывать данные из используемого формата

- Нельзя понять, что лежит в сообщении, без декодирования

Зачастую можно добиться схожих результатов с помощью gzip (brotli)

Бинарные форматы со схемами

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;
}

Бинарные форматы со схемами

+ Объем передаваемых данных значительно меньше

+ Встроенная валидация данных по схеме

+ Обычно позволяет изменять схему с сохранением обратной совместимости (версионирование)

Бинарные форматы со схемами

- Больше нагрузка на CPU

- Нельзя понять, что лежит в сообщении, без декодирования

- Нужно передавать схемы всем системам, которые должны будут работать с данными!

Шаблоны построения API

Велосипеды

Велосипеды

@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'

Плюсы/минусы

+ Легко начать

- Сложно объяснить пользователям как использовать апи

- Нет готовых стандартных инструментов

Все запросы попадают на один 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

XML-RPC - устарел

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

gRPC

Экосистема - свои сервера, свои клиенты, свой формат сериализации.

Кодогенерация сервера и клиента.

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

OpenAPI

http://editor.swagger.io/

Можно генерировать спеку из кода (flask-rest-plus)

Можно часть кода вынести в спеку (conexion)

Можно генерировать клиентские библиотеки по спеке

Аутентификация

только HTTPS!

Аутентификация через заголовки Basic Auth

Проблемы

  • Нужно везде таскать логин пароль в открытом виде (BASE64)
  • Нужно хранить логин/пароль на стороне клиента
  • Смена пароля потребует повторной аутентификации во всех системах

Mypy

http://mypy-lang.org/

Инструмент статического анализа типов в python коде

Ловит ошибки, даже если аннотаций нет

Стандартная библиотека и многие сторонние уже аннотированы

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}

Документация

Автодополнение

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