Architecture
Request lifecycle
How an HTTP request reaches a plugin handler: route collection, named middleware, constraints, body limits, CORS, recovery and request context values.
On this page
The serve command builds one http.Handler from every plugin's route declarations and serves it with the Go standard library. This page follows a request from the socket to your handler and back.
Building the router#
When serve starts, surf.BuildRouter collects the routes:
- It registers the built-in middleware (
throttle,body.limit,locale.from-principaland, when the admin is enabled,backend). - It registers the named middleware of every plugin that implements
pact.HasMiddleware,pact.HasMiddlewareFactoriesorpact.HasHouseMiddleware, and the rate-limit buckets of everysurf.BucketProvider. - It calls
pact.HasRoutes.Routeson every plugin in activation order, passing apact.Routerthat records groups, routes, middleware names and constraints. - It mounts the cabana admin API and checks every route.
surf.Assemble then compiles the routes onto a standard library http.ServeMux. A duplicate route, an unknown middleware name or a malformed throttle parameter fails here, at boot, and serve exits with the error instead of serving a broken router. ./bin/acme route:list builds the same router without opening the database and prints the route table.
Declaring routes#
A plugin declares routes the way a WinterCMS routes.php file does, with groups that share a prefix and middleware:
Route::group(['prefix' => 'api/blog', 'middleware' => ['auth']], function () {
Route::get('posts/{id}', 'Acme\Blog\Http\Posts@show')->where('id', '[0-9]+');
});
In Go the same declaration is a pact.HasRoutes method. Paths use Go http.ServeMux patterns, pact.Router.Where and pact.Router.WhereIn constrain the last declared route, and middleware names are strings that must be registered by the time the router is built. The surf reference has the full route builder with rate-limit buckets and per-route body limits.
The wrapping order#
surf wraps every non-raw route in the same layers. From the outside in:
- CORS, only for the paths configured under
http.cors.paths, including preflight requests. - Panic recovery. A panic becomes an opaque JSON 500 from wire, and because the response is buffered until the handler returns, the client never receives half a body.
- The request locale, taken from the
Accept-Languageheader and stored in the context. - The body limit:
http.body_limits.default_bytes, or the route's ownbody.limit:<bytes>. - The route's middleware, in the order you listed them: group middleware first, then the route's own.
- The path constraints. A request whose parameter fails
pact.Router.Whereorpact.Router.WhereIngets a 404 before your handler runs. - Your handler.
Raw groups, declared with pact.Router.GroupRaw, are for webhooks and file streams. They skip the default body limit, refuse house middleware, and a panic in them returns a bare 500.
Request context values#
WinterCMS reads the current locale and user through facades. SummerCMS carries them on the request's context.Context. surf stores the locale with towel.WithLocale, the authentication middleware stores the signed-in principal, and your own middleware can add the organization or collection with towel.WithOrganization and towel.WithCollection. Any code that receives the context reads them back without a global lookup:
// A middleware stores the organization once for the whole request.
withAcme := func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := towel.WithOrganization(r.Context(), "acme")
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// The handler reads the values back from its request context.
listPosts := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
org, _ := towel.Organization(r.Context())
locale, ok := towel.Locale(r.Context())
if !ok {
locale = "en"
}
fmt.Fprintf(w, "posts for %s in %s", org, locale)
})
// surf sets the locale from Accept-Language; here the test sets it.
req := httptest.NewRequest(http.MethodGet, "/api/blog/posts", nil)
req = req.WithContext(towel.WithLocale(req.Context(), "pl"))
rec := httptest.NewRecorder()
withAcme(listPosts).ServeHTTP(rec, req)
fmt.Println(rec.Body.String())
// Output: posts for acme in pl
Writing responses#
Handlers write JSON with wire.WriteJSON, which keeps bodies byte-compatible with a Laravel backend: HTML characters are not escaped and there is no trailing newline. wire.Slice turns a nil list into [], wire.Time marshals timestamps in Carbon's +00:00 form and wire.TriBool models a nullable boolean. Errors that should look like your API's error envelope go through the house middleware a plugin registers with pact.HasHouseMiddleware.