Multi-Language Errors with Golang

Multi-Language Errors with Golang

·

5 min read

Friendly messages should be shown to users if any errors occur. We can achieve this at frontend level. But, maintaining error codes with their meaning is tedious, and backend system may mismatch with frontend. In this blog, I will show the way backend translates errors into multiple languages. This way can remove mismatch between backend and frontend and enable flexibility for adding info into translated messages.

Full code can be found at: github.com/liamhieuvu/go-multi-lang-errors

Build a simple server

Imagine that we are building an API to create a user with some fields, and these fields should be validated by backend. We will use gin framework to build a server to handle user creation requests.

mkdir multilang
cd multilang

go mod init multilang
touch main.go
package main

import (
    "net/http"

    "github.com/gin-gonic/gin"
)

type user struct {
    Name  string
    Age   uint8
    Email string
}

func main() {
    r := gin.Default()
    r.POST("/users", createUser)
    _ = r.Run()
}

func createUser(c *gin.Context) {
    var u user
    if err := c.ShouldBindJSON(&u); err != nil {
        c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"message": err.Error()})
        return
    }
    c.JSON(http.StatusOK, gin.H{"message": "successful"})
}

For simplification, createUser function only returns successful message and does nothing. We will focus to input validation and its error translation to different languages. Now, we can call API like this:

curl --request POST 'localhost:8080/users' \
--header 'Content-Type: application/json' \
--data-raw '{ "name": "Liam" }'
# output: {"message":"successful"}

Add input validation

We will use validator package to check input with struct tags. We want:

  • User must provide his name
  • User age must be between 10 and 90 years old
  • User must provide valid email
import (
    ...
    "github.com/go-playground/validator/v10"
)

type user struct {
    Name  string `validate:"required"`
    Age   uint8  `validate:"gte=10,lte=90"`
    Email string `validate:"required,email"`
}

var validate *validator.Validate

func main() {
    setup()
    ...
}

func createUser(c *gin.Context) {
    var u user
    if err := c.ShouldBindJSON(&u); err != nil { ... }

    if err := validate.Struct(u); err != nil {
        c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"rawMessage": err.Error()})
        return
    }
    ...
}

func setup() {
    validate = validator.New()
}

If we try to create a user without email, we will receive a non-friendly message:

curl --request POST 'localhost:8080/users' \
--header 'Content-Type: application/json' \
--data-raw '{
    "name": "Liam",
    "age": 30
}'
# output: {"rawMessage":"Key: 'user.Email' Error:Field validation for 'Email' failed on the 'required' tag"}

Define error translation

We will use universal-translator to translate the non-friendly error messages to English and Vietnamese. In validator, the Validate struct has this method:

func (v *Validate) RegisterTranslation(
    tag string,
    trans ut.Translator,
    registerFn RegisterTranslationsFunc,
    translationFn TranslationFunc,
) (err error) {

This method requires a tag (required, gte, email, etc. supported by validator) we want to translate, a translation engine trans, a dictionary registerFn, and the the way to translate translationFn. Next, we will define translations to translate sentence, dicts to translate field name, and translationFunc to define how to translate.

// trans.go
import (
    ut "github.com/go-playground/universal-translator"
    "github.com/go-playground/validator/v10"
)

var translations = map[string]map[string]string{
    "en": {
        "required": "{0} is a required field.",
        "email":    "{0} is invalid.",
        "gte":      "{0} must be {1} or greater.",
        "lte":      "{0} must be {1} or smaller.",
    },
    "vi": {
        "required": "{0} là trường bắt buộc.",
        "email":    "{0} không hợp lệ.",
        "gte":      "{0} phải bằng hoặc lớn hơn {1}.",
        "lte":      "{0} phải bằng hoặc nhỏ hơn {1}.",
    },
}

var dicts = map[string]map[string]string{
    "vi": {
        "Name": "Tên",
        "Age":  "Tuổi",
    },
}

func translationFunc(t ut.Translator, fe validator.FieldError) string {
    field, err := t.T(fe.Field())
    if err != nil {
        field = fe.Field()
    }
    msg, err := t.T(fe.Tag(), field, fe.Param())
    if err != nil {
        return fe.Error()
    }
    return msg
}

In the translationFunc function, we first try to translate the field name, then we translate the sentence with this field name as {0} and fe.Param() as {1} (if existed, e.g. the param is 90 if validating with lte=90). Our work is simple, register translations and dicts to the engine and translationFunc to our validator:

// main.go
import (
    ...
    "github.com/go-playground/locales/en"
    "github.com/go-playground/locales/vi"
    ut "github.com/go-playground/universal-translator"
)
...
func setup() {
    validate = validator.New()
    enLocale := en.New()
    utrans = ut.New(enLocale, enLocale, vi.New())

    for locale, dict := range dicts {
        engine, _ := utrans.FindTranslator(locale)
        for key, trans := range dict {
            _ = engine.Add(key, trans, false)
        }
    }

    for locale, translation := range translations {
        engine, _ := utrans.FindTranslator(locale)
        for tag, trans := range translation {
            _ = validate.RegisterTranslation(tag, engine, func(t ut.Translator) error {
                return t.Add(tag, trans, false)
            }, translationFunc)
        }
    }
}

Apply error translation

We will apply the translation to the user creation request. We expect to see a friendly message if there are any errors.

// main.go
...
func createUser(c *gin.Context) {
    var u user
    if err := c.ShouldBindJSON(&u); err != nil { ... }

    if err := validate.Struct(u); err != nil {
        transErrs := err.(validator.ValidationErrors).Translate(getTransFromParam(c)) // 1
        c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
            "message":    getErrMsg(transErrs), // 2
            "rawMessage": err.Error(),
        })
        return
    }
    ...
}

If error, we will translate the error according to locale param, which is en or vi. Then, we merge translated errors to 1 message.

// trans.go
func getTransFromParam(c *gin.Context) ut.Translator {
    t, found := utrans.GetTranslator(c.Query("locale"))
    if !found {
        t, _ = utrans.GetTranslator("en")
    }
    return t
}

func getErrMsg(errs validator.ValidationErrorsTranslations) string {
    messages := make([]string, 0, len(errs))
    for _, v := range errs {
        messages = append(messages, v)
    }
    return strings.Join(messages, " ")
}

It's done. Let's check the result:

curl --request POST 'localhost:8080/users?locale=vi' \
--header 'Content-Type: application/json' \
--data-raw '{
    "age": 100,
    "email": "liam@gmailcom"
}'
# {"message":"Tên là trường bắt buộc. Tuổi phải bằng hoặc nhỏ hơn 90. Email không hợp lệ."}

curl --request POST 'localhost:8080/trans/users?locale=en' \
--header 'Content-Type: application/json' \
--data-raw '{
    "age": 100,
    "email": "liam@gmailcom"
}'
# {"message":"Name is a required field. Age must be 90 or smaller. Email is invalid."}

Thank you for reading my blog.