mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-08 00:26:37 +08:00
refactor(message_gateway): upgrade notification template engine with text/template and rich helpers
This commit is contained in:
@@ -4,17 +4,263 @@
|
|||||||
package push
|
package push
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
|
"regexp"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// placeholderRegex matches {{ ... }} tags
|
||||||
|
placeholderRegex = regexp.MustCompile(`\{\{\s*([^}]+?)\s*\}\}`)
|
||||||
|
// identifierRegex matches simple identifiers like name or user.username
|
||||||
|
identifierRegex = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_.]*$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// jsonMap is a map that serializes to JSON when printed as a string in templates.
|
||||||
|
type jsonMap map[string]any
|
||||||
|
|
||||||
|
func (m jsonMap) String() string {
|
||||||
|
b, err := json.Marshal(map[string]any(m))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("%v", map[string]any(m))
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m jsonMap) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(map[string]any(m))
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsonSlice is a slice that serializes to JSON when printed as a string in templates.
|
||||||
|
type jsonSlice []any
|
||||||
|
|
||||||
|
func (s jsonSlice) String() string {
|
||||||
|
b, err := json.Marshal([]any(s))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("%v", []any(s))
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s jsonSlice) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal([]any(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultFuncMap = template.FuncMap{
|
||||||
|
"default": func(fallback any, val any) any {
|
||||||
|
if val == nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
switch v := val.(type) {
|
||||||
|
case string:
|
||||||
|
if v == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
case bool:
|
||||||
|
if !v {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
case int:
|
||||||
|
if v == 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
case int32:
|
||||||
|
if v == 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
case int64:
|
||||||
|
if v == 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
case float64:
|
||||||
|
if v == 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
},
|
||||||
|
"toJson": func(v any) string {
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprint(v)
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
},
|
||||||
|
"upper": strings.ToUpper,
|
||||||
|
"lower": strings.ToLower,
|
||||||
|
"trim": strings.TrimSpace,
|
||||||
|
"dateFormat": func(format string, t any) string {
|
||||||
|
switch v := t.(type) {
|
||||||
|
case time.Time:
|
||||||
|
return v.Format(format)
|
||||||
|
case *time.Time:
|
||||||
|
if v != nil {
|
||||||
|
return v.Format(format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Sprint(t)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasKey checks if a dot-delimited or plain key exists in body
|
||||||
|
func hasKey(body map[string]any, key string) bool {
|
||||||
|
if _, ok := body[key]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
parts := strings.Split(key, ".")
|
||||||
|
var cur any = body
|
||||||
|
for _, part := range parts {
|
||||||
|
m, ok := cur.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val, exists := m[part]
|
||||||
|
if !exists {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
cur = val
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeTemplate converts legacy {{key}} / {{user.name}} into Go template {{.user.name}}
|
||||||
|
// while preserving Go template keywords, dot expressions, pipelines, and missing placeholders.
|
||||||
|
func normalizeTemplate(tmpl string, body map[string]any) string {
|
||||||
|
return placeholderRegex.ReplaceAllStringFunc(tmpl, func(match string) string {
|
||||||
|
sub := strings.TrimSpace(match[2 : len(match)-2])
|
||||||
|
if sub == "" {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
// If it's already a dot expression or special variable ($...)
|
||||||
|
if strings.HasPrefix(sub, ".") || strings.HasPrefix(sub, "$") {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
// If it's a known Go template keyword or block
|
||||||
|
firstWord := strings.Fields(sub)[0]
|
||||||
|
switch firstWord {
|
||||||
|
case "if", "else", "end", "range", "with", "template", "define", "block", "nil", "true", "false":
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
// Check if it's a pipeline like `key | default "val"`
|
||||||
|
if strings.Contains(sub, "|") {
|
||||||
|
const pipelineSplitCount = 2
|
||||||
|
parts := strings.SplitN(sub, "|", pipelineSplitCount)
|
||||||
|
left := strings.TrimSpace(parts[0])
|
||||||
|
right := strings.TrimSpace(parts[1])
|
||||||
|
if identifierRegex.MatchString(left) && !strings.HasPrefix(left, ".") && !strings.HasPrefix(left, "$") {
|
||||||
|
return fmt.Sprintf("{{ .%s | %s }}", left, right)
|
||||||
|
}
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
// Simple identifier: if present in body, convert to dot expression; otherwise preserve as is for fallback
|
||||||
|
if identifierRegex.MatchString(sub) {
|
||||||
|
if hasKey(body, sub) {
|
||||||
|
return fmt.Sprintf("{{ .%s }}", sub)
|
||||||
|
}
|
||||||
|
// Missing key: keep original text so fallback or literal is preserved
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
return match
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// prepareContext pre-processes the body map so that:
|
||||||
|
// 1. Dotted keys like "user.name" are expanded to nested map structure.
|
||||||
|
// 2. Complex structs, slices, and maps have JSON-friendly string representations when directly interpolated.
|
||||||
|
func prepareContext(body map[string]any) jsonMap {
|
||||||
|
if body == nil {
|
||||||
|
return make(jsonMap)
|
||||||
|
}
|
||||||
|
ctx := make(jsonMap, len(body))
|
||||||
|
for k, v := range body {
|
||||||
|
formatted := formatContextValue(v)
|
||||||
|
ctx[k] = formatted
|
||||||
|
// If key contains '.', expand into nested hierarchy
|
||||||
|
if strings.Contains(k, ".") {
|
||||||
|
parts := strings.Split(k, ".")
|
||||||
|
cur := ctx
|
||||||
|
for i := 0; i < len(parts)-1; i++ {
|
||||||
|
sub, ok := cur[parts[i]].(jsonMap)
|
||||||
|
if !ok {
|
||||||
|
sub = make(jsonMap)
|
||||||
|
cur[parts[i]] = sub
|
||||||
|
}
|
||||||
|
cur = sub
|
||||||
|
}
|
||||||
|
cur[parts[len(parts)-1]] = formatted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatContextValue formats slices and maps to JSON representation for direct string printing,
|
||||||
|
// while preserving basic scalar types for template functions.
|
||||||
|
func formatContextValue(v any) any {
|
||||||
|
if v == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch val := v.(type) {
|
||||||
|
case string, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, bool, time.Time:
|
||||||
|
return val
|
||||||
|
case []byte:
|
||||||
|
return string(val)
|
||||||
|
case map[string]any:
|
||||||
|
jm := make(jsonMap, len(val))
|
||||||
|
for k, subVal := range val {
|
||||||
|
jm[k] = formatContextValue(subVal)
|
||||||
|
}
|
||||||
|
return jm
|
||||||
|
case []any:
|
||||||
|
js := make(jsonSlice, len(val))
|
||||||
|
for i, subVal := range val {
|
||||||
|
js[i] = formatContextValue(subVal)
|
||||||
|
}
|
||||||
|
return js
|
||||||
|
case []string:
|
||||||
|
js := make(jsonSlice, len(val))
|
||||||
|
for i, subVal := range val {
|
||||||
|
js[i] = subVal
|
||||||
|
}
|
||||||
|
return js
|
||||||
|
default:
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ParseTemplate parses template strings by replacing {{placeholder}} structures with values from body.
|
// ParseTemplate parses template strings by replacing {{placeholder}} structures with values from body.
|
||||||
// It is a single-pass parser designed for high performance and low allocations.
|
// It supports Go text/template expressions (e.g. if/else, pipelines, default, toJson) as well as legacy {{key}} placeholders.
|
||||||
func ParseTemplate(template string, body map[string]any) string {
|
func ParseTemplate(templateStr string, body map[string]any) string {
|
||||||
|
if templateStr == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized := normalizeTemplate(templateStr, body)
|
||||||
|
ctx := prepareContext(body)
|
||||||
|
|
||||||
|
tmpl, err := template.New("push_tmpl").
|
||||||
|
Funcs(defaultFuncMap).
|
||||||
|
Option("missingkey=zero").
|
||||||
|
Parse(normalized)
|
||||||
|
if err != nil {
|
||||||
|
return fallbackReplace(templateStr, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err = tmpl.Execute(&buf, ctx); err != nil {
|
||||||
|
return fallbackReplace(templateStr, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func fallbackReplace(template string, body map[string]any) string {
|
||||||
var buf strings.Builder
|
var buf strings.Builder
|
||||||
buf.Grow(len(template))
|
buf.Grow(len(template))
|
||||||
|
|
||||||
@@ -25,27 +271,25 @@ func ParseTemplate(template string, body map[string]any) string {
|
|||||||
buf.WriteString(template[i:])
|
buf.WriteString(template[i:])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
// Write prefix
|
|
||||||
buf.WriteString(template[i : i+pos])
|
buf.WriteString(template[i : i+pos])
|
||||||
i += pos + 2 // skip "{{"
|
i += pos + 2
|
||||||
|
|
||||||
endPos := strings.Index(template[i:], "}}")
|
endPos := strings.Index(template[i:], "}}")
|
||||||
if endPos == -1 {
|
if endPos == -1 {
|
||||||
// Unbalanced "{{"
|
|
||||||
buf.WriteString("{{")
|
buf.WriteString("{{")
|
||||||
buf.WriteString(template[i:])
|
buf.WriteString(template[i:])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
key := template[i : i+endPos]
|
key := strings.TrimSpace(template[i : i+endPos])
|
||||||
|
key = strings.TrimPrefix(key, ".")
|
||||||
if val, ok := body[key]; ok {
|
if val, ok := body[key]; ok {
|
||||||
buf.WriteString(formatValue(val))
|
buf.WriteString(formatValue(val))
|
||||||
} else {
|
} else {
|
||||||
// Keep the placeholder if key not found
|
|
||||||
buf.WriteString("{{")
|
buf.WriteString("{{")
|
||||||
buf.WriteString(key)
|
buf.WriteString(template[i : i+endPos])
|
||||||
buf.WriteString("}}")
|
buf.WriteString("}}")
|
||||||
}
|
}
|
||||||
i += endPos + 2 // skip "}}"
|
i += endPos + 2
|
||||||
}
|
}
|
||||||
return buf.String()
|
return buf.String()
|
||||||
}
|
}
|
||||||
@@ -70,7 +314,6 @@ func formatValue(v any) string {
|
|||||||
case bool:
|
case bool:
|
||||||
return strconv.FormatBool(val)
|
return strconv.FormatBool(val)
|
||||||
default:
|
default:
|
||||||
// If it's a map, slice, or struct, marshal it to JSON.
|
|
||||||
b, err := json.Marshal(v)
|
b, err := json.Marshal(v)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return string(b)
|
return string(b)
|
||||||
|
|||||||
@@ -64,6 +64,60 @@ func TestParseTemplate(t *testing.T) {
|
|||||||
body: map[string]any{"obj": map[string]any{"key": "value"}},
|
body: map[string]any{"obj": map[string]any{"key": "value"}},
|
||||||
expected: `obj: {"key":"value"}`,
|
expected: `obj: {"key":"value"}`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "nested property from flat map",
|
||||||
|
template: "hello {{user.username}}",
|
||||||
|
body: map[string]any{"user.username": "Alice"},
|
||||||
|
expected: "hello Alice",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nested property from nested map",
|
||||||
|
template: "hello {{user.username}}",
|
||||||
|
body: map[string]any{"user": map[string]any{"username": "Bob"}},
|
||||||
|
expected: "hello Bob",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "go template dot syntax",
|
||||||
|
template: "hello {{.user.username}}",
|
||||||
|
body: map[string]any{"user": map[string]any{"username": "Charlie"}},
|
||||||
|
expected: "hello Charlie",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "default value helper fallback",
|
||||||
|
template: "hello {{.nickname | default \"Guest\"}}",
|
||||||
|
body: map[string]any{"nickname": ""},
|
||||||
|
expected: "hello Guest",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "default value helper provided",
|
||||||
|
template: "hello {{.nickname | default \"Guest\"}}",
|
||||||
|
body: map[string]any{"nickname": "David"},
|
||||||
|
expected: "hello David",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "conditional if else true",
|
||||||
|
template: "{{if .is_admin}}Admin: {{.name}}{{else}}User: {{.name}}{{end}}",
|
||||||
|
body: map[string]any{"is_admin": true, "name": "Eve"},
|
||||||
|
expected: "Admin: Eve",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "conditional if else false",
|
||||||
|
template: "{{if .is_admin}}Admin: {{.name}}{{else}}User: {{.name}}{{end}}",
|
||||||
|
body: map[string]any{"is_admin": false, "name": "Frank"},
|
||||||
|
expected: "User: Frank",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "upper and lower helper",
|
||||||
|
template: "{{.title | upper}} - {{.level | lower}}",
|
||||||
|
body: map[string]any{"title": "Warning", "level": "INFO"},
|
||||||
|
expected: "WARNING - info",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "toJson helper",
|
||||||
|
template: "payload: {{toJson .data}}",
|
||||||
|
body: map[string]any{"data": map[string]any{"status": "ok"}},
|
||||||
|
expected: `payload: {"status":"ok"}`,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|||||||
Reference in New Issue
Block a user