Skip to content
This repository was archived by the owner on Jun 12, 2024. It is now read-only.

Commit 61a10a7

Browse files
authored
feat: allow customizing http handler error parsing (#270)
Expose the default Handler implementation and allow customizing the error parsing by passing a new parser function. This allows overriding the error handling but with a reasonable default. Additionally, change ValidationErrorsToFiedErrorResponse signature to accept any map[string]error. This is the underlying type for a validation.Error, so it is backwards compatible, but it is also more flexible and will accept more type aliases for validation errors, making it easier to use. Signed-off-by: Lucas Roesler <[email protected]>
1 parent b086620 commit 61a10a7

File tree

3 files changed

+132
-92
lines changed

3 files changed

+132
-92
lines changed

pkg/errors/errors.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ type ValidationErrors = validation.Errors
4444

4545
// ValidationErrorsToFieldErrorResponse converts validation errors to the format that is
4646
// served by HTTP handlers
47-
func ValidationErrorsToFieldErrorResponse(errs ValidationErrors) (resp ErrorResponse) {
47+
func ValidationErrorsToFieldErrorResponse(errs map[string]error) (resp ErrorResponse) {
4848
resp.Errors = make([]APIErrorMessenger, 0, len(errs))
4949
for key, fieldErr := range errs {
5050
if fieldErr == nil {

pkg/http/handlers/base.go

Lines changed: 117 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import (
66
"encoding/json"
77
"io"
88
"net/http"
9-
"strings"
109

1110
cerrors "github.com/contiamo/go-base/v4/pkg/errors"
1211
"github.com/contiamo/go-base/v4/pkg/tracing"
@@ -20,6 +19,9 @@ const (
2019
Megabyte = 1024 * 1024
2120
)
2221

22+
// ErrorParser is a function that parses an error into an HTTP status code and response body.
23+
type ErrorParser = func(ctx context.Context, err error, debug bool) (int, interface{})
24+
2325
// BaseHandler contains all the base functions every handler should have
2426
type BaseHandler interface {
2527
tracing.Tracer
@@ -34,48 +36,105 @@ type BaseHandler interface {
3436
// NewBaseHandler creates a new base HTTP handler that
3537
// contains shared logic among all the handlers.
3638
// The handler supports parsing and writing JSON objects
37-
// `maxBodyBytes` is the maximal request body size, < 0 means the default Megabyte.
39+
// `maxBodyBytes` is the maximal request body size, < 0 means the default Megabyte. Using 0 will disable the limit.
3840
// `componentName` is used for tracing to identify to which
3941
// component this handler belongs to.
40-
func NewBaseHandler(componentName string, maxBodyBytes int64, debug bool) BaseHandler {
42+
func NewBaseHandler(componentName string, maxBodyBytes int64, debug bool) *Handler {
4143
if maxBodyBytes < 0 {
4244
maxBodyBytes = Megabyte
4345
}
44-
return &baseHandler{
46+
return &Handler{
4547
Tracer: tracing.NewTracer("handlers", componentName),
46-
maxBodyBytes: maxBodyBytes,
47-
debug: debug,
48+
MaxBodyBytes: maxBodyBytes,
49+
Debug: debug,
4850
}
4951
}
5052

5153
// NewBasehandlerWithTracer create a new base HTTP handler, like NewBaseHandler, but allows
5254
// the caller to configure the Tracer implementation independently.
53-
func NewBaseHandlerWithTracer(tracer tracing.Tracer, maxBodyBytes int64, debug bool) BaseHandler {
55+
//
56+
// Deprecated: you can now configure/override the default Tracer using
57+
//
58+
// h := NewBaseHandler(componentName, maxBodyBytes, debug)
59+
// h.Tracer = tracing.NewTracer("handlers", componentName)
60+
func NewBaseHandlerWithTracer(tracer tracing.Tracer, maxBodyBytes int64, debug bool) *Handler {
5461
if maxBodyBytes < 0 {
5562
maxBodyBytes = Megabyte
5663
}
57-
return &baseHandler{
64+
return &Handler{
5865
Tracer: tracer,
59-
maxBodyBytes: maxBodyBytes,
60-
debug: debug,
66+
MaxBodyBytes: maxBodyBytes,
67+
ErrorParser: DefaultErrorParser,
68+
Debug: debug,
6169
}
6270
}
6371

64-
type baseHandler struct {
72+
// Handler is the default implementation of BaseHandler and is suitable for use in
73+
// most REST API implementations.
74+
type Handler struct {
6575
tracing.Tracer
66-
maxBodyBytes int64
67-
debug bool
76+
// ErrorParser is used to parse error objects into HTTP status codes and response bodies.
77+
ErrorParser ErrorParser
78+
// MaxBodyBytes is the maximal request body size, < 0 means the default Megabyte.
79+
// Using 0 will disable the limit and allow parsing streams.
80+
MaxBodyBytes int64
81+
// Debug was used to enable/disable Debug mode, when enabled error messages will be included in responses.
82+
Debug bool
6883
}
6984

70-
func (h *baseHandler) Error(ctx context.Context, w http.ResponseWriter, err error) {
85+
func (h *Handler) Error(ctx context.Context, w http.ResponseWriter, err error) {
7186
span, _ := h.StartSpan(ctx, "Error")
7287
defer h.FinishSpan(span, nil)
7388

74-
if err == nil {
75-
w.WriteHeader(http.StatusOK)
89+
parser := h.ErrorParser
90+
if parser == nil {
91+
parser = DefaultErrorParser
92+
}
93+
94+
status, resp := parser(ctx, err, h.Debug)
95+
h.Write(ctx, w, status, resp)
96+
}
97+
98+
func (h *Handler) Write(ctx context.Context, w http.ResponseWriter, status int, obj interface{}) {
99+
span, _ := h.StartSpan(ctx, "Write")
100+
var err error
101+
defer func() {
102+
h.FinishSpan(span, err)
103+
}()
104+
105+
if obj == nil {
106+
w.WriteHeader(status)
76107
return
77108
}
78109

110+
w.Header().Set("Content-Type", "application/json")
111+
w.WriteHeader(status)
112+
enc := json.NewEncoder(w)
113+
err = enc.Encode(obj)
114+
}
115+
116+
func (h *Handler) Parse(r *http.Request, out interface{}) error {
117+
var body io.Reader = r.Body
118+
if h.MaxBodyBytes > 0 {
119+
body = io.LimitReader(r.Body, h.MaxBodyBytes)
120+
}
121+
122+
dec := json.NewDecoder(body)
123+
err := dec.Decode(out)
124+
if err != nil {
125+
return &parseError{cause: err}
126+
}
127+
return nil
128+
}
129+
130+
// DefaultErrorParser provides a reasonable default error parser that can handle
131+
// the various sentile errors in go-base as well as ozzo-validation errors.
132+
func DefaultErrorParser(ctx context.Context, err error, debug bool) (int, interface{}) {
133+
// hey what are you doing here?
134+
if err == nil {
135+
return http.StatusOK, nil
136+
}
137+
79138
genErrResp := cerrors.ErrorResponse{
80139
Errors: []cerrors.APIErrorMessenger{
81140
&cerrors.GeneralError{
@@ -84,93 +143,68 @@ func (h *baseHandler) Error(ctx context.Context, w http.ResponseWriter, err erro
84143
}},
85144
}
86145

87-
// Handler concrete errors:
88-
// we can extend this error list in the future if needed
146+
// Handle sentinel errors
89147
switch err {
90-
case cerrors.ErrNotImplemented:
91-
h.Write(ctx, w, http.StatusNotImplemented, genErrResp)
92-
return
93-
case cerrors.ErrAuthorization:
94-
h.Write(ctx, w, http.StatusUnauthorized, genErrResp)
95-
return
96148
case cerrors.ErrPermission:
97-
h.Write(ctx, w, http.StatusForbidden, genErrResp)
98-
return
99-
case cerrors.ErrForm:
100-
h.Write(ctx, w, http.StatusUnprocessableEntity, genErrResp)
101-
return
102-
case sql.ErrNoRows, cerrors.ErrNotFound:
103-
h.Write(ctx, w, http.StatusNotFound, genErrResp)
104-
return
149+
return http.StatusForbidden, genErrResp
150+
case cerrors.ErrAuthorization:
151+
return http.StatusUnauthorized, genErrResp
152+
case cerrors.ErrInternal:
153+
return http.StatusInternalServerError, genErrResp
105154
case cerrors.ErrInvalidParameters:
106-
h.Write(ctx, w, http.StatusBadRequest, genErrResp)
107-
return
108-
case cerrors.ErrUnsupportedMediaType:
109-
h.Write(ctx, w, http.StatusUnsupportedMediaType, genErrResp)
110-
return
155+
return http.StatusBadRequest, genErrResp
156+
case cerrors.ErrUnmarshalling, cerrors.ErrForm:
157+
return http.StatusUnprocessableEntity, genErrResp
158+
case sql.ErrNoRows, cerrors.ErrNotFound:
159+
return http.StatusNotFound, genErrResp
111160
case cerrors.ErrNotImplemented:
112-
h.Write(ctx, w, http.StatusNotImplemented, genErrResp)
113-
return
161+
return http.StatusNotImplemented, genErrResp
162+
case cerrors.ErrUnsupportedMediaType:
163+
return http.StatusUnsupportedMediaType, genErrResp
114164
}
115165

116-
if strings.HasPrefix(err.Error(), cerrors.ErrUnmarshalling.Error()) {
117-
h.Write(ctx, w, http.StatusUnprocessableEntity, genErrResp)
118-
return
166+
// Handle error types that wrap other errors
167+
var parseErr parseError
168+
if errors.As(err, &parseErr) {
169+
return http.StatusUnprocessableEntity, genErrResp
170+
}
171+
172+
var userErr cerrors.UserError
173+
if errors.As(err, &userErr) {
174+
return http.StatusBadRequest, genErrResp
119175
}
120176

121-
// Handle error types that wrap other errors
122177
switch e := err.(type) {
123-
// covers ozzo v1 errors and go-base ValidationErrors
124178
case cerrors.ValidationErrors:
125-
h.Write(
126-
ctx,
127-
w,
128-
http.StatusUnprocessableEntity,
129-
cerrors.ValidationErrorsToFieldErrorResponse(e),
130-
)
131-
return
179+
return http.StatusUnprocessableEntity, cerrors.ValidationErrorsToFieldErrorResponse(e)
132180
case validation.Errors:
133-
h.Write(
134-
ctx,
135-
w,
136-
http.StatusUnprocessableEntity,
137-
cerrors.ValidationErrorsToFieldErrorResponse(cerrors.ValidationErrors(e)),
138-
)
139-
return
140-
case cerrors.UserError:
141-
h.Write(ctx, w, http.StatusBadRequest, genErrResp)
142-
return
181+
return http.StatusUnprocessableEntity, cerrors.ValidationErrorsToFieldErrorResponse(e)
143182
default:
144-
if !h.debug {
183+
if !debug {
145184
for idx, e := range genErrResp.Errors {
146185
genErrResp.Errors[idx] = e.Scrubbed(cerrors.ErrInternal.Error())
147186
}
148187
}
149-
h.Write(ctx, w, http.StatusInternalServerError, genErrResp)
188+
return http.StatusInternalServerError, genErrResp
150189
}
151190
}
152191

153-
func (h *baseHandler) Write(ctx context.Context, w http.ResponseWriter, status int, obj interface{}) {
154-
span, _ := h.StartSpan(ctx, "Write")
155-
var err error
156-
defer func() {
157-
h.FinishSpan(span, err)
158-
}()
192+
type parseError struct {
193+
cause error
194+
}
159195

160-
w.Header().Set("Content-Type", "application/json")
161-
w.WriteHeader(status)
162-
if obj != nil {
163-
enc := json.NewEncoder(w)
164-
err = enc.Encode(obj)
165-
}
196+
func (e parseError) Error() string {
197+
return cerrors.ErrUnmarshalling.Error() + ": " + e.cause.Error()
166198
}
167199

168-
func (h *baseHandler) Parse(r *http.Request, out interface{}) error {
169-
limitedBody := io.LimitReader(r.Body, h.maxBodyBytes)
170-
dec := json.NewDecoder(limitedBody)
171-
err := dec.Decode(out)
172-
if err != nil {
173-
return errors.Wrap(err, cerrors.ErrUnmarshalling.Error())
174-
}
175-
return nil
200+
func (e parseError) Unwrap() error {
201+
return e.cause
202+
}
203+
204+
func (e parseError) As(target interface{}) bool {
205+
return errors.As(e.cause, &target)
206+
}
207+
208+
func (e parseError) Is(target error) bool {
209+
return errors.Is(e.cause, target)
176210
}

pkg/http/handlers/base_test.go

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,15 @@ func TestWrite(t *testing.T) {
7070
require.Equal(t, "application/json", resp.Header().Get("content-type"))
7171
require.Equal(t, "{\"Name\":\"some\"}\n", resp.Body.String())
7272
})
73+
74+
t.Run("Write handle nil objects", func(t *testing.T) {
75+
h := NewBaseHandler("test", Megabyte, false)
76+
resp := httptest.NewRecorder()
77+
h.Write(context.Background(), resp, http.StatusCreated, nil)
78+
require.Equal(t, http.StatusCreated, resp.Code)
79+
require.Equal(t, "", resp.Header().Get("content-type"))
80+
require.Equal(t, "", resp.Body.String())
81+
})
7382
}
7483

7584
func TestError(t *testing.T) {
@@ -99,11 +108,8 @@ func TestError(t *testing.T) {
99108
expBody: `{"errors":[{"type":"GeneralError","message":"You don't have required permission to perform this action"}]}`,
100109
},
101110
{
102-
name: "Returns 422 when ErrUnmarshalling",
103-
err: errors.Wrap(
104-
errors.New("unexpected end of file"),
105-
cerrors.ErrUnmarshalling.Error(),
106-
),
111+
name: "Returns 422 when ErrUnmarshalling",
112+
err: &parseError{cause: errors.New("unexpected end of file")},
107113
expStatus: http.StatusUnprocessableEntity,
108114
expBody: `{"errors":[{"type":"GeneralError","message":"Failed to read JSON from the request body: unexpected end of file"}]}`,
109115
},
@@ -133,13 +139,13 @@ func TestError(t *testing.T) {
133139
},
134140
{
135141
name: "Returns 422 and field errors when validation errors",
136-
err: validation.Errors{"field": errors.New("terrible")},
142+
err: validation.Errors{"field": errors.New("terrible")}.Filter(),
137143
expStatus: http.StatusUnprocessableEntity,
138144
expBody: `{"errors":[{"type":"FieldError","message":"terrible","key":"field"}]}`,
139145
},
140146
{
141-
name: "Returns 422 and field errors when validation errors",
142-
err: validationV1.Errors{"field": errors.New("terrible")},
147+
name: "Returns 422 and field errors when validation v1 errors",
148+
err: validationV1.Errors{"field": errors.New("terrible")}.Filter(),
143149
expStatus: http.StatusUnprocessableEntity,
144150
expBody: `{"errors":[{"type":"FieldError","message":"terrible","key":"field"}]}`,
145151
},

0 commit comments

Comments
 (0)