joetjen.net
EN DE
Web-Framework

Kernkonzepte

Der Vertrag und die darauf gebauten Bibliotheken — jeweils mit Code aus WebGlues eigenen Quellen, Tests und der Demo-Anwendung, und aus Lictor, wo die Demo eine Bibliothek nicht nutzt.

In Entwicklung · v0.8

Der Vertrag

Ein Request ist eine Map mit :method (ein kleingeschriebenes Keyword), :path (noch prozentkodiert), :query, :scheme, :host, :port, :remote, :version, :headers und :body. Eine Response ist eine Map mit :status, :headers und :body. Das ist webglue_spec im Ganzen.

  • Header sind auf beiden Seiten eine Map von kleingeschriebenem Namen auf eine Liste von Werten — eine Liste ist die einzige Form, die für Set-Cookie stimmt.
  • Ein Body ist eine Zeichenkette, ein einmal lesbarer Stream oder nil.
  • Middleware fügt eigene Schlüssel hinzu — :session, :path-params, :query-params, :form-params, :csrf-token, :media-type, :current-user, :repo — und jeder ist bei der Bibliothek dokumentiert, die ihn setzt.

Abbrechen braucht keine Markierung. Eine Middleware, die den Request stoppen soll, ruft den Handler einfach nicht auf; ein :halted-Flag wurde erwogen und als Sicherheitsrisiko verworfen.

(defn wrap-auth [handler]
  (fn [request]
    (if (authorised? request)
      (handler request)
      {:status 403 :headers {} :body nil})))

Handler und Middleware

Middleware schreibt man von Hand genauso, wie die Bibliotheken sie schreiben. Die der Demo legt den angemeldeten Benutzer auf den Request:

(defn wrap-user
  [handler] :spec [function -> function]
  (fn [request]
    (let [named (or (:user (:session request)) (spec/header request "x-demo-user"))]
      (handler (if (nil? named)
                   request
                   (map/assoc request :current-user named))))))

Routing

Eine Routing-Tabelle ist Daten: ein Vektor aus [method pattern handler], der Reihe nach geprüft, der erste Treffer gewinnt. In einem Muster passt eine Zeichenkette auf sich selbst, ein Keyword bindet ein Segment, und :* bindet den Rest; "/users/:id" ist die Kurzform von ["users" :id]. Treffer landen dekodiert in :path-params. Ein Handler in der Tabelle darf selbst umhüllt sein — hier drei davon mit Content-Negotiation:

(defn routes [] :spec [-> vector]
  [(router/get "/" page)
   (router/get "/socket/tickets" watch)
   (router/post "/session" sign-in)
   (router/delete "/session" sign-out)
   (router/group "/tickets"
                 [(router/post "/" raise)
                  (router/get "/" (negotiation/wrap index formats))
                  (router/get "/:id" (negotiation/wrap show formats))
                  (router/get "/:id/trail" (negotiation/wrap trail-of formats))
                  (router/post "/:id/assign" assign)
                  (router/post "/:id/hold" hold)
                  (router/post "/:id/resume" resume)
                  (router/post "/:id/close" close)])])
(router/segments "/users/:id")                       ; => ["users" :id]
(router/group "/users" [[:get [] :index]])           ; => [[:get ["users"] :index]]
(router/allowed [[:get ["users"] :index]] "/users")  ; => [:get :head :options]

HEAD fällt auf GET zurück, und OPTIONS wird aus der Tabelle mit einem Allow-Header beantwortet. router/handler antwortet mit 404, wenn nichts passt; router/wrap reicht stattdessen an den nächsten Handler weiter. prx routes webglue.demo.web gibt die Tabelle aus.

Templates

Ein Template ist eine HTML-Datei mit Lücken. {{ … }} setzt einen Wert ins Markup, und ein Attribut mit führendem Doppelpunkt — :if, :for — ist eine Direktive, deren Wert Praxis ist. In einer Lücke steht gewöhnliches Praxis, gelesen vom Praxis-Reader; eine zweite Ausdruckssprache gibt es nicht. Ein Template deklariert seine Parameter, ein falsch geschriebener ist also ein Kompilierfehler und kein nil auf der Seite. Das Markup muss wohlgeformt sein: Ein fehlendes schließendes Tag ist ein Fehler mit Zeilennummer.

webglue_demo/apps/web/templates/tickets.html
{{:params [tickets q]}}
<!doctype html>
<html lang="en">
  <head><title>Tickets</title></head>
  <body>
    <h1>Tickets</h1>
    <form method="get" action="/"><input type="search" name="q" value="{{ q }}"><button>Search</button></form>
    <ul id="tickets" :if="(praxis.core/not (praxis.seq/empty? tickets))">{{ (for [t tickets] (ticket-row {:ticket t})) }}</ul>
    <p :if="(praxis.core/and (praxis.seq/empty? tickets) (praxis.string/blank? q))">Nothing yet.</p>
    <p :if="(praxis.core/and (praxis.seq/empty? tickets) (praxis.core/not (praxis.string/blank? q)))">Nothing matches.</p>
  </body>
</html>

deftemplates kompiliert zur Compile-Zeit jede .html-Datei unter einem Verzeichnis in den aufrufenden Namespace, eine Funktion pro Datei, der Pfad im Namen: Aus templates/ticket-row.html wird ticket-row. Nichts wird generiert oder eingecheckt. view/page beantwortet einen Baum als HTML-Response, und view/document tut dasselbe mit vorangestelltem Doctype — und lehnt einen Baum ab, der schon einen hat.

(ns webglue.demo.templates
  "Every page and fragment this app renders."
  (:require [webglue.view :refer [deftemplates]]))

(deftemplates "templates")

(defn- page [request] :spec [map -> map]
  (with [#(:ok query) (searched request)]
        (view/page (templates/tickets {:tickets (store/search (db/repo-of request) query)
                                       :q       (or (:q query) "")}))
        [#(:error failures) (text 400 (complaint failures))]))

Ein Template liefert einen Markup-Baum, niemals eine Zeichenkette; ein Wert wird also durch die Position maskiert, an der er landet, nicht weil jemand ans Maskieren gedacht hat. Parsen und Kompilieren sind keine Web-Anliegen und liegen in zwei allgemeinen Praxis-Bibliotheken, quire (Markup-Text zu einem Baum gelesen) und stencil (ein Template zu einer Funktion kompiliert); nur webglue_view, das die Templates eines Projekts findet und mit einem davon eine Response beantwortet, gehört zu WebGlue.

Content-Negotiation

webglue_content_negotiation liest Accept einmal, nach RFC 9110, und vergleicht es mit dem, was ein Handler anbietet, in dessen eigener Reihenfolge. Der spezifischste passende Bereich bestimmt den Wert eines Angebots, die höchste Qualität gewinnt, und bei gleicher Qualität entscheidet die Reihenfolge des Servers — ein Client, der */* schickt, bekommt also das erste Angebot. Für diese Bibliothek ist ein Medientyp nur eine Zeichenkette: Sie wählt, und der Handler schreibt.

(negotiation/choose "application/json" ["application/dxn" "application/json"])
; => "application/json"

(negotiation/choose "*/*" ["application/dxn" "application/json"])
; => "application/dxn"

(negotiation/choose "image/png" ["application/dxn" "application/json"])
; => nil

negotiation/wrap legt die Wahl als :media-type auf den Request, ergänzt jede Response um Vary: Accept und antwortet selbst mit 406 samt den Angeboten, wenn nichts akzeptabel ist — der Handler läuft dann nicht. Die Demo bietet zuerst Klartext an, weil das, was sie ohne Nachfrage liest, curl und ein Mensch ist:

(def- formats
  ["text/plain" "application/dxn" "application/json"])

(defn- answered [request status value said] :spec [map integer any function -> map]
  (let [type (negotiation/media-type request)]
    (cond
      (= type "application/dxn")  (encoded status type (dxn/encode value))
      (= type "application/json") (encoded status type (json/generate value))
      true                        (text status (said value)))))

Formular-Bodies

webglue_params liest, was ein Request als Text mitbringt, in Maps: den Query-String nach :query-params, einen URL-kodierten Formular-Body nach :form-params. Beide sind immer vorhanden, leer statt fehlend, und beide bilden Zeichenketten auf Zeichenketten ab — aus "2" eine 2 zu machen ist Aufgabe der Validierung, damit das Dekodieren eines Bodys und das Vertrauen in ihn nie derselbe Schritt sind. Ein kaputtes Escape wird mit 400 beantwortet, ein Body über einem Megabyte (oder :max-length) mit 413, bevor der Handler erreicht wird.

(defn register [request]
  (let [form (:form-params request)]
    (praxis.map/get form "username")))

Ein wiederholtes Feld behält seinen letzten Wert, und JSON-, DXN- und Multipart-Bodies werden noch nicht gelesen; beides steht im Backlog, bis eine Journey es braucht.

CSRF-Schutz

webglue_server_csrf ist das Synchronizer-Token: in der Session gehalten, jedem Request als :csrf-token mitgegeben und bei jedem unsicheren Request zurückverlangt — als Formularfeld _csrf oder, für ein Skript, als Header x-csrf-token. Ein Request ohne Token wird mit 403 beantwortet und erreicht den Handler nie. GET, HEAD, OPTIONS und TRACE werden nicht geprüft, und ein Token wird in konstanter Zeit verglichen. Die Reihenfolge der drei Middlewares ist entscheidend: die Session außen, weil sie das Token hält; Params danach, weil das Token im Formular ankommt; CSRF innen, um das herum, was es schützt.

(-> (router/handler (routes))
    (csrf/wrap)
    (params/wrap)
    (session/wrap {:store (memory/store)}))

(csrf/field {:csrf-token "t"})   ; => [:input {:type "hidden" :name "_csrf" :value "t"}]

Bibliotheken, die eine Seite eines Sicherheitsaustauschs übernehmen, tragen die Seite vorn im Namen — webglue_server_csrf, später webglue_client_csrf für die Browser-Hälfte —, damit jede Seite beisammen steht.

Server

Ein Server-Adapter wird als Funktion übergeben, nicht über ein Atom benannt — ein Server, den das Projekt nicht hat, ist ein Name, der nicht kompiliert. Ein Adapter hat drei Verben: start, stop und child, das die Child-Spec für die Supervision liefert, die eine Anwendung tatsächlich nutzt.

webglue_demo/apps/web/src/webglue/demo.prax
(defn init [_argument] :spec [any -> tuple]
  (supervisor/tree (supervisor/flags :one-for-one)
                   [(pubsub/child)
                    (presence/child)
                    (supervisor/child webglue.session.memory nil)
                    (supervisor/child webglue.demo.store.memory nil)
                    (server/child (adapter) (web/app) {:port (port)})]))

(defn- adapter [] :spec [-> function]
  (if (= (system/env "WEBGLUE_ADAPTER") "wrangler")
      wrangler/child
      bandit/child))

Es gibt zwei Adapter. webglue_bandit läuft auf Bandit und Plug und bietet HTTP/1.1, HTTP/2 und WebSockets; Plug bleibt vollständig in dieser einen Bibliothek. webglue_wrangler ist ein in Praxis geschriebener HTTP/1.1-Server, der den Vertrag direkt spricht, mit Keep-Alive, Pipelining, gestreamten Bodies und WebSockets. Standard wird er erst, wenn er es sich verdient hat: Eine Paritäts-Suite schickt dieselben rohen Bytes durch beide Adapter und erwartet dieselbe Antwort — beim ersten Lauf fand sie echte Fehler in Wrangler.

Sessions

Ein Handler liest :session aus dem Request — immer eine Map, nie nil. Um eine Session zu schreiben, setzt er :session auf der Response; nil beendet sie; ohne den Schlüssel ändert sich nichts. Jeder Schreibvorgang erzeugt ein neues Token, was Session-Fixation ausschließt. Das Cookie ist standardmäßig HttpOnly, SameSite=Lax und Path=/ und kommt neben jedes Cookie, das der Handler selbst gesetzt hat; die Speicherung liegt hinter einem store-Behaviour.

(defn- sign-in [request] :spec [map -> map]
  (let [who (map/get (form-of request) "user")]
    (if (praxis.string/blank? (or who ""))
        (text 400 "a session needs a user")
        (map/assoc (text 200 (str/join (list "signed in as " who) ""))
                   :session {:user who}))))

(defn- sign-out [_request] :spec [map -> map]
  (map/assoc (text 200 "signed out") :session nil))

Statische Dateien

static/wrap beantwortet GET und HEAD für Dateien unterhalb eines Verzeichnisses, mit dem Content-Type aus der Dateiendung. Alles andere fällt durch — eine fehlende Datei wird zum 404 der Anwendung selbst. Path-Traversal wird abgewiesen, indem geprüft wird, wohin ein Pfad aufgelöst wird, nicht welche Schreibweisen er nutzt:

(deftest and-a-path-climbing-out-of-the-directory-is-refused
  (assert= (:status (serve (test/request :get "/assets/../../project.pxn"))) 404)
  (assert= (:status (serve (test/request :get "/assets/css/../../../../etc/passwd"))) 404))

Stylesheets mit Tailwind

webglue_tailwind baut das Stylesheet einer Anwendung mit Tailwinds eigenständigem CLI. Das Binary wird für die Maschine in einer angepinnten Version geladen (standardmäßig 4.3.3), gegen seine SHA-256-Prüfsumme geprüft, bevor es überhaupt ausführbar ist, und unter .prx/tools/ zwischengespeichert; eingecheckt wird nichts. In den Build hängt es sich über zwei prx-Schritte: :prepare läuft nach jedem Kompilieren, :alongside läuft neben prx run und endet mit ihm. Es baut :always neu oder :on-change, anhand eines Stempels über das Stylesheet, seine Plugins und die Quellen. Lictor gestaltet seine Seiten auf diese Weise mit daisyUI.

project.pxn
:deps [[webglue_tailwind "~> 0.8"]]

:prepare   ["tailwind build"]
:alongside ["tailwind watch"]

:tailwind {:input  "assets/app.css"
           :output "priv/static/assets/app.css"}

Validierung

Eine Funktion, check, und Regeln als Daten. Hinein gehen String-Schlüssel, heraus kommen Keyword-Schlüssel, Werte werden aus Text umgewandelt, und jeder Fehler wird zurückgegeben — nicht nur der erste.

(v/check {:page {:type :integer}} {"page" "2"})     ; => #(:ok {:page 2})
(v/check {:debug {:type :boolean}} {"debug" "on"})  ; => #(:ok {:debug true})

(def- raising
  {:subject  {:type :string :required true :max 200}
   :body     {:type :string :default ""}
   :reporter {:type :string}})

(defn- raise [request] :spec [map -> map]
  (with [#(:ok asked) (v/check raising (form-of request))]
        (let [repo (db/repo-of request)
              made (store/raise repo (:subject asked) (:body asked) (:reporter asked))]
          (recorded repo (:id made) :raised (:current-user request))
          (text 201 (rendered (announced :raised made))))
        [#(:error failures) (text 400 (complaint failures))]))

Die Suche der Demo nutzt dieselbe Bibliothek für einen Query-String ohne Schema dahinter: :status muss einer der vier Zustände eines Tickets sein, alles andere ist ein 400, das das Feld nennt.

Die Datenschicht

db/wrap legt ein Repository als :repo auf den Request, optional mit Checkout und Transaktion; db/repo-of liest es wieder aus. Ein Rollback ist ausdrücklich — (db/rollback response) — und wird nie aus einem Statuscode erraten. WebGlue ruft auf, was immer ihm übergeben wird; Ecto ist also eine Abhängigkeit der Anwendung, nicht des Frameworks. Die Demo nutzt die Nahtstelle für einen In-Memory-Store hinter einem Behaviour und führt darin ein Audit-Protokoll: die erste Änderung in der Demo, die aus zwei Schreibvorgängen besteht — wofür :transaction da ist, sobald der Store eine Datenbank ist.

Sockets

Ein Socket ist eine Response. Ein Handler antwortet mit (socket/upgrading handlers state), einer 101-Response mit einem :socket-Schlüssel — sie ist also schon durch die Session und jede andere Middleware gelaufen, womit die Authentifizierung von Sockets geklärt ist. Es gibt vier optionale Callbacks, :connected, :received, :informed (eine Nachricht von anderswo im System) und :closed, sowie drei Antworten: socket/keeping, socket/pushing, socket/closing.

(defn- watch [request] :spec [map -> map]
  (socket/upgrading {:connected joined :informed told :received asked}
                    {:topic "tickets" :who (:current-user request) :sent 0}))

(defn- joined [state] :spec [map -> tuple]
  (do
    (pubsub/subscribe (:topic state))
    (presence/track (:topic state) (or (:who state) "anonymous") {})
    (socket/pushing (fragment/frame
                      (fragment/data (:topic state)
                                     {:watching (:topic state)
                                      :whom     (praxis.map/keys
                                                  (presence/who (:topic state)))})
                      on-the-wire)
                    state)))

Pub/Sub und Presence

Ein Subscriber ist ein Prozess. (pubsub/subscribe "topic") und (pubsub/broadcast "topic" message) laufen auf Erlangs pg und umspannen damit den Cluster, sobald Knoten verbunden sind; webglue_pubsub_redis ist ein zweiter Adapter hinter derselben Nahtstelle. (presence/track topic key meta) erfasst den aufrufenden Prozess und überwacht ihn — ohne Heartbeat — und Beitritte und Abgänge gehen auf demselben Topic als {:presence-diff {:joined … :left …}} hinaus. Der Socket der Demo bekam Presence-Diffs, ohne dass dafür eine Zeile Code nötig war.

(cluster/child {:strategy (cluster/static (list "web@10.0.0.4" "web@10.0.0.5"))})
(cluster/child {:strategy (cluster/by-dns "app.svc.cluster.local" "web")})

Clustering findet Knoten über eine Strategie, die schlicht eine Funktion ist, die Knotennamen liefert.

Server-Push

webglue_fragment schickt zwei Arten von Umschlägen an verbundene Clients: Daten und HTML-Fragmente, adressiert an ein Ziel. Ein Fragment ist ein Markup-Baum aus gewöhnlichen Vektoren und Maps, keine Zeichenkette; der Serialisierer maskiert nach Position, ein Ticket mit dem Titel <script>alert(1)</script> bleibt also Text. Frames gehen standardmäßig als binäres DXN hinaus, oder als DXN-Text oder JSON.

Weil ein Template einen Baum liefert, sind die Zeile, die eine Seite rendert, und die Zeile, die ein Socket pusht, eine einzige Datei:

webglue_demo/apps/web/templates/ticket-row.html
{{:params [ticket]}}
<li class="{{ (praxis.string/str (:status ticket)) }}" id="ticket-{{ (praxis.string/str (:id ticket)) }}"><span class="id">#{{ (praxis.string/str (:id ticket)) }}</span><span class="subject">{{ (:subject ticket) }}</span>{{ (praxis.core/if (praxis.core/nil? (:assignee ticket)) nil [:span {:class "who"} (:assignee ticket)]) }}</li>
(defn- row [given] :spec [map -> any]
  (templates/ticket-row {:ticket given}))

(defn- announced [event ticket] :spec [keyword map -> map]
  (do
    (fragment/push-data "tickets" {:event event :ticket ticket})
    (fragment/push-html "tickets" "#tickets" (row ticket))
    ticket))

Testen

Weil eine Anwendung eine Funktion ist, braucht ihr Test keinen Server. test/request baut eine vollständige Request-Map, test/answering ist ein Platzhalter-Handler mit fester Antwort, und test/echoing gibt den empfangenen Request zurück — so sieht man, was eine Middleware getan hat.

(deftest echoing-is-how-you-see-what-a-middleware-did
  (let [wrap (fn [handler]
               (fn [request] (handler (praxis.map/assoc request :locale "de"))))
        app  (wrap (test/echoing))]
    (assert= (:locale (:request (app (test/request :get "/")))) "de")))

Jedes Beispiel in der Dokumentation einer Bibliothek läuft als Doctest; ein Docstring, der ein Ergebnis behauptet, wird also wie ein Test geprüft.

Konfiguration

Konfiguration ist CASC. Die Hauptdatei importiert jede Datei aus dem Verzeichnis der aktuellen Umgebung — zuletzt, damit die Umgebung gewinnt — und jedes Anliegen bekommt eine eigene Datei. Ein Geheimnis kommt aus der Umgebung, mit einem Standardwert, der einen unkonfigurierten Laptop zum Laufen bringt:

config/config.casc
#@version = 1.0

webglue {
}

webglue_pubsub_redis {
  url = "${WEBGLUE_REDIS_URL:redis://localhost:6379}"
}

# Last, so what an environment says wins over what is above it.
import "${PRX_ENV:dev}/*.casc"
config/dev/logger.casc
#@version = 1.0

praxis.logger {
  level = debug
  formatter = "praxis.logfmt"
}