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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ e.Use(mw)
// now all HTTP routes will be handled by the middleware, and any requests that are invalid will be rejected
```

### The validated request body

This middleware enables `openapi3filter.Options.KeepValidatedValue` by default. After a request body passes validation, the `c.Request().Body` seen by handlers implements `interface{ ValidatedValue() any }`, returning the decoded value that was validated (schema defaults included), and the body can still be read as usual. This lets a handler, such as an `oapi-codegen` strict server that supports it, bind exactly the value that was validated rather than decoding the body a second time, which closes gaps where a second decode could disagree with validation (for example, `encoding/json` matching object keys case-insensitively, so `{"role":"user","ROLE":"admin"}` validates as `role: user` but binds as `Role: "admin"`).

To opt out, for instance to limit memory retention for very large request bodies, set `Options.DiscardValidatedValue: true`. This takes precedence over `Options.Options.KeepValidatedValue`.

Any middleware that runs after the validator and replaces the request body (decompression, size limiting, logging wrappers, ...) hides the validated value from handlers, so such middleware belongs before the validator.

## FAQs

### What versions of Echo does this support?
Expand Down
30 changes: 28 additions & 2 deletions oapi_validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,16 @@ type Options struct {
// Prefix is stripped from the request path before validation. This is useful when the API is mounted under a sub-path
// (e.g. "/api") that isn't part of the OpenAPI spec's paths. The prefix must start with "/" if set.
Prefix string
// DiscardValidatedValue turns off openapi3filter.Options.KeepValidatedValue,
// which this middleware enables by default. With it enabled, the request body
// seen by handlers exposes the value that was validated through a
// ValidatedValue() method, so a handler (for example an oapi-codegen strict
// server) can bind that value instead of decoding the body a second time.
// Set this to drop the decoded value after validation, for instance to limit
// memory retention for very large request bodies.
//
// Options.Options.KeepValidatedValue is overridden by this setting.
DiscardValidatedValue bool
}

// OapiRequestValidatorWithOptions Creates the middleware to validate that incoming requests match the given OpenAPI 3.x spec, allowing explicit configuration.
Expand Down Expand Up @@ -127,7 +137,9 @@ func OapiRequestValidatorWithOptions(spec *openapi3.T, options *Options) echo.Mi
}

// ValidateRequestFromContext is called from the middleware above and actually does the work
// of validating a request.
// of validating a request. A nil options validates with the defaults, which, as
// with a non-nil options, include openapi3filter.Options.KeepValidatedValue (see
// Options.DiscardValidatedValue).
func ValidateRequestFromContext(ctx echo.Context, router routers.Router, options *Options) *echo.HTTPError {
req := ctx.Request()

Expand Down Expand Up @@ -192,14 +204,14 @@ func ValidateRequestFromContext(ctx echo.Context, router routers.Router, options
Request: req,
PathParams: pathParams,
Route: route,
Options: filterOptions(options),
}

// Pass the Echo context into the request validator, so that any callbacks
// which it invokes make it available.
requestContext := context.WithValue(context.Background(), EchoContextKey, ctx) //nolint:staticcheck

if options != nil {
validationInput.Options = &options.Options
validationInput.ParamDecoder = options.ParamDecoder
requestContext = context.WithValue(requestContext, UserDataKey, options.UserData) //nolint:staticcheck
}
Expand Down Expand Up @@ -266,6 +278,20 @@ func GetUserData(c context.Context) any {
return c.Value(UserDataKey)
}

// filterOptions returns the openapi3filter.Options to validate a request with:
// a copy of options.Options, so the caller's struct is never modified, with
// KeepValidatedValue enabled unless options.DiscardValidatedValue is set. With
// nil options, KeepValidatedValue is enabled and everything else is left at
// its default.
func filterOptions(options *Options) *openapi3filter.Options {
if options == nil {
return &openapi3filter.Options{KeepValidatedValue: true}
}
filterOpts := options.Options
filterOpts.KeepValidatedValue = !options.DiscardValidatedValue
return &filterOpts
}

// attempt to get the skipper from the options whether it is set or not
func getSkipperFromOptions(options *Options) echomiddleware.Skipper {
if options == nil {
Expand Down
94 changes: 94 additions & 0 deletions oapi_validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -740,6 +740,100 @@ func TestPrefixKeepValidatedValue(t *testing.T) {
assert.True(t, called, "Handler should have been called")
}

// TestKeepValidatedValueDefault checks that openapi3filter's
// KeepValidatedValue is enabled by default, so the handler's request body
// exposes the validated value, that DiscardValidatedValue turns it off, and
// that the caller's Options are not modified.
func TestKeepValidatedValueDefault(t *testing.T) {
const body = `{"name":"Marcin"}`

tests := []struct {
name string
options *Options // nil means OapiRequestValidator(spec)
// wantValue is whether the handler's body should implement
// ValidatedValue().
wantValue bool
}{
{
name: "no options",
wantValue: true,
},
{
name: "empty options",
options: &Options{},
wantValue: true,
},
{
name: "DiscardValidatedValue",
options: &Options{DiscardValidatedValue: true},
wantValue: false,
},
{
name: "DiscardValidatedValue overrides openapi3filter KeepValidatedValue",
options: &Options{
DiscardValidatedValue: true,
Options: openapi3filter.Options{KeepValidatedValue: true},
},
wantValue: false,
},
{
// The body is not validated, so there is no validated value to
// keep, but the request must still go through untouched.
name: "ExcludeRequestBody",
options: &Options{
Options: openapi3filter.Options{ExcludeRequestBody: true},
},
wantValue: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
spec, err := openapi3.NewLoader().LoadFromData(testSchema)
require.NoError(t, err, "Error initializing OpenAPI spec")

var callerOpts openapi3filter.Options
e := echo.New()
if tt.options == nil {
e.Use(OapiRequestValidator(spec))
} else {
tt.options.SilenceServersWarning = true
callerOpts = tt.options.Options
e.Use(OapiRequestValidatorWithOptions(spec, tt.options))
}

called := false
e.POST("/resource", func(c echo.Context) error {
called = true
vv, ok := c.Request().Body.(interface{ ValidatedValue() any })
if tt.wantValue {
if assert.True(t, ok, "request body should implement ValidatedValue(), got %T", c.Request().Body) {
assert.Equal(t, map[string]any{"name": "Marcin"}, vv.ValidatedValue())
}
} else {
assert.False(t, ok, "request body should not implement ValidatedValue(), got %T", c.Request().Body)
}
data, err := io.ReadAll(c.Request().Body)
require.NoError(t, err)
assert.Equal(t, body, string(data))
return c.NoContent(http.StatusNoContent)
})

r := httptest.NewRequest(http.MethodPost, "http://test.hostname/resource", strings.NewReader(body))
r.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
e.ServeHTTP(rec, r)

assert.Equal(t, http.StatusNoContent, rec.Code)
assert.True(t, called, "Handler should have been called")
if tt.options != nil {
assert.Equal(t, callerOpts.KeepValidatedValue, tt.options.Options.KeepValidatedValue,
"the caller's openapi3filter.Options should not be modified")
}
})
}
}

// TestPrefixEncodedPathParams checks that a percent-encoded path parameter
// still routes under a Prefix: URL.RawPath must be trimmed along with
// URL.Path, and both restored for the handler.
Expand Down
Loading