Esercizio - Pagina AJAX per creare e cancellare prenotazioni (applicazione web)
Esercizio originale, nello stile della prova di programmazione (JavaScript che usa l'API REST). Teoria: AJAX e fetch - applicazioni Web 2.0 e MVC rivistoAJAX (Asynchronous JavaScript and XML, oggi quasi sempre con JSON) permette a JavaScript di chiedere dati al server in background e aggiornare solo una parte del DOM, senza ricaricare la pagina. L'oggetto storico è XMLHttpRequest (open, send, eventi onload e onerror); l'API moderna è fetch, che restituisce una Promise di Response (ok, status, json()). Una Promise rappresenta un risultato futuro (in attesa, mantenuta, rifiutata) e si concatena con then, catch e finally; async/await è la sintassi che la fa leggere come codice sequenziale, e dopo ogni await il resto della funzione riparte quando la Promise si risolve (il codice sincrono finisce prima, poi vengono le promise, poi i timer). Attenzione: fetch rifiuta la Promise solo per errori di rete; un 404 o un 500 sono risposte e si controllano con response.ok. Il client delle aule ha api.js (funzioni che chiamano l'API REST e lanciano ErroreApi se il codice non è 2xx) e pagina.js (legge il form, chiama api.js, aggiorna la tabella con textContent, un solo gestore di click per i bottoni di cancellazione). Il paradigma MVC viene rivisto: il server resta con model e controller REST ma restituisce solo dati, la view è nel browser (DOM costruito da JavaScript) e il controller della pagina sono i gestori di eventi; lo stato dell'interfaccia sta nel client e il server è senza stato. La same-origin policy (origine = schema, host, porta) impedisce a uno script di leggere risposte di altre origini; CORS permette al server di autorizzarle con Access-Control-Allow-Origin, e le richieste non semplici (JSON, DELETE) sono precedute da una richiesta OPTIONS di preflight.AJAX e fetch - applicazioni Web 2.0 e MVC rivisto →, JavaScript - linguaggio, DOM ed eventiJavaScript (standard ECMAScript, Ecma-262) è il linguaggio dei browser: tipizzato dinamicamente, con sette tipi primitivi (number, string, boolean, undefined, null, bigint, symbol) e gli oggetti (anche array e funzioni); typeof null vale "object" per un errore storico; == converte i tipi, === no (si usa sempre ===); valori falsy: false, 0, 0n, "", null, undefined, NaN. let e const hanno visibilità di blocco e una zona morta prima della dichiarazione, var visibilità di funzione e hoisting; const impedisce di riassegnare il nome, non di modificare l'oggetto. Funzioni: dichiarazione (hoisted), espressione, arrow function (this lessicale), parametri predefiniti, rest e spread, destructuring. Array: map, filter, reduce, find, some, every, sort (senza comparatore ordina come stringhe). Closure: una funzione ricorda le variabili dell'ambiente in cui è nata. Eventi: addEventListener; propagazione in tre fasi (cattura, target, bolla), stopPropagation, preventDefault, delegazione con event.target e closest. DOM: albero di nodi; querySelector, createElement, append, replaceChildren, textContent (sicuro) contro innerHTML (rischio di XSS), classList, dataset. Moduli ES (type="module", import e export) sono differiti e in modalità strict. Event loop: un solo thread, call stack, coda dei task (setTimeout, eventi) e coda dei microtask (promise, await) svuotata dopo ogni task: l'esecuzione asincrona (fetch, async e await, Promise.all) è alla base di AJAX e il codice lungo blocca la pagina.JavaScript - linguaggio, DOM ed eventi →, HTML5 - struttura del documento, elementi semantici e formUn documento HTML5 ha la forma doctype (), html con lang, head (meta charset UTF-8, viewport, title, link, script) e body. Il parser HTML5 non dà mai errore: corregge (aggiunge il tbody, chiude i p). Gli elementi semantici (header, nav, main, section, article, aside, footer, figure, time) descrivono il ruolo del contenuto e danno ai lettori di schermo i punti di riferimento; div e span sono neutri. Link: a href (assoluto, relativo, #frammento, mailto), rel e target; immagini con alt obbligatorio. Tabelle: table, caption, thead, tbody, th con scope, td, colspan e rowspan, solo per dati. Form: action, method (GET mette i campi nella query string, per letture idempotenti e ricercabili; POST li mette nel corpo, per modificare lo stato), enctype (application/x-www-form-urlencoded predefinito, multipart/form-data per i file); si invia solo ciò che ha name; label for collega etichetta e campo; tipi di input (text, email, number, date, time, radio, checkbox, hidden...); validazione nativa con required, minlength, maxlength, min, max, step, pattern e type, aggirabile e quindi da ripetere sul server; novalidate la spegne. Accessibilità: lang, alt, label, gerarchia dei titoli, navigazione da tastiera, ARIA solo quando l'HTML nativo non basta.HTML5 - struttura del documento, elementi semantici e form →. L'API è quella di Servizi REST in Java - progettazione delle risorse e implementazione con servletUn servizio REST espone risorse, non pagine: il server risponde con dati (JSON) e codici di stato, e la presentazione passa al client. Per progettare l'API si individuano i sostantivi del dominio (aula, prenotazione), si dà a collezioni ed elementi un URI (/api/aule, /api/aule/3, /api/prenotazioni, /api/prenotazioni/7: nomi al plurale, mai verbi), si usano i metodi HTTP con la semantica di RFC 9110 (GET sicuro e idempotente, POST crea e non è idempotente, PUT e DELETE idempotenti) e si filtrano le collezioni con la query (?aula=1&giorno=2026-10-19). I codici fanno parte del contratto: 200 lettura, 201 creazione con Location, 204 cancellazione, 400 richiesta malformata, 404 risorsa inesistente, 405 metodo non consentito con Allow, 409 conflitto (orario già occupato), 415 tipo di contenuto, 422 richiesta ben formata ma non elaborabile (aula inesistente), 500 guasto. Il JSON usa i nomi delle colonne (aula_id, ora_inizio, ora_fine) e gli errori hanno un corpo {"errore": "..."}. In Java una servlet mappata su /api/prenotazioni/* fa il dispatch sul path: getPathInfo() nullo o "/" indica la collezione, "/12" l'elemento; doGet, doPost e doDelete implementano i metodi, Gson converte oggetti e JSON, i metodi non previsti rispondono 405 con l'header Allow. Il servizio è senza stato: niente sessione, ogni richiesta porta tutto ciò che serve.Servizi REST in Java - progettazione delle risorse e implementazione con servlet →. Il codice è stato provato con Node.js: api.js e giorno.js contro un server simulato che rispetta il contratto dell'API (stessi metodi, URI, codici), pagina.js con un DOM minimo simulato; i risultati riportati sono le uscite reali di quelle prove. Non è stato aperto in un browser.
Testo
L'API restituisce JSON con i nomi delle colonne (id, aula_id, giorno, ora_inizio, ora_fine, studente; per le aule id, nome, posti). Si scrive la pagina prenota.html:
- un form con aula (menu a tendina riempito dall'API), giorno, ora d'inizio, ora di fine e studente, e un pulsante "Prenota";
- sotto, una tabella delle prenotazioni dell'aula e del giorno scelti, aggiornata al cambio di aula o giorno;
- la prenotazione si crea con
POST /api/prenotazioni, la tabella si aggiorna senza ricaricare la pagina; se l'orario è occupato (409) si mostra "Orario già occupato."; - ogni riga ha un pulsante "Cancella" (
DELETE /api/prenotazioni/{id}); - il pulsante "Prenota" è disabilitato mentre la richiesta è in corso;
- una funzione
prenotazioniDelGiorno(giorno)che restituisce per ogni aula le prenotazioni di quel giorno, facendo le richieste per le aule in parallelo.
Domande: (a) separare il codice in api.js e pagina.js e spiegare perché; (b) scrivere la traccia delle richieste HTTP e delle modifiche alla pagina per la sequenza "apro la pagina, prenoto A1 dalle 11:00 alle 12:30 come Neri, riprovo 10:00-12:00, cancello la prima, cancello di nuovo la stessa"; (c) che cosa succede con un fetch verso un server spento, e con un 404? (d) perché si usa textContent?
Soluzione
(a) Due file con due compiti
| File | Ruolo | Conosce |
|---|---|---|
api.js |
accesso ai dati remoti (il "model" del client) | URL e formato dell'API, fetch, ErroreApi; non la pagina |
pagina.js |
lettura del form e aggiornamento del DOM (view e controller della pagina) | gli id degli elementi e api.js; non gli URL |
Se l'API cambia (un URL, un campo) si modifica un file; se cambia la grafica, l'altro. api.js si prova da solo con un fetch simulato (è stato fatto), senza browser.
// api.js - il "model" del client: parla con l'API REST, non conosce la pagina HTML.
const BASE = "/api"; // l'API sta sotto /api, alla radice del sito
export class ErroreApi extends Error {
constructor(stato, messaggio) {
super(messaggio);
this.stato = stato; // codice di stato HTTP della risposta
}
}
async function richiesta(metodo, percorso, corpo) {
const opzioni = { method: metodo, headers: { Accept: "application/json" } };
if (corpo !== undefined) {
opzioni.headers["Content-Type"] = "application/json";
opzioni.body = JSON.stringify(corpo);
}
const risposta = await fetch(`${BASE}${percorso}`, opzioni); // rifiuta solo se la rete fallisce
if (risposta.status === 204) {
return null; // successo senza corpo (DELETE)
}
const dati = await risposta.json().catch(() => null); // il corpo potrebbe non essere JSON
if (!risposta.ok) { // 4xx e 5xx NON fanno rifiutare fetch: si controllano a mano
throw new ErroreApi(risposta.status, dati?.errore ?? risposta.statusText);
}
return dati;
}
export const elencaAule = () => richiesta("GET", "/aule");
export function elencaPrenotazioni(aulaId, giorno) {
const parametri = new URLSearchParams({ giorno });
if (aulaId) {
parametri.set("aula", aulaId);
}
return richiesta("GET", `/prenotazioni?${parametri}`);
}
export const creaPrenotazione = (prenotazione) => richiesta("POST", "/prenotazioni", prenotazione);
export const cancellaPrenotazione = (id) => richiesta("DELETE", `/prenotazioni/${id}`);La funzione richiesta è l'unico punto che chiama fetch: aggiunge Content-Type e corpo solo se c'è un corpo, restituisce null per 204, e per ogni risposta non ok lancia ErroreApi(stato, messaggio), con il messaggio preso da {"errore": ...}. Così il codice chiamante usa try/catch e non ripete if (!risposta.ok).
Pagina e script
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Prenota un'aula</title>
<style>
.ok { color: #166534; }
.errore { color: #b91c1c; }
td, th { padding: .25rem .75rem; text-align: left; }
</style>
</head>
<body>
<h1>Prenotazione aule</h1>
<form id="modulo">
<label>Aula <select id="aula_id"></select></label>
<label>Giorno <input type="date" id="giorno" required></label>
<label>Dalle <input type="time" id="ora_inizio" required></label>
<label>Alle <input type="time" id="ora_fine" required></label>
<label>Studente <input type="text" id="studente" maxlength="40" required></label>
<button>Prenota</button>
</form>
<p id="messaggio" role="status"></p>
<table>
<thead><tr><th>Dalle</th><th>Alle</th><th>Studente</th><th></th></tr></thead>
<tbody id="righe"></tbody>
</table>
<script type="module" src="js/pagina.js"></script>
</body>
</html>// pagina.js - la "view" e il "controller" del client: legge il form, chiama api.js, aggiorna la tabella.
import { ErroreApi, elencaAule, elencaPrenotazioni, creaPrenotazione, cancellaPrenotazione } from "./api.js";
const $ = (id) => document.getElementById(id);
function messaggio(testo, errore = false) {
const p = $("messaggio");
p.textContent = testo; // textContent: il testo non viene mai interpretato come HTML
p.className = errore ? "errore" : "ok";
}
function riga(prenotazione) {
const tr = document.createElement("tr");
for (const testo of [prenotazione.ora_inizio, prenotazione.ora_fine, prenotazione.studente]) {
const td = document.createElement("td");
td.textContent = testo;
tr.append(td);
}
const bottone = document.createElement("button");
bottone.textContent = "Cancella";
bottone.dataset.id = prenotazione.id;
const tdAzione = document.createElement("td");
tdAzione.append(bottone);
tr.append(tdAzione);
return tr;
}
async function aggiorna() {
try {
const lista = await elencaPrenotazioni($("aula_id").value, $("giorno").value);
$("righe").replaceChildren(...lista.map(riga));
} catch (e) {
messaggio(`Impossibile leggere le prenotazioni: ${e.message}`, true);
}
}
$("modulo").addEventListener("submit", async (evento) => {
evento.preventDefault(); // niente invio classico del form: la pagina non si ricarica
const prenotazione = {
aula_id: Number($("aula_id").value),
giorno: $("giorno").value,
ora_inizio: $("ora_inizio").value,
ora_fine: $("ora_fine").value,
studente: $("studente").value,
};
const bottone = evento.submitter; // il bottone "Prenota" che ha inviato il form
bottone.disabled = true; // un secondo clic durante la richiesta non invia una seconda POST
try {
await creaPrenotazione(prenotazione);
messaggio("Prenotazione registrata.");
await aggiorna();
} catch (e) {
const testo = e instanceof ErroreApi && e.stato === 409 ? "Orario già occupato." : e.message;
messaggio(testo, true);
} finally {
bottone.disabled = false; // in ogni caso, riuscita o no, il bottone torna attivo
}
});
$("righe").addEventListener("click", async (evento) => { // un solo ascoltatore per tutti i bottoni
const id = evento.target.dataset.id;
if (!id) {
return;
}
try {
await cancellaPrenotazione(id);
messaggio("Prenotazione cancellata.");
await aggiorna();
} catch (e) {
messaggio(e.message, true);
}
});
$("aula_id").addEventListener("change", aggiorna);
$("giorno").addEventListener("change", aggiorna);
const aule = await elencaAule(); // await al livello superiore: consentito nei moduli
$("aula_id").replaceChildren(...aule.map((a) => new Option(`${a.nome} (${a.posti} posti)`, a.id)));
$("giorno").value = new Date().toISOString().slice(0, 10);
await aggiorna();- L'ultimo blocco del file (avvio) usa
awaital livello superiore, permesso nei moduli (type="module"). Riempie il menu connew Option(testo, valore), imposta il giorno di oggi e chiamaaggiorna(). - Il form ha
preventDefault()perché l'invio classico ricaricherebbe la pagina: tutto passa dall'API. - Il pulsante "Prenota" si disabilita (
evento.submitter) prima della richiesta e si riabilita nelfinally, sia che la richiesta riesca, sia che fallisca: provato, la cronologia didisabledètruefalseanche nel caso del409. Senzafinallyun errore lascerebbe il pulsante bloccato. - Le righe e i pulsanti "Cancella" sono creati con
createElementetextContent; un soloclicksultbody(delegazione) gestisce tutti i pulsanti, leggendodata-id. - Dopo ogni modifica si richiama
aggiorna(), che richiede l'elenco al server: la tabella mostra sempre lo stato reale, anche se nel frattempo un'altra persona ha prenotato.
Il punto 6: richieste in parallelo
// giorno.js - la vista "tutte le aule di un giorno": due API, richieste in parallelo con Promise.all.
import { elencaAule, elencaPrenotazioni } from "./api.js";
export async function prenotazioniDelGiorno(giorno) {
const aule = await elencaAule(); // 1 richiesta
const liste = await Promise.all(aule.map((a) => elencaPrenotazioni(a.id, giorno))); // N richieste insieme
return aule.map((aula, i) => ({ aula, prenotazioni: liste[i] }));
}
// una riga di testo per aula: "Aula A1 (80 posti): 09:00-11:00 Rossi, 14:00-16:00 Bianchi"
export function riepilogo(gruppi) {
return gruppi.map(({ aula, prenotazioni }) => {
const elenco = prenotazioni.length === 0
? "libera"
: prenotazioni.map((p) => `${p.ora_inizio}-${p.ora_fine} ${p.studente}`).join(", ");
return `${aula.nome} (${aula.posti} posti): ${elenco}`;
});
}elencaAule()serve prima, per sapere quali aule esistono; poi per ogni aula parte una richiesta, tutte insieme:aule.map(...)costruisce un array di Promise (le richieste sono già partite),await Promise.all(...)aspetta che siano tutte finite e restituisce i risultati nello stesso ordine delle aule. Con tre aule, il tempo totale è quello della più lenta e non la somma; unawaitdentro un cicloforle farebbe invece una dopo l'altra.- Se una richiesta fallisce,
Promise.allrifiuta subito eprenotazioniDelGiornolancia l'errore: chi la chiama gestisce contry/catch. ConPromise.allSettledsi avrebbero tutti gli esiti, riusciti o no. - Risultato per il 19 ottobre (uscita della prova):
Aula A1 (80 posti): 09:00-11:00 Rossi, 14:00-16:00 Bianchi
Aula B2 (45 posti): libera
Lab Info (30 posti): liberae le richieste partite (GET unico per le aule, poi tre GET filtrati): GET /api/aule, GET /api/prenotazioni?giorno=2026-10-19&aula=1, ...&aula=2, ...&aula=3.
(b) Traccia della sequenza
Aula A1 e giorno 19 ottobre già scelti (dati dell'esempio). Le richieste sono quelle emesse dal codice nella prova con server e DOM simulati (per l'apertura, quelle del codice di avvio, con il primo elemento del menu già selezionato):
| Azione dell'utente | Richieste HTTP | Effetto sulla pagina |
|---|---|---|
| apre la pagina | GET /api/aule, poi GET /api/prenotazioni?giorno=2026-10-19&aula=1 |
il menu ha 3 aule; la tabella ha 2 righe (Rossi, Bianchi) |
| prenota 11:00-12:30 come Neri | POST /api/prenotazioni 201; poi GET /api/prenotazioni?giorno=2026-10-19&aula=1 |
messaggio "Prenotazione registrata."; tabella a 3 righe |
| riprova 10:00-12:00 | POST /api/prenotazioni 409 |
messaggio "Orario già occupato." (classe errore); nessun GET; tabella ancora a 3 righe |
| cancella la prima (id 5) | DELETE /api/prenotazioni/5 204; poi GET dell'elenco |
messaggio "Prenotazione cancellata."; 2 righe |
| cancella di nuovo la stessa | DELETE /api/prenotazioni/5 404 |
messaggio "prenotazione inesistente" (dal corpo {"errore": ...}); tabella invariata |
Nota: nel secondo tentativo (10:00-12:00) l'intervallo si sovrappone a Rossi (09:00-11:00) e a Neri (11:00-12:30). Nella tabella le righe le ordina il server, la pagina non riordina.
(c) Errori di rete e 404
- Server spento o rete assente:
fetchstesso rifiuta la Promise (TypeError: Failed to fetch). Dentrorichiestal'eccezione risale senza modifiche fino alcatchdella pagina, che mostrae.message. NessunErroreApi, perché non c'è un codice di stato. 404(o500):fetchnon rifiuta: la Promise è mantenuta erisposta.okèfalse. Serichiestanon controllasseok, il404verrebbe trattato come un successo e si proverebbe a mostrare un oggetto di errore come se fosse una prenotazione. Il controllo dioke ilthrow new ErroreApi(...)rendono i due casi uguali per il chiamante: entrambi finiscono in uncatch.
(d) textContent e non innerHTML
Il nome dello studente viene scritto da un utente e arriva alla pagina attraverso il server: assegnarlo a innerHTML vorrebbe dire interpretare come HTML anche <img src=x onerror="...">, cioè eseguire codice di un altro utente nella pagina di chi guarda la tabella (XSS). textContent imposta solo testo: i caratteri < e > restano caratteri. Allo stesso modo il messaggio d'errore del server si mostra con textContent. Il controllo lato server non basta a prevenire questo problema: la protezione va messa dove i dati entrano nel DOM.
Versione ripasso
Richiesta. prenota.html: form (aula da /api/aule, giorno, ora d'inizio, ora di fine, studente, "Prenota"), tabella delle prenotazioni di aula e giorno, POST /api/prenotazioni senza ricaricare la pagina (409 "Orario già occupato."), pulsante "Cancella" per riga (DELETE /api/prenotazioni/{id}), "Prenota" disabilitato durante la richiesta, e prenotazioniDelGiorno(giorno) con le richieste delle aule in parallelo.
Due file. api.js = accesso ai dati remoti (URL, formato, fetch, ErroreApi), non conosce la pagina; pagina.js = form e DOM, non conosce gli URL. Se cambia l'API si tocca un file, se cambia la grafica l'altro; api.js si prova da solo con un fetch simulato.
async function richiesta(metodo, percorso, corpo) {
const opzioni = { method: metodo, headers: { Accept: "application/json" } };
if (corpo !== undefined) {
opzioni.headers["Content-Type"] = "application/json";
opzioni.body = JSON.stringify(corpo);
}
const risposta = await fetch(`${BASE}${percorso}`, opzioni);
if (risposta.status === 204) return null;
const dati = await risposta.json().catch(() => null);
if (!risposta.ok) throw new ErroreApi(risposta.status, dati?.errore ?? risposta.statusText);
return dati;
}Pagina. Script type="module" (await al livello superiore): avvio = await elencaAule() riempie il menu con new Option(testo, valore), giorno di oggi, aggiorna(). aggiorna() chiede l'elenco e sostituisce le righe con replaceChildren.
$("modulo").addEventListener("submit", async (evento) => {
evento.preventDefault(); // niente invio classico: la pagina non si ricarica
const prenotazione = { aula_id: Number($("aula_id").value), giorno: $("giorno").value,
ora_inizio: $("ora_inizio").value, ora_fine: $("ora_fine").value, studente: $("studente").value };
const bottone = evento.submitter;
bottone.disabled = true; // niente doppia POST
try {
await creaPrenotazione(prenotazione);
messaggio("Prenotazione registrata.");
await aggiorna();
} catch (e) {
messaggio(e instanceof ErroreApi && e.stato === 409 ? "Orario già occupato." : e.message, true);
} finally {
bottone.disabled = false; // anche dopo un errore
}
});- Un solo
clicksultbody(delegazione degli eventi);evento.target.dataset.idleggedata-id. Dopo ogni modificaaggiorna(): la tabella mostra lo stato reale del server. Provato: cronologia didisabled=truefalseanche con il409.
Parallelo.
export async function prenotazioniDelGiorno(giorno) {
const aule = await elencaAule();
const liste = await Promise.all(aule.map((a) => elencaPrenotazioni(a.id, giorno))); // richieste insieme
return aule.map((aula, i) => ({ aula, prenotazioni: liste[i] }));
}Promise.allaspetta tutte e restituisce i risultati nell'ordine delle aule; il tempo è quello della richiesta più lenta (conawaitin unforsarebbe la somma). Se una fallisce, rifiuta subito (Promise.allSettleddà tutti gli esiti).- Il 19 ottobre:
Aula A1 (80 posti): 09:00-11:00 Rossi, 14:00-16:00 Bianchi;Aula B2 (45 posti): libera;Lab Info (30 posti): libera.
Traccia (A1, 19 ottobre).
| Azione | Richieste | Pagina |
|---|---|---|
| apre | GET /api/aule, GET /api/prenotazioni?giorno=2026-10-19&aula=1 |
3 aule; 2 righe |
| prenota 11:00-12:30 (Neri) | POST 201, poi GET dell'elenco |
"Prenotazione registrata."; 3 righe |
| riprova 10:00-12:00 | POST 409 |
"Orario già occupato."; nessun GET |
| cancella la 5 | DELETE /api/prenotazioni/5 204, poi GET |
"Prenotazione cancellata."; 2 righe |
| cancella di nuovo la 5 | DELETE 404 |
"prenotazione inesistente"; tabella invariata |
Errori. Server spento: fetch rifiuta (TypeError: Failed to fetch), non c'è un codice di stato. 404/500: la Promise è mantenuta, ok falso; senza il controllo di ok il 404 passerebbe per un successo. Con ErroreApi i due casi finiscono entrambi in un catch.
textContent e non innerHTML: il nome dello studente viene da un altro utente; innerHTML interpreterebbe <img src=x onerror="..."> ed eseguirebbe codice (XSS), textContent assegna solo testo. Vale anche per i messaggi d'errore del server.
Errori tipici
- Niente
finally: il pulsante resta disabilitato dopo un errore. awaitdentro unforper richieste indipendenti (sequenziali, lente).fetchsenza controllo diok;preventDefault()dimenticato; un gestore per ogni pulsante dinamico.- Costruire righe con
innerHTMLe dati del server.