| title | Error handling |
|---|---|
| slug | error-handling |
π¨βπ« Before we start...
- Go
errorvalues usually provide a more detailed context about what actually went wrong. However, returning the actual error messages to end user mostly cause confusion and make the system vulnerable to security threats. So, when an error occurs, we will send a custom but meaningful error message to the end-user.- We choose the
go-playground/validatorfor form validations, as it supports struct level validations, extensive validation rules, customizable error handling, etc.
While an error occurs, returning the actual error details to the end-user could result in a poor user/ developer experience and could potentially expose sensitive information and posing a security risk. Let's identify the error cases and standardize our error messages.
| Errors can be occurred | HTTP code | Custom error for response |
|---|---|---|
| While inserting data into the database | 500 | db data insert failure |
| While accessing data from the database | 500 | db data access failure |
| While updating data in the database | 500 | db data update failure |
| While removing data in the database | 500 | db data remove failure |
| While encoding json to generate the response | 500 | json encode failure |
| While decoding json to read data from create/ update forms | 500 | json decode failure |
| While decoding book ID from URL parameters as a valid UUID | 400 | invalid url param - id |
| While validating forms | 422 | *array of error messages |
π¨βπ« In the next article, we'll add structured logging capabilities and log the actual error for debugging and auditing purposes.
Let's add the above error messages to api/resource/common/err/err.go with helper functions.
var (
RespDBDataInsertFailure = []byte(`{"error": "db data insert failure"}`)
RespDBDataAccessFailure = []byte(`{"error": "db data access failure"}`)
RespDBDataUpdateFailure = []byte(`{"error": "db data update failure"}`)
RespDBDataRemoveFailure = []byte(`{"error": "db data remove failure"}`)
RespJSONEncodeFailure = []byte(`{"error": "json encode failure"}`)
RespJSONDecodeFailure = []byte(`{"error": "json decode failure"}`)
RespInvalidURLParamID = []byte(`{"error": "invalid url param-id"}`)
)
func ServerError(w http.ResponseWriter, reps []byte) {
w.WriteHeader(http.StatusInternalServerError)
w.Write(reps)
}
func BadRequest(w http.ResponseWriter, reps []byte) {
w.WriteHeader(http.StatusBadRequest)
w.Write(reps)
}
func ValidationErrors(w http.ResponseWriter, reps []byte) {
w.WriteHeader(http.StatusUnprocessableEntity)
w.Write(reps)
}Let's update the handlers in api/resource/book/handler.go.
import (
e "myapp/api/resource/common/err"
)
func (a *API) List(w http.ResponseWriter, r *http.Request) {
books, err := a.repository.List()
if err != nil {
e.ServerError(w, e.RespDBDataAccessFailure)
return
}
if len(books) == 0 {
fmt.Fprint(w, "[]")
return
}
if err := json.NewEncoder(w).Encode(books.ToDto()); err != nil {
e.ServerError(w, e.RespJSONEncodeFailure)
return
}
}
func (a *API) Create(w http.ResponseWriter, r *http.Request) {
form := &Form{}
if err := json.NewDecoder(r.Body).Decode(form); err != nil {
e.ServerError(w, e.RespJSONDecodeFailure)
return
}
newBook := form.ToModel()
newBook.ID = uuid.New()
_, err := a.repository.Create(newBook)
if err != nil {
e.ServerError(w, e.RespDBDataInsertFailure)
return
}
w.WriteHeader(http.StatusCreated)
}
func (a *API) Read(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
e.BadRequest(w, e.RespInvalidURLParamID)
return
}
book, err := a.repository.Read(id)
if err != nil {
if err == gorm.ErrRecordNotFound {
w.WriteHeader(http.StatusNotFound)
return
}
e.ServerError(w, e.RespDBDataAccessFailure)
return
}
dto := book.ToDto()
if err := json.NewEncoder(w).Encode(dto); err != nil {
e.ServerError(w, e.RespJSONEncodeFailure)
return
}
}
func (a *API) Update(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
e.BadRequest(w, e.RespInvalidURLParamID)
return
}
form := &Form{}
if err := json.NewDecoder(r.Body).Decode(form); err != nil {
e.ServerError(w, e.RespJSONDecodeFailure)
return
}
book := form.ToModel()
book.ID = id
rows, err := a.repository.Update(book)
if err != nil {
e.ServerError(w, e.RespDBDataUpdateFailure)
return
}
if rows == 0 {
w.WriteHeader(http.StatusNotFound)
return
}
}
func (a *API) Delete(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
e.BadRequest(w, e.RespInvalidURLParamID)
return
}
rows, err := a.repository.Delete(id)
if err != nil {
e.BadRequest(w, e.RespDBDataRemoveFailure)
return
}
if rows == 0 {
w.WriteHeader(http.StatusNotFound)
return
}
}With this, our custom error messages should appear while an error occurs.
In here, we add go-playground/validator, set struct level validations to the create/ update book form, add custom validator with own custom validation rule alphaspace and add it to the API.
go get github.com/go-playground/validator/v10
In here, we use their built-in required, datetime, max, url validate tags and our own custom alphaspace validation. Let's update the form in api/resource/book/model.go
type Form struct {
Title string `json:"title" validate:"required,max=255"`
Author string `json:"author" validate:"required,alphaspace,max=255"`
PublishedDate string `json:"published_date" validate:"required,datetime=2006-01-02"`
ImageURL string `json:"image_url" validate:"url"`
Description string `json:"description"`
}π On the go-playground/validator README file, you can see the built-in validation rules they provide.
We combine their struct-level and custom validation example implementations.
package validator
import (
"reflect"
"regexp"
"strings"
"github.com/go-playground/validator/v10"
)
const alphaSpaceRegexString string = "^[a-zA-Z ]+$"
func New() *validator.Validate {
validate := validator.New()
validate.RegisterTagNameFunc(func(fld reflect.StructField) string {
name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0]
if name == "-" {
return ""
}
return name
})
validate.RegisterValidation("alphaspace", isAlphaSpace)
return validate
}
func isAlphaSpace(fl validator.FieldLevel) bool {
reg := regexp.MustCompile(alphaSpaceRegexString)
return reg.MatchString(fl.Field().String())
}RegisterTagNameFunc helps to set the error message form field name according to the json tag, instead of using capitalized struct field name. isAlphaSpace is the custom validation rule to validate the fields with only alphabetic characters and spaces.
import "github.com/go-playground/validator/v10"
type API struct {
repository *Repository
validator *validator.Validate
}
func New(db *gorm.DB, v *validator.Validate) *API {
return &API{
repository: NewRepository(db),
validator: v,
}
}import "github.com/go-playground/validator/v10"
func New(db *gorm.DB, v *validator.Validate) *chi.Mux {
r := chi.NewRouter()
r.Get("/livez", health.Read)
r.Route("/v1", func(r chi.Router) {
bookAPI := book.New(db, v)import validatorUtil "myapp/util/validator"
func main() {
c := config.New()
v := validatorUtil.New()
var logLevel gormlogger.LogLevel
if c.DB.Debug {
logLevel = gormlogger.Info
} else {
logLevel = gormlogger.Error
}
dbString := fmt.Sprintf(fmtDBString, c.DB.Host, c.DB.Username, c.DB.Password, c.DB.DBName, c.DB.Port)
db, err := gorm.Open(postgres.Open(dbString), &gorm.Config{Logger: gormlogger.Default.LogMode(logLevel)})
if err != nil {
log.Fatal("DB connection start failure")
return
}
r := router.New(db, v)When we add a new package and use it, we have to run go mod tidy to reorganize the dependencies in the go.mod file.
Let's check the error of validator.Validate Struct() via the API.validator.Struct()
func (a *API) Create(w http.ResponseWriter, r *http.Request) {
form := &Form{}
if err := json.NewDecoder(r.Body).Decode(form); err != nil {
e.ServerError(w, e.RespJSONDecodeFailure)
return
}
if err := a.validator.Struct(form); err != nil {
fmt.Println(err)
return
}
newBook := form.ToModel()
newBook.ID = uuid.New()
_, err := a.repository.Create(newBook)
if err != nil {
e.ServerError(w, e.RespDBDataInsertFailure)
return
}
w.WriteHeader(http.StatusCreated)
}It returns an array of FieldError's.
Key: 'Form.title' Error:Field validation for 'title' failed on the 'required' tag
Key: 'Form.author' Error:Field validation for 'author' failed on the 'required' tag
Key: 'Form.published_date' Error:Field validation for 'published_date' failed on the 'required' tag
Key: 'Form.image_url' Error:Field validation for 'image_url' failed on the 'url' tag
go-playground/validator comes with go-playground/locales andgo-playground/universal-translator packages. In here, we use our own much simpler approach to support custom error messages and generate json error response.
package validator
import (
"fmt"
"github.com/go-playground/validator/v10"
)
type ErrResponse struct {
Errors []string `json:"errors"`
}
func ToErrResponse(err error) *ErrResponse {
if fieldErrors, ok := err.(validator.ValidationErrors); ok {
resp := ErrResponse{
Errors: make([]string, len(fieldErrors)),
}
for i, err := range fieldErrors {
switch err.Tag() {
case "required":
resp.Errors[i] = fmt.Sprintf("%s is a required field", err.Field())
case "max":
resp.Errors[i] = fmt.Sprintf("%s must be a maximum of %s in length", err.Field(), err.Param())
case "url":
resp.Errors[i] = fmt.Sprintf("%s must be a valid URL", err.Field())
case "alphaspace":
resp.Errors[i] = fmt.Sprintf("%s can only contain alphabetic and space characters", err.Field())
case "datetime":
if err.Param() == "2006-01-02" {
resp.Errors[i] = fmt.Sprintf("%s must be a valid date", err.Field())
} else {
resp.Errors[i] = fmt.Sprintf("%s must follow %s format", err.Field(), err.Param())
}
default:
resp.Errors[i] = fmt.Sprintf("something wrong on %s; %s", err.Field(), err.Tag())
}
}
return &resp
}
return nil
}The ToErrResponse() function helps to convert the array of FieldError's returns from go-playground/validator into an ErrResponse struct, which we can be used to generate the JSON error response. For example,
{
"errors": [
"title is a required field",
"author is a required field",
"published_date is a required field",
"image_url must be a valid URL"
]
}import validatorUtil "myapp/util/validator"
func (a *API) Create(w http.ResponseWriter, r *http.Request) {
form := &Form{}
if err := json.NewDecoder(r.Body).Decode(form); err != nil {
e.ServerError(w, e.RespJSONDecodeFailure)
return
}
if err := a.validator.Struct(form); err != nil {
respBody, err := json.Marshal(validatorUtil.ToErrResponse(err))
if err != nil {
e.ServerError(w, e.RespJSONEncodeFailure)
return
}
e.ValidationErrors(w, respBody)
return
}
newBook := form.ToModel()
newBook.ID = uuid.New()
_, err := a.repository.Create(newBook)
if err != nil {
e.ServerError(w, e.RespDBDataInsertFailure)
return
}
w.WriteHeader(http.StatusCreated)
}
func (a *API) Update(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
e.BadRequest(w, e.RespInvalidURLParamID)
return
}
form := &Form{}
if err := json.NewDecoder(r.Body).Decode(form); err != nil {
e.ServerError(w, e.RespJSONDecodeFailure)
return
}
if err := a.validator.Struct(form); err != nil {
respBody, err := json.Marshal(validatorUtil.ToErrResponse(err))
if err != nil {
e.ServerError(w, e.RespJSONEncodeFailure)
return
}
e.ValidationErrors(w, respBody)
return
}
book := form.ToModel()
book.ID = id
rows, err := a.repository.Update(book)
if err != nil {
e.ServerError(w, e.RespDBDataUpdateFailure)
return
}
if rows == 0 {
w.WriteHeader(http.StatusNotFound)
return
}
}myapp
βββ cmd
β βββ api
β β βββ main.go
β βββ migrate
β βββ main.go
β
βββ api
β βββ router
β β βββ router.go
β β
β βββ resource
β βββ health
β β βββ handler.go
β βββ book
β β βββ handler.go
β β βββ model.go
β β βββ repository.go
β β βββ repository_test.go
β βββ common
β βββ err
β βββ err.go
β
βββ migrations
β βββ 00001_create_books_table.sql
β
βββ config
β βββ config.go
β
βββ .env
β
βββ go.mod
βββ go.sum
β
βββ mock
β βββ db
β βββ db.go
βββ util
β βββ test
β β βββ test.go
β βββ validator
β βββ validator.go
β βββ response.go
β
βββ compose.yml
βββ DockerfileIn the next article, weβll add the request logs, error logs and the logger to our application.