Skip to content

Commit 84950ed

Browse files
committed
tpl/openapi: Add support for OpenAPI external file references
Fixes #8067
1 parent 2b337cd commit 84950ed

File tree

8 files changed

+404
-16
lines changed

8 files changed

+404
-16
lines changed

‎deps/deps.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,9 @@ type DepsCfg struct {
417417
// i18n handling.
418418
TranslationProvider ResourceProvider
419419

420+
// Build triggered by the IntegrationTest framework.
421+
IsIntegrationTest bool
422+
420423
// ChangesFromBuild for changes passed back to the server/watch process.
421424
ChangesFromBuild chan []identity.Identity
422425
}

‎hugolib/integrationtest_builder.go‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import (
3535
"github.com/gohugoio/hugo/hugofs"
3636
"github.com/gohugoio/hugo/hugofs/hglob"
3737
"github.com/gohugoio/hugo/hugolib/sitesmatrix"
38+
"github.com/gohugoio/hugo/identity"
3839
"github.com/spf13/afero"
3940
"github.com/spf13/cast"
4041
"golang.org/x/text/unicode/norm"
@@ -838,7 +839,11 @@ func (s *IntegrationTestBuilder) initBuilder() error {
838839

839840
s.Assert(err, qt.IsNil)
840841

841-
depsCfg := deps.DepsCfg{Configs: res, Fs: fs, LogLevel: logger.Level(), StdErr: logger.StdErr()}
842+
// changes received from Hugo in watch mode.
843+
// In the full setup, this channel is created in the commands package.
844+
changesFromBuild := make(chan []identity.Identity, 10)
845+
846+
depsCfg := deps.DepsCfg{Configs: res, Fs: fs, LogLevel: logger.Level(), StdErr: logger.StdErr(), ChangesFromBuild: changesFromBuild, IsIntegrationTest: true}
842847
sites, err := NewHugoSites(depsCfg)
843848
if err != nil {
844849
initErr = err
@@ -849,6 +854,20 @@ func (s *IntegrationTestBuilder) initBuilder() error {
849854
return
850855
}
851856

857+
go func() {
858+
for id := range changesFromBuild {
859+
whatChanged := &WhatChanged{}
860+
for _, v := range id {
861+
whatChanged.Add(v)
862+
}
863+
bcfg := s.Cfg.BuildCfg
864+
bcfg.WhatChanged = whatChanged
865+
if err := s.build(bcfg); err != nil {
866+
s.Fatalf("Build failed after change: %s", err)
867+
}
868+
}
869+
}()
870+
852871
s.H = sites
853872
s.fs = fs
854873

‎hugolib/rebuild_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -845,7 +845,7 @@ func TestRebuildEditSectionRemoveDate(t *testing.T) {
845845
func TestRebuildVariations(t *testing.T) {
846846
// t.Parallel() not supported, see https://github.com/fortytw2/leaktest/issues/4
847847
// This leaktest seems to be a little bit shaky on Travis.
848-
if !htesting.IsCI() {
848+
if !htesting.IsRealCI() {
849849
defer leaktest.CheckTimeout(t, 10*time.Second)()
850850
}
851851

‎hugolib/site.go‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,14 @@ func NewHugoSites(cfg deps.DepsCfg) (*HugoSites, error) {
265265
return nil, err
266266
}
267267

268+
// Prevent leaking goroutines in tests.
269+
if cfg.IsIntegrationTest && cfg.ChangesFromBuild != nil {
270+
firstSiteDeps.BuildClosers.Add(types.CloserFunc(func() error {
271+
close(cfg.ChangesFromBuild)
272+
return nil
273+
}))
274+
}
275+
268276
batcherClient, err := esbuild.NewBatcherClient(firstSiteDeps)
269277
if err != nil {
270278
return nil, err

‎resources/resource.go‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525

2626
"github.com/gohugoio/hugo/identity"
2727
"github.com/gohugoio/hugo/resources/internal"
28+
"github.com/spf13/cast"
2829

2930
"github.com/gohugoio/hugo/common/hashing"
3031
"github.com/gohugoio/hugo/common/herrors"
@@ -703,6 +704,18 @@ func InternalResourceSourcePath(r resource.Resource) string {
703704
return ""
704705
}
705706

707+
// InternalResourceSourceContent is used internally to get the source content for a Resource.
708+
func InternalResourceSourceContent(ctx context.Context, r resource.Resource) (string, error) {
709+
if cp, ok := r.(resource.ContentProvider); ok {
710+
c, err := cp.Content(ctx)
711+
if err != nil {
712+
return "", err
713+
}
714+
return cast.ToStringE(c)
715+
}
716+
return "", nil
717+
}
718+
706719
// InternalResourceSourcePathBestEffort is used internally to get the source path for a Resource.
707720
// Used for error messages etc.
708721
// It will fall back to the target path if the source path is not available.

‎tpl/openapi/openapi3/init.go‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818

1919
"github.com/gohugoio/hugo/deps"
2020
"github.com/gohugoio/hugo/tpl/internal"
21+
resourcestpl "github.com/gohugoio/hugo/tpl/resources"
2122
)
2223

2324
const name = "openapi3"
@@ -29,6 +30,17 @@ func init() {
2930
ns := &internal.TemplateFuncsNamespace{
3031
Name: name,
3132
Context: func(cctx context.Context, args ...any) (any, error) { return ctx, nil },
33+
OnCreated: func(m map[string]any) {
34+
for _, v := range m {
35+
switch v := v.(type) {
36+
case *resourcestpl.Namespace:
37+
ctx.resourcesNs = v
38+
}
39+
}
40+
if ctx.resourcesNs == nil {
41+
panic("resources namespace not found")
42+
}
43+
},
3244
}
3345

3446
ns.AddMethodMapping(ctx.Unmarshal,

‎tpl/openapi/openapi3/openapi3.go‎

Lines changed: 109 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,26 @@
1515
package openapi3
1616

1717
import (
18+
"context"
1819
"errors"
1920
"fmt"
2021
"io"
22+
"net/url"
23+
"path"
24+
"path/filepath"
25+
"strings"
2126

2227
kopenapi3 "github.com/getkin/kin-openapi/openapi3"
2328
"github.com/gohugoio/hugo/cache/dynacache"
29+
"github.com/gohugoio/hugo/common/hashing"
30+
"github.com/gohugoio/hugo/common/maps"
2431
"github.com/gohugoio/hugo/deps"
2532
"github.com/gohugoio/hugo/identity"
2633
"github.com/gohugoio/hugo/parser/metadecoders"
34+
"github.com/gohugoio/hugo/resources"
2735
"github.com/gohugoio/hugo/resources/resource"
36+
resourcestpl "github.com/gohugoio/hugo/tpl/resources"
37+
"github.com/mitchellh/mapstructure"
2838
)
2939

3040
// New returns a new instance of the openapi3-namespaced template functions.
@@ -37,8 +47,9 @@ func New(deps *deps.Deps) *Namespace {
3747

3848
// Namespace provides template functions for the "openapi3".
3949
type Namespace struct {
40-
cache *dynacache.Partition[string, *OpenAPIDocument]
41-
deps *deps.Deps
50+
cache *dynacache.Partition[string, *OpenAPIDocument]
51+
deps *deps.Deps
52+
resourcesNs *resourcestpl.Namespace
4253
}
4354

4455
// OpenAPIDocument represents an OpenAPI 3 document.
@@ -51,13 +62,35 @@ func (o *OpenAPIDocument) GetIdentityGroup() identity.Identity {
5162
return o.identityGroup
5263
}
5364

65+
type unmarshalOptions struct {
66+
// Options passed to resources.GetRemote when resolving remote $ref.
67+
GetRemote map[string]any
68+
}
69+
5470
// Unmarshal unmarshals the given resource into an OpenAPI 3 document.
55-
func (ns *Namespace) Unmarshal(r resource.UnmarshableResource) (*OpenAPIDocument, error) {
71+
func (ns *Namespace) Unmarshal(ctx context.Context, args ...any) (*OpenAPIDocument, error) {
72+
if len(args) < 1 || len(args) > 2 {
73+
return nil, errors.New("must provide a Resource and optionally an options map")
74+
}
75+
76+
r := args[0].(resource.UnmarshableResource)
5677
key := r.Key()
5778
if key == "" {
5879
return nil, errors.New("no Key set in Resource")
5980
}
6081

82+
var opts unmarshalOptions
83+
if len(args) > 1 {
84+
optsm, err := maps.ToStringMapE(args[1])
85+
if err != nil {
86+
return nil, err
87+
}
88+
if err := mapstructure.WeakDecode(optsm, &opts); err != nil {
89+
return nil, err
90+
}
91+
key += "_" + hashing.HashString(optsm)
92+
}
93+
6194
v, err := ns.cache.GetOrCreate(key, func(string) (*OpenAPIDocument, error) {
6295
f := metadecoders.FormatFromStrings(r.MediaType().Suffixes()...)
6396
if f == "" {
@@ -86,13 +119,84 @@ func (ns *Namespace) Unmarshal(r resource.UnmarshableResource) (*OpenAPIDocument
86119
return nil, err
87120
}
88121

89-
err = kopenapi3.NewLoader().ResolveRefsIn(s, nil)
122+
var resourcePath string
123+
if res, ok := r.(resource.Resource); ok {
124+
resourcePath = resources.InternalResourceSourcePath(res)
125+
}
126+
var relDir string
127+
if resourcePath != "" {
128+
if rel, ok := ns.deps.Assets.MakePathRelative(filepath.FromSlash(resourcePath), true); ok {
129+
relDir = filepath.Dir(rel)
130+
}
131+
}
132+
133+
var idm identity.Manager = identity.NopManager
134+
if v := identity.GetDependencyManager(r); v != nil {
135+
idm = v
136+
}
137+
idg := identity.FirstIdentity(r)
138+
139+
resolver := &refResolver{
140+
ctx: ctx,
141+
idm: idm,
142+
opts: opts,
143+
relBase: filepath.ToSlash(relDir),
144+
ns: ns,
145+
}
90146

91-
return &OpenAPIDocument{T: s, identityGroup: identity.FirstIdentity(r)}, err
147+
loader := kopenapi3.NewLoader()
148+
loader.IsExternalRefsAllowed = true
149+
loader.ReadFromURIFunc = resolver.resolveExternalRef
150+
err = loader.ResolveRefsIn(s, nil)
151+
152+
return &OpenAPIDocument{T: s, identityGroup: idg}, err
92153
})
93154
if err != nil {
94155
return nil, err
95156
}
96157

97158
return v, nil
98159
}
160+
161+
type refResolver struct {
162+
ctx context.Context
163+
idm identity.Manager
164+
opts unmarshalOptions
165+
relBase string
166+
ns *Namespace
167+
}
168+
169+
// resolveExternalRef resolves external references in OpenAPI documents by either fetching
170+
// remote URLs or loading local files from the assets directory, depending on the reference location.
171+
func (r *refResolver) resolveExternalRef(loader *kopenapi3.Loader, loc *url.URL) ([]byte, error) {
172+
if loc.Scheme != "" && loc.Host != "" {
173+
res, err := r.ns.resourcesNs.GetRemote(loc.String(), r.opts.GetRemote)
174+
if err != nil {
175+
return nil, fmt.Errorf("failed to get remote ref %q: %w", loc.String(), err)
176+
}
177+
content, err := resources.InternalResourceSourceContent(r.ctx, res)
178+
if err != nil {
179+
return nil, fmt.Errorf("failed to read remote ref %q: %w", loc.String(), err)
180+
}
181+
r.idm.AddIdentity(identity.FirstIdentity(res))
182+
return []byte(content), nil
183+
}
184+
185+
var filename string
186+
if strings.HasPrefix(loc.Path, "/") {
187+
filename = loc.Path
188+
} else {
189+
filename = path.Join(r.relBase, loc.Path)
190+
}
191+
192+
res := r.ns.resourcesNs.Get(filename)
193+
if res == nil {
194+
return nil, fmt.Errorf("local ref %q not found", loc.String())
195+
}
196+
content, err := resources.InternalResourceSourceContent(r.ctx, res)
197+
if err != nil {
198+
return nil, fmt.Errorf("failed to read local ref %q: %w", loc.String(), err)
199+
}
200+
r.idm.AddIdentity(identity.FirstIdentity(res))
201+
return []byte(content), nil
202+
}

0 commit comments

Comments
 (0)