본문 바로가기

Golang/web-framework

[Gin] 바인딩 및 에러 처리하기

gin 은 golang의 인기있는(스타가 많은?) web framework 중 하나이며 http request의 header, uri, query, json body
등을 바인딩 하는 방법을 살펴보고 주의해야 할 점에 대해 알아보겠습니다.

목차

  1. 바인딩 할 태그 정의
  2. Validation 추가
  3. ShouldBindXXX 이용하기
  4. 전체 소스 코드
  5. 테스트 진행하기
  6. 에러 핸들링하기
  7. 마무리

1. 바인딩 할 태그 정의

우선 아래와 같이 태그를 이용하여 바인딩 할 필드를 정의할 수 있습니다.

  1. header : header:"header-key"
  2. uri : uri:"uri-path"
  3. query : form:"query-parameter-name"
type HeaderParameter struct {
    XRequestID string `header:"X-Request-ID"`
}
type UriParameter struct {
    ID string `uri:"id"`
}
type QueryParameter struct {
    Hex  string `form:"hex"`
    Size uint   `form:"size"`
}

2. Validation 추가

gin의 문서를 살펴보면 go-playground의 validator 라이브러리를 사용하는 것을 알 수 있습니다.

 

Gin uses go-playground/validator/v10 for validation. Check the full docs on tags usage here.

 

위의 validator 예제를 살펴보면 validate 태그를 이용하지만, gin에서는 binding 태그를 이용하여

유효성을 검사할 수 있습니다. 아래의 예제는 Query parameter인 ["hex", "size"] 파라미터에 유효성을 추가한 예제 입니다.
(hex 파라미터에는 16진수 포맷을, size 파라미터에는 없으면 생략 + 3보다 큰 값)

type QueryParameter struct {
    Hex  string `form:"hex" binding:"required,hexadecimal"`
    Size uint   `form:"size" binding:"omitempty,gte=3"`
}

3. ShouldBindXXX 이용하기

마지막으로 위에서 정의한 구조체를 기준으로 ShouldBindXXX를 이용하여 바인딩하는 방법을 살펴보겠습니다.

gin에서는 아래와 같은 ShouldBindXXX 함수가 있습니다.

ShouldBind, ShouldBindJSON, ShouldBindXML, ShouldBindQuery, ShouldBindYAML, ShouldBindHeader

(BindXXX 함수와 차이점은 gin.context에 에러를 포함하지 않고 erorr를 직접 처리합니다.)

 

// bind header
if err := c.ShouldBindHeader(&header); err != nil {
    // handle header bind error
    c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
        "message": err.Error(),
    })
    return
}
// bind uri
if err := c.ShouldBindUri(&uri); err != nil {
    // handle uri bind error
    c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
        "message": err.Error(),
    })
    return
}
// bind query parameter
if err := c.ShouldBindQuery(&query); err != nil {
    // handle query bind error
    c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
        "message": err.Error(),
    })
    return
}

4. 전체 소스 코드

import (
    "github.com/gin-gonic/gin"
    "log"
    "net/http"
)

type HeaderParameter struct {
    XRequestID string `header:"X-Request-ID"`
}
type UriParameter struct {
    ID string `uri:"id"`
}
type QueryParameter struct {
    Hex  string `form:"hex" binding:"required,hexadecimal"`
    Size uint   `form:"size" binding:"omitempty,gte=3"`
}

func main() {
    e := gin.New()
    e.GET("/bind/:id", func(c *gin.Context) {
        var (
            header HeaderParameter
            uri    UriParameter
            query  QueryParameter
        )
        // bind header
        if err := c.ShouldBindHeader(&header); err != nil {
            // handle header bind error
            c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
                "message": err.Error(),
            })
            return
        }
        // bind uri
        if err := c.ShouldBindUri(&uri); err != nil {
            // handle uri bind error
            c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
                "message": err.Error(),
            })
            return
        }
        // bind query parameter
        if err := c.ShouldBindQuery(&query); err != nil {
            // handle query bind error
            c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
                "message": err.Error(),
            })
            return
        }

        c.JSON(http.StatusOK, gin.H{
            "header": gin.H{
                "X-Request-ID": header.XRequestID,
            },
            "uri": gin.H{
                "id": uri.ID,
            },
            "query": gin.H{
                "hex":  query.Hex,
                "size": query.Size,
            },
        })
    })

    if err := e.Run(":3000"); err != nil {
        log.Fatal(err)
    }
}

5. 테스트 진행하기

.http 파일을 이용해서 유효한 요청과 잘못된 요청을 테스트해보겠습니다.

### Valid requests
http://localhost:3000/bind/100?hex=1&size=10
x-request-id: request-id-uuid

{
  "header": {
    "X-Request-ID": "request-id-uuid"
  },
  "query": {
    "hex": "1",
    "size": 10
  },
  "uri": {
    "id": "100"
  }
}

Response code: 200 (OK); Time: 39ms; Content length: 94 bytes

이번엔 400 Bad request 를 요청해보겠습니다.

### Bad request
http://localhost:3000/bind/100?hex=1&size=2
x-request-id: request-id-uuid

{
  "message": "Key: 'QueryParameter.Size' Error:Field validation for 'Size' failed on the 'gte' tag"
}

Response code: 400 (Bad Request); Time: 30ms; Content length: 98 bytes

6. 에러 핸들링하기

위에서 err.Error() 를 통해 에러에 대한 메시지를 응답하면, 구조체에 대한 필드 및 유효성 검사 태그 등 모든 정보가 포함 된 메시지를 확인할 수 있습니다.

잘못된 요청에 대하여 아래와 같이 잘못된 필드(field), 요청 값(value), 메시지(message) 를 포함하도록 해보겠습니다.

{
  "errors": [
    {
      "field": "size",
      "message": "greater than or quauls to 3",
      "value": 2
    }
  ]
}

우선 gin의 ShouldBindXXX() 함수를 호출하면 go-playground/validator의 type ValidationErrors []FieldError를 반환하는 것을 확인할 수 있습니다.

FieldError를 아래와 같이 살펴보면 Field(), Value(), ActualTag()를 통해서 우리가 원하는 에러 메시지 포맷을 정의할 수 있습니다.

// FieldError contains all functions to get error details
type FieldError interface {

    // returns the validation tag that failed, even if an
    // alias the actual tag within the alias will be returned.
    // If an 'or' validation fails the entire or will be returned.
    //
    // eg. alias "iscolor": "hexcolor|rgb|rgba|hsl|hsla"
    // will return "hexcolor|rgb|rgba|hsl|hsla"
    ActualTag() string

    // returns the fields name with the tag name taking precedence over the
    // fields actual name.
    //
    // eq. JSON name "fname"
    // see StructField for comparison
    Field() string

    // returns the actual fields value in case needed for creating the error
    // message
    Value() interface{}

    ...
}

하지만 FieldError::Field() 함수를 호출하면 이전에 구조체에서 정의했던 form:"size"size 문자열을 반환하는게 아니라

구조체의 필드이름, 즉 Size를 반환하게 됩니다. 그래서 우리가 원하는 해당 필드값을 얻기 위해 해당 구조체와 tag 파라미터가 필요합니다.

구조체와 tag파라미터가 있으면 reflection을 이용하여 해당 태그의 이름을 추출할 수 있습니다.

func handleBindError(c *gin.Context, obj interface{}, tag string, err error) {
    switch err.(type) {
    case validator.ValidationErrors:
        var errs []gin.H
        vErrs := err.(validator.ValidationErrors)
        e := reflect.TypeOf(obj).Elem()
        for _, vErr := range vErrs {
            field, _ := e.FieldByName(vErr.Field())
            tagName, _ := field.Tag.Lookup(tag)
            value := vErr.Value()
            var message string
            switch vErr.ActualTag() {
            case "required":
                message = fmt.Sprintf("required %s", tagName)
            case "hexadecimal":
                message = fmt.Sprintf("required hexadecimal format")
            case "gte":
                message = fmt.Sprintf("greater than or quauls to %s", vErr.Param())
            case "cgte":
                message = fmt.Sprintf("greater than or quauls to %s", vErr.Param())
            case "numeric":
                message = fmt.Sprintf("%s must be numeric", tagName)
            default:
                message = err.Error()
            }
            errs = append(errs, gin.H{
                "field":   tagName,
                "value":   value,
                "message": message,
            })
        }
        c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
            "errors": errs,
        })
        return
    }
    c.AbortWithStatusJSON(http.StatusBadRequest, err.Error())
}

handleBindError()를 이용하는 코드

// bind header
if err := c.ShouldBindHeader(&header); err != nil {
    // handle header bind error
    handleBindError(c, &header, "header", err)
    return
}
// bind uri
if err := c.ShouldBindUri(&uri); err != nil {
    // handle uri bind error
    handleBindError(c, &uri, "uri", err)
    return
}
// bind query parameter
if err := c.ShouldBindQuery(&query); err != nil {
    // handle query bind error
    handleBindError(c, &query, "form", err)
    return
}

Bad request 테스트

### Bad request
http://localhost:3000/bind/100?hex=1&size=2
x-request-id: request-id-uuid

{
  "errors": [
    {
      "field": "size",
      "message": "greater than or quauls to 3",
      "value": 2
    }
  ]
}

Response code: 400 (Bad Request); Time: 24ms; Content length: 79 bytes

마무리

위에서 살펴본 이슈 이외에도 size=a 이런식으로 uint 값으로 매핑할 수 없는 경우 strconv.NumError를 반환하게 됩니다.
그래서 위에서 처리한 switch 문의 default case에 포함하게 됩니다. 이 문제를 해결하기 위해서는 필드 모두 string 타입으로 변경하고
custom validator를 이용하여 유효성을 검사하고, 실제로 사용할 때는 변환 작업이 필요해보입니다.

Github에서 gin을 이용하는 여러 코드를 살펴봤을때 ShouldBindXXX를 사용하기 보다는 모두 string 타입으로 정의 후 직접
바인딩 및 유효성을 검사하는 것을 확인할 수 있었습니다.