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: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
module github.com/go-openapi/loads

require (
github.com/go-openapi/analysis v0.25.4
github.com/go-openapi/spec v0.22.8
github.com/go-openapi/analysis v0.25.5
github.com/go-openapi/spec v0.22.9
github.com/go-openapi/swag/loading v0.27.3
github.com/go-openapi/swag/yamlutils v0.27.3
github.com/go-openapi/testify/enable/yaml/v2 v2.6.0
Expand Down
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
github.com/go-openapi/analysis v0.25.4 h1:fc00W0n7P4Q/X2RMStwaYY9uakhXS8N+zAQCW++dGFY=
github.com/go-openapi/analysis v0.25.4/go.mod h1:d3UGtQC5uq5Kqqqis2VH09Km/v3vwsWrYkbp4gdm+Rc=
github.com/go-openapi/analysis v0.25.5 h1:xPYEvTb90o1y0epuiOPAoG4QqahjP3cdp5xNlHeKJRI=
github.com/go-openapi/analysis v0.25.5/go.mod h1:d3UGtQC5uq5Kqqqis2VH09Km/v3vwsWrYkbp4gdm+Rc=
github.com/go-openapi/errors v0.22.8 h1:oP7sW7TWc3wFFjrzzj0nI83H2qMBkNjNfSd+XRejk/I=
github.com/go-openapi/errors v0.22.8/go.mod h1:BuUoHcYrU6E7V9gfj1I5wLQqgtIHnup/alXZ8KdgQ0w=
github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s=
github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y=
github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY=
github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0=
github.com/go-openapi/spec v0.22.8 h1:Nx5XYgR0nTjRLHdtTp+mpdw/QhVTiDXMNDRrhkUrgaI=
github.com/go-openapi/spec v0.22.8/go.mod h1:b/mNUYIOQOyIiUzUzXEE8xzyZqf93KvM9hQGP91yfl0=
github.com/go-openapi/spec v0.22.9 h1:/vKIFDcGKp0ktZWGbym/tJEWbk6/XOEmAVU0kqKMH+w=
github.com/go-openapi/spec v0.22.9/go.mod h1:b/mNUYIOQOyIiUzUzXEE8xzyZqf93KvM9hQGP91yfl0=
github.com/go-openapi/strfmt v0.27.0 h1:kbcTeaD9TXuXD0hhMXzuYa1sdTo6+dWGvwjW93E80IM=
github.com/go-openapi/strfmt v0.27.0/go.mod h1:s/qhDqfY72irigXUGJmtgid2Rm+3tnz3k8hZaRmvWYc=
github.com/go-openapi/swag/conv v0.27.3 h1:iqJFmGEjmX3AY0lSszABFqRVqOSt99XS0LzNIMJYuhU=
Expand Down
45 changes: 45 additions & 0 deletions loaders.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,31 @@ func defaultLoaders() *loader {
})
}

// LoaderChain links a list of [DocLoaderWithMatch] into a single [DocLoader], preserving order.
// Entries with a nil Fn are skipped. Loading options passed at call time are forwarded to the
// matched loader.
//
// Combined with [LoaderWithOptions], it composes a self-contained loader (for example a
// format-dispatching YAML/JSON chain, each entry carrying its own options) that a caller can inject
// through [WithDocLoader] instead of relying on the package-level global loaders.
//
// The returned DocLoader is never nil: when no usable loader is provided, it yields [ErrNoLoader]
// on every call. This fails closed rather than returning nil (which every caller would have to
// guard against, at the risk of a nil-func panic) or silently falling back to an unconfined
// loader.
func LoaderChain(ldrs ...DocLoaderWithMatch) DocLoader {
loader := buildLoaderChain(ldrs...)

return func(pth string, opts ...loading.Option) (json.RawMessage, error) {
l := loader.clone()
if l != nil {
l.loadingOptions = opts
}

return l.Load(pth) // nil-safe: yields ErrNoLoader when the chain is empty
}
}

// buildLoaderChain links a list of [DocLoaderWithMatch] into a loader chain, preserving order.
// Entries with a nil Fn are skipped. Returns nil when no usable loader is provided.
func buildLoaderChain(ldrs ...DocLoaderWithMatch) *loader {
Expand All @@ -73,6 +98,26 @@ func buildLoaderChain(ldrs ...DocLoaderWithMatch) *loader {
// DocLoader represents a doc loader type.
type DocLoader func(string, ...loading.Option) (json.RawMessage, error)

// LoaderWithOptions returns a [DocLoader] that always applies opts.
//
// Use it to bind a set of [loading.Option] to a loader so they apply to every load — for example a
// custom HTTP client or timeout, authentication or custom headers, an embedded or rooted file
// system, or a remote-address restriction. This is the building block for a document loader that
// carries its own options, avoiding reliance on the package-level global loaders.
//
// opts are appended after any options passed at call time, so they take precedence (loading
// options are last-wins). This also makes confinement options (e.g. [loading.WithRoot]) win over
// any caller-supplied options.
func LoaderWithOptions(fn DocLoader, opts ...loading.Option) DocLoader {
return func(path string, callOpts ...loading.Option) (json.RawMessage, error) {
all := make([]loading.Option, 0, len(callOpts)+len(opts))
all = append(all, callOpts...)
all = append(all, opts...)

return fn(path, all...)
}
}

// DocMatcher represents a predicate to check if a loader matches.
type DocMatcher func(string) bool

Expand Down
129 changes: 129 additions & 0 deletions loaders_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,15 @@
package loads

import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"

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

Expand All @@ -24,3 +31,125 @@ func TestLoader_EdgeCases(t *testing.T) {
cnext := clone.WithHead(nil)
require.Equal(t, clone, cnext)
}

// errBoom is a static error used by loader tests to assert error propagation.
var errBoom = errors.New("boom")

func okLoader(tag string, sink *string) DocLoader {
return func(_ string, _ ...loading.Option) (json.RawMessage, error) {
if sink != nil {
*sink = tag
}
return json.RawMessage(`{"loaded":"` + tag + `"}`), nil
}
}

func TestLoaderChain(t *testing.T) {
t.Run("empty or all-nil chain yields ErrNoLoader without panicking (never nil)", func(t *testing.T) {
for name, chain := range map[string]DocLoader{
"no loaders": LoaderChain(),
"nil Fn entry": LoaderChain(DocLoaderWithMatch{Fn: nil}),
"NewDocLoader nil Fn": LoaderChain(NewDocLoaderWithMatch(nil, nil)),
"only non-matching": LoaderChain(NewDocLoaderWithMatch(okLoader("x", nil), func(string) bool { return false })),
} {
t.Run(name, func(t *testing.T) {
require.NotNil(t, chain, "LoaderChain must never return a nil DocLoader")
require.NotPanics(t, func() {
_, err := chain("whatever.json")
require.ErrorIs(t, err, ErrNoLoader)
})
})
}
})

t.Run("dispatches to the first matching loader, preserving order", func(t *testing.T) {
var got string
chain := LoaderChain(
NewDocLoaderWithMatch(okLoader("yaml", &got), loading.YAMLMatcher),
NewDocLoaderWithMatch(okLoader("json", &got), nil), // catch-all fallback
)

_, err := chain("spec.yaml")
require.NoError(t, err)
require.Equal(t, "yaml", got)

_, err = chain("spec.json")
require.NoError(t, err)
require.Equal(t, "json", got)
})

t.Run("forwards call-time options to the matched loader", func(t *testing.T) {
var count int
chain := LoaderChain(NewDocLoaderWithMatch(func(_ string, opts ...loading.Option) (json.RawMessage, error) {
count = len(opts)
return json.RawMessage(`{}`), nil
}, nil))

_, err := chain("x.json", loading.WithTimeout(time.Second), loading.WithRoot(t.TempDir()))
require.NoError(t, err)
require.Equal(t, 2, count)
})

t.Run("falls through on error and aggregates as ErrLoads", func(t *testing.T) {
chain := LoaderChain(NewDocLoaderWithMatch(func(string, ...loading.Option) (json.RawMessage, error) {
return nil, errBoom
}, nil))

_, err := chain("x.json")
require.ErrorIs(t, err, ErrLoads)
require.ErrorIs(t, err, errBoom)
})
}

func TestLoaderWithOptions(t *testing.T) {
t.Run("appends fixed options after call-time options", func(t *testing.T) {
var received int
wrapped := LoaderWithOptions(func(_ string, opts ...loading.Option) (json.RawMessage, error) {
received = len(opts)
return json.RawMessage(`{}`), nil
}, loading.WithRoot("/a"), loading.WithTimeout(time.Second))

_, err := wrapped("x.json", loading.WithHTTPClient(nil))
require.NoError(t, err)
require.Equal(t, 3, received) // 1 call-time + 2 fixed
})

t.Run("fixed options take precedence over call-time options (last-wins)", func(t *testing.T) {
// A confined WithRoot supplied to LoaderWithOptions must win over a call-time WithRoot
// pointing elsewhere.
root := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(root, "in.json"), []byte(`{"ok":true}`), 0o600))
elsewhere := t.TempDir()

confined := LoaderWithOptions(JSONDoc, loading.WithRoot(root))

// call-time asks for a different root; the fixed root wins, so the in-root file loads
b, err := confined("in.json", loading.WithRoot(elsewhere))
require.NoError(t, err)
require.True(t, strings.Contains(string(b), "ok"))
})
}

// TestLoaderChain_ConfinedIntegration mirrors the go-openapi/loads consumer pattern used by
// go-swagger: a LoaderChain of option-wrapped YAML/JSON loaders confines every load, even though
// the chain itself forwards no options.
func TestLoaderChain_ConfinedIntegration(t *testing.T) {
root := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(root, "api.json"), []byte(`{"swagger":"2.0"}`), 0o600))
parent := filepath.Dir(root)
require.NoError(t, os.WriteFile(filepath.Join(parent, "secret.json"), []byte(`{"secret":true}`), 0o600))

chain := LoaderChain(
NewDocLoaderWithMatch(LoaderWithOptions(loading.YAMLDoc, loading.WithRoot(root)), loading.YAMLMatcher),
NewDocLoaderWithMatch(LoaderWithOptions(loading.JSONDoc, loading.WithRoot(root)), nil),
)

// an in-root document loads
b, err := chain("api.json")
require.NoError(t, err)
require.True(t, strings.Contains(string(b), "swagger"))

// a path escaping the root is rejected, even though chain() passes no call-time options
_, err = chain("../secret.json")
require.Error(t, err)
}
5 changes: 3 additions & 2 deletions options.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,9 @@ func WithDocLoaderMatches(l ...DocLoaderWithMatch) LoaderOption {
// WithLoadingOptions adds some [loading.Option] to be added when calling a registered loader.
//
// The options are attached to the document's loader, so they apply both to the initial load
// and to every "$ref" resolved during [Document.Expanded]. This is the recommended place to
// confine loading of untrusted input, for example with [loading.WithRoot] (local) and
// and to every "$ref" resolved during [Document.Expanded].
//
// This is the recommended place to confine loading of untrusted input, for example with [loading.WithRoot] (local) and
// [loading.WithHTTPClient] (remote). See the package documentation on Security.
func WithLoadingOptions(loadingOptions ...loading.Option) LoaderOption {
return func(opt *options) {
Expand Down
Loading