diff --git a/.golangci.yml b/.golangci.yml index 3e58db1..6a7fd3a 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -33,9 +33,8 @@ linters-settings: # https://staticcheck.io/docs/options#checks checks: [ "all","-SA1019","-SA1029" ] gosec: - excludes: [ "G204", "G301", "G302", "G304", "G306", "G601", "G101", "G407" ] + excludes: [ "G204", "G301", "G302", "G304", "G306", "G601", "G101" ] exclude-generated: true - exclude-test-files: true config: global: nosec: true diff --git a/Makefile b/Makefile index 78d8e2f..9625ffc 100644 --- a/Makefile +++ b/Makefile @@ -49,7 +49,7 @@ prereq:: GOBIN=${TOOLS_DIR} $(GOCMD) install go.uber.org/mock/mockgen@v0.5.0 ${TOOLS_DIR}/mockgen --version -build:: +build:: clean generate-mock $(GOCMD) env GOOS GOARCH $(GOCMD) build -ldflags="${LINKERFLAGS}" -gcflags ${COMPILERFLAGS} -o ${BINARY_CLI}/application-cli-plugin main.go diff --git a/application/app/context.go b/application/app/context.go index 130e991..fa912f6 100644 --- a/application/app/context.go +++ b/application/app/context.go @@ -1,28 +1,36 @@ package app import ( + "github.com/jfrog/jfrog-cli-application/application/service/applications" "github.com/jfrog/jfrog-cli-application/application/service/systems" "github.com/jfrog/jfrog-cli-application/application/service/versions" ) type Context interface { + GetApplicationService() applications.ApplicationService GetVersionService() versions.VersionService GetSystemService() systems.SystemService GetConfig() interface{} } type context struct { - versionService versions.VersionService - systemService systems.SystemService + applicationService applications.ApplicationService + versionService versions.VersionService + systemService systems.SystemService } func NewAppContext() Context { return &context{ - versionService: versions.NewVersionService(), - systemService: systems.NewSystemService(), + applicationService: applications.NewApplicationService(), + versionService: versions.NewVersionService(), + systemService: systems.NewSystemService(), } } +func (c *context) GetApplicationService() applications.ApplicationService { + return c.applicationService +} + func (c *context) GetVersionService() versions.VersionService { return c.versionService } diff --git a/application/app/context_test.go b/application/app/context_test.go index 8e03a08..32ec69c 100644 --- a/application/app/context_test.go +++ b/application/app/context_test.go @@ -3,6 +3,7 @@ package app import ( "testing" + mockapplications "github.com/jfrog/jfrog-cli-application/application/service/applications/mocks" mocksystems "github.com/jfrog/jfrog-cli-application/application/service/systems/mocks" mockversions "github.com/jfrog/jfrog-cli-application/application/service/versions/mocks" @@ -12,10 +13,19 @@ import ( func TestNewAppContext(t *testing.T) { ctx := NewAppContext() assert.NotNil(t, ctx) + assert.NotNil(t, ctx.GetApplicationService()) assert.NotNil(t, ctx.GetVersionService()) assert.NotNil(t, ctx.GetSystemService()) } +func TestGetApplicationService(t *testing.T) { + mockApplicationService := &mockapplications.MockApplicationService{} + ctx := &context{ + applicationService: mockApplicationService, + } + assert.Equal(t, mockApplicationService, ctx.GetApplicationService()) +} + func TestGetVersionService(t *testing.T) { mockVersionService := &mockversions.MockVersionService{} ctx := &context{ diff --git a/application/cli/cli.go b/application/cli/cli.go index af5d41d..af131dc 100644 --- a/application/cli/cli.go +++ b/application/cli/cli.go @@ -2,6 +2,7 @@ package cli import ( "github.com/jfrog/jfrog-cli-application/application/app" + "github.com/jfrog/jfrog-cli-application/application/commands/application" "github.com/jfrog/jfrog-cli-application/application/commands/system" "github.com/jfrog/jfrog-cli-application/application/commands/version" "github.com/jfrog/jfrog-cli-core/v2/plugins/components" @@ -38,6 +39,7 @@ func GetJfrogApplicationCli() components.App { system.GetPingCommand(appContext), version.GetCreateAppVersionCommand(appContext), version.GetPromoteAppVersionCommand(appContext), + application.GetCreateAppCommand(appContext), }, ) return appEntity diff --git a/application/commands/application/create_app_cmd.go b/application/commands/application/create_app_cmd.go new file mode 100644 index 0000000..fddf8ea --- /dev/null +++ b/application/commands/application/create_app_cmd.go @@ -0,0 +1,134 @@ +package application + +import ( + pluginsCommon "github.com/jfrog/jfrog-cli-core/v2/plugins/common" + + "github.com/jfrog/jfrog-cli-application/application/commands/utils" + "github.com/jfrog/jfrog-cli-application/application/model" + "github.com/jfrog/jfrog-cli-application/application/service" + commonCLiCommands "github.com/jfrog/jfrog-cli-core/v2/common/commands" + "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + coreConfig "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-client-go/utils/errorutils" + + "github.com/jfrog/jfrog-cli-application/application/app" + "github.com/jfrog/jfrog-cli-application/application/commands" + "github.com/jfrog/jfrog-cli-application/application/common" + "github.com/jfrog/jfrog-cli-application/application/service/applications" +) + +type createAppCommand struct { + serverDetails *coreConfig.ServerDetails + applicationService applications.ApplicationService + requestBody *model.CreateAppRequest +} + +func (cac *createAppCommand) Run() error { + ctx, err := service.NewContext(*cac.serverDetails) + if err != nil { + return err + } + + return cac.applicationService.CreateApplication(ctx, cac.requestBody) +} + +func (cac *createAppCommand) ServerDetails() (*coreConfig.ServerDetails, error) { + return cac.serverDetails, nil +} + +func (cac *createAppCommand) CommandName() string { + return commands.CreateApp +} + +func (cac *createAppCommand) buildRequestPayload(ctx *components.Context) (*model.CreateAppRequest, error) { + applicationKey := ctx.Arguments[0] + applicationName := ctx.GetStringFlagValue(commands.ApplicationNameFlag) + if applicationName == "" { + // Default to the application key if application name is not provided + applicationName = applicationKey + } + + project := ctx.GetStringFlagValue(commands.ProjectFlag) + if project == "" { + return nil, errorutils.CheckErrorf("--%s is mandatory", commands.ProjectFlag) + } + + businessCriticalityStr := ctx.GetStringFlagValue(commands.BusinessCriticalityFlag) + businessCriticality, err := utils.ValidateEnumFlag( + commands.BusinessCriticalityFlag, + businessCriticalityStr, + model.BusinessCriticalityUnspecified, + model.BusinessCriticalityValues) + if err != nil { + return nil, err + } + + maturityLevelStr := ctx.GetStringFlagValue(commands.MaturityLevelFlag) + maturityLevel, err := utils.ValidateEnumFlag( + commands.MaturityLevelFlag, + maturityLevelStr, + model.MaturityLevelUnspecified, + model.MaturityLevelValues) + if err != nil { + return nil, err + } + + description := ctx.GetStringFlagValue(commands.DescriptionFlag) + userOwners := utils.ParseSliceFlag(ctx.GetStringFlagValue(commands.UserOwnersFlag)) + groupOwners := utils.ParseSliceFlag(ctx.GetStringFlagValue(commands.GroupOwnersFlag)) + labelsMap, err := utils.ParseMapFlag(ctx.GetStringFlagValue(commands.LabelsFlag)) + if err != nil { + return nil, err + } + + return &model.CreateAppRequest{ + ApplicationName: applicationName, + ApplicationKey: applicationKey, + Description: description, + ProjectKey: project, + MaturityLevel: maturityLevel, + BusinessCriticality: businessCriticality, + Labels: labelsMap, + UserOwners: userOwners, + GroupOwners: groupOwners, + }, nil +} + +func (cac *createAppCommand) prepareAndRunCommand(ctx *components.Context) error { + if len(ctx.Arguments) != 1 { + return pluginsCommon.WrongNumberOfArgumentsHandler(ctx) + } + + var err error + cac.requestBody, err = cac.buildRequestPayload(ctx) + if err != nil { + return err + } + + cac.serverDetails, err = utils.ServerDetailsByFlags(ctx) + if err != nil { + return err + } + + return commonCLiCommands.Exec(cac) +} + +func GetCreateAppCommand(appContext app.Context) components.Command { + cmd := &createAppCommand{ + applicationService: appContext.GetApplicationService(), + } + return components.Command{ + Name: "create", + Description: "Create a new application", + Category: common.CategoryApplication, + Arguments: []components.Argument{ + { + Name: "application-key", + Description: "The key of the application to create", + Optional: false, + }, + }, + Flags: commands.GetCommandFlags(commands.CreateApp), + Action: cmd.prepareAndRunCommand, + } +} diff --git a/application/commands/application/create_app_cmd_test.go b/application/commands/application/create_app_cmd_test.go new file mode 100644 index 0000000..9855df2 --- /dev/null +++ b/application/commands/application/create_app_cmd_test.go @@ -0,0 +1,86 @@ +package application + +import ( + "errors" + "flag" + "testing" + + "github.com/urfave/cli" + + "github.com/jfrog/jfrog-cli-application/application/model" + mockapps "github.com/jfrog/jfrog-cli-application/application/service/applications/mocks" + "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +func TestCreateAppCommand_Run(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + serverDetails := &config.ServerDetails{Url: "https://example.com"} + requestPayload := &model.CreateAppRequest{ + ApplicationKey: "app-key", + ApplicationName: "app-name", + ProjectKey: "proj-key", + } + + mockAppService := mockapps.NewMockApplicationService(ctrl) + mockAppService.EXPECT().CreateApplication(gomock.Any(), requestPayload).Return(nil).Times(1) + + cmd := &createAppCommand{ + applicationService: mockAppService, + serverDetails: serverDetails, + requestBody: requestPayload, + } + + err := cmd.Run() + assert.NoError(t, err) +} + +func TestCreateAppCommand_Error(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + serverDetails := &config.ServerDetails{Url: "https://example.com"} + requestPayload := &model.CreateAppRequest{ + ApplicationKey: "app-key", + ApplicationName: "app-name", + ProjectKey: "proj-key", + } + + mockAppService := mockapps.NewMockApplicationService(ctrl) + mockAppService.EXPECT().CreateApplication(gomock.Any(), requestPayload).Return(errors.New("failed to create an application. Status code: 500")).Times(1) + + cmd := &createAppCommand{ + applicationService: mockAppService, + serverDetails: serverDetails, + requestBody: requestPayload, + } + + err := cmd.Run() + assert.Error(t, err) + assert.Equal(t, "failed to create an application. Status code: 500", err.Error()) +} + +func TestCreateAppCommand_WrongNumberOfArguments(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + app := cli.NewApp() + set := flag.NewFlagSet("test", 0) + ctx := cli.NewContext(app, set, nil) + + mockAppService := mockapps.NewMockApplicationService(ctrl) + cmd := &createAppCommand{ + applicationService: mockAppService, + } + + // Test with no arguments + context, err := components.ConvertContext(ctx) + assert.NoError(t, err) + + err = cmd.prepareAndRunCommand(context) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Wrong number of arguments") +} diff --git a/application/commands/flags.go b/application/commands/flags.go index 36573d6..c91048d 100644 --- a/application/commands/flags.go +++ b/application/commands/flags.go @@ -1,14 +1,17 @@ package commands import ( + "github.com/jfrog/jfrog-cli-application/application/model" pluginsCommon "github.com/jfrog/jfrog-cli-core/v2/plugins/common" "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" ) const ( Ping = "ping" CreateAppVersion = "create-app-version" PromoteAppVersion = "promote-app-version" + CreateApp = "create-app" ) const ( @@ -18,14 +21,22 @@ const ( accessToken = "access-token" ProjectFlag = "project" - ApplicationKeyFlag = "app-key" - PackageTypeFlag = "package-type" - PackageNameFlag = "package-name" - PackageVersionFlag = "package-version" - PackageRepositoryFlag = "package-repository" - SpecFlag = "spec" - SpecVarsFlag = "spec-vars" - StageVarsFlag = "stage" + ApplicationKeyFlag = "application-key" + PackageTypeFlag = "package-type" + PackageNameFlag = "package-name" + PackageVersionFlag = "package-version" + PackageRepositoryFlag = "package-repository" + SpecFlag = "spec" + SpecVarsFlag = "spec-vars" + StageVarsFlag = "stage" + ApplicationNameFlag = "application-name" + DescriptionFlag = "desc" + BusinessCriticalityFlag = "business-criticality" + MaturityLevelFlag = "maturity-level" + LabelsFlag = "labels" + UserOwnersFlag = "user-owners" + GroupOwnersFlag = "group-owners" + SigningKeyFlag = "signing-key" ) // Flag keys mapped to their corresponding components.Flag definition. @@ -35,16 +46,24 @@ var flagsMap = map[string]components.Flag{ url: components.NewStringFlag(url, "JFrog Platform URL.", func(f *components.StringFlag) { f.Mandatory = false }), user: components.NewStringFlag(user, "JFrog username.", func(f *components.StringFlag) { f.Mandatory = false }), accessToken: components.NewStringFlag(accessToken, "JFrog access token.", func(f *components.StringFlag) { f.Mandatory = false }), - ProjectFlag: components.NewStringFlag(ProjectFlag, "Project key associated with the created evidence.", func(f *components.StringFlag) { f.Mandatory = false }), + ProjectFlag: components.NewStringFlag(ProjectFlag, "Project key associated with the application.", func(f *components.StringFlag) { f.Mandatory = true }), - ApplicationKeyFlag: components.NewStringFlag(ApplicationKeyFlag, "Application key.", func(f *components.StringFlag) { f.Mandatory = false }), - PackageTypeFlag: components.NewStringFlag(PackageTypeFlag, "Package type.", func(f *components.StringFlag) { f.Mandatory = false }), - PackageNameFlag: components.NewStringFlag(PackageNameFlag, "Package name.", func(f *components.StringFlag) { f.Mandatory = false }), - PackageVersionFlag: components.NewStringFlag(PackageVersionFlag, "Package version.", func(f *components.StringFlag) { f.Mandatory = false }), - PackageRepositoryFlag: components.NewStringFlag(PackageRepositoryFlag, "Package storing repository.", func(f *components.StringFlag) { f.Mandatory = false }), - SpecFlag: components.NewStringFlag(SpecFlag, "A path to the specification file.", func(f *components.StringFlag) { f.Mandatory = false }), - SpecVarsFlag: components.NewStringFlag(SpecVarsFlag, "List of semicolon-separated(;) variables in the form of \"key1=value1;key2=value2;...\" (wrapped by quotes) to be replaced in the File Spec. In the File Spec, the variables should be used as follows: ${key1}.` `", func(f *components.StringFlag) { f.Mandatory = false }), - StageVarsFlag: components.NewStringFlag(StageVarsFlag, "Promotion stage.", func(f *components.StringFlag) { f.Mandatory = true }), + ApplicationKeyFlag: components.NewStringFlag(ApplicationKeyFlag, "Application key.", func(f *components.StringFlag) { f.Mandatory = false }), + PackageTypeFlag: components.NewStringFlag(PackageTypeFlag, "Package type.", func(f *components.StringFlag) { f.Mandatory = false }), + PackageNameFlag: components.NewStringFlag(PackageNameFlag, "Package name.", func(f *components.StringFlag) { f.Mandatory = false }), + PackageVersionFlag: components.NewStringFlag(PackageVersionFlag, "Package version.", func(f *components.StringFlag) { f.Mandatory = false }), + PackageRepositoryFlag: components.NewStringFlag(PackageRepositoryFlag, "Package storing repository.", func(f *components.StringFlag) { f.Mandatory = false }), + SpecFlag: components.NewStringFlag(SpecFlag, "A path to the specification file.", func(f *components.StringFlag) { f.Mandatory = false }), + SpecVarsFlag: components.NewStringFlag(SpecVarsFlag, "List of semicolon-separated (;) variables in the form of \"key1=value1;key2=value2;...\" (wrapped by quotes) to be replaced in the File Spec. In the File Spec, the variables should be used as follows: ${key1}.", func(f *components.StringFlag) { f.Mandatory = false }), + StageVarsFlag: components.NewStringFlag(StageVarsFlag, "Promotion stage.", func(f *components.StringFlag) { f.Mandatory = true }), + ApplicationNameFlag: components.NewStringFlag(ApplicationNameFlag, "The display name of the application.", func(f *components.StringFlag) { f.Mandatory = false }), + DescriptionFlag: components.NewStringFlag(DescriptionFlag, "The description of the application.", func(f *components.StringFlag) { f.Mandatory = false }), + BusinessCriticalityFlag: components.NewStringFlag(BusinessCriticalityFlag, "The business criticality level. The following values are supported: "+coreutils.ListToText(model.BusinessCriticalityValues), func(f *components.StringFlag) { f.DefaultValue = model.BusinessCriticalityValues[0] }), + MaturityLevelFlag: components.NewStringFlag(MaturityLevelFlag, "The maturity level.", func(f *components.StringFlag) { f.DefaultValue = model.MaturityLevelValues[0] }), + LabelsFlag: components.NewStringFlag(LabelsFlag, "List of semicolon-separated (;) labels in the form of \"key1=value1;key2=value2;...\" (wrapped by quotes).", func(f *components.StringFlag) { f.Mandatory = false }), + UserOwnersFlag: components.NewStringFlag(UserOwnersFlag, "Comma-separated list of user owners.", func(f *components.StringFlag) { f.Mandatory = false }), + GroupOwnersFlag: components.NewStringFlag(GroupOwnersFlag, "Comma-separated list of group owners.", func(f *components.StringFlag) { f.Mandatory = false }), + SigningKeyFlag: components.NewStringFlag(SigningKeyFlag, "The GPG/RSA key-pair name given in Artifactory.", func(f *components.StringFlag) { f.Mandatory = false }), } var commandFlags = map[string][]string{ @@ -53,7 +72,6 @@ var commandFlags = map[string][]string{ user, accessToken, ServerId, - ProjectFlag, ApplicationKeyFlag, PackageTypeFlag, PackageNameFlag, @@ -67,7 +85,6 @@ var commandFlags = map[string][]string{ user, accessToken, ServerId, - ProjectFlag, ApplicationKeyFlag, StageVarsFlag, }, @@ -78,6 +95,24 @@ var commandFlags = map[string][]string{ accessToken, ServerId, }, + + CreateApp: { + url, + user, + accessToken, + ServerId, + ApplicationNameFlag, + ProjectFlag, + DescriptionFlag, + BusinessCriticalityFlag, + MaturityLevelFlag, + LabelsFlag, + UserOwnersFlag, + GroupOwnersFlag, + SigningKeyFlag, + SpecFlag, + SpecVarsFlag, + }, } func GetCommandFlags(cmdKey string) []components.Flag { diff --git a/application/commands/utils/utils.go b/application/commands/utils/utils.go index 2e00eba..2d6d0f8 100644 --- a/application/commands/utils/utils.go +++ b/application/commands/utils/utils.go @@ -2,11 +2,14 @@ package utils import ( "fmt" + "slices" + "strings" commonCliUtils "github.com/jfrog/jfrog-cli-core/v2/common/cliutils" pluginsCommon "github.com/jfrog/jfrog-cli-core/v2/plugins/common" "github.com/jfrog/jfrog-cli-core/v2/plugins/components" coreConfig "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" "github.com/jfrog/jfrog-client-go/utils/errorutils" ) @@ -31,3 +34,50 @@ func ServerDetailsByFlags(ctx *components.Context) (*coreConfig.ServerDetails, e return serverDetails, nil } + +// ParseSliceFlag parses a comma-separated string into a slice of strings. +func ParseSliceFlag(flagValue string) []string { + if flagValue == "" { + return nil + } + values := strings.Split(flagValue, ";") + + for i, v := range values { + values[i] = strings.TrimSpace(v) + } + return values +} + +// ParseMapFlag parses a semicolon-separated string of key=value pairs into a map[string]string. +// Returns an error if any pair does not contain exactly one '='. +func ParseMapFlag(flagValue string) (map[string]string, error) { + if flagValue == "" { + return nil, nil + } + result := make(map[string]string) + pairs := strings.Split(flagValue, ";") + for _, pair := range pairs { + keyValue := strings.SplitN(pair, "=", 2) + if len(keyValue) != 2 { + return nil, errorutils.CheckErrorf("invalid key-value pair: '%s' (expected format key=value)", pair) + } + result[strings.TrimSpace(keyValue[0])] = strings.TrimSpace(keyValue[1]) + } + return result, nil +} + +// ValidateEnumFlag validates that a flag value is in the list of allowed values. +// If the value is empty, returns the default value. +// Otherwise, returns an error if the value is not in the allowed values. +func ValidateEnumFlag(flagName, value string, defaultValue string, allowedValues []string) (string, error) { + if value == "" { + return defaultValue, nil + } + + if slices.Contains(allowedValues, value) { + return value, nil + } + + return "", errorutils.CheckErrorf("invalid value for --%s: '%s'. Allowed values: %s", + flagName, value, coreutils.ListToText(allowedValues)) +} diff --git a/application/commands/utils/utils_test.go b/application/commands/utils/utils_test.go new file mode 100644 index 0000000..310bd2c --- /dev/null +++ b/application/commands/utils/utils_test.go @@ -0,0 +1,116 @@ +package utils + +import ( + "reflect" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseSliceFlag(t *testing.T) { + tests := []struct { + name string + input string + expected []string + }{ + {"empty string", "", nil}, + {"single value", "foo", []string{"foo"}}, + {"multiple values", "foo;bar;baz", []string{"foo", "bar", "baz"}}, + {"values with spaces", " foo ; bar ;baz ", []string{"foo", "bar", "baz"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ParseSliceFlag(tt.input) + assert.Equal(t, tt.expected, result, "ParseSliceFlag(%q) = %v, want %v", tt.input, result, tt.expected) + }) + } +} + +func TestParseMapFlag(t *testing.T) { + tests := []struct { + name string + input string + expected map[string]string + expectErr bool + }{ + {"empty string", "", nil, false}, + {"single pair", "foo=bar", map[string]string{"foo": "bar"}, false}, + {"multiple pairs", "foo=bar;baz=qux", map[string]string{"foo": "bar", "baz": "qux"}, false}, + {"pairs with spaces", " foo = bar ; baz = qux ", map[string]string{"foo": "bar", "baz": "qux"}, false}, + {"missing value", "foo=;bar=baz", map[string]string{"foo": "", "bar": "baz"}, false}, + {"missing key", "=bar", map[string]string{"": "bar"}, false}, + {"no equal sign", "foo;bar=baz", nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ParseMapFlag(tt.input) + if tt.expectErr { + assert.Error(t, err, "ParseMapFlag(%q) expected error, got nil", tt.input) + return + } + assert.NoError(t, err, "ParseMapFlag(%q) unexpected error: %v", tt.input, err) + assert.True(t, reflect.DeepEqual(result, tt.expected), "ParseMapFlag(%q) = %v, want %v", tt.input, result, tt.expected) + }) + } +} + +func TestValidateEnumFlag(t *testing.T) { + tests := []struct { + name string + flagName string + value string + validValues []string + defaultValue string + expectError bool + expectedValue string + }{ + { + name: "valid value", + flagName: "test-flag", + value: "foo", + validValues: []string{"foo", "bar", "baz"}, + defaultValue: "", + expectError: false, + expectedValue: "foo", + }, + { + name: "invalid value with default", + flagName: "test-flag", + value: "invalid", + validValues: []string{"foo", "bar", "baz"}, + defaultValue: "bar", + expectError: true, + expectedValue: "", + }, + { + name: "invalid value without default", + flagName: "test-flag", + value: "invalid", + validValues: []string{"foo", "bar", "baz"}, + defaultValue: "", + expectError: true, + expectedValue: "", + }, + { + name: "empty value with default", + flagName: "test-flag", + value: "", + validValues: []string{"foo", "bar", "baz"}, + defaultValue: "baz", + expectError: false, + expectedValue: "baz", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ValidateEnumFlag(tt.flagName, tt.value, tt.defaultValue, tt.validValues) + if tt.expectError { + assert.Error(t, err, "ValidateEnumFlag(%q) expected error, got nil", tt.value) + return + } + assert.NoError(t, err, "ValidateEnumFlag(%q) unexpected error: %v", tt.value, err) + assert.Equal(t, tt.expectedValue, result, "ValidateEnumFlag(%q) = %v, want %v", tt.value, result, tt.expectedValue) + }) + } +} diff --git a/application/common/categories.go b/application/common/categories.go index c06cb4f..9a30756 100644 --- a/application/common/categories.go +++ b/application/common/categories.go @@ -1,6 +1,7 @@ package common const ( - CategorySystem = "system" - CategoryVersion = "version" + CategorySystem = "system" + CategoryVersion = "version" + CategoryApplication = "application" ) diff --git a/application/http/http_client.go b/application/http/http_client.go index 8281ff4..ff25cca 100644 --- a/application/http/http_client.go +++ b/application/http/http_client.go @@ -17,7 +17,7 @@ import ( "github.com/jfrog/jfrog-client-go/utils/io/httputils" ) -const applicationApiPath = "application/api" +const appTrustApiPath = "apptrust/api" type AppHttpClient interface { GetHttpClient() *jfroghttpclient.JfrogHttpClient @@ -83,7 +83,7 @@ func (c *appHttpClient) GetHttpClient() *jfroghttpclient.JfrogHttpClient { } func (c *appHttpClient) Post(path string, requestBody interface{}) (resp *http.Response, body []byte, err error) { - url, err := utils.BuildUrl(c.serverDetails.Url, applicationApiPath+path, nil) + url, err := utils.BuildUrl(c.serverDetails.Url, appTrustApiPath+path, nil) if err != nil { return nil, nil, err } @@ -98,7 +98,7 @@ func (c *appHttpClient) Post(path string, requestBody interface{}) (resp *http.R } func (c *appHttpClient) Get(path string) (resp *http.Response, body []byte, err error) { - url, err := utils.BuildUrl(c.serverDetails.Url, applicationApiPath+path, nil) + url, err := utils.BuildUrl(c.serverDetails.Url, appTrustApiPath+path, nil) if err != nil { return nil, nil, err } diff --git a/application/model/create_app_request.go b/application/model/create_app_request.go new file mode 100644 index 0000000..e7e143d --- /dev/null +++ b/application/model/create_app_request.go @@ -0,0 +1,43 @@ +package model + +const ( + BusinessCriticalityUnspecified = "unspecified" + BusinessCriticalityLow = "low" + BusinessCriticalityMedium = "medium" + BusinessCriticalityHigh = "high" + BusinessCriticalityCritical = "critical" + + MaturityLevelUnspecified = "unspecified" + MaturityLevelExperimental = "experimental" + MaturityLevelProduction = "production" + MaturityLevelEndOfLife = "end_of_life" +) + +var ( + BusinessCriticalityValues = []string{ + BusinessCriticalityUnspecified, + BusinessCriticalityLow, + BusinessCriticalityMedium, + BusinessCriticalityHigh, + BusinessCriticalityCritical, + } + + MaturityLevelValues = []string{ + MaturityLevelUnspecified, + MaturityLevelExperimental, + MaturityLevelProduction, + MaturityLevelEndOfLife, + } +) + +type CreateAppRequest struct { + ApplicationName string `json:"application_name"` + ApplicationKey string `json:"application_key"` + ProjectKey string `json:"project_key"` + Description string `json:"description,omitempty"` + MaturityLevel string `json:"maturity_level,omitempty"` + BusinessCriticality string `json:"criticality,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + UserOwners []string `json:"user_owners,omitempty"` + GroupOwners []string `json:"group_owners,omitempty"` +} diff --git a/application/service/applications/application_service.go b/application/service/applications/application_service.go new file mode 100644 index 0000000..ab9e952 --- /dev/null +++ b/application/service/applications/application_service.go @@ -0,0 +1,38 @@ +package applications + +//go:generate ${PROJECT_DIR}/scripts/mockgen.sh ${GOFILE} + +import ( + "fmt" + "net/http" + + "github.com/jfrog/jfrog-client-go/utils/errorutils" + + "github.com/jfrog/jfrog-cli-application/application/model" + "github.com/jfrog/jfrog-cli-application/application/service" +) + +type ApplicationService interface { + CreateApplication(ctx service.Context, requestBody *model.CreateAppRequest) error +} + +type applicationService struct{} + +func NewApplicationService() ApplicationService { + return &applicationService{} +} + +func (as *applicationService) CreateApplication(ctx service.Context, requestBody *model.CreateAppRequest) error { + response, responseBody, err := ctx.GetHttpClient().Post("/v1/applications", requestBody) + if err != nil { + return err + } + + if response.StatusCode != http.StatusCreated { + return errorutils.CheckErrorf("failed to create an application. Status code: %d.\n%s", + response.StatusCode, responseBody) + } + + fmt.Println(string(responseBody)) + return nil +} diff --git a/application/service/applications/application_service_test.go b/application/service/applications/application_service_test.go new file mode 100644 index 0000000..36db89a --- /dev/null +++ b/application/service/applications/application_service_test.go @@ -0,0 +1,67 @@ +package applications + +import ( + "errors" + "net/http" + "testing" + + mockhttp "github.com/jfrog/jfrog-cli-application/application/http/mocks" + "github.com/jfrog/jfrog-cli-application/application/model" + mockservice "github.com/jfrog/jfrog-cli-application/application/service/mocks" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +func TestApplicationService_CreateApplication(t *testing.T) { + tests := []struct { + name string + mockResponse *http.Response + mockBody []byte + mockError error + expectedError string + }{ + { + name: "CreateApplication successful", + mockResponse: &http.Response{StatusCode: http.StatusCreated}, + mockBody: []byte(`{"application_key":"app-123"}`), + mockError: nil, + expectedError: "", + }, + { + name: "CreateApplication failed with non-201 status code", + mockResponse: &http.Response{StatusCode: http.StatusBadRequest}, + mockBody: []byte(""), + mockError: nil, + expectedError: "failed to create an application. Status code: 400.\n", + }, + { + name: "CreateApplication failed with error", + mockResponse: nil, + mockBody: nil, + mockError: errors.New("http error"), + expectedError: "http error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockHttpClient := mockhttp.NewMockAppHttpClient(ctrl) + mockHttpClient.EXPECT().Post("/v1/applications", gomock.Any()).Return(tt.mockResponse, tt.mockBody, tt.mockError) + + mockCtx := mockservice.NewMockContext(ctrl) + mockCtx.EXPECT().GetHttpClient().Return(mockHttpClient).Times(1) + + as := NewApplicationService() + err := as.CreateApplication(mockCtx, &model.CreateAppRequest{ApplicationKey: "app-123"}) + + if tt.expectedError != "" { + assert.EqualError(t, err, tt.expectedError) + } else { + assert.NoError(t, err) + } + }) + } +}