Nous avons vu dans les trois précédents articles un premier exemple de programmation d’une API Web sous Flask, comment connecter une API Web à une base de donnée SqLite et comment programmer et documenter une API Web avec Python, Flask, Swagger et Connexion. Nous allons voir dans cet article et le prochain comment combiner ces éléments avec la gestion d’une base de données à l’aide de SQLAlchemy et Marshmallow.
1. Prérequis techniques
Commençons par installer les bibliothèques Python dont nous aurons besoin sous Anaconda, à l’aide de la commande suivante, dans une fenêtre terminal :
$ conda install -c conda-forge Flask-SQLAlchemy flask- marshmallow marshmallow-sqlalchemy marshmallow
Cette commande permet d’installer les bibliothèques suivantes :
- flask-SQLAlchemy, qui permet d’accéder à des bases de données de façon compatible avec Flask
- flask-marshmallow, qui permet de convertir des objets Python en structures sérialisables (c’est à dire codables comme une suite d’informations plus petites).
- marshmallow-sqlalchemy, qui permet de sérialiser et désérialiser des objets Python générés par SQLAlchemy
- marshmallow, qui fournit l’essentiel des fonctionnalités Marshmallow de sérialisation / désérialisation
2. Présentation générale
Dans le troisième article, on a vu comment créer une API REST autorisant les opérations CRUD et bien documentée en utilisant Python, Flask, Connexion et Swagger.
Toutefois, les données étaient stockées dans une structure PEOPLE qui était réinitialisée à chaque démarrage du serveur.
On va voir dans la suite comment stocker la structure PEOPLE et les actions fournies par l’API de façon permanente dans une base de données, à l’aide de SQLAlchemy et Marshmallow.
SQLAlchemy fournit un ORM (Object Relational Model, Modèle Relationnel Objet) qui permet de stocker les données des objets Python dans une représentation de type base de donnée.
Cela permet de continuer à programmer dans l’esprit Python, sans se préoccuper des détails de la représentation des données d’un objet dans la base de données.
Marshmallow fournit des outils permettant de sérialiser et désérialiser des objets Python au fur et à mesure qu’ils sont émis ou reçus par notre API REST au format JSON.
Marshmallow convertit notamment des instances de classe Python en des objets qui peuvent être convertis en JSON.
Le code correspondant à cet article se trouve sur ici.
3. Les données
Les modifications que l’on va effectuer vont transférer les données vers une table d’une base de données.
Cela signifie que les données seront sauvegardées « en dur » et accessibles entre deux exécutions de server.py.
Comme le nom de famille était la clef du dictionnaire Python PEOPLE, le code empêchait de modifier le nom d’une personne. Le transfert vers une base de données permettra d’autoriser cette modification, le nom de famille n’étant plus utilisé comme clef.
Conceptuellement, on peut voir une base de données comme un tableau bi-dimensionnel où chaque ligne correspond à un enregistrement et où chaque colonne correspond à un champ de l’enregistrement.
Les bases de données disposent souvent d’un indice auto-incrémenté permettant d’identifier les lignes.
C’est ce qu’on appelle la clef primaire.
À chaque enregistrement de la table correspondra une valeur unique de la clef primaire.
Le fait de disposer d’une clef primaire indépendante de nos données autorise à modifier n’importe quel autre champ des enregistrements, dont le nom de famille.
On adopte une convention standard pour les bases de données en donnant un nom singulier à la table : on l’appellera person.
4. Interaction avec la base de données
On va utiliser SQLite comme moteur de base de données pour stocker les données PEOPLE.
SQLite est la base de données la plus distribuée au monde et elle est disponible en standard sous Python.
Elle est rapide, travaille sur des fichiers et est adaptée à de nombreux types de projets.
Il s’agit d’un RDBMS complet (Relational DataBase Management System), qui inclut SQL.
Supposons que la table person existe dans une base de données SQLite.
On peut alors utiliser SQL pour récupérer les données.
Contrairement aux langages de programmation de type Python, SQL ne dit pas comment récupérer les données mais se contente de spécifier quelles données sont requises, en laissant le soin du comment au moteur de base de données.
Une requête SQL permettant de récupérer la liste de toutes les personnes présente dans notre base de données, triée par ordre alphabétique du nom de famille, se présente de la façon suivante :
SELECT * FROM person ORDER BY 'lname';
On peut soumettre la requête précédente dans une fenêtre terminal après avoir lancé SQLite à l’aide de la commande
$ sqlite3 people.db
ce qui donne le résultat suivant :
SHELL
sqlite> select * from person order by 'lname';
1|Dupont|Jean|2022-04-24 09:30:48.713385
2|Durand|Louise|2022-04-24 09:30:48.715009
3|Lopez|Francois|2022-04-24 09:30:48.716247 |
La sortie précédente correspond à l’ensemble de enregistrements de la base de données, les différents champs étant séparés par le caractère « pipe » |.
En fait, il est possible de soumettre la requête précédente directement à partir de Python. Le résultat serait une liste de tuples. La liste contient l’ensemble des enregistrements, chaque tuple correspondant à un enregistrement.
Toutefois, récupérer les données de cette façon n’est pas vraiment compatible avec l’esprit Python.
Le fait d’obtenir une liste correspond bien à Python mais le fait d’avoir des tuples est problématique : c’est au programmeur de connaitre l’indice de chaque champ pour accéder aux données.
On procèderait de la façon suivante sous Python :
Le programme précédent se déroule de la façon suivante :
La ligne 1 importe le module sqlite3.
La ligne 3 crée une connexion au fichier de base de données.
La ligne 4 crée un curseur de connexion (qui va parcourir la base).
La ligne 5 utilise le curseur pour exécuter une requête SQL exprimée sous la forme d’une chaîne de caractères.
La ligne 6 récupère l’ensemble des enregistrements renvoyés par la requête SQL et les assigne à la variable people
Les lignes 7 et 8 itèrent sur la liste people et imprime le nom et le prénom de chaque personne
La variable people a la structure suivante :
[(1, 'Dupont', 'Jean', '2022-04-24 09:30:48.713385'),
(2, 'Durand', 'Louise', '2022-04-24 09:30:48.715009'),
(3, 'Lopez', 'Francois', '2022-04-24 09:30:48.716247')]
Le résultat de l’exécution du programme précédent est le suivant : |
Jean Dupont
Louise Durand
Francois Lopez |
Dans le programme précédent, on doit savoir que le nom de famille est en position 1 et le prénom en position 2 dans les tuples renvoyés.
De plus, la structure interne de person doit être connue lorsqu’on passe la variable d’itération person comme paramètre à une fonction ou méthode.
Ce serait beaucoup plus pratique d’avoir person sous la forme d’un objet Python, les différents champs correspondant à des attributs de l’objet.
Ceci peut être fait avec SQLAlchemy.
5. Le risque d’injection SQL
Dans le programme précédent, la requête SQL est une simple chaîne de caractères passée à la base de données pour exécution.
Cela ne pose pas de problème car la chaîne est entièrement sous le contrôle du programmeur.
Par contre, notre application Web partira d’un input utilisateur vers l’API REST pour construire une requête SQL.
Cela peut constituer une faille de sécurité de notre application.
La requête API REST pour accéder à une personne particulière a la forme :
GET /api/people/{lname}
Cela signifie que l’API attend une variable, lname, dans le chemin d’URL, qu’elle utilise pour trouver une personne particulière.
Le code Python correspondant prend la forme suivante :
Le code précédent fonctionne de la façon suivante :
La ligne 1 fixe la valeur de la variable lname à ‘Dupont’. Cela proviendrait du chemin d’URL dans le cadre de l’API.
Le code SQL généré par la ligne 2 a la forme suivante :
SQL
SELECT * FROM person WHERE lname = 'Dupont' |
Lorsque cette requête SQL est exécutée par la base de données, la personne correspondante est recherchée dans la base et les informations associées récupérées.
C’est le fonctionnement attendu ; toutefois, un utilisateur mal intentionné peut, dans ce cadre de travail, effectuer une injection SQL (SQL injection).
Par exemple, un utilisateur mal intentionné peut requêter l’API de la façon suivante :
GET /api/people/Farrell');DROP TABLE person; |
La requête précédente fixe la valeur de la variable lname à : ‘Dupont’);DROP TABLE person;’, ce qui a pour effet de générer la requête SQL suivante :
SQL
SELECT * FROM person WHERE lname = 'Dupont');DROP TABLE person; |
La requête SQL précédente est valide.
Elle aurait pour effet de commencer par rechercher dans la table une personne nommée ‘Dupont’ puis, rencontrant ensuite un point-virgule, elle passerait à la requête suivante, qui a pour effet de supprimer l’intégralité de la table person.
Il est clair que cela mettrait notre application hors d’état de fonctionner.
Une façon d’éviter ce genre de situation consiste à filtrer les données fournies par les utilisateurs pour s’assurer qu’elles ne contiennent rien de dangereux pour l’application.
Il peut être compliqué de le faire et, ce d’autant plus, qu’il faudrait examiner toutes les interactions des utilisateurs avec la base de données.
Une solution plus pratique consiste à utiliser SQLAlchemy.
En effet, SQLAlchemy se chargera de filtrer les requêtes émises par les utilisateurs vers la base de données avant de créer les requêtes SQL correspondantes.
C’est une autre raison d’utiliser SQLAlchemy lorsqu’on travaille avec des bases de données sur le Web.
6. Le modèle SQLAlchemy
QLAlchemy est un projet important et fournit de très nombreux outils permettant de travailler avec des bases de données sous Python.
Un des outils qu’elle fournit est un ORM (Object Relational Mapper).
C’est ce qu’on va utiliser pour créer et travailler avec la base person sous Python.
La Programmation Orientée Objet (P.O.O.) permet de lier les données et leur manipulation, c’est-à-dire les fonctions qui opèrent sur les données.
En créant des classes SQLAlchemy, on sera à même de relier les champs de la base de données à certains types de manipulations, ce qui permettra d’interagir avec les données.
La définition en termes de classes SQLAlchemy des données présente dans la table person est la suivante :
La classe Person hérite de db.model, qui fournit les attributs et fonctionnalités permettant de manipuler des bases de données.
Le reste des définitions correspondent à des attributs de classes.
__tablename__ = person fait le lien entre la classe Person et la table person.
person_id = db.Column(db.Integer, primary_key = True) crée une colonne dans la base de données contenant un entier utilisé comme clef primaire. Cela implique que person_id est une valeur entière auto-incrémentée.
lname = db.Column(db.String) crée le champ « nom de famille », c’est-à-dire une colonne de la base contenant des chaînes de caractères.
name = db.Column(db.String) crée le champ « prénom », c’est-à-dire une colonne de la base de données contenant des chaînes de caractères.
timestamp = db.Column(db.DateTime, default = datetime.utcnow, on update = datetime.utcnow)crée un champ « timestamp », c’est-à-dire une colonne dans la base de données contenant des valeurs date/heure. Le paramètre default = datetime.utcnow fixe comme valeur pour défaut pour le « timestamp » la valeur courante utcnow lorsqu’un enregistrement est créé. De même, on update = datetime.utcnowmet à jour le « timestamp » avec la valeur utcnow lorsque l’enregistrement est mis à jour.
Pourquoi utiliser utcnow comme « timestamp » ?
En fait la méthode datetime.utcnow( )renvoie l’instant UTC courant (UTC : Universel Temps Coordonné). C’est une façon de standardiser le « timestamp ».
Cela permet notamment d’effectuer des calculs plus simples sur les dates/heures.
Si on accède à l’application depuis des créneaux horaires différents, il suffit de connaître le créneau horaire pour convertir les dates/heures.
Ce serait plus compliqué si on utilisait un « timestamp » en dates/ heures locales.
Que nous apporte la classe Person ?
L’idée est de pouvoir requêter la table à l’aide de SQLAlchemy et d’obtenir comme résultat une liste d’instances de la classe Person.
A titre d’exemple, reprenons la requête SQL précédente :
SQL
SELECT * FROM people ORDER BY lname; |
En utilisant SQLAlchemy, la requête prend la forme suivant :
Ignorons la ligne 1 pour l’instant.
L’instruction SQLAlchemy Person.query.order_by(Person.lname).all( )fournit une liste d’objets de classe Person correspondant à tous les enregistrements de la table person, triée par nom de famille.
La variable people contient la liste d’objets.
Ensuite, le programme itère sur la variable people, en affichant successivement le prénom et le nom de chaque personne présente dans la base.
Notons que le programme ne fait pas appel à l’indexation pour accéder aux champs fname et lname ; il utilise les attributs des objets Person.
Utiliser SQLAlchemy autorise à penser en termes d’objets plutôt qu’en termes de requêtes SQL. Plus la base de données est importante, plus c’est pratique et intéressant.
7. Sérialisation
Travailler avec le modèle SQLAlchemy est très pratique en termes de programmation.
C’est surtout pratique quand on écrit des programmes manipulant les données et effectuant des calculs sur celles-ci.
Toutefois, l’application qu’on a en vue ici est une API REST qui fournit des opérations CRUD sur les données et qui, de ce fait, n’implique pas des manipulations de données très diverses ou compliquées.
Notre API REST travaille avec des données au format JSON, ce qui peut poser un problème de compatibilité avec le modèle SQLAlchemy.
Comme les données renvoyées par SQLAlchemy sont des objets Python (des instances de classes Python), le module Connexion ne peut les convertir au format JSON.
Rappelons que Connexion est le module utilisé pour implémenter notre API, configurée à l’aide d’un fichier YAML.
La sérialisation est l’opération consistant à convertir un objet Python en structures de données plus simples, qui peuvent être formatées en utilisant les types de données JSON (JSON datatypes), ces derniers étant listés ci-dessous :
- string : chaîne de caractères
- number : nombres autorisés par Python (integers, floats, longs)
- object : équivalent grossièrement à des dictionnaires Python
- array : grossièrement équivalent aux listes Python
- boolean : prenant sous JSON les valeurs true ou false mais True ou False sous Python
- null : équivalent du None sous Python
A titre d’exemple, la classe Person contient un « timestamp », qui est un objet DateTime Python.
Il n’y a pas d’équivalent sous JSON, donc l’objet DateTime doit être converti en chaîne de caractères pour être manipulé sous JSON.
La classe Person est en fait suffisamment simple pour qu’on puisse envisager d’en extraire les attributs et de créer manuellement des dictionnaires correspondant aux URL de notre API.
Dans des situations plus complexes, avec des modèles SQLAlchemy plus importants, ce n’est plus le cas.
On a donc recours à un module, nommé Marshmallow, qui fera le travail pour nous.
8. Marshmallow
Marshmallow permet de créer une classe PersonSchema, qui est le pendant de la classe Person qu’on a créée sous SQLAlchemy.
Toutefois, au lieu de mettre en correspondance les tables de la base de données et leurs champs avec des classes et leurs attributs , la classe PersonSchema définit la façon dont les attributs d’une classe seront converties en un format JSON.
Voici la définition de classe Marshmallow pour les données de notre table person :
La classe PersonSchema hérite de la classe ma.ModelSchema.
Celle-ci est une classe de base Marshmallow et fournit un ensemble d’attributs et de fonctionnalités qui vont permettre de sérialiser des objets Python Person au format JSON et de désérialiser(l’opération inverse) des données JSON sous la forme d’instances de la classe Person.
Le reste de la définition se déroule de la façon suivante :
class Meta : définit une classe nommée Meta au sein de notre classe. La classe ModelSchema dont hérite PersonSchema recherche cette classe interne Meta et l’utilise pour trouver le modèle SQLAlchemy Person et la session de base de données db.session. C’est ce qui permet à Marshmallow de sérialiser/désérialiser les attributs de la classe Person.
model : spécifie le modèle SQLAlchemy à utiliser pour sérialiser/désérialiser des données.
db.session : spécifie la session de base de données à utiliser pour l’introspection c’est-à-dire l’examen et la détermination des types des attributs.
9. Création de la base de données
À ce stade, on a résolu d’utiliser SQLALchemy pour manipuler notre base de données, ce qui permet de se concentrer sur le modèle de données et la façon de les manipuler.
À présent, on va créer notre base de données.
À cet effet, on va utiliser SQLite.
On utilisera SQLite pour deux raisons :
- Elle est fournie par défaut avec Python et n’a donc pas à être installée comme un module séparé.
- Elle sauvegarde toute l’information dans un seul fichier et, de ce fait, elle est simple à configurer et à utiliser.
Il serait possible d’utiliser un serveur de bases de données séparé, du type MySQL ou PostgreSQL, mais cela impliquerait d’installer ces systèmes et de les mettre en route, ce qui est bien au-delà du propos de cette série d’articles.
Comme la base de données est manipulée à l’aide de SQLAlchemy, les détails de son implémentation ne sont pas importants.
Nous allons créer un programme build_database.py afin de créer et d’initialiser la base de données SQLite people.db qui contiendra nos données.
Ce faisant, on va créer deux modules additionnels config.py et models.py qui seront utilisés par build_database.py et server.py.
config.py : se chargera d’importer et de configurer les différents modules requis. Cela inclura Flask, Connexion,SQLAlchemy et Marshmallow. Comme il sera utilisé à la fois par build_database.py et server.py, une partie de la configuration ne s’appliquera qu’à server.py.
models.py : c’est le module qui se chargera de créer la classe SQLAlchemy Person et la classe Marshmallow PersonSchema. Ce module dépendra du module config.py via certains objets créés et configurés dans ce dernier.
Le module config
Le module config.py regroupe toute l’information de configuration.
Voici le code correspondant :
Les lignes 2-4 importent Connexion, SQLAlchemy et Marshmallow.
La ligne 6 crée la variable basedir qui pointe vers le répertoire dans lequel le programme est exécuté.
La ligne 9 utilise la variable basedir pour créer l’instance de l’application Connexion et lui fournit le chemin vers le fichier de configuration swagger.yml.
La ligne 12 crée une variable app qui correspond à l’instance Flask initialisée par Connexion.
La ligne 15 utilise la variable app pour configurer SQLAlchemy.
On commence par fixer SQLAlchemy_ECHO à True, ce qui a pour effet de renvoyer les requêtes SQL exécutées par SQLAlchemy à la console.
C’est très utile en phase de mise au point et de débogage.
En environnement de production, on la fixe à False.
La ligne 19 fixe SQLALCHEMY_DATABASE_URI à « sqlite://// » + os.path.join(basedir, « people.db »).
Cela indique à SQLALchemy d’utiliser SQLite comme base de données et un fichier nommé people.db dans le répertoire courant comme fichier de données.
D’autres moteurs de bases de données, comme MySQL ou PostgreSQL, seraient configurés avec une SQLALCHEMY_DATABASE_URI différente.
La ligne 20 fixe SQLALCHEMY_TRACK_MODIFICATIONS à False, ce qui a pour effet de désactiver le système de gestion d’événements SQLAlchemy, qui est actif par défaut.
Ce système est utile pour les programmes basés sur des évènements (clicks, …) mais ralentit l’exécution. Notre programme n’étant pas basé sur des événements, on le désactive.
La ligne 22 crée la variable db en faisant appel à SQLAlchemy(app).
Cela initialise SQLAlchemy avec l’information de configuration qu’on vient de spécifier. La variable db sera importée dans le programme build_database.py pour lui donner accès à SQLAlchemy et à la base de données. Il en sera de même dans les modules server.py et people.py.
La ligne 25 crée la variable ma en faisant appel à Marshmallow(app). Cela initialise Marshmallow et autorise l’introspection sur les composantes SQLAlchemy attachées à la base de données. Marshmallow doit de ce fait être initialisé après SQLAlchemy.
Le module Models
Le module models.py est utilisé pour définir les classes SQLAlchemy Person et Marshmallow PersonSchema, comme vu précédemment.
Voici le code correspondant :
La ligne 1 importe la classe datetime du module datetime disponible par défaut sous Python.
La ligne 2 importe les objets db et ma du module config.py. Cela donne au programme l’accès aux attributs SQLAlchemy et aux méthodes attachés à l’objet db, ainsi qu’aux attributs Marshmallow et aux méthodes attachés à l’objet ma.
Les lignes 4 à 11 définissent la classe Person de façon à ce qu’elle bénéficie des fonctionnalités SQLAlchemy, telles que la connexion à une base de données et l’accès à ses tables.
Les lignes 14 à 17 définissent la classe PersonSchema de façon à ce qu’elle bénéficie des fonctionnalités Marshmallow. Ainsi, l’introspection sur la classe Person permet de sérialiser/désérialiser les instances de cette classe.
Création de la base de données
On a vu comment les tables d’une base de données pouvaient être mises en correspondance avec des classes SQLAlchemy.
À présent, on va créer la base de données et lui affecter des données.
À cet effet, on va utiliser le programme build_database.py suivant :
La ligne 2 importe l’objet db du module config.py.
La ligne 3 importe la classe Person du module models.py.
Les lignes 6 à 10 créent la structure PEOPLE, qui est une liste de dictionnaires contenant les données. Notons qu’une telle structure peut facilement être créée à partir d’un fichier CSV par exemple.
Les lignes 13 et 14 font un peu de ménage en effaçant le fichier people.db s’il existe déjà. Cela permet de s’assurer qu’on repart de zéro si on doit recréer la base de données.
La ligne 17 crée la base de données à l’aide de l’appel db.create_all( ). Cela crée la base de données en utilisant l’instance db importée du module config. L’instance db est notre connexion à la base de données.
Les lignes 20 à 22 itèrent sur la liste PEOPLE et utilisent les dictionnaires qu’elle contient pour instancier la classe Person.
Après que celle-ci soit instanciée, on appelle la fonction db.session.add(p).
L’instance de connexion db est utilisée pour accéder à l’objet session.
La session est ce qui gère les actions effectuées sur la base de données.
Ici, on exécute la méthode add(p) pour ajouter une nouvelle instance de Person à l’objet session.
La ligne 24 appelle db.session.commit( ) pour sauvegarder l’ensemble des objets Person créés dans la base de données.
À la ligne 22, rien n’est encore sauvegardé dans la base de données ; tout est sauvegardé dans l’objet session. C’est seulement suite à l’exécution de db.session.commit( ) que la session interagit avec la base de données et lui applique les actions spécifiées.
Sous SQLAlchemy, la session est un objet important. Celle-ci relie la base de données et les objets SQLAlchemy manipulés dans un programme Python.
Elle permet de maintenir la cohérence entre les données du programme et les données de la base de données.
Toutes les actions effectuées y sont sauvegardées et elle met à jour la base de données en fonction des actions explicites ou implicites engagées dans le programme.
On est à présent en mesure de créer et d’initialiser la base de données.
Il suffit d’exécuter le programme build_database.py, par exemple sous Spyder.
Lorsque le programme est exécuté, il affiche les messages de SQLAlchemy sur la console. C’est dû au fait qu’on a fixé SQLALCHEMY_ECHO à True dans le module config.py.
L’essentiel de ce qui est affiché correspond aux requêtes SQL générées par SQLAlchemy pour créer et renseigner le fichier de base de données people.db.
Nous verrons dans le prochain article comment mettre à jour notre API à l’aide de tous les outils dont nous disposons à présent.
10. Références bibliographiques
- Creating Web APIs with Python and Flask, Patrick Smyth, 2022 : https://programminghistorian.org/en/lessons/creating-apis-with-python-and-flask.
- Python REST APIs With Flask, Connexion, and SQLAlchemy, Doug Farrell, 2022 : https://realpython.com/flask-connexion-rest-api/.
- Python REST APIs With Flask, Connexion, and SQLAlchemy, Doug Farrell, 2022 : https://realpython.com/flask-connexion-rest-api-part-2/.
- Flask RESTful documentation, 2020 : https://flask-restful.readthedocs.io/en/latest/index.html.
- Flask Web Development : Developing Web Applications with Python (2ème édition). M. Grinberg. O’Reilly 2018.
- Architectural Styles and the Design of Network-Based Software Architectures. T. Fielding. Thèse, Université de Californie, 2000.