Apuntes DAM
Volver al inicio

Generar un formulario desde su descripción en JSON y validar los envíos

Ejercicio de JavaScriptMedioUnos 70 minutos

Genera la interfaz a partir de una descripción, como FXML o los layouts de Android: de un JSON con los campos sale un formulario HTML accesible (label, fieldset, required, min, max) y, con las mismas reglas, se validan los envíos y se convierten en un objeto con tipos.

  • Interfaces generadas a partir de una descripción
  • Formularios HTML accesibles
  • label, fieldset y legend
  • Validación en cliente y en servidor
  • URLSearchParams y x-www-form-urlencoded
  • Escapar HTML

Enunciado

Muchas interfaces no se programan a mano: se describen en un documento (XML en FXML y en los layouts de Android, JSON en los generadores de formularios de los CMS o en JSON Forms) y un programa construye la interfaz a partir de él. La ventaja es doble: cambiar el formulario es cambiar datos, y las mismas reglas sirven para dibujarlo y para validar lo que llega.

La entrada empieza con la descripción en JSON (un titulo y una lista de campos con nombre, etiqueta, tipo, obligatorio, min, max y opciones), sigue con --- y después cada línea es un envío tal como lo manda el navegador (application/x-www-form-urlencoded: nombre=Ana+L%C3%B3pez&edad=19).

Qué tiene que hacer el programa

  1. Revisa cada campo: Campo N (nombre): nombre no válido (debe empezar por letra y seguir con letras, cifras o _; si no hay nombre, solo Campo N), tipo desconocido «t», le faltan las opciones (para opcion y radio) y nombre repetido. Los campos con error se ignoran; sin etiqueta, se usa el nombre.
  2. Genera el formulario como en los ejemplos: <form id="slug-del-titulo" novalidate>, el <h2>, por cada campo su label for (con * si es obligatorio) y su control (text, email, number, date, textarea, select con una primera opción vacía Elige una opción, un fieldset con legend y un radio por opción, o un checkbox dentro de su label), required en los obligatorios, minlength/maxlength en los textos y min/max en los números, y el botón Enviar. Los textos, escapados (&, <, >, ").
  3. Valida cada envío campo a campo, en orden. Vacío (o solo espacios): es obligatorio si lo es; si no, vale null (o false en una casilla). Si no: número (no es un número, mínimo N, máximo N), fecha AAAA-MM-DD que exista (no es una fecha válida), correo (no es un correo válido), longitud de los textos en caracteres (mínimo N caracteres, máximo N caracteres), opción («v» no es una opción válida) y casilla, que solo admite on (valor no válido «v»). Al final, los datos que no son de ningún campo: x: campo desconocido.
  4. Cada envío escribe Envío N: correcto y, en la línea siguiente, dos espacios y el objeto con los valores (números como número, casillas como booleano) en JSON; o Envío N: E errores (1 error) y cada error como - campo: mensaje.
  5. Si la descripción no es JSON o no tiene la lista campos: La descripción no es válida: hace falta un JSON con una lista «campos» y nada más.

Entrada

La descripción en JSON (puede ocupar varias líneas).

Una línea ---.

Un envío por línea, codificado como un formulario (clave=valor&clave=valor).

Datos de referencia

Tipos de campo
tipoControl HTMLAtributos
texto<input type="text">minlength, maxlength
email<input type="email">minlength, maxlength
numero<input type="number">min, max
fecha<input type="date">
area<textarea>minlength, maxlength
opcion<select> con sus <option>
radio<fieldset> con un radio por opción
casilla<input type="checkbox">

Ejemplos de ejecución

Tu programa debe escribir exactamente esta salida para estas entradas. Las pruebas del editor incluyen estos ejemplos y otros casos ocultos.

Una matrícula correcta y otra con errores

Entrada

{
  "titulo": "Matrícula de FP",
  "campos": [
    {"nombre": "nombre", "etiqueta": "Nombre completo", "tipo": "texto", "obligatorio": true, "min": 3, "max": 60},
    {"nombre": "email", "etiqueta": "Correo", "tipo": "email", "obligatorio": true},
    {"nombre": "edad", "etiqueta": "Edad", "tipo": "numero", "min": 16, "max": 99},
    {"nombre": "ciclo", "etiqueta": "Ciclo", "tipo": "opcion", "opciones": ["DAM", "DAW", "ASIR"], "obligatorio": true},
    {"nombre": "turno", "etiqueta": "Turno", "tipo": "radio", "opciones": ["mañana", "tarde"]},
    {"nombre": "nacimiento", "etiqueta": "Fecha de nacimiento", "tipo": "fecha"},
    {"nombre": "comentarios", "etiqueta": "Comentarios", "tipo": "area", "max": 20},
    {"nombre": "condiciones", "etiqueta": "Acepto las condiciones", "tipo": "casilla", "obligatorio": true}
  ]
}
---
nombre=Ana+L%C3%B3pez&email=ana%40ies.es&edad=19&ciclo=DAM&turno=ma%C3%B1ana&nacimiento=2006-03-14&condiciones=on
nombre=Al&email=al%40ies&edad=120&ciclo=SMR&turno=noche&nacimiento=2006-02-30&comentarios=Quiero+hacer+la+FCT+en+Irlanda

Salida por consola

<form id="matricula-de-fp" novalidate>
  <h2>Matrícula de FP</h2>
  <label for="nombre">Nombre completo *</label>
  <input type="text" id="nombre" name="nombre" required minlength="3" maxlength="60">
  <label for="email">Correo *</label>
  <input type="email" id="email" name="email" required>
  <label for="edad">Edad</label>
  <input type="number" id="edad" name="edad" min="16" max="99">
  <label for="ciclo">Ciclo *</label>
  <select id="ciclo" name="ciclo" required>
    <option value="">Elige una opción</option>
    <option value="DAM">DAM</option>
    <option value="DAW">DAW</option>
    <option value="ASIR">ASIR</option>
  </select>
  <fieldset>
    <legend>Turno</legend>
    <label><input type="radio" name="turno" value="mañana"> mañana</label>
    <label><input type="radio" name="turno" value="tarde"> tarde</label>
  </fieldset>
  <label for="nacimiento">Fecha de nacimiento</label>
  <input type="date" id="nacimiento" name="nacimiento">
  <label for="comentarios">Comentarios</label>
  <textarea id="comentarios" name="comentarios" maxlength="20"></textarea>
  <label><input type="checkbox" id="condiciones" name="condiciones" required> Acepto las condiciones *</label>
  <button type="submit">Enviar</button>
</form>
Envío 1: correcto
  {"nombre":"Ana López","email":"ana@ies.es","edad":19,"ciclo":"DAM","turno":"mañana","nacimiento":"2006-03-14","comentarios":null,"condiciones":true}
Envío 2: 8 errores
  - nombre: mínimo 3 caracteres
  - email: no es un correo válido
  - edad: máximo 99
  - ciclo: «SMR» no es una opción válida
  - turno: «noche» no es una opción válida
  - nacimiento: no es una fecha válida
  - comentarios: máximo 20 caracteres
  - condiciones: es obligatorio

Campos mal descritos y envíos vacíos

Entrada

{"titulo": "Encuesta <rápida>", "campos": [
  {"nombre": "valoracion", "tipo": "numero", "obligatorio": true, "min": 1, "max": 5},
  {"nombre": "1dato", "tipo": "texto"},
  {"nombre": "color", "tipo": "color"},
  {"nombre": "medio", "tipo": "radio"},
  {"nombre": "valoracion", "tipo": "texto"},
  {"nombre": "aviso", "etiqueta": "Avisadme por \"correo\"", "tipo": "casilla"}
]}
---
valoracion=4&aviso=on
valoracion=cuatro&aviso=si&extra=1
aviso=

Salida por consola

Campo 2 (1dato): nombre no válido
Campo 3 (color): tipo desconocido «color»
Campo 4 (medio): le faltan las opciones
Campo 5 (valoracion): nombre repetido
<form id="encuesta-rapida" novalidate>
  <h2>Encuesta &lt;rápida&gt;</h2>
  <label for="valoracion">valoracion *</label>
  <input type="number" id="valoracion" name="valoracion" required min="1" max="5">
  <label><input type="checkbox" id="aviso" name="aviso"> Avisadme por &quot;correo&quot;</label>
  <button type="submit">Enviar</button>
</form>
Envío 1: correcto
  {"valoracion":4,"aviso":true}
Envío 2: 3 errores
  - valoracion: no es un número
  - aviso: valor no válido «si»
  - extra: campo desconocido
Envío 3: 1 error
  - valoracion: es obligatorio

Guía paso a paso

Intenta resolverlo por tu cuenta y abre un paso solo cuando te atasques: cada uno te acerca a la solución sin dártela entera.

1. Un switch por tipo

Recorre los campos y, según el tipo, añade las líneas de HTML a una lista. Una función atributo(nombre, valor) que devuelva "" si el valor es undefined evita llenar el código de if.

javascript
const atributo = (nombre, valor) => (valor === undefined ? "" : ` ${nombre}="${valor}"`);
html.push(`  <input type="number" id="${c.nombre}" name="${c.nombre}"${atributo("min", c.min)}>`);
2. Accesible desde el principio

Cada control con su label for (o dentro del label, en la casilla); los radios agrupados en un fieldset cuya legend es la pregunta. Así un lector de pantalla lee «Turno, mañana, botón de opción».

3. Leer el envío

new URLSearchParams(linea) decodifica el formato de los formularios (+ es un espacio, %C3%B3 es ó). get devuelve null si el campo no llegó: pasa con una casilla sin marcar.

4. Las mismas reglas

Valida con los mismos min, max y opciones que pusiste en el HTML: el navegador las comprueba antes de enviar, pero el servidor nunca debe fiarse.

Resuélvelo aquí

El editor trae el esqueleto del programa. Pulsa «Ejecutar» para comprobarlo con los ejemplos y con 2 casos ocultos que buscan los errores típicos.

🟨JavaScriptGenerar un formulario desde su descripción en JSON y validar los envíosMedio

Ejemplo

Entrada (lo que se escribe por teclado)
{
  "titulo": "Matrícula de FP",
  "campos": [
    {"nombre": "nombre", "etiqueta": "Nombre completo", "tipo": "texto", "obligatorio": true, "min": 3, "max": 60},
    {"nombre": "email", "etiqueta": "Correo", "tipo": "email", "obligatorio": true},
    {"nombre": "edad", "etiqueta": "Edad", "tipo": "numero", "min": 16, "max": 99},
    {"nombre": "ciclo", "etiqueta": "Ciclo", "tipo": "opcion", "opciones": ["DAM", "DAW", "ASIR"], "obligatorio": true},
    {"nombre": "turno", "etiqueta": "Turno", "tipo": "radio", "opciones": ["mañana", "tarde"]},
    {"nombre": "nacimiento", "etiqueta": "Fecha de nacimiento", "tipo": "fecha"},
    {"nombre": "comentarios", "etiqueta": "Comentarios", "tipo": "area", "max": 20},
    {"nombre": "condiciones", "etiqueta": "Acepto las condiciones", "tipo": "casilla", "obligatorio": true}
  ]
}
---
nombre=Ana+L%C3%B3pez&email=ana%40ies.es&edad=19&ciclo=DAM&turno=ma%C3%B1ana&nacimiento=2006-03-14&condiciones=on
nombre=Al&email=al%40ies&edad=120&ciclo=SMR&turno=noche&nacimiento=2006-02-30&comentarios=Quiero+hacer+la+FCT+en+Irlanda
Salida esperada
<form id="matricula-de-fp" novalidate>
  <h2>Matrícula de FP</h2>
  <label for="nombre">Nombre completo *</label>
  <input type="text" id="nombre" name="nombre" required minlength="3" maxlength="60">
  <label for="email">Correo *</label>
  <input type="email" id="email" name="email" required>
  <label for="edad">Edad</label>
  <input type="number" id="edad" name="edad" min="16" max="99">
  <label for="ciclo">Ciclo *</label>
  <select id="ciclo" name="ciclo" required>
    <option value="">Elige una opción</option>
    <option value="DAM">DAM</option>
    <option value="DAW">DAW</option>
    <option value="ASIR">ASIR</option>
  </select>
  <fieldset>
    <legend>Turno</legend>
    <label><input type="radio" name="turno" value="mañana"> mañana</label>
    <label><input type="radio" name="turno" value="tarde"> tarde</label>
  </fieldset>
  <label for="nacimiento">Fecha de nacimiento</label>
  <input type="date" id="nacimiento" name="nacimiento">
  <label for="comentarios">Comentarios</label>
  <textarea id="comentarios" name="comentarios" maxlength="20"></textarea>
  <label><input type="checkbox" id="condiciones" name="condiciones" required> Acepto las condiciones *</label>
  <button type="submit">Enviar</button>
</form>
Envío 1: correcto
  {"nombre":"Ana López","email":"ana@ies.es","edad":19,"ciclo":"DAM","turno":"mañana","nacimiento":"2006-03-14","comentarios":null,"condiciones":true}
Envío 2: 8 errores
  - nombre: mínimo 3 caracteres
  - email: no es un correo válido
  - edad: máximo 99
  - ciclo: «SMR» no es una opción válida
  - turno: «noche» no es una opción válida
  - nacimiento: no es una fecha válida
  - comentarios: máximo 20 caracteres
  - condiciones: es obligatorio
⏳
Test oculto #3
⏳
Test oculto #4
0/4 tests pasados · pulsa un test para ver su entrada y su salida esperada

Solución explicada

Ver la solución completa
javascript
1const lineas = require("fs").readFileSync(0, "utf8").split("\n");
2
3const TIPOS = ["texto", "email", "numero", "fecha", "area", "opcion", "radio", "casilla"];
4
5const escapar = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
6
7/** "Matrícula de FP" → "matricula-de-fp" */
8const slug = (s) => s.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
9
10/** Comprueba la descripción de cada campo y devuelve solo los válidos. */
11function revisarCampos(campos) {
12  const validos = [];
13  campos.forEach((c, i) => {
14    const quien = `Campo ${i + 1}${c && c.nombre ? ` (${c.nombre})` : ""}`;
15    if (!c || typeof c.nombre !== "string" || !/^[a-z][a-z0-9_]*$/i.test(c.nombre)) console.log(`${quien}: nombre no válido`);
16    else if (!TIPOS.includes(c.tipo)) console.log(`${quien}: tipo desconocido «${c.tipo}»`);
17    else if ((c.tipo === "opcion" || c.tipo === "radio") && (!Array.isArray(c.opciones) || c.opciones.length === 0)) console.log(`${quien}: le faltan las opciones`);
18    else if (validos.some((v) => v.nombre === c.nombre)) console.log(`${quien}: nombre repetido`);
19    else validos.push({ ...c, etiqueta: c.etiqueta ?? c.nombre });
20  });
21  return validos;
22}
23
24const atributo = (nombre, valor) => (valor === undefined ? "" : ` ${nombre}="${escapar(valor)}"`);
25
26function generar(titulo, campos) {
27  const html = [`<form id="${slug(titulo) || "formulario"}" novalidate>`, `  <h2>${escapar(titulo)}</h2>`];
28  for (const c of campos) {
29    const etiqueta = escapar(c.etiqueta) + (c.obligatorio ? " *" : "");
30    const comunes = `id="${c.nombre}" name="${c.nombre}"${c.obligatorio ? " required" : ""}`;
31    switch (c.tipo) {
32      case "texto":
33      case "email":
34        html.push(`  <label for="${c.nombre}">${etiqueta}</label>`);
35        html.push(`  <input type="${c.tipo === "texto" ? "text" : "email"}" ${comunes}${atributo("minlength", c.min)}${atributo("maxlength", c.max)}>`);
36        break;
37      case "numero":
38        html.push(`  <label for="${c.nombre}">${etiqueta}</label>`);
39        html.push(`  <input type="number" ${comunes}${atributo("min", c.min)}${atributo("max", c.max)}>`);
40        break;
41      case "fecha":
42        html.push(`  <label for="${c.nombre}">${etiqueta}</label>`);
43        html.push(`  <input type="date" ${comunes}>`);
44        break;
45      case "area":
46        html.push(`  <label for="${c.nombre}">${etiqueta}</label>`);
47        html.push(`  <textarea ${comunes}${atributo("minlength", c.min)}${atributo("maxlength", c.max)}></textarea>`);
48        break;
49      case "opcion":
50        html.push(`  <label for="${c.nombre}">${etiqueta}</label>`);
51        html.push(`  <select ${comunes}>`);
52        html.push(`    <option value="">Elige una opción</option>`);
53        for (const o of c.opciones) html.push(`    <option value="${escapar(o)}">${escapar(o)}</option>`);
54        html.push("  </select>");
55        break;
56      case "radio":
57        // un grupo de radios va en un fieldset: la leyenda es la pregunta y cada label, una respuesta
58        html.push("  <fieldset>");
59        html.push(`    <legend>${etiqueta}</legend>`);
60        for (const o of c.opciones) {
61          html.push(`    <label><input type="radio" name="${c.nombre}" value="${escapar(o)}"${c.obligatorio ? " required" : ""}> ${escapar(o)}</label>`);
62        }
63        html.push("  </fieldset>");
64        break;
65      case "casilla":
66        html.push(`  <label><input type="checkbox" ${comunes}> ${etiqueta}</label>`);
67        break;
68    }
69  }
70  html.push('  <button type="submit">Enviar</button>', "</form>");
71  return html;
72}
73
74function fechaValida(t) {
75  if (!/^\d{4}-\d{2}-\d{2}$/.test(t)) return false;
76  const [a, m, d] = t.split("-").map(Number);
77  const f = new Date(Date.UTC(a, m - 1, d));
78  return f.getUTCFullYear() === a && f.getUTCMonth() === m - 1 && f.getUTCDate() === d;
79}
80
81/** Valida un envío (application/x-www-form-urlencoded) y lo convierte en un objeto con tipos. */
82function validar(campos, cuerpo) {
83  const datos = new URLSearchParams(cuerpo);
84  const errores = [], valores = {};
85  for (const c of campos) {
86    const bruto = datos.get(c.nombre);
87    const t = bruto === null ? "" : bruto.trim();
88    if (t === "") {
89      if (c.obligatorio) errores.push(`${c.nombre}: es obligatorio`);
90      valores[c.nombre] = c.tipo === "casilla" ? false : null;
91      continue;
92    }
93    const largo = [...t].length;
94    switch (c.tipo) {
95      case "numero": {
96        if (!/^-?\d+(\.\d+)?$/.test(t)) {
97          errores.push(`${c.nombre}: no es un número`);
98          break;
99        }
100        const n = Number(t);
101        if (c.min !== undefined && n < c.min) errores.push(`${c.nombre}: mínimo ${c.min}`);
102        else if (c.max !== undefined && n > c.max) errores.push(`${c.nombre}: máximo ${c.max}`);
103        valores[c.nombre] = n;
104        break;
105      }
106      case "fecha":
107        if (!fechaValida(t)) errores.push(`${c.nombre}: no es una fecha válida`);
108        valores[c.nombre] = t;
109        break;
110      case "email":
111      case "texto":
112      case "area":
113        if (c.tipo === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t)) errores.push(`${c.nombre}: no es un correo válido`);
114        else if (c.min !== undefined && largo < c.min) errores.push(`${c.nombre}: mínimo ${c.min} caracteres`);
115        else if (c.max !== undefined && largo > c.max) errores.push(`${c.nombre}: máximo ${c.max} caracteres`);
116        valores[c.nombre] = t;
117        break;
118      case "opcion":
119      case "radio":
120        if (!c.opciones.includes(t)) errores.push(`${c.nombre}: «${t}» no es una opción válida`);
121        valores[c.nombre] = t;
122        break;
123      case "casilla":
124        if (t !== "on") errores.push(`${c.nombre}: valor no válido «${t}»`);
125        valores[c.nombre] = true;
126        break;
127    }
128  }
129  for (const clave of new Set(datos.keys())) {
130    if (!campos.some((c) => c.nombre === clave)) errores.push(`${clave}: campo desconocido`);
131  }
132  return { errores, valores };
133}
134
135function main() {
136  const corte = lineas.findIndex((l) => l.trim() === "---");
137  const fin = corte < 0 ? lineas.length : corte;
138  let esquema;
139  try {
140    esquema = JSON.parse(lineas.slice(0, fin).join("\n"));
141    if (!esquema || !Array.isArray(esquema.campos)) throw new Error();
142  } catch {
143    console.log("La descripción no es válida: hace falta un JSON con una lista «campos»");
144    return;
145  }
146  const campos = revisarCampos(esquema.campos);
147  for (const l of generar(esquema.titulo ?? "Formulario", campos)) console.log(l);
148  let n = 0;
149  for (const linea of lineas.slice(fin + 1)) {
150    if (!linea.trim()) continue;
151    n++;
152    const { errores, valores } = validar(campos, linea.trim());
153    if (errores.length === 0) {
154      console.log(`Envío ${n}: correcto`);
155      console.log("  " + JSON.stringify(valores));
156    } else {
157      console.log(`Envío ${n}: ${errores.length} ${errores.length === 1 ? "error" : "errores"}`);
158      for (const e of errores) console.log("  - " + e);
159    }
160  }
161}
162
163main();

Separar la descripción de la interfaz del código que la dibuja es lo que hacen FXML en JavaFX, los layouts XML de Android o los formularios de los CMS: el diseñador cambia el documento y la aplicación no se recompila.

La validación del navegador (required, min, maxlength) mejora la experiencia, pero cualquiera puede saltársela mandando la petición a mano. Por eso el mismo esquema valida otra vez lo que llega: una sola fuente de reglas para el cliente y el servidor.

Escapar los textos al generar el HTML evita que una etiqueta con < rompa la página o, peor, que un dato malicioso inyecte un <script> (XSS).

novalidate desactiva los avisos del navegador para que la página muestre los suyos, con el mismo estilo y los mensajes que quiera; las reglas siguen en el HTML para los lectores de pantalla.

Para ir más allá

  • Añade un tipo grupo con sus propios campos, que se genere como un fieldset anidado.
  • Genera también los mensajes de error junto a cada campo, enlazados con aria-describedby.
  • Lee la descripción en XML en lugar de JSON, como un layout de Android.

Dónde se explica