diff --git a/index/extract_refs.go b/index/extract_refs.go index 176fe51c..154e0280 100644 --- a/index/extract_refs.go +++ b/index/extract_refs.go @@ -11,7 +11,6 @@ import ( "os" "path/filepath" "runtime" - "slices" "sort" "strconv" "strings" @@ -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 { + 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 { @@ -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) { if nodeId := FindSchemaIdInNode(node); nodeId != "" { resolvedNodeId, _ := ResolveSchemaId(nodeId, parentBaseUri) if resolvedNodeId == "" { @@ -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-") { @@ -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] @@ -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, @@ -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 } diff --git a/index/extract_refs_test.go b/index/extract_refs_test.go index dcf89edf..44db8447 100644 --- a/index/extract_refs_test.go +++ b/index/extract_refs_test.go @@ -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)) + }) + } +} diff --git a/index/schema_id_test.go b/index/schema_id_test.go index feed0d80..af0050e6 100644 --- a/index/schema_id_test.go +++ b/index/schema_id_test.go @@ -750,6 +750,224 @@ components: assert.Contains(t, petEntry.DefinitionPath, "Pet") } +func TestSchemaId_IgnoredUnderExampleAndExamples(t *testing.T) { + 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"