DEV Community

Matija Krajnik
Matija Krajnik

Posted on • Updated on • Originally published at letscode.blog

Custom validation errors

If you try to create new account using too short password, you will get error Key: 'User.Password' Error:Field validation for 'Password' failed on the 'min' tag. This is not really user friendly, so it should be changed for better user experience. Let's see how we can transform that to our own custom error messages. For that purpose we will create new Gin handler functions in internal/server/middleware.go file:

func customErrors(ctx *gin.Context) {
  ctx.Next()
  if len(ctx.Errors) > 0 {
    for _, err := range ctx.Errors {
      // Check error type
      switch err.Type {
      case gin.ErrorTypePublic:
        // Show public errors only if nothing has been written yet
        if !ctx.Writer.Written() {
          ctx.AbortWithStatusJSON(ctx.Writer.Status(), gin.H{"error": err.Error()})
        }
      case gin.ErrorTypeBind:
        errMap := make(map[string]string)
        if errs, ok := err.Err.(validator.ValidationErrors); ok {
          for _, fieldErr := range []validator.FieldError(errs) {
            errMap[fieldErr.Field()] = customValidationError(fieldErr)
          }
        }

        status := http.StatusBadRequest
        // Preserve current status
        if ctx.Writer.Status() != http.StatusOK {
          status = ctx.Writer.Status()
        }
        ctx.AbortWithStatusJSON(status, gin.H{"error": errMap})
      default:
        // Log other errors
        log.Error().Err(err.Err).Msg("Other error")
      }
    }

    // If there was no public or bind error, display default 500 message
    if !ctx.Writer.Written() {
      ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": InternalServerError})
    }
  }
}

func customValidationError(err validator.FieldError) string {
  switch err.Tag() {
  case "required":
    return fmt.Sprintf("%s is required.", err.Field())
  case "min":
    return fmt.Sprintf("%s must be longer than or equal %s characters.", err.Field(), err.Param())
  case "max":
    return fmt.Sprintf("%s cannot be longer than %s characters.", err.Field(), err.Param())
  default:
    return err.Error()
  }
}
Enter fullscreen mode Exit fullscreen mode

Constant InternalServerError is defined in internal/server/server.go:

const InternalServerError = "Something went wrong!"
Enter fullscreen mode Exit fullscreen mode

Let's use new Gin middleware in internal/server/router.go:

func setRouter() *gin.Engine {
  // Creates default gin router with Logger and Recovery middleware already attached
  router := gin.Default()

  // Enables automatic redirection if the current route can't be matched but a
  // handler for the path with (without) the trailing slash exists.
  router.RedirectTrailingSlash = true

  // Create API route group
  api := router.Group("/api")
  api.Use(customErrors)
  {
    api.POST("/signup", gin.Bind(store.User{}), signUp)
    api.POST("/signin", gin.Bind(store.User{}), signIn)
  }

  authorized := api.Group("/")
  authorized.Use(authorization)
  {
    authorized.GET("/posts", indexPosts)
    authorized.POST("/posts", createPost)
    authorized.PUT("/posts", updatePost)
    authorized.DELETE("/posts/:id", deletePost)
  }

  router.NoRoute(func(ctx *gin.Context) { ctx.JSON(http.StatusNotFound, gin.H{}) })

  return router
}
Enter fullscreen mode Exit fullscreen mode

As you can see, we are now using customErrors middleware in api group. But that's not the only change. Notice updated routes for signing in and signing up:

api.POST("/signup", gin.Bind(store.User{}), signUp)
api.POST("/signin", gin.Bind(store.User{}), signIn)
Enter fullscreen mode Exit fullscreen mode

With these changes, we are trying to bind request data before even hitting signUp and signIn handlers, which means that handlers will only be reached if form validations are passed. With setup like this, handlers don't need to think about binding errors, because there was none if handler is reached. With that in mind, let's update these 2 handlers:

func signUp(ctx *gin.Context) {
  user := ctx.MustGet(gin.BindKey).(*store.User)
  if err := store.AddUser(user); err != nil {
    ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
    return
  }
  ctx.JSON(http.StatusOK, gin.H{
    "msg": "Signed up successfully.",
    "jwt": generateJWT(user),
  })
}

func signIn(ctx *gin.Context) {
  user := ctx.MustGet(gin.BindKey).(*store.User)
  user, err := store.Authenticate(user.Username, user.Password)
  if err != nil {
    ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Sign in failed."})
    return
  }

  ctx.JSON(http.StatusOK, gin.H{
    "msg": "Signed in successfully.",
    "jwt": generateJWT(user),
  })
}
Enter fullscreen mode Exit fullscreen mode

Our handlers are much simpler now and they are only handling database errors. If you again try to create account with too short username and password, you will see more readable and descriptive errors:

User friendly validation errors

We can now do same thing with Post handlers. I will not show you the solution here, so you can try it yourself for practice, but you can find it on RGB GitHub repo.

Discussion (0)