Skip to content
Merged
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
214 changes: 214 additions & 0 deletions tools/cli/internal/cli/sunset/diff.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
// Copyright 2026 MongoDB Inc
//
// 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 sunset

import (
"encoding/json"
"fmt"
"sort"
"strings"

"github.com/mongodb/openapi/tools/cli/internal/cli/flag"
"github.com/mongodb/openapi/tools/cli/internal/cli/usage"
"github.com/mongodb/openapi/tools/cli/internal/openapi"
"github.com/mongodb/openapi/tools/cli/internal/openapi/sunset"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
)

type DiffOpts struct {
fs afero.Fs
basePath string
specPath string
outputPath string
format string
}

type Diff struct {
Operation string `json:"http_method" yaml:"http_method"`
Path string `json:"path" yaml:"path"`

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

could we also include the base and and target spec file name? I think it would be a useful info to see

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done, added to output diff, example:

[
  {
    "http_method": "GET",
    "path": "/api/atlas/v2/groups/{groupId}/serverless",
    "version": "2023-01-01",
    "base_sunset_date": "2027-01-15",
    "spec_sunset_date": "2027-01-18",
    "base_spec": "../../../openapi/.raw/v2.json",
    "spec": "../../../openapi/.raw/v2-qa.json",
    "team": "Atlas Clusters Security III"
  },
  {
    "http_method": "GET",
    "path": "/api/atlas/v2/groups/{groupId}/serverless/{name}",
    "version": "2023-01-01",
    "base_sunset_date": "2027-01-15",
    "spec_sunset_date": "2027-01-18",
    "base_spec": "../../../openapi/.raw/v2.json",
    "spec": "../../../openapi/.raw/v2-qa.json",
    "team": "Atlas Clusters Security III"
  },
]

Version string `json:"version" yaml:"version"`
BaseSunsetDate string `json:"base_sunset_date" yaml:"base_sunset_date"`
SpecSunsetDate string `json:"spec_sunset_date" yaml:"spec_sunset_date"`
BaseSpec string `json:"base_spec" yaml:"base_spec"`
Spec string `json:"spec" yaml:"spec"`
Team string `json:"team" yaml:"team"`
}

func (o *DiffOpts) Run() error {
loader := openapi.NewOpenAPI3()

// Load base spec
baseSpecInfo, err := loader.CreateOpenAPISpecFromPath(o.basePath)
if err != nil {
return err
}

// Load comparison spec
specInfo, err := loader.CreateOpenAPISpecFromPath(o.specPath)
if err != nil {
return err
}

// Get sunsets from both specs
baseSunsets := sunset.NewListFromSpec(baseSpecInfo)
specSunsets := sunset.NewListFromSpec(specInfo)

// Find differences
var diffs = findDiffs(baseSunsets, specSunsets, o.basePath, o.specPath)

// Write to output
bytes, err := o.newSunsetDiffBytes(diffs)
if err != nil {
return err
}

if o.outputPath != "" {
return afero.WriteFile(o.fs, o.outputPath, bytes, 0o600)
}

fmt.Println(string(bytes))
return nil
}

func findDiffs(baseSunsets, specSunsets []*sunset.Sunset, baseSpecPath, specPath string) []*Diff {
// Create maps for easy lookup
baseMap := make(map[string]*sunset.Sunset)
for _, s := range baseSunsets {
key := makeKey(s.Path, s.Operation, s.Version)
baseMap[key] = s
}

specMap := make(map[string]*sunset.Sunset)
for _, s := range specSunsets {
key := makeKey(s.Path, s.Operation, s.Version)
specMap[key] = s
}

// Find differences
var diffs []*Diff

// Check endpoints in base spec
for key, baseSunset := range baseMap {
if specSunset, exists := specMap[key]; exists {
// Endpoint exists in both specs
if baseSunset.SunsetDate != specSunset.SunsetDate {
// Different sunset dates
diffs = append(diffs, &Diff{
Operation: baseSunset.Operation,
Path: baseSunset.Path,
Version: baseSunset.Version,
BaseSunsetDate: baseSunset.SunsetDate,
SpecSunsetDate: specSunset.SunsetDate,
BaseSpec: baseSpecPath,
Spec: specPath,
Team: baseSunset.Team,
})
}
} else {
// Endpoint only in base spec (has sunset in base, not in spec)
diffs = append(diffs, &Diff{
Operation: baseSunset.Operation,
Path: baseSunset.Path,
Version: baseSunset.Version,
BaseSunsetDate: baseSunset.SunsetDate,
SpecSunsetDate: "",
BaseSpec: baseSpecPath,
Spec: specPath,
Team: baseSunset.Team,
})
}
}

// Check endpoints only in spec (has sunset in spec, not in base)
for key, specSunset := range specMap {
if _, exists := baseMap[key]; !exists {
diffs = append(diffs, &Diff{
Operation: specSunset.Operation,
Path: specSunset.Path,
Version: specSunset.Version,
BaseSunsetDate: "",
SpecSunsetDate: specSunset.SunsetDate,
BaseSpec: baseSpecPath,
Spec: specPath,
Team: specSunset.Team,
})
}
}

// Sort diffs by path, operation and version for consistent output
sort.Slice(diffs, func(i, j int) bool {
iKey := makeKey(diffs[i].Path, diffs[i].Operation, diffs[i].Version)
jKey := makeKey(diffs[j].Path, diffs[j].Operation, diffs[j].Version)
return iKey < jKey
})

return diffs
}

func makeKey(path, operation, version string) string {
return operation + "-" + path + "-" + version
}

func (o *DiffOpts) newSunsetDiffBytes(diffs []*Diff) ([]byte, error) {
data, err := json.MarshalIndent(diffs, "", " ")
if err != nil {
return nil, err
}

if format := strings.ToLower(o.format); format == "json" {
return data, nil
}

var jsonData any
if mErr := json.Unmarshal(data, &jsonData); mErr != nil {
return nil, mErr
}

yamlData, err := yaml.Marshal(jsonData)
if err != nil {
return nil, err
}

return yamlData, nil
}

// DiffBuilder builds the diff command with the following signature:
// sunset diff --base base-spec.json --spec spec.json.
func DiffBuilder() *cobra.Command {
opts := &DiffOpts{
fs: afero.NewOsFs(),
}

cmd := &cobra.Command{
Use: "diff --base spec1.json --spec spec2.json",
Short: "List API endpoints with different sunset dates between two OpenAPI specs.",
Args: cobra.NoArgs,
RunE: func(_ *cobra.Command, _ []string) error {
return opts.Run()
},
}

cmd.Flags().StringVarP(&opts.basePath, flag.Base, flag.BaseShort, "", usage.Base)
cmd.Flags().StringVarP(&opts.specPath, flag.Spec, flag.SpecShort, "", usage.Spec)
cmd.Flags().StringVarP(&opts.outputPath, flag.Output, flag.OutputShort, "", usage.Output)
cmd.Flags().StringVarP(&opts.format, flag.Format, flag.FormatShort, "json", usage.Format)

_ = cmd.MarkFlagRequired(flag.Base)
_ = cmd.MarkFlagRequired(flag.Spec)

return cmd
}
167 changes: 167 additions & 0 deletions tools/cli/internal/cli/sunset/diff_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
// Copyright 2026 MongoDB Inc
//
// 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 sunset

import (
"testing"

"github.com/mongodb/openapi/tools/cli/internal/openapi/sunset"
"github.com/stretchr/testify/assert"
)

func TestFindDiffsEmpty(t *testing.T) {
baseSpecSunsets := []*sunset.Sunset{
{
Operation: "GET",
Path: "/api/atlas/v2/example/info",
Version: "2023-01-01",
SunsetDate: "2025-06-01",
Team: "Team",
},
}

diff := findDiffs(baseSpecSunsets, baseSpecSunsets, "base.json", "spec.json")
assert.Empty(t, diff)
}

func TestFindDiffsNotEmpty(t *testing.T) {
baseSpecSunsets := []*sunset.Sunset{
{
Operation: "GET",
Path: "/api/atlas/v2/example/info",
Version: "2023-01-01",
SunsetDate: "2025-06-01",
Team: "Team",
},
{
Operation: "GET",
Path: "/api/atlas/v2/versions",
Version: "2023-01-01",
SunsetDate: "2025-06-01",
Team: "APIx",
},
{
Operation: "GET",
Path: "/api/atlas/v2/test",
Version: "2023-01-01",
SunsetDate: "2025-06-01",
Team: "Test",
},
{
Operation: "GET",
Path: "/api/atlas/v2/groups",
Version: "2023-01-01",
SunsetDate: "2025-06-01",
Team: "Groups",
},
{
Operation: "GET",
Path: "/api/atlas/v2/groups",
Version: "2023-02-01",
SunsetDate: "2025-06-01",
Team: "Groups",
},
}
specSunsets := []*sunset.Sunset{
{
Operation: "GET",
Path: "/api/atlas/v2/example/info",
Version: "2023-01-01",
SunsetDate: "2025-06-01",
Team: "Team",
},
{
Operation: "GET",
Path: "/api/atlas/v2/versions",
Version: "2023-01-01",
SunsetDate: "2025-06-02",
Team: "APIx",
},
{
Operation: "GET",
Path: "/api/atlas/v2/users",
Version: "2023-01-01",
SunsetDate: "2025-06-01",
Team: "Users",
},
{
Operation: "GET",
Path: "/api/atlas/v2/groups",
Version: "2023-01-01",
SunsetDate: "2025-06-03",
Team: "Groups",
},
{
Operation: "GET",
Path: "/api/atlas/v2/groups",
Version: "2023-02-01",
SunsetDate: "2025-06-03",
Team: "Groups",
},
}

diff := findDiffs(baseSpecSunsets, specSunsets, "base.json", "spec.json")
assert.Len(t, diff, 5)

assert.Equal(t, "GET", diff[0].Operation)
assert.Equal(t, "/api/atlas/v2/groups", diff[0].Path)
assert.Equal(t, "2023-01-01", diff[0].Version)
assert.Equal(t, "2025-06-01", diff[0].BaseSunsetDate)
assert.Equal(t, "2025-06-03", diff[0].SpecSunsetDate)
assert.Equal(t, "base.json", diff[0].BaseSpec)
assert.Equal(t, "spec.json", diff[0].Spec)
assert.Equal(t, "Groups", diff[0].Team)

assert.Equal(t, "GET", diff[1].Operation)
assert.Equal(t, "/api/atlas/v2/groups", diff[1].Path)
assert.Equal(t, "2023-02-01", diff[1].Version)
assert.Equal(t, "2025-06-01", diff[1].BaseSunsetDate)
assert.Equal(t, "2025-06-03", diff[1].SpecSunsetDate)
assert.Equal(t, "base.json", diff[1].BaseSpec)
assert.Equal(t, "spec.json", diff[1].Spec)
assert.Equal(t, "Groups", diff[1].Team)

assert.Equal(t, "GET", diff[2].Operation)
assert.Equal(t, "/api/atlas/v2/test", diff[2].Path)
assert.Equal(t, "2023-01-01", diff[2].Version)
assert.Equal(t, "2025-06-01", diff[2].BaseSunsetDate)
assert.Empty(t, diff[2].SpecSunsetDate)
assert.Equal(t, "base.json", diff[2].BaseSpec)
assert.Equal(t, "spec.json", diff[2].Spec)
assert.Equal(t, "Test", diff[2].Team)

assert.Equal(t, "GET", diff[3].Operation)
assert.Equal(t, "/api/atlas/v2/users", diff[3].Path)
assert.Equal(t, "2023-01-01", diff[3].Version)
assert.Empty(t, diff[3].BaseSunsetDate)
assert.Equal(t, "2025-06-01", diff[3].SpecSunsetDate)
assert.Equal(t, "base.json", diff[3].BaseSpec)
assert.Equal(t, "spec.json", diff[3].Spec)
assert.Equal(t, "Users", diff[3].Team)

assert.Equal(t, "GET", diff[4].Operation)
assert.Equal(t, "/api/atlas/v2/versions", diff[4].Path)
assert.Equal(t, "2023-01-01", diff[4].Version)
assert.Equal(t, "2025-06-01", diff[4].BaseSunsetDate)
assert.Equal(t, "2025-06-02", diff[4].SpecSunsetDate)
assert.Equal(t, "base.json", diff[4].BaseSpec)
assert.Equal(t, "spec.json", diff[4].Spec)
assert.Equal(t, "APIx", diff[4].Team)
}

func TestMakeKey(t *testing.T) {
key := makeKey("/api/atlas/v2/groups", "GET", "2023-01-01")
assert.Equal(t, "GET-/api/atlas/v2/groups-2023-01-01", key)
}
2 changes: 1 addition & 1 deletion tools/cli/internal/cli/sunset/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ func (o *ListOpts) validate() error {
return nil
}

// ListBuilder builds the merge command with the following signature:
// ListBuilder builds the list command with the following signature:
// sunset ls -s spec.json -f 2024-01-01 -t 2024-09-22.
func ListBuilder() *cobra.Command {
opts := &ListOpts{
Expand Down
1 change: 1 addition & 0 deletions tools/cli/internal/cli/sunset/sunset.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ func Builder() *cobra.Command {
}

cmd.AddCommand(ListBuilder())
cmd.AddCommand(DiffBuilder())

return cmd
}
Loading
Loading