Tutoriel sur GraphQL

1. Objectif
Nous allons ici apprendre les bases de l’utilisation en Python (avec
Flask et Ariadne) de GraphQL. Nous tentons ici de mettre en avant les
avantages de GraphQL au travers d’une utilisation concrète sur le service
Movie précédemment codé en REST et en gRPC.
2. Installation de Ariadne et GraphQL
-
Créez votre répertoire de travail, placez-y un répertoire
datacontenant le fichiermovie.jsonprécédemment utilisé. -
Placez vous dans votre environnement virtuel
Python 3contenant déjàFlask -
Intallez le paquet
ariadnequi est une librairie clienteGraphQLorientée "schéma"
mkdir mon_path
cd mon_path
mkdir data
cp chemin_vers_moviejson/movie.json ./data
cp -r chemin_vers_venv/venv ./
source venv/bin/activate
pip3 install ariadne
|
Note
|
Nous pourrions aussi utiliser Graphene au lieu de Ariadne, mais que
je trouve personnellement moins facile d’accès, détails sur
Graphene ici. Page de Ariadne ici.
|
|
Important
|
Comme pour les tutos et TPs précédents, il faut adapter les
commandes ci-dessous à votre OS et votre environnement. Par exemple sous
windows le chemin d’activation de l’environnement virtuel est différent, et
vous n’avez peut être pas besoin d’utiliser pip3 mais pip.
|
3. Ré-écrriture d’un point d’entrée GET de Movie en GraphQL
Nous allons dans un premier temps coder le service Movie en GraphQL avec
uniquement le service de pouvoir demander les informations relatives à un
film à partir de son ID.
3.1. Un premier schéma
Nous allons créer un fichier schéma GraphQL. Ce fichier est relativement
proche d’un fichier proto en gRPC et contient l’ensemble des requêtes
qu’il est possible de faire sur le service spécifié, ainsi que les types
associés.
touch movie.graphql
Voici le contenu de ce fichier. Nous avons ici la déclaration d’une seule
requête nommée movie_with_id qui prend en argument un String obligatoire
(!) que nous nommons _id, et qui retourne un élément de type Movie. Ce
type est décrit juste après comme un objet constitué des fields suivants : un
ID, un titre, un directeur, et une note.
type Query {
movie_with_id(_id: String!): Movie
}
type Movie {
id: String!
title: String!
director: String!
rating: Float!
}
3.2. Un premier "Resolver"
Un "resolver" est une fonction qui permet de résoudre le type d’un field
lorsque celui-ci n’est pas Scalar. Ils permettent donc de résoudre les
requêtes en parcourant les fields du schéma GraphQL. Créons un fichier
spécifique qui contiendra l’ensemble des fonctions de résolution.
touch resolvers.py
Nous allons y définir une unique fonction de résolution pour le moment que
nous allons appeler par simplicité comme la requête du schéma
movie_with_id, mais nous aurions pu l’appeler autrement.
La signature d’un "resolver" est particulière, il s’agit d’une fonction
appelée par GraphQL lors de la résolution d’un field. Le premier
argument de la fonction correspond à l’objet parent du field. Cela
parait pour le moment un peu flou mais s’éclairera par la suite. Ici il n’y a
pas de field parent car ce resolver est appelé directement depuis la requête
(objet racine _). Le deuxième argument correspond aux informations de la
requête, puis viennent ensuite les arguments correspondant aux entrées de la
requête comme déclarées dans le schéma : ici donc _id.
Dans ce resolver nous allons simplement charger en lecture le fichier movie
.json puis y chercher l’entrée correspondante à _id. Une fois trouvé nous
retournons le film associé.
import json
def movie_with_id(_,info,_id):
with open('{}/data/movies.json'.format("."), "r") as file:
movies = json.load(file)
for movie in movies['movies']:
if movie['id'] == _id:
return movie
Ariadne s’occupe de faire la correspondance entre le format json du film
retourné et les attributs du type Movie déclaré dans le schéma, à condition
que les clés correspondent aux "fields" du type déclaré. Ici nous avons bien
correspondance :
{
"title": "The Good Dinosaur",
"rating": 7.4,
"director": "Peter Sohn",
"id": "720d006c-3a57-4b6a-b18f-9b713b073f3c"
}
type Movie {
id: String!
title: String!
director: String!
rating: Float!
}
3.3. Création des points d’entrée, types, bindings et schémas
Nous devons maintenant faire en sorte que GraphQL intègre le schéma défini
et associe ses fields objets (non scalar) aux resolvers associés.
GraphQL (dans sa version Ariadne ou Graphene) s’intègre très bien à
Flask. Nous allons créer un fichier Python Flask movie.py correspondant à
notre service.
Voici le fichier de base de notre service avec un point d’entrée racine. Nous
voyons déjà que nous y importons des objets de Ariadne que nous
détaillerons ensuite. Nous importons aussi le resolver que nous nommons r.
from ariadne import graphql_sync, make_executable_schema, load_schema_from_path, ObjectType, QueryType
from ariadne.constants import PLAYGROUND_HTML
from flask import Flask, request, jsonify
import resolvers as r
PORT = 5000
HOST = 'localhost'
app = Flask(__name__)
# todo create elements for Ariadne
# root message
@app.route("/", methods=['GET'])
def home():
return make_response("<h1 style='color:blue'>Welcome to the Movie service!</h1>",200)
if __name__ == "__main__":
print("Server running in port %s"%(PORT))
app.run(host=HOST, port=PORT)
GraphQL utilise simplement un point d’entrée pour s’intégrer à Flask sur
une méthode GET et une méthode POST. La requêtre HTTP/1.1 de type GET
permet d’accéder au "playground" GraphQL permettant de mettre en forme ses
requêtes. Le deuxième HTTP/1.1 de type POST permet d’envoyer ses requêtes GraphQL.
@app.route('/graphql', methods=['GET'])
def playground():
return PLAYGROUND_HTML, 200
@app.route('/graphql', methods=['POST'])
def graphql_server():
# A COMPLETER
Pour compléter la requête de type POST nous allons devoir créer un certain
nombre d’éléments juste après le commentaire # todo create elements for
Ariadne.
Tout d’abord, il nous faut charger les types déclarés dans le schéma GraphQL
type_defs = load_schema_from_path('movie.graphql')
Nous devons ensuite créer les objets associés au schéma. Ici nous avons pour
le moment deux types d’objets, Query et Movie.
query = QueryType()
movie = ObjectType('Movie')
Nous devons ensuite associer le "resolver" que nous avons codé à la requête associée dans le schéma.
query.set_field('movie_with_id', r.movie_with_id)
Enfin, nous créons un schéma dit exécutable avec les éléments précédents.
schema = make_executable_schema(type_defs, movie, query)
Voici maintenant le contenu du point d’entrée utilisant la méthode POST.
Nou y utilisons la fonction graphql_sync importée au départ et qui permet à
partir du body ou contenu de la requête POST, c’est-à-dire de la requête
GraphQL (ici placée dans data), de résoudre la requête en utilisant le
schéma créé précédemment. Cette fonction retourne le résultat de la requête et
un code de succès.
@app.route('/graphql', methods=['POST'])
def graphql_server():
data = request.get_json()
success, result = graphql_sync(
schema,
data,
context_value=None,
debug=app.debug
)
status_code = 200 if success else 400
return jsonify(result), status_code
3.4. Tests
Nous allons maintenant tester notre service et notre unique requête possible
movie_with_id. Commençons par lancer notre service :
python3 movie.py
Vous devriez observer la sortie habituelle Flask:
Server running in port 5000
* Serving Flask app "movie" (lazy loading)
* Environment: production
WARNING: This is a development server. Do not use it in a production deployment.
Use a production WSGI server instead.
* Debug mode: off
* Running on http://localhost:5000/ (Press CTRL+C to quit)
Allons maintenant dans un navigateur sur le point d’entrée /graphql. Nous
utilisons ici la méthode GET et nous allons donc arriver sur le
"playground" GraphQL qui permet d’écrire ses requêtes et de les envoyer au
service.
Pour commencer faisons la requête suivante :
query{
movie_with_id(_id:"96798c08-d19b-4986-a05d-7da856efb697") {
id
title
rating
director
}
}
Nous faison donc une requête query movie_with_id en donnant l’id
96798c08-d19b-4986-a05d-7da856efb697, et nous demandons l’ensemble des
données sur le film à savoir l’id, le titre, la note et le directeur. Vous
devez observer la réponse suivante en json :
{
"data": {
"movie_with_id": {
"director": "Jonathan Levine",
"id": "96798c08-d19b-4986-a05d-7da856efb697",
"rating": 7.4,
"title": "The Night Before"
}
}
}
C’est ici que nous allons voir la puissance de GraphQL puisque nous allons
pouvoir faire une requête contenant uniquement les données dont nous avons
besoin. Par exemple je ne peux avoir besoin que du titre et de la note du film :
query{
movie_with_id(_id:"96798c08-d19b-4986-a05d-7da856efb697") {
title
rating
}
}
{
"data": {
"movie_with_id": {
"rating": 7.4,
"title": "The Night Before"
}
}
}
GraphQL permet donc de s’adapter à chaque client et de n’envoyer sur le
réseau que les donées d’intérêt sans pour autant multiplier le nombre de
points d’entrée pour le service.
4. Ré-écriture d’un point d’entrée POST de Movie en GraphQL
Mais alors s’il y a un seul point d’entrée utilisant les méthodes GET et
POST comment spécifier une modification ou une création de données au
service comme nous l’aurions fait en REST avec les méthodes POST, PUT
et DELETE ?
4.1. Objets "mutation"
Avec un resolver on écrit le code que l’on souhaite exécuter pour résoudre la
valeur d’un field. Donc le resolver d’une requête pourrait venir modifier ou
supprimer des données. Toutefois il est préférable que l’utilisateur sache
qu’il va modifier des données et GraphQL offre donc le type Mutation.
Voici notre nouveau schéma :
type Query {
movie_with_id(_id: String!): Movie
}
type Mutation {
update_movie_rate(_id: String!, _rate: Float!): Movie
}
type Movie {
id: String!
title: String!
director: String!
rating: Float!
}
Nous avons ajouté une mutation update_movie_rate qui prend en entrée un
_id et une nouvelle note _rate à affecter au film correspondant à l’id. La
mutation nous retourne le film avec la note mise à jour.
4.2. Resolver d’une mutation
Tout comme pour une requête nous devons écrire un "resolver" pour une mutation.
def update_movie_rate(_,info,_id,_rate):
newmovies = {}
newmovie = {}
with open('{}/data/movies.json'.format("."), "r") as rfile:
movies = json.load(rfile)
for movie in movies['movies']:
if movie['id'] == _id:
movie['rating'] = _rate
newmovie = movie
newmovies = movies
with open('{}/data/movies.json'.format("."), "w") as wfile:
json.dump(newmovies, wfile)
return newmovie
Comme pour movie_with_id, le premier argument du resolver correspond à
l’objet parent dans la mutation. Ici il n’y a pas d’objet parent car ce
resolver est appelé directement à l’appel de la mutaion (type racine _). Le
deuxième argument correspond aux informations de la requête, puis viennent
ensuite les arguments correspondant aux entrées de la mutation donc _id et
_rate.
Dans ce resolver, nous commençons par trouver le film correspondant à _id,
nous mettons alors à jour sa note avec _rate. Nous récupérons les données
correspondante à ce film dans newmovie ainsi que plus globalement le
nouveau json modifié dans newmovies. Nous écrivons maintenant le nouveau
fichier movies.json et nous retournons en réponse de la requête le film
newmovie.
4.3. Création du type, binding et schéma associé
Nous devons maintenant associer le resolver à la mutation déclarée dans le schéma. Nous faisons donc les modifications suivantes :
mutation = MutationType()
mutation.set_field('update_movie_rate', r.update_movie_rate)
schema = make_executable_schema(type_defs, movie, query, mutation)
4.4. Tests
Une mutation marche donc en tout points comme une requête, mais permet à l’utilisateur de savoir qu’il va modifier des informations.
Relançons le servic Movie et allons dans le point d’entrée /graphql. Nous
allons maintenant faire la requête suivante :
mutation{
update_movie_rate(_id:"a8034f44-aee4-44cf-b32c-74cf452aaaae",_rate:8.4) {
title
rating
}
}
Ici nous mettons à jour la note du film "The Martian" à 8.4 au lieu de 8
.2, et nous demandons à GraphQL de nous retourner uniquement le titre et
la nouvelle note du film. Nous obtenons la réponse suivante et le fichier
movie.json a été modifié.
{
"data": {
"update_movie_rate": {
"rating": 8.4,
"title": "The Martian"
}
}
}
5. Evolution du service Movie
Nous allons faire évoluer le service Movie afin de voir un autre intérêt de
GraphQL en plus de pouvoir demander la forme de réponse de son choix et
d’éviter de transporter de l’information inutile sur le réseau.
5.1. Les acteurs actors.json
Pour cela nous allons ajouter un fichier data/actors.json qui est
téléchargeable sur Moodle. Un exemple d’acteur dans ce fichier :
{
"id": "actor4",
"firstname": "George",
"lastname": "Clooney",
"birthyear": 1961,
"films": ["a8034f44-aee4-44cf-b32c-74cf452aaaae"]
}
Vous pouvez voir que la définition d’un acteur contient une liste de films dans lesquels l’acteur a joué.
5.2. Reflexion sur une nouvelle fonctionnalité de notre service, comparaison REST et GraphQL
A partir de ce nouveau fichier nous allons pouvoir retourner en plus des
informations sur le film des informations sur les acteurs qui ont joué dedans
. Pour cela une jointure est nécessaire sur l’ID des films entre les
données contenues dans movie.json et actors.json.
En REST deux possibilités s’offrent à nous :
-
Modifier le point d’entrée existant
/movie/<movieid>de façon à ce que la réponse contienne également les informations sur les acteurs du film, mais l’inconvénient est que si des clients étaient satisfaits de l’information sur les films uniquement, plus de données leur seront transmises. -
Créer un nouveau point d’entrée spécifique par exemple
/movieactors/<movieid>
La puissance de GraphQL, et en particulier l’aspect hiérarchique des
"resolvers", va nous permettre de gérer ces nouvelles informations en fonction
du besoin des utilisateurs sans modifier les points d’entrée.
5.3. Nouveau schéma
Nous allons modifier le type Movie du schéma comme ceci :
type Movie {
id: String!
title: String!
director: String!
rating: Float!
actors: [Actor]
}
Nous y avons ajouté un field actors qui correspond à une liste d’acteurs de
type Actor défini comme ceci (en suivant les informations du fichier
actors.json) avec un identifiant, un prénom, un nom, une année de naissance
et une liste d’id de films :
type Actor {
id: String!
firstname: String!
lastname: String!
birthyear: Int!
films: [String!]
}
Ici donc la requête movie_with_id qui retourne un type Movie doit
également retourner une liste d’acteurs. Voyons ce qui se passe si nous
tentons de lancer notre service et de faire la requête suivante :
query{
movie_with_id(_id:"96798c08-d19b-4986-a05d-7da856efb697") {
title
rating
actors{
firstname
lastname
birthyear
}
}
}
La requête contient maintenant la description de la réponse attendue pour la liste des acteurs qui ont joué dans le film. Mais nous obtenons la réponse suivante :
{
"data": {
"movie_with_id": {
"actors": null,
"rating": 7.4,
"title": "The Night Before"
}
}
}
La liste des acteurs n’est pas trouvée et c’est bien normal puisque la clé
actors n’existe pas dans le json`du film et que nous n’avons codé nulle part
comment comment résoudre ce field (faire la jointure avec `actors.json) !
5.4. Resolver imbriqués
Ce qui se passe concrètement c’est que GraphQL résout la requête mais
lorsqu’il doit construire la liste des acteurs il ne sait pas comment le
résoudre. Nous allons donc lui offrir un resolver mais qui cette fois ne sera
pas rattaché à la requête principale directement (premier argument _) mais à
l’objet parent movie. Voici le code de notre resolver :
def resolve_actors_in_movie(movie, info):
with open('{}/data/actors.json'.format("."), "r") as file:
data = json.load(file)
actors = [actor for actor in data['actors'] if movie['id'] in actor['films']]
return actors
Le contexte dans lequel se trouve notre resolver est de connaître l’objet
movie duquel est apparu l’appel à ce resolver. Il a donc accès aux données
associées à ce film movie qui est un dictionnaire correspondant aux données
json. Notre resolver va ouvrir le fichier actors.json et va construire la
liste des acteurs dont le film d’intérêt movie['id'] se trouvera dans la
liste des films actor['films'].
|
Tip
|
Nous utilisons ici une liste compréhension Pyhton qui permet de
construire des listes de façon très concise et élégante.
|
5.5. Création du type, binding et schéma associé
Nous allons maintenant devoir déclarer le type actor
actor = ObjectType('Actor')
Nous allons devoir attacher notre resolver à la liste des acteurs à
construire dans le type Movie du schéma. Pour cela nous utilisons comme
avant la méthode set_field mais cette fois pour l’objet movie que nous
avons construit. Le field à résoudre dans le schéma est actors et nous y
associons notre resolver r.resolve_actors_in_movie.
movie.set_field('actors', r.resolve_actors_in_movie)
Enfin, à la création de notre schéma exécutable nous devons ajouter le type
actor.
schema = make_executable_schema(type_defs, movie, query, mutation, actor)
5.6. Tests
Relançons notre service et envoyons à nouveau notre requête :
query{
movie_with_id(_id:"96798c08-d19b-4986-a05d-7da856efb697") {
title
rating
actors{
firstname
lastname
birthyear
}
}
}
{
"data": {
"movie_with_id": {
"actors": [
{
"birthyear": 1974,
"firstname": "Leonardo",
"lastname": "DiCaprio"
},
{
"birthyear": 1964,
"firstname": "Monica",
"lastname": "Bellucci"
},
{
"birthyear": 1958,
"firstname": "Alain",
"lastname": "Chabat"
}
],
"rating": 7.4,
"title": "The Night Before"
}
}
}
Ainsi, sans modification des points d’entrée existants, ni sans création d’un
nouveau point d’entrée les clients peuvent s’ils le souhaitent demander des
informations sur les acteurs pour le film d’intérêt. Cela illustre la grande
flexibilité offerte par GraphQL dans la gestion des interfaces.
6. Pour terminer
Si l’on souhaite faire plusieurs requêtes ou plusieurs mutations, il faut les réunir dans le schéma sous cette forme :
type Query {
movie_with_id(_id: String!): Movie
actor_with_id(_id: String!): Actor
}
Enfin, vous pouvez tester l’envoi de requêtes à Movie en utilisant Postman
au lieu du "playground" GraphQL. Cela illustrera encore mieux qu’une
requête GraphQL est une requête HTTP/1.1 avec la méthode POST.