Skip to content

Commit 0f945bf

Browse files
committed
feat: basic function
1 parent 0d55e07 commit 0f945bf

File tree

4 files changed

+313
-1
lines changed

4 files changed

+313
-1
lines changed

README.md

+64-1
Original file line numberDiff line numberDiff line change
@@ -1 +1,64 @@
1-
# patch
1+
# patch
2+
3+
This package aims to answer the question asked when implementing **PATCH** requests of RESTful API in Go:
4+
5+
<div align="center">
6+
<p style="font-weight: bold;">
7+
Q: How to tell if a field is missing in the payload of a PATCH request?
8+
</p>
9+
</div>
10+
11+
Since we are using generics, **Go 1.8+ is required**.
12+
13+
---
14+
15+
Here we only talk about JSON payloads as it's the most frequently used format when developing a RESTful API.
16+
17+
```go
18+
type UserPatch struct {
19+
Name string
20+
Age int
21+
Gender string
22+
}
23+
24+
func PatchUser(rw http.ResponseWriter, r *http.Request) {
25+
var payload UserPatch
26+
json.NewDecoder(r.Body).Decode(&payload)
27+
28+
// Both the requests `{"Name":"","Age":18}` and `{"Age":18}` can
29+
// cause `payload.Name == ""`.
30+
// ** How can we distinguish the two? **
31+
// "A field is missing" and "a field is empty" are semantically different.
32+
if payload.Name == "" {
33+
// do sth...
34+
}
35+
}
36+
```
37+
38+
## Solution: add a sentinel to each field
39+
40+
Using `patch.Field` to define/wrap your fields in a struct.
41+
42+
```go
43+
import "github.com/ggicci/patch"
44+
45+
type UserPatch struct {
46+
Name patch.Field[string]
47+
Age patch.Field[int]
48+
Gender patch.Field[string]
49+
}
50+
51+
func PatchUser(rw http.ResponseWriter, r *http.Request) {
52+
var payload UserPatch
53+
json.NewDecoder(r.Body).Decode(&payload)
54+
55+
if !payload.Name.Valid {
56+
// error: field "Name" is missing
57+
}
58+
}
59+
```
60+
61+
Now we can tell **a field is missing** from **a field is empty** by consulting the sentinel `Field.Valid`:
62+
63+
- when `Field.Valid == false`, then _field is missing_
64+
- when `Field.Valid == true`, then _field is provided_

go.mod

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module github.com/ggicci/patch
2+
3+
go 1.18

patch.go

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package patch
2+
3+
import (
4+
"encoding/json"
5+
)
6+
7+
// Field is a wrapper which can tell if a field was unmarshalled from the data provided.
8+
// When `Field.Valid` is true, which means `Field.Value` is populated from decoding the raw data.
9+
// Otherwise, no data was provided, i.e. field missing.
10+
type Field[T any] struct {
11+
Value T
12+
Valid bool
13+
}
14+
15+
func (f Field[T]) MarshalJSON() ([]byte, error) {
16+
return json.Marshal(f.Value)
17+
}
18+
19+
func (f *Field[T]) UnmarshalJSON(data []byte) error {
20+
err := json.Unmarshal(data, &f.Value)
21+
if err == nil {
22+
f.Valid = true
23+
}
24+
return err
25+
}

patch_test.go

+221
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
package patch_test
2+
3+
import (
4+
"encoding/json"
5+
"reflect"
6+
"testing"
7+
"time"
8+
9+
"github.com/ggicci/patch"
10+
)
11+
12+
func shouldBeNil(t *testing.T, err error, failMessage string) {
13+
if err != nil {
14+
t.Logf("%s, got error: %v", failMessage, err)
15+
t.Fail()
16+
}
17+
}
18+
19+
func shouldResemble(t *testing.T, va, vb any, failMessage string) {
20+
if reflect.DeepEqual(va, vb) {
21+
return
22+
}
23+
t.Logf("%s, expected %#v, got %#v", failMessage, va, vb)
24+
t.Fail()
25+
}
26+
27+
func fixedZone(offset int) *time.Location {
28+
if offset == 0 {
29+
return time.UTC
30+
}
31+
_, localOffset := time.Now().Local().Zone()
32+
if offset == localOffset {
33+
return time.Local
34+
}
35+
return time.FixedZone("", offset)
36+
}
37+
38+
func testJSONMarshalling(t *testing.T, tc testcase) {
39+
bs, err := json.Marshal(tc.Expected)
40+
if err != nil {
41+
t.Logf("marshal failed, got error: %v", err)
42+
t.Fail()
43+
}
44+
if string(bs) != tc.Content {
45+
t.Logf("marshal failed, expected %q, got %q", tc.Content, string(bs))
46+
t.Fail()
47+
}
48+
}
49+
50+
func testJSONUnmarshalling(t *testing.T, tc testcase) {
51+
rt := reflect.TypeOf(tc.Expected) // type: patch.Field
52+
rv := reflect.New(rt) // rv: *patch.Field
53+
54+
shouldBeNil(t, json.Unmarshal([]byte(tc.Content), rv.Interface()), "unmarshal failed")
55+
shouldResemble(t, rv.Elem().Interface(), tc.Expected, "unmarshal failed")
56+
}
57+
58+
type testcase struct {
59+
Content string
60+
Expected any
61+
}
62+
63+
type GitHubProfile struct {
64+
Id int64 `json:"id"`
65+
Login string `json:"login"`
66+
AvatarUrl string `json:"avatar_url"`
67+
}
68+
69+
type GenderType string
70+
71+
type Account struct {
72+
Id int64
73+
Email string
74+
Tags []string
75+
Gender GenderType
76+
GitHub *GitHubProfile
77+
}
78+
79+
type AccountPatch struct {
80+
Email patch.Field[string] `json:"email"`
81+
Tags patch.Field[[]string] `json:"tags"`
82+
Gender patch.Field[GenderType] `json:"gender"`
83+
GitHub patch.Field[*GitHubProfile] `json:"github"`
84+
}
85+
86+
func TestField(t *testing.T) {
87+
var cases = []testcase{
88+
{"true", patch.Field[bool]{true, true}},
89+
{"false", patch.Field[bool]{false, true}},
90+
{"2045", patch.Field[int]{2045, true}},
91+
{"127", patch.Field[int8]{127, true}},
92+
{"32767", patch.Field[int16]{32767, true}},
93+
{"2147483647", patch.Field[int32]{2147483647, true}},
94+
{"9223372036854775807", patch.Field[int64]{9223372036854775807, true}},
95+
{"2045", patch.Field[uint]{2045, true}},
96+
{"255", patch.Field[uint8]{255, true}},
97+
{"65535", patch.Field[uint16]{65535, true}},
98+
{"4294967295", patch.Field[uint32]{4294967295, true}},
99+
{"18446744073709551615", patch.Field[uint64]{18446744073709551615, true}},
100+
{"3.14", patch.Field[float32]{3.14, true}},
101+
{"3.14", patch.Field[float64]{3.14, true}},
102+
{"\"hello\"", patch.Field[string]{"hello", true}},
103+
104+
// Array
105+
{`[true,false]`, patch.Field[[]bool]{[]bool{true, false}, true}},
106+
{"[1,2,3]", patch.Field[[]int]{[]int{1, 2, 3}, true}},
107+
{"[1,2,3]", patch.Field[[]int8]{[]int8{1, 2, 3}, true}},
108+
{"[1,2,3]", patch.Field[[]int16]{[]int16{1, 2, 3}, true}},
109+
{"[1,2,3]", patch.Field[[]int32]{[]int32{1, 2, 3}, true}},
110+
{"[1,2,3]", patch.Field[[]int64]{[]int64{1, 2, 3}, true}},
111+
{"[1,2,3]", patch.Field[[]uint]{[]uint{1, 2, 3}, true}},
112+
// NOTE(ggicci): []uint8 is a special case, check TestFieldUint8Array
113+
{"[1,2,3]", patch.Field[[]uint16]{[]uint16{1, 2, 3}, true}},
114+
{"[1,2,3]", patch.Field[[]uint32]{[]uint32{1, 2, 3}, true}},
115+
{"[1,2,3]", patch.Field[[]uint64]{[]uint64{1, 2, 3}, true}},
116+
{"[0.618,1,3.14]", patch.Field[[]float32]{[]float32{0.618, 1, 3.14}, true}},
117+
{"[0.618,1,3.14]", patch.Field[[]float64]{[]float64{0.618, 1, 3.14}, true}},
118+
{`["hello","world"]`, patch.Field[[]string]{[]string{"hello", "world"}, true}},
119+
120+
// time.Time
121+
{
122+
`"2019-08-25T07:19:34Z"`,
123+
patch.Field[time.Time]{
124+
time.Date(2019, 8, 25, 7, 19, 34, 0, fixedZone(0)),
125+
true,
126+
},
127+
},
128+
{
129+
`"1991-11-10T08:00:00-07:00"`,
130+
patch.Field[time.Time]{
131+
time.Date(1991, 11, 10, 8, 0, 0, 0, fixedZone(-7*3600)),
132+
true,
133+
},
134+
},
135+
{
136+
`"1991-11-10T08:00:00+08:00"`,
137+
patch.Field[time.Time]{
138+
time.Date(1991, 11, 10, 8, 0, 0, 0, fixedZone(+8*3600)),
139+
true,
140+
},
141+
},
142+
143+
// Custom structs
144+
{
145+
`{"Id":1000,"Email":"ggicci@example.com","Tags":["developer","修勾"],"Gender":"male","GitHub":{"id":3077555,"login":"ggicci","avatar_url":"https://avatars.githubusercontent.com/u/3077555?v=4"}}`,
146+
patch.Field[*Account]{
147+
&Account{
148+
Id: 1000,
149+
Email: "ggicci@example.com",
150+
Tags: []string{"developer", "修勾"},
151+
Gender: "male",
152+
GitHub: &GitHubProfile{
153+
Id: 3077555,
154+
Login: "ggicci",
155+
AvatarUrl: "https://avatars.githubusercontent.com/u/3077555?v=4",
156+
},
157+
},
158+
true,
159+
},
160+
},
161+
}
162+
163+
for _, c := range cases {
164+
testJSONMarshalling(t, c)
165+
testJSONUnmarshalling(t, c)
166+
}
167+
}
168+
169+
// TestFieldUint8Array runs JSON marshalling & unmarshalling tests on type Field[[]uint8].
170+
// Because in golang's encoding/json package, encoding uint8[] is special.
171+
// See: https://golang.org/pkg/encoding/json/#Marshal
172+
//
173+
// > Array and slice values encode as JSON arrays, except that []byte encodes
174+
// as a base64-encoded string, and a nil slice encodes as the null JSON
175+
// value.
176+
//
177+
// uint8 the set of all unsigned 8-bit integers (0 to 255)
178+
// byte alias for uint8
179+
func TestFieldUint8Array(t *testing.T) {
180+
var a1 patch.Field[[]uint8]
181+
// unmarshal
182+
shouldBeNil(t, json.Unmarshal([]byte("[1,2,3]"), &a1), "unmarshal Field[[]uint8] failed")
183+
shouldResemble(t, patch.Field[[]uint8]{[]uint8{1, 2, 3}, true}, a1, "unmarshal Field[[]uint8] failed")
184+
185+
// marshal
186+
var a2 = patch.Field[[]uint8]{[]uint8{1, 2, 3}, true}
187+
out, err := json.Marshal(a2)
188+
shouldBeNil(t, err, "marshal Field[[]uint8] failed")
189+
shouldResemble(t, `"AQID"`, string(out), "marshal Field[[]uint8] failed")
190+
}
191+
192+
func TestPatchStructs(t *testing.T) {
193+
var testcases = []testcase{
194+
{
195+
`{"email":"ggicci.2@example.com","tags":["artist","photographer"]}`,
196+
AccountPatch{
197+
Email: patch.Field[string]{"ggicci.2@example.com", true},
198+
Gender: patch.Field[GenderType]{"", false},
199+
Tags: patch.Field[[]string]{[]string{"artist", "photographer"}, true},
200+
GitHub: patch.Field[*GitHubProfile]{nil, false},
201+
},
202+
},
203+
{
204+
`{"tags":null,"gender":"female","github":{"id":100,"login":"ggicci.2","avatar_url":null}}`,
205+
AccountPatch{
206+
Email: patch.Field[string]{"", false},
207+
Gender: patch.Field[GenderType]{"female", true},
208+
Tags: patch.Field[[]string]{nil, true},
209+
GitHub: patch.Field[*GitHubProfile]{&GitHubProfile{
210+
Id: 100,
211+
Login: "ggicci.2",
212+
AvatarUrl: "",
213+
}, true},
214+
},
215+
},
216+
}
217+
218+
for _, c := range testcases {
219+
testJSONUnmarshalling(t, c)
220+
}
221+
}

0 commit comments

Comments
 (0)