diff --git a/tools/cli/internal/openapi/sunset/sunset.go b/tools/cli/internal/openapi/sunset/sunset.go index c08c0f3583..fac298239b 100644 --- a/tools/cli/internal/openapi/sunset/sunset.go +++ b/tools/cli/internal/openapi/sunset/sunset.go @@ -15,11 +15,6 @@ package sunset import ( - "maps" - "regexp" - "slices" - "sort" - "github.com/oasdiff/kin-openapi/openapi3" "github.com/oasdiff/oasdiff/load" ) @@ -45,30 +40,29 @@ func NewListFromSpec(spec *load.SpecInfo) []*Sunset { for path, pathBody := range paths.Map() { for operationName, operationBody := range pathBody.Operations() { teamName := teamName(operationBody) - extensions := successResponseExtensions(operationBody.Responses.Map()) - if extensions == nil { - continue - } - - apiVersion, ok := extensions[apiVersionExtensionName] - if !ok { - continue - } - - sunsetExt, ok := extensions[sunsetExtensionName] - if !ok { - continue - } - - sunset := Sunset{ - Operation: operationName, - Path: path, - SunsetDate: sunsetExt.(string), - Version: apiVersion.(string), - Team: teamName, + extensionsList := successResponseExtensions(operationBody.Responses.Map()) + + for _, extensions := range extensionsList { + apiVersion, ok := extensions[apiVersionExtensionName] + if !ok { + continue + } + + sunsetExt, ok := extensions[sunsetExtensionName] + if !ok { + continue + } + + sunset := Sunset{ + Operation: operationName, + Path: path, + SunsetDate: sunsetExt.(string), + Version: apiVersion.(string), + Team: teamName, + } + + sunsets = append(sunsets, &sunset) } - - sunsets = append(sunsets, &sunset) } } @@ -95,7 +89,7 @@ func teamName(op *openapi3.Operation) string { // 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 { +func successResponseExtensions(responsesMap map[string]*openapi3.ResponseRef) []map[string]any { if val, ok := responsesMap["200"]; ok { return contentExtensions(val.Value.Content) } @@ -112,36 +106,28 @@ 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. +// contentExtensions extracts extensions from all OpenAPI content entries that have a sunset extension. // -// 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. +// The function iterates over all content entries and returns the extensions for each entry +// that contains a sunset extension, allowing multiple API versions with different sunset +// dates to be tracked independently. // // 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 { - 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 +// - A slice of extension maps, one per content entry that has a sunset extension, +// or nil if no entries have sunset extensions +func contentExtensions(content openapi3.Content) []map[string]any { + var result []map[string]any + for _, mediaType := range content { + if mediaType.Extensions == nil { + continue } - // Strings with dates should come before those without. - return dateI != "" - }) - - return content[keysContent[0]].Extensions + if _, ok := mediaType.Extensions[sunsetExtensionName]; !ok { + continue + } + result = append(result, mediaType.Extensions) + } + return result } diff --git a/tools/cli/internal/openapi/sunset/sunset_test.go b/tools/cli/internal/openapi/sunset/sunset_test.go index 84a73e804a..ba31a4264c 100644 --- a/tools/cli/internal/openapi/sunset/sunset_test.go +++ b/tools/cli/internal/openapi/sunset/sunset_test.go @@ -73,6 +73,47 @@ func TestNewSunsetListFromSpec(t *testing.T) { }, expected: nil, }, + { + name: "Multiple versions with sunset extensions", + specInfo: &load.SpecInfo{ + Spec: &openapi3.T{ + Paths: openapi3.NewPaths(openapi3.WithPath("/example", &openapi3.PathItem{ + Get: &openapi3.Operation{ + Responses: openapi3.NewResponses(openapi3.WithName("200", &openapi3.Response{ + Content: openapi3.Content{ + "application/vnd.atlas.2023-01-01+json": &openapi3.MediaType{ + Extensions: map[string]any{ + sunsetExtensionName: "2025-12-31", + apiVersionExtensionName: "2023-01-01", + }, + }, + "application/vnd.atlas.2024-05-01+json": &openapi3.MediaType{ + Extensions: map[string]any{ + sunsetExtensionName: "2025-06-01", + apiVersionExtensionName: "2024-05-01", + }, + }, + }, + })), + }, + })), + }, + }, + expected: []*Sunset{ + { + Operation: "GET", + Path: "/example", + Version: "2023-01-01", + SunsetDate: "2025-12-31", + }, + { + Operation: "GET", + Path: "/example", + Version: "2024-05-01", + SunsetDate: "2025-06-01", + }, + }, + }, { name: "201 operations with extensions", specInfo: &load.SpecInfo{ @@ -109,7 +150,7 @@ func TestNewSunsetListFromSpec(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { result := NewListFromSpec(test.specInfo) - assert.Equal(t, test.expected, result) + assert.ElementsMatch(t, test.expected, result) }) } } @@ -118,7 +159,7 @@ func TestNewExtensionsFrom2xxResponse(t *testing.T) { tests := []struct { name string responsesMap map[string]*openapi3.ResponseRef - expected map[string]any + expected []map[string]any }{ { name: "Valid 200 response with extensions", @@ -136,9 +177,11 @@ func TestNewExtensionsFrom2xxResponse(t *testing.T) { }, }, }, - expected: map[string]any{ - sunsetExtensionName: "2025-12-31", - apiVersionExtensionName: "v1.0", + expected: []map[string]any{ + { + sunsetExtensionName: "2025-12-31", + apiVersionExtensionName: "v1.0", + }, }, }, { @@ -150,6 +193,34 @@ func TestNewExtensionsFrom2xxResponse(t *testing.T) { }, expected: nil, }, + { + name: "Content entry without sunset extension is skipped", + responsesMap: map[string]*openapi3.ResponseRef{ + "200": { + Value: &openapi3.Response{ + Content: openapi3.Content{ + "application/vnd.atlas.2023-01-01+json": &openapi3.MediaType{ + Extensions: map[string]any{ + sunsetExtensionName: "2025-12-31", + apiVersionExtensionName: "2023-01-01", + }, + }, + "application/vnd.atlas.2024-05-01+json": &openapi3.MediaType{ + Extensions: map[string]any{ + apiVersionExtensionName: "2024-05-01", + }, + }, + }, + }, + }, + }, + expected: []map[string]any{ + { + sunsetExtensionName: "2025-12-31", + apiVersionExtensionName: "2023-01-01", + }, + }, + }, { name: "Empty extensions for 2xx response", responsesMap: map[string]*openapi3.ResponseRef{