From 8b10eaa597cab32fca0662885990da0a9897bcbf Mon Sep 17 00:00:00 2001 From: Andrei Matei Date: Thu, 2 Apr 2026 13:31:35 +0100 Subject: [PATCH 1/4] fix: fix sunset ls to report all sunsetting versions of a resource --- tools/cli/internal/openapi/sunset/sunset.go | 94 ++++++++----------- .../internal/openapi/sunset/sunset_test.go | 81 +++++++++++++++- 2 files changed, 116 insertions(+), 59 deletions(-) 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{ From e19b96a10096d49a11856517bff67ffab6b9fe20 Mon Sep 17 00:00:00 2001 From: Andrei Matei Date: Wed, 8 Apr 2026 12:29:39 +0100 Subject: [PATCH 2/4] fix: improve sunset ls naming and sort by sunset date --- tools/cli/internal/cli/sunset/list.go | 7 ++- tools/cli/internal/openapi/sunset/sunset.go | 45 ++++++------------- .../internal/openapi/sunset/sunset_test.go | 2 +- 3 files changed, 19 insertions(+), 35 deletions(-) diff --git a/tools/cli/internal/cli/sunset/list.go b/tools/cli/internal/cli/sunset/list.go index 2fa552bb8b..1aa66de4ae 100644 --- a/tools/cli/internal/cli/sunset/list.go +++ b/tools/cli/internal/cli/sunset/list.go @@ -53,12 +53,15 @@ func (o *ListOpts) Run() error { return err } - // order sunset elements per Path,Operation in ascending order + // order sunset elements per Path, Operation, SunsetDate 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 + if sunsets[i].Operation != sunsets[j].Operation { + return sunsets[i].Operation < sunsets[j].Operation + } + return sunsets[i].SunsetDate < sunsets[j].SunsetDate }) bytes, err := o.newSunsetListBytes(sunsets) diff --git a/tools/cli/internal/openapi/sunset/sunset.go b/tools/cli/internal/openapi/sunset/sunset.go index fac298239b..4f899d88b7 100644 --- a/tools/cli/internal/openapi/sunset/sunset.go +++ b/tools/cli/internal/openapi/sunset/sunset.go @@ -40,7 +40,7 @@ func NewListFromSpec(spec *load.SpecInfo) []*Sunset { for path, pathBody := range paths.Map() { for operationName, operationBody := range pathBody.Operations() { teamName := teamName(operationBody) - extensionsList := successResponseExtensions(operationBody.Responses.Map()) + extensionsList := sunsetExtensionsFromResponses(operationBody.Responses.Map()) for _, extensions := range extensionsList { apiVersion, ok := extensions[apiVersionExtensionName] @@ -76,49 +76,30 @@ 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 { +// sunsetExtensionsFromResponses searches through response objects for successful HTTP status +// codes (200, 201, 202, 204) and returns the sunset-related extensions from all content +// entries of the first successful response found. +func sunsetExtensionsFromResponses(responsesMap map[string]*openapi3.ResponseRef) []map[string]any { if val, ok := responsesMap["200"]; ok { - return contentExtensions(val.Value.Content) + return sunsetExtensionsFromContent(val.Value.Content) } if val, ok := responsesMap["201"]; ok { - return contentExtensions(val.Value.Content) + return sunsetExtensionsFromContent(val.Value.Content) } if val, ok := responsesMap["202"]; ok { - return contentExtensions(val.Value.Content) + return sunsetExtensionsFromContent(val.Value.Content) } if val, ok := responsesMap["204"]; ok { - return contentExtensions(val.Value.Content) + return sunsetExtensionsFromContent(val.Value.Content) } return nil } -// contentExtensions extracts extensions from all OpenAPI content entries that have a sunset extension. -// -// 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 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 { +// sunsetExtensionsFromContent extracts extensions from all content entries that have a +// sunset extension, allowing multiple API versions with different sunset dates to be +// tracked independently. +func sunsetExtensionsFromContent(content openapi3.Content) []map[string]any { var result []map[string]any for _, mediaType := range content { if mediaType.Extensions == nil { diff --git a/tools/cli/internal/openapi/sunset/sunset_test.go b/tools/cli/internal/openapi/sunset/sunset_test.go index ba31a4264c..c1aedbb77a 100644 --- a/tools/cli/internal/openapi/sunset/sunset_test.go +++ b/tools/cli/internal/openapi/sunset/sunset_test.go @@ -238,7 +238,7 @@ func TestNewExtensionsFrom2xxResponse(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - result := successResponseExtensions(test.responsesMap) + result := sunsetExtensionsFromResponses(test.responsesMap) assert.Equal(t, test.expected, result) }) } From e36b48ab5c0286709fde6568019110524cfafe32 Mon Sep 17 00:00:00 2001 From: Andrei Matei Date: Wed, 8 Apr 2026 12:39:34 +0100 Subject: [PATCH 3/4] Revert "fix: improve sunset ls naming and sort by sunset date" This reverts commit e19b96a10096d49a11856517bff67ffab6b9fe20. --- tools/cli/internal/cli/sunset/list.go | 7 +-- tools/cli/internal/openapi/sunset/sunset.go | 45 +++++++++++++------ .../internal/openapi/sunset/sunset_test.go | 2 +- 3 files changed, 35 insertions(+), 19 deletions(-) diff --git a/tools/cli/internal/cli/sunset/list.go b/tools/cli/internal/cli/sunset/list.go index 1aa66de4ae..2fa552bb8b 100644 --- a/tools/cli/internal/cli/sunset/list.go +++ b/tools/cli/internal/cli/sunset/list.go @@ -53,15 +53,12 @@ func (o *ListOpts) Run() error { return err } - // order sunset elements per Path, Operation, SunsetDate in ascending order + // 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 } - if sunsets[i].Operation != sunsets[j].Operation { - return sunsets[i].Operation < sunsets[j].Operation - } - return sunsets[i].SunsetDate < sunsets[j].SunsetDate + return sunsets[i].Operation < sunsets[j].Operation }) bytes, err := o.newSunsetListBytes(sunsets) diff --git a/tools/cli/internal/openapi/sunset/sunset.go b/tools/cli/internal/openapi/sunset/sunset.go index 4f899d88b7..fac298239b 100644 --- a/tools/cli/internal/openapi/sunset/sunset.go +++ b/tools/cli/internal/openapi/sunset/sunset.go @@ -40,7 +40,7 @@ func NewListFromSpec(spec *load.SpecInfo) []*Sunset { for path, pathBody := range paths.Map() { for operationName, operationBody := range pathBody.Operations() { teamName := teamName(operationBody) - extensionsList := sunsetExtensionsFromResponses(operationBody.Responses.Map()) + extensionsList := successResponseExtensions(operationBody.Responses.Map()) for _, extensions := range extensionsList { apiVersion, ok := extensions[apiVersionExtensionName] @@ -76,30 +76,49 @@ func teamName(op *openapi3.Operation) string { return "" } -// sunsetExtensionsFromResponses searches through response objects for successful HTTP status -// codes (200, 201, 202, 204) and returns the sunset-related extensions from all content -// entries of the first successful response found. -func sunsetExtensionsFromResponses(responsesMap map[string]*openapi3.ResponseRef) []map[string]any { +// 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 sunsetExtensionsFromContent(val.Value.Content) + return contentExtensions(val.Value.Content) } if val, ok := responsesMap["201"]; ok { - return sunsetExtensionsFromContent(val.Value.Content) + return contentExtensions(val.Value.Content) } if val, ok := responsesMap["202"]; ok { - return sunsetExtensionsFromContent(val.Value.Content) + return contentExtensions(val.Value.Content) } if val, ok := responsesMap["204"]; ok { - return sunsetExtensionsFromContent(val.Value.Content) + return contentExtensions(val.Value.Content) } return nil } -// sunsetExtensionsFromContent extracts extensions from all content entries that have a -// sunset extension, allowing multiple API versions with different sunset dates to be -// tracked independently. -func sunsetExtensionsFromContent(content openapi3.Content) []map[string]any { +// contentExtensions extracts extensions from all OpenAPI content entries that have a sunset extension. +// +// 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 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 { diff --git a/tools/cli/internal/openapi/sunset/sunset_test.go b/tools/cli/internal/openapi/sunset/sunset_test.go index c1aedbb77a..ba31a4264c 100644 --- a/tools/cli/internal/openapi/sunset/sunset_test.go +++ b/tools/cli/internal/openapi/sunset/sunset_test.go @@ -238,7 +238,7 @@ func TestNewExtensionsFrom2xxResponse(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - result := sunsetExtensionsFromResponses(test.responsesMap) + result := successResponseExtensions(test.responsesMap) assert.Equal(t, test.expected, result) }) } From adbbd60fa08068d8668bd9b1d5d78a2f7a86392a Mon Sep 17 00:00:00 2001 From: Andrei Matei Date: Wed, 8 Apr 2026 12:42:25 +0100 Subject: [PATCH 4/4] fix: sort sunset list by sunset date then version --- tools/cli/internal/openapi/sunset/sunset.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tools/cli/internal/openapi/sunset/sunset.go b/tools/cli/internal/openapi/sunset/sunset.go index fac298239b..abf7740e29 100644 --- a/tools/cli/internal/openapi/sunset/sunset.go +++ b/tools/cli/internal/openapi/sunset/sunset.go @@ -15,6 +15,8 @@ package sunset import ( + "sort" + "github.com/oasdiff/kin-openapi/openapi3" "github.com/oasdiff/oasdiff/load" ) @@ -66,6 +68,13 @@ func NewListFromSpec(spec *load.SpecInfo) []*Sunset { } } + sort.Slice(sunsets, func(i, j int) bool { + if sunsets[i].SunsetDate != sunsets[j].SunsetDate { + return sunsets[i].SunsetDate < sunsets[j].SunsetDate + } + return sunsets[i].Version < sunsets[j].Version + }) + return sunsets }