Comment tirer profit des décorateurs Python pour gérer ses services d’API ?

Pour les développeurs Python débutants, le concept de décorateurs peut paraître obscur, mais il s’agit pourtant d’une fonctionnalité largement répandue et utilisée dans de nombreux modules et projets. Nous allons voir dans cet article un exemple de cas d’usage qui a permis de tirer profit de l’utilisation des décorateurs dans la gestion de réponses de requêtes API. Mais avant cela, rappelons quelques notions fondamentales sur les décorateurs en Python.

Un peu d'histoire

La notion de décorateur (ou de fonction décoratrice) en Python a été introduite dans la version Python 2.4 dans l’optique d’offrir une nouvelle syntaxe plus lisible et plus intuitive pour appliquer des transformations d’objets déclarés. Pour les adeptes de la programmation orientée objet, ce principe est loin d’être nouveau, puisque son utilisation est très similaire à l’un des vingt-trois patrons de conception structurels du GoF : le patron décorateur.

La syntaxe de déclaration d’une fonction décoratrice est la suivante :

@decorator_2
@decorator_1
def my_function(arg1, arg2):
    print("Hello World!")
 
my_function(1, 2)

Ce qui revient à écrire :

def my_function(arg1, arg2):
    print("Hello World!")

my_function = decorator_2(decorator_1(my_function))
my_function(1, 2)

Implémentation

def my_decorator(func):
    def func_wrapper(*args, **kwargs):
        print("Before calling func()")
        func(*args, **kwargs)
        print("After calling func()")
    return func_wrapper

Un décorateur n’est ni plus ni moins qu’une fonction qui prend en paramètre une fonction et qui retourne une fonction (ou à minima un objet de type « callable »). Dans l’exemple ci-dessus, le décorateur my_decorator prend en argument la fonction func et retourne la fonction func_wrapper définie localement. À premier œil, la syntaxe de définition d’un décorateur peut paraître déroutante mais retenez simplement que :

  • Une fonction décoratrice peut envelopper une fonction déclarée localement dans le but de pouvoir accéder aux arguments de la fonction décorée identifiable grâce au mot-clé @.
  • Les arguments de la fonction sont paquetés successivement sous forme de tuple et de dictionnaire dans args et kwargs, puis sont dépaquetés en une suite d’arguments lors de l’appel à la fonction décorée.

Vous comprendrez alors que l’intérêt qui se cache derrière ce genre de pratique est de pouvoir obtenir un comportement commun à plusieurs fonctions de manière générique. On peut par exemple imaginer ajouter des traitements antérieurs/postérieurs à l’invocation des fonctions décorées, modifier les arguments passés en paramètres de celles-ci ou encore modifier de façon commune les valeurs retournées. Les décorateurs s’avèrent donc pratiques pour éviter les redondances de code.

Un peu de contexte

Peu importe la technologie que vous utilisez pour implémenter vos services métiers d’API, il est toujours crucial d’anticiper et de gérer avec rigueur les erreurs qui peuvent survenir lors de l’exécution de requêtes, afin de respecter, d’une part, les bonnes pratiques de conception d’API et de garantir, d’autre part, la meilleure expérience pour les utilisateurs finaux. Dans ce contexte, l’utilisation de décorateurs peut s’avérer judicieuse pour intercepter communément les erreurs d’exécution de votre code et uniformiser les réponses renvoyées au consommateur.

Show me the trick!

Admettons que nous définissions par exemple deux services d’API basiques, dont l’un permet de créer un utilisateur avec un pseudonyme, et l’autre de récupérer un utilisateur par son identifiant :

@my_decorator
def post(user_data):
    return User(**user_data).save()

@my_decorator
def get(user_id):
    return User.get_one(user_id)

Dans l’exemple ci-dessus, il est évident que les consommateurs de ces services seront rapidement frustrés, puisque nous ne garantissons aucunement que les méthodes save() et get_one() n’engendrent pas une erreur inattendue lors de la consommation de ces derniers. Par conséquent, la réponse retournée sera indéchiffrable pour le consommateur et ce dernier se retrouvera bloqué. De plus, il est difficile de maintenir et de garantir l’uniformité des réponses retournées par nos services sans avoir un service centralisé capable de les formater.

Pour pallier à ce problème, voici une fonction décoratrice capable d’intercepter n’importe quelle exception levée par une fonction décorée et d’uniformiser les réponses des services appelés :

Et l’utilisation de cette fonction décoratrice par les deux services précédemment définis est faite simplement de la manière suivante :

@wrap_response
def post(user_data):
    return User(**user_data).save()

@wrap_response
def get(user_id):
    return User.get_one(user_id)

Pour aller plus loin...

Pour aller plus loin, on peut aussi imaginer vouloir obtenir un comportement spécifique pour certaines de nos fonctions décorées en passant simplement un ou plusieurs paramètres au décorateur. Dans notre cas, l’utilisation de paramètres peut être intéressante pour, par exemple, gérer différemment les appels en fonction de la méthode de requête utilisée. Il suffit pour cela de créer une fonction supplémentaire qui retourne un décorateur, et de passer les paramètres au décorateur :

Dans cet exemple, le passage du paramètre allowed_methods dans le décorateur nous permet non seulement de vérifier génériquement la méthode de requête HTTP utilisée, mais surtout d’empêcher les consommateurs de faire un appel à un service via une méthode inappropriée. Ici, l’invocation du service create_user() est seulement limitée aux actions de type POST.

Tests unitaires

Les décorateurs Python peuvent être facilement testés unitairement grâce à l’utilisation d’objets simulacres, plus connus sous le nom de « mock ». Pour ce faire, on va tout simplement appliquer au décorateur une fonction factice dont le comportement est simulé. Dans ce genre de cas, on préférera utiliser l’auto-spécification afin de créer la fonction factice prenant exactement les mêmes attributs et méthodes que la fonction réelle. Cela permet de garantir que l’objet simulacre échouera exactement de la même manière que l’objet remplacé s’ils ne sont pas utilisés correctement.

Retour d'expérience

Gestion des exécutions AWS Lambda avec des décorateurs

Ces exemples de cas d’usage de décorateurs Python se prêtent particulièrement bien pour une gestion propre et efficace des fonctions AWS Lambda intégrées comme back-end d’API. En effet, avec l’intégration de proxy Lambda, API Gateway impose à la fonction Lambda du back-end de renvoyer un format de sortie bien défini. C’est pourquoi l’utilisation de décorateurs pour centraliser la gestion du format de sortie des fonctions Lambda doit être une option à prendre en considération lors de vos développements. La réécriture du format de sortie pour chaque nouvelle fonction Lambda entraîne de la redondance de code et peut être source d’erreurs.

De même, l’interception des erreurs d’exécution de vos fonctions Lambda au sein d’une fonction décoratrice offre une meilleure maintenabilité et lisibilité dans votre code. Veillez alors à intercepter minutieusement les erreurs intégrées ainsi que les erreurs externes pouvant survenir. Une fois interceptées, préférez la génération d’erreurs personnalisées et explicites pour l'utilisateur dans le cas échéant. De plus, prenez garde à ce que vos fonctions Lambda ne renvoient pas dans leur corps de réponse des informations spécifiques et relatives aux erreurs de votre application. Cela peut paraître anodin, mais c’est typiquement le genre de vulnérabilité technique qui peut être remonté lors d’un audit technique de sécurité. Par exemple, imaginez un cas où les erreurs de validation de schémas de données sont interceptées dans votre fonction Lambda et que la chaîne de caractères de l’exception est directement intégrée dans le corps de la réponse. Un utilisateur malveillant pourrait très bien envoyer un message malformé au serveur d’API pour provoquer des erreurs de validation et ainsi obtenir des informations sur les technologies utilisées pour l’implémentation de l’application. Il est alors préférable de renvoyer des messages génériques sans laisser apparaître le nom des technologies utilisées.

Conclusion

J'espère que cet article vous aura permis de découvrir d'une autre façon les décorateurs Python et comment ceux-ci peuvent vous aider. Notamment grâce à un exemple de mise en place de services back-end, nous avons pu voir comment optimiser la complexité et réduire la duplication de code. Sachez qu’il existe un grand nombre de situations où l’utilisation de ce concept de programmation peut être utile (mise en cache de résultats de fonctions, gestion de rôles et permissions, etc.). Pour d’avantages d’exemples, je vous invite à lire ce tutoriel avancé et détaillé sur les décorateurs Python.

Références

Python Software Foundation. (2003, 9 juin). PEP 318 -- Decorators for Functions and Methods. Python.org. https://www.python.org/dev/peps/pep-0318/

Python Software Foundation. unittest.mock — mock object library. Python.org https://docs.python.org/3/library/unittest.mock.html

Refactoring.Guru. Décorateur. Refactoring.Guru. https://refactoring.guru/fr/design-patterns/decorator

Amazon Web Services. Configuration d'intégrations de proxy Lambda dans API Gateway. AWS Documentation. https://docs.aws.amazon.com/fr_fr/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html