diff --git a/README.md b/README.md index 72dc5d1..2d9efe6 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,97 @@ -# `net/http` Middleware +# OpenAPI Validation Middleware for `net/http`-compatible servers + +An HTTP middleware to perform validation of incoming requests via an OpenAPI specification. + +This project is a lightweight wrapper over the excellent [kin-openapi](https://github.com/getkin/kin-openapi) library's [`openapi3filter` package](https://pkg.go.dev/github.com/getkin/kin-openapi/openapi3filter). + +This is _intended_ to be used with code that's generated through [`oapi-codegen`](https://github.com/oapi-codegen/oapi-codegen), but should work otherwise. ⚠️ This README may be for the latest development version, which may contain unreleased changes. Please ensure you're looking at the README for the latest release version. -Middleware for servers that implement `net/http` handlers, for use with [deepmap/oapi-codegen](https://github.com/deepmap/oapi-codegen), which has been tested to work with: +## Usage + +You can add the middleware to your project with: + +```sh +go get github.com/oapi-codegen/nethttp-middleware +``` + +There is a full example of usage in [the Go doc for this project](https://pkg.go.dev/github.com/oapi-codegen/nethttp-middleware#pkg-examples). + +A simplified version of this code is as follows: + +```go +rawSpec := ` +openapi: "3.0.0" +# ... +` +spec, _ := openapi3.NewLoader().LoadFromData([]byte(rawSpec)) + +// NOTE that we need to make sure that the `Servers` aren't set, otherwise the OpenAPI validation middleware will validate that the `Host` header (of incoming requests) are targeting known `Servers` in the OpenAPI spec +// See also: Options#SilenceServersWarning +spec.Servers = nil + +router := http.NewServeMux() + +router.HandleFunc("/resource", func(w http.ResponseWriter, r *http.Request) { + fmt.Printf("%s /resource was called\n", r.Method) + + if r.Method == http.MethodPost { + w.WriteHeader(http.StatusNoContent) + return + } + + w.WriteHeader(http.StatusMethodNotAllowed) +}) + +use := func(r *http.ServeMux, middlewares ...func(next http.Handler) http.Handler) http.Handler { + var s http.Handler + s = r + + for _, mw := range middlewares { + s = mw(s) + } + + return s +} + +// create middleware +mw := middleware.OapiRequestValidatorWithOptions(spec, &middleware.Options{ + Options: openapi3filter.Options{ + AuthenticationFunc: authenticationFunc, + }, +}) + +// then wire it in +server := use(router, mw) + +// now all HTTP routes will be handled by the middleware, and any requests that are invalid will be rejected +``` + +## FAQs + +### Which HTTP servers should this work with? + +If you're using something that's compliant with `net/http` (which should be all Go web frameworks / routers / HTTP servers) it should work as-is. + +We explicitly test with the following servers, as they correspond with versions used by users of [oapi-codegen/oapi-codegen](https://github.com/oapi-codegen/oapi-codegen): - [Chi](https://github.com/go-chi/chi) - [gorilla/mux](https://github.com/gorilla/mux) - [net/http](https://pkg.go.dev/net/http) -But if you're using something that's compliant with `net/http` it should work as-is. +### "This doesn't support ..." / "I think it's a bug that ..." + +As this project is a lightweight wrapper over [kin-openapi](https://github.com/getkin/kin-openapi)'s [`openapi3filter` package](https://pkg.go.dev/github.com/getkin/kin-openapi/openapi3filter), it's _likely_ that any bugs/features are better sent upstream. + +However, it's worth raising an issue here instead, as it'll allow us to triage it before it goes to the kin-openapi maintainers. + +Additionally, as `oapi-codegen` contains [a number of middleware modules](https://github.com/search?q=org%3Aoapi-codegen+middleware&type=repositories), we'll very likely want to implement the same functionality across all the middlewares, so it may take a bit more coordination to get the changes in across our middlewares. + +### I've just updated my version of `kin-openapi`, and now I can't build my code 😠 + +The [kin-openapi](https://github.com/getkin/kin-openapi) project - which we 💜 for providing a great library and set of tooling for interacting with OpenAPI - is a pre-v1 release, which means that they're within their rights to push breaking changes. + +This may lead to breakage in your consuming code, and if so, sorry that's happened! -Licensed under the Apache-2.0. +We'll be aware of the issue, and will work to update both the core `oapi-codegen` and the middlewares accordingly. diff --git a/oapi_validate.go b/oapi_validate.go index 2299d29..9e8b126 100644 --- a/oapi_validate.go +++ b/oapi_validate.go @@ -1,6 +1,10 @@ -// Package middleware implements middleware function for net/http compatible router -// which validates incoming HTTP requests to make sure that they conform to the given OAPI 3.0 specification. -// When OAPI validation fails on the request, we return an HTTP/400. +// Provide HTTP middleware functionality to validate that incoming requests conform to a given OpenAPI 3.x specification. +// +// This provides middleware for any `net/http` conforming HTTP Server. +// +// This package is a lightweight wrapper over https://pkg.go.dev/github.com/getkin/kin-openapi/openapi3filter from https://pkg.go.dev/github.com/getkin/kin-openapi. +// +// This is _intended_ to be used with code that's generated through https://pkg.go.dev/github.com/oapi-codegen/oapi-codegen, but should work otherwise. package nethttpmiddleware import ( @@ -19,24 +23,33 @@ import ( // ErrorHandler is called when there is an error in validation type ErrorHandler func(w http.ResponseWriter, message string, statusCode int) -// MultiErrorHandler is called when oapi returns a MultiError type +// MultiErrorHandler is called when the OpenAPI filter returns an openapi3.MultiError (https://pkg.go.dev/github.com/getkin/kin-openapi/openapi3#MultiError) type MultiErrorHandler func(openapi3.MultiError) (int, error) -// Options to customize request validation, openapi3filter specified options will be passed through. +// Options allows configuring the OapiRequestValidator. type Options struct { - Options openapi3filter.Options - ErrorHandler ErrorHandler + // Options contains any configuration for the underlying `openapi3filter` + Options openapi3filter.Options + // ErrorHandler is called when a validation error occurs. + // + // If not provided, `http.Error` will be called + ErrorHandler ErrorHandler + // MultiErrorHandler is called when there is an openapi3.MultiError (https://pkg.go.dev/github.com/getkin/kin-openapi/openapi3#MultiError) returned by the `openapi3filter`. + // + // If not provided `defaultMultiErrorHandler` will be used. MultiErrorHandler MultiErrorHandler // SilenceServersWarning allows silencing a warning for https://github.com/deepmap/oapi-codegen/issues/882 that reports when an OpenAPI spec has `spec.Servers != nil` SilenceServersWarning bool } -// OapiRequestValidator Creates middleware to validate request by OpenAPI spec. +// OapiRequestValidator Creates the middleware to validate that incoming requests match the given OpenAPI 3.x spec, with a default set of configuration. func OapiRequestValidator(spec *openapi3.T) func(next http.Handler) http.Handler { return OapiRequestValidatorWithOptions(spec, nil) } -// OapiRequestValidatorWithOptions Creates middleware to validate request by OpenAPI spec. +// OapiRequestValidatorWithOptions Creates the middleware to validate that incoming requests match the given OpenAPI 3.x spec, allowing explicit configuration. +// +// NOTE that this may panic if the OpenAPI spec isn't valid, or if it cannot be used to create the middleware func OapiRequestValidatorWithOptions(spec *openapi3.T, options *Options) func(next http.Handler) http.Handler { if spec.Servers != nil && (options == nil || !options.SilenceServersWarning) { log.Println("WARN: OapiRequestValidatorWithOptions called with an OpenAPI spec that has `Servers` set. This may lead to an HTTP 400 with `no matching operation was found` when sending a valid request, as the validator performs `Host` header validation. If you're expecting `Host` header validation, you can silence this warning by setting `Options.SilenceServersWarning = true`. See https://github.com/deepmap/oapi-codegen/issues/882 for more information.") diff --git a/oapi_validate_example_test.go b/oapi_validate_example_test.go new file mode 100644 index 0000000..d0cab9a --- /dev/null +++ b/oapi_validate_example_test.go @@ -0,0 +1,332 @@ +package nethttpmiddleware_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/openapi3filter" + middleware "github.com/oapi-codegen/nethttp-middleware" +) + +func ExampleOapiRequestValidatorWithOptions() { + rawSpec := ` +openapi: "3.0.0" +info: + version: 1.0.0 + title: TestServer +servers: + - url: http://example.com/ +paths: + /resource: + post: + operationId: createResource + responses: + '204': + description: No content + requestBody: + required: true + content: + application/json: + schema: + properties: + name: + type: string + additionalProperties: false + /protected_resource: + get: + operationId: getProtectedResource + security: + - BearerAuth: + - someScope + responses: + '204': + description: no content +components: + securitySchemes: + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT +` + + must := func(err error) { + if err != nil { + panic(err) + } + } + + use := func(r *http.ServeMux, middlewares ...func(next http.Handler) http.Handler) http.Handler { + var s http.Handler + s = r + + for _, mw := range middlewares { + s = mw(s) + } + + return s + } + + logResponseBody := func(rr *httptest.ResponseRecorder) { + if rr.Result().Body != nil { + data, _ := io.ReadAll(rr.Result().Body) + if len(data) > 0 { + fmt.Printf("Response body: %s", data) + } + } + } + + spec, err := openapi3.NewLoader().LoadFromData([]byte(rawSpec)) + must(err) + + // NOTE that we need to make sure that the `Servers` aren't set, otherwise the OpenAPI validation middleware will validate that the `Host` header (of incoming requests) are targeting known `Servers` in the OpenAPI spec + // See also: Options#SilenceServersWarning + spec.Servers = nil + + router := http.NewServeMux() + + router.HandleFunc("/resource", func(w http.ResponseWriter, r *http.Request) { + fmt.Printf("%s /resource was called\n", r.Method) + + if r.Method == http.MethodPost { + w.WriteHeader(http.StatusNoContent) + return + } + + w.WriteHeader(http.StatusMethodNotAllowed) + }) + + router.HandleFunc("/protected_resource", func(w http.ResponseWriter, r *http.Request) { + // NOTE that we're setting up our `authenticationFunc` (below) to /never/ allow any requests in - so if we get a response from this endpoint, our `authenticationFunc` hasn't correctly worked + + if r.Method == http.MethodGet { + w.WriteHeader(http.StatusNoContent) + return + } + + w.WriteHeader(http.StatusMethodNotAllowed) + }) + + authenticationFunc := func(ctx context.Context, ai *openapi3filter.AuthenticationInput) error { + fmt.Printf("`AuthenticationFunc` was called for securitySchemeName=%s\n", ai.SecuritySchemeName) + return fmt.Errorf("this check always fails - don't let anyone in!") + } + + // create middleware + mw := middleware.OapiRequestValidatorWithOptions(spec, &middleware.Options{ + Options: openapi3filter.Options{ + AuthenticationFunc: authenticationFunc, + }, + }) + + // then wire it in + server := use(router, mw) + + // ================================================================================ + fmt.Println("# A request that is malformed is rejected with HTTP 400 Bad Request (with no request body)") + + req, err := http.NewRequest(http.MethodPost, "/resource", bytes.NewReader(nil)) + must(err) + req.Header.Set("Content-Type", "application/json") + + rr := httptest.NewRecorder() + + server.ServeHTTP(rr, req) + + fmt.Printf("Received an HTTP %d response. Expected HTTP 400\n", rr.Code) + logResponseBody(rr) + fmt.Println() + + // ================================================================================ + fmt.Println("# A request that is malformed is rejected with HTTP 400 Bad Request (because an invalid property is sent, and we have `additionalProperties: false`)") + body := map[string]string{ + "invalid": "not expected", + } + + data, err := json.Marshal(body) + must(err) + + req, err = http.NewRequest(http.MethodPost, "/resource", bytes.NewReader(data)) + must(err) + req.Header.Set("Content-Type", "application/json") + + rr = httptest.NewRecorder() + + server.ServeHTTP(rr, req) + + fmt.Printf("Received an HTTP %d response. Expected HTTP 400\n", rr.Code) + logResponseBody(rr) + fmt.Println() + + // ================================================================================ + fmt.Println("# A request that is well-formed is passed through to the Handler") + body = map[string]string{ + "name": "Jamie", + } + + data, err = json.Marshal(body) + must(err) + + req, err = http.NewRequest(http.MethodPost, "/resource", bytes.NewReader(data)) + must(err) + req.Header.Set("Content-Type", "application/json") + + rr = httptest.NewRecorder() + + server.ServeHTTP(rr, req) + + fmt.Printf("Received an HTTP %d response. Expected HTTP 204\n", rr.Code) + logResponseBody(rr) + fmt.Println() + + // ================================================================================ + fmt.Println("# A request to an authenticated endpoint must go through an `AuthenticationFunc`, and if it fails, an HTTP 401 is returned") + + req, err = http.NewRequest(http.MethodGet, "/protected_resource", nil) + must(err) + + rr = httptest.NewRecorder() + + server.ServeHTTP(rr, req) + + fmt.Printf("Received an HTTP %d response. Expected HTTP 401\n", rr.Code) + logResponseBody(rr) + fmt.Println() + + // Output: + // # A request that is malformed is rejected with HTTP 400 Bad Request (with no request body) + // Received an HTTP 400 response. Expected HTTP 400 + // Response body: request body has an error: value is required but missing + // + // # A request that is malformed is rejected with HTTP 400 Bad Request (because an invalid property is sent, and we have `additionalProperties: false`) + // Received an HTTP 400 response. Expected HTTP 400 + // Response body: request body has an error: doesn't match schema: property "invalid" is unsupported + // + // # A request that is well-formed is passed through to the Handler + // POST /resource was called + // Received an HTTP 204 response. Expected HTTP 204 + // + // # A request to an authenticated endpoint must go through an `AuthenticationFunc`, and if it fails, an HTTP 401 is returned + // `AuthenticationFunc` was called for securitySchemeName=BearerAuth + // Received an HTTP 401 response. Expected HTTP 401 + // Response body: security requirements failed: this check always fails - don't let anyone in! +} + +func ExampleOapiRequestValidatorWithOptions_withErrorHandler() { + rawSpec := ` +openapi: "3.0.0" +info: + version: 1.0.0 + title: TestServer +servers: + - url: http://example.com/ +paths: + /resource: + post: + operationId: createResource + responses: + '204': + description: No content + requestBody: + required: true + content: + application/json: + schema: + properties: + name: + type: string + additionalProperties: false +` + + must := func(err error) { + if err != nil { + panic(err) + } + } + + use := func(r *http.ServeMux, middlewares ...func(next http.Handler) http.Handler) http.Handler { + var s http.Handler + s = r + + for _, mw := range middlewares { + s = mw(s) + } + + return s + } + + logResponseBody := func(rr *httptest.ResponseRecorder) { + if rr.Result().Body != nil { + data, _ := io.ReadAll(rr.Result().Body) + if len(data) > 0 { + fmt.Printf("Response body: %s", data) + } + } + } + + spec, err := openapi3.NewLoader().LoadFromData([]byte(rawSpec)) + must(err) + + // NOTE that we need to make sure that the `Servers` aren't set, otherwise the OpenAPI validation middleware will validate that the `Host` header (of incoming requests) are targeting known `Servers` in the OpenAPI spec + // See also: Options#SilenceServersWarning + spec.Servers = nil + + router := http.NewServeMux() + + router.HandleFunc("/resource", func(w http.ResponseWriter, r *http.Request) { + fmt.Printf("%s /resource was called\n", r.Method) + + if r.Method == http.MethodPost { + w.WriteHeader(http.StatusNoContent) + return + } + + w.WriteHeader(http.StatusMethodNotAllowed) + }) + + authenticationFunc := func(ctx context.Context, ai *openapi3filter.AuthenticationInput) error { + fmt.Printf("`AuthenticationFunc` was called for securitySchemeName=%s\n", ai.SecuritySchemeName) + return fmt.Errorf("this check always fails - don't let anyone in!") + } + + errorHandlerFunc := func(w http.ResponseWriter, message string, statusCode int) { + fmt.Printf("ErrorHandler: An HTTP %d was returned by the middleware with error message: request body has an error: value is required but missing\n", statusCode) + http.Error(w, "This was rewritten by the ErrorHandler", statusCode) + } + + // create middleware + mw := middleware.OapiRequestValidatorWithOptions(spec, &middleware.Options{ + Options: openapi3filter.Options{ + AuthenticationFunc: authenticationFunc, + }, + ErrorHandler: errorHandlerFunc, + }) + + // then wire it in + server := use(router, mw) + + // ================================================================================ + fmt.Println("# A request that is malformed is rejected with HTTP 400 Bad Request (with no request body), and is then logged by the ErrorHandler") + + req, err := http.NewRequest(http.MethodPost, "/resource", bytes.NewReader(nil)) + must(err) + req.Header.Set("Content-Type", "application/json") + + rr := httptest.NewRecorder() + + server.ServeHTTP(rr, req) + + fmt.Printf("Received an HTTP %d response. Expected HTTP 400\n", rr.Code) + logResponseBody(rr) + + // Output: + // # A request that is malformed is rejected with HTTP 400 Bad Request (with no request body), and is then logged by the ErrorHandler + // ErrorHandler: An HTTP 400 was returned by the middleware with error message: request body has an error: value is required but missing + // Received an HTTP 400 response. Expected HTTP 400 + // Response body: This was rewritten by the ErrorHandler +}