Skip to content
45 changes: 40 additions & 5 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,31 @@ func WithDate(date time.Time) Option {
return func(v *APIVersion) error {
v.version = date.Format(dateFormat)
v.versionDate = date
v.stabilityVersion = StableStabilityLevel
return nil
}
}

// WithStabilityLevel sets the version and stability level on the APIVersion.
func WithStabilityLevel(version, stabilityLevel string) Option {
return func(v *APIVersion) error {
v.stabilityVersion = stabilityLevel
v.version = version
if stabilityLevel != StableStabilityLevel {
return nil
}

versionDate, err := DateFromVersion(version)
if err != nil {
return err
}

v.versionDate = versionDate
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 +103,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 @@ -125,7 +155,12 @@ func Parse(contentType string) (string, error) {
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]"
)
65 changes: 45 additions & 20 deletions tools/cli/internal/cli/versions/versions.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,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 @@ -28,11 +29,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 @@ -43,12 +45,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 @@ -57,7 +54,8 @@ func (o *Opts) Run() error {
return fmt.Errorf("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 @@ -70,7 +68,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 @@ -94,32 +111,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 @@ -130,7 +153,9 @@ 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
}
78 changes: 76 additions & 2 deletions tools/cli/internal/cli/versions/versions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import (
"github.com/stretchr/testify/assert"
)

func TestVersions(t *testing.T) {
func TestVersions_Run(t *testing.T) {
fs := afero.NewMemMapFs()
opts := &Opts{
basePath: "../../../test/data/base_spec.json",
Expand All @@ -43,7 +43,7 @@ func TestVersions(t *testing.T) {
assert.Contains(t, string(b), "2023-02-01")
}

func TestVersionWithEnv(t *testing.T) {
func TestVersion_RunWithEnv(t *testing.T) {
fs := afero.NewMemMapFs()
opts := &Opts{
basePath: "../../../test/data/base_spec.json",
Expand All @@ -65,3 +65,77 @@ func TestVersionWithEnv(t *testing.T) {
assert.NotEmpty(t, b)
assert.Contains(t, string(b), "2023-02-01")
}

func TestVersion_RunWithPreview(t *testing.T) {
fs := afero.NewMemMapFs()
opts := &Opts{
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)
}

b, err := afero.ReadFile(fs, opts.outputPath)
if err != nil {
t.Fatalf("ReadFile() unexpected error: %v", 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",
}

if err := opts.Run(); err != nil {
t.Fatalf("Run() unexpected error: %v", err)
}

b, err := afero.ReadFile(fs, opts.outputPath)
if err != nil {
t.Fatalf("ReadFile() unexpected error: %v", 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",
}

if err := opts.Run(); err != nil {
t.Fatalf("Run() unexpected error: %v", err)
}

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

// Check initial versions
assert.NotEmpty(t, b)
assert.Contains(t, string(b), "2023-02-01")
assert.NotContains(t, string(b), "preview")
}
4 changes: 2 additions & 2 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 @@ -44,6 +44,6 @@ func replaceVersion(input string, v *apiversion.APIVersion) string {
return input // No match found, return the original string
}

replacement := fmt.Sprintf("application/vnd.atlas.%s+%s", v.String(), matches[4])
replacement := fmt.Sprintf("application/vnd.atlas.%s+%s", v.String(), matches[5])
return apiversion.ContentPattern.ReplaceAllString(input, replacement)
}
14 changes: 10 additions & 4 deletions tools/cli/internal/openapi/versions.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,24 @@ import (
"github.com/mongodb/openapi/tools/cli/internal/openapi/filter"
)

// ExtractVersionsWithEnv extracts API version Content Type strings from the given OpenAPI specification and environment.
// When env is not set, the function returns the API Versions from all the environments.
func ExtractVersionsWithEnv(oas *openapi3.T, env string) ([]string, error) {
if env == "" {
return extractVersions(oas)
}

// We need to remove the version that are hidden for the given environment
doc, err := filter.ApplyFilters(oas, filter.NewMetadata(nil, env), filter.FiltersToGetVersions)
if err != nil {
return nil, nil
return nil, err
}

return ExtractVersions(doc)
return extractVersions(doc)
}

// ExtractVersions extracts version strings from an OpenAPI specification.
func ExtractVersions(oas *openapi3.T) ([]string, error) {
// extractVersions extracts version strings from an OpenAPI specification.
func extractVersions(oas *openapi3.T) ([]string, error) {
versions := make(map[string]struct{})
for _, pathItem := range oas.Paths.Map() {
if pathItem == nil {
Expand Down
Loading
Loading