Skip to content

Commit

Permalink
feat(#33): unify api error handling further
Browse files Browse the repository at this point in the history
  • Loading branch information
Jumpy-Squirrel committed Oct 2, 2024
1 parent e5fb619 commit ef34411
Show file tree
Hide file tree
Showing 11 changed files with 70 additions and 97 deletions.
42 changes: 25 additions & 17 deletions internal/application/common/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ type APIError interface {
error
Status() int
Response() modelsv1.Error
InternalCauses() []error // not for sending to the client, but useful for logging
}

// ErrorMessageCode is a key to use for error messages in frontends or other automated systems interacting
Expand Down Expand Up @@ -44,32 +45,32 @@ const (

// construct specific API errors

func NewBadRequest(ctx context.Context, message ErrorMessageCode, details url.Values) APIError {
return NewAPIError(ctx, http.StatusBadRequest, message, details)
func NewBadRequest(ctx context.Context, message ErrorMessageCode, details url.Values, internalCauses ...error) APIError {
return NewAPIError(ctx, http.StatusBadRequest, message, details, internalCauses...)
}

func NewUnauthorized(ctx context.Context, message ErrorMessageCode, details url.Values) APIError {
return NewAPIError(ctx, http.StatusUnauthorized, message, details)
func NewUnauthorized(ctx context.Context, message ErrorMessageCode, details url.Values, internalCauses ...error) APIError {
return NewAPIError(ctx, http.StatusUnauthorized, message, details, internalCauses...)
}

func NewForbidden(ctx context.Context, message ErrorMessageCode, details url.Values) APIError {
return NewAPIError(ctx, http.StatusForbidden, message, details)
func NewForbidden(ctx context.Context, message ErrorMessageCode, details url.Values, internalCauses ...error) APIError {
return NewAPIError(ctx, http.StatusForbidden, message, details, internalCauses...)
}

func NewNotFound(ctx context.Context, message ErrorMessageCode, details url.Values) APIError {
return NewAPIError(ctx, http.StatusNotFound, message, details)
func NewNotFound(ctx context.Context, message ErrorMessageCode, details url.Values, internalCauses ...error) APIError {
return NewAPIError(ctx, http.StatusNotFound, message, details, internalCauses...)
}

func NewConflict(ctx context.Context, message ErrorMessageCode, details url.Values) APIError {
return NewAPIError(ctx, http.StatusConflict, message, details)
func NewConflict(ctx context.Context, message ErrorMessageCode, details url.Values, internalCauses ...error) APIError {
return NewAPIError(ctx, http.StatusConflict, message, details, internalCauses...)
}

func NewInternalServerError(ctx context.Context, message ErrorMessageCode, details url.Values) APIError {
return NewAPIError(ctx, http.StatusInternalServerError, message, details)
func NewInternalServerError(ctx context.Context, message ErrorMessageCode, details url.Values, internalCauses ...error) APIError {
return NewAPIError(ctx, http.StatusInternalServerError, message, details, internalCauses...)
}

func NewBadGateway(ctx context.Context, message ErrorMessageCode, details url.Values) APIError {
return NewAPIError(ctx, http.StatusBadGateway, message, details)
func NewBadGateway(ctx context.Context, message ErrorMessageCode, details url.Values, internalCauses ...error) APIError {
return NewAPIError(ctx, http.StatusBadGateway, message, details, internalCauses...)
}

// check for API errors
Expand Down Expand Up @@ -110,7 +111,8 @@ func IsAPIError(err error) bool {
const isoDateTimeFormat = "2006-01-02T15:04:05-07:00"

// NewAPIError creates a generic API error from directly provided information.
func NewAPIError(ctx context.Context, status int, message ErrorMessageCode, details url.Values) APIError {
func NewAPIError(ctx context.Context, status int, message ErrorMessageCode, details url.Values, internalCauses ...error) APIError {

return &StatusError{
errStatus: status,
response: modelsv1.Error{
Expand All @@ -119,14 +121,16 @@ func NewAPIError(ctx context.Context, status int, message ErrorMessageCode, deta
Message: string(message),
Details: details,
},
internalCauses: internalCauses,
}
}

var _ error = (*StatusError)(nil)

type StatusError struct {
errStatus int
response modelsv1.Error
errStatus int
response modelsv1.Error
internalCauses []error
}

func (se *StatusError) Error() string {
Expand All @@ -141,6 +145,10 @@ func (se *StatusError) Response() modelsv1.Error {
return se.response
}

func (se *StatusError) InternalCauses() []error {
return se.internalCauses
}

func isAPIErrorWithStatus(status int, err error) bool {
apiError, ok := err.(APIError)
return ok && status == apiError.Status()
Expand Down
12 changes: 7 additions & 5 deletions internal/application/web/endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ type (
Endpoint[Req, Res any] func(ctx context.Context, request *Req, w http.ResponseWriter) (*Res, error)
)

func CreateHandler[Req, Res any](endpoint Endpoint[Req, Res],
func CreateHandler[Req, Res any](
endpoint Endpoint[Req, Res],
requestHandler RequestHandler[Req],
responseHandler ResponseHandler[Res],
) http.Handler {
Expand All @@ -36,24 +37,25 @@ func CreateHandler[Req, Res any](endpoint Endpoint[Req, Res],
defer func() {
err := r.Body.Close()
if err != nil {
aulogging.InfoErrf(ctx, err, "Error when closing the request body. [error]: %v", err)
aulogging.WarnErrf(ctx, err, "error while closing the request body: %v", err)
}
}()

request, err := requestHandler(r, w)
if err != nil {
aulogging.InfoErrf(ctx, err, "An error occurred while parsing the request. [error]: %v", err)
SendErrorResponse(ctx, w, err)
return
}

response, err := endpoint(ctx, request, w)
if err != nil {
aulogging.InfoErrf(ctx, err, "An error occurred during the request. [error]: %v", err)
SendErrorResponse(ctx, w, err)
return
}

if err := responseHandler(ctx, response, w); err != nil {
aulogging.InfoErrf(ctx, err, "An error occurred during the handling of the response. [error]: %v", err)
// cannot SendErrorResponse(ctx, w, err) - likely already have started sending in responseHandler
aulogging.ErrorErrf(ctx, err, "An error occurred during the handling of the response - response may have been incomplete. [error]: %v", err)
}
})
}
6 changes: 6 additions & 0 deletions internal/application/web/response.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package web
import (
"context"
"encoding/json"
"fmt"
aulogging "github.com/StephanHCB/go-autumn-logging"
"github.com/eurofurence/reg-room-service/internal/application/common"
"net/http"
Expand Down Expand Up @@ -45,6 +46,11 @@ func SendErrorResponse(ctx context.Context, w http.ResponseWriter, err error) {
// which contains relevant information about the failed request to the client.
// The function will also set the http status according to the provided status.
func SendAPIErrorResponse(ctx context.Context, w http.ResponseWriter, apiErr common.APIError) {
aulogging.InfoErrf(ctx, apiErr, fmt.Sprintf("api response status %d: %v", apiErr.Status(), apiErr))
for _, cause := range apiErr.InternalCauses() {
aulogging.InfoErrf(ctx, cause, fmt.Sprintf("... caused by: %v", cause))
}

w.WriteHeader(apiErr.Status())

EncodeToJSON(ctx, w, apiErr.Response())
Expand Down
3 changes: 1 addition & 2 deletions internal/controller/v1/countdownctl/countdown_get.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,7 @@ func (*Handler) GetCountdownRequest(r *http.Request, w http.ResponseWriter) (*Ge
aulogging.Warn(ctx, "mock time specified")
mockTime, err := time.Parse(mockTimeFormat, currentTimeIsoParam)
if err != nil {
web.SendErrorResponse(ctx, w, common.NewBadRequest(ctx, common.RequestParseFailed, common.Details("mock time specified but failed to parse")))
return nil, err
return nil, common.NewBadRequest(ctx, common.RequestParseFailed, common.Details("mock time specified but failed to parse"))
}
return &GetCountdownRequest{mockTime: &mockTime}, nil
}
Expand Down
6 changes: 1 addition & 5 deletions internal/controller/v1/groupsctl/groups_delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package groupsctl

import (
"context"
"github.com/eurofurence/reg-room-service/internal/application/web"
"github.com/go-chi/chi/v5"
"net/http"

Expand All @@ -19,16 +18,13 @@ type DeleteGroupRequest struct {
// Only Admins or the current group owner can do this.
func (h *Controller) DeleteGroup(ctx context.Context, req *DeleteGroupRequest, w http.ResponseWriter) (*modelsv1.Empty, error) {
err := h.svc.DeleteGroup(ctx, req.groupID)
if err != nil {
web.SendErrorResponse(ctx, w, err)
}
return nil, err
}

// DeleteGroupRequest parses and returns a request containing information to call the DeleteGroup function.
func (h *Controller) DeleteGroupRequest(r *http.Request, w http.ResponseWriter) (*DeleteGroupRequest, error) {
groupID := chi.URLParam(r, "uuid")
if err := validateGroupID(r.Context(), w, groupID); err != nil {
if err := validateGroupID(r.Context(), groupID); err != nil {
return nil, err
}

Expand Down
19 changes: 5 additions & 14 deletions internal/controller/v1/groupsctl/groups_get.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ type ListGroupsRequest struct {
func (h *Controller) ListGroups(ctx context.Context, req *ListGroupsRequest, w http.ResponseWriter) (*modelsv1.GroupList, error) {
groups, err := h.svc.FindGroups(ctx, req.MinSize, req.MaxSize, req.MemberIDs)
if err != nil {
web.SendErrorResponse(ctx, w, err)
return nil, err
}

Expand All @@ -41,16 +40,14 @@ func (h *Controller) ListGroupsRequest(r *http.Request, w http.ResponseWriter) (
queryIDs := query.Get("member_ids")
memberIDs, err := util.ParseMemberIDs(queryIDs)
if err != nil {
web.SendErrorResponse(ctx, w, common.NewBadRequest(ctx, common.RequestParseFailed, common.Details(err.Error())))
return nil, err
return nil, common.NewBadRequest(ctx, common.RequestParseFailed, common.Details(err.Error()))
}

req.MemberIDs = memberIDs
if minSize := query.Get("min_size"); minSize != "" {
val, err := util.ParseUInt[uint](minSize)
if err != nil {
web.SendErrorResponse(ctx, w, common.NewBadRequest(ctx, common.RequestParseFailed, common.Details(err.Error())))
return nil, err
return nil, common.NewBadRequest(ctx, common.RequestParseFailed, common.Details(err.Error()))
}

req.MinSize = val
Expand All @@ -59,12 +56,10 @@ func (h *Controller) ListGroupsRequest(r *http.Request, w http.ResponseWriter) (
if maxSize := query.Get("max_size"); maxSize != "" {
val, err := util.ParseInt[int](maxSize)
if err != nil {
web.SendErrorResponse(ctx, w, common.NewBadRequest(ctx, common.RequestParseFailed, common.Details(err.Error())))
return nil, err
return nil, common.NewBadRequest(ctx, common.RequestParseFailed, common.Details(err.Error()))
}
if val < -1 {
web.SendErrorResponse(ctx, w, common.NewBadRequest(ctx, common.RequestParseFailed, common.Details("maxSize cannot be less than -1")))
return nil, err
return nil, common.NewBadRequest(ctx, common.RequestParseFailed, common.Details("maxSize cannot be less than -1"))
}

req.MaxSize = val
Expand All @@ -84,7 +79,6 @@ type FindMyGroupRequest struct{}
func (h *Controller) FindMyGroup(ctx context.Context, req *FindMyGroupRequest, w http.ResponseWriter) (*modelsv1.Group, error) {
group, err := h.svc.FindMyGroup(ctx)
if err != nil {
web.SendErrorResponse(ctx, w, err)
return nil, err
}

Expand All @@ -107,7 +101,6 @@ type FindGroupByIDRequest struct {
func (h *Controller) FindGroupByID(ctx context.Context, req *FindGroupByIDRequest, w http.ResponseWriter) (*modelsv1.Group, error) {
grp, err := h.svc.GetGroupByID(ctx, req.GroupID)
if err != nil {
web.SendErrorResponse(ctx, w, err)
return nil, err
}

Expand All @@ -117,9 +110,7 @@ func (h *Controller) FindGroupByID(ctx context.Context, req *FindGroupByIDReques
func (h *Controller) FindGroupByIDRequest(r *http.Request, w http.ResponseWriter) (*FindGroupByIDRequest, error) {
groupID := chi.URLParam(r, "uuid")
if _, err := uuid.Parse(groupID); err != nil {
ctx := r.Context()
web.SendErrorResponse(ctx, w, common.NewBadRequest(ctx, common.GroupIDInvalid, url.Values{"details": []string{"you must specify a valid uuid"}}))
return nil, err
return nil, common.NewBadRequest(r.Context(), common.GroupIDInvalid, url.Values{"details": []string{"you must specify a valid uuid"}})
}

req := &FindGroupByIDRequest{
Expand Down
14 changes: 3 additions & 11 deletions internal/controller/v1/groupsctl/groups_post.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ package groupsctl

import (
"context"
aulogging "github.com/StephanHCB/go-autumn-logging"
"github.com/eurofurence/reg-room-service/internal/application/web"
"errors"
"github.com/eurofurence/reg-room-service/internal/controller/v1/util"
"net/http"
"net/url"
Expand All @@ -29,15 +28,12 @@ type CreateGroupRequest struct {
func (h *Controller) CreateGroup(ctx context.Context, req *CreateGroupRequest, w http.ResponseWriter) (*modelsv1.Empty, error) {
newGroupUUID, err := h.svc.CreateGroup(ctx, req.Group)
if err != nil {
web.SendErrorResponse(ctx, w, err)
return nil, err
}

requestURL, ok := ctx.Value(common.CtxKeyRequestURL{}).(*url.URL)
if !ok {
aulogging.Error(ctx, "could not retrieve base URL from context")
web.SendErrorResponse(ctx, w, nil)
return nil, nil
return nil, errors.New("could not retrieve base URL from context - this is an implementation error")
}

w.Header().Set("Location", path.Join(requestURL.Path, newGroupUUID))
Expand All @@ -48,11 +44,7 @@ func (h *Controller) CreateGroupRequest(r *http.Request, w http.ResponseWriter)
var group modelsv1.GroupCreate

if err := util.NewStrictJSONDecoder(r.Body).Decode(&group); err != nil {
ctx := r.Context()
web.SendErrorResponse(ctx, w, common.NewBadRequest(ctx,
common.GroupDataInvalid, common.Details("invalid json provided"),
))
return nil, err
return nil, common.NewBadRequest(r.Context(), common.GroupDataInvalid, common.Details("invalid json provided"))
}

cgr := &CreateGroupRequest{
Expand Down
12 changes: 4 additions & 8 deletions internal/controller/v1/groupsctl/groups_put.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ package groupsctl

import (
"context"
aulogging "github.com/StephanHCB/go-autumn-logging"
"github.com/eurofurence/reg-room-service/internal/application/web"
"errors"
"github.com/eurofurence/reg-room-service/internal/controller/v1/util"
"github.com/go-chi/chi/v5"
"net/http"
Expand All @@ -22,14 +21,12 @@ type UpdateGroupRequest struct {
// Admins or the current group owner can change the group owner to any member of the group.
func (h *Controller) UpdateGroup(ctx context.Context, req *UpdateGroupRequest, w http.ResponseWriter) (*modelsv1.Empty, error) {
if err := h.svc.UpdateGroup(ctx, req.Group); err != nil {
web.SendErrorResponse(ctx, w, err)
return nil, err
}

reqURL, ok := ctx.Value(common.CtxKeyRequestURL{}).(*url.URL)
if !ok {
aulogging.Error(ctx, "unable to retrieve URL from context")
return nil, nil
return nil, errors.New("unable to retrieve URL from context - this is an implementation error")
}

w.Header().Set("Location", reqURL.Path)
Expand All @@ -41,15 +38,14 @@ func (h *Controller) UpdateGroupRequest(r *http.Request, w http.ResponseWriter)
ctx := r.Context()

groupID := chi.URLParam(r, "uuid")
if err := validateGroupID(ctx, w, groupID); err != nil {
if err := validateGroupID(ctx, groupID); err != nil {
return nil, err
}

var group modelsv1.Group

if err := util.NewStrictJSONDecoder(r.Body).Decode(&group); err != nil {
web.SendErrorResponse(ctx, w, common.NewBadRequest(ctx, common.GroupDataInvalid, common.Details("invalid json provided")))
return nil, err
return nil, common.NewBadRequest(ctx, common.GroupDataInvalid, common.Details("invalid json provided"))
}

group.ID = groupID
Expand Down
12 changes: 6 additions & 6 deletions internal/controller/v1/groupsctl/members_delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package groupsctl

import (
"context"
"github.com/eurofurence/reg-room-service/internal/application/web"
"github.com/eurofurence/reg-room-service/internal/controller/v1/util"
"github.com/go-chi/chi/v5"
"net/http"
Expand All @@ -21,24 +20,25 @@ type RemoveGroupMemberRequest struct {
//
// Details see OpenAPI spec.
func (h *Controller) RemoveGroupMember(ctx context.Context, req *RemoveGroupMemberRequest, w http.ResponseWriter) (*modelsv1.Empty, error) {
// TODO

return nil, nil
}

// RemoveGroupMemberRequest validates and creates the request for the RemoveGroupMember operation.
func (h *Controller) RemoveGroupMemberRequest(r *http.Request, w http.ResponseWriter) (*RemoveGroupMemberRequest, error) {
const uuidParam, badeNumberParam = "uuid", "badgenumber"

ctx := r.Context()

groupID := chi.URLParam(r, uuidParam)
if err := validateGroupID(r.Context(), w, groupID); err != nil {
if err := validateGroupID(ctx, groupID); err != nil {
return nil, err
}

badgeNumber, err := util.ParseUInt[uint](chi.URLParam(r, badeNumberParam))
if err != nil {
ctx := r.Context()
web.SendErrorResponse(ctx, w,
common.NewBadRequest(ctx, common.GroupDataInvalid, common.Details("invalid type for badge number")))
return nil, err
return nil, common.NewBadRequest(ctx, common.GroupDataInvalid, common.Details("invalid type for badge number"))
}

return &RemoveGroupMemberRequest{groupID, badgeNumber}, nil
Expand Down
Loading

0 comments on commit ef34411

Please sign in to comment.