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.

In Entwicklung · v0.4

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, :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.

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 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:

config/config.casc
#@version = 1.0

webglue {
}

# 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"
}