Skip to content

Commit

Permalink
feat: openapi request and response validation middleware
Browse files Browse the repository at this point in the history
This introduces a simple validation middleware which makes use of the
recently added openapi3filter.Validator (see
https://pkg.go.dev/github.com/getkin/kin-openapi/openapi3filter#example-Validator)
  • Loading branch information
cmars committed Jan 14, 2022
1 parent 421434d commit bf75b03
Show file tree
Hide file tree
Showing 6 changed files with 594 additions and 19 deletions.
1 change: 1 addition & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU=
github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
Expand Down
3 changes: 3 additions & 0 deletions versionware/export_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package versionware

var DefaultValidatorConfig = defaultValidatorConfig
8 changes: 5 additions & 3 deletions versionware/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ func NewHandler(vhs ...VersionHandler) *Handler {
h := &Handler{
handlers: make([]http.Handler, len(vhs)),
versions: make([]vervet.Version, len(vhs)),
errFunc: defaultErrorHandler,
errFunc: DefaultVersionError,
}
handlerVersions := map[string]http.Handler{}
for i := range vhs {
Expand All @@ -59,8 +59,10 @@ func NewHandler(vhs ...VersionHandler) *Handler {
return h
}

func defaultErrorHandler(w http.ResponseWriter, r *http.Request, status int, err error) {
http.Error(w, err.Error(), status)
// DefaultVersionError provides a basic implementation of VersionErrorHandler
// that uses http.Error.
func DefaultVersionError(w http.ResponseWriter, r *http.Request, status int, err error) {
http.Error(w, http.StatusText(status), status)
}

// HandleErrors changes the default error handler to the provided function. It
Expand Down
32 changes: 16 additions & 16 deletions versionware/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,19 +75,16 @@ func TestHandler(t *testing.T) {
c.Assert(err, qt.IsNil)
}),
}}...)
s := httptest.NewServer(h)
c.Cleanup(s.Close)

tests := []struct {
requested, resolved string
contents string
status int
}{{
"2021-08-31", "", "no matching version\n", 404,
"2021-08-31", "", "Not Found\n", 404,
}, {
"bad wolf", "", "400 Bad Request", 400,
"", "", "Bad Request\n", 400,
}, {
"", "", "missing required query parameter 'version'\n", 400,
"bad wolf", "", "400 Bad Request", 400,
}, {
"2021-09-16", "2021-09-01", "sept", 200,
}, {
Expand All @@ -100,15 +97,18 @@ func TestHandler(t *testing.T) {
"2023-02-05", "2021-11-01", "nov", 200,
}}
for i, test := range tests {
c.Logf("test#%d: requested %s resolved %s", i, test.requested, test.resolved)
req, err := http.NewRequest("GET", s.URL+"?version="+test.requested, nil)
c.Assert(err, qt.IsNil)
resp, err := s.Client().Do(req)
c.Assert(err, qt.IsNil)
defer resp.Body.Close()
c.Assert(resp.StatusCode, qt.Equals, test.status)
contents, err := ioutil.ReadAll(resp.Body)
c.Assert(err, qt.IsNil)
c.Assert(string(contents), qt.Equals, test.contents)
c.Run(fmt.Sprintf("%d requested %s resolved %s", i, test.requested, test.resolved), func(c *qt.C) {
s := httptest.NewServer(h)
c.Cleanup(s.Close)
req, err := http.NewRequest("GET", s.URL+"?version="+test.requested, nil)
c.Assert(err, qt.IsNil)
resp, err := s.Client().Do(req)
c.Assert(err, qt.IsNil)
defer resp.Body.Close()
c.Assert(resp.StatusCode, qt.Equals, test.status)
contents, err := ioutil.ReadAll(resp.Body)
c.Assert(err, qt.IsNil)
c.Assert(string(contents), qt.Equals, test.contents)
})
}
}
125 changes: 125 additions & 0 deletions versionware/validator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package versionware

import (
"fmt"
"net/http"
"sort"

"github.com/getkin/kin-openapi/openapi3"
"github.com/getkin/kin-openapi/openapi3filter"
"github.com/getkin/kin-openapi/routers/gorillamux"

"github.com/snyk/vervet"
)

// Validator provides versioned OpenAPI validation middleware for HTTP requests
// and responses.
type Validator struct {
versions vervet.VersionSlice
validators []*openapi3filter.Validator
errFunc VersionErrorHandler
}

// ValidatorConfig defines how a new Validator may be configured.
type ValidatorConfig struct {
// ServerURL overrides the server URLs in the given OpenAPI specs to match
// the URL of requests reaching the backend service. If unset, requests
// must match the servers defined in OpenAPI specs.
ServerURL string

// VersionError is called on any error that occurs when trying to resolve the
// API version.
VersionError VersionErrorHandler

// Options further configure the request and response validation. See
// https://pkg.go.dev/github.com/getkin/kin-openapi/openapi3filter#ValidatorOption
// for available options.
Options []openapi3filter.ValidatorOption
}

var defaultValidatorConfig = ValidatorConfig{
VersionError: DefaultVersionError,
Options: []openapi3filter.ValidatorOption{
openapi3filter.OnErr(func(w http.ResponseWriter, status int, code openapi3filter.ErrCode, _ error) {
statusText := http.StatusText(http.StatusInternalServerError)
switch code {
case openapi3filter.ErrCodeCannotFindRoute:
statusText = "Not Found"
case openapi3filter.ErrCodeRequestInvalid:
statusText = "Bad Request"
}
http.Error(w, statusText, status)
}),
},
}

// NewValidator returns a new validation middleware, which validates versioned
// requests according to the given OpenAPI spec versions. For configuration
// defaults, a nil config may be used.
func NewValidator(config *ValidatorConfig, docs ...*openapi3.T) (*Validator, error) {
if config == nil {
config = &defaultValidatorConfig
}
if config.ServerURL != "" {
for i := range docs {
docs[i].Servers = []*openapi3.Server{{URL: config.ServerURL}}
}
}
v := &Validator{
versions: make([]vervet.Version, len(docs)),
validators: make([]*openapi3filter.Validator, len(docs)),
errFunc: config.VersionError,
}
validatorVersions := map[string]*openapi3filter.Validator{}
for i := range docs {
if config.ServerURL != "" {
docs[i].Servers = []*openapi3.Server{{URL: config.ServerURL}}
}
versionStr, err := vervet.ExtensionString(docs[i].ExtensionProps, vervet.ExtSnykApiVersion)
if err != nil {
return nil, err
}
version, err := vervet.ParseVersion(versionStr)
if err != nil {
return nil, err
}
v.versions[i] = *version
router, err := gorillamux.NewRouter(docs[i])
if err != nil {
return nil, err
}
validatorVersions[version.String()] = openapi3filter.NewValidator(router, config.Options...)
}
sort.Sort(v.versions)
for i := range v.versions {
v.validators[i] = validatorVersions[v.versions[i].String()]
}
return v, nil
}

// Middleware returns an http.Handler which wraps the given handler with
// request and response validation according to the requested API version.
func (v *Validator) Middleware(h http.Handler) http.Handler {
handlers := make([]http.Handler, len(v.validators))
for i := range v.versions {
handlers[i] = v.validators[i].Middleware(h)
}
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
versionParam := req.URL.Query().Get("version")
if versionParam == "" {
v.errFunc(w, req, http.StatusBadRequest, fmt.Errorf("missing required query parameter 'version'"))
return
}
requested, err := vervet.ParseVersion(versionParam)
if err != nil {
v.errFunc(w, req, http.StatusBadRequest, err)
return
}
resolvedIndex, err := v.versions.ResolveIndex(*requested)
if err != nil {
v.errFunc(w, req, http.StatusNotFound, err)
return
}
handlers[resolvedIndex].ServeHTTP(w, req)
})
}
Loading

0 comments on commit bf75b03

Please sign in to comment.