Adieu les Controller avec Spring data REST
Spring data rest est une partie du framework Spring data qui permet d’exposer des Web Services basés sur les repository.
Introduction
Cela fait maintenant plusieurs années que je code avec Spring, et suite à l’avènement des micro services, certaines API exposent du CRUD sans règle métier. Qui n’a pas croisé un micro-service “en couche” qui fait passe-plat vers le repository:
Pourquoi écrire un “service” et un “controller” s’ils ne font que passe-plat ?
N’ayant pas de bonne réponse dans mon cas d’usage, le controller et le service deviennent, pour moi, du code inutile. J’ai donc cherché à les supprimer. Pour la partie “service”: aucun souci, il suffit de supprimer la classe. Pour la partie “controller”: c’est plus compliqué, puisqu’il sert à déclarer nos routes, c’est là que Spring Data Rest intervient!
Mise en place d’un projet de test
On commence par importer les bonnes dépendances avec maven: java 11 et spring-boot 2.4.5
Nous ajoutons une base H2 (in memory) et Lombok pour faciliter le développement de notre test.
Ensuite un peu de conf pour générer la BDD directement depuis les annotations JPA.
Let’s code !
Ici nous décrivons des maisons avec leurs habitants, leurs adresses et leurs meubles.
Ensuite nous implémentons les repositories correspondants aux entités.
Note importante: aucun repository n’est mis en place pour les habitants.
S’équiper correctement
Une bonne façon d’explorer les possibilités offertes par spring data rest est d’utiliser un navigateur HAL. Il suffit d’ajouter la dépendance suivante et d’appeler l’endpoint http://localhost:8080/browser/index.html
Il ne reste plus qu’à tester
Démarrons notre serveur avec le jeu de données suivant.
Rechercher de la donnée
Nous retrouvons bien notre liste de maisons avec leurs adresse, les fournitures et les habitants associés.
A noter: pour les habitants, comme le repository n’a pas été créé, aucun service n’expose la donnée. Celle ci se retrouve donc directement dans le JSON de la maison.
Pour les autres, l’api fournit un lien vers la donnée dans le plus strict respect des normes HATEOS.
Insérer de la donnée
Pour les opérations d’insertion, afin de lier la ressource courante à une autre ressource, si un repository existe, on fournira un “path” vers la ressource cible. Sinon il faut fournir un JSON correspondant à la structure de l’objet.
Nous insérons d’abord une “furniture”
Ensuite nous pouvons insérer une “house” avec la “furniture” créée précédemment
Alors GET houses/5/furnitures
est bien un lien vers les meubles de la maison, on y retrouvera le “sofa” créé précédemment.
Autres opérations possibles
Le PATCH exemple: PATCH /houses/4
permet de modifier de la donnée existante. La donnée peut être définie partiellement.
Le DELETE exemple DELETE /houses/4
permet de supprimer la ressource existante
Le PUT exemple PUT /houses/4
permet de créer ou de modifier de la data existante qui à la différence du PATCH doit être définie dans sa totalité
Quelques méthodes “maison”
Nous allons ajouter à l’un de nos repository une méthode personnalisée construite avec JPA.
Cette méthode est automatiquement exposée sous le “search” de la ressource fournie par le repository. Là ou toutes les méthodes sont listées
Ainsi, on pourra interroger le serveur de cette façon et récupérer la ressource House
Tri/pagination
Pour appliquer le tri et la pagination sur une entité, il suffit de modifier la classe étendue par le repository avec PagingAndSortingRepository<T, ID>
.
Si par exemple, on fait la modification pour la classe FurnituresRepository
:
Le service est alors défini de la façon suivante, et l’on voit apparaître 3 nouveaux paramètres :
Que l’on va utiliser de la manière suivante :
- pagination
- tri
Et si l’on souhaite appliquer le tri sur plus d’une propriété, on utilise plusieurs fois le paramètre sort
, alors le tri sera appliqué dans l'ordre d'apparition des paramètres dans l'URL.
Validation
Nous souhaitons maintenant créer un validateur empêchant la sauvegarde d’une maison ayant le nom “test”. D’après la documentation, il faut implémenter un “validator” et générer un bean dans notre contexte d’application avec un nom ayant le formalisme suivant:
[eventName][entityName]Validator
.
Ainsi le code suivant devrait fonctionner:
Lors de l’appel à la méthode “supports”, si le retour est “true”, c’est à dire que la classe à valider est “House”, alors la méthode validate est exécutée sinon le validator n’est pas pris en compte.
Note importante: le @Component("beforeCreateHouseValidator")
doit permettre d’assigner le validator au bon “event”. Cependant, un bug est en cours de traitement sur le projet et empêche ce fonctionnement. Nous allons donc supprimer l’annotation. Plusieurs “workarounds” sont disponibles sur le ticket, nous nous intéressons ici à l’approche “manuelle”.
Il faut ajouter une configuration pour indiquer à Spring lors de quel “event” le validator est exécuté.
Ici notre, validator est exécuté avant chaque “create” (http POST).
Modifier le format de la ressource
Pour ce faire il suffit d’utiliser les “projections”. Une projection est une “vue” particulière de la ressource qui peut être utilisée par le client.
Par exemple, si nous souhaitons avoir une visualisation de “House” avec uniquement le “name” et la liste des “furnitures”. Il faut créer la projection.
Attention, pour être scanné automatiquement par Spring, les projections doivent être dans le même package que l’entity cible. Sinon il est possible d’enregistrer une projection manuellement.
Ensuite cette “vue” peut être exploitée de cette manière:
Note importante: dans le cas d’une projection, tous les champs sont directement fournis “inline”. C’est pourquoi les informations de “furnitures” sont fournies directement dans le tableau et non pas au travers d’une référence, même si le repository est exposé.
Afficher la data d’un fils dont le repository existe
Il est possible de déterminer une projection “par défaut” utilisée uniquement depuis une ressource parente. Par exemple, on peut ajouter l’annotation:
@RepositoryRestResource(excerptProjection=FurnitureExcerpt.class)
au FurnituresRepository. Ainsi, lorsque l’on charge une maison la donnée de “furniture” est fournie directement. House prend alors la forme suivante:
Note importante: la ressource GET /furnitures
reste inchangée, il s’agit là d’avoir une “preview” de l’objet depuis les parents.
Gérer la concurrence
Nous souhaitons maintenant gérer les modifications concurrentes d’une ressource. Cela permet, entre autre, d’empêcher le scénario suivant:
un utilisateur A accède à une ressource qu’il souhaite modifier,
un utilisateur B accède à la même ressource,
l’utilisateur A pousse une modification,
l’utilisateur B pousse une autre modification.
Alors B écrase les modifications apportées par A sans s’en rendre compte.
Pour éviter cet effet, Spring data rest propose d’utiliser la “version” d’une ressource au travers d’un header appelé ETAG.
Il faut ajouter une version à notre entity
Suite à cet ajout, un ETAG sera fourni dans le header pour chaque “GET”.
Par exemple GET /houses/1
fournira ETAG:1
.
Lors du PUT permettant de modifier la maison on ajoutera le header If-Match:1
Par exemple PUT /houses/1
avec header If-Match:1
.
Suite au PUT, le ETAG devient 2. Si l’on réitère l’opération en gardant le
If-Match:1
alors on reçoit une réponse 412 precondition failed
De la même façon, on peut utiliser une date lors des GET afin de recevoir la donné uniquement si celle si a été modifié. On ajoute
@LastModifiedDate Date date
dans l’entity, un nouvel header apparait dans les réponses suite à un GET, le Last-Modified, que l’on peut l’utiliser lors d’un GET postérieur avec le header If-Modified-Since:MA-DATE
.
Ainsi, si la date fournie est antérieure a Last-Modified alors une réponse 200 est retournée avec la ressource, sinon une réponse 304 not modified est retournée. Cela permet d’optimiser les performance en limitant l’usage du réseau.
Limitations, où quand et comment remettre des controllers
Dans certains cas, il arrive que les opérations fournies ne correspondent pas aux process fonctionnels, il peut être nécessaire de créer manuellement des “controllers”.
Pour ce faire, il existe plusieurs annotations :
@BasePathAwareController
et@RepositoryRestController
sont utilisées pour créer manuellement des endpoints, en profitant des configurations Spring Data REST du projet.@RestController
(annotations standard REST) créée, par contre un ensemble parallèle de endpoints avec des options de configuration différentes (mappeurs différents, gestionnaires d’erreurs différents, etc.).
Un petit exemple avec l’annotation @RepositoryRestController
Conclusion
Spring Data Rest permet d’exposer et manipuler des web services en implémentant uniquement les “repository”. L’outil a de nombreux avantages mais aussi quelques inconvénients, il faut donc l’utiliser dans le bon cas.
Les plus:
- Allège le code, le rend facile à maintenir
- Fourni un Webservice “standard” qui suit les normes HATEOAS
- Permet de manipuler/modifier simplement le Webservice
- Mise en œuvre simple de la gestion de la concurrence
- Permet facilement l’ajout de règles personnalisées
- Prise en main rapide
Les moins:
- HATEOAS peut amener de la latence s’il est mal exploité
- HATEOAS peut être complexe à utiliser pour les non initiés
- Le projet est moins connu que Spring Web
- Peu d’intérêt si votre API a de nombreuses règles métiers
Les sources du test sont disponibles ici.