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

9.3 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.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:

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 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.
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.

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

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.