mirror of
https://github.com/tarampampam/error-pages.git
synced 2024-08-30 18:22:40 +00:00
wip: 🔕 temporary commit
This commit is contained in:
parent
c1eaee0287
commit
65fc5ecc7f
@ -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)
|
||||
}
|
||||
|
33
internal/template/props.go
Normal file
33
internal/template/props.go
Normal 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
|
||||
}
|
42
internal/template/props_test.go
Normal file
42
internal/template/props_test.go
Normal 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,
|
||||
})
|
||||
}
|
130
internal/template/template.go
Normal file
130
internal/template/template.go
Normal 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
|
||||
}
|
231
internal/template/template_test.go
Normal file
231
internal/template/template_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user