]> git.maquefel.me Git - brevno-suite/hugo/commitdiff
tpl/openapi: Add support for OpenAPI external file references
authorBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Thu, 20 Nov 2025 10:01:23 +0000 (11:01 +0100)
committerBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Fri, 21 Nov 2025 15:03:05 +0000 (16:03 +0100)
Fixes #8067

deps/deps.go
hugolib/integrationtest_builder.go
hugolib/rebuild_test.go
hugolib/site.go
resources/resource.go
tpl/openapi/openapi3/init.go
tpl/openapi/openapi3/openapi3.go
tpl/openapi/openapi3/openapi3_integration_test.go

index acafb850e7471a990684949d59b1a77b148babc6..0d02bbda291d5aea8dd7d3113ad0a67cbd2bdf51 100644 (file)
@@ -417,6 +417,9 @@ type DepsCfg struct {
        // i18n handling.
        TranslationProvider ResourceProvider
 
+       // Build triggered by the IntegrationTest framework.
+       IsIntegrationTest bool
+
        // ChangesFromBuild for changes passed back to the server/watch process.
        ChangesFromBuild chan []identity.Identity
 }
index c9d7beea977e568df9eecfec4d281d64b321a6a7..163f0366548cec00e7b0957d91ec388a23c80207 100644 (file)
@@ -35,6 +35,7 @@ import (
        "github.com/gohugoio/hugo/hugofs"
        "github.com/gohugoio/hugo/hugofs/hglob"
        "github.com/gohugoio/hugo/hugolib/sitesmatrix"
+       "github.com/gohugoio/hugo/identity"
        "github.com/spf13/afero"
        "github.com/spf13/cast"
        "golang.org/x/text/unicode/norm"
@@ -838,7 +839,11 @@ func (s *IntegrationTestBuilder) initBuilder() error {
 
                s.Assert(err, qt.IsNil)
 
-               depsCfg := deps.DepsCfg{Configs: res, Fs: fs, LogLevel: logger.Level(), StdErr: logger.StdErr()}
+               // changes received from Hugo in watch mode.
+               // In the full setup, this channel is created in the commands package.
+               changesFromBuild := make(chan []identity.Identity, 10)
+
+               depsCfg := deps.DepsCfg{Configs: res, Fs: fs, LogLevel: logger.Level(), StdErr: logger.StdErr(), ChangesFromBuild: changesFromBuild, IsIntegrationTest: true}
                sites, err := NewHugoSites(depsCfg)
                if err != nil {
                        initErr = err
@@ -849,6 +854,20 @@ func (s *IntegrationTestBuilder) initBuilder() error {
                        return
                }
 
+               go func() {
+                       for id := range changesFromBuild {
+                               whatChanged := &WhatChanged{}
+                               for _, v := range id {
+                                       whatChanged.Add(v)
+                               }
+                               bcfg := s.Cfg.BuildCfg
+                               bcfg.WhatChanged = whatChanged
+                               if err := s.build(bcfg); err != nil {
+                                       s.Fatalf("Build failed after change: %s", err)
+                               }
+                       }
+               }()
+
                s.H = sites
                s.fs = fs
 
index afb2d7909c0e8f75db9590e990cbf30b70571ecd..c4d7f754fedd0dee586bb5500b7efddbfeb0687b 100644 (file)
@@ -845,7 +845,7 @@ func TestRebuildEditSectionRemoveDate(t *testing.T) {
 func TestRebuildVariations(t *testing.T) {
        // t.Parallel() not supported, see https://github.com/fortytw2/leaktest/issues/4
        // This leaktest seems to be a little bit shaky on Travis.
-       if !htesting.IsCI() {
+       if !htesting.IsRealCI() {
                defer leaktest.CheckTimeout(t, 10*time.Second)()
        }
 
index edcf2548a93081050080694a3ad2ab2547fe2fb2..a01afe6de94ba4d0e5aa0af89837b45a1e2f47a7 100644 (file)
@@ -265,6 +265,14 @@ func NewHugoSites(cfg deps.DepsCfg) (*HugoSites, error) {
                return nil, err
        }
 
+       // Prevent leaking goroutines in tests.
+       if cfg.IsIntegrationTest && cfg.ChangesFromBuild != nil {
+               firstSiteDeps.BuildClosers.Add(types.CloserFunc(func() error {
+                       close(cfg.ChangesFromBuild)
+                       return nil
+               }))
+       }
+
        batcherClient, err := esbuild.NewBatcherClient(firstSiteDeps)
        if err != nil {
                return nil, err
index 0e7b36f853580821f3b0eebb582ade7a1cfde76b..9886f2db4c0c8c9bdf8dcf52fe596297a702b039 100644 (file)
@@ -25,6 +25,7 @@ import (
 
        "github.com/gohugoio/hugo/identity"
        "github.com/gohugoio/hugo/resources/internal"
+       "github.com/spf13/cast"
 
        "github.com/gohugoio/hugo/common/hashing"
        "github.com/gohugoio/hugo/common/herrors"
@@ -703,6 +704,18 @@ func InternalResourceSourcePath(r resource.Resource) string {
        return ""
 }
 
+// InternalResourceSourceContent is used internally to get the source content for a Resource.
+func InternalResourceSourceContent(ctx context.Context, r resource.Resource) (string, error) {
+       if cp, ok := r.(resource.ContentProvider); ok {
+               c, err := cp.Content(ctx)
+               if err != nil {
+                       return "", err
+               }
+               return cast.ToStringE(c)
+       }
+       return "", nil
+}
+
 // InternalResourceSourcePathBestEffort is used internally to get the source path for a Resource.
 // Used for error messages etc.
 // It will fall back to the target path if the source path is not available.
index 4d88e148e036e5a24ab12d8bfff68f05bd7bb88f..7afcf66704202da830831b3091c7ac9683765a52 100644 (file)
@@ -18,6 +18,7 @@ import (
 
        "github.com/gohugoio/hugo/deps"
        "github.com/gohugoio/hugo/tpl/internal"
+       resourcestpl "github.com/gohugoio/hugo/tpl/resources"
 )
 
 const name = "openapi3"
@@ -29,6 +30,17 @@ func init() {
                ns := &internal.TemplateFuncsNamespace{
                        Name:    name,
                        Context: func(cctx context.Context, args ...any) (any, error) { return ctx, nil },
+                       OnCreated: func(m map[string]any) {
+                               for _, v := range m {
+                                       switch v := v.(type) {
+                                       case *resourcestpl.Namespace:
+                                               ctx.resourcesNs = v
+                                       }
+                               }
+                               if ctx.resourcesNs == nil {
+                                       panic("resources namespace not found")
+                               }
+                       },
                }
 
                ns.AddMethodMapping(ctx.Unmarshal,
index 9bdc93eeb4cefe7c49cd99edd770a72f35a56175..4cadae52399aab06d068be97b9e11a0e417507e3 100644 (file)
 package openapi3
 
 import (
+       "context"
        "errors"
        "fmt"
        "io"
+       "net/url"
+       "path"
+       "path/filepath"
+       "strings"
 
        kopenapi3 "github.com/getkin/kin-openapi/openapi3"
        "github.com/gohugoio/hugo/cache/dynacache"
+       "github.com/gohugoio/hugo/common/hashing"
+       "github.com/gohugoio/hugo/common/maps"
        "github.com/gohugoio/hugo/deps"
        "github.com/gohugoio/hugo/identity"
        "github.com/gohugoio/hugo/parser/metadecoders"
+       "github.com/gohugoio/hugo/resources"
        "github.com/gohugoio/hugo/resources/resource"
+       resourcestpl "github.com/gohugoio/hugo/tpl/resources"
+       "github.com/mitchellh/mapstructure"
 )
 
 // New returns a new instance of the openapi3-namespaced template functions.
@@ -37,8 +47,9 @@ func New(deps *deps.Deps) *Namespace {
 
 // Namespace provides template functions for the "openapi3".
 type Namespace struct {
-       cache *dynacache.Partition[string, *OpenAPIDocument]
-       deps  *deps.Deps
+       cache       *dynacache.Partition[string, *OpenAPIDocument]
+       deps        *deps.Deps
+       resourcesNs *resourcestpl.Namespace
 }
 
 // OpenAPIDocument represents an OpenAPI 3 document.
@@ -51,13 +62,35 @@ func (o *OpenAPIDocument) GetIdentityGroup() identity.Identity {
        return o.identityGroup
 }
 
+type unmarshalOptions struct {
+       // Options passed to resources.GetRemote when resolving remote $ref.
+       GetRemote map[string]any
+}
+
 // Unmarshal unmarshals the given resource into an OpenAPI 3 document.
-func (ns *Namespace) Unmarshal(r resource.UnmarshableResource) (*OpenAPIDocument, error) {
+func (ns *Namespace) Unmarshal(ctx context.Context, args ...any) (*OpenAPIDocument, error) {
+       if len(args) < 1 || len(args) > 2 {
+               return nil, errors.New("must provide a Resource and optionally an options map")
+       }
+
+       r := args[0].(resource.UnmarshableResource)
        key := r.Key()
        if key == "" {
                return nil, errors.New("no Key set in Resource")
        }
 
+       var opts unmarshalOptions
+       if len(args) > 1 {
+               optsm, err := maps.ToStringMapE(args[1])
+               if err != nil {
+                       return nil, err
+               }
+               if err := mapstructure.WeakDecode(optsm, &opts); err != nil {
+                       return nil, err
+               }
+               key += "_" + hashing.HashString(optsm)
+       }
+
        v, err := ns.cache.GetOrCreate(key, func(string) (*OpenAPIDocument, error) {
                f := metadecoders.FormatFromStrings(r.MediaType().Suffixes()...)
                if f == "" {
@@ -86,9 +119,37 @@ func (ns *Namespace) Unmarshal(r resource.UnmarshableResource) (*OpenAPIDocument
                        return nil, err
                }
 
-               err = kopenapi3.NewLoader().ResolveRefsIn(s, nil)
+               var resourcePath string
+               if res, ok := r.(resource.Resource); ok {
+                       resourcePath = resources.InternalResourceSourcePath(res)
+               }
+               var relDir string
+               if resourcePath != "" {
+                       if rel, ok := ns.deps.Assets.MakePathRelative(filepath.FromSlash(resourcePath), true); ok {
+                               relDir = filepath.Dir(rel)
+                       }
+               }
+
+               var idm identity.Manager = identity.NopManager
+               if v := identity.GetDependencyManager(r); v != nil {
+                       idm = v
+               }
+               idg := identity.FirstIdentity(r)
+
+               resolver := &refResolver{
+                       ctx:     ctx,
+                       idm:     idm,
+                       opts:    opts,
+                       relBase: filepath.ToSlash(relDir),
+                       ns:      ns,
+               }
 
-               return &OpenAPIDocument{T: s, identityGroup: identity.FirstIdentity(r)}, err
+               loader := kopenapi3.NewLoader()
+               loader.IsExternalRefsAllowed = true
+               loader.ReadFromURIFunc = resolver.resolveExternalRef
+               err = loader.ResolveRefsIn(s, nil)
+
+               return &OpenAPIDocument{T: s, identityGroup: idg}, err
        })
        if err != nil {
                return nil, err
@@ -96,3 +157,46 @@ func (ns *Namespace) Unmarshal(r resource.UnmarshableResource) (*OpenAPIDocument
 
        return v, nil
 }
+
+type refResolver struct {
+       ctx     context.Context
+       idm     identity.Manager
+       opts    unmarshalOptions
+       relBase string
+       ns      *Namespace
+}
+
+// resolveExternalRef resolves external references in OpenAPI documents by either fetching
+// remote URLs or loading local files from the assets directory, depending on the reference location.
+func (r *refResolver) resolveExternalRef(loader *kopenapi3.Loader, loc *url.URL) ([]byte, error) {
+       if loc.Scheme != "" && loc.Host != "" {
+               res, err := r.ns.resourcesNs.GetRemote(loc.String(), r.opts.GetRemote)
+               if err != nil {
+                       return nil, fmt.Errorf("failed to get remote ref %q: %w", loc.String(), err)
+               }
+               content, err := resources.InternalResourceSourceContent(r.ctx, res)
+               if err != nil {
+                       return nil, fmt.Errorf("failed to read remote ref %q: %w", loc.String(), err)
+               }
+               r.idm.AddIdentity(identity.FirstIdentity(res))
+               return []byte(content), nil
+       }
+
+       var filename string
+       if strings.HasPrefix(loc.Path, "/") {
+               filename = loc.Path
+       } else {
+               filename = path.Join(r.relBase, loc.Path)
+       }
+
+       res := r.ns.resourcesNs.Get(filename)
+       if res == nil {
+               return nil, fmt.Errorf("local ref %q not found", loc.String())
+       }
+       content, err := resources.InternalResourceSourceContent(r.ctx, res)
+       if err != nil {
+               return nil, fmt.Errorf("failed to read local ref %q: %w", loc.String(), err)
+       }
+       r.idm.AddIdentity(identity.FirstIdentity(res))
+       return []byte(content), nil
+}
index 6914a60b3919e9f527a36b3621278e0b7c982ce1..9040f965b940bc9c8f2be3aeecc9132ec9fc1e1f 100644 (file)
 package openapi3_test
 
 import (
+       "fmt"
+       "net/http"
+       "net/http/httptest"
        "strings"
        "testing"
+       "time"
 
        "github.com/gohugoio/hugo/hugolib"
 )
@@ -49,20 +53,15 @@ paths:
                 type: array
                 items: 
                   type: string
--- config.toml --
+-- hugo.toml --
 baseURL = 'http://example.com/'
--- layouts/index.html --
+disableLiveReload = true
+-- layouts/home.html --
 {{ $api := resources.Get "api/myapi.yaml" | openapi3.Unmarshal }}
 API: {{ $api.Info.Title | safeHTML }}
   `
 
-       b := hugolib.NewIntegrationTestBuilder(
-               hugolib.IntegrationTestConfig{
-                       T:           t,
-                       Running:     true,
-                       TxtarString: files,
-               },
-       ).Build()
+       b := hugolib.TestRunning(t, files)
 
        b.AssertFileContent("public/index.html", `API: Sample API`)
 
@@ -72,3 +71,233 @@ API: {{ $api.Info.Title | safeHTML }}
 
        b.AssertFileContent("public/index.html", `API: Hugo API`)
 }
+
+// Test data borrowed/adapted from https://github.com/getkin/kin-openapi/tree/master/openapi3/testdata/refInLocalRef
+// The templates below would be simpler if kin-openapi's T would serialize to JSON better, see https://github.com/getkin/kin-openapi/issues/561
+const refLocalTemplate = `
+-- hugo.toml --
+baseURL = 'http://example.com/'
+disableKinds = ["page", "section", "taxonomy", "term", "sitemap", "robotsTXT", "404"]
+disableLiveReload = true
+[HTTPCache]
+[HTTPCache.cache]
+[HTTPCache.cache.for]
+includes = ['**']
+[[HTTPCache.polls]]
+high = '100ms'
+low = '50ms'
+[HTTPCache.polls.for]
+includes = ['**']
+-- assets/api/myapi.json --
+{
+  "openapi": "3.0.3",
+  "info": {
+    "title": "Reference in reference example",
+    "version": "1.0.0"
+  },
+  "paths": {
+    "/api/test/ref/in/ref": {
+      "post": {
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object",
+                "properties" : {
+                  "data": {
+                    "$ref": "#/components/schemas/Request"
+                  }
+                }
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "messages/response.json"
+                }
+              }
+            }
+          }
+        }
+      }
+    }
+  },
+  "components": {
+    "schemas": {
+      "Request": {
+        "$ref": "messages/request.json"
+      }
+    }
+  }
+}
+-- assets/api/messages/request.json --
+{
+  "type": "object",
+  "required": [
+    "definition_reference"
+  ],
+  "properties": {
+    "definition_reference": {
+      "$ref": "./data.json"
+    }
+  }
+}
+-- assets/api/messages/response.json --
+{
+  "type": "object",
+  "properties": {
+    "id": {
+      "type": "integer",
+      "format": "uint64"
+    }
+  }
+}
+-- assets/api/messages/data.json --
+{
+  "type": "object",
+  "properties": {
+    "id": {
+      "type": "integer",
+      "format": "int32"
+    },
+    "ref_prop_part": {
+      "$ref": "DATAPART_REF"
+    }
+  }
+}
+-- assets/api/messages/dataPart.json --
+{
+  "type": "object",
+  "properties": {
+    "idPart": {
+      "type": "integer",
+      "format": "int64"
+    }
+  }
+}
+-- layouts/home.html --
+{{ $getremote := dict
+  "method" "post"
+  "key" time.Now.UnixNano
+}}
+{{ $opts := dict
+  "getremote" $getremote
+}}
+{{ with resources.Get "api/myapi.json" }}
+  {{ with openapi3.Unmarshal . $opts }}
+    Title: {{ .Info.Title | safeHTML }}
+    {{ range $k, $v := .Paths.Map }}
+      {{ $post := index $v.Post }}
+      {{ with index $post.RequestBody.Value.Content  }}
+        {{ $mt := (index .   "application/json")}}
+        {{ $data := index $mt.Schema.Value.Properties "data" }}
+        RequestBody: {{ template "printschema" $mt.Schema.Value  }}$
+      {{ end }}
+      {{ $response := index (index $post.Responses.Map "200").Value.Content "application/json" }}
+      Response: {{ template "printschema" $response.Schema.Value }}
+    {{ end }}
+  {{ end }}
+{{ end }}
+{{ define "printschema" }}{{ with .Format}}Format: {{ . }}|{{ end }}{{ with .Properties }} Properties: {{ range $k, $v := . }}{{ $k}}: {{ template "printitems" . -}}{{ end }}{{ end -}}${{ end }}
+{{ define "printitems" }}{{ if eq (printf "%T" .) "*openapi3.SchemaRef" }}{{ template "printschema" .Value  }}{{ else }}{{ template "printitem" . -}}{{ end -}}{{ end }}
+{{ define "printitem" }}{{ printf "%T: %s" . (.|debug.Dump) | safeHTML }}{{ end }}
+
+`
+
+func TestUnmarshalRefLocal(t *testing.T) {
+       files := strings.ReplaceAll(refLocalTemplate, "DATAPART_REF", "./dataPart.json")
+
+       b := hugolib.Test(t, files)
+
+       b.AssertFileContent("public/index.html",
+               `Reference in reference example`,
+               "RequestBody:  Properties: data:  Properties: definition_reference:  Properties: id: Format: int32|$ref_prop_part:  Properties: idPart: Format: int64|$",
+               "Response:  Properties: id: Format: uint64|$",
+       )
+}
+
+func TestUnmarshalRefLocalEdit(t *testing.T) {
+       files := strings.ReplaceAll(refLocalTemplate, "DATAPART_REF", "./dataPart.json")
+
+       b := hugolib.TestRunning(t, files)
+
+       b.AssertFileContent("public/index.html",
+               "RequestBody:  Properties: data:  Properties: definition_reference:  Properties: id: Format: int32|$ref_prop_part:  Properties: idPart: Format: int64|$",
+       )
+
+       b.EditFileReplaceAll("assets/api/messages/dataPart.json", "int64", "int8").Build()
+
+       b.AssertFileContent("public/index.html",
+               "RequestBody:  Properties: data:  Properties: definition_reference:  Properties: id: Format: int32|$ref_prop_part:  Properties: idPart: Format: int8|$",
+       )
+}
+
+func TestUnmarshalRefRemote(t *testing.T) {
+       createFiles := func(t *testing.T) string {
+               counter := 0
+               ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+                       if r.Method == "POST" && r.URL.Path == "/api/messages/dataPart.json" {
+                               n := 8
+
+                               // This endpoint will also be called by the poller, so we distinguish
+                               // between the first and subsequent calls.
+                               if counter > 0 {
+                                       n = 16
+                               }
+                               counter++
+
+                               w.Header().Set("Content-Type", "application/json")
+                               response := fmt.Sprintf(`{
+  "type": "object",
+  "properties": {
+    "idPart": {
+      "type": "integer",
+      "format": "int%d"
+    }
+  }
+}`, n)
+                               w.Write([]byte(response))
+                               return
+                       }
+
+                       http.Error(w, "Not found", http.StatusNotFound)
+               }))
+
+               t.Cleanup(func() {
+                       ts.Close()
+               })
+
+               dataPartRef := ts.URL + "/api/messages/dataPart.json"
+               return strings.ReplaceAll(refLocalTemplate, "DATAPART_REF", dataPartRef)
+       }
+
+       t.Run("Build", func(t *testing.T) {
+               b := hugolib.Test(t, createFiles(t))
+
+               b.AssertFileContent("public/index.html",
+                       `Reference in reference example`,
+                       "RequestBody:  Properties: data:  Properties: definition_reference:  Properties: id: Format: int32|$ref_prop_part:  Properties: idPart: Format: int8|$",
+                       "Response:  Properties: id: Format: uint64|$",
+               )
+       })
+
+       t.Run("Rebuild", func(t *testing.T) {
+               b := hugolib.TestRunning(t, createFiles(t))
+
+               b.AssertFileContent("public/index.html",
+                       "idPart: Format: int8|$",
+               )
+
+               // Rebuild triggered by remote polling.
+               time.Sleep(800 * time.Millisecond)
+
+               b.AssertFileContent("public/index.html",
+                       "idPart: Format: int16|$",
+               )
+       })
+}