Empêcher les requêtes de pré-vérification CORS grâce à la négociation de contenu
Publié le 04 janvier 2022
Dans les applications web modernes, il est courant de servir l'API web et l'application frontend à partir de sous-domaines différents :
- https://api.exemple.com : votre API Web, qui expose généralement des documents JSON.
- https://exemple.com : votre application web, généralement construite en JavaScript, qui génère des documents HTML à partir des données JSON brutes récupérées grâce à l'API.
C'était le modèle mis en œuvre par API Platform jusqu'à l'année dernière mais nous l'avons changé pour deux raisons principales : les performances et les principes REST.
CORS nuit aux performances
Parlons d'abord des performances. Lorsque vous servez votre API à partir d'une origine différente de celle de l'application frontend, les navigateurs envoient automatiquement une requête OPTIONS supplémentaire avant toute requête adressée à l'API. Il s'agit d'une requête de pré-vérification, qui est nécessaire en raison du CORS (Cross-Origin Resource Sharing ou partage des ressources entre origines multiples). Ces requêtes de pré-vérification garantissent que l'application frontend est autorisée à interagir avec les ressources servies par l'API.
Comme l'explique Nick Olinger dans un récent article, ces requêtes de pré-vérification ajoutent une latence inutile, elles ralentissent les applications, gaspillent la batterie des appareils mobiles et augmentent la charge des serveurs.
Le problème des requêtes de pré-vérification est encore plus grave lors de la mise en œuvre de styles d'API tirant parti des capacités de multiplexage de HTTP/2 et HTTP/3, comme Vulcain.rocks. L'idée principale des approches similaires à Vulcain est de télécharger de petites ressources en parallèle, uniquement lorsque cela est nécessaire et avec un bon taux de réussite du cache. Ces approches sont des alternatives au téléchargement de gros documents composés comme dans GraphQL ou JSON:API. Mais si - à cause de ce problème de requêtes preflight - le nombre de demandes effectuées est multiplié par 2, la latence sera mauvaise et les avantages des approches de type Vulcain seront réduits à néant.
La solution pour éviter ces requêtes est simple : servir l'API et l’application frontend à partir de la même origine ! Et c'est ce que nous avons fait dans API Platform 2.6.
Dans son article, Nick propose une solution rapide : configurez votre reverse proxy pour créer un alias de votre API en tant que chemin avec la même origine que votre application web. https://api.exemple.com devient https://exemple.com/api, toutes les ressources appartiennent à la même origine, problème résolu ! Mais nous pouvons faire encore mieux en suivant les principes REST.
Une même ressource, des représentations différentes
Le modèle architectural du web est connu sous le nom de REST (Representational State Transfer). Ce style a été défini par Roy Fielding alors qu'il travaillait sur les standards qui constituent encore aujourd’hui la base duweb : URI (Uniform Resource Identifier) et HTTP (Hypertext Transfer Protocol) :
Fondamentalement, une bonne application web devrait suivre les principes REST, car REST est le style architectural du web lui-même.
Dans sa thèse de doctorat, Fielding définit les concepts de "ressource" et de "représentation de ressource". Une ressource est une abstraction d'information. Il peut s'agir de "toute information qui peut être nommée". Sur le web, une ressource est identifiée par un URI (aussi connu sous le nom d'URL). Une représentation de ressource est une concrétion, c'est "une séquence d'octets, plus des métadonnées de représentation pour décrire ces octets". Habituellement, la représentation d'une ressource est connue sous le nom de "fichier" ou de "document". Cela signifie que la même ressource, ayant le même identifiant (son URI ou son URL), peut avoir différentes représentations.
Comme un exemple vaut mieux qu’un long discours, imaginons une plateforme de blog. Cette plateforme expose des articles de blog. Les articles sont disponibles par le biais d'une API web, pour être récupérés par des machines. Ils sont également disponibles sur un site web, sous forme d'articles lisibles par des humains. Sous le capot, le site web interroge l'API web pour récupérer les articles sous forme de données brutes au format JSON et les transforme en un document HTML lisible par les humains.
Si nous pensons en termes de concepts REST, nous avons une ressource (l'abstraction) : l'article de blog. Cette ressource a un identifiant unique : son URI, par exemple https://exemple.com/blog/a-propos-des-requetes. Cette ressource a deux représentations :
- Le document JSON, servi par l'API : la représentation de la ressource
- Le document HTML, servi par le site web : représentation de la même ressource lisible par un humain
Ça semble pas mal, mais si les deux représentations ont le même identifiant, comment le serveur peut-il savoir celle qu'il doit retourner ? C'est là qu'intervient la négociation du contenu.
REST, ainsi que le protocole HTTP, définissent des mécanismes permettant au serveur d'envoyer la représentation appropriée de la ressource. Grâce au mécanisme de "négociation proactive", le client peut informer le serveur qu'il préfère une représentation JSON de la ressource, en utilisant l'en-tête de requête Accept :
GET /blog/a-propos-des-requetes HTTP/1.1
Host: exemple.com
Accept: application/json
HTTP/1.1 200 OK
Content-Type: application/json
{
"title": "A propos des requetes",
"...": "..."
}
De même, un navigateur web peut utiliser l'en-tête Accept pour demander une représentation HTML :
GET /blog/a-propos-des-requetes HTTP/1.1
Host: exemple.com
Accept: text/html
HTTP/1.1 200 OK
Content-Type: text/html
<!DOCTYPE html>
<title>A propos des requetes</title>
<!-- yes, this is a valid HTML document! -->
Nous disposons désormais d'un moyen élégant pour se passer des requêtes de pré-vérification CORS ! En vérifiant les principes REST, nous avons identifié que nous ne servions pas deux ressources différentes, mais deux représentations d'une même ressource. La négociation de contenu permet au serveur d'envoyer la représentation appropriée de la ressource en fonction du client : notre application JavaScript obtiendra la représentation JSON tandis que le navigateur web obtiendra la représentation HTML. Comme toutes les ressources sont maintenant servies à partir de la même origine (et ont même une URL identique), nous n'avons plus besoin de requêtes de pré-vérification.
Cependant, de nos jours, les API et les sites web sont généralement développés à l'aide de technologies différentes (par exemple Go, Rust ou PHP pour l'API, JavaScript pour le site web). Et c'est pourquoi il est pratique de les servir à partir de différents sous-domaines. Est-ce que nous tournons en rond ? Par chance, les serveurs web modernes et les reverse proxies permettent d'acheminer facilement les requêtes vers différents backends en fonction des en-têtes de négociation du contenu.
Router les requêtes avec le serveur web Caddy
Comme vous le savez peut-être, nous aimons beaucoup chez Les-Tilleuls.coop le serveur web Caddy ! Si vous ne connaissez pas Caddy, il s'agit d'une alternative moderne, disposant de nombreuses fonctionnalités et distribué sous forme de logiciel libre à Apache et NGINX. Caddy est écrit en Go. Utilisé comme un reverse proxy, Caddy permet d'inspecter facilement la requête et de la router vers l'API web ou vers le site web en fonction de l'en-tête Accept. Voici une version simplifiée du fichier Caddyfile fourni par API Platform 2.6 pour y parvenir :
# Matches requests for HTML documents
@pwa expression `{header.Accept}.matches("\\btext/html\\b")`
reverse_proxy @pwa http://front
reverse_proxy http://api
Tout d'abord, un request matcher est défini. Si l'en-tête Accept contient le type MIME text/html, la requête est acheminée vers l'application JavaScript (http://front), sinon, la requête est transmise à l'API (http://api). Il s'agit d'URL internes. L'URL publique est la même pour les deux représentations, Caddy se charge de la répartition. Et voilà ! Plus de requêtes de pré-vérification, et un design propre respectant les principes REST !
API Platform contient un exemple plus avancé. Bien entendu, il est également possible de réaliser cette opération à l'aide d'autres reverse proxies.