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 :

Principaux segments d'urn URL

  1. On commence par le protocole (parfois appelé scheme), qui va jusqu’au deux-points inclus ; ici c’est https:.
  2. Ensuite vient l’authentification éventuelle (voir plus bas).
  3. 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.
  4. 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.
  5. Suit alors le chemin (pathname, parfois path), qui n’est jamais vide (il est par défaut à /) ; ici c’est /api/v1/batch.
  6. 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.
  7. 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 :

URL HTTPS avec authentification HTTP Basic inclue

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 et password
  • hostname, port, host (hostname:port) et origin (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 le toString() 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é-valeur
  • delete(name) pour virer une association
  • has(name) pour tester la présence
  • entries(), keys() et values() pour les itérateurs associés (l’itérabilité par défaut étant calée sur entries(), comme pour une Map)

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 que set(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 🔥 !