Skip to content
Draft
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
94 changes: 40 additions & 54 deletions tools/cli/internal/openapi/sunset/sunset.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,6 @@
package sunset

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

"github.com/oasdiff/kin-openapi/openapi3"
"github.com/oasdiff/oasdiff/load"
)
Expand All @@ -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)
}
}

Expand All @@ -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)
}
Expand All @@ -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
}
81 changes: 76 additions & 5 deletions tools/cli/internal/openapi/sunset/sunset_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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)
})
}
}
Expand All @@ -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",
Expand All @@ -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",
},
},
},
{
Expand All @@ -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{
Expand Down
Loading