Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,5 +40,7 @@ See also .claude/plans/ROADMAP.md.
- Struct fields **must** have a `json` tag to be reachable; untagged fields are ignored
(differs from `encoding/json` which defaults to the Go field name).
- Anonymous embedded struct fields are traversed only if tagged.
- The RFC 6901 `"-"` array suffix (append) is **not** implemented.
- The RFC 6901 `"-"` array suffix is supported on `Pointer.Set` as an append
operation (RFC 6902 convention). On `Pointer.Get` / `Pointer.Offset` it is
always an error per RFC 6901 §4.

21 changes: 14 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ You may join the discord community by clicking the invite link on the discord ba

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

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

## Status

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

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

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

## Licensing

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

## Limitations

The 4.Evaluation part of the previous reference, starting with 'If the currently referenced value is a JSON array,
the reference token MUST contain either...' is not implemented.

That is because our implementation of the JSON pointer only supports explicit references to array elements:
the provision in the spec to resolve non-existent members as "the last element in the array",
using the special trailing character "-" is not implemented.
* [RFC6901][RFC6901] is now fully supported, including trailing "-" semantics for arrays (for `Set` operations).
* JSON name detection in go `struct`s
- Unlike go standard marshaling, untagged fields do not default to the go field name and are ignored.
- anonymous fields are not traversed if untagged

## Other documentation

Expand Down Expand Up @@ -156,3 +162,4 @@ Maintainers can cut a new release by either:
[goversion-url]: https://github.com/go-openapi/jsonpointer/blob/master/go.mod
[top-badge]: https://img.shields.io/github/languages/top/go-openapi/jsonpointer
[commits-badge]: https://img.shields.io/github/commits-since/go-openapi/jsonpointer/latest
[RFC6901]: https://www.rfc-editor.org/rfc/rfc6901
243 changes: 243 additions & 0 deletions dash_token_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
// SPDX-FileCopyrightText: Copyright (c) 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0

package jsonpointer

import (
"errors"
"testing"

"github.com/go-openapi/testify/v2/assert"
"github.com/go-openapi/testify/v2/require"
)

// RFC 6901 §4: the "-" token refers to the (nonexistent) element after the
// last array element. It is always an error on Get/Offset, valid only as
// the terminal token of a Set against a slice (append, per RFC 6902).

func TestDashToken_GetAlwaysErrors(t *testing.T) {
t.Parallel()

t.Run("terminal dash on slice in map", func(t *testing.T) {
doc := map[string]any{"arr": []any{1, 2, 3}}
p, err := New("/arr/-")
require.NoError(t, err)

_, _, err = p.Get(doc)
require.Error(t, err)
require.ErrorIs(t, err, ErrDashToken)
require.ErrorIs(t, err, ErrPointer)
})

t.Run("terminal dash on top-level slice", func(t *testing.T) {
doc := []int{1, 2, 3}
p, err := New("/-")
require.NoError(t, err)

_, _, err = p.Get(doc)
require.Error(t, err)
require.ErrorIs(t, err, ErrDashToken)
})

t.Run("intermediate dash during get", func(t *testing.T) {
doc := map[string]any{"arr": []any{map[string]any{"x": 1}}}
p, err := New("/arr/-/x")
require.NoError(t, err)

_, _, err = p.Get(doc)
require.Error(t, err)
require.ErrorIs(t, err, ErrDashToken)
})

t.Run("GetForToken on slice with dash", func(t *testing.T) {
_, _, err := GetForToken([]int{1, 2}, "-")
require.Error(t, err)
require.ErrorIs(t, err, ErrDashToken)
})

t.Run("dash on map key is a regular lookup, not an error", func(t *testing.T) {
// "-" is only special for arrays. A literal "-" key in a map is fine.
doc := map[string]any{"-": 42}
p, err := New("/-")
require.NoError(t, err)

v, _, err := p.Get(doc)
require.NoError(t, err)
assert.Equal(t, 42, v)
})
}

func TestDashToken_OffsetErrors(t *testing.T) {
t.Parallel()

doc := `{"arr":[1,2,3]}`
p, err := New("/arr/-")
require.NoError(t, err)

_, err = p.Offset(doc)
require.Error(t, err)
require.ErrorIs(t, err, ErrDashToken)
}

func TestDashToken_SetAppend(t *testing.T) {
t.Parallel()

t.Run("append into slice nested in a map (in place)", func(t *testing.T) {
doc := map[string]any{"arr": []any{1, 2}}
p, err := New("/arr/-")
require.NoError(t, err)

out, err := p.Set(doc, 3)
require.NoError(t, err)

// returned doc is the same map reference
assert.Equal(t, doc, out)

// map's slice was rebound in place
arr, ok := doc["arr"].([]any)
require.True(t, ok)
assert.Equal(t, []any{1, 2, 3}, arr)
})

t.Run("append into top-level slice passed by value (return value is source of truth)", func(t *testing.T) {
doc := []int{1, 2}
p, err := New("/-")
require.NoError(t, err)

out, err := p.Set(doc, 3)
require.NoError(t, err)

// returned doc has the appended element
outSlice, ok := out.([]int)
require.True(t, ok)
assert.Equal(t, []int{1, 2, 3}, outSlice)
})

t.Run("append into top-level *[]T (in place)", func(t *testing.T) {
doc := []int{1, 2}
p, err := New("/-")
require.NoError(t, err)

_, err = p.Set(&doc, 3)
require.NoError(t, err)

// caller's slice variable now has the appended element
assert.Equal(t, []int{1, 2, 3}, doc)
})

t.Run("append into struct slice field reached via pointer (in place)", func(t *testing.T) {
type holder struct {
Arr []int `json:"arr"`
}
doc := &holder{Arr: []int{1, 2}}
p, err := New("/arr/-")
require.NoError(t, err)

_, err = p.Set(doc, 3)
require.NoError(t, err)

assert.Equal(t, []int{1, 2, 3}, doc.Arr)
})

t.Run("append into deeply nested slice", func(t *testing.T) {
doc := map[string]any{
"outer": []any{
map[string]any{"inner": []any{"a"}},
},
}
p, err := New("/outer/0/inner/-")
require.NoError(t, err)

_, err = p.Set(doc, "b")
require.NoError(t, err)

outer, ok := doc["outer"].([]any)
require.True(t, ok)
first, ok := outer[0].(map[string]any)
require.True(t, ok)
inner, ok := first["inner"].([]any)
require.True(t, ok)
assert.Equal(t, []any{"a", "b"}, inner)
})

t.Run("SetForToken with dash appends", func(t *testing.T) {
out, err := SetForToken([]int{1, 2}, "-", 3)
require.NoError(t, err)

outSlice, ok := out.([]int)
require.True(t, ok)
assert.Equal(t, []int{1, 2, 3}, outSlice)
})
}

func TestDashToken_SetErrors(t *testing.T) {
t.Parallel()

t.Run("intermediate dash is rejected", func(t *testing.T) {
doc := map[string]any{"arr": []any{1, 2}}
p, err := New("/arr/-/x")
require.NoError(t, err)

_, err = p.Set(doc, 3)
require.Error(t, err)
require.ErrorIs(t, err, ErrDashToken)
})

t.Run("append with wrong element type fails", func(t *testing.T) {
doc := map[string]any{"arr": []int{1, 2}}
p, err := New("/arr/-")
require.NoError(t, err)

_, err = p.Set(doc, "not-an-int")
require.Error(t, err)
})
}

// dashSetter captures whatever token JSONSet receives, including "-".
type dashSetter struct {
key string
value any
}

func (d *dashSetter) JSONSet(key string, value any) error {
d.key = key
d.value = value
return nil
}

func TestDashToken_JSONSetableReceivesRawDash(t *testing.T) {
t.Parallel()

// When the terminal parent implements JSONSetable, the dash token is
// passed through verbatim. Semantics are the user type's responsibility.
ds := &dashSetter{}
p, err := New("/-")
require.NoError(t, err)

_, err = p.Set(ds, 42)
require.NoError(t, err)
assert.Equal(t, "-", ds.key)
assert.Equal(t, 42, ds.value)
}

func TestDashToken_RoundTrip(t *testing.T) {
t.Parallel()

p, err := New("/a/-")
require.NoError(t, err)
assert.Equal(t, "/a/-", p.String())
assert.Equal(t, []string{"a", "-"}, p.DecodedTokens())
}

func TestDashToken_WrappedErrors(t *testing.T) {
t.Parallel()

// Ensure errors.Is works through both wraps.
p, _ := New("/arr/-")
doc := map[string]any{"arr": []any{}}

_, _, err := p.Get(doc)
require.Error(t, err)
assert.True(t, errors.Is(err, ErrDashToken))
assert.True(t, errors.Is(err, ErrPointer))
}
24 changes: 24 additions & 0 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,20 @@ const (

// ErrUnsupportedValueType indicates that a value of the wrong type is being set.
ErrUnsupportedValueType pointerError = "only structs, pointers, maps and slices are supported for setting values"

// ErrDashToken indicates use of the RFC 6901 "-" reference token
// in a context where it cannot be resolved.
//
// Per RFC 6901 §4 the "-" token refers to the (nonexistent) element
// after the last array element. It may only be used as the terminal
// token of a [Pointer.Set] against a slice, where it means "append".
// Any other use (get, offset, intermediate traversal, non-slice target)
// is an error condition that wraps this sentinel.
ErrDashToken pointerError = `the "-" array token cannot be resolved here` //nolint:gosec // G101 false positive: this is a JSON Pointer reference token, not a credential.
)

const dashToken = "-"

func errNoKey(key string) error {
return fmt.Errorf("object has no key %q: %w", key, ErrPointer)
}
Expand All @@ -33,3 +45,15 @@ func errOutOfBounds(length, idx int) error {
func errInvalidReference(token string) error {
return fmt.Errorf("invalid token reference %q: %w", token, ErrPointer)
}

func errDashOnGet() error {
return fmt.Errorf("cannot resolve %q token on get: %w: %w", dashToken, ErrDashToken, ErrPointer)
}

func errDashIntermediate() error {
return fmt.Errorf("the %q token may only appear as the terminal token of a pointer: %w: %w", dashToken, ErrDashToken, ErrPointer)
}

func errDashOnOffset() error {
return fmt.Errorf("cannot compute offset for %q token (nonexistent element): %w: %w", dashToken, ErrDashToken, ErrPointer)
}
56 changes: 56 additions & 0 deletions examples_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,59 @@ func ExamplePointer_Set() {
// result: &jsonpointer.exampleDocument{Foo:[]string{"bar", "hey my"}}
// doc: jsonpointer.exampleDocument{Foo:[]string{"bar", "hey my"}}
}

// ExamplePointer_Set_append demonstrates the RFC 6901 "-" token as an
// append operation on a slice. On nested slices reached through an
// addressable parent (map entry, pointer to struct, ...), the append is
// performed in place and the returned document is the same reference.
func ExamplePointer_Set_append() {
doc := map[string]any{"foo": []any{"bar"}}

pointer, err := New("/foo/-")
if err != nil {
fmt.Println(err)

return
}

if _, err := pointer.Set(doc, "baz"); err != nil {
fmt.Println(err)

return
}

fmt.Printf("doc: %v\n", doc["foo"])

// Output:
// doc: [bar baz]
}

// ExamplePointer_Set_appendTopLevelSlice shows the one case where the
// returned document is load-bearing: appending to a top-level slice
// passed by value. The library cannot rebind the slice header in the
// caller's variable, so callers must use the returned document (or pass
// *[]T to get in-place rebind).
func ExamplePointer_Set_appendTopLevelSlice() {
doc := []int{1, 2}

pointer, err := New("/-")
if err != nil {
fmt.Println(err)

return
}

out, err := pointer.Set(doc, 3)
if err != nil {
fmt.Println(err)

return
}

fmt.Printf("original: %v\n", doc)
fmt.Printf("returned: %v\n", out)

// Output:
// original: [1 2]
// returned: [1 2 3]
}
Loading
Loading