wip: 🔕 temporary commit

This commit is contained in:
Paramtamtam 2024-06-25 18:29:18 +04:00
parent c1eaee0287
commit 65fc5ecc7f
No known key found for this signature in database
GPG Key ID: 366371698FAD0A2B
5 changed files with 438 additions and 2 deletions

View File

@ -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)
}

View File

@ -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
}

View File

@ -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,
})
}

View File

@ -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
}

View File

@ -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: "<!-- comment --><html><body>{{code}}: {{ message }} {{description}}</body></html>",
giveProps: template.Props{Code: "201", Message: "lorem ipsum"},
wantResult: "<!-- comment --><html><body>201: lorem ipsum </body></html>",
},
"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)
}
})
}
}