module Bezel
bezel
A web framework for Quarz, in the shape of
Phoenix: an immutable Conn flows through plugs, a router and a
controller action, and comes out with a response. Data goes through
rotor on PostgreSQL
(pg). Nothing is looked up by
reflection at run time and no template is interpreted.
# Cog.toml of an application
[dependencies]
Bezel = { git = "https://github.com/quarz-lang/bezel", branch = "main" }
The API reference is at https://bezel.api.quarz.dev.
Work in progress (the plan is issue #1). What exists: the connection,
parameters, plugs, endpoints, configuration, error pages, Bezel.start,
the built-in plugs, the router and controllers, and the HTML DSL with
forms over rotor's changesets, sessions, the flash, CSRF protection,
secure headers, host authorization, password hashes and signed tokens,
test helpers, the template compiler, and the bezel command.
class Shop::Endpoint < Bezel::Endpoint
def self.plugs -> Array[Bezel::Plug]
[Bezel::Plug::RequestId.new,
Bezel::Plug::Logger.new,
Bezel::Plug::Static.new(at: "/", from: "priv/static"),
Bezel::Plug::Parsers.new(parsers: [:urlencoded, :json], max_body: 1_048_576),
Bezel::Plug::MethodOverride.new,
Bezel::Plug::Head.new,
Bezel::Plug::Timeout.new(15.seconds),
Bezel.plug { |conn| conn.text("hello #{conn.params.fetch("name", "world")}") }]
end
end
# HOST, PORT, DATABASE_URL, SECRET_KEY_BASE... from the environment, over
# the application's settings for its BEZEL_ENV (prod when unset: run with
# BEZEL_ENV=dev when developing)
Bezel.start(Shop::Endpoint, {dev: Bezel::Settings.new(database_url: "postgres://localhost/shop_dev"),
prod: Bezel::Settings.new(port: 8080)})
Bezel::Connis adatavalue: the request, the merged parameters (Bezel::Params: query, body, path;params.int("id"),params["user"].fetch("email"), a missing or malformed one a400), assigns, session and flash, and the response being made (status, headers, body,halted?). Every change makes a new one.- A plug is a
Bezel::Plug(call(conn) -> Conn, andwrapfor one that acts around the rest, asLoggerandTimeoutdo) or a block (Bezel.plug { |conn| ... }). A plug that answers halts the pipeline; an exception a plug raises halts it too, kept in the connection (conn.error) with what the plugs before it did. - An endpoint lists its plugs in
def self.plugs. A request no plug answers is a404. An exception is answered with the error page:Bezel::NotFound,Bezel::Forbiddenand the otherHTTP::Errors with their status, anything else with500, reported to the endpoint'son_error. Indevandtestthe page shows the exception, its backtrace and the request (HTML, or JSON for a JSONAccept); inprodit shows the status only. Headers the plugs set (the request id) stay on the error page. Bezel::ConfigreadsBEZEL_ENV(dev,test,prod;prodwhen unset, so that a deploy that forgets it shows no backtraces),HOST,PORT,DATABASE_URL,POOL_SIZE,SECRET_KEY_BASE(required inprod, 64 bytes or more),TRUSTED_PROXIES,TLS_CERT/TLS_KEYandLOG_LEVELover the application's ownBezel::Settingsper environment.Bezel.startserves the endpoint withHTTP::Server, opens aPG::Poolwhen there is aDATABASE_URL, and stops gracefully onSIGTERMandSIGINT(a second signal ends the process at once).
A router declares its routes in one routes block, checked where it is
compiled. A controller declares its plugs, one plug or skip line
each (a controller without plugs of its own writes controller); the
class generates the dispatch to its actions, which runs the plugs:
class Shop::UsersController < Bezel::Controller
plug :require_login, except: [:index, :show]
def index(conn: Bezel::Conn) -> Bezel::Conn = text(conn, "users")
def show(conn: Bezel::Conn) -> Bezel::Conn = text(conn, "user #{conn.params.int("id")}")
# new, create, edit, update, delete ...
private def require_login(conn: Bezel::Conn) -> Bezel::Conn = ...
end
class Shop::Router < Bezel::Router
def self.browser -> Array[Bezel::Plug] = [Bezel::Plug::Accepts.new(["html"])]
def self.api -> Array[Bezel::Plug] = [Bezel::Plug::Accepts.new(["json"])]
routes do
scope "/", pipeline: :browser do
get "/", Shop::PageController, :home, as: "home"
resources "/users", Shop::UsersController do
resources "/posts", Shop::PostsController, only: [:index, :show]
end
end
scope "/api", pipeline: :api, as: "api" do
resources "/users", Shop::API::UsersController, except: [:new, :edit]
end
end
end
Shop::Router.user_path(id: 5) # => "/users/5"
Shop::Router.user_post_path(user_id: 5, id: 2) # => "/users/5/posts/2"
- A route whose controller lacks its action, a pipeline the router does not define, a controller plug that is not a method, and a route helper given a wrong keyword are compile errors.
resourcesmakes the seven routes (index,new,create,show,edit,updatewithPATCHandPUT,delete),only:/except:choose, and a block nests routes under/{user_id}.- Matching is the standard
HTTP::Router's: the most specific pattern,HEADbyGET,405withAllowfor another method's path.
Views are Quarz code: Bezel.html gives a builder with a method per
HTML element, every text and attribute escaped, attributes as typed
keywords (class_name: is the class attribute, since class cannot
name a parameter; class: waits for quarz-lang/quarz#382). A layout is
a function taking the inner HTML; a component includes
Bezel::Component; a form over a changeset shows its values and errors:
module UserView
def self.edit(conn: Bezel::Conn, cs: Rotor::Changeset[User]) -> Bezel::Html
Bezel.html(conn) do |h|
h.h1("Edit #{cs.data.name}")
h.form_for(cs, action: Shop::Router.user_path(id: cs.data.id), method: :patch) do |f|
f.label(:email)
f.email_field(:email)
f.errors(:email)
f.submit("Save")
end
h.link("Delete", to: Shop::Router.user_path(id: cs.data.id), method: :delete, confirm: "Sure?")
end
end
end
- A misspelt attribute or one of the wrong type is a compile error;
attrs:passes others (event handlerson...are refused),data:andaria:thedata-*andaria-*ones. - URL attributes, typed or given in
attrs:(andsrcset, a meta refresh), refusejavascript:and other schemes than http, https, mailto and tel (relative URLs are fine);srcdocis refused;<script>and<style>refuse content that could end them. Astyleis escaped but not checked: do not build one from input. - Forms carry the CSRF token (from
Bezel.html(conn), once the CSRF plug sets it) and_method, name their fieldsuser[email], and show the input as typed for a field with errors.link'sconfirm:writesdata-confirmfor the application's script.
Templates are HTML with Quarz expressions (*.html.bz), compiled ahead
of time into DSL code (Bezel::Templates; bezel templates writes
src/views/generated/, committed, with each template's digest in the
header). Nothing is interpreted at run time and the generated code is
type-checked and escaped as hand-written DSL code is:
{@args conn: Bezel::Conn, users: Array[User], banner: String}
<h1 class="title">Users</h1>
<ul :if={!users.empty?}>
<li :for={u <- users}><a href={Shop::Router.user_path(id: u.id)}>{u.name}</a></li>
</ul>
<.card title="Help">{users.size} users.<:footer>{raw(banner)}</:footer></.card>
{expr}writes a value escaped (aBezel::Htmlas it is, nil as nothing);{raw(expr)}, likeBezel::Html.rawin the code, writes a string unescaped, and nothing else does.:for={pattern <- expr}and:if={expr}repeat or guard an element;<.card>is a component (Card.new(title: "Help"), the content itscontent),<:footer>a slot (afooter:argument, aBezel::Html).- Code in braces is one expression the compiler can delimit as Quarz
will:
/and%are operators with a space on each side (a.size / 2) or start a literal after(,[, a comma, the opening brace, or a spaced=~,=,=>,&&,||or keyword argument (gsub(/\s/, ""),s =~ /x/); no#comments or heredocs. - An unknown element or attribute, a boolean attribute given a string,
an unclosed tag: an error naming the template's line.
<!-- -->is a comment of the template, left out of the page.
A browser pipeline adds sessions and protection:
def self.browser -> Array[Bezel::Plug]
[Bezel::Plug::HostAuthorization.new(["shop.example"]),
Bezel::Plug::SecureHeaders.new, # first: error pages get them too
Bezel::Plug::Session.new(key: "_shop"), # an encrypted cookie (AES-256-GCM)
Bezel::Plug::Flash.new,
Bezel::Plug::CSRF.new] # forms from Bezel.html(conn) carry the token
end
- The session holds strings (
conn.put_session("user_id", "7"),conn.session["user_id"]), encrypted under a key derived fromSECRET_KEY_BASE; its cookie isHttpOnly,SameSite=Lax, andSecureoutsidedev. A cookie that does not decrypt is an empty session. - The flash lives one request:
conn.put_flash(:info, "Saved")before a redirect,conn.flash[:info]after it. - CSRF: unsafe methods need the session's token (masked per request) in
_csrf_tokenorX-CSRF-Token, and a request from another origin (Origin,Sec-Fetch-Site) is refused. Bezel::Password.hash/verify?/needs_rehash?(argon2id,Crypto::Argon2, with the cost ofSettings'spassword_params:) andBezel::Token.sign/verify(HMAC, expiring, bound to a purpose, and to afingerprintsuch as the password hash so that a reset link stops working once used) for links sent by mail.conn.session.renewafter a login drops the CSRF secret;dropthenputstarts a new session.
An application is tested through its endpoint, in the test's task,
without sockets (Bezel::Testing):
res = Bezel::Testing.get(Shop::Endpoint, "/users")
page = Bezel::Testing.html_response(res, 200) # a parsed page
page.has_element?("form[action=\"/users\"] input[name=\"user[email]\"]")
page.has_text?("Users")
c = Bezel::Testing::Client.new(Shop::Endpoint, session_key: "_shop") # keeps cookies
c.login_as({"user_id" => "7"}) # the session cookie, as a login writes it
c.post("/users", params: {"user[email]" => "ann@example.com"})
c.flash[:info] # => "Welcome", for the next request
c.follow_redirect # which shows it (and takes it)
Bezel::Testing.json_response(c.get("/api/users"), 200)
Requests in tests skip the CSRF check (a flag of the test application,
refused in prod); Bezel::Testing.app(skip_csrf: false) tests it.
The bezel command
cli/ is the bezel executable (cd cli && quarz build -o bezel, then
put it on the PATH):
bezel new shop # an application: endpoint, router, a page, layout, test, CI
cd shop
bezel server # serves http://localhost:4000, restarting on changes
bezel gen controller about index team
bezel gen model tag name:string
bezel gen migration add_index_to_tags
bezel gen job send_welcome user_id:integer # a divider job (src/jobs/), in Shop::Jobs.all
bezel jobs # runs the jobs: a divider worker (BEZEL_ENV=dev)
bezel gen resource post title:string body:text # model, migration, controller, views, routes
bezel templates [--check] # src/views/*/*.html.bz -> src/views/generated/ (Shop::PageView.home)
bezel migrations [--check] # src/migrations.qz, the list of src/migrations/
bezel routes # the routes of Shop::Router
bezel migrate | rollback | status # with DATABASE_URL (and BEZEL_ENV) set
bezel test
- The application's
src/main.qzcallsBezel.main(ARGV, Shop::Endpoint, settings, router:, migrations:): without arguments it serves;routes,migrate [--to V] [--allow-out-of-order] [--lock-timeout MS] [--table NAME],rollback [--steps N | --to V] [--lock-timeout MS] [--table NAME]andstatus [--table NAME]are whatbezelruns in it (quarz run -- routes); the migration commands need no SECRET_KEY_BASE. - Migrations are
src/migrations/<version>_<name>.qz, each aShop::Migrations::<Name> < Rotor::Migration;genwrites them with a UTC timestamp and rewrites the listShop::Migrations.all. bezel genadds its routes before the router's# bezel gen: routesline.- Background jobs are divider's:
a new application depends on it and runs its migrations with its own.
bezel gen job NAME [FIELD:TYPE...]writessrc/jobs/<name>.qz(aShop::Jobs::<Name> < Divider::Job, withdata Argsandjob_args Argswhen it has fields) and the listShop::Jobs.all(src/jobs.qz).src/main.qzruns them onquarz run -- jobs(whatbezel jobsruns; a compiled application:./shop jobs): a worker with a queue of 5 at once for each queue the jobs name; enqueue withShop::Jobs::SendWelcome.enqueue(repo: conn.repo, user_id: 42). bezel serverruns the application withBEZEL_ENV=dev(unless set) and its output on the terminal. It looks for changes every half second: a changed source (src/**/*.qz), template (*.html.bz) orCog.tomlcompiles the templates again and restarts the application (TERM, then KILL after 5 seconds). One that exits on its own (a compile error) is started again at the next change. Ctrl-C stops both.
License
MIT No Attribution: see LICENSE.
Functions
def html(conn: Bezel::Conn? = …, &block: (Bezel::HtmlBuilder) -> Void) -> Bezel::Html
HTML made by the block, on a fresh HtmlBuilder. With conn, the
builder's forms carry the connection's CSRF token (Conn#csrf_token).
def plug(&f: shareable (Bezel::Conn) -> Bezel::Conn) -> Bezel::Plug
A plug calling the block (which captures by value).
hi = Bezel.plug { |conn| conn.put_resp_header("x-hi", "1") }
hi.call(Bezel::Conn.build(HTTP::Server::Request.get("/"))).resp_headers["x-hi"] # => "1"
def start(endpoint: Type[Bezel::Endpoint], settings: Hash[Symbol, Bezel::Settings] = …, *, config: Bezel::Config? = …, grace: Duration = …) -> Void
Serves endpoint until the process is told to stop: loads the
configuration (Bezel::Config.load over the application's settings,
{dev: MyApp::Config.dev, ...}, its name taken from the endpoint's
namespace, MyApp::Endpoint giving my_app; or config when given),
opens the database's pool when there is a DATABASE_URL, and runs an
HTTP::Server with the endpoint's handler on HOST:PORT (HTTPS with
TLS_CERT and TLS_KEY).
SIGTERM shuts the server down gracefully: no new connections, the
requests in flight given grace to finish, then cancelled; SIGINT
(Ctrl-C) does the same with one second. A second signal during the
shutdown ends the process at once (status 1), for a request that
never waits. Returns once the server has stopped and the pool is
closed, with the default actions of SIGTERM and SIGINT back: traps the
application set for them before are dropped, not restored. A task's
configuration (Config.for_task in prod without SECRET_KEY_BASE) is
refused at once (Bezel::ConfigError): it signs no session.
# src/main.qz of an application
class Store < Bezel::Endpoint
def self.plugs -> Array[Bezel::Plug] = [Bezel::Plug::Logger.new, Bezel.plug { |conn| conn.text("hi") }]
end
Bezel.start(Store, {prod: Bezel::Settings.new(port: 8080)})
def server(endpoint: Type[Bezel::Endpoint], app: Bezel::App, *, port: Int? = …) -> HTTP::Server
The server Bezel.start runs, for endpoint in app, bound at once
(port 0 picks a free port: a test reads it from local_address),
not yet serving.
def app_name(endpoint: Type[Bezel::Endpoint]) -> String
The application's name for endpoint: its outermost namespace in
snake_case (MyApp::Endpoint is my_app).
def main(argv: Array[String], endpoint: Type[Bezel::Endpoint], settings: Hash[Symbol, Bezel::Settings] = …, *, router: Type[Bezel::Router]? = …, migrations: Array[Type[Rotor::Migration]] = …, config: Bezel::Config? = …, out: IO = …) -> Int
The application's program: what bezel runs in it (quarz run -- routes), and serving without arguments. Returns the exit status.
- (no arguments) or
server: serves, asBezel.start. routes: prints the router's routes, a line each (Route#to_s).migrate [--to VERSION],rollback [--steps N | --to VERSION],status: runsRotor::Migratorwithmigrationsover the database of DATABASE_URL, logging each migration to the standard output; options--allow-out-of-order,--lock-timeout MSand--table NAMEpass through.
A wrong command or option prints the usage on the standard error and
returns 2; a failure (no DATABASE_URL, a migration error) prints it
and returns 1. config replaces the configuration Config.load makes
of the environment and settings.
# src/main.qz of an application (`bezel new` writes one, with its
# router and `Shop::Migrations.all`)
class ShopEndpoint < Bezel::Endpoint
def self.plugs -> Array[Bezel::Plug] = [Bezel.plug { |conn| conn.text("hi") }]
end
exit(Bezel.main(ARGV, ShopEndpoint, {dev: Bezel::Settings.new(port: 4000)}))
Classes and modules
Bezel::AppWhat every request of an application shares: its configuration and, with a DATABASE_URL, the database's pool. Bezel.start makes it; a test makes one with App.new(config).Bezel::BadRequest400 Bad Request: a parameter missing or malformed, a body that does not parse.Bezel::ComponentA component: a class that renders a piece of HTML, given the HTML its caller's block made (Html.empty without a block), with HtmlBuilder#component.Bezel::ConfigAn application's settings, for one environment: what Config.load makes of the environment variables over the application's Settings.Bezel::ConfigErrorA setting is missing or wrong (Bezel::Config): raised when the configuration is loaded, before the server starts. Not an HTTP error.Bezel::ConnA connection: one request and the response being made for it, flowing through the plugs of an endpoint (Bezel::Plug). A Conn is a value: every change makes a new one (with, or the methods below), which a plug returns. A plug that answers (send_resp and friends) and stops the rest of the pipeline also calls halt.Bezel::ControllerThe base of an application's controllers. A controller's actions are its public methods taking and returning a Conn (but the helpers below), which the router calls for its routes. Its body declares its plugs, one plug (or skip) line each, or, without plugs of its own, writes controller; either generates dispatch over the actions, which runs the plugs before them:Bezel::ControllerPlugA controller's plug, as a plug line declares it, with the skips of later lines and subclasses' lines.Bezel::CookieOverflowA session that does not fit its cookie (Session::CookieStore): a 500, since the application put too much in it.Bezel::CookiesRequest cookies and Set-Cookie values.Bezel::ElementsThe HTML elements, as HtmlBuilder's methods: each writes its element with the attributes given (see HtmlBuilder).Bezel::EndpointAn application's entry: the plugs every request goes through, in order, the last of them usually the router. An endpoint is a class whose plugs the application writes; Bezel.start serves it, and a test calls it without a socket (MyApp::Endpoint.call(req)).Bezel::ErrorThe root of the errors an application raises to answer with a status: raise Bezel::NotFound in an action is a 404. The endpoint turns each into its status, with the error page (Bezel::ErrorPage); any other StandardError is a 500. Being an HTTP::Error, it is also answered with its status by a bare HTTP::Server.Bezel::ErrorPageThe answer to a request that raised: an HTTP::Error (Bezel::NotFound and the others) with its status, anything else with 500.Bezel::FlashThe flash: messages kept for the next request (put), and those the previous request left ([]). the flash plug (bezel#6) carries them in the session (kept is what goes there).Bezel::Forbidden403 Forbidden.Bezel::FormBuilderWrites the fields of a form over a rotor changeset (HtmlBuilder#form_for): each field is named <prefix>[<field>] (user[email]), with the id <prefix>_<field>, and shows the value the changeset holds: its change, else the model's; for a permitted field with errors and no change (a value that did not cast, or a blank required one), the input as given, so that it shows as typed. errors writes the changeset's errors on a field.Bezel::HtmlHTML that is safe to send: what Bezel.html builds, every text in it escaped. (Html.new(source) is Html.raw: the caller vouches.) A value: it is concatenated with +, put in another page with HtmlBuilder#append, and sent with Conn#html or a controller's render.Bezel::HtmlBuilderWrites HTML: an element per method (h.div, h.a, ...; see HtmlBuilder in src/html/elements.qz), each taking its content as a text (escaped) or a block, its attributes as keywords, and the global ones (id:, class_name: for class, style:, title:, lang:, dir:, hidden:, tabindex:, role:, data: for data-*, aria: for aria-*, and attrs: for any other). (class cannot name a parameter; class: waits for quarz-lang/quarz#382.) A String attribute is escaped, nil leaves it out, true writes a boolean attribute and false none; an attribute given twice is an ArgumentError. A URL attribute (href, src, action, formaction, cite, poster, a srcset's candidates, a meta refresh's url=, and those names and the other URL attributes of AttrList::URL_NAMES in attrs:) must be relative or of the schemes http, https, mailto or tel (data:image/ for images): another (javascript:) is an ArgumentError. srcdoc is refused. A style is escaped but not checked: do not build one from input.Bezel::InvalidCSRFTokenThe request did not prove that it came from the application's own pages: Plug::CSRF refused it (a 403).Bezel::KeysKeys derived from the application's SECRET_KEY_BASE, one per purpose (the session cookie's, the tokens'): PBKDF2-SHA256 of the secret with the purpose as the salt. Derived once per secret and purpose, then kept for the process.Bezel::LogEventWhat Bezel::Plug::Logger reports of one request.Bezel::NotAcceptable406 Not Acceptable: no format the client accepts.Bezel::NotFound404 Not Found.Bezel::ParamOne parameter's value: a string, a list (tags[]=a&tags[]=b, a JSON array) or a nested map (user[email]=x, a JSON object).Bezel::ParamsA request's parameters, merged: the query's, then the body's (a form or a JSON object, Bezel::Plug::Parsers), then the path's (the router's), a later one replacing an earlier one of the same name. Values are strings, lists and nested maps (Bezel::Param); the typed readers raise Bezel::BadRequest (a 400) for a parameter missing or malformed, so that an action reads what it needs in one call.Bezel::PasswordPassword hashes: argon2id (Crypto::Argon2), as PHC strings ($argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>) that name their parameters, with a random salt and a check in constant time.Bezel::PayloadTooLarge413 Content Too Large: a body over the parsers' max_body.Bezel::PipelineA plug running other plugs in order (a pipeline as one plug), halting when one of them does.Bezel::PlugA step of an endpoint's pipeline: takes the connection and returns it changed, as Elixir's Plug does. A plug that answers the request calls Conn#halt (or one of Conn#text, Conn#html, Conn#json, Conn#redirect, Conn#send_resp, which halt), and the plugs after it do not run.Bezel::PlugSkipA skip of a plug: before the actions only names (all when it and except are empty), but those except names.Bezel::RouteOne route of a router's table: requests of method matching pattern (as HTTP::Router reads patterns: /users/{id}) go through the pipelines, then to action of controller. name is the route's name, for its helpers (user gives user_path), or nil.Bezel::RouteBuilderWhat a router's routes block runs on: it collects the routes. Paths are joined to the enclosing scopes' (scope "/admin" then get "/users" is /admin/users), and route names to their prefixes.Bezel::RouterAn application's router: a plug (the endpoint's last, usually) that matches a request to a route of its routes statement, runs the route's pipelines, and calls the route's action on its controller (Controller#dispatch, which runs the controller's plugs first).Bezel::RunnerAn endpoint's handler: its plugs and the application, made once and shared by the server's connections.Bezel::SessionThe session's values, which Plug::Session loads from its store (Session::Store) and writes back when they changed; empty, and not loaded, without that plug. Values are strings (an application stores ids, not objects).Bezel::SettingsWhat an application's config/<env>.qz gives for one environment (MyApp::Config.dev returns one): every setting optional, the environment variables taking precedence over it. Secrets are not here: SECRET_KEY_BASE comes from the environment only.Bezel::TemplatesThe template compiler: HEEx-like templates (*.html.bz) made into Quarz code over the HTML DSL (Bezel.html), which bezel templates writes to src/views/generated/ and the application compiles as its own code. Nothing is interpreted at run time; the generated code is type-checked as any other, and escapes as the DSL does.Bezel::TestingTesting an application through its endpoint, in the test's own task, without sockets: requests built in memory go through every plug, the router and the actions, and the responses come back to be checked.Bezel::TimeoutError503 Service Unavailable: the request ran out of the time the Bezel::Plug::Timeout plug gives it.Bezel::TokenSigned, expiring tokens for links sent by mail (confirm an address, reset a password): data (an id), the purpose and the expiry, signed with HMAC-SHA256 under a key derived from SECRET_KEY_BASE. A token is good for its purpose only, until it expires; its data can be read by anyone holding it (it is signed, not encrypted).Bezel::Unauthorized401 Unauthorized.Bezel::UnsupportedMediaType415 Unsupported Media Type: a body of a type no parser takes.