Repository: getkin/kin-openapi
Pull request #462 (2021-12-13 02:41:58)

Add openapi3 validator middleware
This change introduces middleware which wraps an `http.Handler` with OpenAPI 3 request and response validation.

In developing this middleware, I thought it would benefit others trying to do the same. This is my first time contributing here, happy to answer any questions and discuss further.

Thank you for such an awesome OpenAPI library by the way!

cmars: @fenollp Thanks for the review! I believe I've addressed comments, open to discuss further. PTAL :pray: 

Patch:
diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml
index 4a5b87c..fc619f0 100644
--- a/.github/workflows/go.yml
+++ b/.github/workflows/go.yml
@@ -64,6 +64,7 @@ jobs:
     - if: runner.os == 'Linux'
       run: git --no-pager diff && [[ $(git --no-pager diff --name-only | wc -l) = 0 ]]
 
+    - run: go test -count=10 -v -run TestIssue356 ./routers
     - run: go test ./...
     - run: go test -v -run TestRaceyPatternSchema -race ./...
       env:
diff --git a/openapi3filter/middleware.go b/openapi3filter/middleware.go
new file mode 100644
index 0000000..3709faf
--- /dev/null
+++ b/openapi3filter/middleware.go
@@ -0,0 +1,273 @@
+package openapi3filter
+
+import (
+	"bytes"
+	"io"
+	"io/ioutil"
+	"log"
+	"net/http"
+
+	"github.com/getkin/kin-openapi/routers"
+)
+
+// Validator provides HTTP request and response validation middleware.
+type Validator struct {
+	router  routers.Router
+	errFunc ErrFunc
+	logFunc LogFunc
+	strict  bool
+}
+
+// ErrFunc handles errors that may occur during validation.
+type ErrFunc func(w http.ResponseWriter, status int, code ErrCode, err error)
+
+// LogFunc handles log messages that may occur during validation.
+type LogFunc func(message string, err error)
+
+// ErrCode is used for classification of different types of errors that may
+// occur during validation. These may be used to write an appropriate response
+// in ErrFunc.
+type ErrCode int
+
+const (
+	// ErrCodeOK indicates no error. It is also the default value.
+	ErrCodeOK = 0
+	// ErrCodeCannotFindRoute happens when the validator fails to resolve the
+	// request to a defined OpenAPI route.
+	ErrCodeCannotFindRoute = iota
+	// ErrCodeRequestInvalid happens when the inbound request does not conform
+	// to the OpenAPI 3 specification.
+	ErrCodeRequestInvalid = iota
+	// ErrCodeResponseInvalid happens when the wrapped handler response does
+	// not conform to the OpenAPI 3 specification.
+	ErrCodeResponseInvalid = iota
+)
+
+func (e ErrCode) responseText() string {
+	switch e {
+	case ErrCodeOK:
+		return "OK"
+	case ErrCodeCannotFindRoute:
+		return "not found"
+	case ErrCodeRequestInvalid:
+		return "bad request"
+	default:
+		return "server error"
+	}
+}
+
+// NewValidator returns a new response validation middlware, using the given
+// routes from an OpenAPI 3 specification.
+func NewValidator(router routers.Router, options ...ValidatorOption) *Validator {
+	v := &Validator{
+		router: router,
+		errFunc: func(w http.ResponseWriter, status int, code ErrCode, _ error) {
+			http.Error(w, code.responseText(), status)
+		},
+		logFunc: func(message string, err error) {
+			log.Printf("%s: %v", message, err)
+		},
+	}
+	for i := range options {
+		options[i](v)
+	}
+	return v
+}
+
+// ValidatorOption defines an option that may be specified when creating a
+// Validator.
+type ValidatorOption func(*Validator)
+
+// OnErr provides a callback that handles writing an HTTP response on a
+// validation error. This allows customization of error responses without
+// prescribing a particular form. This callback is only called on response
+// validator errors in Strict mode.
+func OnErr(f ErrFunc) ValidatorOption {
+	return func(v *Validator) {
+		v.errFunc = f
+	}
+}
+
+// OnLog provides a callback that handles logging in the Validator. This allows
+// the validator to integrate with a services' existing logging system without
+// prescribing a particular one.
+func OnLog(f LogFunc) ValidatorOption {
+	return func(v *Validator) {
+		v.logFunc = f
+	}
+}
+
+// Strict, if set, causes an internal server error to be sent if the wrapped
+// handler response fails response validation. If not set, the response is sent
+// and the error is only logged.
+func Strict(strict bool) ValidatorOption {
+	return func(v *Validator) {
+		v.strict = strict
+	}
+}
+
+// Middleware returns an http.Handler which wraps the given handler with
+// request and response validation.
+func (v *Validator) Middleware(h http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		route, pathParams, err := v.router.FindRoute(r)
+		if err != nil {
+			v.logFunc("validation error: failed to find route for "+r.URL.String(), err)
+			v.errFunc(w, http.StatusNotFound, ErrCodeCannotFindRoute, err)
+			return
+		}
+		requestValidationInput := &RequestValidationInput{
+			Request:    r,
+			PathParams: pathParams,
+			Route:      route,
+		}
+		if err = ValidateRequest(r.Context(), requestValidationInput); err != nil {
+			v.logFunc("invalid request", err)
+			v.errFunc(w, http.StatusBadRequest, ErrCodeRequestInvalid, err)
+			return
+		}
+
+		var wr responseWrapper
+		if v.strict {
+			wr = &strictResponseWrapper{w: w}
+		} else {
+			wr = newWarnResponseWrapper(w)
+		}
+
+		h.ServeHTTP(wr, r)
+
+		if err = ValidateResponse(r.Context(), &ResponseValidationInput{
+			RequestValidationInput: requestValidationInput,
+			Status:                 wr.statusCode(),
+			Header:                 wr.Header(),
+			Body:                   ioutil.NopCloser(bytes.NewBuffer(wr.bodyContents())),
+		}); err != nil {
+			v.logFunc("invalid response", err)
+			if v.strict {
+				v.errFunc(w, http.StatusInternalServerError, ErrCodeResponseInvalid, err)
+			}
+			return
+		}
+
+		if err = wr.flushBodyContents(); err != nil {
+			v.logFunc("failed to write response", err)
+		}
+	})
+}
+
+type responseWrapper interface {
+	http.ResponseWriter
+
+	// flushBodyContents writes the buffered response to the client, if it has
+	// not yet been written.
+	flushBodyContents() error
+
+	// statusCode returns the response status code, 0 if not set yet.
+	statusCode() int
+
+	// bodyContents returns the buffered
+	bodyContents() []byte
+}
+
+type warnResponseWrapper struct {
+	w             http.ResponseWriter
+	headerWritten bool
+	status        int
+	body          bytes.Buffer
+	tee           io.Writer
+}
+
+func newWarnResponseWrapper(w http.ResponseWriter) *warnResponseWrapper {
+	wr := &warnResponseWrapper{
+		w: w,
+	}
+	wr.tee = io.MultiWriter(w, &wr.body)
+	return wr
+}
+
+// Write implements http.ResponseWriter.
+func (wr *warnResponseWrapper) Write(b []byte) (int, error) {
+	if !wr.headerWritten {
+		wr.WriteHeader(http.StatusOK)
+	}
+	return wr.tee.Write(b)
+}
+
+// WriteHeader implements http.ResponseWriter.
+func (wr *warnResponseWrapper) WriteHeader(status int) {
+	if !wr.headerWritten {
+		// If the header hasn't been written, record the status for response
+		// validation.
+		wr.status = status
+		wr.headerWritten = true
+	}
+	wr.w.WriteHeader(wr.status)
+}
+
+// Header implements http.ResponseWriter.
+func (wr *warnResponseWrapper) Header() http.Header {
+	return wr.w.Header()
+}
+
+// Flush implements the optional http.Flusher interface.
+func (wr *warnResponseWrapper) Flush() {
+	// If the wrapped http.ResponseWriter implements optional http.Flusher,
+	// pass through.
+	if fl, ok := wr.w.(http.Flusher); ok {
+		fl.Flush()
+	}
+}
+
+func (wr *warnResponseWrapper) flushBodyContents() error {
+	return nil
+}
+
+func (wr *warnResponseWrapper) statusCode() int {
+	return wr.status
+}
+
+func (wr *warnResponseWrapper) bodyContents() []byte {
+	return wr.body.Bytes()
+}
+
+type strictResponseWrapper struct {
+	w             http.ResponseWriter
+	headerWritten bool
+	status        int
+	body          bytes.Buffer
+}
+
+// Write implements http.ResponseWriter.
+func (wr *strictResponseWrapper) Write(b []byte) (int, error) {
+	if !wr.headerWritten {
+		wr.WriteHeader(http.StatusOK)
+	}
+	return wr.body.Write(b)
+}
+
+// WriteHeader implements http.ResponseWriter.
+func (wr *strictResponseWrapper) WriteHeader(status int) {
+	if !wr.headerWritten {
+		wr.status = status
+		wr.headerWritten = true
+	}
+}
+
+// Header implements http.ResponseWriter.
+func (wr *strictResponseWrapper) Header() http.Header {
+	return wr.w.Header()
+}
+
+func (wr *strictResponseWrapper) flushBodyContents() error {
+	wr.w.WriteHeader(wr.status)
+	_, err := wr.w.Write(wr.body.Bytes())
+	return err
+}
+
+func (wr *strictResponseWrapper) statusCode() int {
+	return wr.status
+}
+
+func (wr *strictResponseWrapper) bodyContents() []byte {
+	return wr.body.Bytes()
+}
diff --git a/routers/gorillamux/router.go b/routers/gorillamux/router.go
index eec1f01..cec702a 100644
--- a/routers/gorillamux/router.go
+++ b/routers/gorillamux/router.go
@@ -17,6 +17,8 @@ import (
 	"github.com/gorilla/mux"
 )
 
+var _ routers.Router = &Router{}
+
 // Router helps link http.Request.s and an OpenAPIv3 spec
 type Router struct {
 	muxes  []*mux.Route
@@ -107,10 +109,10 @@ func (r *Router) FindRoute(req *http.Request) (*routers.Route, map[string]string
 			if err := match.MatchErr; err != nil {
 				// What then?
 			}
-			route := r.routes[i]
+			route := *r.routes[i]
 			route.Method = req.Method
 			route.Operation = route.Spec.Paths[route.Path].GetOperation(route.Method)
-			return route, match.Vars, nil
+			return &route, match.Vars, nil
 		}
 		switch match.MatchErr {
 		case nil:
diff --git a/routers/types.go b/routers/types.go
index 3cdbee3..93746cf 100644
--- a/routers/types.go
+++ b/routers/types.go
@@ -8,6 +8,13 @@ import (
 
 // Router helps link http.Request.s and an OpenAPIv3 spec
 type Router interface {
+	// FindRoute matches an HTTP request with the operation it resolves to.
+	// Hosts are matched from the OpenAPIv3 servers key.
+	//
+	// If you experience ErrPathNotFound and have localhost hosts specified as your servers,
+	// turning these server URLs as relative (leaving only the path) should resolve this.
+	//
+	// See openapi3filter for example uses with request and response validation.
 	FindRoute(req *http.Request) (route *Route, pathParams map[string]string, err error)
 }
 


Test patch:
diff --git a/openapi3filter/middleware_test.go b/openapi3filter/middleware_test.go
new file mode 100644
index 0000000..4d88aaf
--- /dev/null
+++ b/openapi3filter/middleware_test.go
@@ -0,0 +1,513 @@
+package openapi3filter_test
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"net/http"
+	"net/http/httptest"
+	"path"
+	"regexp"
+	"strconv"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/getkin/kin-openapi/openapi3"
+	"github.com/getkin/kin-openapi/openapi3filter"
+	"github.com/getkin/kin-openapi/routers/gorillamux"
+)
+
+const validatorSpec = `
+openapi: 3.0.0
+info:
+  title: 'Validator'
+  version: '0.0.0'
+paths:
+  /test:
+    post:
+      operationId: newTest
+      description: create a new test
+      parameters:
+        - in: query
+          name: version
+          schema:
+            type: string
+          required: true
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema: { $ref: '#/components/schemas/TestContents' }
+      responses:
+        '201':
+          description: 'created test'
+          content:
+            application/json:
+              schema: { $ref: '#/components/schemas/TestResource' }
+        '400': { $ref: '#/components/responses/ErrorResponse' }
+        '500': { $ref: '#/components/responses/ErrorResponse' }
+  /test/{id}:
+    get:
+      operationId: getTest
+      description: get a test
+      parameters:
+        - in: path
+          name: id
+          schema:
+            type: string
+          required: true
+        - in: query
+          name: version
+          schema:
+            type: string
+          required: true
+      responses:
+        '200':
+          description: 'respond with test resource'
+          content:
+            application/json:
+              schema: { $ref: '#/components/schemas/TestResource' }
+        '400': { $ref: '#/components/responses/ErrorResponse' }
+        '404': { $ref: '#/components/responses/ErrorResponse' }
+        '500': { $ref: '#/components/responses/ErrorResponse' }
+components:
+  schemas:
+    TestContents:
+      type: object
+      properties:
+        name:
+          type: string
+        expected:
+          type: number
+        actual:
+          type: number
+      required: [name, expected, actual]
+      additionalProperties: false
+    TestResource:
+      type: object
+      properties:
+        id:
+          type: string
+        contents:
+          { $ref: '#/components/schemas/TestContents' }
+      required: [id, contents]
+      additionalProperties: false
+    Error:
+      type: object
+      properties:
+        code:
+          type: string
+        message:
+          type: string
+      required: [code, message]
+      additionalProperties: false
+  responses:
+    ErrorResponse:
+      description: 'an error occurred'
+      content:
+        application/json:
+          schema: { $ref: '#/components/schemas/Error' }
+`
+
+type validatorTestHandler struct {
+	contentType       string
+	getBody, postBody string
+	errBody           string
+	errStatusCode     int
+}
+
+const validatorOkResponse = `{"id": "42", "contents": {"name": "foo", "expected": 9, "actual": 10}}`
+
+func (h validatorTestHandler) withDefaults() validatorTestHandler {
+	if h.contentType == "" {
+		h.contentType = "application/json"
+	}
+	if h.getBody == "" {
+		h.getBody = validatorOkResponse
+	}
+	if h.postBody == "" {
+		h.postBody = validatorOkResponse
+	}
+	if h.errBody == "" {
+		h.errBody = `{"code":"bad","message":"bad things"}`
+	}
+	return h
+}
+
+var testUrlRE = regexp.MustCompile(`^/test(/\d+)?$`)
+
+func (h *validatorTestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	w.Header().Set("Content-Type", h.contentType)
+	if h.errStatusCode != 0 {
+		w.WriteHeader(h.errStatusCode)
+		w.Write([]byte(h.errBody))
+		return
+	}
+	if !testUrlRE.MatchString(r.URL.Path) {
+		w.WriteHeader(http.StatusNotFound)
+		w.Write([]byte(h.errBody))
+		return
+	}
+	switch r.Method {
+	case "GET":
+		w.WriteHeader(http.StatusOK)
+		w.Write([]byte(h.getBody))
+	case "POST":
+		w.WriteHeader(http.StatusCreated)
+		w.Write([]byte(h.postBody))
+	default:
+		http.Error(w, h.errBody, http.StatusMethodNotAllowed)
+	}
+}
+
+func TestValidator(t *testing.T) {
+	doc, err := openapi3.NewLoader().LoadFromData([]byte(validatorSpec))
+	require.NoError(t, err, "failed to load test fixture spec")
+
+	ctx := context.Background()
+	err = doc.Validate(ctx)
+	require.NoError(t, err, "invalid test fixture spec")
+
+	type testRequest struct {
+		method, path, body, contentType string
+	}
+	type testResponse struct {
+		statusCode int
+		body       string
+	}
+	tests := []struct {
+		name     string
+		handler  validatorTestHandler
+		options  []openapi3filter.ValidatorOption
+		request  testRequest
+		response testResponse
+		strict   bool
+	}{{
+		name:    "valid GET",
+		handler: validatorTestHandler{}.withDefaults(),
+		request: testRequest{
+			method: "GET",
+			path:   "/test/42?version=1",
+		},
+		response: testResponse{
+			200, validatorOkResponse,
+		},
+		strict: true,
+	}, {
+		name:    "valid POST",
+		handler: validatorTestHandler{}.withDefaults(),
+		request: testRequest{
+			method:      "POST",
+			path:        "/test?version=1",
+			body:        `{"name": "foo", "expected": 9, "actual": 10}`,
+			contentType: "application/json",
+		},
+		response: testResponse{
+			201, validatorOkResponse,
+		},
+		strict: true,
+	}, {
+		name:    "not found; no GET operation for /test",
+		handler: validatorTestHandler{}.withDefaults(),
+		request: testRequest{
+			method: "GET",
+			path:   "/test?version=1",
+		},
+		response: testResponse{
+			404, "not found\n",
+		},
+		strict: true,
+	}, {
+		name:    "not found; no POST operation for /test/42",
+		handler: validatorTestHandler{}.withDefaults(),
+		request: testRequest{
+			method: "POST",
+			path:   "/test/42?version=1",
+		},
+		response: testResponse{
+			404, "not found\n",
+		},
+		strict: true,
+	}, {
+		name:    "invalid request; missing version",
+		handler: validatorTestHandler{}.withDefaults(),
+		request: testRequest{
+			method: "GET",
+			path:   "/test/42",
+		},
+		response: testResponse{
+			400, "bad request\n",
+		},
+		strict: true,
+	}, {
+		name:    "invalid POST request; wrong property type",
+		handler: validatorTestHandler{}.withDefaults(),
+		request: testRequest{
+			method:      "POST",
+			path:        "/test?version=1",
+			body:        `{"name": "foo", "expected": "nine", "actual": "ten"}`,
+			contentType: "application/json",
+		},
+		response: testResponse{
+			400, "bad request\n",
+		},
+		strict: true,
+	}, {
+		name:    "invalid POST request; missing property",
+		handler: validatorTestHandler{}.withDefaults(),
+		request: testRequest{
+			method:      "POST",
+			path:        "/test?version=1",
+			body:        `{"name": "foo", "expected": 9}`,
+			contentType: "application/json",
+		},
+		response: testResponse{
+			400, "bad request\n",
+		},
+		strict: true,
+	}, {
+		name:    "invalid POST request; extra property",
+		handler: validatorTestHandler{}.withDefaults(),
+		request: testRequest{
+			method:      "POST",
+			path:        "/test?version=1",
+			body:        `{"name": "foo", "expected": 9, "actual": 10, "ideal": 8}`,
+			contentType: "application/json",
+		},
+		response: testResponse{
+			400, "bad request\n",
+		},
+		strict: true,
+	}, {
+		name: "valid response; 404 error",
+		handler: validatorTestHandler{
+			contentType:   "application/json",
+			errBody:       `{"code": "404", "message": "not found"}`,
+			errStatusCode: 404,
+		}.withDefaults(),
+		request: testRequest{
+			method: "GET",
+			path:   "/test/42?version=1",
+		},
+		response: testResponse{
+			404, `{"code": "404", "message": "not found"}`,
+		},
+		strict: true,
+	}, {
+		name: "invalid response; invalid error",
+		handler: validatorTestHandler{
+			errBody:       `"not found"`,
+			errStatusCode: 404,
+		}.withDefaults(),
+		request: testRequest{
+			method: "GET",
+			path:   "/test/42?version=1",
+		},
+		response: testResponse{
+			500, "server error\n",
+		},
+		strict: true,
+	}, {
+		name: "invalid POST response; not strict",
+		handler: validatorTestHandler{
+			postBody: `{"id": "42", "contents": {"name": "foo", "expected": 9, "actual": 10}, "extra": true}`,
+		}.withDefaults(),
+		request: testRequest{
+			method:      "POST",
+			path:        "/test?version=1",
+			body:        `{"name": "foo", "expected": 9, "actual": 10}`,
+			contentType: "application/json",
+		},
+		response: testResponse{
+			statusCode: 201,
+			body:       `{"id": "42", "contents": {"name": "foo", "expected": 9, "actual": 10}, "extra": true}`,
+		},
+		strict: false,
+	}}
+	for i, test := range tests {
+		t.Logf("test#%d: %s", i, test.name)
+		t.Run(test.name, func(t *testing.T) {
+			// Set up a test HTTP server
+			var h http.Handler
+			s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				h.ServeHTTP(w, r)
+			}))
+			defer s.Close()
+
+			// Update the OpenAPI servers section with the test server URL This is
+			// needed by the router which matches request routes for OpenAPI
+			// validation.
+			doc.Servers = []*openapi3.Server{{URL: s.URL}}
+			err = doc.Validate(ctx)
+			require.NoError(t, err, "failed to validate with test server")
+
+			// Create the router and validator
+			router, err := gorillamux.NewRouter(doc)
+			require.NoError(t, err, "failed to create router")
+
+			// Now wrap the test handler with the validator middlware
+			v := openapi3filter.NewValidator(router, append(test.options, openapi3filter.Strict(test.strict))...)
+			h = v.Middleware(&test.handler)
+
+			// Test: make a client request
+			var requestBody io.Reader
+			if test.request.body != "" {
+				requestBody = bytes.NewBufferString(test.request.body)
+			}
+			req, err := http.NewRequest(test.request.method, s.URL+test.request.path, requestBody)
+			require.NoError(t, err, "failed to create request")
+
+			if test.request.contentType != "" {
+				req.Header.Set("Content-Type", test.request.contentType)
+			}
+			resp, err := s.Client().Do(req)
+			require.NoError(t, err, "request failed")
+			defer resp.Body.Close()
+			require.Equalf(t, test.response.statusCode, resp.StatusCode,
+				"response code expect %d got %d", test.response.statusCode, resp.StatusCode)
+
+			body, err := ioutil.ReadAll(resp.Body)
+			require.NoError(t, err, "failed to read response body")
+			require.Equalf(t, test.response.body, string(body),
+				"response body expect %q got %q", test.response.body, string(body))
+		})
+	}
+}
+
+func ExampleValidator() {
+	// OpenAPI specification for a simple service that squares integers, with
+	// some limitations.
+	doc, err := openapi3.NewLoader().LoadFromData([]byte(`
+info:
+openapi: 3.0.0
+info:
+  title: 'Validator - square example'
+  version: '0.0.0'
+paths:
+  /square/{x}:
+    get:
+      description: square an integer
+      parameters:
+        - name: x
+          in: path
+          schema:
+            type: integer
+          required: true
+      responses:
+        '200':
+          description: squared integer response
+          content:
+            "application/json":
+              schema:
+                type: object
+                properties:
+                  result:
+                    type: integer
+                    minimum: 0
+                    maximum: 1000000
+                required: [result]
+                additionalProperties: false`[1:]))
+	if err != nil {
+		panic(err)
+	}
+
+	// Square service handler sanity checks inputs, but just crashes on invalid
+	// requests.
+	squareHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		xParam := path.Base(r.URL.Path)
+		x, err := strconv.Atoi(xParam)
+		if err != nil {
+			panic(err)
+		}
+		w.Header().Set("Content-Type", "application/json")
+		result := map[string]interface{}{"result": x * x}
+		if x == 42 {
+			// An easter egg. Unfortunately, the spec does not allow additional properties...
+			result["comment"] = "the answer to the ulitimate question of life, the universe, and everything"
+		}
+		if err = json.NewEncoder(w).Encode(&result); err != nil {
+			panic(err)
+		}
+	})
+
+	// Start an http server.
+	var mainHandler http.Handler
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		// Why are we wrapping the main server handler with a closure here?
+		// Validation matches request Host: to server URLs in the spec. With an
+		// httptest.Server, the URL is dynamic and we have to create it first!
+		// In a real configured service, this is less likely to be needed.
+		mainHandler.ServeHTTP(w, r)
+	}))
+	defer srv.Close()
+
+	// Patch the OpenAPI spec to match the httptest.Server.URL. Only needed
+	// because the server URL is dynamic here.
+	doc.Servers = []*openapi3.Server{{URL: srv.URL}}
+	if err := doc.Validate(context.Background()); err != nil { // Assert our OpenAPI is valid!
+		panic(err)
+	}
+	// This router is used by the validator to match requests with the OpenAPI
+	// spec. It does not place restrictions on how the wrapped handler routes
+	// requests; use of gorilla/mux is just a validator implementation detail.
+	router, err := gorillamux.NewRouter(doc)
+	if err != nil {
+		panic(err)
+	}
+	// Strict validation will respond HTTP 500 if the service tries to emit a
+	// response that does not conform to the OpenAPI spec. Very useful for
+	// testing a service against its spec in development and CI. In production,
+	// availability may be more important than strictness.
+	v := openapi3filter.NewValidator(router, openapi3filter.Strict(true),
+		openapi3filter.OnErr(func(w http.ResponseWriter, status int, code openapi3filter.ErrCode, err error) {
+			// Customize validation error responses to use JSON
+			w.Header().Set("Content-Type", "application/json")
+			w.WriteHeader(status)
+			json.NewEncoder(w).Encode(map[string]interface{}{
+				"status":  status,
+				"message": http.StatusText(status),
+			})
+		}))
+	// Now we can finally set the main server handler.
+	mainHandler = v.Middleware(squareHandler)
+
+	printResp := func(resp *http.Response, err error) {
+		if err != nil {
+			panic(err)
+		}
+		defer resp.Body.Close()
+		contents, err := ioutil.ReadAll(resp.Body)
+		if err != nil {
+			panic(err)
+		}
+		fmt.Println(resp.StatusCode, strings.TrimSpace(string(contents)))
+	}
+	// Valid requests to our sum service
+	printResp(srv.Client().Get(srv.URL + "/square/2"))
+	printResp(srv.Client().Get(srv.URL + "/square/789"))
+	// 404 Not found requests - method or path not found
+	printResp(srv.Client().Post(srv.URL+"/square/2", "application/json", bytes.NewBufferString(`{"result": 5}`)))
+	printResp(srv.Client().Get(srv.URL + "/sum/2"))
+	printResp(srv.Client().Get(srv.URL + "/square/circle/4")) // Handler would process this; validation rejects it
+	printResp(srv.Client().Get(srv.URL + "/square"))
+	// 400 Bad requests - note they never reach the wrapped square handler (which would panic)
+	printResp(srv.Client().Get(srv.URL + "/square/five"))
+	// 500 Invalid responses
+	printResp(srv.Client().Get(srv.URL + "/square/42"))    // Our "easter egg" added a property which is not allowed
+	printResp(srv.Client().Get(srv.URL + "/square/65536")) // Answer overflows the maximum allowed value (1000000)
+	// Output:
+	// 200 {"result":4}
+	// 200 {"result":622521}
+	// 404 {"message":"Not Found","status":404}
+	// 404 {"message":"Not Found","status":404}
+	// 404 {"message":"Not Found","status":404}
+	// 404 {"message":"Not Found","status":404}
+	// 400 {"message":"Bad Request","status":400}
+	// 500 {"message":"Internal Server Error","status":500}
+	// 500 {"message":"Internal Server Error","status":500}
+}
diff --git a/routers/issue356_test.go b/routers/issue356_test.go
new file mode 100644
index 0000000..307e52a
--- /dev/null
+++ b/routers/issue356_test.go
@@ -0,0 +1,141 @@
+package routers_test
+
+import (
+	"context"
+	"io/ioutil"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"github.com/getkin/kin-openapi/openapi3"
+	"github.com/getkin/kin-openapi/openapi3filter"
+	"github.com/getkin/kin-openapi/routers"
+	"github.com/getkin/kin-openapi/routers/gorillamux"
+	"github.com/getkin/kin-openapi/routers/legacy"
+	"github.com/stretchr/testify/require"
+)
+
+func TestIssue356(t *testing.T) {
+	spec := func(servers string) []byte {
+		return []byte(`
+openapi: 3.0.0
+info:
+  title: Example
+  version: '1.0'
+  description: test
+servers:
+` + servers + `
+paths:
+  /test:
+    post:
+      responses:
+        '201':
+          description: Created
+          content:
+            application/json:
+              schema: {type: object}
+      requestBody:
+        content:
+          application/json:
+            schema: {type: object}
+        description: ''
+      description: Create a test object
+`)
+	}
+
+	for servers, expectError := range map[string]bool{
+		`
+- url: http://localhost:3000/base
+- url: /base
+`: false,
+
+		`
+- url: /base
+- url: http://localhost:3000/base
+`: false,
+
+		`- url: /base`: false,
+
+		`- url: http://localhost:3000/base`: true,
+
+		``: true,
+	} {
+		loader := &openapi3.Loader{Context: context.Background()}
+		t.Logf("using servers: %q (%v)", servers, expectError)
+		doc, err := loader.LoadFromData(spec(servers))
+		require.NoError(t, err)
+		err = doc.Validate(context.Background())
+		require.NoError(t, err)
+
+		for i, newRouter := range []func(*openapi3.T) (routers.Router, error){gorillamux.NewRouter, legacy.NewRouter} {
+			t.Logf("using NewRouter from %s", map[int]string{0: "gorillamux", 1: "legacy"}[i])
+			router, err := newRouter(doc)
+			require.NoError(t, err)
+
+			if true {
+				t.Logf("using naked newRouter")
+				httpReq, err := http.NewRequest(http.MethodPost, "/base/test", strings.NewReader(`{}`))
+				require.NoError(t, err)
+				httpReq.Header.Set("Content-Type", "application/json")
+
+				route, pathParams, err := router.FindRoute(httpReq)
+				if expectError {
+					require.Error(t, err, routers.ErrPathNotFound)
+					return
+				}
+				require.NoError(t, err)
+
+				requestValidationInput := &openapi3filter.RequestValidationInput{
+					Request:    httpReq,
+					PathParams: pathParams,
+					Route:      route,
+				}
+				err = openapi3filter.ValidateRequest(context.Background(), requestValidationInput)
+				require.NoError(t, err)
+			}
+
+			if true {
+				t.Logf("using httptest.NewServer")
+				ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+					route, pathParams, err := router.FindRoute(r)
+					if err != nil {
+						w.WriteHeader(http.StatusInternalServerError)
+						w.Write([]byte(err.Error()))
+						return
+					}
+
+					requestValidationInput := &openapi3filter.RequestValidationInput{
+						Request:    r,
+						PathParams: pathParams,
+						Route:      route,
+					}
+					err = openapi3filter.ValidateRequest(r.Context(), requestValidationInput)
+					require.NoError(t, err)
+
+					w.Header().Set("Content-Type", "application/json")
+					w.Write([]byte("{}"))
+				}))
+				defer ts.Close()
+
+				req, err := http.NewRequest(http.MethodPost, ts.URL+"/base/test", strings.NewReader(`{}`))
+				require.NoError(t, err)
+				req.Header.Set("Content-Type", "application/json")
+
+				rep, err := http.DefaultClient.Do(req)
+				require.NoError(t, err)
+				defer rep.Body.Close()
+				body, err := ioutil.ReadAll(rep.Body)
+				require.NoError(t, err)
+
+				if expectError {
+					require.Equal(t, 500, rep.StatusCode)
+					require.Equal(t, routers.ErrPathNotFound.Error(), string(body))
+					return
+				}
+				require.Equal(t, 200, rep.StatusCode)
+				require.Equal(t, "{}", string(body))
+			}
+		}
+	}
+}