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
This commit is contained in:
210
docs/services/routing.md
Normal file
210
docs/services/routing.md
Normal file
@@ -0,0 +1,210 @@
|
||||
---
|
||||
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.
|
||||
Reference in New Issue
Block a user