Skip to content
Draft
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
40 changes: 40 additions & 0 deletions helpers/json_pointer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Copyright 2023 Princess B33f Heavy Industries / Dave Shanley

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2025

// SPDX-License-Identifier: MIT

package helpers

import (
"fmt"
"strings"
)

// EscapeJSONPointerSegment escapes a single segment for use in a JSON Pointer (RFC 6901).
// It replaces '~' with '~0' and '/' with '~1'.
func EscapeJSONPointerSegment(segment string) string {
escaped := strings.ReplaceAll(segment, "~", "~0")
escaped = strings.ReplaceAll(escaped, "/", "~1")
return escaped
}

// ConstructParameterJSONPointer constructs a full JSON Pointer path for a parameter
// in the OpenAPI specification.
// Format: /paths/{path}/{method}/parameters/{paramName}/schema/{keyword}
// The path segment is automatically escaped according to RFC 6901.
func ConstructParameterJSONPointer(pathTemplate, method, paramName, keyword string) string {
escapedPath := EscapeJSONPointerSegment(pathTemplate)
escapedPath = strings.TrimPrefix(escapedPath, "~1") // Remove leading slash encoding
method = strings.ToLower(method)
return fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/%s", escapedPath, method, paramName, keyword)
}

// ConstructResponseHeaderJSONPointer constructs a full JSON Pointer path for a response header
// in the OpenAPI specification.
// Format: /paths/{path}/{method}/responses/{statusCode}/headers/{headerName}/{keyword}
// The path segment is automatically escaped according to RFC 6901.
func ConstructResponseHeaderJSONPointer(pathTemplate, method, statusCode, headerName, keyword string) string {
escapedPath := EscapeJSONPointerSegment(pathTemplate)
escapedPath = strings.TrimPrefix(escapedPath, "~1") // Remove leading slash encoding
method = strings.ToLower(method)
return fmt.Sprintf("/paths/%s/%s/responses/%s/headers/%s/%s", escapedPath, method, statusCode, headerName, keyword)
}

150 changes: 150 additions & 0 deletions helpers/json_pointer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
// Copyright 2023 Princess B33f Heavy Industries / Dave Shanley

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2025

// SPDX-License-Identifier: MIT

package helpers

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestEscapeJSONPointerSegment(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "no special characters",
input: "simple",
expected: "simple",
},
{
name: "tilde only",
input: "some~thing",
expected: "some~0thing",
},
{
name: "slash only",
input: "path/to/something",
expected: "path~1to~1something",
},
{
name: "both tilde and slash",
input: "path/with~special/chars~",
expected: "path~1with~0special~1chars~0",
},
{
name: "path template",
input: "/users/{id}",
expected: "~1users~1{id}",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := EscapeJSONPointerSegment(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}

func TestConstructParameterJSONPointer(t *testing.T) {
tests := []struct {
name string
pathTemplate string
method string
paramName string
keyword string
expected string
}{
{
name: "simple path with query parameter type",
pathTemplate: "/users",
method: "GET",
paramName: "limit",
keyword: "type",
expected: "/paths/users/get/parameters/limit/schema/type",
},
{
name: "path with parameter and enum keyword",
pathTemplate: "/users/{id}",
method: "POST",
paramName: "status",
keyword: "enum",
expected: "/paths/users~1{id}/post/parameters/status/schema/enum",
},
{
name: "path with tilde character",
pathTemplate: "/some~path",
method: "PUT",
paramName: "value",
keyword: "format",
expected: "/paths/some~0path/put/parameters/value/schema/format",
},
{
name: "path with multiple slashes",
pathTemplate: "/api/v1/users/{userId}/posts/{postId}",
method: "DELETE",
paramName: "filter",
keyword: "required",
expected: "/paths/api~1v1~1users~1{userId}~1posts~1{postId}/delete/parameters/filter/schema/required",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ConstructParameterJSONPointer(tt.pathTemplate, tt.method, tt.paramName, tt.keyword)
assert.Equal(t, tt.expected, result)
})
}
}

func TestConstructResponseHeaderJSONPointer(t *testing.T) {
tests := []struct {
name string
pathTemplate string
method string
statusCode string
headerName string
keyword string
expected string
}{
{
name: "simple response header",
pathTemplate: "/health",
method: "GET",
statusCode: "200",
headerName: "X-Request-ID",
keyword: "required",
expected: "/paths/health/get/responses/200/headers/X-Request-ID/required",
},
{
name: "path with parameter",
pathTemplate: "/users/{id}",
method: "POST",
statusCode: "201",
headerName: "Location",
keyword: "schema",
expected: "/paths/users~1{id}/post/responses/201/headers/Location/schema",
},
{
name: "path with tilde and slash",
pathTemplate: "/some~path/to/resource",
method: "PUT",
statusCode: "204",
headerName: "ETag",
keyword: "type",
expected: "/paths/some~0path~1to~1resource/put/responses/204/headers/ETag/type",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ConstructResponseHeaderJSONPointer(tt.pathTemplate, tt.method, tt.statusCode, tt.headerName, tt.keyword)
assert.Equal(t, tt.expected, result)
})
}
}

2 changes: 1 addition & 1 deletion responses/validate_body.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ func (v *responseBodyValidator) ValidateResponseBodyWithPathItem(request *http.R
if foundResponse != nil {
// check for headers in the response
if foundResponse.Headers != nil {
if ok, herrs := ValidateResponseHeaders(request, response, foundResponse.Headers); !ok {
if ok, herrs := ValidateResponseHeaders(request, response, foundResponse.Headers, pathFound, codeStr); !ok {
validationErrors = append(validationErrors, herrs...)
}
}
Expand Down
16 changes: 16 additions & 0 deletions responses/validate_headers.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ func ValidateResponseHeaders(
request *http.Request,
response *http.Response,
headers *orderedmap.Map[string, *v3.Header],
pathTemplate string,
statusCode string,
opts ...config.Option,
) (bool, []*errors.ValidationError) {
options := config.NewValidationOptions(opts...)
Expand Down Expand Up @@ -53,6 +55,14 @@ func ValidateResponseHeaders(
for name, header := range headers.FromOldest() {
if header.Required {
if _, ok := locatedHeaders[strings.ToLower(name)]; !ok {
// Construct full OpenAPI path for KeywordLocation
// e.g., /paths/~1health/get/responses/200/headers/chicken-nuggets/required
escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0")
escapedPath = strings.ReplaceAll(escapedPath, "/", "~1")
method := strings.ToLower(request.Method)
keywordLocation := fmt.Sprintf("/paths/%s/%s/responses/%s/headers/%s/required",
escapedPath, method, statusCode, name)

validationErrors = append(validationErrors, &errors.ValidationError{
ValidationType: helpers.ResponseBodyValidation,
ValidationSubType: helpers.ParameterValidationHeader,
Expand All @@ -63,6 +73,12 @@ func ValidateResponseHeaders(
HowToFix: errors.HowToFixMissingHeader,
RequestPath: request.URL.Path,
RequestMethod: request.Method,
SchemaValidationErrors: []*errors.SchemaValidationFailure{{
Reason: fmt.Sprintf("Required header '%s' is missing", name),
FieldName: name,
InstancePath: []string{name},
KeywordLocation: keywordLocation,
}},
})
}
}
Expand Down
6 changes: 3 additions & 3 deletions responses/validate_headers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ paths:
headers := m.Model.Paths.PathItems.GetOrZero("/health").Get.Responses.Codes.GetOrZero("200").Headers

// validate!
valid, errors := ValidateResponseHeaders(request, response, headers)
valid, errors := ValidateResponseHeaders(request, response, headers, "/health", "200")

assert.False(t, valid)
assert.Len(t, errors, 1)
Expand All @@ -76,7 +76,7 @@ paths:
headers = m.Model.Paths.PathItems.GetOrZero("/health").Get.Responses.Codes.GetOrZero("200").Headers

// validate!
valid, errors = ValidateResponseHeaders(request, response, headers)
valid, errors = ValidateResponseHeaders(request, response, headers, "/health", "200")

assert.False(t, valid)
assert.Len(t, errors, 1)
Expand Down Expand Up @@ -125,7 +125,7 @@ paths:
headers := m.Model.Paths.PathItems.GetOrZero("/health").Get.Responses.Codes.GetOrZero("200").Headers

// validate!
valid, errors := ValidateResponseHeaders(request, response, headers)
valid, errors := ValidateResponseHeaders(request, response, headers, "/health", "200")

assert.True(t, valid)
assert.Len(t, errors, 0)
Expand Down