X

Encodage des données pour les requêtes POST


À l’heure actuelle, lorsque vous accédez à copilot.github.com, vous êtes accueilli avec cet exemple :

async function isPositive(text) {
  const response = await fetch(`http://text-processing.com/api/sentiment/`, {
    method: 'POST',
    body: `text=${text}`,
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
    },
  });
  const json = await response.json();
  return json.label === 'pos';
}

C’est mauvais et pourrait entraîner les problèmes de sécurité. Voici où les choses tournent mal : body: `text=${text}`. Le texte sans échappement est ajouté dans un format avec un encodage défini. C’est similaire à l’injection SQL/HTML, car quelque chose destiné à être une “valeur” peut interagir directement avec le format.

Je vais creuser dans le bon sens, mais aussi me promener dans certaines API moins connues :

URLSearchParams gère l’encodage et le décodage application/x-www-form-urlencoded données. C’est plutôt pratique, parce que, eh bien…

Le application/x-www-form-urlencoded est à bien des égards une monstruosité aberrante, le résultat de nombreuses années d’accidents de mise en œuvre et de compromis conduisant à un ensemble d’exigences nécessaires à l’interopérabilité, mais ne représentant en aucun cas de bonnes pratiques de conception. En particulier, les lecteurs sont avertis de prêter une attention particulière aux détails tordus impliquant des conversions répétées (et dans certains cas imbriquées) entre les codages de caractères et les séquences d’octets. Malheureusement, le format est largement utilisé en raison de la prévalence des formulaires HTML.

— La norme URL

… alors oui, c’est une mauvaise idée d’essayer de l’encoder/décoder vous-même. Voici comment cela fonctionne:

const searchParams = new URLSearchParams();
searchParams.set('foo', 'bar');
searchParams.set('hello', 'world');


console.log(searchParams.toString());

Le constructeur accepte également un tableau de paires nom/valeur, ou un itérateur qui génère des paires nom/valeur :

const searchParams = new URLSearchParams([
  ['foo', 'bar'],
  ['hello', 'world'],
]);


console.log(searchParams.toString());

Un objet:

const searchParams = new URLSearchParams({
  foo: 'bar',
  hello: 'world',
});


console.log(searchParams.toString());

Ou une chaîne :

const searchParams = new URLSearchParams('foo=bar&hello=world');


console.log(searchParams.toString());

Lecture URLSearchParams

Il existe plusieurs méthodes pour lire et muter URLSearchParamsqui sont documentés sur MDN, mais si vous souhaitez gérer toutes les données, son itérateur est pratique :

for (const [key, value] of searchParams) {
  console.log(key, value);
}

Ce qui signifie que vous pouvez facilement le convertir en un tableau de paires nom/valeur :


const keyValuePairs = [...searchParams];

Ou utilisez-le avec des API qui prennent en charge les itérateurs qui génèrent des paires nom/valeur, telles que Object.fromEntriesqui le convertit en objet :


const data = Object.fromEntries(searchParams);

Mais sachez que la conversion en objet est parfois une conversion avec perte :

const searchParams = new URLSearchParams([
  ['foo', 'bar'],
  ['foo', 'hello'],
]);


console.log(searchParams.toString());


const data = Object.fromEntries(searchParams);

url.searchParams

Les objets URL ont un searchParams propriété qui est vraiment pratique:

const url = new URL('https://jakearchibald.com/?foo=bar&hello=world');


console.log(url.searchParams.get('hello'));

Malheureusement, location.searchParams est indéfini. C’est parce que la définition de window.location est compliqué par la façon dont certaines propriétés de celui-ci fonctionnent à travers les origines. Par exemple paramètre otherWindow.location.href fonctionne à travers les origines, mais obtenir ce n’est pas permis. Quoi qu’il en soit, pour contourner cela:


location.searchParams;

const url = new URL(location.href);

url.searchParams;


const searchParams = new URLSearchParams(location.search);

URLSearchParams en tant que corps Fetch

Ok, maintenant nous arrivons au point. Le code dans l’exemple au début de l’article est cassé car il n’échappe pas à l’entrée :

const value = 'hello&world';
const badEncoding = `text=${value}`;


console.log([...new URLSearchParams(badEncoding)]);

const correctEncoding = new URLSearchParams({ text: value });


console.log(correctEncoding.toString());

Pour faciliter les choses, URLSearchParams peut être utilisé directement comme Request ou Response body, donc la version “correcte” du code depuis le début de l’article est :

async function isPositive(text) {
  const response = await fetch(`http://text-processing.com/api/sentiment/`, {
    method: 'POST',
    body: new URLSearchParams({ text }),
  });
  const json = await response.json();
  return json.label === 'pos';
}

Si tu utilises URLSearchParams en tant que corps, le Content-Type l’en-tête est automatiquement défini sur application/x-www-form-urlencoded, ce qui est formidable car même après plus de 20 ans d’expérience en tant que développeur Web, je ne me souviens jamais de ce type de contenu. Vous pouvez toujours fournir le vôtre Content-Type header pour remplacer la valeur par défaut.

Vous ne pouvez pas lire un Request ou Response corps comme URLSearchParamsmais il y a des moyens de contourner cela…

FormData les objets peuvent représenter l’état nom/valeur d’un formulaire HTML. Cela signifie que les valeurs peuvent être des fichiers, comme elles le peuvent avec <input type="file">.

Vous pouvez remplir FormData indiquer directement :

const formData = new FormData();
formData.set('foo', 'bar');
formData.set('hello', 'world');

C’est aussi un itérateur, donc il peut être converti en un tableau de paires nom/valeur, ou un objet, comme vous pouvez le faire avec URLSearchParams. Mais contrairement à URLSearchParamsvous pouvez lire un formulaire HTML directement comme FormData:

const formElement = document.querySelector('form');
const formData = new FormData(formElement);
console.log(formData.get('username'));

Cela vous donne les données qui seraient soumises par le formulaire. Je trouve souvent cela beaucoup plus facile que d’obtenir les données de chaque élément individuellement.

FormData en tant que corps Fetch

Semblable à URLSearchParamsvous pouvez utiliser FormData directement en tant que fetch body :

const formData = new FormData();
formData.set('foo', 'bar');
formData.set('hello', 'world');

fetch(url, {
  method: 'POST',
  body: formData,
});

Cela définit automatiquement le Content-Type en-tête à multipart/form-dataet envoie les données dans ce format :

const formData = new FormData();
formData.set('foo', 'bar');
formData.set('hello', 'world');

const request = new Request('', { method: 'POST', body: formData });
console.log(await request.text());

… qui enregistre quelque chose comme :

------WebKitFormBoundaryUekOXqmLphEavsu5
Content-Disposition: form-data; name="foo"

bar
------WebKitFormBoundaryUekOXqmLphEavsu5
Content-Disposition: form-data; name="hello"

world
------WebKitFormBoundaryUekOXqmLphEavsu5--

C’est ce que multipart/form-data ressemble à. C’est plus complexe que application/x-www-form-urlencoded, mais il peut inclure des données de fichier. Cependant, certains serveurs ne peuvent pas gérer multipart/form-data, y compris Express. Si vous voulez soutenir multipart/form-data dans Express, vous devez utiliser quelque chose comme busboy ou redoutable.

Mais que se passe-t-il si vous voulez envoyer un formulaire en tant que application/x-www-form-urlencoded? Bien…

Conversion en URLSearchParams

Depuis le URLSearchParams le constructeur accepte un itérateur qui produit des paires nom/valeur, et FormDataL’itérateur de fait exactement cela, vous pouvez convertir de l’un à l’autre :

const formElement = document.querySelector('form');
const formData = new FormData(formElement);
const searchParams = new URLSearchParams(formData);

fetch(url, {
  method: 'POST',
  body: searchParams,
});

Cependant, cette conversion sera lancée si les données du formulaire contiennent un fichier. application/x-www-form-urlencoded ne peut pas représenter les données de fichier, donc ni l’un ni l’autre ne peut URLSearchParams.

Lecture des corps Fetch en tant que FormData

Vous pouvez également lire un Request ou Response objet comme FormData:

const formData = await request.formData();

Cela fonctionne si le corps de la requête/réponse est multipart/form-data ou application/x-www-form-urlencoded. C’est particulièrement utile pour gérer les soumissions de formulaires dans un service worker.

Il existe quelques autres formats qui peuvent être récupérés :

Blobs

Blob objets (et donc File puisqu’il hérite de Blob) peut être récupérer des corps :

fetch(url, {
  method: 'POST',
  body: blob,
});

Cela définit automatiquement le Content-Type à la valeur de blob.type.

Cordes

fetch(url, {
  method: 'POST',
  body: JSON.stringify({ hello: 'world' }),
  headers: { 'Content-Type': 'application/json' },
});

Cela définit automatiquement le Content-Type pour text/plain;charset=UTF-8mais comme toujours, il peut être remplacé, comme je l’ai fait ci-dessus.

Tampons

ArrayBuffer objets, et tout ce qui est soutenu par un tableau tampon tel que Uint8Arraypeut être utilisé comme corps d’extraction :

fetch(url, {
  method: 'POST',
  body: new Uint8Array([
    
  ]),
  headers: { 'Content-Type': 'image/png' },
});

Cela ne définit pas le Content-Type header automatiquement, vous devez donc le faire vous-même.

Ruisseaux

Et enfin, les corps récupérés peuvent être des flux ! Pour Response objets, cela permet toutes sortes de divertissements avec un travailleur de service, et plus récemment, ils peuvent également être utilisés avec des requêtes.

Alors oui, n’essayez pas de gérer multipart/form-data ou application/x-www-form-urlencoded vous-même, laissez FormData et URLSearchParams faites le dur labeur!

Je ne suis pas non plus contre des choses comme GitHub Copilot. Traitez simplement la sortie comme une réponse sur StackOverflow et examinez-la avant de la valider.

Nicholas Mendez m’a tweeté demander comment FormData peut être sérialisé en JSON sans perte de données.

Les formulaires peuvent contenir des champs comme celui-ci :

<select multiple name="tvShows">
  <option>Motherland</option>
  <option>Taskmaster</option></select>

…où plusieurs valeurs peuvent être sélectionnées, ou vous pouvez avoir plusieurs entrées avec le même nom :

<fieldset>
  <legend>TV Shows</legend>
  <label>
    <input type="checkbox" name="tvShows" value="Motherland" />
    Motherland
  </label>
  <label>
    <input type="checkbox" name="tvShows" value="Taskmaster" />
    Taskmaster
  </label></fieldset>

Le résultat est un FormData objet qui a plusieurs entrées avec le même nom, comme ceci :

const formData = new FormData();
formData.append('foo', 'bar');
formData.append('tvShows', 'Motherland');
formData.append('tvShows', 'Taskmaster');

Et comme nous l’avons vu avec URLSearchParamscertaines conversions d’objets entraînent des pertes :


const data = Object.fromEntries(formData);

Il existe plusieurs façons d’éviter la perte de données et de se retrouver avec quelque chose de JSON-stringifyable. Tout d’abord, il y a le tableau des paires nom/valeur :


const data = [...formData];

Mais si vous voulez un objet plutôt qu’un tableau, vous pouvez faire ceci :

const data = Object.fromEntries(
  
  [...new Set(formData.keys())]
    
    .map((key) => [key, formData.getAll(key)]),
);

… ce qui vous donne :

{
  "foo": ["bar"],
  "tvShows": ["Motherland", "Taskmaster"]
}

J’aime que chaque valeur soit un tableau, même s’il n’a qu’un seul élément. Cela empêche beaucoup de ramifications de code sur le serveur et simplifie la validation. Cependant, vous préférerez peut-être la convention PHP/Perl où un nom de champ qui se termine par [] signifie “cela devrait produire un tableau”:

<select multiple name="tvShows[]"></select>

Et pour le convertir :

const data = Object.fromEntries(
  
  [...new Set(formData.keys())].map((key) =>
    key.endsWith('[]')
      ? 
        [key.slice(0, -2), formData.getAll(key)]
      : 
        [key, formData.get(key)],
  ),
);

… ce qui vous donne :

{
  "foo": "bar",
  "tvShows": ["Motherland", "Taskmaster"]
}

Rappelez-vous, n’essayez pas de convertir un formulaire en JSON si le formulaire contient des fichiers. Si tel est le cas, vous êtes bien mieux avec multipart/form-data.