Skip to content

Commit 363307f

Browse files
committed
Addressed #46
A new function in the `response` package named `ValidateResponseHeaders` will perform a required check, as well as a schema check, if required is set to `true`.
1 parent 38c612f commit 363307f

File tree

6 files changed

+312
-3
lines changed

6 files changed

+312
-3
lines changed

errors/parameters_howtofix.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,5 @@ const (
2828
HowToFixPathMethod = "Add the missing operation to the contract for the path"
2929
HowToFixInvalidMaxItems = "Reduce the number of items in the array to %d or less"
3030
HowToFixInvalidMinItems = "Increase the number of items in the array to %d or more"
31+
HowToFixMissingHeader = "Make sure the service responding sets the required headers with this response code"
3132
)

parameters/validate_parameter.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,10 @@ func ValidateParameterSchema(
117117
}
118118
} else {
119119
decodedString, _ := url.QueryUnescape(rawBlob)
120-
_ = json.Unmarshal([]byte(decodedString), &decodedObj)
120+
err := json.Unmarshal([]byte(decodedString), &decodedObj)
121+
if err != nil {
122+
decodedObj = rawBlob
123+
}
121124
validEncoding = true
122125
}
123126
// 3. create a new json schema compiler and add the schema to it
@@ -178,8 +181,8 @@ func ValidateParameterSchema(
178181
Message: fmt.Sprintf("%s '%s' cannot be decoded", entity, name),
179182
Reason: fmt.Sprintf("%s '%s' is defined as an object, "+
180183
"however it failed to be decoded as an object", reasonEntity, name),
181-
SpecLine: schema.GoLow().Type.KeyNode.Line,
182-
SpecCol: schema.GoLow().Type.KeyNode.Column,
184+
SpecLine: schema.GoLow().RootNode.Line,
185+
SpecCol: schema.GoLow().RootNode.Column,
183186
HowToFix: errors.HowToFixDecodingError,
184187
})
185188
}

responses/validate_body.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ func (v *responseBodyValidator) ValidateResponseBodyWithPathItem(request *http.R
9090
if operation.Responses.Default != nil && operation.Responses.Default.Content != nil {
9191
// check content type has been defined in the contract
9292
if mediaType, ok := operation.Responses.Default.Content.Get(mediaTypeSting); ok {
93+
foundResponse = operation.Responses.Default
9394
validationErrors = append(validationErrors,
9495
v.checkResponseSchema(request, response, contentType, mediaType)...)
9596
} else {
@@ -108,6 +109,15 @@ func (v *responseBodyValidator) ValidateResponseBodyWithPathItem(request *http.R
108109
}
109110
}
110111

112+
if foundResponse != nil {
113+
// check for headers in the response
114+
if foundResponse.Headers != nil {
115+
if ok, herrs := ValidateResponseHeaders(request, response, foundResponse.Headers); !ok {
116+
validationErrors = append(validationErrors, herrs...)
117+
}
118+
}
119+
}
120+
111121
errors.PopulateValidationErrors(validationErrors, request, pathFound)
112122

113123
if len(validationErrors) > 0 {

responses/validate_headers.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// Copyright 2023-2025 Princess Beef Heavy Industries, LLC / Dave Shanley
2+
// https://pb33f.io
3+
4+
package responses
5+
6+
import (
7+
"fmt"
8+
"github.com/pb33f/libopenapi-validator/helpers"
9+
"github.com/pb33f/libopenapi-validator/parameters"
10+
v3 "github.com/pb33f/libopenapi/datamodel/high/v3"
11+
lowv3 "github.com/pb33f/libopenapi/datamodel/low/v3"
12+
"github.com/pb33f/libopenapi/orderedmap"
13+
14+
"net/http"
15+
"strings"
16+
17+
"github.com/pb33f/libopenapi-validator/config"
18+
"github.com/pb33f/libopenapi-validator/errors"
19+
)
20+
21+
// ValidateResponseHeaders validates the response headers against the OpenAPI spec.
22+
func ValidateResponseHeaders(
23+
request *http.Request,
24+
response *http.Response,
25+
headers *orderedmap.Map[string, *v3.Header],
26+
opts ...config.Option,
27+
) (bool, []*errors.ValidationError) {
28+
options := config.NewValidationOptions(opts...)
29+
30+
// locate headers
31+
type headerPair struct {
32+
name string
33+
value []string
34+
model *v3.Header
35+
}
36+
locatedHeaders := make(map[string]headerPair)
37+
var validationErrors []*errors.ValidationError
38+
// iterate through the response headers
39+
for name, v := range response.Header {
40+
// check if the model is in the spec
41+
for k, header := range headers.FromOldest() {
42+
if strings.EqualFold(k, name) {
43+
locatedHeaders[strings.ToLower(name)] = headerPair{
44+
name: k,
45+
value: v,
46+
model: header,
47+
}
48+
}
49+
}
50+
}
51+
52+
// determine if any required headers are missing from the response
53+
for name, header := range headers.FromOldest() {
54+
if header.Required {
55+
if _, ok := locatedHeaders[strings.ToLower(name)]; !ok {
56+
validationErrors = append(validationErrors, &errors.ValidationError{
57+
ValidationType: helpers.ResponseBodyValidation,
58+
ValidationSubType: helpers.ParameterValidationHeader,
59+
Message: "Missing required model",
60+
Reason: fmt.Sprintf("Required model '%s' was not found in response", name),
61+
SpecLine: header.GoLow().KeyNode.Line,
62+
SpecCol: header.GoLow().KeyNode.Column,
63+
HowToFix: errors.HowToFixMissingHeader,
64+
RequestPath: request.URL.Path,
65+
RequestMethod: request.Method,
66+
})
67+
}
68+
}
69+
}
70+
71+
// validate the model schemas if they are set.
72+
for h, header := range locatedHeaders {
73+
if header.model.Schema != nil {
74+
schema := header.model.Schema.Schema()
75+
if schema != nil && header.model.Required {
76+
77+
for _, headerValue := range header.value {
78+
validationErrors = append(validationErrors,
79+
parameters.ValidateParameterSchema(schema, nil, headerValue, "header",
80+
"response header", h, helpers.ResponseBodyValidation, lowv3.HeadersLabel, options)...)
81+
82+
}
83+
}
84+
}
85+
}
86+
if len(validationErrors) == 0 {
87+
return true, nil
88+
}
89+
return false, validationErrors
90+
}

responses/validate_headers_test.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// Copyright 2023-2025 Princess Beef Heavy Industries, LLC / Dave Shanley
2+
// https://pb33f.io
3+
4+
package responses
5+
6+
import (
7+
"github.com/pb33f/libopenapi"
8+
"github.com/stretchr/testify/assert"
9+
"net/http"
10+
"net/http/httptest"
11+
"testing"
12+
)
13+
14+
func TestValidateResponseHeaders(t *testing.T) {
15+
spec := `openapi: "3.0.0"
16+
info:
17+
title: Healthcheck
18+
version: '0.1.0'
19+
paths:
20+
/health:
21+
get:
22+
responses:
23+
'200':
24+
headers:
25+
chicken-nuggets:
26+
description: chicken nuggets response
27+
required: true
28+
schema:
29+
type: integer
30+
description: pet response`
31+
32+
doc, _ := libopenapi.NewDocument([]byte(spec))
33+
34+
m, _ := doc.BuildV3Model()
35+
36+
// build a request
37+
request, _ := http.NewRequest(http.MethodGet, "https://things.com/health", nil)
38+
39+
// simulate a request/response
40+
res := httptest.NewRecorder()
41+
handler := func(w http.ResponseWriter, r *http.Request) {
42+
w.Header().Set("Chicken-Cakes", "I should fail")
43+
w.WriteHeader(http.StatusOK)
44+
_, _ = w.Write(nil)
45+
}
46+
47+
// fire the request
48+
handler(res, request)
49+
50+
// record response
51+
response := res.Result()
52+
53+
headers := m.Model.Paths.PathItems.GetOrZero("/health").Get.Responses.Codes.GetOrZero("200").Headers
54+
55+
// validate!
56+
valid, errors := ValidateResponseHeaders(request, response, headers)
57+
58+
assert.False(t, valid)
59+
assert.Len(t, errors, 1)
60+
assert.Equal(t, errors[0].Message, "Missing required model")
61+
assert.Equal(t, errors[0].Reason, "Required model 'chicken-nuggets' was not found in response")
62+
63+
res = httptest.NewRecorder()
64+
handler = func(w http.ResponseWriter, r *http.Request) {
65+
w.Header().Set("Chicken-Nuggets", "I should fail")
66+
w.WriteHeader(http.StatusOK)
67+
_, _ = w.Write(nil)
68+
}
69+
70+
// fire the request
71+
handler(res, request)
72+
73+
response = res.Result()
74+
75+
headers = m.Model.Paths.PathItems.GetOrZero("/health").Get.Responses.Codes.GetOrZero("200").Headers
76+
77+
// validate!
78+
valid, errors = ValidateResponseHeaders(request, response, headers)
79+
80+
assert.False(t, valid)
81+
assert.Len(t, errors, 1)
82+
assert.Equal(t, errors[0].Message, "header 'chicken-nuggets' failed to validate")
83+
assert.Equal(t, errors[0].Reason, "response header 'chicken-nuggets' is defined as an integer, however it failed to pass a schema validation")
84+
85+
}

validator_examples_test.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,3 +261,123 @@ func ExampleNewValidator_validateHttpResponse() {
261261
// Output: Type: response, Failure: 200 response body for '/pet/findByStatus' failed to validate schema
262262
// Schema Error: got string, want integer, Line: 19, Col: 27
263263
}
264+
265+
func ExampleNewValidator_testResponseHeaders() {
266+
// 1. Load the OpenAPI 3+ spec into a byte array
267+
petstore := []byte(`openapi: "3.0.0"
268+
info:
269+
title: Healthcheck
270+
version: '0.1.0'
271+
paths:
272+
/health:
273+
get:
274+
responses:
275+
'200':
276+
headers:
277+
chicken-nuggets:
278+
description: chicken nuggets response
279+
required: true
280+
schema:
281+
type: integer
282+
description: pet response`)
283+
284+
// 2. Create a new OpenAPI document using libopenapi
285+
document, docErrs := libopenapi.NewDocument(petstore)
286+
287+
if docErrs != nil {
288+
panic(docErrs)
289+
}
290+
291+
// 3. Create a new validator
292+
docValidator, validatorErrs := NewValidator(document)
293+
294+
if validatorErrs != nil {
295+
panic(validatorErrs)
296+
}
297+
298+
// 6. Create a new *http.Request (normally, this would be where the host application will pass in the request)
299+
request, _ := http.NewRequest(http.MethodGet, "/health", nil)
300+
301+
// 7. Simulate a request/response, in this case the contract returns a 200 with an array of pets.
302+
// Normally, this would be where the host application would pass in the response.
303+
recorder := httptest.NewRecorder()
304+
handler := func(w http.ResponseWriter, r *http.Request) {
305+
// set return content type.
306+
w.Header().Set("Chicken-Nuggets", "I am a chicken nugget, and not an integer")
307+
w.WriteHeader(http.StatusOK)
308+
_, _ = w.Write(nil)
309+
}
310+
311+
// simulate request/response
312+
handler(recorder, request)
313+
314+
// 7. Validate the response only
315+
valid, validationErrs := docValidator.ValidateHttpResponse(request, recorder.Result())
316+
317+
if !valid {
318+
for _, e := range validationErrs {
319+
// 5. Handle the error
320+
fmt.Printf("Type: %s, Failure: %s\n", e.ValidationType, e.Message)
321+
}
322+
}
323+
// Output: Type: response, Failure: header 'chicken-nuggets' failed to validate
324+
}
325+
326+
func ExampleNewValidator_responseHeaderNotRequired() {
327+
// 1. Load the OpenAPI 3+ spec into a byte array
328+
petstore := []byte(`openapi: "3.0.0"
329+
info:
330+
title: Healthcheck
331+
version: '0.1.0'
332+
paths:
333+
/health:
334+
get:
335+
responses:
336+
'200':
337+
headers:
338+
chicken-nuggets:
339+
description: chicken nuggets response
340+
required: false
341+
schema:
342+
type: integer
343+
description: pet response`)
344+
345+
// 2. Create a new OpenAPI document using libopenapi
346+
document, docErrs := libopenapi.NewDocument(petstore)
347+
348+
if docErrs != nil {
349+
panic(docErrs)
350+
}
351+
352+
// 3. Create a new validator
353+
docValidator, validatorErrs := NewValidator(document)
354+
355+
if validatorErrs != nil {
356+
panic(validatorErrs)
357+
}
358+
359+
// 6. Create a new *http.Request (normally, this would be where the host application will pass in the request)
360+
request, _ := http.NewRequest(http.MethodGet, "/health", nil)
361+
362+
// 7. Simulate a request/response, in this case the contract returns a 200 with an array of pets.
363+
// Normally, this would be where the host application would pass in the response.
364+
recorder := httptest.NewRecorder()
365+
handler := func(w http.ResponseWriter, r *http.Request) {
366+
// set return content type.
367+
w.Header().Set("Chicken-Nuggets", "I am a chicken nugget, and not an integer")
368+
w.WriteHeader(http.StatusOK)
369+
_, _ = w.Write(nil)
370+
}
371+
372+
// simulate request/response
373+
handler(recorder, request)
374+
375+
// 7. Validate the response only
376+
valid, _ := docValidator.ValidateHttpResponse(request, recorder.Result())
377+
378+
if !valid {
379+
panic("the header is not required, it should not fail")
380+
}
381+
fmt.Println("Header is not required, validation passed")
382+
// Output: Header is not required, validation passed
383+
}

0 commit comments

Comments
 (0)