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
47 changes: 47 additions & 0 deletions ifaces.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// SPDX-FileCopyrightText: Copyright (c) 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0

package jsonpointer

import "reflect"

// JSONPointable is an interface for structs to implement,
// when they need to customize the json pointer process or want to avoid the use of reflection.
type JSONPointable interface {
// JSONLookup returns a value pointed at this (unescaped) key.
JSONLookup(key string) (any, error)
}

// JSONSetable is an interface for structs to implement,
// when they need to customize the json pointer process or want to avoid the use of reflection.
//
// # Handling of the RFC 6901 "-" token
//
// When a type implementing JSONSetable is the terminal parent of a [Pointer.Set]
// call, the library passes the raw reference token to JSONSet without
// interpretation. In particular, the RFC 6901 "-" token (which conventionally
// means "append" for arrays, per RFC 6902) is forwarded verbatim as the key
// argument. Implementations that model an array-like container are expected
// to give "-" the append semantics; implementations that do not should return
// an error wrapping [ErrDashToken] (or [ErrPointer]) for clarity.
//
// Implementations are responsible for any in-place mutation: the library does
// not attempt to rebind the result of JSONSet into a parent container.
type JSONSetable interface {
// JSONSet sets the value pointed at the (unescaped) key.
//
// The key may be the RFC 6901 "-" token when the pointer targets a
// slice-like member; see the interface documentation for details.
JSONSet(key string, value any) error
}

// NameProvider knows how to resolve go struct fields into json names.
//
// The default provider is brought by [github.com/go-openapi/swag/jsonname.DefaultJSONNameProvider].
type NameProvider interface {
// GetGoName gets the go name for a json property name
GetGoName(subject any, name string) (string, bool)

// GetGoNameForType gets the go name for a given type for a json property name
GetGoNameForType(tpe reflect.Type, name string) (string, bool)
}
70 changes: 70 additions & 0 deletions options.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// SPDX-FileCopyrightText: Copyright (c) 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0

package jsonpointer

import (
"sync"

"github.com/go-openapi/swag/jsonname"
)

// Option to tune the behavior of a JSON [Pointer].
type Option func(*options)

var (
//nolint:gochecknoglobals // package level defaults are provided as a convenient, backward-compatible way to adopt options.
defaultOptions = options{
provider: jsonname.DefaultJSONNameProvider,
}
//nolint:gochecknoglobals // guards defaultOptions against concurrent SetDefaultNameProvider / read races (testing)
defaultOptionsMu sync.RWMutex
)

// SetDefaultNameProvider sets the [NameProvider] as a package-level default.
//
// It is safe to call concurrently with [Pointer.Get], [Pointer.Set],
// [GetForToken] and [SetForToken]. The typical usage is to call it once
// at initialization time.
//
// A nil provider is ignored.
func SetDefaultNameProvider(provider NameProvider) {
if provider == nil {
return
}

defaultOptionsMu.Lock()
defer defaultOptionsMu.Unlock()

defaultOptions.provider = provider
}

// DefaultNameProvider returns the current package-level [NameProvider].
func DefaultNameProvider() NameProvider { //nolint:ireturn // returning the interface is the point — callers pick their own implementation.
defaultOptionsMu.RLock()
defer defaultOptionsMu.RUnlock()

return defaultOptions.provider
}

// WithNameProvider injects a custom [NameProvider] to resolve json names from go struct types.
func WithNameProvider(provider NameProvider) Option {
return func(o *options) {
o.provider = provider
}
}

type options struct {
provider NameProvider
}

func optionsWithDefaults(opts []Option) options {
var o options
o.provider = DefaultNameProvider()

for _, apply := range opts {
apply(&o)
}

return o
}
126 changes: 126 additions & 0 deletions options_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// SPDX-FileCopyrightText: Copyright (c) 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0

package jsonpointer

import (
"reflect"
"sync"
"testing"

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

// stubNameProvider is a NameProvider that maps JSON names to Go field names
// via a fixed dictionary. It lets tests observe which provider was used by
// the resolver without relying on the default reflection-based behavior.
type stubNameProvider struct {
mu sync.Mutex
mapping map[string]string
lookups []string
forTypes []string
}

func (s *stubNameProvider) GetGoName(_ any, name string) (string, bool) {
s.record(name, false)
goName, ok := s.mapping[name]
return goName, ok
}

func (s *stubNameProvider) GetGoNameForType(_ reflect.Type, name string) (string, bool) {
s.record(name, true)
goName, ok := s.mapping[name]
return goName, ok
}

func (s *stubNameProvider) record(name string, forType bool) {
s.mu.Lock()
defer s.mu.Unlock()

if forType {
s.forTypes = append(s.forTypes, name)
return
}
s.lookups = append(s.lookups, name)
}

type optionStruct struct {
// intentional: the JSON name "renamed" is deliberately not a valid
// struct tag so that only a custom provider can resolve it.
Field string
}

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

stub := &stubNameProvider{mapping: map[string]string{"renamed": "Field"}}

doc := optionStruct{Field: "hello"}
p, err := New("/renamed")
require.NoError(t, err)

v, _, err := p.Get(doc, WithNameProvider(stub))
require.NoError(t, err)
assert.Equal(t, "hello", v)

stub.mu.Lock()
defer stub.mu.Unlock()
assert.Contains(t, stub.forTypes, "renamed", "custom provider must be consulted")
}

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

stub := &stubNameProvider{mapping: map[string]string{"renamed": "Field"}}

doc := &optionStruct{Field: "before"}
p, err := New("/renamed")
require.NoError(t, err)

_, err = p.Set(doc, "after", WithNameProvider(stub))
require.NoError(t, err)
assert.Equal(t, "after", doc.Field)
}

func TestSetDefaultNameProvider_roundTrip(t *testing.T) {
// Not Parallel: mutates package state.
original := DefaultNameProvider()
t.Cleanup(func() { SetDefaultNameProvider(original) })

stub := &stubNameProvider{mapping: map[string]string{"renamed": "Field"}}
SetDefaultNameProvider(stub)

assert.Same(t, stub, DefaultNameProvider())

doc := optionStruct{Field: "hello"}
p, err := New("/renamed")
require.NoError(t, err)

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

func TestSetDefaultNameProvider_nilIgnored(t *testing.T) {
// Not Parallel: mutates package state.
original := DefaultNameProvider()
t.Cleanup(func() { SetDefaultNameProvider(original) })

SetDefaultNameProvider(nil)
assert.Same(t, original, DefaultNameProvider(), "nil must be a no-op")
}

func TestDefaultNameProvider_reachesGetForToken(t *testing.T) {
// Not Parallel: mutates package state.
original := DefaultNameProvider()
t.Cleanup(func() { SetDefaultNameProvider(original) })

stub := &stubNameProvider{mapping: map[string]string{"renamed": "Field"}}
SetDefaultNameProvider(stub)

doc := optionStruct{Field: "hello"}
v, _, err := GetForToken(doc, "renamed")
require.NoError(t, err)
assert.Equal(t, "hello", v)
}
Loading
Loading