Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions DELETE_ME.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ It is only here to orient the initial project owner.
- A runnable hexagonal HTTP API server (chi + Huma) at `github.com/meigma/template-go-api`, with a `todo` example resource served under a `/v1` URL version prefix, RFC 9457 errors, unversioned `/healthz`, `/readyz`, and `/metrics`, runtime API docs at `/docs`, and an `openapi` spec-export command.
- A PostgreSQL persistence adapter (pgx + sqlc typed queries + goose migrations) behind the domain's `todo.Repository` port: a `migrate` subcommand, a committed-and-drift-guarded sqlc layer, a real `/readyz` check, and container-backed integration tests. The port is the seam — implement it to back the template with a different datastore.
- An authorization tier (Cedar via `cedar-go`) with a deny-by-default Huma middleware, a modular per-resource authz slice pattern, and authentication deferred to the integrator: a placeholder API-key authenticator (`X-API-Key`/Bearer, backed by an `api_keys` table) and dev-only mock keys seeded for the Compose demo. Replace the authenticator with real authn — see step 6.
- Per-client rate limiting (on by default): a Huma middleware that throttles by client IP **before** authentication and returns RFC 9457 `429` with `Retry-After`. The shipped limiter is in-process (token bucket, `golang.org/x/time/rate`) behind a `ratelimit.Limiter` port — the seam for a distributed (for example, Redis-backed) limiter. See the README's [Rate limiting](README.md#rate-limiting) section.
- A Cobra/Viper entrypoint under `cmd/template-go-api` and `internal/cli` exposing `serve` (default), `version`, `openapi`, and `migrate`.
- Moon tasks for `format`, `lint`, `build`, `test`, and `check`, plus `sqlc` / `sqlc-check` (regenerate and drift-guard the typed query layer), `mockery` / `mockery-check` (regenerate and drift-guard the testify mocks), `migrate` (run database migrations), and `test-integration` (container-backed adapter tests).
- `golangci-lint`, `sqlc`, `goose`, and `mockery` wired through Proto and Moon.
Expand Down
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,9 @@ default.
| `--db-max-conns` | `TEMPLATE_GO_API_DB_MAX_CONNS` | `0` | maximum PostgreSQL pool connections; `0` uses the driver default |
| `--authz-enabled` | `TEMPLATE_GO_API_AUTHZ_ENABLED` | `true` | enable the [authorization](#authorization) middleware (deny-by-default); `false` bypasses it entirely |
| `--authz-policy-dir` | `TEMPLATE_GO_API_AUTHZ_POLICY_DIR` | _(none)_ | directory of `.cedar` files to load instead of the embedded policies; empty uses the embedded set |
| `--rate-limit-enabled` | `TEMPLATE_GO_API_RATE_LIMIT_ENABLED` | `true` | enable per-client [rate limiting](#rate-limiting); `false` disables throttling entirely |
| `--rate-limit-rps` | `TEMPLATE_GO_API_RATE_LIMIT_RPS` | `10` | sustained per-client request rate (requests/second) |
| `--rate-limit-burst` | `TEMPLATE_GO_API_RATE_LIMIT_BURST` | `20` | per-client burst size (token-bucket depth) |

CORS is off until you set origins. Client IP is read from the direct TCP peer
unless you opt into a trusted proxy header — never from `X-Forwarded-For`
Expand Down Expand Up @@ -440,6 +443,34 @@ policy in one slice can reference shared principal roles (`principal in
Role::"admin"`) or another slice's entities. Cross-cutting rules and shared
principal/role types live in the base package's `base.cedar`.

## Rate limiting

The API is rate limited per client out of the box (`--rate-limit-enabled`,
default true). The limiter is a Huma middleware installed **before**
authentication, so an over-limit request is rejected with `429 Too Many
Requests` before it reaches the credential store — protecting the auth path and
database from anonymous floods. The infrastructure routes (`/healthz`,
`/readyz`, `/metrics`) bypass Huma and are never limited.

Requests are keyed by **client IP** (the spoof-safe IP the
[`--trusted-proxy-header`](#configuration) logic resolves), allowing a burst of
`--rate-limit-burst` and a sustained `--rate-limit-rps` per second (a token
bucket). A throttled response is RFC 9457 `application/problem+json` and carries
a `Retry-After` header (whole seconds).

The shipped limiter is **in-process** (`golang.org/x/time/rate`), with per-key
buckets evicted after an idle period to bound memory. That is the right default
for a single instance; behind multiple replicas a shared backend keeps the limit
global. The `ratelimit.Limiter` port is the seam: implement `Allow` over a store
such as Redis and wire your adapter in `internal/app/app.go` — the middleware and
key function are unchanged. To limit authenticated callers instead of IPs, swap
the key function (`adapterhttp.ClientIPKeyFunc`) for one that reads the principal.

> The IETF [RateLimit header fields](https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/)
> (`RateLimit`/`RateLimit-Policy`) are still a draft and map only loosely onto a
> token bucket, so the template advertises the limit with the stable `Retry-After`
> header and leaves those headers as a documented enhancement.

## Testing

Unit tests sit beside the code and use [Testify](https://github.com/stretchr/testify)
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ require (
github.com/stretchr/testify v1.11.1
github.com/testcontainers/testcontainers-go v0.43.0
github.com/testcontainers/testcontainers-go/modules/postgres v0.43.0
golang.org/x/time v0.11.0
)

require (
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,8 @@ golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
Expand Down
24 changes: 24 additions & 0 deletions internal/adapter/http/ratelimit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package http

import (
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humachi"
chimiddleware "github.com/go-chi/chi/v5/middleware"
)

// ClientIPKeyFunc keys the rate limiter by the resolved client IP. It reads the
// IP the ClientIP middleware stored on the request context — spoof-safe, since
// that middleware honors --trusted-proxy-header and otherwise trusts only the
// TCP peer — so the limiter and the access log agree on who the client is.
//
// It has the signature of ratelimit.KeyFunc and is the default key for the
// rate-limit middleware. To limit authenticated callers instead, swap in a key
// function that reads the principal (see internal/authz) from the context;
// keying lives here, in the transport, so the limiter core stays
// router-agnostic. It never errors: an unresolved IP yields the empty key, which
// simply shares one bucket rather than failing the request.
func ClientIPKeyFunc(ctx huma.Context) (string, error) {
r, _ := humachi.Unwrap(ctx)

return chimiddleware.GetClientIP(r.Context()), nil
}
34 changes: 25 additions & 9 deletions internal/adapter/http/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ type RouterDeps struct {
Readiness []ReadinessCheck
// Register mounts resource operations onto the Huma API.
Register Registrar
// InstallRateLimit installs the rate-limit Huma middleware on the API. Like
// InstallAuthz it MUST run before the resource operations are registered (Huma
// snapshots the middleware stack per operation at registration), and it runs
// BEFORE InstallAuthz so an over-limit request is rejected before
// authentication touches the credential store. Nil (or a disabled middleware)
// leaves the API unthrottled. The infrastructure routes bypass Huma, so they
// are never rate limited.
InstallRateLimit func(huma.API)
// InstallAuthz installs the authentication/authorization Huma middleware on
// the API. It MUST run before the resource operations are registered: Huma
// snapshots the API's middleware stack into each operation at registration
Expand All @@ -61,12 +69,15 @@ type RouterDeps struct {
func NewRouter(deps RouterDeps) http.Handler {
mux := chi.NewMux()

// Core middleware, outermost first. Deferred seams (insert here in later
// slices): authn/authz and rate limiting.
// Core chi middleware, outermost first. The rate-limit and authn/authz
// middleware are Huma middleware (installed on the API below), not chi
// middleware, so they run only for API operations and never for the
// infrastructure routes.
//
// Client-IP runs first so the request id, access log, and metrics all see
// the resolved IP. CORS sits after the access log (so preflight responses are
// logged and metered) and is installed only when origins are configured.
// Client-IP runs first so the request id, access log, metrics, and the
// rate limiter all see the resolved IP. CORS sits after the access log (so
// preflight responses are logged and metered) and is installed only when
// origins are configured.
mux.Use(middleware.ClientIP(deps.TrustedProxyHeader))
mux.Use(chimiddleware.RequestID)
mux.Use(middleware.Recoverer(deps.Logger))
Expand All @@ -92,10 +103,15 @@ func NewRouter(deps RouterDeps) http.Handler {
})

api := NewAPI(mux, deps.Version)
// The authn/authz Huma middleware is installed BEFORE the operations are
// registered: Huma bakes the API's middleware stack into each operation at
// registration time, so middleware added afterward would never run. It is a
// no-op when authorization is disabled.
// The rate-limit and authn/authz Huma middleware are installed BEFORE the
// operations are registered: Huma bakes the API's middleware stack into each
// operation at registration time, so middleware added afterward would never
// run. Rate limiting is installed first so it runs outermost — an over-limit
// request is rejected before authentication runs. Each is a no-op when its
// feature is disabled.
if deps.InstallRateLimit != nil {
deps.InstallRateLimit(api)
}
if deps.InstallAuthz != nil {
deps.InstallAuthz(api)
}
Expand Down
40 changes: 36 additions & 4 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,25 @@ import (

"github.com/danielgtaylor/huma/v2"
"github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/time/rate"

adapterhttp "github.com/meigma/template-go-api/internal/adapter/http"
"github.com/meigma/template-go-api/internal/adapter/postgres"
"github.com/meigma/template-go-api/internal/authz"
"github.com/meigma/template-go-api/internal/authz/apikey"
"github.com/meigma/template-go-api/internal/config"
"github.com/meigma/template-go-api/internal/observability"
"github.com/meigma/template-go-api/internal/ratelimit"
"github.com/meigma/template-go-api/internal/todo"
todoauthz "github.com/meigma/template-go-api/internal/todo/authz"
"github.com/meigma/template-go-api/internal/todo/httpapi"
todopostgres "github.com/meigma/template-go-api/internal/todo/postgres"
)

// rateLimiterIdleTTL is how long an idle per-client bucket is kept before the
// in-process limiter evicts it, bounding memory under churning client keys.
const rateLimiterIdleTTL = 10 * time.Minute

// App is a fully wired API server ready to Run.
type App struct {
server *http.Server
Expand All @@ -35,6 +41,9 @@ type App struct {
// pool is the PostgreSQL connection pool, closed during graceful shutdown.
// It is nil when a repository is injected with WithRepository (tests).
pool *pgxpool.Pool
// rateLimiter is the in-process rate limiter whose janitor goroutine is
// stopped during graceful shutdown. It is nil when rate limiting is disabled.
rateLimiter *ratelimit.InMemory
}

// Option configures how New wires the application.
Expand Down Expand Up @@ -95,6 +104,8 @@ func New(
return nil, err
}

rateLimiter, installRateLimit := buildRateLimiter(cfg, logger)

// An empty metrics-addr co-locates /metrics on the API listener; otherwise a
// dedicated metrics server (below) serves it off the API surface.
serveMetricsInline := cfg.MetricsAddr == ""
Expand All @@ -108,10 +119,11 @@ func New(
TrustedProxyHeader: cfg.TrustedProxyHeader,
// The postgres store contributes a real connectivity check here; an
// injected repository (tests) contributes none, so /readyz is always ready.
Readiness: readiness,
Register: registerResources(service),
InstallAuthz: installAuthz,
FinalizeAuthz: finalizeAuthz,
Readiness: readiness,
Register: registerResources(service),
InstallRateLimit: installRateLimit,
InstallAuthz: installAuthz,
FinalizeAuthz: finalizeAuthz,
})

server := &http.Server{
Expand Down Expand Up @@ -141,6 +153,7 @@ func New(
logger: logger,
grace: cfg.ShutdownGrace,
pool: pool,
rateLimiter: rateLimiter,
}, nil
}

Expand Down Expand Up @@ -240,6 +253,25 @@ func resolveAuthenticator(
return apikey.NewAuthenticator(apikey.NewStore(pool)), nil
}

// buildRateLimiter constructs the rate limiter and the hook that installs the
// rate-limit middleware on the API. When rate limiting is disabled it returns a
// nil limiter and a nil hook, so NewRouter leaves the API unthrottled. The
// limiter is keyed by client IP (adapterhttp.ClientIPKeyFunc); swap that key
// function for a principal-based one to limit authenticated callers instead.
// The returned limiter runs a janitor goroutine the App stops on shutdown.
func buildRateLimiter(cfg config.Config, logger *slog.Logger) (*ratelimit.InMemory, func(huma.API)) {
if !cfg.RateLimitEnabled {
return nil, nil
}

limiter := ratelimit.NewInMemory(rate.Limit(cfg.RateLimitRPS), cfg.RateLimitBurst, rateLimiterIdleTTL)
install := func(api huma.API) {
ratelimit.NewMiddleware(api, limiter, adapterhttp.ClientIPKeyFunc, logger, true).Install()
}

return limiter, install
}

// Handler returns the assembled HTTP handler, primarily for functional tests.
func (a *App) Handler() http.Handler {
return a.server.Handler
Expand Down
45 changes: 45 additions & 0 deletions internal/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,48 @@ func TestAppWiringDeniesUnauthorized(t *testing.T) {
application.Handler().ServeHTTP(createRec, createReq)
assert.Equal(t, http.StatusForbidden, createRec.Code)
}

// TestAppWiringRateLimits proves the rate-limit middleware is wired into the
// composed server, runs before authentication, and exempts the infrastructure
// routes. With a burst of one, the second API request from the same client is
// throttled — and it returns 429, not the 403 a denied caller would get, which
// shows the limiter runs before authorization. The /healthz route is never
// limited because the infra routes bypass Huma.
func TestAppWiringRateLimits(t *testing.T) {
t.Parallel()

vp := viper.New()
vp.Set("rate-limit-rps", 1)
vp.Set("rate-limit-burst", 1)
cfg := config.Load(vp)
logger := observability.NewLogger(io.Discard, slog.LevelError, "json")
application, err := app.New(
context.Background(), cfg, logger, "test",
app.WithRepository(todotest.NewRepository()),
app.WithAuthenticator(stubAuthenticator{roles: []string{"user"}}),
)
require.NoError(t, err)
handler := application.Handler()

// Infra routes bypass Huma, so they are never rate limited: repeated /healthz
// hits all succeed despite the burst of one.
for range 3 {
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
assert.Equal(t, http.StatusOK, rec.Code)
}

post := func() int {
req := httptest.NewRequest(http.MethodPost, "/v1/todos", strings.NewReader(`{"title":"x"}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)

return rec.Code
}

// The single burst token lets the first request through; the next request
// from the same client is throttled before authorization runs.
assert.Equal(t, http.StatusCreated, post())
assert.Equal(t, http.StatusTooManyRequests, post())
}
14 changes: 14 additions & 0 deletions internal/app/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ func (a *App) Run(ctx context.Context) error {
// Close the database pool (when postgres) on every exit path, after the
// servers have returned — including when a server fails to start.
defer a.closePool(ctx)
// Stop the in-process rate limiter's janitor goroutine on every exit path.
defer a.stopRateLimiter(ctx)

servers := a.servers()
serveErr := make(chan error, len(servers))
Expand Down Expand Up @@ -91,3 +93,15 @@ func (a *App) closePool(ctx context.Context) {
a.logger.InfoContext(ctx, "closing database pool")
a.pool.Close()
}

// stopRateLimiter stops the in-process rate limiter's janitor goroutine when one
// is configured. It is deferred in Run so it executes on every exit path. It is
// a no-op when rate limiting is disabled (no limiter was built).
func (a *App) stopRateLimiter(ctx context.Context) {
if a.rateLimiter == nil {
return
}

a.logger.InfoContext(ctx, "stopping rate limiter")
a.rateLimiter.Stop()
}
Loading
Loading