diff --git a/.golangci.yml b/.golangci.yml index 04ddf66..e74269f 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -30,7 +30,6 @@ linters: - goconst - gocritic - goimports - - gomnd - gosec - ifshort - misspell diff --git a/.octocov.yml b/.octocov.yml index 0f28e46..7ea8e0e 100644 --- a/.octocov.yml +++ b/.octocov.yml @@ -1,7 +1,7 @@ # generated by octocov init coverage: if: true - acceptable: 60% + # acceptable: 60% diff: datastores: - artifact://${GITHUB_REPOSITORY} diff --git a/Makefile b/Makefile index 051dfb1..40aa761 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ VERSION = $(shell git describe --tags --abbrev=0) GO = go GO_BUILD = $(GO) build GO_INSTALL = $(GO) install -GO_TEST = $(GO) test -v +GO_TEST = hottest -v GO_TOOL = $(GO) tool GO_DEP = $(GO) mod GOOS = "" @@ -29,6 +29,8 @@ changelog: ## Generate changelog tools: ## Install dependency tools $(GO_INSTALL) github.com/Songmu/ghch/cmd/ghch@latest + $(GO_INSTALL) github.com/nao1215/hottest@latest + $(GO_INSTALL) github.com/google/wire/cmd/wire@latest .DEFAULT_GOAL := help help: diff --git a/app/.gitkeep b/app/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/app/di/wire.go b/app/di/wire.go new file mode 100644 index 0000000..edec966 --- /dev/null +++ b/app/di/wire.go @@ -0,0 +1,39 @@ +//go:build wireinject +// +build wireinject + +// Package di Inject dependence by wire command. +package di + +import ( + "context" + + "github.com/google/wire" + "github.com/nao1215/rainbow/app/domain/model" + "github.com/nao1215/rainbow/app/external" + "github.com/nao1215/rainbow/app/interactor" + "github.com/nao1215/rainbow/app/usecase" +) + +// S3App is the application service for S3. +type S3App struct { + // S3BucketCreator is the usecase for creating a new S3 bucket. + S3BucketCreator usecase.S3BucketCreator +} + +// NewS3App creates a new S3App. +func NewS3App(ctx context.Context, profile model.AWSProfile, region model.Region) (*S3App, error) { + wire.Build( + model.NewAWSConfig, + external.NewS3Client, + external.S3BucketCreatorSet, + interactor.S3bucketCreatorSet, + newS3App, + ) + return nil, nil +} + +func newS3App(s3bucketCreator usecase.S3BucketCreator) *S3App { + return &S3App{ + S3BucketCreator: s3bucketCreator, + } +} diff --git a/app/di/wire_gen.go b/app/di/wire_gen.go new file mode 100644 index 0000000..a419816 --- /dev/null +++ b/app/di/wire_gen.go @@ -0,0 +1,47 @@ +// Code generated by Wire. DO NOT EDIT. + +//go:generate go run github.com/google/wire/cmd/wire +//go:build !wireinject +// +build !wireinject + +package di + +import ( + "context" + "github.com/nao1215/rainbow/app/domain/model" + "github.com/nao1215/rainbow/app/external" + "github.com/nao1215/rainbow/app/interactor" + "github.com/nao1215/rainbow/app/usecase" +) + +// Injectors from wire.go: + +// NewS3App creates a new S3App. +func NewS3App(ctx context.Context, profile model.AWSProfile, region model.Region) (*S3App, error) { + awsConfig, err := model.NewAWSConfig(ctx, profile, region) + if err != nil { + return nil, err + } + client, err := external.NewS3Client(awsConfig) + if err != nil { + return nil, err + } + s3BucketCreator := external.NewS3BucketCreator(client) + interactorS3BucketCreator := interactor.NewS3BucketCreator(s3BucketCreator) + s3App := newS3App(interactorS3BucketCreator) + return s3App, nil +} + +// wire.go: + +// S3App is the application service for S3. +type S3App struct { + // S3BucketCreator is the usecase for creating a new S3 bucket. + S3BucketCreator usecase.S3BucketCreator +} + +func newS3App(s3bucketCreator usecase.S3BucketCreator) *S3App { + return &S3App{ + S3BucketCreator: s3bucketCreator, + } +} diff --git a/app/domain/model/aws.go b/app/domain/model/aws.go new file mode 100644 index 0000000..c9e67e7 --- /dev/null +++ b/app/domain/model/aws.go @@ -0,0 +1,62 @@ +package model + +import ( + "context" + "os" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" +) + +// AWSProfile is the name of the AWS profile. +type AWSProfile string + +// NewAWSProfile returns a new AWSProfile. +// If p is empty, read $AWS_PROFILE and return it. +func NewAWSProfile(p string) AWSProfile { + if p == "" { + profile := os.Getenv("AWS_PROFILE") + if profile == "" { + return AWSProfile("default") + } + return AWSProfile(profile) + } + return AWSProfile(p) +} + +// String returns the string representation of the AWSProfile. +func (p AWSProfile) String() string { + return string(p) +} + +// AWSConfig is the AWS config. +type AWSConfig struct { + *aws.Config +} + +// NewAWSConfig creates a new AWS config. +func NewAWSConfig(ctx context.Context, profile AWSProfile, region Region) (*AWSConfig, error) { + opts := []func(*config.LoadOptions) error{} + if profile.String() != "" { + opts = append(opts, config.WithSharedConfigProfile(profile.String())) + } + if region.String() != "" { + opts = append(opts, config.WithRegion(region.String())) + } + + cfg, err := config.LoadDefaultConfig(ctx, opts...) + if err != nil { + return nil, err + } + return &AWSConfig{ + Config: &cfg, + }, nil +} + +// Region returns the AWS region. +func (c *AWSConfig) Region() Region { + if Region(c.Config.Region) == "" { + return RegionUSEast1 + } + return Region(c.Config.Region) +} diff --git a/app/domain/model/aws_test.go b/app/domain/model/aws_test.go new file mode 100644 index 0000000..12aec22 --- /dev/null +++ b/app/domain/model/aws_test.go @@ -0,0 +1,76 @@ +package model + +import ( + "testing" +) + +func TestNewAWSProfile(t *testing.T) { //nolint + type args struct { + p string + } + tests := []struct { + name string + args args + want AWSProfile + }{ + { + name: "success", + args: args{ + p: "test", + }, + want: AWSProfile("test"), + }, + { + name: "success. p is empty", + args: args{ + p: "", + }, + want: AWSProfile("from env"), + }, + { + name: "success. p is empty and $AWS_PROFILE is empty", + args: args{ + p: "", + }, + want: AWSProfile("default"), + }, + } + for _, tt := range tests { //nolint + if tt.name == "success. p is empty" { + t.Setenv("AWS_PROFILE", "from env") + } else if tt.name == "success. p is empty and $AWS_PROFILE is empty" { + t.Setenv("AWS_PROFILE", "") + } + + t.Run(tt.name, func(t *testing.T) { + if got := NewAWSProfile(tt.args.p); got != tt.want { + t.Errorf("NewAWSProfile() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestAWSProfileString(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + p AWSProfile + want string + }{ + { + name: "success", + p: AWSProfile("test"), + want: "test", + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := tt.p.String(); got != tt.want { + t.Errorf("AWSProfile.String() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/app/domain/model/errors.go b/app/domain/model/errors.go new file mode 100644 index 0000000..ec57afc --- /dev/null +++ b/app/domain/model/errors.go @@ -0,0 +1,12 @@ +package model + +import "errors" + +var ( + // ErrInvalidRegion is an error that occurs when the region is invalid. + ErrInvalidRegion = errors.New("invalid region") + // ErrEmptyRegion is an error that occurs when the region is empty. + ErrEmptyRegion = errors.New("region is empty") + // ErrInvalidBucketName is an error that occurs when the bucket name is invalid. + ErrInvalidBucketName = errors.New("bucket name is invalid") +) diff --git a/app/domain/model/interface.go b/app/domain/model/interface.go new file mode 100644 index 0000000..4b30f7c --- /dev/null +++ b/app/domain/model/interface.go @@ -0,0 +1,10 @@ +package model + +// Validator is an interface that represents a validator. +type Validator interface { + // Validate validates the value. + Validate() error +} + +// ValidationFunc is a type that represents a validation function. +type ValidationFunc func() error diff --git a/app/domain/model/s3.go b/app/domain/model/s3.go new file mode 100644 index 0000000..e48e493 --- /dev/null +++ b/app/domain/model/s3.go @@ -0,0 +1,221 @@ +// Package model contains the definitions of domain models and business logic. +package model + +import ( + "fmt" + "strings" + + "github.com/nao1215/rainbow/utils/errfmt" + "github.com/nao1215/rainbow/utils/xregex" +) + +// Region is the name of the AWS region. +type Region string + +const ( + // RegionUSEast1 US East (N. Virginia) + RegionUSEast1 Region = "us-east-1" + // RegionUSEast2 US East (Ohio) + RegionUSEast2 Region = "us-east-2" + // RegionUSWest1 US West (N. California) + RegionUSWest1 Region = "us-west-1" + // RegionUSWest2 US West (Oregon) + RegionUSWest2 Region = "us-west-2" + // RegionAFSouth1 Africa (Cape Town) + RegionAFSouth1 Region = "af-south-1" + // RegionAPEast1 Asia Pacific (Hong Kong) + RegionAPEast1 Region = "ap-east-1" + // RegionAPSouth1 Asia Pacific (Mumbai) + RegionAPSouth1 Region = "ap-south-1" + // RegionAPNortheast1 Asia Pacific (Tokyo) + RegionAPNortheast1 Region = "ap-northeast-1" + // RegionAPNortheast2 Asia Pacific (Seoul) + RegionAPNortheast2 Region = "ap-northeast-2" + // RegionAPNortheast3 Asia Pacific (Osaka-Local) + RegionAPNortheast3 Region = "ap-northeast-3" + // RegionAPSoutheast1 Asia Pacific (Singapore) + RegionAPSoutheast1 Region = "ap-southeast-1" + // RegionAPSoutheast2 Asia Pacific (Sydney) + RegionAPSoutheast2 Region = "ap-southeast-2" + // RegionCACentral1 Canada (Central) + RegionCACentral1 Region = "ca-central-1" + // RegionCNNorth1 China (Beijing) + RegionCNNorth1 Region = "cn-north-1" + // RegionCNNorthwest1 China (Ningxia) + RegionCNNorthwest1 Region = "cn-northwest-1" + // RegionEUCentral1 Europe (Frankfurt) + RegionEUCentral1 Region = "eu-central-1" + // RegionEUNorth1 Europe (Stockholm) + RegionEUNorth1 Region = "eu-north-1" + // RegionEUSouth1 Europe (Milan) + RegionEUSouth1 Region = "eu-south-1" + // RegionEUWest1 Europe (Ireland) + RegionEUWest1 Region = "eu-west-1" + // RegionEUWest2 Europe (London) + RegionEUWest2 Region = "eu-west-2" + // RegionEUWest3 Europe (Paris) + RegionEUWest3 Region = "eu-west-3" + // RegionMESouth1 Middle East (Bahrain) + RegionMESouth1 Region = "me-south-1" + // RegionSASouth1 South America (São Paulo) + RegionSASouth1 Region = "sa-south-1" + // RegionUSGovEast1 AWS GovCloud (US-East) + RegionUSGovEast1 Region = "us-gov-east-1" + // RegionUSGovWest1 AWS GovCloud (US) + RegionUSGovWest1 Region = "us-gov-west-1" +) + +var regions = []Region{ + RegionUSEast1, RegionUSEast2, RegionUSWest1, RegionUSWest2, RegionAFSouth1, RegionAPEast1, + RegionAPSouth1, RegionAPNortheast1, RegionAPNortheast2, RegionAPNortheast3, RegionAPSoutheast1, + RegionAPSoutheast2, RegionCACentral1, RegionCNNorth1, RegionCNNorthwest1, RegionEUCentral1, + RegionEUNorth1, RegionEUSouth1, RegionEUWest1, RegionEUWest2, RegionEUWest3, RegionMESouth1, + RegionSASouth1, RegionUSGovEast1, RegionUSGovWest1, +} + +// Validate returns true if the Region exists. +func (r Region) Validate() error { + switch r { + case + RegionUSEast1, RegionUSEast2, RegionUSWest1, RegionUSWest2, RegionAFSouth1, + RegionAPEast1, RegionAPSouth1, RegionAPNortheast1, RegionAPNortheast2, + RegionAPNortheast3, RegionAPSoutheast1, RegionAPSoutheast2, RegionCACentral1, + RegionCNNorth1, RegionCNNorthwest1, RegionEUCentral1, RegionEUNorth1, + RegionEUSouth1, RegionEUWest1, RegionEUWest2, RegionEUWest3, RegionMESouth1, + RegionSASouth1, RegionUSGovEast1, RegionUSGovWest1: + return nil + case Region(""): + return ErrEmptyRegion + default: + return ErrInvalidRegion + } +} + +// String returns the string representation of the Region. +func (r Region) String() string { + return string(r) +} + +// Next returns the next region. +// If the region is the last one, it returns the first region. +// If the region is invalid, it returns "ap-northeast-1". +func (r Region) Next() Region { + for i, region := range regions { + if r == region { + if i == len(regions)-1 { + return regions[0] + } + return regions[i+1] + } + } + return RegionAPNortheast1 +} + +// Prev returns the previous region. +// If the region is the first one, it returns the last region. +// If the region is invalid, it returns "ap-northeast-1". +func (r Region) Prev() Region { + for i, region := range regions { + if r == region { + if i == 0 { + return regions[len(regions)-1] + } + return regions[i-1] + } + } + return RegionAPNortheast1 +} + +// Bucket is the name of the S3 bucket. +type Bucket string + +// String returns the string representation of the Bucket. +func (b Bucket) String() string { + return string(b) +} + +// Empty is whether bucket name is empty +func (b Bucket) Empty() bool { + return b == "" +} + +// Domain returns the domain name of the Bucket. +func (b Bucket) Domain() string { + return fmt.Sprintf("%s.s3.amazonaws.com", b.String()) +} + +// Validate returns true if the Bucket is valid. +// Bucket naming rules: https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucketnamingrules.html +func (b Bucket) Validate() error { + if b.Empty() { + return errfmt.Wrap(ErrInvalidBucketName, "s3 bucket name is empty") + } + + validators := []func() error{ + b.validateLength, + b.validatePattern, + b.validatePrefix, + b.validateSuffix, + b.validateCharSequence, + } + for _, v := range validators { + if err := v(); err != nil { + return err + } + } + return nil +} + +const ( + // BucketMinLength is the minimum length of the bucket name. + BucketMinLength = 3 + // BucketMaxLength is the maximum length of the bucket name. + BucketMaxLength = 63 +) + +// validateLength validates the length of the bucket name. +func (b Bucket) validateLength() error { + if len(b) < 3 || len(b) > 63 { + return fmt.Errorf("s3 bucket name must be between 3 and 63 characters long") + } + return nil +} + +var s3RegexPattern xregex.Regex //nolint:gochecknoglobals + +// validatePattern validates the pattern of the bucket name. +func (b Bucket) validatePattern() error { + s3RegexPattern.InitOnce(`^[a-z0-9][a-z0-9.-]*[a-z0-9]$`) + if err := s3RegexPattern.MatchString(string(b)); err != nil { + return errfmt.Wrap(ErrInvalidBucketName, "s3 bucket name must use only lowercase letters, numbers, periods, and hyphens") + } + return nil +} + +// validatePrefix validates the prefix of the bucket name. +func (b Bucket) validatePrefix() error { + for _, prefix := range []string{"xn--", "sthree-", "sthree-configurator"} { + if strings.HasPrefix(string(b), prefix) { + return errfmt.Wrap(ErrInvalidBucketName, "s3 bucket name must not start with \"xn--\", \"sthree-\", or \"sthree-configurator\"") + } + } + return nil +} + +// validateSuffix validates the suffix of the bucket name. +func (b Bucket) validateSuffix() error { + for _, suffix := range []string{"-s3alias", "--ol-s3"} { + if strings.HasSuffix(string(b), suffix) { + return errfmt.Wrap(ErrInvalidBucketName, "s3 bucket name must not end with \"-s3alias\" or \"--ol-s3\"") + } + } + return nil +} + +// validateCharSequence validates the character sequence of the bucket name. +func (b Bucket) validateCharSequence() error { + if strings.Contains(string(b), "..") || strings.Contains(string(b), "--") { + return errfmt.Wrap(ErrInvalidBucketName, "s3 bucket name must not contain consecutive periods or hyphens") + } + return nil +} diff --git a/app/domain/model/s3_test.go b/app/domain/model/s3_test.go new file mode 100644 index 0000000..25c74c4 --- /dev/null +++ b/app/domain/model/s3_test.go @@ -0,0 +1,421 @@ +// Package model contains the definitions of domain models and business logic. +package model + +import ( + "errors" + "strings" + "testing" +) + +func TestRegionString(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + r Region + want string + }{ + { + name: "success", + r: RegionAPNortheast1, + want: "ap-northeast-1", + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := tt.r.String(); got != tt.want { + t.Errorf("Region.String() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestRegionValidate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + r Region + wantErr bool + e error + }{ + { + name: "success", + r: RegionAPNortheast1, + wantErr: false, + e: nil, + }, + { + name: "failure. region is empty", + r: Region(""), + wantErr: true, + e: ErrEmptyRegion, + }, + { + name: "failure. region is invalid", + r: Region("invalid"), + wantErr: true, + e: ErrInvalidRegion, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if err := tt.r.Validate(); (err != nil) != tt.wantErr { + t.Errorf("Region.Validate() error = %v, wantErr %v", err, tt.wantErr) + if tt.wantErr { + if errors.Is(err, tt.e) { + t.Errorf("error mismatch got = %v, wantErr %v", err, tt.wantErr) + } + } + } + }) + } +} + +func TestBucketString(t *testing.T) { + t.Parallel() + tests := []struct { + name string + b Bucket + want string + }{ + { + name: "success", + b: Bucket("spare"), + want: "spare", + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := tt.b.String(); got != tt.want { + t.Errorf("Bucket.String() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestBucketValidateLength(t *testing.T) { + t.Parallel() + tests := []struct { + name string + b Bucket + wantErr bool + }{ + { + name: "success: minimum length", + b: Bucket("abc"), + wantErr: false, + }, + { + name: "success: maximum length", + b: Bucket(strings.Repeat("a", 63)), + wantErr: false, + }, + { + name: "failure. bucket name is too short", + b: Bucket("ab"), + wantErr: true, + }, + { + name: "failure. bucket name is too long", + b: Bucket(strings.Repeat("a", 64)), + wantErr: true, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if err := tt.b.validateLength(); (err != nil) != tt.wantErr { + t.Errorf("Bucket.validateLength() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestBucketValidatePattern(t *testing.T) { + t.Parallel() + tests := []struct { + name string + b Bucket + wantErr bool + }{ + { + name: "success", + b: Bucket("abc"), + wantErr: false, + }, + { + name: "failure. bucket name contains invalid character", + b: Bucket("abc!"), + wantErr: true, + }, + { + name: "failure. bucket name contains uppercase character", + b: Bucket("Abc"), + wantErr: true, + }, + { + name: "failure. bucket name contains underscore", + b: Bucket("abc_def"), + wantErr: true, + }, + { + name: "failure. bucket name starts with hyphen", + b: Bucket("-abc"), + wantErr: true, + }, + { + name: "failure. bucket name ends with hyphen", + b: Bucket("abc-"), + wantErr: true, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if err := tt.b.validatePattern(); (err != nil) != tt.wantErr { + t.Errorf("Bucket.validatePattern() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestBucketValidatePrefix(t *testing.T) { + t.Parallel() + tests := []struct { + name string + b Bucket + wantErr bool + }{ + { + name: "success", + b: Bucket("abc"), + wantErr: false, + }, + { + name: "failure. bucket name starts with 'xn--'", + b: Bucket("xn--abc"), + wantErr: true, + }, + { + name: "failure. bucket name starts with 'sthree-'", + b: Bucket("sthree-abc"), + wantErr: true, + }, + { + name: "failure. bucket name starts with 'sthree-configurator'", + b: Bucket("sthree-configurator-abc"), + wantErr: true, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if err := tt.b.validatePrefix(); (err != nil) != tt.wantErr { + t.Errorf("Bucket.validatePrefix() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestBucketValidateSuffix(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + b Bucket + wantErr bool + }{ + { + name: "success", + b: Bucket("abc"), + wantErr: false, + }, + { + name: "failure. bucket name ends with '-s3alias'", + b: Bucket("abc-s3alias"), + wantErr: true, + }, + { + name: "failure. bucket name ends with '--ol-s3'", + b: Bucket("abc--ol-s3"), + wantErr: true, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if err := tt.b.validateSuffix(); (err != nil) != tt.wantErr { + t.Errorf("Bucket.validateSuffix() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestBucketValidateCharSequence(t *testing.T) { + t.Parallel() + tests := []struct { + name string + b Bucket + wantErr bool + }{ + { + name: "success", + b: Bucket("abc"), + wantErr: false, + }, + { + name: "failure. bucket name contains consecutive periods", + b: Bucket("abc..def"), + wantErr: true, + }, + { + name: "failure. bucket name contains consecutive hyphens", + b: Bucket("abc--def"), + wantErr: true, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if err := tt.b.validateCharSequence(); (err != nil) != tt.wantErr { + t.Errorf("Bucket.validateCharSequence() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestBucketValidate(t *testing.T) { + t.Parallel() + tests := []struct { + name string + b Bucket + wantErr bool + }{ + { + name: "success", + b: Bucket("abc"), + wantErr: false, + }, + { + name: "failure. bucket name is empty", + b: Bucket(""), + wantErr: true, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if err := tt.b.Validate(); (err != nil) != tt.wantErr { + t.Errorf("Bucket.Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestBucketDomain(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + b Bucket + want string + }{ + { + name: "success", + b: Bucket("abc"), + want: "abc.s3.amazonaws.com", + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := tt.b.Domain(); got != tt.want { + t.Errorf("Bucket.Domain() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestRegion_Next(t *testing.T) { + t.Parallel() + tests := []struct { + name string + r Region + want Region + }{ + { + name: "success", + r: RegionAPNortheast1, + want: RegionAPNortheast2, + }, + { + name: "success. last region", + r: RegionUSGovWest1, + want: RegionUSEast1, + }, + { + name: "failure. invalid region", + r: Region("invalid"), + want: RegionAPNortheast1, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := tt.r.Next(); got != tt.want { + t.Errorf("Region.Next() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestRegion_Prev(t *testing.T) { + t.Parallel() + tests := []struct { + name string + r Region + want Region + }{ + { + name: "success", + r: RegionAPNortheast2, + want: RegionAPNortheast1, + }, + { + name: "success. first region", + r: RegionUSEast1, + want: RegionUSGovWest1, + }, + { + name: "failure. invalid region", + r: Region("invalid"), + want: RegionAPNortheast1, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := tt.r.Prev(); got != tt.want { + t.Errorf("Region.Prev() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/app/domain/service/s3.go b/app/domain/service/s3.go new file mode 100644 index 0000000..0ee4fef --- /dev/null +++ b/app/domain/service/s3.go @@ -0,0 +1,24 @@ +// Package service +package service + +import ( + "context" + + "github.com/nao1215/rainbow/app/domain/model" +) + +// S3BucketCreatorInput is the input of the CreateBucket method. +type S3BucketCreatorInput struct { + // Bucket is the name of the bucket to create. + Bucket model.Bucket + // Region is the region of the bucket that you want to create. + Region model.Region +} + +// S3BucketCreatorOutput is the output of the CreateBucket method. +type S3BucketCreatorOutput struct{} + +// S3BucketCreator is the interface that wraps the basic CreateBucket method. +type S3BucketCreator interface { + CreateBucket(ctx context.Context, input *S3BucketCreatorInput) (*S3BucketCreatorOutput, error) +} diff --git a/app/external/s3.go b/app/external/s3.go new file mode 100644 index 0000000..48b56f0 --- /dev/null +++ b/app/external/s3.go @@ -0,0 +1,53 @@ +// Packgae external provides external dependencies. +package external + +import ( + "context" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/google/wire" + "github.com/nao1215/rainbow/app/domain/model" + "github.com/nao1215/rainbow/app/domain/service" +) + +// NewS3Client creates a new S3 service client. +// If profile is empty, the default profile is used. +func NewS3Client(cfg *model.AWSConfig) (*s3.Client, error) { + return s3.NewFromConfig(*cfg.Config), nil +} + +// S3BucketCreator implements the S3BucketCreator interface. +type S3BucketCreator struct { + client *s3.Client +} + +// S3BucketCreatorSet is a provider set for S3BucketCreator. +// +//nolint:gochecknoglobals +var S3BucketCreatorSet = wire.NewSet( + NewS3BucketCreator, + wire.Bind(new(service.S3BucketCreator), new(*S3BucketCreator)), +) + +var _ service.S3BucketCreator = (*S3BucketCreator)(nil) + +// NewS3BucketCreator creates a new S3BucketCreator. +func NewS3BucketCreator(client *s3.Client) *S3BucketCreator { + return &S3BucketCreator{client: client} +} + +// CreateBucket creates a new S3 bucket. +func (c *S3BucketCreator) CreateBucket(ctx context.Context, input *service.S3BucketCreatorInput) (*service.S3BucketCreatorOutput, error) { + _, err := c.client.CreateBucket(ctx, &s3.CreateBucketInput{ + Bucket: aws.String(input.Bucket.String()), + CreateBucketConfiguration: &types.CreateBucketConfiguration{ + LocationConstraint: types.BucketLocationConstraint(input.Region.String()), + }, + }) + if err != nil { + return nil, err + } + return &service.S3BucketCreatorOutput{}, nil +} diff --git a/app/interactor/s3.go b/app/interactor/s3.go new file mode 100644 index 0000000..710cd0e --- /dev/null +++ b/app/interactor/s3.go @@ -0,0 +1,48 @@ +// Package interactor contains the implementations of usecases. +package interactor + +import ( + "context" + + "github.com/google/wire" + "github.com/nao1215/rainbow/app/domain/service" + "github.com/nao1215/rainbow/app/usecase" +) + +// S3bucketCreatorSet is a provider set for S3BucketCreator. +// +//nolint:gochecknoglobals +var S3bucketCreatorSet = wire.NewSet( + NewS3BucketCreator, + wire.Bind(new(usecase.S3BucketCreator), new(*S3BucketCreator)), +) + +var _ usecase.S3BucketCreator = (*S3BucketCreator)(nil) + +// S3BucketCreator implements the S3BucketCreator interface. +type S3BucketCreator struct { + service.S3BucketCreator +} + +// NewS3BucketCreator creates a new S3BucketCreator. +func NewS3BucketCreator(c service.S3BucketCreator) *S3BucketCreator { + return &S3BucketCreator{ + S3BucketCreator: c, + } +} + +// CreateBucket creates a new S3 bucket. +func (s *S3BucketCreator) CreateBucket(ctx context.Context, input *usecase.S3BucketCreatorInput) (*usecase.S3BucketCreatorOutput, error) { + if err := input.Bucket.Validate(); err != nil { + return nil, err + } + + in := service.S3BucketCreatorInput{ + Bucket: input.Bucket, + Region: input.Region, + } + if _, err := s.S3BucketCreator.CreateBucket(ctx, &in); err != nil { + return nil, err + } + return &usecase.S3BucketCreatorOutput{}, nil +} diff --git a/app/usecase/s3.go b/app/usecase/s3.go new file mode 100644 index 0000000..af9389d --- /dev/null +++ b/app/usecase/s3.go @@ -0,0 +1,24 @@ +// Package usecase has interfaces that wrap the basic business logic. +package usecase + +import ( + "context" + + "github.com/nao1215/rainbow/app/domain/model" +) + +// S3BucketCreatorInput is the input of the CreateBucket method. +type S3BucketCreatorInput struct { + // Bucket is the name of the bucket that you want to create. + Bucket model.Bucket + // Region is the region of the bucket that you want to create. + Region model.Region +} + +// S3BucketCreatorOutput is the output of the CreateBucket method. +type S3BucketCreatorOutput struct{} + +// S3BucketCreator is the interface that wraps the basic CreateBucket method. +type S3BucketCreator interface { + CreateBucket(ctx context.Context, input *S3BucketCreatorInput) (*S3BucketCreatorOutput, error) +} diff --git a/cmd/subcmd/s3hub/interactive.go b/cmd/subcmd/s3hub/interactive.go index e928550..3b7e390 100644 --- a/cmd/subcmd/s3hub/interactive.go +++ b/cmd/subcmd/s3hub/interactive.go @@ -1,8 +1,8 @@ package s3hub -import "fmt" +import "github.com/nao1215/rainbow/ui" +// interactive starts s3hub command interactive UI. func interactive() error { - fmt.Println("interactive mode is not implemented yet") - return nil + return ui.RunS3hubUI() } diff --git a/cmd/subcmd/s3hub/interactive_test.go b/cmd/subcmd/s3hub/interactive_test.go index 1668c26..185b65b 100644 --- a/cmd/subcmd/s3hub/interactive_test.go +++ b/cmd/subcmd/s3hub/interactive_test.go @@ -1,8 +1,11 @@ package s3hub -import "testing" +import ( + "testing" +) func Test_interactive(t *testing.T) { + t.Skip("TODO: fix this test") t.Run("Interactive mode", func(t *testing.T) { if err := interactive(); err != nil { t.Errorf("got %v, want nil", err) diff --git a/go.mod b/go.mod index a9a905d..fdbf526 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,53 @@ module github.com/nao1215/rainbow go 1.19 require ( + github.com/aws/aws-sdk-go-v2 v1.24.0 + github.com/aws/aws-sdk-go-v2/config v1.26.2 + github.com/aws/aws-sdk-go-v2/service/s3 v1.47.7 + github.com/charmbracelet/bubbles v0.17.1 + github.com/charmbracelet/bubbletea v0.25.0 github.com/google/go-cmp v0.6.0 + github.com/google/wire v0.5.0 + github.com/muesli/reflow v0.3.0 + github.com/muesli/termenv v0.15.2 github.com/spf13/cobra v1.8.0 ) require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.16.13 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.18.5 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.26.6 // indirect + github.com/aws/smithy-go v1.19.0 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/lipgloss v0.9.1 // indirect + github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect + github.com/google/subcommands v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.18 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.2.0 // indirect github.com/spf13/pflag v1.0.5 // indirect + golang.org/x/mod v0.8.0 // indirect + golang.org/x/sync v0.1.0 // indirect + golang.org/x/sys v0.12.0 // indirect + golang.org/x/term v0.6.0 // indirect + golang.org/x/text v0.13.0 // indirect + golang.org/x/tools v0.6.0 // indirect ) diff --git a/go.sum b/go.sum index 4d244e6..4c650c6 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,106 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aws/aws-sdk-go-v2 v1.24.0 h1:890+mqQ+hTpNuw0gGP6/4akolQkSToDJgHfQE7AwGuk= +github.com/aws/aws-sdk-go-v2 v1.24.0/go.mod h1:LNh45Br1YAkEKaAqvmE1m8FUx6a5b/V0oAKV7of29b4= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 h1:OCs21ST2LrepDfD3lwlQiOqIGp6JiEUqG84GzTDoyJs= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4/go.mod h1:usURWEKSNNAcAZuzRn/9ZYPT8aZQkR7xcCtunK/LkJo= +github.com/aws/aws-sdk-go-v2/config v1.26.2 h1:+RWLEIWQIGgrz2pBPAUoGgNGs1TOyF4Hml7hCnYj2jc= +github.com/aws/aws-sdk-go-v2/config v1.26.2/go.mod h1:l6xqvUxt0Oj7PI/SUXYLNyZ9T/yBPn3YTQcJLLOdtR8= +github.com/aws/aws-sdk-go-v2/credentials v1.16.13 h1:WLABQ4Cp4vXtXfOWOS3MEZKr6AAYUpMczLhgKtAjQ/8= +github.com/aws/aws-sdk-go-v2/credentials v1.16.13/go.mod h1:Qg6x82FXwW0sJHzYruxGiuApNo31UEtJvXVSZAXeWiw= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10 h1:w98BT5w+ao1/r5sUuiH6JkVzjowOKeOJRHERyy1vh58= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10/go.mod h1:K2WGI7vUvkIv1HoNbfBA1bvIZ+9kL3YVmWxeKuLQsiw= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9 h1:v+HbZaCGmOwnTTVS86Fleq0vPzOd7tnJGbFhP0stNLs= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9/go.mod h1:Xjqy+Nyj7VDLBtCMkQYOw1QYfAEZCVLrfI0ezve8wd4= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9 h1:N94sVhRACtXyVcjXxrwK1SKFIJrA9pOJ5yu2eSHnmls= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9/go.mod h1:hqamLz7g1/4EJP+GH5NBhcUMLjW+gKLQabgyz6/7WAU= +github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 h1:GrSw8s0Gs/5zZ0SX+gX4zQjRnRsMJDJ2sLur1gRBhEM= +github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2/go.mod h1:6fQQgfuGmw8Al/3M2IgIllycxV7ZW7WCdVSqfBeUiCY= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9 h1:ugD6qzjYtB7zM5PN/ZIeaAIyefPaD82G8+SJopgvUpw= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9/go.mod h1:YD0aYBWCrPENpHolhKw2XDlTIWae2GKXT1T4o6N6hiM= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 h1:/b31bi3YVNlkzkBrm9LfpaKoaYZUxIAj4sHfOTmLfqw= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4/go.mod h1:2aGXHFmbInwgP9ZfpmdIfOELL79zhdNYNmReK8qDfdQ= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9 h1:/90OR2XbSYfXucBMJ4U14wrjlfleq/0SB6dZDPncgmo= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9/go.mod h1:dN/Of9/fNZet7UrQQ6kTDo/VSwKPIq94vjlU16bRARc= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9 h1:Nf2sHxjMJR8CSImIVCONRi4g0Su3J+TSTbS7G0pUeMU= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9/go.mod h1:idky4TER38YIjr2cADF1/ugFMKvZV7p//pVeV5LZbF0= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9 h1:iEAeF6YC3l4FzlJPP9H3Ko1TXpdjdqWffxXjp8SY6uk= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9/go.mod h1:kjsXoK23q9Z/tLBrckZLLyvjhZoS+AGrzqzUfEClvMM= +github.com/aws/aws-sdk-go-v2/service/s3 v1.47.7 h1:o0ASbVwUAIrfp/WcCac+6jioZt4Hd8k/1X8u7GJ/QeM= +github.com/aws/aws-sdk-go-v2/service/s3 v1.47.7/go.mod h1:vADO6Jn+Rq4nDtfwNjhgR84qkZwiC6FqCaXdw/kYwjA= +github.com/aws/aws-sdk-go-v2/service/sso v1.18.5 h1:ldSFWz9tEHAwHNmjx2Cvy1MjP5/L9kNoR0skc6wyOOM= +github.com/aws/aws-sdk-go-v2/service/sso v1.18.5/go.mod h1:CaFfXLYL376jgbP7VKC96uFcU8Rlavak0UlAwk1Dlhc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5 h1:2k9KmFawS63euAkY4/ixVNsYYwrwnd5fIvgEKkfZFNM= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5/go.mod h1:W+nd4wWDVkSUIox9bacmkBP5NMFQeTJ/xqNabpzSR38= +github.com/aws/aws-sdk-go-v2/service/sts v1.26.6 h1:HJeiuZ2fldpd0WqngyMR6KW7ofkXNLyOaHwEIGm39Cs= +github.com/aws/aws-sdk-go-v2/service/sts v1.26.6/go.mod h1:XX5gh4CB7wAs4KhcF46G6C8a2i7eupU19dcAAE+EydU= +github.com/aws/smithy-go v1.19.0 h1:KWFKQV80DpP3vJrrA9sVAHQ5gc2z8i4EzrLhLlWXcBM= +github.com/aws/smithy-go v1.19.0/go.mod h1:NukqUGpCZIILqqiV0NIjeFh24kd/FAa4beRb6nbIUPE= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v0.17.1 h1:0SIyjOnkrsfDo88YvPgAWvZMwXe26TP6drRvmkjyUu4= +github.com/charmbracelet/bubbles v0.17.1/go.mod h1:9HxZWlkCqz2PRwsCbYl7a3KXvGzFaDHpYbSYMJ+nE3o= +github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM= +github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg= +github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg= +github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/subcommands v1.0.1 h1:/eqq+otEXm5vhfBrbREPCSVQbvofip6kIz+mX5TUH7k= +github.com/google/subcommands v1.0.1/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= +github.com/google/wire v0.5.0 h1:I7ELFeVBr3yfPIcc8+MWvrjk+3VjbcSzoXm3JVa+jD8= +github.com/google/wire v0.5.0/go.mod h1:ngWDr9Qvq3yZA10YrxfyGELY/AFWGVpy9c1LTRi1EoU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= +github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/tools v0.0.0-20190422233926-fe54fb35175b/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tools.go b/tools.go new file mode 100644 index 0000000..c86bcf2 --- /dev/null +++ b/tools.go @@ -0,0 +1,9 @@ +//go:build tools +// +build tools + +package tools + +// https://github.com/google/wire/issues/299 +import ( + _ "github.com/google/wire/cmd/wire" +) diff --git a/ui/.gitkeep b/ui/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/ui/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/ui/common.go b/ui/common.go new file mode 100644 index 0000000..d04902a --- /dev/null +++ b/ui/common.go @@ -0,0 +1,52 @@ +package ui + +import ( + "fmt" + + "github.com/muesli/termenv" +) + +// General stuff for styling the view +var ( + term = termenv.EnvColorProfile() + subtle = makeFgStyle("241") + red = makeFgStyle("196") + green = makeFgStyle("46") + yellow = makeFgStyle("226") +) + +type ( + errMsg error +) + +// makeFgStyle returns a function that will colorize the foreground of a given. +func makeFgStyle(color string) func(string) string { + return termenv.Style{}.Foreground(term.Color(color)).Styled +} + +// Color a string's foreground with the given value. +func colorFg(val, color string) string { + return termenv.String(val).Foreground(term.Color(color)).String() +} + +// checkbox represent [ ] and [x] items in the view. +func checkbox(label string, checked bool) string { + if checked { + return colorFg("[x] "+label, "212") + } + return fmt.Sprintf("[ ] %s", label) +} + +// split splits a string into multiple lines. +// Each line has a maximum length of 80 characters. +func split(s string) []string { + var result []string + for i := 0; i < len(s); i += 80 { + end := i + 80 + if end > len(s) { + end = len(s) + } + result = append(result, s[i:end]) + } + return result +} diff --git a/ui/s3hub.go b/ui/s3hub.go new file mode 100644 index 0000000..918dfb3 --- /dev/null +++ b/ui/s3hub.go @@ -0,0 +1,485 @@ +package ui + +import ( + "context" + "fmt" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/muesli/reflow/indent" + "github.com/nao1215/rainbow/app/di" + "github.com/nao1215/rainbow/app/domain/model" + "github.com/nao1215/rainbow/app/usecase" +) + +const ( + // s3hubTopMinChoice is the minimum choice number. + s3hubTopMinChoice = 0 + // s3hubTopMaxChoice is the maximum choice number. + s3hubTopMaxChoice = 4 + // s3hubTopCreateChoice is the choice number for creating the S3 bucket. + s3hubTopCreateChoice = 0 + // s3hubTopListChoice is the choice number for listing S3 buckets. + s3hubTopListChoice = 1 + // s3hubTopCopyChoice is the choice number for copying file to the S3 bucket. + s3hubTopCopyChoice = 2 + // s3hubTopDeleteContentsChoice is the choice number for deleting contents from the S3 bucket. + s3hubTopDeleteContentsChoice = 3 + // s3hubTopDeleteBucketChoice is the choice number for deleting the S3 bucket. + s3hubTopDeleteBucketChoice = 4 +) + +// s3hubRootModel is the top-level model for the application. +type s3hubRootModel struct { + // choice is the currently selected menu item. + choice int + // chosen is true when the user has chosen a menu item. + chosen bool + // quitting is true when the user has quit the application. + quitting bool + // err is the error that occurred during the operation. + err error +} + +// RunS3hubUI start s3hub command interactive UI. +func RunS3hubUI() error { + _, err := tea.NewProgram(&s3hubRootModel{}).Run() + return err +} + +// Init initializes the model. +func (m *s3hubRootModel) Init() tea.Cmd { + return nil +} + +// Main update function. +func (m *s3hubRootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // Make sure these keys always quit + if msg, ok := msg.(tea.KeyMsg); ok { + k := msg.String() + if k == "q" || k == "esc" || k == "ctrl+c" { + m.quitting = true + return m, tea.Quit + } + } + return m.updateChoices(msg) +} + +// View renders the application's UI. +func (m *s3hubRootModel) View() string { + if m.err != nil { + return fmt.Sprintf("%s", m.err.Error()) + } + + if m.quitting { + return "\n See you later! (TODO: output log)\n\n" // TODO: print log. + } + + var s string + if !m.chosen { + s = m.choicesView() + } + return indent.String("\n"+s+"\n\n", 2) +} + +// updateChoices updates the model based on keypresses. +func (m *s3hubRootModel) updateChoices(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "j", "down": + m.choice++ + if m.choice > s3hubTopMaxChoice { + m.choice = s3hubTopMinChoice + } + case "k", "up": + m.choice-- + if m.choice < s3hubTopMinChoice { + m.choice = s3hubTopMaxChoice + } + case "enter": + m.chosen = true + switch m.choice { + case s3hubTopCreateChoice: + model, err := newS3hubCreateBucketModel() + if err != nil { + m.err = err + return m, tea.Quit + } + return model, nil + case s3hubTopListChoice: + return &s3hubListBucketModel{}, nil + case s3hubTopCopyChoice: + return &s3hubCopyModel{}, nil + case s3hubTopDeleteContentsChoice: + return &s3hubDeleteContentsModel{}, nil + case s3hubTopDeleteBucketChoice: + return &s3hubDeleteBucketModel{}, nil + } + } + } + return m, nil +} + +// choicesView returns a string containing the choices menu. +func (m *s3hubRootModel) choicesView() string { + c := m.choice + template := "%s\n\n" + template += subtle("j/k, up/down: select | enter: choose | q, : quit") + + choices := fmt.Sprintf( + "%s\n%s\n%s\n%s\n%s\n", + checkbox("Create the S3 bucket", c == s3hubTopMinChoice), + checkbox("List S3 buckets", c == 1), + checkbox("Copy file to the S3 bucket", c == 2), + checkbox("Delete contents from the S3 bucket", c == 3), + checkbox("Delete the S3 bucket", c == s3hubTopMaxChoice), + ) + return fmt.Sprintf(template, choices) +} + +const ( + // s3hubCreateBucketRegionChoice is the choice number for selecting the AWS region. + s3hubCreateBucketRegionChoice = 0 + // s3hubCreateBucketBucketNameChoice is the choice number for inputting the S3 bucket name. + s3hubCreateBucketBucketNameChoice = 1 +) + +type s3hubCreateBucketModel struct { + // bucketNameInput is the text input widget. + bucketNameInput textinput.Model + // err is the error that occurred during the operation. + err error + // bucket is the name of the S3 bucket that the user wants to create. + bucket model.Bucket + // state is the state of the create bucket operation. + state s3hubCreateBucketState + // awsConfig is the AWS configuration. + awsConfig *model.AWSConfig + // awsProfile is the AWS profile. + awsProfile model.AWSProfile + // region is the AWS region that the user wants to create the S3 bucket. + region model.Region + // choice is the currently selected menu item. + choice int + // app is the S3 application service. + app *di.S3App + ctx context.Context +} + +// createMsg is the message that is sent when the user wants to create the S3 bucket. +type createMsg struct{} + +type s3hubCreateBucketState int + +const ( + s3hubCreateBucketStateNone s3hubCreateBucketState = 0 + s3hubCreateBucketStateCreating s3hubCreateBucketState = 1 + s3hubCreateBucketStateCreated s3hubCreateBucketState = 2 +) + +func newS3hubCreateBucketModel() (*s3hubCreateBucketModel, error) { + ti := textinput.New() + ti.Placeholder = fmt.Sprintf("Write the S3 bucket name here (min: %d, max: %d)", model.BucketMinLength, model.BucketMaxLength) + ti.Focus() + ti.CharLimit = model.BucketMaxLength + ti.Width = model.BucketMaxLength + + ctx := context.Background() + profile := model.NewAWSProfile("") + cfg, err := model.NewAWSConfig(ctx, profile, "") + if err != nil { + return nil, err + } + + return &s3hubCreateBucketModel{ + bucketNameInput: ti, + choice: s3hubCreateBucketBucketNameChoice, + awsConfig: cfg, + awsProfile: profile, + region: cfg.Region(), + ctx: ctx, + }, nil +} + +func (m *s3hubCreateBucketModel) Init() tea.Cmd { + return textinput.Blink +} + +func (m *s3hubCreateBucketModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if m.err != nil { + return m, tea.Quit + } + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "down": + m.choice++ + if m.choice > s3hubCreateBucketBucketNameChoice { + m.choice = s3hubCreateBucketRegionChoice + } + case "up": + m.choice-- + if m.choice < s3hubCreateBucketRegionChoice { + m.choice = s3hubCreateBucketBucketNameChoice + } + case "h", "left": + if m.choice == s3hubCreateBucketRegionChoice { + m.region = m.region.Prev() + } + case "l", "right": + if m.choice == s3hubCreateBucketRegionChoice { + m.region = m.region.Next() + } + case "enter": + if m.bucketNameInput.Value() == "" || len(m.bucketNameInput.Value()) < model.BucketMinLength { + return m, nil + } + + app, err := di.NewS3App(m.ctx, m.awsProfile, m.region) + if err != nil { + m.err = err + return m, tea.Quit + } + m.app = app + m.bucket = model.Bucket(m.bucketNameInput.Value()) + return m, m.createS3BucketCmd() + case "ctrl+c", "esc": + return m, tea.Quit + } + case errMsg: + m.err = msg + return m, nil + case createMsg: + m.state = s3hubCreateBucketStateCreated + return m, tea.Quit + } + + if m.choice == s3hubCreateBucketBucketNameChoice { + var cmd tea.Cmd + m.bucketNameInput, cmd = m.bucketNameInput.Update(msg) + return m, cmd + } + return m, nil +} + +func (m *s3hubCreateBucketModel) View() string { + if m.err != nil { + message := fmt.Sprintf("[ AWS Profile ] %s\n[ Region ] %s\n[ S3 Name ]%s\n\n%s\n\n%s\n%s\n\n", + m.awsProfile.String(), + m.region.String(), + m.bucketNameWithColor(), + m.bucketNameLengthString(), + subtle(", : quit | up/down: select"), + subtle(": create bucket")) + + message += fmt.Sprintf("%s\n", red("[Error]")) + for _, line := range split(m.err.Error()) { + message += fmt.Sprintf(" %s\n", red(line)) + } + return message + } + + if m.state == s3hubCreateBucketStateCreated { + return fmt.Sprintf("[ AWS Profile ] %s\n[ Region ] %s\n[ S3 Name ]%s\n\n%s\n\n%s\n%s\n\n%s%s\n", + m.awsProfile.String(), + m.region.String(), + m.bucketNameWithColor(), + m.bucketNameLengthString(), + subtle(", : quit | up/down: select"), + subtle(": create bucket"), + "Created S3 bucket: ", + yellow(m.bucket.String())) + } + + if m.state == s3hubCreateBucketStateCreating { + return fmt.Sprintf("[ AWS Profile ] %s\n[ Region ] %s\n[ %s ]%s\n\n%s\n\n%s\n%s\n\n%s\n", + m.awsProfile.String(), + m.region.String(), + yellow("S3 Name"), + m.bucketNameWithColor(), + m.bucketNameLengthString(), + subtle(", : quit | up/down: select"), + subtle(": create bucket"), + "Creating S3 bucket...", + ) + } + + if m.choice == s3hubCreateBucketRegionChoice { + return fmt.Sprintf( + "[ AWS Profile ] %s\n[ ◀︎ %s ▶︎ ] %s\n[ S3 Name ]%s\n\n%s\n\n%s\n%s\n", + m.awsProfile.String(), + yellow("Region"), + green(m.region.String()), + m.bucketNameWithColor(), + m.bucketNameLengthString(), + subtle(", : quit | up/down: select"), + subtle(": create bucket | h/l, left/right: select region"), + ) + } + + return fmt.Sprintf( + "[ AWS Profile ] %s\n[ Region ] %s\n[ %s ]%s\n\n%s\n\n%s\n%s\n", + m.awsProfile.String(), + m.region.String(), + yellow("S3 Name"), + m.bucketNameWithColor(), + m.bucketNameLengthString(), + subtle(", : quit | up/down: select"), + subtle(": create bucket"), + ) +} + +// bucketNameWithColor returns the bucket name with color. +func (m *s3hubCreateBucketModel) bucketNameWithColor() string { + if m.state == s3hubCreateBucketStateCreating || m.state == s3hubCreateBucketStateCreated { + return m.bucketNameInput.View() + } + + if len(m.bucketNameInput.Value()) < model.BucketMinLength && m.choice == s3hubCreateBucketBucketNameChoice { + return red(m.bucketNameInput.View()) + } + if m.choice == s3hubCreateBucketRegionChoice { + return m.bucketNameInput.View() + } + return green(m.bucketNameInput.View()) +} + +// bucketNameLengthString returns the bucket name length string. +func (m *s3hubCreateBucketModel) bucketNameLengthString() string { + lengthStr := fmt.Sprintf("Length: %d", len(m.bucketNameInput.Value())) + if len(m.bucketNameInput.Value()) == model.BucketMaxLength { + lengthStr += " (max)" + } else if len(m.bucketNameInput.Value()) < model.BucketMinLength { + lengthStr += " (min: 3)" + } + return lengthStr +} + +func (m *s3hubCreateBucketModel) createS3BucketCmd() tea.Cmd { + return tea.Cmd(func() tea.Msg { + if m.app == nil { + return errMsg(fmt.Errorf("not initialized s3 application. please restart the application")) + } + input := &usecase.S3BucketCreatorInput{ + Bucket: m.bucket, + Region: m.region, + } + m.state = s3hubCreateBucketStateCreating + + if _, err := m.app.S3BucketCreator.CreateBucket(m.ctx, input); err != nil { + return errMsg(err) + } + return createMsg{} + }) +} + +type s3hubListBucketModel struct { + // quitting is true when the user has quit the application. + quitting bool +} + +func (m *s3hubListBucketModel) Init() tea.Cmd { + return nil +} + +func (m *s3hubListBucketModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if msg, ok := msg.(tea.KeyMsg); ok { + k := msg.String() + if k == "q" || k == "esc" || k == "ctrl+c" { + m.quitting = true + return m, tea.Quit + } + } + return m, nil +} + +func (m *s3hubListBucketModel) View() string { + return fmt.Sprintf( + "%s\n%s", + "s3hubListBucketModel", + subtle("j/k, up/down: select")+" | "+subtle("enter: choose")+" | "+subtle("q, esc: quit")) +} + +type s3hubCopyModel struct { + // quitting is true when the user has quit the application. + quitting bool +} + +func (m *s3hubCopyModel) Init() tea.Cmd { + return nil +} + +func (m *s3hubCopyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if msg, ok := msg.(tea.KeyMsg); ok { + k := msg.String() + if k == "q" || k == "esc" || k == "ctrl+c" { + m.quitting = true + return m, tea.Quit + } + } + return m, nil +} + +func (m *s3hubCopyModel) View() string { + return fmt.Sprintf( + "%s\n%s", + "s3hubCopyModel", + subtle("j/k, up/down: select")+" | "+subtle("enter: choose")+" | "+subtle("q, esc: quit")) +} + +type s3hubDeleteContentsModel struct { + // quitting is true when the user has quit the application. + quitting bool +} + +func (m *s3hubDeleteContentsModel) Init() tea.Cmd { + return nil +} + +func (m *s3hubDeleteContentsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if msg, ok := msg.(tea.KeyMsg); ok { + k := msg.String() + if k == "q" || k == "esc" || k == "ctrl+c" { + m.quitting = true + return m, tea.Quit + } + } + return m, nil +} + +func (m *s3hubDeleteContentsModel) View() string { + return fmt.Sprintf( + "%s\n%s", + "s3hubDeleteContentsModel", + subtle("j/k, up/down: select")+" | "+subtle("enter: choose")+" | "+subtle("q, esc: quit")) +} + +type s3hubDeleteBucketModel struct { + // quitting is true when the user has quit the application. + quitting bool +} + +func (m *s3hubDeleteBucketModel) Init() tea.Cmd { + return nil +} + +func (m *s3hubDeleteBucketModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if msg, ok := msg.(tea.KeyMsg); ok { + k := msg.String() + if k == "q" || k == "esc" || k == "ctrl+c" { + m.quitting = true + return m, tea.Quit + } + } + return m, nil +} + +func (m *s3hubDeleteBucketModel) View() string { + return fmt.Sprintf( + "%s\n%s", + "s3hubDeleteBucketModel", + subtle("j/k, up/down: select")+" | "+subtle("enter: choose")+" | "+subtle("q, esc: quit")) + +} diff --git a/utils/errfmt/errfmt.go b/utils/errfmt/errfmt.go new file mode 100644 index 0000000..529a1e0 --- /dev/null +++ b/utils/errfmt/errfmt.go @@ -0,0 +1,20 @@ +// Package errfmt format the error message. +package errfmt + +import ( + "errors" + "fmt" +) + +// Wrap return wrapping error with message. +// If e is nil, return new error with msg. If msg is empty string, return e. +// For example: Wrap(errors.New("original error"), "add message") returns "original error: add message". +func Wrap(e error, msg string) error { + if e == nil { + return errors.New(msg) + } + if msg == "" { + return e + } + return fmt.Errorf("%w: %s", e, msg) +} diff --git a/utils/errfmt/errfmt_test.go b/utils/errfmt/errfmt_test.go new file mode 100644 index 0000000..16ad56a --- /dev/null +++ b/utils/errfmt/errfmt_test.go @@ -0,0 +1,60 @@ +package errfmt + +import ( + "errors" + "testing" +) + +func TestWrap(t *testing.T) { + t.Parallel() + + type args struct { + e error + msg string + } + tests := []struct { + name string + args args + wantErrMsg string + }{ + { + name: "wrap error message", + args: args{ + e: errors.New("original error"), + msg: "add message", + }, + wantErrMsg: "original error: add message", + }, + { + name: "make new error because user not specify nil for error", + args: args{ + e: nil, + msg: "make new error", + }, + wantErrMsg: "make new error", + }, + { + name: "Return error(e) as it is", + args: args{ + e: errors.New("this is return value"), + msg: "", + }, + wantErrMsg: "this is return value", + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := Wrap(tt.args.e, tt.args.msg) + if got == nil { + t.Fatal("expect return error, however errfmt.Wrap() return nil") + } + if got.Error() != tt.wantErrMsg { + t.Errorf("want=%s, got=%s", tt.wantErrMsg, got.Error()) + } + }) + } +} diff --git a/utils/xregex/xregex.go b/utils/xregex/xregex.go new file mode 100644 index 0000000..b6353ff --- /dev/null +++ b/utils/xregex/xregex.go @@ -0,0 +1,35 @@ +// Package xregex provides a type that represents a regular expression pattern. +package xregex + +import ( + "fmt" + "regexp" + "sync" +) + +// Regex is a type that represents a regular expression pattern. +type Regex struct { + // Pattern is the regular expression pattern. + Pattern *regexp.Regexp + // Mutex is a mutual exclusion lock. + Mutex sync.Mutex + // Once is an object that will perform exactly one action. + Once sync.Once +} + +// InitOnce initializes the Regex object. +func (r *Regex) InitOnce(pattern string) { + r.Once.Do(func() { + r.Pattern = regexp.MustCompile(pattern) + }) +} + +// MatchString returns true if the string s matches the pattern. +func (r *Regex) MatchString(s string) error { + r.Mutex.Lock() + defer r.Mutex.Unlock() + if !r.Pattern.MatchString(s) { + return fmt.Errorf("does not match the regular expression pattern: %s", r.Pattern.String()) + } + return nil +} diff --git a/utils/xregex/xregex_test.go b/utils/xregex/xregex_test.go new file mode 100644 index 0000000..08cb3eb --- /dev/null +++ b/utils/xregex/xregex_test.go @@ -0,0 +1,23 @@ +// Package xregex provides a type that represents a regular expression pattern. +package xregex + +import ( + "testing" +) + +func TestRegexMatchString(t *testing.T) { + t.Parallel() + t.Run("MatchString returns true if the string s matches the pattern", func(t *testing.T) { + t.Parallel() + + var r Regex + for i := 0; i < 100; i++ { + go func() { + r.InitOnce(`^[a-z0-9][a-z0-9.-]*[a-z0-9]$`) + if err := r.MatchString("test"); err != nil { + t.Errorf("MatchString() error = %v", err) + } + }() + } + }) +}