Skip to content

Commit 9f0c1f9

Browse files
authored
feat: additional jsonutil features (#15)
This change updates the jsonutil package to add functions related to saving and loading JSON content to and from disk. It also adjusts the existing funcions to accomodate the new functions. The following are added: * `UnmarshalFromReaderInto`: This function provides functionality that was previously `UnmarshalFromReader`. * `LoadAs`: This function reads the given path from the filesystem and unmarshals it into a new instance of the given type. * `LoadWith`: This function calls the given read function and unmarshals the returned content into a new instance of the given type. * `Save`: This function unmarshals the given instance and writes it to the specified path in the filesystem. * `SaveWith`: this funcion unmarshals the given instance and passes the content, specified path, and specified mode to the specified write function. The following have been updated: * `PrintJSONOn`: This funtion no longer includes a trailing newline in content written to the provided io.Writer. It has been updated to take a type parameter. * `ToJSON`: This function no longer includes a trainling newlint in the returned string. It has been updated to take a type parameter. * `UnmarshalFromReader`: This function has been updated to return a new instance of the type being unmarshaled. The previous behavior of updating a instance allocated by the caller is now provided by `UnmarshalFromReaderInto` Signed-off-by: m-d-key <[email protected]> Co-authored-by: m-d-key <[email protected]>
1 parent 7920d7d commit 9f0c1f9

File tree

3 files changed

+189
-44
lines changed

3 files changed

+189
-44
lines changed

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,12 @@ make test-with-coverage
7070

7171
### Commits
7272

73-
This project uses [semantic versioning](https://semver.org/) and [conventional
74-
commits](https://www.conventionalcommits.org/en/v1.0.0/).
73+
This project currently uses loose [semantic versioning](https://semver.org/) and
74+
[conventional commits](https://www.conventionalcommits.org/en/v1.0.0/). The API
75+
is undergoing rapid evolution until 1.0 is released. An evaluation will be made
76+
at that time as to how strictly semantic versioning will be followed. Efforts
77+
will be made to communicate breaking changes but please pin to a specific
78+
version until then.
7579

7680
## License
7781

pkg/jsonutils/jsonutils.go

Lines changed: 79 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -8,63 +8,106 @@ import (
88
"fmt"
99
"io"
1010
"os"
11-
"strings"
1211

1312
"github.com/sassoftware/sas-ggdk/pkg/result"
1413
)
1514

15+
const (
16+
prefix = ``
17+
indent = ` `
18+
)
19+
1620
// PrintJSONOn pretty-prints the given data as JSON on the given writer. If the
1721
// given data is not valid JSON an error is returned.
18-
func PrintJSONOn(data any, writer io.Writer) error {
19-
bites, err := json.MarshalIndent(data, ``, ` `)
20-
if err != nil {
22+
func PrintJSONOn[T any](data T, writer io.Writer) error {
23+
bites := result.New(json.MarshalIndent(data, prefix, indent))
24+
return result.MapErrorOnly(func(b []byte) error {
25+
_, err := fmt.Fprintf(writer, "%s", b)
2126
return err
22-
}
23-
content := string(bites)
24-
_, err = fmt.Fprintf(writer, "%s\n", content)
25-
return err
27+
}, bites)
2628
}
2729

2830
// ToJSON returns a pretty-printed JSON string for the given data. If the given
2931
// data is not valid JSON an error is returned. If you are marshaling a large
3032
// data structure and are concerned about the performance of iteratively growing
3133
// the destination strings.Builder then create your own builder, grow it to your
32-
// expected size, and then call PrintJSONOn.
33-
func ToJSON(data any) result.Result[string] {
34-
writer := new(strings.Builder)
35-
err := PrintJSONOn(data, writer)
36-
if err != nil {
37-
return result.Error[string](err)
38-
}
39-
content := writer.String()
40-
return result.Ok(content)
34+
// expected size, and then call PrintJSONOn. If you want the bytes then call the
35+
// following.
36+
//
37+
// result.New(json.MarshalIndent(data, ``, ` `))
38+
func ToJSON[T any](data T) result.Result[string] {
39+
bites := result.New(json.MarshalIndent(data, prefix, indent))
40+
return result.MapNoError(func(b []byte) string { return string(b) }, bites)
4141
}
4242

43-
// UnmarshalFromReader inflates the JSON in the given reader and populates the
44-
// given instance. If the data in the reader is not valid JSON an error is
45-
// returned.
46-
func UnmarshalFromReader(reader io.Reader, instance any) error {
47-
bites, err := io.ReadAll(reader)
48-
if err != nil {
49-
return err
50-
}
51-
err = json.Unmarshal(bites, instance)
52-
if err != nil {
53-
return err
54-
}
55-
return nil
43+
// UnmarshalFromReader unmarshals the JSON from the given reader and populates a
44+
// new instance of T. The reader will be read fully before returning. If the
45+
// data in the reader is not valid JSON an error is returned.
46+
func UnmarshalFromReader[T any](reader io.Reader) result.Result[T] {
47+
bites := result.New(io.ReadAll(reader))
48+
return result.FlatMap(UnmarshalAs[T], bites)
49+
}
50+
51+
// UnmarshalAs ummarshals the given content into a result of a new instance of T.
52+
func UnmarshalAs[T any](content []byte) result.Result[T] {
53+
var t T
54+
err := json.Unmarshal(content, &t)
55+
return result.New(t, err)
56+
}
57+
58+
// UnmarshalFromReaderInto unmarshals the JSON from the given reader and
59+
// populates the given instance. The reader will be read fully before returning.
60+
// If the data in the reader is not valid JSON an error is returned.
61+
func UnmarshalFromReaderInto[T any](reader io.Reader, value *T) error {
62+
bites := result.New(io.ReadAll(reader))
63+
return result.MapErrorOnly(func(b []byte) error {
64+
return json.Unmarshal(b, value)
65+
}, bites)
5666
}
5767

5868
// LoadAs reads the content from the given path and ummarshals it into a result
5969
// of a new instance of T.
6070
func LoadAs[T any](path string) result.Result[T] {
61-
content := result.New(os.ReadFile(path))
71+
return LoadWith[T](os.ReadFile, path)
72+
}
73+
74+
// LoadWith passes the given path to the given read function to get the content.
75+
// That content is then marshaled into a result of a new instance of T. This is
76+
// useful when using a file system abstraction like
77+
// https://github.com/spf13/afero.
78+
//
79+
// fs := afero.Afero{Fs: afero.NewMemMapFs()}
80+
// err := jsonutils.LoadWith[string](fs.ReadFile, "/tmp/person.json")
81+
func LoadWith[T any](
82+
read func(string) ([]byte, error),
83+
path string,
84+
) result.Result[T] {
85+
content := result.New(read(path))
6286
return result.FlatMap(UnmarshalAs[T], content)
6387
}
6488

65-
// UnmarshalAs ummarshals the given content into a result of anew instance of T.
66-
func UnmarshalAs[T any](content []byte) result.Result[T] {
67-
var t T
68-
err := json.Unmarshal(content, &t)
69-
return result.New(t, err)
89+
// Save marshals the given T and writes it to a file at the given path with the
90+
// given permissions. The file is truncated if it exists.
91+
func Save[T any](value T, path string, perm os.FileMode) error {
92+
return SaveWith(os.WriteFile, value, path, perm)
93+
}
94+
95+
// SaveWith marshals the given T and calls the given writeFunc with the
96+
// resulting content, given path, and given permissions.This is
97+
// useful when using a file system abstraction like
98+
// https://github.com/spf13/afero.
99+
//
100+
// fs := afero.Afero{Fs: afero.NewMemMapFs()}
101+
// err := jsonutils.SaveWith(fs.WriteFile, instance, "/tmp/person.json", 0700)
102+
func SaveWith[T any](
103+
writeFunc func(string, []byte, os.FileMode) error,
104+
value T,
105+
path string,
106+
perm os.FileMode,
107+
) error {
108+
content := result.New(json.Marshal(value))
109+
writeF := func(b []byte) error {
110+
return writeFunc(path, b, perm)
111+
}
112+
return result.MapErrorOnly(writeF, content)
70113
}

pkg/jsonutils/jsonutils_test.go

Lines changed: 104 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ func Test_PrintJSONOn(t *testing.T) {
3939
err = jsonutils.PrintJSONOn(instance, buffer)
4040
require.NoError(t, err)
4141
actual := buffer.Bytes()
42-
require.Equal(t, expected, actual)
42+
require.Equal(t, bytes.TrimSpace(expected), actual)
4343
}
4444

4545
func Test_PrintJSONOn_fail(t *testing.T) {
@@ -65,7 +65,7 @@ func Test_ToJSON(t *testing.T) {
6565
require.NoError(t, err)
6666
actual := jsonutils.ToJSON(instance)
6767
require.NoError(t, actual.Error())
68-
require.Equal(t, expected, actual.MustGet())
68+
require.Equal(t, strings.TrimSpace(expected), actual.MustGet())
6969
}
7070

7171
func Test_ToJSON_fail(t *testing.T) {
@@ -81,10 +81,19 @@ func Test_ToJSON_fail(t *testing.T) {
8181
}
8282

8383
func Test_UnmarshalFromReader(t *testing.T) {
84+
reader, err := toTestdataFile(jsonFilename)
85+
require.NoError(t, err)
86+
r := jsonutils.UnmarshalFromReader[person](reader)
87+
require.False(t, r.IsError())
88+
require.Equal(t, personName, r.MustGet().Name)
89+
require.Equal(t, personAge, r.MustGet().Age)
90+
}
91+
92+
func Test_UnmarshalFromReaderInto(t *testing.T) {
8493
reader, err := toTestdataFile(jsonFilename)
8594
require.NoError(t, err)
8695
instance := new(person)
87-
err = jsonutils.UnmarshalFromReader(reader, instance)
96+
err = jsonutils.UnmarshalFromReaderInto(reader, instance)
8897
require.NoError(t, err)
8998
require.Equal(t, personName, instance.Name)
9099
require.Equal(t, personAge, instance.Age)
@@ -98,13 +107,27 @@ func (reader *failingReader) Read(_ []byte) (n int, err error) {
98107

99108
func Test_UnmarshalFromReader_failingReader(t *testing.T) {
100109
reader := new(failingReader)
101-
err := jsonutils.UnmarshalFromReader(reader, nil)
102-
require.Error(t, err)
110+
r := jsonutils.UnmarshalFromReader[int](reader)
111+
require.True(t, r.IsError())
112+
require.Error(t, r.Error())
103113
}
104114

105115
func Test_UnmarshalFromReader_failingUnmarshal(t *testing.T) {
106116
reader := strings.NewReader(`not JSON`)
107-
err := jsonutils.UnmarshalFromReader(reader, nil)
117+
r := jsonutils.UnmarshalFromReader[int](reader)
118+
require.True(t, r.IsError())
119+
require.Error(t, r.Error())
120+
}
121+
122+
func Test_UnmarshalFromReaderInto_failingReader(t *testing.T) {
123+
reader := new(failingReader)
124+
err := jsonutils.UnmarshalFromReaderInto[int](reader, nil)
125+
require.Error(t, err)
126+
}
127+
128+
func Test_UnmarshalFromReaderInto_failingUnmarshal(t *testing.T) {
129+
reader := strings.NewReader(`not JSON`)
130+
err := jsonutils.UnmarshalFromReaderInto[int](reader, nil)
108131
require.Error(t, err)
109132
}
110133

@@ -133,6 +156,81 @@ func Test_LoadAs_fail(t *testing.T) {
133156
require.True(t, actual.IsError())
134157
}
135158

159+
func Test_LoadWith(t *testing.T) {
160+
expected := person{
161+
Name: "John Smith",
162+
Age: 75,
163+
}
164+
readF := func(_ string) ([]byte, error) {
165+
return []byte(`{"name":"John Smith","age":75}`), nil
166+
}
167+
actual := jsonutils.LoadWith[person](readF, "/tmp/person.json")
168+
require.False(t, actual.IsError())
169+
require.Equal(t, expected, actual.MustGet())
170+
}
171+
172+
func Test_LoadWith_ptr(t *testing.T) {
173+
expectedPtr := &person{
174+
Name: "John Smith",
175+
Age: 75,
176+
}
177+
readF := func(_ string) ([]byte, error) {
178+
return []byte(`{"name":"John Smith","age":75}`), nil
179+
}
180+
actualPtr := jsonutils.LoadWith[*person](readF, "/tmp/person.json")
181+
require.False(t, actualPtr.IsError())
182+
require.Equal(t, expectedPtr, actualPtr.MustGet())
183+
}
184+
185+
func Test_LoadWith_fail(t *testing.T) {
186+
readF := func(_ string) ([]byte, error) {
187+
return []byte{}, errors.New("failed")
188+
}
189+
actual := jsonutils.LoadWith[person](readF, "/tmp/missing.json")
190+
require.True(t, actual.IsError())
191+
}
192+
193+
func Test_Save(t *testing.T) {
194+
instance := person{
195+
Name: "John Smith",
196+
Age: 75,
197+
}
198+
tmpdir := t.TempDir()
199+
path := filepath.Join(tmpdir, "person.json")
200+
err := jsonutils.Save(instance, path, 0700)
201+
require.NoError(t, err)
202+
defer func() { require.NoError(t, os.RemoveAll(tmpdir)) }()
203+
_, err = os.Stat(path)
204+
require.NoError(t, err)
205+
}
206+
207+
func Test_SaveWith(t *testing.T) {
208+
instance := person{
209+
Name: "John Smith",
210+
Age: 75,
211+
}
212+
called := false
213+
saveF := func(_ string, _ []byte, _ os.FileMode) error {
214+
called = true
215+
return nil
216+
}
217+
err := jsonutils.SaveWith(saveF, instance, "/tmp/person.json", 0700)
218+
require.NoError(t, err)
219+
require.True(t, called)
220+
}
221+
222+
func Test_SaveWith_failed(t *testing.T) {
223+
instance := person{
224+
Name: "John Smith",
225+
Age: 75,
226+
}
227+
saveF := func(_ string, _ []byte, _ os.FileMode) error {
228+
return errors.New("failed")
229+
}
230+
err := jsonutils.SaveWith(saveF, instance, "/tmp/person.json", 0700)
231+
require.Error(t, err)
232+
}
233+
136234
func Test_UnmarshalAs(t *testing.T) {
137235
expectedPtr := person{
138236
Name: "John Smith",

0 commit comments

Comments
 (0)