From 831a97e1f31203a0d50eb16ebd9f2bbd3c968a50 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Tue, 21 Jul 2026 00:34:21 +0200 Subject: [PATCH] feat(loaders): exposed loader chaining and builder with options Signed-off-by: Frederic BIDON --- go.mod | 4 +- go.sum | 8 +-- loaders.go | 45 +++++++++++++++++ loaders_test.go | 129 ++++++++++++++++++++++++++++++++++++++++++++++++ options.go | 5 +- 5 files changed, 183 insertions(+), 8 deletions(-) diff --git a/go.mod b/go.mod index 56dd32a..efc4e92 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index b267de6..0f53a26 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/loaders.go b/loaders.go index f8a2a94..ba5c48b 100644 --- a/loaders.go +++ b/loaders.go @@ -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 { @@ -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 diff --git a/loaders_test.go b/loaders_test.go index acdbee9..f8b6428 100644 --- a/loaders_test.go +++ b/loaders_test.go @@ -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" ) @@ -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) +} diff --git a/options.go b/options.go index fec2052..6a4bc69 100644 --- a/options.go +++ b/options.go @@ -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) {