goslicemultipartgo-gin

How to parse slice of structs in Go Gin multipart request


This is my application:

func main() {
    router := gin.Default()
    router.POST("user", func(c *gin.Context) {
        type address struct {
            City    string `form:"city"`
            Country string `form:"country"`
        }
        type user struct {
            Name      string    `form:"name"`
            Addresses []address `form:"addresses"`
        }

        var payload user
        err := c.Bind(&payload)
        if err != nil {
            c.JSON(http.StatusBadRequest, gin.H{})
            return
        }

        file, err := c.FormFile("image")
        if err != nil {
            c.JSON(http.StatusBadRequest, gin.H{})
            return
        }

        fmt.Printf("TODO: save user image %s\n", file.Filename)
        fmt.Printf("TODO: save user %s with addresses %d\n", payload.Name, len(payload.Addresses))
    })
    router.Run(":8080")
}

This is my request:

curl --location 'http://localhost:8080/user' \
--form 'name="John Smith"' \
--form 'addresses[0][city]="London"' \
--form 'addresses[0][country]="United Kingdom"' \
--form 'image=@"/Users/me/Documents/app/fixtures/user.jpeg"'

The problem is that addresses are nil. I'm not able to find example of how to correctly deserialize multipart request with embedded slice data. what am I doing wrong?


update:

I exported the curl command from Postman. apparently, the POST method is implicit if you have "--form" parameter. here are logs from my app:

TODO: save user image user.jpeg
TODO: save user John Smith with addresses 0

update 2:

Turns out that sending array like this is wrong:

--form 'addresses[0][city]="London"'

What gin supports by default is sending multiple fields with the same name, which then will be turned into an array:

--form 'addresses=... --form 'addresses=...

so that led me to a somewhat hacky solution of passing each address as query parameters and then parsing them individually:

request:

curl --location 'http://localhost:8080/user' \
--form 'name="John Smith"' \
--form 'addresses="?city=London&country=UK"' \
--form 'addresses="?city=Berlin&country=Germany"'
--form 'image=@"/Users/foo/bar/baz/user.jpeg"' \

the app:

func main() {
    router := gin.Default()
    router.POST("user", func(c *gin.Context) {
        type address struct {
            City    string `form:"city"`
            Country string `form:"country"`
        }
        type user struct {
            Name      string   `form:"name"`
            Addresses []string `form:"addresses"` // collection_format:"multi"
        }

        var payload user
        err := c.Bind(&payload)
        if err != nil {
            c.JSON(http.StatusBadRequest, gin.H{})
            return
        }

        var addresses []address
        for _, path := range payload.Addresses {
            parsed, err := url.Parse(path)
            if err != nil {
                continue
            }
            query := parsed.Query()
            addresses = append(addresses, address{
                City:    query.Get("city"),
                Country: query.Get("country"),
            })
        }

        file, err := c.FormFile("image")
        if err != nil {
            c.JSON(http.StatusBadRequest, gin.H{})
            return
        }

        fmt.Printf("TODO: save user image %s\n", file.Filename)
        fmt.Printf("TODO: save user %s with addresses %d\n", payload.Name, len(addresses))

    })
    router.Run(":8080")
}

So please, if someone have a better solution please let me know.


Solution

  • One Solution is instead of defining addresses as slice of string define it as slice of address and pass each address as json instead of query params.

    request -

    curl --location 'http://localhost:8080/getForm' \
    --form 'name ="Zargam"' \
    --form 'addresses="{\"city\":\"Delhi\",\"country\":\"India\"}"' \
    --form 'addresses="{\"city\":\"Shanghai\",\"country\":\"China\"}"'
    

    Code-

    package main
    
    import (
        "encoding/json"
        "net/http"
    
        "github.com/gin-gonic/gin"
    )
    
    type address struct {
        City    string `form:"city"`
        Country string `form:"country"`
    }
    type user struct {
        Name      string    `form:"name"`
        Addresses []address `form:"addresses"` // collection_format:"multi"
    }
    
    func processForm(c *gin.Context) {
        var payload user
        err := c.Bind(&payload)
        if err != nil {
            c.JSON(http.StatusBadRequest, gin.H{})
            return
        }
        payloadByte, _ := json.Marshal(payload)
    
        c.JSON(http.StatusOK, gin.H{"message": "success", "data": string(payloadByte)})
        return
    }
    
    func main() {
        r := gin.Default()
        r.POST("/getForm", processForm)
        r.Run() // listen and serve on 0.0.0.0:8080 (for windows "localhost:8080")
    }