Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to tell the client they need to send an integer instead of a string, from a Go server?

Tags:

json

go

Let's say I have the following Go struct on the server

type account struct {
    Name    string
    Balance int
}

I want to call json.Decode on the incoming request to parse it into an account.

    var ac account
    err := json.NewDecoder(r.Body).Decode(&ac)

If the client sends the following request:

{
    "name": "[email protected]", 
    "balance": "3"
}

Decode() will return the following error:

json: cannot unmarshal string into Go value of type int

Now it's possible to parse that back into "you sent a string for Balance, but you really should have sent an integer", but it's tricky, because you don't know the field name. It also gets a lot trickier if you have a lot of fields in the request - you don't know which one failed to parse.

What's the best way to take that incoming request, in Go, and return the error message, "Balance must be a string", for any arbitrary number of integer fields on a request?

like image 487
Kevin Burke Avatar asked Feb 09 '16 16:02

Kevin Burke


2 Answers

You can use a custom type with custom unmarshaling algorythm for your "Balance" field.

Now there are two possibilities:

  • Handle both types:

    package main
    
    import (
        "encoding/json"
        "fmt"
        "strconv"
    )
    
    type Int int
    
    type account struct {
        Name    string
        Balance Int
    }
    
    func (i *Int) UnmarshalJSON(b []byte) (err error) {
        var s string
        err = json.Unmarshal(b, &s)
        if err == nil {
            var n int
            n, err = strconv.Atoi(s)
            if err != nil {
                return
            }
            *i = Int(n)
            return
        }
    
        var n int
        err = json.Unmarshal(b, &n)
        if err == nil {
            *i = Int(n)
        }
        return
    }
    
    func main() {
        for _, in := range [...]string{
            `{"Name": "foo", "Balance": 42}`,
            `{"Name": "foo", "Balance": "111"}`,
        } {
            var a account
            err := json.Unmarshal([]byte(in), &a)
            if err != nil {
                fmt.Printf("Error decoding JSON: %v\n", err)
            } else {
                fmt.Printf("Decoded OK: %v\n", a)
            }
        }
    }
    

    Playground link.

  • Handle only a numeric type, and fail anything else with a sensible error:

    package main
    
    import (
        "encoding/json"
        "fmt"
    )
    
    type Int int
    
    type account struct {
        Name    string
        Balance Int
    }
    
    type FormatError struct {
        Want   string
        Got    string
        Offset int64
    }
    
    func (fe *FormatError) Error() string {
        return fmt.Sprintf("Invalid data format at %d: want: %s, got: %s",
            fe.Offset, fe.Want, fe.Got)
    }
    
    func (i *Int) UnmarshalJSON(b []byte) (err error) {
        var n int
        err = json.Unmarshal(b, &n)
        if err == nil {
            *i = Int(n)
            return
        }
        if ute, ok := err.(*json.UnmarshalTypeError); ok {
            err = &FormatError{
                Want:   "number",
                Got:    ute.Value,
                Offset: ute.Offset,
            }
        }
        return
    }
    
    func main() {
        for _, in := range [...]string{
            `{"Name": "foo", "Balance": 42}`,
            `{"Name": "foo", "Balance": "111"}`,
        } {
            var a account
            err := json.Unmarshal([]byte(in), &a)
            if err != nil {
                fmt.Printf("Error decoding JSON: %#v\n", err)
                fmt.Printf("Error decoding JSON: %v\n", err)
            } else {
                fmt.Printf("Decoded OK: %v\n", a)
            }
        }
    }
    

    Playground link.

There is a third possibility: write custom unmarshaler for the whole account type, but it requires more involved code because you'd need to actually iterate over the input JSON data using the methods of the encoding/json.Decoder type.

After reading your

What's the best way to take that incoming request, in Go, and return the error message, "Balance must be a string", for any arbitrary number of integer fields on a request?

more carefully, I admit having a custom parser for the whole type is the only sensible possibility unless you are OK with a 3rd-party package implementing a parser supporting validation via JSON schema (I think I'd look at this first as juju is a quite established product).

like image 161
kostix Avatar answered Oct 25 '22 19:10

kostix


A solution for this could be to use a type assertion by using a map to unmarshal the JSON data into:

type account struct {
    Name    string
    Balance int
}

var str = `{
    "name": "[email protected]", 
    "balance": "3"
}`

func main() {
    var testing = map[string]interface{}{}
    err := json.Unmarshal([]byte(str), &testing)
    if err != nil {
        fmt.Println(err)
    }

    val, ok := testing["balance"]
    if !ok {
        fmt.Println("missing field balance")
        return
    }

    nv, ok := val.(float64)
    if !ok {
        fmt.Println("balance should be a number")
        return
    }

    fmt.Printf("%+v\n", nv)
}

See http://play.golang.org/p/iV7Qa1RrQZ

The type assertion here is done using float64 because it is the default number type supported by Go's JSON decoder.

It should be noted that this use of interface{} is probably not worth the trouble.

The UnmarshalTypeError (https://golang.org/pkg/encoding/json/#UnmarshalFieldError) contains an Offset field that could allow retrieving the contents of the JSON data that triggered the error.

You could for example return a message of the sort:

cannot unmarshal string into Go value of type int near `"balance": "3"`
like image 2
SirDarius Avatar answered Oct 25 '22 19:10

SirDarius