Types pour les API HTTP écrites en Python: expérience Instagram

Aujourd'hui, nous publions le deuxième matériel de la série consacrée à l'utilisation de Python sur Instagram. La dernière fois, il vérifiait les types de code de serveur Instagram. Le serveur est un monolithe écrit en Python. Il se compose de plusieurs millions de lignes de code et compte plusieurs milliers de points de terminaison Django.



Cet article explique comment Instagram utilise les types pour documenter les API HTTP et appliquer les contrats lors de leur utilisation.

Aperçu de la situation


Lorsque vous ouvrez le client mobile Instagram, celui-ci, via HTTP, accède à l'API JSON de notre serveur Python (Django).

Voici quelques informations sur notre système qui vous permettront de vous faire une idée de la complexité de l'API que nous utilisons pour organiser le travail du client mobile. Voici donc ce que nous avons:

  • Plus de 2000 points de terminaison sur le serveur.
  • Plus de 200 champs de niveau supérieur dans un objet de données client qui représente une image, une vidéo ou une histoire dans une application.
  • Des centaines de programmeurs qui écrivent du code serveur (et encore plus qui traitent avec le client).
  • Des centaines de validations de code serveur effectuées quotidiennement et modifiant l'API. Cela est nécessaire pour assurer la prise en charge des nouvelles fonctionnalités du système.

Nous utilisons des types pour documenter nos API HTTP complexes et en constante évolution et pour appliquer les contrats lorsque nous les utilisons.

Les types


Commençons par le tout début. La description de la syntaxe des annotations de type dans le code Python est apparue dans PEP 484 . Pourquoi ajouter des annotations de type au code?

Considérez la fonction qui télécharge des informations sur le héros de Star Wars:

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

Pour comprendre cette fonction, vous devez lire son code. Cela fait, vous pouvez découvrir les éléments suivants:

  • Il prend l'identifiant entier ( id ) du caractère.
  • Il prend la valeur de l'énumération correspondante ( calendar ). Par exemple, Calendar.BBY signifie «Avant la bataille de Yavin», c'est-à-dire «Avant la bataille de Yavin».
  • Il renvoie des informations sur le personnage sous la forme d'une entité contenant des champs représentant l'identifiant de ce personnage, son nom et son année de naissance.

La fonction a un contrat implicite, dont le programmeur doit restaurer chaque fois qu'il lit le code de la fonction. Mais le code de fonction n'est écrit qu'une seule fois, et vous devez le lire plusieurs fois, donc cette approche pour travailler avec ce code n'est pas particulièrement bonne.

De plus, il est difficile de vérifier que le mécanisme qui appelle la fonction adhère au contrat implicite décrit ci-dessus. De même, il est difficile de vérifier que ce contrat est respecté dans le corps de la fonction. Dans une grande base de code, de telles situations peuvent entraîner des erreurs.

Considérons maintenant la même fonction qui déclare les annotations de type:

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

Les annotations de type vous permettent d'exprimer explicitement le contrat de cette fonction. Afin de comprendre ce qui doit être entré dans une fonction et ce que cette fonction renvoie, il suffit de lire sa signature. Un système de vérification de type peut analyser statiquement la fonction et vérifier la conformité au contrat dans le code. Cela vous permet de vous débarrasser de toute une classe d'erreurs!

Types pour diverses API HTTP


Nous développerons une API HTTP qui vous permettra de recevoir des informations sur les héros de Star Wars. Pour décrire le contrat explicite utilisé lors de l'utilisation de cette API, nous utiliserons des annotations de type.

Notre API doit accepter l'identificateur de caractère ( id ) comme paramètre d'URL et la valeur de l'énumération du calendar comme paramètre de demande. L'API doit renvoyer une réponse JSON avec des informations sur les caractères.

Voici à quoi ressemble la demande d'API et la réponse qu'elle renvoie:

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

Pour implémenter cette API dans Django, vous devez d'abord enregistrer le chemin URL et la fonction d'affichage responsable de la réception de la requête HTTP effectuée le long de ce chemin et du retour de la réponse.

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

La fonction, en entrée, accepte les paramètres de requête et d'URL (dans notre cas, id ). Il analyse et convertit le paramètre de demande de calendar , qui est la valeur de l'énumération correspondante, en type requis. Il charge les données de caractères du magasin et renvoie un dictionnaire sérialisé en JSON et enveloppé dans une réponse 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)) 

Bien que la fonction soit fournie avec des annotations de type, elle ne décrit pas explicitement le contrat matériel pour l'API HTTP. À partir de la signature de cette fonction, nous ne pouvons pas trouver les noms ou les types de paramètres de demande, ou les champs de réponse et leurs types.

Est-il possible de faire en sorte que la signature de la fonction-représentation soit exactement la même informative que la signature de la fonction précédemment considérée avec des annotations de type?

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

Les paramètres de fonction peuvent être des paramètres de requête (URL, requête ou paramètres de corps de requête). Le type de valeur renvoyé par la fonction peut représenter le contenu de la réponse. Avec cette approche, nous aurions à notre disposition un contrat explicite et compréhensible pour l'API HTTP, dont le respect pourrait être assuré par un système de vérification de type.

Implémentation


Comment mettre en œuvre cette idée?

Nous utilisons un décorateur pour convertir une fonction de représentation fortement typée en une fonction de représentation Django. Cette étape ne nécessite pas de modifications en termes de travail avec le framework Django. Nous pouvons utiliser le même middleware, les mêmes routes et d'autres composants auxquels nous sommes habitués.

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

Considérez les détails de l' api_view décorateur 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 

C'est un morceau de code difficile à comprendre. Analysons ses caractéristiques.
Nous, en tant que valeur d'entrée, prenons une fonction de représentation fortement typée et l'enveloppons dans une fonction de représentation Django régulière, que nous renvoyons:

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

Jetez maintenant un œil à l'implémentation de la fonction de vue Django. Nous devons d'abord construire des arguments pour une fonction de présentation fortement typée. Nous utilisons l'introspection et le module d' inspection pour obtenir la signature de cette fonction et itérer sur ses paramètres:

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

Pour chaque paramètre, nous appelons la fonction d' extract , qui extrait la valeur du paramètre de la demande.

Ensuite, nous convertissons le paramètre en le type attendu spécifié dans la signature (par exemple, convertissons le calendar de la chaîne en une valeur qui est un élément de l'énumération Calendar ).

 param.annotation(extract(request, param)) 

Nous appelons une fonction de vue fortement typée avec les arguments que nous avons construits:

 data = view(**params) 

La fonction renvoie une valeur fortement typée de la classe Character . Nous prenons cette valeur, la transformons en dictionnaire et l'enveloppons dans une réponse HTTP au format JSON:

 return JsonResponse(asdict(data)) 

Super! Nous avons maintenant une fonction de vue Django qui encapsule une fonction de vue fortement typée. Enfin, jetez un œil à la fonction d' 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) 

Chaque paramètre peut être un paramètre URL ou un paramètre de demande. Le chemin de l'URL de demande (le chemin que nous avons enregistré au tout début) est disponible dans l'objet route du système de localisateur d'URL Django. Nous vérifions le nom du paramètre dans le chemin. S'il y a un nom, alors nous avons un paramètre URL. Cela signifie que nous pouvons en quelque sorte l'extraire de la demande. Sinon, il s'agit d'un paramètre de requête et nous pouvons également l'extraire, mais d'une autre manière.

C’est tout. Il s'agit d'une implémentation simplifiée, mais elle illustre l'idée de base de taper une API.

Types de données


Le type utilisé pour représenter le contenu de la réponse HTTP (c'est-à-dire Character ) peut être représenté soit par une classe de données soit par un dictionnaire typé.

Une classe de données est un format de description de classe compact qui représente des données.

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

Instagram utilise généralement des classes de données pour modéliser les objets de réponse HTTP. Voici leurs principales caractéristiques:

  • Ils génèrent automatiquement des constructions de modèles et diverses méthodes d'assistance.
  • Ils sont compréhensibles pour les systèmes de vérification de type, ce qui signifie que les valeurs peuvent être soumises à des vérifications de type.
  • Ils maintiennent l'immunité grâce à la construction frozen=True .
  • Ils sont disponibles dans la bibliothèque standard Python 3.7 ou en tant que backport dans l'index du package Python.

Malheureusement, Instagram a une base de code obsolète qui utilise de gros dictionnaires non typés, passés entre les fonctions et les modules. Il ne serait pas facile de traduire tout ce code des dictionnaires en classes de données. Par conséquent, nous utilisons des classes de données pour le nouveau code et dans du code obsolète, nous utilisons des dictionnaires typés .

L'utilisation de dictionnaires typés nous permet d'ajouter des annotations de type aux objets du dictionnaire client et, sans changer le comportement d'un système de travail, d'utiliser les capacités de vérification de type.

 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 

Gestion des erreurs


La fonction d'affichage devrait renvoyer des informations de caractère sous la forme d'une entité de Character . Que devons-nous faire si nous devons renvoyer une erreur au client?

Vous pouvez lever une exception qui sera interceptée par le framework et convertie en une réponse HTTP avec des informations d'erreur.

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

Cet exemple illustre également la méthode HTTP dans le décorateur, qui définit les méthodes HTTP autorisées pour cette API.

Les outils


L'API HTTP est fortement typée à l'aide de la méthode HTTP, des types de demande et des types de réponse. Nous pouvons introspecter cette API et déterminer qu'elle doit accepter une demande GET avec la chaîne id dans le chemin URL et avec la valeur de calendar liée à l'énumération correspondante dans la chaîne de requête. Nous pouvons également apprendre qu'en réponse à une telle demande, une réponse JSON doit être fournie avec des informations sur la nature du Character .

Que faire de toutes ces informations?

OpenAPI est un format de description d'API sur la base duquel un riche ensemble d'outils auxiliaires est créé. C'est tout un écosystème. Si nous écrivons du code pour effectuer une introspection de point final et générer des spécifications OpenAPI basées sur les données reçues, cela signifiera que nous aurons les capacités de ces outils.

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

Nous pouvons générer la documentation de l'API HTTP pour l'API get_character , qui comprend les noms, les types, les informations de demande et de réponse. Il s'agit d'un niveau d'abstraction approprié pour les développeurs clients qui doivent répondre aux demandes au point de terminaison approprié. Ils n'ont pas besoin de lire le code d'implémentation Python pour ce point de terminaison.


Documentation API

Sur cette base, vous pouvez créer des outils supplémentaires. Par exemple, un moyen d'exécuter une demande à partir d'un navigateur. Cela permet aux développeurs d'accéder aux API HTTP qui les intéressent sans avoir à écrire de code. Nous pouvons même générer du code client de type sécurisé pour nous assurer que les types fonctionnent correctement à la fois sur le client et sur le serveur. Pour cette raison, nous pouvons avoir à notre disposition une API strictement typée sur le serveur, dont les appels sont effectués en utilisant du code client strictement typé.

De plus, nous pouvons créer un système de vérification de la compatibilité descendante. Que se passe-t-il si nous publions une nouvelle version du code serveur pour accéder à l'API en question, nous devons utiliser id , name et birth_year , puis nous comprenons que nous ne connaissons pas les anniversaires de tous les personnages? Dans ce cas, le paramètre birth_year être rendu facultatif, mais les anciennes versions de clients qui attendent un paramètre similaire peuvent simplement cesser de fonctionner. Bien que nos API diffèrent par leur typage explicite, les types correspondants peuvent changer (par exemple, l'API changera si l'utilisation de l'année de naissance du personnage était d'abord obligatoire puis devenait facultative). Nous pouvons suivre les modifications de l'API et avertir les développeurs d'API en leur donnant des invites au bon moment qu'en effectuant certaines modifications, ils peuvent perturber les performances des clients.

Résumé


Il existe toute une gamme de protocoles d'application que les ordinateurs peuvent utiliser pour communiquer entre eux.

Un côté de ce spectre est représenté par des cadres RPC comme Thrift et gRPC. Ils diffèrent en ce qu'ils définissent généralement des types stricts pour les demandes et les réponses et génèrent du code client et serveur pour organiser le fonctionnement des demandes. Ils peuvent se passer de HTTP et même de JSON.

D'un autre côté, il existe des frameworks Web non structurés écrits en Python qui n'ont pas de contrats explicites pour les demandes et les réponses. Notre approche offre des opportunités typiques pour des frameworks plus clairement structurés, mais en même temps vous permet de continuer à utiliser le bundle HTTP + JSON et contribue au fait que vous devez apporter un minimum de modifications au code de l'application.

Il est important de noter que cette idée n'est pas nouvelle. Il existe de nombreux frameworks écrits dans des langages fortement typés qui fournissent aux développeurs les fonctionnalités que nous avons décrites. Si nous parlons de Python, alors c'est, par exemple, le framework APIStar .

Nous avons réussi à commander l'utilisation de types pour l'API HTTP. Nous avons pu appliquer l'approche décrite pour taper l'API dans l'ensemble de notre base de code car elle est bien applicable aux fonctions de présentation existantes. La valeur de ce que nous avons fait est évidente pour tous nos programmeurs. À savoir, nous parlons du fait que la documentation générée automatiquement est devenue un moyen de communication efficace entre ceux qui développent le serveur et ceux qui écrivent le client Instagram.

Chers lecteurs! Comment abordez-vous la conception des API HTTP dans vos projets Python?


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


All Articles