diff --git a/internal/template/doc.go b/internal/template/doc.go new file mode 100644 index 0000000..456e2a2 --- /dev/null +++ b/internal/template/doc.go @@ -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. +package template diff --git a/internal/template/template.go b/internal/template/template.go new file mode 100644 index 0000000..73cae9f --- /dev/null +++ b/internal/template/template.go @@ -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. +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 +} diff --git a/internal/template/template_test.go b/internal/template/template_test.go new file mode 100644 index 0000000..3d5c4d1 --- /dev/null +++ b/internal/template/template_test.go @@ -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 }}")) }) +}