DevGang
Авторизоваться

Типы для HTTP API-интерфейсов Python: история из Instagram 

Этот пост о том, как мы используем типы для документирования и обеспечения исполнения контракта для наших API-интерфейсов Python HTTP. В ближайшие несколько недель мы поделимся подробностями о дополнительных инструментах и ​​методах, которые мы разработали для управления качеством нашей кодовой базы.

Background

Когда вы открываете приложение Instagram на своем мобильном клиенте, оно отправляет запросы на наш сервер Python (Django) через JSON, HTTP API.

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

  • более 2000 конечных точек на сервере
  • более 200 полей верхнего уровня в объекте данных клиента, который представляет это изображение, видео или историю в приложении
  • Сотни инженеров, пишущих код для сервера (и даже больше на клиенте!)
  • Сотни коммитов на сервер каждый день, которые могут изменять API для поддержки новых функций

Мы используем типы для документирования и реализации контракта для нашего сложного, развивающегося HTTP API.

Типы

Давайте начнем с самого начала. PEP 484 ввел синтаксис для добавления аннотаций типов в код Python. Но зачем вообще добавлять аннотации типов?

Рассмотрим функцию, которая извлекает персонажа из звездных войн:

def get_character(id, calendar):
   if id == 1000:
       return Character(
           id=1000,
           name="Luke Skywalker",
           birth_year="19BBY" if calendar == Calendar.BBY else ...
       )
   ...

Чтобы понять функцию get_character, вы должны прочитать ее тело.

  • принимает идентификатор целочисленного символа
  • принимает перечисление calendar
  • возвращает символ с полями id, name и год рождения

Функция имеет неявный контракт, который вы должны восстанавливать каждый раз, когда читаете код. Но код пишется один раз и читается много раз, так что это не работает хорошо.

Кроме того, трудно проверить, что вызывающие функции и само тело функции придерживаются неявного контракта. В большой кодовой базе это может привести к ошибкам.

Рассмотрим вместо этого функцию с аннотациями типов:

def get_character(id: int, calendar: Calendar) -> Character:
   ...

С аннотациями типов существует явный договор. Вам нужно только прочитать сигнатуру функции, чтобы понять ее ввод и вывод. Проверщик типов может статически проверять соответствие кода контракту, устраняя целый класс ошибок!

Типы для HTTP API

Давайте разработаем HTTP API для извлечения символа звездных войн и используем аннотации типов для определения явного контракта для него.

HTTP API должен принимать идентификатор символа в качестве параметра url, а систему календаря - в качестве параметра запроса. Должен возвращать JSON-ответ для персонажа.

curl -X GET https://api.starwars.com/characters/1000?calendar=BBY
{
    "id": 1000,
    "name": "Luke Skywalker",
    "birth_year": "19BBY"
}

Чтобы реализовать этот API в Django, вы сначала регистрируете путь URL-адреса и функцию просмотра, отвечающую за передачу HTTP-запроса на этот путь URL-адреса и возврат ответа.

urlpatterns = [
   url("characters//", get_character)
]

Функция view принимает запрос и параметры url (в данном случае id) в качестве входных данных. Функция анализирует и преобразует параметр запроса календаря, выбирает символ из хранилища и возвращает словарь, который сериализован как JSON и включен в HTTP-ответ.

def get_character(request: IGWSGIRequest, id: str) -> JsonResponse:
    calendar = Calendar(request.GET.get("calendar", "BBY"))
    character = Store.get_character(id, calendar)
    return JsonResponse(asdict(character))

Хотя функция представления имеет аннотации типов, она не определяет строгий, явный контракт для HTTP API. Из подписи мы не знаем имен или типов параметров запроса, полей в ответе или их типов.

Вместо этого, что если бы мы могли сделать сигнатуру функции представления точно такой же, как и в предыдущей аннотированной функции?

def get_character(id: int, calendar: Calendar) -> Character:
   ...

Параметры функции могут представлять параметры запроса (параметры url, query или body). Тип возвращаемого значения функции может представлять содержание ответа. Тогда у нас будет явный, простой для понимания контракт для HTTP API, который может обеспечить проверка типов.

Реализация

Итак, как мы можем реализовать эту идею?

Давайте используем декоратор для преобразования строго типизированной функции просмотра в функцию просмотра Django. Этот дизайн не требует никаких изменений в структуре Django. Мы можем использовать ту же самую маршрутизацию, промежуточное программное обеспечение и другие компоненты, с которыми мы знакомы.

@api_view
def get_character(id: int, calendar: Calendar) -> Character:
   ...

Давайте погрузимся в реализацию декоратора api_view:

def api_view(view):
   @functools.wraps(view)
   def django_view(request, *args, **kwargs):
       params = {
           param_name: param.annotation(extract(request, param))
           for param_name, param in inspect.signature(view).parameters.items()
       }
       data = view(**params)
       return JsonResponse(asdict(data))
   
   return django_view

Это плотный кусок кода. Давайте рассмотрим его по частям.

Мы берем в качестве входных данных строго типизированное представление и оборачиваем его в обычную функцию представления Django, которую мы возвращаем:

def api_view(view):
   @functools.wraps(view)
   def django_view(request, *args, **kwargs):
       ...
   return django_view

Теперь давайте посмотрим на реализацию представления Django. Сначала мы должны построить аргументы для строго типизированной функции представления. Мы используем интроспекцию с модулем inspect, чтобы получить сигнатуру строго типизированной функции представления и перебрать ее параметры:

for param_name, param in inspect.signature(view).parameters.items()

Для каждого из параметров мы вызываем функцию extract, которая извлекает значение параметра из запроса.

Затем мы приводим значение параметра к ожидаемому типу из подписи (например, приводим систему календаря из строки к значению enum).

param.annotation(extract(request, param))

Мы вызываем строго типизированную функцию представления с аргументами параметров, которые мы создали:

data = view(**params)

Возвращает строго типизированный класс (например Character). Мы берем этот класс, преобразуем его в словарь и оборачиваем в ответ JSON, HTTP:

return JsonResponse(asdict(data))

Итак, теперь у нас есть представление Django, которое может обернуть строго типизированное представление. Наконец, давайте посмотрим на эту функцию extract:

def extract(request: HttpRequest, param: Parameter) -> Any:
   if request.resolver_match.route.contains(f"<{param}>"):
       return request.resolver_match.kwargs.get(param.name)
   else:
       return request.GET.get(param.name)

Каждый параметр может быть параметром url или параметром запроса. URL-адрес запроса (URL-путь, который мы зарегистрировали в качестве первого шага) доступен в объекте маршрута распознавателя URL Django. Мы проверяем, присутствует ли имя параметра в пути. Если это так, то это параметр url, и мы можем извлечь его из запроса одним способом. В противном случае это параметр запроса, и мы можем извлечь его другим способом.

Вот и все! Это упрощенная реализация, но она иллюстрирует основные идеи.

Типы данных

Тип, используемый для представления содержимого ответа HTTP (например Character), может использовать либо класс данных, либо типизированный словарь.

Класс - данных является кратким способом определить класс , который представляет данные.

from dataclasses import dataclass

@dataclass(frozen=True)
class Character:
   id: int
   name: str
   birth_year: str

luke = Character(
   id=1000, 
   name="Luke Skywalker", 
   birth_year="19BBY"
)

Классы данных являются предпочтительным способом моделирования объектов ответов HTTP в Instagram. Они помагают:

  • автоматически генерировать шаблонные конструкторы, equals и другие методы
  • понятны проверщикам типов и могут быть проверены
  • может обеспечить неизменность с frozen=True
  • доступны в стандартной библиотеке Python 3.7 или в качестве бэкпорта в индексе пакетов Python

К сожалению, в Instagram у нас есть устаревшая кодовая база, которая использует большие нетипизированные словари, передаваемые между функциями и модулями. Было бы трудно перенести весь этот код из словарей в классы данных. Таким образом, пока мы используем классы данных для нового кода, мы используем типовые словари для унаследованного кода.

Типизированные словари позволяют нам добавлять аннотации типов для клиентских объектов словаря и получать выгоду от проверки типов без изменения поведения во время выполнения.

from mypy_extensions import TypedDict
class Character(TypedDict):
   id: int
   name: str
   birth_year: str

luke: Character = {"id": 1000}
luke["name"] = "Luke Skywalker"
luke["birth_year"] = 19  # ошибка типа, birth_year ожидает str
luke["invalid_key"]  # ошибка типа, invalid_key не существует

Обработка ошибок

Функция представления ожидает от нас возврата символа. Что мы делаем, когда хотим вернуть ошибку клиенту?

Мы можем вызвать исключение, которое фреймворк перехватит и переведет в ответ об ошибке HTTP.

@api_view("GET")
def get_character(id: str, calendar: Calendar) -> Character:
   try:
       return Store.get_character(id)
   except CharacterNotFound:
       raise Http404Exception()

В этом примере также показан метод HTTP в декораторе, который указывает разрешенные методы HTTP для этого API.

Tooling

HTTP API строго типизирован с помощью метода HTTP, типов запросов и типов ответов. Мы можем проанализировать API и определить, что он должен получить запрос GET со строкой id в пути URL и enum calendar в строке запроса, и он вернет ответ JSON с Character.

Что мы можем сделать со всей этой информацией?

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

paths:
  /characters/{id}:
    get:
      parameters:
        - in: path
          name: id
          schema:
            type: integer
          required: true
        - in: query
          name: calendar
          schema:
            type: string
            enum: ["BBY"]
      responses:
        '200':
          content:
            application/json:
              schema: 
                type: object
                ...

Мы можем сгенерировать HTTP API документацию для get_character, которая включает имена, типы и документацию для запроса и ответа. Это правильный уровень абстракции для разработчиков-клиентов, которые хотят сделать запрос к конечной точке; они не должны читать код Python.

Перевод статьи: Types for Python HTTP APIs: An Instagram Story
Источник: instagram-engineering.com

#Python
Комментарии
Чтобы оставить комментарий, необходимо авторизоваться