diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 9b8dafe..247df76 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -31,7 +31,8 @@ jobs: # Run tests - name: Run tests - run: go test ./... -v -count=1 -race -coverprofile coverage.out + # Exclude the ping grpc client from coverage + run: go test ./... -v -count=1 -race -coverprofile coverage_tmp.out && cat coverage_tmp.out | grep -v ping > coverage.out # Test Coverage check - name: Test Coverage - Test coverage shall be above threshold diff --git a/README.md b/README.md index c72c16e..1950ddf 100644 --- a/README.md +++ b/README.md @@ -294,7 +294,7 @@ func main() { } ``` -### Converting HTTP status codes to SimpleError +### Converting HTTP status codes to SimpleError from HTTP Clients The standard library `http.DefaultTransport` will return all successfully transported request/responses without error. However, most applications will react to those responses by looking at the HTTP status code. From the application's point @@ -305,23 +305,70 @@ using `simplehttp.EnableHTTPStatusErrors(rt http.RoundTripper)`. ## GRPC Status Codes +gRPC status codes can be set automatically by using the [ecosystem/grpc](https://github.com/lobocv/simplerr/tree/master/ecosystem/grpc) +package to translate `simplerr` error codes to gRPC status codes and vice versa. + +### Converting SimpleError to gRPC status codes Since GRPC functions return an error, it is even convenient to integrate error code translation using an interceptor (middleware). -The package [ecosystem/grpc](https://github.com/lobocv/simplerr/tree/master/ecosystem/http) defines an interceptor +The package [ecosystem/grpc](https://github.com/lobocv/simplerr/tree/master/ecosystem/grpc) defines an interceptor that detects if the returned error is a `SimpleError` and then translates the error code into a GRPC status code. A mapping for several codes is provided using the `DefaultMapping()` function. This can be changed by providing an alternative mapping when creating the interceptor: ```go func main() { - // Get the default mapping provided by simplerr - m := simplerr.DefaultMapping() + // Get the default registry mapping provided by simplerr + reg := simplegprc.GetDefaultRegistry() + // Add another mapping from simplerr code to GRPC code + m := simplegprc.DefaultMapping() m[simplerr.CodeMalformedRequest] = codes.InvalidArgument + + // Update the mapping in the default registry + reg.SetMapping(m) + // Create the interceptor by providing the mapping interceptor := simplerr.TranslateErrorCode(m) + + // Attach the interceptor to the server + // ... } ``` +### Converting gRPC status codes to SimpleError from gRPC Clients + +You can get your gRPC clients to return simplerr compatible errors by using the `ReturnSimpleErrors` unary client +interceptor. This interceptor examines the gRPC code in errors returned by the client and wraps them in an error that +is compatible with simplerror while also maintaining compatibility with the gprc error checking functions +`status.FromError()` and `status.Code()`: + +```go +func main() { + // Create the interceptor by providing the mapping. + // The nil argument means to use the default registry. + interceptor := ReturnSimpleErrors(nil) + + conn, err := grpc.Dial(":5001", + grpc.WithUnaryInterceptor(interceptor), + ) + + client := ping.NewPingServiceClient(conn) +} +``` + +Using this interceptor, you will be able to extract the grpc method and `*status.Status` object from the error: + +```go +v, ok := simplerr.GetAttribute(err, simplegrpc.AttrGRPCStatus) +status := v.(*status.Status) +``` + +```go +v, ok := simplerr.GetAttribute(err, simplegrpc.AttrGRPCMethod) +method := v.(string) +``` + + # Contributing Contributions and pull requests to `simplerr` are welcome but must align with the goals of the package: diff --git a/ecosystem/grpc/client_interceptor.go b/ecosystem/grpc/client_interceptor.go new file mode 100644 index 0000000..434d382 --- /dev/null +++ b/ecosystem/grpc/client_interceptor.go @@ -0,0 +1,58 @@ +package simplegrpc + +import ( + "context" + "github.com/lobocv/simplerr" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type attr int + +const ( + // AttrGRPCMethod is the simplerr attribute key for retrieving the grpc method + AttrGRPCMethod = attr(1) + // AttrGRPCStatus is the simplerr attribute key for retrieving the grpc Status object + AttrGRPCStatus = attr(2) +) + +// ReturnSimpleErrors returns a unary client interceptor that converts errors returned by the client to simplerr compatible +// errors. The underlying grpc status and code can still be extracted using the same status.FromError() and status.Code() methods +func ReturnSimpleErrors(registry *Registry) grpc.UnaryClientInterceptor { + + if registry == nil { + registry = defaultRegistry + } + + return func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { + + // Call the gRPC method + err := invoker(ctx, method, req, reply, cc, opts...) + if err == nil { + return nil + } + + grpcCode := codes.Unknown + msg := err.Error() + + serr := simplerr.New(msg).Attr(AttrGRPCMethod, method) // nolint: govet + + // Check if the error is a gRPC status error + // The GRPC framework seems to always return grpc errors on the client side, even if the server does not + // Therefore, this block should always run + if st, ok := status.FromError(err); ok { + _ = serr.Attr(AttrGRPCStatus, st) + + grpcCode = st.Code() + simplerrCode, _ := registry.getGRPCCode(grpcCode) + _ = serr.Code(simplerrCode) + } + + return &grpcError{ + SimpleError: serr, + code: grpcCode, + } + + } +} diff --git a/ecosystem/grpc/client_interceptor_test.go b/ecosystem/grpc/client_interceptor_test.go new file mode 100644 index 0000000..8010b1f --- /dev/null +++ b/ecosystem/grpc/client_interceptor_test.go @@ -0,0 +1,117 @@ +package simplegrpc + +import ( + "context" + "fmt" + "github.com/lobocv/simplerr" + "github.com/lobocv/simplerr/ecosystem/grpc/internal/ping" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "net" + "testing" +) + +type PingService struct { + err error +} + +func (s *PingService) Ping(_ context.Context, _ *ping.PingRequest) (*ping.PingResponse, error) { + // Your implementation of the Ping method goes here + fmt.Println("Received Ping request") + if s.err != nil { + return nil, s.err + } + return &ping.PingResponse{}, nil +} + +func setupServerAndClient(port int) (*PingService, ping.PingServiceClient) { + + server := grpc.NewServer() + service := &PingService{err: status.Error(codes.NotFound, "test error")} + ping.RegisterPingServiceServer(server, service) + + // Create a listener on TCP port 50051 + listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) + if err != nil { + panic(fmt.Sprintf("Error creating listener: %v", err)) + } + + go func() { + if err = server.Serve(listener); err != nil { + panic(fmt.Sprintf("Error serving: %v", err)) + } + }() + + defaultInverseMapping := DefaultInverseMapping() + defaultInverseMapping[codes.DataLoss] = simplerr.CodeResourceExhausted + GetDefaultRegistry().SetInverseMapping(defaultInverseMapping) + + interceptor := ReturnSimpleErrors(nil) + + conn, err := grpc.NewClient(fmt.Sprintf(":%d", port), + grpc.WithUnaryInterceptor(interceptor), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + panic(err) + } + client := ping.NewPingServiceClient(conn) + + return service, client +} + +func TestClientInterceptor(t *testing.T) { + + server, client := setupServerAndClient(50051) + _, err := client.Ping(context.Background(), &ping.PingRequest{}) + + require.True(t, simplerr.HasErrorCode(err, simplerr.CodeNotFound), "simplerror code can be detected") + require.Equal(t, codes.NotFound, status.Code(err), "grpc code can be detected with grpc status package") + + st, ok := simplerr.GetAttribute(err, AttrGRPCStatus) + require.True(t, ok) + require.Equal(t, codes.NotFound, st.(*status.Status).Code(), "can get the grpc Status") // nolint: errcheck + + method, ok := simplerr.GetAttribute(err, AttrGRPCMethod) + require.True(t, ok) + require.Equal(t, "/ping.PingService/Ping", method, "can get the grpc method which errored") + + // Test the custom added mapping + server.err = status.Error(codes.DataLoss, "test error") + _, err = client.Ping(context.Background(), &ping.PingRequest{}) + require.True(t, simplerr.HasErrorCode(err, simplerr.CodeResourceExhausted), "simplerror code can be detected") + +} + +// When a non grpc error is returned, the client still returns a grpc error with code Unknown +// Our interceptor should still be able to detect attributes on the error +func TestClientInterceptorNotGPRCError(t *testing.T) { + + server, client := setupServerAndClient(50052) + server.err = fmt.Errorf("not a grpc error") + + _, err := client.Ping(context.Background(), &ping.PingRequest{}) + + require.True(t, simplerr.HasErrorCode(err, simplerr.CodeUnknown), "simplerror code can be detected") + require.Equal(t, codes.Unknown, status.Code(err), "grpc code can be detected with grpc status package") + + st, ok := simplerr.GetAttribute(err, AttrGRPCStatus) + require.True(t, ok) + require.Equal(t, codes.Unknown, st.(*status.Status).Code(), "can get the grpc Status") // nolint: errcheck + + method, ok := simplerr.GetAttribute(err, AttrGRPCMethod) + require.True(t, ok) + require.Equal(t, "/ping.PingService/Ping", method, "can get the grpc method which errored") + +} + +func TestClientInterceptorNoError(t *testing.T) { + server, client := setupServerAndClient(50053) + server.err = nil + + _, err := client.Ping(context.Background(), &ping.PingRequest{}) + require.Nil(t, err) +} diff --git a/ecosystem/grpc/defaults.go b/ecosystem/grpc/defaults.go new file mode 100644 index 0000000..d3cd76d --- /dev/null +++ b/ecosystem/grpc/defaults.go @@ -0,0 +1,54 @@ +package simplegrpc + +import ( + "github.com/lobocv/simplerr" + "google.golang.org/grpc/codes" +) + +var ( + // defaultRegistry is a global registry used by default. + defaultRegistry = NewRegistry() +) + +// GetDefaultRegistry returns the currently registered default registry used by this package. +func GetDefaultRegistry() *Registry { + return defaultRegistry +} + +// DefaultMapping returns the default mapping of SimpleError codes to gRPC error codes +func DefaultMapping() map[simplerr.Code]codes.Code { + m := map[simplerr.Code]codes.Code{ + simplerr.CodeUnknown: codes.Unknown, + simplerr.CodeAlreadyExists: codes.AlreadyExists, + simplerr.CodeNotFound: codes.NotFound, + simplerr.CodeDeadlineExceeded: codes.DeadlineExceeded, + simplerr.CodeCanceled: codes.Canceled, + simplerr.CodeUnauthenticated: codes.Unauthenticated, + simplerr.CodePermissionDenied: codes.PermissionDenied, + simplerr.CodeNotImplemented: codes.Unimplemented, + simplerr.CodeInvalidArgument: codes.InvalidArgument, + simplerr.CodeResourceExhausted: codes.ResourceExhausted, + simplerr.CodeUnavailable: codes.Unavailable, + } + + return m +} + +// DefaultInverseMapping returns the default inverse mapping of gRPC error codes to SimpleError codes +func DefaultInverseMapping() map[codes.Code]simplerr.Code { + m := map[codes.Code]simplerr.Code{ + codes.Unknown: simplerr.CodeUnknown, + codes.AlreadyExists: simplerr.CodeAlreadyExists, + codes.NotFound: simplerr.CodeNotFound, + codes.DeadlineExceeded: simplerr.CodeDeadlineExceeded, + codes.Canceled: simplerr.CodeCanceled, + codes.Unauthenticated: simplerr.CodeUnauthenticated, + codes.PermissionDenied: simplerr.CodePermissionDenied, + codes.Unimplemented: simplerr.CodeNotImplemented, + codes.InvalidArgument: simplerr.CodeInvalidArgument, + codes.ResourceExhausted: simplerr.CodeResourceExhausted, + codes.Unavailable: simplerr.CodeUnavailable, + } + + return m +} diff --git a/ecosystem/grpc/grpc_error.go b/ecosystem/grpc/grpc_error.go new file mode 100644 index 0000000..f4c24b0 --- /dev/null +++ b/ecosystem/grpc/grpc_error.go @@ -0,0 +1,31 @@ +package simplegrpc + +import ( + "github.com/lobocv/simplerr" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// grpcError is a wrapper that exposes a SimpleError in a way that implements the gRPC status interface +// This is required because the grpc `status` library returns an error that does not implement unwrapping. +type grpcError struct { + *simplerr.SimpleError + code codes.Code +} + +// Unwrap implement the interface required for error unwrapping +func (e *grpcError) Unwrap() error { + return e.SimpleError +} + +// GRPCStatus implements an interface that the gRPC framework uses to return the gRPC status code +func (e *grpcError) GRPCStatus() *status.Status { + // If the status was attached as an attribute, return it + v, _ := simplerr.GetAttribute(e.SimpleError, AttrGRPCStatus) + st, ok := v.(*status.Status) + if ok { + return st + } + + return status.New(e.code, e.Error()) +} diff --git a/ecosystem/grpc/internal/ping/ping.pb.go b/ecosystem/grpc/internal/ping/ping.pb.go new file mode 100644 index 0000000..90489bf --- /dev/null +++ b/ecosystem/grpc/internal/ping/ping.pb.go @@ -0,0 +1,193 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// source: projects/content/pkg/organic/temp/ping.proto + +package ping + +import ( + context "context" + fmt "fmt" + proto "github.com/golang/protobuf/proto" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" + math "math" +) + +// Reference imports to suppress errors if they are not otherwise used. +var _ = proto.Marshal +var _ = fmt.Errorf +var _ = math.Inf + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the proto package it is being compiled against. +// A compilation error at this line likely means your copy of the +// proto package needs to be updated. +const _ = proto.ProtoPackageIsVersion3 // please upgrade the proto package + +// Define the request message +type PingRequest struct { + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *PingRequest) Reset() { *m = PingRequest{} } +func (m *PingRequest) String() string { return proto.CompactTextString(m) } +func (*PingRequest) ProtoMessage() {} +func (*PingRequest) Descriptor() ([]byte, []int) { + return fileDescriptor_3341227b34ddad8e, []int{0} +} + +func (m *PingRequest) XXX_Unmarshal(b []byte) error { + return xxx_messageInfo_PingRequest.Unmarshal(m, b) +} +func (m *PingRequest) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + return xxx_messageInfo_PingRequest.Marshal(b, m, deterministic) +} +func (m *PingRequest) XXX_Merge(src proto.Message) { + xxx_messageInfo_PingRequest.Merge(m, src) +} +func (m *PingRequest) XXX_Size() int { + return xxx_messageInfo_PingRequest.Size(m) +} +func (m *PingRequest) XXX_DiscardUnknown() { + xxx_messageInfo_PingRequest.DiscardUnknown(m) +} + +var xxx_messageInfo_PingRequest proto.InternalMessageInfo + +// Define the response message +type PingResponse struct { + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *PingResponse) Reset() { *m = PingResponse{} } +func (m *PingResponse) String() string { return proto.CompactTextString(m) } +func (*PingResponse) ProtoMessage() {} +func (*PingResponse) Descriptor() ([]byte, []int) { + return fileDescriptor_3341227b34ddad8e, []int{1} +} + +func (m *PingResponse) XXX_Unmarshal(b []byte) error { + return xxx_messageInfo_PingResponse.Unmarshal(m, b) +} +func (m *PingResponse) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + return xxx_messageInfo_PingResponse.Marshal(b, m, deterministic) +} +func (m *PingResponse) XXX_Merge(src proto.Message) { + xxx_messageInfo_PingResponse.Merge(m, src) +} +func (m *PingResponse) XXX_Size() int { + return xxx_messageInfo_PingResponse.Size(m) +} +func (m *PingResponse) XXX_DiscardUnknown() { + xxx_messageInfo_PingResponse.DiscardUnknown(m) +} + +var xxx_messageInfo_PingResponse proto.InternalMessageInfo + +func init() { + proto.RegisterType((*PingRequest)(nil), "ping.PingRequest") + proto.RegisterType((*PingResponse)(nil), "ping.PingResponse") +} + +func init() { + proto.RegisterFile("projects/content/pkg/organic/temp/ping.proto", fileDescriptor_3341227b34ddad8e) +} + +var fileDescriptor_3341227b34ddad8e = []byte{ + // 136 bytes of a gzipped FileDescriptorProto + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xe2, 0xd2, 0x29, 0x28, 0xca, 0xcf, + 0x4a, 0x4d, 0x2e, 0x29, 0xd6, 0x4f, 0xce, 0xcf, 0x2b, 0x49, 0xcd, 0x2b, 0xd1, 0x2f, 0xc8, 0x4e, + 0xd7, 0xcf, 0x2f, 0x4a, 0x4f, 0xcc, 0xcb, 0x4c, 0xd6, 0x2f, 0x49, 0xcd, 0x2d, 0xd0, 0x2f, 0xc8, + 0xcc, 0x4b, 0xd7, 0x2b, 0x28, 0xca, 0x2f, 0xc9, 0x17, 0x62, 0x01, 0xb1, 0x95, 0x78, 0xb9, 0xb8, + 0x03, 0x32, 0xf3, 0xd2, 0x83, 0x52, 0x0b, 0x4b, 0x53, 0x8b, 0x4b, 0x94, 0xf8, 0xb8, 0x78, 0x20, + 0xdc, 0xe2, 0x82, 0xfc, 0xbc, 0xe2, 0x54, 0x23, 0x1b, 0x88, 0x74, 0x70, 0x6a, 0x51, 0x59, 0x66, + 0x72, 0xaa, 0x90, 0x2e, 0x17, 0x0b, 0x88, 0x2b, 0x24, 0xa8, 0x07, 0x36, 0x08, 0x49, 0xa7, 0x94, + 0x10, 0xb2, 0x10, 0x44, 0x77, 0x12, 0x1b, 0xd8, 0x26, 0x63, 0x40, 0x00, 0x00, 0x00, 0xff, 0xff, + 0x9c, 0x61, 0xa6, 0xa9, 0x99, 0x00, 0x00, 0x00, +} + +// Reference imports to suppress errors if they are not otherwise used. +var _ context.Context +var _ grpc.ClientConn + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +const _ = grpc.SupportPackageIsVersion4 + +// PingServiceClient is the client API for PingService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://godoc.org/google.golang.org/grpc#ClientConn.NewStream. +type PingServiceClient interface { + // RPC method for ping + Ping(ctx context.Context, in *PingRequest, opts ...grpc.CallOption) (*PingResponse, error) +} + +type pingServiceClient struct { + cc *grpc.ClientConn +} + +func NewPingServiceClient(cc *grpc.ClientConn) PingServiceClient { + return &pingServiceClient{cc} +} + +func (c *pingServiceClient) Ping(ctx context.Context, in *PingRequest, opts ...grpc.CallOption) (*PingResponse, error) { + out := new(PingResponse) + err := c.cc.Invoke(ctx, "/ping.PingService/Ping", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +// PingServiceServer is the server API for PingService service. +type PingServiceServer interface { + // RPC method for ping + Ping(context.Context, *PingRequest) (*PingResponse, error) +} + +// UnimplementedPingServiceServer can be embedded to have forward compatible implementations. +type UnimplementedPingServiceServer struct { +} + +func (*UnimplementedPingServiceServer) Ping(ctx context.Context, req *PingRequest) (*PingResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method Ping not implemented") +} + +func RegisterPingServiceServer(s *grpc.Server, srv PingServiceServer) { + s.RegisterService(&_PingService_serviceDesc, srv) +} + +func _PingService_Ping_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(PingRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(PingServiceServer).Ping(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/ping.PingService/Ping", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(PingServiceServer).Ping(ctx, req.(*PingRequest)) + } + return interceptor(ctx, in, info, handler) +} + +var _PingService_serviceDesc = grpc.ServiceDesc{ + ServiceName: "ping.PingService", + HandlerType: (*PingServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Ping", + Handler: _PingService_Ping_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "projects/content/pkg/organic/temp/ping.proto", +} diff --git a/ecosystem/grpc/internal/ping/ping.proto b/ecosystem/grpc/internal/ping/ping.proto new file mode 100644 index 0000000..41f8045 --- /dev/null +++ b/ecosystem/grpc/internal/ping/ping.proto @@ -0,0 +1,19 @@ +syntax = "proto3"; + +package ping; + +// Define the service +service PingService { + // RPC method for ping + rpc Ping (PingRequest) returns (PingResponse); +} + +// Define the request message +message PingRequest { + // You can add any necessary fields here +} + +// Define the response message +message PingResponse { + // You can add any necessary fields here +} diff --git a/ecosystem/grpc/registry.go b/ecosystem/grpc/registry.go new file mode 100644 index 0000000..6aea68f --- /dev/null +++ b/ecosystem/grpc/registry.go @@ -0,0 +1,39 @@ +package simplegrpc + +import ( + "github.com/lobocv/simplerr" + "google.golang.org/grpc/codes" +) + +// Registry is a registry which contains the mapping between simplerr codes and grpc error codes +type Registry struct { + toGRPC map[simplerr.Code]codes.Code + fromGRPC map[codes.Code]simplerr.Code +} + +// NewRegistry creates a new registry which contains the mapping to and from simplerr codes and grpc error codes +func NewRegistry() *Registry { + return &Registry{ + toGRPC: DefaultMapping(), + fromGRPC: DefaultInverseMapping(), + } +} + +// SetMapping sets the mapping from simplerr.Code to gRPC code +func (r *Registry) SetMapping(m map[simplerr.Code]codes.Code) { + r.toGRPC = m +} + +// SetInverseMapping sets the mapping from gRPC code to simplerr.Code +func (r *Registry) SetInverseMapping(m map[codes.Code]simplerr.Code) { + r.fromGRPC = m +} + +// getGRPCCode gets the simplerr Code that corresponds to the GRPC code. It returns CodeUnknown if it cannot map the status. +func (r *Registry) getGRPCCode(grpcCode codes.Code) (code simplerr.Code, found bool) { + code, ok := r.fromGRPC[grpcCode] + if !ok { + return simplerr.CodeUnknown, false + } + return code, true +} diff --git a/ecosystem/grpc/registry_test.go b/ecosystem/grpc/registry_test.go new file mode 100644 index 0000000..a9f8af9 --- /dev/null +++ b/ecosystem/grpc/registry_test.go @@ -0,0 +1,20 @@ +package simplegrpc + +import ( + "github.com/lobocv/simplerr" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "testing" +) + +func TestGetGPRCCode(t *testing.T) { + + reg := NewRegistry() + simplerrCode, found := reg.getGRPCCode(codes.NotFound) + require.True(t, found) + require.Equal(t, simplerr.CodeNotFound, simplerrCode) + + simplerrCode, found = reg.getGRPCCode(codes.Code(100000)) + require.False(t, found) + require.Equal(t, simplerr.CodeUnknown, simplerrCode) +} diff --git a/ecosystem/grpc/server_interceptor.go b/ecosystem/grpc/server_interceptor.go new file mode 100644 index 0000000..e9b9690 --- /dev/null +++ b/ecosystem/grpc/server_interceptor.go @@ -0,0 +1,54 @@ +package simplegrpc + +import ( + "context" + "github.com/lobocv/simplerr" + "google.golang.org/grpc" +) + +// TranslateErrorCode inspects the error to see if it is a SimpleError. If it is, it attempts to translate the +// SimpleError code to the corresponding grpc error code. +// If no translation exists it returns a grpc error with Unknown error code. +func TranslateErrorCode(registry *Registry) grpc.UnaryServerInterceptor { + + if registry == nil { + registry = defaultRegistry + } + + // Get a list of simplerr codes to search for in the error chain + var simplerrCodes []simplerr.Code + for c := range registry.toGRPC { + // Ignore CodeUnknown because it is the default code + if c == simplerr.CodeUnknown { + continue + } + simplerrCodes = append(simplerrCodes, c) + } + + return func(ctx context.Context, req interface{}, _ *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp interface{}, err error) { + r, err := handler(ctx, req) + // If no error, return early + if err == nil { + return r, nil + } + + // Check the error to see if it's a SimpleError, then translate to the gRPC code + if e := simplerr.As(err); e != nil { + + // Check if the error has any of the codes in it's chain + code, ok := simplerr.HasErrorCodes(e, simplerrCodes...) + if !ok { + return r, err + } + + // Get the gRPC code, this lookup should never fail + grpcCode := registry.toGRPC[code] + return r, &grpcError{ + SimpleError: e, + code: grpcCode, + } + } + + return r, err + } +} diff --git a/ecosystem/grpc/translate_error_code_test.go b/ecosystem/grpc/server_interceptor_test.go similarity index 60% rename from ecosystem/grpc/translate_error_code_test.go rename to ecosystem/grpc/server_interceptor_test.go index 62bad05..71b0e15 100644 --- a/ecosystem/grpc/translate_error_code_test.go +++ b/ecosystem/grpc/server_interceptor_test.go @@ -27,10 +27,12 @@ func TestTranslateErrorCode(t *testing.T) { } // Alter the default mapping + reg := GetDefaultRegistry() m := DefaultMapping() m[simplerr.CodeMalformedRequest] = codes.InvalidArgument + reg.SetMapping(m) - interceptor := TranslateErrorCode(m) + interceptor := TranslateErrorCode(reg) for _, tc := range testCases { _, gotErr := interceptor(context.Background(), nil, nil, func(ctx context.Context, req interface{}) (interface{}, error) { return 1, tc.err @@ -48,3 +50,27 @@ func TestTranslateErrorCode(t *testing.T) { } } + +// Test that multiple different registry can be used at the same time +func TestMultipleRegistry(t *testing.T) { + ctx := context.Background() + // Create and use two different registries (default and a custom) in the interceptors + + interceptor1 := TranslateErrorCode(nil) + + reg2 := NewRegistry() + m2 := DefaultMapping() + m2[simplerr.CodeDeadlineExceeded] = codes.Internal + reg2.SetMapping(m2) + interceptor2 := TranslateErrorCode(reg2) + + _, gotErr := interceptor1(ctx, nil, nil, func(ctx context.Context, req interface{}) (interface{}, error) { + return 1, simplerr.New("error occurred").Code(simplerr.CodeDeadlineExceeded) + }) + require.Equal(t, codes.DeadlineExceeded, status.Code(gotErr)) + + _, gotErr = interceptor2(ctx, nil, nil, func(ctx context.Context, req interface{}) (interface{}, error) { + return 1, simplerr.New("error occurred").Code(simplerr.CodeDeadlineExceeded) + }) + require.Equal(t, codes.Internal, status.Code(gotErr)) +} diff --git a/ecosystem/grpc/translate_error_code.go b/ecosystem/grpc/translate_error_code.go deleted file mode 100644 index cd9e7ee..0000000 --- a/ecosystem/grpc/translate_error_code.go +++ /dev/null @@ -1,88 +0,0 @@ -package simplegrpc - -import ( - "context" - "github.com/lobocv/simplerr" - - "google.golang.org/grpc" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" -) - -// DefaultMapping returns the default mapping of SimpleError codes to gRPC error codes -func DefaultMapping() map[simplerr.Code]codes.Code { - m := map[simplerr.Code]codes.Code{ - simplerr.CodeUnknown: codes.Unknown, - simplerr.CodeAlreadyExists: codes.AlreadyExists, - simplerr.CodeNotFound: codes.NotFound, - simplerr.CodeDeadlineExceeded: codes.DeadlineExceeded, - simplerr.CodeCanceled: codes.Canceled, - simplerr.CodeUnauthenticated: codes.Unauthenticated, - simplerr.CodePermissionDenied: codes.PermissionDenied, - simplerr.CodeNotImplemented: codes.Unimplemented, - simplerr.CodeInvalidArgument: codes.InvalidArgument, - simplerr.CodeResourceExhausted: codes.ResourceExhausted, - } - - return m -} - -// TranslateErrorCode inspects the error to see if it is a SimpleError. If it is, it attempts to translate the -// SimpleError code to the corresponding grpc error code. -// If no translation exists it returns a grpc error with Unknown error code. -func TranslateErrorCode(toGRPC map[simplerr.Code]codes.Code) grpc.UnaryServerInterceptor { - - // Get a list of simplerr codes to search for in the error chain - var simplerrCodes []simplerr.Code - for c := range toGRPC { - // Ignore CodeUnknown because it is the default code - if c == simplerr.CodeUnknown { - continue - } - simplerrCodes = append(simplerrCodes, c) - } - - return func(ctx context.Context, req interface{}, _ *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp interface{}, err error) { - r, err := handler(ctx, req) - // If no error, return early - if err == nil { - return r, nil - } - - // Check the error to see if it's a SimpleError, then translate to the gRPC code - if e := simplerr.As(err); e != nil { - - // Check if the error has any of the codes in it's chain - code, ok := simplerr.HasErrorCodes(e, simplerrCodes...) - if !ok { - return r, err - } - - // Get the gRPC code, this lookup should never fail - grpcCode := toGRPC[code] - return r, &grpcError{ - SimpleError: e, - code: grpcCode, - } - } - - return r, err - } -} - -// grpcError is a wrapper that exposes a SimpleError in a way that implements the gRPC status interface -// This is required because the grpc `status` library returns an error that does not implement unwrapping. -type grpcError struct { - *simplerr.SimpleError - code codes.Code -} - -// Unwrap implement the interface required for error unwrapping -func (e *grpcError) Unwrap() error { - return e.SimpleError -} - -// GRPCStatus implements an interface that the gRPC framework uses to return the gRPC status code -func (e *grpcError) GRPCStatus() *status.Status { - return status.New(e.code, e.Error()) -} diff --git a/ecosystem/http/translate_error_code.go b/ecosystem/http/translate_error_code.go index 638e58b..7274559 100644 --- a/ecosystem/http/translate_error_code.go +++ b/ecosystem/http/translate_error_code.go @@ -10,13 +10,16 @@ import ( // HTTPStatus is the HTTP status code type HTTPStatus = int -var mapping map[simplerr.Code]HTTPStatus -var inverseMapping map[HTTPStatus]simplerr.Code +var ( + mapping map[simplerr.Code]HTTPStatus -var simplerrCodes []simplerr.Code -var defaultErrorStatus = http.StatusInternalServerError + inverseMapping map[HTTPStatus]simplerr.Code -var lock = sync.Mutex{} + simplerrCodes []simplerr.Code + defaultErrorStatus = http.StatusInternalServerError + + lock = sync.Mutex{} +) // DefaultMapping returns the default mapping of SimpleError codes to HTTP status codes func DefaultMapping() map[simplerr.Code]HTTPStatus { @@ -130,7 +133,7 @@ func GetStatus(err error) (status HTTPStatus, found bool) { return httpCode, true } -// GetCode gets the simplerror Code that corresponds to the HTTPStatus. It returns CodeUknown if it cannot map the status. +// GetCode gets the simplerror Code that corresponds to the HTTPStatus. It returns CodeUnknown if it cannot map the status. func GetCode(status HTTPStatus) (code simplerr.Code, found bool) { code, ok := inverseMapping[status] if !ok { diff --git a/go.mod b/go.mod index 36493b2..53194bf 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/lobocv/simplerr go 1.23.2 require ( + github.com/golang/protobuf v1.5.4 github.com/stretchr/testify v1.10.0 google.golang.org/grpc v1.69.4 )