From 80882577dd9fd0df2b39aad168c98c174705c86a Mon Sep 17 00:00:00 2001 From: saisatishkarra Date: Wed, 11 Feb 2026 17:58:18 -0600 Subject: [PATCH 1/3] feat: add support for implicit and explicit head response validation --- helpers/operation_utilities.go | 7 +- helpers/operation_utilities_test.go | 24 ++- paths/specificity.go | 6 +- paths/specificity_test.go | 6 + responses/validate_body.go | 1 - responses/validate_response.go | 28 ++++ responses/validate_response_test.go | 52 +++++++ validator_test.go | 219 ++++++++++++++++++++++++++++ 8 files changed, 338 insertions(+), 5 deletions(-) diff --git a/helpers/operation_utilities.go b/helpers/operation_utilities.go index a4ceadd6..cc54e727 100644 --- a/helpers/operation_utilities.go +++ b/helpers/operation_utilities.go @@ -7,7 +7,7 @@ import ( "mime" "net/http" - "github.com/pb33f/libopenapi/datamodel/high/v3" + v3 "github.com/pb33f/libopenapi/datamodel/high/v3" ) // ExtractOperation extracts the operation from the path item based on the request method. If there is no @@ -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: diff --git a/helpers/operation_utilities_test.go b/helpers/operation_utilities_test.go index 9d1f01fe..9a4e826f 100644 --- a/helpers/operation_utilities_test.go +++ b/helpers/operation_utilities_test.go @@ -8,7 +8,7 @@ import ( "net/http" "testing" - "github.com/pb33f/libopenapi/datamodel/high/v3" + v3 "github.com/pb33f/libopenapi/datamodel/high/v3" "github.com/stretchr/testify/require" ) @@ -112,3 +112,25 @@ 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) +} diff --git a/paths/specificity.go b/paths/specificity.go index ddf83880..ef84b796 100644 --- a/paths/specificity.go +++ b/paths/specificity.go @@ -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: diff --git a/paths/specificity_test.go b/paths/specificity_test.go index 76b88e49..4c1f52c8 100644 --- a/paths/specificity_test.go +++ b/paths/specificity_test.go @@ -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{}}, diff --git a/responses/validate_body.go b/responses/validate_body.go index ae09b307..e2072046 100644 --- a/responses/validate_body.go +++ b/responses/validate_body.go @@ -138,7 +138,6 @@ func (v *responseBodyValidator) checkResponseSchema( // extract schema from media type if mediaType.Schema != nil { schema := mediaType.Schema.Schema() - // Validate response schema valid, vErrs := ValidateResponseSchema(&ValidateResponseSchemaInput{ Request: request, diff --git a/responses/validate_response.go b/responses/validate_response.go index f63a6a35..eef0b8a0 100644 --- a/responses/validate_response.go +++ b/responses/validate_response.go @@ -158,6 +158,12 @@ 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 request != nil && request.Method == http.MethodHead { + return true, validationErrors + } + // cannot decode the response body, so it's not valid violation := &errors.SchemaValidationFailure{ Reason: "response is empty", @@ -210,6 +216,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 diff --git a/responses/validate_response_test.go b/responses/validate_response_test.go index 241e1ac3..0f3d0298 100644 --- a/responses/validate_response_test.go +++ b/responses/validate_response_test.go @@ -291,3 +291,55 @@ 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") +} diff --git a/validator_test.go b/validator_test.go index 5b77126f..f5daa2ae 100644 --- a/validator_test.go +++ b/validator_test.go @@ -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) +} From 3bc7bfae85170bcd58fde9c28259d22603222ee7 Mon Sep 17 00:00:00 2001 From: saisatishkarra Date: Fri, 13 Feb 2026 10:44:24 -0600 Subject: [PATCH 2/3] fix: validate errors within head response object --- helpers/operation_utilities.go | 2 +- helpers/operation_utilities_test.go | 2 +- responses/validate_body.go | 1 + responses/validate_response.go | 7 ++++--- responses/validate_response_test.go | 2 +- 5 files changed, 8 insertions(+), 6 deletions(-) diff --git a/helpers/operation_utilities.go b/helpers/operation_utilities.go index cc54e727..b66030a5 100644 --- a/helpers/operation_utilities.go +++ b/helpers/operation_utilities.go @@ -7,7 +7,7 @@ import ( "mime" "net/http" - v3 "github.com/pb33f/libopenapi/datamodel/high/v3" + "github.com/pb33f/libopenapi/datamodel/high/v3" ) // ExtractOperation extracts the operation from the path item based on the request method. If there is no diff --git a/helpers/operation_utilities_test.go b/helpers/operation_utilities_test.go index 9a4e826f..c9350a38 100644 --- a/helpers/operation_utilities_test.go +++ b/helpers/operation_utilities_test.go @@ -8,7 +8,7 @@ import ( "net/http" "testing" - v3 "github.com/pb33f/libopenapi/datamodel/high/v3" + "github.com/pb33f/libopenapi/datamodel/high/v3" "github.com/stretchr/testify/require" ) diff --git a/responses/validate_body.go b/responses/validate_body.go index e2072046..ae09b307 100644 --- a/responses/validate_body.go +++ b/responses/validate_body.go @@ -138,6 +138,7 @@ func (v *responseBodyValidator) checkResponseSchema( // extract schema from media type if mediaType.Schema != nil { schema := mediaType.Schema.Schema() + // Validate response schema valid, vErrs := ValidateResponseSchema(&ValidateResponseSchemaInput{ Request: request, diff --git a/responses/validate_response.go b/responses/validate_response.go index eef0b8a0..748b2a12 100644 --- a/responses/validate_response.go +++ b/responses/validate_response.go @@ -156,14 +156,14 @@ func ValidateResponseSchema(input *ValidateResponseSchemaInput) (bool, []*errors request := input.Request response := input.Response schema := input.Schema + if response == nil || response.Body == http.NoBody { // skip response body validation for head request after processing schema - if request != nil && request.Method == http.MethodHead { - return true, validationErrors + 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", @@ -215,6 +215,7 @@ 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 { diff --git a/responses/validate_response_test.go b/responses/validate_response_test.go index 0f3d0298..6cd91434 100644 --- a/responses/validate_response_test.go +++ b/responses/validate_response_test.go @@ -295,7 +295,7 @@ func TestValidateResponseSchema_ResponseMissing(t *testing.T) { schema := parseSchemaFromSpec(t, `type: object properties: name: - type: string`, 3.1) + type: string`, 3.1) // Response body missing (NoBody) for a non-HEAD request should error valid, errs := ValidateResponseSchema(&ValidateResponseSchemaInput{ From a3753a1d3b96e8f511e5e978411145ac6f644554 Mon Sep 17 00:00:00 2001 From: saisatishkarra Date: Fri, 13 Feb 2026 11:25:25 -0600 Subject: [PATCH 3/3] fix: linting issues --- helpers/operation_utilities_test.go | 1 + responses/validate_response.go | 2 -- responses/validate_response_test.go | 1 + 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/helpers/operation_utilities_test.go b/helpers/operation_utilities_test.go index c9350a38..c6433ad0 100644 --- a/helpers/operation_utilities_test.go +++ b/helpers/operation_utilities_test.go @@ -112,6 +112,7 @@ 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"}, diff --git a/responses/validate_response.go b/responses/validate_response.go index 748b2a12..ffaf1658 100644 --- a/responses/validate_response.go +++ b/responses/validate_response.go @@ -156,7 +156,6 @@ func ValidateResponseSchema(input *ValidateResponseSchemaInput) (bool, []*errors request := input.Request response := input.Response schema := input.Schema - if response == nil || response.Body == http.NoBody { @@ -215,7 +214,6 @@ 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 { diff --git a/responses/validate_response_test.go b/responses/validate_response_test.go index 6cd91434..5a9eb310 100644 --- a/responses/validate_response_test.go +++ b/responses/validate_response_test.go @@ -291,6 +291,7 @@ 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: