From 13b0d86d7630a89dc94b0a172b2d04ba8a236875 Mon Sep 17 00:00:00 2001 From: Dave Henderson Date: Sat, 28 May 2022 09:44:56 -0400 Subject: New gomplate.Renderer interface Signed-off-by: Dave Henderson --- data/datasource.go | 1 + docs/content/usage.md | 5 +- gomplate.go | 114 +++++--------------- gomplate_test.go | 78 +++++++------- render.go | 271 +++++++++++++++++++++++++++++++++++++++++++++++ render_test.go | 155 +++++++++++++++++++++++++++ template.go | 93 +++++----------- template_test.go | 39 +++---- template_unix_test.go | 17 ++- template_windows_test.go | 17 ++- 10 files changed, 555 insertions(+), 235 deletions(-) create mode 100644 render.go create mode 100644 render_test.go 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: "", - 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: "", 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: "", - contents: cfg.Input, - target: target, - mode: mode, - modeOverride: modeOverride, + templates = []Template{{ + Name: "", + 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) } } -- cgit v1.2.3