From 1f6225c869e7c084523b5f9c78b1a73f0ba43cd4 Mon Sep 17 00:00:00 2001 From: Michael Bonifacio Date: Sat, 15 Nov 2025 10:24:07 -0800 Subject: [PATCH 1/2] feat: add centralized JSON Pointer construction helpers Creates helper functions in helpers package for constructing RFC 6901-compliant JSON Pointer paths to OpenAPI specification locations. New functions: - EscapeJSONPointerSegment: Escapes ~ and / characters per RFC 6901 - ConstructParameterJSONPointer: Builds paths for parameter schemas - ConstructResponseHeaderJSONPointer: Builds paths for response headers This eliminates duplication of the escaping logic across 72+ locations in the codebase and provides a single source of truth for JSON Pointer construction. Pattern: Before: Manual escaping in each error function (3 lines of code each) After: Single function call with semantic naming Next step: Refactor all existing inline JSON Pointer construction to use these helpers. --- helpers/json_pointer.go | 40 ++++++++++ helpers/json_pointer_test.go | 150 +++++++++++++++++++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 helpers/json_pointer.go create mode 100644 helpers/json_pointer_test.go diff --git a/helpers/json_pointer.go b/helpers/json_pointer.go new file mode 100644 index 00000000..3ec390cc --- /dev/null +++ b/helpers/json_pointer.go @@ -0,0 +1,40 @@ +// Copyright 2023 Princess B33f Heavy Industries / Dave Shanley +// 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) +} + diff --git a/helpers/json_pointer_test.go b/helpers/json_pointer_test.go new file mode 100644 index 00000000..f96eb8ad --- /dev/null +++ b/helpers/json_pointer_test.go @@ -0,0 +1,150 @@ +// Copyright 2023 Princess B33f Heavy Industries / Dave Shanley +// 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) + }) + } +} + From 80d4fa77b8829d72ec7dd77a3ed3251134e26dd8 Mon Sep 17 00:00:00 2001 From: Michael Bonifacio Date: Fri, 7 Nov 2025 16:25:33 -0800 Subject: [PATCH 2/2] Response headers: add SchemaValidationFailure with full OpenAPI path When a response header is marked as required in the OpenAPI schema and is missing from the response, this is a schema constraint violation. Added SchemaValidationFailure with full OpenAPI path context for KeywordLocation. Updated ValidateResponseHeaders signature to accept pathTemplate and statusCode to construct full JSON Pointer paths like: /paths/~1health/get/responses/200/headers/chicken-nuggets/required This makes header validation consistent with request/response body validation, which also uses full OpenAPI document paths for KeywordLocation. Note: Considered using relative paths (/header-name/required) but chose full paths for consistency with body validation patterns. Both approaches have tradeoffs documented in PR description. --- responses/validate_body.go | 2 +- responses/validate_headers.go | 16 ++++++++++++++++ responses/validate_headers_test.go | 6 +++--- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/responses/validate_body.go b/responses/validate_body.go index fc760dbb..87bb53a8 100644 --- a/responses/validate_body.go +++ b/responses/validate_body.go @@ -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...) } } diff --git a/responses/validate_headers.go b/responses/validate_headers.go index ee284fa9..2d5499ca 100644 --- a/responses/validate_headers.go +++ b/responses/validate_headers.go @@ -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...) @@ -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, @@ -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, + }}, }) } } diff --git a/responses/validate_headers_test.go b/responses/validate_headers_test.go index feb56001..ddcf5214 100644 --- a/responses/validate_headers_test.go +++ b/responses/validate_headers_test.go @@ -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) @@ -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) @@ -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)