Repository: getkin/kin-openapi
Pull request #712 (2022-12-16 06:00:14)

openapi3filter: validate non-string headers
Previously the code assumed response headers were only strings. Now the code can validate any type of response header


Patch:
diff --git a/jsoninfo/marshal.go b/jsoninfo/marshal.go
index 6e946d8..f2abf7c 100644
--- a/jsoninfo/marshal.go
+++ b/jsoninfo/marshal.go
@@ -23,7 +23,7 @@ type ObjectEncoder struct {
 
 func NewObjectEncoder() *ObjectEncoder {
 	return &ObjectEncoder{
-		result: make(map[string]json.RawMessage, 8),
+		result: make(map[string]json.RawMessage),
 	}
 }
 
diff --git a/openapi2/openapi2.go b/openapi2/openapi2.go
index dcc5ddb..430e398 100644
--- a/openapi2/openapi2.go
+++ b/openapi2/openapi2.go
@@ -42,7 +42,7 @@ func (doc *T) UnmarshalJSON(data []byte) error {
 func (doc *T) AddOperation(path string, method string, operation *Operation) {
 	paths := doc.Paths
 	if paths == nil {
-		paths = make(map[string]*PathItem, 8)
+		paths = make(map[string]*PathItem)
 		doc.Paths = paths
 	}
 	pathItem := paths[path]
@@ -77,7 +77,7 @@ func (pathItem *PathItem) UnmarshalJSON(data []byte) error {
 }
 
 func (pathItem *PathItem) Operations() map[string]*Operation {
-	operations := make(map[string]*Operation, 8)
+	operations := make(map[string]*Operation)
 	if v := pathItem.Delete; v != nil {
 		operations[http.MethodDelete] = v
 	}
diff --git a/openapi2conv/openapi2_conv.go b/openapi2conv/openapi2_conv.go
index 8c08205..b81e3c2 100644
--- a/openapi2conv/openapi2_conv.go
+++ b/openapi2conv/openapi2_conv.go
@@ -1206,7 +1206,7 @@ func stripNonCustomExtensions(extensions map[string]interface{}) {
 func addPathExtensions(doc2 *openapi2.T, path string, extensionProps openapi3.ExtensionProps) {
 	paths := doc2.Paths
 	if paths == nil {
-		paths = make(map[string]*openapi2.PathItem, 8)
+		paths = make(map[string]*openapi2.PathItem)
 		doc2.Paths = paths
 	}
 	pathItem := paths[path]
diff --git a/openapi3/content.go b/openapi3/content.go
index 8abd411..81b070e 100644
--- a/openapi3/content.go
+++ b/openapi3/content.go
@@ -10,7 +10,7 @@ import (
 type Content map[string]*MediaType
 
 func NewContent() Content {
-	return make(map[string]*MediaType, 4)
+	return make(map[string]*MediaType)
 }
 
 func NewContentWithSchema(schema *Schema, consumes []string) Content {
diff --git a/openapi3/path_item.go b/openapi3/path_item.go
index db83a0a..5323dc1 100644
--- a/openapi3/path_item.go
+++ b/openapi3/path_item.go
@@ -41,7 +41,7 @@ func (pathItem *PathItem) UnmarshalJSON(data []byte) error {
 }
 
 func (pathItem *PathItem) Operations() map[string]*Operation {
-	operations := make(map[string]*Operation, 4)
+	operations := make(map[string]*Operation)
 	if v := pathItem.Connect; v != nil {
 		operations[http.MethodConnect] = v
 	}
diff --git a/openapi3/schema.go b/openapi3/schema.go
index c5af853..bbce46c 100644
--- a/openapi3/schema.go
+++ b/openapi3/schema.go
@@ -12,6 +12,7 @@ import (
 	"regexp"
 	"sort"
 	"strconv"
+	"strings"
 	"unicode/utf16"
 
 	"github.com/go-openapi/jsonpointer"
@@ -180,7 +181,14 @@ func (schema *Schema) MarshalJSON() ([]byte, error) {
 
 // UnmarshalJSON sets Schema to a copy of data.
 func (schema *Schema) UnmarshalJSON(data []byte) error {
-	return jsoninfo.UnmarshalStrictStruct(data, schema)
+	err := jsoninfo.UnmarshalStrictStruct(data, schema)
+	if schema.Format == "date" {
+		// This is a fix for: https://github.com/getkin/kin-openapi/issues/697
+		if eg, ok := schema.Example.(string); ok {
+			schema.Example = strings.TrimSuffix(eg, "T00:00:00Z")
+		}
+	}
+	return err
 }
 
 // JSONLookup implements github.com/go-openapi/jsonpointer#JSONPointable
@@ -1042,6 +1050,10 @@ func (schema *Schema) visitSetOperations(settings *schemaValidationSettings, val
 	return
 }
 
+// The value is not considered in visitJSONNull because according to the spec
+// "null is not supported as a type" unless `nullable` is also set to true
+// https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#data-types
+// https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#schema-object
 func (schema *Schema) visitJSONNull(settings *schemaValidationSettings) (err error) {
 	if schema.Nullable {
 		return
diff --git a/openapi3filter/req_resp_decoder.go b/openapi3filter/req_resp_decoder.go
index 870651c..4791b4a 100644
--- a/openapi3filter/req_resp_decoder.go
+++ b/openapi3filter/req_resp_decoder.go
@@ -339,7 +339,7 @@ func decodeValue(dec valueDecoder, param string, sm *openapi3.SerializationMetho
 		}
 		_, found = vDecoder.values[param]
 	case *headerParamDecoder:
-		_, found = vDecoder.header[param]
+		_, found = vDecoder.header[http.CanonicalHeaderKey(param)]
 	case *cookieParamDecoder:
 		_, err := vDecoder.req.Cookie(param)
 		found = err != http.ErrNoCookie
@@ -888,7 +888,7 @@ func parseArray(raw []string, schemaRef *openapi3.SchemaRef) ([]interface{}, err
 
 // parsePrimitive returns a value that is created by parsing a source string to a primitive type
 // that is specified by a schema. The function returns nil when the source string is empty.
-// The function panics when a schema has a non primitive type.
+// The function panics when a schema has a non-primitive type.
 func parsePrimitive(raw string, schema *openapi3.SchemaRef) (interface{}, error) {
 	if raw == "" {
 		return nil, nil
diff --git a/openapi3filter/validate_response.go b/openapi3filter/validate_response.go
index 27bef82..c1be319 100644
--- a/openapi3filter/validate_response.go
+++ b/openapi3filter/validate_response.go
@@ -78,24 +78,10 @@ func ValidateResponse(ctx context.Context, input *ResponseValidationInput) error
 		}
 	}
 	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,
-			}
+	for _, headerName := range headers {
+		headerRef := response.Headers[headerName]
+		if err := validateResponseHeader(headerName, headerRef, input, opts); err != nil {
+			return err
 		}
 	}
 
@@ -171,6 +157,46 @@ func ValidateResponse(ctx context.Context, input *ResponseValidationInput) error
 	return nil
 }
 
+func validateResponseHeader(headerName string, headerRef *openapi3.HeaderRef, input *ResponseValidationInput, opts []openapi3.SchemaValidationOption) error {
+	var err error
+	var decodedValue interface{}
+	var found bool
+	var sm *openapi3.SerializationMethod
+	dec := &headerParamDecoder{header: input.Header}
+
+	if sm, err = headerRef.Value.SerializationMethod(); err != nil {
+		return &ResponseError{
+			Input:  input,
+			Reason: fmt.Sprintf("unable to get header %q serialization method", headerName),
+			Err:    err,
+		}
+	}
+
+	if decodedValue, found, err = decodeValue(dec, headerName, sm, headerRef.Value.Schema, headerRef.Value.Required); err != nil {
+		return &ResponseError{
+			Input:  input,
+			Reason: fmt.Sprintf("unable to decode header %q value", headerName),
+			Err:    err,
+		}
+	}
+
+	if found {
+		if err = headerRef.Value.Schema.Value.VisitJSON(decodedValue, opts...); err != nil {
+			return &ResponseError{
+				Input:  input,
+				Reason: fmt.Sprintf("response header %q doesn't match schema", headerName),
+				Err:    err,
+			}
+		}
+	} else if headerRef.Value.Required {
+		return &ResponseError{
+			Input:  input,
+			Reason: fmt.Sprintf("response header %q missing", headerName),
+		}
+	}
+	return nil
+}
+
 // getSchemaIdentifier gets something by which a schema could be identified.
 // A schema by itself doesn't have a true identity field. This function makes
 // a best effort to get a value that can fill that void.
diff --git a/routers/legacy/router.go b/routers/legacy/router.go
index 74e3873..911422b 100644
--- a/routers/legacy/router.go
+++ b/routers/legacy/router.go
@@ -124,7 +124,7 @@ func (router *Router) FindRoute(req *http.Request) (*routers.Route, map[string]s
 				Reason: routers.ErrPathNotFound.Error(),
 			}
 		}
-		pathParams = make(map[string]string, 8)
+		pathParams = make(map[string]string)
 		paramNames, err := server.ParameterNames()
 		if err != nil {
 			return nil, nil, err


Test patch:
diff --git a/openapi3/issue697_test.go b/openapi3/issue697_test.go
new file mode 100644
index 0000000..c731758
--- /dev/null
+++ b/openapi3/issue697_test.go
@@ -0,0 +1,15 @@
+package openapi3
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestIssue697(t *testing.T) {
+	loader := NewLoader()
+	doc, err := loader.LoadFromFile("testdata/issue697.yml")
+	require.NoError(t, err)
+	err = doc.Validate(loader.Context)
+	require.NoError(t, err)
+}
diff --git a/openapi3/testdata/issue697.yml b/openapi3/testdata/issue697.yml
new file mode 100644
index 0000000..71a4b2a
--- /dev/null
+++ b/openapi3/testdata/issue697.yml
@@ -0,0 +1,14 @@
+openapi: 3.0.1
+components:
+  schemas:
+    API:
+      properties:
+        dateExample:
+          type: string
+          format: date
+          example: 2019-09-12
+info:
+  title: sample
+  version: version not set
+paths: {}
+
diff --git a/openapi3filter/issue201_test.go b/openapi3filter/issue201_test.go
index 8b2b99d..7e2eaab 100644
--- a/openapi3filter/issue201_test.go
+++ b/openapi3filter/issue201_test.go
@@ -17,7 +17,7 @@ func TestIssue201(t *testing.T) {
 	loader := openapi3.NewLoader()
 	ctx := loader.Context
 	spec := `
-openapi: '3'
+openapi: '3.0.3'
 info:
   version: 1.0.0
   title: Sample API
@@ -37,20 +37,24 @@ paths:
               description: ''
               required: true
               schema:
+                type: string
                 pattern: '^blip$'
             x-blop:
               description: ''
               schema:
+                type: string
                 pattern: '^blop$'
             X-Blap:
               description: ''
               required: true
               schema:
+                type: string
                 pattern: '^blap$'
             X-Blup:
               description: ''
               required: true
               schema:
+                type: string
                 pattern: '^blup$'
 `[1:]
 
@@ -94,7 +98,7 @@ paths:
 		},
 
 		"invalid required header": {
-			err: `response header "X-Blup" doesn't match the schema: string "bluuuuuup" doesn't match the regular expression "^blup$"`,
+			err: `response header "X-Blup" doesn't match schema: string "bluuuuuup" doesn't match the regular expression "^blup$"`,
 			headers: map[string]string{
 				"X-Blip": "blip",
 				"x-blop": "blop",
diff --git a/openapi3filter/validate_response_test.go b/openapi3filter/validate_response_test.go
new file mode 100644
index 0000000..5ce657b
--- /dev/null
+++ b/openapi3filter/validate_response_test.go
@@ -0,0 +1,215 @@
+package openapi3filter
+
+import (
+	"io"
+	"net/http"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/getkin/kin-openapi/openapi3"
+)
+
+func Test_validateResponseHeader(t *testing.T) {
+	type args struct {
+		headerName string
+		headerRef  *openapi3.HeaderRef
+	}
+	tests := []struct {
+		name            string
+		args            args
+		isHeaderPresent bool
+		headerVals      []string
+		wantErr         bool
+		wantErrMsg      string
+	}{
+		{
+			name: "test required string header with single string value",
+			args: args{
+				headerName: "X-Blab",
+				headerRef:  newHeaderRef(openapi3.NewStringSchema(), true),
+			},
+			isHeaderPresent: true,
+			headerVals:      []string{"blab"},
+			wantErr:         false,
+		},
+		{
+			name: "test required string header with single, empty string value",
+			args: args{
+				headerName: "X-Blab",
+				headerRef:  newHeaderRef(openapi3.NewStringSchema(), true),
+			},
+			isHeaderPresent: true,
+			headerVals:      []string{""},
+			wantErr:         true,
+			wantErrMsg:      `response header "X-Blab" doesn't match schema: Value is not nullable`,
+		},
+		{
+			name: "test optional string header with single string value",
+			args: args{
+				headerName: "X-Blab",
+				headerRef:  newHeaderRef(openapi3.NewStringSchema(), false),
+			},
+			isHeaderPresent: false,
+			headerVals:      []string{"blab"},
+			wantErr:         false,
+		},
+		{
+			name: "test required, but missing string header",
+			args: args{
+				headerName: "X-Blab",
+				headerRef:  newHeaderRef(openapi3.NewStringSchema(), true),
+			},
+			isHeaderPresent: false,
+			headerVals:      nil,
+			wantErr:         true,
+			wantErrMsg:      `response header "X-Blab" missing`,
+		},
+		{
+			name: "test integer header with single integer value",
+			args: args{
+				headerName: "X-Blab",
+				headerRef:  newHeaderRef(openapi3.NewIntegerSchema(), true),
+			},
+			isHeaderPresent: true,
+			headerVals:      []string{"88"},
+			wantErr:         false,
+		},
+		{
+			name: "test integer header with single string value",
+			args: args{
+				headerName: "X-Blab",
+				headerRef:  newHeaderRef(openapi3.NewIntegerSchema(), true),
+			},
+			isHeaderPresent: true,
+			headerVals:      []string{"blab"},
+			wantErr:         true,
+			wantErrMsg:      `unable to decode header "X-Blab" value: value blab: an invalid integer: invalid syntax`,
+		},
+		{
+			name: "test int64 header with single int64 value",
+			args: args{
+				headerName: "X-Blab",
+				headerRef:  newHeaderRef(openapi3.NewInt64Schema(), true),
+			},
+			isHeaderPresent: true,
+			headerVals:      []string{"88"},
+			wantErr:         false,
+		},
+		{
+			name: "test int32 header with single int32 value",
+			args: args{
+				headerName: "X-Blab",
+				headerRef:  newHeaderRef(openapi3.NewInt32Schema(), true),
+			},
+			isHeaderPresent: true,
+			headerVals:      []string{"88"},
+			wantErr:         false,
+		},
+		{
+			name: "test float64 header with single float64 value",
+			args: args{
+				headerName: "X-Blab",
+				headerRef:  newHeaderRef(openapi3.NewFloat64Schema(), true),
+			},
+			isHeaderPresent: true,
+			headerVals:      []string{"88.87"},
+			wantErr:         false,
+		},
+		{
+			name: "test integer header with multiple csv integer values",
+			args: args{
+				headerName: "X-blab",
+				headerRef:  newHeaderRef(newArraySchema(openapi3.NewIntegerSchema()), true),
+			},
+			isHeaderPresent: true,
+			headerVals:      []string{"87,88"},
+			wantErr:         false,
+		},
+		{
+			name: "test integer header with multiple integer values",
+			args: args{
+				headerName: "X-blab",
+				headerRef:  newHeaderRef(newArraySchema(openapi3.NewIntegerSchema()), true),
+			},
+			isHeaderPresent: true,
+			headerVals:      []string{"87", "88"},
+			wantErr:         false,
+		},
+		{
+			name: "test non-typed, nullable header with single string value",
+			args: args{
+				headerName: "X-blab",
+				headerRef:  newHeaderRef(&openapi3.Schema{Nullable: true}, true),
+			},
+			isHeaderPresent: true,
+			headerVals:      []string{"blab"},
+			wantErr:         false,
+		},
+		{
+			name: "test required non-typed, nullable header not present",
+			args: args{
+				headerName: "X-blab",
+				headerRef:  newHeaderRef(&openapi3.Schema{Nullable: true}, true),
+			},
+			isHeaderPresent: false,
+			headerVals:      []string{"blab"},
+			wantErr:         true,
+			wantErrMsg:      `response header "X-blab" missing`,
+		},
+		{
+			name: "test non-typed, non-nullable header with single string value",
+			args: args{
+				headerName: "X-blab",
+				headerRef:  newHeaderRef(&openapi3.Schema{Nullable: false}, true),
+			},
+			isHeaderPresent: true,
+			headerVals:      []string{"blab"},
+			wantErr:         true,
+			wantErrMsg:      `response header "X-blab" doesn't match schema: Value is not nullable`,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := newInputDefault()
+			opts := []openapi3.SchemaValidationOption(nil)
+			if tt.isHeaderPresent {
+				input.Header = map[string][]string{http.CanonicalHeaderKey(tt.args.headerName): tt.headerVals}
+			}
+
+			err := validateResponseHeader(tt.args.headerName, tt.args.headerRef, input, opts)
+			if tt.wantErr {
+				require.NotEmpty(t, tt.wantErrMsg, "wanted error message is not populated")
+				require.Error(t, err)
+				require.Contains(t, err.Error(), tt.wantErrMsg)
+			} else {
+				require.NoError(t, err)
+			}
+		})
+	}
+}
+
+func newInputDefault() *ResponseValidationInput {
+	return &ResponseValidationInput{
+		RequestValidationInput: &RequestValidationInput{
+			Request:    nil,
+			PathParams: nil,
+			Route:      nil,
+		},
+		Status: 200,
+		Header: nil,
+		Body:   io.NopCloser(strings.NewReader(`{}`)),
+	}
+}
+
+func newHeaderRef(schema *openapi3.Schema, required bool) *openapi3.HeaderRef {
+	return &openapi3.HeaderRef{Value: &openapi3.Header{Parameter: openapi3.Parameter{Schema: &openapi3.SchemaRef{Value: schema}, Required: required}}}
+}
+
+func newArraySchema(schema *openapi3.Schema) *openapi3.Schema {
+	arraySchema := openapi3.NewArraySchema()
+	arraySchema.Items = openapi3.NewSchemaRef("", schema)
+
+	return arraySchema
+}