# [FR] Workshop GraphQL-Ruby
Chez [Tymate](https://tymate.com), la question de l'utilisation de [GraphQL](https://graphql.org/) pour nos API se pose depuis longtemps. La plupart de nos applications desktop étant conçues avec React, c'était évidemment une demande récurrente de nos dévelopeurs front-end.
Je m'appelle [Julien](https://twitter.com/Sylfaen), j'ai 32 ans et je suis développeur Ruby depuis un peu plus d'un an, après une reconversion professionnelle. Cet article est une retranscription libre d'un workshop interne à Tymate au cours duquel j'ai présenté mon retour d'expérience sur une première utilisation de la gem [GraphQL Ruby](https://graphql-ruby.org).
La suite présume une connaissance sommaire du fonctionnement de GraphQL.
## Structure des données
La première chose à savoir en commençant à travailler avec GraphQL, c’est que tout est typé. Chaque donnée qu’on utilise ou qu’on veut sérialiser doit être clairement définie. Ce n'est pas une habitude courante dans une utilisation conventionnelle de Ruby. Mais cela s'avère rapidement très confortable.
### Types
On va donc naturellement commencer par définir des `Types`.
D’abord, on créera des `Types` pour les `models` de notre API dont on aura besoin pour nos requêtes GraphQL. La définition des `Types` GraphQL ressemble beaucoup à la logique qu’on retrouve dans les serializers sur nos API REST classiques, à savoir la définition des données qu’on pourra consulter, et la forme sous laquelle ces données sont mises à disposition, mais va évidemment plus loin en intégrant le typage, des validations, éventuellement une gestion de droits...
Ci-dessous, un exemple de type GraphQL correspondant à un `model` de notre API :
```ruby
module Types
class ProviderType < Types::BaseObject
# Ces lignes permettent d'intégrer les spécificités de Relay au type
# on y reviendra plus bas dans l'article
implements GraphQL::Relay::Node.interface
global_id_field :id
# Ici, on définit les champs qu'on va pouvoir requêter sur notre objet
# il peut s'agir de champs en base, de méthodes issues du model
# voire de méthodes directement définies dans le type.
# Pour chaque champ, on doit spécifier le type
# et la possibilité d'être nulle.
field :display_name, String, null: true
field :phone_number, String, null: true
field :siret, String, null: true
field :email, String, null: true
field :iban, String, null: true
field :bic, String, null: true
field :address, Types::AddressType, null: true
field :created_at, GraphQL::Types::ISO8601DateTime, null: true
field :updated_at, GraphQL::Types::ISO8601DateTime, null: true
# Ici, on choisit de réécrire la relation address du model.
# La simple définition du field address suffit
# pour retourner les bons objets.
# Mais ce faisant, on peut intégrer la logique de
# la gem BatchLoader pour éradiquer les N+1 de notre requête.
def address
BatchLoader::GraphQL.for(object).batch do |providers_ids, loader|
Address.where(
addressable_type: 'Provider', addressable_id: providers_ids
).each { |address| loader.call(object, address) }
end
end
end
end
```
### Queries
Une fois qu'on a défini les`Types` dont on a besoin, ils apparaissent certes directement dans la documentation générée automatiquement par [GraphiQL](https://github.com/graphql/graphiql/tree/master/packages/graphiql#readme) mais ils ne peuvent pas être directement consultés par les consommateurs de l'API.
Pour cela, il est nécessaire de créer des queries. Il s'agit de l'équivalent d'une requête GET en REST. Le consommateur va demander à consulter soit un objet spécifique, soit une collection d'objets (qu'on appellera une `connection` dans le cadre de la méthologie Relay, j'y reviendrai).
#### Afficher un objet isolé
Les queries se définissent dans la classe `QueryType` :
```ruby
module Types
class QueryType < Types::BaseObject
# Ici, le field correspond au nom de la query
# les arguments passés dans le block permettront
# de retrouver l'objet recherché.
field :item, Types::ItemType, null: false do
argument :id, ID, required: true
end
# Contrairement à la définition des types de models vue plus haut,
# pour les queries le field ne se suffit pas à lui-même.
# Il est nécessaire de définir dans une méthode du même nom
# la logique qui va résoudre la query.
def item(id:)
Item.find(id)
end
end
end
```
Dans son fonctionnement, une query est très proche de ce qu'on fait dans un controller REST. On définit la requête dans un *field*, auquel on associe le type du ou des objets qu'on va retourner, et des arguments qui vont permettre de récupérer l'objet voulu, par exemple un ID.
Dans la méthode item, on récupère les arguments passés dans le field, et on va chercher l'objet correspondant.
##### Alléger ses queries
Dès qu'on commence à avoir beaucoup de `models` différents qu'on veut pouvoir afficher dans une requête, on va être confronté à pas mal de duplications de code. Un peu de métaprogrammation permet d'alléger notre `query_type.rb` :
```ruby
module Types
class QueryType < Types::BaseObject
# On définit d'abord une méthode commune qui retrouve un objet
# à partir d'un ID unique fourni par Relay
def item(id:)
ApiSchema.object_from_id(id, context)
end
# Et on crée les fields qui appellent la méthode sus-définie pour tous
# les types pour lesquels on a besoin d'une query
%i[
something
something_else
attachment
user
identity
drink
food
provider
].each do |method|
field method,
"Types::#{method.to_s.camelize}Type".constantize,
null: false do
argument :id, ID, required: true
end
alias_method method, :item
end
end
end
```
#### Afficher une collection d'objets
Par défaut, il n'y a pas grande différence entre une query qui retourne un objet et une autre qui retourne une collection d'objets. On va tout simplement devoir spécifier que le résultat de la requête sera un tableau en passant le type lui-même entre `[]` :
```ruby
module Types
class QueryType < Types::BaseObject
field :items, [Types::ItemType], null: false do
# on ne cherche plus un item par son ID, mais par son contenant
argument :category_id, ID, required: true
end
def item(category_id:)
Category.find(category_id).items
end
end
end
```
On appelle cette query ainsi :
```
query items {
items {
id
displayName
}
}
```
Et on obtient le json suivant en réponse :
```json
[
{
"id": "1",
"displayName": "toto"
},
{
"id": "2",
"displayName": "tata"
}
]
```
### Relay et les connections
Cela peut fonctionner pour des besoins très basiques, mais ça va être vite très limité.
Pour des requêtes mieux structurées, Facebook a créé Relay, un client GraphQL qui introduit deux paradigmes très pratiques (gérés nativement par la gem) :
- on travaille avec des IDs globaux (des strings créés à partir d'un encodage en base64 de `["NomDuModel, ID"]`)
- une nomenclature bien spécifique pour organiser et consommer les collections d'objets : les `connections`
> *edit: J'ai réalisé le workshop initial en m'appuyant sur la version v1.9 de la gem graphql-ruby. La notion de `connection` a été extraite de Relay pour devenir le formatage par défaut des collections dans la v1.10.*
L'ID global à la place des ID classiques est là avant toute chose pour les applications JS qui vont consommer l'API. Cela permet notamment de toujours utiliser cet ID comme clé dans les boucles d'objets. Du point de vue de l'API, travailler avec des ID uniques indépendamment du type d'objet est également très pratique.
#### Nomenclature
Pour ce qui est des `connections`, voici à quoi ressemble notre précédente query adaptée sous ce format :
```
{
items(first: 2) {
totalCount
pageInfo {
hasNextPage
hasPreviousPage
endCursor
startCursor
}
edges {
cursor
node {
id
displayName
}
}
}
}
```
Et la réponse correspondante :
```json
{
"data": {
"items": {
"totalCount": 351,
"pageInfo": {
"hasNextPage": true,
"hasPreviousPage": false,
"endCursor": "Mw",
"startCursor": "MQ"
},
"edges": [
{
"cursor": "MQ",
"node": {
"id": "UGxhY2UtMzUy",
"displayName": "Redbeard"
}
},
{
"cursor": "Mg",
"node": {
"id": "UGxhY2UtMzUx",
"displayName": "Frey of Riverrun"
}
},
{
"cursor": "Mw",
"node": {
"id": "QmlsbGVyLTI=",
"displayName": "Something Else"
}
}
]
}
}
}
```
La `connection` nous donne accès à des informations complémentaires en plus de notre collection. Par défaut, la gem nous génère le `pageInfo` qui sert à la pagination par curseur, mais on peut également ajouter des champs personnalisés comme ici le `totalCount` rajouté pour gérer une pagination numérotée plus traditionnelle.
Les `edges` sont prévus pour contenir des informations liés au rapport entre l'objet et sa collection. Par défaut, il va contenir le curseur, qui représente le positionnement du node qu'il contient au sein de la collection. Mais il est possible de définir ses propres champs personnalisés.
Les `nodes` sont tout simplement les objets de la collection.
#### Intégration
L'utilisation de relay apporte des données précieuses aux requêtes, mais elle requiert en conséquence plus de verbosité dans la définition des queries. Concrètement, au lieu d'une `query`, on va devoir définir 3 types :
- la `query`
- la `connection`
- le `edge`
##### ConnectionType
La définition du type de la `connection` comprend 3 éléments :
- la spécification du EdgeType à utiliser
- les paramètres que l'on peut appliquer à cette `connection`
- les champs personnalisés qu'on peut demander en retour
```ruby
class Types::Connections::ProvidersConnectionType < Types::BaseConnection
# On commence par appeler la classe du EdgeType qu'on veut associer
# à la connection
edge_type(Types::Edges::ProviderEdge)
# En définissant ici des types Input, on pourra ensuite les appeler dans
# les queries associées à cette connection.
# Je reviens plus bas sur les arguments eq/ne/in plus spécifiquement.
class Types::ProviderApprovedInput < ::Types::BaseInputObject
argument :eq, Boolean, required: false
argument :ne, Boolean, required: false
argument :in, [Boolean], required: false
end
class Types::ProviderFilterInput < ::Types::BaseInputObject
argument :approved, Types::ProviderApprovedInput, required: false
end
# Absent par défaut, on peut intégrer un compteur du nombre d'objets
# dans la collection en créant un field et son resolver
# dans le ConnectionType
field :total_count, Integer, null: false
def total_count
skipped = object.arguments[:skip] || 0
object.nodes.size + skipped
end
end
```
##### EdgeType
Le `edge` peut être pensé comme une table de liaison, qui fait la passerelle entre la `connection` et les `nodes` qu'elle contient. Par défaut, on doit y définir le `node_type` pour identifier le type d'objet retourné par notre `connection`. Mais il est également possible d'y définir des méthodes personnalisées.
Je n'ai cependant pas encore rencontré de cas d'usage pour cette possibilité.
```ruby
class Types::Edges::ProviderEdge < GraphQL::Types::Relay::BaseEdge
node_type(Types::ProviderType)
end
```
##### QueryType
Enfin, une fois la `connection` bien définie, il faut l'appeler:
- soit dans une query spécifique
- soit dans le type d'un objet parent
Pour cela, au lieu d'associer le field au type de l'objet final retourné, on l'associe au type de la `connection`.
```ruby
module Types
class QueryType < Types::BaseObject
field :providers, Types::Connections::ProvidersConnectionType, null: true
def providers
Provider.all
end
end
end
```
```ruby
module Types
class ParentType < Types::BaseObject
# ...
global_id_field :id
field :display_name, String, null: true
# ...
field :providers, Types::Connections::ProvidersConnectionType, null: true
def providers
BatchLoader.for(object.id).batch(default_value: []) do |ids, loader|
Provider.where(parent_id: ids).each do |rk|
loader.call(rk.parent_id) { |i| i << rk }
end
end
end
end
end
```
Par défaut, on peut passer tous les arguments de pagination de base à la requête (`first`, `after`, `before`, `last`...). Si nécessaire, on peut spécifier des arguments supplémentaires pour préciser notre requête :
```ruby
module Types
class QueryType < Types::BaseObject
field :providers, Types::Connections::ProvidersConnectionType, null: true do
# filter appelle les InputTypes spécifiques à ProvidersConnectionType
# qu'on a définit plus haut
argument :filter, Types::ProviderFilterInput, required: false
end
def providers
Provider.all
end
end
end
```
##### Extraction des queries
Toutes les queries qu'on veut exposer sur notre API doivent être définies dans le fichier `query_type.rb`montré juste avant. Mais avec le gain en complexité d'une API, le fichier va être vite surchargé. Alors il est évidemment possible d'extraire la logique des queries dans d'autres fichiers, des resolvers.
Le fichier `query_type.rb` se présentera alors ainsi :
```ruby
module Types
class QueryType < Types::BaseObject
# ...
field :all_providers_connection, resolver: Queries::AllProvidersConnection
# ...
end
```
La logique de la query va se trouver dans un fichier à part :
```ruby
module Queries
class AllProvidersConnection < Queries::BaseQuery
description 'list all providers'
type Types::Connections::ProvidersConnectionType, null: false
argument :filter, Types::ProviderFilterInput, required: false
argument :search, String, required: false
argument :skip, Int, required: false
argument :order, Types::Order, required: false
def resolve(**args)
res = connection_with_arguments(Provider.all, args)
res = apply_filter(res, args[:filter])
res
end
end
end
```
Les méthodes personnalisées `connection_with_arguments` et `apply_filter` sont définies dans `BaseQuery`.
##### Trier et filtrer les connections
`connection_with_arguments` me permet d'intégrer des arguments de tri et de pagination numérotée à mes queries.
```ruby
def connection_with_arguments(res, **args)
order = args[:order] || { by: :id, direction: :desc }
res = res.filter(args[:search]) if args[:search]
res = res.offset(args[:skip]) if args[:skip]
# la spécification du nom de la table est nécessaire pour permettre
# le tri lorsque la requête SQL initiale contient des jointures
res = res.order(
"#{res.model.table_name}.#{order[:by]} #{order[:direction]}"
)
res
end
```
`apply_filter` permet d'avoir une logique globale à tous les arguments `filter` de l'API.
Habituellement, dans nos API REST, nous avons l'habitude d'utiliser des scopes pour permettre aux utilisateurs de filtrer les résultats d'une requête. Mais ces filtres restent assez sommaires. Dans la conception de cette première API GraphQL, en travaillant avec mon collègue développeur React et consommateur de l'API, nous avons souhaité aller un peu plus loin. GraphQL permet de choisir précisément les données qu'on désire recevoir, alors autant donner également la possibilité de les filtrer avec précision.
On a donc cherché une structure existante pour formater les filtres et nous avons décidé de nous baser sur la norme proposée dans [la documentation de GatsbyJS](https://www.gatsbyjs.org/docs/graphql-reference/#skip).
> ### Complete list of possible operators
>
> *In the playground below the list, there is an example query with a description of what the query does for each operator.*
>
> * `eq` : short for `equal`, must match the given data exactly
> * `ne` : short for `not equal`, must be different from the given data
> * `regex` : short for `regular expression`, must match the given pattern. Note that backslashes need to be escaped twice, so `/\w+/` needs to be written as `"/\\\\w+/"`.
> * `glob` : short for `global`, allows to use wildcard * which acts as a placeholder for any non-empty string
> * `in` : short for `in array`, must be an element of the array
> * `nin` : short for `not in array`, must NOT be an element of the array
> * `gt` : short for `greater than`, must be greater than given value
> * `gte` : short for `greater than or equal`, must be greater than or equal to given value
> * `lt` : short for `less than`, must be less than given value
> * `lte` : short for `less than or equal`, must be less than or equal to given value
> * `elemMatch` : short for `element match`, this indicates that the field you are filtering will return an array of elements, on which you can apply a filter using the previous operators
L'intégration actuelle ressemble à ceci :
```ruby
def apply_filter(scope, filters)
return scope unless filters
filters.keys.each do |filter_key|
filters[filter_key].keys.each do |arg_key|
value = filters[filter_key][arg_key]
# Ici on traduit les ID globaux qu'on reçoit du consommateur de l'API
# en ID classiques reconnus par Postgresql
if filter_key.match?(/([a-z])_id/)
value = [filters[filter_key][arg_key]].flatten.map do |id|
GraphQL::Schema::UniqueWithinType.decode(id)[1].to_i
end
end
scope = case arg_key
when :eq, :in
scope.where(filter_key => value)
when :ne, :nin
scope.where.not(filter_key => value)
when :start_with
scope.where("#{filter_key} ILIKE ?", "#{value}%")
else
scope
end
end
end
scope
end
```
C'est encore un travail en cours, le code est rudimentaire, mais cela suffit pour filtrer les premières requêtes.
### Pagination
Sur cette premier application, les écrans étaient encore pensés avec une pagination numérotée. J'ai donc dû intégrer des champs par défaut à toutes les `connections` :
```ruby
class Types::BaseConnection < GraphQL::Types::Relay::BaseConnection
field :total_count, Integer, null: false
field :total_pages, Integer, null: false
field :current_page, Integer, null: false
def total_count
return @total_count if @total_count.present?
skipped = object.arguments[:skip] || 0
@total_count = object.nodes.size + skipped
end
def total_pages
page_size = object.first if object.respond_to?(:first)
page_size ||= object.max_page_size
total_count / page_size + 1
end
def current_page
page_size = object.first if object.respond_to?(:first)
page_size ||= object.max_page_size
skipped = object.arguments[:skip] || 0
(skipped / page_size) + 1
end
end
```
Cela fonctionne, mais c'est regrettable parce que GraphQL est vraiment pensé pour la navigation par curseur, on est donc obligé de réinventer la roue à plusieurs endroits alors qu'on a un fonctionnement clef en main et certainement plus performant à disposition.
On gagnerait certainement beaucoup en performances à pousser la pagination par curseur partout où on pourrait se passer du nombre de page total, du numéro de page, du nombre d'éléments dans la collection.
# Du coup, c'est bien ?
Notre première fonctionnalité utilisant GraphQL va partir en production cette semaine, alors il est encore un peu tôt pour dresser un bilan complet de l'utilisation de ce langage API.
Néanmoins, les avantages pour nos développeurs front-end sont immédiats, tant React et GraphQL sont pensés pour être utilisés ensemble.
En revanche, si ce premier projet n'est consommé que par une application web, il faudra bientôt se poser la question des librairies GraphQL pour langages mobile, notamment sur Flutter, les tester et espérer qu'elles offrent le même confort d'utilisation.
Côté back-end, j'ai apprécié de travailler sur des classes entièrement typées. Bien que cela soit quelque peu verbeux, ça impose une certaine rigueur qui, une fois le réflexe acquis, devient particulièrement confortable (moins de bugs surprises, quand ça casse on sait plus rapidement pourquoi).
:::info
Note: Cela m'a d'ailleurs motivé à me réintéresser à Sorbet, peut-être le sujet d'un prochain article ?
:::
Forcément, on doit réapprendre toute sa façon de construire les services de son API, mais je pense que c'est pour le mieux. Sans même envisager de basculer intégralement de REST à GraphQL, connaître et tester les paradigmes de l'un et de l'autre ne peut qu'améliorer notre façon de travailler.
Restera la question des performances, notamment la lutte contre les requêtes N+1... Affaire à suivre !