Services
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.
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.Groupadds a path prefix and a middleware list to the routes declared inside it; groups nest.surf.Usebuilds the list.pact.Router.Get,pact.Router.Post,pact.Router.Put,pact.Router.Patchandpact.Router.Deletetake a path in Go's pattern syntax (/posts/{id}) and optional middleware names for that route alone.pact.Router.Whererestricts a path parameter of the route declared just before it to a regular expression matched against the whole segment, andpact.Router.WhereInto 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.