Skip to content
Closed
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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,23 @@

Middleware for the [Fiber web server](https://github.com/gofiber/fiber) for use with [deepmap/oapi-codegen](https://github.com/deepmap/oapi-codegen).

## Options

`OapiRequestValidatorWithOptions` accepts an `Options` struct whose `Options`
field is passed through to kin-openapi's `openapi3filter`.

This middleware enables `openapi3filter.Options.KeepValidatedValue` by default,
which keeps the decoded, validated request body on the validated request behind
a `ValidatedValue()` method, so that a handler such as an oapi-codegen strict
server can bind exactly the value that was validated rather than decoding the
body a second time. Set `Options.DiscardValidatedValue` to `true` to turn this
off, for instance to limit memory retention for very large request bodies; it
overrides any `KeepValidatedValue` set in `Options.Options`.

Note that fiber handlers do not receive the validated value yet: this
middleware validates a `net/http` copy of the request converted from the fiber
context, so nothing kept on that copy reaches the handler. The default is set
for consistency with the other oapi-codegen middlewares and for a future change
that exposes the value.

Licensed under the Apache-2.0.
30 changes: 29 additions & 1 deletion oapi_validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,34 @@ type Options struct {
ParamDecoder openapi3filter.ContentParameterDecoder
UserData interface{}
MultiErrorHandler MultiErrorHandler
// 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.
//
// Note: this middleware currently validates a copy of the request, so fiber
// handlers do not yet observe the validated value; the setting is kept for
// consistency with the other oapi-codegen middlewares and for a future change
// that exposes it.
DiscardValidatedValue bool
}

// filterOptions returns the openapi3filter.Options to validate with: a copy of
// options.Options (or the zero value when options is nil) with
// KeepValidatedValue set according to options.DiscardValidatedValue. The
// caller's Options are never modified.
func filterOptions(options *Options) *openapi3filter.Options {
if options == nil {
return &openapi3filter.Options{KeepValidatedValue: true}
}
filterOpts := options.Options
filterOpts.KeepValidatedValue = !options.DiscardValidatedValue
return &filterOpts
}

// OapiRequestValidatorWithOptions creates a validator from a swagger object, with validation options
Expand Down Expand Up @@ -117,14 +145,14 @@ func ValidateRequestFromContext(c *fiber.Ctx, router routers.Router, options *Op
Request: r,
PathParams: pathParams,
Route: route,
Options: filterOptions(options),
}

// Pass the fiber context into the request validator, so that any callbacks
// which it invokes make it available.
requestContext := context.WithValue(context.Background(), ctxKeyFiberContext{}, c) //nolint:staticcheck

if options != nil {
requestValidationInput.Options = &options.Options
requestValidationInput.ParamDecoder = options.ParamDecoder
requestContext = context.WithValue(requestContext, ctxKeyUserData{}, options.UserData) //nolint:staticcheck
}
Expand Down
165 changes: 165 additions & 0 deletions oapi_validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -432,3 +432,168 @@ func TestOapiRequestValidatorWithOptionsMultiErrorAndCustomHandler(t *testing.T)
called = false
}
}

func TestFilterOptions(t *testing.T) {
t.Run("nil options keep the validated value", func(t *testing.T) {
got := filterOptions(nil)
require.NotNil(t, got)
assert.True(t, got.KeepValidatedValue)
})

t.Run("zero options keep the validated value", func(t *testing.T) {
got := filterOptions(&Options{})
require.NotNil(t, got)
assert.True(t, got.KeepValidatedValue)
})

t.Run("DiscardValidatedValue turns it off", func(t *testing.T) {
got := filterOptions(&Options{DiscardValidatedValue: true})
require.NotNil(t, got)
assert.False(t, got.KeepValidatedValue)
})

t.Run("DiscardValidatedValue overrides Options.KeepValidatedValue", func(t *testing.T) {
got := filterOptions(&Options{
Options: openapi3filter.Options{KeepValidatedValue: true},
DiscardValidatedValue: true,
})
assert.False(t, got.KeepValidatedValue)
})

t.Run("other fields are preserved and the caller's struct is not mutated", func(t *testing.T) {
options := &Options{
Options: openapi3filter.Options{
ExcludeRequestBody: true,
IncludeResponseStatus: true,
MultiError: true,
SkipSettingDefaults: true,
},
}
got := filterOptions(options)
assert.True(t, got.KeepValidatedValue)
assert.True(t, got.ExcludeRequestBody)
assert.True(t, got.IncludeResponseStatus)
assert.True(t, got.MultiError)
assert.True(t, got.SkipSettingDefaults)

assert.NotSame(t, &options.Options, got)
assert.False(t, options.Options.KeepValidatedValue, "caller's Options must not be modified")
assert.False(t, options.DiscardValidatedValue)
})
}

// TestValidatedValueOption checks that request validation behaves the same
// whether the validated value is kept (the default) or discarded. Fiber
// handlers cannot observe the validated value yet, because validation runs on
// a converted copy of the request, so this only checks what is observable:
// valid requests reach the handler with their body intact, invalid ones are
// rejected.
func TestValidatedValueOption(t *testing.T) {
swagger, err := openapi3.NewLoader().LoadFromData(testSchema)
require.NoError(t, err, "Error initializing swagger")

tests := []struct {
name string
middleware func() fiber.Handler
}{
{
name: "default without options",
middleware: func() fiber.Handler { return OapiRequestValidator(swagger) },
},
{
name: "default with zero options",
middleware: func() fiber.Handler { return OapiRequestValidatorWithOptions(swagger, &Options{}) },
},
{
name: "DiscardValidatedValue",
middleware: func() fiber.Handler {
return OapiRequestValidatorWithOptions(swagger, &Options{DiscardValidatedValue: true})
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
app := fiber.New()
app.Use(tt.middleware())

called := false
var gotBody []byte
app.Get("/resource", func(c *fiber.Ctx) error {
called = true
return nil
})
app.Post("/resource", func(c *fiber.Ctx) error {
called = true
gotBody = append([]byte(nil), c.Body()...)
return c.SendStatus(http.StatusNoContent)
})

// A valid query parameter passes.
{
res := doGet(t, app, "https://deepmap.ai/resource?id=50")
assert.Equal(t, http.StatusOK, res.StatusCode)
assert.True(t, called, "Handler should have been called")
called = false
}

// An out-of-spec query parameter is rejected.
{
res := doGet(t, app, "https://deepmap.ai/resource?id=500")
assert.Equal(t, http.StatusBadRequest, res.StatusCode)
assert.False(t, called, "Handler should not have been called")
called = false
}

// A valid body passes and reaches the handler unchanged.
{
body := map[string]any{"name": "Marcin"}
res := doPost(t, app, "https://deepmap.ai/resource", body)
assert.Equal(t, http.StatusNoContent, res.StatusCode)
assert.True(t, called, "Handler should have been called")
assert.JSONEq(t, `{"name":"Marcin"}`, string(gotBody))
called = false
gotBody = nil
}

// A body that violates the schema is rejected.
{
body := map[string]any{"name": 7}
res := doPost(t, app, "https://deepmap.ai/resource", body)
assert.Equal(t, http.StatusBadRequest, res.StatusCode)
assert.False(t, called, "Handler should not have been called")
called = false
}
})
}
}

// TestValidatedValueOptionPreservesUnexportedOptions checks that copying the
// caller's openapi3filter.Options keeps settings that are only reachable
// through methods, such as the custom schema error function.
func TestValidatedValueOptionPreservesUnexportedOptions(t *testing.T) {
swagger, err := openapi3.NewLoader().LoadFromData(testSchema)
require.NoError(t, err, "Error initializing swagger")

options := &Options{
ErrorHandler: func(c *fiber.Ctx, message string, statusCode int) {
_ = c.Status(statusCode).SendString(message)
},
}
options.Options.WithCustomSchemaErrorFunc(func(*openapi3.SchemaError) string {
return "custom schema error"
})

app := fiber.New()
app.Use(OapiRequestValidatorWithOptions(swagger, options))
app.Post("/resource", func(c *fiber.Ctx) error {
return c.SendStatus(http.StatusNoContent)
})

res := doPost(t, app, "https://deepmap.ai/resource", map[string]any{"name": 7})
assert.Equal(t, http.StatusBadRequest, res.StatusCode)
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
assert.Contains(t, string(body), "custom schema error")
assert.False(t, options.Options.KeepValidatedValue, "caller's Options must not be modified")
}
Loading