Generar un formulario desde su descripción en JSON y validar los envíos
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
- Revisa cada campo:
Campo N (nombre): nombre no válido(debe empezar por letra y seguir con letras, cifras o_; si no hay nombre, soloCampo N),tipo desconocido «t»,le faltan las opciones(paraopcionyradio) ynombre repetido. Los campos con error se ignoran; sinetiqueta, se usa el nombre. - Genera el formulario como en los ejemplos:
<form id="slug-del-titulo" novalidate>, el<h2>, por cada campo sulabel for(con*si es obligatorio) y su control (text,email,number,date,textarea,selectcon una primera opción vacíaElige una opción, unfieldsetconlegendy un radio por opción, o un checkbox dentro de sulabel),requireden los obligatorios,minlength/maxlengthen los textos ymin/maxen los números, y el botónEnviar. Los textos, escapados (&,<,>,"). - Valida cada envío campo a campo, en orden. Vacío (o solo espacios):
es obligatoriosi lo es; si no, valenull(ofalseen 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 admiteon(valor no válido «v»). Al final, los datos que no son de ningún campo:x: campo desconocido. - Cada envío escribe
Envío N: correctoy, en la línea siguiente, dos espacios y el objeto con los valores (números como número, casillas como booleano) en JSON; oEnvío N: E errores(1 error) y cada error como- campo: mensaje. - 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
| tipo | Control HTML | Atributos |
|---|---|---|
| texto | <input type="text"> | minlength, maxlength |
| <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+IrlandaSalida 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 obligatorioCampos 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 <rápida></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 "correo"</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 obligatorioGuí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.
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.
Ejemplo
{
"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
<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 obligatorioSolución explicada
Ver la solución completa
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
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
grupocon sus propios campos, que se genere como unfieldsetanidado. - 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.