Skip to content
36 changes: 30 additions & 6 deletions tools/cli/internal/apiversion/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,18 @@ import (
)

type APIVersion struct {
version string
versionDate time.Time
version string
stabilityVersion string
versionDate time.Time
}

const (
dateFormat = "2006-01-02"
dateFormat = "2006-01-02"
StableStabilityLevel = "STABLE"
PreviewStabilityLevel = "PREVIEW"
)

var ContentPattern = regexp.MustCompile(`application/vnd\.atlas\.(\d{4})-(\d{2})-(\d{2})\+(.+)`)
var contentPattern = regexp.MustCompile(`application/vnd\.atlas\.((\d{4})-(\d{2})-(\d{2})|preview)\+(.+)`)

// Option is a function that sets a value on the APIVersion.
type Option func(v *APIVersion) error
Expand Down Expand Up @@ -67,10 +70,12 @@ func WithDate(date time.Time) Option {
return func(v *APIVersion) error {
v.version = date.Format(dateFormat)
v.versionDate = date
v.stabilityVersion = StableStabilityLevel
return nil
}
}

// WithContent returns an Option to generate a new APIVersion given the contentType.
func WithContent(contentType string) Option {
return func(v *APIVersion) error {
version, err := Parse(contentType)
Expand All @@ -79,6 +84,12 @@ func WithContent(contentType string) Option {
}

v.version = version
v.stabilityVersion = StableStabilityLevel
if version == PreviewStabilityLevel {
v.stabilityVersion = PreviewStabilityLevel
return nil
}

v.versionDate, err = DateFromVersion(version)
if err != nil {
return err
Expand Down Expand Up @@ -119,13 +130,26 @@ func (v *APIVersion) Date() time.Time {
return v.versionDate
}

func FindMatchesFromContentType(contentType string) []string {
return contentPattern.FindStringSubmatch(contentType)
}

func ReplaceContentType(contentType, replacement string) string {
return contentPattern.ReplaceAllString(contentType, replacement)
}

// Parse extracts the version date from the content type.
func Parse(contentType string) (string, error) {
matches := ContentPattern.FindStringSubmatch(contentType)
matches := contentPattern.FindStringSubmatch(contentType)
if matches == nil {
return "", fmt.Errorf("invalid content type: %s", contentType)
}
return fmt.Sprintf("%s-%s-%s", matches[1], matches[2], matches[3]), nil

if len(matches) == 3 {
return fmt.Sprintf("%s-%s-%s", matches[2], matches[3], matches[4]), nil
}

return matches[1], nil
}

// FindLatestContentVersionMatched finds the latest content version that matches the requested version.
Expand Down
2 changes: 2 additions & 0 deletions tools/cli/internal/cli/flag/flag.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,6 @@ const (
ChannelIDShort = "c"
From = "from"
To = "to"
StabilityLevel = "stability-level"
StabilityLevelShort = "l"
)
1 change: 1 addition & 0 deletions tools/cli/internal/cli/usage/usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,5 @@ const (
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"
StabilityLevel = "Stability level related to the API Version. Valid values: [STABLE, PREVIEW]"
)
64 changes: 44 additions & 20 deletions tools/cli/internal/cli/versions/versions.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"fmt"
"strings"

"github.com/mongodb/openapi/tools/cli/internal/apiversion"
"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"
Expand All @@ -29,11 +30,12 @@ import (
)

type Opts struct {
fs afero.Fs
basePath string
outputPath string
format string
env string
fs afero.Fs
basePath string
outputPath string
format string
env string
stabilityLevel string
}

func (o *Opts) Run() error {
Expand All @@ -44,12 +46,7 @@ func (o *Opts) Run() error {
}

var versions []string
if o.env == "" {
versions, err = openapi.ExtractVersions(specInfo.Spec)
} else {
versions, err = openapi.ExtractVersionsWithEnv(specInfo.Spec, o.env)
}

versions, err = openapi.ExtractVersionsWithEnv(specInfo.Spec, o.env)
if err != nil {
return err
}
Expand All @@ -58,7 +55,8 @@ func (o *Opts) Run() error {
return errors.New("no versions found in the OpenAPI specification")
}

bytes, err := o.getVersionBytes(versions)
versions = o.filterStabilityLevelVersions(versions)
bytes, err := o.versionsAsBytes(versions)
if err != nil {
return err
}
Expand All @@ -71,7 +69,26 @@ func (o *Opts) Run() error {
return nil
}

func (o *Opts) getVersionBytes(versions []string) ([]byte, error) {
func (o *Opts) filterStabilityLevelVersions(apiVersions []string) []string {
if o.stabilityLevel == "" || apiVersions == nil {
return apiVersions
}

var out []string
for _, v := range apiVersions {
if o.stabilityLevel == apiversion.PreviewStabilityLevel && strings.Contains(v, "preview") {
out = append(out, v)
}

if o.stabilityLevel == apiversion.StableStabilityLevel && !strings.Contains(v, "preview") {
out = append(out, v)
}
}

return out
}

func (o *Opts) versionsAsBytes(versions []string) ([]byte, error) {
data, err := json.MarshalIndent(versions, "", " ")
if err != nil {
return nil, err
Expand All @@ -95,32 +112,38 @@ func (o *Opts) getVersionBytes(versions []string) ([]byte, error) {
}

func (o *Opts) PreRunE(_ []string) error {
o.stabilityLevel = strings.ToUpper(o.stabilityLevel)
if o.stabilityLevel != "" && o.stabilityLevel != apiversion.PreviewStabilityLevel && o.stabilityLevel != apiversion.StableStabilityLevel {
return fmt.Errorf("stability level must be %q or %q, got %q", apiversion.PreviewStabilityLevel, apiversion.StableStabilityLevel, o.stabilityLevel)
}

if o.basePath == "" {
return fmt.Errorf("no OAS detected. Please, use the flag %s to include the base OAS", flag.Base)
return fmt.Errorf("no OAS detected. Please, use the flag %q to include the base OAS", flag.Base)
}

if o.outputPath != "" && !strings.Contains(o.outputPath, ".json") && !strings.Contains(o.outputPath, ".yaml") {
return fmt.Errorf("output file must be either a JSON or YAML file, got %s", o.outputPath)
return fmt.Errorf("output file must be either a JSON or YAML file, got %q", o.outputPath)
}

if o.format != "json" && o.format != "yaml" {
return fmt.Errorf("output format must be either 'json' or 'yaml', got %s", o.format)
return fmt.Errorf("output format must be either 'json' or 'yaml', got %q", o.format)
}

return nil
}

// Builder builds the versions command with the following signature:
// versions -s oas.
// versions -s oas --env dev|qa|staging|prod -stability-level STABLE|PREVIEW.
func Builder() *cobra.Command {
opts := &Opts{
fs: afero.NewOsFs(),
}

cmd := &cobra.Command{
Use: "versions -s spec ",
Short: "Get a list of versions from an OpenAPI specification.",
Args: cobra.NoArgs,
Use: "versions -s spec ",
Aliases: []string{"versions list", "versions ls"},
Short: "Get a list of versions from an OpenAPI specification.",
Args: cobra.NoArgs,
PreRunE: func(_ *cobra.Command, args []string) error {
return opts.PreRunE(args)
},
Expand All @@ -131,6 +154,7 @@ func Builder() *cobra.Command {

cmd.Flags().StringVarP(&opts.basePath, flag.Spec, flag.SpecShort, "", usage.Spec)
cmd.Flags().StringVar(&opts.env, flag.Environment, "", usage.Environment)
cmd.Flags().StringVarP(&opts.stabilityLevel, flag.StabilityLevel, flag.StabilityLevelShort, "", usage.StabilityLevel)
cmd.Flags().StringVarP(&opts.outputPath, flag.Output, flag.OutputShort, "", usage.Output)
cmd.Flags().StringVarP(&opts.format, flag.Format, flag.FormatShort, "json", usage.Format)
return cmd
Expand Down
73 changes: 61 additions & 12 deletions tools/cli/internal/cli/versions/versions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,49 +19,98 @@ import (

"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestVersions(t *testing.T) {
func TestVersions_Run(t *testing.T) {
fs := afero.NewMemMapFs()
opts := &Opts{
basePath: "../../../test/data/base_spec.json",
outputPath: "foas.json",
fs: fs,
}

if err := opts.Run(); err != nil {
t.Fatalf("Run() unexpected error: %v", err)
require.NoError(t, opts.Run())
b, err := afero.ReadFile(fs, opts.outputPath)
require.NoError(t, err)
// Check initial versions
assert.NotEmpty(t, b)
assert.Contains(t, string(b), "2023-02-01")
}

func TestVersion_RunWithEnv(t *testing.T) {
fs := afero.NewMemMapFs()
opts := &Opts{
basePath: "../../../test/data/base_spec.json",
outputPath: "foas.json",
fs: fs,
env: "staging",
}

require.NoError(t, opts.Run())
b, err := afero.ReadFile(fs, opts.outputPath)
if err != nil {
t.Fatalf("ReadFile() unexpected error: %v", err)
}
require.NoError(t, err)

// Check initial versions
assert.NotEmpty(t, b)
assert.Contains(t, string(b), "2023-02-01")
}

func TestVersionWithEnv(t *testing.T) {
func TestVersion_RunWithPreview(t *testing.T) {
fs := afero.NewMemMapFs()
opts := &Opts{
basePath: "../../../test/data/base_spec.json",
basePath: "../../../test/data/base_spec_with_preview.json",
outputPath: "foas.json",
fs: fs,
env: "staging",
}

if err := opts.Run(); err != nil {
t.Fatalf("Run() unexpected error: %v", err)
require.NoError(t, opts.Run())
b, err := afero.ReadFile(fs, opts.outputPath)
require.NoError(t, err)

// Check initial versions
assert.NotEmpty(t, b)
assert.Contains(t, string(b), "2023-02-01")
assert.Contains(t, string(b), "preview")
}

func TestVersion_RunStabilityLevelPreview(t *testing.T) {
fs := afero.NewMemMapFs()
opts := &Opts{
basePath: "../../../test/data/base_spec_with_preview.json",
outputPath: "foas.json",
fs: fs,
env: "staging",
stabilityLevel: "PREVIEW",
}

require.NoError(t, opts.Run())
b, err := afero.ReadFile(fs, opts.outputPath)
if err != nil {
t.Fatalf("ReadFile() unexpected error: %v", err)
require.NoError(t, err)

// Check initial versions
assert.NotEmpty(t, b)
assert.NotContains(t, string(b), "2023-02-01")
assert.Contains(t, string(b), "preview")
}

func TestVersion_RunStabilityLevelStable(t *testing.T) {
fs := afero.NewMemMapFs()
opts := &Opts{
basePath: "../../../test/data/base_spec_with_preview.json",
outputPath: "foas.json",
fs: fs,
env: "staging",
stabilityLevel: "STABLE",
}

require.NoError(t, opts.Run())
b, err := afero.ReadFile(fs, opts.outputPath)
require.NoError(t, err)

// Check initial versions
assert.NotEmpty(t, b)
assert.Contains(t, string(b), "2023-02-01")
assert.NotContains(t, string(b), "preview")
}
8 changes: 4 additions & 4 deletions tools/cli/internal/openapi/filter/info.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import (
"github.com/mongodb/openapi/tools/cli/internal/apiversion"
)

// Filter: InfoFilter is a filter that modifies the Info object in the OpenAPI spec.
// InfoFilter is a filter that modifies the Info object in the OpenAPI spec.
type InfoFilter struct {
oas *openapi3.T
metadata *Metadata
Expand All @@ -39,11 +39,11 @@ func (f *InfoFilter) Apply() error {
}

func replaceVersion(input string, v *apiversion.APIVersion) string {
matches := apiversion.ContentPattern.FindStringSubmatch(input)
matches := apiversion.FindMatchesFromContentType(input)
if matches == nil {
return input // No match found, return the original string
}

replacement := fmt.Sprintf("application/vnd.atlas.%s+%s", v.String(), matches[4])
return apiversion.ContentPattern.ReplaceAllString(input, replacement)
replacement := fmt.Sprintf("application/vnd.atlas.%s+%s", v.String(), matches[5])
return apiversion.ReplaceContentType(input, replacement)
}
3 changes: 2 additions & 1 deletion tools/cli/internal/openapi/filter/tags.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 filter

import (
Expand All @@ -19,7 +20,7 @@ import (
"github.com/getkin/kin-openapi/openapi3"
)

// Filter: TagsFilter removes tags that are not used in the operations.
// TagsFilter removes tags that are not used in the operations.
type TagsFilter struct {
oas *openapi3.T
}
Expand Down
Loading
Loading