Tinkoff Python

Episode 7

Разработка API

WIFI

65539-03378

В предыдущей серии...

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

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

@app.route('/')
def index():
    return render_template('index.html', tasks=tasks)

@app.route('/create', methods=['POST'])
def create():
    description = request.form['description']
    create_task(description)
    return redirect(url_for('index'))

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

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

Почему?

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

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

API - application programming interface

HTTP API состоит из эндпоинтов

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

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

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

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

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

  • Современный стандарт
  • Человеко-читаемые данные
  • Удобно работать из js
  • Компактнее чем xml
  • Будет достаточен в 99% случаев

ujson

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 

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

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'

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

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

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

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

RPC

  • Все запросы идут на один эндпоинт
  • Оперирует объектами и методами
  • Использует http только как транспорт (только post запросы, только 200 code status в ответах)

Можно написать что-то свое

@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()

Какие методы мы бы написали для корзины покупок?

Корзина покупок

  • User.add_item(user_id, item_id) - все товары в корзине
  • User.add_item(user_id, item_id) - добавить товар
  • Item.get_info(item_id) - информация о конкретном товаре
  • User.del_item(user_id, item_id) - удалить товар
  • User.buy() - купить все, что в корзине

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

+ Легко проектировать api, методы из кода напрямую ложатся на методы RPC

+ Можно использовать в качестве транспорта не только http (ws) без больших изменений в коде

+ Хорошо подходит для внутренних api внутри одной системы для взаимодействия между сервисами

- Описывать и документировать API все еще сложно, нужно пояснять смысл методов и придумывать им понятные названия

gRPC

gRPC

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

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

Representational State Transfer 

REST

  • API оперирует ресурсами
  • Каждый ресурс имеет свой уникальный url
  • Операции кодируются HTTP методами (get, post, put, delete, etc.)
  • Ошибки отдаются с использованием HTTP status codes (400, etc.), а подробное описание ошибки в теле ответа в json
  • Имя ресурса во множественно числе (/users)
  • GET /users - вернет список всех объектов 
  • POST /users с json в теле запроса - создаст новый объект
  • GET /users/<user_id> - вернет объект
  • DELETE /users/<user_id> - удалит объект
  • PUT /users/<user_id> с json в теле запроса - обновит объект переданными данными (в теле запроса должны быть все поля!)
@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 400  # Bad Request

Добавление друга на REST

Какие методы мы бы написали для корзины покупок?

Корзина покупок

  • GET /users/1/bucket/items - все товары в корзине
  • POST /users/1/bucket/items - добавить товар
  • GET /users/1/bucket/items/<item_id> - информация о конкретном товаре
  • DELETE /users/1/bucket/items/<item_id> - удалить товар
  • POST /users/1/purchase создать покупку

Идемпотентность

В контексте веб обычно подразумевается, что на один и тот же запрос будет одно и то же действие на сервере и такой запрос можно безопасно повторять, ответ в некоторых случаях может отличаться (DELETE 204 or 404).

Такие методы как GET PUT DELETE считаются идемпотентными, а POST нет!

stateless

Все свое нашу с собой! В запросе должны быть все данные для его осуществления. Сервер не хранит состояние конкретного клиента на своей стороне и результат каждого запроса не зависит от предыдущих.

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

+ Удобно документировать апи, методы и ошибки HTTP достаточно понятны и очевидны.

+ Хорошо подходит для публичных API для сторонних разработчиков

+ Хорошо поддается дополнительной обработке на промежуточных узлах и кэшированию

- Сложнее проектировать api, нужно придумывать как описать свою систему в виде набора ресурсов

- Нет единого стандарта (как передавать ошибки, в каком формате передавать данные)

- Жестко привязан к HTTP протоколу

Перерыв?

swagger

Может быть автоматически сгенерирован!

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

только HTTPS!

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

Проблемы

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

Аутентификация с помощью токена

  • Передаем в заголовках только токен доступа.
  • Можно отозвать конкретный токен в случае, если он скомпрометирован.
  • Можно указать время жизни отдельного токена, после которого придется получать новый.
  • Токен выдается при аутентификации по логину и паролю или генерируется по запросу авторизованного пользователя

Аутентификация третьей стороной

Если у нас есть множество сервисов проверять авторизацию на каждом из них накладно, поэтому можно каждый запрос верифицировать через отдельный auth сервер.

Сделать это можно как на уровне каждого сервиса, так и на уровне балансера (nginx)

OAuth2

Проблема токена в том, что нужно где-то хранить его привязку к конкретному пользователю (например чтобы получить user_id)

Важно! Данные не зашифрованы и могут быть прочитаны на клиенте!

>>> import jwt
>>> encoded = jwt.encode({'some': 'payload'}, 'secret', algorithm='HS256')
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.'
'eyJzb21lIjoicGF5bG9hZCJ9.'
'4twFt5NiznN84AWoo1d7KO1T_yoc0Z6XOpOVswacPZg'

>>> jwt.decode(encoded, 'secret', algorithms=['HS256'])
{'some': 'payload'}

JWT Example

Преимущества

  • Можно хранить полезную информацию
  • Можно не проверять аутентификацию, пока токен не устареет
  • Нельзя подделать

Не храним в payload`е важные приватные данные, они могут быть легко прочитаны клиентом!

Чтобы не вводить логин/пароль каждый раз, когда токен устареет, используется схема с двумя токенами:

access - на доступ, передается с каждым запросом

refresh - на получение новой пары токенов, когда старый access токен устареет

refresh токены нужно хранить на сервере, чтобы иметь возможность отозвать (для разных устройств свои)

Что? Где? Когда?

Участники вы, преподаватели и стажеры :)
Дата: 31 марта в 15:00 (продолжительность 2-3 часа);
Место: https://vk.com/bub_ekat
Обещаем, что точно будет весело и интересно!

Вопросы?

Tinkoff Python 7

By Afonasev Evgeniy

Tinkoff Python 7

  • 692