Skip to content

Commit 16629f8

Browse files
saisatishkarradaveshanley
authored andcommitted
feat: add support for implicit and explicit head response validation
1 parent 6138997 commit 16629f8

8 files changed

Lines changed: 338 additions & 5 deletions

helpers/operation_utilities.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import (
77
"mime"
88
"net/http"
99

10-
"github.com/pb33f/libopenapi/datamodel/high/v3"
10+
v3 "github.com/pb33f/libopenapi/datamodel/high/v3"
1111
)
1212

1313
// 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 {
2525
case http.MethodOptions:
2626
return item.Options
2727
case http.MethodHead:
28-
return item.Head
28+
if item.Head != nil {
29+
return item.Head
30+
}
31+
return item.Get
2932
case http.MethodPatch:
3033
return item.Patch
3134
case http.MethodTrace:

helpers/operation_utilities_test.go

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import (
88
"net/http"
99
"testing"
1010

11-
"github.com/pb33f/libopenapi/datamodel/high/v3"
11+
v3 "github.com/pb33f/libopenapi/datamodel/high/v3"
1212
"github.com/stretchr/testify/require"
1313
)
1414

@@ -112,3 +112,25 @@ func TestExtractContentType(t *testing.T) {
112112
require.Empty(t, charset)
113113
require.Empty(t, boundary)
114114
}
115+
func TestExtractOperationHeadFallback(t *testing.T) {
116+
pathItem := &v3.PathItem{
117+
Get: &v3.Operation{Summary: "GET operation"},
118+
Head: nil,
119+
}
120+
121+
req, _ := http.NewRequest(http.MethodHead, "/", nil)
122+
operation := ExtractOperation(req, pathItem)
123+
require.NotNil(t, operation)
124+
require.Equal(t, "GET operation", operation.Summary)
125+
}
126+
127+
func TestExtractOperationHeadFallbackNoGet(t *testing.T) {
128+
pathItem := &v3.PathItem{
129+
Head: nil,
130+
Get: nil,
131+
}
132+
133+
req, _ := http.NewRequest(http.MethodHead, "/", nil)
134+
operation := ExtractOperation(req, pathItem)
135+
require.Nil(t, operation)
136+
}

paths/specificity.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,11 @@ func pathHasMethod(pathItem *v3.PathItem, method string) bool {
6565
case http.MethodOptions:
6666
return pathItem.Options != nil
6767
case http.MethodHead:
68-
return pathItem.Head != nil
68+
// Treat HEAD as present when either
69+
// a Head operation exists or, if Head is absent, when a Get exists
70+
// per HTTP semantics (HEAD can be handled by GET if no explicit
71+
// HEAD operation is defined).
72+
return pathItem.Head != nil || pathItem.Get != nil
6973
case http.MethodPatch:
7074
return pathItem.Patch != nil
7175
case http.MethodTrace:

paths/specificity_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,12 @@ func TestPathHasMethod(t *testing.T) {
172172
method: "HEAD",
173173
expected: true,
174174
},
175+
{
176+
name: "HEAD if GET exists",
177+
pathItem: &v3.PathItem{Get: &v3.Operation{}},
178+
method: "HEAD",
179+
expected: true,
180+
},
175181
{
176182
name: "PATCH exists",
177183
pathItem: &v3.PathItem{Patch: &v3.Operation{}},

responses/validate_body.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,6 @@ func (v *responseBodyValidator) checkResponseSchema(
138138
// extract schema from media type
139139
if mediaType.Schema != nil {
140140
schema := mediaType.Schema.Schema()
141-
142141
// Validate response schema
143142
valid, vErrs := ValidateResponseSchema(&ValidateResponseSchemaInput{
144143
Request: request,

responses/validate_response.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,12 @@ func ValidateResponseSchema(input *ValidateResponseSchemaInput) (bool, []*errors
158158
schema := input.Schema
159159

160160
if response == nil || response.Body == http.NoBody {
161+
162+
// skip response body validation for head request after processing schema
163+
if request != nil && request.Method == http.MethodHead {
164+
return true, validationErrors
165+
}
166+
161167
// cannot decode the response body, so it's not valid
162168
violation := &errors.SchemaValidationFailure{
163169
Reason: "response is empty",
@@ -210,6 +216,28 @@ func ValidateResponseSchema(input *ValidateResponseSchemaInput) (bool, []*errors
210216
var decodedObj interface{}
211217

212218
if len(responseBody) > 0 {
219+
// Per RFC7231, a response to a HEAD request MUST NOT include a message body.
220+
if request != nil && request.Method == http.MethodHead {
221+
violation := &errors.SchemaValidationFailure{
222+
Reason: "HEAD responses must not include a message body",
223+
Location: "response body",
224+
ReferenceObject: string(responseBody),
225+
ReferenceSchema: referenceSchema,
226+
}
227+
validationErrors = append(validationErrors, &errors.ValidationError{
228+
ValidationType: helpers.ResponseBodyValidation,
229+
ValidationSubType: helpers.Schema,
230+
Message: fmt.Sprintf("%s response for '%s' must not include a body",
231+
request.Method, request.URL.Path),
232+
Reason: "The response to a HEAD request must not contain a body",
233+
SpecLine: 1,
234+
SpecCol: 0,
235+
SchemaValidationErrors: []*errors.SchemaValidationFailure{violation},
236+
HowToFix: "ensure no response body is present for HEAD requests",
237+
Context: referenceSchema,
238+
})
239+
return false, validationErrors
240+
}
213241
err := json.Unmarshal(responseBody, &decodedObj)
214242
if err != nil {
215243
// cannot decode the response body, so it's not valid

responses/validate_response_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,3 +291,55 @@ components:
291291
assert.Contains(t, errors[0].Message, "failed schema rendering")
292292
assert.Contains(t, errors[0].Reason, "circular reference")
293293
}
294+
func TestValidateResponseSchema_ResponseMissing(t *testing.T) {
295+
schema := parseSchemaFromSpec(t, `type: object
296+
properties:
297+
name:
298+
type: string`, 3.1)
299+
300+
// Response body missing (NoBody) for a non-HEAD request should error
301+
valid, errs := ValidateResponseSchema(&ValidateResponseSchemaInput{
302+
Request: postRequest(),
303+
Response: &http.Response{StatusCode: http.StatusOK, Body: http.NoBody},
304+
Schema: schema,
305+
Version: 3.1,
306+
})
307+
308+
assert.False(t, valid)
309+
require.Len(t, errs, 1)
310+
assert.Contains(t, errs[0].Message, "response object is missing")
311+
}
312+
313+
func TestValidateResponseSchema_HeadEmptySkipsValidation(t *testing.T) {
314+
schema := parseSchemaFromSpec(t, `type: object`, 3.1)
315+
316+
req, _ := http.NewRequest(http.MethodHead, "/test", nil)
317+
resp := &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
318+
319+
valid, errs := ValidateResponseSchema(&ValidateResponseSchemaInput{
320+
Request: req,
321+
Response: resp,
322+
Schema: schema,
323+
Version: 3.1,
324+
})
325+
326+
assert.True(t, valid)
327+
assert.Len(t, errs, 0)
328+
}
329+
330+
func TestValidateResponseSchema_HeadWithBodyFails(t *testing.T) {
331+
schema := parseSchemaFromSpec(t, `type: object`, 3.1)
332+
333+
req, _ := http.NewRequest(http.MethodHead, "/test", nil)
334+
335+
valid, errs := ValidateResponseSchema(&ValidateResponseSchemaInput{
336+
Request: req,
337+
Response: responseWithBody(`{"name":"bob"}`),
338+
Schema: schema,
339+
Version: 3.1,
340+
})
341+
342+
assert.False(t, valid)
343+
require.Len(t, errs, 1)
344+
assert.Contains(t, errs[0].Reason, "must not contain a body")
345+
}

0 commit comments

Comments
 (0)