Esercizio - Progettazione dell'API REST delle prenotazioni (applicazione web)
Esercizio originale, nello stile di una domanda di progettazione seguita da una parte di codice. Teoria: 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 →; vincoli REST: Web service, XML, JSON, SOAP e RESTUn web service e' un servizio software accessibile via rete da altri programmi, con messaggi in formato standard su HTTP; i formati di dati sono XML (marcatori annidati, attributi, namespace, validazione con XSD) e JSON (RFC 8259: oggetti, array, stringhe, numeri, true, false, null; piu' compatto e diretto per i linguaggi di programmazione); SOAP e' un protocollo di messaggi XML (Envelope con Header facoltativo e Body, Fault per gli errori) inviati di solito con una POST HTTP e descritti da un file WSDL; REST e' uno stile architetturale (Fielding) in cui il servizio espone risorse identificate da URI e le manipola con i metodi HTTP: GET legge, POST crea, PUT sostituisce, PATCH modifica, DELETE elimina, con codici di stato significativi (200, 201 con Location, 204, 400, 404, 409), senza stato sul server, con cache e interfaccia uniforme.Web service, XML, JSON, SOAP e REST →. Il codice Java è stato compilato contro una copia delle sole firme dell'API Jakarta Servlet e provato con richieste simulate (servlet con DAO in memoria sugli stessi dati e un piccolo Gson per la prova); la condizione SQL della modifica è stata provata con SQLite.
Testo
Sull'applicazione delle aule (aule Aula A1 80 posti, Aula B2 45, Lab Info 30; quattro prenotazioni di esempio, tra cui Rossi in A1 il 19 ottobre dalle 09:00 alle 11:00 e Bianchi in A1 dalle 14:00 alle 16:00) un client deve poter:
- leggere l'elenco delle aule e una singola aula;
- leggere le prenotazioni, filtrate per aula e/o per giorno, e una singola prenotazione;
- creare una prenotazione;
- cancellarla;
- spostarne l'orario (stessa aula e stesso giorno);
- conoscere le fasce libere di un'aula in un giorno, dentro l'orario di apertura 08:00-20:00.
Parte A. Progettare l'API: risorse, URI, metodi, codici di stato in caso di successo e di errore, con le scelte motivate (annidare o filtrare, che metodo per lo spostamento, come esporre le fasce libere). Mostrare il JSON di una risposta per il punto 6.
Parte B. Implementare con una servlet il punto 6 (GET), separando il calcolo dall'accesso al database.
Parte C. Per il punto 5, dire come si rende corretto il controllo delle sovrapposizioni: una prenotazione spostata non deve "scontrarsi con se stessa".
Soluzione
Parte A: tabella dell'API
| # | Metodo | URI | Successo | Errori |
|---|---|---|---|---|
| 1 | GET |
/api/aule |
200 array |
500 |
| 1 | GET |
/api/aule/{id} |
200 |
404 |
| 2 | GET |
/api/prenotazioni?aula=&giorno= |
200 array (anche vuoto) |
400 filtro malformato |
| 2 | GET |
/api/prenotazioni/{id} |
200 |
404 |
| 3 | POST |
/api/prenotazioni |
201 + Location + corpo |
400 dati o JSON malformati, 415 non JSON, 422 aula inesistente, 409 orario occupato |
| 4 | DELETE |
/api/prenotazioni/{id} |
204 |
404 |
| 5 | PUT |
/api/prenotazioni/{id} |
200 + corpo aggiornato |
400, 404, 415, 422, 409 |
| 6 | GET |
/api/disponibilita?aula=&giorno= |
200 array di fasce |
400 parametri mancanti o malformati, 404 aula inesistente |
| tutti | metodo non previsto | 405 con Allow |
Le scelte:
- Filtrare, non annidare. Le prenotazioni hanno un URI proprio (
/api/prenotazioni/7) e i filtriaulaegiornosi combinano; un URI annidato (/api/aule/1/prenotazioni) obbligherebbe a passare sempre dall'aula, e non permette di chiedere "tutte le prenotazioni del giorno". - Spostamento con
PUT. Cambia lo stato di una risorsa già identificata, quindi non è unPOST(che crea).PUTsostituisce l'intera rappresentazione ed è idempotente: ripetere la stessa richiesta lascia la prenotazione nello stesso stato (e, con il controllo giusto della parte C, risponde ancora200).PATCHcon i soli campiora_inizioeora_finesarebbe ammissibile ma non garantisce l'idempotenza; con cancella + ricrea si perderebbe l'identificatore. Non si usa un URI con verbo (/api/prenotazioni/7/sposta). - Le fasce libere sono una risorsa derivata, di sola lettura. Non esistono come righe di tabella, ma sono qualcosa che si può chiedere: un nome (
disponibilita, la disponibilità) e unaGET. Si usa la query (?aula=1&giorno=2026-10-19) perché identifica una vista calcolata su due parametri; l'alternativa/api/aule/{id}/disponibilita?giorno=...è equivalente e legittima. MaiPOST: non modifica nulla e deve poter essere memorizzata e ritentata. - Errori e codici come nella nota:
400richiesta che non si capisce;422si capisce ma ha un significato impossibile;404risorsa inesistente;405metodo non previsto sull'URI, conAllow;409conflitto con lo stato;415formato del corpo. Un'aula senza prenotazioni nel giorno non è un errore:200con una fascia che copre tutto il giorno; un'aula che non esiste è404.
JSON della disponibilità. Aula A1 il 19 ottobre (prenotata dalle 09:00 alle 11:00 e dalle 14:00 alle 16:00):
GET /api/disponibilita?aula=1&giorno=2026-10-19 HTTP/1.1
Host: localhost:8080
Accept: application/jsonHTTP/1.1 200
Content-Type: application/json;charset=UTF-8
[{"ora_inizio":"08:00","ora_fine":"09:00"},{"ora_inizio":"11:00","ora_fine":"14:00"},{"ora_inizio":"16:00","ora_fine":"20:00"}]Parte B: il calcolo e la servlet
Il calcolo delle fasce non dipende da HTTP né dal database: si scrive in una classe a parte, che prende la lista delle prenotazioni del giorno e restituisce le fasce. Si può provare senza server e il DAO resta quello già esistente (lista(aula, giorno)).
L'algoritmo: si ordinano le prenotazioni per ora d'inizio e si tiene un cursore (l'istante fino a cui tutto è stato esaminato), inizialmente l'apertura. Per ogni prenotazione: se comincia dopo il cursore, tra cursore e inizio c'è una fascia libera; il cursore avanza alla fine della prenotazione, ma solo se questa è più avanti del cursore (le prenotazioni di una stessa aula e giorno non si sovrappongono, ma il controllo rende il codice robusto anche con dati incoerenti). Alla fine, se il cursore è prima della chiusura, c'è l'ultima fascia. Gli estremi coincidenti non producono fasce vuote (isAfter è stretto): 11:00-13:00 subito dopo 09:00-11:00 non lascia buchi.
package it.unipd.aule.rest;
import it.unipd.aule.model.Prenotazione;
import java.time.LocalTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
/** Calcolo delle fasce libere di un'aula in un giorno, dentro l'orario di apertura. Nessun accesso al database. */
final class FasceLibere {
/** Un intervallo libero, serializzato in JSON come {"ora_inizio": "...", "ora_fine": "..."}. */
static final class Fascia {
final String oraInizio;
final String oraFine;
Fascia(LocalTime inizio, LocalTime fine) {
this.oraInizio = inizio.toString();
this.oraFine = fine.toString();
}
}
private FasceLibere() { }
/** prenotazioni = quelle dell'aula nel giorno (in qualunque ordine); apertura e chiusura delimitano il giorno. */
static List<Fascia> calcola(List<Prenotazione> prenotazioni, LocalTime apertura, LocalTime chiusura) {
List<Prenotazione> ordinate = new ArrayList<>(prenotazioni);
ordinate.sort(Comparator.comparing(p -> LocalTime.parse(p.getOraInizio())));
List<Fascia> libere = new ArrayList<>();
LocalTime cursore = apertura; // fino a qui è tutto già considerato
for (Prenotazione p : ordinate) {
LocalTime inizio = LocalTime.parse(p.getOraInizio());
LocalTime fine = LocalTime.parse(p.getOraFine());
if (inizio.isAfter(cursore)) { // c'è un buco prima di questa prenotazione
libere.add(new Fascia(cursore, inizio.isAfter(chiusura) ? chiusura : inizio));
}
if (fine.isAfter(cursore)) {
cursore = fine;
}
}
if (cursore.isBefore(chiusura)) { // buco finale
libere.add(new Fascia(cursore, chiusura));
}
return libere;
}
}package it.unipd.aule.rest;
import it.unipd.aule.dao.AulaDAO;
import it.unipd.aule.dao.PrenotazioneDAO;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.sql.SQLException;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.format.DateTimeParseException;
/** Risorsa derivata, di sola lettura: GET /api/disponibilita?aula=1&giorno=2026-10-19 elenca le fasce libere (08:00-20:00). */
@WebServlet("/api/disponibilita")
public class DisponibilitaServlet extends HttpServlet {
private static final LocalTime APERTURA = LocalTime.of(8, 0);
private static final LocalTime CHIUSURA = LocalTime.of(20, 0);
private AulaDAO aulaDAO;
private PrenotazioneDAO prenotazioneDAO;
@Override
public void init() {
aulaDAO = (AulaDAO) getServletContext().getAttribute("aulaDAO");
prenotazioneDAO = (PrenotazioneDAO) getServletContext().getAttribute("prenotazioneDAO");
}
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
try {
String aula = req.getParameter("aula");
String giorno = req.getParameter("giorno");
if (aula == null || giorno == null) {
Json.errore(resp, HttpServletResponse.SC_BAD_REQUEST, "servono i parametri aula e giorno");
return;
}
int aulaId = Integer.parseInt(aula);
LocalDate.parse(giorno);
if (aulaDAO.perId(aulaId) == null) {
Json.errore(resp, HttpServletResponse.SC_NOT_FOUND, "aula inesistente");
return;
}
Json.invia(resp, HttpServletResponse.SC_OK,
FasceLibere.calcola(prenotazioneDAO.lista(aulaId, giorno), APERTURA, CHIUSURA));
} catch (NumberFormatException | DateTimeParseException e) {
Json.errore(resp, HttpServletResponse.SC_BAD_REQUEST, "aula intero, giorno aaaa-mm-gg");
} catch (SQLException e) {
log("errore SQL", e);
Json.errore(resp, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "errore interno");
}
}
}- Il campo
Fascia.oraIniziodiventaora_inizionel JSON per la stessa politica di denominazione delle altre servlet (Json.GSON). - La servlet fa soltanto: leggere e validare i parametri, controllare che l'aula esista (
404), chiamare il DAO, chiamare il calcolo, rispondere. I parametri non numerici o in un formato sbagliato danno400(un unicocatch). doGetè il solo metodo ridefinito: gli altri metodi ricevono dal container405.
Esiti provati con richieste simulate:
| Richiesta | Risposta |
|---|---|
aula=1&giorno=2026-10-19 |
200, fasce 08:00-09:00, 11:00-14:00, 16:00-20:00 |
aula=2&giorno=2026-10-19 (nessuna prenotazione) |
200, 08:00-20:00 |
aula=9&giorno=2026-10-19 |
404 {"errore":"aula inesistente"} |
aula=1 (manca giorno) |
400 {"errore":"servono i parametri aula e giorno"} |
aula=x&giorno=2026-10-19 |
400 |
Parte C: spostare senza scontrarsi con se stessa
Il PUT sulla prenotazione 1, da 09:00-11:00 a 10:00-12:00, ha il corpo
{"aula_id":1,"giorno":"2026-10-19","ora_inizio":"10:00","ora_fine":"12:00","studente":"Rossi"}. Se si riusasse tale e quale la query della creazione, la prenotazione 1 stessa (09:00-11:00) risulterebbe in conflitto con il nuovo intervallo (10:00-12:00), e lo spostamento verrebbe respinto con 409 anche se nessun altro occupa l'aula. Il rimedio è escludere la riga che si sta modificando:
SELECT COUNT(*)
FROM prenotazione
WHERE aula_id = ? AND giorno = ?
AND ora_inizio < ? AND ora_fine > ?
AND id <> ?; -- id della prenotazione che si spostaProva in SQLite sui dati dell'esempio (la riga 1 è Rossi 09:00-11:00, la 2 Bianchi 14:00-16:00):
| Spostamento della prenotazione 1 | Conflitti senza id <> ? |
Conflitti con id <> ? |
|---|---|---|
| 10:00-12:00 (libero, tranne se stessa) | 1 (se stessa) | 0 |
| 13:00-15:00 (tocca Bianchi 14:00-16:00) | 1 (Bianchi) | 1 |
Quindi il PUT da 10:00 a 12:00 risponde 200, mentre da 13:00 a 15:00 risponde 409. Il metodo del DAO è l'analogo di crea (transazione, blocco della riga dell'aula con FOR UPDATE, controllo con id <> ?, UPDATE ... WHERE id = ?), e la servlet gestisce /api/prenotazioni/{id} in doPut: 404 se l'id non esiste, 415/400/422 come per la creazione. Ripetere lo stesso PUT dà ancora 200 (la riga è esclusa dal controllo): è l'idempotenza.
Versione ripasso
Requisiti. Leggere aule (elenco e singola); leggere prenotazioni (filtro per aula e giorno, singola); creare; cancellare; spostare l'orario; conoscere le fasce libere di un'aula in un giorno (apertura 08:00-20:00).
| # | Metodo | URI | Successo | Errori |
|---|---|---|---|---|
| 1 | GET |
/api/aule, /api/aule/{id} |
200 |
404 |
| 2 | GET |
/api/prenotazioni?aula=&giorno=, /api/prenotazioni/{id} |
200 |
400, 404 |
| 3 | POST |
/api/prenotazioni |
201 + Location |
400, 415, 422, 409 |
| 4 | DELETE |
/api/prenotazioni/{id} |
204 |
404 |
| 5 | PUT |
/api/prenotazioni/{id} |
200 + corpo |
400, 404, 415, 422, 409 |
| 6 | GET |
/api/disponibilita?aula=&giorno= |
200 array di fasce |
400, 404 aula inesistente |
| tutti | altro metodo | 405 + Allow |
Scelte.
- Filtrare e non annidare: URI proprio per la prenotazione, filtri combinabili (anche "tutte le prenotazioni del giorno").
- Spostamento =
PUTsull'URI della prenotazione (risorsa già identificata, rappresentazione completa, idempotente);PATCHammissibile ma non idempotente garantito; mai/sposta. - Fasce libere = risorsa derivata, di sola lettura,
GETcon la query; maiPOST. Aula senza prenotazioni200con una fascia 08:00-20:00; aula inesistente404.
[{"ora_inizio":"08:00","ora_fine":"09:00"},{"ora_inizio":"11:00","ora_fine":"14:00"},{"ora_inizio":"16:00","ora_fine":"20:00"}](A1 il 19 ottobre, con Rossi 09:00-11:00 e Bianchi 14:00-16:00.)
Calcolo delle fasce in una classe separata (nessun HTTP né database): ordinare le prenotazioni per inizio, cursore = apertura; per ognuna, se l'inizio è dopo il cursore c'è una fascia libera fino all'inizio, poi il cursore passa alla fine; in fondo, se il cursore è prima della chiusura, ultima fascia. Gli estremi coincidenti non danno fasce vuote.
LocalTime cursore = apertura;
for (Prenotazione p : ordinate) {
LocalTime inizio = LocalTime.parse(p.getOraInizio());
LocalTime fine = LocalTime.parse(p.getOraFine());
if (inizio.isAfter(cursore)) libere.add(new Fascia(cursore, inizio.isAfter(chiusura) ? chiusura : inizio));
if (fine.isAfter(cursore)) cursore = fine;
}
if (cursore.isBefore(chiusura)) libere.add(new Fascia(cursore, chiusura));Servlet DisponibilitaServlet su /api/disponibilita: legge aula e giorno (mancanti 400), Integer.parseInt e LocalDate.parse (errore 400), aulaDAO.perId nullo 404, FasceLibere.calcola(prenotazioneDAO.lista(aulaId, giorno), APERTURA, CHIUSURA), risposta 200. Solo doGet; gli altri metodi 405. Il campo oraInizio diventa ora_inizio con la politica di nomi di Gson.
| Richiesta | Risposta |
|---|---|
aula=1&giorno=2026-10-19 |
200, 08:00-09:00, 11:00-14:00, 16:00-20:00 |
aula=2&giorno=2026-10-19 |
200, 08:00-20:00 |
aula=9&giorno=2026-10-19 |
404 |
aula=1 |
400 |
aula=x&giorno=2026-10-19 |
400 |
Spostare senza scontrarsi con se stessa. Riusare la query di creazione farebbe contare come conflitto la riga stessa (09:00-11:00 contro 10:00-12:00): 409 sbagliato. Si esclude la riga con id <> ?:
SELECT COUNT(*) FROM prenotazione
WHERE aula_id = ? AND giorno = ?
AND ora_inizio < ? AND ora_fine > ?
AND id <> ?;| Spostamento della prenotazione 1 | senza id <> ? |
con id <> ? |
|---|---|---|
| 10:00-12:00 | 1 (se stessa) | 0 |
| 13:00-15:00 (tocca Bianchi 14-16) | 1 | 1 |
Quindi PUT a 10:00-12:00 200, a 13:00-15:00 409. Il metodo del DAO è l'analogo di crea (transazione, FOR UPDATE sull'aula, controllo con id <> ?, UPDATE). Ripetere lo stesso PUT dà ancora 200: idempotenza.
Errori tipici
- Verbi nell'URI (
/sposta);POSTper una lettura calcolata. - Annidare tutto e non poter chiedere il giorno intero.
- Dimenticare di escludere la riga stessa nel controllo di una modifica.
404per "nessuna fascia occupata" (è200); fasce vuote tra prenotazioni consecutive.