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)})

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"

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

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>

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

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

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).

src/html.qz:64

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"

src/plug.qz:76

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)})

src/start.qz:27

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.

src/start.qz:66

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).

src/start.qz:74

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, as Bezel.start.
  • routes: prints the router's routes, a line each (Route#to_s).
  • migrate [--to VERSION], rollback [--steps N | --to VERSION], status: runs Rotor::Migrator with migrations over the database of DATABASE_URL, logging each migration to the standard output; options --allow-out-of-order, --lock-timeout MS and --table NAME pass 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)}))

src/tasks.qz:26

Classes and modules