diff --git a/README.md b/README.md index ae32a09..af409df 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,7 @@ err := c.Call( ```go import ( http "go.unistack.org/micro-client-http/v4" + status "go.unistack.org/micro-client-http/v4/status" jsoncodec "go.unistack.org/micro-codec-json/v4" ) @@ -151,4 +152,13 @@ err := c.Call( "403": &protoSpecialError{}, // <- key is the HTTP status code that is mapped to this error }), ) + +if err != nil { + s, ok := status.FromError(err) + if !ok {...} + + code := s.Code() // HTTP status code + message := s.Message() // HTTP status text + details := s.Details() // Error type mapped from ErrorMap +} ``` diff --git a/client_unary_call.go b/client_unary_call.go index 0507449..3dbd6ee 100644 --- a/client_unary_call.go +++ b/client_unary_call.go @@ -16,6 +16,8 @@ import ( "go.unistack.org/micro/v4/logger" "go.unistack.org/micro/v4/metadata" "go.unistack.org/micro/v4/selector" + + "go.unistack.org/micro-client-http/v4/status" ) func (c *Client) fnCall(ctx context.Context, req client.Request, rsp any, opts ...client.CallOption) error { @@ -246,6 +248,8 @@ func (c *Client) parseRsp(ctx context.Context, hrsp *http.Response, rsp any, opt return nil } + s := status.New(hrsp.StatusCode) + var mappedErr any errMap, ok := errorMapFromOpts(opts) @@ -257,17 +261,12 @@ func (c *Client) parseRsp(ctx context.Context, hrsp *http.Response, rsp any, opt } if !ok || mappedErr == nil { - return errors.New("go.micro.client", string(buf), int32(hrsp.StatusCode)) + return s.Err() } if err = cf.Unmarshal(buf, mappedErr); err != nil { return errors.InternalServerError("go.micro.client", "unmarshal response: %v", err) } - if v, ok := mappedErr.(error); ok { - return v - } - - // if the error map item does not implement the error interface, wrap it - return &Error{err: mappedErr} + return s.WithDetails(mappedErr).Err() } diff --git a/client_unary_call_test.go b/client_unary_call_test.go index e0147fc..b9a5a2b 100644 --- a/client_unary_call_test.go +++ b/client_unary_call_test.go @@ -11,11 +11,13 @@ import ( "github.com/stretchr/testify/require" jsoncodec "go.unistack.org/micro-codec-json/v4" "go.unistack.org/micro/v4/client" + microerr "go.unistack.org/micro/v4/errors" "go.unistack.org/micro/v4/metadata" "google.golang.org/protobuf/proto" httpcli "go.unistack.org/micro-client-http/v4" pb "go.unistack.org/micro-client-http/v4/builder/proto" + "go.unistack.org/micro-client-http/v4/status" ) func TestClient_Call_Get(t *testing.T) { @@ -648,7 +650,7 @@ func TestClient_Call_Delete(t *testing.T) { } } -func TestClient_Call_ErrorsMap(t *testing.T) { +func TestClient_Call_APIError_WithErrorsMap(t *testing.T) { type ( request = pb.Test_Client_Call_Request response = pb.Test_Client_Call_Response @@ -657,9 +659,9 @@ func TestClient_Call_ErrorsMap(t *testing.T) { ) tests := []struct { - name string - serverMock func() *httptest.Server - expectedErr error + name string + serverMock func() *httptest.Server + expectedStatus *status.Status }{ { name: "default error", @@ -699,7 +701,12 @@ func TestClient_Call_ErrorsMap(t *testing.T) { require.NoError(t, err) })) }, - expectedErr: &defaultError{Code: "default-error-code", Msg: "default-error-message"}, + expectedStatus: status.New(http.StatusBadRequest).WithDetails( + &defaultError{ + Code: "default-error-code", + Msg: "default-error-message", + }, + ), }, { name: "special error", @@ -740,7 +747,13 @@ func TestClient_Call_ErrorsMap(t *testing.T) { require.NoError(t, err) })) }, - expectedErr: &specialError{Code: "special-error-code", Msg: "special-error-message", Warning: "special-error-warning"}, + expectedStatus: status.New(http.StatusForbidden).WithDetails( + &specialError{ + Code: "special-error-code", + Msg: "special-error-message", + Warning: "special-error-warning", + }, + ), }, } @@ -783,7 +796,11 @@ func TestClient_Call_ErrorsMap(t *testing.T) { opts..., ) - require.Equal(t, tt.expectedErr.Error(), err.Error()) + require.Error(t, err) + s, ok := status.FromError(err) + require.True(t, ok) + require.NotNil(t, s) + require.Equal(t, tt.expectedStatus, s) require.Empty(t, rsp) require.Equal(t, "application/json", respMetadata.GetJoined("Content-Type")) @@ -792,6 +809,91 @@ func TestClient_Call_ErrorsMap(t *testing.T) { } } +func TestClient_Call_APIError_WithoutErrorsMap(t *testing.T) { + type ( + request = pb.Test_Client_Call_Request + response = pb.Test_Client_Call_Response + ) + + serverMock := func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Validate request + require.Equal(t, "POST", r.Method) + require.Equal(t, "/user/products", r.URL.RequestURI()) + + require.Equal(t, "application/json", r.Header.Get("Content-Type")) + require.Equal(t, "Bearer token", r.Header.Get("Authorization")) + require.Equal(t, "My-Header-Value", r.Header.Get("My-Header")) + + buf, err := io.ReadAll(r.Body) + require.NoError(t, err) + defer r.Body.Close() + + c := jsoncodec.NewCodec() + + req := &request{} + err = c.Unmarshal(buf, req) + require.NoError(t, err) + require.True(t, proto.Equal(&request{UserId: "123", OrderId: 456}, req)) + + // Return response + w.Header().Set("Content-Type", "application/json") + w.Header().Set("My-Header", "My-Header-Value") + w.WriteHeader(http.StatusConflict) + + resp := map[string]interface{}{"message": "not-mapped-error"} + buf, err = c.Marshal(resp) + require.NoError(t, err) + _, err = w.Write(buf) + require.NoError(t, err) + })) + } + expectedStatus := status.New(http.StatusConflict) + + server := serverMock() + defer server.Close() + + httpClient := httpcli.NewClient( + client.Codec("application/json", jsoncodec.NewCodec()), + ) + + var ( + ctx = metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("Authorization", "Bearer token", "My-Header", "My-Header-Value"), + ) + req = &request{UserId: "123", OrderId: 456} + rsp = &response{} + + respMetadata = metadata.Metadata{} + ) + + opts := []client.CallOption{ + client.WithAddress(server.URL), + client.WithResponseMetadata(&respMetadata), + httpcli.Method(http.MethodPost), + httpcli.Path("/user/products"), + httpcli.Body("*"), + } + + err := httpClient.Call( + ctx, + httpClient.NewRequest("test.service", "Test.Call", req), + rsp, + opts..., + ) + + require.Error(t, err) + s, ok := status.FromError(err) + require.True(t, ok) + require.NotNil(t, s) + require.Equal(t, expectedStatus, s) + require.Empty(t, rsp) + + require.Equal(t, "application/json", respMetadata.GetJoined("Content-Type")) + require.Equal(t, "My-Header-Value", respMetadata.GetJoined("My-Header")) +} + func TestClient_Call_HeadersAndCookies(t *testing.T) { type ( request = pb.Test_Client_Call_Request @@ -1197,6 +1299,12 @@ func TestClient_Call_RequestTimeoutError(t *testing.T) { opts..., ) require.Error(t, err) + require.Equal(t, err, µerr.Error{ + ID: "go.micro.client", + Detail: "context deadline exceeded", + Status: "Request Timeout", + Code: http.StatusRequestTimeout, + }) } func TestClient_Call_ContextDeadlineError(t *testing.T) { @@ -1239,6 +1347,12 @@ func TestClient_Call_ContextDeadlineError(t *testing.T) { opts..., ) require.Error(t, err) + require.Equal(t, err, µerr.Error{ + ID: "go.micro.client", + Detail: "context deadline exceeded", + Status: "Request Timeout", + Code: http.StatusRequestTimeout, + }) } func TestClient_Call_ContextCanceled(t *testing.T) { @@ -1281,4 +1395,10 @@ func TestClient_Call_ContextCanceled(t *testing.T) { opts..., ) require.Error(t, err) + require.Equal(t, err, µerr.Error{ + ID: "go.micro.client", + Detail: "context canceled", + Status: "Request Timeout", + Code: http.StatusRequestTimeout, + }) } diff --git a/error.go b/error.go deleted file mode 100644 index d878791..0000000 --- a/error.go +++ /dev/null @@ -1,12 +0,0 @@ -package http - -import "fmt" - -// Error is used when items in the error map do not implement the error interface and need to be wrapped. -type Error struct { - err any -} - -func (e *Error) Error() string { - return fmt.Sprintf("%+v", e.err) -} diff --git a/status/error.go b/status/error.go new file mode 100644 index 0000000..1d54926 --- /dev/null +++ b/status/error.go @@ -0,0 +1,14 @@ +package status + +// Error is a thin wrapper around Status that implements the error interface. +type Error struct { + s *Status +} + +func (e *Error) Error() string { + return e.s.String() +} + +func (e *Error) HTTPStatus() *Status { + return e.s +} diff --git a/status/status.go b/status/status.go new file mode 100644 index 0000000..32c7152 --- /dev/null +++ b/status/status.go @@ -0,0 +1,54 @@ +package status + +import ( + "errors" + "fmt" + "net/http" +) + +// Status represents the outcome of an HTTP request in a style similar to gRPC status. +type Status struct { + code int // HTTP status code + message string // HTTP status text + details any // parsed error object +} + +func New(statusCode int) *Status { + return &Status{code: statusCode, message: http.StatusText(statusCode)} +} + +func FromError(err error) (*Status, bool) { + if err == nil { + return nil, false + } + var e *Error + if errors.As(err, &e) { + return e.HTTPStatus(), true + } + return nil, false +} + +func (s *Status) Code() int { + return s.code +} + +func (s *Status) Message() string { + return s.message +} + +func (s *Status) Details() any { + return s.details +} + +func (s *Status) WithDetails(details any) *Status { + s.details = details + return s +} + +func (s *Status) String() string { + return fmt.Sprintf("http error: code = %d desc = %s", s.Code(), s.Message()) +} + +func (s *Status) Err() error { + return &Error{s: s} +} diff --git a/status/status_test.go b/status/status_test.go new file mode 100644 index 0000000..e368d5b --- /dev/null +++ b/status/status_test.go @@ -0,0 +1,97 @@ +package status_test + +import ( + "errors" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + + "go.unistack.org/micro-client-http/v4/status" +) + +type fakeError struct{ s *status.Status } + +func (fe *fakeError) Error() string { + return fe.s.String() +} + +func TestNew(t *testing.T) { + s := status.New(http.StatusNotFound) + require.Equal(t, http.StatusNotFound, s.Code()) + require.Equal(t, "Not Found", s.Message()) +} + +func TestFromError(t *testing.T) { + tests := []struct { + name string + input error + wantStatus *status.Status + wantOK bool + }{ + { + name: "nil error", + input: nil, + wantStatus: nil, + wantOK: false, + }, + { + name: "simple error", + input: errors.New("some error"), + wantStatus: nil, + wantOK: false, + }, + { + name: "unexpected type of error", + input: func() error { + return &fakeError{s: status.New(http.StatusNotFound)} + }(), + wantStatus: nil, + wantOK: false, + }, + { + name: "expected type of error", + input: status.New(http.StatusNotFound).Err(), + wantStatus: status.New(http.StatusNotFound), + wantOK: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, ok := status.FromError(tt.input) + require.Equal(t, tt.wantStatus, result) + require.Equal(t, tt.wantOK, ok) + }) + } +} + +func TestStatus_Code(t *testing.T) { + s := status.New(http.StatusNotFound) + require.Equal(t, http.StatusNotFound, s.Code()) +} + +func TestStatus_Message(t *testing.T) { + s := status.New(http.StatusNotFound) + require.Equal(t, "Not Found", s.Message()) +} + +func TestStatus_WithDetails(t *testing.T) { + s := status.New(http.StatusNotFound).WithDetails(errors.New("some error")) + require.Equal(t, errors.New("some error"), s.Details()) +} + +func TestStatus_String(t *testing.T) { + s := status.New(http.StatusInternalServerError) + expected := fmt.Sprintf("http error: code = %d desc = %s", 500, "Internal Server Error") + require.Equal(t, expected, s.String()) +} + +func TestStatus_Err(t *testing.T) { + var e *status.Error + s := status.New(http.StatusForbidden) + require.Error(t, s.Err()) + require.ErrorAs(t, s.Err(), &e) + require.Equal(t, status.New(http.StatusForbidden), e.HTTPStatus()) +}