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.
3
Answers
I was able to manage your needs with the following solution. Let’s start with the production code.
handlers.go
fileHere, there are a couple of things to be aware of:
form:"username"
on theUsername
field. Thanks to this,gin
knows where to look for the field in the incoming HTTP Request.ShouldBind
that takes care of the rest.Now, let’s switch to the test code.
handlers_test.go
fileThe test file builds and runs only a single test. However, you can surely expand on it.
Here, we can summarize what’s going on in the following list:
multipart/form-data
request to pass to theUpdateProfile
Gin handler.avatar
.username
with the valueivan_pesenti
.multipartWriter
before issuing the request. This is imperative!The rest of the test file should be pretty straight-forward, so I won’t spend extra time explaining it!
Let me know whether it’s clear or you have other questions, thanks!
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):
Can gin parse other content type in
multipart/form-data
automatically?For example,
xml
oryaml
.The current gin (@1.9.0) does not parse
xml
oryaml
inmultipart/form-data
automatically.json
is lucky because gin happens to parse the form field value usingjson.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):(Please don’t get confused with
xml
in anapplication/xml
request oryaml
in anapplication/x-yaml
request. This is only required when thexml
content oryaml
content is in amultipart/form-data
request).Misc
c.BindJSON
can not be used to read json frommultipart/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 messageinvalid character '-' in numeric literal
.c.BindJSON
afterc.FormFile("avatar")
does not work because callingc.FormFile
makes the whole request body being read. Andc.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
:Thanks for reading!
This is a new question that has nothing to do with the original question. So I will post a new answer for it (maybe we should create a new question instead. I will move this answer to the new question if one is created).
The error is caused by the fact that the session is not added to the context yet. I will try to explain how session works generally with q sequence diagram.
You see that before the session middleware is executed for a request,
sessions.Default(c)
is not available yet (see step 2 and step 7).So it’s naturally to add a middleware after the session middleware so that it can access and modify the session:
Notes: Since the session is touched in the test, if there is something wrong with the session, maybe the test can not catch it. So don’t forget to add a test to make an request to let it create the real session, and pass the cookies from this request (let’s assume it uses cookies) to the next request that will read from the session.