Tutoriel sur GraphQL

imt

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

  1. Créez votre répertoire de travail, placez-y un répertoire data contenant le fichier movie.json précédemment utilisé.

  2. Placez vous dans votre environnement virtuel Python 3 contenant déjà Flask

  3. Intallez le paquet ariadne qui est une librairie cliente GraphQL orienté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 :

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

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