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 :
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 :
- 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
Requêtes synchrones (Query & Mutation)
Nous allons commencer le 1er test avec une requête de lecture sur une adresse :
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 :
Enfin, on peut tester les erreurs en essayant de réinsérer le même article :
Requêtes asynchrones (Souscription)
On commence par demander un abonnement sur les articles servant à visser/dévisser (« screw » dans la langue de Shakespeare) :
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) :
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
- Généralités sur HTTP : Hypertext Transfer Protocol
- Généralités sur les bonnes pratiques GraphQL : GraphQL Best Practices
- Encapsulation dans HTTP : Serving over HTTP
- Authentification : Authentication and Express Middleware
- Gestion des autorisations (utilisateur, session, contexte) : Authorization
- Récupération de GraphiQL : GraphiQL.app
- Présentation de GraphiQL : GraphiQL: GraphQL’s Killer App
- 1ère partie de cette série d’articles : GraphQL : exemple de création d’un serveur
- Le code de l’exemple utilisé pour cet article : dummyPythonGraphQLServer
1 réflexion sur “GraphQL : encapsulation dans HTTP”
Ping : GraphQL : interfaçage avec OSB - EASYTEAM
Les commentaires sont fermés.