Skip to content

Commit 9b39ffc

Browse files
luke-hagar-spclaude
authored andcommitted
Fix underOpenAPIExamplePath to distinguish property names from OpenAPI keywords
The previous implementation matched any path segment named "example" or "examples", which incorrectly suppressed $id extraction for schemas under properties with those names. Now checks the preceding segment — if it is "properties" or "patternProperties", the segment is a property name and not an OpenAPI example keyword. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8c0eff3 commit 9b39ffc

3 files changed

Lines changed: 130 additions & 10 deletions

File tree

index/extract_refs.go

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import (
1111
"os"
1212
"path/filepath"
1313
"runtime"
14-
"slices"
1514
"sort"
1615
"strconv"
1716
"strings"
@@ -57,10 +56,18 @@ func isArrayOfSchemaContainingNode(v string) bool {
5756
return false
5857
}
5958

60-
// underOpenAPIExamplePath reports whether seenPath is under OpenAPI example or examples payload
61-
// (sample data, not schema). Matches description/summary and properties skipping in this file.
59+
// underOpenAPIExamplePath reports whether seenPath is under an OpenAPI example or examples
60+
// keyword (sample data, not schema). A segment named "example" or "examples" that is preceded
61+
// by "properties" or "patternProperties" is a schema property name, not an OpenAPI keyword.
6262
func underOpenAPIExamplePath(seenPath []string) bool {
63-
return slices.Contains(seenPath, "example") || slices.Contains(seenPath, "examples")
63+
for i, p := range seenPath {
64+
if p == "example" || p == "examples" {
65+
if i == 0 || (seenPath[i-1] != "properties" && seenPath[i-1] != "patternProperties") {
66+
return true
67+
}
68+
}
69+
}
70+
return false
6471
}
6572

6673
// ExtractRefs will return a deduplicated slice of references for every unique ref found in the document.
@@ -176,11 +183,13 @@ func (index *SpecIndex) ExtractRefs(ctx context.Context, node, parent *yaml.Node
176183
if len(seenPath) > 0 {
177184
skip := false
178185

179-
// iterate through the path and look for an item named 'examples' or 'example'
180-
for _, p := range seenPath {
186+
// iterate through the path and look for an OpenAPI example/examples keyword or extension
187+
for j, p := range seenPath {
181188
if p == "examples" || p == "example" {
182-
skip = true
183-
break
189+
if j == 0 || (seenPath[j-1] != "properties" && seenPath[j-1] != "patternProperties") {
190+
skip = true
191+
break
192+
}
184193
}
185194
// look for any extension in the path and ignore it
186195
if strings.HasPrefix(p, "x-") {
@@ -670,7 +679,7 @@ func (index *SpecIndex) ExtractRefs(ctx context.Context, node, parent *yaml.Node
670679
prev = n.Value
671680
continue
672681
}
673-
if !slices.Contains(seenPath, "example") && !slices.Contains(seenPath, "examples") {
682+
if !underOpenAPIExamplePath(seenPath) {
674683
ref := &DescriptionReference{
675684
ParentNode: parent,
676685
Content: node.Content[i+1].Value,
@@ -699,7 +708,7 @@ func (index *SpecIndex) ExtractRefs(ctx context.Context, node, parent *yaml.Node
699708
continue
700709
}
701710

702-
if slices.Contains(seenPath, "example") || slices.Contains(seenPath, "examples") {
711+
if underOpenAPIExamplePath(seenPath) {
703712
continue
704713
}
705714

index/extract_refs_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -723,6 +723,11 @@ func TestUnderOpenAPIExamplePath(t *testing.T) {
723723
{"under_example", []string{"paths", "get", "responses", "200", "content", "application/json", "schema", "example"}, true},
724724
{"under_examples", []string{"content", "application/json", "schema", "examples", "sample", "value"}, true},
725725
{"example_not_whole_segment", []string{"paths", "exampled"}, false},
726+
{"example_as_property_name", []string{"components", "schemas", "Foo", "properties", "example"}, false},
727+
{"examples_as_property_name", []string{"components", "schemas", "Foo", "properties", "examples"}, false},
728+
{"nested_under_property_example", []string{"components", "schemas", "Foo", "properties", "example", "properties", "id"}, false},
729+
{"patternProperties_example", []string{"components", "schemas", "Foo", "patternProperties", "example"}, false},
730+
{"real_example_after_property_example", []string{"components", "schemas", "Foo", "properties", "example", "example"}, true},
726731
}
727732
for _, tt := range tests {
728733
t.Run(tt.name, func(t *testing.T) {

index/schema_id_test.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -862,6 +862,112 @@ paths:
862862
})
863863
}
864864

865+
func TestSchemaId_NotIgnoredUnderPropertiesExample(t *testing.T) {
866+
t.Run("property_named_example", func(t *testing.T) {
867+
spec := `openapi: "3.1.0"
868+
info:
869+
title: Test API
870+
version: 1.0.0
871+
components:
872+
schemas:
873+
MySchema:
874+
$id: "https://example.com/schemas/myschema.json"
875+
type: object
876+
properties:
877+
example:
878+
$id: "https://example.com/schemas/example-prop.json"
879+
type: object
880+
properties:
881+
id:
882+
type: string
883+
`
884+
var rootNode yaml.Node
885+
err := yaml.Unmarshal([]byte(spec), &rootNode)
886+
assert.NoError(t, err)
887+
888+
config := CreateClosedAPIIndexConfig()
889+
config.SpecAbsolutePath = "https://example.com/openapi.yaml"
890+
index := NewSpecIndexWithConfig(&rootNode, config)
891+
assert.NotNil(t, index)
892+
893+
allIds := index.GetAllSchemaIds()
894+
assert.Len(t, allIds, 2)
895+
assert.NotNil(t, allIds["https://example.com/schemas/myschema.json"])
896+
assert.NotNil(t, allIds["https://example.com/schemas/example-prop.json"])
897+
})
898+
899+
t.Run("property_named_examples", func(t *testing.T) {
900+
spec := `openapi: "3.1.0"
901+
info:
902+
title: Test API
903+
version: 1.0.0
904+
components:
905+
schemas:
906+
MySchema:
907+
type: object
908+
properties:
909+
examples:
910+
$id: "https://example.com/schemas/examples-prop.json"
911+
type: object
912+
properties:
913+
list:
914+
type: array
915+
`
916+
var rootNode yaml.Node
917+
err := yaml.Unmarshal([]byte(spec), &rootNode)
918+
assert.NoError(t, err)
919+
920+
config := CreateClosedAPIIndexConfig()
921+
config.SpecAbsolutePath = "https://example.com/openapi.yaml"
922+
index := NewSpecIndexWithConfig(&rootNode, config)
923+
assert.NotNil(t, index)
924+
925+
allIds := index.GetAllSchemaIds()
926+
assert.Len(t, allIds, 1)
927+
assert.NotNil(t, allIds["https://example.com/schemas/examples-prop.json"])
928+
})
929+
930+
t.Run("real_example_still_ignored", func(t *testing.T) {
931+
spec := `openapi: "3.1.0"
932+
info:
933+
title: Test API
934+
version: 1.0.0
935+
paths:
936+
/pets:
937+
get:
938+
responses:
939+
"200":
940+
description: ok
941+
content:
942+
application/json:
943+
schema:
944+
$id: "https://example.com/schemas/pet.json"
945+
type: object
946+
properties:
947+
example:
948+
$id: "https://example.com/schemas/example-prop.json"
949+
type: string
950+
example:
951+
$id: "https://example.com/should-not-register"
952+
id: "1"
953+
`
954+
var rootNode yaml.Node
955+
err := yaml.Unmarshal([]byte(spec), &rootNode)
956+
assert.NoError(t, err)
957+
958+
config := CreateClosedAPIIndexConfig()
959+
config.SpecAbsolutePath = "https://example.com/openapi.yaml"
960+
index := NewSpecIndexWithConfig(&rootNode, config)
961+
assert.NotNil(t, index)
962+
963+
allIds := index.GetAllSchemaIds()
964+
assert.Len(t, allIds, 2)
965+
assert.NotNil(t, allIds["https://example.com/schemas/pet.json"])
966+
assert.NotNil(t, allIds["https://example.com/schemas/example-prop.json"])
967+
assert.Nil(t, allIds["https://example.com/should-not-register"])
968+
})
969+
}
970+
865971
func TestSchemaId_ExtractionWithInvalidId(t *testing.T) {
866972
// OpenAPI 3.1 spec with invalid $id (contains fragment)
867973
spec := `openapi: "3.1.0"

0 commit comments

Comments
 (0)