diff --git a/cmd/error-pages/main.go b/cmd/error-pages/main.go index 852576e..0b9a42d 100644 --- a/cmd/error-pages/main.go +++ b/cmd/error-pages/main.go @@ -2,12 +2,12 @@ package main import ( "context" + "fmt" "os" "os/signal" "path/filepath" "syscall" - "github.com/fatih/color" "go.uber.org/automaxprocs/maxprocs" "gh.tarampamp.am/error-pages/internal/cli" @@ -19,7 +19,7 @@ func main() { _, _ = maxprocs.Set(maxprocs.Min(1), maxprocs.Logger(func(_ string, _ ...any) {})) if err := run(); err != nil { - _, _ = color.New(color.FgHiRed, color.Bold).Fprintln(os.Stderr, err.Error()) + _, _ = fmt.Fprintln(os.Stderr, err.Error()) os.Exit(1) } diff --git a/internal/template/props.go b/internal/template/props.go new file mode 100644 index 0000000..719ee82 --- /dev/null +++ b/internal/template/props.go @@ -0,0 +1,33 @@ +package template + +import "reflect" + +//nolint:lll +type Props struct { + Code string `token:"code"` // http status code + Message string `token:"message"` // status message + Description string `token:"description"` // status description + OriginalURI string `token:"original_uri"` // (ingress-nginx) URI that caused the error + Namespace string `token:"namespace"` // (ingress-nginx) namespace where the backend Service is located + IngressName string `token:"ingress_name"` // (ingress-nginx) name of the Ingress where the backend is defined + ServiceName string `token:"service_name"` // (ingress-nginx) name of the Service backing the backend + ServicePort string `token:"service_port"` // (ingress-nginx) port number of the Service backing the backend + RequestID string `token:"request_id"` // (ingress-nginx) unique ID that identifies the request - same as for backend service + ForwardedFor string `token:"forwarded_for"` // the value of the `X-Forwarded-For` header + Host string `token:"host"` // the value of the `Host` header + ShowRequestDetails bool `token:"show_details"` // (config) show request details? + L10nDisabled bool `token:"l10n_disabled"` // (config) disable localization feature? +} + +// Values convert the Props struct into a map where each key is a token associated with its corresponding value. +func (p Props) Values() map[string]any { + var result = make(map[string]any, reflect.ValueOf(p).NumField()) + + for i, v := 0, reflect.ValueOf(p); i < v.NumField(); i++ { + if token, tagExists := v.Type().Field(i).Tag.Lookup("token"); tagExists { + result[token] = v.Field(i).Interface() + } + } + + return result +} diff --git a/internal/template/props_test.go b/internal/template/props_test.go new file mode 100644 index 0000000..b073f76 --- /dev/null +++ b/internal/template/props_test.go @@ -0,0 +1,42 @@ +package template_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "gh.tarampamp.am/error-pages/internal/template" +) + +func TestProps_Values(t *testing.T) { + t.Parallel() + + assert.Equal(t, template.Props{ + Code: "a", + Message: "b", + Description: "c", + OriginalURI: "d", + Namespace: "e", + IngressName: "f", + ServiceName: "g", + ServicePort: "h", + RequestID: "i", + ForwardedFor: "j", + L10nDisabled: true, + ShowRequestDetails: false, + }.Values(), map[string]any{ + "code": "a", + "message": "b", + "description": "c", + "original_uri": "d", + "namespace": "e", + "ingress_name": "f", + "service_name": "g", + "service_port": "h", + "request_id": "i", + "forwarded_for": "j", + "host": "", // empty because it's not set + "l10n_disabled": true, + "show_details": false, + }) +} diff --git a/internal/template/template.go b/internal/template/template.go new file mode 100644 index 0000000..e89ecee --- /dev/null +++ b/internal/template/template.go @@ -0,0 +1,130 @@ +package template + +import ( + "encoding/json" + "fmt" + "maps" + "os" + "strconv" + "strings" + "text/template" + "time" + + "gh.tarampamp.am/error-pages/internal/appmeta" +) + +var builtInFunctions = template.FuncMap{ //nolint:gochecknoglobals + // current time: + // `{{ now.Unix }}` // `1631610000` + // `{{ now.Hour }}:{{ now.Minute }}:{{ now.Second }}` // `15:4:5` + "now": time.Now, + + // current hostname: + // `{{ hostname }}` // `localhost` + "hostname": func() string { h, _ := os.Hostname(); return h }, //nolint:nlreturn + + // json-serialized value (safe to use with any type): + // `{{ json "test" }}` // `"test"` + // `{{ json 42 }}` // `42` + "json": func(v any) string { b, _ := json.Marshal(v); return string(b) }, //nolint:nlreturn,errchkjson + + // cast any type to int, or return 0 if it's not possible: + // `{{ int "42" }}` // `42` + // `{{ int 42 }}` // `42` + // `{{ int 3.14 }}` // `3` + // `{{ int "test" }}` // `0` + // `{{ int "42test" }}` // `0` + "int": func(v any) int { // cast any type to int, or return 0 if it's not possible + switch v := v.(type) { + case string: + if i, err := strconv.Atoi(strings.TrimSpace(v)); err == nil { + return i + } + case int: + return v + case int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: + if i, err := strconv.Atoi(fmt.Sprintf("%d", v)); err == nil { // not effective, but safe + return i + } + case float32, float64: + if i, err := strconv.ParseFloat(fmt.Sprintf("%f", v), 32); err == nil { // not effective, but safe + return int(i) + } + case fmt.Stringer: + if i, err := strconv.Atoi(v.String()); err == nil { + return i + } + } + + return 0 + }, + + // current application version: + // `{{ version }}` // `1.0.0` + "version": appmeta.Version, + + // counts the number of non-overlapping instances of substr in s: + // `{{ strCount "test" "t" }}` // `2` + "strCount": strings.Count, + + // reports whether substr is within s: + // `{{ strContains "test" "es" }}` // `true` + // `{{ strContains "test" "ez" }}` // `false` + "strContains": strings.Contains, + + // returns a slice of the string s, with all leading and trailing white space removed: + // `{{ strTrimSpace " test " }}` // `test` + "strTrimSpace": strings.TrimSpace, + + // returns s without the provided leading prefix string: + // `{{ strTrimPrefix "test" "te" }}` // `st` + "strTrimPrefix": strings.TrimPrefix, + + // returns s without the provided trailing suffix string: + // `{{ strTrimSuffix "test" "st" }}` // `te` + "strTrimSuffix": strings.TrimSuffix, + + // returns a copy of the string s with all non-overlapping instances of old replaced by new: + // `{{ strReplace "test" "t" "z" }}` // `zesz` + "strReplace": strings.ReplaceAll, + + // returns the index of the first instance of substr in s, or -1 if substr is not present in s: + // `{{ strIndex "barfoobaz" "foo" }}` // `3` + "strIndex": strings.Index, + + // splits the string s around each instance of one or more consecutive white space characters: + // `{{ strFields "foo bar baz" }}` // `[foo bar baz]` + "strFields": strings.Fields, + + // retrieves the value of the environment variable named by the key: + // `{{ env "SHELL" }}` // `/bin/bash` + "env": os.Getenv, +} + +func Render(content string, props Props) (string, error) { + var fns = maps.Clone(builtInFunctions) + + maps.Copy(fns, template.FuncMap{ // add custom functions + "hide_details": func() bool { return !props.ShowRequestDetails }, // inverted logic + "l10n_enabled": func() bool { return !props.L10nDisabled }, // inverted logic + }) + + // allow the direct access to the properties tokens, e.g. `{{ service_port | json }}` + // instead of `{{ .service_port | json }}` + for k, v := range props.Values() { + fns[k] = func() any { return v } + } + + tmpl, tErr := template.New("template").Funcs(fns).Parse(content) + if tErr != nil { + return "", fmt.Errorf("failed to parse template: %w", tErr) + } + + var buf strings.Builder + + if err := tmpl.Execute(&buf, props); err != nil { + return "", err + } + + return buf.String(), nil +} diff --git a/internal/template/template_test.go b/internal/template/template_test.go new file mode 100644 index 0000000..332b439 --- /dev/null +++ b/internal/template/template_test.go @@ -0,0 +1,231 @@ +package template_test + +import ( + "os" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "gh.tarampamp.am/error-pages/internal/appmeta" + "gh.tarampamp.am/error-pages/internal/template" +) + +func TestRender_BuiltInFunction(t *testing.T) { + t.Parallel() + + var hostname, hErr = os.Hostname() + + require.NoError(t, hErr) + + for name, tt := range map[string]struct { + giveTemplate string + wantResult string + wantErrMsg string + }{ + "now (unix)": { + giveTemplate: `{{ now.Unix }}`, + wantResult: strconv.Itoa(int(time.Now().Unix())), + }, + "now (time)": { + giveTemplate: `{{ now.Hour }}:{{ now.Minute }}:{{ now.Second }}`, + wantResult: time.Now().Format("15:4:5"), + }, + "hostname": {giveTemplate: `{{ hostname }}`, wantResult: hostname}, + "json (string)": {giveTemplate: `{{ json "test" }}`, wantResult: `"test"`}, + "json (int)": {giveTemplate: `{{ json 42 }}`, wantResult: `42`}, + "json (func result)": {giveTemplate: `{{ json hostname }}`, wantResult: `"` + hostname + `"`}, + "int (string)": {giveTemplate: `{{ int "42" }}`, wantResult: `42`}, + "int (int)": {giveTemplate: `{{ int 42 }}`, wantResult: `42`}, + "int (float)": {giveTemplate: `{{ int 3.14 }}`, wantResult: `3`}, + "int (wrong string)": {giveTemplate: `{{ int "test" }}`, wantResult: `0`}, + "int (string with numbers)": {giveTemplate: `{{ int "42test" }}`, wantResult: `0`}, + "version": {giveTemplate: `{{ version }}`, wantResult: appmeta.Version()}, + "strCount": {giveTemplate: `{{ strCount "test" "t" }}`, wantResult: `2`}, + "strContains (true)": {giveTemplate: `{{ strContains "test" "es" }}`, wantResult: `true`}, + "strContains (false)": {giveTemplate: `{{ strContains "test" "ez" }}`, wantResult: `false`}, + "strTrimSpace": {giveTemplate: `{{ strTrimSpace " test " }}`, wantResult: `test`}, + "strTrimPrefix": {giveTemplate: `{{ strTrimPrefix "test" "te" }}`, wantResult: `st`}, + "strTrimSuffix": {giveTemplate: `{{ strTrimSuffix "test" "st" }}`, wantResult: `te`}, + "strReplace": {giveTemplate: `{{ strReplace "test" "t" "z" }}`, wantResult: `zesz`}, + "strIndex": {giveTemplate: `{{ strIndex "barfoobaz" "foo" }}`, wantResult: `3`}, + "strFields": {giveTemplate: `{{ strFields "foo bar baz" }}`, wantResult: `[foo bar baz]`}, + "env (ok)": {giveTemplate: `{{ env "TEST_ENV_VAR" }}`, wantResult: "unit-test"}, + "env (not found)": {giveTemplate: `{{ env "NOT_FOUND_ENV_VAR" }}`, wantResult: ""}, + } { + t.Run(name, func(t *testing.T) { + require.NoError(t, os.Setenv("TEST_ENV_VAR", "unit-test")) + + defer func() { require.NoError(t, os.Unsetenv("TEST_ENV_VAR")) }() + + var result, err = template.Render(tt.giveTemplate, template.Props{}) + + if tt.wantErrMsg != "" { + assert.ErrorContains(t, err, tt.wantErrMsg) + assert.Empty(t, result) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.wantResult, result) + } + }) + } +} + +func TestRender(t *testing.T) { + t.Parallel() + + for name, tt := range map[string]struct { + giveTemplate string + giveProps template.Props + wantResult string + wantErrMsg string + }{ + "common case": { + giveTemplate: "{{code}}: {{ message }} {{description}}", + giveProps: template.Props{Code: "404", Message: "Not found", Description: "Blah"}, + wantResult: "404: Not found Blah", + }, + "html markup": { + giveTemplate: "{{code}}: {{ message }} {{description}}", + giveProps: template.Props{Code: "201", Message: "lorem ipsum"}, + wantResult: "201: lorem ipsum ", + }, + "with line breakers": { + giveTemplate: "\t {{code}}: {{ message }} {{description}}\n", + giveProps: template.Props{}, + wantResult: "\t : \n", + }, + "golang template": { + giveTemplate: "\t {{code}} {{ .Code }}{{ if .Message }} Yeah {{end}}", + giveProps: template.Props{Code: "201", Message: "lorem ipsum"}, + wantResult: "\t 201 201 Yeah ", + }, + + "json common case": { + giveTemplate: `{"code": {{code | json}}, "message": {"here":[ {{ message | json }} ]}, "desc": "{{description}}"}`, + giveProps: template.Props{Code: `404'"{`, Message: "Not found\t\r\n"}, + wantResult: `{"code": "404'\"{", "message": {"here":[ "Not found\t\r\n" ]}, "desc": ""}`, + }, + "json golang template": { + giveTemplate: `{"code": "{{code}}", "message": {"here":[ "{{ if .Message }} Yeah {{end}}" ]}}`, + giveProps: template.Props{Code: "201", Message: "lorem ipsum"}, + wantResult: `{"code": "201", "message": {"here":[ " Yeah " ]}}`, + }, + + "fn l10n_enabled": { + giveTemplate: "{{ if l10n_enabled }}Y{{ else }}N{{ end }}", + giveProps: template.Props{L10nDisabled: true}, + wantResult: "N", + }, + "fn l10n_disabled": { + giveTemplate: "{{ if l10n_disabled }}Y{{ else }}N{{ end }}", + giveProps: template.Props{L10nDisabled: true}, + wantResult: "Y", + }, + + "complete example with every property and function": { + giveProps: template.Props{ + Code: "404", + Message: "Not found", + Description: "Blah", + OriginalURI: "/test", + Namespace: "default", + IngressName: "test-ingress", + ServiceName: "test-service", + ServicePort: "80", + RequestID: "123456", + ForwardedFor: "123.123.123.123:321", + Host: "test-host", + ShowRequestDetails: true, + L10nDisabled: false, + }, + giveTemplate: ` + == Props as functions == + code: {{code}} + message: {{message}} + description: {{description}} + original_uri: {{original_uri}} + namespace: {{namespace}} + ingress_name: {{ingress_name}} + service_name: {{service_name}} + service_port: {{service_port}} + request_id: {{request_id}} + forwarded_for: {{forwarded_for}} + host: {{host}} + show_details: {{show_details}} + l10n_disabled: {{l10n_disabled}} + + == Props as properties == + .Code: {{ .Code }} + .Message: {{ .Message }} + .Description: {{ .Description }} + .OriginalURI: {{ .OriginalURI }} + .Namespace: {{ .Namespace }} + .IngressName: {{ .IngressName }} + .ServiceName: {{ .ServiceName }} + .ServicePort: {{ .ServicePort }} + .RequestID: {{ .RequestID }} + .ForwardedFor: {{ .ForwardedFor }} + .Host: {{ .Host }} + .ShowRequestDetails: {{ .ShowRequestDetails }} + .L10nDisabled: {{ .L10nDisabled }} + + == Custom functions == + hide_details: {{ hide_details }} + l10n_enabled: {{ l10n_enabled }} +`, + wantResult: ` + == Props as functions == + code: 404 + message: Not found + description: Blah + original_uri: /test + namespace: default + ingress_name: test-ingress + service_name: test-service + service_port: 80 + request_id: 123456 + forwarded_for: 123.123.123.123:321 + host: test-host + show_details: true + l10n_disabled: false + + == Props as properties == + .Code: 404 + .Message: Not found + .Description: Blah + .OriginalURI: /test + .Namespace: default + .IngressName: test-ingress + .ServiceName: test-service + .ServicePort: 80 + .RequestID: 123456 + .ForwardedFor: 123.123.123.123:321 + .Host: test-host + .ShowRequestDetails: true + .L10nDisabled: false + + == Custom functions == + hide_details: false + l10n_enabled: true +`, + }, + + "wrong template": {giveTemplate: `{{ foo() }}`, wantErrMsg: `function "foo" not defined`}, + "wrong template #2": {giveTemplate: `{{ fo`, wantErrMsg: "failed to parse template"}, + } { + t.Run(name, func(t *testing.T) { + var result, err = template.Render(tt.giveTemplate, tt.giveProps) + + if tt.wantErrMsg != "" { + assert.ErrorContains(t, err, tt.wantErrMsg) + assert.Empty(t, result) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.wantResult, result) + } + }) + } +}