Skip to content

Commit 1d9633f

Browse files
committed
Enhance API to support string and custom types for JSON Patch operations
1 parent 907b030 commit 1d9633f

File tree

5 files changed

+349
-22
lines changed

5 files changed

+349
-22
lines changed

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ go get github.com/robertjndw/go-json-patch
1212

1313
- **Apply** a JSON Patch document to a target JSON document
1414
- **Create** a JSON Patch by diffing two JSON documents
15+
- **Generic API** — all public functions accept `[]byte`, `string`, or custom types with those underlying types
1516
- Full support for all six RFC 6902 operations: `add`, `remove`, `replace`, `move`, `copy`, `test`
1617
- Strict JSON Pointer (RFC 6901) parsing with `~` and `/` escaping and escape validation
1718
- Atomic patch application — if any operation fails, the entire patch is rejected
@@ -94,6 +95,33 @@ if err != nil {
9495
}
9596
```
9697

98+
### Using Strings Instead of []byte
99+
100+
All public functions accept `string` as well as `[]byte` — no manual conversion needed:
101+
102+
```go
103+
result, err := jsonpatch.Apply(
104+
`{"foo": "bar"}`,
105+
`[{"op": "add", "path": "/baz", "value": "qux"}]`,
106+
)
107+
// result is a string: {"baz":"qux","foo":"bar"}
108+
109+
patch, err := jsonpatch.CreatePatch(
110+
`{"name": "Alice"}`,
111+
`{"name": "Bob"}`,
112+
)
113+
```
114+
115+
Custom types with an underlying `[]byte` or `string` type also work thanks to the `~` approximation constraint:
116+
117+
```go
118+
type JSONDoc string
119+
120+
doc := JSONDoc(`{"foo": "bar"}`)
121+
patch := JSONDoc(`[{"op": "add", "path": "/baz", "value": 1}]`)
122+
result, err := jsonpatch.Apply(doc, patch) // result is JSONDoc
123+
```
124+
97125
### Build Operations Programmatically
98126

99127
```go

apply.go

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,40 +6,43 @@ import (
66
"reflect"
77
)
88

9-
// Apply applies a JSON Patch document (as raw JSON bytes) to a target JSON
10-
// document (as raw JSON bytes). It returns the patched document as raw JSON
11-
// bytes. Operations are applied sequentially; if any operation fails, the
12-
// entire patch is aborted and an error is returned (atomic semantics per
13-
// RFC 5789).
14-
func Apply(docJSON, patchJSON []byte) ([]byte, error) {
9+
// Apply applies a JSON Patch document to a target JSON document.
10+
// Both arguments must be valid JSON encoded as []byte or string (or any type
11+
// with one of those underlying types). The return type matches the input type.
12+
// Operations are applied sequentially; if any operation fails, the entire
13+
// patch is aborted and an error is returned (atomic semantics per RFC 5789).
14+
func Apply[D Document](docJSON, patchJSON D) (D, error) {
15+
var zero D
1516
patch, err := DecodePatch(patchJSON)
1617
if err != nil {
17-
return nil, err
18+
return zero, err
1819
}
1920
return ApplyPatch(docJSON, patch)
2021
}
2122

22-
// ApplyPatch applies a decoded Patch to a target JSON document (as raw JSON bytes).
23-
// It returns the patched document as raw JSON bytes.
24-
func ApplyPatch(docJSON []byte, patch Patch) ([]byte, error) {
23+
// ApplyPatch applies a decoded Patch to a target JSON document.
24+
// The document can be []byte or string (or any type with one of those
25+
// underlying types). The return type matches the input type.
26+
func ApplyPatch[D Document](docJSON D, patch Patch) (D, error) {
27+
var zero D
2528
var doc interface{}
26-
if err := json.Unmarshal(docJSON, &doc); err != nil {
27-
return nil, fmt.Errorf("failed to decode target document: %w", err)
29+
if err := json.Unmarshal(toBytes(docJSON), &doc); err != nil {
30+
return zero, fmt.Errorf("failed to decode target document: %w", err)
2831
}
2932

3033
var err error
3134
for i, op := range patch {
3235
doc, err = applyOperation(doc, op)
3336
if err != nil {
34-
return nil, fmt.Errorf("operation %d (%s %s) failed: %w", i, op.Op, op.Path, err)
37+
return zero, fmt.Errorf("operation %d (%s %s) failed: %w", i, op.Op, op.Path, err)
3538
}
3639
}
3740

3841
result, err := json.Marshal(doc)
3942
if err != nil {
40-
return nil, fmt.Errorf("failed to marshal result: %w", err)
43+
return zero, fmt.Errorf("failed to marshal result: %w", err)
4144
}
42-
return result, nil
45+
return fromBytes[D](result), nil
4346
}
4447

4548
// applyOperation applies a single operation to the document.

create.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,15 @@ import (
99

1010
// CreatePatch generates a JSON Patch document (RFC 6902) that transforms
1111
// the original JSON document into the modified JSON document.
12-
// Both arguments must be valid JSON bytes.
13-
func CreatePatch(original, modified []byte) (Patch, error) {
12+
// Both arguments must be valid JSON encoded as []byte or string (or any type
13+
// with one of those underlying types).
14+
func CreatePatch[D Document](original, modified D) (Patch, error) {
1415
var origDoc, modDoc interface{}
1516

16-
if err := json.Unmarshal(original, &origDoc); err != nil {
17+
if err := json.Unmarshal(toBytes(original), &origDoc); err != nil {
1718
return nil, fmt.Errorf("failed to decode original document: %w", err)
1819
}
19-
if err := json.Unmarshal(modified, &modDoc); err != nil {
20+
if err := json.Unmarshal(toBytes(modified), &modDoc); err != nil {
2021
return nil, fmt.Errorf("failed to decode modified document: %w", err)
2122
}
2223

generics_test.go

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
package jsonpatch
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
)
7+
8+
// customBytes is a custom type with underlying type []byte, used to test the
9+
// ~[]byte part of the Document constraint.
10+
type customBytes []byte
11+
12+
// customString is a custom type with underlying type string, used to test the
13+
// ~string part of the Document constraint.
14+
type customString string
15+
16+
// ---------------------------------------------------------------------------
17+
// Apply – string inputs
18+
// ---------------------------------------------------------------------------
19+
20+
func TestApply_StringInputs(t *testing.T) {
21+
doc := `{"foo": "bar"}`
22+
patch := `[{"op": "add", "path": "/baz", "value": "qux"}]`
23+
24+
result, err := Apply(doc, patch)
25+
if err != nil {
26+
t.Fatal(err)
27+
}
28+
29+
// result should be a string
30+
assertJSONEqual(t, `{"baz": "qux", "foo": "bar"}`, result)
31+
}
32+
33+
func TestApply_StringInputs_Replace(t *testing.T) {
34+
doc := `{"name": "Alice", "age": 30}`
35+
patch := `[{"op": "replace", "path": "/name", "value": "Bob"}]`
36+
37+
result, err := Apply(doc, patch)
38+
if err != nil {
39+
t.Fatal(err)
40+
}
41+
42+
assertJSONEqual(t, `{"name": "Bob", "age": 30}`, result)
43+
}
44+
45+
func TestApply_StringInputs_Remove(t *testing.T) {
46+
doc := `{"foo": "bar", "baz": "qux"}`
47+
patch := `[{"op": "remove", "path": "/baz"}]`
48+
49+
result, err := Apply(doc, patch)
50+
if err != nil {
51+
t.Fatal(err)
52+
}
53+
54+
assertJSONEqual(t, `{"foo": "bar"}`, result)
55+
}
56+
57+
func TestApply_StringInputs_MultipleOps(t *testing.T) {
58+
doc := `{"name": "Alice", "email": "alice@example.com"}`
59+
patch := `[
60+
{"op": "replace", "path": "/name", "value": "Bob"},
61+
{"op": "add", "path": "/phone", "value": "+1-555-0100"},
62+
{"op": "remove", "path": "/email"}
63+
]`
64+
65+
result, err := Apply(doc, patch)
66+
if err != nil {
67+
t.Fatal(err)
68+
}
69+
70+
assertJSONEqual(t, `{"name": "Bob", "phone": "+1-555-0100"}`, result)
71+
}
72+
73+
func TestApply_StringInputs_Array(t *testing.T) {
74+
doc := `{"tags": ["go", "json"]}`
75+
patch := `[{"op": "add", "path": "/tags/-", "value": "patch"}]`
76+
77+
result, err := Apply(doc, patch)
78+
if err != nil {
79+
t.Fatal(err)
80+
}
81+
82+
assertJSONEqual(t, `{"tags": ["go", "json", "patch"]}`, result)
83+
}
84+
85+
// ---------------------------------------------------------------------------
86+
// ApplyPatch – string inputs
87+
// ---------------------------------------------------------------------------
88+
89+
func TestApplyPatch_StringInput(t *testing.T) {
90+
patchOps, err := DecodePatch(`[{"op": "add", "path": "/baz", "value": "qux"}]`)
91+
if err != nil {
92+
t.Fatal(err)
93+
}
94+
95+
result, err := ApplyPatch(`{"foo": "bar"}`, patchOps)
96+
if err != nil {
97+
t.Fatal(err)
98+
}
99+
100+
assertJSONEqual(t, `{"baz": "qux", "foo": "bar"}`, result)
101+
}
102+
103+
// ---------------------------------------------------------------------------
104+
// DecodePatch – string input
105+
// ---------------------------------------------------------------------------
106+
107+
func TestDecodePatch_StringInput(t *testing.T) {
108+
patch, err := DecodePatch(`[
109+
{"op": "add", "path": "/foo", "value": 1},
110+
{"op": "remove", "path": "/bar"},
111+
{"op": "replace", "path": "/baz", "value": "qux"}
112+
]`)
113+
if err != nil {
114+
t.Fatal(err)
115+
}
116+
117+
if len(patch) != 3 {
118+
t.Fatalf("expected 3 operations, got %d", len(patch))
119+
}
120+
if patch[0].Op != OpAdd {
121+
t.Errorf("expected first op to be %q, got %q", OpAdd, patch[0].Op)
122+
}
123+
if patch[1].Op != OpRemove {
124+
t.Errorf("expected second op to be %q, got %q", OpRemove, patch[1].Op)
125+
}
126+
if patch[2].Op != OpReplace {
127+
t.Errorf("expected third op to be %q, got %q", OpReplace, patch[2].Op)
128+
}
129+
}
130+
131+
// ---------------------------------------------------------------------------
132+
// CreatePatch – string inputs
133+
// ---------------------------------------------------------------------------
134+
135+
func TestCreatePatch_StringInputs(t *testing.T) {
136+
original := `{"foo": "bar"}`
137+
modified := `{"foo": "bar", "baz": "qux"}`
138+
139+
patch, err := CreatePatch(original, modified)
140+
if err != nil {
141+
t.Fatal(err)
142+
}
143+
144+
// Verify the patch can be applied (using string ApplyPatch)
145+
result, err := ApplyPatch(original, patch)
146+
if err != nil {
147+
t.Fatal(err)
148+
}
149+
assertJSONEqual(t, modified, result)
150+
}
151+
152+
func TestCreatePatch_StringInputs_Nested(t *testing.T) {
153+
original := `{"user": {"name": "Alice", "role": "viewer"}}`
154+
modified := `{"user": {"name": "Alice", "role": "admin"}}`
155+
156+
patch, err := CreatePatch(original, modified)
157+
if err != nil {
158+
t.Fatal(err)
159+
}
160+
161+
result, err := ApplyPatch(original, patch)
162+
if err != nil {
163+
t.Fatal(err)
164+
}
165+
assertJSONEqual(t, modified, result)
166+
}
167+
168+
// ---------------------------------------------------------------------------
169+
// Custom types (testing the ~ approximation constraint)
170+
// ---------------------------------------------------------------------------
171+
172+
func TestApply_CustomBytesType(t *testing.T) {
173+
doc := customBytes(`{"foo": "bar"}`)
174+
patch := customBytes(`[{"op": "add", "path": "/baz", "value": "qux"}]`)
175+
176+
result, err := Apply(doc, patch)
177+
if err != nil {
178+
t.Fatal(err)
179+
}
180+
181+
// result is customBytes
182+
assertJSONEqual(t, `{"baz": "qux", "foo": "bar"}`, string(result))
183+
}
184+
185+
func TestApply_CustomStringType(t *testing.T) {
186+
doc := customString(`{"foo": "bar"}`)
187+
patch := customString(`[{"op": "add", "path": "/baz", "value": "qux"}]`)
188+
189+
result, err := Apply(doc, patch)
190+
if err != nil {
191+
t.Fatal(err)
192+
}
193+
194+
// result is customString
195+
assertJSONEqual(t, `{"baz": "qux", "foo": "bar"}`, string(result))
196+
}
197+
198+
func TestCreatePatch_CustomStringType(t *testing.T) {
199+
original := customString(`{"foo": "bar"}`)
200+
modified := customString(`{"foo": "baz"}`)
201+
202+
patch, err := CreatePatch(original, modified)
203+
if err != nil {
204+
t.Fatal(err)
205+
}
206+
207+
if len(patch) != 1 {
208+
t.Fatalf("expected 1 operation, got %d", len(patch))
209+
}
210+
if patch[0].Op != OpReplace {
211+
t.Errorf("expected replace op, got %q", patch[0].Op)
212+
}
213+
}
214+
215+
// ---------------------------------------------------------------------------
216+
// Round-trip: string → patch → string
217+
// ---------------------------------------------------------------------------
218+
219+
func TestRoundTrip_StringWorkflow(t *testing.T) {
220+
// A complete workflow using only strings — no []byte anywhere
221+
original := `{"items": [1, 2, 3], "count": 3}`
222+
modified := `{"items": [1, 2, 3, 4], "count": 4}`
223+
224+
// Create patch from string documents
225+
patch, err := CreatePatch(original, modified)
226+
if err != nil {
227+
t.Fatal(err)
228+
}
229+
230+
// Marshal the patch to JSON (for transport/storage)
231+
patchJSON, err := json.Marshal(patch)
232+
if err != nil {
233+
t.Fatal(err)
234+
}
235+
236+
// Decode from string
237+
decoded, err := DecodePatch(string(patchJSON))
238+
if err != nil {
239+
t.Fatal(err)
240+
}
241+
242+
// Apply to string document
243+
result, err := ApplyPatch(original, decoded)
244+
if err != nil {
245+
t.Fatal(err)
246+
}
247+
248+
assertJSONEqual(t, modified, result)
249+
}
250+
251+
// ---------------------------------------------------------------------------
252+
// Error cases with string inputs
253+
// ---------------------------------------------------------------------------
254+
255+
func TestApply_StringInputs_InvalidJSON(t *testing.T) {
256+
_, err := Apply(`not valid json`, `[{"op": "add", "path": "/foo", "value": 1}]`)
257+
if err == nil {
258+
t.Fatal("expected error for invalid JSON document")
259+
}
260+
}
261+
262+
func TestApply_StringInputs_InvalidPatch(t *testing.T) {
263+
_, err := Apply(`{"foo": "bar"}`, `not a patch`)
264+
if err == nil {
265+
t.Fatal("expected error for invalid patch JSON")
266+
}
267+
}
268+
269+
func TestCreatePatch_StringInputs_InvalidOriginal(t *testing.T) {
270+
_, err := CreatePatch(`not json`, `{"foo": "bar"}`)
271+
if err == nil {
272+
t.Fatal("expected error for invalid original JSON")
273+
}
274+
}

0 commit comments

Comments
 (0)