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