GraphQL : encapsulation dans HTTP

Présentation

Dans un précédent article, nous avons vu comment créer un serveur d’API GraphQL. Il restait toutefois un point rédhibitoire : le service n’était pas exposé à l’extérieur.
Cet article va répondre à ce problème en montrant un exemple d’encapsulation dans HTTP.

 

Implémentation

Recommandations officielles GraphQL

Le site officiel du langage GraphQL donne des recommandations sur l’encapsulation dans HTTP.
Pour résumer, on peut retenir :

  • L’utilisation d’un point d’entrée unique : Par convention, « /graphql ».
  • La possibilité de passer les requêtes via le verbe GET : Dans ce cas, la requête aura la forme « http://[server]:[port]/[path]?query=[query]&variables=[variables] » (avec les variables « query » pour la requête et « variables » dont le nom est explicite).
  • Mais aussi par POST : Le corps du message sera alors au format JSON ‘{« query »:[query], »variables »:[variables]}’.
  • La réponse sera aussi au format JSON : avec la forme ‘{« data »: [data], »errors »: [errors]}’ (avec « data » pour les résultats » et « errors » pour toutes les erreurs rencontrées).

Échanges client / serveur

Pour ce qui est des échanges client/serveur, il faut prévoir 2 types de flux résumés dans ce schéma :

Échanges client / serveur
Échanges client / serveur

Requêtes synchrones (Query & Mutation)

Ici, quand un client envoie une requête, il ne continuera son exécution qu’après avoir reçu la réponse. C’est le mode de fonctionnement normal de HTTP ainsi que des fonctions de lecture/écriture classique (sur service web ou une base de données relationnelles).
Ce mode convient dans le langage GraphQL pour les « Query » et « Mutation ». C’est aussi le plus simple à implémenter puisque l’on a un dialogue alterné entre 2 machines (analogie possible avec une conversation orale normale).

Requêtes asynchrones (Souscription)

Ici, quand un client envoie une requête, il continue son exécution sans attendre la réponse. Cette réponse arrivera plus tard et sera traitée dans un autre processus (analogie possible avec une correspondance par échange de courriers). Ce mode de communication correspond à une « Subscription » dans le langage GraphQL mais HTTP ne fonctionne pas comme cela.
Pour cela, nous allons diviser techniquement le flux en 2 parties indépendantes :

  • La 1ère pour transmettre la requête d’abonnement qui contient (entre autre) l’adresse où renvoyer les réponses.
    Le processus principal du serveur GraphQL se chargera de recevoir la requête. Il répondra par un simple acquittement pour valider l’adresse de retour.
    Note : Rien n’interdit d’envoyer une adresse de retour qui n’est pas celle de l’expéditeur. Il faut juste que cette adresse soit valide.
  • La 2nde pour renvoyer ces réponses quand nécessaire.
    Côté serveur, un processus spécifique pour chaque souscription sera créé, par le processus principal, pour émettre ces réponses. Ce processus s’attendra à ce que l’abonné acquitte la réception du message.

Mise en pratique

Ces échanges seront implémentés sur le serveur de cette façon :

Implémentation
Implémentation
  • Processus principal « HTTPRequestHandler » : c’est lui qui va recevoir toutes les requêtes.
    Si elles sont synchrones, il les exécutera et renverra les réponses immédiatement.
    Sinon, une fois l’adresse de retour validée, il va lancer un …
    Pour le code :

    class HTTPRequestHandler(BaseHTTPRequestHandler):
        # INFO : in a real application, those fields should be dynamically parametrised
        host = '0.0.0.0'
        port = 8081
        expectedPath = "/graphql"
        # intercept GET request
        def do_GET(self):
            # specific input parser
            # INFO : GET request is like : http://[server]/[path]?query=[query]&variables=[variables]
            def getParser(self):
                query = urlsplit(self.path)[3]
                parameters = parse_qs(query)
                request = parameters.get("query")[0] # text
                variables = loads(parameters.get("variables")[0]) # JSON/dict
                return request, variables
            # do execution
            self.execute(getParser)
        # intercept POST request
        def do_POST(self):
            # specific input parser
            # INFO : POST body is like : {'query':[query],'variables':[variables]}
            def postParser(self):
                bodyLength = int(self.headers.get("content-length"))
                parameters = loads(self.rfile.read(bodyLength).decode())
                request = parameters.get("query") # text
                variables = parameters.get("variables") # JSON/dict
                return request, variables
            # do execution
            self.execute(postParser)
        # execute action
        def execute(self,specificInputParser):
            message = None
            try :
                # parse & check path
                path = urlsplit(self.path)[2]
                if path.upper() != HTTPRequestHandler.expectedPath.upper():
                    raise Exception("Expected path : " + HTTPRequestHandler.expectedPath)
                # parse input
                request, variables = specificInputParser(self)
                # asynchronous subscription
                firstWord = request.split(maxsplit=1)[0]
                if firstWord.upper() == "SUBSCRIPTION":
                    # check if call back exists
                    if "callbackEndpoint" not in variables:
                        raise Exception("Please define an callback endpoint")
                    try:
                        callbackEndpoint = urlsplit(variables["callbackEndpoint"])
                        callbackHost, callbackPort = (callbackEndpoint[1]).split(':')
                        callbackConnection = HTTPConnection(callbackHost, callbackPort)
                        callbackPath = callbackEndpoint[2]
                        # INFO : here the endpoint must respond
                        callbackConnection.request("POST", callbackPath)
                        # WARN : always acknowledge the response to unlock socket
                        callbackConnection.getresponse()
                    except Exception as exeception:
                        # manage error
                        raise Exception("Your callbackendpoint ("+variables["callbackEndpoint"]+") is not responding")
                    # run subscription
                    SubscriptionThread(request, variables).start()
                    message = dumps({"data" : "Check the callback endpoint for subscription result"})
                # synchronous query/mutation
                else:
                    # execute query & format response
                    rawResponse = schema.execute(request, variables=variables)
                    message = formatJsonResponse(rawResponse)
            except Exception as exeception:
                # manage error
                message = dumps({"errors": [str(exeception)]})
            finally:
                # finalize response
                '''INFO : server will always send a technically fine response
                if any error occurs, it will be set in errors array'''
                self.send_response(200)  # OK
                self.send_header('Content-type', 'text/json')
                self.end_headers()
                self.wfile.write(bytes(message, "utf8"))
  • … Thread dédié à l’abonnement « SubscriptionThread » : pour chaque requête d’abonnement, une instance spécifique est créée.
    A l’intérieur de cette classe, on a une sous-classe de type « Observer » qui déclenchera les requêtes au fil du temps (article précédent) et enverra la réponse à l’adresse demandée.
    Pour le code :

    class SubscriptionThread(Thread):
        # inner observer
        class InnerObserver(SubscriptionObserver):
            # inheritance
            def on_next(self, value):
                rawResponse = schema.execute(self.query, variables=self.variables)
                jsonResponse = formatJsonResponse(rawResponse)
                # WARN : always acknowledge the response to unlock socket
                self.callbackConnection.getresponse()
            # constructor
            def __init__(self, request, parameters):
                super().__init__(request, parameters)
                # set callback connection
                callbackEndpoint = urlsplit(parameters["callbackEndpoint"])
                callbackHost, callbackPort = (callbackEndpoint[1]).split(':')
                self.callbackConnection = HTTPConnection(callbackHost, callbackPort)
                self.callbackPath = callbackEndpoint[2]
        # inheritance
        def run(self):
            observable = schema.execute(self.request, variables=self.parameters, allow_subscriptions=True)
            # check if query/variable error
            # INFO : on query/variable error, we get an standard JSON response, not an observable
            observer = self.observer
            if hasattr(observable,"errors"):
                jsonResponse = formatJsonResponse(observable)
                observer.callbackConnection.request("POST", observer.callbackPath, jsonResponse)
                # WARN : always acknowledge the response to unlock socket
                observer.callbackConnection.getresponse()
            else :
                # INFO : timestamp decorator function increment a loop counter name 'value'
                observable.interval(observer.period * 1000).timestamp().filter( lambda o: o.value < observer.occurencesNumber).subscribe(observer=observer)
                # wait expected time before shuting down thread
                sleep(observer.period * (observer.occurencesNumber + 1))
                observer.callbackConnection.close()
        # constructor
        def __init__(self, request, parameters):
            super().__init__()
            self.request = request
            self.parameters = parameters
            # set callback connection
            self.observer = SubscriptionThread.InnerObserver(self.request, self.parameters)
  • Point de réception des abonnements « HTTPCallbackStub » : un serveur HTTP basique et indépendant qui se contente d’afficher toutes les requêtes reçues (via POST).
    Son code se contente juste de :

    class HTTPCallbackStub(BaseHTTPRequestHandler):
        # INFO : in a real application, those fields should be dynamically parametrised
        host = '0.0.0.0'
        port = 8082
        expectedPath = "/dummyCallback"
        def do_POST(self):
            self.send_response(200)  # OK
            self.end_headers()
            bodyLength = int(self.headers.get("content-length"))
            content = dumps(loads(self.rfile.read(bodyLength).decode()), indent=4)
            print(asctime(localtime()) + '\n' + content)

 

Tests

Pour notre exemple, nous allons utiliser les machines suivantes :

  • Un serveur GraphQL pour recevoir et traiter les requêtes. Son IP, utilisée dans la barre d’adresse de GraphIQL : 192.168.72.128
  • Un client GraphiQL pour envoyer les requêtes et recevoir les réponses synchrones Query/Mutation (ou un simple acquittement pour les asynchrones). Son IP n’a pas d’importance.
  • Un serveur dédié à la réception et l’affichage des réponses asynchrones Subscription. Son IP, utilisée dans les paramètres d’abonnement comme adresse de réponse : 192.168.72.129

Pour le client, on va utiliser l’outil : GraphiQL. C’est un client simple d’utilisation et assez intuitif, surtout si l’on a déjà utilisé des outils similaires (Postman par exemple pour tester des API REST).
Pour résumer, les éléments minimaux pour nos tests :

  • Une barre d’adresse pour saisir le point d’entrée du service : tout en haut
  • Une zone pour la requête : à gauche en haut
  • Une zone pour les paramètres: à gauche en bas
  • Une zone pour afficher la réponse : à droite
GraphiQL
GraphiQL

Requêtes synchrones (Query & Mutation)

Nous allons commencer le 1er test avec une requête de lecture sur une adresse :

Exemple de requête de lecture
Exemple de requête de lecture

Note : La barre d’adresse référence notre serveur GraphQL (IP #128).
Ensuite, on peut tester une mutation, comme insérer un nouvel article :

Création d'un article
Création d’un article

Enfin, on peut tester les erreurs en essayant de réinsérer le même article :

Erreur : insertion d'un doublon
Erreur : insertion d’un doublon

Requêtes asynchrones (Souscription)

On commence par demander un abonnement sur les articles servant à visser/dévisser (« screw » dans la langue de Shakespeare) :

Requête d'abonnement
Requête d’abonnement

Note :

  • Les paramètres contiennent l’adresse de renvoi des informations récupérées (IP #129).
  • La zone de réponse nous dit de vérifier les résultats à cette adresse de renvoi.

En se connectant à l’adresse de renvoi, on peut voir les messages reçus au cours du temps :

192.168.72.128 - - [16/Nov/2018 16:41:52] "POST / HTTP/1.1" 200 -
Fri Nov 16 16:41:52 2018
{
    "data": {
        "readItems": []
    }
}
...
192.168.72.128 - - [16/Nov/2018 16:41:55] "POST / HTTP/1.1" 200 -
Fri Nov 16 16:41:55 2018
{
    "data": {
        "readItems": []
    }
}
192.168.72.128 - - [16/Nov/2018 16:41:56] "POST / HTTP/1.1" 200 -
Fri Nov 16 16:41:56 2018
{
    "data": {
        "readItems": [
            {
                "name": "screwdriver",
                "price": 1.0
            },
            {
                "name": "corkscrew",
                "price": 7.8
            }
        ]
    }
}
...
192.168.72.128 - - [16/Nov/2018 16:42:00] "POST / HTTP/1.1" 200 -
Fri Nov 16 16:42:00 2018
{
    "data": {
        "readItems": [
            {
                "name": "screwdriver",
                "price": 1.0
            },
            {
                "name": "corkscrew",
                "price": 7.8
            }
        ]
    }
}
192.168.72.128 - - [16/Nov/2018 16:42:01] "POST / HTTP/1.1" 200 -
Fri Nov 16 16:42:01 2018
{
    "data": {
        "readItems": []
    }
}
...
192.168.72.128 - - [16/Nov/2018 16:42:11] "POST / HTTP/1.1" 200 -
Fri Nov 16 16:42:11 2018
{
    "data": {
        "readItems": []
    }
}

Note :

  • Au départ, il n’existe aucun article correspondant à nos critères.
  • Ensuite, on insère 2 articles (screwdriver / tournevis et corkscrew / tire-bouchon).
  • A la fin, on supprime ces articles.

Il ne reste plus qu’à essayer un abonnement vers une adresse non prévue (ici localhost) :

Erreur d'abonnement : mauvaise redirection
Erreur d’abonnement : mauvaise redirection

Note : La zone de réponse indique clairement que l’adresse de réception n’est pas valide.

 

Bilan

Le site officiel du langage GraphQL permet de standardiser le point d’entrée ainsi que le contenu des requêtes GET et POST.
Pour les requêtes synchrones, l’encapsulation d’une API GraphQL dans HTTP se fait en suivant simplement les standards du protocole.
Pour les souscriptions, le problème est plus complexe. En effet, une souscription est asynchrone alors que HTTP est synchrone.
Pour cela, il faut prévoir :

  • Un 2nd point d’entrée pour recevoir le flux au fil de l’eau. Il peut s’agir d’un autre service web indépendant qui traitera le flux selon le contexte.
  • Dans la requête initiale, prévoir une adresse de rappel pour joindre ce 2nd point d’entrée.
  • Pour chaque requête de souscription reçue ou chaque message de résultat envoyé, il faut toujours acquitter la réponse, même si cette réponse n’est pas utilisée par la suite. Une requête HTTP non acquittée peut bloquer les autres requêtes à venir et donc fortement perturber le service.
  • Les flux asynchrones sont complexes à déboguer et la remontée d’erreurs n’est pas simple non plus. Pensez donc à vérifier l’état du service de rappel avant d’acquitter la demande de souscription.

A la différence d’une API REST, à partir du moment où le serveur tourne et reçoit une requête, il doit renvoyer une réponse au statut OK (code HTTP 200). Toutes les erreurs doivent être renvoyées dans le champs « errors » prévu à cet effet.
Pour tester l’API, il existe l’utilitaire GraphiQL. Il s’agit d’un client (à l’instar de Postman pour REST) qui prend en entrée une URL, une requête et des paramètres GraphQL et affiche la réponse. On peut aussi paramétrer les entêtes HTTP si besoin (notamment pour la compression et la sécurité).
En parlant de compression et sécurité, il est possible de pousser plus loin l’encapsulation dans HTTP. Cela n’est pas propre à GraphQL mais concerne l’ensemble de services web. Il existe des recommandations de chiffrement (HTTPS), d’authentification (OAuth 2) et de compression (GZIP) sur le site officiel de l’API. Ces sujets sont trop génériques pour être abordés dans cet article mais il est bon de les garder à l’esprit dans un projet en conditions réelles.

 

Références

 

 

1 réflexion sur “GraphQL : encapsulation dans HTTP”

  1. Ping : GraphQL : interfaçage avec OSB - EASYTEAM

Les commentaires sont fermés.