Skip to content

Commit 31a58b9

Browse files
openapi3: add JoinFunc callback for custom path resolution
Replace the colon-specific handling with a general-purpose JoinFunc callback on Loader. This lets callers override how relative $ref paths are resolved against the base path — useful when loading specs from non-filesystem sources (git objects, remote archives, etc.) where the base path follows a different convention than filesystem paths. When JoinFunc is nil (the default), behavior is unchanged. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3de8209 commit 31a58b9

3 files changed

Lines changed: 185 additions & 35 deletions

File tree

openapi3/loader.go

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ type Loader struct {
4141
// ReadFromURIFunc allows overriding the any file/URL reading func
4242
ReadFromURIFunc ReadFromURIFunc
4343

44+
// JoinFunc allows overriding how relative $ref paths are resolved against
45+
// a base path. When set, it is called instead of the default join logic
46+
// that uses path.Dir and path.Join. This is useful when loading specs from
47+
// non-filesystem sources (e.g. git objects, remote archives) where the base
48+
// path follows a different convention than filesystem paths.
49+
JoinFunc func(basePath *url.URL, relativePath *url.URL) *url.URL
50+
4451
Context context.Context
4552

4653
rootDir string
@@ -107,7 +114,7 @@ func (loader *Loader) loadSingleElementFromURI(ref string, rootPath *url.URL, el
107114
}
108115
}
109116

110-
resolvedPath, err := resolvePathWithRef(ref, rootPath)
117+
resolvedPath, err := loader.resolvePathWithRef(ref, rootPath)
111118
if err != nil {
112119
return nil, err
113120
}
@@ -273,42 +280,36 @@ func (loader *Loader) ResolveRefsIn(doc *T, location *url.URL) (err error) {
273280
return
274281
}
275282

276-
func join(basePath *url.URL, relativePath *url.URL) *url.URL {
283+
func defaultJoin(basePath *url.URL, relativePath *url.URL) *url.URL {
277284
if basePath == nil {
278285
return relativePath
279286
}
280287
newPath := *basePath
281-
// Handle git ref paths like "origin/main:openapi.yaml" where ":"
282-
// separates the ref from the file path. path.Dir does not understand
283-
// this syntax and would treat the colon as a regular character.
284-
if i := strings.IndexByte(newPath.Path, ':'); i >= 0 {
285-
prefix := newPath.Path[:i+1] // e.g. "origin/main:"
286-
filePath := newPath.Path[i+1:]
287-
newPath.Path = prefix + path.Join(path.Dir(filePath), relativePath.Path)
288-
} else {
289-
newPath.Path = path.Join(path.Dir(newPath.Path), relativePath.Path)
290-
}
288+
newPath.Path = path.Join(path.Dir(newPath.Path), relativePath.Path)
291289
return &newPath
292290
}
293291

294-
func resolvePath(basePath *url.URL, componentPath *url.URL) *url.URL {
292+
func (loader *Loader) resolvePath(basePath *url.URL, componentPath *url.URL) *url.URL {
295293
if is_file(componentPath) {
296294
// support absolute paths
297295
if filepath.IsAbs(componentPath.Path) {
298296
return componentPath
299297
}
300-
return join(basePath, componentPath)
298+
if loader.JoinFunc != nil {
299+
return loader.JoinFunc(basePath, componentPath)
300+
}
301+
return defaultJoin(basePath, componentPath)
301302
}
302303
return componentPath
303304
}
304305

305-
func resolvePathWithRef(ref string, rootPath *url.URL) (*url.URL, error) {
306+
func (loader *Loader) resolvePathWithRef(ref string, rootPath *url.URL) (*url.URL, error) {
306307
parsedURL, err := url.Parse(ref)
307308
if err != nil {
308309
return nil, fmt.Errorf("cannot parse reference: %q: %w", ref, err)
309310
}
310311

311-
resolvedPath := resolvePath(rootPath, parsedURL)
312+
resolvedPath := loader.resolvePath(rootPath, parsedURL)
312313
resolvedPath.Fragment = parsedURL.Fragment
313314
return resolvedPath, nil
314315
}
@@ -333,7 +334,7 @@ func (loader *Loader) resolveRefPath(ref string, path *url.URL) (*url.URL, error
333334
}
334335
}
335336

336-
resolvedPath, err := resolvePathWithRef(ref, path)
337+
resolvedPath, err := loader.resolvePathWithRef(ref, path)
337338
if err != nil {
338339
return nil, err
339340
}

openapi3/loader_example_test.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package openapi3_test
2+
3+
import (
4+
"fmt"
5+
"net/url"
6+
"os"
7+
"path"
8+
"path/filepath"
9+
"strings"
10+
11+
"github.com/getkin/kin-openapi/openapi3"
12+
)
13+
14+
// ExampleLoader_JoinFunc demonstrates how to use JoinFunc to load a multi-file
15+
// OpenAPI spec from a virtual path scheme (e.g. git refs like "rev:file.yaml").
16+
//
17+
// When loading specs from non-filesystem sources via LoadFromDataWithPath and
18+
// ReadFromURIFunc, the base path may use a custom prefix convention. The default
19+
// path resolution uses path.Dir which does not understand such prefixes. JoinFunc
20+
// lets the caller override path resolution to preserve the prefix.
21+
func ExampleLoader_JoinFunc() {
22+
// Set up test files in a temp directory.
23+
dir, _ := os.MkdirTemp("", "joinfunc-example")
24+
defer os.RemoveAll(dir)
25+
26+
root := `openapi: "3.0.0"
27+
info:
28+
title: Pet API
29+
version: "1.0"
30+
paths: {}
31+
components:
32+
schemas:
33+
Pet:
34+
$ref: "./schemas/pet.yaml"
35+
`
36+
pet := `type: object
37+
properties:
38+
name:
39+
type: string
40+
`
41+
os.MkdirAll(filepath.Join(dir, "schemas"), 0o755)
42+
os.WriteFile(filepath.Join(dir, "root.yaml"), []byte(root), 0o644)
43+
os.WriteFile(filepath.Join(dir, "schemas", "pet.yaml"), []byte(pet), 0o644)
44+
45+
// Use a "rev:" prefix to simulate a virtual path scheme.
46+
const prefix = "rev:"
47+
48+
loader := openapi3.NewLoader()
49+
loader.IsExternalRefsAllowed = true
50+
51+
// ReadFromURIFunc strips the prefix and reads from the real filesystem.
52+
loader.ReadFromURIFunc = func(loader *openapi3.Loader, location *url.URL) ([]byte, error) {
53+
p := location.Path
54+
if strings.HasPrefix(p, prefix) {
55+
p = p[len(prefix):]
56+
}
57+
return os.ReadFile(filepath.Join(dir, filepath.FromSlash(p)))
58+
}
59+
60+
// JoinFunc preserves the prefix when resolving relative $ref paths.
61+
// Without this, path.Dir("rev:root.yaml") returns "." and $ref resolution breaks.
62+
loader.JoinFunc = func(basePath *url.URL, relativePath *url.URL) *url.URL {
63+
if basePath == nil {
64+
return relativePath
65+
}
66+
result := *basePath
67+
base := basePath.Path
68+
if i := strings.IndexByte(base, ':'); i >= 0 {
69+
pfx := base[:i+1]
70+
filePart := base[i+1:]
71+
result.Path = pfx + path.Join(path.Dir(filePart), relativePath.Path)
72+
} else {
73+
result.Path = path.Join(path.Dir(base), relativePath.Path)
74+
}
75+
return &result
76+
}
77+
78+
rootContent, _ := os.ReadFile(filepath.Join(dir, "root.yaml"))
79+
doc, err := loader.LoadFromDataWithPath(rootContent, &url.URL{Path: prefix + "root.yaml"})
80+
if err != nil {
81+
fmt.Println("error:", err)
82+
return
83+
}
84+
85+
petSchema := doc.Components.Schemas["Pet"]
86+
nameType := petSchema.Value.Properties["name"].Value.Type.Slice()[0]
87+
fmt.Println("Pet.name type:", nameType)
88+
// Output: Pet.name type: string
89+
}

openapi3/loader_test.go

Lines changed: 78 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import (
77
"net/http"
88
"net/http/httptest"
99
"net/url"
10+
"os"
11+
"path"
12+
"path/filepath"
1013
"strings"
1114
"testing"
1215

@@ -673,36 +676,30 @@ func TestReadFromIoReader_Nil(t *testing.T) {
673676
require.EqualError(t, err, "invalid reader: <nil>")
674677
}
675678

676-
func TestJoinGitRefPath(t *testing.T) {
679+
func TestDefaultJoin(t *testing.T) {
677680
tests := []struct {
678681
name string
679682
base string
680683
rel string
681684
expected string
682685
}{
683686
{
684-
name: "git ref with relative path",
685-
base: "origin/main:openapi.yaml",
687+
name: "relative path",
688+
base: "/home/user/openapi.yaml",
686689
rel: "schemas/pet.yaml",
687-
expected: "origin/main:schemas/pet.yaml",
690+
expected: "/home/user/schemas/pet.yaml",
688691
},
689692
{
690-
name: "git ref with dot-slash relative path",
691-
base: "origin/main:openapi.yaml",
693+
name: "dot-slash relative path",
694+
base: "/home/user/openapi.yaml",
692695
rel: "./schemas/pet.yaml",
693-
expected: "origin/main:schemas/pet.yaml",
696+
expected: "/home/user/schemas/pet.yaml",
694697
},
695698
{
696-
name: "git ref with nested base path",
697-
base: "origin/main:api/v1/openapi.yaml",
699+
name: "parent directory",
700+
base: "/home/user/api/v1/openapi.yaml",
698701
rel: "../common/types.yaml",
699-
expected: "origin/main:api/common/types.yaml",
700-
},
701-
{
702-
name: "regular path without colon",
703-
base: "/home/user/openapi.yaml",
704-
rel: "schemas/pet.yaml",
705-
expected: "/home/user/schemas/pet.yaml",
702+
expected: "/home/user/api/common/types.yaml",
706703
},
707704
{
708705
name: "nil base returns relative",
@@ -716,13 +713,76 @@ func TestJoinGitRefPath(t *testing.T) {
716713
t.Run(tt.name, func(t *testing.T) {
717714
rel := &url.URL{Path: tt.rel}
718715
if tt.base == "" {
719-
result := join(nil, rel)
716+
result := defaultJoin(nil, rel)
720717
require.Equal(t, tt.expected, result.Path)
721718
return
722719
}
723720
base := &url.URL{Path: tt.base}
724-
result := join(base, rel)
721+
result := defaultJoin(base, rel)
725722
require.Equal(t, tt.expected, result.Path)
726723
})
727724
}
728725
}
726+
727+
func TestJoinFunc(t *testing.T) {
728+
// Create a multi-file spec in a temp directory
729+
dir := t.TempDir()
730+
731+
root := `openapi: "3.0.0"
732+
info:
733+
title: Test
734+
version: "1.0"
735+
paths: {}
736+
components:
737+
schemas:
738+
Pet:
739+
$ref: "./schemas/pet.yaml"
740+
`
741+
pet := `type: object
742+
properties:
743+
name:
744+
type: string
745+
`
746+
require.NoError(t, os.MkdirAll(filepath.Join(dir, "schemas"), 0o755))
747+
require.NoError(t, os.WriteFile(filepath.Join(dir, "root.yaml"), []byte(root), 0o644))
748+
require.NoError(t, os.WriteFile(filepath.Join(dir, "schemas", "pet.yaml"), []byte(pet), 0o644))
749+
750+
// Simulate a virtual prefix (like a git ref "rev:") by storing files
751+
// under their real paths but loading via a prefixed base path.
752+
// Without JoinFunc, path.Dir("myprefix:root.yaml") returns "." which
753+
// breaks resolution. With JoinFunc, we split on ":" and resolve correctly.
754+
prefix := "myprefix:"
755+
756+
loader := NewLoader()
757+
loader.IsExternalRefsAllowed = true
758+
loader.ReadFromURIFunc = func(loader *Loader, location *url.URL) ([]byte, error) {
759+
p := location.Path
760+
if strings.HasPrefix(p, prefix) {
761+
p = p[len(prefix):]
762+
}
763+
return os.ReadFile(filepath.Join(dir, filepath.FromSlash(p)))
764+
}
765+
loader.JoinFunc = func(basePath *url.URL, relativePath *url.URL) *url.URL {
766+
if basePath == nil {
767+
return relativePath
768+
}
769+
newPath := *basePath
770+
base := basePath.Path
771+
if i := strings.IndexByte(base, ':'); i >= 0 {
772+
pfx := base[:i+1]
773+
filePart := base[i+1:]
774+
newPath.Path = pfx + path.Join(path.Dir(filePart), relativePath.Path)
775+
} else {
776+
newPath.Path = path.Join(path.Dir(base), relativePath.Path)
777+
}
778+
return &newPath
779+
}
780+
781+
rootContent, err := os.ReadFile(filepath.Join(dir, "root.yaml"))
782+
require.NoError(t, err)
783+
784+
doc, err := loader.LoadFromDataWithPath(rootContent, &url.URL{Path: prefix + "root.yaml"})
785+
require.NoError(t, err)
786+
require.NotNil(t, doc.Components.Schemas["Pet"])
787+
require.Equal(t, "string", doc.Components.Schemas["Pet"].Value.Properties["name"].Value.Type.Slice()[0])
788+
}

0 commit comments

Comments
 (0)