- the two pinned jsDelivr scripts carry the per-request nonce next to their SRI hash; script-src holds only the nonce, no host source
303 lines
19 KiB
Markdown
303 lines
19 KiB
Markdown
---
|
|
title: Routing
|
|
description: Declare a plugin's HTTP routes with groups, auth groups, path constraints and named middleware through pact.HasRoutes, and write JSON responses with wire.
|
|
section: services
|
|
order: 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](../../modules/surf/README.md) 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:
|
|
|
|
```go src=modules/surf/example_test.go#BlogPlugin.Routes
|
|
// 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.Group` adds a path prefix and a middleware list to the routes declared inside it; groups nest. `surf.Use` builds the list.
|
|
- `pact.Router.Get`, `pact.Router.Post`, `pact.Router.Put`, `pact.Router.Patch` and `pact.Router.Delete` take a path in Go's pattern syntax (`/posts/{id}`) and optional middleware names for that route alone.
|
|
- `pact.Router.Where` restricts a path parameter of the route declared just before it to a regular expression matched against the whole segment, and `pact.Router.WhereIn` to 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:
|
|
|
|
```go src=modules/surf/example_test.go#showPost
|
|
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 `Allow` header that ServeMux gives for the same routes without the overlap.
|
|
- **More specific routes** stay outside the family: a literal `GET /shelves/items/similar` keeps answering its own path.
|
|
- **Route table:** `surf.Router.Routes` and `route:list` list 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](../../modules/bouncer/README.md) JWT guard into named middleware and returns it from `pact.HasMiddleware`:
|
|
|
|
```go src=modules/surf/example_test.go#BlogPlugin.Middlewares
|
|
// 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](authentication.md) 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](rate-limiting.md). |
|
|
| `body.limit:<bytes>` | Replaces the default request body limit for the route. |
|
|
| `cors:<profile>` | CORS headers from `http.cors_profiles.<profile>` on every response of the route, refusals and recovered panics included, whatever its position in the list; see [CORS profiles](#cors-profiles). |
|
|
| `turnstile:open`, `turnstile:closed` | Cloudflare Turnstile verification; see [Turnstile](authentication.md#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`](../../modules/surf/README.md) 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](../architecture/request-lifecycle.md).
|
|
|
|
`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 `[]`:
|
|
|
|
```go src=modules/wire/example_test.go#ExampleWriteJSON
|
|
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`:
|
|
|
|
```go src=modules/surf/example_test.go#ExampleAssemble
|
|
// 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:
|
|
|
|
```sh
|
|
./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`:
|
|
|
|
```go src=modules/surf/example_test.go#ExampleBuildRouter
|
|
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:
|
|
|
|
```yaml
|
|
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: true` together with `*` in `allowed_origins` fails 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.
|
|
- With `supports_credentials: true`, three more rules close other spellings of the same exposure, and each refusal names the key with its index, such as `http.cors.allowed_origins[1]`:
|
|
- an `allowed_origins` entry with a `*` is accepted only when the scheme is literal and the `*` is the whole leftmost host label, as in `https://*.example.com`; `https://*`, `*://*`, `https://*example.com` and `http*://www.example.com` fail the start-up;
|
|
- every `allowed_origins_patterns` entry must be anchored with `^` and `$`, as in `^https://[a-z]+\.example\.com$`; an unanchored pattern matches inside any origin, so `https://.*\.example\.com` would trust `https://x.example.com.attacker.net`;
|
|
- no origin rule may match an origin outside the configured domains: surf tries each rule against probe origins such as `https://cors-probe.invalid` and `null`, which refuses `https://*.com` and `^https?://.*$`.
|
|
|
|
Without credentials these entries load as before.
|
|
- An allowed origin is echoed exactly in `Access-Control-Allow-Origin`; `*` is sent only for `allowed_origins: ["*"]` without credentials, and that answer is unchanged from earlier releases.
|
|
- When the answer depends on the request's `Origin` (with `supports_credentials`, with any pattern, or with more than one listed origin), every response on a CORS path carries `Vary: Origin`, the preflight and a disallowed origin's response included, so a shared cache never serves one origin's answer to another.
|
|
- An `allowed_origins` entry with a `*` inside, such as `https://*.example.com`, matches like Laravel's `Str::is`: the `*` stands for any characters and the rest is literal.
|
|
- `allowed_origins_patterns` entries 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](authentication.md#cookie-transport)) needs the www origin listed and credentials on:
|
|
|
|
```yaml
|
|
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`:
|
|
|
|
```yaml
|
|
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`. The route, or its group, then lists `cors:spa` among its middleware names, as it lists `throttle:60,1`. surf applies the profile outside every other layer of the route, whatever its position in the list or the group nesting. Every response of the route therefore carries the profile's headers, including refusals from guards (401, 403), throttle (429) and Turnstile (422, 503), the body limit (413) and recovered panics (500); otherwise the browser would hide them from the page, and a single-page app could not see the 401 that starts its cookie refresh.
|
|
|
|
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:
|
|
|
|
```go src=modules/surf/example_test.go#BlogPlugin.APIDocsSpec
|
|
// 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` whose only script source is a per-request nonce: scripts run only with the page's nonce. The two jsDelivr scripts carry it as well as their SRI hash and the inline start-up script carries it, so no other jsDelivr script can run, even if HTML were injected into the page. The page also sends `X-Content-Type-Options: nosniff` and `Referrer-Policy: no-referrer`.
|