Version
- 1.0 (07/2023)
Statut
Contexte du projet
Ce projet a été réalisé dans le cadre de la formation Développeur d’application Java proposée par OpenClassrooms. L’objectif était de créer une API REST capable d’envoyer des informations aux systèmes de services de secours, en fonction des demandes.
Étant donné qu’il s’agissait d’un premier projet de découverte du framework Spring, les données ont été stockées dans un fichier JSON plutôt que dans une base de données, afin de simplifier la mise en place et de se concentrer sur la compréhension des mécanismes fondamentaux.
Gallerie
Fonctionnalités
- Récupération des numéros de téléphone : Accès aux numéros des habitants en fonction de leur station de pompiers.
- Récupération des adresses email : Extraction des adresses email des habitants par ville.
- Liste des enfants par adresse : Identification des enfants résidant à une adresse spécifique.
- Recherche par nom de famille : Obtention des informations d’une ou plusieurs personnes à partir de leur nom de famille.
- Couverture par station de pompiers : Récupération des listes de personnes protégées par une station de pompiers donnée.
- Réponse en cas d’urgence : Extraction d’informations clés en cas d’incendie ou d’inondation.
En plus de ces fonctionnalités, une gestion complète des entités suivantes est disponible, incluant l’ajout, la modification et la suppression des données :
- Résidents
- Dossiers médicaux
- Stations de pompiers
Stack technique
Ce projet étant exclusivement orienté backend, aucune technologie frontend n’a été utilisée. Concernant le backend, le stockage des données au format JSON et l’utilisation du framework Spring étaient des exigences du projet.
Ayant toutefois carte blanche pour l’outil de build, j’ai opté pour Gradle que je préfère à Maven pour la meilleure lisibilité de son fichier de configuration, et donc sa plus grande facilité à le maintenir. Même s’il est plus complexe à configurer au départ, il est également plus flexible et plus rapide que son concurrent.
Pour la documentation de l’API, j’ai intégré Swagger. Cet outil génère automatiquement une documentation interactive à partir des annotations dans le code, permettant de tester les requêtes sans passer par un client REST comme Postman. Même si cette documentation n’était pas exigée, j’ai pris l’initiative de l’ajouter pour me familiariser avec Swagger et apporter une plus-value au projet.
Organisation du code
L’organisation du projet suit le modèle MVC (Modèle-Vue-Contrôleur) afin de garantir une séparation claire des responsabilités et d’optimiser la maintenabilité du code. Toutefois, puisqu’il s’agit d’une API REST, le projet ne comporte pas de “Vue” à proprement parler. Voici la structure adoptée :
Nom du package | Responsabilité |
---|---|
Config | Centralise les configurations de l'application. |
Controller | Gère les requêtes HTTP et renvoie les données demandées sous forme de JSON. |
Service | Contient la logique métier et le traitement des données. |
Repository | Gère les interactions avec les données. En l'absence de base de données, il stocke et manipule le fichier JSON. |
DTO | Transporte les données entre les différentes couches de l'application sans exposer directement les entités du modèle. |
Model | Définit les entités et objets métiers utilisés dans l'API. |
Exception | Centralise la gestion des erreurs et des exceptions. |
Util | Fournit des classes utilitaires, notamment pour calculer l'âge des résidents à partir de leur date de naissance. |
En parallèle, un dossier resources
contient les fichiers de configuration de l’API.
Développement
Les API, en particulier les API REST, facilitent la communication entre applications via le protocole HTTP. Ces API utilisent des endpoints — des URL spécifiques — pour accéder à des ressources, comme par exemple /persons
pour récupérer la liste des habitants.
Les méthodes HTTP définissent l’action à effectuer sur ces ressources, les plus courantes étant GET
pour récupérer des données, POST
pour en ajouter, PUT
pour les modifier, et DELETE
pour les supprimer. Bien qu’aucune autorisation particulière ne soit requise pour accéder à ces ressources dans ce projet, il est crucial de garantir la sécurité des données fournies par l’utilisateur, notamment lors des opérations POST ou PUT.
Pour ce faire, j’ai mis en place des règles de validation avec Jakarta Bean Validation, qui permet de définir des contraintes sur les champs des entités. Par exemple, dans la classe Person, j’ai ajouté des annotations pour vérifier que les champs respectent certaines contraintes.
Note : les annotations @Schema
ne sont pas des contraintes de validation, mais servent à générer la documentation Swagger.
public record Person(
@Schema(description = "First name of the person", example = "John")
@NotBlank(message = "First name is mandatory")
@Size(min = 2, max = 50, message = "First name must be between 2 and 50 characters")
@Pattern(regexp = "^[A-Z][a-z]*(?:[ '-][A-Z][a-z-' ]*)*$", message = "First name must start with an uppercase letter and can only contain letters, hyphens, spaces and apostrophes")
String firstName,
@Schema(description = "Last name of the person", example = "Doe")
@NotBlank(message = "Last name is mandatory")
@Size(min = 2, max = 50, message = "Last name must be between 2 and 50 characters")
@Pattern(regexp = "^[A-Z][a-z]*(?:[ '-][A-Z][a-z-' ]*)*$", message = "Last name must start with an uppercase letter and can only contain letters, hyphens, spaces and apostrophes")
String lastName,
@Schema(description = "Address of the person", example = "123 Main St")
@NotBlank(message = "Address is mandatory")
@Size(min = 5, max = 100, message = "Address must be between 5 and 100 characters")
@Pattern(regexp = "^\\d+ [A-Za-z\\d '-]+$", message = "Address must start with a number followed by a space and then letters, numbers, spaces, hyphens or apostrophes")
String address,
@Schema(description = "City of the person", example = "Culver")
@NotBlank(message = "City is mandatory")
@Size(min = 2, max = 50, message = "City name must be between 2 and 50 characters")
@Pattern(regexp = "^[A-Z][a-z]*(?:[ '-][A-Z][a-z-' ]*)*$", message = "City name must start with an uppercase letter and can only contain letters, hyphens, spaces and apostrophes")
String city,
@Schema(description = "Zip code of the person", example = "97451")
@NotBlank(message = "Zipcode is mandatory")
@Pattern(regexp = "^\\d{5}$", message = "Zip code must be in the format 12345")
String zip,
@Schema(description = "Phone number of the person", example = "123-456-7890")
@NotBlank(message = "Phone number is mandatory")
@Pattern(regexp = "^\\d{3}-\\d{3}-\\d{4}$", message = "Phone number must be in the format 123-456-7890")
String phone,
@Schema(description = "Email of the person", example = "john.doe@example.com")
@NotBlank(message = "Email is mandatory")
@Email(message = "Email format is not respected")
String email
) {
}
Une fois ceci défini, il suffit de préciser l’annotation @Valid
sur le paramètre de la méthode pour que Spring vérifie automatiquement les contraintes de validation. Par exemple, pour la méthode qui réceptionne les requêtes de création de personne dans le controller dédié aux personnes :
@Operation(summary = "Creates a person", description = "Creates a person with several information : last name, first name, address, city, zip, phone, email.")
@ApiResponses(value = {
@ApiResponse(responseCode = "201", description = "Person has been created"),
@ApiResponse(responseCode = "400", description = "Person with specified data are not valid.",
content = {@Content(schema = @Schema(implementation = Map.class))}),
@ApiResponse(responseCode = "409", description = "Person with specified last name and first name already exists.",
content = {@Content(schema = @Schema(implementation = Error.class))})
})
@PostMapping("/person")
public ResponseEntity<Void> create(@RequestBody @Valid Person person) {
Logger.info("Request to create a new person : {}", person);
return personService.create(person);
}
Ici, on définit une méthode create
qui prend en paramètre un objet Person
annoté avec @RequestBody
pour indiquer à Spring de désérialiser le corps de la requête en un objet Person
. On ajoute également l’annotation @Valid
pour que Spring vérifie automatiquement les contraintes de validation définies sur la classe Person
.
La méthode du controller en appelle une autre du service personService
qui se charge de la logique métier.
En cas de succès, la méthode retourne une réponse avec le statut CREATED
donc 201. En cas d’erreur, elle retourne une réponse avec le statut BAD_REQUEST
donc 400 si les données de la personne ne sont pas valides, ou CONFLICT
donc 409 si la personne existe déjà.
Les annotations @Operation
et @ApiResponses
permettent de documenter l’API et de décrire les réponses possibles pour cette méthode. Cela permet de générer automatiquement une documentation Swagger à partir de ces annotations.
La gestion des exceptions
Concernant la gestion des exceptions, j’ai mis en place une classe GlobalExceptionHandler
qui centralise la gestion des erreurs et des exceptions dans l’application. Cette classe est annotée avec @RestControllerAdvice
afin que tous les controllers bénéficient d’une gestion unifiée des erreurs. Cela permet de décharger les controllers des blocs try-catch
en déplaçant la logique de gestion des exceptions vers un seul endroit, améliorant ainsi la lisibilité et la maintenabilité du code.
Par exemple, voici comment une exception de type AlreadyExistException
est gérée :
@ExceptionHandler(AlreadyExistException.class)
@ResponseStatus(HttpStatus.CONFLICT)
public Error handleAlreadyExistException(AlreadyExistException ex) {
return new Error(ex.getMessage());
}
Dans un premier temps, on définit une méthode annotée avec @ExceptionHandler
qui prend en paramètre le type de l’exception à intercepter. Ici, il s’agit de AlreadyExistException
. Ensuite, on précise le statut de la réponse HTTP à renvoyer en cas d’exception, ici CONFLICT
donc 409. Enfin, on retourne un objet Error
qui contient le message de l’exception pour l’envoyer au client, afin qu’il puisse comprendre ce qui s’est passé.
Afin de simplifier la création des objets de réponse, j’ai utilisé un record pour modéliser l’erreur de manière concise et immuable :
public record Error(String message) { }
Cette méthode est appelée automatiquement par Spring lorsqu’une exception de type AlreadyExistException
est levée dans un controller.
Complications rencontrées
Au début du developpement, j’ai opté pour l’utilisation des records sur les entités du modèle, car je pensais alors que je n’aurai pas besoin de faire de traitement supplémentaire sur les données. Toutefois, un problème s’est posé concernant la date de naissance contenu dans les dossiers médicaux. En l’état, l’insertion de la date par le consommateur de l’API est assez rigide puisqu’elle doit respecter le format un peu particulier stocké dans le JSON qui est MM/dd/yyyy
. Cela peut être source d’erreur pour l’utilisateur et complique l’utilisation de l’API.
Un record ne permet pas de modifier son contenu après sa création, ce qui m’a empêché de reformater la date de naissance. Changer ces records en classes aurait nécessité une refonte significative du code, ce que j’ai voulu éviter. Toutefois, cette décision a entraîné une autre difficulté : les contraintes de validation sur la date, spécifiée comme un LocalDate
, nécessitaient une annotation supplémentaire pour le format de désérialisation. J’ai donc ajouté l’annotation @JsonFormat
pour préciser le format de la date.
@Schema(description = "Birthdate of the person", type = "string", example = "12/29/2000")
@NotNull(message = "Birthdate is mandatory")
@PastOrPresent(message = "Birthdate can't be in the future")
@JsonFormat(pattern = "MM/dd/yyyy")
LocalDate birthdate,
Cependant, si l’utilisateur entre un format de date incorrect, cela ne déclenche pas une exception de validation de Jakarta Bean Validation, mais plutôt une exception de désérialisation de la librairie Jackson. J’ai prévu de traiter cette exception dans le GlobalExceptionHandler
en renvoyant un statut HTTP 406 au lieu de 400.
@ExceptionHandler(DateTimeParseException.class)
@ResponseStatus(HttpStatus.NOT_ACCEPTABLE)
public Error handleDateTimeParseException(DateTimeParseException ex) {
Logger.error(ex, "Failed to parse date");
return new Error("Birthdate must be in the format MM/dd/yyyy");
}
Ce comportement peut être déroutant pour l’utilisateur, qui s’attendrait à une erreur de validation. Si j’avais choisi des classes plutôt que des records, j’aurais pu reformater la date pour éviter cette confusion.
Les tests
Le projet est couvert par des tests unitaires, réalisés avec JUnit et la librairie AssertJ pour des assertions à la fois plus lisibles et plus avancées. Ils ont été conçus dans le respect des principes FIRST (Fast, Isolated, Repeatable, Self-validating, Timely). Autrement dit, ces tests sont rapides à exécuter, isolés des dépendances externes, reproductibles quel que soit l’environnement, autovalidant (ils renvoient un résultat clair et précis), et développé au bon moment, c’est-à-dire en parallèle de l’implémentation du code.
Pour la réalisation de ces tests, il a fallu utiliser la mécanique des mocks fournie par la librairie Mockito afin de simuler des objets ou des comportements externes pour tester une fonctionnalité isolément.
Différents scénarios ont été testés, notamment les cas normaux et les cas d’erreurs afin de garantir le bon fonctionnement de l’API dans toutes les situations. Par exemple, pour la création d’une station de pompiers, j’ai vérifié le cas où tout s’exécute comme prévu grâce au test ci-dessous :
@Test
@DisplayName("Test creating fire station")
public void create_ShouldReturnCreatedResponseEntity() {
// Given
FireStation fireStation = new FireStation("125 Schrimp St", 4);
when(jsonFileHandler.sortFireStationsByStationNumber(any(Data.class))).thenAnswer(invocation -> invocation.getArgument(0));
doNothing().when(jsonFileHandler).writeData(any(Data.class));
// When
ResponseEntity<Void> result = fireStationService.create(fireStation);
// Then
verify(jsonFileHandler).sortFireStationsByStationNumber(any(Data.class));
verify(jsonFileHandler).writeData(any(Data.class));
assertThat(data.fireStations().contains(fireStation)).isTrue();
assertThat(result.getStatusCode()).isEqualTo(HttpStatus.CREATED);
}
Dans le test, on commence par définir le contexte en créant une station de pompiers. Ensuite, on simule le comportement des méthodes sortFireStationsByStationNumber
et writeData
de la classe JsonFileHandler
pour qu’elles retournent les données telles quelles et ne fassent rien respectivement.
Comme on ne teste que la classe FireStationService
, on ne s’intéresse pas à la logique des méthodes sortFireStationsByStationNumber
et writeData
. On se contente donc de simuler leur comportement pour ne pas interférer avec le test. Plus précisément ici, on utilise doNothing
pour la méthode writeData
car elle ne retourne rien et ne fait que modifier les données du JSON. En revanche, on utilise thenAnswer
pour la méthode sortFireStationsByStationNumber
car elle retourne les données modifiées, donc doNothing
ne conviendrait pas.
Puis, on appelle la méthode create
de la classe FireStationService
avec la station de pompiers en paramètre. Enfin, on vérifie que les méthodes sortFireStationsByStationNumber
et writeData
ont bien été appelées, que la station de pompiers a bien été ajoutée aux données et que le statut de la réponse est bien CREATED
donc 201.
Et j’ai également vérifié le cas où une exception est levée si la station de pompiers existe déjà :
@Test
@DisplayName("Test already exist exception")
public void create_ShouldReturnAlreadyExistException_WhenFireStationAlreadyExist() {
// Given
FireStation fireStation = new FireStation("123 Main St", 1);
// When / Then
assertThatThrownBy(() -> fireStationService.create(fireStation))
.isInstanceOf(AlreadyExistException.class);
verify(jsonFileHandler, never()).sortFireStationsByStationNumber(any(Data.class));
verify(jsonFileHandler, never()).writeData(any(Data.class));
}
Ici, comme précédemment, on commence par définir le contexte du test en créant une station de pompiers. Et on vérifie immédiatemment après que la méthode create
lève bien une exception de type AlreadyExistException
si la station de pompiers existe déjà. Enfin, on s’assure que les méthodes sortFireStationsByStationNumber
et writeData
ne sont jamais appelées puisque la station de pompiers n’a pas été ajoutée.
La couverture de code globale estimé par JaCoCo est de :
93%