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

### Validated request body

The middleware enables kin-openapi's [`openapi3filter.Options.KeepValidatedValue`](https://pkg.go.dev/github.com/getkin/kin-openapi/openapi3filter#Options) by default. After a request body passes validation, the body that handlers see (`c.Request().Body`) also has a `ValidatedValue() any` method returning the decoded value that was validated, and it can still be read as usual. This lets a handler, such as an `oapi-codegen` strict server, bind exactly the value that was validated rather than decoding the body a second time, which could interpret it differently (for example, `encoding/json` matches object member names case-insensitively, so `{"role":"user","ROLE":"admin"}` would validate as `role: user` but decode as `Role: "admin"`). To drop the decoded value after validation instead, for instance to limit memory retention for very large request bodies, set `Options.DiscardValidatedValue`; it overrides `Options.Options.KeepValidatedValue`.

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

## FAQs

### What versions of Echo does this support?
Expand Down
25 changes: 24 additions & 1 deletion 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 @@ -181,14 +191,14 @@ func ValidateRequestFromContext(ctx *echo.Context, router routers.Router, option
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 @@ -256,6 +266,19 @@ 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. nil
// options get the defaults, which keep the validated value.
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
77 changes: 76 additions & 1 deletion oapi_validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -549,7 +549,9 @@ func TestOapiRequestValidatorWithPrefixRequestBody(t *testing.T) {
require.NoError(t, got.readErr)
assert.Equal(t, body, string(got.body))
assert.Equal(t, "/api/resource", got.path)
assert.False(t, got.hasValidated, "body should only expose ValidatedValue() with KeepValidatedValue")
// KeepValidatedValue is on by default.
require.True(t, got.hasValidated, "handler's body should expose ValidatedValue()")
assert.Equal(t, map[string]any{"name": "x"}, got.validated)

// A body that doesn't match the schema is still rejected.
got = prefixedHandlerResult{}
Expand Down Expand Up @@ -645,6 +647,79 @@ components:
})
}

// TestOapiRequestValidatorKeepValidatedValue checks that the middleware enables
// openapi3filter.Options.KeepValidatedValue unless Options.DiscardValidatedValue
// is set, so the handler's request body exposes the validated value through
// ValidatedValue() while remaining readable.
func TestOapiRequestValidatorKeepValidatedValue(t *testing.T) {
// Not JSON-canonical on purpose: the handler must see these exact bytes,
// not a re-encoding of the decoded value.
const body = `{ "name" : "x" }`

spec, err := openapi3.NewLoader().LoadFromData(testSchema)
require.NoError(t, err)

// serve sends body to POST /resource through mw and returns what the
// handler saw.
serve := func(t *testing.T, mw echo.MiddlewareFunc) prefixedHandlerResult {
t.Helper()
e := echo.New()
e.Use(mw)
var got prefixedHandlerResult
e.POST("/resource", got.handler(http.StatusNoContent))

rec := httptest.NewRecorder()
e.ServeHTTP(rec, newRawJSONPost("http://test.hostname/resource", body))

require.Equal(t, http.StatusNoContent, rec.Code, rec.Body.String())
require.True(t, got.called, "handler should have been called")
require.NoError(t, got.readErr)
assert.Equal(t, body, string(got.body), "body should still be readable with the original bytes")
return got
}

t.Run("default without options", func(t *testing.T) {
got := serve(t, OapiRequestValidator(spec))
require.True(t, got.hasValidated, "handler's body should expose ValidatedValue()")
assert.Equal(t, map[string]any{"name": "x"}, got.validated)
})

t.Run("default with empty options", func(t *testing.T) {
options := &Options{}
got := serve(t, OapiRequestValidatorWithOptions(spec, options))
require.True(t, got.hasValidated, "handler's body should expose ValidatedValue()")
assert.Equal(t, map[string]any{"name": "x"}, got.validated)
assert.False(t, options.Options.KeepValidatedValue, "the caller's options should not be modified")
})

t.Run("DiscardValidatedValue", func(t *testing.T) {
got := serve(t, OapiRequestValidatorWithOptions(spec, &Options{
DiscardValidatedValue: true,
}))
assert.False(t, got.hasValidated, "handler's body should not expose ValidatedValue()")
})

t.Run("DiscardValidatedValue overrides openapi3filter KeepValidatedValue", func(t *testing.T) {
got := serve(t, OapiRequestValidatorWithOptions(spec, &Options{
DiscardValidatedValue: true,
Options: openapi3filter.Options{
KeepValidatedValue: true,
},
}))
assert.False(t, got.hasValidated, "handler's body should not expose ValidatedValue()")
})

t.Run("ExcludeRequestBody", func(t *testing.T) {
got := serve(t, OapiRequestValidatorWithOptions(spec, &Options{
Options: openapi3filter.Options{
ExcludeRequestBody: true,
},
}))
// The body was not validated, so there is no validated value to keep.
assert.False(t, got.hasValidated, "handler's body should not expose ValidatedValue()")
})
}

// TestOapiRequestValidatorWithPrefixEncodedPathParams checks that the prefix
// is trimmed from URL.RawPath as well as URL.Path, and both are restored
// before the handler runs.
Expand Down
Loading