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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,11 @@ Middleware for the [Fiber v3 web server](https://github.com/gofiber/fiber) for u
> [!NOTE]
> Using Fiber v2? You want [`oapi-codegen/fiber-middleware`](https://github.com/oapi-codegen/fiber-middleware) instead.

## Validated request body value

By default, `OapiRequestValidator` and `OapiRequestValidatorWithOptions` enable kin-openapi's `openapi3filter.Options.KeepValidatedValue`, so the validated request body exposes the decoded value through a `ValidatedValue() any` method. This lets a handler, such as an oapi-codegen strict server, bind the exact value that passed validation instead of decoding the body a second time. Set `Options.DiscardValidatedValue` to `true` to turn this off, for example to limit memory retention for very large request bodies; it overrides `Options.Options.KeepValidatedValue`.

> [!NOTE]
> This middleware currently validates a copy of the request (built with Fiber's `adaptor.ConvertRequest`), so Fiber handlers do not yet receive the validated value. The default and the `DiscardValidatedValue` option exist for consistency with the other oapi-codegen middlewares and for a future change that exposes the value to handlers.

Licensed under the Apache-2.0.
29 changes: 28 additions & 1 deletion oapi_validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,21 @@ 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
}

// OapiRequestValidatorWithOptions creates a validator from a swagger object, with validation options
Expand Down Expand Up @@ -117,14 +132,14 @@ func ValidateRequestFromContext(c fiber.Ctx, router routers.Router, options *Opt
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 Expand Up @@ -155,6 +170,18 @@ func ValidateRequestFromContext(c fiber.Ctx, router routers.Router, options *Opt
return nil
}

// filterOptions returns the openapi3filter.Options used to validate a request.
// It works on a copy, so the caller's Options are never mutated, and enables
// KeepValidatedValue unless Options.DiscardValidatedValue is set.
func filterOptions(options *Options) *openapi3filter.Options {
if options == nil {
return &openapi3filter.Options{KeepValidatedValue: true}
}
filterOpts := options.Options
filterOpts.KeepValidatedValue = !options.DiscardValidatedValue
return &filterOpts
}

// GetFiberContext gets the fiber context from within requests. It returns
// nil if not found or wrong type.
func GetFiberContext(c context.Context) fiber.Ctx {
Expand Down
150 changes: 150 additions & 0 deletions oapi_validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -432,3 +432,153 @@ 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) {
authFunc := func(context.Context, *openapi3filter.AuthenticationInput) error { return nil }
options := &Options{
Options: openapi3filter.Options{
ExcludeRequestBody: true,
MultiError: true,
SkipSettingDefaults: true,
AuthenticationFunc: authFunc,
},
}

got := filterOptions(options)

assert.True(t, got.KeepValidatedValue)
assert.True(t, got.ExcludeRequestBody)
assert.True(t, got.MultiError)
assert.True(t, got.SkipSettingDefaults)
assert.NotNil(t, got.AuthenticationFunc)

assert.NotSame(t, &options.Options, got, "filterOptions must return a copy")
assert.False(t, options.Options.KeepValidatedValue, "caller's Options must not be mutated")
assert.True(t, options.Options.ExcludeRequestBody)
})
}

// TestValidatedValueSetting checks that requests validate the same way with
// KeepValidatedValue on (the default) and off (DiscardValidatedValue), and
// that the fiber handler still sees the original request body either way.
func TestValidatedValueSetting(t *testing.T) {
swagger, err := openapi3.NewLoader().LoadFromData(testSchema)
require.NoError(t, err, "Error initializing swagger")

configs := []struct {
name string
options *Options
}{
{name: "nil options", options: nil},
{name: "default options", options: &Options{}},
{name: "DiscardValidatedValue", options: &Options{DiscardValidatedValue: true}},
}

requests := []struct {
name string
method string
url string
body interface{}
wantStatus int
wantCalled bool
}{
{
name: "valid body",
method: http.MethodPost,
url: "https://deepmap.ai/resource",
body: map[string]interface{}{"name": "Marcin"},
wantStatus: http.StatusNoContent,
wantCalled: true,
},
{
name: "invalid body",
method: http.MethodPost,
url: "https://deepmap.ai/resource",
body: map[string]interface{}{"name": 7},
wantStatus: http.StatusBadRequest,
wantCalled: false,
},
{
name: "valid query parameter",
method: http.MethodGet,
url: "https://deepmap.ai/resource?id=50",
wantStatus: http.StatusNoContent,
wantCalled: true,
},
{
name: "invalid query parameter",
method: http.MethodGet,
url: "https://deepmap.ai/resource?id=500",
wantStatus: http.StatusBadRequest,
wantCalled: false,
},
}

for _, cfg := range configs {
t.Run(cfg.name, func(t *testing.T) {
app := fiber.New()
app.Use(OapiRequestValidatorWithOptions(swagger, cfg.options))

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

for _, tc := range requests {
t.Run(tc.name, func(t *testing.T) {
called = false
handlerBody = nil

var res *http.Response
if tc.method == http.MethodPost {
res = doPost(t, app, tc.url, tc.body)
} else {
res = doGet(t, app, tc.url)
}

assert.Equal(t, tc.wantStatus, res.StatusCode)
assert.Equal(t, tc.wantCalled, called)

if tc.wantCalled && tc.body != nil {
want, err := json.Marshal(tc.body)
require.NoError(t, err)
assert.JSONEq(t, string(want), string(handlerBody),
"handler must see the original request body")
}
})
}
})
}
}
Loading