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 config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ server:
ready: /ready # Custom readiness endpoint path
gzip:
minlength: 1024 # Min response size (bytes) before gzip applies; smaller responses skip compression
bodylimit: 10485760 # Max request body size in bytes (default 10 MB); raise for large uploads. 0 = default; negative rejected.
responsetime:
enabled: false # Opt-in X-Response-Time header (per-request processing time); off by default — OTel provides richer latency telemetry. SERVER_RESPONSETIME_ENABLED=true to restore.

Expand Down
1 change: 1 addition & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,7 @@ func loadDefaults(k *koanf.Koanf) error {
"server.path.health": "/health",
"server.path.ready": "/ready",
"server.gzip.minlength": 1024,
"server.bodylimit": DefaultBodyLimitBytes,

// Database defaults not provided for deterministic behavior
// Database will only be enabled when explicitly configured
Expand Down
4 changes: 3 additions & 1 deletion config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ func TestLoadWithDefaults(t *testing.T) {
assert.Equal(t, 5*time.Second, cfg.Server.Timeout.Middleware)
assert.Equal(t, 10*time.Second, cfg.Server.Timeout.Shutdown)
assert.Equal(t, 1024, cfg.Server.Gzip.MinLength)
assert.Equal(t, int64(10*1024*1024), cfg.Server.BodyLimit)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
assert.False(t, cfg.Server.ResponseTime.Enabled, "X-Response-Time header must default to opt-out")

// Database should be disabled by default (no defaults provided)
Expand Down Expand Up @@ -363,6 +364,7 @@ func TestLoadDefaultsInternalFunction(t *testing.T) {
assert.Equal(t, "60s", k.String("server.timeout.idle"))
assert.Equal(t, "5s", k.String("server.timeout.middleware"))
assert.Equal(t, "10s", k.String("server.timeout.shutdown"))
assert.Equal(t, int64(10*1024*1024), k.Int64("server.bodylimit"))

// Database defaults should NOT be provided
assert.Equal(t, "", k.String("database.type"))
Expand Down Expand Up @@ -786,7 +788,7 @@ func clearEnvironmentVariables() {
"SERVER_HOST", "SERVER_PORT", "SERVER_TIMEOUT_READ", "SERVER_TIMEOUT_WRITE",
"SERVER_TIMEOUT_IDLE", "SERVER_TIMEOUT_MIDDLEWARE", "SERVER_TIMEOUT_SHUTDOWN",
"SERVER_PATH_BASE", "SERVER_PATH_HEALTH", "SERVER_PATH_READY", "SERVER_GZIP_MINLENGTH",
"SERVER_RESPONSETIME_ENABLED",
"SERVER_BODYLIMIT", "SERVER_RESPONSETIME_ENABLED",
"DATABASE_TYPE", "DATABASE_HOST", "DATABASE_PORT", testDatabaseDatabase,
testDatabaseUsername, "DATABASE_PASSWORD", "DATABASE_TLS_MODE",
testDatabaseMaxConns, "DATABASE_POOL_IDLE_CONNECTIONS",
Expand Down
5 changes: 5 additions & 0 deletions config/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,11 @@ type ServerConfig struct {
Path PathConfig `koanf:"path" json:"path" yaml:"path" toml:"path" mapstructure:"path"`
Gzip GzipConfig `koanf:"gzip" json:"gzip" yaml:"gzip" toml:"gzip" mapstructure:"gzip"`

// BodyLimit is the maximum request body size in bytes. A value of 0 resolves
// to the framework default (10 MB) at wire-up; a negative value is rejected by
// config validation.
BodyLimit int64 `koanf:"bodylimit" json:"bodylimit" yaml:"bodylimit" toml:"bodylimit" mapstructure:"bodylimit"`

ResponseTime ResponseTimeConfig `koanf:"responsetime" json:"responsetime" yaml:"responsetime" toml:"responsetime" mapstructure:"responsetime"`

// LogRoutes toggles the per-route "Route registered" startup log lines
Expand Down
13 changes: 13 additions & 0 deletions config/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ const (
// Connection layers compare against this constant to decide whether to
// apply per-connection timezone setup.
TimezoneDisabledSentinel = "-"

// DefaultBodyLimitBytes is the maximum request body size (10 MB) applied when
// server.bodylimit is unset or resolves to a non-positive value. Single source
// of truth for both the koanf default (loadDefaults) and the server-side
// wire-up fallback (server.SetupMiddlewares).
DefaultBodyLimitBytes int64 = 10 * 1024 * 1024
)

// Messaging reconnection defaults
Expand Down Expand Up @@ -305,6 +311,13 @@ func validateServer(cfg *ServerConfig) error {
return NewValidationError("server.gzip.minlength", errMustBeNonNegative)
}

// A negative body limit is an operator error (a typo silently reverting to the
// default is worse than a startup failure). Zero is permitted and resolves to
// the framework default at wire-up (see server.SetupMiddlewares).
if cfg.BodyLimit < 0 {
return NewValidationError("server.bodylimit", errMustBeNonNegative)
}

return nil
}

Expand Down
14 changes: 14 additions & 0 deletions config/validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,20 @@ func TestValidateServerFailures(t *testing.T) {
},
expectedError: "server.gzip.minlength",
},
{
name: "negative_bodylimit",
cfg: ServerConfig{
Port: 8080,
Timeout: TimeoutConfig{
Read: 15 * time.Second,
Write: 30 * time.Second,
Middleware: 5 * time.Second,
Shutdown: 10 * time.Second,
},
BodyLimit: -1,
},
expectedError: "server.bodylimit",
},
}

for _, tt := range tests {
Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ require (
github.com/knadh/koanf/providers/file v1.2.1
github.com/knadh/koanf/providers/rawbytes v1.0.0
github.com/knadh/koanf/v2 v2.3.5
github.com/labstack/echo-opentelemetry v0.0.2
github.com/labstack/echo/v5 v5.2.1
github.com/labstack/echo-opentelemetry v0.0.3
github.com/labstack/echo/v5 v5.3.0
github.com/rabbitmq/amqp091-go v1.12.0
github.com/redis/go-redis/v9 v9.21.0
github.com/rs/zerolog v1.35.1
Expand Down
12 changes: 6 additions & 6 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -116,10 +116,10 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo-opentelemetry v0.0.2 h1:zNzIDYf2uXSYgpBXcuwRELrnOOFSRvMaC41DTF80ke8=
github.com/labstack/echo-opentelemetry v0.0.2/go.mod h1:kBwoqFuXPxpM9fxbs++asMsI42uOufQjuYJut3qqg6w=
github.com/labstack/echo/v5 v5.2.1 h1:TzpIksY6zLMzV0T0ycYbvTEoj9w6o6AcL5twg182VTY=
github.com/labstack/echo/v5 v5.2.1/go.mod h1:SyvlSdObGjRXeQfCCXW/sybkZdOOQZBmpKF0bvALaeo=
github.com/labstack/echo-opentelemetry v0.0.3 h1:r4HAlV3PniSlUmsqpXQ+YwFxo9RNuEOm2bGU9hAOhUY=
github.com/labstack/echo-opentelemetry v0.0.3/go.mod h1:gGPMgJmQkDreqWyEzfPccPhaT8B3XLag2AByizhNoM4=
github.com/labstack/echo/v5 v5.3.0 h1:KT74Mprk053PQEHwSZdeCDIz1BigTZOZhavMD0c9Fjs=
github.com/labstack/echo/v5 v5.3.0/go.mod h1:Q3j2+clBRgJr0O3DDONQeXNsM7RHgSwUhcuo47unqm8=
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw=
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
Expand Down Expand Up @@ -218,8 +218,8 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
go.opentelemetry.io/contrib/instrumentation/runtime v0.69.0 h1:MtkMsuRo3zEXTTMALfyrszwCDZTkB6wolyPjbwFAdq0=
go.opentelemetry.io/contrib/instrumentation/runtime v0.69.0/go.mod h1:FYTxnpsm+UPD0erZNq20GvnM8T2YQHiHtT2vokdpoac=
go.opentelemetry.io/contrib/propagators/b3 v1.39.0 h1:PI7pt9pkSnimWcp5sQhUA9OzLbc3Ba4sL+VEUTNsxrk=
go.opentelemetry.io/contrib/propagators/b3 v1.39.0/go.mod h1:5gV/EzPnfYIwjzj+6y8tbGW2PKWhcsz5e/7twptRVQY=
go.opentelemetry.io/contrib/propagators/b3 v1.42.0 h1:B2Pew5ufEtgkjLF+tSkXjgYZXQr9m7aCm1wLKB0URbU=
go.opentelemetry.io/contrib/propagators/b3 v1.42.0/go.mod h1:iPgUcSEF5DORW6+yNbdw/YevUy+QqJ508ncjhrRSCjc=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 h1:rydZ9sxbcFdm/oWrVyfLTjHIygMgv0bEeMd+3B/BvoM=
Expand Down
11 changes: 11 additions & 0 deletions renovate.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@
"config:recommended"
],
"postUpdateOptions": ["gomodTidy"],
"packageRules": [
{
"description": "Keep the echo/v5 engine and its OpenTelemetry companion on one PR so the engine and its instrumentation always update together.",
"matchDatasources": ["go"],
"matchPackageNames": [
"github.com/labstack/echo/v5",
"github.com/labstack/echo-opentelemetry"
],
"groupName": "echo (engine + otel)"
}
],
"customManagers": [
{
"customType": "regex",
Expand Down
1 change: 1 addition & 0 deletions server/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const (
errCodeConflict = "CONFLICT"
errCodeTooManyRequests = "TOO_MANY_REQUESTS"
errCodeServiceUnavailable = "SERVICE_UNAVAILABLE"
errCodeMethodNotAllowed = "METHOD_NOT_ALLOWED"
errCodeInternalError = "INTERNAL_ERROR"
)

Expand Down
37 changes: 30 additions & 7 deletions server/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,20 +206,43 @@ type PathParam struct {
// including any group/base-path prefix (e.g. "/api/cards/:cardId/status").
// It is the template the application registered, NOT the concrete URL
// (use Request().URL.Path for that). Empty before routing completes and on
// unmatched (404) requests; on 405 the engine sets the best-matching route's
// template (engine-defined, not a contract).
func (c HandlerContext) RouteTemplate() string { return c.ectx.Path() }
// unmatched (404) requests; on a top-level 405 the engine sets the best-matching
// route's template (engine-defined, not a contract). Note the asymmetry under a
// middleware-bearing group: a wrong-method or unmatched sub-path there resolves
// to the group's implicit catch-all (a 404), so it returns "" rather than a
// best-match template.
func (c HandlerContext) RouteTemplate() string {
if c.ectx.RouteInfo().Method == echo.RouteNotFound {
return "" // group implicit catch-all (echo v5.3.0): unmatched, no template
}
return c.ectx.Path()
}

// isUnmatchedRoute reports whether the request did not resolve to a real
// application route: the global 404/405 sentinel fallbacks OR a group's
// implicit catch-all. echo v5.3.0 restored v4 per-group auto-404 routes for
// middleware-bearing groups; their RouteInfo has an empty Name but
// Method == echo.RouteNotFound, which the old Name-only check missed.
func (c HandlerContext) isUnmatchedRoute() bool {
ri := c.ectx.RouteInfo()
return ri.Name == echo.NotFoundRouteName ||
ri.Name == echo.MethodNotAllowedRouteName ||
ri.Method == echo.RouteNotFound
}

// PathParams returns the matched path parameters in route-template order.
// The returned slice is a defensive copy: safe to retain past the request;
// mutating it does not affect Param() or struct-tag binding. Empty when no
// route matched (pre-route, 404, 405) — parameter state is only meaningful
// for a matched route.
func (c HandlerContext) PathParams() []PathParam {
// Param names are stamped only on a full method+path match; on 404/405 the
// pooled context's value slots can pair with a PREVIOUS request's names
// (phantom params), so treat unmatched requests as having no parameters.
if name := c.ectx.RouteInfo().Name; name == echo.NotFoundRouteName || name == echo.MethodNotAllowedRouteName {
// Param names are stamped only on a real method+path match. On an unmatched
// request — the global 404/405 fallback, or a middleware-bearing group's
// implicit catch-all — echo may still leave values in the pooled slots: stale
// names from a prior pooled request, or the catch-all's own synthetic wildcard
// ("*") capture. Neither is a real application parameter, so treat unmatched
// requests as having none.
if c.isUnmatchedRoute() {
return []PathParam{}
}
// echo's PathValues() returns a slice header ALIASING the pooled context's
Expand Down
Loading