Files
summercms/docs/services/routing.md
Jakub Zych fbdeb20126 feat(13-01): register overlapping constrained routes in surf
- compile groups routes ServeMux refuses side by side into overlap families
  and registers each under one generated method-less pattern
- the family handler tries members in registration order on literals and
  Where constraints, sets their path values and runs their own wrapped chain
- no match answers the bare 404; a method mismatch answers ServeMux's 405
  and Allow for the same table without the overlap
- unsupported shapes (same shape, {name...}, shadowing route) fail at boot
- README and docs/services/routing.md describe the behaviour
2026-10-03 06:22:20 +02:00

11 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})
}

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