A from-scratch web framework in Odin

Heimdall is listening.

Gjallarhorn is the horn Heimdall sounds at the gates of Ásgarð. Here it is a small, honest web framework — an HTTP server, a router, an onion of middleware, and an ORM that speaks PostgreSQL on a hand-rolled wire protocol. No libpq, no dependencies. Just structs, runes, and the well of memory.

The bridge from request to response

What Gjallarhorn is

Every request crosses a Bifrost — the rainbow bridge, and the object that carries a request in and a response back out. It threads through a chain of Runes (middleware), lands on a Handler, and may draw from Mímir's well to remember or recall rows. The whole framework is one Odin package split across a handful of files, each keeping its registration verb beside its logic.

Heimdall, the server

A blocking accept loop on core:net. It parses the request line, bounds the read buffer, and hands a fresh Bifrost to the rune chain. server.odin

Bifrost, the bridge

The request/response object. Path params, response headers, and the text / json helpers a handler calls. Named so it never shadows Odin's context. bifrost.odin

Runes, the middleware

Each rune wraps the rest of the pipeline, onion-style. With no closures in Odin, the remaining chain is threaded through the Bifrost itself. middleware.odin

Mímir, the well

The ORM. Your db:-tagged structs are a shape; Mímir remembers them as rows and migrates them at startup. mimir.odin · postgres.odin

Loom, the templates

The templating engine. A template is the warp strung on the loom; weave runs your data through it as the weft to produce HTML. A Jinja dialect, HTML-escaped by default. loom.odin

From socket to status code

The path a request walks

  1. 1

    Heimdall accepts

    The accept loop reads up to MAX_REQUEST bytes, parses METHOD SP TARGET SP HTTP/1.1, strips the query string, and builds a Bifrost.

  2. 2

    The runes wrap

    next(&b) advances an index through the registered middleware — logger, then cors — each free to act before and after the call downstream.

  3. 3

    Dispatch matches

    Method first, then a segment-wise path match. :name segments capture into params. No route? Static mounts are tried for GET, else a 404.

  4. 4

    The handler answers

    Your procedure reads params, perhaps draws a well, and writes back with text or json. The response crosses the Bifrost and the socket closes.

Free procedures, explicit app

Routing

Handlers are plain procedures of type proc(b: ^Bifrost) — Odin has no methods or closures, so the app is always passed by pointer. Register routes with the method verbs; literal paths should be declared before :param patterns so a capture never swallows a fixed segment.

// Literal routes before the :id pattern, else ":id" captures "schema".
gh.get(app, "/sample/schema", schema_handler)
gh.get(app, "/sample/:id", get_handler)

// CRUD against Postgres via Mímir. Method is matched before path.
gh.post(app, "/sample/:name", create_handler)      // create
gh.put(app, "/sample/:id/:name", update_handler)   // update
gh.delete(app, "/sample/:id", delete_handler)      // delete

Inside a handler, params come back idiomatically as (value, ok):

get_handler :: proc(b: ^gh.Bifrost) {
    id, ok := gh.param_int(b, "id")
    if !ok {
        gh.text(b, 400, "id must be an integer")
        return
    }
    gh.json(b, 200, Sample{id = id, name = "thing"})
}
An onion you thread, not capture

Runes — the middleware chain

A rune is a proc(b: ^Bifrost, next: Next). Inscribe one with rune; they run in registration order, each wrapping everything after it. Calling next(b) runs the next layer — or, at the end, dispatches the route. Skip the call to short-circuit, the way cors answers a preflight OPTIONS directly.

gh.rune(&app, gh.logger)   // → GET /sample/7
gh.rune(&app, gh.cors)     // CORS headers + preflight short-circuit

cors :: proc(b: ^gh.Bifrost, next: gh.Next) {
    gh.set_header(b, "Access-Control-Allow-Origin", "*")
    if b.method == .Options {
        gh.text(b, 204, "")  // do not call next
        return
    }
    next(b)  // run the rest of the onion
}
Óðinn gave an eye for a single draught

Mímir — the ORM and the well of memory

Mímir guards the well beneath Yggdrasil whose water is memory. Here your structs describe a shape and Mímir remembers it as rows. db: struct tags drive everything; you never write a CREATE TABLE. The well speaks a dialect (Postgres, MySQL, or SQLite) and — crucially — values never reach the SQL string. Every value is a bound parameter. That is the injection checkpoint, held end to end through the hand-rolled Postgres wire client.

// The "M" in MVC. Tags: column name, then flags.
Sample :: struct {
    id:   int    `db:"id,pk,auto"`,   // auto primary key
    name: string `db:"name,notnull"`, // required text
}

// Hand Mímir the model once; migrate() carves its table at run().
gh.remember(app, Sample)
carveCREATE TABLEcarve a struct's shape into the well
offerINSERToffer a value to the well (RETURNING the pk on Postgres)
recallSELECTrecall rows — a Query you refine with whose / order_by / limit
amendUPDATEamend a remembered row by its primary key
forgetDELETEmake the well forget a row by its primary key

Building procs need only the dialect; exec / query reach through the app's live connection. A handler draws its well straight from the Bifrost:

create_handler :: proc(b: ^gh.Bifrost) {
    name, _ := gh.param(b, "name")
    w := gh.well(b)
    rows, ok := gh.query(w, gh.offer(w, Sample{name = name}))
    if !ok {
        gh.text(b, 503, "database unavailable")
        return
    }
    id, _ := strconv.parse_int(rows.rows[0][0])  // the RETURNING id
    gh.json(b, 201, Sample{id = id, name = name})
}
Offline by design. Leave dbname empty and Mímir stays out of the water — migrations print their DDL instead of executing, so local development needs no database at all. Set a dbname to go live: run() connects, runs the startup/auth handshake (trust, cleartext, MD5, or SCRAM-SHA-256, with optional TLS), and auto-migrates every remembered model into real tables.
Files, served safely

Static mounts

hail mounts a directory under a URL prefix for GET requests. Explicit routes win; mounts are tried only when nothing matches. The security checkpoint here is path traversal — a request path is joined, cleaned, and must stay inside the mount root, or it's a 403. This very page is served that way.

// Serve ./public at /static — this page lives here.
gh.hail(&app, "/static", "./public")
The Norns weave the threads of fate

Loom — the templating engine

At the well of Urðr the Norns weave the threads of fate; so here a template is the warp already strung on the loom, and weave runs the weft of your data through it to produce the finished cloth — HTML. The dialect is Jinja's, pared to its load-bearing parts: output an expression, pipe it through filters, branch with if, and iterate with for.

<!-- A template: the warp strung on the loom -->
<h1>{{ title | upper }}</h1>
{% if user %}<p>Welcome, {{ user.name }}.</p>{% else %}<p>Hail, stranger.</p>{% endif %}
<ul>
{% for n in norns %}  <li>{{ loop.index }}. {{ n | capitalize }}</li>
{% else %}  <li>the threads are cut</li>
{% endfor %}</ul>
{{ expr }}outputrender an expression, HTML-escaped by default
| filterpipelineupper · lower · capitalize · default · join · length · first · last · safe
{% if %}branchif / elif / else / endif
{% for %}iteratefor x in xs … else (empty case) … endfor, with a loop binding
{# … #}commentdropped before rendering

Inside a for, a loop binding carries index / index0 / first / last / length, as in Jinja. You thread the context with warp (named values) and list (iterables), then weave:

ctx := gh.warp(
    {"title", "Gjallarhorn"},
    {"user", gh.warp({"name", "Heimdallr"})},
    {"norns", gh.list("urd", "verdandi", "skuld")},
)
html, _ := gh.weave(src, ctx)     // string in, woven HTML out

From a handler, render loads a template file, weaves a context through it, and sends it as HTML. Or mount a whole directory with the four-arg hail: a Provider builds the context fresh per request, so it can read the path, params, or the well.

// One file, one context, sent as text/html.
gh.render(b, "./templates/hello.html", ctx)

// Mount ./templates at /pages; GET /pages/x.html weaves templates/x.html.
gh.hail(&app, "/pages", "./templates", provider)
XSS is the checkpoint. Every {{ … }} is HTML-escaped unless its filter pipeline ends in | safe (or | escape, which escapes then marks safe). The decision rides alongside each value as it's evaluated, so it's made per output, never globally. Template path is supplied by the handler, not the request — the user-path traversal checkpoint lives over in hail's static mounts.
The whole ceremony

Quickstart

Construct the app, inscribe your runes, hail your static dir, register your routes, and sound the horn. Defining a tagged struct and remembering it is the entire schema step — the table follows from the shape.

main :: proc() {
    app := gh.new(gh.Config{
        port    = 8091,
        db_type = .Postgres,
        postgres = gh.Postgres_Config{
            host = "127.0.0.1", port = 5432,
            user = "app", password = "secret", dbname = "gjallarhorn",
        },
    })

    gh.rune(&app, gh.logger)             // onion order
    gh.rune(&app, gh.cors)
    gh.hail(&app, "/static", "./public") // serve files

    sample.register(&app)                // remember models + routes
    gh.run(&app)                         // connect, migrate, listen
}

The sample API

GET/sample/schemainspect the DDL & SQL Mímir generates
GET/sample/:idread a row
POST/sample/:namecreate — returns the new id
PUT/sample/:id/:namerename by id
DELETE/sample/:idforget by id

Data rides in the path — the server has no request-body parser yet. Honest about its phase, like the rest of the codebase.