Convertidor de JSON a XML
Convierte a XML, y mira qué se ha renombrado y por qué.
Todo se ejecuta en esta pestaña. Nada de lo que pegues se sube, se registra ni se envía a ningún sitio. Abre el panel de red y compruébalo.
Pega JSON arriba y aparece XML bien formado al lado, con declaración, sangría de verdad y cada carácter especial escapado. Si el JSON no se analiza obtienes el mensaje del propio analizador en lugar de un panel en blanco, y si alguna clave ha tenido que renombrarse para convertirse en un nombre de elemento XML legal, la herramienta dice cuál y en qué se ha convertido.
Necesitas esto cuando algo aguas abajo solo habla XML: un endpoint SOAP, la importación de un ERP heredado, un formato de intercambio validado con XSD, un fixture para un servicio que estás simulando. Es la mitad menos glamurosa del par y la que tiene los bordes más afilados, porque XML impone restricciones que JSON no tiene y esas restricciones hay que resolverlas en algún sitio.
Todo se ejecuta en esta pestaña. No se sube nada, lo cual conviene decir porque el JSON que la gente pega en conversores suele ser una respuesta de API capturada, y las respuestas de API capturadas contienen tokens, números de cuenta y registros de clientes.
XML necesita exactamente una raíz. JSON no.
El RFC 8259 permite cualquier valor en el nivel superior de un documento JSON: un objeto, un array, una cadena, un número, true, false o null. XML 1.0 exige exactamente un elemento raíz que contenga todo lo demás, así que el desajuste se resuelve en cada conversión.
Un objeto con exactamente una clave ya tiene raíz natural, así que esa clave se convierte en el elemento raíz y no se inventa nada. {"order": {...}} da <order>...</order> sin envoltorio, que es el caso común porque es la forma que devuelve convertir XML a JSON. Cualquier otra cosa se envuelve, y el panel de notas lo dice:
- Un objeto con dos o más claves de nivel superior se envuelve en un único elemento, llamado root por defecto y editable en la fila de controles.
- Un array de nivel superior se envuelve dos veces, porque un array no tiene nombre de elemento propio: cada miembro se convierte en <item> dentro de <root>.
- Un escalar de nivel superior se convierte en el texto del elemento raíz, así que el documento JSON 42 da <root>42</root>.
- Un null de nivel superior da un elemento raíz vacío, <root/>.
Los arrays repiten el nombre del elemento. No reciben envoltorio.
Esta es la decisión que la mayoría de conversores invierten. {"line": ["a", "b"]} se convierte en dos elementos <line> hermanos, no en un elemento <line> con dos hijos <item>. La repetición es como XML expresa una lista; es la razón por la que la dirección de XML a JSON tiene el problema del singleton. Inventar un envoltorio produce XML que ningún esquema existente aceptaría, y no haría el viaje de vuelta.
De ahí se siguen dos consecuencias. Un array vacío no produce nada en absoluto, así que la clave desaparece: cero repeticiones de un elemento son cero elementos. Y un array de arrays se aplana, porque el array interior no tiene nombre distinto del exterior, así que [[1,2],[3]] bajo la clave a da tres elementos <a>. Si alguna de las dos cosas importa, reestructura el JSON antes.
{
"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>Las claves JSON a menudo no son nombres XML legales
La sección 2.3 de XML 1.0 define un Name como un NameStartChar seguido de NameChars. Un NameStartChar es una letra, un guion bajo o dos puntos; no es un dígito, ni un espacio, ni un ampersand, ni un signo de dólar. Una clave JSON no tiene esa restricción, así que "2024 total", "user@email" y "$ref" son claves corrientes y ninguna es un nombre de elemento legal.
El mundo de .NET y XSD escapa, convirtiendo un espacio en _x0020_, lo cual es exacto e ilegible. Esta herramienta sanea e informa en su lugar: los caracteres ilegales se sustituyen uno a uno en vez de eliminarse, y un nombre que siga empezando por un dígito recibe un prefijo. Ese es el punto, porque eliminar colapsa claves distintas en un mismo nombre y sustituir no. Cada renombrado aparece en el panel de notas.
- "2024 total" pasa a _2024_total: se sustituye el espacio y luego el dígito inicial obliga al prefijo.
- "2024-total" pasa a _2024-total: el guion ya es legal, así que solo el dígito inicial necesita el prefijo. Las dos siguen siendo distintas, que es exactamente lo que eliminar te habría costado.
- "user@email" pasa a user_email, "$ref" pasa a _ref, y una clave vacía pasa a ser un guion bajo suelto.
- Una clave que ya es legal pasa intacta, incluida una con dos puntos: "soap:Body" se queda como "soap:Body". Eso da un elemento con prefijo y sin declaración xmlns, que está bien formado pero no es correcto respecto a espacios de nombres.
Atributos, texto, y qué pierde JSON primero
Las claves que empiezan por @_ se convierten en atributos del elemento contenedor, y una clave llamada #text aporta el contenido de texto. Las dos coinciden con la dirección de XML a JSON, así que la salida de aquella página se convierte de vuelta sin más. Los valores de atributo se escapan más duro que el texto: además de &, < y ", el writer escapa tabulador, salto de línea y retorno de carro como referencias numéricas de carácter, porque la sección 3.3.3 de XML 1.0 normaliza el espacio literal de los valores de atributo a espacios al volver a analizar.
Dos pérdidas ocurren dentro del propio JSON, antes de que esta herramienta intervenga, y parecen errores de conversión. Los números JSON son dobles IEEE 754, así que un identificador de diecinueve dígitos escrito como número desnudo ya ha perdido sus últimas cifras cuando el texto se analiza. Y las claves duplicadas las resuelve el analizador, ganando la última. Hay además una peculiaridad de JavaScript: las claves que parecen índices de array se enumeran primero y en orden numérico ascendente, así que un objeto que mezcle "2", "10" y "name" no emitirá sus elementos en el orden en que los escribiste.
null y el objeto vacío producen los dos <x/>, así que son indistinguibles y ambos vuelven como cadena vacía. Si necesitas la distinción, xsi:nil="true" es la única forma respaldada por los estándares de decir "presente pero nulo", y requiere el espacio de nombres xsi declarado en un ancestro.
Hacer esto desde código
La misma conversión en los cuatro lenguajes que más consumen XML, más PHP y una línea de shell. Los flags de seguridad importan en el camino de vuelta: analizar JSON no es el riesgo, pero el código que convierte JSON a XML casi siempre vuelve a analizar ese XML en algún sitio, y los valores por defecto de Java y .NET resolverán un DOCTYPE si aparece.
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 -Fíjate en lo que ninguna de estas bibliotecas hace: sanear una clave hasta convertirla en un nombre XML legal. Jackson, Json.NET y XMLBuilder o lanzan una excepción o emiten XML que no se analizará, y yq lo emite en silencio. Si tus claves JSON vienen de entrada de usuario, de una lista de columnas de base de datos o de la fila de cabeceras de una hoja de cálculo, el paso de saneado te toca escribirlo a ti, y sustituir caracteres en vez de borrarlos es lo que evita que dos claves parecidas acaben siendo un mismo elemento.
Preguntas frecuentes
¿Sale mi JSON del navegador?
No. El analizador de JSON, el saneador de nombres y el escritor de XML son todos JavaScript ejecutándose en esta pestaña, y no hay componente de servidor con el que hablar. Abre el panel de Red y convierte algo; no se solicita nada.
Esta es la dirección donde más importa. El JSON que se pega en un conversor suele ser una respuesta capturada de una API en vivo mientras se depura, con su token de acceso o un registro completo de cliente. Varias herramientas posicionadas para esta búsqueda envían ese payload a un servidor, y una publica los documentos guardados en una URL adivinable.
¿Por qué mi JSON está envuelto en un elemento <root>?
Porque XML permite exactamente un elemento raíz y tu JSON tenía más de una clave de nivel superior, o era un array, o un escalar suelto. No hay forma de escribir dos raíces hermanas en XML.
Un objeto con exactamente una clave se deja en paz: esa clave se convierte en la raíz y no se añade envoltorio, así que {"order": {...}} da <order>, mientras que añadir una segunda clave de nivel superior da <root>. El nombre del envoltorio es editable en la fila de controles. Si el XML va a un sitio que valida, ponlo a lo que el esquema espere.
¿Cómo se convierten los arrays JSON?
Repitiendo el nombre del elemento una vez por miembro, sin envoltorio. {"line": ["a", "b"]} produce dos elementos <line> uno al lado del otro. Así es como XML representa una lista, y es lo que hace que la salida haga el viaje de vuelta. Algunos conversores producen en cambio <line><item>a</item><item>b</item></line>, que se lee más como el JSON y falla la validación contra cualquier esquema escrito para XML de verdad.
De ahí salen dos casos límite. Un array vacío no emite nada, así que la clave desaparece, y un array anidado directamente dentro de otro se aplana, porque el interior no tiene nombre propio.
¿Qué pasa con las claves que no son nombres de elemento XML válidos?
Se renombran, y cada renombrado se lista en el panel de notas junto a la salida. Los caracteres ilegales se sustituyen uno a uno por un guion bajo, y un nombre que siga empezando por dígito recibe un guion bajo delante.
Sustituir en vez de eliminar es deliberado: eliminar convertiría "2024 total" y "2024total" en el mismo elemento y fusionaría dos campos distintos. Aun así no es una inyección perfecta: "first name" y "first_name" acaban las dos como first_name, porque el guion bajo ya era legal en la segunda.
¿Cómo consigo atributos en vez de elementos hijos?
Pon el prefijo @_ a la clave. {"user": {"@_id": "7", "name": "Alice"}} produce <user id="7"><name>Alice</name></user>. El prefijo es editable sobre el editor; vacíalo y nunca se escribirá nada como atributo.
Pon ahí solo escalares. El valor se convierte a cadena, así que un objeto bajo una clave @_ acaba como el inútil texto [object Object]. Una cosa que hace este writer y la mayoría no: los tabuladores, saltos de línea y retornos de carro dentro de un valor de atributo se escriben como referencias numéricas de carácter, así que un valor multilínea sobrevive a un nuevo análisis en vez de aplanarse a espacios.
¿Puedo convertir de vuelta a JSON y recuperar lo que tenía?
Para la mayoría de documentos sí, si usas la página de XML a JSON con los mismos ajustes de @_ y #text. Ese emparejamiento es la razón de que se eligieran esos valores por defecto.
Cuatro cosas no sobreviven. null y {} se convierten los dos en <x/> y vuelven como cadena vacía. Un array vacío desaparece por completo. Los tipos numéricos de JSON se pierden, porque XML no tiene tipos, así que 42 vuelve como "42" salvo que actives la conversión. Y el orden de las claves no es significativo en ninguno de los dos formatos. Si el viaje de ida y vuelta exacto es un requisito, mantén el XML como fuente de verdad y léelo con XPath.