Kernkonzepte
Der Vertrag und die darauf gebauten Bibliotheken — jeweils mit Code aus Webglues eigenen Quellen, Tests und der Demo-Anwendung.
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-Cookiestimmt. - Ein Body ist eine Zeichenkette, ein einmal lesbarer Stream oder
nil. - Middleware fügt eigene Schlüssel hinzu —
:session,:path-params,: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.
(defn routes [] :spec [-> vector]
[(router/get "/socket/tickets" watch)
(router/post "/session" sign-in)
(router/delete "/session" sign-out)
(router/group "/tickets"
[(router/post "/" raise)
(router/get "/" index)
(router/get "/:id" show)
(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.
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.
(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 4000})]))
(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=/; 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))
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))]
(text 201 (rendered (announced :raised (store/raise (db/repo-of request)
(:subject asked)
(:body asked)
(:reporter asked)))))
[#(:error failures) (text 400 (complaint failures))]))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.
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; ein Redis-Adapter ist eine Startoption entfernt. (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.
(defn- row [given] :spec [map -> any]
[:li {:class (str/str (:status given)) :id (str/join (list "ticket-" (str/str (:id given))) "")}
[:span {:class "id"} (str/join (list "#" (str/str (:id given))) "")]
[:span {:class "subject"} (:subject given)]
(if (nil? (:assignee given))
nil
[:span {:class "who"} (:assignee 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")))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:
#@version = 1.0
webglue {
}
# Last, so what an environment says wins over what is above it.
import "${PRX_ENV:dev}/*.casc"#@version = 1.0
praxis.logger {
level = debug
formatter = "praxis.logfmt"
}