👨🏫 Before we start…
- Go
error
values 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/validator
for form validations, as it supports struct level validations, extensive validation rules, customizable error handling, etc.
Adding error messages to the API
1. Identify and standardize errors
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.
2. Define helper functions
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)
}
3. Update API handlers
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.
Adding validator to the API
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.
1. Add go-playground/validator
go get github.com/go-playground/validator/v10
2. Set validation tags
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.
3. Add util/validator/validator.go
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.
4. Add validator as an API dependency
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,
}
}
5. Update router
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)
6. Update cmd/api/main.go
import validatorUil "myapp/util/validator"
func main() {
c := config.New()
v := validatorUil.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)
7. Run go mod tidy
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.
Using validator from handlers
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.
1. Add util/validator/response.go
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"
]
}
2. Update handlers
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
}
}
📁 Final project structure
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
│
├── docker-compose.yml
└── Dockerfile
👨🏫 What’s next…
In the next article, we’ll add the request logs, error logs and the logger to our application.