jeudi 17 décembre 2020

Retour d'expériences sur la conception d'API REST

Extrait d'éléments concernant le modèle d'API REST datant de 2017

 Ce document est un condensé des éléments qui ont orientés les choix généraux d'implémentation des API dans le cadre du Projet Vitam. Ils datent de 2017 mais sont, selon moi, toujours d'actualité et sans doute réutilisable dans d'autres contextes.

Attention, il ne s'agit absolument pas d'un document officiel, mais d'un condensé de mes propres notes et de mon expérience, y compris a posteriori de cette période. Il ne s'agit pas d'une "norme", ni d'une obligation, certains éléments ayant forcément un parti pris.

Mais j'espère que cela pourra être utile à d'autres.


1         RESTFull

Un service REST full devrait être implémenté avec une couche la plus légère possible afin d’éviter des écueils de performances en raison de certains échanges en grand nombre (les « heartbeats » notamment).

  • Il est recommandé d’étudier finement la stack de service HTTP REST (de type JAX-RS par exemple) pour minimiser l’impact ;
  • Il est recommandé de favoriser le principe NIO (Non blocking Input/Output) pour limiter d’une part le nombre de threads actifs à un instant T au strict minimum et donc la mémoire, mais aussi aux écriture/lectures de fichiers selon une logique de flux pour favoriser les performances.
  • En cas de panne d’une des instances, les autres services doivent se répartir les items qui étaient à sa charge à un instant T, par exemple en passant par l’orchestrateur. Les doublons sont autorisés (« at least once »).
  • En cas de reprise d’une instance, son activité initiale sera nulle, sans reprise. L’orchestrateur se chargera de répartir les demandes sur les instances.

Chaque service doit être scalable horizontalement afin d’assurer un équilibrage de charge et en même temps une haute disponibilité. Afin de centraliser les états (le service étant d’un point de vue externe state-less par serveur mais state-full en logique globale), une centralisation des données et des fichiers (référentiels des échanges) et d’un orchestrateur peut s'avérer indispensable.

1.1        Modèle REST

Ce modèle se veut pragmatique et n’impose pas une application dogmatique des principes REST. Par exemple, il n’impose pas le versioning des applications via le header ACCEPT mais plutôt par version dans l’URL.

En se basant sur le modèle de maturité de Richardson qui peut se représenter ainsi :

  • Niveau 0 : type « RPC » ;
  • Niveau 1 : Notion de ressources ;
  • Niveau 2 : Utilisation idoine des verbes http ;
  • Niveau 3 : HATEOS (Hypertext As The Engine Of Application State).

 

Le niveau 3 de ce modèle n’est pas un objectif mais vise les règles les plus efficaces (probablement entre le niveau 2 et 3).

Le leitmotiv de l’API peut se résumer aux principes suivants : faciliter l’interopérabilité entre les applications consommatrices et l’application (mais aussi les interactions entre les composants de l’application eux-mêmes) et être simple de compréhension par les développeurs consommateurs de services (API cohérente, simple, suivant des paradigmes éprouvés et connus, API autodescriptive).

Il est bon de rappeler que comme tout programme, et en particulier s'agissant d'interfaces, une API est destinée avant tout aux "humains" et non à des "machines". Il convient de se rappeler de ce point à chaque instant, tant dans la nature de l'URI, des paramètres que des logiques séquentielles des appels.

1.2        L’architecture REST, définition

1.2.1        Architecture REST

La définition de l’architecture REST est basée sur la thèse de Roy Thomas Fielding : « Architectural Styles and the Design of Network-based Software Architecture » (traduction issue de la page suivantes : http://opikanoba.org/tr/fielding/rest/). Les chapitres suivants se basent sur cette traduction.

 

Le modèle REST (Representational State Transfer) est une abstraction des éléments architecturaux d’un système réparti d’hypermédias. REST est indépendant des détails de mise en œuvre des composants et de syntaxe de protocole. Il se concentre ainsi sur les rôles des composants, les contraintes sur leur interaction avec d'autres composants, et leur interprétation des éléments de données significatives. Il englobe les contraintes fondamentales sur les composants, les connecteurs et les données qui définissent la base de l'architecture du Web et ainsi l'essence de leur comportement en tant qu’application réseau. 

1.2.2        Ressources

L'abstraction principale de l'information dans REST est la ressource. Toute information pouvant être nommée peut être une ressource : un document ou une image, un service temporel (par exemple « le temps d'aujourd'hui à Marseille »), une collection d'autres ressources, un objet non-virtuel (par exemple une personne) et ainsi de suite. En d'autres termes, tout concept pouvant être la cible d'une référence hypertexte doit entrer dans la définition d'une ressource.

1.2.3        Identifiants de ressource

REST utilise un identifiant de ressource pour identifier une ressource particulière impliquée dans une interaction entre les composants. L'autorité de nommage ayant assigné l’identifiant à la ressource, lui permettant d’être référencée, est responsable du maintien de la validité sémantique des correspondances dans le temps (c'est à dire en s'assurant que la fonction définissant la relation ne change pas).

1.2.4        Représentation

Les composants REST effectuent des actions sur une ressource en utilisant une représentation pour capturer l’état courant ou prévu de cette ressource et en transférant cette représentation entre les composants. Une représentation est une séquence d’octets, plus des métadonnées qui les décrivent. Des noms comme document, fichier, entité de message HTTP, instance ou variante sont utilisés pour désigner une représentation de façon générale mais sont moins précis.

1.2.5        Sans état (Stateless)

La communication doit être par nature sans état, comme dans le modèle « client/serveur sans état ». Ainsi chaque requête du client vers le serveur doit contenir toutes les informations nécessaires pour que cette demande soit comprise, et elle ne peut tirer profit d'aucun contexte stocké sur le serveur. L'état de la session est donc entièrement détenu par le client.

Cette contrainte induit les propriétés suivantes : visibilité, fiabilité et faculté de montée en charge. La visibilité est améliorée car un système de supervision n’a pas besoin de regarder au-delà des simples informations de la requête afin d’en déterminer sa nature complète. La fiabilité est améliorée car il est plus simple de faire face (recovery) à des échecs partiels. Enfin, la possibilité de montée en charge est meilleure, car le fait de ne pas stocker l’état entre les requêtes permet aux composants du serveur de libérer rapidement les ressources.

 

En pratique, les API REST sont basées sur le protocole http/s. Elles permettent des interactions entre des clients (applications clientes ou serveur) et des serveurs à travers l’utilisation d’un ensemble de verbes http (GET/POST/PUT/DELETE/OPTIONS/HEAD…) dont la sémantique se doit d’être respectée. Les clients manipulent des ressources (des noms) à travers d’URL uniques dont la pérennité doit être assurée par le serveur.

La suite du document définit pour chacun de ces points les règles et conventions envisagées.

1.3        Règles générales

1.3.1        Noms de domaine d’accès aux API

Deux cas distincts sont à prévoir, d’une part l’accès aux API externes, d’autre part l’accès aux API internes.

Pour les API de type externes, le nom de domaine sera unique et de la forme :

https://api.domaine_racine.fr/

1.4        Versioning des API

1.4.1        Généralités

La gestion du versioning des API est cruciale dès la mise en place du projet. Elle garantit l’interopérabilité des différents clients et des services mis à disposition à moyen voire long terme. Elle autorise l’amélioration de l’API et des changements plus ou moins profonds sans engendrer d’incompatibilité.

Comme pour une interface de programmation, une API doit rester autant que possible compatible ascendante. Tant que cette compatibilité est assurée, une nouvelle version majeure n'est pas nécessaire et même non souhaitable.

1.4.2        Politique d’incrément de version d’API

Il est entendu par « version de l’API », version de l’interface de service et non de version applicative. Une implémentation de l’API version « N » peut bien évidemment évoluer sans toucher le versioning de l’API.

La version d’une API n’est modifiée que si elle a un impact avéré sur les clients. Il n’est donc pas forcément nécessaire de faire évoluer la version de l’API, si par exemple, un nouveau paramètre de filtrage apparaît sur une ressource, ou qu’un nouveau verbe http est supporté.

 

Le cas de l’ajout d’un champ optionnel à une ressource peut sembler du même ordre –mais peut poser problème si les clients ne sont pas suffisamment laxistes lors de la dé-sérialisation des données. Par exemple avec Jackson en java, si la classe à instancier n’est pas annotée @JsonIgnoreProperties(ignoreUnknown=true), la dé-sérialisation provoque une levée d’exception.

 

À contrario, l’interface de service peut ne pas bouger, mais les règles métier sous-jacentes avoir suffisamment évoluées pour provoquer une incompatibilité avec les clients. Ce cas provoque donc lui aussi une montée de version de l’API.

1.5        Choix d’implémentation

Le choix effectué pour l’implémentation REST du versioning des API se porte sur l’intégration de la version dans l’URL. Ce choix n’est pas nécessairement le plus respectueux de la philosophie REST – et notamment l’aspect uniforme de la ressource –, mais à l’avantage d’être plus simple d’appréhension et plus direct que l’utilisation de l’entête HTTP « Accept ».

Le numéro de version ne comporte qu’un numéro de majeur entier (v1, v2, …) mais en aucun cas un nombre à virgule flottante (v1.42). 

Exemple d’url :

Protocole

Nom de domaine

Nom de l'application (domaine fonctionnel)

Version du service

Nom de la ressource

Suite de l’url

https://

api.domaine_racine.fr

/app_domain

/v2

/server

Un numéro de version complet de la forme « majeur.mineur », à usage informatif seulement, est renvoyé par le serveur lors de chaque réponse au client via un header http spécifique, par exemple ApiFullVersion. Les versions complètes 1.2 et 1.65 sont toutes les deux relatives à la version 1 de l’API du service, la comptabilité ascendante doit être assurée sur les versions mineures. Seule une version mineure est exposée pour un majeur donné.

Exemple :

Requête :

GET https://api.domaine_racine.fr/admin/v2/server/2

< 200 OK

< FullApiVersion: 2.56

< {…}

1.5.1        Obtention des versions disponibles d’un service

Il serait utile de consulter l’ensemble des versions d’une API déployée : cette fonctionnalité pourrait permettre aux clients de vérifier leur compatibilité avec les API déployées. Cette fonctionnalité pourrait être implémentée par un GET sur l’URL précédent la version.

Cette fonctionnalité ne pouvant être versionnée, elle se doit d’être stable dans le temps.

 

Exemple :

GET https://api.domaine_racine.fr/admin

< 200 OK

< [

<    { ‘api_version’ : 1,       ‘api_full_version’ : 1.57 },

<    { ‘api_version’ : 2,       ‘api_full_version’ : 2.0  },

< ]

1.6        Ressources

La granularité d’un module REST doit au moins être au niveau de la ressource (un nom au pluriel, et les sous-ressources associées).

Elle permet ainsi un déploiement soit combiné de différentes ressources sur un même serveur REST, soit au contraire séparées dans des serveurs différents, permettant ainsi de bénéficier de l’approche micro-services, et notamment la mise à jour par sous-ensemble, mais sans avoir la démultiplication des services et donc de leur gestion en production, en conservant une échelle raisonnable du nombre de « services » à gérer.

1.6.1        Définition

Les API REST manipulent des ressources. Les ressources sont des « noms » et non des verbes définissant une action. En REST, les actions associées aux manipulations à effectuer sur les ressources sont modélisées par des verbes HTTP. Les ressources sont manipulées à travers d’URL qui sont un moyen de les localiser de manière unique.

 

Les ressources exposées par les API ne sont pas nécessairement des types de données existant explicitement dans la couche de persistance - bien que ce soit souvent le cas, mais peuvent être purement des représentations supports pour les API.

En résumé, une ressource est :

  • Un nom et non un verbe ;
  • Est unique ;
  • Peut être représentée par des données ("peut" ne signifie pas "doit", et inversement une donnée n'est pas forcément représentée par une ressource) ;
  • Est référencée par une URL.

1.6.2        Granularité d’une ressource

Le découpage très fin des API en ressources dissociées n’est pas toujours pertinent, il vaut parfois mieux agréger des ressources filles en retour d’un appel GET sur une ressource parente si la donnée fille est quasi systématiquement utile dans le cadre de la manipulation du parent. Non seulement l’API est ainsi plus simple à manipuler, mais cela diminue aussi notablement le nombre d’appels serveur à effectuer – et améliore donc les performances du système. Ce point reste un problème de modélisation pour lequel il n’existe pas de règle générale. Il faut donc y répondre au cas par cas en anticipant au maximum les cas d’utilisation de l’API, et en faisant preuve de pragmatisme.

1.6.3        Règles concernant les URL

  • Nommage

            Dans les URL, le nom de la ressource est au pluriel, et en anglais.

            Exemple :

GET https://xxx/v1/servers

  •  Casse 

La convention de casse imposée est « snake_case » pour les URL et les éventuels paramètres de requête.

L’objectif est double :

    • Limiter les erreurs d’écriture (plus probable avec l’utilisation de « camel case ») ;
    • Faciliter la lecture.

             Exemple :

GET https://xxx/v1/load_balancers?backend_status=up

  • Relations entre ressources

Une ressource « appartenant » à une ressource parente est accessible par une sous arborescence du parent. Attention cependant à ne pas utiliser de profondeur trop importante, une limite de 2 à 3 niveaux semble être un bon compromis.

 Exemple :

GET https://xxx/v1/servers/12/cpus/2/load_average

Dans le cas d’une structure très arborescente, il paraît plus opportun de mettre à plat la structure et faire porter les informations de type père/fils en tant qu’attribut de la ressource courante, en spécifiant par exemple les liens vers ces ressources.

 Exemple fictif :

GET https://xxx/v1/directory/12

< 200 OK

< {  “id”: 12,

<    ”name”:”administration”,

<    ...

<    ”link_parent_node” : ”https://xxx/v1/directory/0”,

<    ”link_children_nodes” : [

<      ”https://xxx/v1/directory/42”,

<      ”https://xxx/v1/directory/314”

<   ]

< }

1.6.4        Représentation

Les API REST en général sont au format JSON. Ce paragraphe ne s’attardera donc que sur ce format.

  • Entêtes HTTP

S’agissant du format JSON, les en-têtes HTTP émis par le serveur doivent donc contenir le content-type suivant :

 Content-Type : application/json

Il n’est pas prévu actuellement d’utiliser un content-type « vendor » spécifique.

  • Langue 

Tous les attributs doivent être en anglais.

Ceci est un principe courant en programmation. Il est en effet considéré comme une mauvaise pratique que de mélanger plusieurs langues dans un même scope. Ainsi, les langages de programmations étant très majoritairement en Anglais, il convient d'écrire les noms des objets, des méthodes et des attributs ou variables en Anglais également. Ceci facilite la relecture a posteriori par de nouveaux développeurs par exemple en charge de sa maintenance ou de son évolution.

L'API ne déroge pas en général à cette règle. Seuls les commentaires dans les API peuvent être admis dans une autre langue (le Français), même si l'usage veut que ce soit plutôt porté dans une documentation spécifique additionnelle.

  •  Casse 

Pour les attributs du format JSON, plusieurs choix de la convention de casse sont possibles, dont le « snake_case » ou « camelCase ». L’objectif est de rester homogène globalement (pas forcément avec les paramètres dans les URL qui peuvent respecter le format standard « snake_case » tandis que le Json utiliserait le format « camelCase » ).

 

Exemple :

« snake_case »

{

  ”my_field” : 1,

  ”other_field” : ”string”,

}

« camelCase »

{

  ”myField” : 1,

  ”otherField” : ”string”,

}

  • « Pretty print » & GZIP 

Un des leitmotivs étant de faciliter le travail des développeurs consommateurs de services, le mode « pretty print » sera activé par défaut lors de l’envoi de messages JSON des serveurs vers les clients, facilitant la lecture.

Ce mode, bien que plus verbeux (présence de nombreux espaces), n’est que peu pénalisant lorsque le flux est gzippé (surcoût inférieur à 3%).

L’utilisation de flux HTTP gzippés est hautement recommandée pour économiser de la bande passante (plus de 50% de gain sur du json), et pour un coût de zip/unzip très faible.

  • Format de message des erreurs

Lorsqu’une requête à l’API REST provoque une erreur, qu’elle soit technique ou fonctionnelle (statuts http 40X et 50X), le formalisme proposé implique la présence d’un code d’erreur textuel, message court et d’une description longue.

Le message court représente la description du code d’erreur (un code a toujours le même message court). Le message long représente la description du contextuelle de l’erreur, il peut inclure des descriptions techniques détaillées. Le code court peut être un numérique ou un code alphanumérique, mais autant que possible court (comme un trigramme) et à peu près compréhensible.

Exemple :

{

  ”code” : ”FBD”, (ou 403 pour Forbidden)

  ”message” : ”The application is not authorized to use this functionality”,

  ”description” :  ”The application 'Twitter' is not authorized to use the 'administration' service, the role 'ADMIN_ROLE' is mandatory.”

}

Une erreur peut éventuellement être le résultat de plus d’erreurs, par exemple lors de la validation du JSON entrant. Dans ce cas, il est envisageable de renvoyer, sous l’égide d’une erreur principale, la liste des erreurs ayant provoquées l’échec de la requête.

Exemple :

{

  ”code”: ”the_code”,

  ”message” : ”string”,

  ”description”: ”string”,

  ”errors” : [

    {”code” : ”c1”, ”message” : ”s1”, ”description” :  ”long s1”},

    {”code” : ”c2”, ”message” : ”s2”, ”description” :  ”long s2”},

    {”code” : ”c3”, ”message” : ”s3”, ”description” :  ”long s3”}

  ]

}

  • Cohérence du nommage des attributs

Si un attribut est présent dans plusieurs collections, il devrait avoir le même type.

Exemple :

L’attribut « name » est un « string » pour toutes les ressources.

  •  Attributs booléens

Si un attribut est un booléen, il ne faut pas le préfixer par is_ ou has_ (ou autre), son nom d’attribut suffit.

Exemple :

    • is_administrator  => Mauvaise pratique, même si très répandue
    • administrator     => Bonne pratique

 

Eviter la forme négative dans les noms d’attributs afin d’éviter les doubles négations dans le code.

Exemple :

    • disable  => Mauvaise pratique
    • enable   => Bonne pratique
  • Attributs de type « date »

Ne jamais utiliser le timestamp dans l’API pour éviter les « clock skew », utiliser l’ISO 8601 :

    • 2014-01-10T03:06:17.396Z (de préférence)
    • 2014-01-09T22:06:17+05:00 

1.7        Actions sur les ressources

1.7.1        Sémantique HTTP concernant les actions

Une action sur une ressource est déterminée par la méthode HTTP correspondante et doit respecter la sémantique du protocole :

  • GET : Permet d’accéder en lecture à une ressource. Sur une collection cette requête provoque la récupération de l’ensemble ou d'une partie des ressources (fonctions de recherche, pagination) ;
  • POST : Utilisé au niveau de la collection, permet la création d’une nouvelle ressource ;
  • PUT : Met à jour tous les attributs d’une ressource, si un attribut est absent du corps de la requête, il sera mis à null. Ce dernier point ne s’applique pas aux identifiants de ressource ;
  • PATCH : Met à jour le ou les attributs d’une ressource. Les attributs sont spécifiés dans le corps de la requête ;
    • A noter que le cas du PUT vs PATCH peut être simplifié si l'API ne fait jamais appel par exemple à une mise à jour globale d'un objet. Dans ce cas, PUT peut être utilisé en remplacement de PATCH, à condition d'être clair dans sa sémantique.
  • DELETE : Supprime la ressource ou collection spécifiée dans l’URL ;
  • HEAD : Retourne les métadonnées d’une ressource (headers), par exemple l’empreinte d'un fichier ; Il peut être utilisé pour tester l'existence d'une ressource ;
  • OPTIONS : Retourne les informations sur les opérations possibles sur cette API pour ce client.

1.7.2        Méthodes HTTP et interopérabilité

1.7.2.1         PUT/PATCH/DELETE

Tous les clients ne supportent pas les méthodes HTTP PUT/PATCH/DELETE, l’API doit donc permettre aux clients de passer par un POST en valorisant l’en-tête X-HTTP-Method-Override avec la méthode attendue réellement par l’API.

DELETE https://xxx/v1/tickets/1

 Doit être équivalent à:

POST https://xxx/v1/tickets/1

< X-HTTP-Method-Override: DELETE

1.7.2.2         Requête GET avec un body

De nombreux clients ne supportent pas la présence d’un body en requête de type GET, l’API doit donc permettre aux clients de passer par un POST en valorisant l’en-tête X-HTTP-Method-Override avec la méthode GET.

GET https://xxx/v1/tickets/1

< {

< ...

< }

Doit être équivalent à :

POST https://xxx/v1/tickets/1

< X-HTTP-Method-Override: GET

< {

< ...

< }

1.7.3        Convention des actions de modification PUT/POST/PATCH/DELETE

Les actions de modifications (PUT, POST, PATCH) doivent retourner l’objet modifié (notamment pour tenir compte des changements non fournis par les paramètres), par exemple : created_at et updates_at sont modifiés par le serveur et non l’émetteur.

Dans le cas d’un POST (création), HTTP 201 devrait être retourné en incluant le « Location header » pointant vers l’URL de ressource nouvellement créée, et ce même si la ressource est retournée dans le corps du message.

Un DELETE exécuté sans erreur provoque un retour avec un statut HTTP 204.

1.7.4        Idempotence

Chaque action doit autant que possible respecter les règles d’idempotence associées à la méthode HTTP utilisée. Une action est dite « idempotente » si le résultat de son appel sur une ressource donne un résultat constant. Pour simplifier, deux appels consécutifs provoquent le même résultat sur la ressource (fof=f).

 Une exception sera faite des modifications d’attributs effectuées implicitement par le serveur. Un exemple d’attribut entrant dans cette catégorie pourrait être un attribut exposant la date de dernière modification d’une ressource.

 Le tableau suivant spécifie pour chaque méthode le comportement attendu de l’action :

Method

Idempotent

Ressource non modifiée

GET

Oui

Oui

POST

Non

Non

PUT

Oui

Non

PATCH

Non

Non

DELETE

Oui

Non

HEAD

Oui

Oui

OPTIONS

Oui

Oui

1.7.5        Codes de retour HTTP

Tous les codes HTTP standards peuvent être utilisés. Il faut cependant rester pragmatique et simplifier l’usage de l’API en n’essayant pas à tout prix d’utiliser toute la palette de statuts. Il est donc probable que l’utilisation des codes 405 à 429 reste marginale au profit du plus générique 400.

Code

Description

200

OK

201

Created (l'objet est créé)

202

Accepted (la requête est acceptée et mise en attente pour exécution)

204

No Content (Réponse en succès à une requête sans body : exemple sur une commande DELETE)

206

Partial Content (Réponse partielle du serveur – peut être utilisé dans le cas de la pagination par exemple)

301

Moved permanently (1 requête fait appel à une API obsolète/non supportée et donc renvoie vers une nouvelle API)

302

Moved Temporarily (1 requête fait appel à une version dépréciée mais encore supportée)

304

Not Modified (non modifié : le client peut utiliser la donnée en cache)

400

Bad request (requête mal formée)

401

Unauthorized (authentification en erreur)

403

Forbidden (l'habilitation ne permet pas d'exécuter cette commande)

404

Not Found (la ressource demandée n'existe pas)

405

Method Not Allowed (l'objet visé ne permet pas d'exécuter cette commande)

409

Conflict (un conflit sur des opérations a été détecté)

410

Gone (la ressource n'est plus disponible : utile pour les API obsolètes)

412

Precondition Failed (une précondition provoque une erreur)

413

Request Entity Too Large (une requête est trop grande)

415

Unsupported Media Type (« content type » incorrect dans la requête)

422

Unprocessable Entity (utilisé pour des erreurs de validation)

429

Too Many Requests (requête rejetée pour cause de taux d'usage – rate limit -)

500

Internal Server Error (ne devrait jamais apparaître)

501

Not Implemented (fonction en cours de développement ou non disponible dans le contexte courant)

503

Service Unavailable (fonction temporairement non disponible)

 Légende :

  • 2XX – Succès de nature variable
  • 3XX – Statut Intermédiaire
  • 4XX – Erreur dans la requête du client
  • 5XX – Erreur sur la partie serveur

1.7.6        Recherche, filtres et pagination

Sont développées dans ce paragraphe les caractéristiques attendues concernant la recherche « simple » sur une collection. Il ne s'agit évidemment que de recommandations issues de l'expérience.

 

Les recherches simples définies ci-dessous sont basées sur des techniques classiques utilisées dans le monde des API REST (mais pas forcément dans les plus pures règles de l’art, le caractère simple des API étant toujours une priorité).

Les mots réservés utilisés en paramètre d'URL peuvent être préfixés par un underscore afin d'éviter toute collision avec un nom d'attribut. 

  • Pagination par défaut 

Tous les appels GET sur les collections « publiques » devraient renvoyer par défaut des résultats paginés, et ce, sans que le client n’ait eu besoin de le spécifier. Cette mesure permet de préserver les ressources serveur en cas de mauvaise manipulation de l’API sur de collections à grande volumétrie.

Par contre, les API à usages internes peuvent utiliser une pagination beaucoup plus large, voire l’intégralité des éléments, à charge de la mise en œuvre des collections « publiques » d’effectuer une mise en cache et une pagination pour les clients (en général, il est admis que le client dispose de 10 pages, les API « publiques » disposant de 1000 pages et au-delà il s’agit de refaire une requête vers le back-office).

  • Nombre de résultats maximum par page borné

Le nombre d’éléments maximum susceptible d’être retournés par page doit être défini pour chaque collection – si le client demande un nombre d’éléments plus important que ce seuil, le serveur doit remonter une erreur (statut 400 par exemple).

Un seuil de 25 à 50 éléments complexes par page au maximum semble raisonnable dans la majorité des cas d’utilisation, mais ce seuil pourra être élevé en fonction du contexte.

 

Un mécanisme de multiples paginations peut être mis en place pour optimiser l’expérience utilisateur, notamment pour les IHM, par exemple :

  1. L’appel aux API internes peut retourner une pagination par exemple entre 100 000 éléments et 1 million ; La pagination est implicitement réalisée par le principe du « Limit, Offset ».
  2. L’appel aux API « publiques » met en cache ce résultat et sert la quantité nécessaire pour 1 000 à 10 000 éléments vers le serveur de présentation ; La pagination et cache (avec invalidation de cache au bout d’un délai par exemple de 5 minutes) est basée sur une solution clef/valeur (en mémoire ou matérialisée) telle que Redis.
  3. Le serveur de présentation met en cache les 1 000 à 10 000 éléments et renvoie les 10 premières pages vers le navigateur client, soit avec 50 éléments par page, il sert un lot de 500 éléments sur l’ensemble dont il dispose ; La gestion des pages est effectuée classiquement entre le serveur de présentation et le navigateur (en mémoire des deux côtés et selon la session utilisateur).
  4. Le navigateur, via JavaScript, utilise ces 10 pages en local pour le cache et lorsqu’il déborde de ce cache, il effectue une requête vers le serveur de présentation qui lui ressert 10 pages ;
  5. Si le serveur de présentation est au-delà de sa limite des données qu’il possède, il réalise un appel vers les API « publiques ».
  6. Si les API « publiques » sortent du cadre de son cache ou si le cache est invalidé (« time out »), il appelle les API internes du back-office pour récupérer les résultats attendus (sur la base d’un lot de 100 000 à 1 million d’éléments par exemple).
  •  Formalisme de pagination pour les requêtes simples

Si le choix sur les API est de ne pas se baser sur les en-têtes HTTP, mais de passer par des paramètres d’URL du type, la pagination pourra s'opérer ainsi :

GET https://xxx/v1/tickets?_offset=10&_limit=12

En l’absence de ces paramètres, le paramètre « _offset » est placé implicitement à 0 et le paramètre « _limit » au maximum d’éléments autorisés par page (par exemple 50).

 D'autres façons de procédert existent.


  • Formalisme de filtrage pour les requêtes simples

Un filtrage par attribut peut être possible. Il pourrait être du type :

GET https://xxx/v1/tickets?state=open&severity=minor

  •  Formalisme de tri pour les requêtes simples

La fonctionnalité de recherche peut permettre le tri sur un ou plusieurs attributs. Dans l’exemple ci-dessous, la requête spécifie que les résultats doivent être triés par l’attribut «severity» puis par l’attribut «created_at». Par défaut le tri est ascendant, sauf si l’attribut appartient à la liste des valeurs spécifiées dans le paramètre «_desc».

GET https://xxx/v1/tickets?_sort=severity,created_at&_desc=created_at

  •  Formalisme de restriction de champs pour les requêtes simples

La fonctionnalité de recherche peut prévoir un moyen permettant de restreindre les champs à remonter dans la réponse. Cette fonctionnalité peut être implémentée en ajoutant un paramètre de requête « _fields » dont les valeurs sont les attributs à intégrer. L’absence de ce paramètre indique que tous les champs sont ciblés.

GET https://xxx/v1/tickets?_fields=severity,id

  •  Alias

Des requêtes identifiées comme récurrentes doivent pouvoir être exposées au niveau de la collection.

GET https://xxx/v1/tickets/opened

Pourrait être un alias sur la requête :

GET https://xxx/v1/tickets?state=open&sort=severity

  •  Format de la réponse

La réponse JSON à une recherche peut être composée d’une enveloppe contenant les informations suivantes :

    • la pagination ;
    • les critères de tri appliqués, liste vide s’il n’y a aucun filtre explicite ;
    • les filtres appliqués, liste vide s’il n’y a aucun filtre ;
    • les champs demandés, liste vide si tous les champs sont demandés ;
    • Une liste de résultat, potentiellement vide.

Le format de la réponse pourrait s’inspirer d’un modèle équivalent à celui-ci :

{

  “hits” : {

      “total” : 67,

      “offset” : 12,

      “limit” : 50

  },

  “filters” : [

      { “state”:”open” },

      { “severity”:”minor” }

  ],

  “fields” : [

      “severity”,

      “issue_id”

  ],

  “results” : [

       { … },

       { … },

      

  ]

}

1.7.7        Cas particulier (actions RPC)

Dans le cas particulier où une action ne peut être modélisée simplement comme manipulation d’une ressource, il est autorisé – à la marge – d’opter pour une exposition de service de type RPC, c'est-à-dire en utilisant non plus un nom, mais un verbe (bien qu'en anglais le verbe soit également souvent un nom).

Exemple :

POST https://xxx/v1/servers/5/reboot

< { “when”:”Now” }

1.8        Appels REST et résultats asynchrones

1.8.1        Introduction

Certains services ne sont pas en mesure de retourner un résultat en un temps raisonnable – à l’échelle d’une requête HTTP. Ces requêtes sont alors traitées de manière asynchrone et permettent un fonctionnement selon un mode de pulling du client, ou, même si plus complexe, selon un système de callback

Ce mode se base sur des attributs (header ou body) qui contiennent l’état de la tâche concernée :

  • un identifiant technique de la tâche (lié à une création de type POST par exemple sur la collection associée);
  • un statut de la tâche, cet attribut peut prendre par exemple les valeurs suivantes : 'pending', 'done', 'error' ;
  • une date de soumission de la tâche ;
  • une date de passage de l'état 'pending' à l'état 'end' ou 'error'. La valeur de ce champ reste à 'null' tant que la tâche est à l'état 'pending' ;
  • une date d'expiration, fixée lors du passage de la ressource à l'état 'done' ou 'error',  elle pourra être fixée selon les besoins fonctionnels. 
    • Exemple : end_date+24h. La valeur de ce champ reste à 'null' tant que la tâche est à l'état 'pending'. Lorsque la date d'expiration est dépassée, un mécanisme de purge peu alors libérer les ressources ;
  • un champ (complexe) d'erreur dont la valeur de ce champ reste à 'null' tant que la tâche n'est pas à l'état 'error'. Elle devrait possèder la même structure que celle définie précédemment pour la gestion des retours d'erreurs.

Un GET sur cette ressource retourne un 202 tant que la tâche est à l'état 'pending'.

1.8.2        Exemples

Ce chapitre montre le fonctionnement du mode pulling, se basant sur un exemple fictif de demande de récupération d'un snapshot d'un serveur (processus long et données volumineuses).

Pulling

  • Création de la demande

Requête du client :

POST https://xxx/v1/servers/5/snapshot

Réponse du serveur : Le serveur lance l'exécution de la tâche, créé une nouvelle tâche de suivi de snapshot, renvoie un statut HTTP 202 et fourni le lien vers la ressource snapshot nouvellement créée dans le header « Location ».

202 OK

Location: https://xxx/v1/servers/5/snapshot/456

{

  'id' : 456,

  'state' : 'pending',

  'start_date' : '2014-01-10T03:06:17.396Z',

  'end_date':null,

  'expiration_date':null,

  'result_link':null,

  'error':null

}

  • Pulling du client : tâche toujours en cours

 Requête du client : Le client effectue des requêtes régulières pour vérifier l'état de la tâche.

GET https://xxx/v1/servers/5/snapshot/456

Réponse du serveur : La tâche n'est pas terminée, le serveur revoie un statut HTTP 202 et la représentation de la tâche asynchrone.

202 OK

{

  'id' : 456,

  'state' : 'pending',

  'start_date' : '2014-01-10T03:06:17.396Z',

  'end_date':null,

  'expiration_date':null,

  'result_link':null,

  'error':null

}

  • Pulling du client : tâche terminée

 Requête du client : Le client effectue des requêtes régulières pour vérifier l'état de la tâche.

GET https://xxx/v1/servers/5/snapshot/456

Réponse du serveur : La tâche est terminée, le serveur revoie un statut HTTP 200 et la représentation de la tâche asynchrone.

200 OK

{

  'id' : 456,

  'state' : 'done',

  'start_date' : '2014-01-10T03:06:17.396Z',

  'end_date':'2014-01-20T03:06:17.396Z',

  'expiration_date':'2014-02-20T03:06:17.396Z',

  'result': {Json de résultat},

  'error':null

}

  • En cas d’erreur

 Requête du client :  Le client effectue des requêtes régulières pour vérifier l'état de la tâche.

GET https://xxx/v1/servers/5/snapshot/456

Réponse du serveur : La tâche est terminée mais en erreur d’habilitation pour l’exemple, le serveur revoie un statut HTTP 403 et la représentation de la tâche asynchrone.

403 OK

{

  'id' : 456,

  'state' : 'error',

  'start_date' : '2014-01-10T03:06:17.396Z',

  'end_date':'2014-01-20T03:06:17.396Z',

  'expiration_date':'2014-02-20T03:06:17.396Z',

  'error': {

     ”code” : ”FBD”,

     ”message” : ”The application is not authorized to use this functionality”,

     ”description” :  ”The application 'Twitter' is not authorized to use the 'administration' service, the role 'ADMIN_ROLE' is mandatory.”

   }

 

 

Pulling

Il s'agit ici d'éviter le pulling de tous les clients, qui peuvent conduire à un excès de consommation des ressources du service (appels trop nombreux et/ou trop fréquents). Le principe est de permettre au service d'effectuer un rappel sur une requête fournie en paramètre pour permettre le déclenchement asynchrone de la récupération du résultat.

  • Création de la demande avec requête de "callback"

Requête du client :

POST https://xxx/v1/servers/5/snapshot

Headers :

  callback=post https://yyy/otherUri

Réponse du serveur : La réponse est similaire mais fait apparaître le callback en plus.

202 OK

Location: https://xxx/v1/servers/5/snapshot/456

{

  'id' : 456,

  'state' : 'pending',

  'start_date' : '2014-01-10T03:06:17.396Z',

  'end_date':null,

  'expiration_date':null,

  'result_link':null,

  'error':null,

  'callback':'post https://yyy/otherUri'

}

  • Callback

Lorsque la tâche est accomplie, en succès ou en erreur, le service appelle la requête telle que positionnée lors de la demande (alternativement, cette url de callback peut être paramétrée pour chaque client sans avoir à être fournie à chaque appel) et place dans le body l'informartion basique du résultat, sans détail. L'objet ici n'est pas de fournir le résultat complet mais juste une information que la ressource est enfin disponible.

POST  https://yyy/otherUri

{

  'location' : 'Location: https://xxx/v1/servers/5/snapshot/456',

  'state' : 'xxx'

}

  • Pulling unique de fin

A charge ensuite à l'appelé, suite au callback, de réaliser l'appel GET à la ressource pour obtenir le résultat final (en succès ou en erreur).

GET https://xxx/v1/servers/5/snapshot/456

1.9        Identifiant de Corrélation

A des fins de traçabilité et de débogage, un identifiant de corrélation devrait être associé à chaque requête et transmis de service en service puis renvoyé au client initiateur de l’appel.

Cet identifiant est attendu par chaque service dans un header HTTP (par exemple : X-REQUESTID). Si cet identifiant n’existe pas, il est généré (via un UUID). Dans tous les cas, il est ajouté au contexte de l’appel courant.

  • L’identifiant de corrélation devrait être inséré dans chaque ligne de log.
  • L’identifiant de corrélation devrait être inséré dans le header HTTP de chaque appel de services REST sous-jacents.
  • L’identifiant de corrélation est inséré dans le header HTTP de la réponse au client.

L’objet est de pouvoir ensuite via un système d’analyse des logs de comprendre le temps passé dans chaque composant pour une demande, le nombre d’appels et les services induits.

1.10   Rate limiting

S’il s’avère nécessaire d’implémenter un mécanisme de « rate-limiting », le système devra indiquer les métriques d’utilisation du client en retour de requête en insérant les header HTTP suivants :

  • X-Rate-Limit-Limit : nombre de requêtes autorisées dans la période ;
  • X-Rate-Limit-Remaining : nombre de requêtes restantes autorisées dans la période ;
  • X-Rate-Limit-Reset : nombre de secondes avant la fin de la période courante.

En cas de dépassement de capacité, le code « 429 Too Many Requests » est renvoyé au client. La granularité de ce système pourrait descendre jusqu’à une gestion par combinaison Client/Api. L’élément logiciel implémentant cette fonctionnalité pourrait être placé juste derrière le « proxy » authentifiant pour bénéficier des headers d’authentification du client, tout en préservant des appels inutiles aux services exposés.

Des éléments réseau de sécurité dédiés et de niveau 7 compléteront ce dispositif en frontal du système. Ils constituent une première ligne de défense en cas de tentative d'attaque (attaque de type DOS par exemple).

2         Autres spécifications

2.1        Langage de requêtes

De manière usuelle, en architecture REST, les requêtes sont souvent simplistes, allant de la simple sélection au travers d’un identifiant unique, en passant par des filtres simples additionnels ajoutés en complément à la requête.

·        Requête sur un identifiant

GET https://xxx/v1/tickets/1234

·        Requête par filtre, y compris avec un tri

GET https://xxx/v1/tickets?state=open&sort=severity

 

Mais il arrive que des critères plus complexes doivent être mis en œuvre. Dans ce cas de figure, il est important de déterminer la longévité du produit ciblé à construire et les interactions avec les partenaires. Plus la durée de validité du produit est courte et les critères stables, y compris malgré un nombre élevé de partenaire, plus la logique d’expression de la requête peut être simplifiée :

-         Un mode « figé » où les cas de critères sont préétablis (comme pour « state=open&sort=severity »)

-         Un mode « laxe » mais où l’expression de la requête est très proche du modèle d’implémentation sous-jacent :

  • SQL
  • SparQL
  • Elasticsearch QL

Si la durée du produit est longue (au sens que l’implémentation sous-jacente puisse changée de support, dont le moteur de données utilisé), il est alors important de se séparer de la représentation interne pour en faire une abstraction, ce qu’on appelle alors un DSL (Domain Specific Language). Il est important de comprendre alors les éléments fondateurs d’une requête pour permettre à ce langage d’être le plus universel possible, et extensible dans le temps.

 

Au sein du Programme Vitam, une telle implémentation a été réalisée afin de permettre (attention, susceptible d'avoir changé depuis 2017) :

  • La possibilité de changer de technologie sous-jacente pour une solution logicielle dont l’objectif est de durer au moins 20 années
  • La possibilité de réaliser des requêtes complexes

La structure globale choisie fut la suivante :

{

  "$roots": [ "id0" ],

  "$query": [

    { "$match": { "Title": "titre" }, "$depth": 4 }

  ],

  "$filter": { "$limit": 100 },

  "$projection": { "$fields": { "#id": 1, "Title": 1, "#type": 1, "#parents": 1, "#object": 1 } },

  "$facetQuery": { "$terms": "#object.#type" } //(**UNSUPPORTED**)

}

  • $roots désigne la ou les racines de la structure arborescente visée par la requête (à ne conserver dans un cadre général que si les données ont un caractère arborescent)
  • $query désigne la partie sélection des données, à base d’opérateurs logiques, comparatifs et textuels ; en SQL, cela correspondrait à la partie critères de recherche après un « WHERE »
  • $filter désigne la partie de la requête, hors sélection des données, qui filtre le résultat en nombre ($limit), en ordre ($orderby) et en position ($offset)
  • $projection désigne ce que l’on veut récupérer comme résultat (pour le SQL correspond à la partie entre « SELECT » et « FROM »)
  • $facetQuery désigne des résultats complémentaires d’agrégats que l’on souhaite obtenir en appui à la requête réalisée (futurs filtres additionnels possibles qui viendraient s’ajouter à $query)

Le détail du DSL Vitam peut être trouvé ici :

http://www.programmevitam.fr/ressources/DocCourante/html/manuel-integration

2.2        Services transverses

Des services transverses devraient être définis pour chaque service. Il est important pour l’exploitabilité du système que l’interface de ces services soit homogène sur tous les modules (format et type des données remontées, url d’accès, etc.).

 Ces services devront répondre aux besoins suivants :

  • API de vérification du statut du module – utilisé par exemple comme « healthcheck » par un équilibreur de charge. L’exécution de ce service doit donc être peu coûteuse en ressources, car potentiellement appelé très fréquemment ;
  • API de vérification de l’état des dépendances directes du module (test de connectivité) ;
  • API d'activation/désactivation d'un service – voire arrêt/redémarrage d’un service si applicable ;
  • API de récupération des métriques du module :
    • Métriques système et JVM ;
    • Métriques d’utilisation des API (par version).

            Cette fonctionnalité pourrait remonter pour chaque « endpoint » :

      • le nombre d’appels ;
      • les temps d’exécution  maximum/minimum/moyen/etc ;
      • le nombre d’erreurs rencontrées ;
      • etc.

 Le tout sur différentes fenêtres temporelles.

  • API de sauvegarde (export) et de restauration (import) – des fichiers de configuration et éventuellement des données si la contrainte est applicable ;
  • API permettant de consulter la version du service (cf. chapitre dédié à la gestion des services REST), et la version de l’application déployée.

 Parmi les options possibles pour répondre à ces besoins, on peut considérer :

  • L’utilisation du module METRICS dans le service JAX-RS ;
  • L’utilisation de l’analyse des logs de serveur via un outil du type ELK (et donc l’API sera celle d’Elasticsearch ou celle de Kibana).

2.6        Tests

Autant que possible, il est souhaitable que les tests (quelques soient leur nature) puissent être réalisés de manière automatisée (via Jenkins ou équivalent) et non de manière humaine. L'objectif ainsi poursuivi est de pouvoir les exécuter autant de fois que nécessaire, y compris si possible après chaque nouvelle soumission de modification de code, avant leur validation formelle (dans l'esprit de Git).

2.6.1        Tests unitaires pour chaque « fonctionnalité » avec la couverture des cas d’erreurs et des cas de succès

  • Ceci inclut également les tests unitaires lors de la découverte d’un bug, le test devant démontrer le bug puis sa correction ;
  • L’objectif de ces tests est d’éviter les régressions fonctionnelles ou de contrat de service d’une API (Java ou REST) à un niveau élémentaire (sans scénario métier compliqué).

2.6.2        Tests d’intégration pour chaque scénario fonctionnel avec la couverture des cas d’erreurs et des cas de succès

L’objectif est de tester de bout en bout un scénario métier (parfois technique), en passant par l’ensemble des composants nécessaires (en général depuis l’API dite « externe » de l’application). Ces mêmes tests pourront être réutilisés pour certains pour conduire à des tests de performances et de stress.

Il peut être admis que les tests fonctionnels de la dernière version ne soient pas déjà automatisés mais le seront avant la livraison de la version suivante.

2.7        Sécurité dans le cadre des développements

Chaque code devra respecter les standards de sécurité, et en particulier ceux prescrits par l’OWASP (www.owasp.org). Les modules SonarQube Java et JavaScript intègrent des analyses de sécurité orientées codes selon quelques critères notamment OWASP.

Avec Java, il est conseillé d'étudier notamment l'intégration des bibliothèques Java suivantes :

  • OWASP
    • Json-Santitizer : s’assure qu’un Json est « sans erreur » au sens contenu (pas de lien HTML, pas de Javascript implicite, …)
    • ESAPI : s’assure de différentes fonctionnalités de contrôles (date, chaine de caractères, email, …), vient aussi en complément que ce soit pour les XML ou les JSON sur les failles de sécurité de types XSS ;
  • Sécurité des accès :
    • Shiro : permet de gérer des « droits » d’accès aux API, des règles de filtrage, et d’authentification.

Aucun commentaire:

Publier un commentaire