Skip to content

Commit

Permalink
Import spec-diffing code that @Jamaalbwells built for apigee/registry. (
Browse files Browse the repository at this point in the history
  • Loading branch information
timburks authored Mar 23, 2023
1 parent c5fc27f commit 7df88d7
Show file tree
Hide file tree
Showing 16 changed files with 2,055 additions and 6 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
// Copyright 2021 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package breakingchangedetector

import (
"regexp"

"github.com/apigee/registry-experimental/rpc"
)

type detectionPattern struct {
PositiveMatchRegex *regexp.Regexp
NegativeMatchRegex *regexp.Regexp
}

var (
unsafeAdds = []detectionPattern{
{
PositiveMatchRegex: regexp.MustCompile("(components.)+(.|)+(schemas)+(.)+(required)"),
},
}

unsafeDeletes = []detectionPattern{
{
PositiveMatchRegex: regexp.MustCompile("(components.)+(.|)+(schemas)+(.)"),
NegativeMatchRegex: regexp.MustCompile("(components.)+(.|)+(schemas)+(.)+(required)"),
},
{
PositiveMatchRegex: regexp.MustCompile("(paths.)+(.|)"),
NegativeMatchRegex: regexp.MustCompile("((paths.)+(.|)+(tags))+(|)+((paths.)+(.|)+(description))"),
},
}

unsafeMods = []detectionPattern{
{
PositiveMatchRegex: regexp.MustCompile("(components.)+(.|)+(schemas)+(.|)+(type)"),
},
{
PositiveMatchRegex: regexp.MustCompile("(paths.)+(.|)+(type)"),
NegativeMatchRegex: regexp.MustCompile("((paths.)+(.|)+(tags))+(|)+((paths.)+(.|)+(description))"),
},
}

safeAdds = []detectionPattern{
{
PositiveMatchRegex: regexp.MustCompile("(info.)+(.)"),
},
{
PositiveMatchRegex: regexp.MustCompile("(tags.)+(.)"),
},
{
PositiveMatchRegex: regexp.MustCompile("(components.)+(.|)+(schemas)+(.)"),
NegativeMatchRegex: regexp.MustCompile("(components.)+(.|)+(schemas)+(.)+(required)"),
},
}

safeDeletes = []detectionPattern{
{
PositiveMatchRegex: regexp.MustCompile("(info.)+(.)"),
},
{
PositiveMatchRegex: regexp.MustCompile("(tags.)+(.)"),
},
}

safeMods = []detectionPattern{
{
PositiveMatchRegex: regexp.MustCompile("(info.)+(.)"),
},
{
PositiveMatchRegex: regexp.MustCompile("(tags.)+(.)"),
},
}
)

// GetChangeDetails compares each change in a diff Proto to the relevant change type detection Patterns.
// Each change is then categorized as breaking, nonbreaking, or unknown.
func GetChangeDetails(diff *rpc.Diff) *rpc.ChangeDetails {
return &rpc.ChangeDetails{
BreakingChanges: getBreakingChanges(diff),
NonBreakingChanges: getNonBreakingChanges(diff),
UnknownChanges: getUnknownChanges(diff),
}
}

func getBreakingChanges(diff *rpc.Diff) *rpc.Diff {
breakingChanges := &rpc.Diff{
Additions: []string{},
Deletions: []string{},
Modifications: map[string]*rpc.Diff_ValueChange{},
}
for _, addition := range diff.GetAdditions() {
if fitsAnyPattern(unsafeAdds, addition) {
breakingChanges.Additions = append(breakingChanges.Additions, addition)
}
}

for _, deletion := range diff.GetDeletions() {
if fitsAnyPattern(unsafeDeletes, deletion) {
breakingChanges.Deletions = append(breakingChanges.Deletions, deletion)
}
}

for modification, modValue := range diff.GetModifications() {
if fitsAnyPattern(unsafeMods, modification) {
breakingChanges.Modifications[modification] = modValue
}
}
return breakingChanges
}

func getNonBreakingChanges(diff *rpc.Diff) *rpc.Diff {
nonBreakingChanges := &rpc.Diff{
Additions: []string{},
Deletions: []string{},
Modifications: map[string]*rpc.Diff_ValueChange{},
}
for _, addition := range diff.GetAdditions() {
if fitsAnyPattern(safeAdds, addition) {
nonBreakingChanges.Additions = append(nonBreakingChanges.Additions, addition)
}
}
for _, deletion := range diff.GetDeletions() {
if fitsAnyPattern(safeDeletes, deletion) {
nonBreakingChanges.Deletions = append(nonBreakingChanges.Deletions, deletion)
}
}

for modification, modValue := range diff.GetModifications() {
if fitsAnyPattern(safeMods, modification) {
nonBreakingChanges.Modifications[modification] = modValue
}
}
return nonBreakingChanges
}

func getUnknownChanges(diff *rpc.Diff) *rpc.Diff {
unknownChanges := &rpc.Diff{
Additions: []string{},
Deletions: []string{},
Modifications: map[string]*rpc.Diff_ValueChange{},
}
for _, addition := range diff.GetAdditions() {
if !fitsAnyPattern(safeAdds, addition) && !fitsAnyPattern(unsafeAdds, addition) {
unknownChanges.Additions = append(unknownChanges.Additions, addition)
}
}

for _, deletion := range diff.GetDeletions() {
if !fitsAnyPattern(safeDeletes, deletion) && !fitsAnyPattern(unsafeDeletes, deletion) {
unknownChanges.Deletions = append(unknownChanges.Deletions, deletion)
}
}

for modification, modValue := range diff.GetModifications() {
if !fitsAnyPattern(safeMods, modification) && !fitsAnyPattern(unsafeMods, modification) {
unknownChanges.Modifications[modification] = modValue
}
}
return unknownChanges
}

func fitsAnyPattern(patterns []detectionPattern, change string) bool {
for _, pattern := range patterns {
if p := pattern.NegativeMatchRegex; p != nil && p.MatchString(change) {
continue
}
if p := pattern.PositiveMatchRegex; p != nil && p.MatchString(change) {
return true
}
}
return false
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// Copyright 2021 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package breakingchangedetector

import (
"testing"

"github.com/apigee/registry-experimental/rpc"
"github.com/apigee/registry/pkg/connection/grpctest"
"github.com/apigee/registry/server/registry"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"google.golang.org/protobuf/testing/protocmp"
)

// TestMain will set up a local RegistryServer and grpc.Server for all
// tests in this package if APG_REGISTRY_ADDRESS env var is not set
// for the client.
func TestMain(m *testing.M) {
grpctest.TestMain(m, registry.Config{})
}

func TestChanges(t *testing.T) {
tests := []struct {
desc string
diffProto *rpc.Diff
wantProto *rpc.ChangeDetails
}{
{
desc: "Components.Required field Addition Breaking Test",
diffProto: &rpc.Diff{
Additions: []string{"components.schemas.x.required.x"},
},
wantProto: &rpc.ChangeDetails{
BreakingChanges: &rpc.Diff{
Additions: []string{"components.schemas.x.required.x"},
},
NonBreakingChanges: &rpc.Diff{},
UnknownChanges: &rpc.Diff{},
},
},
{
desc: "Components.Schemas field Deletion Breaking Test",
diffProto: &rpc.Diff{
Deletions: []string{"components.schemas.x.x"},
},
wantProto: &rpc.ChangeDetails{
BreakingChanges: &rpc.Diff{
Deletions: []string{"components.schemas.x.x"},
},
NonBreakingChanges: &rpc.Diff{},
UnknownChanges: &rpc.Diff{},
},
},
{
desc: "Components.Schema.Type field Modification Breaking Test",
diffProto: &rpc.Diff{
Modifications: map[string]*rpc.Diff_ValueChange{
"components.schemas.x.properties.type": {
To: "float",
From: "int64",
},
},
},
wantProto: &rpc.ChangeDetails{
BreakingChanges: &rpc.Diff{
Modifications: map[string]*rpc.Diff_ValueChange{
"components.schemas.x.properties.type": {
To: "float",
From: "int64",
},
},
},
NonBreakingChanges: &rpc.Diff{},
UnknownChanges: &rpc.Diff{},
},
},
{
desc: "Info field Addition NonBreaking Test",
diffProto: &rpc.Diff{
Additions: []string{"info.x.x"},
},
wantProto: &rpc.ChangeDetails{
BreakingChanges: &rpc.Diff{},
NonBreakingChanges: &rpc.Diff{
Additions: []string{"info.x.x"},
},
UnknownChanges: &rpc.Diff{},
},
},
{
desc: "Info field Deletion NonBreaking Test",
diffProto: &rpc.Diff{
Deletions: []string{"info.x.x"},
},
wantProto: &rpc.ChangeDetails{
BreakingChanges: &rpc.Diff{},
NonBreakingChanges: &rpc.Diff{
Deletions: []string{"info.x.x"},
},
UnknownChanges: &rpc.Diff{},
},
},
{
desc: "Info field Modification NonBreaking Test",
diffProto: &rpc.Diff{
Modifications: map[string]*rpc.Diff_ValueChange{
"info.x.x.x": {
To: "to",
From: "from",
},
},
},
wantProto: &rpc.ChangeDetails{
BreakingChanges: &rpc.Diff{},
NonBreakingChanges: &rpc.Diff{
Modifications: map[string]*rpc.Diff_ValueChange{
"info.x.x.x": {
To: "to",
From: "from",
},
},
},
UnknownChanges: &rpc.Diff{},
},
},
{
desc: "Components.Schemas field Addition NonBreaking Test",
diffProto: &rpc.Diff{
Additions: []string{"components.schemas.x.x"},
},
wantProto: &rpc.ChangeDetails{
BreakingChanges: &rpc.Diff{},
NonBreakingChanges: &rpc.Diff{
Additions: []string{"components.schemas.x.x"},
},
UnknownChanges: &rpc.Diff{},
},
},
}
for _, test := range tests {
t.Run(test.desc, func(t *testing.T) {
gotProto := GetChangeDetails(test.diffProto)
opts := cmp.Options{
protocmp.Transform(),
cmpopts.SortSlices(func(a, b string) bool { return a < b }),
}
if !cmp.Equal(test.wantProto, gotProto, opts) {
t.Errorf("GetDiff returned unexpected diff (-want +got):\n%s", cmp.Diff(test.wantProto, gotProto, opts))
}
})
}
}
Loading

0 comments on commit 7df88d7

Please sign in to comment.