diff --git a/errors.go b/errors.go index e94f038..f1df4e3 100644 --- a/errors.go +++ b/errors.go @@ -3,6 +3,11 @@ package loads +import ( + "errors" + "fmt" +) + type loaderError string func (e loaderError) Error() string { @@ -11,7 +16,7 @@ func (e loaderError) Error() string { const ( // ErrLoads is an error returned by the loads package. - ErrLoads loaderError = "loaderrs error" + ErrLoads loaderError = "cannot load spec" // ErrNoLoader indicates that no configured loader matched the input. ErrNoLoader loaderError = "no loader matched" @@ -20,3 +25,17 @@ const ( // to a non-public address (loopback, private, link-local, or unspecified). ErrForbiddenAddress loaderError = "blocked dial to a non-public address" ) + +// errLoads marks err as an error from this package, so callers may test it with +// [errors.Is] against [ErrLoads]. +// +// The cause is reported on a single line, after the sentinel. An error that already +// carries the sentinel is returned unchanged: loaders are chained, and reporting +// "cannot load spec" once per link in the chain would tell the caller nothing. +func errLoads(err error) error { + if err == nil || errors.Is(err, ErrLoads) { + return err + } + + return fmt.Errorf("%w: %w", ErrLoads, err) +} diff --git a/loaders.go b/loaders.go index ba5c48b..dc5f22f 100644 --- a/loaders.go +++ b/loaders.go @@ -5,7 +5,6 @@ package loads import ( "encoding/json" - "errors" "net/url" "slices" @@ -162,7 +161,7 @@ func (l *loader) WithNext(next *loader) *loader { func (l *loader) Load(path string) (json.RawMessage, error) { _, erp := url.Parse(path) if erp != nil { - return nil, errors.Join(erp, ErrLoads) + return nil, errLoads(erp) } var lastErr error = ErrNoLoader // default error if no match was found @@ -180,7 +179,7 @@ func (l *loader) Load(path string) (json.RawMessage, error) { lastErr = err } - return nil, errors.Join(lastErr, ErrLoads) + return nil, errLoads(lastErr) } func (l *loader) clone() *loader { @@ -202,7 +201,7 @@ func (l *loader) clone() *loader { func JSONDoc(path string, opts ...loading.Option) (json.RawMessage, error) { data, err := loading.LoadFromFileOrHTTP(path, opts...) if err != nil { - return nil, errors.Join(err, ErrLoads) + return nil, errLoads(err) } return json.RawMessage(data), nil } diff --git a/loaders_test.go b/loaders_test.go index f8b6428..f5056c1 100644 --- a/loaders_test.go +++ b/loaders_test.go @@ -6,6 +6,8 @@ package loads import ( "encoding/json" "errors" + "fmt" + "io/fs" "os" "path/filepath" "strings" @@ -99,6 +101,30 @@ func TestLoaderChain(t *testing.T) { require.ErrorIs(t, err, ErrLoads) require.ErrorIs(t, err, errBoom) }) + + t.Run("should report ErrLoads only once", func(t *testing.T) { + // a loader that already marked its error should not have the sentinel added again + chain := LoaderChain(NewDocLoaderWithMatch(func(string, ...loading.Option) (json.RawMessage, error) { + return nil, fmt.Errorf("%w: %w", ErrLoads, errBoom) + }, nil)) + + _, err := chain("x.json") + require.ErrorIs(t, err, ErrLoads) + require.ErrorIs(t, err, errBoom) + require.Equal(t, 1, strings.Count(err.Error(), ErrLoads.Error())) + }) +} + +func TestSpecNotFound(t *testing.T) { + // the sentinel is reported once, on a single line, with the cause of the failure + _, err := Spec(filepath.Join(t.TempDir(), "nowhere.json")) + require.Error(t, err) + require.ErrorIs(t, err, ErrLoads) + require.ErrorIs(t, err, fs.ErrNotExist) + + message := err.Error() + require.Equal(t, 1, strings.Count(message, ErrLoads.Error())) + require.Equal(t, 0, strings.Count(message, "\n")) } func TestLoaderWithOptions(t *testing.T) { diff --git a/spec.go b/spec.go index 40eaff2..e9e4b57 100644 --- a/spec.go +++ b/spec.go @@ -7,7 +7,6 @@ import ( "bytes" "encoding/gob" "encoding/json" - "errors" "fmt" "maps" @@ -110,7 +109,7 @@ func Analyzed(data json.RawMessage, version string, options ...LoaderOption) (*D version = "2.0" } if version != "2.0" { - return nil, fmt.Errorf("spec version %q is not supported: %w", version, ErrLoads) + return nil, fmt.Errorf("%w: spec version %q is not supported", ErrLoads, version) } raw, err := trimData(data) // trim blanks, then convert yaml docs into json @@ -120,12 +119,12 @@ func Analyzed(data json.RawMessage, version string, options ...LoaderOption) (*D swspec := new(spec.Swagger) if err = json.Unmarshal(raw, swspec); err != nil { - return nil, errors.Join(err, ErrLoads) + return nil, errLoads(err) } origsqspec, err := cloneSpec(swspec) if err != nil { - return nil, errors.Join(err, ErrLoads) + return nil, errLoads(err) } d := &Document{ @@ -153,12 +152,12 @@ func trimData(in json.RawMessage) (json.RawMessage, error) { // assume yaml doc: convert it to json yml, err := yamlutils.BytesToYAMLDoc(trimmed) if err != nil { - return nil, fmt.Errorf("analyzed: %w: %w", err, ErrLoads) + return nil, fmt.Errorf("analyzed: %w", errLoads(err)) } d, err := yamlutils.YAMLToJSON(yml) if err != nil { - return nil, fmt.Errorf("analyzed: %w: %w", err, ErrLoads) + return nil, fmt.Errorf("analyzed: %w", errLoads(err)) } return d, nil