Skip to content

Commit bca4023

Browse files
authored
feat: the RFC 6901 "-" array suffix is now supported (#121)
`Pointer.Set` may append elements to an array using this syntax. On `Pointer.Get` / `Pointer.Offset` it is always an error per RFC 6901 §4. * fixes #120 Signed-off-by: Frederic BIDON <fredbi@yahoo.com>
1 parent 827f12d commit bca4023

7 files changed

Lines changed: 522 additions & 48 deletions

File tree

.claude/CLAUDE.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,5 +40,7 @@ See also .claude/plans/ROADMAP.md.
4040
- Struct fields **must** have a `json` tag to be reachable; untagged fields are ignored
4141
(differs from `encoding/json` which defaults to the Go field name).
4242
- Anonymous embedded struct fields are traversed only if tagged.
43-
- The RFC 6901 `"-"` array suffix (append) is **not** implemented.
43+
- The RFC 6901 `"-"` array suffix is supported on `Pointer.Set` as an append
44+
operation (RFC 6902 convention). On `Pointer.Get` / `Pointer.Offset` it is
45+
always an error per RFC 6901 §4.
4446

README.md

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ You may join the discord community by clicking the invite link on the discord ba
2424

2525
Or join our Slack channel: [![Slack Channel][slack-logo]![slack-badge]][slack-url]
2626

27+
* **2026-04-15** : added support for trailing "-" for arrays
28+
* this brings full support of [RFC6901][RFC6901]
29+
* this is supported for types relying on the reflection-based implemented
30+
* API semantics remain essentially unaltered. Exception: `Pointer.Set(document any,value any) (document any, err error)`
31+
can only perform a best-effort to mutate the input document in place. In the case of adding elements to an array with a
32+
trailing "-", either pass a mutable array (`*[]T`) as the input document, or use the returned updated document instead.
33+
* types that implement the `JSONSetable` interface may not implement the mutation implied by the trailing "-"
34+
2735
## Status
2836

2937
API is stable.
@@ -88,7 +96,7 @@ See <https://github.com/go-openapi/jsonpointer/releases>
8896

8997
<https://tools.ietf.org/html/draft-ietf-appsawg-json-pointer-07>
9098

91-
also known as [RFC6901](https://www.rfc-editor.org/rfc/rfc6901)
99+
also known as [RFC6901][RFC6901].
92100

93101
## Licensing
94102

@@ -99,12 +107,10 @@ on top of which it has been built.
99107

100108
## Limitations
101109

102-
The 4.Evaluation part of the previous reference, starting with 'If the currently referenced value is a JSON array,
103-
the reference token MUST contain either...' is not implemented.
104-
105-
That is because our implementation of the JSON pointer only supports explicit references to array elements:
106-
the provision in the spec to resolve non-existent members as "the last element in the array",
107-
using the special trailing character "-" is not implemented.
110+
* [RFC6901][RFC6901] is now fully supported, including trailing "-" semantics for arrays (for `Set` operations).
111+
* JSON name detection in go `struct`s
112+
- Unlike go standard marshaling, untagged fields do not default to the go field name and are ignored.
113+
- anonymous fields are not traversed if untagged
108114

109115
## Other documentation
110116

@@ -156,3 +162,4 @@ Maintainers can cut a new release by either:
156162
[goversion-url]: https://github.com/go-openapi/jsonpointer/blob/master/go.mod
157163
[top-badge]: https://img.shields.io/github/languages/top/go-openapi/jsonpointer
158164
[commits-badge]: https://img.shields.io/github/commits-since/go-openapi/jsonpointer/latest
165+
[RFC6901]: https://www.rfc-editor.org/rfc/rfc6901

dash_token_test.go

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2015-2025 go-swagger maintainers
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package jsonpointer
5+
6+
import (
7+
"errors"
8+
"testing"
9+
10+
"github.com/go-openapi/testify/v2/assert"
11+
"github.com/go-openapi/testify/v2/require"
12+
)
13+
14+
// RFC 6901 §4: the "-" token refers to the (nonexistent) element after the
15+
// last array element. It is always an error on Get/Offset, valid only as
16+
// the terminal token of a Set against a slice (append, per RFC 6902).
17+
18+
func TestDashToken_GetAlwaysErrors(t *testing.T) {
19+
t.Parallel()
20+
21+
t.Run("terminal dash on slice in map", func(t *testing.T) {
22+
doc := map[string]any{"arr": []any{1, 2, 3}}
23+
p, err := New("/arr/-")
24+
require.NoError(t, err)
25+
26+
_, _, err = p.Get(doc)
27+
require.Error(t, err)
28+
require.ErrorIs(t, err, ErrDashToken)
29+
require.ErrorIs(t, err, ErrPointer)
30+
})
31+
32+
t.Run("terminal dash on top-level slice", func(t *testing.T) {
33+
doc := []int{1, 2, 3}
34+
p, err := New("/-")
35+
require.NoError(t, err)
36+
37+
_, _, err = p.Get(doc)
38+
require.Error(t, err)
39+
require.ErrorIs(t, err, ErrDashToken)
40+
})
41+
42+
t.Run("intermediate dash during get", func(t *testing.T) {
43+
doc := map[string]any{"arr": []any{map[string]any{"x": 1}}}
44+
p, err := New("/arr/-/x")
45+
require.NoError(t, err)
46+
47+
_, _, err = p.Get(doc)
48+
require.Error(t, err)
49+
require.ErrorIs(t, err, ErrDashToken)
50+
})
51+
52+
t.Run("GetForToken on slice with dash", func(t *testing.T) {
53+
_, _, err := GetForToken([]int{1, 2}, "-")
54+
require.Error(t, err)
55+
require.ErrorIs(t, err, ErrDashToken)
56+
})
57+
58+
t.Run("dash on map key is a regular lookup, not an error", func(t *testing.T) {
59+
// "-" is only special for arrays. A literal "-" key in a map is fine.
60+
doc := map[string]any{"-": 42}
61+
p, err := New("/-")
62+
require.NoError(t, err)
63+
64+
v, _, err := p.Get(doc)
65+
require.NoError(t, err)
66+
assert.Equal(t, 42, v)
67+
})
68+
}
69+
70+
func TestDashToken_OffsetErrors(t *testing.T) {
71+
t.Parallel()
72+
73+
doc := `{"arr":[1,2,3]}`
74+
p, err := New("/arr/-")
75+
require.NoError(t, err)
76+
77+
_, err = p.Offset(doc)
78+
require.Error(t, err)
79+
require.ErrorIs(t, err, ErrDashToken)
80+
}
81+
82+
func TestDashToken_SetAppend(t *testing.T) {
83+
t.Parallel()
84+
85+
t.Run("append into slice nested in a map (in place)", func(t *testing.T) {
86+
doc := map[string]any{"arr": []any{1, 2}}
87+
p, err := New("/arr/-")
88+
require.NoError(t, err)
89+
90+
out, err := p.Set(doc, 3)
91+
require.NoError(t, err)
92+
93+
// returned doc is the same map reference
94+
assert.Equal(t, doc, out)
95+
96+
// map's slice was rebound in place
97+
arr, ok := doc["arr"].([]any)
98+
require.True(t, ok)
99+
assert.Equal(t, []any{1, 2, 3}, arr)
100+
})
101+
102+
t.Run("append into top-level slice passed by value (return value is source of truth)", func(t *testing.T) {
103+
doc := []int{1, 2}
104+
p, err := New("/-")
105+
require.NoError(t, err)
106+
107+
out, err := p.Set(doc, 3)
108+
require.NoError(t, err)
109+
110+
// returned doc has the appended element
111+
outSlice, ok := out.([]int)
112+
require.True(t, ok)
113+
assert.Equal(t, []int{1, 2, 3}, outSlice)
114+
})
115+
116+
t.Run("append into top-level *[]T (in place)", func(t *testing.T) {
117+
doc := []int{1, 2}
118+
p, err := New("/-")
119+
require.NoError(t, err)
120+
121+
_, err = p.Set(&doc, 3)
122+
require.NoError(t, err)
123+
124+
// caller's slice variable now has the appended element
125+
assert.Equal(t, []int{1, 2, 3}, doc)
126+
})
127+
128+
t.Run("append into struct slice field reached via pointer (in place)", func(t *testing.T) {
129+
type holder struct {
130+
Arr []int `json:"arr"`
131+
}
132+
doc := &holder{Arr: []int{1, 2}}
133+
p, err := New("/arr/-")
134+
require.NoError(t, err)
135+
136+
_, err = p.Set(doc, 3)
137+
require.NoError(t, err)
138+
139+
assert.Equal(t, []int{1, 2, 3}, doc.Arr)
140+
})
141+
142+
t.Run("append into deeply nested slice", func(t *testing.T) {
143+
doc := map[string]any{
144+
"outer": []any{
145+
map[string]any{"inner": []any{"a"}},
146+
},
147+
}
148+
p, err := New("/outer/0/inner/-")
149+
require.NoError(t, err)
150+
151+
_, err = p.Set(doc, "b")
152+
require.NoError(t, err)
153+
154+
outer, ok := doc["outer"].([]any)
155+
require.True(t, ok)
156+
first, ok := outer[0].(map[string]any)
157+
require.True(t, ok)
158+
inner, ok := first["inner"].([]any)
159+
require.True(t, ok)
160+
assert.Equal(t, []any{"a", "b"}, inner)
161+
})
162+
163+
t.Run("SetForToken with dash appends", func(t *testing.T) {
164+
out, err := SetForToken([]int{1, 2}, "-", 3)
165+
require.NoError(t, err)
166+
167+
outSlice, ok := out.([]int)
168+
require.True(t, ok)
169+
assert.Equal(t, []int{1, 2, 3}, outSlice)
170+
})
171+
}
172+
173+
func TestDashToken_SetErrors(t *testing.T) {
174+
t.Parallel()
175+
176+
t.Run("intermediate dash is rejected", func(t *testing.T) {
177+
doc := map[string]any{"arr": []any{1, 2}}
178+
p, err := New("/arr/-/x")
179+
require.NoError(t, err)
180+
181+
_, err = p.Set(doc, 3)
182+
require.Error(t, err)
183+
require.ErrorIs(t, err, ErrDashToken)
184+
})
185+
186+
t.Run("append with wrong element type fails", func(t *testing.T) {
187+
doc := map[string]any{"arr": []int{1, 2}}
188+
p, err := New("/arr/-")
189+
require.NoError(t, err)
190+
191+
_, err = p.Set(doc, "not-an-int")
192+
require.Error(t, err)
193+
})
194+
}
195+
196+
// dashSetter captures whatever token JSONSet receives, including "-".
197+
type dashSetter struct {
198+
key string
199+
value any
200+
}
201+
202+
func (d *dashSetter) JSONSet(key string, value any) error {
203+
d.key = key
204+
d.value = value
205+
return nil
206+
}
207+
208+
func TestDashToken_JSONSetableReceivesRawDash(t *testing.T) {
209+
t.Parallel()
210+
211+
// When the terminal parent implements JSONSetable, the dash token is
212+
// passed through verbatim. Semantics are the user type's responsibility.
213+
ds := &dashSetter{}
214+
p, err := New("/-")
215+
require.NoError(t, err)
216+
217+
_, err = p.Set(ds, 42)
218+
require.NoError(t, err)
219+
assert.Equal(t, "-", ds.key)
220+
assert.Equal(t, 42, ds.value)
221+
}
222+
223+
func TestDashToken_RoundTrip(t *testing.T) {
224+
t.Parallel()
225+
226+
p, err := New("/a/-")
227+
require.NoError(t, err)
228+
assert.Equal(t, "/a/-", p.String())
229+
assert.Equal(t, []string{"a", "-"}, p.DecodedTokens())
230+
}
231+
232+
func TestDashToken_WrappedErrors(t *testing.T) {
233+
t.Parallel()
234+
235+
// Ensure errors.Is works through both wraps.
236+
p, _ := New("/arr/-")
237+
doc := map[string]any{"arr": []any{}}
238+
239+
_, _, err := p.Get(doc)
240+
require.Error(t, err)
241+
assert.True(t, errors.Is(err, ErrDashToken))
242+
assert.True(t, errors.Is(err, ErrPointer))
243+
}

errors.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,20 @@ const (
2020

2121
// ErrUnsupportedValueType indicates that a value of the wrong type is being set.
2222
ErrUnsupportedValueType pointerError = "only structs, pointers, maps and slices are supported for setting values"
23+
24+
// ErrDashToken indicates use of the RFC 6901 "-" reference token
25+
// in a context where it cannot be resolved.
26+
//
27+
// Per RFC 6901 §4 the "-" token refers to the (nonexistent) element
28+
// after the last array element. It may only be used as the terminal
29+
// token of a [Pointer.Set] against a slice, where it means "append".
30+
// Any other use (get, offset, intermediate traversal, non-slice target)
31+
// is an error condition that wraps this sentinel.
32+
ErrDashToken pointerError = `the "-" array token cannot be resolved here` //nolint:gosec // G101 false positive: this is a JSON Pointer reference token, not a credential.
2333
)
2434

35+
const dashToken = "-"
36+
2537
func errNoKey(key string) error {
2638
return fmt.Errorf("object has no key %q: %w", key, ErrPointer)
2739
}
@@ -33,3 +45,15 @@ func errOutOfBounds(length, idx int) error {
3345
func errInvalidReference(token string) error {
3446
return fmt.Errorf("invalid token reference %q: %w", token, ErrPointer)
3547
}
48+
49+
func errDashOnGet() error {
50+
return fmt.Errorf("cannot resolve %q token on get: %w: %w", dashToken, ErrDashToken, ErrPointer)
51+
}
52+
53+
func errDashIntermediate() error {
54+
return fmt.Errorf("the %q token may only appear as the terminal token of a pointer: %w: %w", dashToken, ErrDashToken, ErrPointer)
55+
}
56+
57+
func errDashOnOffset() error {
58+
return fmt.Errorf("cannot compute offset for %q token (nonexistent element): %w: %w", dashToken, ErrDashToken, ErrPointer)
59+
}

examples_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,3 +129,59 @@ func ExamplePointer_Set() {
129129
// result: &jsonpointer.exampleDocument{Foo:[]string{"bar", "hey my"}}
130130
// doc: jsonpointer.exampleDocument{Foo:[]string{"bar", "hey my"}}
131131
}
132+
133+
// ExamplePointer_Set_append demonstrates the RFC 6901 "-" token as an
134+
// append operation on a slice. On nested slices reached through an
135+
// addressable parent (map entry, pointer to struct, ...), the append is
136+
// performed in place and the returned document is the same reference.
137+
func ExamplePointer_Set_append() {
138+
doc := map[string]any{"foo": []any{"bar"}}
139+
140+
pointer, err := New("/foo/-")
141+
if err != nil {
142+
fmt.Println(err)
143+
144+
return
145+
}
146+
147+
if _, err := pointer.Set(doc, "baz"); err != nil {
148+
fmt.Println(err)
149+
150+
return
151+
}
152+
153+
fmt.Printf("doc: %v\n", doc["foo"])
154+
155+
// Output:
156+
// doc: [bar baz]
157+
}
158+
159+
// ExamplePointer_Set_appendTopLevelSlice shows the one case where the
160+
// returned document is load-bearing: appending to a top-level slice
161+
// passed by value. The library cannot rebind the slice header in the
162+
// caller's variable, so callers must use the returned document (or pass
163+
// *[]T to get in-place rebind).
164+
func ExamplePointer_Set_appendTopLevelSlice() {
165+
doc := []int{1, 2}
166+
167+
pointer, err := New("/-")
168+
if err != nil {
169+
fmt.Println(err)
170+
171+
return
172+
}
173+
174+
out, err := pointer.Set(doc, 3)
175+
if err != nil {
176+
fmt.Println(err)
177+
178+
return
179+
}
180+
181+
fmt.Printf("original: %v\n", doc)
182+
fmt.Printf("returned: %v\n", out)
183+
184+
// Output:
185+
// original: [1 2]
186+
// returned: [1 2 3]
187+
}

0 commit comments

Comments
 (0)