API reference

surf

HTTP routing for SummerCMS: collects plugin routes and named middleware into a `net/http` ServeMux with constraints, rate limiting, body limits, CORS and panic recovery, and provides the `serve` and `route:list` commands.

On this page

import "git.golem15.com/golem15/summercms/modules/surf"

Overview#

surf turns the routes that plugins declare through pact.HasRoutes into one http.Handler. surf.BuildRouter registers the built-in and plugin middleware, walks every plugin's route declarations through a Laravel-style group builder (surf.Router, implementing pact.Router), mounts the cabana admin, and checks every route at boot; surf.Assemble then compiles the result onto a standard library ServeMux. Configuration mistakes such as duplicate routes, unknown middleware names or malformed throttles fail at boot, not on the first request. It is the counterpart of WinterCMS's plugin routes.php files with Laravel's Route::group, ->middleware(), ->where() and throttle middleware.

Features#

  • Laravel-style route groups: surf.Router.Group with a path prefix and middleware list (surf.Use builds the list), surf.Router.Get, surf.Router.Post, surf.Router.Put, surf.Router.Patch and surf.Router.Delete (the same methods exist on each surf.Group), with Go 1.22+ path patterns such as /posts/{id}.
  • Path constraints: surf.Router.Where (regex, anchored to the whole segment) and surf.Router.WhereIn (allow-list) apply to the last declared route; a request that fails a constraint gets a 404. surf.IntParam reads a positive integer path value.
  • Named middleware from plugins (pact.HasMiddleware), parameterized middleware used as name:param (pact.HasMiddlewareFactories) and house middleware for the JSON envelope and error handling (pact.HasHouseMiddleware). Duplicate or unknown names fail boot.
  • Raw groups (surf.Router.GroupRaw) for routes that must not be wrapped in house middleware, such as webhooks or file streams: house middleware is refused there, the default body limit is skipped and a panic returns a bare 500.
  • Built-in middleware names: throttle:<bucket> or throttle:<max>,<minutes>, body.limit:<bytes>, locale.from-principal, plus backend (the admin guard) when the admin is enabled.
  • Fixed-window rate limiting (surf.FixedWindowLimiter): named buckets from plugins that implement surf.BucketProvider, or inline limits keyed by the signed-in user, or by client IP for guests. Rejected requests get a 429 with Retry-After and X-RateLimit-* headers. The in-process surf.MemoryStore sits behind the surf.Store interface.
  • Client IP resolution for limiter keys (surf.ClientIP) that only trusts X-Forwarded-For hops when the direct peer is inside a configured trusted proxy range (surf.TrustedProxies).
  • Every non-raw route runs inside JSON panic recovery (an opaque 500 via wire), gets the request locale from the Accept-Language header (see towel) and a request body cap. Responses are buffered until the handler returns, so a panic never leaves a half-written body.
  • Path-scoped CORS configured with the same keys as Laravel's config/cors.php (surf.CORSConfig), including preflight handling.
  • surf.LocaleFromPrincipal switches the request locale to the signed-in user's preferred locale.
  • A read-only route table (surf.Router.Routes) and the serve and route:list commands.

Usage#

A plugin declares routes, middleware and a rate-limit bucket; the runtime assembles them:

package blog

import (
	"net/http"
	"time"

	"git.golem15.com/golem15/summercms/modules/backpack"
	"git.golem15.com/golem15/summercms/modules/pact"
	"git.golem15.com/golem15/summercms/modules/surf"
	"git.golem15.com/golem15/summercms/modules/wire"
)

type Plugin struct{}

func (Plugin) ID() string                     { return "acme.blog" }
func (Plugin) Requires() []string             { return nil }
func (Plugin) Register(*backpack.App) error   { return nil }
func (Plugin) Boot(*backpack.App) error       { return nil }

func (Plugin) Middlewares() map[string]pact.Middleware {
	return map[string]pact.Middleware{
		"blog.no-store": func(next http.Handler) http.Handler {
			return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
				w.Header().Set("Cache-Control", "no-store")
				next.ServeHTTP(w, r)
			})
		},
	}
}

func (Plugin) Buckets() map[string]surf.Bucket {
	return map[string]surf.Bucket{
		"blog.comments": {
			Max:   5,
			Decay: time.Minute,
			Key:   func(r *http.Request) string { return "comments|" + surf.ClientIP(r, nil) },
		},
	}
}

func (Plugin) 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, "blog.no-store")
		g.WhereIn("status", "draft", "published")
		g.Post("/posts/{id}/comments", addComment, "throttle:blog.comments", "body.limit:65536")
	})
	return nil
}

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

func listPosts(w http.ResponseWriter, r *http.Request)  { wire.WriteJSON(w, http.StatusOK, []string{}) }
func addComment(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusCreated) }

The generated application main wires surf in with surf.ServeCommand and surf.RouteListCommand; tests can call surf.Assemble(app, plugins) and drive the returned handler with net/http/httptest.

API reference#

Identifier Description
surf.BuildRouter Registers built-in and plugin middleware, buckets, routes and the admin, and validates every route without compiling.
surf.Assemble surf.BuildRouter plus compilation into the final http.Handler.
surf.Router The route builder; implements pact.Router. surf.New creates an empty one.
surf.Group A prefixed route collection with inherited middleware.
surf.Router.RegisterMiddleware Stores a named middleware; duplicates fail.
surf.Router.RegisterMiddlewareFactory Stores a parameterized middleware used as name:param.
surf.Router.Routes Returns a copy of the registered routes as surf.RouteInfo values.
surf.RouteInfo Method, pattern, owning plugin, middleware and raw flag of one route.
surf.Use Builds a middleware name list for a group.
surf.Constraint A compiled path-parameter restriction built by surf.Regex or surf.Enum.
surf.IntParam Reads a positive integer path value.
surf.Bucket A named rate limit: maximum attempts, window length and key function.
surf.BucketProvider Implemented by plugins that declare named buckets.
surf.FixedWindowLimiter The rate limiter behind the throttle middleware; surf.NewFixedWindowLimiter creates one.
surf.Store Atomic fixed-window admission; surf.NewMemoryStore is the in-process implementation.
surf.ClientIP Resolves the client IP, honouring trusted proxies.
surf.TrustedProxies Parses http.trusted_proxies into CIDR prefixes.
surf.CORSConfig CORS settings; surf.LoadCORSConfig reads them from config.
surf.LocaleFromPrincipal Middleware that applies the signed-in user's preferred locale.
surf.ServeCommand The serve console command.
surf.RouteListCommand The route:list console command.

Configuration#

surf.BuildRouter reads these keys from the app's compass config:

Key Default Controls
http.body_limits.default_bytes none, required Request body cap in bytes for every non-raw route; body.limit:<bytes> overrides it per route. Must be a whole number of at least 1.
http.body_limits.upload_bytes none, required Upload body cap in bytes. Must be a whole number of at least 1; it is validated at boot.
http.trusted_proxies empty List of CIDRs whose X-Forwarded-For header is trusted when resolving the client IP. Malformed entries are skipped.
http.cors.paths empty Path globs (api/*) that get CORS headers. With no http.cors section no CORS headers are sent.
http.cors.allowed_origins empty Allowed origins; * allows any.
http.cors.allowed_origins_patterns empty Regular expressions matched against the origin.
http.cors.allowed_methods empty Methods sent in Access-Control-Allow-Methods; * allows any.
http.cors.allowed_headers empty Headers sent in Access-Control-Allow-Headers; * allows any.
http.cors.exposed_headers empty Headers sent in Access-Control-Expose-Headers.
http.cors.max_age 0 Preflight cache time in seconds.
http.cors.supports_credentials false Sends Access-Control-Allow-Credentials: true.
http:
  body_limits:
    default_bytes: 1048576
    upload_bytes: 20971520
  trusted_proxies: ["10.0.0.0/8"]
  cors:
    paths: ["api/*"]
    allowed_origins: ["https://blog.example.com"]
    allowed_methods: ["*"]
    allowed_headers: ["*"]
    supports_credentials: true

The serve command also opens the database through lagoon and the uploads bucket through attach.OpenBucket, so their settings must be present as well. It starts the background job worker of conga in the same process unless queue.work_in_serve is false (default true); set it to false when a separate queue:work process runs the jobs. The other queue.* keys are documented in the conga README.

CLI commands#

Command Flags Description
serve --addr (default :8080) Opens the database and uploads bucket, assembles the router, starts the in-process job worker (see queue.work_in_serve) and serves HTTP until SIGINT or SIGTERM, then shuts the server and the worker down gracefully within 10 seconds.
route:list none Builds the router the same way serve does, without opening the database or listening, and prints a table of method, pattern, plugin, middleware and raw flag for every route.

Dependencies#

  • SummerCMS modules: backpack, bonfire, bouncer, cabana, compass, conga (the in-process job worker of serve), lagoon (including lagoon/attach), pact, party, towel, wire.
  • Third-party: gocloud.dev/blob (uploads bucket opened by serve).
  • Standard library: bytes, context, fmt, math, net, net/http, net/netip, os, os/signal, regexp, strconv, strings, sync, syscall, time.

Testing#

go test ./modules/surf/...

The tests use net/http/httptest and in-memory stores and need no external services.