diff --git a/openapi3/loader.go b/openapi3/loader.go index f7fc8cd87..a48b001ad 100644 --- a/openapi3/loader.go +++ b/openapi3/loader.go @@ -516,6 +516,10 @@ func (loader *Loader) resolveComponent(doc *T, ref string, path *url.URL, resolv if err := codec(cursor, resolved); err != nil { return nil, nil, fmt.Errorf("bad data in %q (expecting %s)", ref, readableType(resolved)) } + // The value came from a generic map in T.Extensions (a $ref to an + // arbitrary top-level key), so the json round-trip above stripped its + // origins. Re-attach them from the file, best-effort. + loader.attachOriginToResolved(resolved, componentPath, fragment) return componentDoc, componentPath, nil default: @@ -523,6 +527,36 @@ func (loader *Loader) resolveComponent(doc *T, ref string, path *url.URL, resolv } } +// attachOriginToResolved re-attaches source origins to a component resolved +// through the generic-map path: a $ref to a schema under an arbitrary top-level +// key lands in T.Extensions, and the json round-trip in resolveComponent strips +// its origin. It re-derives the component file's origin tree, walks to the ref +// fragment, and applies that subtree, so the object carries the same origins a +// typed resolution would, with the original file's line numbers. Best-effort: +// any failure leaves the object without origins, exactly as before. +func (loader *Loader) attachOriginToResolved(resolved any, componentPath *url.URL, fragment string) { + if !loader.IncludeOrigin || componentPath == nil { + return + } + data, err := loader.readURL(componentPath) + if err != nil { + return + } + tree := originTree(data, componentPath) + if tree == nil { + return + } + for _, part := range strings.Split(strings.Trim(fragment, "/"), "/") { + if part == "" { + continue + } + if tree = tree.Fields[unescapeRefString(part)]; tree == nil { + return + } + } + applyOrigins(resolved, tree) +} + func readableType(x any) string { switch x.(type) { case *Callback: diff --git a/openapi3/origin.go b/openapi3/origin.go index e55695a5b..c24655340 100644 --- a/openapi3/origin.go +++ b/openapi3/origin.go @@ -1,6 +1,7 @@ package openapi3 import ( + "net/url" "reflect" "sort" "strings" @@ -324,3 +325,24 @@ func jsonTagName(f reflect.StructField) string { name, _, _ := strings.Cut(tag, ",") return name } + +// originTree parses data solely for its origin tree, used to re-attach source +// origins to a component resolved through the generic-map path: a $ref to a +// schema under an arbitrary top-level key lands in T.Extensions and loses its +// origin in the json round-trip (see resolveComponent). Returns nil when parsing +// fails; callers degrade to no origin. +func originTree(data []byte, location *url.URL) *yaml.OriginTree { + var file string + if location != nil { + file = location.String() + } + var v any + tree, err := yaml.Unmarshal(data, &v, yaml.DecodeOpts{ + Origin: yaml.OriginOpt{Enabled: true, File: file}, + DisableTimestamps: true, + }) + if err != nil { + return nil + } + return tree +} diff --git a/openapi3/origin_external_ref_test.go b/openapi3/origin_external_ref_test.go new file mode 100644 index 000000000..5410310e6 --- /dev/null +++ b/openapi3/origin_external_ref_test.go @@ -0,0 +1,31 @@ +package openapi3 + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// A $ref to a schema stored under an arbitrary top-level key in another file +// (e.g. "./schemas.yaml#/User", the Swagger-2-era "definitions bag") must carry +// the origin of the file it lives in, like a $ref into /components/schemas does. +// The key is not a typed field of T, so the loader reaches it through T.Extensions +// (a generic map); the origin must survive that path. +func TestOrigin_ExternalRefToArbitraryTopLevelKey(t *testing.T) { + loader := NewLoader() + loader.IncludeOrigin = true + loader.IsExternalRefsAllowed = true + + doc, err := loader.LoadFromFile("testdata/origin/arbitrary_key.yaml") + require.NoError(t, err) + + user := doc.Paths.Value("/users").Get.Responses.Value("200").Value.Content["application/json"].Schema.Value + require.NotNil(t, user, "the User schema resolves") + + require.NotNil(t, user.Origin, "a schema $ref'd under an arbitrary top-level key must carry an origin") + require.NotNil(t, user.Origin.Key, "the origin has a key location") + require.Equal(t, "arbitrary_key_schemas.yaml", filepath.Base(user.Origin.Key.File), + "origin points at the file the schema lives in, not the referencing document") + require.NotZero(t, user.Origin.Key.Line, "the origin has a source line") +} diff --git a/openapi3/testdata/origin/arbitrary_key.yaml b/openapi3/testdata/origin/arbitrary_key.yaml new file mode 100644 index 000000000..11cf195fe --- /dev/null +++ b/openapi3/testdata/origin/arbitrary_key.yaml @@ -0,0 +1,14 @@ +openapi: 3.0.0 +info: + title: Arbitrary top-level key ref + version: 1.0.0 +paths: + /users: + get: + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "./arbitrary_key_schemas.yaml#/User" diff --git a/openapi3/testdata/origin/arbitrary_key_schemas.yaml b/openapi3/testdata/origin/arbitrary_key_schemas.yaml new file mode 100644 index 000000000..8474e357b --- /dev/null +++ b/openapi3/testdata/origin/arbitrary_key_schemas.yaml @@ -0,0 +1,7 @@ +User: + type: object + properties: + id: + type: string + name: + type: string