Building a site
Middleware and your own API
Standard net/http middleware with app.Use, cache keys that depend on a header with collage.Vary, and your own http.Handler with app.Handle.
collage renders pages. The rest of what a Go program does over HTTP — an API, authentication, language negotiation, rate limiting — you write the way you already would, and plug in at one of two points: middleware, which runs around every request, and your own handler, which answers every request under a prefix.
Middleware: app.Use
if err := app.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Frame-Options", "DENY")
next.ServeHTTP(w, r)
})
}); err != nil {
return err
}
It is the standard func(http.Handler) http.Handler shape, so any middleware written
for net/http works unchanged. The first one registered is the outermost. Like every
registration it must happen before the application starts; afterwards it returns
collage.ErrAppStarted.
Two things about where it runs:
- Before routing. It sees every request — pages, documents, actions, mounted static files and your own handlers — including the ones that end in a 404. It can answer a request by itself, with a 401 or a redirect, and nothing behind it runs.
- Inside the framework's guard. It runs within collage's span, metrics and panic
recovery. That is the difference from wrapping
app.Handler()in middleware of your own: a panic in your middleware is an ordinary 500 on the normal error path instead of a dropped connection, and a request it answers itself is counted like any other.
Passing values to data handlers
What middleware puts in the request's context is what data handlers receive as
ctx. That is how a signed-in user, a feature flag or a tenant reaches the
fragments that need it:
type userKey struct{}
app.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if user, ok := sessions.User(r); ok {
r = r.WithContext(context.WithValue(r.Context(), userKey{}, user))
}
next.ServeHTTP(w, r)
})
})
func accountData(ctx context.Context, rc *collage.RenderContext) (accountView, []string, error) {
user, ok := ctx.Value(userKey{}).(User)
if !ok {
return accountView{}, nil, nil // signed out: the fragment renders its signed-out state
}
return accountView{Name: user.Name}, nil, nil
}
Mind the cache. A page that renders differently per user must not be cached by URL,
or the first reader's version is everyone's: make it Dynamic(), or tell the cache
what it varies on with collage.Vary below.
A static export renders without a request, so no middleware runs during one. A data handler reading a context value must cope with its absence — which it has to anyway, for a reader who is not signed in.
Content that depends on a header: collage.Vary
The page cache is keyed by URL. A cached page whose content depends on a request
header — Accept-Language, a device class, an A/B cohort — serves whichever version
was rendered first to everybody. collage.Vary, called from middleware, adds a
dimension to the cache key:
type langKey struct{}
app.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
lang := "en"
if strings.HasPrefix(r.Header.Get("Accept-Language"), "tr") {
lang = "tr"
}
if err := collage.Vary(r, "Accept-Language", lang); err != nil {
app.Logger().Error("vary", "error", err)
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), langKey{}, lang)))
})
})
- The value you resolved goes into the key, not the raw header. Browsers spell
their preferences a hundred ways —
tr-TR,tr;q=0.9,tr,tr-TR— and all of them are one page. Reduce the header to the handful of values your pages actually differ by, and the cache holds that many entries. - The header name goes into the response's
Varyheader, so a CDN or proxy between you and the reader keeps the versions apart too. It is only set on publicly cacheable responses; ano-storeresponse has nothing to keep apart. - Call it from middleware. Declarations close when middleware is done and
routing begins, on every route — a page, cached or not, a document, an action, a
mount, an
app.Handlehandler. Called after routing, from a data handler say,Varyreturnscollage.ErrVaryTooLateinstead of pretending to work (since v0.11.0; before, it did so only on a cached page and silently did nothing elsewhere). Called on a request collage is not serving, it returnscollage.ErrVaryOutsideRequest. - Declaring the same header twice keeps the last value.
This is also how to negotiate a language: collage selects a locale from the URL
only, and leaves reading Accept-Language to you. Redirect a browser to /tr from
middleware, or render one URL per language and declare it with Vary. See
Links and locales.
Your own handler: app.Handle
api := http.NewServeMux()
api.HandleFunc("GET /api/users/{id}", getUser)
api.HandleFunc("POST /api/users", createUser)
if err := app.Handle("/api/", api); err != nil {
return err
}
Every request whose path begins with the prefix goes to your handler, with the path
unchanged — getUser sees /api/users/42. Wrap it in http.StripPrefix if it
expects otherwise. Any http.Handler will do: http.ServeMux, chi, a gRPC gateway,
a reverse proxy.
collage does nothing to it. There is no request-forgery check, no body-size
limit and no cache. What it accepts, how much it reads and what it caches are yours
to decide. What it does get is what every request gets: the span, the metrics, the
panic guard, the middleware registered with app.Use, and draining on graceful
shutdown. A 5xx it answers with is reported to plugins' error hooks as
collage.ErrHandlerFailed; its 4xx answers are its own business.
Prefixes and conflicts
The prefix must begin and end with / and cannot be / alone
(collage.ErrInvalidHandlerPrefix). A handler at / would take every request from
every page; if that is really what you want, put app.Handler() inside a mux of your
own instead.
A prefix may not cover anything collage routes. app.Handle("/api/", ...) next to
an action at /api/count is refused with collage.ErrMountShadowsRoute, and two
handlers, or a handler and a static mount, that overlap are refused with
collage.ErrMountConflict. The check runs when the application starts, so it does
not matter which was registered first; app.ListenAndServe and app.Start return
the error.
The check compares literal paths. It cannot see a dynamic pattern that would match
under the prefix: with a catch-all page at /{rest...} and a handler at /api/, the URL
/api/users goes to the handler, which is the trade you make by mounting a prefix.
Invalidating from your API
Your handler can drop cached pages like anything else can. When an API call changes content, invalidate the tags the pages built from it declared:
func updatePost(app *collage.App, posts *store.Posts) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if err := posts.Update(r.Context(), slug, r.Body); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := app.InvalidateTags(r.Context(), "post:"+slug, "blog:posts"); err != nil {
app.Logger().Error("invalidate", "slug", slug, "error", err)
}
w.WriteHeader(http.StatusNoContent)
}
}
app.InvalidateTagsN does the same and also returns how many cache entries the tags
reached, which is useful in a webhook's response or a log line.
A CMS webhook is the usual case: the CMS calls /api/hooks/cms with the changed
entry, the handler checks the webhook's signature and invalidates that entry's tag.
Only that entry's pages, and whatever collage.Cached values carried the same tag,
are rendered again. See Caching.
When to use an action instead
app.Handle is for code that is not about your pages — an API with its own
authentication, a service you proxy to, a router you already have. For an endpoint
that belongs to the site, use an action:
- A form post, or a
fetch()from one of your pages. An action checks the request-forgery token; a handler does not, so a browser-facingPOSTthroughapp.Handleis one any other site can make on your reader's behalf. - Anything that should be bounded. An action's body is limited by
Server.MaxBodyBytes(4 MiB by default) or its ownWithMaxBodyBytes. - An endpoint that answers with a fragment or a page — the changed part of a form,
a validation failure — since an action can return
collage.RenderFragmentorcollage.RenderPage. - An endpoint that drops cached pages when it succeeds, through
ActionResult.InvalidateTags. - A URL that should exist in every locale the site has: an action's paths are keyed by locale, like a page's.
count := collage.NewAction("count").
WithPath("en", "/api/count").
WithMethods(http.MethodPost).
WithHandler(func(context.Context, *collage.RenderContext) (*collage.ActionResult, error) {
result, err := collage.JSONOf(http.StatusOK, countResponse{Count: store.Increment()})
if err != nil {
return nil, err
}
result.InvalidateTags = []string{store.CountTag}
return result, nil
}).
Build()
That is the counter from the project collage new scaffolds: a JSON endpoint that
needs the forgery token and invalidates the page showing the count. For a webhook
from a service that cannot send a token, an action with WithoutCSRF() still gets
the body limit.