Manipulation robuste des URL avec `URL` et `URLSearchParams`
Par Christophe Porteneuve • Publié le 10 avril 2023
• 5 min
Triturer des URL, c’est fréquent. On y cherche une info, on veut modifier une URL existante, on veut en construire une de toutes pièces : tout ça arrive régulièrement, mais y’a pas mal de pièges et d’aspects techniques parfois déroutants. Ça marche en dev, mais plus en prod, confronté à des vraies données, des vrais cas de figure.
On a pourtant tout ce qu’il faut pour faire ça plus vite, en plus court, et de façon robuste !
Petits rappels sur la structure d’une URL
Une URL contient en fait un paquet de composantes potentielles. Le diagramme ci-dessous illustre les principaux :
- On commence par le protocole (parfois appelé scheme), qui va jusqu’au deux-points inclus ; ici c’est
https:
. - Ensuite vient l’authentification éventuelle (voir plus bas).
- Puis c’est le tour du nom d’hôte (hostname), qui est un FQDN (Fully-Qualified Domain Name) ; ici c’est
my.project.test
. - Il est potentiellement suivi d’un deux-point et d’un port réseau, surtout si celui-ci n’est pas le port par défaut du protocole (par exemple, le port HTTP par défaut est 80, celui de HTTPS est 443). Ici c’est
4443
. - Suit alors le chemin (pathname, parfois path), qui n’est jamais vide (il est par défaut à
/
) ; ici c’est/api/v1/batch
. - On a ensuite la recherche (search, souvent appelée query ou query string), qui peut être vide. Elle court du point d’interrogation jusqu’au hash éventuel. Ici, c’est
?locale=en&mode=editorial
. - Enfin, on peut avoir un hash (parfois appelé anchor), qui indique le plus souvent un fragment de page web à cibler, ou une instruction de routage côté client. Il peut être vide, et vaut ici
#key=a2bc043
.
Ensemble, le nom d’hôte et le port contituent l’hôte (host), ici my.project.test:443
. Dans le même esprit, le protocole et l’hôte constituent l’origine (origin), ici https://my.project.test:4443
, qui sert de base aux politiques CORS et CSP, et à pas mal d’autres contraintes de sécurité.
Il est par ailleurs possible de transmettre une authentification HTTP Basic en insérant un segment entre le protocole et le nom d’hôte :
Tout ça est difficile à manipuler manuellement, notamment car :
- Il faut échapper tous les caractères problématiques (genre un slash dans un segment de chemin)
- Il faut encoder la recherche en conformité avec le dernier standard applicable, et ça resserre les vis au fil du temps (actuellement c’est la RFC 3986 qui fait foi (et les mises à jour de la RFC 8820), et tous les systèmes ne sont pas toujours à jour sur les dernières contraintes). Bref, c’est relou.
Manipuler tout ça avec URL
Le constructeur global URL
est notre ami :
new URL(
'https://tdd:foobar@my.project.test:4443/api/v1/batch?locale=en&mode=editorial#key=a2bc043'
)
// => URL {origin: 'https://my.project.test:4443', protocol: 'https:' …
Il n’exige qu’un argument, mais dans un contexte front-end on prendra soin de toujours lui fournir le 2e, qui indique une base de résolution, afin de gérer les URL relatives aussi :
new URL('../foo')
// => TypeError: Failed to construct 'URL': Invalid URL
new URL('../foo', window.location)
// => URL { origin: 'http…' … }
On voit ici que comme le 1er argument, le 2e n’a pas besoin d’être une String
.
Une fois qu’on a un objet URL
sous la main, on peut lire et écrire tous les segments, y compris les agrégats :
protocol
(inclut le:
)username
etpassword
hostname
,port
,host
(hostname:port) etorigin
(protocol//host)pathname
(jamais vide)search
(mais on a mieux, on va y venir)hash
Il existe aussi la propriété href
, renvoyée implicitement par son toString
, qui représente l’URL dans sa totalité.
Quelques exemples :
const url = new URL(
'https://tdd:foobar@my.project.test:4443/api/v1/batch?locale=en&mode=editorial#key=a2bc043'
)
url.origin
// => 'https://my.project.test:4443'
url.port = url.hash = url.username = url.password = ''
url.path = '/api/v2/batches'
url.href
// => 'https://my.project.test/api/v2/batches?locale=en&mode=editorial'
On peut bien sûr se galérer à analyser / modifier search
à la main, mais on a mieux : la propriété en lecture seule searchParams
est une instance dynamiquement liée de URLSearchParams
, qu’on va voir maintenant.
Manipuler la query string avec URLSearchParams
Les segments de recherche (query strings) ont un format pas si simple que ça. Tu en as déjà vu, c’est sûr :
?given=%C3%89lodie&family=Jaubert+Porteneuve
Il y a la question des espaces, de l’encodage en hexadécimal de chaque octet des codets et surrogate pairs UTF-8, etc. Pas glop !
Ce format est utilisé plus largement : c’est par exemple lui qui encode les contenus de formulaires web en mode POST (sauf s’ils envoient des fichiers). La requête HTTP porte alors le type MIME application/x-www-form-urlencoded
, qui suit les mêmes règles.
C’est sans doute pourquoi le constructeur URLSearchParams
existe indépendamment de URL
.
On peut construire un tel objet de plein de façons, c’est très flexible :
- Un dictionnaire clés / valeurs sous forme d’un objet nu
- Un dictionnaire clés / valeurs sous forme d’un itérateur de paires, tel qu’une
Map
ou un simple tableau de paires - Une
String
représentant un segment de recherche (avec ou sans le?
au début), ou à défaut un objet dont letoString()
produira ça :
const query = new Map()
query.set('locale', 'en')
query.set('model', 'editorial')
new URLSearchParams({ locale: 'en', mode: 'editorial' })
new URLSearchParams([
['locale', 'en'],
['mode', 'editorial'],
])
new URLSearchParams(query)
new URLSearchParams('locale=en&mode=editorial')
// Vraiment inutile : préfère `url.searchParams`
new URLSearchParams(url.search)
Ça se comporte presque comme une Map
, au sens où on retrouve la plupart des méthodes habituelles :
get(name)
pour récupérer la valeur associée à une cléset(name, value)
pour définir (ou remplacer) une association clé-valeurdelete(name)
pour virer une associationhas(name)
pour tester la présenceentries()
,keys()
etvalues()
pour les itérateurs associés (l’itérabilité par défaut étant calée surentries()
, comme pour uneMap
)
Le conversion String
(avec toString()
produit la version encodée, sans point d’interrogation (puisqu’il n’y en a pas dans tous les cas d’usage).
Détail amusant : une méthode sort()
trie les clés. Je vois pas trop l’intérêt, mais OK.
On a toutefois une subtilité : les clés à valeurs multiples. On peut en effet très bien avoir une recherche du genre :
?cat=games&cat=books&cat=videos&pageSize=50
En pratique, on a des méthodes dédiées à ces cas-là :
getAll()
renvoie un tableau des valeurs associées (quitte à ce qu’il soit vide)append(name, value)
ajoute une valeur associée à la clé, alors queset(name, value)
remplace toutes les associations éventuelles existantes par une seule.- Les itérateurs émettent une association pour chaque valeur.
const sp = new URLSearchParams('locale=en&mode=editorial')
sp.append('mode', 'expanded')
sp.set('lang', sp.get('locale'))
sp.delete('locale')
sp.getAll('mode')
// => ['editorial', 'expanded']
Array.from(sp)
// => [['mode', 'editorial'], ['mode', 'expanded'], ['lang', 'en']]
sp.sort()
sp.toString()
// => 'lang=en&mode=editorial&mode=expanded'
Comme je l’ai dit tout à l’heure, la propriété searchParams
des objets URL
est dynamiquement liée. Elle interagit donc avec leur représentation interne :
const url = new URL('https://demo.test?locale=en&mode=expanded')
url.searchParams.append('mode', 'details')
url.searchParams.delete('locale')
url.search
// => '?mode=expanded&mode=details'
url.href
// => 'https://demo.test/?mode=expanded&mode=details'
Trop cool, non ?
Un mot sur le module querystring
de Node.js…
Côté Node.js, depuis le début on manipule les URL avec les modules noyau url
et querystring
, dont l’API varie un peu, ainsi que les détails de conformité aux dernières RFC parues :
// ⚠️ NE FAIS PAS ÇA : DÉPRÉCIÉ VOIRE DANGEREUX
import { parse as parseURL, format as formatURL } from 'node:url'
import { parse as parseQuery, stringify as formatQuery } from 'node:querystring'
const url = parseURL('https://demo.test?locale=en&mode=expanded')
// => Url { protocol: 'https:', host: 'demo.test', port: null, search: …
const query = parseQuery(url.query)
// => { locale: 'en', mode: 'expanded' }
delete query.locale
query.mode = [query.mode, 'details']
url.pathname = '/api/v1/batch'
url.search = '?' + formatQuery(query)
formatURL(url)
// => 'https://demo.test/api/v1/batch?locale=en&mode=expanded&mode=details'
Cette API est certe un poil plus performante (en particulier pour l’analyse de la recherche), mais on est vraiment en train de trier les microsecondes.
Certaines de ces fonctions sont en tout cas dépréciées pour raison de sécurité (notamment la fonction parse
du module noyau url
), et d’une façon générale il est préférable d’utiliser les « API web » (URL
et URLSearchParams
) afin d’éviter d’écrire des codes différents entre le back et le front.
C’est dispo où ?
Sur tous les navigateurs modernes et sur Node.js depuis la 10.0 (pour la dispo en tant que globales).
Références
Les docs interactives en français du MDN sont comme toujours super utiles :
Des astuces en veux-tu en voilà !
On a tout plein d’articles et de vidéos existants et encore beaucoup à venir. Pour ne rien manquer, tu devrais penser à t’abonner à notre newsletter, à notre chaîne YouTube, nous suivre sur Twitter ou encore mieux, à suivre une de nos formations du feu de dieu 🔥 !