summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDave Henderson <dhenderson@gmail.com>2022-05-28 09:44:56 -0400
committerDave Henderson <dhenderson@gmail.com>2022-06-12 16:56:00 -0400
commit13b0d86d7630a89dc94b0a172b2d04ba8a236875 (patch)
tree5c5a39d70efdc30f8df19a71edab828244701e73
parent0158ecc5e479c514898f406db63454e3570c56c6 (diff)
New gomplate.Renderer interface
Signed-off-by: Dave Henderson <dhenderson@gmail.com>
-rw-r--r--data/datasource.go1
-rw-r--r--docs/content/usage.md5
-rw-r--r--gomplate.go114
-rw-r--r--gomplate_test.go78
-rw-r--r--render.go271
-rw-r--r--render_test.go155
-rw-r--r--template.go93
-rw-r--r--template_test.go39
-rw-r--r--template_unix_test.go17
-rw-r--r--template_windows_test.go17
10 files changed, 555 insertions, 235 deletions
diff --git a/data/datasource.go b/data/datasource.go
index adeb4a4b..a28a66f7 100644
--- a/data/datasource.go
+++ b/data/datasource.go
@@ -140,6 +140,7 @@ func FromConfig(ctx context.Context, cfg *config.Config) *Data {
}
// Source - a data source
+// Deprecated: will be replaced in future
type Source struct {
Alias string
URL *url.URL
diff --git a/docs/content/usage.md b/docs/content/usage.md
index 1a918791..cb61bc84 100644
--- a/docs/content/usage.md
+++ b/docs/content/usage.md
@@ -269,8 +269,9 @@ Hello World
All arguments provided to the function will be passed as positional arguments to
the plugin, and the plugin's standard output stream (`Stdout`) will be printed
-to the rendered output. Currently there is no way to set the plugin's standard
-input stream (`Stdin`).
+to the rendered output. To instead pipe the final argument of the function to
+the plugin's standard input stream, use the [config file](../config/#plugins)
+and set the `pipe` field.
If the plugin exits with a non-zero exit code, gomplate will also fail. All signals
caught by gomplate will be propagated to the plugin. Any output on the standard
diff --git a/gomplate.go b/gomplate.go
index a0d37b91..585c9acb 100644
--- a/gomplate.go
+++ b/gomplate.go
@@ -6,8 +6,6 @@ import (
"bytes"
"context"
"fmt"
- "io"
- "os"
"path/filepath"
"strings"
"text/template"
@@ -16,47 +14,11 @@ import (
"github.com/hairyhenderson/gomplate/v3/data"
"github.com/hairyhenderson/gomplate/v3/internal/config"
"github.com/pkg/errors"
- "github.com/rs/zerolog"
)
-// gomplate -
-type gomplate struct {
- tmplctx interface{}
- funcMap template.FuncMap
- nestedTemplates config.Templates
-
- leftDelim, rightDelim string
-}
-
-// runTemplate -
-func (g *gomplate) runTemplate(ctx context.Context, t *tplate) error {
- tmpl, err := t.toGoTemplate(ctx, g)
- if err != nil {
- return err
- }
-
- wr, ok := t.target.(io.Closer)
- if ok && wr != os.Stdout {
- defer wr.Close()
- }
-
- return tmpl.Execute(t.target, g.tmplctx)
-}
-
-// newGomplate -
-func newGomplate(funcMap template.FuncMap, leftDelim, rightDelim string, nested config.Templates, tctx interface{}) *gomplate {
- return &gomplate{
- leftDelim: leftDelim,
- rightDelim: rightDelim,
- funcMap: funcMap,
- nestedTemplates: nested,
- tmplctx: tctx,
- }
-}
-
// RunTemplates - run all gomplate templates specified by the given configuration
//
-// Deprecated: use Run instead
+// Deprecated: use the Renderer interface instead
func RunTemplates(o *Config) error {
cfg, err := o.toNewConfig()
if err != nil {
@@ -67,8 +29,6 @@ func RunTemplates(o *Config) error {
// Run all gomplate templates specified by the given configuration
func Run(ctx context.Context, cfg *config.Config) error {
- log := zerolog.Ctx(ctx)
-
Metrics = newMetrics()
defer runCleanupHooks()
@@ -80,59 +40,43 @@ func Run(ctx context.Context, cfg *config.Config) error {
return fmt.Errorf("failed to validate config: %w\n%+v", err, cfg)
}
- d := data.FromConfig(ctx, cfg)
- log.Debug().Str("data", fmt.Sprintf("%+v", d)).Msg("created data from config")
-
- addCleanupHook(d.Cleanup)
-
- aliases := []string{}
- for k := range cfg.Context {
- aliases = append(aliases, k)
- }
- c, err := createTmplContext(ctx, aliases, d)
- if err != nil {
- return err
- }
-
- funcMap := CreateFuncs(ctx, d)
+ funcMap := template.FuncMap{}
err = bindPlugins(ctx, cfg, funcMap)
if err != nil {
return err
}
- g := newGomplate(funcMap, cfg.LDelim, cfg.RDelim, cfg.Templates, c)
- return g.runTemplates(ctx, cfg)
-}
+ // if a custom Stdin is set in the config, inject it into the context now
+ ctx = data.ContextWithStdin(ctx, cfg.Stdin)
+
+ opts := optionsFromConfig(cfg)
+ opts.Funcs = funcMap
+ tr := NewRenderer(opts)
-func (g *gomplate) runTemplates(ctx context.Context, cfg *config.Config) error {
start := time.Now()
- tmpl, err := gatherTemplates(ctx, cfg, chooseNamer(cfg, g))
+
+ namer := chooseNamer(cfg, tr)
+ tmpl, err := gatherTemplates(ctx, cfg, namer)
Metrics.GatherDuration = time.Since(start)
if err != nil {
Metrics.Errors++
return fmt.Errorf("failed to gather templates for rendering: %w", err)
}
Metrics.TemplatesGathered = len(tmpl)
- start = time.Now()
- defer func() { Metrics.TotalRenderDuration = time.Since(start) }()
- for _, t := range tmpl {
- tstart := time.Now()
- err := g.runTemplate(ctx, t)
- Metrics.RenderDuration[t.name] = time.Since(tstart)
- if err != nil {
- Metrics.Errors++
- return fmt.Errorf("failed to render template %s: %w", t.name, err)
- }
- Metrics.TemplatesProcessed++
+
+ err = tr.RenderTemplates(ctx, tmpl)
+ if err != nil {
+ return err
}
+
return nil
}
-func chooseNamer(cfg *config.Config, g *gomplate) func(context.Context, string) (string, error) {
+func chooseNamer(cfg *config.Config, tr *Renderer) func(context.Context, string) (string, error) {
if cfg.OutputMap == "" {
return simpleNamer(cfg.OutputDir)
}
- return mappingNamer(cfg.OutputMap, g)
+ return mappingNamer(cfg.OutputMap, tr)
}
func simpleNamer(outDir string) func(ctx context.Context, inPath string) (string, error) {
@@ -142,21 +86,19 @@ func simpleNamer(outDir string) func(ctx context.Context, inPath string) (string
}
}
-func mappingNamer(outMap string, g *gomplate) func(context.Context, string) (string, error) {
+func mappingNamer(outMap string, tr *Renderer) func(context.Context, string) (string, error) {
return func(ctx context.Context, inPath string) (string, error) {
- out := &bytes.Buffer{}
- t := &tplate{
- name: "<OutputMap>",
- contents: outMap,
- target: out,
- }
- tpl, err := t.toGoTemplate(ctx, g)
+ tr.data.Ctx = ctx
+ tcontext, err := createTmplContext(ctx, tr.tctxAliases, tr.data)
if err != nil {
return "", err
}
+
+ // add '.in' to the template context and preserve the original context
+ // in '.ctx'
tctx := &tmplctx{}
// nolint: gocritic
- switch c := g.tmplctx.(type) {
+ switch c := tcontext.(type) {
case *tmplctx:
for k, v := range *c {
if k != "in" && k != "ctx" {
@@ -164,10 +106,12 @@ func mappingNamer(outMap string, g *gomplate) func(context.Context, string) (str
}
}
}
- (*tctx)["ctx"] = g.tmplctx
+ (*tctx)["ctx"] = tcontext
(*tctx)["in"] = inPath
- err = tpl.Execute(t.target, tctx)
+ out := &bytes.Buffer{}
+ err = tr.renderTemplatesWithData(ctx,
+ []Template{{Name: "<OutputMap>", Text: outMap, Writer: out}}, tctx)
if err != nil {
return "", errors.Wrapf(err, "failed to render outputMap with ctx %+v and inPath %s", tctx, inPath)
}
diff --git a/gomplate_test.go b/gomplate_test.go
index 28adbf8d..19a5a865 100644
--- a/gomplate_test.go
+++ b/gomplate_test.go
@@ -17,34 +17,34 @@ import (
"github.com/stretchr/testify/assert"
)
-func testTemplate(t *testing.T, g *gomplate, tmpl string) string {
+func testTemplate(t *testing.T, tr *Renderer, tmpl string) string {
t.Helper()
var out bytes.Buffer
- err := g.runTemplate(context.Background(), &tplate{name: "testtemplate", contents: tmpl, target: &out})
+ err := tr.Render(context.Background(), "testtemplate", tmpl, &out)
assert.NoError(t, err)
return out.String()
}
func TestGetenvTemplates(t *testing.T) {
- g := &gomplate{
- funcMap: template.FuncMap{
+ tr := NewRenderer(Options{
+ Funcs: template.FuncMap{
"getenv": env.Getenv,
"bool": conv.Bool,
},
- }
- assert.Empty(t, testTemplate(t, g, `{{getenv "BLAHBLAHBLAH"}}`))
- assert.Equal(t, os.Getenv("USER"), testTemplate(t, g, `{{getenv "USER"}}`))
- assert.Equal(t, "default value", testTemplate(t, g, `{{getenv "BLAHBLAHBLAH" "default value"}}`))
+ })
+ assert.Empty(t, testTemplate(t, tr, `{{getenv "BLAHBLAHBLAH"}}`))
+ assert.Equal(t, os.Getenv("USER"), testTemplate(t, tr, `{{getenv "USER"}}`))
+ assert.Equal(t, "default value", testTemplate(t, tr, `{{getenv "BLAHBLAHBLAH" "default value"}}`))
}
func TestBoolTemplates(t *testing.T) {
- g := &gomplate{
- funcMap: template.FuncMap{
+ g := NewRenderer(Options{
+ Funcs: template.FuncMap{
"bool": conv.Bool,
},
- }
+ })
assert.Equal(t, "true", testTemplate(t, g, `{{bool "true"}}`))
assert.Equal(t, "false", testTemplate(t, g, `{{bool "false"}}`))
assert.Equal(t, "false", testTemplate(t, g, `{{bool "foo"}}`))
@@ -52,9 +52,9 @@ func TestBoolTemplates(t *testing.T) {
}
func TestEc2MetaTemplates(t *testing.T) {
- createGomplate := func(data map[string]string, region string) *gomplate {
+ createGomplate := func(data map[string]string, region string) *Renderer {
ec2meta := aws.MockEC2Meta(data, nil, region)
- return &gomplate{funcMap: template.FuncMap{"ec2meta": ec2meta.Meta}}
+ return NewRenderer(Options{Funcs: template.FuncMap{"ec2meta": ec2meta.Meta}})
}
g := createGomplate(nil, "")
@@ -69,36 +69,36 @@ func TestEc2MetaTemplates(t *testing.T) {
func TestEc2MetaTemplates_WithJSON(t *testing.T) {
ec2meta := aws.MockEC2Meta(map[string]string{"obj": `"foo": "bar"`}, map[string]string{"obj": `"foo": "baz"`}, "")
- g := &gomplate{
- funcMap: template.FuncMap{
+ g := NewRenderer(Options{
+ Funcs: template.FuncMap{
"ec2meta": ec2meta.Meta,
"ec2dynamic": ec2meta.Dynamic,
"json": data.JSON,
},
- }
+ })
assert.Equal(t, "bar", testTemplate(t, g, `{{ (ec2meta "obj" | json).foo }}`))
assert.Equal(t, "baz", testTemplate(t, g, `{{ (ec2dynamic "obj" | json).foo }}`))
}
func TestJSONArrayTemplates(t *testing.T) {
- g := &gomplate{
- funcMap: template.FuncMap{
+ g := NewRenderer(Options{
+ Funcs: template.FuncMap{
"jsonArray": data.JSONArray,
},
- }
+ })
assert.Equal(t, "[foo bar]", testTemplate(t, g, `{{jsonArray "[\"foo\",\"bar\"]"}}`))
assert.Equal(t, "bar", testTemplate(t, g, `{{ index (jsonArray "[\"foo\",\"bar\"]") 1 }}`))
}
func TestYAMLTemplates(t *testing.T) {
- g := &gomplate{
- funcMap: template.FuncMap{
+ g := NewRenderer(Options{
+ Funcs: template.FuncMap{
"yaml": data.YAML,
"yamlArray": data.YAMLArray,
},
- }
+ })
assert.Equal(t, "bar", testTemplate(t, g, `{{(yaml "foo: bar").foo}}`))
assert.Equal(t, "[foo bar]", testTemplate(t, g, `{{yamlArray "- foo\n- bar\n"}}`))
@@ -106,23 +106,23 @@ func TestYAMLTemplates(t *testing.T) {
}
func TestSliceTemplates(t *testing.T) {
- g := &gomplate{
- funcMap: template.FuncMap{
+ g := NewRenderer(Options{
+ Funcs: template.FuncMap{
"slice": conv.Slice,
},
- }
+ })
assert.Equal(t, "foo", testTemplate(t, g, `{{index (slice "foo") 0}}`))
assert.Equal(t, `[foo bar 42]`, testTemplate(t, g, `{{slice "foo" "bar" 42}}`))
assert.Equal(t, `helloworld`, testTemplate(t, g, `{{range slice "hello" "world"}}{{.}}{{end}}`))
}
func TestHasTemplate(t *testing.T) {
- g := &gomplate{
- funcMap: template.FuncMap{
+ g := NewRenderer(Options{
+ Funcs: template.FuncMap{
"yaml": data.YAML,
"has": conv.Has,
},
- }
+ })
assert.Equal(t, "true", testTemplate(t, g, `{{has ("foo:\n bar: true" | yaml) "foo"}}`))
assert.Equal(t, "true", testTemplate(t, g, `{{has ("foo:\n bar: true" | yaml).foo "bar"}}`))
assert.Equal(t, "false", testTemplate(t, g, `{{has ("foo: true" | yaml) "bah"}}`))
@@ -141,11 +141,10 @@ func TestHasTemplate(t *testing.T) {
}
func TestCustomDelim(t *testing.T) {
- g := &gomplate{
- leftDelim: "[",
- rightDelim: "]",
- funcMap: template.FuncMap{},
- }
+ g := NewRenderer(Options{
+ LDelim: "[",
+ RDelim: "]",
+ })
assert.Equal(t, "hi", testTemplate(t, g, `[print "hi"]`))
}
@@ -170,16 +169,19 @@ func TestSimpleNamer(t *testing.T) {
func TestMappingNamer(t *testing.T) {
ctx := context.Background()
- g := &gomplate{funcMap: map[string]interface{}{
- "foo": func() string { return "foo" },
- }}
- n := mappingNamer("out/{{ .in }}", g)
+ tr := &Renderer{
+ data: &data.Data{},
+ funcs: map[string]interface{}{
+ "foo": func() string { return "foo" },
+ },
+ }
+ n := mappingNamer("out/{{ .in }}", tr)
out, err := n(ctx, "file")
assert.NoError(t, err)
expected := filepath.FromSlash("out/file")
assert.Equal(t, expected, out)
- n = mappingNamer("out/{{ foo }}{{ .in }}", g)
+ n = mappingNamer("out/{{ foo }}{{ .in }}", tr)
out, err = n(ctx, "file")
assert.NoError(t, err)
expected = filepath.FromSlash("out/foofile")
diff --git a/render.go b/render.go
new file mode 100644
index 00000000..fc6fe41d
--- /dev/null
+++ b/render.go
@@ -0,0 +1,271 @@
+package gomplate
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "text/template"
+ "time"
+
+ "github.com/hairyhenderson/gomplate/v3/data"
+ "github.com/hairyhenderson/gomplate/v3/funcs" //nolint:staticcheck
+ "github.com/hairyhenderson/gomplate/v3/internal/config"
+)
+
+// Options for template rendering.
+//
+// Experimental: subject to breaking changes before the next major release
+type Options struct {
+ // Datasources - map of datasources to be read on demand when the
+ // 'datasource'/'ds'/'include' functions are used.
+ Datasources map[string]Datasource
+ // Context - map of datasources to be read immediately and added to the
+ // template's context
+ Context map[string]Datasource
+ // Templates - map of templates that can be referenced as nested templates
+ Templates map[string]Datasource
+
+ // Extra HTTP headers not attached to pre-defined datsources. Potentially
+ // used by datasources defined in the template.
+ ExtraHeaders map[string]http.Header
+
+ // Funcs - map of functions to be added to the default template functions.
+ // Duplicate functions will be overwritten by entries in this map.
+ Funcs template.FuncMap
+
+ // LeftDelim - set the left action delimiter for the template and all nested
+ // templates to the specified string. Defaults to "{{"
+ LDelim string
+ // RightDelim - set the right action delimiter for the template and all nested
+ // templates to the specified string. Defaults to "{{"
+ RDelim string
+
+ // Experimental - enable experimental features
+ Experimental bool
+}
+
+// optionsFromConfig - create a set of options from the internal config struct.
+// Does not set the Funcs field.
+func optionsFromConfig(cfg *config.Config) Options {
+ ds := make(map[string]Datasource, len(cfg.DataSources))
+ for k, v := range cfg.DataSources {
+ ds[k] = Datasource{
+ URL: v.URL,
+ Header: v.Header,
+ }
+ }
+ cs := make(map[string]Datasource, len(cfg.Context))
+ for k, v := range cfg.Context {
+ cs[k] = Datasource{
+ URL: v.URL,
+ Header: v.Header,
+ }
+ }
+ ts := make(map[string]Datasource, len(cfg.Templates))
+ for k, v := range cfg.Templates {
+ ts[k] = Datasource{
+ URL: v.URL,
+ Header: v.Header,
+ }
+ }
+
+ opts := Options{
+ Datasources: ds,
+ Context: cs,
+ Templates: ts,
+ ExtraHeaders: cfg.ExtraHeaders,
+ LDelim: cfg.LDelim,
+ RDelim: cfg.RDelim,
+ Experimental: cfg.Experimental,
+ }
+
+ return opts
+}
+
+// Datasource - a datasource URL with optional headers
+//
+// Experimental: subject to breaking changes before the next major release
+type Datasource struct {
+ URL *url.URL
+ Header http.Header
+}
+
+// Renderer provides gomplate's core template rendering functionality.
+// It should be initialized with NewRenderer.
+//
+// Experimental: subject to breaking changes before the next major release
+type Renderer struct {
+ data *data.Data
+ nested config.Templates
+ funcs template.FuncMap
+ lDelim string
+ rDelim string
+ tctxAliases []string
+}
+
+// NewRenderer creates a new template renderer with the specified options.
+// The returned renderer can be reused, but it is not (yet) safe for concurrent
+// use.
+//
+// Experimental: subject to breaking changes before the next major release
+func NewRenderer(opts Options) *Renderer {
+ if Metrics == nil {
+ Metrics = newMetrics()
+ }
+
+ tctxAliases := []string{}
+ sources := map[string]*data.Source{}
+
+ for alias, ds := range opts.Context {
+ tctxAliases = append(tctxAliases, alias)
+ sources[alias] = &data.Source{
+ Alias: alias,
+ URL: ds.URL,
+ Header: ds.Header,
+ }
+ }
+ for alias, ds := range opts.Datasources {
+ sources[alias] = &data.Source{
+ Alias: alias,
+ URL: ds.URL,
+ Header: ds.Header,
+ }
+ }
+
+ // convert the internal config.Templates to a map[string]Datasource
+ // TODO: simplify when config.Templates is removed
+ nested := config.Templates{}
+ for alias, ds := range opts.Templates {
+ nested[alias] = config.DataSource{
+ URL: ds.URL,
+ Header: ds.Header,
+ }
+ }
+
+ d := &data.Data{
+ ExtraHeaders: opts.ExtraHeaders,
+ Sources: sources,
+ }
+
+ // make sure data cleanups are run on exit
+ addCleanupHook(d.Cleanup)
+
+ if opts.Funcs == nil {
+ opts.Funcs = template.FuncMap{}
+ }
+
+ return &Renderer{
+ nested: nested,
+ data: d,
+ funcs: opts.Funcs,
+ tctxAliases: tctxAliases,
+ lDelim: opts.LDelim,
+ rDelim: opts.RDelim,
+ }
+}
+
+// Template contains the basic data needed to render a template with a Renderer
+//
+// Experimental: subject to breaking changes before the next major release
+type Template struct {
+ // Writer is the writer to output the rendered template to. If this writer
+ // is a non-os.Stdout io.Closer, it will be closed after the template is
+ // rendered.
+ Writer io.Writer
+ // Name is the name of the template - used for error messages
+ Name string
+ // Text is the template text
+ Text string
+}
+
+// RenderTemplates renders a list of templates, parsing each template's Text
+// and executing it, outputting to its Writer. If a template's Writer is a
+// non-os.Stdout io.Closer, it will be closed after the template is rendered.
+//
+// Experimental: subject to breaking changes before the next major release
+func (t *Renderer) RenderTemplates(ctx context.Context, templates []Template) error {
+ // we need to inject the current context into the Data value, because
+ // the Datasource method may need it
+ // TODO: remove this in v4
+ t.data.Ctx = ctx
+
+ // configure the template context with the refreshed Data value
+ // only done here because the data context may have changed
+ tmplctx, err := createTmplContext(ctx, t.tctxAliases, t.data)
+ if err != nil {
+ return err
+ }
+
+ return t.renderTemplatesWithData(ctx, templates, tmplctx)
+}
+
+func (t *Renderer) renderTemplatesWithData(ctx context.Context, templates []Template, tmplctx interface{}) error {
+ // update funcs with the current context
+ // only done here to ensure the context is properly set in func namespaces
+ f := template.FuncMap{}
+ addToMap(f, funcs.CreateDataFuncs(ctx, t.data))
+ addToMap(f, funcs.CreateAWSFuncs(ctx))
+ addToMap(f, funcs.CreateGCPFuncs(ctx))
+ addToMap(f, funcs.CreateBase64Funcs(ctx))
+ addToMap(f, funcs.CreateNetFuncs(ctx))
+ addToMap(f, funcs.CreateReFuncs(ctx))
+ addToMap(f, funcs.CreateStringFuncs(ctx))
+ addToMap(f, funcs.CreateEnvFuncs(ctx))
+ addToMap(f, funcs.CreateConvFuncs(ctx))
+ addToMap(f, funcs.CreateTimeFuncs(ctx))
+ addToMap(f, funcs.CreateMathFuncs(ctx))
+ addToMap(f, funcs.CreateCryptoFuncs(ctx))
+ addToMap(f, funcs.CreateFileFuncs(ctx))
+ addToMap(f, funcs.CreateFilePathFuncs(ctx))
+ addToMap(f, funcs.CreatePathFuncs(ctx))
+ addToMap(f, funcs.CreateSockaddrFuncs(ctx))
+ addToMap(f, funcs.CreateTestFuncs(ctx))
+ addToMap(f, funcs.CreateCollFuncs(ctx))
+ addToMap(f, funcs.CreateUUIDFuncs(ctx))
+ addToMap(f, funcs.CreateRandomFuncs(ctx))
+
+ // add user-defined funcs last so they override the built-in funcs
+ addToMap(f, t.funcs)
+
+ // track some metrics for debug output
+ start := time.Now()
+ defer func() { Metrics.TotalRenderDuration = time.Since(start) }()
+ for _, template := range templates {
+ if template.Writer != nil {
+ wr, ok := template.Writer.(io.Closer)
+ if ok && wr != os.Stdout {
+ defer wr.Close()
+ }
+ }
+
+ tstart := time.Now()
+ tmpl, err := parseTemplate(ctx, template.Name, template.Text,
+ f, tmplctx, t.nested, t.lDelim, t.rDelim)
+ if err != nil {
+ return err
+ }
+
+ err = tmpl.Execute(template.Writer, tmplctx)
+ Metrics.RenderDuration[template.Name] = time.Since(tstart)
+ if err != nil {
+ Metrics.Errors++
+ return fmt.Errorf("failed to render template %s: %w", template.Name, err)
+ }
+ Metrics.TemplatesProcessed++
+ }
+ return nil
+}
+
+// Render is a convenience method for rendering a single template. For more
+// than one template, use RenderTemplates. If wr is a non-os.Stdout
+// io.Closer, it will be closed after the template is rendered.
+//
+// Experimental: subject to breaking changes before the next major release
+func (t *Renderer) Render(ctx context.Context, name, text string, wr io.Writer) error {
+ return t.RenderTemplates(ctx, []Template{
+ {Name: name, Text: text, Writer: wr},
+ })
+}
diff --git a/render_test.go b/render_test.go
new file mode 100644
index 00000000..b40d0a10
--- /dev/null
+++ b/render_test.go
@@ -0,0 +1,155 @@
+package gomplate
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "net/url"
+ "os"
+ "strings"
+ "testing"
+ "testing/fstest"
+
+ "github.com/hairyhenderson/go-fsimpl"
+ "github.com/hairyhenderson/gomplate/v3/data"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestRenderTemplate(t *testing.T) {
+ fsys := fstest.MapFS{}
+ ctx := ContextWithFSProvider(context.Background(),
+ fsimpl.WrappedFSProvider(fsys, "mem"))
+
+ // no options - built-in function
+ tr := NewRenderer(Options{})
+ out := &bytes.Buffer{}
+ err := tr.Render(ctx, "test", "{{ `hello world` | toUpper }}", out)
+ assert.NoError(t, err)
+ assert.Equal(t, "HELLO WORLD", out.String())
+
+ // with datasource and context
+ hu, _ := url.Parse("stdin:")
+ wu, _ := url.Parse("env:WORLD")
+
+ os.Setenv("WORLD", "world")
+ defer os.Unsetenv("WORLD")
+
+ tr = NewRenderer(Options{
+ Context: map[string]Datasource{
+ "hi": {URL: hu},
+ },
+ Datasources: map[string]Datasource{
+ "world": {URL: wu},
+ },
+ })
+ ctx = data.ContextWithStdin(ctx, strings.NewReader("hello"))
+ out = &bytes.Buffer{}
+ err = tr.Render(ctx, "test", `{{ .hi | toUpper }} {{ (ds "world") | toUpper }}`, out)
+ assert.NoError(t, err)
+ assert.Equal(t, "HELLO WORLD", out.String())
+
+ // with a nested template
+ nu, _ := url.Parse("nested.tmpl")
+ fsys["nested.tmpl"] = &fstest.MapFile{Data: []byte(
+ `<< . | toUpper >>`)}
+
+ tr = NewRenderer(Options{
+ Templates: map[string]Datasource{
+ "nested": {URL: nu},
+ },
+ LDelim: "<<",
+ RDelim: ">>",
+ })
+ out = &bytes.Buffer{}
+ err = tr.Render(ctx, "test", `<< template "nested" "hello" >>`, out)
+ assert.NoError(t, err)
+ assert.Equal(t, "HELLO", out.String())
+
+ // errors contain the template name
+ tr = NewRenderer(Options{})
+ err = tr.Render(ctx, "foo", `{{ bogus }}`, &bytes.Buffer{})
+ assert.ErrorContains(t, err, "template: foo:")
+}
+
+//// examples
+
+func ExampleRenderer() {
+ ctx := context.Background()
+
+ // create a new template renderer
+ tr := NewRenderer(Options{})
+
+ // render a template to stdout
+ err := tr.Render(ctx, "mytemplate",
+ `{{ "hello, world!" | toUpper }}`,
+ os.Stdout)
+ if err != nil {
+ fmt.Println("gomplate error:", err)
+ }
+
+ // Output:
+ // HELLO, WORLD!
+}
+
+func ExampleRenderer_manyTemplates() {
+ ctx := context.Background()
+
+ // create a new template renderer
+ tr := NewRenderer(Options{})
+
+ templates := []Template{
+ {
+ Name: "one.tmpl",
+ Text: `contents of {{ tmpl.Path }}`,
+ Writer: &bytes.Buffer{},
+ },
+ {
+ Name: "two.tmpl",
+ Text: `{{ "hello world" | toUpper }}`,
+ Writer: &bytes.Buffer{},
+ },
+ {
+ Name: "three.tmpl",
+ Text: `1 + 1 = {{ math.Add 1 1 }}`,
+ Writer: &bytes.Buffer{},
+ },
+ }
+
+ // render the templates
+ err := tr.RenderTemplates(ctx, templates)
+ if err != nil {
+ panic(err)
+ }
+
+ for _, t := range templates {
+ fmt.Printf("%s: %s\n", t.Name, t.Writer.(*bytes.Buffer).String())
+ }
+
+ // Output:
+ // one.tmpl: contents of one.tmpl
+ // two.tmpl: HELLO WORLD
+ // three.tmpl: 1 + 1 = 2
+}
+
+func ExampleRenderer_datasources() {
+ ctx := context.Background()
+
+ // a datasource that retrieves JSON from a maritime registry dataset
+ u, _ := url.Parse("https://www.econdb.com/maritime/vessel/9437/")
+ tr := NewRenderer(Options{
+ Context: map[string]Datasource{
+ "vessel": {URL: u},
+ },
+ })
+
+ err := tr.Render(ctx, "jsontest",
+ `{{"\U0001F6A2"}} The {{ .vessel.data.Name }}'s call sign is {{ .vessel.data.Callsign }}, `+
+ `and it has a draught of {{ .vessel.data.Draught }}.`,
+ os.Stdout)
+ if err != nil {
+ panic(err)
+ }
+
+ // Output:
+ // 🚢 The MONTREAL EXPRESS's call sign is ZCET4, and it has a draught of 10.5.
+}
diff --git a/template.go b/template.go
index 81ecbf14..2d67cf3c 100644
--- a/template.go
+++ b/template.go
@@ -5,7 +5,6 @@ import (
"fmt"
"io"
"io/fs"
- "io/ioutil"
"os"
"path"
"path/filepath"
@@ -27,15 +26,6 @@ const gomplateignore = ".gomplateignore"
// for overriding in tests
var aferoFS = afero.NewOsFs()
-// tplate - models a gomplate template file...
-type tplate struct {
- name string
- target io.Writer
- contents string
- mode os.FileMode
- modeOverride bool
-}
-
func addTmplFuncs(f template.FuncMap, root *template.Template, tctx interface{}, path string) {
t := tmpl.New(root, tctx, path)
tns := func() *tmpl.Template { return t }
@@ -74,24 +64,23 @@ func FSProviderFromContext(ctx context.Context) fsimpl.FSProvider {
return nil
}
-// toGoTemplate - parses t.contents as a Go template named t.name with the
-// configured funcMap, delimiters, and nested templates.
-func (t *tplate) toGoTemplate(ctx context.Context, g *gomplate) (tmpl *template.Template, err error) {
- tmpl = template.New(t.name)
+// parseTemplate - parses text as a Go template with the given name and options
+func parseTemplate(ctx context.Context, name, text string, funcs template.FuncMap, tmplctx interface{}, nested config.Templates, leftDelim, rightDelim string) (tmpl *template.Template, err error) {
+ tmpl = template.New(name)
tmpl.Option("missingkey=error")
- funcMap := copyFuncMap(g.funcMap)
+ funcMap := copyFuncMap(funcs)
// the "tmpl" funcs get added here because they need access to the root template and context
- addTmplFuncs(funcMap, tmpl, g.tmplctx, t.name)
+ addTmplFuncs(funcMap, tmpl, tmplctx, name)
tmpl.Funcs(funcMap)
- tmpl.Delims(g.leftDelim, g.rightDelim)
- _, err = tmpl.Parse(t.contents)
+ tmpl.Delims(leftDelim, rightDelim)
+ _, err = tmpl.Parse(text)
if err != nil {
return nil, err
}
- err = parseNestedTemplates(ctx, g.nestedTemplates, tmpl)
+ err = parseNestedTemplates(ctx, nested, tmpl)
if err != nil {
return nil, fmt.Errorf("parse nested templates: %w", err)
}
@@ -181,29 +170,9 @@ func parseNestedTemplate(ctx context.Context, fsys fs.FS, alias, fname string, t
return nil
}
-// loadContents - reads the template
-func (t *tplate) loadContents(in io.Reader) ([]byte, error) {
- if in == nil {
- f, err := aferoFS.OpenFile(t.name, os.O_RDONLY, 0)
- if err != nil {
- return nil, fmt.Errorf("failed to open %s: %w", t.name, err)
- }
- // nolint: errcheck
- defer f.Close()
- in = f
- }
-
- b, err := ioutil.ReadAll(in)
- if err != nil {
- return nil, fmt.Errorf("failed to load contents of %s: %w", t.name, err)
- }
-
- return b, nil
-}
-
-// gatherTemplates - gather and prepare input template(s) and output file(s) for rendering
+// gatherTemplates - gather and prepare templates for rendering
// nolint: gocyclo
-func gatherTemplates(ctx context.Context, cfg *config.Config, outFileNamer func(context.Context, string) (string, error)) (templates []*tplate, err error) {
+func gatherTemplates(ctx context.Context, cfg *config.Config, outFileNamer func(context.Context, string) (string, error)) (templates []Template, err error) {
mode, modeOverride, err := cfg.GetMode()
if err != nil {
return nil, err
@@ -219,12 +188,10 @@ func gatherTemplates(ctx context.Context, cfg *config.Config, outFileNamer func(
return nil, oerr
}
- templates = []*tplate{{
- name: "<arg>",
- contents: cfg.Input,
- target: target,
- mode: mode,
- modeOverride: modeOverride,
+ templates = []Template{{
+ Name: "<arg>",
+ Text: cfg.Input,
+ Writer: target,
}}
case cfg.InputDir != "":
// input dirs presume output dirs are set too
@@ -233,9 +200,9 @@ func gatherTemplates(ctx context.Context, cfg *config.Config, outFileNamer func(
return nil, err
}
case cfg.Input == "":
- templates = make([]*tplate, len(cfg.InputFiles))
+ templates = make([]Template, len(cfg.InputFiles))
for i := range cfg.InputFiles {
- templates[i], err = fileToTemplates(cfg, cfg.InputFiles[i], cfg.OutputFiles[i], mode, modeOverride)
+ templates[i], err = fileToTemplate(cfg, cfg.InputFiles[i], cfg.OutputFiles[i], mode, modeOverride)
if err != nil {
return nil, err
}
@@ -248,7 +215,7 @@ func gatherTemplates(ctx context.Context, cfg *config.Config, outFileNamer func(
// walkDir - given an input dir `dir` and an output dir `outDir`, and a list
// of .gomplateignore and exclude globs (if any), walk the input directory and create a list of
// tplate objects, and an error, if any.
-func walkDir(ctx context.Context, cfg *config.Config, dir string, outFileNamer func(context.Context, string) (string, error), excludeGlob []string, mode os.FileMode, modeOverride bool) ([]*tplate, error) {
+func walkDir(ctx context.Context, cfg *config.Config, dir string, outFileNamer func(context.Context, string) (string, error), excludeGlob []string, mode os.FileMode, modeOverride bool) ([]Template, error) {
dir = filepath.Clean(dir)
dirStat, err := aferoFS.Stat(dir)
@@ -257,7 +224,7 @@ func walkDir(ctx context.Context, cfg *config.Config, dir string, outFileNamer f
}
dirMode := dirStat.Mode()
- templates := make([]*tplate, 0)
+ templates := make([]Template, 0)
matcher := xignore.NewMatcher(aferoFS)
// work around bug in xignore - a basedir of '.' doesn't work
@@ -283,7 +250,7 @@ func walkDir(ctx context.Context, cfg *config.Config, dir string, outFileNamer f
return nil, err
}
- tpl, err := fileToTemplates(cfg, inFile, outFile, mode, modeOverride)
+ tpl, err := fileToTemplate(cfg, inFile, outFile, mode, modeOverride)
if err != nil {
return nil, err
}
@@ -299,21 +266,21 @@ func walkDir(ctx context.Context, cfg *config.Config, dir string, outFileNamer f
return templates, nil
}
-func fileToTemplates(cfg *config.Config, inFile, outFile string, mode os.FileMode, modeOverride bool) (*tplate, error) {
+func fileToTemplate(cfg *config.Config, inFile, outFile string, mode os.FileMode, modeOverride bool) (Template, error) {
source := ""
//nolint:nestif
if inFile == "-" {
b, err := io.ReadAll(cfg.Stdin)
if err != nil {
- return nil, fmt.Errorf("failed to read from stdin: %w", err)
+ return Template{}, fmt.Errorf("failed to read from stdin: %w", err)
}
source = string(b)
} else {
si, err := aferoFS.Stat(inFile)
if err != nil {
- return nil, err
+ return Template{}, err
}
if mode == 0 {
mode = si.Mode()
@@ -323,7 +290,7 @@ func fileToTemplates(cfg *config.Config, inFile, outFile string, mode os.FileMod
// file descriptors.
f, err := aferoFS.OpenFile(inFile, os.O_RDONLY, 0)
if err != nil {
- return nil, fmt.Errorf("failed to open %s: %w", inFile, err)
+ return Template{}, fmt.Errorf("failed to open %s: %w", inFile, err)
}
//nolint: errcheck
@@ -331,7 +298,7 @@ func fileToTemplates(cfg *config.Config, inFile, outFile string, mode os.FileMod
b, err := io.ReadAll(f)
if err != nil {
- return nil, fmt.Errorf("failed to read %s: %w", inFile, err)
+ return Template{}, fmt.Errorf("failed to read %s: %w", inFile, err)
}
source = string(b)
@@ -341,15 +308,13 @@ func fileToTemplates(cfg *config.Config, inFile, outFile string, mode os.FileMod
// caller later
target, err := openOutFile(outFile, 0755, mode, modeOverride, cfg.Stdout, cfg.SuppressEmpty)
if err != nil {
- return nil, err
+ return Template{}, err
}
- tmpl := &tplate{
- name: inFile,
- contents: source,
- target: target,
- mode: mode,
- modeOverride: modeOverride,
+ tmpl := Template{
+ Name: inFile,
+ Text: source,
+ Writer: target,
}
return tmpl, nil
diff --git a/template_test.go b/template_test.go
index b5511fc2..d6afb391 100644
--- a/template_test.go
+++ b/template_test.go
@@ -45,19 +45,6 @@ func TestOpenOutFile(t *testing.T) {
assert.Equal(t, cfg.Stdout, f)
}
-func TestLoadContents(t *testing.T) {
- origfs := aferoFS
- defer func() { aferoFS = origfs }()
- aferoFS = afero.NewMemMapFs()
-
- afero.WriteFile(aferoFS, "foo", []byte("contents"), 0644)
-
- tmpl := &tplate{name: "foo"}
- b, err := tmpl.loadContents(nil)
- assert.NoError(t, err)
- assert.Equal(t, "contents", string(b))
-}
-
func TestGatherTemplates(t *testing.T) {
ctx := context.Background()
@@ -87,8 +74,8 @@ func TestGatherTemplates(t *testing.T) {
templates, err = gatherTemplates(ctx, cfg, nil)
assert.NoError(t, err)
assert.Len(t, templates, 1)
- assert.Equal(t, "foo", templates[0].contents)
- assert.Equal(t, cfg.Stdout, templates[0].target)
+ assert.Equal(t, "foo", templates[0].Text)
+ assert.Equal(t, cfg.Stdout, templates[0].Writer)
templates, err = gatherTemplates(ctx, &config.Config{
Input: "foo",
@@ -96,14 +83,14 @@ func TestGatherTemplates(t *testing.T) {
}, nil)
assert.NoError(t, err)
assert.Len(t, templates, 1)
- assert.Equal(t, iohelpers.NormalizeFileMode(0644), templates[0].mode)
+ // assert.Equal(t, iohelpers.NormalizeFileMode(0644), templates[0].mode)
// out file is created only on demand
_, err = aferoFS.Stat("out")
assert.Error(t, err)
assert.True(t, os.IsNotExist(err))
- _, err = templates[0].target.Write([]byte("hello world"))
+ _, err = templates[0].Writer.Write([]byte("hello world"))
assert.NoError(t, err)
info, err := aferoFS.Stat("out")
@@ -119,11 +106,11 @@ func TestGatherTemplates(t *testing.T) {
templates, err = gatherTemplates(ctx, cfg, nil)
assert.NoError(t, err)
assert.Len(t, templates, 1)
- assert.Equal(t, "bar", templates[0].contents)
- assert.NotEqual(t, cfg.Stdout, templates[0].target)
- assert.Equal(t, os.FileMode(0600), templates[0].mode)
+ assert.Equal(t, "bar", templates[0].Text)
+ assert.NotEqual(t, cfg.Stdout, templates[0].Writer)
+ // assert.Equal(t, os.FileMode(0600), templates[0].mode)
- _, err = templates[0].target.Write([]byte("hello world"))
+ _, err = templates[0].Writer.Write([]byte("hello world"))
assert.NoError(t, err)
info, err = aferoFS.Stat("out")
@@ -140,11 +127,11 @@ func TestGatherTemplates(t *testing.T) {
templates, err = gatherTemplates(ctx, cfg, nil)
assert.NoError(t, err)
assert.Len(t, templates, 1)
- assert.Equal(t, "bar", templates[0].contents)
- assert.NotEqual(t, cfg.Stdout, templates[0].target)
- assert.Equal(t, iohelpers.NormalizeFileMode(0755), templates[0].mode)
+ assert.Equal(t, "bar", templates[0].Text)
+ assert.NotEqual(t, cfg.Stdout, templates[0].Writer)
+ // assert.Equal(t, iohelpers.NormalizeFileMode(0755), templates[0].mode)
- _, err = templates[0].target.Write([]byte("hello world"))
+ _, err = templates[0].Writer.Write([]byte("hello world"))
assert.NoError(t, err)
info, err = aferoFS.Stat("out")
@@ -158,7 +145,7 @@ func TestGatherTemplates(t *testing.T) {
}, simpleNamer("out"))
assert.NoError(t, err)
assert.Len(t, templates, 3)
- assert.Equal(t, "foo", templates[0].contents)
+ assert.Equal(t, "foo", templates[0].Text)
aferoFS.Remove("out")
}
diff --git a/template_unix_test.go b/template_unix_test.go
index 94e3e206..aa47e01a 100644
--- a/template_unix_test.go
+++ b/template_unix_test.go
@@ -32,22 +32,19 @@ func TestWalkDir(t *testing.T) {
templates, err := walkDir(ctx, cfg, "/indir", simpleNamer("/outdir"), []string{"*/two"}, 0, false)
assert.NoError(t, err)
- expected := []*tplate{
+ expected := []Template{
{
- name: "/indir/one/bar",
- contents: "bar",
- mode: 0664,
+ Name: "/indir/one/bar",
+ Text: "bar",
},
{
- name: "/indir/one/foo",
- contents: "foo",
- mode: 0644,
+ Name: "/indir/one/foo",
+ Text: "foo",
},
}
assert.Len(t, templates, 2)
for i, tmpl := range templates {
- assert.Equal(t, expected[i].name, tmpl.name)
- assert.Equal(t, expected[i].contents, tmpl.contents)
- assert.Equal(t, expected[i].mode, tmpl.mode)
+ assert.Equal(t, expected[i].Name, tmpl.Name)
+ assert.Equal(t, expected[i].Text, tmpl.Text)
}
}
diff --git a/template_windows_test.go b/template_windows_test.go
index 00374e2c..f5e2bbe7 100644
--- a/template_windows_test.go
+++ b/template_windows_test.go
@@ -32,22 +32,19 @@ func TestWalkDir(t *testing.T) {
templates, err := walkDir(ctx, cfg, `C:\indir`, simpleNamer(`C:\outdir`), []string{`*\two`}, 0, false)
assert.NoError(t, err)
- expected := []*tplate{
+ expected := []Template{
{
- name: `C:\indir\one\bar`,
- contents: "bar",
- mode: 0644,
+ Name: `C:\indir\one\bar`,
+ Text: "bar",
},
{
- name: `C:\indir\one\foo`,
- contents: "foo",
- mode: 0644,
+ Name: `C:\indir\one\foo`,
+ Text: "foo",
},
}
assert.Len(t, templates, 2)
for i, tmpl := range templates {
- assert.Equal(t, expected[i].name, tmpl.name)
- assert.Equal(t, expected[i].contents, tmpl.contents)
- assert.Equal(t, expected[i].mode, tmpl.mode)
+ assert.Equal(t, expected[i].Name, tmpl.Name)
+ assert.Equal(t, expected[i].Text, tmpl.Text)
}
}