๐ค The shape of an app
A Rastrillo app is five files plus migrations.go. rastrillo new
writes all of them, and they are worth reading in order before you
change anything.
internal/<app>/models.go plain GORM structs
internal/<app>/migrations.go Schema and BootSchema
internal/<app>/app.go migrate.Apply, sessions, identity plugin, router
internal/<app>/handlers.go the owner-scoped CRUD
internal/<app>/render.go embedded templates, flash/session-aware page data
cmd/<app>/main.go Resolve -> db.Open -> App -> Serve
examples/notes in the framework repo is the worked reference.
main.go
logger := slog.Default()
opts, err := rastrillo.Resolve(rastrillo.Options{DBPath: "notes.db", Logger: logger})
if err != nil {
logger.Error("resolve", "err", err)
os.Exit(1)
}
d, err := db.Open(opts.DBPath, logger)
if err != nil {
logger.Error("open db", "err", err)
os.Exit(1)
}
defer d.Close()
mux, err := notes.App(d, origin, logger)
if err != nil {
logger.Error("build", "err", err)
os.Exit(1)
}
opts.Mux = mux
opts.DBPath = ""
if err := rastrillo.Serve(opts); err != nil {
logger.Error("serve", "err", err)
os.Exit(1)
}
Two lines there are easy to miss: set opts.Mux after App returns,
and blank opts.DBPath so Serve does not open the database file a
second time.
Use Resolve and Serve, not Run
rastrillo.Run is the right call when you let the framework own the
database. Your scaffolded app does not โ it calls db.Open itself โ so
it uses Resolve and Serve instead.
The reason is small and annoying. Run re-parses argv and repopulates
Options.DBPath, so Serve would open a second connection to the file
db.Open already owns. Resolve does the same activation-argv and
$STATE_DIRECTORY work without the serving, and hands you the resolved
options.
What you get from Resolve and Serve
The whole platform contract, and you should never hand-roll any of it:
activation argv in both shapes the platform uses, LISTEN_FDS socket
activation, $STATE_DIRECTORY resolution for a relative database path,
GET /healthz and GET /api/version, the SIGTERM drain, and baseline
security headers with your own Set winning.
Deploying covers what the platform does with all of that.
app.go
The whole wiring, in order:
func App(d *db.DB, origin string, logger *slog.Logger) (*http.ServeMux, error) {
if _, err := migrate.Apply(context.Background(), d, BootSchema); err != nil {
return nil, err
}
writer, err := d.G.DB()
if err != nil {
return nil, err
}
sess, err := sessions.New(sessions.Config{DB: writer, Origin: origin, Logger: logger})
if err != nil {
return nil, err
}
a := &app{db: d.G}
ph, err := password.New(password.Config{
Sessions: sess, Lookup: lookupUser(d.G), Create: createUser(d.G),
RenderSignin: renderSignin, RenderSignup: renderSignup,
})
if err != nil {
return nil, err
}
r := chi.NewRouter()
r.Use(csrf.Protect(origin))
r.Get("/signin", ph.SigninPage)
r.Post("/signin", ph.Signin)
r.Get("/signup", ph.SignupPage)
r.Post("/signup", ph.Signup)
r.Post("/signout", ph.Signout)
r.Group(func(r chi.Router) {
r.Use(sess.Require)
r.Get("/", a.listNotes)
})
mux := http.NewServeMux()
mux.Handle("/", r)
return mux, nil
}
The order matters in a few places. migrate.Apply runs before anything
reads a table โ Migrations explains BootSchema.
csrf.Protect(origin) goes on app-wide, above the route groups, so a
route you add in six months is protected without you remembering
anything. Signed-in routes live in a chi.Group with sess.Require,
while sign-in and sign-up sit outside it, since a signed-out visitor has
to be able to reach them.
App returns the *http.ServeMux that main.go hands to Serve as
opts.Mux.
handlers.go
Handlers hang off one struct holding the *gorm.DB:
type app struct{ db *gorm.DB }
func (a *app) owned(r *http.Request) *gorm.DB {
uid, _ := sessions.UserID(r)
return scope.Owned(a.db, uid)
}
That owned(r) method is the most important line in the app.
Scoping explains what goes wrong without it, and the
one identity plugin where dropping the ok is a bug.
render.go
Parse one *template.Template per page โ layout plus that page โ
instead of one tree containing everything. Two pages can then both
define "content", which they otherwise could not.
This is also where flash.Take(w, r) gets called, once per page, so the
layout can render a notice. Templates covers what is
available inside them.
render.go also holds ErrorPage, a rastrillo.ErrorPageFunc that
renders templates/errors.html โ ui's error-page partial inside
your own layout. main.go points opts.ErrorPage at it so a panic gets
a real page; wire it to Ctx.ErrorPage too and the 500 a handler
answers looks the same as the 500 a panic answers.
Before you call it done
- Every handler on an owned model goes through the
owned(r)method. - Every update names its columns in
.Select(...). csrf.Protect(origin)is mounted app-wide, above the route groups.- Signed-in routes, jobs' included, are in a group with
sess.Require. - One
migrate.Applyruns at boot, andmake cirunsrastrillo migration check. opts.DBPathis blanked beforeServewhen your app opened its own handle.- Not-found and not-yours both answer 404.
Read this page as markdown โ exact, unstyled, and cheap for an agent to fetch.