Skip to content
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
5 changes: 4 additions & 1 deletion helpers/operation_utilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ func ExtractOperation(request *http.Request, item *v3.PathItem) *v3.Operation {
case http.MethodOptions:
return item.Options
case http.MethodHead:
return item.Head
if item.Head != nil {
return item.Head
}
return item.Get
case http.MethodPatch:
return item.Patch
case http.MethodTrace:
Expand Down
23 changes: 23 additions & 0 deletions helpers/operation_utilities_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,26 @@ func TestExtractContentType(t *testing.T) {
require.Empty(t, charset)
require.Empty(t, boundary)
}

func TestExtractOperationHeadFallback(t *testing.T) {
pathItem := &v3.PathItem{
Get: &v3.Operation{Summary: "GET operation"},
Head: nil,
}

req, _ := http.NewRequest(http.MethodHead, "/", nil)
operation := ExtractOperation(req, pathItem)
require.NotNil(t, operation)
require.Equal(t, "GET operation", operation.Summary)
}

func TestExtractOperationHeadFallbackNoGet(t *testing.T) {
pathItem := &v3.PathItem{
Head: nil,
Get: nil,
}

req, _ := http.NewRequest(http.MethodHead, "/", nil)
operation := ExtractOperation(req, pathItem)
require.Nil(t, operation)
}
6 changes: 5 additions & 1 deletion paths/specificity.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,11 @@ func pathHasMethod(pathItem *v3.PathItem, method string) bool {
case http.MethodOptions:
return pathItem.Options != nil
case http.MethodHead:
return pathItem.Head != nil
// Treat HEAD as present when either
// a Head operation exists or, if Head is absent, when a Get exists
// per HTTP semantics (HEAD can be handled by GET if no explicit
// HEAD operation is defined).
return pathItem.Head != nil || pathItem.Get != nil
case http.MethodPatch:
return pathItem.Patch != nil
case http.MethodTrace:
Expand Down
6 changes: 6 additions & 0 deletions paths/specificity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,12 @@ func TestPathHasMethod(t *testing.T) {
method: "HEAD",
expected: true,
},
{
name: "HEAD if GET exists",
pathItem: &v3.PathItem{Get: &v3.Operation{}},
method: "HEAD",
expected: true,
},
{
name: "PATCH exists",
pathItem: &v3.PathItem{Patch: &v3.Operation{}},
Expand Down
27 changes: 27 additions & 0 deletions responses/validate_response.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,11 @@ func ValidateResponseSchema(input *ValidateResponseSchemaInput) (bool, []*errors
schema := input.Schema

if response == nil || response.Body == http.NoBody {

// skip response body validation for head request after processing schema
if response != nil && request != nil && request.Method == http.MethodHead {
return len(validationErrors) == 0, validationErrors
}
// cannot decode the response body, so it's not valid
violation := &errors.SchemaValidationFailure{
Reason: "response is empty",
Expand Down Expand Up @@ -210,6 +215,28 @@ func ValidateResponseSchema(input *ValidateResponseSchemaInput) (bool, []*errors
var decodedObj interface{}

if len(responseBody) > 0 {
// Per RFC7231, a response to a HEAD request MUST NOT include a message body.
if request != nil && request.Method == http.MethodHead {
violation := &errors.SchemaValidationFailure{
Reason: "HEAD responses must not include a message body",
Location: "response body",
ReferenceObject: string(responseBody),
ReferenceSchema: referenceSchema,
}
validationErrors = append(validationErrors, &errors.ValidationError{
ValidationType: helpers.ResponseBodyValidation,
ValidationSubType: helpers.Schema,
Message: fmt.Sprintf("%s response for '%s' must not include a body",
request.Method, request.URL.Path),
Reason: "The response to a HEAD request must not contain a body",
SpecLine: 1,
SpecCol: 0,
SchemaValidationErrors: []*errors.SchemaValidationFailure{violation},
HowToFix: "ensure no response body is present for HEAD requests",
Context: referenceSchema,
})
return false, validationErrors
}
err := json.Unmarshal(responseBody, &decodedObj)
if err != nil {
// cannot decode the response body, so it's not valid
Expand Down
53 changes: 53 additions & 0 deletions responses/validate_response_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -291,3 +291,56 @@ components:
assert.Contains(t, errors[0].Message, "failed schema rendering")
assert.Contains(t, errors[0].Reason, "circular reference")
}

func TestValidateResponseSchema_ResponseMissing(t *testing.T) {
schema := parseSchemaFromSpec(t, `type: object
properties:
name:
type: string`, 3.1)

// Response body missing (NoBody) for a non-HEAD request should error
valid, errs := ValidateResponseSchema(&ValidateResponseSchemaInput{
Request: postRequest(),
Response: &http.Response{StatusCode: http.StatusOK, Body: http.NoBody},
Schema: schema,
Version: 3.1,
})

assert.False(t, valid)
require.Len(t, errs, 1)
assert.Contains(t, errs[0].Message, "response object is missing")
}

func TestValidateResponseSchema_HeadEmptySkipsValidation(t *testing.T) {
schema := parseSchemaFromSpec(t, `type: object`, 3.1)

req, _ := http.NewRequest(http.MethodHead, "/test", nil)
resp := &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}

valid, errs := ValidateResponseSchema(&ValidateResponseSchemaInput{
Request: req,
Response: resp,
Schema: schema,
Version: 3.1,
})

assert.True(t, valid)
assert.Len(t, errs, 0)
}

func TestValidateResponseSchema_HeadWithBodyFails(t *testing.T) {
schema := parseSchemaFromSpec(t, `type: object`, 3.1)

req, _ := http.NewRequest(http.MethodHead, "/test", nil)

valid, errs := ValidateResponseSchema(&ValidateResponseSchemaInput{
Request: req,
Response: responseWithBody(`{"name":"bob"}`),
Schema: schema,
Version: 3.1,
})

assert.False(t, valid)
require.Len(t, errs, 1)
assert.Contains(t, errs[0].Reason, "must not contain a body")
}
219 changes: 219 additions & 0 deletions validator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2438,3 +2438,222 @@ func TestSortValidationErrors_SingleElement(t *testing.T) {
assert.Len(t, errs, 1)
assert.Equal(t, helpers.ParameterValidation, errs[0].ValidationType)
}

func TestHEAD_ExplicitOperation_ResponseValidation(t *testing.T) {
spec := `openapi: 3.1.0
paths:
/resource:
head:
responses:
"200":
description: ok
content:
application/json:
schema:
type: object
properties:
ok:
type: boolean
`
doc, err := libopenapi.NewDocument([]byte(spec))
require.NoError(t, err)

v, errs := NewValidator(doc)
require.Empty(t, errs)

// create a HEAD request
request, _ := http.NewRequest(http.MethodHead, "https://example.com/resource", nil)

// simulate a server response that includes a JSON body matching the schema
bodyBytes, _ := json.Marshal(map[string]bool{"ok": true})
response := &http.Response{
StatusCode: 200,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(bytes.NewBuffer(bodyBytes)),
}

valid, valErrs := v.ValidateHttpResponse(request, response)
assert.False(t, valid)
assert.Len(t, valErrs, 1)

// Also validate a response without a body (common for HEAD)
responseNoBody := &http.Response{
StatusCode: 200,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: http.NoBody,
}

validNoBody, valErrsNoBody := v.ValidateHttpResponse(request, responseNoBody)
assert.True(t, validNoBody)
assert.Len(t, valErrsNoBody, 0)
}

func TestHEAD_ImplicitViaGET_ResponseValidation(t *testing.T) {
// This spec defines only GET for /resource. Ensure a HEAD request that returns the same body
// as GET will still validate against the documented GET response.
spec := `openapi: 3.1.0
paths:
/resource:
get:
responses:
"200":
description: ok
content:
application/json:
schema:
type: object
properties:
ok:
type: boolean
`
doc, err := libopenapi.NewDocument([]byte(spec))
require.NoError(t, err)

v, errs := NewValidator(doc)
require.Empty(t, errs)

// create a HEAD request (no explicit HEAD operation in the spec)
request, _ := http.NewRequest(http.MethodHead, "https://example.com/resource", nil)

// simulate a server response that includes a JSON body like the GET response would.
bodyBytes, _ := json.Marshal(map[string]bool{"ok": true})
response := &http.Response{
StatusCode: 200,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(bytes.NewBuffer(bodyBytes)),
}

valid, valErrs := v.ValidateHttpResponse(request, response)
// Expect validation to succeed when HEAD responses are validated against GET response definitions.
assert.False(t, valid)
assert.Len(t, valErrs, 1)

responseNoBody := &http.Response{
StatusCode: 200,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: http.NoBody,
}

validNoBody, valErrsNoBody := v.ValidateHttpResponse(request, responseNoBody)
// Expect validation to succeed when HEAD responses are validated against GET response definitions.
assert.True(t, validNoBody)
assert.Len(t, valErrsNoBody, 0)
}

func TestHEAD_BothGETAndHEAD_SameSchema_Valid(t *testing.T) {
spec := `openapi: 3.1.0
paths:
/resource:
get:
responses:
"200":
description: ok
content:
application/json:
schema:
type: object
properties:
ok:
type: boolean
head:
responses:
"200":
description: ok
content:
application/json:
schema:
type: object
properties:
ok:
type: boolean
`
doc, err := libopenapi.NewDocument([]byte(spec))
require.NoError(t, err)

v, errs := NewValidator(doc)
require.Empty(t, errs)

// HEAD request to /resource
request, _ := http.NewRequest(http.MethodHead, "https://example.com/resource", nil)

// server returns a JSON body that matches the schema
bodyBytes, _ := json.Marshal(map[string]bool{"ok": true})
response := &http.Response{
StatusCode: 200,
Header: http.Header{helpers.ContentTypeHeader: []string{"application/json"}},
Body: io.NopCloser(bytes.NewBuffer(bodyBytes)),
}

valid, valErrs := v.ValidateHttpResponse(request, response)
assert.False(t, valid)
assert.Len(t, valErrs, 1)

responseNoBody := &http.Response{
StatusCode: 200,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: http.NoBody,
}

validNoBody, valErrsNoBody := v.ValidateHttpResponse(request, responseNoBody)
// Expect validation to succeed when HEAD responses are validated against GET response definitions.
assert.True(t, validNoBody)
assert.Len(t, valErrsNoBody, 0)
}

func TestHEAD_BothGETAndHEAD_DifferentSchemas_HeadPreferred(t *testing.T) {
spec := `openapi: 3.1.0
paths:
/resource:
get:
responses:
"200":
description: get schema
content:
application/json:
schema:
type: object
required: ["g"]
properties:
g:
type: string
head:
responses:
"200":
description: head schema
headers:
content-length:
description: size of the file
schema:
type: integer
`
doc, err := libopenapi.NewDocument([]byte(spec))
require.NoError(t, err)

v, errs := NewValidator(doc)
require.Empty(t, errs)

// Case A: response matches HEAD schema has content-length header but response contains content-type
reqA, _ := http.NewRequest(http.MethodHead, "https://example.com/resource", nil)
respA := &http.Response{
StatusCode: 200,
Header: http.Header{helpers.ContentTypeHeader: []string{"application/json"}},
Body: http.NoBody,
}
// No support for response headers validation.
// Only validates response content-type: json
validA, errsA := v.ValidateHttpResponse(reqA, respA)
assert.True(t, validA)
assert.Len(t, errsA, 0)

// Case B: response matches GET schema (has "g") but not HEAD (missing required "h") -> should fail
reqB, _ := http.NewRequest(http.MethodGet, "https://example.com/resource", nil)
bodyB, _ := json.Marshal(map[string]string{"g": "get-value"})
respB := &http.Response{
StatusCode: 200,
Header: http.Header{helpers.ContentTypeHeader: []string{"application/json"}},
Body: io.NopCloser(bytes.NewBuffer(bodyB)),
}
validB, errsB := v.ValidateHttpResponse(reqB, respB)
assert.True(t, validB)
assert.Len(t, errsB, 0)
}