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/di/wire.go b/app/di/wire.go new file mode 100644 index 0000000..3c7542f --- /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..35be60f --- /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..c920299 --- /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(string(region))) + } + + 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 index 5702a6e..25ad058 100644 --- a/app/domain/model/s3.go +++ b/app/domain/model/s3.go @@ -1,6 +1,14 @@ // 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 @@ -57,8 +65,8 @@ const ( RegionUSGovWest1 Region = "us-gov-west-1" ) -// Valid returns true if the Region exists. -func (r Region) Valid() bool { +// Validate returns true if the Region exists. +func (r Region) Validate() error { switch r { case RegionUSEast1, RegionUSEast2, RegionUSWest1, RegionUSWest2, RegionAFSouth1, @@ -67,9 +75,11 @@ func (r Region) Valid() bool { RegionCNNorth1, RegionCNNorthwest1, RegionEUCentral1, RegionEUNorth1, RegionEUSouth1, RegionEUWest1, RegionEUWest2, RegionEUWest3, RegionMESouth1, RegionSASouth1, RegionUSGovEast1, RegionUSGovWest1: - return true + return nil + case Region(""): + return ErrEmptyRegion default: - return false + return ErrInvalidRegion } } @@ -81,13 +91,93 @@ func (r Region) String() string { // Bucket is the name of the S3 bucket. type Bucket string -// Valid returns true if the Bucket is valid (it's not empty). -func (b Bucket) Valid() bool { - // TODO: check strictly - return b != "" -} - // 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 index 69e4c2e..802322a 100644 --- a/app/domain/model/s3_test.go +++ b/app/domain/model/s3_test.go @@ -2,6 +2,8 @@ package model import ( + "errors" + "strings" "testing" ) @@ -15,7 +17,7 @@ func TestRegionString(t *testing.T) { }{ { name: "success", - r: Region("ap-northeast-1"), + r: RegionAPNortheast1, want: "ap-northeast-1", }, } @@ -30,30 +32,47 @@ func TestRegionString(t *testing.T) { } } -func TestBucketValid(t *testing.T) { +func TestRegionValidate(t *testing.T) { t.Parallel() + tests := []struct { - name string - b Bucket - want bool + name string + r Region + wantErr bool + e error }{ { - name: "success", - b: Bucket("rainbow"), - want: true, + name: "success", + r: RegionAPNortheast1, + wantErr: false, + e: nil, + }, + { + name: "failure. region is empty", + r: Region(""), + wantErr: true, + e: ErrEmptyRegion, }, { - name: "failure. bucket name is empty", - b: Bucket(""), - want: false, + 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 got := tt.b.Valid(); got != tt.want { - t.Errorf("Bucket.Valid() = %v, want %v", got, tt.want) + 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) + } + } } }) } @@ -68,8 +87,8 @@ func TestBucketString(t *testing.T) { }{ { name: "success", - b: Bucket("rainbow"), - want: "rainbow", + b: Bucket("spare"), + want: "spare", }, } for _, tt := range tests { @@ -82,3 +101,253 @@ func TestBucketString(t *testing.T) { }) } } + +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) + } + }) + } +} diff --git a/app/domain/service/s3.go b/app/domain/service/s3.go index 9abd15d..110efa9 100644 --- a/app/domain/service/s3.go +++ b/app/domain/service/s3.go @@ -1,2 +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 name of the AWS region. + 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 index d244f26..8f9269b 100644 --- a/app/external/s3.go +++ b/app/external/s3.go @@ -1 +1,48 @@ package external + +import ( + "context" + + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/google/wire" + "github.com/nao1215/rainbow/app/domain/model" + "github.com/nao1215/rainbow/app/domain/service" + "github.com/shogo82148/pointer" +) + +// 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: pointer.String(input.Bucket.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..7775150 --- /dev/null +++ b/app/interactor/s3.go @@ -0,0 +1,36 @@ +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) { + return &usecase.S3BucketCreatorOutput{}, nil +} diff --git a/app/usecase/s3.go b/app/usecase/s3.go new file mode 100644 index 0000000..65fb1db --- /dev/null +++ b/app/usecase/s3.go @@ -0,0 +1,17 @@ +package usecase + +import "context" + +// S3BucketCreatorInput is the input of the CreateBucket method. +type S3BucketCreatorInput struct { + Bucket string + Region string +} + +// 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/go.mod b/go.mod index d1269e3..d404221 100644 --- a/go.mod +++ b/go.mod @@ -3,19 +3,40 @@ module github.com/nao1215/rainbow go 1.19 require ( + 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/shogo82148/pointer v1.3.0 github.com/spf13/cobra v1.8.0 ) require ( github.com/atotto/clipboard v0.1.4 // indirect + github.com/aws/aws-sdk-go-v2 v1.24.0 // 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 @@ -23,10 +44,13 @@ require ( 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.6.0-dev.0.20220419223038-86c51ed26bb4 // 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.3.8 // indirect + golang.org/x/tools v0.1.12 // indirect ) diff --git a/go.sum b/go.sum index 6d7be3b..f00b90d 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,41 @@ 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= @@ -11,8 +47,13 @@ github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9 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= @@ -32,23 +73,36 @@ 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/shogo82148/pointer v1.3.0 h1:LW5V2jUAjFNjS8e7k/PgFoh3EavOSB/vvN85aGue5+I= +github.com/shogo82148/pointer v1.3.0/go.mod h1:agZ5JFpavFPXznbWonIvbG78NDfvDTFppe+7o53up5w= 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.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +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.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/tools v0.0.0-20190422233926-fe54fb35175b/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 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/s3hub.go b/ui/s3hub.go index 7fd692d..414c9da 100644 --- a/ui/s3hub.go +++ b/ui/s3hub.go @@ -1,11 +1,13 @@ 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/domain/model" ) const ( @@ -33,6 +35,8 @@ type s3hubRootModel struct { 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. @@ -61,8 +65,12 @@ func (m *s3hubRootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // 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!\n\n" // TODO: print log. + return "\n See you later! (TODO: output log)\n\n" // TODO: print log. } var s string @@ -91,7 +99,12 @@ func (m *s3hubRootModel) updateChoices(msg tea.Msg) (tea.Model, tea.Cmd) { m.chosen = true switch m.choice { case s3hubTopCreateChoice: - return newS3hubCreateBucketModel(), nil + model, err := newS3hubCreateBucketModel() + if err != nil { + m.err = err + return m, tea.Quit + } + return model, nil case s3hubTopListChoice: return &s3hubListBucketModel{}, nil case s3hubTopCopyChoice: @@ -132,6 +145,10 @@ type s3hubCreateBucketModel struct { bucketName string // 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 } // createMsg is the message that is sent when the user wants to create the S3 bucket. @@ -145,16 +162,24 @@ const ( s3hubCreateBucketStateCreated s3hubCreateBucketState = 2 ) -func newS3hubCreateBucketModel() *s3hubCreateBucketModel { +func newS3hubCreateBucketModel() (*s3hubCreateBucketModel, error) { ti := textinput.New() - ti.Placeholder = "Write the S3 bucket name here" + ti.Placeholder = fmt.Sprintf("Write the S3 bucket name here (min: %d, max: %d)", model.BucketMinLength, model.BucketMaxLength) ti.Focus() - ti.CharLimit = 63 - ti.Width = 63 + ti.CharLimit = model.BucketMaxLength + ti.Width = model.BucketMaxLength - return &s3hubCreateBucketModel{ - textInput: ti, + profile := model.NewAWSProfile("") + cfg, err := model.NewAWSConfig(context.Background(), profile, "") + if err != nil { + return nil, err } + + return &s3hubCreateBucketModel{ + textInput: ti, + awsConfig: cfg, + awsProfile: profile, + }, nil } func (m *s3hubCreateBucketModel) Init() tea.Cmd { @@ -170,7 +195,7 @@ func (m *s3hubCreateBucketModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: switch msg.Type { case tea.KeyEnter: - if m.textInput.Value() == "" { + if m.textInput.Value() == "" || len(m.textInput.Value()) < model.BucketMinLength { return m, nil } m.bucketName = m.textInput.Value() @@ -207,12 +232,15 @@ func (m *s3hubCreateBucketModel) View() string { } lengthStr := fmt.Sprintf("Length: %d", len(m.textInput.Value())) - if len(m.textInput.Value()) == 63 { // TODO: remove magic number. + if len(m.textInput.Value()) == model.BucketMaxLength { lengthStr += " (max)" + } else if len(m.textInput.Value()) < model.BucketMinLength { + lengthStr += " (min: 3)" } return fmt.Sprintf( - "[Input S3 name] %s\n\n%s\n\n%s", + "[ AWS Profile ] %s\n[ Region ] %s\n[Input S3 name] %s\n\n%s\n\n%s", + m.awsProfile.String(), m.awsConfig.Region().String(), m.textInput.View(), lengthStr, subtle(", : 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) + } + }() + } + }) +}