Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions internal/template/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Package template renders the templated fields used by the proxy against
// per-request values.
//
// Templates are ordinary text/template source. They are bound to the context
// they render against:
//
// - [ParseRouting] yields a template over a [RoutingContext] (local namespace
// and metadata), used while routing rules choose an upstream. The remote
// namespace is not available here because translation is defined per
// upstream.
// - [ParseUpstream] yields a template over an [UpstreamContext] (local and
// remote namespace, and metadata), used to render the chosen upstream's
// hostPort and serverName.
//
// A reference to a field absent from the bound context (such as RemoteNamespace
// in a routing template) fails at parse time. A reference to an absent metadata
// key renders as the empty string.
Comment thread
pseudomuto marked this conversation as resolved.
package template
96 changes: 96 additions & 0 deletions internal/template/template.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package template

import (
"fmt"
"strings"
texttemplate "text/template"
)

var probeMeta = map[string]string{
"dc": "probe",
"x-cluster": "probe",
}

type (
// RoutingContext is available when evaluating routing rules, before an
// upstream has been selected. It deliberately omits the remote namespace:
// namespace translation is defined per upstream, so the remote name is not
// known until routing has chosen one.
RoutingContext struct {
LocalNamespace string
Metadata map[string]string
}

// UpstreamContext is available when rendering an upstream's hostPort and
// serverName, after routing has selected the upstream and the remote
// namespace is known.
UpstreamContext struct {
LocalNamespace string
RemoteNamespace string
Metadata map[string]string
}

// Template is a compiled template bound to the context type T it renders
// against. Construct one with [ParseRouting] or [ParseUpstream].
Template[T any] struct {
tmpl *texttemplate.Template
raw string
}
)

// ParseRouting compiles s into a [Template] rendered against a [RoutingContext].
// A reference to a field absent from that context (such as RemoteNamespace)
// fails here rather than at request time.
func ParseRouting(s string) (*Template[RoutingContext], error) {
return parse(s, RoutingContext{LocalNamespace: "probe", Metadata: probeMeta})
}

// ParseUpstream compiles s into a [Template] rendered against an
// [UpstreamContext].
func ParseUpstream(s string) (*Template[UpstreamContext], error) {
return parse(s, UpstreamContext{LocalNamespace: "probe", RemoteNamespace: "probe", Metadata: probeMeta})
}

// Must returns t or panics if err is non-nil. It wraps a Parse call for
// package-level variables and tests where an invalid template is a programming
// error.
func Must[T any](t *Template[T], err error) *Template[T] {
if err != nil {
panic(err)
}

return t
}

// Render evaluates the template against ctx. A reference to an absent metadata
// key renders as the empty string.
func (t *Template[T]) Render(ctx T) (string, error) {
var sb strings.Builder
if err := t.tmpl.Execute(&sb, ctx); err != nil {
return "", fmt.Errorf("template: render %q: %w", t.raw, err)
}

return sb.String(), nil
}

// String returns the raw template source.
func (t *Template[T]) String() string {
return t.raw
}

// parse compiles s and validates it by rendering against probe, surfacing
// references to unknown fields at parse time. Absent metadata keys render empty
// under missingkey=zero and so do not trip the check.
Comment thread
pseudomuto marked this conversation as resolved.
func parse[T any](s string, probe T) (*Template[T], error) {
tmpl, err := texttemplate.New("template").Option("missingkey=zero").Parse(s)
if err != nil {
return nil, fmt.Errorf("template: parse %q: %w", s, err)
}

t := &Template[T]{tmpl: tmpl, raw: s}
if _, err := t.Render(probe); err != nil {
return nil, err
}

return t, nil
}
161 changes: 161 additions & 0 deletions internal/template/template_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package template_test

import (
"testing"

"github.com/stretchr/testify/require"

"github.com/temporalio/temporal-proxy/internal/template"
)

func TestParseUpstream_Render(t *testing.T) {
t.Parallel()

tests := []struct {
name string
tmpl string
ctx template.UpstreamContext
want string
}{
{
name: "static string round-trips",
tmpl: "localhost:7233",
want: "localhost:7233",
},
{
name: "remote namespace in hostPort",
tmpl: "{{ .RemoteNamespace }}.acme-cloud.tmprl.cloud:7233",
ctx: template.UpstreamContext{RemoteNamespace: "payments"},
want: "payments.acme-cloud.tmprl.cloud:7233",
},
{
name: "remote namespace in serverName",
tmpl: "{{ .RemoteNamespace }}.aws.tmprl.cloud",
ctx: template.UpstreamContext{RemoteNamespace: "payments"},
want: "payments.aws.tmprl.cloud",
},
{
name: "local namespace",
tmpl: "{{ .LocalNamespace }}",
ctx: template.UpstreamContext{LocalNamespace: "orders"},
want: "orders",
},
{
name: "metadata dot form",
tmpl: "{{ .Metadata.dc }}.internal",
ctx: template.UpstreamContext{Metadata: map[string]string{"dc": "west"}},
want: "west.internal",
},
{
name: "metadata index form",
tmpl: `{{ index .Metadata "x-cluster" }}`,
ctx: template.UpstreamContext{Metadata: map[string]string{"x-cluster": "c1"}},
want: "c1",
},
{
name: "absent metadata key renders empty",
tmpl: "{{ .Metadata.dc }}.internal",
ctx: template.UpstreamContext{Metadata: map[string]string{}},
want: ".internal",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

tmpl, err := template.ParseUpstream(tt.tmpl)
require.NoError(t, err)
require.Equal(t, tt.tmpl, tmpl.String())

got, err := tmpl.Render(tt.ctx)
require.NoError(t, err)
require.Equal(t, tt.want, got)
})
}
}

func TestParseRouting_Render(t *testing.T) {
t.Parallel()

tests := []struct {
name string
tmpl string
ctx template.RoutingContext
want string
}{
{
name: "local namespace",
tmpl: "{{ .LocalNamespace }}",
ctx: template.RoutingContext{LocalNamespace: "orders"},
want: "orders",
},
{
name: "metadata index form",
tmpl: `{{ index .Metadata "x-cluster" }}`,
ctx: template.RoutingContext{Metadata: map[string]string{"x-cluster": "c1"}},
want: "c1",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

tmpl, err := template.ParseRouting(tt.tmpl)
require.NoError(t, err)

got, err := tmpl.Render(tt.ctx)
require.NoError(t, err)
require.Equal(t, tt.want, got)
})
}
}

// A routing template cannot reference RemoteNamespace (it is not known until an
// upstream is selected); the same template is valid for an upstream.
func TestRemoteNamespace_scopedToUpstream(t *testing.T) {
t.Parallel()

const tmpl = "{{ .RemoteNamespace }}.acme-cloud.tmprl.cloud:7233"

_, err := template.ParseRouting(tmpl)
require.Error(t, err)
require.Contains(t, err.Error(), "RemoteNamespace")

_, err = template.ParseUpstream(tmpl)
require.NoError(t, err)
}

func TestParse_errors(t *testing.T) {
t.Parallel()

tests := []struct {
name string
tmpl string
}{
{name: "malformed syntax", tmpl: "{{ .LocalNamespace"},
{name: "unknown field", tmpl: "{{ .Foo }}.acme.com"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

_, rErr := template.ParseRouting(tt.tmpl)
require.Error(t, rErr)

_, uErr := template.ParseUpstream(tt.tmpl)
require.Error(t, uErr)
})
}
}

func TestMust(t *testing.T) {
t.Parallel()

require.NotPanics(t, func() { template.Must(template.ParseUpstream("{{ .RemoteNamespace }}.acme.com")) })
require.NotPanics(t, func() { template.Must(template.ParseRouting("{{ .LocalNamespace }}")) })
require.Panics(t, func() { template.Must(template.ParseRouting("{{ .RemoteNamespace }}")) })
require.Panics(t, func() { template.Must(template.ParseUpstream("{{ .Foo }}")) })
}
Loading