diff --git a/tools/cli/internal/cli/sunset/list.go b/tools/cli/internal/cli/sunset/list.go index d6816c20da..2fa552bb8b 100644 --- a/tools/cli/internal/cli/sunset/list.go +++ b/tools/cli/internal/cli/sunset/list.go @@ -17,6 +17,7 @@ package sunset import ( "encoding/json" "fmt" + "sort" "strings" "time" @@ -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 @@ -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 } diff --git a/tools/cli/internal/cli/sunset/list_test.go b/tools/cli/internal/cli/sunset/list_test.go index 8d4f04f062..42fe58188f 100644 --- a/tools/cli/internal/cli/sunset/list_test.go +++ b/tools/cli/internal/cli/sunset/list_test.go @@ -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" @@ -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"}, } diff --git a/tools/cli/internal/openapi/sunset/sunset.go b/tools/cli/internal/openapi/sunset/sunset.go index 2d7303917e..f29abfc8b3 100644 --- a/tools/cli/internal/openapi/sunset/sunset.go +++ b/tools/cli/internal/openapi/sunset/sunset.go @@ -15,6 +15,11 @@ package sunset import ( + "maps" + "regexp" + "slices" + "sort" + "github.com/getkin/kin-openapi/openapi3" "github.com/tufin/oasdiff/load" ) @@ -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) @@ -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}`) + // 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 }