Search code examples
jsongomultipartform-datago-gin

Golang gin receive json data and image


I have this code for request handler:

func (h *Handlers) UpdateProfile() gin.HandlerFunc {
    type request struct {
        Username    string `json:"username" binding:"required,min=4,max=20"`
        Description string `json:"description" binding:"required,max=100"`
    }

    return func(c *gin.Context) {
        var updateRequest request

        if err := c.BindJSON(&updateRequest); err != nil {
            var validationErrors validator.ValidationErrors

            if errors.As(err, &validationErrors) {
                validateErrors := base.BindingError(validationErrors)
                c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": validateErrors})
            } else {
                c.AbortWithError(http.StatusBadRequest, err)
            }

            return
        }

        avatar, err := c.FormFile("avatar")
        if err != nil {
            c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
                "error": "image not contains in request",
            })
            return
        }

        log.Print(avatar)

        if avatar.Size > 3<<20 { // if avatar size more than 3mb
            c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
                "error": "image is too large",
            })
            return
        }

        file, err := avatar.Open()
        if err != nil {
            c.AbortWithError(http.StatusInternalServerError, err)
        }

        session := sessions.Default(c)
        id := session.Get("sessionId")
        log.Printf("ID type: %T", id)

        err = h.userService.UpdateProfile(fmt.Sprintf("%v", id), file, updateRequest.Username, updateRequest.Description)
        if err != nil {
            c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{})
            return
        }

        c.IndentedJSON(http.StatusNoContent, gin.H{"message": "succesfull update"})
    }
}

And I have this unit test for this handler:

func TestUser_UpdateProfile(t *testing.T) {
    type testCase struct {
        name               string
        image              io.Reader
        username           string
        description        string
        expectedStatusCode int
    }

    router := gin.Default()

    memStore := memstore.NewStore([]byte("secret"))
    router.Use(sessions.Sessions("session", memStore))

    userGroup := router.Group("user")
    repo := user.NewMemory()
    service := userService.New(repo)
    userHandlers.Register(userGroup, service)

    testImage := make([]byte, 100)
    rand.Read(testImage)
    image := bytes.NewReader(testImage)

    testCases := []testCase{
        {
            name:               "Request With Image",
            image:              image,
            username:           "bobik",
            description:        "wanna be sharik",
            expectedStatusCode: http.StatusNoContent,
        },
        {
            name:               "Request Without Image",
            image:              nil,
            username:           "sharik",
            description:        "wanna be bobik",
            expectedStatusCode: http.StatusNoContent,
        },
    }

    for _, tc := range testCases {
        t.Run(tc.name, func(t *testing.T) {
            body := &bytes.Buffer{}
            writer := multipart.NewWriter(body)

            imageWriter, err := writer.CreateFormFile("avatar", "test_avatar.jpg")
            if err != nil {
                t.Fatal(err)
            }

            if _, err := io.Copy(imageWriter, image); err != nil {
                t.Fatal(err)
            }

            data := map[string]interface{}{
                "username":    tc.username,
                "description": tc.description,
            }
            jsonData, err := json.Marshal(data)
            if err != nil {
                t.Fatal(err)
            }

            jsonWriter, err := writer.CreateFormField("json")
            if err != nil {
                t.Fatal(err)
            }

            if _, err := jsonWriter.Write(jsonData); err != nil {
                t.Fatal(err)
            }

            writer.Close()

            // Creating request
            req := httptest.NewRequest(
                http.MethodPost,
                "http://localhost:8080/user/account/updateprofile",
                body,
            )
            req.Header.Set("Content-Type", writer.FormDataContentType())
            log.Print(req)

            w := httptest.NewRecorder()
            router.ServeHTTP(w, req)

            assert.Equal(t, tc.expectedStatusCode, w.Result().StatusCode)
        })
    }
}

During test I have this error: Error #01: invalid character '-' in numeric literal

And here is request body (I am printing it with log.Print(req)):

&{POST http://localhost:8080/user/account/updateprofile HTTP/1.1 1 1 map[Content-Type:[multipart/form-data; boundary=30b24345de9d8d83ecbdd146262d86894c45b4f3485e4615553621fd2035]] {--30b24345de9d8d83ecbdd146262d86894c45b4f3485e4615553621fd2035
Content-Disposition: form-data; name="avatar"; filename="test_avatar.jpg"
Content-Type: application/octet-stream


--30b24345de9d8d83ecbdd146262d86894c45b4f3485e4615553621fd2035
Content-Disposition: form-data; name="json"

{"description":"wanna be bobik","username":"sharik"}
--30b24345de9d8d83ecbdd146262d86894c45b4f3485e4615553621fd2035--
} <nil> 414 [] false localhost:8080 map[] map[] <nil> map[] 192.0.2.1:1234 http://localhost:8080/user/account/updateprofile <nil> <nil> <nil> <nil>}

First I just have strings as json data and converted it to bytes. When error appeared I converted json data using json.Marshal, but it didn't work out. I want to parse json data with c.Bind and parse given image with c.FormFile, does it possible?

Upd. I replaced code to get avatar first, and then get json by Bind structure. Now I have EOF error.


Solution

  • TL;DR

    We can define a struct to receive the json data and image file at the same time (pay attention to the field tags):

    var updateRequest struct {
        Avatar *multipart.FileHeader `form:"avatar" binding:"required"`
        User   struct {
            Username    string `json:"username" binding:"required,min=4,max=20"`
            Description string `json:"description" binding:"required,max=100"`
        } `form:"user" binding:"required"`
    }
    
    // c.ShouldBind will choose binding.FormMultipart based on the Content-Type header.
    // We call c.ShouldBindWith to make it explicitly.
    if err := c.ShouldBindWith(&updateRequest, binding.FormMultipart); err != nil {
        _ = c.AbortWithError(http.StatusBadRequest, err)
        return
    }
    

    Can gin parse other content type in multipart/form-data automatically?

    For example, xml or yaml.

    The current gin (@1.9.0) does not parse xml or yaml in multipart/form-data automatically. json is lucky because gin happens to parse the form field value using json.Unmarshal when the target field is a struct or map. See binding.setWithProperType.

    We can parse them ourself like this (updateRequest.Event is the string value from the form):

    var event struct {
        At     time.Time `xml:"time" binding:"required"`
        Player string    `xml:"player" binding:"required"`
        Action string    `xml:"action" binding:"required"`
    }
    
    if err := binding.XML.BindBody([]byte(updateRequest.Event), &event); err != nil {
        _ = c.AbortWithError(http.StatusBadRequest, err)
        return
    }
    

    (Please don't get confused with xml in an application/xml request or yaml in an application/x-yaml request. This is only required when the xml content or yaml content is in a multipart/form-data request).

    Misc

    1. c.BindJSON can not be used to read json from multipart/form-data because it assumes that the request body starts with a valid json. But it's starts with a boundary, which looks like --30b24345d.... That's why it failed with error message invalid character '-' in numeric literal.
    2. Calling c.BindJSON after c.FormFile("avatar") does not work because calling c.FormFile makes the whole request body being read. And c.BindJSON has nothing to read later. That's why you see the EOF error.

    The demo in a single runnable file

    Here is the full demo. Run with go test ./... -v -count 1:

    package m
    
    import (
        "bytes"
        "crypto/rand"
        "fmt"
        "io"
        "mime/multipart"
        "net/http"
        "net/http/httptest"
        "testing"
        "time"
    
        "github.com/gin-gonic/gin"
        "github.com/gin-gonic/gin/binding"
        "github.com/stretchr/testify/assert"
    )
    
    func handle(c *gin.Context) {
        var updateRequest struct {
            Avatar *multipart.FileHeader `form:"avatar" binding:"required"`
            User   struct {
                Username    string `json:"username" binding:"required,min=4,max=20"`
                Description string `json:"description" binding:"required,max=100"`
            } `form:"user" binding:"required"`
            Event string `form:"event" binding:"required"`
        }
    
        // c.ShouldBind will choose binding.FormMultipart based on the Content-Type header.
        // We call c.ShouldBindWith to make it explicitly.
        if err := c.ShouldBindWith(&updateRequest, binding.FormMultipart); err != nil {
            _ = c.AbortWithError(http.StatusBadRequest, err)
            return
        }
        fmt.Printf("%#v\n", updateRequest)
    
        var event struct {
            At     time.Time `xml:"time" binding:"required"`
            Player string    `xml:"player" binding:"required"`
            Action string    `xml:"action" binding:"required"`
        }
    
        if err := binding.XML.BindBody([]byte(updateRequest.Event), &event); err != nil {
            _ = c.AbortWithError(http.StatusBadRequest, err)
            return
        }
    
        fmt.Printf("%#v\n", event)
    }
    
    func TestMultipartForm(t *testing.T) {
        testImage := make([]byte, 100)
    
        if _, err := rand.Read(testImage); err != nil {
            t.Fatal(err)
        }
        image := bytes.NewReader(testImage)
    
        body := &bytes.Buffer{}
        writer := multipart.NewWriter(body)
    
        imageWriter, err := writer.CreateFormFile("avatar", "test_avatar.jpg")
        if err != nil {
            t.Fatal(err)
        }
    
        if _, err := io.Copy(imageWriter, image); err != nil {
            t.Fatal(err)
        }
    
        if err := writer.WriteField("user", `{"username":"bobik","description":"wanna be sharik"}`); err != nil {
            t.Fatal(err)
        }
    
        xmlBody := `<?xml version="1.0" encoding="UTF-8"?>
    <root>
       <time>2023-02-14T19:04:12Z</time>
       <player>playerOne</player>
       <action>strike (miss)</action>
    </root>`
        if err := writer.WriteField("event", xmlBody); err != nil {
            t.Fatal(err)
        }
    
        writer.Close()
    
        req := httptest.NewRequest(
            http.MethodPost,
            "http://localhost:8080/update",
            body,
        )
        req.Header.Set("Content-Type", writer.FormDataContentType())
        fmt.Printf("%v\n", req)
    
        w := httptest.NewRecorder()
        c, engine := gin.CreateTestContext(w)
        engine.POST("/update", handle)
        c.Request = req
        engine.HandleContext(c)
    
        assert.Equal(t, 200, w.Result().StatusCode)
    }
    

    Thanks for reading!