Vai al contenuto

L'API REST

In questa lezione impari a

  • progettare gli endpoint di un'API REST
  • restituire JSON con i codici di stato corretti
  • ricevere JSON in una richiesta POST e validarlo
  • provare un'API con Thunder Client e con Python
  • usare l'API da una pagina web con JavaScript

Le pagine della bacheca sono fatte per le persone. Ma se domani qualcuno volesse un'app per il telefono, un bot per Telegram o uno schermo nell'atrio della scuola che mostra gli avvisi? Non dovrebbero "leggere" l'HTML: serve un'API, cioè degli indirizzi che restituiscono solo dati, in formato JSON.

Il kit della lezione

Avviatelo e aprite /api/annunci nel browser: vedete i dati "nudi". Poi aprite la pagina Live dal menu, e in un'altra scheda pubblicate un annuncio: entro 5 secondi compare nella pagina Live, senza ricaricare.

Un'applicazione, due "porte"

                         β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ app.py ────────────────┐
  persona (browser) ───► β”‚ /, /annuncio/3, /nuovo   β†’ HTML (Jinja2)β”‚
                         β”‚                                        │──► bacheca.db
  programma ───────────► β”‚ /api/annunci, /api/...   β†’ JSON         β”‚
  (app, script, JS)      β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Stessi dati, stesso database, stesse funzioni (trova_annuncio, valida_annuncio): cambia solo il formato della risposta.

REST: risorse e metodi

In un'API REST gli indirizzi indicano cose (le risorse), e il metodo HTTP dice cosa farci:

Metodo Indirizzo Azione Risposta
GET /api/annunci elenco 200 + lista
GET /api/annunci?categoria=evento elenco filtrato 200 + lista
GET /api/annunci/3 un annuncio 200, oppure 404
POST /api/annunci crea 201 + l'annuncio creato, oppure 400
DELETE /api/annunci/3 elimina 200, oppure 404
PUT /api/annunci/3 modifica (missione 3)

Notate: non ci sono indirizzi come /api/elimina_annuncio?id=3. L'indirizzo Γ¨ sempre la risorsa; l'azione la dice il metodo.

Leggere: GET

@app.route("/api/annunci")
def api_elenco():
    categoria = request.args.get("categoria")
    if categoria:
        righe = leggi_tutti("SELECT * FROM annunci WHERE categoria = ? ORDER BY data DESC, id DESC", (categoria,))
    else:
        righe = leggi_tutti("SELECT * FROM annunci ORDER BY data DESC, id DESC")
    return jsonify([dict(r) for r in righe])

Due cose nuove:

  • jsonify(...) trasforma liste e dizionari Python in testo JSON e aggiunge l'intestazione Content-Type: application/json.
  • dict(r): le righe di sqlite3 non sono "JSON-abili", quindi le trasformiamo in dizionari veri. [dict(r) for r in righe] lo fa per tutte.
@app.route("/api/annunci/<int:id>")
def api_annuncio(id):
    a = trova_annuncio(id)
    if a is None:
        return jsonify({"errore": "annuncio non trovato"}), 404
    return jsonify(dict(a))

Anche l'errore Γ¨ JSON, con il codice di stato giusto. Un programma non legge le frasi: controlla il codice. Per un'API, i codici sono importantissimi.

Creare: POST con JSON

@app.route("/api/annunci", methods=["POST"])
def api_crea():
    dati = request.get_json(silent=True)          # il JSON del corpo, oppure None
    if dati is None:
        return jsonify({"errore": "serve un corpo JSON"}), 400

    titolo = str(dati.get("titolo", "")).strip()
    testo = str(dati.get("testo", "")).strip()
    categoria = dati.get("categoria", "avviso")

    errori = valida_annuncio(titolo, testo, categoria)     # la stessa del form!
    if errori:
        return jsonify({"errori": errori}), 400

    nuovo_id = esegui("INSERT INTO annunci (titolo, testo, categoria) VALUES (?, ?, ?)",
                      (titolo, testo, categoria))
    return jsonify(dict(trova_annuncio(nuovo_id))), 201
  • Nel form i dati arrivavano in request.form; in un'API arrivano come JSON e si leggono con request.get_json(). Con silent=True, se il corpo non Γ¨ JSON valido otteniamo None invece di un errore.
  • str(...): in JSON il client potrebbe mandare un numero o una lista al posto di una stringa. Convertendo, .strip() non fallisce.
  • La validazione Γ¨ una funzione sola, valida_annuncio, usata sia dal form /nuovo sia dall'API. Le regole sono scritte in un posto solo: se cambiano, cambiano ovunque. Guardate in app.py com'Γ¨ diventata la route /nuovo.
  • La risposta Γ¨ 201 Created con l'annuncio completo, compresi id e data decisi dal database.
Codice Significato Nella bacheca
200 OK lettura o eliminazione riuscita
201 Created annuncio creato
400 Bad Request JSON mancante o dati non validi
404 Not Found annuncio inesistente
405 Method Not Allowed metodo non previsto (es. PUT prima della missione 3)

Provare l'API con Thunder Client

Il browser sa fare solo GET. Per gli altri metodi: icona del fulmine di Thunder Client in VS Code β†’ New Request.

  1. GET http://127.0.0.1:5000/api/annunci β†’ Send. A destra: il codice (200), il tempo, il JSON.
  2. POST http://127.0.0.1:5000/api/annunci, scheda Body β†’ JSON:
{ "titolo": "Dal Thunder Client", "testo": "Prova dell'API", "categoria": "evento" }
  1. Rifate la POST con "titolo": "X": risposta 400 con l'elenco degli errori.
  2. DELETE http://127.0.0.1:5000/api/annunci/1 β†’ 200; ripetetela β†’ 404.

Un client in Python

Nel kit c'Γ¨ client.py: avviate la bacheca in un terminale e il client in un secondo terminale.

import requests

API = "http://127.0.0.1:5000/api/annunci"

r = requests.get(API, timeout=5)
for a in r.json():                                     # JSON β†’ lista di dizionari
    print(f"#{a['id']} [{a['categoria']}] {a['titolo']}")

r = requests.post(API, json={"titolo": "Dal client", "testo": "...", "categoria": "avviso"}, timeout=5)
print(r.status_code, r.json())                         # 201 e l'annuncio creato

json=... trasforma il dizionario in JSON e imposta da solo l'intestazione Content-Type.

Un client nel browser: fetch

La pagina Live (templates/live.html + static/js/live.js) non riceve gli annunci da Jinja2: Γ¨ vuota, e li chiede all'API con JavaScript.

async function aggiorna() {
  const risposta = await fetch("/api/annunci");   // richiesta GET all'API
  const annunci = await risposta.json();          // JSON β†’ array JavaScript
  // ... per ogni annuncio crea una scheda nella pagina
}

aggiorna();
setInterval(aggiorna, 5000);                      // ripeti ogni 5 secondi

È lo stesso schema della chat di quarta. Nel codice del kit, i testi si inseriscono con textContent e non con innerHTML: così un annuncio con dentro del codice HTML viene mostrato come testo e non eseguito. Ricordate l'escape automatico di Jinja2? In JavaScript tocca a noi.

Per far partire il JavaScript solo nella pagina Live, base.html ha un nuovo blocco vuoto, {% block script %}{% endblock %}, prima del piè di pagina: live.html lo riempie con il suo <script>.

Missioni

⭐ Missione 1 – Il collaudo

Con Thunder Client eseguite e annotate in una tabella (richiesta, codice, risposta): l'elenco, l'elenco filtrato per compito, un annuncio esistente, uno inesistente, una POST corretta, una POST senza testo, una POST con "categoria": "pippo", una DELETE. Poi eseguite client.py.

⭐⭐ Missione 2 – Solo gli ultimi

Fate in modo che /api/annunci?limite=5 restituisca solo i 5 annunci piΓΉ recenti. Se limite non Γ¨ un numero, rispondete 400.

Aiutino

request.args.get("limite", type=int) restituisce giΓ  un intero, oppure None se manca o non Γ¨ un numero. In SQL: ... LIMIT ?. Per distinguere "manca" da "sbagliato", controllate prima se "limite" in request.args.

⭐⭐⭐ Missione 3 – Modificare: PUT

Aggiungete PUT /api/annunci/<id>: riceve un JSON con titolo, testo e categoria, li valida con valida_annuncio, esegue l'UPDATE e restituisce l'annuncio aggiornato (200). Se l'annuncio non esiste: 404.

⭐⭐⭐ Missione 4 – Lo schermo dell'atrio

Create la pagina /schermo: sfondo scuro, scritte grandi, solo gli annunci di tipo evento, aggiornata ogni 10 secondi con fetch su /api/annunci?categoria=evento. Pensata per stare accesa su un televisore.

⭐⭐⭐ Missione 5 – Il bot

Scrivete bot.py: ogni 30 secondi controlla l'API e, se trova annunci nuovi rispetto al controllo precedente, stampa "πŸ“Œ Nuovo annuncio: …". Provatelo pubblicando dal sito mentre il bot gira.

Aiutino

Tenete un insieme (set) degli id giΓ  visti. A ogni giro: nuovi = [a for a in annunci if a["id"] not in visti]. Per aspettare: import time e time.sleep(30).