diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 381e7d26..3d621e62 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -2,6 +2,11 @@ name: go on: pull_request: push: + branches: + - 'master' +permissions: + contents: read + jobs: build-and-test: env: @@ -11,7 +16,7 @@ jobs: strategy: fail-fast: true matrix: - go: ['1.21.5'] + go: ['1.21.6'] os: - ubuntu-latest - windows-latest @@ -67,15 +72,6 @@ jobs: - run: go fmt ./... - run: git --no-pager diff --exit-code - - if: runner.os == 'Linux' - name: golangci-lint - uses: golangci/golangci-lint-action@v3 - with: - version: v1.55 - - - id: govulncheck - uses: golang/govulncheck-action@v1 - - run: go test ./... -coverprofile=coverage.txt -covermode=atomic env: CGO_ENABLED: '1' diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..a7b12128 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,50 @@ +name: golangci-lint +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + golangci: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-go@v4 + with: + go-version: '1.21' + cache: false + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v3 + with: + # Require: The version of golangci-lint to use. + # When `install-mode` is `binary` (default) the value can be v1.2 or v1.2.3 or `latest` to use the latest version. + # When `install-mode` is `goinstall` the value can be v1.2.3, `latest`, or the hash of a commit. + version: latest + + # Optional: working directory, useful for monorepos + # working-directory: somedir + + # Optional: golangci-lint command line arguments. + # + # Note: By default, the `.golangci.yml` file should be at the root of the repository. + # The location of the configuration file can be changed by using `--config=` + # args: --timeout=30m --config=/my/path/.golangci.yml --issues-exit-code=0 + + # Optional: show only new issues if it's a pull request. The default value is `false`. + # only-new-issues: true + + # Optional: if set to true, then all caching functionality will be completely disabled, + # takes precedence over all other caching options. + # skip-cache: true + + # Optional: if set to true, then the action won't cache or restore ~/go/pkg. + # skip-pkg-cache: true + + # Optional: if set to true, then the action won't cache or restore ~/.cache/go-build. + # skip-build-cache: true + + # Optional: The mode to install golangci-lint. It can be 'binary' or 'goinstall'. + # install-mode: "goinstall" diff --git a/.github/workflows/vuln.yaml b/.github/workflows/vuln.yaml new file mode 100644 index 00000000..a66bdb41 --- /dev/null +++ b/.github/workflows/vuln.yaml @@ -0,0 +1,19 @@ +name: govulncheck +on: + push: + pull_request: + +permissions: + contents: read + # Optional: allow read access to pull request. Use with `only-new-issues` option. + # pull-requests: read + +jobs: + govulncheck_job: + runs-on: ubuntu-latest + name: Run govulncheck + steps: + - id: govulncheck + uses: golang/govulncheck-action@v1 + with: + go-version-input: 1.21.6 diff --git a/Dockerfile b/Dockerfile index 947b2efa..8a1fe061 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ ### Go get dependecies and build ### -FROM golang:1.21.5 as builder +FROM golang:1.21.6 as builder ENV PLATFORM docker WORKDIR /go/src/app COPY go.mod go.sum ./ diff --git a/data/empty.yaml b/data/empty.yaml new file mode 100644 index 00000000..acf14455 --- /dev/null +++ b/data/empty.yaml @@ -0,0 +1,5 @@ +openapi: 3.0.1 +info: + title: Test API + version: v1 +paths: diff --git a/data/simple3.yaml b/data/simple3.yaml new file mode 100644 index 00000000..be67c478 --- /dev/null +++ b/data/simple3.yaml @@ -0,0 +1,14 @@ +info: + title: Tufin + version: 1.0.0 +openapi: 3.0.3 +paths: + /api/test: + get: + responses: + 200: + description: OK + post: + responses: + 201: + description: OK diff --git a/data/simple4.yaml b/data/simple4.yaml new file mode 100644 index 00000000..4d4299e2 --- /dev/null +++ b/data/simple4.yaml @@ -0,0 +1,19 @@ +info: + title: Tufin + version: 1.0.0 +openapi: 3.0.3 +paths: + /api/test: + get: + parameters: + - name: a + in: query + schema: + type: integer + responses: + 200: + description: OK + post: + responses: + 201: + description: OK diff --git a/data/simple5.yaml b/data/simple5.yaml new file mode 100644 index 00000000..3e3a3324 --- /dev/null +++ b/data/simple5.yaml @@ -0,0 +1,19 @@ +info: + title: Tufin + version: 1.0.0 +openapi: 3.0.3 +paths: + /api/test: + get: + parameters: + - name: a + in: query + schema: + type: string + responses: + 200: + description: OK + post: + responses: + 201: + description: OK diff --git a/delta/delta.go b/delta/delta.go new file mode 100644 index 00000000..fa32f6dc --- /dev/null +++ b/delta/delta.go @@ -0,0 +1,38 @@ +package delta + +import ( + "github.com/tufin/oasdiff/diff" +) + +const coefficient = 0.5 + +func Get(asymmetric bool, diffReport *diff.Diff) float64 { + if diffReport.Empty() { + return 0 + } + + return getEndpointsDelta(asymmetric, diffReport.EndpointsDiff) +} + +func ratio(asymmetric bool, added int, deleted int, modifiedDelta float64, all int) float64 { + if asymmetric { + added = 0 + } + + return (float64(added+deleted) + modifiedDelta) / float64(all) +} + +func modifiedLeafDelta(asymmetric bool, modified float64) float64 { + if asymmetric { + return modified / 2 + } + + return modified +} + +func boolToFloat64(b bool) float64 { + if b { + return 1.0 + } + return 0.0 +} diff --git a/delta/delta_test.go b/delta/delta_test.go new file mode 100644 index 00000000..a7b897f5 --- /dev/null +++ b/delta/delta_test.go @@ -0,0 +1,230 @@ +package delta_test + +import ( + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/stretchr/testify/require" + "github.com/tufin/oasdiff/delta" + "github.com/tufin/oasdiff/diff" + "github.com/tufin/oasdiff/utils" +) + +func TestEmpty(t *testing.T) { + d := &diff.Diff{} + require.Equal(t, 0.0, delta.Get(false, d)) +} + +func TestEndpointAdded(t *testing.T) { + d := &diff.Diff{ + EndpointsDiff: &diff.EndpointsDiff{ + Added: diff.Endpoints{ + diff.Endpoint{ + Method: "GET", + Path: "/test", + }, + }, + Unchanged: diff.Endpoints{ + diff.Endpoint{ + Method: "POST", + Path: "/test", + }, + }, + }, + } + + require.Equal(t, 0.5, delta.Get(false, d)) + require.Equal(t, 0.0, delta.Get(true, d)) +} + +func TestEndpointDeleted(t *testing.T) { + d := &diff.Diff{ + EndpointsDiff: &diff.EndpointsDiff{ + Deleted: diff.Endpoints{ + diff.Endpoint{ + Method: "GET", + Path: "/test", + }, + }, + Unchanged: diff.Endpoints{ + diff.Endpoint{ + Method: "POST", + Path: "/test", + }, + }, + }, + } + + require.Equal(t, 0.5, delta.Get(false, d)) + require.Equal(t, 0.5, delta.Get(true, d)) +} + +func TestEndpointAddedAndDeleted(t *testing.T) { + d := &diff.Diff{ + EndpointsDiff: &diff.EndpointsDiff{ + Added: diff.Endpoints{ + diff.Endpoint{ + Method: "GET", + Path: "/test", + }, + }, + Deleted: diff.Endpoints{ + diff.Endpoint{ + Method: "POST", + Path: "/test", + }, + }, + }, + } + + require.Equal(t, 1.0, delta.Get(false, d)) + require.Equal(t, 0.5, delta.Get(true, d)) +} + +func TestParameters(t *testing.T) { + d := &diff.Diff{ + EndpointsDiff: &diff.EndpointsDiff{ + Modified: diff.ModifiedEndpoints{ + diff.Endpoint{ + Method: "GET", + Path: "/test", + }: &diff.MethodDiff{ + ParametersDiff: &diff.ParametersDiffByLocation{ + Deleted: diff.ParamNamesByLocation{ + "query": utils.StringList{"a"}, + }, + }, + }, + }, + }, + } + + require.Equal(t, 0.5, delta.Get(false, d)) + require.Equal(t, 0.5, delta.Get(true, d)) +} + +func TestResponses_AddedAndDeleted(t *testing.T) { + d := &diff.Diff{ + EndpointsDiff: &diff.EndpointsDiff{ + Modified: diff.ModifiedEndpoints{ + diff.Endpoint{ + Method: "GET", + Path: "/test", + }: &diff.MethodDiff{ + ResponsesDiff: &diff.ResponsesDiff{ + Added: utils.StringList{"201"}, + Deleted: utils.StringList{"200"}, + }, + }, + }, + }, + } + + require.Equal(t, 0.5, delta.Get(false, d)) + require.Equal(t, 0.25, delta.Get(true, d)) +} + +func TestResponses_Modified(t *testing.T) { + d := &diff.Diff{ + EndpointsDiff: &diff.EndpointsDiff{ + Modified: diff.ModifiedEndpoints{ + diff.Endpoint{ + Method: "GET", + Path: "/test", + }: &diff.MethodDiff{ + ResponsesDiff: &diff.ResponsesDiff{ + Modified: diff.ModifiedResponses{ + "200": &diff.ResponseDiff{ + ContentDiff: &diff.ContentDiff{ + MediaTypeAdded: utils.StringList{"json"}, + }, + }, + }, + }, + }, + }, + }, + } + + require.Equal(t, 0.25, delta.Get(false, d)) + require.Equal(t, 0.125, delta.Get(true, d)) +} + +func TestSchema(t *testing.T) { + d := &diff.Diff{ + EndpointsDiff: &diff.EndpointsDiff{ + Modified: diff.ModifiedEndpoints{ + diff.Endpoint{ + Method: "GET", + Path: "/test", + }: &diff.MethodDiff{ + ParametersDiff: &diff.ParametersDiffByLocation{ + Modified: diff.ParamDiffByLocation{ + "query": diff.ParamDiffs{ + "a": &diff.ParameterDiff{ + SchemaDiff: &diff.SchemaDiff{ + TypeDiff: &diff.ValueDiff{ + From: "integer", + To: "string", + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + require.Equal(t, 0.25, delta.Get(false, d)) + require.Equal(t, 0.125, delta.Get(true, d)) +} + +func TestSymmetric(t *testing.T) { + specs := utils.StringList{"../data/simple.yaml", "../data/simple1.yaml", "../data/simple2.yaml", "../data/simple3.yaml", "../data/simple4.yaml", "../data/simple5.yaml"} + specPairs := specs.CartesianProduct(specs) + + loader := openapi3.NewLoader() + for _, pair := range specPairs { + s1, err := loader.LoadFromFile(pair.X) + require.NoError(t, err) + + s2, err := loader.LoadFromFile(pair.Y) + require.NoError(t, err) + + d1, err := diff.Get(diff.NewConfig(), s1, s2) + require.NoError(t, err) + + d2, err := diff.Get(diff.NewConfig(), s2, s1) + require.NoError(t, err) + + require.Equal(t, delta.Get(false, d1), delta.Get(false, d2), pair) + } +} + +func TestAsymmetric(t *testing.T) { + specs := utils.StringList{"../data/simple.yaml", "../data/simple1.yaml", "../data/simple2.yaml", "../data/simple3.yaml", "../data/simple4.yaml", "../data/simple5.yaml"} + specPairs := specs.CartesianProduct(specs) + + loader := openapi3.NewLoader() + for _, pair := range specPairs { + s1, err := loader.LoadFromFile(pair.X) + require.NoError(t, err) + + s2, err := loader.LoadFromFile(pair.Y) + require.NoError(t, err) + + d1, err := diff.Get(diff.NewConfig(), s1, s2) + require.NoError(t, err) + asymmetric1 := delta.Get(true, d1) + + d2, err := diff.Get(diff.NewConfig(), s2, s1) + require.NoError(t, err) + asymmetric2 := delta.Get(true, d2) + + symmetric := delta.Get(false, d2) + + require.Equal(t, asymmetric1+asymmetric2, symmetric, pair) + } +} diff --git a/delta/doc.go b/delta/doc.go new file mode 100644 index 00000000..2c157ea6 --- /dev/null +++ b/delta/doc.go @@ -0,0 +1,16 @@ +/* +Package delta provides a distance function for OpenAPI Spec 3. +The delta is a numeric value between 0 and 1 representing the distance between base and revision specs. + +For any spec, a: delta(a, a) = 0 +For any two specs, a and b, with no common elements: delta(a, b) = 1 + +Symmetric mode: +Delta considers both elements of base that are deleted in revision and elements of base that are added in revision. +For any two specs, a and b: delta(a, b) = delta(b, a) + +Asymmetric mode: +Delta only considers elements of base that are deleted in revision but not elements of base that are added in revision. +For any two specs, a and b: Delta(a, b) + Delta(b, a) = 1 +*/ +package delta diff --git a/delta/endpoints.go b/delta/endpoints.go new file mode 100644 index 00000000..15eeb5a6 --- /dev/null +++ b/delta/endpoints.go @@ -0,0 +1,43 @@ +package delta + +import ( + "github.com/tufin/oasdiff/diff" +) + +func getEndpointsDelta(asymmetric bool, d *diff.EndpointsDiff) float64 { + if d.Empty() { + return 0 + } + + added := len(d.Added) + deleted := len(d.Deleted) + modified := len(d.Modified) + unchanged := len(d.Unchanged) + all := added + deleted + modified + unchanged + + modifiedDelta := coefficient * getModifiedEndpointsDelta(asymmetric, d.Modified) + + return ratio(asymmetric, added, deleted, modifiedDelta, all) +} + +func getModifiedEndpointsDelta(asymmetric bool, d diff.ModifiedEndpoints) float64 { + weightedDeltas := make([]*WeightedDelta, len(d)) + i := 0 + for _, methodDiff := range d { + weightedDeltas[i] = NewWeightedDelta(getModifiedEndpointDelta(asymmetric, methodDiff), 1) + i++ + } + return weightedAverage(weightedDeltas) +} + +func getModifiedEndpointDelta(asymmetric bool, d *diff.MethodDiff) float64 { + if d.Empty() { + return 0 + } + + // TODO: consider additional elements of MethodDiff + paramsDelta := getParametersDelta(asymmetric, d.ParametersDiff) + responsesDelta := getResponsesDelta(asymmetric, d.ResponsesDiff) + + return weightedAverage([]*WeightedDelta{paramsDelta, responsesDelta}) +} diff --git a/delta/parameters.go b/delta/parameters.go new file mode 100644 index 00000000..e581b794 --- /dev/null +++ b/delta/parameters.go @@ -0,0 +1,47 @@ +package delta + +import ( + "github.com/tufin/oasdiff/diff" +) + +func getParametersDelta(asymmetric bool, d *diff.ParametersDiffByLocation) *WeightedDelta { + if d.Empty() { + return &WeightedDelta{} + } + + added := d.Added.Len() + deleted := d.Deleted.Len() + modified := d.Modified.Len() + unchanged := d.Unchanged.Len() + all := added + deleted + modified + unchanged + + modifiedDelta := coefficient * getModifiedParametersDelta(asymmetric, d.Modified) + + return NewWeightedDelta( + ratio(asymmetric, added, deleted, modifiedDelta, all), + all, + ) +} + +func getModifiedParametersDelta(asymmetric bool, d diff.ParamDiffByLocation) float64 { + weightedDeltas := make([]*WeightedDelta, len(d)) + i := 0 + for _, paramsDiff := range d { + for _, parameterDiff := range paramsDiff { + weightedDeltas[i] = NewWeightedDelta(getModifiedParameterDelta(asymmetric, parameterDiff), 1) + i++ + } + } + return weightedAverage(weightedDeltas) +} + +func getModifiedParameterDelta(asymmetric bool, d *diff.ParameterDiff) float64 { + if d.Empty() { + return 0.0 + } + + // TODO: consider additional elements of ParameterDiff + schemaDelta := getSchemaDelta(asymmetric, d.SchemaDiff) + + return schemaDelta +} diff --git a/delta/responses.go b/delta/responses.go new file mode 100644 index 00000000..7c068818 --- /dev/null +++ b/delta/responses.go @@ -0,0 +1,22 @@ +package delta + +import ( + "github.com/tufin/oasdiff/diff" +) + +func getResponsesDelta(asymmetric bool, d *diff.ResponsesDiff) *WeightedDelta { + if d.Empty() { + return &WeightedDelta{} + } + + added := d.Added.Len() + deleted := d.Deleted.Len() + modified := len(d.Modified) + unchanged := d.Unchanged.Len() + all := added + deleted + modified + unchanged + + // TODO: drill down into modified + modifiedDelta := coefficient * modifiedLeafDelta(asymmetric, float64(modified)) + + return NewWeightedDelta(ratio(asymmetric, added, deleted, modifiedDelta, all), all) +} diff --git a/delta/schema.go b/delta/schema.go new file mode 100644 index 00000000..0f57fa75 --- /dev/null +++ b/delta/schema.go @@ -0,0 +1,16 @@ +package delta + +import ( + "github.com/tufin/oasdiff/diff" +) + +func getSchemaDelta(asymmetric bool, d *diff.SchemaDiff) float64 { + if d.Empty() { + return 0 + } + + // consider additional fields of schema + typeDelta := modifiedLeafDelta(asymmetric, boolToFloat64(!d.TypeDiff.Empty())) + + return typeDelta +} diff --git a/delta/weighted_delta.go b/delta/weighted_delta.go new file mode 100644 index 00000000..fff024c2 --- /dev/null +++ b/delta/weighted_delta.go @@ -0,0 +1,26 @@ +package delta + +type WeightedDelta struct { + delta float64 + weight int +} + +func NewWeightedDelta(delta float64, weight int) *WeightedDelta { + return &WeightedDelta{ + delta: delta, + weight: weight, + } +} + +func weightedAverage(weightedDeltas []*WeightedDelta) float64 { + dividend := 0.0 + divisor := 0 + for _, weightedDelta := range weightedDeltas { + dividend += weightedDelta.delta * float64(weightedDelta.weight) + divisor += weightedDelta.weight + } + if dividend == 0 { + return 0 + } + return dividend / float64(divisor) +} diff --git a/diff/diff_test.go b/diff/diff_test.go index 1fec259b..7fe7b8ee 100644 --- a/diff/diff_test.go +++ b/diff/diff_test.go @@ -85,9 +85,10 @@ func TestDiff_ModifiedOperation(t *testing.T) { require.NoError(t, err) require.Equal(t, &diff.OperationsDiff{ - Added: utils.StringList{"GET"}, - Deleted: utils.StringList{"POST"}, - Modified: diff.ModifiedOperations{}, + Added: utils.StringList{"GET"}, + Deleted: utils.StringList{"POST"}, + Modified: diff.ModifiedOperations{}, + Unchanged: utils.StringList{}, }, d.PathsDiff.Modified["/api/test"].OperationsDiff) } diff --git a/diff/endpoints_diff.go b/diff/endpoints_diff.go index 83707f4f..aaccbfdb 100644 --- a/diff/endpoints_diff.go +++ b/diff/endpoints_diff.go @@ -13,9 +13,10 @@ For example, if there's a new path "/test" with method POST then EndpointsDiff w Or, if path "/test" was modified to include a new methdod, PUT, then EndpointsDiff will describe this as a new endpoint: PUT /test. */ type EndpointsDiff struct { - Added Endpoints `json:"added,omitempty" yaml:"added,omitempty"` - Deleted Endpoints `json:"deleted,omitempty" yaml:"deleted,omitempty"` - Modified ModifiedEndpoints `json:"modified,omitempty" yaml:"modified,omitempty"` + Added Endpoints `json:"added,omitempty" yaml:"added,omitempty"` + Deleted Endpoints `json:"deleted,omitempty" yaml:"deleted,omitempty"` + Modified ModifiedEndpoints `json:"modified,omitempty" yaml:"modified,omitempty"` + Unchanged Endpoints `json:"unchanged,omitempty" yaml:"unchanged,omitempty"` } // Endpoint is a combination of an HTTP method and a Path @@ -37,9 +38,10 @@ func (diff *EndpointsDiff) Empty() bool { func newEndpointsDiff() *EndpointsDiff { return &EndpointsDiff{ - Added: Endpoints{}, - Deleted: Endpoints{}, - Modified: ModifiedEndpoints{}, + Added: Endpoints{}, + Deleted: Endpoints{}, + Modified: ModifiedEndpoints{}, + Unchanged: Endpoints{}, } } @@ -109,6 +111,13 @@ func (diff *EndpointsDiff) addDeletedPath(path string, method string) { }) } +func (diff *EndpointsDiff) addUnchangedPath(path string, method string) { + diff.Unchanged = append(diff.Unchanged, Endpoint{ + Method: method, + Path: path, + }) +} + func (diff *EndpointsDiff) addModifiedPaths(config *Config, state *state, path string, pathItemPair *pathItemPair) error { pathDiff, err := getPathDiff(config, state, pathItemPair) @@ -135,6 +144,10 @@ func (diff *EndpointsDiff) addModifiedPaths(config *Config, state *state, path s }] = methodDiff } + for _, method := range pathDiff.OperationsDiff.Unchanged { + diff.addUnchangedPath(path, method) + } + return nil } diff --git a/diff/operations_diff.go b/diff/operations_diff.go index 5af29252..d0f4ba9e 100644 --- a/diff/operations_diff.go +++ b/diff/operations_diff.go @@ -11,9 +11,10 @@ import ( // OperationsDiff describes the changes between a pair of operation objects (https://swagger.io/specification/#operation-object) of two path item objects type OperationsDiff struct { - Added utils.StringList `json:"added,omitempty" yaml:"added,omitempty"` - Deleted utils.StringList `json:"deleted,omitempty" yaml:"deleted,omitempty"` - Modified ModifiedOperations `json:"modified,omitempty" yaml:"modified,omitempty"` + Added utils.StringList `json:"added,omitempty" yaml:"added,omitempty"` + Deleted utils.StringList `json:"deleted,omitempty" yaml:"deleted,omitempty"` + Modified ModifiedOperations `json:"modified,omitempty" yaml:"modified,omitempty"` + Unchanged utils.StringList `json:"unchanged,omitempty" yaml:"unchanged,omitempty"` } // Empty indicates whether a change was found in this element @@ -29,9 +30,10 @@ func (operationsDiff *OperationsDiff) Empty() bool { func newOperationsDiff() *OperationsDiff { return &OperationsDiff{ - Added: utils.StringList{}, - Deleted: utils.StringList{}, - Modified: ModifiedOperations{}, + Added: utils.StringList{}, + Deleted: utils.StringList{}, + Modified: ModifiedOperations{}, + Unchanged: utils.StringList{}, } } @@ -105,6 +107,8 @@ func (operationsDiff *OperationsDiff) diffOperation(config *Config, state *state if !diff.Empty() { operationsDiff.Modified[method] = diff + } else { + operationsDiff.Unchanged = append(operationsDiff.Unchanged, method) } return nil diff --git a/diff/parameters_diff_by_location.go b/diff/parameters_diff_by_location.go index f119c1d5..55ee44e1 100644 --- a/diff/parameters_diff_by_location.go +++ b/diff/parameters_diff_by_location.go @@ -9,9 +9,10 @@ import ( // ParametersDiffByLocation describes the changes, grouped by param location, between a pair of lists of parameter objects: https://swagger.io/specification/#parameter-object type ParametersDiffByLocation struct { - Added ParamNamesByLocation `json:"added,omitempty" yaml:"added,omitempty"` - Deleted ParamNamesByLocation `json:"deleted,omitempty" yaml:"deleted,omitempty"` - Modified ParamDiffByLocation `json:"modified,omitempty" yaml:"modified,omitempty"` + Added ParamNamesByLocation `json:"added,omitempty" yaml:"added,omitempty"` + Deleted ParamNamesByLocation `json:"deleted,omitempty" yaml:"deleted,omitempty"` + Modified ParamDiffByLocation `json:"modified,omitempty" yaml:"modified,omitempty"` + Unchanged ParamNamesByLocation `json:"unchanged,omitempty" yaml:"unchanged,omitempty"` } // Empty indicates whether a change was found in this element @@ -31,14 +32,33 @@ var ParamLocations = []string{openapi3.ParameterInPath, openapi3.ParameterInQuer // ParamNamesByLocation maps param location (path, query, header or cookie) to the params in this location type ParamNamesByLocation map[string]utils.StringList +// Len returns the number of all params in all locations +func (params ParamNamesByLocation) Len() int { + return lenNested(params) +} + // ParamDiffByLocation maps param location (path, query, header or cookie) to param diffs in this location type ParamDiffByLocation map[string]ParamDiffs +// Len returns the number of all params in all locations +func (params ParamDiffByLocation) Len() int { + return lenNested(params) +} + +func lenNested[T utils.StringList | ParamDiffs](mapOfList map[string]T) int { + result := 0 + for _, l := range mapOfList { + result += len(l) + } + return result +} + func newParametersDiffByLocation() *ParametersDiffByLocation { return &ParametersDiffByLocation{ - Added: ParamNamesByLocation{}, - Deleted: ParamNamesByLocation{}, - Modified: ParamDiffByLocation{}, + Added: ParamNamesByLocation{}, + Deleted: ParamNamesByLocation{}, + Modified: ParamDiffByLocation{}, + Unchanged: ParamNamesByLocation{}, } } @@ -72,6 +92,15 @@ func (diff *ParametersDiffByLocation) addModifiedParam(param *openapi3.Parameter } } +func (diff *ParametersDiffByLocation) addUnchangedParam(param *openapi3.Parameter) { + + if paramNames, ok := diff.Unchanged[param.In]; ok { + diff.Unchanged[param.In] = append(paramNames, param.Name) + } else { + diff.Unchanged[param.In] = utils.StringList{param.Name} + } +} + func getParametersDiffByLocation(config *Config, state *state, params1, params2 openapi3.Parameters, pathParamsMap PathParamsMap) (*ParametersDiffByLocation, error) { diff, err := getParametersDiffByLocationInternal(config, state, params1, params2, pathParamsMap) if err != nil { @@ -106,7 +135,9 @@ func getParametersDiffByLocationInternal(config *Config, state *state, params1, return nil, err } - if !diff.Empty() { + if diff.Empty() { + result.addUnchangedParam(param1) + } else { result.addModifiedParam(param1, diff) } } else { diff --git a/diff/parameters_diff_by_location_test.go b/diff/parameters_diff_by_location_test.go new file mode 100644 index 00000000..d68e5db3 --- /dev/null +++ b/diff/parameters_diff_by_location_test.go @@ -0,0 +1,23 @@ +package diff_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + "github.com/tufin/oasdiff/diff" + "github.com/tufin/oasdiff/utils" +) + +func TestParamNamesByLocation_Len(t *testing.T) { + require.Equal(t, 3, diff.ParamNamesByLocation{ + "query": utils.StringList{"name"}, + "header": utils.StringList{"id", "organization"}, + }.Len()) +} + +func TestParamDiffByLocation_Len(t *testing.T) { + require.Equal(t, 3, diff.ParamDiffByLocation{ + "query": diff.ParamDiffs{"query": &diff.ParameterDiff{}}, + "header": diff.ParamDiffs{"id": &diff.ParameterDiff{}, "organization": &diff.ParameterDiff{}}, + }.Len()) +} diff --git a/diff/responses_diff.go b/diff/responses_diff.go index 9bf35c1c..4d6a678d 100644 --- a/diff/responses_diff.go +++ b/diff/responses_diff.go @@ -9,9 +9,10 @@ import ( // ResponsesDiff describes the changes between a pair of sets of response objects: https://swagger.io/specification/#responses-object type ResponsesDiff struct { - Added utils.StringList `json:"added,omitempty" yaml:"added,omitempty"` - Deleted utils.StringList `json:"deleted,omitempty" yaml:"deleted,omitempty"` - Modified ModifiedResponses `json:"modified,omitempty" yaml:"modified,omitempty"` + Added utils.StringList `json:"added,omitempty" yaml:"added,omitempty"` + Deleted utils.StringList `json:"deleted,omitempty" yaml:"deleted,omitempty"` + Modified ModifiedResponses `json:"modified,omitempty" yaml:"modified,omitempty"` + Unchanged utils.StringList `json:"unchanged,omitempty" yaml:"unchanged,omitempty"` } // Empty indicates whether a change was found in this element @@ -30,9 +31,10 @@ type ModifiedResponses map[string]*ResponseDiff func newResponsesDiff() *ResponsesDiff { return &ResponsesDiff{ - Added: utils.StringList{}, - Deleted: utils.StringList{}, - Modified: ModifiedResponses{}, + Added: utils.StringList{}, + Deleted: utils.StringList{}, + Modified: ModifiedResponses{}, + Unchanged: utils.StringList{}, } } @@ -73,7 +75,9 @@ func getResponsesDiffInternal(config *Config, state *state, responses1, response if err != nil { return nil, err } - if !diff.Empty() { + if diff.Empty() { + result.Unchanged = append(result.Unchanged, responseValue1) + } else { result.Modified[responseValue1] = diff } } else { diff --git a/go.mod b/go.mod index c9b126e2..0becc12f 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,8 @@ module github.com/tufin/oasdiff go 1.21.4 +toolchain go1.21.6 + require ( cloud.google.com/go v0.112.0 github.com/TwiN/go-color v1.4.1 @@ -11,7 +13,7 @@ require ( github.com/stretchr/testify v1.8.4 github.com/yargevad/filepathx v1.0.0 github.com/yuin/goldmark v1.6.0 - golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb + golang.org/x/exp v0.0.0-20240119083558-1b970713d09a gopkg.in/yaml.v3 v3.0.1 ) @@ -21,13 +23,13 @@ require ( github.com/rogpeppe/go-internal v1.11.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/pflag v1.0.5 // indirect - golang.org/x/sys v0.15.0 // indirect + golang.org/x/sys v0.16.0 // indirect ) require ( github.com/davecgh/go-spew v1.1.1 // indirect - github.com/go-openapi/jsonpointer v0.20.0 // indirect - github.com/go-openapi/swag v0.22.4 // indirect + github.com/go-openapi/jsonpointer v0.20.2 // indirect + github.com/go-openapi/swag v0.22.7 // indirect github.com/invopop/yaml v0.2.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect diff --git a/go.sum b/go.sum index e1ef1870..d254aa87 100644 --- a/go.sum +++ b/go.sum @@ -10,10 +10,10 @@ github.com/denisbrodbeck/machineid v1.0.1 h1:geKr9qtkB876mXguW2X6TU4ZynleN6ezuMS github.com/denisbrodbeck/machineid v1.0.1/go.mod h1:dJUwb7PTidGDeYyUBmXZ2GphQBbjJCrnectwCyxcUSI= github.com/getkin/kin-openapi v0.122.0 h1:WB9Jbl0Hp/T79/JF9xlSW5Kl9uYdk/AWD0yAd9HOM10= github.com/getkin/kin-openapi v0.122.0/go.mod h1:PCWw/lfBrJY4HcdqE3jj+QFkaFK8ABoqo7PvqVhXXqw= -github.com/go-openapi/jsonpointer v0.20.0 h1:ESKJdU9ASRfaPNOPRx12IUyA1vn3R9GiE3KYD14BXdQ= -github.com/go-openapi/jsonpointer v0.20.0/go.mod h1:6PGzBjjIIumbLYysB73Klnms1mwnU4G3YHOECG3CedA= -github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU= -github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/jsonpointer v0.20.2 h1:mQc3nmndL8ZBzStEo3JYF8wzmeWffDH4VbXz58sAx6Q= +github.com/go-openapi/jsonpointer v0.20.2/go.mod h1:bHen+N0u1KEO3YlmqOjTT9Adn1RfD91Ar825/PuiRVs= +github.com/go-openapi/swag v0.22.7 h1:JWrc1uc/P9cSomxfnsFSVWoE1FW6bNbrVPmpQYpCcR8= +github.com/go-openapi/swag v0.22.7/go.mod h1:Gl91UqO+btAM0plGGxHqJcQZ1ZTy6jbmridBTsDy8A0= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= @@ -61,11 +61,11 @@ github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5 github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA= github.com/yuin/goldmark v1.6.0 h1:boZcn2GTjpsynOsC0iJHnBWa4Bi0qzfJjthwauItG68= github.com/yuin/goldmark v1.6.0/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb h1:c0vyKkb6yr3KR7jEfJaOSv4lG7xPkbN6r52aJz1d8a8= -golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI= +golang.org/x/exp v0.0.0-20240119083558-1b970713d09a h1:Q8/wZp0KX97QFTc2ywcOE0YRjZPVIx+MXInMzdvQqcA= +golang.org/x/exp v0.0.0-20240119083558-1b970713d09a/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/changelog_flags.go b/internal/changelog_flags.go index 2a0991db..05775bd5 100644 --- a/internal/changelog_flags.go +++ b/internal/changelog_flags.go @@ -103,6 +103,10 @@ func (flags *ChangelogFlags) getFailOnDiff() bool { return false } +func (flags *ChangelogFlags) getAsymmetric() bool { + return false +} + func (flags *ChangelogFlags) setBase(source *load.Source) { flags.base = source } diff --git a/internal/delta.go b/internal/delta.go new file mode 100644 index 00000000..5a86afd4 --- /dev/null +++ b/internal/delta.go @@ -0,0 +1,53 @@ +package internal + +import ( + "fmt" + "io" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/spf13/cobra" + "github.com/tufin/oasdiff/delta" + "github.com/tufin/oasdiff/diff" +) + +func getDeltaCmd() *cobra.Command { + + flags := DeltaFlags{} + + cmd := cobra.Command{ + Use: "delta base revision [flags]", + Short: "Calculate the delta value", + Long: `Calculate a numeric value representing the delta between base and revision specs.` + specHelp, + Args: getParseArgs(&flags), + RunE: getRun(&flags, runDelta), + } + + cmd.PersistentFlags().BoolVarP(&flags.composed, "composed", "c", false, "work in 'composed' mode, compare paths in all specs matching base and revision globs") + enumWithOptions(&cmd, newEnumSliceValue(diff.ExcludeDiffOptions, nil, &flags.excludeElements), "exclude-elements", "e", "comma-separated list of elements to exclude") + cmd.PersistentFlags().StringVarP(&flags.matchPath, "match-path", "p", "", "include only paths that match this regular expression") + cmd.PersistentFlags().StringVarP(&flags.filterExtension, "filter-extension", "", "", "exclude paths and operations with an OpenAPI Extension matching this regular expression") + cmd.PersistentFlags().IntVarP(&flags.circularReferenceCounter, "max-circular-dep", "", 5, "maximum allowed number of circular dependencies between objects in OpenAPI specs") + cmd.PersistentFlags().StringVarP(&flags.prefixBase, "prefix-base", "", "", "add this prefix to paths in base-spec before comparison") + cmd.PersistentFlags().StringVarP(&flags.prefixRevision, "prefix-revision", "", "", "add this prefix to paths in revised-spec before comparison") + cmd.PersistentFlags().StringVarP(&flags.stripPrefixBase, "strip-prefix-base", "", "", "strip this prefix from paths in base-spec before comparison") + cmd.PersistentFlags().StringVarP(&flags.stripPrefixRevision, "strip-prefix-revision", "", "", "strip this prefix from paths in revised-spec before comparison") + cmd.PersistentFlags().BoolVarP(&flags.includePathParams, "include-path-params", "", false, "include path parameter names in endpoint matching") + cmd.PersistentFlags().BoolVarP(&flags.flatten, "flatten", "", false, "merge subschemas under allOf before diff") + cmd.PersistentFlags().BoolVarP(&flags.asymmetric, "asymmetric", "", false, "perform asymmetric diff (only elements of base that are missing in revision)") + + return &cmd +} + +func runDelta(flags Flags, stdout io.Writer) (bool, *ReturnError) { + + openapi3.CircularReferenceCounter = flags.getCircularReferenceCounter() + + diffResult, err := calcDiff(flags) + if err != nil { + return false, err + } + + _, _ = fmt.Fprintf(stdout, "%g\n", delta.Get(flags.getAsymmetric(), diffResult.diffReport)) + + return false, nil +} diff --git a/internal/delta_flags.go b/internal/delta_flags.go new file mode 100644 index 00000000..7d63d6ab --- /dev/null +++ b/internal/delta_flags.go @@ -0,0 +1,10 @@ +package internal + +type DeltaFlags struct { + DiffFlags + asymmetric bool +} + +func (flags *DeltaFlags) getAsymmetric() bool { + return flags.asymmetric +} diff --git a/internal/diff_flags.go b/internal/diff_flags.go index 45eb7212..541ba9dd 100644 --- a/internal/diff_flags.go +++ b/internal/diff_flags.go @@ -96,6 +96,10 @@ func (flags *DiffFlags) getFailOnDiff() bool { return flags.failOnDiff } +func (flags *DiffFlags) getAsymmetric() bool { + return false +} + func (flags *DiffFlags) setBase(source *load.Source) { flags.base = source } diff --git a/internal/flags.go b/internal/flags.go index d71e07f2..ff1be7f1 100644 --- a/internal/flags.go +++ b/internal/flags.go @@ -23,6 +23,7 @@ type Flags interface { getFormat() string getFailOn() string getFailOnDiff() bool + getAsymmetric() bool setBase(source *load.Source) setRevision(source *load.Source) diff --git a/internal/qr_code.go b/internal/qr_code.go index ccd130df..0d78366a 100644 --- a/internal/qr_code.go +++ b/internal/qr_code.go @@ -8,8 +8,8 @@ func getQRCodeCmd() *cobra.Command { cmd := cobra.Command{ Use: "qr", - Short: "Display QR code", - Long: "Display QR code", + Short: "Display QR code of oasdiff repo", + Long: "Display QR code of the URL of the oasdiff repository", Args: cobra.NoArgs, ValidArgsFunction: cobra.NoFileCompletions, // see https://github.com/spf13/cobra/issues/1969 RunE: func(cmd *cobra.Command, args []string) error { diff --git a/internal/run.go b/internal/run.go index 78dddc72..4a2fa379 100644 --- a/internal/run.go +++ b/internal/run.go @@ -40,6 +40,7 @@ func Run(args []string, stdout io.Writer, stderr io.Writer) int { getFlattenCmd(), getChecksCmd(), getQRCodeCmd(), + getDeltaCmd(), ) return run(rootCmd) diff --git a/internal/run_test.go b/internal/run_test.go index f67b78ef..b4389dc4 100644 --- a/internal/run_test.go +++ b/internal/run_test.go @@ -289,3 +289,7 @@ func Test_ColorWithNonTextFormat(t *testing.T) { require.Equal(t, 100, internal.Run(cmdToArgs("oasdiff changelog ../data/allof/simple.yaml ../data/allof/revision.yaml -f yaml --color always"), io.Discard, &stderr)) require.Equal(t, "Error: --color flag is only relevant with 'text' or 'singleline' formats\n", stderr.String()) } + +func Test_Delta(t *testing.T) { + require.Zero(t, internal.Run(cmdToArgs("oasdiff delta ../data/simple1.yaml ../data/simple2.yaml"), io.Discard, io.Discard)) +} diff --git a/utils/string_list.go b/utils/string_list.go index d2dcfaf0..4d4b268b 100644 --- a/utils/string_list.go +++ b/utils/string_list.go @@ -35,6 +35,18 @@ func (stringList *StringList) Minus(other StringList) StringList { return stringList.ToStringSet().Minus(other.ToStringSet()).ToStringList() } +func (stringList *StringList) CartesianProduct(other StringList) []StringPair { + result := make([]StringPair, stringList.Len()*other.Len()) + i := 0 + for _, a := range *stringList { + for _, b := range other { + result[i] = StringPair{a, b} + i++ + } + } + return result +} + func (list StringList) ToStringSet() StringSet { result := make(StringSet, len(list)) diff --git a/utils/string_list_test.go b/utils/string_list_test.go index 84485ef1..8b03d74c 100644 --- a/utils/string_list_test.go +++ b/utils/string_list_test.go @@ -19,3 +19,10 @@ func Test_StringList(t *testing.T) { require.True(t, l.Contains("b")) require.True(t, l.Minus(l).ToStringSet().Empty()) } + +func Test_CartesianProduct(t *testing.T) { + l1 := utils.StringList{"a", "b", "c"} + l2 := utils.StringList{"x", "y"} + require.Equal(t, 6, len(l1.CartesianProduct(l2))) + require.Equal(t, utils.StringPair{"b", "y"}, l1.CartesianProduct(l2)[3]) +} diff --git a/utils/string_pair.go b/utils/string_pair.go new file mode 100644 index 00000000..c14f58ad --- /dev/null +++ b/utils/string_pair.go @@ -0,0 +1,6 @@ +package utils + +type StringPair struct { + X string + Y string +}