- 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
211 lines
9.3 KiB
Markdown
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.
|