返回资讯中心
外部精选
软件工程
#开发者实时流

A full-featured web framework written in Gleam, running on the BEAM

|> controller.get("/", fn(ctx) { controller.text(ctx, "Howdy!") })

Hacker News 最新crowdhailer2026/10/29 · 2 天前30 分钟阅读

以下正文同步自 Hacker News 最新,版权归原站所有,已转换为易读排版。

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

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, and bool require the parameter to be present.

optional_string, optional_int, and optional_bool return Option values.

string_or, int_or, and bool_or use a default only for missing parameters.

strings collects 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.

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:

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.

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.

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

string requires a cookie; absence returns 400 with {"error":"missing cookie theme"} for a cookie named theme.

optional_string returns Option(String); string_or defaults only when absent.

Empty values remain empty. Duplicate names and invalid percent encoding return JSON 400 responses 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.

set and delete preserve existing headers, including other Set-Cookie headers.

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.

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.

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 start

正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。

打开原文
去社区讨论 →