Blog Arolla

Écrire une API REST en Java

API ou API ?

API peut vouloir dire plusieurs choses :

  • API = contrat
  • API = SDK
  • API = API web = API REST (ou SOAP, GraphQL)
  • API = point d'entrée (end-points)

Ici, on va

  • parler un peu de comment bien définir le contrat,
  • mais surtout parler de l'implémentation en Java des end-points d'API web REST.

Note : en fait, dans API, on devrait considérer que le P veut dire que c'est publique :).

Quelques bonnes pratiques

Bien prioriser

Deux principes aident à savoir où mettre l'effort, et prioriser les API que l'on veut fournir.

  • Ce qui est simple doit être facile, ce qui est compliqué doit être possible. Un besoin simple doit être facilement faisable avec l'API, mais le compliqué doit quand même être faisable, même s'il n'y pas besoin que ce soit simple.
  • Facile à utiliser correctement, doit demander des efforts pour être utilisé incorrectement. Par exemple avoir le message d'erreur qui t'explique comment faire ("vous n'êtes pas identifiés, utiliser le service xxx pour le faire").

Bien spécifier en amont et gérer les évolutions

Concernant l'implémentation des API, il est trop tard pour se poser les questions de design au moment du dev : le coût de modification est alors trop lourd (contrairement à d'autres composants, couches... où le design peut émerger pendant le développement).

Une meilleure pratique est que, lorsqu'une évolution fonctionnelle impacte une API, soient définis en amont

  • contrat
  • documentation
  • exemple d'utilisation par l'ensemble de l'équipe (pendant une heure par exemple).

Il faut accepter qu'on n'y arrive pas du premier coup. Assumer qu'on aura plusieurs versions. Et donc prévoir dès le début les transitions de version :

  • en intégrant le numéro de version dans l'API : la bonne pratique actuelle veut que ce soit dans l'url elle même. D'autres personnes ont poussé que ce soit dans le header, mais on suppose que les utilisateurs, autant internes (développeur en tests unitaires), qu'externes, trouvaient ça moins facile que de l'avoir dans l'URL ;
  • mettre rapidement des indicateurs d'usage, pour savoir, à la transition, quel client utilise encore l'ancienne version ;
  • si la logique, ou le workflow, change dans la nouvelle version, on peut vouloir changer les verbes pour éviter de faire croire au client que c'est "presque pareil" : c'est "éviter les migrations trop silencieuses" ;
  • et toujours, donner une bonne raison au client pour changer.

Note pour gérer la retrocompatibilité dans le code : si on accepte d'ignorer les attributs inconnus, on aura du mal à dire au client s'il a fait un faute de frappe dans le nom de l'attribut. Le choix dépendra du contexte, du type de client et de la relation qu'on cherche à avoir avec lui.

En tous cas, d'après les retours d'expériences collectés, il semble difficile, voire prétentieux, d'imaginer le besoin du client à sa place.

Bien définir les retours, en particulier quand il y a des erreurs

Toujours mettre un code d'erreur, en même temps que le message, voire un lien vers la doc, voire que la doc soit générée à partir du code. On a l'exemple d'une application où la documentation des messages d'erreur étaient dans le code, à partir duquel était généré un site web, vers lequel pointait le front quand il récupérait une erreur.

Utiliser les bons codes HTTP.

  • OK (200) -> synchrone vs Accepter (202) -> asynchrone.
  • Si mauvais type : 400, avec le bon message.

Détecter tout de suite si le message est absurde (beaucoup trop long etc.), avant même de chercher à rentrer dans le code métier. Il y a donc deux étapes, dans une approche "DDD" où tout passe par des value objects immutables :

  • la validation que l'appel HTTP n'est pas déconnant : couche infra, typiquement dans le converter ou le contrôleur ;
  • la validation que les données sont valides fonctionnellement : couche métier, typiquement dans le constructeur.

D'autres approches existent, où l'ensemble des validation, souvent non bloquantes, est effectué dans le contrôleur, mais qui n'empêchera pas d'autres parties du code de construire des objets invalides.

Enfin, avoir conscience de la tension entre sécurité et aide aux utilsateurs, par exemple, le compromis entre donner des informations pour aider les utilisateurs, mais pas trop pour ne pas aider les hackers :

  • parfois renvoyer une 404 au lieu d'une 403 si on ne veut pas montrer que ça existe mais que c'est juste que le client n'a pas le droit ;
  • ne pas renvoyer ce qui plante, car c'est une façon d'être attaqué. Pas de stacktrace. Mais un correlation id pour aller les chercher dans les logs. Il faut donc aussi avoir une manière très simple en un coup de pouvoir accéder à ces logs concaténés filtré par correlation id.

Et pour aller plus loin

Se poser la question de l'idempotence des API. Utiliser les bons verbes HTTP. POST est idempotent strict (il doit rendre toujours le même résultat, doit pouvoir être mis en cache). Utiliser PATCH sinon.

Faire attention à la cohérence de l'API (notamment des routes, pluriel / singulier).

DELETE vs PATCH. DELETE détruit une entité, alors que PATCH supprime un partie de l'entité. Ca fait déborder la modélisation dans l'API - et oblige à considérer qu'être une entité est un concept métier, public.

Enfin, souvent l'appelant (et son framework) va influencer inconsciement le contrat. De même l'esprit du langage derrière (ex s'il support le typage) transparaîtra dans l'API : existance de timestamp, nom des variables (snake_case ou camelCase...).

De même, savoir si API doit coller au métier ou non doit être sujet à discussion, mais plus du point de vue de l'approche architecturale : jusqu'où l'on veut décorreler infra et métier.

Implémentation

Avantage / inconvénent Springboot 2

Springboot offre sûrement une des manières les plus simple d'implémenter des API REST en Java. Mais nous trouvons que les tutoriaux Spring, en se concentrant sur le côté magique, n'insistent pas assez sur le fait que ce n'est pas si cher d'écrire du meilleur code, au sens clean code. Notamment en ce qui concerne le nommage, éviter les type primitifs, et, promouvoir l'usage des value objects.

En effet, out of the box, Springboot + Jackson permet de mapper directement un fragment json dans des objets java.

Néanmoins :

  • les noms des attributs de l'objet, voire des paramètres du constructeur, doivent être les noms des attributs du json.
  • les types des feuilles seront a priori des types primitifs (on ne peut les mapper sur des value objects par exemple sauf à avoir des constructeurs de value object ayant comme nom de paramètre le nom de l'attribut).

Une première approche est d'avoir des DTO correspondant aux entrées Json, que l'on mappera avec les objets métier (et à ce moment, on utilisera les pratiques VO / validation à la construction), dans une couche infra.

Une solution intermédiaire est d'utiliser les configurations Jackson et les converter d'une manière générale.

  • On peut passer par Jackson en configurant quel contructeur / factory il doit utiliser.
  • Pour encore plus de souplesse, on peut passer par des converters.

Pour le dire vite, mais un exemple seront donnés plus bas, on peut considérer la séquence suivante :

Json -> (Jackson) -> (Converter) -> (Constructeur) -> objet métier

Remarque : dans ce cas, il se peut que le type de la donnée dans le json ne soit pas celui du paramètre de la méthode. Exemple, si j'ai accountId dans le Json, je peux très bien avoir un Account en entrée de la méthode correspondante dans le controller, sachant que j'aurais fais le lookup dans le converter. Dans ce cas, ne pas céder à la simplicité en appelant le paramètre accountID dans la méthode mais en mettant la bonne annotation @PathVariable.

Pour le retour, utiliser l'équivalence entre Exception et Code retour.

Autres frameworks

Autre framework : micronaut.

Exemple

Sujet

Le but est de développer une API pour modifier deux attributs d'une entité : la dénomination usuelle et l'enseigne d'un établissement.

  • Id métier : Siret
  • une des deux valeurs à modifier est obligatoire
  • enseigne peut être mis à vide, mais pas dénomination usuelle
  • chaque attribut a des règles de validation (pas d'accent...)

On remarquera que ça ne déclenche pas d'événements métier derrière : si ça doit créer un dossier à l'INSEE, par exemple, on orienterait l'API vers de la gestion de dossier.

Côté input

Deux approches en termes de contrats, côté input :

  • Soit l'id est dans l'url, et on met les données directement à la racine.
  • Soit tout est dans le json, et on encapsule les données métier dans une sous structure.

Solution 1

route : /etablissement/51320534400031/denomination [PATCH]

{
  "raison_sociale": "Arolla SAS"
  "enseigne": "Arolla"
}

Mais cela nécessite que le siret soit normalisé, hors les utilisateurs mettent parfois des espaces ou des tirets (513-205-344 00031), voire oublient les 0 en tête pour les anciens Siret à 11 caractères.

Il faudrait donc plutôt une clé agnostique, sans sens métier, mais alors proposer un service qui fait le mapping Siret / clé agnostique.

Solution 2

route /denomination [PATCH]

{
"siret": "51320534400031",
"denomination": {
    "raison_sociale": "Arolla SAS"
    "enseigne": "Arolla"
  }
}

On choisit d'encapsuler ce qui n'est pas la clé de recherche dans un objet à part ; on a de la chance ici, il a un nom métier. Mais cela peut déclencher pas mal de discussion, notamment de tout mettre à plat à la racine.

Note pour l'implémentation

Nous pouvons avoir deux approches, en fonction de si c'est Jackson ou des converters qui font le job. C'est l'objet de l'exercice : comment sortir du magique Jackson-springboot, qui empêche de faire du clean code.

Est-ce que "enseigne" et "raison sociale" sont des VO, ou c'est juste "dénomination" ? Ca dépendra sûrement si ces objets se baladent l'un sans l'autre.

Côté output

Retour : a priori juste la partie "dénomination", pas tout l'établissement (pas toute l'agrégat - 100 attributs => notion de catégorie)

C'est donc strictement idempotent, dans le sens ou même s'il y a des appels concurrents, on aura toujours la même réponse (si on renvoyait toute l'entité, ce serait presque idempotent : pas de risques à appeler plusieurs fois, les effets ne se cumuleront pas, mais les retours pourraient ne pas être identiques en fonction des modification faites entre temps.)

Implémentation

Cf.

Côté client

Client de test SpringBoot

Deux manières, avec ou sans Serveur HTTP réellement lancé.

En lançant un serveur HTTP

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class IntegrationTest {

    @LocalServerPort
    private int port;

    @Test
    public void appelerLAPIdUpdateDevraitRenvoyerLaDesignation() throws JSONException {
        TestRestTemplate testRestTemplate = new TestRestTemplate();
        String response = testRestTemplate.
                patchForObject("http://localhost:" + this.port + "/etablissement/12345678901234/designation/",
                        Map.of("denomination_usuelle", "Arolla SAS", "enseigne", "Arolla"),
                        String.class);

        JSONAssert.assertEquals("{\"denomination_usuelle\":\"Arolla SAS\", \"enseigne\":\"Arolla\"}", response, false);
    }
    ...
}

Attention à PATCH qui n'est pas connu du client HTTP Spring de base. (Le client par défaut passe par java.net.HttpURLConnection qui ne connait pas PATCH). Pour corriger cela, il suffit de mettre la dépendance vers la bonne bibliothèque :

        <dependency>
            <groupId>org.apache.httpcomponents</groupId>
            <artifactId>httpclient</artifactId>
            <scope>test</scope>
        </dependency>

En simulant un serveur HTTP avec MockMVC

@SpringBootTest
@AutoConfigureMockMvc
public class LightIntegrationTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    public void appelerLAPIdUpdateDevraitRenvoyerLaDesignation() throws Exception {
        mockMvc.perform(patch(
                        "/etablissement/12345678901234/designation/")
                        .content("{\"denomination_usuelle\":\"Arolla SAS\", \"enseigne\":\"Arolla\"}")
                        .contentType(MediaType.APPLICATION_JSON))
                .andExpect(content().json("{\"denomination_usuelle\":\"Arolla SAS\", \"enseigne\":\"Arolla\"}"));
    }
    ...
}

Ici, il n'y a pas de HTTP réellement : c'est Spring qui appelle Spring dans la même Application Spring. Pas de soucis de bibliothèque côté client du coup.

Autre

Allez jeter un coup d'oeil à UniRest !

Côté requête

Niveau 0, on se balade des maps

    @PatchMapping(value = "/etablissement/{siret}/designation/")
    public Ma<String, String> updateDesignationForSiret(String siret, @RequestBody Map designation) {
        return Map.of("denomination_usuelle", (String)designation.get("denomination_usuelle"), "enseigne", (String)designation.get("enseigne"));
    }

Niveau 1, les nom des champs JSon doivent correspondre aux attributs du constructeur

    public Designation(String denomination_usuelle, String enseigne) {
        this.denomination_usuelle = clean(denomination_usuelle);
        this.enseigne = clean(enseigne);
    }
 ...

    @PatchMapping(value = "/etablissement/{siret}/designation/")
    public <String, String> updateDesignationForSiret(String siret, @RequestBody Designation designation) {
        return Map.of("denomination_usuelle", designation.getDenomination_usuelle(), "enseigne", designation.getEnseigne());
    }

Niveau 2, on explicite le mapping champs JSon / attribut Java dans l'objet métier

    @JsonCreator
    public Designation(@JsonProperty("denomination_usuelle") String denominationUsuelle, String enseigne) {
        this.denominationUsuelle = clean(denominationUsuelle);
        this.enseigne = clean(enseigne);
    }

Niveau 3, on sort ce mapping du métier

Cas du payload : on utilise des customizer

@Configuration
public class JacksonConfiguration {

    @Bean
    public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() {
        return builder -> builder
                .mixIn(Designation.class, DesignationMixin.class);
    }
}
...

public class DesignationMixin {

    @JsonProperty
    private String denominationUsuelle;

    @JsonProperty
    private String enseigne;

    @JsonCreator
    public DesignationMixin(@JsonProperty("denomination_usuelle") String denominationUsuelle,
                            @JsonProperty("enseigne") String enseigne) {
    }
}

Cas des variables de chemin : on utilise les converter Spring

    @PatchMapping(value = "/etablissement/{siret}/designation/")
    public Map<String, String> updateDesignationForSiret(@PathVariable Siret siret, @RequestBody Designation designation) {
        //...
    }

...

@Component
public class SiretConverter implements Converter<String, Siret> {

    @Override
    public Siret convert(String input) {
		//Faire ce qu'il doit être fait pour créer un Siret à partir d'une String...
    }
}

Note : beaucoup d'autre annotations existent (format de date, traduction par défaut Snake Case / Camel Case...). On ne devrait pas trop avoir besoin d'aller au niveau 4 !

Niveau 4, on utilise des HttpMessageConverter pour accéder à l'arbre JSon lui-même et triturer le payload

@Configuration
public class OverkillJacksonConfiguration {

    @Bean
    public HttpMessageConverter<Object> createJsonToDesignationConverter() {
        return converterWithDeserializerForType(Designation.class, new JsonDeserializer<>() {
            @Override
            public Designation deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException {
                JsonNode node = jsonParser.getCodec().readTree(jsonParser);
                return new Designation(node.get("denomination_usuelle").asText(), node.get("enseigne").asText());
            }
        });
    }

    private static <T> MappingJackson2HttpMessageConverter converterWithDeserializerForType(Class<T> type, JsonDeserializer<T> jsonDeserializer) {
        return new MappingJackson2HttpMessageConverter(
                Jackson2ObjectMapperBuilder.json().build().registerModule(
                        new SimpleModule().addDeserializer(type,
                                jsonDeserializer)
                )
        );
    }
}

Côté service

Ne modifier que ce qui n'est pas null

    public Designation updateDesignation(Siret siret, Designation targetValue) {
        Designation oldDesignation = annuaire.findDesignationBySiret(siret);

        Designation newDesignation = new Designation(
                targetValue.getDenominationUsuelle() != null ? targetValue.getDenominationUsuelle() : oldDesignation.getDenominationUsuelle(),
                targetValue.getEnseigne() != null ? targetValue.getEnseigne() : oldDesignation.getEnseigne()
        );

        annuaire.storeDesignation(siret, newDesignation);

        return newDesignation;
    }

Côté réponse

Mapper une exception avec un code retour et les bons messages

@RestControllerAdvice
public class ControllerAdvisor {

    @ExceptionHandler(UnknownEntityException.class)
    public ResponseEntity<DomainExceptionMessage> handleInvalidDomainValueException(UnknownEntityException ex) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new DomainExceptionMessage(ex.getCode(), ex.getMessage()));
    }
}

Plus de publications
Plus de publications
Plus de publications

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *