Skip to content
Open
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
34 changes: 26 additions & 8 deletions index/extract_refs.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import (
"os"
"path/filepath"
"runtime"
"slices"
"sort"
"strconv"
"strings"
Expand Down Expand Up @@ -57,6 +56,20 @@ func isArrayOfSchemaContainingNode(v string) bool {
return false
}

// underOpenAPIExamplePath reports whether seenPath is under an OpenAPI example or examples
// keyword (sample data, not schema). A segment named "example" or "examples" that is preceded
// by "properties" or "patternProperties" is a schema property name, not an OpenAPI keyword.
func underOpenAPIExamplePath(seenPath []string) bool {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This helper is too broad. seenPath includes ordinary schema property names, so treating any segment equal to example or examples as an OpenAPI sample payload will incorrectly suppress valid $id extraction for schemas under properties with those names.

for i, p := range seenPath {
if p == "example" || p == "examples" {
if i == 0 || (seenPath[i-1] != "properties" && seenPath[i-1] != "patternProperties") {
return true
}
}
}
return false
}

// ExtractRefs will return a deduplicated slice of references for every unique ref found in the document.
// The total number of refs, will generally be much higher, you can extract those from GetRawReferenceCount()
func (index *SpecIndex) ExtractRefs(ctx context.Context, node, parent *yaml.Node, seenPath []string, level int, poly bool, pName string) []*Reference {
Expand All @@ -77,7 +90,7 @@ func (index *SpecIndex) ExtractRefs(ctx context.Context, node, parent *yaml.Node

// Check if THIS node has a $id and update scope for processing children
// This must happen before iterating children so they see the updated scope
if node.Kind == yaml.MappingNode {
if node.Kind == yaml.MappingNode && !underOpenAPIExamplePath(seenPath) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Skipping the node-level $id scope update here causes the same false positive: any real schema reached through a property named example or examples will no longer establish its own $id base URI for child traversal.

if nodeId := FindSchemaIdInNode(node); nodeId != "" {
resolvedNodeId, _ := ResolveSchemaId(nodeId, parentBaseUri)
if resolvedNodeId == "" {
Expand Down Expand Up @@ -170,11 +183,13 @@ func (index *SpecIndex) ExtractRefs(ctx context.Context, node, parent *yaml.Node
if len(seenPath) > 0 {
skip := false

// iterate through the path and look for an item named 'examples' or 'example'
for _, p := range seenPath {
// iterate through the path and look for an OpenAPI example/examples keyword or extension
for j, p := range seenPath {
if p == "examples" || p == "example" {
skip = true
break
if j == 0 || (seenPath[j-1] != "properties" && seenPath[j-1] != "patternProperties") {
skip = true
break
}
}
// look for any extension in the path and ignore it
if strings.HasPrefix(p, "x-") {
Expand Down Expand Up @@ -557,6 +572,9 @@ func (index *SpecIndex) ExtractRefs(ctx context.Context, node, parent *yaml.Node

// Detect and register JSON Schema 2020-12 $id declarations
if i%2 == 0 && n.Value == "$id" {
if underOpenAPIExamplePath(seenPath) {
continue
}
if len(node.Content) > i+1 && utils.IsNodeStringValue(node.Content[i+1]) {
idValue := node.Content[i+1].Value
idNode := node.Content[i+1]
Expand Down Expand Up @@ -661,7 +679,7 @@ func (index *SpecIndex) ExtractRefs(ctx context.Context, node, parent *yaml.Node
prev = n.Value
continue
}
if !slices.Contains(seenPath, "example") && !slices.Contains(seenPath, "examples") {
if !underOpenAPIExamplePath(seenPath) {
ref := &DescriptionReference{
ParentNode: parent,
Content: node.Content[i+1].Value,
Expand Down Expand Up @@ -690,7 +708,7 @@ func (index *SpecIndex) ExtractRefs(ctx context.Context, node, parent *yaml.Node
continue
}

if slices.Contains(seenPath, "example") || slices.Contains(seenPath, "examples") {
if underOpenAPIExamplePath(seenPath) {
continue
}

Expand Down
24 changes: 24 additions & 0 deletions index/extract_refs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -711,3 +711,27 @@ components:
func TestSpecIndex_isExternalReference_Nil(t *testing.T) {
assert.False(t, isExternalReference(nil))
}

func TestUnderOpenAPIExamplePath(t *testing.T) {
tests := []struct {
name string
path []string
want bool
}{
{"empty", nil, false},
{"no_example_segments", []string{"paths", "get", "responses", "200", "content", "application/json", "schema"}, false},
{"under_example", []string{"paths", "get", "responses", "200", "content", "application/json", "schema", "example"}, true},
{"under_examples", []string{"content", "application/json", "schema", "examples", "sample", "value"}, true},
{"example_not_whole_segment", []string{"paths", "exampled"}, false},
{"example_as_property_name", []string{"components", "schemas", "Foo", "properties", "example"}, false},
{"examples_as_property_name", []string{"components", "schemas", "Foo", "properties", "examples"}, false},
{"nested_under_property_example", []string{"components", "schemas", "Foo", "properties", "example", "properties", "id"}, false},
{"patternProperties_example", []string{"components", "schemas", "Foo", "patternProperties", "example"}, false},
{"real_example_after_property_example", []string{"components", "schemas", "Foo", "properties", "example", "example"}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, underOpenAPIExamplePath(tt.path))
})
}
}
218 changes: 218 additions & 0 deletions index/schema_id_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -750,6 +750,224 @@ components:
assert.Contains(t, petEntry.DefinitionPath, "Pet")
}

func TestSchemaId_IgnoredUnderExampleAndExamples(t *testing.T) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

These tests only cover $id inside actual OpenAPI example and examples payloads, so they miss the regression where a legitimate schema under properties.example or properties.examples is now incorrectly ignored.

t.Run("example_payload", func(t *testing.T) {
spec := `openapi: "3.1.0"
info:
title: Test API
version: 1.0.0
paths:
/pets:
get:
responses:
"200":
description: ok
content:
application/json:
schema:
$id: "https://example.com/schemas/pet.json"
type: object
properties:
id:
type: string
example:
$id: "https://example.com/should-not-register"
id: "1"
`
var rootNode yaml.Node
err := yaml.Unmarshal([]byte(spec), &rootNode)
assert.NoError(t, err)

config := CreateClosedAPIIndexConfig()
config.SpecAbsolutePath = "https://example.com/openapi.yaml"
index := NewSpecIndexWithConfig(&rootNode, config)
assert.NotNil(t, index)

allIds := index.GetAllSchemaIds()
assert.Len(t, allIds, 1)
assert.NotNil(t, allIds["https://example.com/schemas/pet.json"])
assert.Nil(t, allIds["https://example.com/should-not-register"])
})

t.Run("examples_named_value", func(t *testing.T) {
spec := `openapi: "3.1.0"
info:
title: Test API
version: 1.0.0
paths:
/widgets:
get:
responses:
"200":
description: ok
content:
application/json:
schema:
$id: "https://example.com/schemas/widget.json"
type: object
examples:
sample:
value:
$id: "https://example.com/fake-from-examples"
foo: bar
`
var rootNode yaml.Node
err := yaml.Unmarshal([]byte(spec), &rootNode)
assert.NoError(t, err)

config := CreateClosedAPIIndexConfig()
config.SpecAbsolutePath = "https://example.com/openapi.yaml"
index := NewSpecIndexWithConfig(&rootNode, config)
assert.NotNil(t, index)

allIds := index.GetAllSchemaIds()
assert.Len(t, allIds, 1)
assert.NotNil(t, allIds["https://example.com/schemas/widget.json"])
assert.Nil(t, allIds["https://example.com/fake-from-examples"])
})

t.Run("invalid_id_in_example_no_index_error", func(t *testing.T) {
spec := `openapi: "3.1.0"
info:
title: Test API
version: 1.0.0
paths:
/x:
get:
responses:
"200":
description: ok
content:
application/json:
schema:
type: object
example:
$id: "https://bad.com/schema#fragment"
k: v
`
var rootNode yaml.Node
err := yaml.Unmarshal([]byte(spec), &rootNode)
assert.NoError(t, err)

config := CreateClosedAPIIndexConfig()
config.SpecAbsolutePath = "https://example.com/openapi.yaml"
index := NewSpecIndexWithConfig(&rootNode, config)
assert.NotNil(t, index)

assert.Len(t, index.GetAllSchemaIds(), 0)
for _, e := range index.GetReferenceIndexErrors() {
assert.False(t, strings.Contains(e.Error(), "invalid $id"),
"$id inside example must not be validated as schema $id: %v", e)
}
})
}

func TestSchemaId_NotIgnoredUnderPropertiesExample(t *testing.T) {
t.Run("property_named_example", func(t *testing.T) {
spec := `openapi: "3.1.0"
info:
title: Test API
version: 1.0.0
components:
schemas:
MySchema:
$id: "https://example.com/schemas/myschema.json"
type: object
properties:
example:
$id: "https://example.com/schemas/example-prop.json"
type: object
properties:
id:
type: string
`
var rootNode yaml.Node
err := yaml.Unmarshal([]byte(spec), &rootNode)
assert.NoError(t, err)

config := CreateClosedAPIIndexConfig()
config.SpecAbsolutePath = "https://example.com/openapi.yaml"
index := NewSpecIndexWithConfig(&rootNode, config)
assert.NotNil(t, index)

allIds := index.GetAllSchemaIds()
assert.Len(t, allIds, 2)
assert.NotNil(t, allIds["https://example.com/schemas/myschema.json"])
assert.NotNil(t, allIds["https://example.com/schemas/example-prop.json"])
})

t.Run("property_named_examples", func(t *testing.T) {
spec := `openapi: "3.1.0"
info:
title: Test API
version: 1.0.0
components:
schemas:
MySchema:
type: object
properties:
examples:
$id: "https://example.com/schemas/examples-prop.json"
type: object
properties:
list:
type: array
`
var rootNode yaml.Node
err := yaml.Unmarshal([]byte(spec), &rootNode)
assert.NoError(t, err)

config := CreateClosedAPIIndexConfig()
config.SpecAbsolutePath = "https://example.com/openapi.yaml"
index := NewSpecIndexWithConfig(&rootNode, config)
assert.NotNil(t, index)

allIds := index.GetAllSchemaIds()
assert.Len(t, allIds, 1)
assert.NotNil(t, allIds["https://example.com/schemas/examples-prop.json"])
})

t.Run("real_example_still_ignored", func(t *testing.T) {
spec := `openapi: "3.1.0"
info:
title: Test API
version: 1.0.0
paths:
/pets:
get:
responses:
"200":
description: ok
content:
application/json:
schema:
$id: "https://example.com/schemas/pet.json"
type: object
properties:
example:
$id: "https://example.com/schemas/example-prop.json"
type: string
example:
$id: "https://example.com/should-not-register"
id: "1"
`
var rootNode yaml.Node
err := yaml.Unmarshal([]byte(spec), &rootNode)
assert.NoError(t, err)

config := CreateClosedAPIIndexConfig()
config.SpecAbsolutePath = "https://example.com/openapi.yaml"
index := NewSpecIndexWithConfig(&rootNode, config)
assert.NotNil(t, index)

allIds := index.GetAllSchemaIds()
assert.Len(t, allIds, 2)
assert.NotNil(t, allIds["https://example.com/schemas/pet.json"])
assert.NotNil(t, allIds["https://example.com/schemas/example-prop.json"])
assert.Nil(t, allIds["https://example.com/should-not-register"])
})
}

func TestSchemaId_ExtractionWithInvalidId(t *testing.T) {
// OpenAPI 3.1 spec with invalid $id (contains fragment)
spec := `openapi: "3.1.0"
Expand Down