Repository: getkin/kin-openapi
Pull request #650 (2022-10-28 14:03:38)

openapi3filter: add missing response headers validation
Fixes https://github.com/getkin/kin-openapi/issues/201


Patch:
diff --git a/go.mod b/go.mod
index 8d93cf7..942b319 100644
--- a/go.mod
+++ b/go.mod
@@ -7,7 +7,7 @@ require (
 	github.com/gorilla/mux v1.8.0
 	github.com/invopop/yaml v0.1.0
 	github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826
-	github.com/stretchr/testify v1.5.1
+	github.com/stretchr/testify v1.8.1
 	gopkg.in/yaml.v2 v2.4.0 // indirect
 	gopkg.in/yaml.v3 v3.0.1
 )
diff --git a/go.sum b/go.sum
index 074de2a..4982bc7 100644
--- a/go.sum
+++ b/go.sum
@@ -22,15 +22,20 @@ github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwd
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
-github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
-github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/openapi3filter/validate_response.go b/openapi3filter/validate_response.go
index ffb7a1f..e90b5d6 100644
--- a/openapi3filter/validate_response.go
+++ b/openapi3filter/validate_response.go
@@ -7,6 +7,7 @@ import (
 	"fmt"
 	"io/ioutil"
 	"net/http"
+	"sort"
 
 	"github.com/getkin/kin-openapi/openapi3"
 )
@@ -61,6 +62,39 @@ func ValidateResponse(ctx context.Context, input *ResponseValidationInput) error
 		return &ResponseError{Input: input, Reason: "response has not been resolved"}
 	}
 
+	opts := make([]openapi3.SchemaValidationOption, 0, 2)
+	if options.MultiError {
+		opts = append(opts, openapi3.MultiErrors())
+	}
+
+	headers := make([]string, 0, len(response.Headers))
+	for k := range response.Headers {
+		if k != headerCT {
+			headers = append(headers, k)
+		}
+	}
+	sort.Strings(headers)
+	for _, k := range headers {
+		s := response.Headers[k]
+		h := input.Header.Get(k)
+		if h == "" {
+			if s.Value.Required {
+				return &ResponseError{
+					Input:  input,
+					Reason: fmt.Sprintf("response header %q missing", k),
+				}
+			}
+			continue
+		}
+		if err := s.Value.Schema.Value.VisitJSON(h, opts...); err != nil {
+			return &ResponseError{
+				Input:  input,
+				Reason: fmt.Sprintf("response header %q doesn't match the schema", k),
+				Err:    err,
+			}
+		}
+	}
+
 	if options.ExcludeResponseBody {
 		// A user turned off validation of a response's body.
 		return nil
@@ -120,14 +154,8 @@ func ValidateResponse(ctx context.Context, input *ResponseValidationInput) error
 		}
 	}
 
-	opts := make([]openapi3.SchemaValidationOption, 0, 2) // 2 potential opts here
-	opts = append(opts, openapi3.VisitAsResponse())
-	if options.MultiError {
-		opts = append(opts, openapi3.MultiErrors())
-	}
-
 	// Validate data with the schema.
-	if err := contentType.Schema.Value.VisitJSON(value, opts...); err != nil {
+	if err := contentType.Schema.Value.VisitJSON(value, append(opts, openapi3.VisitAsResponse())...); err != nil {
 		return &ResponseError{
 			Input:  input,
 			Reason: "response body doesn't match the schema",


Test patch:
diff --git a/openapi3filter/issue201_test.go b/openapi3filter/issue201_test.go
new file mode 100644
index 0000000..8db8620
--- /dev/null
+++ b/openapi3filter/issue201_test.go
@@ -0,0 +1,138 @@
+package openapi3filter
+
+import (
+	"context"
+	"io"
+	"net/http"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/getkin/kin-openapi/openapi3"
+	"github.com/getkin/kin-openapi/routers/gorillamux"
+)
+
+func TestIssue201(t *testing.T) {
+	loader := openapi3.NewLoader()
+	ctx := loader.Context
+	spec := `
+openapi: '3'
+info:
+  version: 1.0.0
+  title: Sample API
+paths:
+  /_:
+    get:
+      description: ''
+      responses:
+        default:
+          description: ''
+          content:
+            application/json:
+              schema:
+                type: object
+          headers:
+            X-Blip:
+              description: ''
+              required: true
+              schema:
+                pattern: '^blip$'
+            x-blop:
+              description: ''
+              schema:
+                pattern: '^blop$'
+            X-Blap:
+              description: ''
+              required: true
+              schema:
+                pattern: '^blap$'
+            X-Blup:
+              description: ''
+              required: true
+              schema:
+                pattern: '^blup$'
+`[1:]
+
+	doc, err := loader.LoadFromData([]byte(spec))
+	require.NoError(t, err)
+
+	err = doc.Validate(ctx)
+	require.NoError(t, err)
+
+	for name, testcase := range map[string]struct {
+		headers map[string]string
+		err     string
+	}{
+
+		"no error": {
+			headers: map[string]string{
+				"X-Blip": "blip",
+				"x-blop": "blop",
+				"X-Blap": "blap",
+				"X-Blup": "blup",
+			},
+		},
+
+		"missing non-required header": {
+			headers: map[string]string{
+				"X-Blip": "blip",
+				// "x-blop": "blop",
+				"X-Blap": "blap",
+				"X-Blup": "blup",
+			},
+		},
+
+		"missing required header": {
+			err: `response header "X-Blip" missing`,
+			headers: map[string]string{
+				// "X-Blip": "blip",
+				"x-blop": "blop",
+				"X-Blap": "blap",
+				"X-Blup": "blup",
+			},
+		},
+
+		"invalid required header": {
+			err: `response header "X-Blup" doesn't match the schema: string doesn't match the regular expression "^blup$"`,
+			headers: map[string]string{
+				"X-Blip": "blip",
+				"x-blop": "blop",
+				"X-Blap": "blap",
+				"X-Blup": "bluuuuuup",
+			},
+		},
+	} {
+		t.Run(name, func(t *testing.T) {
+			router, err := gorillamux.NewRouter(doc)
+			require.NoError(t, err)
+
+			r, err := http.NewRequest(http.MethodGet, `/_`, nil)
+			require.NoError(t, err)
+
+			r.Header.Add(headerCT, "application/json")
+			for k, v := range testcase.headers {
+				r.Header.Add(k, v)
+			}
+
+			route, pathParams, err := router.FindRoute(r)
+			require.NoError(t, err)
+
+			err = ValidateResponse(context.Background(), &ResponseValidationInput{
+				RequestValidationInput: &RequestValidationInput{
+					Request:    r,
+					PathParams: pathParams,
+					Route:      route,
+				},
+				Status: 200,
+				Header: r.Header,
+				Body:   io.NopCloser(strings.NewReader(`{}`)),
+			})
+			if e := testcase.err; e != "" {
+				require.ErrorContains(t, err, e)
+			} else {
+				require.NoError(t, err)
+			}
+		})
+	}
+}