Abstract dependencies

Projects depend on packages, internal ones or from 3rd parties. In all cases, your architecture should dictate what it needs and not evolve around a package, otherwise changing the dependency with another package and mocking it in tests could take unnecessary effort and time.

While it’s very easy to include a package in any file you need and start using it, time will show it’s painful to spread dependencies all over. Someday you’ll want to change a package because it’s not maintained anymore or you discover security issues which lead to loss of trust, or maybe you just want to experiment with another package.

This is where abstraction comes in, helping to decouple an implementation in your project, to set the rules which a dependency must follow. Each package has its own API, thus you need to wrap it by your API.

To show an example, I’ve chosen to integrate a validation package into Echo framework. The validator package requires you to tag your structs with its validation rules.

type request struct {
   Name   validate:"required"`
   Tag    validate:"required,alpha"`
   Offset validate:"gte=0"`
   Limit  validate:"gte=1,lte=30"`
}

Tags are easy to be removed or changed, I don’t consider this level of coupling to be dangerous.

Then, you get a validator instance and validate your struct:

req := request{}

v := validator.New()

err := v.Struct(req)
if errs, ok := err.(validator.ValidationErrors); ok {
   // iterate errs and get detailed information
}

The above is not your API, it’s the package’s. And the error details are in a format you may not like. To make everything yours, from the API to the output, a simple package can wrap the 3rd party one and thus settle some important things:

  • Define your own API through an interface, setting the coupling rules through your app
  • Model the error details format
package validator

import (
   "strings"

   validate "github.com/go-playground/validator"
)

// Validator offers struct validation
type Validator interface {
   Validate(s interface{}) error
}

// Error provides validation detailed information
type Error struct {
   Err    error
   Fields []Field
}

// Error returns the string representation of the error validation error
func (e *Error) Error() string {
   return e.Err.Error()
}

// Field representation of each struct field with validation error
type Field struct {
   Name  string      `json:"name"`
   Error string      `json:"error"`
   Value interface{} `json:"value"`
}

type validator struct {
   v *validate.Validate
}

func (v *validator) Validate(s interface{}) (err error) {
   if err = v.v.Struct(s); err == nil {
      return
   }

   if errs, ok := err.(validate.ValidationErrors); ok {
      fields := make([]Field, 0, len(errs))
      for _, e := range errs {
         fields = append(fields, Field{
            Name:  field(e.Namespace()),
            Value: e.Value(),
            Error: translate(e),
         })
      }

      return &Error{
         Err:    err,
         Fields: fields,
      }
   }

   return
}

// New returns a new Validator instance
func New() Validator {
   return &validator{
      v: validate.New(),
   }
}

func field(s string) (field string) {
   field = s
   if index := strings.Index(s, "."); index > -1 && index < len(s) {
      field = s[index+1:]
   }

   return strings.ToLower(field)
}

func translate(e validate.FieldError) string {
   translations := map[string]string{
      "required": "Is empty.",
      "gte":      "Must be equal to or greater than {param}.",
      "lt":       "Must be lower than {param}.",
      "lte":      "Must be lower than or equal to {param}.",
      "alpha":    "Must contain only letters.",
   }

   if t, ok := translations[e.Tag()]; ok {
      return strings.ReplaceAll(t, "{param}", e.Param())
   }

   return "invalid value"
}

The validator is now yours, you set the rules:

type Validator interface {
   Validate(s interface{}) error
}

The error response is structured as your app needs with the information which is helpful for you, based on an error type which you can check after a validation is performed.

type Error struct {
   Err    error
   Fields []Field
}

func (e *Error) Error() string {
   return e.Err.Error()
}

type Field struct {
   Name  string      `json:"name"`
   Error string      `json:"error"`
   Value interface{} `json:"value"`
}

The Validate method holds the highest level of coupling with the 3rd party dependency, using its API and translating its errors into yours.

And because output should be defined and predictable as well as the input is, a 100% coverage ensures you are in control:

package validator

import (
   "testing"

   "github.com/stretchr/testify/assert"
)

func TestValidator_Validate(t *testing.T) {
   user := struct {
      Age  int    `validate:"gt=18"`
      Name string `validate:"alphanum"`
   }{
      Age:  25,
      Name: "Doe",
   }

   v := New()
   err := v.Validate(user)

   assert.NoError(t, err)
}

func TestValidator_ValidateWithValidationErrors(t *testing.T) {
   user := struct {
      Age     int    `validate:"gte=18"`
      Name    string `validate:"alphanum"`
      Height  int    `validate:"lte=130"`
      Profile struct {
         Description string `validate:"required"`
      }
   }{
      Age:    7,
      Name:   "John Doe",
      Height: 131,
      Profile: struct {
         Description string `validate:"required"`
      }{
         Description: "",
      },
   }

   v := New()
   err := v.Validate(user)

   assert.Error(t, err)
   assert.NotEmpty(t, err.Error())

   fields := []Field{
      {
         Name:  "age",
         Value: 7,
         Error: "Must be equal to or greater than 18.",
      },
      {
         Name:  "name",
         Value: "John Doe",
         Error: "invalid value",
      },
      {
         Name:  "height",
         Value: 131,
         Error: "Must be lower than or equal to 130.",
      },
      {
         Name:  "description",
         Value: "",
         Error: "Is empty.",
      },
   }

   assert.Equal(t, fields, err.(*Error).Fields)
}

func TestValidator_ValidateWithValidationFail(t *testing.T) {
   user := 0

   v := New()
   err := v.Validate(user)

   assert.Error(t, err)
}

func TestValidator_ValidateWithUndefinedTag(t *testing.T) {
   user := struct {
      Age int `validate:"undefined tag"`
   }{
      Age: 7,
   }

   defer func() {
      assert.NotEmpty(t, recover())
   }()

   v := New()
   _ = v.Validate(user)
}

Here you are an example of an app depending on your package. It validates user input and returns an error message adapted to your needs. Follow the comments for details.

package main

import (
   // The abstracted validator package
   "app/internal/validator"

   "net/http"

   "github.com/labstack/echo/v4"
   "github.com/labstack/echo/v4/middleware"
)

// A struct to bind the query string to
type request struct {
   Name   string `query:"name" validate:"required"`
   Tag    string `query:"tag" validate:"required,alpha"`
   Offset int    `query:"offset" validate:"gte=0"`
   Limit  int    `query:"limit" validate:"gte=1,lte=30"`
}

// Default error response
type errorResponse struct {
   Error string `json:"error"`
}

// Validation error response
type validationErrorResponse struct {
   *errorResponse
   Fields []validator.Field `json:"fields"`
}

// HTTP handler
func users(c echo.Context) error {
   req := request{}

   if err := c.Bind(&req); err != nil {
      return err
   }

   if err := c.Validate(req); err != nil {
      return err
   }

   return c.JSON(http.StatusOK, req)
}

func main() {
   server := echo.New()
   server.Debug = false

   server.Use(
      middleware.Recover(),
      middleware.Logger(),
      middleware.RequestID(),
   )

   // Get a validator instance
   v := validator.New()

   // and bind it to Echo. Data validation is a critical operation,
   // and this framework came up with a great solution of allowing you
   // to use any validator you like.
   server.Validator = v

   // Custom error handling
   server.HTTPErrorHandler = func(err error, c echo.Context) {
      status := http.StatusInternalServerError
      var response interface{}

      switch e := err.(type) {
      // Check if a validation error was returned by handler
      case *validator.Error:
         response = &validationErrorResponse{
            errorResponse: &errorResponse{
               Error: "Invalid input",
            },
            Fields: e.Fields,
         }
         status = http.StatusBadRequest
      default:
         message := "Internal server error"
         if c.Echo().Debug {
            message = err.Error()
         }
         response = &errorResponse{
            Error: message,
         }
      }

      err = echo.NewHTTPError(status, response).SetInternal(err)
      c.Echo().DefaultHTTPErrorHandler(err, c)
   }

   server.GET("/", users)
   server.Logger.Fatal(server.Start(":8877"))
}

Leave a Reply

Your email address will not be published.

This site uses Akismet to reduce spam. Learn how your comment data is processed.