Tipos para APIs HTTP escritas em Python: experiência no Instagram

Hoje estamos publicando o segundo material da série dedicada ao uso do Python no Instagram. Na última vez, verificou os tipos de código do servidor do Instagram. O servidor é um monólito escrito em Python. Consiste em vários milhões de linhas de código e possui vários milhares de terminais do Django.



Este artigo é sobre como o Instagram usa tipos para documentar APIs HTTP e aplicar contratos ao trabalhar com eles.

Visão geral da situação


Quando você abre o cliente móvel do Instagram, ele, via HTTP, acessa a API JSON do nosso servidor Python (Django).

Aqui estão algumas informações sobre o nosso sistema que permitirão que você tenha uma idéia da complexidade da API que usamos para organizar o trabalho do cliente móvel. Então aqui está o que temos:

  • Mais de 2000 pontos de extremidade no servidor.
  • Mais de 200 campos de nível superior em um objeto de dados do cliente que representa uma imagem, vídeo ou história em um aplicativo.
  • Centenas de programadores que escrevem código do servidor (e ainda mais que lidam com o cliente).
  • Centenas de confirmações no código do servidor feitas diariamente e na modificação da API. Isso é necessário para fornecer suporte para novos recursos do sistema.

Usamos tipos para documentar nossas APIs HTTP complexas e em constante evolução e para impor contratos ao trabalhar com elas.

Tipos


Vamos começar do começo. A descrição da sintaxe para anotações de tipo no código Python apareceu no PEP 484 . Por que adicionar anotações de tipo ao código?

Considere a função que baixa informações sobre o herói de Guerra nas Estrelas:

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

Para entender essa função, você precisa ler seu código. Feito isso, você pode descobrir o seguinte:

  • É necessário o identificador inteiro ( id ) do caractere.
  • Ele pega o valor da enumeração correspondente ( calendar ). Por exemplo, Calendar.BBY significa "Antes da Batalha de Yavin", ou seja, "Antes da Batalha de Yavin".
  • Ele retorna informações sobre o caractere na forma de uma entidade que contém campos representando o identificador desse caractere, seu nome e ano de nascimento.

A função possui um contrato implícito, cujo significado o programador deve restaurar toda vez que lê o código da função. Mas o código de função é escrito apenas uma vez e você deve lê-lo várias vezes; portanto, essa abordagem para trabalhar com esse código não é particularmente boa.

Além disso, é difícil verificar se o mecanismo que chama a função adere ao contrato implícito descrito acima. Da mesma forma, é difícil verificar se este contrato é respeitado no corpo da função. Em uma grande base de código, essas situações podem levar a erros.

Agora considere a mesma função que declara anotações de tipo:

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

As anotações de tipo permitem que você expresse explicitamente o contrato dessa função. Para entender o que precisa ser inserido em uma função e o que essa função retorna, basta ler sua assinatura. Um sistema de verificação de tipo pode analisar estaticamente a função e verificar a conformidade com o contrato no código. Isso permite que você se livre de toda uma classe de erros!

Tipos para várias APIs HTTP


Vamos desenvolver uma API HTTP que permite que você receba informações sobre os heróis de Star Wars. Para descrever o contrato explícito usado ao trabalhar com esta API, usaremos anotações de tipo.

Nossa API deve aceitar o identificador de caracteres ( id ) como um parâmetro de URL e o valor da enumeração de calendar como um parâmetro de solicitação. A API deve retornar uma resposta JSON com informações de caracteres.

Veja como é a solicitação da API e a resposta que ela retorna:

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

Para implementar esta API no Django, primeiro você precisa registrar o caminho da URL e a função de apresentação responsável por receber a solicitação HTTP feita ao longo desse caminho e retornar a resposta.

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

A função, como entrada, aceita os parâmetros request e URL (no nosso caso, id ). Ele analisa e lança o parâmetro de solicitação de calendar , que é o valor da enumeração correspondente, para o tipo necessário. Ele carrega dados de caracteres da loja e retorna um dicionário serializado em JSON e agrupado em uma resposta 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)) 

Embora a função seja fornecida com anotações de tipo, ela não descreve explicitamente o contrato definitivo para a API HTTP. A partir da assinatura desta função, não podemos descobrir os nomes ou tipos de parâmetros de solicitação ou campos de resposta e seus tipos.

É possível tornar a assinatura da representação da função exatamente a mesma informação que a assinatura da função considerada anteriormente com anotações de tipo?

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

Os parâmetros de função podem ser parâmetros de consulta (URL, consulta ou parâmetros do corpo da consulta). O tipo de valor retornado pela função pode representar o conteúdo da resposta. Com essa abordagem, teríamos à disposição um contrato explícito e compreensível para a API HTTP, cuja observância poderia ser assegurada por um sistema de verificação de tipo.

Implementação


Como implementar essa ideia?

Nós usamos um decorador para converter uma função de representação fortemente tipada em uma função de representação do Django. Esta etapa não requer alterações em termos de trabalho com a estrutura do Django. Podemos usar o mesmo middleware, as mesmas rotas e outros componentes aos quais estamos acostumados.

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

Considere os detalhes da api_view decorador 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 

Este é um pedaço difícil de código para entender. Vamos analisar suas características.
Nós, como valor de entrada, pegamos uma função de representação fortemente tipada e a envolvemos em uma função de representação regular do Django, que retornamos:

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

Agora dê uma olhada na implementação da função view do Django. Primeiro, precisamos construir argumentos para uma função de apresentação fortemente tipada. Usamos a introspecção e o módulo inspecionar para obter a assinatura dessa função e iterar sobre seus parâmetros:

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

Para cada parâmetro, chamamos a função extract , que extrai o valor do parâmetro da solicitação.

Em seguida, convertemos o parâmetro no tipo esperado especificado na assinatura (por exemplo, convertemos o calendar da string em um valor que é um elemento da enumeração do Calendar ).

 param.annotation(extract(request, param)) 

Chamamos uma função de exibição fortemente tipada com os argumentos que construímos:

 data = view(**params) 

A função retorna um valor fortemente tipado da classe Character . Nós pegamos esse valor, transformamos em um dicionário e envolvemos em uma resposta HTTP no formato JSON:

 return JsonResponse(asdict(data)) 

Ótimo! Agora temos uma função de exibição do Django que envolve uma função de exibição fortemente tipada. Por fim, dê uma olhada na função 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) 

Cada parâmetro pode ser um parâmetro de URL ou de solicitação. O caminho da URL de solicitação (o caminho que registramos no início) está disponível no objeto de rota do sistema localizador de URLs do Django. Verificamos o nome do parâmetro no caminho. Se houver um nome, temos um parâmetro de URL. Isso significa que, de alguma forma, podemos extraí-lo da solicitação. Caso contrário, este é um parâmetro de consulta e também podemos extraí-lo, mas de alguma outra maneira.

Isso é tudo. Esta é uma implementação simplificada, mas ilustra a ideia básica de digitar uma API.

Tipos de dados


O tipo usado para representar o conteúdo da resposta HTTP (ou seja, Character ) pode ser representado por uma classe de dados ou por um dicionário digitado.

Uma classe de dados é um formato de descrição de classe compacto que representa dados.

 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" ) 

O Instagram normalmente usa classes de dados para modelar objetos de resposta HTTP. Aqui estão suas principais características:

  • Eles geram automaticamente construções de modelo e vários métodos auxiliares.
  • Eles são compreensíveis para digitar sistemas de verificação, o que significa que os valores podem estar sujeitos a verificação de tipo.
  • Eles mantêm a imunidade graças à construção frozen=True .
  • Eles estão disponíveis na biblioteca padrão do Python 3.7 ou como um backport no Python Package Index.

Infelizmente, o Instagram tem uma base de código desatualizada que usa dicionários grandes e sem tipo, passados ​​entre funções e módulos. Não seria fácil traduzir todo esse código de dicionários para classes de dados. Como resultado, nós, usando classes de dados para o novo código, e no código desatualizado, usamos dicionários digitados .

O uso de dicionários digitados nos permite adicionar anotações de tipo aos objetos de dicionário do cliente e, sem alterar o comportamento de um sistema em funcionamento, usar os recursos de verificação de tipo.

 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 # type error, birth_year expects a str luke["invalid_key"] # type error, invalid_key does not exist 

Tratamento de erros


Espera-se que a função view retorne informações sobre caracteres na forma de uma entidade Character . O que devemos fazer se precisarmos retornar um erro ao cliente?

Você pode lançar uma exceção que será capturada pela estrutura e convertida em uma resposta HTTP com informações de erro.

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

Este exemplo também demonstra o método HTTP no decorador, que define os métodos HTTP permitidos para esta API.

As ferramentas


A API HTTP é fortemente digitada usando o método HTTP, tipos de solicitação e tipos de resposta. Podemos examinar essa API e determinar se ela deve aceitar uma solicitação GET com a sequência de id no caminho da URL e com o valor do calendar relacionado à enumeração correspondente na sequência de consultas. Também podemos aprender que, em resposta a essa solicitação, uma resposta JSON deve ser fornecida com informações sobre a natureza do Character .

O que pode ser feito com todas essas informações?

OpenAPI é um formato de descrição de API com base no qual um conjunto rico de ferramentas auxiliares é criado. Este é um ecossistema inteiro. Se escrevermos algum código para executar a introspecção de terminais e gerar especificações OpenAPI com base nos dados recebidos, isso significa que teremos os recursos dessas ferramentas.

 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                ... 

Podemos gerar documentação da API HTTP para a API get_character , que inclui nomes, tipos, informações de solicitação e resposta. Esse é um nível de abstração apropriado para desenvolvedores de clientes que precisam atender a solicitações no terminal apropriado. Eles não precisam ler o código de implementação do Python para este terminal.


Documentação da API

Nesta base, você pode criar ferramentas adicionais. Por exemplo, um meio de executar uma solicitação de um navegador. Isso permite que os desenvolvedores acessem as APIs HTTP de seu interesse sem precisar escrever código. Podemos até gerar código de cliente com segurança de tipo para garantir que os tipos funcionem corretamente no cliente e no servidor. Devido a isso, podemos ter à nossa disposição uma API estritamente digitada no servidor, para as quais as chamadas são realizadas usando código de cliente estritamente digitado.

Além disso, podemos criar um sistema de verificação de compatibilidade com versões anteriores. O que acontece se lançarmos uma nova versão do código do servidor para acessar a API em questão, precisamos usar id , name e ano de birth_year e entendemos que não sabemos os aniversários de todos os caracteres? Nesse caso, o parâmetro birth_year precisará ser opcional, mas versões antigas de clientes que esperam um parâmetro semelhante podem simplesmente parar de funcionar. Embora nossas APIs sejam diferentes na digitação explícita, os tipos correspondentes podem mudar (por exemplo, a API mudará se o uso do ano de nascimento do personagem for primeiro obrigatório e depois se tornar opcional). Podemos rastrear alterações de API e avisar os desenvolvedores de API, fornecendo avisos no momento certo de que, ao fazer algumas alterações, eles podem atrapalhar o desempenho dos clientes.

Sumário


Existe toda uma gama de protocolos de aplicativos que os computadores podem usar para se comunicar.

Um lado desse espectro é representado por estruturas RPC como Thrift e gRPC. Eles diferem na medida em que geralmente definem tipos estritos para solicitações e respostas e geram código de cliente e servidor para organizar a operação de solicitações. Eles podem ficar sem HTTP e até sem JSON.

Por outro lado, existem estruturas da Web não estruturadas escritas em Python que não possuem contratos explícitos para solicitações e respostas. Nossa abordagem fornece oportunidades típicas para estruturas mais claramente estruturadas, mas ao mesmo tempo permite que você continue usando o pacote HTTP + JSON e contribui para o fato de que você deve fazer um mínimo de alterações no código do aplicativo.

É importante notar que essa ideia não é nova. Existem muitas estruturas escritas em linguagens fortemente tipadas que fornecem aos desenvolvedores os recursos que descrevemos. Se falamos sobre Python, esse é, por exemplo, o framework APIStar .

Comissionamos com êxito o uso de tipos para a API HTTP. Conseguimos aplicar a abordagem descrita para digitar a API em toda a nossa base de códigos, devido ao fato de ser bem aplicável às funções de apresentação existentes. O valor do que fizemos é óbvio para todos os nossos programadores. Ou seja, estamos falando do fato de que a documentação gerada automaticamente se tornou um meio eficaz de comunicação entre quem desenvolve o servidor e quem escreve o cliente do Instagram.

Caros leitores! Como você aborda o design de APIs HTTP em seus projetos Python?


Source: https://habr.com/ru/post/pt474308/


All Articles