howdy
gleam add howdy@1
import gleam/erlang/process
import howdy
import howdy/controller
pub fn main() -> Nil {
let hello =
controller.new("/")
|> controller.get("/", fn(ctx) { controller.text(ctx, "Howdy!") })
let assert Ok(_) =
howdy.new()
|> howdy.controller(hello)
|> howdy.start
process.sleep_forever()
}
The server defaults to 0.0.0.0:8787 (all IPv4 interfaces). Override either
setting in the app pipeline:
howdy.new()
|> howdy.controller(hello)
|> howdy.bind(to: "127.0.0.1")
|> howdy.listening(on: 8080)
|> howdy.start
howdy.start returns the server’s process and the howdy.Address it is
listening on, whose port is the real one even when configured with port 0.
Once listening, it prints a HOWDY ASCII banner, the framework version (howdy.version),
and the bound URL. Keep the calling process alive while serving requests.
For anything beyond a script, run the server under a supervisor as an OTP
application with howdy.supervised and howdy.start_application, so a crash
is restarted and SIGTERM lets open requests finish; see
docs/guides/supervision.djot.
Howdy runs on the ewe server, but your code never
needs to name it. For ewe options howdy.start does not offer, pass
howdy.handler(app) to ewe.new yourself.
HTTP/2 is always on. Add howdy.tls(cert: "priv/cert.pem", key: "priv/key.pem")
to serve HTTPS, which is also what lets browsers use HTTP/2. See
docs/guides/supervision.djot for running the
app as a release, and check a deployed instance with scripts/smoke.sh, which
probes HTTP/1.1, HTTP/2, forwarded headers and WebSocket origins.
Further documentation can be found at https://howdy.hexdocs.pm/.
Query strings
Use howdy/query to extract typed query parameters in a handler:
import gleam/json
import howdy/controller
import howdy/query
pub fn users_controller() {
controller.new("/users")
|> controller.get("/", fn(ctx) {
use search <- query.string_or(ctx, "search", default: "")
use page <- query.int_or(ctx, "page", default: 1)
use active <- query.bool_or(ctx, "active", default: True)
controller.json(ctx, json.object([
#("search", json.string(search)),
#("page", json.int(page)),
#("active", json.bool(active)),
]))
})
}
string,int, andboolrequire the parameter to be present.optional_string,optional_int, andoptional_boolreturnOptionvalues.string_or,int_or, andbool_oruse a default only for missing parameters.stringscollects repeated values in request order, or returns[]if absent.
Invalid input returns a JSON 400 response and stops the handler. For example,
query.int(ctx, "page") returns {"error":"missing query parameter page"}
when absent, or {"error":"query parameter page must be an integer"} for
?page=hello. Defaults never hide invalid values.
Empty strings are preserved. Booleans accept only true and false. Singular
helpers reject duplicate keys, and all helpers reject invalid query encoding.
Use howdy/validate for constraints such as a minimum page number.
Forms
Use howdy/form for HTML forms, posted as application/x-www-form-urlencoded
or as multipart/form-data with file uploads. form.read reads the body once
into a Form; fields are then taken from that value. Field problems come back as howdy/validate errors rather than
responses, so a page can be rendered again with its errors and the values the
user typed:
import howdy/controller
import howdy/form.{type Form}
import howdy/validate
fn signup(fields: Form) -> validate.Result(Signup) {
use email <- form.string(fields, "email", [validate.trim(), validate.email()])
use age <- form.int(fields, "age", [validate.min(13)])
use nickname <- form.optional_string(fields, "nickname", [validate.trim()])
use newsletter <- form.checkbox(fields, "newsletter")
validate.ok(Signup(email:, age:, nickname:, newsletter:))
}
pub fn signup_controller() {
controller.new("/signup")
|> controller.post("/", fn(ctx) {
use fields <- form.read(ctx)
case signup(fields) {
Ok(signup) -> welcome(ctx, signup)
Error(errors) ->
controller.html(ctx, signup_page(fields, errors))
|> controller.with_status(422)
}
})
}
stringandintrequire the field; a missing one reportsis required.optional_stringandoptional_intreturnOptionvalues. Every field is checked, so all errors are reported together.checkboxisTruewhen the field was submitted at all, since browsers leave unticked checkboxes out.stringscollects repeated values such as a multi-select, in order.- When rendering the page again,
form.value(fields, "email")gives the submitted value or"", andform.error(errors, "email")the field’s problem, if any, whichproblem.messageputs into words in any language. Escape submitted values like any other user input. get,allandfieldsgive raw access.form.validated(ctx, signup)answers a failed validation with the JSON422ofbody.validated, for endpoints that are not pages.
Browsers submit an empty input as name=. string keeps the empty string, so
pair it with validate.not_empty(); int treats it as missing and the
optional_* helpers as None. Singular helpers reject repeated fields.
read returns 415 unless the content type is
application/x-www-form-urlencoded, and 400 for a body that is badly encoded
or over one mebibyte; read_with_limit changes the limit. Multipart forms,
and so file uploads, are not supported yet. A local variable named form
would shadow the module, so call the value something else, such as fields.
Cross-site request forgery
A page on another site can make a browser post a form to your application, and the browser attaches the user’s cookies, because it decides by destination rather than by who asked. Form posts need no CORS preflight, so nothing stops the request from arriving. An attacker cannot read the response, so this is about writes, not disclosure.
Two defences already apply. cookie.defaults() sets SameSite=Lax, which
keeps cookies off cross-site writes, and howdy_auth
requires a matching Origin on cookie-authenticated writes, both on its own
routes and on any route behind its guard. An application that uses that guard
for every write is already protected.
For applications that manage their own session cookies, howdy/csrf applies
the same check:
import howdy/csrf
howdy.new()
|> howdy.middleware(csrf.middleware(csrf.new(["https://example.com"])))
GET, HEAD and OPTIONS pass through, so pages and preflights still work.
Every other method must carry exactly one origin header matching one of the
origins, or it gets 403 before the handler runs. null, a different scheme,
a different port and a host that merely starts with an allowed one are all
rejected. Origins must be scheme://host with an optional port; anything else
panics when built.
Some proxies and privacy tools strip origin. When it is missing, a single
sec-fetch-site: same-origin header is accepted in its place: browsers set it
themselves and scripts cannot forge it. An origin that is present always
decides, and a request with neither header fails closed.
The protection is header-based by design and uses no tokens. Every current
browser sends these headers. Go’s standard library protects against CSRF the
same way, with http.CrossOriginProtection. OWASP’s CSRF cheat sheet treats
Sec-Fetch-Site with an origin fallback as a primary defence, and its
Application Security Verification Standard (4.0, requirement 13.2.3) lists
origin header checks among the accepted protections.
Because it rejects requests with neither header, it also rejects non-browser clients. Where those authenticate with a bearer token rather than a cookie, a hostile page cannot make a browser send one on their behalf, so exempt them:
csrf.new(["https://example.com"])
|> csrf.exempt(fn(ctx) {
case request.get_header(ctx.request, "authorization") {
Ok("Bearer " <> _) -> True
_ -> False
}
})
csrf.check(ctx, origins) runs the same check inside a guard or a single
handler. Keep SameSite cookies as well, since the two fail in different
ways, and never write on a GET route, because those are not checked.
Cookies
Read cookies with use and write them through response pipelines:
import howdy/controller
import howdy/cookie
pub fn preferences_controller() {
controller.new("/preferences")
|> controller.get("/", fn(ctx) {
use theme <- cookie.string_or(ctx, "theme", default: "system")
controller.text(ctx, theme)
})
|> controller.post("/dark", fn(ctx) {
let options =
cookie.defaults()
|> cookie.max_age(60 * 60 * 24 * 30)
|> cookie.same_site(cookie.Lax)
controller.text(ctx, "Preference saved")
|> cookie.set("theme", "dark", options)
})
|> controller.delete("/", fn(ctx) {
controller.text(ctx, "Preference cleared")
|> cookie.delete("theme", cookie.defaults())
})
}
stringrequires a cookie; absence returns400with{"error":"missing cookie theme"}for a cookie namedtheme.optional_stringreturnsOption(String);string_ordefaults only when absent.- Empty values remain empty. Duplicate names and invalid percent encoding return
JSON
400responses and stop the handler. The HTTP parser ignores malformed pairs. - Values are percent-encoded when written and decoded when read, preserving spaces, Unicode, punctuation, and literal percent signs. External cookie writers should use the same encoding convention.
setanddeletepreserve existing headers, including otherSet-Cookieheaders.
Defaults enable Secure, HttpOnly, SameSite=Lax, and Path=/, with no domain
attribute or persistent lifetime. Configure them using max_age (seconds),
same_site (Lax, Strict, or None), path, domain, secure, and http_only.
For local HTTP development use cookie.defaults() |> cookie.secure(False).
SameSite=None requires Secure. Invalid names, paths, domains, or incompatible
options panic as configuration errors; values are safely encoded by set.
To delete a cookie, pass options with the same path and domain used when setting
it. Deletion writes an empty value, Max-Age=0, and an expiry in the past, following
the browser cookie deletion rules.
These helpers read and write cookies; they do not sign them or validate sessions.
For authentication, let a guard validate the session and return 401 when needed.
Flash messages
howdy/flash carries a short message across a redirect, such as “Settings saved”
after a form post, in a signed cookie that is cleared once shown:
let flash = flash.new(secret: secret_key_base)
// The form handler redirects with a message...
controller.status(ctx, 303)
|> response.set_header("location", "/settings")
|> flash.put(flash, flash.Success, "Settings saved")
// ...and the page it lands on shows it once, as toasts with howdy_ui.
use messages <- flash.take(ctx, flash)
let w = words.english()
toast.region(w, [], toast.flashed(w, messages))
Authentication and authorization
howdy_auth is an optional package with email-token, opt-in password
and built-in Google login and registration, browser cookie sessions, bearer-token API routes, and optional
starter pages. Custom pages can call the same JSON endpoints or use the headless
operations directly. Separate role-based authorization supports simple role
checks and permissions scoped to an application or organization.
The package accepts a configured Gloo Repo (PostgreSQL or SQLite), owns its
schema, and ships explicit, checksummed migrations.
howdy_database provides those migrations and the
portable transactions beneath them, and applications can use it for their own
tables, with or without auth.
See the runnable example and the package documentation
for setup and current scope; enterprise federation is not yet
implemented.
Static files
howdy/static serves the files in a directory. Mount it like any controller:
import howdy/static
howdy.new()
|> howdy.controller(api_controller())
|> howdy.controller(static.serve("/", from: "priv/public"))
GET /css/site.css answers with priv/public/css/site.css and a content type
from its extension. GET / and GET /docs/ answer with the index.html in
that directory. Only GET and HEAD are handled, directories are never
listed, and paths that would escape the root get a 404. On a live
connection files are sent by ewe without being held in memory.
The public directory is for trusted deployment assets. The root itself and
every requested component must be real files/directories: symbolic links,
including linked indexes and fallbacks, return 404. Index and fallback names
must be relative paths without .., drive prefixes or backslashes; invalid
configuration panics when built. Keep this tree inaccessible to untrusted
writers. These path checks do not protect against concurrent filesystem swaps;
do not use this controller for an attacker-writable upload directory.
Build it step by step to change the defaults:
static.new(from: "priv/public")
|> static.at("/assets") // mount under a prefix, default "/"
|> static.index("home.html") // directory index, default "index.html"
|> static.max_age(seconds: 86_400) // add a cache-control header
|> static.fallback("index.html") // single-page apps: unknown paths get this file
|> static.build
The result is an ordinary controller, so controller.middleware and the rest
apply. A controller at "/" matches every path, so add it last, and mount it
under a prefix when the app has a version group: the group only answers paths
no controller matched.
To send one file from a handler, use static.file. It answers 404 in the
standard error shape when the file is missing:
controller.get("/invoice/:id", fn(ctx) {
use id <- param.int(ctx, "id")
static.file(ctx, "priv/invoices/" <> int.to_string(id) <> ".pdf")
})
Request logging
Use howdy/logger to log the request method, path and response status with
the logging library at Info level, for example GET /user/2 -> 200:
import howdy
import howdy/logger
import logging
logging.configure()
howdy.new()
|> howdy.middleware(logger.log)
Configure logging once at application startup. Add the logger before other middleware to include their rejected requests and CORS preflight responses. The response is returned unchanged. Query strings, headers and bodies are omitted. Unmatched routes bypass middleware, so their 404 responses are not logged; handlers that panic do not produce a request log either.
Health checks
howdy.health_check answers load balancers, container platforms and uptime
monitors at GET /up, before routing and middleware, and without a log line
or trace for each probe. Checks run concurrently within a timeout, and any
failure turns the 200 into a 503:
import howdy
import howdy/database
import howdy/health
howdy.new()
|> howdy.health_check(
health.new() |> health.check("database", database.health_check(db)),
)
See docs/guides/health-checks.djot for liveness and readiness probes.
CORS
Browsers only let a page read cross-origin responses that say so. Attach
howdy/cors as the outermost middleware to add those headers and answer
preflight requests:
import gleam/http
import howdy
import howdy/cors
let policy =
cors.new()
|> cors.allow_origins(["https://app.example.com", "http://localhost:5173"])
|> cors.allow_methods([http.Get, http.Post, http.Delete])
|> cors.allow_headers(["content-type", "authorization"])
|> cors.expose_headers(["x-request-id"])
|> cors.allow_credentials()
|> cors.max_age(600)
howdy.new()
|> howdy.middleware(cors.middleware(policy))
|> howdy.middleware(require_api_key)
|> howdy.controller(user.controller())
cors.new()allows no origins. Add them withallow_origins,allow_any_origin, orallow_origins_matchingwith your own function.cors.allow_all()is a ready-made policy for public APIs: any origin and any requested header, without credentials.- Origins are
scheme://hostwith an optional port, such ashttp://localhost:5173, and match case-insensitively. A path, trailing slash or*panics as a configuration error. Listnullto allow sandboxed pages. - Methods default to
GET,HEAD,POST,PUT,PATCHandDELETE. No request headers beyond the browser safelist are allowed until you name them withallow_headersor echo whatever was asked for withallow_any_header. allow_credentialscannot be combined withallow_any_origin; building the middleware panics. Useallow_origins_matching(fn(_) { True })if you really mean it.
A preflight is an OPTIONS request carrying origin and
access-control-request-method. The middleware answers it with 204 and never
calls the handler, so put CORS before authentication or rate limiting, which
would otherwise reject the credential-less preflight. Other requests with an
origin run as normal and the response gains the CORS headers, including error
responses, so the browser can show a 422 instead of a CORS failure. Requests
without an origin pass through without CORS headers.
The router answers OPTIONS for any path that has routes, even without an
explicit OPTIONS route: a 204 with an allow header, run through the
controller’s middleware but not its guard. Declare an OPTIONS route yourself
to override that. Paths with no routes are 404 before any middleware runs, so
they carry no CORS headers.
Responses that depend on the origin carry vary: origin, including requests
without an Origin header. This also applies to wildcard policies because they
omit CORS headers when Origin is absent. Existing Vary fields are preserved,
including version-group headers and Vary: *.
Versioning
Group controllers by API version and let newer versions fall back to older ones for anything they do not override:
import howdy
import howdy/version
let api =
version.new(version.path())
|> version.default("v1")
|> version.add("v1", [user_v1.controller(), order.controller()])
|> version.add("v2", [user_v2.controller()])
howdy.new()
|> howdy.controller(status.controller())
|> howdy.versions(api)
|> howdy.handler
GET /v2/usershits thev2user controller.GET /v2/ordersfalls back to thev1order controller. Fallback is per route, soPOST /v2/usersalso falls back ifv2only declaresGET. Fallback walks versions in reverse declaration order.GET /usersuses the default version. Without a default a missing version is404for the path strategy and400for the others.version.no_fallbackmakes every version answer only its own routes.- Unversioned controllers are matched first and never see a version.
- Handlers and middleware read
ctx.version, anOption(String). The original request path is preserved, including the version segment.
One strategy applies per app. Pick it with the argument to version.new:
version.path()reads/v2/...and strips the segment before routing. This is the default choice: URLs are shareable, cacheable and easy to test.version.header("x-api-version")reads a request header.version.accept("vnd.howdy")readsapplication/vnd.howdy.v2+jsonfrom theacceptheader.version.custom(fn)runs your own function from the request to anOption(String).
Header and accept strategies answer 400 with {"error":"unknown API version v9"}
for undeclared versions and add a vary header to every response from the group.
Mixing strategies with precedence rules is deliberately unsupported; write a custom
resolver if you need it. Versions are opaque strings ordered by declaration, so date
based names such as 2026-09-14 work as well as v1.
Duplicate version names, a default that was never added, and mounting two groups on one app panic when the app is built.
WebSockets
A WebSocket is a GET route that ends in websocket.upgrade, so it goes
through the same middleware, guards and versioning as any other route. Browsers
cannot set headers on the handshake, so socket guards usually read a cookie or
a query parameter.
import howdy/websocket
import howdy/websocket/channel
controller.new("chat")
|> controller.get("/:room", fn(ctx) {
use room <- param.string(ctx, "room")
websocket.new(fn(socket) {
channel.join(socket, "room:" <> room)
room
})
|> websocket.on_text(fn(_socket, room, text) {
channel.broadcast_text("room:" <> room, text)
websocket.continue(room)
})
|> websocket.on_close(fn(_socket, _room) { Nil })
|> websocket.upgrade(ctx)
})
websocket.new(on_open)returns the starting state. Addon_text,on_binaryoron_json(decoder, ...)for frames from the client, andon_messagefor messages other processes send towebsocket.subject(socket). Every callback returnswebsocket.continue(state)orwebsocket.close(socket, code, reason).- Send with
send_text,send_binaryorsend_jsonon the socket handle. howdy/websocket/channelgroups sockets under a topic.channel.joinandchannel.leavemanage membership;channel.broadcast_textandchannel.broadcast_jsonreach every socket on a topic from anywhere in the program, including plain HTTP handlers. Topics are Erlangpggroups, so a socket that closes or crashes is removed automatically.- In
howdy/testingthere is no connection to upgrade, so a socket route answers426there. Test the guards and parameters in front of it as usual.
See examples/chat for a working chat room.
Socket upgrades check browser Origin before opening the connection. The
default accepts only the request’s own HTTP(S) scheme, hostname and port;
malformed, duplicate and null origins receive 403. Behind a reverse proxy
that terminates TLS, the scheme comes from X-Forwarded-Proto and the host
from X-Forwarded-Host when the proxy sends them, so live pages work behind
Traefik, Caddy, nginx and most hosting platforms without configuration. A
hostile page cannot set those headers on a browser’s handshake, so the check
is no weaker for it; client addresses still ignore them. Non-browser clients
without Origin remain supported; add websocket.require_origin for a
browser-only endpoint. Origin checks complement authentication, not replace it.
For pages served from another origin, such as a separate frontend, configure exact public origins on the socket builder:
websocket.new(on_open)
|> websocket.allow_origins(["https://app.example.com"])
|> websocket.require_origin
|> websocket.upgrade(ctx)
For live components, apply these options to live.socket(app, args) or live.socket_shared(runtime) before
websocket.upgrade(ctx); the direct live.serve helpers use the same-origin
default.
Server-sent events
howdy/sse keeps a GET response open and pushes events to the browser’s
EventSource. Like a socket it is an ordinary route, runs in its own process
and is reached from anywhere through topics or its subject.
import howdy/sse
controller.new("orders")
|> controller.get("/events", fn(ctx) {
sse.new(fn(stream) {
sse.join(stream, "orders")
Nil
})
|> sse.start(ctx)
})
// Anywhere else in the program:
sse.broadcast("orders", sse.json(order_json) |> sse.name("order.created"))
sse.new(on_open)returns the starting state;on_messagehandles what other processes send tosse.subject(stream), andon_closeruns however the stream ends. Callbacks returnsse.continue(state)orsse.stop().- Build events with
sse.eventorsse.json, thenname,idandretry.sse.last_event_id(stream)is the ID a reconnecting browser last saw. sse.join,sse.leaveandsse.broadcastwork like WebSocket channels, across nodes, but on their own topics.- A comment is sent every 15 seconds so proxies keep idle streams open; change
it with
sse.heartbeator turn it off withsse.no_heartbeat. testing.open_events(res)runs a stream in a test, withtesting.next_eventandtesting.close_events.
For one long body sent a piece at a time, such as a CSV export,
stream.body from howdy/stream writes it in chunks as it is produced.
Pages and live components
Web pages, themes and Lustre server components live in a separate package,
howdy_ui, so howdy itself stays free of Lustre and Sketch.
import howdy/ui
import howdy/ui/live
import howdy/ui/page
controller.new("/")
|> controller.get("/", fn(ctx) {
use theme <- cookie.string_or(ctx, "theme", default: "system")
page.new("Counter")
|> page.theme(theme)
|> page.live
|> page.body([ui.h1("Counter"), live.mount("/counter")])
|> page.respond(ctx)
})
|> controller.get("/counter", fn(ctx) { live.serve(ctx, counter.app(), with: 0) })
Themes are records that compile to CSS variables, and every component reads
them, so switching light and dark is one attribute change. Components can be
copied into your project to edit with gleam run -m howdy/ui add button. The CSS is
embedded in each page by default, served as one file in development with
ui.stylesheet, or written to a minified static file for publishing with
howdy/ui/export. See examples/live for a working page.
Rich text
howdy/ui/rich_text is an editor for formatted writing, with mentions and
file attachments. It posts a JSON document; decoding it is the safety
check, and document.view draws it through Lustre, so nothing the browser
sends is ever trusted as HTML:
rich_text.new(name: "body")
|> rich_text.value(post.body)
|> rich_text.mentions(trigger: "@", kind: "person", search: "/people/search")
|> rich_text.view
use body <- rich_text.field(fields, "body", [rich_text.not_empty()])
Mount ui.rich_text(), which serves the editor’s browser code. See
docs/ui/rich-text.djot.
Islands
An island is a Lustre app in a module under an islands directory,
rendered on the server and then run in the browser, for the parts of a
page that need to answer without a round trip:
// src/my_app/islands/counter.gleam
pub fn island() {
island.simple(init:, update:, view:, flags: decode.int, encode: json.int)
}
// in a page
page.body([island.mount(counter.island, with: 5)])
Mount ui.islands(), which builds the islands on demand in development.
For a release, gleam run -m howdy/ui export bundles them and exports the
app. See docs/ui/islands.djot.
Calling other services
howdy_remote is an optional package for typed calls
between services. Define a procedure once in a module both services share,
serve it from the service that owns the data, and call it from anywhere:
pub fn get_user() -> remote.Procedure(Int, User) {
remote.procedure("users.get", input: codec.int(), output: user_codec())
}
// In the users service. Handlers return the usual `service.Result`.
remote.server()
|> remote.handle(users_api.get_user(), user_service.find)
|> remote.start
// In any other service.
remote.call(remote.cluster(), users_api.get_user(), id, timeout: 5000)
|> remote.respond(ctx, user.to_json)
Calls travel over Erlang distribution between trusted nodes, or over HTTP
with a bearer token across a trust boundary. The call site stays the same;
only the target changes. See examples/remote for two services calling each other.
Hot reload
howdy_dev is a dev dependency that rebuilds and
reloads the app when a file under src changes, and refreshes open pages.
Put an entry point in dev/ and run it with gleam dev:
import howdy/dev
import my_app
pub fn main() {
let assert Ok(_) = dev.start(my_app.app)
process.sleep_forever()
}
It polls files rather than using a native watcher, so it works the same on
macOS. Nothing from it reaches a gleam export erlang-shipment.
Development admin
howdy_admin is a dev dependency that mounts an admin
area at /_howdy from the same dev/ entry point: the tables of the app’s
database in a grid that follows changes, its users and groups, and its
roles and permissions, with a button to sign in to the app as any user. The app registers what it has,
since Gleam cannot discover packages at runtime:
dev.start(fn() {
my_app.app(db, identity)
|> admin.mount(
admin.new() |> admin.auth(identity) |> admin.authorization(permissions),
)
})
See examples/admin for a working app.
Telemetry
howdy_telemetry switches on OpenTelemetry. Howdy
opens a span for every request, query, remote call, email and sign-in with
howdy/trace, but they go nowhere until the app starts telemetry, so apps
that do not want it pay next to nothing. Send traces to any OTLP collector
in production, or record them in memory for the admin to show:
let assert Ok(Nil) =
telemetry.new("acme-web")
|> telemetry.otlp
|> telemetry.start
howdy_mail writes email with
smail templates and sends it over SMTP, Resend,
SendGrid or an adapter of your own. In development an outbox keeps the mail
instead, and the admin shows it as it arrives, along with previews of every
template rendered from sample data:
let mailer =
mail.mailer(smtp.adapter(smtp_config))
|> mail.default_from(mail.named("Acme", "hello@acme.test"))
mail.message()
|> mail.to([mail.address(user.email)])
|> mail.subject("Welcome to Acme")
|> mail.template(welcome_email(user))
|> mail.send(mailer, _)
howdy/auth/emails gives howdy_auth a ready-made email for every purpose.
File storage
howdy_storage keeps files, such as uploads from
howdy/form, behind one API: an S3-compatible bucket (AWS S3, Cloudflare R2,
Tigris, MinIO, Garage and others), a local folder for development and single
servers, or memory for tests. Browsers fetch files through signed URLs that
expire, so files never pass through the app on their way out:
let files = s3.adapter(s3_config)
let key = storage.new_key(prefix: "avatars", file_name: upload.file_name)
use Nil <- result.try(
storage.put_file(files, key, upload.path, content_type: "image/png"),
)
storage.url(files, key, expires_in: 3600)
Internationalisation
Core’s howdy/locale works out which locale a request wants, from the URL, a
cookie, a saved preference or accept-language.
howdy_i18n turns ICU MessageFormat catalogues into
typed Gleam functions, and writes numbers, money, dates, relative times and
lists from CLDR data. Validation problems, Howdy UI and Howdy Auth follow the
same locale:
let l = locale.detect(ctx, locales)
let t = i18n.new(locale.to_string(l))
messages.cart_items(t, count: 3) // "3 Artikel"
format.currency(t, minor: 1999, code: "EUR") // "19,99 €"
problem.message(problem.TooShort(8), problem.translated(l))
// "muss mindestens 8 Zeichen lang sein"
Feature flags
howdy_flags keeps feature flags defined in code and
switched at runtime: a kill switch, users, organizations and groups a flag is
allowed or blocked for, and a rollout to a share of users that only ever adds
people as it grows, raised by hand or step by step on a schedule. Checks read
memory, never the store, and every change is recorded and can be undone.
Settings live in the app’s database by default, in memory, or anywhere a
store can load them from. In development the admin shows them under
Flags; on your own servers, howdy/flags/cli manages them from a task
module:
let assert Ok(store) = flags_database.store(db)
let assert Ok(features) =
flags.new(store) |> flags.register([new_checkout()]) |> flags.start
flags.enabled(features, new_checkout(), for: flags.user(user.id))
Background jobs
howdy_jobs keeps work to be done later in the app’s
database, on PostgreSQL or SQLite, and does it outside the request. A job is
queued inside the transaction that makes it necessary, so it exists only if
that commits; it is retried with a growing delay when it fails, and can be
unique, delayed or run on a cron schedule, once per time however many nodes
run it. In development the admin shows the queues and every job under
Jobs; on your own servers, howdy/jobs/cli manages them from a task
module:
pub fn welcome_email() -> jobs.Job(String) {
jobs.define("welcome_email", encode: json.string, decoder: decode.string)
}
use conn <- database.transaction(db)
use user <- result.try(users.insert(conn, form))
jobs.new(welcome_email(), user.id) |> jobs.insert(into: conn)
jobs.runner(db)
|> jobs.handle(welcome_email(), fn(user_id, _attempt) {
send_welcome(user_id)
})
|> jobs.supervised
Notifications
howdy_notifications tells users what happened: each
notification is kept in an inbox in the app’s database until read, pushed
straight away to every page the user has open, and emailed or sent as a browser
notification (Web Push) when the kind and the user’s preferences ask for it.
Email can wait to see whether the notification is read in the app first, or be
gathered into a digest; background jobs send it and retry. howdy_ui’s
bell shows the unread count and the newest, live:
notifications.draft("comment.created", "Ana commented on your post")
|> notifications.link("/posts/42")
|> notifications.send(notifier, to: post.author_id)
bell.new(id: "inbox", source: "/notifications") |> bell.unread(count) |> bell.view
Caching
howdy/conditional answers 304 Not Modified when the browser already has
the current version of a response, and 412 when a write was based on an
old one. Describe the version, and the rest of the handler only runs when
the client needs the body:
use <- conditional.when(ctx, conditional.etag(["note", id, version]))
controller.json(ctx, note_to_json(note))
howdy_cache keeps the results of expensive work, in memory or
the app’s database, and drops them by tag when the data changes. Errors are
never cached, invalidations inside a transaction wait for it to end, and a
failing store falls back to computing:
let entry = cache.entry("dashboard:" <> team, ttl: 300) |> cache.tags(["projects"])
use <- cache.fetch(app_cache, entry, dashboard_codec())
build_dashboard(db, team)
Testing
howdy/testing runs requests through an app without starting a server. The
whole pipeline runs: routing, middleware, guards, versioning, body decoding and
validation. Keep the app separate from main so tests can build it too:
import gleam/json
import howdy/problem
import howdy/service.{FieldError}
import howdy/testing
import my_app
pub fn create_user_test() {
let res =
testing.post("/user", json.object([#("name", json.string("Ada"))]))
|> testing.header("x-api-key", "secret")
|> testing.send(my_app.app())
assert res.status == 201
assert testing.json(res, user.decoder()) == Ok(User(id: 1, name: "Ada"))
}
pub fn blank_name_is_rejected_test() {
let res =
testing.post("/user", json.object([#("name", json.string(""))]))
|> testing.send(my_app.app())
assert res.status == 422
assert testing.field_errors(res)
== Ok([FieldError("name", problem.Blank)])
}
- Build requests with
get,delete, orpost,putandpatchtaking JSON.post_formtakes form fields.request(method, path)covers the rest. A query string in the path is kept. - Adjust them with
header,query,cookie,json_body,form_body,text_body,bytes_bodyandfrom_ip, which makesrate_limit.by_ipcount the request. Requests are plaingleam/http/requestvalues, so its functions work too. - Read responses with
text,bytes,jsonwith a decoder,errorfor the message in{"error": "..."},field_errorsfor a422, andcookiesfor the name and value pairs the response sets.
Both example applications have a test suite written this way.
Development
gleam run # Run the project
gleam test # Run the tests
Performance checks
howdy.start compiles route and middleware tables once. When integrating with
Ewe directly, construct howdy.handler(app) once and reuse the returned handler.
Declaration order, wildcard fallback and version fallback remain unchanged.
Route dispatch is indexed, so its cost does not grow with the number of
controllers; a 512-controller app dispatches as fast as a one-controller app.
Run gleam run -m routing_benchmark for dispatch throughput and heap allocation,
and gleam run -m rate_limit_benchmark for identity-cardinality scaling.
CI tests every package and every example in examples/, and runs both
benchmarks. Generated build/ directories are ignored throughout the repository.
Reserved optional-package modules
The core package reserves howdy/auth, every howdy/auth/* module and
howdy/authorization for the optional howdy_auth package, and
howdy/database and howdy/migration for howdy_database,
howdy/remote and every howdy/remote/* module for howdy_remote, and
howdy/admin and every howdy/admin/* module for howdy_admin, and
howdy/mail and every howdy/mail/* module for howdy_mail, and
howdy/storage and every howdy/storage/* module for howdy_storage,
howdy/flags and every howdy/flags/* module for howdy_flags,
howdy/notifications and every howdy/notifications/* module for
howdy_notifications,
howdy/jobs and every howdy/jobs/* module for howdy_jobs,
howdy/cache and every howdy/cache/* module for howdy_cache,
howdy/openapi and every howdy/openapi/* module for howdy_openapi,
howdy/telemetry and every howdy/telemetry/* module for howdy_telemetry,
howdy/ui and every howdy/ui/* module for howdy_ui, howdy/i18n and
every howdy/i18n/* module for howdy_i18n, and howdy/dev and
every howdy/dev/* module for howdy_dev. Core must not
define these modules: Gleam/BEAM module names are
global across dependencies. CI runs scripts/check-auth-namespace.sh to
reject collisions. Applications should put their own modules in their own
namespace.