Skip to content

fix: Fix foascli sunset list to print sunset endpoints with deterministic order #804

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Jul 7, 2025
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
10 changes: 9 additions & 1 deletion tools/cli/internal/cli/sunset/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ package sunset
import (
"encoding/json"
"fmt"
"sort"
"strings"
"time"

Expand Down Expand Up @@ -52,6 +53,14 @@ func (o *ListOpts) Run() error {
return err
}

// order sunset elements per Path,Operation in ascending order
sort.Slice(sunsets, func(i, j int) bool {
if sunsets[i].Path != sunsets[j].Path {
return sunsets[i].Path < sunsets[j].Path
}
return sunsets[i].Operation < sunsets[j].Operation
})

bytes, err := o.newSunsetListBytes(sunsets)
if err != nil {
return err
Expand Down Expand Up @@ -170,6 +179,5 @@ func ListBuilder() *cobra.Command {
cmd.Flags().StringVarP(&opts.format, flag.Format, flag.FormatShort, "json", usage.Format)

_ = cmd.MarkFlagRequired(flag.Spec)

return cmd
}
99 changes: 99 additions & 0 deletions tools/cli/internal/cli/sunset/list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,11 @@
package sunset

import (
"encoding/json"
"reflect"
"testing"

"github.com/mongodb/openapi/tools/cli/internal/openapi/sunset"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand All @@ -28,10 +31,106 @@ func TestList_Run(t *testing.T) {
basePath: "../../../test/data/base_spec.json",
outputPath: "foas.json",
fs: fs,
format: "json",
from: "2024-09-22",
to: "2026-09-22",
}

require.NoError(t, opts.Run())
b, err := afero.ReadFile(fs, opts.outputPath)
require.NoError(t, err)
assert.NotEmpty(t, b)
var results []*sunset.Sunset
require.NoError(t, json.Unmarshal(b, &results))
if !reflect.DeepEqual(results, expectedResults) {
gotPretty, _ := json.MarshalIndent(results, "", " ")
wantPretty, _ := json.MarshalIndent(expectedResults, "", " ")
t.Errorf("mismatch:\nGot:\n%s\nWant:\n%s", string(gotPretty), string(wantPretty))
}
}

var expectedResults = []*sunset.Sunset{
{Operation: "GET", Path: "/api/atlas/v2/example/info", SunsetDate: "2025-06-01", Team: "APIx",
Version: "2023-01-01"},
{Operation: "GET",
Path: "/api/atlas/v2/federationSettings/{federationSettingsId}/identityProviders/{identityProviderId}",
SunsetDate: "2025-01-01", Team: "IAM", Version: "2023-01-01"},
{Operation: "PATCH",
Path: "/api/atlas/v2/federationSettings/{federationSettingsId}/identityProviders/{identityProviderId}",
SunsetDate: "2025-01-01", Team: "IAM", Version: "2023-01-01"},
{Operation: "PATCH", Path: "/api/atlas/v2/groups/{groupId}/alerts/{alertId}", SunsetDate: "2025-05-30",
Team: "CAP", Version: "2023-01-01"},
{Operation: "GET", Path: "/api/atlas/v2/groups/{groupId}/backup/exportBuckets", SunsetDate: "2025-05-30",
Team: "Backup - Atlas", Version: "2023-01-01"},
{Operation: "POST", Path: "/api/atlas/v2/groups/{groupId}/backup/exportBuckets",
SunsetDate: "2025-05-30", Team: "Backup - Atlas", Version: "2023-01-01"},
{Operation: "GET", Path: "/api/atlas/v2/groups/{groupId}/backup/exportBuckets/{exportBucketId}",
SunsetDate: "2025-05-30", Team: "Backup - Atlas", Version: "2023-01-01"},
{Operation: "GET", Path: "/api/atlas/v2/groups/{groupId}/backupCompliancePolicy",
SunsetDate: "2024-10-01", Team: "Backup - Atlas", Version: "2023-01-01"},
{Operation: "PUT", Path: "/api/atlas/v2/groups/{groupId}/backupCompliancePolicy",
SunsetDate: "2024-10-01", Team: "Backup - Atlas", Version: "2023-01-01"},
{Operation: "POST", Path: "/api/atlas/v2/groups/{groupId}/clusters",
SunsetDate: "2025-06-01", Team: "Atlas Dedicated", Version: "2023-01-01"},
{Operation: "DELETE", Path: "/api/atlas/v2/groups/{groupId}/clusters/{clusterName}",
SunsetDate: "2025-06-01", Team: "Atlas Dedicated", Version: "2023-01-01"},
{Operation: "GET", Path: "/api/atlas/v2/groups/{groupId}/clusters/{clusterName}",
SunsetDate: "2025-06-01", Team: "Atlas Dedicated", Version: "2023-01-01"},
{Operation: "PATCH", Path: "/api/atlas/v2/groups/{groupId}/clusters/{clusterName}",
SunsetDate: "2025-06-01", Team: "Atlas Dedicated", Version: "2023-01-01"},
{Operation: "POST", Path: "/api/atlas/v2/groups/{groupId}/clusters/{clusterName}/fts/indexes",
SunsetDate: "2025-06-01", Team: "Search Web Platform", Version: "2023-01-01"},
{Operation: "GET",
Path: "/api/atlas/v2/groups/{groupId}/clusters/{clusterName}/fts/indexes/{databaseName}/{collectionName}",
SunsetDate: "2025-06-01", Team: "Search Web Platform", Version: "2023-01-01"},
{Operation: "DELETE",
Path: "/api/atlas/v2/groups/{groupId}/clusters/{clusterName}/fts/indexes/{indexId}",
SunsetDate: "2025-06-01", Team: "Search Web Platform", Version: "2023-01-01"},
{Operation: "GET",
Path: "/api/atlas/v2/groups/{groupId}/clusters/{clusterName}/fts/indexes/{indexId}",
SunsetDate: "2025-06-01", Team: "Search Web Platform", Version: "2023-01-01"},
{Operation: "PATCH", Path: "/api/atlas/v2/groups/{groupId}/clusters/{clusterName}/fts/indexes/{indexId}",
SunsetDate: "2025-06-01", Team: "Search Web Platform", Version: "2023-01-01"},
{Operation: "GET", Path: "/api/atlas/v2/groups/{groupId}/clusters/{clusterName}/globalWrites",
SunsetDate: "2025-06-01", Team: "Atlas Dedicated", Version: "2023-01-01"},
{Operation: "DELETE", Path: "/api/atlas/v2/groups/{groupId}/clusters/{clusterName}/globalWrites/customZoneMapping",
SunsetDate: "2025-06-01", Team: "Atlas Dedicated", Version: "2023-01-01"},
{Operation: "POST", Path: "/api/atlas/v2/groups/{groupId}/clusters/{clusterName}/globalWrites/customZoneMapping",
SunsetDate: "2025-06-01", Team: "Atlas Dedicated", Version: "2023-01-01"},
{Operation: "DELETE", Path: "/api/atlas/v2/groups/{groupId}/clusters/{clusterName}/globalWrites/managedNamespaces",
SunsetDate: "2025-06-01", Team: "Atlas Dedicated", Version: "2023-01-01"},
{Operation: "POST", Path: "/api/atlas/v2/groups/{groupId}/clusters/{clusterName}/globalWrites/managedNamespaces",
SunsetDate: "2025-06-01", Team: "Atlas Dedicated", Version: "2023-01-01"},
{Operation: "GET", Path: "/api/atlas/v2/groups/{groupId}/clusters/{clusterName}/processArgs",
SunsetDate: "2025-06-01", Team: "Atlas", Version: "2023-01-01"},
{Operation: "PATCH", Path: "/api/atlas/v2/groups/{groupId}/clusters/{clusterName}/processArgs",
SunsetDate: "2025-06-01", Team: "Atlas", Version: "2023-01-01"},
{Operation: "POST", Path: "/api/atlas/v2/groups/{groupId}/clusters/{clusterName}/restartPrimaries",
SunsetDate: "2025-06-01", Team: "Atlas Dedicated", Version: "2023-01-01"},
{Operation: "DELETE", Path: "/api/atlas/v2/groups/{groupId}/clusters/{clusterName}/search/deployment",
SunsetDate: "2026-03-01", Team: "Search Web Platform", Version: "2023-01-01"},
{Operation: "GET", Path: "/api/atlas/v2/groups/{groupId}/clusters/{clusterName}/search/deployment",
SunsetDate: "2026-03-01", Team: "Search Web Platform", Version: "2023-01-01"},
{Operation: "PATCH", Path: "/api/atlas/v2/groups/{groupId}/clusters/{clusterName}/search/deployment",
SunsetDate: "2026-03-01", Team: "Search Web Platform", Version: "2023-01-01"},
{Operation: "POST", Path: "/api/atlas/v2/groups/{groupId}/clusters/{clusterName}/search/deployment",
SunsetDate: "2026-03-01", Team: "Search Web Platform", Version: "2023-01-01"},
{Operation: "GET", Path: "/api/atlas/v2/groups/{groupId}/clusters/{hostName}/logs/{logName}.gz",
SunsetDate: "2025-06-01", Team: "Atlas Dedicated", Version: "2023-01-01"},
{Operation: "GET", Path: "/api/atlas/v2/groups/{groupId}/invites", SunsetDate: "2024-10-04", Team: "IAM",
Version: "2023-01-01"},
{Operation: "PATCH", Path: "/api/atlas/v2/groups/{groupId}/invites",
SunsetDate: "2024-10-04", Team: "IAM", Version: "2023-01-01"},
{Operation: "POST", Path: "/api/atlas/v2/groups/{groupId}/invites", SunsetDate: "2024-10-04", Team: "IAM",
Version: "2023-01-01"},
{Operation: "DELETE", Path: "/api/atlas/v2/groups/{groupId}/invites/{invitationId}", SunsetDate: "2024-10-04",
Team: "IAM", Version: "2023-01-01"},
{Operation: "GET", Path: "/api/atlas/v2/groups/{groupId}/invites/{invitationId}", SunsetDate: "2024-10-04",
Team: "IAM", Version: "2023-01-01"},
{Operation: "PATCH", Path: "/api/atlas/v2/groups/{groupId}/invites/{invitationId}", SunsetDate: "2024-10-04",
Team: "IAM", Version: "2023-01-01"},
{Operation: "POST", Path: "/api/atlas/v2/groups/{groupId}/liveMigrations", SunsetDate: "2025-05-30",
Team: "Atlas Migrations", Version: "2023-01-01"},
{Operation: "POST", Path: "/api/atlas/v2/groups/{groupId}/liveMigrations/validate", SunsetDate: "2025-05-30",
Team: "Atlas Migrations", Version: "2023-01-01"},
}
53 changes: 49 additions & 4 deletions tools/cli/internal/openapi/sunset/sunset.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@
package sunset

import (
"maps"
"regexp"
"slices"
"sort"

"github.com/getkin/kin-openapi/openapi3"
"github.com/tufin/oasdiff/load"
)
Expand Down Expand Up @@ -77,6 +82,19 @@ func teamName(op *openapi3.Operation) string {
return ""
}

// successResponseExtensions searches through a map of response objects for successful HTTP status
// codes (200, 201, 202, 204) and returns the extensions from the content of the first successful
// response found.
//
// The function prioritizes responses in the following order: 200, 201, 202, 204. For each found
// response, it extracts extensions from its content using the contentExtensions helper function.
//
// Parameters:
// - responsesMap: A map of HTTP status codes to OpenAPI response objects
//
// Returns:
// - A map of extension names to their values from the first successful response content,
// or nil if no successful responses are found or if none contain relevant extensions
func successResponseExtensions(responsesMap map[string]*openapi3.ResponseRef) map[string]any {
if val, ok := responsesMap["200"]; ok {
return contentExtensions(val.Value.Content)
Expand All @@ -94,9 +112,36 @@ func successResponseExtensions(responsesMap map[string]*openapi3.ResponseRef) ma
return nil
}

// contentExtensions extracts extensions from OpenAPI content objects, prioritizing content entries
// with the oldest date in their keys.
//
// The function sorts content keys by date (in YYYY-MM-DD format) if present, with older dates taking
// precedence. If multiple keys contain dates, it selects the entry with the earliest date.
//
// Parameters:
// - content: An OpenAPI content map with media types as keys and schema objects as values
//
// Returns:
// - A map of extension names to their values from the selected content entry,
// or nil if the content map is empty or the selected entry has no extensions
//
// Assumption: the older version will have the earliest sunset date.
func contentExtensions(content openapi3.Content) map[string]any {
for _, v := range content {
return v.Extensions
}
return nil
keysContent := slices.Collect(maps.Keys(content))
// Regex to find a date in YYYY-MM-DD format.
dateRegex := regexp.MustCompile(`\d{4}-\d{2}-\d{2}`)
Copy link
Collaborator

Choose a reason for hiding this comment

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

q: What about upcoming and preview?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

upcoming will sunset with the related stable api so we will get the sunset date from the stable api.

Regarding preview is a bit tricky, I need to think about how to add support for it and if it makes sense 🤔

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I will create a follow up ticket for preview

// we need the content of the API version with the older date.
sort.Slice(keysContent, func(i, j int) bool {
dateI := dateRegex.FindString(keysContent[i])
dateJ := dateRegex.FindString(keysContent[j])

// If both have dates, compare them as strings.
if dateI != "" && dateJ != "" {
return dateI < dateJ
}
// Strings with dates should come before those without.
return dateI != ""
})

return content[keysContent[0]].Extensions
}
Loading