Skip to content

Commit

Permalink
fix: improves UX around validation errors (#1209)
Browse files Browse the repository at this point in the history
  • Loading branch information
ssoroka committed Mar 16, 2022
1 parent f4276e1 commit becacb0
Show file tree
Hide file tree
Showing 7 changed files with 239 additions and 69 deletions.
10 changes: 8 additions & 2 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ var (
)

type Error struct {
Code int32 `json:"code"` // should be a repeat of the http response status code
Message string `json:"message"`
Code int32 `json:"code"` // should be a repeat of the http response status code
Message string `json:"message"`
FieldErrors []FieldError `json:"fieldErrors,omitempty"`
}

type FieldError struct {
FieldName string `json:"fieldName"`
Errors []string `json:"errors"`
}
6 changes: 3 additions & 3 deletions api/destination.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import (

type Destination struct {
ID uid.ID `json:"id"`
UniqueID string `json:"uniqueID" form:"uniqueID"`
UniqueID string `json:"uniqueID" form:"uniqueID" example:"94c2c570a20311180ec325fd56"`
Name string `json:"name" form:"name"`
// created time in seconds since 1970-01-01
Created int64 `json:"created"`
Expand All @@ -16,8 +16,8 @@ type Destination struct {
}

type DestinationConnection struct {
URL string `json:"url" validate:"required"`
CA string `json:"ca"`
URL string `json:"url" validate:"required" example:"aa60eexample.us-west-2.elb.amazonaws.com"`
CA string `json:"ca" example:"-----BEGIN CERTIFICATE-----\nMIIDNTCCAh2gAwIBAgIRALRetnpcTo9O3V2fAK3ix+c\n-----END CERTIFICATE-----\n"`
}

type ListDestinationsRequest struct {
Expand Down
93 changes: 93 additions & 0 deletions internal/server/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package server

import (
"errors"
"fmt"
"net/http"
"strings"

"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"github.com/infrahq/infra/api"
"github.com/infrahq/infra/internal"
"github.com/infrahq/infra/internal/logging"
)

func sendAPIError(c *gin.Context, err error) {
resp := &api.Error{
Code: http.StatusInternalServerError,
Message: "internal server error", // don't leak any info by default
}

var validationErrors = &validator.ValidationErrors{}

switch {
case errors.Is(err, internal.ErrUnauthorized):
resp.Code = http.StatusUnauthorized
resp.Message = "unauthorized"
logging.WrappedSugarLogger(c).Debugw(err.Error(), "statusCode", resp.Code)
case errors.Is(err, internal.ErrForbidden):
resp.Code = http.StatusForbidden
resp.Message = "forbidden"
logging.WrappedSugarLogger(c).Debugw(err.Error(), "statusCode", resp.Code)
case errors.Is(err, internal.ErrDuplicate):
resp.Code = http.StatusConflict
resp.Message = err.Error()
logging.WrappedSugarLogger(c).Debugw(err.Error(), "statusCode", resp.Code)
case errors.Is(err, internal.ErrNotFound):
resp.Code = http.StatusNotFound
resp.Message = err.Error()
logging.WrappedSugarLogger(c).Debugw(err.Error(), "statusCode", resp.Code)
case errors.As(err, validationErrors):
resp.Code = http.StatusBadRequest
resp.Message = err.Error()

parseFieldErrors(resp, validationErrors)

logging.WrappedSugarLogger(c).Debugw(err.Error(), "statusCode", resp.Code)
case errors.Is(err, internal.ErrBadRequest):
resp.Code = http.StatusBadRequest
resp.Message = err.Error()
logging.WrappedSugarLogger(c).Debugw(err.Error(), "statusCode", resp.Code)
case errors.Is(err, internal.ErrNotImplemented):
resp.Code = http.StatusNotImplemented
resp.Message = internal.ErrNotImplemented.Error()
logging.WrappedSugarLogger(c).Debugw(err.Error(), "statusCode", resp.Code)
case errors.Is(err, (*validator.InvalidValidationError)(nil)):
resp.Code = http.StatusBadRequest
resp.Message = err.Error()
logging.WrappedSugarLogger(c).Debugw(err.Error(), "statusCode", resp.Code)
default:
logging.WrappedSugarLogger(c).Errorw(err.Error(), "statusCode", resp.Code)
}

c.JSON(int(resp.Code), resp)
c.Abort()
}

func parseFieldErrors(resp *api.Error, validationErrors *validator.ValidationErrors) {
errs := map[string][]string{}
for _, field := range *validationErrors {
msg := ""
if field.Tag() == "required" {
msg = "is required"
} else {
msg = fmt.Sprintf("failed the %q check", field.Tag())
}

errs[field.Namespace()] = append(errs[field.Field()], msg)
}

for f, vals := range errs {
resp.FieldErrors = append(resp.FieldErrors, api.FieldError{FieldName: f, Errors: vals})
}

// rebuild the error message, because the default is just bad.
if len(resp.FieldErrors) > 0 {
errs := []string{}
for _, fe := range resp.FieldErrors {
errs = append(errs, fe.FieldName+": "+strings.Join(fe.Errors, ", "))
}
resp.Message = strings.Join(errs, ". ")
}
}
115 changes: 115 additions & 0 deletions internal/server/errors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package server

import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"github.com/gin-gonic/gin"
"github.com/infrahq/infra/api"
"github.com/infrahq/infra/internal"
"github.com/stretchr/testify/require"
)

func TestSendAPIError(t *testing.T) {
gin.SetMode(gin.ReleaseMode)

tests := []struct {
err error
result api.Error
}{
{
err: internal.ErrBadRequest,
result: api.Error{
Code: 400,
Message: "bad request",
},
},
{
err: internal.ErrUnauthorized,
result: api.Error{
Code: http.StatusUnauthorized,
Message: "unauthorized",
},
},
{
err: internal.ErrForbidden,
result: api.Error{
Code: http.StatusForbidden,
Message: "forbidden",
},
},
{
err: internal.ErrDuplicate,
result: api.Error{
Code: http.StatusConflict,
Message: "duplicate record",
},
},
{
err: internal.ErrNotFound,
result: api.Error{
Code: http.StatusNotFound,
Message: "record not found",
},
},
{
err: internal.ErrNotImplemented,
result: api.Error{
Code: http.StatusNotImplemented,
Message: "not implemented",
},
},
{
err: validate.Struct(struct {
Email string `validate:"required,email" json:"email"`
}{}),
result: api.Error{
Code: http.StatusBadRequest,
Message: "Email: is required",
FieldErrors: []api.FieldError{
api.FieldError{
FieldName: "Email",
Errors: []string{"is required"},
},
},
},
},
{
err: validate.Struct(struct {
Email string `validate:"required,email" json:"email"`
}{Email: "foo#example!com"}),
result: api.Error{
Code: http.StatusBadRequest,
Message: `Email: failed the "email" check`,
FieldErrors: []api.FieldError{
api.FieldError{
FieldName: "Email",
Errors: []string{`failed the "email" check`},
},
},
},
},
}

for _, test := range tests {
t.Run(test.err.Error(), func(t *testing.T) {
resp := httptest.NewRecorder()
c, _ := gin.CreateTestContext(resp)

sendAPIError(c, test.err)

require.EqualValues(t, test.result.Code, resp.Result().StatusCode)
actual := &api.Error{}
json.NewDecoder(resp.Body).Decode(actual)

require.Equal(t, test.result.Code, actual.Code)
require.Equal(t, test.result.Message, actual.Message)

require.Equal(t, test.result.FieldErrors, actual.FieldErrors)
})

}

}
44 changes: 1 addition & 43 deletions internal/server/api.go → internal/server/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,11 @@ package server
import (
"errors"
"fmt"
"net/http"
"regexp"
"strings"
"time"

"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"gopkg.in/segmentio/analytics-go.v3"

"github.com/infrahq/infra/internal"
Expand All @@ -27,7 +25,7 @@ type API struct {
server *Server
}

func NewAPIMux(server *Server, router *gin.RouterGroup) {
func NewAPI(server *Server, router *gin.RouterGroup) {
a := API{
t: server.tel,
server: server,
Expand All @@ -36,46 +34,6 @@ func NewAPIMux(server *Server, router *gin.RouterGroup) {
a.registerRoutes(router)
}

func sendAPIError(c *gin.Context, err error) {
code := http.StatusInternalServerError
message := "internal server error" // don't leak any info by default

switch {
case errors.Is(err, internal.ErrUnauthorized):
code = http.StatusUnauthorized
message = "unauthorized"
logging.WrappedSugarLogger(c).Debugw(err.Error(), "statusCode", code)
case errors.Is(err, internal.ErrForbidden):
code = http.StatusForbidden
message = "forbidden"
logging.WrappedSugarLogger(c).Debugw(err.Error(), "statusCode", code)
case errors.Is(err, internal.ErrDuplicate):
code = http.StatusConflict
message = err.Error()
logging.WrappedSugarLogger(c).Debugw(err.Error(), "statusCode", code)
case errors.Is(err, internal.ErrNotFound):
code = http.StatusNotFound
message = err.Error()
logging.WrappedSugarLogger(c).Debugw(err.Error(), "statusCode", code)
case errors.Is(err, internal.ErrBadRequest):
code = http.StatusBadRequest
message = err.Error()
logging.WrappedSugarLogger(c).Debugw(err.Error(), "statusCode", code)
case errors.Is(err, (*validator.InvalidValidationError)(nil)):
code = http.StatusBadRequest
message = err.Error()
logging.WrappedSugarLogger(c).Debugw(err.Error(), "statusCode", code)
default:
logging.WrappedSugarLogger(c).Errorw(err.Error(), "statusCode", code)
}

c.JSON(code, &api.Error{
Code: int32(code),
Message: message,
})
c.Abort()
}

func (a *API) ListUsers(c *gin.Context, r *api.ListUsersRequest) ([]api.User, error) {
users, err := access.ListUsers(c, r.Email, r.ProviderID)
if err != nil {
Expand Down

0 comments on commit becacb0

Please sign in to comment.