Skip to content

Commit

Permalink
fix: throw error when conflicting components are found
Browse files Browse the repository at this point in the history
When we roll up versions and copy over paths from older spec, we
naïvely merge the components defined in those specs. This is a problem
when similar named components are defined in two versions of a spec that
is rolled up.

To fix this problem is a lot of effort which we do not have time for
right now, but detecting it is relatively easy. This patch will fail
building specs if we detect such a case so a user can work around it
manually.
  • Loading branch information
jgresty committed Apr 10, 2024
1 parent ace1ba7 commit a682496
Show file tree
Hide file tree
Showing 7 changed files with 142 additions and 40 deletions.
2 changes: 1 addition & 1 deletion collator.go
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ var cmpComponents = cmp.Options{
// Refs themselves can mutate during relocation, so they are excluded from
// content comparison.
cmp.FilterPath(func(p cmp.Path) bool {
return p.Last().String() == ".Ref"
return p.Last().String() == ".Ref" || p.Last().String() == ".extra"
}, cmp.Ignore()),
}

Expand Down
10 changes: 8 additions & 2 deletions internal/compiler/compiler.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,10 +184,16 @@ func (c *Compiler) Build(ctx context.Context, apiName string) error {

// Merge all overlays
for _, doc := range api.overlayIncludes {
vervet.Merge(spec, doc.T, true)
err = vervet.Merge(spec, doc.T, true)
if err != nil {
return buildErr(err)
}
}
for _, doc := range api.overlayInlines {
vervet.Merge(spec, doc, true)
err = vervet.Merge(spec, doc, true)
if err != nil {
return buildErr(err)
}
}

// Write the compiled spec to JSON and YAML
Expand Down
65 changes: 49 additions & 16 deletions merge.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package vervet

import (
"errors"
"sort"

"github.com/getkin/kin-openapi/openapi3"
Expand All @@ -19,15 +20,20 @@ import (
// - This function is suitable for overlay merging scenarios only.
// - Component merging should be removed. Use Collator for safe component
// merging.
func Merge(dst, src *openapi3.T, replace bool) {
mergeComponents(dst, src, replace)
func Merge(dst, src *openapi3.T, replace bool) error {
err := mergeComponents(dst, src, replace)
if err != nil {
return err
}
mergeExtensions(dst, src, replace)
mergeInfo(dst, src, replace)
mergeOpenAPIVersion(dst, src, replace)
mergePaths(dst, src, replace)
mergeSecurityRequirements(dst, src, replace)
mergeServers(dst, src, replace)
mergeTags(dst, src, replace)

return nil
}

func mergeOpenAPIVersion(dst, src *openapi3.T, replace bool) {
Expand Down Expand Up @@ -87,9 +93,9 @@ func initDestinationComponents(dst, src *openapi3.T) {
}
}

func mergeComponents(dst, src *openapi3.T, replace bool) {
func mergeComponents(dst, src *openapi3.T, replace bool) error {
if src.Components == nil {
return
return nil
}

if dst.Components == nil {
Expand All @@ -98,23 +104,50 @@ func mergeComponents(dst, src *openapi3.T, replace bool) {

initDestinationComponents(dst, src)

mergeMap(dst.Components.Schemas, src.Components.Schemas, replace)
mergeMap(dst.Components.Parameters, src.Components.Parameters, replace)
mergeMap(dst.Components.Headers, src.Components.Headers, replace)
mergeMap(dst.Components.RequestBodies, src.Components.RequestBodies, replace)
mergeMap(dst.Components.Responses, src.Components.Responses, replace)
mergeMap(dst.Components.SecuritySchemes, src.Components.SecuritySchemes, replace)
mergeMap(dst.Components.Examples, src.Components.Examples, replace)
mergeMap(dst.Components.Links, src.Components.Links, replace)
mergeMap(dst.Components.Callbacks, src.Components.Callbacks, replace)
err := mergeMap(dst.Components.Schemas, src.Components.Schemas, replace)
if err != nil {
return err
}
err = mergeMap(dst.Components.Parameters, src.Components.Parameters, replace)
if err != nil {
return err
}
err = mergeMap(dst.Components.Headers, src.Components.Headers, replace)
if err != nil {
return err
}
err = mergeMap(dst.Components.RequestBodies, src.Components.RequestBodies, replace)
if err != nil {
return err
}
err = mergeMap(dst.Components.Responses, src.Components.Responses, replace)
if err != nil {
return err
}
err = mergeMap(dst.Components.SecuritySchemes, src.Components.SecuritySchemes, replace)
if err != nil {
return err
}
err = mergeMap(dst.Components.Examples, src.Components.Examples, replace)
if err != nil {
return err
}
err = mergeMap(dst.Components.Links, src.Components.Links, replace)
if err != nil {
return err
}
return mergeMap(dst.Components.Callbacks, src.Components.Callbacks, replace)
}

func mergeMap[T any](dst, src map[string]T, replace bool) {
func mergeMap[T any](dst, src map[string]T, replace bool) error {
for k, v := range src {
if _, ok := dst[k]; !ok || replace {
dst[k] = v
existing, exists := dst[k]
if exists && !replace && !componentsEqual(v, existing) {
return errors.New("conflicting component: " + k)
}
dst[k] = v
}
return nil
}

func mergeExtensions(dst, src *openapi3.T, replace bool) {
Expand Down
30 changes: 22 additions & 8 deletions merge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,18 @@ var openapiCmp = qt.CmpEquals(cmpopts.IgnoreUnexported(

func TestMergeComponents(t *testing.T) {
c := qt.New(t)
c.Run("component without replace and conflict", func(c *qt.C) {
src := mustLoadFile(c, "merge_test_conflict.yaml")
dst := mustLoadFile(c, "merge_test_dst.yaml")
err := vervet.Merge(dst, src, false)
c.Assert(err, qt.IsNotNil)
})
c.Run("component without replace", func(c *qt.C) {
src := mustLoadFile(c, "merge_test_src.yaml")
dstOrig := mustLoadFile(c, "merge_test_dst.yaml")
dst := mustLoadFile(c, "merge_test_dst.yaml")
vervet.Merge(dst, src, false)
err := vervet.Merge(dst, src, false)
c.Assert(err, qt.IsNil)

c.Assert(dst.Components.Schemas["Foo"], openapiCmp, dstOrig.Components.Schemas["Foo"])
c.Assert(dst.Components.Schemas["Bar"], openapiCmp, src.Components.Schemas["Bar"])
Expand Down Expand Up @@ -62,7 +69,8 @@ func TestMergeComponents(t *testing.T) {
src := mustLoadFile(c, "merge_test_src.yaml")
dstOrig := mustLoadFile(c, "merge_test_dst.yaml")
dst := mustLoadFile(c, "merge_test_dst.yaml")
vervet.Merge(dst, src, true)
err := vervet.Merge(dst, src, true)
c.Assert(err, qt.IsNil)

c.Assert(dst.Components.Schemas["Foo"], openapiCmp, src.Components.Schemas["Foo"])
c.Assert(dst.Components.Schemas["Bar"], openapiCmp, src.Components.Schemas["Bar"])
Expand Down Expand Up @@ -96,7 +104,8 @@ func TestMergeComponents(t *testing.T) {
src := mustLoadFile(c, "merge_test_src.yaml")
dstOrig := mustLoadFile(c, "merge_test_dst_missing_components.yaml")
dst := mustLoadFile(c, "merge_test_dst_missing_components.yaml")
vervet.Merge(dst, src, true)
err := vervet.Merge(dst, src, true)
c.Assert(err, qt.IsNil)

c.Assert(dst.Components.Schemas["Foo"], openapiCmp, src.Components.Schemas["Foo"])
c.Assert(dst.Components.Schemas["Bar"], openapiCmp, src.Components.Schemas["Bar"])
Expand Down Expand Up @@ -147,7 +156,8 @@ tags:
c.Run("tags without replace", func(c *qt.C) {
src := mustLoad(c, srcYaml)
dst := mustLoad(c, dstYaml)
vervet.Merge(dst, src, false)
err := vervet.Merge(dst, src, false)
c.Assert(err, qt.IsNil)
c.Assert(dst.Tags, qt.DeepEquals, openapi3.Tags{{
Extensions: map[string]interface{}{},
Name: "bar",
Expand All @@ -165,7 +175,8 @@ tags:
c.Run("tags with replace", func(c *qt.C) {
src := mustLoad(c, srcYaml)
dst := mustLoad(c, dstYaml)
vervet.Merge(dst, src, true)
err := vervet.Merge(dst, src, true)
c.Assert(err, qt.IsNil)
c.Assert(dst.Tags, qt.DeepEquals, openapi3.Tags{{
Extensions: map[string]interface{}{},
Name: "bar",
Expand Down Expand Up @@ -225,7 +236,8 @@ x-extension:
c.Run("without replace", func(c *qt.C) {
src := mustLoad(c, srcYaml)
dst := mustLoad(c, dstYaml)
vervet.Merge(dst, src, false)
err := vervet.Merge(dst, src, false)
c.Assert(err, qt.IsNil)
c.Assert(dst.Info, qt.DeepEquals, &openapi3.Info{
Extensions: map[string]interface{}{},
Title: "Dst",
Expand Down Expand Up @@ -255,7 +267,8 @@ x-extension:
c.Run("with replace", func(c *qt.C) {
src := mustLoad(c, srcYaml)
dst := mustLoad(c, dstYaml)
vervet.Merge(dst, src, true)
err := vervet.Merge(dst, src, true)
c.Assert(err, qt.IsNil)
c.Assert(dst.Info, qt.DeepEquals, &openapi3.Info{
Extensions: map[string]interface{}{},
Title: "Src",
Expand Down Expand Up @@ -303,7 +316,8 @@ paths:
`
src := mustLoad(c, srcYaml)
dst := &openapi3.T{}
vervet.Merge(dst, src, false)
err := vervet.Merge(dst, src, false)
c.Assert(err, qt.IsNil)
c.Assert(dst.Paths, qt.HasLen, 1)
}

Expand Down
16 changes: 12 additions & 4 deletions spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ func (sv *SpecVersions) At(v Version) (*openapi3.T, error) {
return doc, nil
}

func (sv *SpecVersions) resolveOperations() {
func (sv *SpecVersions) resolveOperations() error {
type operationKey struct {
path, operation string
}
Expand Down Expand Up @@ -151,7 +151,14 @@ func (sv *SpecVersions) resolveOperations() {
if currentOp == nil {
// The added operation may reference components from its source
// document; import those that are missing here.
mergeComponents(doc, opValue.src, false)
err := mergeComponents(doc, opValue.src, false)
if err != nil {
return fmt.Errorf(`
Cannot rollup endpoints as there are conflicts in components."
"This is a known problem with Vervet.
Please rename conflicting components as a workaround:
%w`, err)
}
setOperationByName(currentPathItem, opKey.operation, opValue.operation)
}
}
Expand All @@ -161,6 +168,7 @@ func (sv *SpecVersions) resolveOperations() {
currentActiveOps[opKey] = nextOpValue
}
}
return nil
}

var operationNames = []string{
Expand Down Expand Up @@ -250,8 +258,8 @@ func newSpecVersions(specs resourceVersionsSlice) (*SpecVersions, error) {
index: NewVersionIndex(maps.Keys(documentVersions)),
documents: documentVersions,
}
sv.resolveOperations()
return sv, nil
err := sv.resolveOperations()
return sv, err
}

func findResources(root string) ([]string, error) {
Expand Down
45 changes: 45 additions & 0 deletions testdata/merge_test_conflict.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
components:
schemas:
Foo:
type: object
properties:
color:
type: string
enum: ["red", "green", "blue"]
dimension:
type: array
items:
type: number
strange:
type: boolean
parameters:
Foo:
in: path
name: foo
required: true
schema:
type: string
headers:
Foo:
schema:
type: string
requestBodies:
Foo:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Foo'
responses:
'200':
content:
application/vnd.api+json:
schema:
$ref: '#/components/schemas/Foo'
securitySchemes:
Foo:
type: http
scheme: basic
examples:
Foo:
value: foo
14 changes: 5 additions & 9 deletions testdata/merge_test_dst.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,9 @@ components:
properties:
color:
type: string
enum: ["red", "green", "blue"]
dimension:
type: array
items:
type: number
strange:
type: boolean
enum: ["red", "green", "blue", "alpha"]
quantity:
type: number
Baz:
type: object
properties:
Expand All @@ -20,7 +16,7 @@ components:
parameters:
Foo:
in: path
name: foo2
name: foo
required: true
schema:
type: string
Expand Down Expand Up @@ -70,6 +66,6 @@ components:
scheme: basic
examples:
Foo:
value: foo-natured
value: foo
Baz:
value: bazil

0 comments on commit a682496

Please sign in to comment.