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 @@ -4,4 +4,10 @@

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

## Validated request body

The middleware enables kin-openapi's `openapi3filter.Options.KeepValidatedValue` by default. Fiber does not run on `net/http`, so the validator works on a converted copy of the request; after a request body passes validation, the middleware stores the decoded value that was validated in the Fiber context under `ValidatedBodyLocalsKey` (`x-oapi-codegen-validated-body`), readable with `ValidatedBody(c)`. The body bytes seen by the handler (`c.Body()`) are unchanged. oapi-codegen strict servers that support it bind that value instead of decoding the body a second time, so the handler sees exactly what was validated (for example, `encoding/json`'s case-insensitive member matching can no longer make a request validate as one value but bind as another).

To opt out, for instance to limit memory retention for very large request bodies, set `Options.DiscardValidatedValue = true`. This setting overrides `Options.Options.KeepValidatedValue`.

Licensed under the Apache-2.0.
43 changes: 42 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 decoded
// request body value that passed validation is stored in the Fiber context
// under ValidatedBodyLocalsKey, 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
}

// ValidatedBodyLocalsKey is the c.Locals key under which the middleware stores
// the decoded request body value that passed validation (see
// Options.DiscardValidatedValue). The value is what kin-openapi validated:
// nil, bool, string, json.Number, []any or map[string]any.
//
// Strict servers generated by oapi-codegen look the value up under this same
// key, where it is defined independently so generated code does not import
// this module. Keep the two in sync.
const ValidatedBodyLocalsKey = "x-oapi-codegen-validated-body"

// ValidatedBody returns the request body value that passed validation, as
// stored by the middleware under ValidatedBodyLocalsKey, and whether one was
// stored for this request.
func ValidatedBody(c *fiber.Ctx) (any, bool) {
v := c.Locals(ValidatedBodyLocalsKey)
return v, v != nil
}

// OapiRequestValidatorWithOptions creates a validator from a swagger object, with validation options
Expand Down Expand Up @@ -124,9 +152,15 @@ func ValidateRequestFromContext(c *fiber.Ctx, router routers.Router, options *Op
requestContext := context.WithValue(context.Background(), ctxKeyFiberContext{}, c) //nolint:staticcheck

if options != nil {
requestValidationInput.Options = &options.Options
// Copy the openapi3filter options so the caller's struct is not
// mutated when applying our KeepValidatedValue default.
filterOptions := options.Options
filterOptions.KeepValidatedValue = !options.DiscardValidatedValue
requestValidationInput.Options = &filterOptions
requestValidationInput.ParamDecoder = options.ParamDecoder
requestContext = context.WithValue(requestContext, ctxKeyUserData{}, options.UserData) //nolint:staticcheck
} else {
requestValidationInput.Options = &openapi3filter.Options{KeepValidatedValue: true}
}

err = openapi3filter.ValidateRequest(requestContext, requestValidationInput)
Expand All @@ -152,6 +186,13 @@ func ValidateRequestFromContext(c *fiber.Ctx, router routers.Router, options *Op
return fmt.Errorf("error validating request: %w", err)
}
}

// The validator ran on a net/http copy of the request, so the validated
// value it left on that copy's body has to be carried over to the Fiber
// context by hand for handlers to reach it.
if vb, ok := r.Body.(interface{ ValidatedValue() any }); ok {
c.Locals(ValidatedBodyLocalsKey, vb.ValidatedValue())
}
return nil
}

Expand Down
74 changes: 74 additions & 0 deletions oapi_validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -432,3 +432,77 @@ func TestOapiRequestValidatorWithOptionsMultiErrorAndCustomHandler(t *testing.T)
called = false
}
}

func TestOapiRequestValidatorKeepValidatedValue(t *testing.T) {
swagger, err := openapi3.NewLoader().LoadFromData(testSchema)
require.NoError(t, err, "Error initializing swagger")

tests := []struct {
name string
options *Options
wantValidated bool
}{
{
name: "nil options keep the validated value",
options: nil,
wantValidated: true,
},
{
name: "empty options keep the validated value",
options: &Options{},
wantValidated: true,
},
{
name: "DiscardValidatedValue drops the validated value",
options: &Options{
DiscardValidatedValue: true,
// DiscardValidatedValue overrides the openapi3filter setting.
Options: openapi3filter.Options{KeepValidatedValue: true},
},
wantValidated: false,
},
{
name: "ExcludeRequestBody skips body validation",
options: &Options{
Options: openapi3filter.Options{ExcludeRequestBody: true},
},
wantValidated: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
app := fiber.New()
if tt.options == nil {
app.Use(OapiRequestValidator(swagger))
} else {
app.Use(OapiRequestValidatorWithOptions(swagger, tt.options))
}

var got any
var found bool
var rawBody string
app.Post("/resource", func(c *fiber.Ctx) error {
got, found = ValidatedBody(c)
rawBody = string(c.Body())
return c.SendStatus(http.StatusNoContent)
})

body := struct {
Name string `json:"name"`
}{Name: "Marcin"}
res := doPost(t, app, "https://deepmap.ai/resource", body)
require.Equal(t, http.StatusNoContent, res.StatusCode)
// The original body is untouched for the handler either way.
assert.JSONEq(t, `{"name":"Marcin"}`, rawBody)

if !tt.wantValidated {
assert.False(t, found)
assert.Nil(t, got)
return
}
require.True(t, found, "validated value should be stored under ValidatedBodyLocalsKey")
assert.Equal(t, map[string]any{"name": "Marcin"}, got)
})
}
}
Loading