Skip to content

Commit

Permalink
datasizes: add new Size type with json/toml decoding support
Browse files Browse the repository at this point in the history
This commit adds a new `datasizes.Size` type that is an alias
for uint64 but has supports direct json/toml decoding. So sizes
can be specified as 123, "123" or "123 GiB" and this is transparently
handled.

datasizes: tweak error return to avoid "stuttering"

The `datasizes.Size.UnmarshalJSON()` returns a `JSON unmarshal`
prefix. However this is a bit too generic as other (nested)
unmarshalers may also use the prefix. So instead just indicate
the type in the error string itself:
```
error decoding {TOML,JSON} size: ...
```
  • Loading branch information
mvo5 committed Nov 22, 2024
1 parent 98b1ad5 commit 54f019d
Show file tree
Hide file tree
Showing 2 changed files with 197 additions and 0 deletions.
72 changes: 72 additions & 0 deletions pkg/datasizes/size.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package datasizes

import (
"bytes"
"encoding/json"
"fmt"
)

// Size is a wrapper around uint64 with support for reading from string
// yaml/toml, so {"size": 123}, {"size": "1234"}, {"size": "1 GiB"} are
// all supported
type Size uint64

// Uint64 returns the size as uint64. This is a convenience functions,
// it is strictly equivalent to uint64(Size(1))
func (si Size) Uint64() uint64 {
return uint64(si)
}

func (si *Size) UnmarshalTOML(data interface{}) error {
i, err := decodeSize(data)
if err != nil {
return fmt.Errorf("error decoding TOML size: %w", err)
}
*si = Size(i)
return nil
}

func (si *Size) UnmarshalJSON(data []byte) error {
dec := json.NewDecoder(bytes.NewBuffer(data))
dec.UseNumber()

var v interface{}
if err := dec.Decode(&v); err != nil {
return err
}
i, err := decodeSize(v)
if err != nil {
// if only we could do better here and include e.g. the field
// name where this happend but encoding/json does not
// support this, c.f. https://github.com/golang/go/issues/58655
return fmt.Errorf("error decoding size: %w", err)
}
*si = Size(i)
return nil
}

// decodeSize takes an integer or string representing a data size (with a data
// suffix) and returns the uint64 representation.
func decodeSize(size any) (uint64, error) {
switch s := size.(type) {
case string:
return Parse(s)
case json.Number:
i, err := s.Int64()
if i < 0 {
return 0, fmt.Errorf("cannot be negative")
}
return uint64(i), err
case int64:
if s < 0 {
return 0, fmt.Errorf("cannot be negative")
}
return uint64(s), nil
case uint64:
return s, nil
case float64, float32:
return 0, fmt.Errorf("cannot be float")
default:
return 0, fmt.Errorf("failed to convert value \"%v\" to number", size)
}
}
125 changes: 125 additions & 0 deletions pkg/datasizes/size_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package datasizes_test

import (
"encoding/json"
"testing"

"github.com/BurntSushi/toml"
"github.com/stretchr/testify/assert"

"github.com/osbuild/images/pkg/datasizes"
)

func TestSizeUnmarshalTOMLUnhappy(t *testing.T) {
cases := []struct {
name string
input string
err string
}{
{
name: "wrong datatype/bool",
input: `size = true`,
err: `toml: line 1 (last key "size"): error decoding TOML size: failed to convert value "true" to number`,
},
{
name: "wrong datatype/float",
input: `size = 3.14`,
err: `toml: line 1 (last key "size"): error decoding TOML size: cannot be float`,
},
{
name: "wrong unit",
input: `size = "20 KG"`,
err: `toml: line 1 (last key "size"): error decoding TOML size: unknown data size units in string: 20 KG`,
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var v struct {
Size datasizes.Size `toml:"size"`
}
err := toml.Unmarshal([]byte(tc.input), &v)
assert.EqualError(t, err, tc.err, tc.input)
})
}
}

func TestSizeUnmarshalJSONUnhappy(t *testing.T) {
cases := []struct {
name string
input string
err string
}{
{
name: "misize nor string nor int",
input: `{"size": true}`,
err: `error decoding size: failed to convert value "true" to number`,
},
{
name: "wrong datatype/float",
input: `{"size": 3.14}`,
err: `error decoding size: strconv.ParseInt: parsing "3.14": invalid syntax`,
},
{
name: "misize not parseable",
input: `{"size": "20 KG"}`,
err: `error decoding size: unknown data size units in string: 20 KG`,
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var v struct {
Size datasizes.Size `json:"size"`
}
err := json.Unmarshal([]byte(tc.input), &v)
assert.EqualError(t, err, tc.err, tc.input)
})
}
}

func TestSizeUnmarshalHappy(t *testing.T) {
cases := []struct {
name string
inputJSON string
inputTOML string
expected datasizes.Size
}{
{
name: "int",
inputJSON: `{"size": 1234}`,
inputTOML: `size = 1234`,
expected: 1234,
},
{
name: "str",
inputJSON: `{"size": "1234"}`,
inputTOML: `size = "1234"`,
expected: 1234,
},
{
name: "str/with-unit",
inputJSON: `{"size": "1234 MiB"}`,
inputTOML: `size = "1234 MiB"`,
expected: 1234 * datasizes.MiB,
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var v struct {
Size datasizes.Size `json:"size" toml:"size"`
}
err := toml.Unmarshal([]byte(tc.inputTOML), &v)
assert.NoError(t, err)
assert.Equal(t, tc.expected, v.Size, tc.inputTOML)
err = json.Unmarshal([]byte(tc.inputJSON), &v)
assert.NoError(t, err)
assert.Equal(t, tc.expected, v.Size, tc.inputJSON)
})
}
}

func TestSizeUint64(t *testing.T) {
assert.Equal(t, datasizes.Size(1234).Uint64(), uint64(1234))
}

0 comments on commit 54f019d

Please sign in to comment.