Lezione 3 di 7 · 25 min di lettura

Strade e metodi

Un gestore che guarda la richiesta. Smistare per percorso con il pattern matching sulle liste, rispondere secondo il metodo, leggere la query, e provare il gestore con wisp/simulate, senza accendere nessun server.

Il percorso è una lista

Il gestore della lezione precedente rispondeva sempre la stessa cosa. Un sito vero risponde in modo diverso a /, a /books, a /books/42. Decidere quale codice deve rispondere a quale percorso si chiama routing (dall’inglese route, “strada”), e in Gleam non serve niente di speciale per farlo: basta un case.

wisp.path_segments(request) restituisce il percorso spezzato in una lista di stringhe, come request.path_segments della lezione 9.1. E sulle liste sai già fare pattern matching (lezione 3.2):

gleam
case wisp.path_segments(request) {
  [] -> home(request)
  ["books"] -> books(request)
  ["books", id] -> book(request, id)
  _ -> wisp.not_found()
}
  • [] è il percorso vuoto, cioè /, la pagina principale.
  • ["books"] è esattamente /books.
  • ["books", id] è /books/ seguito da un pezzo qualsiasi, che finisce nella variabile id: /books/42 dà id = "42".
  • _ è tutto il resto, a cui rispondiamo 404. wisp.not_found() è una risposta già pronta, con il codice 404 e il corpo Not found.

Il case controlla i rami in ordine, e il compilatore ti obbliga a gestire tutti i casi: un percorso che non corrisponde a nessuna strada non può restare senza risposta.

Il metodo

Allo stesso percorso possono arrivare metodi diversi. /books con GET vuol dire “dammi i libri”, con POST “aggiungi un libro”. Il metodo è il campo request.method, di tipo http.Method, e anche lui si guarda con un case:

gleam
case request.method {
  Get -> list_books()
  Post -> create_book(request)
  _ -> wisp.method_not_allowed([Get, Post])
}

wisp.method_not_allowed risponde 405, e aggiunge l’intestazione allow con l’elenco dei metodi permessi, come vuole il protocollo. Quando una strada accetta un solo metodo, c’è una scorciatoia, wisp.require_method, da usare con use (lezione 5.3):

gleam
fn home(request: Request) -> Response {
  use <- wisp.require_method(request, Get)
  wisp.html_response("<h1>The library</h1>", 200)
}

Se il metodo è GET, require_method chiama il resto della funzione; altrimenti risponde 405 da solo, e il resto non viene mai eseguito. È lo stesso meccanismo di bool.guard: una funzione che riceve “il resto del codice” come ultimo argomento, e decide se chiamarlo.

Provare senza server

Per provare un gestore potresti accendere il server e fare un curl per ogni strada. Funziona, ma è lento: ogni modifica vuol dire spegnere, riaccendere, riscrivere le richieste. C’è un modo migliore, che viene dal fatto che il gestore è solo una funzione: basta chiamarla, con una richiesta finta.

Le richieste finte le costruisce il modulo wisp/simulate: simulate.request(metodo, percorso) crea una wisp.Request, e simulate.read_body(risposta) legge il corpo di una risposta come stringa. Ecco una piccola biblioteca, provata da main:

src/library_routes.gleam
import gleam/http.{Get, Post}
import gleam/int
import gleam/io
import gleam/list
import wisp.{type Request, type Response}
import wisp/simulate

pub fn handle_request(request: Request) -> Response {
  case wisp.path_segments(request) {
    [] -> home(request)
    ["books"] -> books(request)
    ["books", id] -> book(request, id)
    _ -> wisp.not_found()
  }
}

fn home(request: Request) -> Response {
  use <- wisp.require_method(request, Get)
  wisp.html_response("<h1>The library</h1>", 200)
}

fn books(request: Request) -> Response {
  case request.method {
    Get -> wisp.ok() |> wisp.string_body("Dune, Emma, Ulysses")
    Post -> wisp.created()
    _ -> wisp.method_not_allowed([Get, Post])
  }
}

fn book(request: Request, id: String) -> Response {
  use <- wisp.require_method(request, Get)
  case int.parse(id) {
    Ok(number) ->
      wisp.ok() |> wisp.string_body("Book number " <> int.to_string(number))
    Error(_) -> wisp.bad_request("the id must be a number")
  }
}

pub fn main() -> Nil {
  [
    simulate.request(Get, "/"),
    simulate.request(Get, "/books"),
    simulate.request(Post, "/books"),
    simulate.request(http.Delete, "/books"),
    simulate.request(Get, "/books/42"),
    simulate.request(Get, "/books/dune"),
    simulate.request(Get, "/shop"),
  ]
  |> list.each(fn(request) {
    let response = handle_request(request)
    io.println(
      http.method_to_string(request.method)
      <> " "
      <> request.path
      <> " -> "
      <> int.to_string(response.status)
      <> " "
      <> simulate.read_body(response),
    )
  })
}
output
GET / -> 200 <h1>The library</h1>
GET /books -> 200 Dune, Emma, Ulysses
POST /books -> 201 Created
DELETE /books -> 405 Method not allowed
GET /books/42 -> 200 Book number 42
GET /books/dune -> 400 Bad request: the id must be a number
GET /shop -> 404 Not found

Sette richieste, sette risposte, in un attimo e senza aprire nessuna porta. Qualche dettaglio:

  • import gleam/http.{Get, Post} importa due varianti di http.Method senza prefisso (lezione 4.5); Delete, usata una volta sola, resta http.Delete. Il pacchetto gleam_http l’hai aggiunto nella lezione 9.1.
  • import wisp.{type Request, type Response} fa lo stesso con i tipi, così le firme si leggono meglio: fn home(request: Request) -> Response.
  • /books/dune arriva alla strada giusta, ma "dune" non è un numero: wisp.bad_request risponde 400, “hai sbagliato tu”. È il posto giusto per controllare i dati che arrivano da fuori, come i decoder della lezione 8.2.
  • wisp.created() risponde 201. Per ora non crea davvero niente: ci arriveremo.

Questo è anche il modo in cui si testano i server Gleam: gli esempi ufficiali di wisp hanno un file in test/ con una funzione per strada, che fa la richiesta finta e controlla la risposta con assert (lezione 5.5).

Accenderlo davvero

Il gestore di library_routes è pub, quindi un altro modulo può usarlo. Il server vero è un modulo a parte, con il main della lezione precedente:

src/library_server.gleam
import gleam/erlang/process
import library_routes
import mist
import wisp
import wisp/wisp_mist

pub fn main() -> Nil {
  let assert Ok(_) =
    wisp_mist.handler(library_routes.handle_request, wisp.random_string(64))
    |> mist.new
    |> mist.port(8000)
    |> mist.start
  process.sleep_forever()
}

Avvialo con gleam run -m library_server, e prova dal secondo terminale:

terminale
curl localhost:8000/books/42
curl -i -X DELETE localhost:8000/books
output
Book number 42
HTTP/1.1 405 Method Not Allowed
allow: GET, POST
content-length: 18
date: Mon, 28 Sep 2026 13:53:04 GMT
connection: keep-alive

Method not allowed

Le stesse risposte di main, perché è la stessa funzione. Nota l’intestazione allow: GET, POST messa da method_not_allowed. Separare così il gestore dall’avvio del server è la forma che ha quasi ogni progetto wisp.

La query

I parametri dopo il ? si leggono con wisp.get_query(request), che restituisce una lista di coppie #(nome, valore), già decodificate (%20 diventa uno spazio, per esempio). Per trovarne uno c’è list.key_find, che restituisce un Result:

src/greet_query.gleam
import gleam/http.{Get}
import gleam/io
import gleam/list
import gleam/string
import wisp.{type Request, type Response}
import wisp/simulate

fn handle_request(request: Request) -> Response {
  let name = case list.key_find(wisp.get_query(request), "name") {
    Ok(name) -> name
    Error(_) -> "stranger"
  }
  wisp.ok() |> wisp.string_body("Hello, " <> name <> "!")
}

pub fn main() -> Nil {
  let response = handle_request(simulate.request(Get, "/hello?name=Ada"))
  io.println(simulate.read_body(response))
  let response = handle_request(simulate.request(Get, "/hello"))
  io.println(simulate.read_body(response))
  let query = wisp.get_query(simulate.request(Get, "/?a=1&b=two%20words&a=3"))
  io.println(string.inspect(query))
}
output
Hello, Ada!
Hello, stranger!
[#("a", "1"), #("b", "two words"), #("a", "3")]

L’ultima riga mostra due cose da sapere: i valori arrivano già decodificati, e lo stesso nome può comparire più volte. list.key_find restituisce il primo; se ti servono tutti, c’è list.key_filter.

Quando usare la query e quando il percorso? Di solito il percorso dice quale cosa vuoi (/books/42), e la query come la vuoi: filtri, ordinamento, pagina (/books?sort=year&page=2).

Quiz

Una strada è scritta ["books", id]. Quale di questi percorsi NON ci arriva?

Esercizio · sul tuo computer

Il forno

Crea src/bakery.gleam, con un gestore per un forno che accetta solo richieste GET:

  • / risponde Welcome to the bakery;
  • /bread risponde con i pani, separati da virgole: baguette, ciabatta, pretzel (tienili in una const);
  • /bread/<nome> risponde One <nome>, coming up! se quel pane c’è, altrimenti 404;
  • tutto il resto è 404.

Nel main, prova il gestore con simulate su GET /, GET /bread, GET /bread/pretzel, GET /bread/croissant e POST /bread, stampando per ognuna il codice di stato e il corpo:

output
200 Welcome to the bakery
200 baguette, ciabatta, pretzel
200 One pretzel, coming up!
404 Not found
405 Method not allowed

Suggerimento: “solo GET” vale per tutto il forno, quindi require_method può stare in cima al gestore, prima del case.

Mostra una soluzione (prima prova da solo!)
src/bakery.gleam
import gleam/http.{Get, Post}
import gleam/int
import gleam/io
import gleam/list
import gleam/string
import wisp.{type Request, type Response}
import wisp/simulate

const breads = ["baguette", "ciabatta", "pretzel"]

pub fn handle_request(request: Request) -> Response {
  use <- wisp.require_method(request, Get)
  case wisp.path_segments(request) {
    [] -> wisp.ok() |> wisp.string_body("Welcome to the bakery")
    ["bread"] -> wisp.ok() |> wisp.string_body(string.join(breads, ", "))
    ["bread", name] ->
      case list.contains(breads, name) {
        True -> wisp.ok() |> wisp.string_body("One " <> name <> ", coming up!")
        False -> wisp.not_found()
      }
    _ -> wisp.not_found()
  }
}

pub fn main() -> Nil {
  [
    simulate.request(Get, "/"),
    simulate.request(Get, "/bread"),
    simulate.request(Get, "/bread/pretzel"),
    simulate.request(Get, "/bread/croissant"),
    simulate.request(Post, "/bread"),
  ]
  |> list.each(fn(request) {
    let response = handle_request(request)
    io.println(
      int.to_string(response.status) <> " " <> simulate.read_body(response),
    )
  })
}

Con require_method in cima, il POST /bread riceve 405 prima ancora di guardare il percorso. Se vuoi, avvia il forno per davvero con un modulo bakery_server, come library_server, e chiedi a curl localhost:8000/bread/ciabatta.

Ricapitolando

  • Il routing è un case su wisp.path_segments(request): [] è /, ["books", id] cattura un pezzo in una variabile, _ è il 404.
  • Il metodo è request.method: un case con wisp.method_not_allowed([...]) per i metodi che non accetti, oppure use <- wisp.require_method(request, Get) per una strada con un solo metodo.
  • Risposte pronte: wisp.ok() (200), wisp.created() (201), wisp.bad_request(dettaglio) (400), wisp.not_found() (404), wisp.method_not_allowed (405). Per il corpo, wisp.string_body e wisp.html_response.
  • La query: wisp.get_query(request) dà una lista di coppie; list.key_find ne cerca una.
  • wisp/simulate crea richieste finte (simulate.request) e legge le risposte (simulate.read_body): il gestore si prova chiamandolo, senza server.
  • Gestore in un modulo, avvio del server in un altro.

Nella prossima lezione vedremo come avvolgere un gestore con dei middleware, per il log, gli errori e la sicurezza, e riceveremo i dati di un modulo HTML.