Skip to content
Open
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
34 changes: 34 additions & 0 deletions openapi3/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -516,13 +516,47 @@ 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:
return nil, nil, fmt.Errorf("bad data in %q (expecting %s)", ref, readableType(resolved))
}
}

// 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.SplitSeq(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:
Expand Down
22 changes: 22 additions & 0 deletions openapi3/origin.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package openapi3

import (
"net/url"
"reflect"
"sort"
"strings"
Expand Down Expand Up @@ -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
}
31 changes: 31 additions & 0 deletions openapi3/origin_external_ref_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
14 changes: 14 additions & 0 deletions openapi3/testdata/origin/arbitrary_key.yaml
Original file line number Diff line number Diff line change
@@ -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"
7 changes: 7 additions & 0 deletions openapi3/testdata/origin/arbitrary_key_schemas.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
User:
type: object
properties:
id:
type: string
name:
type: string
Loading