Lezione 7 di 7 · 35 min di lettura

Riepilogo e sfida finale

HTTP, mist, wisp, strade, middleware, moduli, JSON e attori in una pagina, tre domande di controllo, e un libro degli ospiti vero, con una pagina per le persone e un'API per i programmi.

Guarda quanta strada

In questo modulo hai scritto il programma che sta dall’altra parte del browser. Adesso sai:

  • com’è fatta una richiesta HTTP e la sua risposta, e maneggiarle con gleam_http;
  • avviare un server con mist e wisp, e parlarci con il browser e con curl;
  • smistare le richieste con un case su percorso e metodo, e provare un gestore con wisp/simulate, senza server;
  • avvolgere un gestore con dei middleware, costruire pagine HTML sicure e leggere i moduli;
  • scrivere un’API JSON, con il codice di stato giusto per ogni errore;
  • usare un attore, passato in un contesto, per uno stato condiviso da tutte le richieste.

La pagina da tenere accanto

CosaCome si scrive
Pacchettigleam add gleam_http wisp mist (più gleam_erlang, e gleam_json per le API)
Avviare il serverwisp_mist.handler(gestore, chiave) \|> mist.new \|> mist.port(8000) \|> mist.start, poi process.sleep_forever()
Un gestorefn handle_request(request: Request) -> Response
Stradecase wisp.path_segments(request) { [] -> ..., ["books", id] -> ..., _ -> wisp.not_found() }
Metodirequest.method; use <- wisp.require_method(request, Get); wisp.method_not_allowed([Get, Post])
Querywisp.get_query(request) \|> list.key_find("name")
Rispostewisp.ok(), wisp.created(), wisp.no_content(), wisp.redirect(to: "/"), wisp.bad_request(d), wisp.not_found()
Corpowisp.html_response(html, 200), wisp.json_response(json, 200), \|> wisp.string_body(testo)
Middlewarefn m(next: fn() -> Response) -> Response, usato con use <- m
Log ed erroriwisp.configure_logger(); use <- wisp.log_request(request); use <- wisp.rescue_crashes
HTML sicurowisp.escape_html(testo)
Moduli e JSONuse form <- wisp.require_form(request), form.values; use data <- wisp.require_json(request)
Provaresimulate.request(Get, "/"), simulate.form_body, simulate.json_body, simulate.read_body
Stato condivisoun attore, in un Context; a mist: fn(request) { handle_request(request, context) }

Trovi tutto anche nel Codex (tasto K).

Quiz di controllo

Quiz

Il tuo gestore riceve DELETE /books, ma la strada /books accetta solo GET e POST. Quale risposta è giusta?

Quiz

Un'API riceve un POST con il corpo {"title": "Dune" (manca la graffa finale) e l'intestazione content-type: application/json. Cosa risponde require_json?

Quiz

Il server conta le visite in un attore. Lo spegni e lo riaccendi. Da che numero riparte il contatore?

La sfida: il libro degli ospiti

Il libro degli ospiti è un classico dei primi siti web: una pagina dove chiunque passi può lasciare il suo nome e un messaggio, e leggere quelli degli altri. Il nostro avrà:

  • GET /: una pagina HTML con un modulo (nome e messaggio) e l’elenco dei messaggi, dal più recente;
  • POST /entries: riceve il modulo, salva il messaggio e rimanda il browser alla pagina principale; se il nome o il messaggio sono vuoti (anche solo spazi), risponde 400;
  • GET /api/entries: gli stessi messaggi in JSON, per i programmi;
  • 405 per i metodi sbagliati sulle tre strade, 404 per tutto il resto;
  • un log per ogni richiesta, e un 500 ordinato se qualcosa si schianta.

I messaggi vivono in un attore: tutti i visitatori vedono gli stessi.

Esercizio · sul tuo computer

Il libro degli ospiti

Crea un progetto nuovo accanto a exercises, nella cartella learn-gleam:

terminale
gleam new guestbook
cd guestbook
gleam add gleam_http gleam_erlang gleam_otp gleam_json wisp mist

Organizza il codice in tre moduli, come lo scaffale del modulo 8:

src/guestbook/store.gleam: l’attore. Un tipo Entry(name: String, text: String), un Message opaco, e le funzioni start(), add(store, entry) e all(store), che restituisce i messaggi dal più recente. Un alias pub type Store = Subject(Message) rende le firme più leggibili.

src/guestbook/router.gleam: il tipo Context(store: Store) e handle_request(request, context), con i middleware e le strade della sfida.

src/guestbook.gleam: il main, che configura il log, avvia l’attore e il server sulla porta 8000.

Prima di accenderlo, prova il router senza server, con un modulo src/try_guestbook.gleam che usa simulate. Poi avvialo con gleam run, apri http://localhost:8000 e lascia un paio di messaggi dal browser. Infine, riavvia il server (così parte vuoto), e dal secondo terminale:

terminale
curl -d 'name=Ada&text=Lovely place!' localhost:8000/entries
curl -d 'name=Joe&text=Hello, <b>world</b>' localhost:8000/entries
curl -d 'name=  &text=Who am I?' localhost:8000/entries
curl localhost:8000/api/entries
output
You are being redirected: /
You are being redirected: /
Bad request: name and text are required
[{"name":"Joe","text":"Hello, <b>world</b>"},{"name":"Ada","text":"Lovely place!"}]

Incolla qui l’output dell’ultimo comando, curl localhost:8000/api/entries.

Mostra una soluzione (prima prova da solo!)

L’attore, con lo stesso schema del contatore della lezione precedente. I messaggi nuovi si aggiungono in testa alla lista, che quindi è già in ordine dal più recente:

src/guestbook/store.gleam
import gleam/erlang/process.{type Subject}
import gleam/otp/actor

pub type Entry {
  Entry(name: String, text: String)
}

pub opaque type Message {
  Add(entry: Entry)
  All(reply_to: Subject(List(Entry)))
}

pub type Store =
  Subject(Message)

pub fn start() -> Result(Store, actor.StartError) {
  case actor.new([]) |> actor.on_message(handle_message) |> actor.start {
    Ok(started) -> Ok(started.data)
    Error(error) -> Error(error)
  }
}

pub fn add(store: Store, entry: Entry) -> Nil {
  actor.send(store, Add(entry))
}

/// The entries, newest first.
pub fn all(store: Store) -> List(Entry) {
  actor.call(store, waiting: 1000, sending: All)
}

fn handle_message(
  entries: List(Entry),
  message: Message,
) -> actor.Next(List(Entry), Message) {
  case message {
    Add(entry) -> actor.continue([entry, ..entries])
    All(reply_to) -> {
      actor.send(reply_to, entries)
      actor.continue(entries)
    }
  }
}

Il router, con i middleware in cima e le strade in un case su percorso e metodo:

src/guestbook/router.gleam
import gleam/http.{Get, Post}
import gleam/json
import gleam/list
import gleam/result
import gleam/string
import guestbook/store.{type Entry, type Store, Entry}
import wisp.{type Request, type Response}

pub type Context {
  Context(store: Store)
}

pub fn handle_request(request: Request, context: Context) -> Response {
  use <- wisp.log_request(request)
  use <- wisp.rescue_crashes
  case wisp.path_segments(request), request.method {
    [], Get -> home_page(context)
    ["entries"], Post -> add_entry(request, context)
    ["api", "entries"], Get -> list_entries(context)
    [], _ -> wisp.method_not_allowed([Get])
    ["entries"], _ -> wisp.method_not_allowed([Post])
    ["api", "entries"], _ -> wisp.method_not_allowed([Get])
    _, _ -> wisp.not_found()
  }
}

fn home_page(context: Context) -> Response {
  let items =
    store.all(context.store)
    |> list.map(fn(entry) {
      "<li><b>"
      <> wisp.escape_html(entry.name)
      <> "</b>: "
      <> wisp.escape_html(entry.text)
      <> "</li>"
    })
    |> string.concat
  let html = "<h1>Guestbook</h1>
<form method='post' action='/entries'>
  <input name='name' placeholder='Your name'>
  <input name='text' placeholder='Your message'>
  <button>Sign</button>
</form>
<ul>" <> items <> "</ul>"
  wisp.html_response(html, 200)
}

fn add_entry(request: Request, context: Context) -> Response {
  use form <- wisp.require_form(request)
  let entry = {
    use name <- result.try(field(form.values, "name"))
    use text <- result.try(field(form.values, "text"))
    Ok(Entry(name:, text:))
  }
  case entry {
    Ok(entry) -> {
      store.add(context.store, entry)
      wisp.redirect(to: "/")
    }
    Error(_) -> wisp.bad_request("name and text are required")
  }
}

/// A form field, trimmed, that must not be empty.
fn field(values: List(#(String, String)), name: String) -> Result(String, Nil) {
  use value <- result.try(list.key_find(values, name))
  case string.trim(value) {
    "" -> Error(Nil)
    value -> Ok(value)
  }
}

fn list_entries(context: Context) -> Response {
  store.all(context.store)
  |> json.array(entry_to_json)
  |> json.to_string
  |> wisp.json_response(200)
}

fn entry_to_json(entry: Entry) -> json.Json {
  json.object([
    #("name", json.string(entry.name)),
    #("text", json.string(entry.text)),
  ])
}

Il modulo principale, che mette insieme i pezzi:

src/guestbook.gleam
import gleam/erlang/process
import guestbook/router.{Context}
import guestbook/store
import mist
import wisp
import wisp/wisp_mist

pub fn main() -> Nil {
  wisp.configure_logger()
  let assert Ok(entries) = store.start()
  let context = Context(store: entries)
  let handler = fn(request) { router.handle_request(request, context) }
  let assert Ok(_) =
    wisp_mist.handler(handler, wisp.random_string(64))
    |> mist.new
    |> mist.port(8000)
    |> mist.start
  process.sleep_forever()
}

E la prova senza server, con un attore tutto suo:

src/try_guestbook.gleam
import gleam/http.{Get, Post}
import gleam/int
import gleam/io
import gleam/list
import guestbook/router.{Context}
import guestbook/store
import wisp/simulate

pub fn main() -> Nil {
  let assert Ok(entries) = store.start()
  let context = Context(store: entries)
  [
    simulate.request(Post, "/entries")
      |> simulate.form_body([#("name", "Ada"), #("text", "Lovely place!")]),
    simulate.request(Post, "/entries")
      |> simulate.form_body([#("name", "Joe"), #("text", "Hello, <b>world</b>")]),
    simulate.request(Post, "/entries")
      |> simulate.form_body([#("name", "  "), #("text", "Who am I?")]),
    simulate.request(Get, "/api/entries"),
    simulate.request(Get, "/"),
    simulate.request(http.Delete, "/entries"),
    simulate.request(Get, "/admin"),
  ]
  |> list.each(fn(request) {
    let response = router.handle_request(request, context)
    io.println(
      http.method_to_string(request.method)
      <> " "
      <> request.path
      <> " -> "
      <> int.to_string(response.status),
    )
    io.println(simulate.read_body(response))
  })
}
output
POST /entries -> 303
You are being redirected: /
POST /entries -> 303
You are being redirected: /
POST /entries -> 400
Bad request: name and text are required
GET /api/entries -> 200
[{"name":"Joe","text":"Hello, <b>world</b>"},{"name":"Ada","text":"Lovely place!"}]
GET / -> 200
<h1>Guestbook</h1>
<form method='post' action='/entries'>
  <input name='name' placeholder='Your name'>
  <input name='text' placeholder='Your message'>
  <button>Sign</button>
</form>
<ul><li><b>Joe</b>: Hello, &lt;b&gt;world&lt;/b&gt;</li><li><b>Ada</b>: Lovely place!</li></ul>
DELETE /entries -> 405
Method not allowed
GET /admin -> 404
Not found

Qualche dettaglio da notare:

  • Nel JSON il messaggio di Joe resta Hello, <b>world</b>: i dati si conservano come sono arrivati, e l’escape si fa solo quando diventano HTML. Nella pagina, infatti, il <b> compare come testo, non come grassetto. Chi legge l’API deciderà da sé come mostrarli.
  • try_guestbook non stampa le righe di log, perché non chiama wisp.configure_logger(): senza, Erlang mostra solo i messaggi più gravi. Il server vero, invece, scrive INFO 303 POST /entries e compagnia.
  • field fa due controlli in uno: il campo c’è, e tolti gli spazi non è vuoto. Per questo il terzo POST, con un nome fatto solo di spazi, riceve 400.
  • store non sa niente di HTTP, e router non sa che dietro Store c’è un attore: sa solo chiamare store.add e store.all. Se un giorno i messaggi finissero in un database, cambierebbe solo store.gleam.

Dove andare da qui

Con questo modulo hai visto il giro completo di un programma Gleam vero: tipi e funzioni, errori come valori, processi e attori, dati dal mondo esterno, e un server che li mette al lavoro per tanti utenti insieme.

Da qui, la strada migliore è costruire qualcosa di tuo: un piccolo sito, un’API per un’idea che hai in testa, uno strumento da riga di comando. Quando ti serve qualcosa di nuovo, i pacchetti si cercano su packages.gleam.run, la documentazione di ognuno sta su hexdocs.pm, e gli esempi ufficiali di wisp mostrano come si fanno cose che qui non abbiamo visto, come i cookie, i file caricati e i database. E per ogni dubbio sul linguaggio, la fonte è sempre la stessa: la documentazione ufficiale di Gleam.