Skip to content

Commit 1588f3f

Browse files
committed
Address #236
better handling of encoding issues pre validation.
1 parent f01d03f commit 1588f3f

2 files changed

Lines changed: 476 additions & 7 deletions

File tree

schema_validation/validate_document.go

Lines changed: 220 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import (
88
"encoding/json"
99
"errors"
1010
"fmt"
11+
"strconv"
12+
"strings"
1113

1214
"github.com/pb33f/libopenapi"
1315
"github.com/santhosh-tekuri/jsonschema/v6"
@@ -20,11 +22,200 @@ import (
2022
"github.com/pb33f/libopenapi-validator/helpers"
2123
)
2224

23-
func normalizeJSON(data any) any {
24-
d, _ := json.Marshal(data)
25+
type nonStringMappingKey struct {
26+
Value string
27+
Tag string
28+
Path []string
29+
Line int
30+
Column int
31+
Sequence bool
32+
}
33+
34+
func normalizeJSON(data any) (any, error) {
35+
d, err := json.Marshal(data)
36+
if err != nil {
37+
return nil, err
38+
}
39+
2540
var normalized any
2641
_ = json.Unmarshal(d, &normalized)
27-
return normalized
42+
return normalized, nil
43+
}
44+
45+
func findNonStringMappingKey(rootNode *yaml.Node) *nonStringMappingKey {
46+
if rootNode == nil {
47+
return nil
48+
}
49+
return findNonStringMappingKeyInNode(rootNode, nil)
50+
}
51+
52+
func findNonStringMappingKeyInNode(node *yaml.Node, path []string) *nonStringMappingKey {
53+
if node == nil {
54+
return nil
55+
}
56+
57+
switch node.Kind {
58+
case yaml.DocumentNode:
59+
for _, child := range node.Content {
60+
if found := findNonStringMappingKeyInNode(child, path); found != nil {
61+
return found
62+
}
63+
}
64+
case yaml.MappingNode:
65+
for i := 0; i+1 < len(node.Content); i += 2 {
66+
keyNode := node.Content[i]
67+
valueNode := node.Content[i+1]
68+
if isMergeMappingKey(keyNode) {
69+
if found := findNonStringMappingKeyInMergeValue(valueNode, path); found != nil {
70+
return found
71+
}
72+
continue
73+
}
74+
nextPath := appendPathSegment(path, keyNode.Value)
75+
if !isStringMappingKey(keyNode) {
76+
return &nonStringMappingKey{
77+
Value: keyNode.Value,
78+
Tag: keyNode.ShortTag(),
79+
Path: nextPath,
80+
Line: keyNode.Line,
81+
Column: keyNode.Column,
82+
Sequence: keyNode.Kind == yaml.SequenceNode,
83+
}
84+
}
85+
if found := findNonStringMappingKeyInNode(valueNode, nextPath); found != nil {
86+
return found
87+
}
88+
}
89+
case yaml.SequenceNode:
90+
for i, child := range node.Content {
91+
if found := findNonStringMappingKeyInNode(child, appendPathSegment(path, strconv.Itoa(i))); found != nil {
92+
return found
93+
}
94+
}
95+
}
96+
97+
return nil
98+
}
99+
100+
func findNonStringMappingKeyInMergeValue(node *yaml.Node, path []string) *nonStringMappingKey {
101+
if node == nil {
102+
return nil
103+
}
104+
105+
switch node.Kind {
106+
case yaml.AliasNode:
107+
return findNonStringMappingKeyInMergeValue(node.Alias, path)
108+
case yaml.SequenceNode:
109+
for _, child := range node.Content {
110+
if found := findNonStringMappingKeyInMergeValue(child, path); found != nil {
111+
return found
112+
}
113+
}
114+
return nil
115+
default:
116+
return findNonStringMappingKeyInNode(node, path)
117+
}
118+
}
119+
120+
func isStringMappingKey(keyNode *yaml.Node) bool {
121+
if keyNode == nil || keyNode.Kind != yaml.ScalarNode {
122+
return false
123+
}
124+
return keyNode.ShortTag() == "!!str"
125+
}
126+
127+
func isMergeMappingKey(keyNode *yaml.Node) bool {
128+
if keyNode == nil || keyNode.Kind != yaml.ScalarNode {
129+
return false
130+
}
131+
return keyNode.ShortTag() == "!!merge" && keyNode.Value == "<<"
132+
}
133+
134+
func appendPathSegment(path []string, segment string) []string {
135+
next := make([]string, 0, len(path)+1)
136+
next = append(next, path...)
137+
return append(next, segment)
138+
}
139+
140+
func buildJSONPointer(path []string) string {
141+
if len(path) == 0 {
142+
return ""
143+
}
144+
var builder strings.Builder
145+
for _, segment := range path {
146+
builder.WriteByte('/')
147+
builder.WriteString(helpers.EscapeJSONPointerSegment(segment))
148+
}
149+
return builder.String()
150+
}
151+
152+
func buildNonStringMappingKeyError(key *nonStringMappingKey) *liberrors.ValidationError {
153+
pointer := buildJSONPointer(key.Path)
154+
reason := fmt.Sprintf("OpenAPI documents require string mapping keys, but found %s key %q at %s",
155+
yamlKeyType(key), key.Value, pointer)
156+
howToFix := "Quote YAML mapping keys that should be strings, because OpenAPI documents must be representable as JSON objects"
157+
158+
if isOperationResponseStatusCodeKey(key.Path) {
159+
reason = fmt.Sprintf("Response status code keys must be strings, quote %s as %q at %s",
160+
key.Value, key.Value, pointer)
161+
howToFix = fmt.Sprintf("Quote the response status code key, for example use %q instead of %s",
162+
key.Value, key.Value)
163+
}
164+
165+
return &liberrors.ValidationError{
166+
ValidationType: helpers.Schema,
167+
ValidationSubType: "document",
168+
Message: "OpenAPI document validation failed",
169+
Reason: reason,
170+
SpecLine: key.Line,
171+
SpecCol: key.Column,
172+
HowToFix: howToFix,
173+
Context: pointer,
174+
}
175+
}
176+
177+
func yamlKeyType(key *nonStringMappingKey) string {
178+
if key == nil {
179+
return "non-string"
180+
}
181+
if key.Sequence {
182+
return "sequence"
183+
}
184+
return strings.TrimPrefix(key.Tag, "!!")
185+
}
186+
187+
func isOperationResponseStatusCodeKey(path []string) bool {
188+
if len(path) < 5 || path[0] != "paths" || path[len(path)-2] != "responses" {
189+
return false
190+
}
191+
for _, segment := range path[2 : len(path)-2] {
192+
if isHTTPMethod(segment) {
193+
return true
194+
}
195+
}
196+
return false
197+
}
198+
199+
func isHTTPMethod(segment string) bool {
200+
switch strings.ToLower(segment) {
201+
case "get", "put", "post", "delete", "options", "head", "patch", "trace":
202+
return true
203+
default:
204+
return false
205+
}
206+
}
207+
208+
func buildDocumentDecodeError(reason, context string) *liberrors.ValidationError {
209+
return &liberrors.ValidationError{
210+
ValidationType: helpers.Schema,
211+
ValidationSubType: "document",
212+
Message: "OpenAPI document validation failed",
213+
Reason: reason,
214+
SpecLine: 1,
215+
SpecCol: 0,
216+
HowToFix: "ensure the OpenAPI document is valid YAML/JSON and can be represented as JSON",
217+
Context: context,
218+
}
28219
}
29220

30221
// ValidateOpenAPIDocument will validate an OpenAPI document against the OpenAPI 2, 3.0 and 3.1 schemas (depending on version)
@@ -59,6 +250,12 @@ func ValidateOpenAPIDocumentWithPrecompiled(doc libopenapi.Document, compiledSch
59250
return false, validationErrors
60251
}
61252

253+
if info.RootNode != nil {
254+
if invalidKey := findNonStringMappingKey(info.RootNode); invalidKey != nil {
255+
return false, []*liberrors.ValidationError{buildNonStringMappingKeyError(invalidKey)}
256+
}
257+
}
258+
62259
// Use the precompiled schema if provided, otherwise compile it
63260
jsch := compiledSchema
64261
if jsch == nil {
@@ -88,11 +285,29 @@ func ValidateOpenAPIDocumentWithPrecompiled(doc libopenapi.Document, compiledSch
88285
if err != nil {
89286
// Fall back to normalizeJSON if UnmarshalJSON fails
90287
if info.SpecJSON != nil {
91-
normalized = normalizeJSON(*info.SpecJSON)
288+
normalized, err = normalizeJSON(*info.SpecJSON)
289+
if err != nil {
290+
return false, []*liberrors.ValidationError{buildDocumentDecodeError(
291+
fmt.Sprintf("The OpenAPI document cannot be converted to JSON: %s", err.Error()),
292+
"SpecJSON",
293+
)}
294+
}
295+
} else {
296+
return false, []*liberrors.ValidationError{buildDocumentDecodeError(
297+
fmt.Sprintf("The document's SpecJSONBytes cannot be decoded as JSON: %s", err.Error()),
298+
"SpecJSONBytes",
299+
)}
92300
}
93301
}
94302
} else if info.SpecJSON != nil {
95-
normalized = normalizeJSON(*info.SpecJSON)
303+
var err error
304+
normalized, err = normalizeJSON(*info.SpecJSON)
305+
if err != nil {
306+
return false, []*liberrors.ValidationError{buildDocumentDecodeError(
307+
fmt.Sprintf("The OpenAPI document cannot be converted to JSON: %s", err.Error()),
308+
"SpecJSON",
309+
)}
310+
}
96311
}
97312

98313
// Validate the document

0 commit comments

Comments
 (0)