From 3ce24fcbb37d144ecb85a6246816c21b221b7acf Mon Sep 17 00:00:00 2001 From: Bastien Rigaud Date: Thu, 15 Feb 2024 14:00:41 +0100 Subject: [PATCH] feat: add grpc and errors --- .github/workflows/release.yml | 42 +++++++ errors/errors.go | 158 +++++++++++++++++++++++++++ go.mod | 16 +++ go.sum | 28 +++++ grpc/config.go | 5 + grpc/errors.go | 51 +++++++++ grpc/interceptors/chain.go | 32 ++++++ grpc/interceptors/logger/client.go | 43 ++++++++ grpc/interceptors/recover/recover.go | 42 +++++++ grpc/interceptors/timeout/timeout.go | 20 ++++ 10 files changed, 437 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 errors/errors.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 grpc/config.go create mode 100644 grpc/errors.go create mode 100644 grpc/interceptors/chain.go create mode 100644 grpc/interceptors/logger/client.go create mode 100644 grpc/interceptors/recover/recover.go create mode 100644 grpc/interceptors/timeout/timeout.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c91dc51 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,42 @@ +name: Create new release + +on: + push: + branches: + - main + +jobs: + create_release: + name: Create new release + runs-on: ubuntu-latest + steps: + - name: Code checkout + uses: actions/checkout@v2 + + - name: Read last release + id: get_last_release + run: echo ::set-output name=version::$(git describe --tags $(git rev-list --tags --max-count=1)) + + - name: Calculate new version + id: calculate_new_version + run: echo ::set-output name=version::$(echo "${{ steps.get_last_release.outputs.version }}" | awk -F. -v OFS=. '{$NF = $NF + 1;} 1') + + - name: Get commit messages + id: get_commit_messages + run: | + git log --pretty=format:"- %s" $(git describe --tags $(git rev-list --tags --max-count=1))..HEAD > commit_messages.txt + cat commit_messages.txt + echo "::set-output name=commit_messages::$(cat commit_messages.txt)" + + - name: Create new release + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: v${{ steps.calculate_new_version.outputs.version }} + release_name: v${{ steps.calculate_new_version.outputs.version }} + body: | + ${{ steps.get_commit_messages.outputs.commit_messages }} + draft: false + prerelease: false \ No newline at end of file diff --git a/errors/errors.go b/errors/errors.go new file mode 100644 index 0000000..b285965 --- /dev/null +++ b/errors/errors.go @@ -0,0 +1,158 @@ +package errors + +import ( + "github.com/pkg/errors" +) + +type ErrorWithKey struct { + Key string +} + +func (e *ErrorWithKey) Error() string { + return e.Key +} + +// NewNotFoundError return a new NotFoundError +func NewNotFoundError(key string) error { + return errors.WithStack(&NotFoundError{ + ErrorWithKey: ErrorWithKey{ + Key: key, + }, + }) +} + +// NotFoundError is used when we cannot find a specified resource +type NotFoundError struct { + ErrorWithKey +} + +// IsNotFoundError verify if an error is a NotFoundError +func IsNotFoundError(err error) bool { + _, ok := errors.Cause(err).(*NotFoundError) + + return ok +} + +// NewBadRequestError return a new BadRequestError +func NewBadRequestError(key string) error { + return errors.WithStack(&BadRequestError{ + ErrorWithKey: ErrorWithKey{ + Key: key, + }, + }) +} + +// BadRequestError is used when the given parameters does not match requirements +type BadRequestError struct { + ErrorWithKey +} + +// IsBadRequestError verify if an error is a BadRequestError +func IsBadRequestError(err error) bool { + _, ok := errors.Cause(err).(*BadRequestError) + + return ok +} + +// NewExpiredResourceError return a new ExpiredResourceError +func NewExpiredResourceError(key string) error { + return errors.WithStack(&ExpiredResourceError{ + ErrorWithKey: ErrorWithKey{ + Key: key, + }, + }) +} + +// ExpiresResourceError is used when the given resource has expired +type ExpiredResourceError struct { + ErrorWithKey +} + +// IsExpiresResourceError verify if an error is a ExpiredResourceError +func IsExpiredResourceError(err error) bool { + _, ok := errors.Cause(err).(*ExpiredResourceError) + + return ok +} + +// NewInternalServerError return a new InternalServerError +func NewInternalServerError(key string) error { + return errors.WithStack(&InternalServerError{ + ErrorWithKey: ErrorWithKey{ + Key: key, + }, + }) +} + +// InternalServerError is used when an error unexpected appears +type InternalServerError struct { + ErrorWithKey +} + +// IsInternalServerError verify if an error is a InternalServerError +func IsInternalServerError(err error) bool { + _, ok := errors.Cause(err).(*InternalServerError) + + return ok +} + +// NewUnauthorizedError return a new UnauthorizedError +func NewUnauthorizedError(key string, subjectAndMessage ...string) error { + return errors.WithStack(&UnauthorizedError{ + ErrorWithKey: ErrorWithKey{key}, + }) +} + +// UnauthorizedError is used when action is not authorized +type UnauthorizedError struct { + ErrorWithKey +} + +// IsUnauthorizedError verify if an error is a UnauthorizedError +func IsUnauthorizedError(err error) bool { + _, ok := errors.Cause(err).(*UnauthorizedError) + + return ok +} + +// NewResourceAlreadyExist return a new ResourceAlreadyExist +func NewResourceAlreadyCreatedError(key string) error { + return errors.WithStack(&ResourceAlreadyCreatedError{ + ErrorWithKey: ErrorWithKey{ + Key: key, + }, + }) +} + +// ResourceAlreadyCreatedError is used when a resource already exist and could not be created another time +type ResourceAlreadyCreatedError struct { + ErrorWithKey +} + +// IsResourceAlreadyCreatedError verify if an error is a ResourceAlreadyCreatedError +func IsResourceAlreadyCreatedError(err error) bool { + _, ok := errors.Cause(err).(*ResourceAlreadyCreatedError) + + return ok +} + +// NewOutdatedResourceError return a new OutdatedResourceError +func NewOutdatedResourceError(key string) error { + return errors.WithStack(&OutdatedResourceError{ + ErrorWithKey: ErrorWithKey{ + Key: key, + }, + }) +} + +// ResourceAlreadyCreatedError is used when a resource already exist and could not be created another time +type OutdatedResourceError struct { + ErrorWithKey +} + +// IsResourceAlreadyCreatedError verify if an error is a ResourceAlreadyCreatedError +func IsOutdatedResourceError(err error) bool { + _, ok := errors.Cause(err).(*OutdatedResourceError) + + return ok +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..df097e6 --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module github.com/Golerplate/pkg + +go 1.21.5 + +require ( + github.com/bufbuild/connect-go v1.10.0 + github.com/pkg/errors v0.9.1 + github.com/rs/zerolog v1.32.0 +) + +require ( + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + golang.org/x/sys v0.14.0 // indirect + google.golang.org/protobuf v1.31.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..38f1ad9 --- /dev/null +++ b/go.sum @@ -0,0 +1,28 @@ +github.com/bufbuild/connect-go v1.10.0 h1:QAJ3G9A1OYQW2Jbk3DeoJbkCxuKArrvZgDt47mjdTbg= +github.com/bufbuild/connect-go v1.10.0/go.mod h1:CAIePUgkDR5pAFaylSMtNK45ANQjp9JvpluG20rhpV8= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0= +github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= +golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= diff --git a/grpc/config.go b/grpc/config.go new file mode 100644 index 0000000..731bd99 --- /dev/null +++ b/grpc/config.go @@ -0,0 +1,5 @@ +package grpc + +type GRPCServerConfig struct { + Port uint16 `env:"GRPC_SERVER_PORT" envDefault:"50051"` +} diff --git a/grpc/errors.go b/grpc/errors.go new file mode 100644 index 0000000..ceb23fa --- /dev/null +++ b/grpc/errors.go @@ -0,0 +1,51 @@ +package grpc + +import ( + "context" + + "github.com/Golerplate/pkg/errors" + connectgo "github.com/bufbuild/connect-go" +) + +// TranslateFromGRPCError translates an error from a gRPC service to a errors. +// If no error is passed, it returns nil. +func TranslateFromGRPCError(_ context.Context, err error) error { + // check if error is nil + if err == nil { + return nil + } + + switch connectgo.CodeOf(err) { + case connectgo.CodeInternal: + return errors.NewInternalServerError(err.Error()) + case connectgo.CodeNotFound: + return errors.NewNotFoundError(err.Error()) + case connectgo.CodeAlreadyExists: + return errors.NewResourceAlreadyCreatedError(err.Error()) + case connectgo.CodeInvalidArgument: + return errors.NewBadRequestError(err.Error()) + default: + return errors.NewInternalServerError(err.Error()) + } +} + +// TranslateToGRPCError translates an error from errors to a gRPC service. +// If no error is passed, it returns nil. +func TranslateToGRPCError(_ context.Context, err error) error { + if err == nil { + return nil + } + + switch { + case errors.IsNotFoundError(err): + return connectgo.NewError(connectgo.CodeNotFound, err) + case errors.IsResourceAlreadyCreatedError(err): + return connectgo.NewError(connectgo.CodeAlreadyExists, err) + case errors.IsBadRequestError(err): + return connectgo.NewError(connectgo.CodeInvalidArgument, err) + case errors.IsInternalServerError(err): + return connectgo.NewError(connectgo.CodeInternal, err) + default: + return connectgo.NewError(connectgo.CodeInternal, err) + } +} diff --git a/grpc/interceptors/chain.go b/grpc/interceptors/chain.go new file mode 100644 index 0000000..e080994 --- /dev/null +++ b/grpc/interceptors/chain.go @@ -0,0 +1,32 @@ +package grpc + +import ( + "time" + + "github.com/Golerplate/pkg/grpc/interceptors/logger" + "github.com/Golerplate/pkg/grpc/interceptors/recover" + "github.com/Golerplate/pkg/grpc/interceptors/timeout" + "github.com/bufbuild/connect-go" +) + +type InterceptorsChain []connect.Interceptor + +func ServerDefaultChain() InterceptorsChain { + return []connect.Interceptor{ + recover.RecoverInterceptor(), + } +} + +func ClientDefaultChain() InterceptorsChain { + return []connect.Interceptor{ + timeout.TimeoutInterceptor(5 * time.Second), + logger.ClientLoggerInterceptor(), + } +} + +func ClientConfigurableChain(t time.Duration) InterceptorsChain { + return []connect.Interceptor{ + timeout.TimeoutInterceptor(t), + logger.ClientLoggerInterceptor(), + } +} diff --git a/grpc/interceptors/logger/client.go b/grpc/interceptors/logger/client.go new file mode 100644 index 0000000..291f708 --- /dev/null +++ b/grpc/interceptors/logger/client.go @@ -0,0 +1,43 @@ +package logger + +import ( + "context" + "time" + + "github.com/bufbuild/connect-go" + "github.com/rs/zerolog/log" +) + +func ClientLoggerInterceptor() connect.UnaryInterceptorFunc { + iceptor := func(next connect.UnaryFunc) connect.UnaryFunc { + return connect.UnaryFunc(func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + startTime := time.Now().UTC() + + resp, err := next(ctx, req) + requestDuration := time.Since(startTime) + + logger := log.Debug() + if err != nil { + logger = log.Error().Err(err) + } + + status := "OK" + if err != nil { + status = "ERROR" + } + + if connect.CodeOf(err) == connect.CodeNotFound { + logger = log.Debug() + } + + logger.Str("protocol", "grpc"). + Str("method", req.Spec().Procedure). + Str("status", status). + Dur("duration", requestDuration). + Msg("sent a grpc call") + + return resp, err + }) + } + return connect.UnaryInterceptorFunc(iceptor) +} diff --git a/grpc/interceptors/recover/recover.go b/grpc/interceptors/recover/recover.go new file mode 100644 index 0000000..c729c44 --- /dev/null +++ b/grpc/interceptors/recover/recover.go @@ -0,0 +1,42 @@ +package recover + +import ( + "context" + "fmt" + "runtime" + + "github.com/bufbuild/connect-go" + "github.com/rs/zerolog/log" +) + +type PanicError struct { + Panic any + Stack []byte +} + +func (e *PanicError) Error() string { + return fmt.Sprintf("panic caught: %v\n\n%s", e.Panic, e.Stack) +} + +// RecoverInterceptor recovers from panics and returns an error. +func RecoverInterceptor() connect.UnaryInterceptorFunc { + iceptor := func(next connect.UnaryFunc) connect.UnaryFunc { + return connect.UnaryFunc(func(ctx context.Context, req connect.AnyRequest) (_ connect.AnyResponse, err error) { + defer func() { + if r := recover(); r != nil { + log.Error().Msg("grpc.interceptors.recover: panic caught") + err = recoverFrom(ctx, r) + } + }() + + return next(ctx, req) + }) + } + return connect.UnaryInterceptorFunc(iceptor) +} + +func recoverFrom(ctx context.Context, p any) error { + stack := make([]byte, 64<<10) + stack = stack[:runtime.Stack(stack, false)] + return &PanicError{Panic: p, Stack: stack} +} diff --git a/grpc/interceptors/timeout/timeout.go b/grpc/interceptors/timeout/timeout.go new file mode 100644 index 0000000..7376d31 --- /dev/null +++ b/grpc/interceptors/timeout/timeout.go @@ -0,0 +1,20 @@ +package timeout + +import ( + "context" + "time" + + "github.com/bufbuild/connect-go" +) + +// TimeoutInterceptor uses the context.WithTimeout to cancel the request if it takes too long. +func TimeoutInterceptor(timeout time.Duration) connect.UnaryInterceptorFunc { + iceptor := func(next connect.UnaryFunc) connect.UnaryFunc { + return connect.UnaryFunc(func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + return next(ctx, req) + }) + } + return connect.UnaryInterceptorFunc(iceptor) +}