Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

openapi3filter: validate non-string headers #712

Merged
merged 6 commits into from
Dec 17, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions openapi3/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -1042,6 +1042,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
Expand Down
8 changes: 6 additions & 2 deletions openapi3filter/issue201_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:]

Expand Down Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions openapi3filter/req_resp_decoder.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
62 changes: 44 additions & 18 deletions openapi3filter/validate_response.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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.
Expand Down
215 changes: 215 additions & 0 deletions openapi3filter/validate_response_test.go
Original file line number Diff line number Diff line change
@@ -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
}