diff --git a/tools/cli/internal/cli/breakingchanges/breakingchanges.go b/tools/cli/internal/cli/breakingchanges/breakingchanges.go index 7e1c9f787..d111c4a0b 100644 --- a/tools/cli/internal/cli/breakingchanges/breakingchanges.go +++ b/tools/cli/internal/cli/breakingchanges/breakingchanges.go @@ -23,9 +23,6 @@ func Builder() *cobra.Command { cmd := &cobra.Command{ Use: "breaking-changes", Short: "Manage API Breaking changes related commands.", - Annotations: map[string]string{ - "toc": "true", - }, } cmd.AddCommand( diff --git a/tools/cli/internal/cli/breakingchanges/exemptions/exemptions.go b/tools/cli/internal/cli/breakingchanges/exemptions/exemptions.go index fae9e9cbe..96baaecc9 100644 --- a/tools/cli/internal/cli/breakingchanges/exemptions/exemptions.go +++ b/tools/cli/internal/cli/breakingchanges/exemptions/exemptions.go @@ -22,9 +22,6 @@ func Builder() *cobra.Command { cmd := &cobra.Command{ Use: "exemptions", Short: "Manage exemptions.", - Annotations: map[string]string{ - "toc": "true", - }, } cmd.AddCommand(ParseBuilder()) diff --git a/tools/cli/internal/cli/changelog/changelog.go b/tools/cli/internal/cli/changelog/changelog.go index df9365913..02aca8780 100644 --- a/tools/cli/internal/cli/changelog/changelog.go +++ b/tools/cli/internal/cli/changelog/changelog.go @@ -24,9 +24,6 @@ func Builder() *cobra.Command { cmd := &cobra.Command{ Use: "changelog", Short: "Manage the API Changelog for the OpenAPI spec.", - Annotations: map[string]string{ - "toc": "true", - }, } cmd.AddCommand( diff --git a/tools/cli/internal/cli/changelog/convert/convert.go b/tools/cli/internal/cli/changelog/convert/convert.go index 8089d2389..0559ddb80 100644 --- a/tools/cli/internal/cli/changelog/convert/convert.go +++ b/tools/cli/internal/cli/changelog/convert/convert.go @@ -22,9 +22,6 @@ func Builder() *cobra.Command { cmd := &cobra.Command{ Use: "convert", Short: "Convert API Changelog entries into another format.", - Annotations: map[string]string{ - "toc": "true", - }, } cmd.AddCommand(SlackBuilder()) diff --git a/tools/cli/internal/cli/changelog/create.go b/tools/cli/internal/cli/changelog/create.go index 750e4b628..737438dd8 100644 --- a/tools/cli/internal/cli/changelog/create.go +++ b/tools/cli/internal/cli/changelog/create.go @@ -116,7 +116,7 @@ func (o *Opts) newOutputFilePath(fileName string) string { return fileName } -// Builder builds the merge command with the following signature: +// CreateBuilder builds the merge command with the following signature: // changelog create -b path_folder -r path_folder --dry-run func CreateBuilder() *cobra.Command { opts := &Opts{ diff --git a/tools/cli/internal/cli/changelog/metadata/metadata.go b/tools/cli/internal/cli/changelog/metadata/metadata.go index a8a3ad722..aaed66c61 100644 --- a/tools/cli/internal/cli/changelog/metadata/metadata.go +++ b/tools/cli/internal/cli/changelog/metadata/metadata.go @@ -22,9 +22,6 @@ func Builder() *cobra.Command { cmd := &cobra.Command{ Use: "metadata", Short: "Manage the API Changelog Metadata.", - Annotations: map[string]string{ - "toc": "true", - }, } cmd.AddCommand(CreateBuilder()) diff --git a/tools/cli/internal/cli/flag/flag.go b/tools/cli/internal/cli/flag/flag.go index 4e520f4a4..38e19f05d 100644 --- a/tools/cli/internal/cli/flag/flag.go +++ b/tools/cli/internal/cli/flag/flag.go @@ -45,4 +45,6 @@ const ( MessageID = "msg-id" ChannelID = "channel-id" ChannelIDShort = "c" + From = "from" + To = "to" ) diff --git a/tools/cli/internal/cli/root/openapi/builder.go b/tools/cli/internal/cli/root/openapi/builder.go index ffb87e827..9c6b4af97 100644 --- a/tools/cli/internal/cli/root/openapi/builder.go +++ b/tools/cli/internal/cli/root/openapi/builder.go @@ -22,6 +22,7 @@ import ( "github.com/mongodb/openapi/tools/cli/internal/cli/changelog" "github.com/mongodb/openapi/tools/cli/internal/cli/merge" "github.com/mongodb/openapi/tools/cli/internal/cli/split" + "github.com/mongodb/openapi/tools/cli/internal/cli/sunset" "github.com/mongodb/openapi/tools/cli/internal/cli/versions" "github.com/mongodb/openapi/tools/cli/internal/version" "github.com/spf13/cobra" @@ -59,6 +60,7 @@ func Builder() *cobra.Command { versions.Builder(), changelog.Builder(), breakingchanges.Builder(), + sunset.Builder(), ) return rootCmd } diff --git a/tools/cli/internal/cli/split/split.go b/tools/cli/internal/cli/split/split.go index 80f40aca0..7ca770eba 100644 --- a/tools/cli/internal/cli/split/split.go +++ b/tools/cli/internal/cli/split/split.go @@ -68,7 +68,7 @@ func (o *Opts) Run() error { } func (o *Opts) filter(oas *openapi3.T, version string) (result *openapi3.T, err error) { - log.Printf("Filtering OpenAPI document by version %s", version) + log.Printf("Filtering OpenAPI document by version %q", version) apiVersion, err := apiversion.New(apiversion.WithVersion(version)) if err != nil { return nil, err diff --git a/tools/cli/internal/cli/sunset/list.go b/tools/cli/internal/cli/sunset/list.go new file mode 100644 index 000000000..df66328d0 --- /dev/null +++ b/tools/cli/internal/cli/sunset/list.go @@ -0,0 +1,176 @@ +// Copyright 2025 MongoDB Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sunset + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/mongodb/openapi/tools/cli/internal/openapi" + + "github.com/mongodb/openapi/tools/cli/internal/cli/flag" + "github.com/mongodb/openapi/tools/cli/internal/cli/usage" + "github.com/mongodb/openapi/tools/cli/internal/openapi/sunset" + "github.com/spf13/afero" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +type ListOpts struct { + fs afero.Fs + basePath string + outputPath string + format string + from string + to string + toDate *time.Time + fromDate *time.Time +} + +func (o *ListOpts) Run() error { + loader := openapi.NewOpenAPI3() + specInfo, err := loader.CreateOpenAPISpecFromPath(o.basePath) + if err != nil { + return err + } + + sunsets, err := o.newSunsetInRange(sunset.NewListFromSpec(specInfo)) + if err != nil { + return err + } + + bytes, err := o.newSunsetListBytes(sunsets) + if err != nil { + return err + } + if o.outputPath != "" { + return afero.WriteFile(o.fs, o.outputPath, bytes, 0o600) + } + + fmt.Println(string(bytes)) + return nil +} + +func (o *ListOpts) newSunsetInRange(sunsets []*sunset.Sunset) ([]*sunset.Sunset, error) { + var out []*sunset.Sunset + if o.from == "" && o.to == "" { + return sunsets, nil + } + + for _, s := range sunsets { + sunsetDate, err := time.Parse("2006-01-02", s.SunsetDate) + if err != nil { + return nil, err + } + + if isDateInRange(&sunsetDate, o.fromDate, o.toDate) { + out = append(out, s) + } + } + + return out, nil +} + +func isDateInRange(date, from, to *time.Time) bool { + if date == nil { + return false + } + + if from != nil && date.Before(*from) { + return false + } + + if to != nil && date.After(*to) { + return false + } + + return true +} + +func (o *ListOpts) newSunsetListBytes(versions []*sunset.Sunset) ([]byte, error) { + data, err := json.MarshalIndent(versions, "", " ") + if err != nil { + return nil, err + } + + if format := strings.ToLower(o.format); format == "json" { + return data, nil + } + + var jsonData any + if mErr := json.Unmarshal(data, &jsonData); mErr != nil { + return nil, mErr + } + + yamlData, err := yaml.Marshal(jsonData) + if err != nil { + return nil, err + } + + return yamlData, nil +} + +func (o *ListOpts) validate() error { + if o.from != "" { + value, err := time.Parse("2006-01-02", o.from) + if err != nil { + return err + } + o.fromDate = &value + } + + if o.to != "" { + value, err := time.Parse("2006-01-02", o.to) + if err != nil { + return err + } + o.toDate = &value + } + + return nil +} + +// ListBuilder builds the merge command with the following signature: +// sunset ls -s spec.json -f 2024-01-01 -t 2024-09-22 +func ListBuilder() *cobra.Command { + opts := &ListOpts{ + fs: afero.NewOsFs(), + } + + cmd := &cobra.Command{ + Use: "list -s spec.json -o json", + Short: "List API endpoints with a Sunset date for a given OpenAPI spec.", + Aliases: []string{"ls"}, + Args: cobra.NoArgs, + PreRunE: func(_ *cobra.Command, _ []string) error { + return opts.validate() + }, + RunE: func(_ *cobra.Command, _ []string) error { + return opts.Run() + }, + } + + cmd.Flags().StringVarP(&opts.basePath, flag.Spec, flag.SpecShort, "", usage.Spec) + cmd.Flags().StringVarP(&opts.outputPath, flag.Output, flag.OutputShort, "", usage.Output) + cmd.Flags().StringVar(&opts.from, flag.From, "", usage.From) + cmd.Flags().StringVar(&opts.to, flag.To, "", usage.To) + 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 new file mode 100644 index 000000000..8d4f04f06 --- /dev/null +++ b/tools/cli/internal/cli/sunset/list_test.go @@ -0,0 +1,37 @@ +// Copyright 2025 MongoDB Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sunset + +import ( + "testing" + + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestList_Run(t *testing.T) { + fs := afero.NewMemMapFs() + opts := &ListOpts{ + basePath: "../../../test/data/base_spec.json", + outputPath: "foas.json", + fs: fs, + } + + require.NoError(t, opts.Run()) + b, err := afero.ReadFile(fs, opts.outputPath) + require.NoError(t, err) + assert.NotEmpty(t, b) +} diff --git a/tools/cli/internal/cli/sunset/sunset.go b/tools/cli/internal/cli/sunset/sunset.go new file mode 100644 index 000000000..bed0ce2b8 --- /dev/null +++ b/tools/cli/internal/cli/sunset/sunset.go @@ -0,0 +1,30 @@ +// Copyright 2025 MongoDB Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sunset + +import ( + "github.com/spf13/cobra" +) + +func Builder() *cobra.Command { + cmd := &cobra.Command{ + Use: "sunset", + Short: "Manage the Sunset API for the OpenAPI spec.", + } + + cmd.AddCommand(ListBuilder()) + + return cmd +} diff --git a/tools/cli/internal/cli/sunset/sunset_test.go b/tools/cli/internal/cli/sunset/sunset_test.go new file mode 100644 index 000000000..ab9523b7b --- /dev/null +++ b/tools/cli/internal/cli/sunset/sunset_test.go @@ -0,0 +1,30 @@ +// Copyright 2025 MongoDB Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sunset + +import ( + "testing" + + "github.com/mongodb/openapi/tools/cli/internal/test" +) + +func TestBuilder(t *testing.T) { + test.CmdValidator( + t, + Builder(), + 1, + []string{}, + ) +} diff --git a/tools/cli/internal/cli/usage/usage.go b/tools/cli/internal/cli/usage/usage.go index e6992e499..cb9df193d 100644 --- a/tools/cli/internal/cli/usage/usage.go +++ b/tools/cli/internal/cli/usage/usage.go @@ -35,4 +35,6 @@ const ( Path = "Path to the changelog file." MessageID = "Message ID of the slack message. This ID is used to add the message as slack thread." SlackChannelID = "Slack Channel ID." + From = "Date in the format YYYY-MM-DD that indicates the start of a date range" + To = "Date in the format YYYY-MM-DD that indicates the end of a date range" ) diff --git a/tools/cli/internal/openapi/sunset/sunset.go b/tools/cli/internal/openapi/sunset/sunset.go new file mode 100644 index 000000000..2d7303917 --- /dev/null +++ b/tools/cli/internal/openapi/sunset/sunset.go @@ -0,0 +1,102 @@ +// Copyright 2025 MongoDB Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sunset + +import ( + "github.com/getkin/kin-openapi/openapi3" + "github.com/tufin/oasdiff/load" +) + +const ( + sunsetExtensionName = "x-sunset" + apiVersionExtensionName = "x-xgen-version" + teamExtensionName = "x-xgen-owner-team" +) + +type Sunset struct { + Operation string `json:"http_method" yaml:"http_method"` + Path string `json:"path" yaml:"path"` + Version string `json:"version" yaml:"version"` + SunsetDate string `json:"sunset_date" yaml:"sunset_date"` + Team string `json:"team" yaml:"team"` +} + +func NewListFromSpec(spec *load.SpecInfo) []*Sunset { + var sunsets []*Sunset + paths := spec.Spec.Paths + + 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, + } + + sunsets = append(sunsets, &sunset) + } + } + + return sunsets +} + +func teamName(op *openapi3.Operation) string { + if value, ok := op.Extensions[teamExtensionName]; ok { + return value.(string) + } + return "" +} + +func successResponseExtensions(responsesMap map[string]*openapi3.ResponseRef) map[string]any { + if val, ok := responsesMap["200"]; ok { + return contentExtensions(val.Value.Content) + } + if val, ok := responsesMap["201"]; ok { + return contentExtensions(val.Value.Content) + } + if val, ok := responsesMap["202"]; ok { + return contentExtensions(val.Value.Content) + } + if val, ok := responsesMap["204"]; ok { + return contentExtensions(val.Value.Content) + } + + return nil +} + +func contentExtensions(content openapi3.Content) map[string]any { + for _, v := range content { + return v.Extensions + } + return nil +} diff --git a/tools/cli/internal/openapi/sunset/sunset_test.go b/tools/cli/internal/openapi/sunset/sunset_test.go new file mode 100644 index 000000000..b30093106 --- /dev/null +++ b/tools/cli/internal/openapi/sunset/sunset_test.go @@ -0,0 +1,174 @@ +package sunset + +import ( + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/stretchr/testify/assert" + "github.com/tufin/oasdiff/load" +) + +func TestNewSunsetListFromSpec(t *testing.T) { + tests := []struct { + name string + specInfo *load.SpecInfo + expected []*Sunset + }{ + { + name: "Single operation with sunset and version 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/json": &openapi3.MediaType{ + Extensions: map[string]any{ + sunsetExtensionName: "2025-12-31", + apiVersionExtensionName: "v1.0", + }, + }, + }, + })), + }, + })), + }, + }, + expected: []*Sunset{ + { + Operation: "GET", + Path: "/example", + Version: "v1.0", + SunsetDate: "2025-12-31", + }, + }, + }, + { + name: "No extensions in response", + 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/json": &openapi3.MediaType{}, + }, + })), + }, + })), + }, + }, + expected: nil, + }, + { + name: "No matching 2xx response", + specInfo: &load.SpecInfo{ + Spec: &openapi3.T{ + Paths: openapi3.NewPaths(openapi3.WithPath("/example", &openapi3.PathItem{ + Get: &openapi3.Operation{ + Responses: openapi3.NewResponses(openapi3.WithName("404", &openapi3.Response{})), + }, + })), + }, + }, + expected: nil, + }, + { + name: "201 operations with extensions", + specInfo: &load.SpecInfo{ + Spec: &openapi3.T{ + Paths: openapi3.NewPaths( + openapi3.WithPath("/example1", &openapi3.PathItem{ + Get: &openapi3.Operation{ + Responses: openapi3.NewResponses(openapi3.WithName("201", &openapi3.Response{ + Content: openapi3.Content{ + "application/json": &openapi3.MediaType{ + Extensions: map[string]any{ + sunsetExtensionName: "2024-06-15", + apiVersionExtensionName: "v2.0", + }, + }, + }, + })), + }, + }), + ), + }, + }, + expected: []*Sunset{ + { + Operation: "GET", + Path: "/example1", + Version: "v2.0", + SunsetDate: "2024-06-15", + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := NewListFromSpec(test.specInfo) + assert.Equal(t, test.expected, result) + }) + } +} + +func TestNewExtensionsFrom2xxResponse(t *testing.T) { + tests := []struct { + name string + responsesMap map[string]*openapi3.ResponseRef + expected map[string]any + }{ + { + name: "Valid 200 response with extensions", + responsesMap: map[string]*openapi3.ResponseRef{ + "200": { + Value: &openapi3.Response{ + Content: openapi3.Content{ + "application/json": &openapi3.MediaType{ + Extensions: map[string]any{ + sunsetExtensionName: "2025-12-31", + apiVersionExtensionName: "v1.0", + }, + }, + }, + }, + }, + }, + expected: map[string]any{ + sunsetExtensionName: "2025-12-31", + apiVersionExtensionName: "v1.0", + }, + }, + { + name: "No matching response", + responsesMap: map[string]*openapi3.ResponseRef{ + "404": { + Value: &openapi3.Response{}, + }, + }, + expected: nil, + }, + { + name: "Empty extensions for 2xx response", + responsesMap: map[string]*openapi3.ResponseRef{ + "200": { + Value: &openapi3.Response{ + Content: openapi3.Content{ + "application/json": &openapi3.MediaType{}, + }, + }, + }, + }, + expected: nil, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := successResponseExtensions(test.responsesMap) + assert.Equal(t, test.expected, result) + }) + } +} diff --git a/tools/cli/internal/openapi/versions.go b/tools/cli/internal/openapi/versions.go index a2b1b7293..e8b45c97d 100644 --- a/tools/cli/internal/openapi/versions.go +++ b/tools/cli/internal/openapi/versions.go @@ -11,6 +11,7 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. + package openapi import (