diff --git a/README.md b/README.md index 8056925..8717cbf 100644 --- a/README.md +++ b/README.md @@ -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? diff --git a/oapi_validate.go b/oapi_validate.go index b44fbec..2fb595a 100644 --- a/oapi_validate.go +++ b/oapi_validate.go @@ -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. @@ -181,6 +191,7 @@ 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 @@ -188,7 +199,6 @@ func ValidateRequestFromContext(ctx *echo.Context, router routers.Router, option 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 } @@ -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 { diff --git a/oapi_validate_test.go b/oapi_validate_test.go index 83adb10..1021cf1 100644 --- a/oapi_validate_test.go +++ b/oapi_validate_test.go @@ -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{} @@ -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.