- surf: APIDocsProvider (one plugin) supplies a YAML or JSON OpenAPI document, converted once at boot to compact JSON in source key order; zero or several providers or a spec that does not convert fail boot - http.api_docs.enabled/path/title mount GET /api-docs and /api-docs.json (summercms.apidocs, public, refusing to replace a plugin route) - the page loads swagger-ui-dist 5.33.1 from jsDelivr with sha384 SRI and crossorigin on all three assets, a per-request CSP nonce for its inline script, nosniff and no-referrer - docs: API documentation page section, README rows, ExampleAPIDocsProvider
18 KiB
title, description, section, order
| title | description | section | order |
|---|---|---|---|
| Routing | Declare a plugin's HTTP routes with groups, auth groups, path constraints and named middleware through pact.HasRoutes, and write JSON responses with wire. | services | 30 |
Routing
A WinterCMS plugin declares its routes in routes.php with Route::group, ->middleware() and ->where(). A SummerCMS plugin implements pact.HasRoutes: its Routes method receives a pact.Router with the same builder shape. surf collects every plugin's routes into one standard library http.ServeMux, and checks all of them when the application starts, so a duplicate route, an unknown middleware name or a malformed throttle stops the start-up instead of failing on the first request.
Handlers are ordinary http.HandlerFunc values. There are no controllers to extend and no request objects to learn.
Declaring routes
This plugin declares public routes, an auth group, path constraints and per-route middleware:
// Routes is the Go form of the plugin's routes.php.
func (p *BlogPlugin) Routes(r pact.Router) error {
r.Group("/api/blog", surf.Use("throttle:60,1"), func(g pact.Router) {
g.Get("/posts/{id}", showPost)
g.Where("id", `[0-9]+`)
g.Get("/posts/{status}/list", listPosts)
g.WhereIn("status", "draft", "published")
// An auth group: every route inside needs a signed-in user.
g.Group("", surf.Use("acme.auth"), func(auth pact.Router) {
auth.Get("/me", showMe)
auth.Post("/posts/{id}/comments", addComment, "throttle:blog.comments", "body.limit:65536")
})
})
return nil
}
pact.Router.Groupadds a path prefix and a middleware list to the routes declared inside it; groups nest.surf.Usebuilds the list.pact.Router.Get,pact.Router.Post,pact.Router.Put,pact.Router.Patchandpact.Router.Deletetake a path in Go's pattern syntax (/posts/{id}) and optional middleware names for that route alone.pact.Router.Whererestricts a path parameter of the route declared just before it to a regular expression matched against the whole segment, andpact.Router.WhereInto a list of values. A request that fails a constraint gets a 404.
In a handler, r.PathValue("status") reads a parameter, and surf.IntParam reads one as a positive integer:
func showPost(w http.ResponseWriter, r *http.Request) {
id, ok := surf.IntParam(r, "id")
if !ok {
http.NotFound(w, r)
return
}
wire.WriteJSON(w, http.StatusOK, map[string]any{"id": id})
}
Overlapping constrained routes
Laravel matches routes in the order routes.php declares them and checks ->where() constraints while it matches. Two routes can therefore share a shape that only their constraints keep apart, for example GET /shelves/items/{id} with a numeric id and GET /shelves/{shelfId}/follow with a numeric shelfId. Both patterns match /shelves/items/follow, and neither is more specific than the other, so the standard library ServeMux refuses to register them together.
surf accepts such routes. When the application starts, it groups every set of routes that ServeMux would refuse side by side into an overlap family and registers the family once, under a generated pattern with a wildcard wherever its routes differ. A request that reaches the family is tried against its routes in registration order. The first route whose literal segments and constraints match runs, with its own path values, middleware, body limit and panic recovery. Declare overlapping routes in the order the PHP file declares them.
- No match: a request whose path no route of the family matches gets the same bare 404 as any unknown path, for every method.
- Wrong method: a request whose path a route matches, but not with its method, gets the 405 and
Allowheader that ServeMux gives for the same routes without the overlap. - More specific routes stay outside the family: a literal
GET /shelves/items/similarkeeps answering its own path. - Route table:
surf.Router.Routesandroute:listlist every route of a family on its own, with its own pattern and middleware.
A family supports literal segments and single-segment {name} wildcards only. These shapes still stop the start-up with a route conflict: two routes of one method that differ only in parameter names, a {name...} or {$} wildcard or a trailing slash in a family, and a more general route of the same method, such as GET /shelves/{a}/{b}, that would take a family member's requests.
Auth groups
An auth group is a group whose middleware list names a guard. The plugin above turns a bouncer JWT guard into named middleware and returns it from pact.HasMiddleware:
// Middlewares registers the plugin's named middleware: here, a JWT guard
// that answers 401 when the request has no valid token.
func (p *BlogPlugin) Middlewares() map[string]pact.Middleware {
guards := bouncer.NewRegistry()
guard := bouncer.NewJWTGuard(secret, users{}, bouncer.NewMemoryBlacklist())
if err := guards.Register(p.ID(), "acme.auth", guard); err != nil {
panic(err)
}
auth, err := guards.Middleware("acme.auth")
if err != nil {
panic(err)
}
return map[string]pact.Middleware{"acme.auth": auth}
}
Every route in the group then requires a valid token, and handlers read the signed-in user with bouncer.User. The guard answers 401 with a JSON body when the token is missing or invalid. See Authentication for guards and tokens.
The admin API uses the built-in backend middleware name, which the framework registers when the admin is enabled.
Middleware
Named middleware is any func(http.Handler) http.Handler a plugin returns from pact.HasMiddleware. A plugin that needs a parameter, used as name:param, returns a factory from pact.HasMiddlewareFactories. Middleware names are global, so prefix them with the plugin: acme.auth, blog.no-store. A duplicate name fails the start-up.
The framework registers these names:
| Name | Does |
|---|---|
throttle:<bucket> or throttle:<max>,<minutes> |
Rate limiting; see Rate limiting. |
body.limit:<bytes> |
Replaces the default request body limit for the route. |
cors:<profile> |
CORS headers from http.cors_profiles.<profile>; see CORS profiles. |
turnstile:open, turnstile:closed |
Cloudflare Turnstile verification; see Turnstile. |
locale.from-principal |
Switches the request locale to the signed-in user's preferred locale. |
backend |
The admin guard, when the admin is enabled. |
Every route also gets, around its own middleware, JSON panic recovery, the request locale from a published surf.LocaleResolver or from Accept-Language when none is published, the body limit from http.body_limits.default_bytes, and CORS headers when its path matches http.cors.paths. The order is described in Request lifecycle.
pact.Router.GroupRaw declares a raw group for routes that must not be wrapped in the house JSON middleware, such as webhooks, file streams or the OAuth endpoints: the default body limit is skipped, and a panic returns a bare 500.
Responses
Write JSON with wire.WriteJSON. It produces what PHP's json_encode produces: HTML characters are not escaped and there is no trailing newline. wire.Time marshals a timestamp as Carbon does (+00:00, never Z), wire.TriBool is a nullable boolean, and wire.Slice turns a nil slice into []:
var tags []string // nil: the post has no tags
warsaw := time.FixedZone("CEST", 2*60*60)
body := postJSON{
ID: 1,
Title: "Tips & <tricks>",
Tags: wire.Slice(tags),
Featured: wire.TriBool{},
Pinned: wire.TriBool{Value: true, Valid: true},
PublishedAt: wire.Time{Time: time.Date(2026, 9, 30, 14, 5, 0, 0, warsaw)},
}
rec := httptest.NewRecorder()
wire.WriteJSON(rec, http.StatusOK, map[string]any{"data": body})
fmt.Println(rec.Code, rec.Header().Get("Content-Type"))
fmt.Printf("%s|\n", rec.Body.String())
rec = httptest.NewRecorder()
wire.WriteOpaque500(rec)
fmt.Println(rec.Code, rec.Body.String())
// Output:
// 200 application/json
// {"data":{"id":1,"title":"Tips & <tricks>","tags":[],"featured":null,"pinned":true,"published_at":"2026-09-30T12:05:00+00:00"}}|
// 500 {"error":true,"message":"Internal server error"}
wire.WriteOpaque500 writes the fixed 500 body that panic recovery also uses; it reveals nothing about the failure.
Testing and listing routes
surf.Assemble builds the complete handler from the application and its plugins, so a test can drive it with net/http/httptest:
// The application passes its config; http.body_limits is required there.
app := backpack.New(nil)
plugin := &BlogPlugin{}
if err := plugin.Register(app); err != nil { // the runtime calls Register
fmt.Println(err)
return
}
h, err := surf.Assemble(app, []party.Plugin{plugin})
if err != nil {
fmt.Println(err)
return
}
token, _, _ := bouncer.Mint(secret, "42", "http://127.0.0.1:8080/api/login", time.Hour)
do := func(method, path string, auth bool) {
req := httptest.NewRequest(method, path, strings.NewReader("{}"))
if auth {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
fmt.Println(method, path, rec.Code, strings.TrimSpace(rec.Body.String()))
}
do("GET", "/api/blog/posts/7", false)
do("GET", "/api/blog/posts/seven", false)
do("GET", "/api/blog/posts/draft/list", false)
do("GET", "/api/blog/posts/deleted/list", false)
do("GET", "/api/blog/me", false)
do("GET", "/api/blog/me", true)
do("POST", "/api/blog/posts/7/comments", true)
do("POST", "/api/blog/posts/7/comments", true)
// Output:
// GET /api/blog/posts/7 200 {"id":7}
// GET /api/blog/posts/seven 404 404 page not found
// GET /api/blog/posts/draft/list 200 {"data":[],"status":"draft"}
// GET /api/blog/posts/deleted/list 404 404 page not found
// GET /api/blog/me 401 {"error":true,"message":"Token not provided"}
// GET /api/blog/me 200 {"id":42}
// POST /api/blog/posts/7/comments 201 {"created":true}
// POST /api/blog/posts/7/comments 429 {"message":"Too Many Attempts."}
route:list builds the router the way serve does, without opening the database or listening, and prints every route with its plugin and middleware:
./bin/acme route:list
The table is followed by a line saying whether the admin is mounted and under which prefix, for example admin: mounted at /backend.
surf.BuildRouter returns the same information to Go code through surf.Router.Routes:
r, err := surf.BuildRouter(backpack.New(nil), []party.Plugin{&BlogPlugin{}})
if err != nil {
fmt.Println(err)
return
}
for _, rt := range r.Routes() {
fmt.Println(rt.Method, rt.Pattern, rt.PluginID, rt.Middleware)
}
// Output:
// GET /api/blog/posts/{id} acme.blog [throttle:60,1]
// GET /api/blog/posts/{status}/list acme.blog [throttle:60,1]
// GET /api/blog/me acme.blog [throttle:60,1 acme.auth]
// POST /api/blog/posts/{id}/comments acme.blog [throttle:60,1 acme.auth throttle:blog.comments body.limit:65536]
CORS
CORS is configured with the keys of Laravel's config/cors.php, under http.cors, and applies only to paths that match http.cors.paths. With no http.cors section, no CORS headers are sent:
cors:
paths: ["api/*"]
allowed_origins: ["https://blog.example.com"]
allowed_methods: ["*"]
allowed_headers: ["*"]
supports_credentials: true
This fragment belongs in config/http.yaml. List the frontend's exact origin; * is for public, credential-free APIs only.
The rules surf applies to the origin answer:
supports_credentials: truetogether with*inallowed_originsfails the start-up. A credentialed answer that trusted any origin would let every site read responses made with the user's cookies; Laravel's php-cors reflects the origin in that case, surf refuses it.- An allowed origin is echoed exactly in
Access-Control-Allow-Origin;*is sent only forallowed_origins: ["*"]without credentials, and that answer is unchanged from earlier releases. - When the answer depends on the request's
Origin(withsupports_credentials, with any pattern, or with more than one listed origin), every response on a CORS path carriesVary: Origin, the preflight and a disallowed origin's response included, so a shared cache never serves one origin's answer to another. - An
allowed_originsentry with a*inside, such ashttps://*.example.com, matches like Laravel'sStr::is: the*stands for any characters and the rest is literal. allowed_origins_patternsentries are Go regular expressions. One that does not compile fails the start-up, and so does one still wrapped in PHP delimiters, such as#^https://.*\.example\.com$#: drop the delimiters and any flags when porting it.
A frontend on https://www.example.com that calls an API on api.example.com with the session cookie (see Cookie transport) needs the www origin listed and credentials on:
cors:
paths: ["api/*"]
allowed_origins: ["https://www.example.com"]
allowed_methods: ["*"]
allowed_headers: ["*"]
supports_credentials: true
The browser sends the request with credentials: "include"; the answer carries Access-Control-Allow-Origin: https://www.example.com, Access-Control-Allow-Credentials: true and Vary: Origin.
surf answers every OPTIONS request on a CORS path itself, with 204, before any route runs, so a plugin never registers an OPTIONS route. A preflight gets the same headers Laravel's HandleCors sends: with allowed_methods: ["*"] the Access-Control-Allow-Methods value is the requested method, upper-cased, and with allowed_headers: ["*"] the Access-Control-Allow-Headers value is the requested header list.
CORS profiles
http.cors applies one policy to a set of paths. A route group that needs its own policy, for example a credentialed one for a single-page app while the rest of the API stays public, names a profile with the cors:<profile> middleware. Profiles live under http.cors_profiles.<name> and take the http.cors keys except paths:
cors_profiles:
spa:
allowed_origins: ["https://www.example.com"]
allowed_methods: ["GET", "POST"]
allowed_headers: ["Content-Type"]
supports_credentials: true
This fragment belongs in config/http.yaml. A route group then lists cors:spa among its middleware names, next to its guard, exactly as it lists throttle:60,1.
A profile is checked like http.cors: credentials with a * origin, a pattern that does not compile and a PHP-delimited pattern fail the start-up, with errors naming http.cors_profiles.<name>.<key>. Profile names use lower-case letters, digits, underscore and dash, and a profile that sets paths fails too.
ServeMux answers an OPTIONS request on a path registered only for GET or POST with 405 before any route middleware runs, so the middleware alone would never see a browser's preflight. surf therefore registers one OPTIONS <path> route for every path whose routes name cors:<profile>, owned by surf in route:list. It answers the preflight from the profile, with the same headers as the global preflight, and runs no other middleware: a preflight carries no credentials, so no auth, throttle or body limit applies. The start-up fails when a route names an unknown profile, when two routes on one path name different profiles, when a plugin already declared an OPTIONS route on that path, and when the path also matches http.cors.paths, because the global wrapper answers every preflight on its paths before routing.
API documentation page
An application can publish a Swagger UI page for its public API. The page is off by default; with http.api_docs.enabled: true surf mounts two public routes, without any middleware:
GET /api-docs: the Swagger UI page.GET /api-docs.json: the OpenAPI document as compact JSON.
The document comes from the one plugin that implements surf.APIDocsProvider. It returns the document as YAML or JSON, usually a file it embeds:
// APIDocsSpec supplies the document behind the opt-in /api-docs page
// (surf.APIDocsProvider).
func (p *BlogPlugin) APIDocsSpec() ([]byte, error) {
return []byte(openAPISpec), nil
}
surf converts the document once at start-up: a document starting with { must be valid JSON, anything else is read as YAML, and the result keeps the source's key order. A document that does not convert fails the start-up with the plugin's name, and so do zero or several providers while the page is enabled. Nothing is generated from the code: the document describes exactly the surface you choose to publish.
| Key | Default | Controls |
|---|---|---|
http.api_docs.enabled |
false |
Mounts the page and the JSON route. |
http.api_docs.path |
/api-docs |
The page path; the JSON route is the same path plus .json. It must be an absolute URL path, and neither route may replace a plugin route. |
http.api_docs.title |
API documentation |
The page title. |
The page loads swagger-ui-dist from jsDelivr at the exact version surf.SwaggerUIVersion, and every script and stylesheet carries a Subresource Integrity hash with crossorigin="anonymous", so the browser refuses a file that changed on the CDN. A floating version such as @5 cannot carry a hash, which is why the version is pinned and moves only with a framework release. The page sends a Content-Security-Policy that allows scripts only from jsDelivr and its own inline script (through a per-request nonce), plus X-Content-Type-Options: nosniff and Referrer-Policy: no-referrer.