Files
summercms/docs/services/routing.md
Jakub Zych efb35a2d35 feat(11.1-04): add the Database section and the core Services pages
- docs/database: models, migrations, queries and pagination, relations,
  casts and validation, attachments and transactions (lagoon.Transaction,
  lagoon.AfterCommit, nested savepoints, lagoon.OnDatabase)
- docs/services: configuration, events, routing with auth groups, rate
  limiting, authentication, the OAuth server, mail and localization
- runnable Examples for lagoon, attach, compass, surf, wire, bouncer,
  wristband, postcard, phrasebook and festival; lagoon TestDocs* regions
  run on the package's Postgres harness through DocsDB
- 15 new required pages
2026-09-30 22:59:25 +02:00

211 lines
9.3 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})
}
```
## 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. |
| `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 `Accept-Language`, 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
```
`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.