Convertisseur JSON vers XML

Convertit en XML, et montre ce qui a été renommé et pourquoi.

Entrée
Sortie
En attenteCollez un document pour le vérifier. La validation se fait à la frappe.

Tout s'exécute dans cet onglet. Rien de ce que vous collez n'est envoyé, journalisé ni transmis où que ce soit. Ouvrez votre panneau réseau et vérifiez.

Collez du JSON ci-dessus et du XML bien formé apparaît à côté, avec une déclaration, une vraie indentation et chaque caractère spécial échappé. Si le JSON ne s’analyse pas, vous obtenez le message de l’analyseur lui-même plutôt qu’un volet vide, et si une clé a dû être renommée pour devenir un nom d’élément XML légal, l’outil dit laquelle et ce qu’elle est devenue.

Vous en avez besoin quand quelque chose en aval ne parle que XML : un point de terminaison SOAP, l’import d’un ERP hérité, un format d’échange validé par XSD, une fixture pour un service que vous simulez. C’est la moitié la moins glamour de la paire et celle aux arêtes les plus vives, car XML impose des contraintes que JSON n’a pas, et ces contraintes doivent bien être tranchées quelque part.

Tout tourne dans cet onglet. Rien n’est envoyé, ce qui mérite d’être dit : le JSON que l’on colle dans un convertisseur est en général une réponse d’API capturée, et les réponses d’API capturées contiennent des jetons, des numéros de compte et des fiches clients.

XML exige exactement une racine. JSON, non.

La RFC 8259 autorise n’importe quelle valeur au niveau supérieur d’un document JSON : un objet, un tableau, une chaîne, un nombre, true, false ou null. XML 1.0 exige exactement un élément racine contenant tout le reste : l’écart est donc arbitré à chaque conversion.

Un objet avec exactement une clé possède déjà une racine naturelle : cette clé devient l’élément racine et rien n’est inventé. {"order": {...}} donne <order>...</order> sans enveloppe, cas courant puisque c’est la forme que renvoie la conversion de XML vers JSON. Tout le reste est enveloppé, et le panneau de notes le signale :

  • Un objet avec au moins deux clés de premier niveau est enveloppé dans un élément unique, nommé root par défaut et modifiable dans la barre de contrôles.
  • Un tableau de premier niveau est enveloppé deux fois, car un tableau n’a pas de nom d’élément propre : chaque membre devient <item> à l’intérieur de <root>.
  • Un scalaire de premier niveau devient le texte de l’élément racine : le document JSON 42 donne <root>42</root>.
  • Un null de premier niveau donne un élément racine vide, <root/>.

Les tableaux répètent le nom de l’élément. Pas d’enveloppe.

C’est la décision que la plupart des convertisseurs prennent à l’envers. {"line": ["a", "b"]} donne deux éléments <line> frères, et non un élément <line> contenant deux enfants <item>. La répétition, c’est ainsi que XML exprime une liste ; c’est même la raison pour laquelle le sens XML vers JSON souffre du problème du singleton. Inventer une enveloppe produit du XML qu’aucun schéma existant n’accepterait, et qui ne referait pas l’aller-retour.

Deux conséquences suivent. Un tableau vide ne produit rien du tout, la clé disparaît donc : zéro répétition d’un élément, c’est zéro élément. Et un tableau de tableaux s’aplatit, parce que le tableau interne n’a pas de nom distinct de l’externe : [[1,2],[3]] sous la clé a donne trois éléments <a>. Si l’un ou l’autre compte, restructurez d’abord le JSON.

{
  "order": {
    "@_id": "00042",
    "line": [ "Widget", "Gasket" ],
    "note": null,
    "meta": {},
    "tags": []
  }
}

<?xml version="1.0" encoding="UTF-8"?>
<order id="00042">
  <line>Widget</line>
  <line>Gasket</line>
  <note/>
  <meta/>
</order>
Tableaux, null, objets vides et tableaux vides.

Les clés JSON ne sont souvent pas des noms XML légaux

La section 2.3 de XML 1.0 définit un Name comme un NameStartChar suivi de NameChars. Un NameStartChar est une lettre, un tiret bas ou un deux-points ; ce n’est pas un chiffre, une espace, une esperluette ni un signe dollar. Une clé JSON n’a pas cette restriction : "2024 total", "user@email" et "$ref" sont des clés ordinaires et aucune n’est un nom d’élément légal.

Le monde .NET et XSD échappe, transformant une espace en _x0020_, ce qui est exact et illisible. Cet outil assainit et signale à la place : les caractères illégaux sont remplacés un pour un plutôt que supprimés, et un nom commençant encore par un chiffre reçoit un préfixe. C’est là tout l’intérêt, car la suppression fond des clés distinctes en un même nom, pas le remplacement. Chaque renommage apparaît dans le panneau de notes.

  • "2024 total" devient _2024_total : l’espace est remplacée, puis le chiffre initial impose le préfixe.
  • "2024-total" devient _2024-total : le tiret est déjà légal, seul le chiffre initial réclame le préfixe. Les deux restent distincts, ce que la suppression vous aurait justement coûté.
  • "user@email" devient user_email, "$ref" devient _ref, et une clé vide devient un simple tiret bas.
  • Une clé déjà légale passe intacte, y compris avec un deux-points : "soap:Body" reste "soap:Body". Cela donne un élément préfixé sans déclaration xmlns, bien formé mais pas correct du point de vue des espaces de noms.

Attributs, texte, et ce que JSON perd en premier

Les clés commençant par @_ deviennent des attributs de l’élément englobant, et une clé nommée #text fournit le contenu textuel. Les deux correspondent au sens XML vers JSON, si bien que la sortie de cette page-là se reconvertit directement. Les valeurs d’attribut sont échappées plus durement que le texte : outre &, < et ", le writer échappe tabulation, saut de ligne et retour chariot en références numériques, car la section 3.3.3 de XML 1.0 normalise en espaces les blancs littéraux des valeurs d’attribut lors d’une réanalyse.

Deux pertes se produisent à l’intérieur même de JSON, avant que cet outil n’intervienne, et elles ressemblent à des bugs de conversion. Les nombres JSON sont des doubles IEEE 754 : un identifiant de dix-neuf chiffres écrit en nombre nu a déjà perdu ses derniers chiffres au moment où le texte est analysé. Et les clés dupliquées sont arbitrées par l’analyseur, la dernière l’emportant. Il y a aussi une bizarrerie JavaScript : les clés ressemblant à des indices de tableau sont énumérées en premier et par ordre numérique croissant, un objet mêlant "2", "10" et "name" n’émettra donc pas ses éléments dans l’ordre où vous les avez écrits.

null et l’objet vide produisent tous deux <x/> : ils sont indiscernables et reviennent tous deux en chaîne vide. Si la distinction vous est nécessaire, xsi:nil="true" est la seule façon normalisée de dire « présent mais nul », et elle réclame l’espace de noms xsi déclaré sur un ancêtre.

Le faire en code

La même conversion dans les quatre langages qui consomment le plus de XML, plus PHP et une ligne de shell. Les drapeaux de sécurité comptent au retour : analyser du JSON n’est pas le risque, mais le code qui convertit JSON en XML réanalyse presque toujours ce XML quelque part, et les valeurs par défaut de Java et .NET résoudront un DOCTYPE s’il s’en présente un.

import { XMLBuilder } from 'fast-xml-parser';

const builder = new XMLBuilder({
  ignoreAttributes: false,        // default is true: @_ keys would be dropped
  attributeNamePrefix: '@_',
  textNodeName: '#text',
  format: true,
  indentBy: '  ',
  suppressEmptyNode: true,        // write <note/> rather than <note></note>
  processEntities: true,          // escape &, < and " in values
});

const xml = '<?xml version="1.0" encoding="UTF-8"?>\n' + builder.build(data);

// XMLBuilder does not sanitise keys. A key of "2024 total" is written
// verbatim and produces XML that will not parse, so check before building:
const illegal = Object.keys(flatten(data))
  .filter((k) => !/^[A-Za-z_][\w.\-]*(:[A-Za-z_][\w.\-]*)?$/.test(k));
if (illegal.length) throw new Error('Illegal XML names: ' + illegal.join(', '));
import json
import re
import xmltodict


def legal_name(key):
    """Replace illegal characters rather than stripping them, so that
    distinct keys stay distinct. Prefix a leading digit."""
    name = re.sub(r'[^\w.\-:]', '_', key, flags=re.UNICODE)
    return name if re.match(r'^[A-Za-z_:]', name) else '_' + name


def sanitise(node):
    if isinstance(node, dict):
        return {legal_name(k): sanitise(v) for k, v in node.items()}
    if isinstance(node, list):
        return [sanitise(v) for v in node]
    return node


data = json.loads(json_source)
if not isinstance(data, dict) or len(data) != 1:
    data = {'root': data}          # xmltodict.unparse requires a single root

print(xmltodict.unparse(
    sanitise(data),
    pretty=True, indent='  ',
    attr_prefix='@_', cdata_key='#text',
    full_document=True,            # emit the <?xml ...?> declaration
))

# xmltodict raises ValueError("Document must have exactly one root.") rather
# than guessing, which is correct behaviour and the reason for the wrap.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.dataformat.xml.XmlMapper;

JsonNode tree = new ObjectMapper().readTree(jsonSource);

XmlMapper xml = new XmlMapper();
xml.enable(SerializationFeature.INDENT_OUTPUT);

// JSON has no root name and Jackson will not invent one, so supply it.
String out = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
    + xml.writer().withRootName("root").writeValueAsString(tree);

// Two things Jackson will not do for you:
//  1. It does not sanitise names. A key with a space throws
//     IllegalArgumentException at write time, which is at least loud.
//  2. It writes every value as a child element. There is no attribute
//     convention on a JsonNode, so @_ keys become elements unless you bind
//     to a class annotated with @JacksonXmlProperty(isAttribute = true).
using System.Xml;
using Newtonsoft.Json;

// The second argument is the root element name, used when the JSON does not
// already have exactly one top-level property. Without it, multi-key JSON
// throws JsonSerializationException rather than producing invalid XML.
XmlDocument? document = JsonConvert.DeserializeXmlNode(jsonSource, "root");

if (document is null) throw new InvalidOperationException("Empty JSON.");

var settings = new XmlWriterSettings { Indent = true, IndentChars = "  " };
using var writer = XmlWriter.Create(Console.Out, settings);
document.Save(writer);

// Json.NET uses "@" for attributes and "#text" for text, so retarget the
// keys if your JSON came from a converter using "@_". It does not sanitise
// names either: a property called "2024 total" throws XmlException("The ''
// character, hexadecimal value 0x20, cannot be included in a name").
<?php
$data = json_decode($source, true, 512, JSON_THROW_ON_ERROR);

function legal_name(string $key): string {
    $name = preg_replace('/[^\w.\-:]/u', '_', $key);
    return preg_match('/^[A-Za-z_:]/', $name) ? $name : '_' . $name;
}

function write_node(XMLWriter $w, string $name, mixed $value): void {
    if (is_array($value) && array_is_list($value)) {
        foreach ($value as $v) write_node($w, $name, $v);   // repeat, no wrapper
        return;
    }
    $w->startElement(legal_name($name));
    if (is_array($value)) {
        foreach ($value as $k => $v) {
            if (str_starts_with((string) $k, '@_')) {
                $w->writeAttribute(legal_name(substr((string) $k, 2)), (string) $v);
            } elseif ($k === '#text') {
                $w->text((string) $v);
            } else {
                write_node($w, (string) $k, $v);
            }
        }
    } elseif ($value !== null) {
        $w->text(is_bool($value) ? ($value ? 'true' : 'false') : (string) $value);
    }
    $w->endElement();
}

$single = count($data) === 1;
$w = new XMLWriter();
$w->openMemory();
$w->setIndent(true);
$w->setIndentString('  ');
$w->startDocument('1.0', 'UTF-8');
write_node($w, $single ? (string) array_key_first($data) : 'root',
               $single ? reset($data) : $data);
$w->endDocument();
echo $w->outputMemory();
# yq v4 (Mike Farah). JSON is a subset of YAML, so -p=json works directly.
yq -p=json -o=xml '.' payload.json

# Set the root and the key conventions to match this page:
yq -p=json -o=xml \
   --xml-attribute-prefix='@_' \
   --xml-content-name='#text' \
   '{"root": .}' payload.json

# yq writes no XML declaration, so prepend one if a consumer expects it, and
# check the result: yq does not sanitise element names.
{ echo '<?xml version="1.0" encoding="UTF-8"?>'
  yq -p=json -o=xml '{"root": .}' payload.json; } | xmllint --noout --nonet -

Notez ce qu’aucune de ces bibliothèques ne fait : assainir une clé en nom XML légal. Jackson, Json.NET et XMLBuilder lèvent une exception ou émettent du XML qui ne s’analysera pas, et yq l’émet en silence. Si vos clés JSON viennent d’une saisie utilisateur, d’une liste de colonnes de base de données ou d’une ligne d’en-têtes de tableur, l’étape d’assainissement vous revient, et remplacer les caractères plutôt que les supprimer est ce qui empêche deux clés voisines de devenir un seul élément.

Questions fréquentes

Mon JSON quitte-t-il le navigateur ?

Non. L’analyseur JSON, l’assainisseur de noms et l’écrivain XML sont tous du JavaScript exécuté dans cet onglet, et il n’existe pas de composant serveur à qui parler. Ouvrez le panneau Réseau et convertissez quelque chose : rien n’est demandé.

C’est dans ce sens que cela compte le plus. Le JSON collé dans un convertisseur est en général une réponse capturée sur une API en production pendant un débogage, avec son jeton d’accès ou une fiche client complète. Plusieurs outils bien placés sur cette recherche envoient cette charge utile à un serveur, et l’un d’eux publie les documents enregistrés à une URL devinable.

Pourquoi mon JSON est-il enveloppé dans un élément <root> ?

Parce que XML n’autorise qu’un seul élément racine et que votre JSON avait plus d’une clé de premier niveau, ou était un tableau, ou un scalaire nu. Il n’existe aucun moyen d’écrire deux racines frères en XML.

Un objet avec exactement une clé est laissé tel quel : cette clé devient la racine et aucune enveloppe n’est ajoutée, donc {"order": {...}} donne <order>, tandis qu’ajouter une deuxième clé de premier niveau donne <root>. Le nom de l’enveloppe se modifie dans la barre de contrôles. Si le XML part vers un endroit qui valide, mettez celui qu’attend le schéma.

Comment les tableaux JSON sont-ils convertis ?

En répétant le nom de l’élément une fois par membre, sans enveloppe. {"line": ["a", "b"]} produit deux éléments <line> côte à côte. C’est ainsi que XML représente une liste, et c’est ce qui permet à la sortie de refaire l’aller-retour. Certains convertisseurs produisent plutôt <line><item>a</item><item>b</item></line>, qui ressemble davantage au JSON et échoue à la validation contre tout schéma écrit pour du vrai XML.

Deux cas limites en découlent. Un tableau vide n’émet rien, la clé disparaît donc, et un tableau imbriqué directement dans un autre s’aplatit, faute de nom propre pour l’interne.

Que deviennent les clés qui ne sont pas des noms d’élément valides ?

Elles sont renommées, et chaque renommage est listé dans le panneau de notes à côté de la sortie. Les caractères illégaux sont remplacés un pour un par un tiret bas, et un nom commençant encore par un chiffre reçoit un tiret bas devant.

Remplacer plutôt que supprimer est délibéré : supprimer ferait de "2024 total" et "2024total" le même élément et fusionnerait deux champs distincts. Ce n’est pas pour autant une injection parfaite : "first name" et "first_name" deviennent tous deux first_name, le tiret bas étant déjà légal dans le second.

Comment obtenir des attributs plutôt que des éléments enfants ?

Préfixez la clé par @_. {"user": {"@_id": "7", "name": "Alice"}} produit <user id="7"><name>Alice</name></user>. Le préfixe se modifie au-dessus de l’éditeur ; videz-le et plus rien ne sera jamais écrit en attribut.

N’y mettez que des scalaires. La valeur est convertie en chaîne, donc un objet sous une clé @_ devient l’inutile texte [object Object]. Une chose que fait ce writer et que la plupart ne font pas : tabulations, sauts de ligne et retours chariot dans une valeur d’attribut sont écrits en références numériques, si bien qu’une valeur multiligne survit à une réanalyse au lieu d’être aplatie en espaces.

Puis-je reconvertir en JSON et retrouver mon point de départ ?

Pour la plupart des documents, oui, si vous utilisez la page XML vers JSON avec les mêmes réglages @_ et #text. C’est pour cet appariement que ces valeurs par défaut ont été choisies.

Quatre choses ne survivent pas. null et {} deviennent tous deux <x/> et reviennent en chaîne vide. Un tableau vide disparaît entièrement. Les types numériques de JSON sont perdus, XML n’ayant pas de types : 42 revient en "42" sauf à activer la conversion. Et l’ordre des clés n’a de sens dans aucun des deux formats. Si l’aller-retour exact est une exigence, gardez le XML comme source de vérité et lisez-le avec XPath.

Outils associés

Pour aller plus loin