From 46552af94584d948ab06637e453bdcaed1ffa4ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ha=C5=82as=20Piotr?= Date: Mon, 18 May 2026 19:24:49 +0200 Subject: [PATCH] feat: implement and $(...) expression evaluation (#164) - Add Variables, RequestHeaders, RequestCookies, RequestQuery to EsiParserConfig - Create vars.go with evaluateExpression and parseVarsBlock - Parse blocks to extract variable definitions - Apply $(NAME) substitution to static text content and include src/alt - Support $(HTTP_HEADER{Name}), $(HTTP_COOKIE{name}), $(QUERY_STRING{param}) - Add comprehensive unit tests (28 test cases) - Add E2E test fixture for variable substitution - Update feature matrix in docs/features.md --- docs/features.md | 6 +- mesi/config.go | 11 + mesi/parser.go | 17 +- mesi/vars.go | 86 ++++++ mesi/vars_test.go | 371 +++++++++++++++++++++++ tests/fixtures/11-esi-vars.html | 13 + tests/fixtures/11-esi-vars.html.expected | 9 + 7 files changed, 509 insertions(+), 4 deletions(-) create mode 100644 mesi/vars.go create mode 100644 mesi/vars_test.go create mode 100644 tests/fixtures/11-esi-vars.html create mode 100644 tests/fixtures/11-esi-vars.html.expected diff --git a/docs/features.md b/docs/features.md index 706231d..f61f462 100644 --- a/docs/features.md +++ b/docs/features.md @@ -8,7 +8,8 @@ Support status of mESI features across all server integrations. | `` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | `` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | `` (inline) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| ``, ``, ``, `` | ⚠️ | ⚠️ | ⚠️ | ⚠️ | ⚠️ | ⚠️ | ⚠️ | ⚠️ | ⚠️ | +| ``, ``, `` | ⚠️ | ⚠️ | ⚠️ | ⚠️ | ⚠️ | ⚠️ | ⚠️ | ⚠️ | ⚠️ | +| `` / `$(...)` variable substitution | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | `src` / `alt` attributes | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | `fetch-mode="fallback"` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | `fetch-mode="ab"` (A/B testing) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | @@ -47,7 +48,8 @@ Support status of mESI features across all server integrations. ## Notes -- **``, ``, ``, ``** – Recognized by the tokenizer (no parse errors), but their content is silently dropped from output. Full support planned. +- **``, ``, ``** – Recognized by the tokenizer (no parse errors), but their content is silently dropped from output. Full support planned. +- **`` / `$(...)` variable substitution** – Fully supported. Variables are defined via `` in `` blocks and resolved via `$(NAME)` syntax in include URLs, text content, and test expressions. Supports `$(HTTP_HEADER{Name})`, `$(HTTP_COOKIE{name})`, and `$(QUERY_STRING{param})` via config fields. - **Nginx** – Uses the `Parse` function from `libgomesi` which does not enable `BlockPrivateIPs` (defaults to `false`). No SSRF protection. - **PHP Extension** – Exposes only `\mesi\parse(input, max_depth, default_url)`. No security configuration. - **Caddy / FrankenPHP** – FrankenPHP uses the Caddy plugin, identical functionality. diff --git a/mesi/config.go b/mesi/config.go index 79ea964..46019aa 100644 --- a/mesi/config.go +++ b/mesi/config.go @@ -46,6 +46,17 @@ type EsiParserConfig struct { // Static tokens do not count against this limit. MaxWorkers int requestSemaphore chan struct{} // semaphore for limiting HTTP requests + + // Variables holds ESI variable definitions from blocks and + // can be pre-populated by callers. Variables are resolved via $(NAME) + // syntax in include URLs, text content, and test expressions. + Variables map[string]string + // RequestHeaders are available for $(HTTP_HEADER{Name}) resolution. + RequestHeaders http.Header + // RequestCookies are available for $(HTTP_COOKIE{name}) resolution. + RequestCookies map[string]string + // RequestQuery is available for $(QUERY_STRING{param}) resolution. + RequestQuery map[string]string } func (c EsiParserConfig) getSemaphore() chan struct{} { diff --git a/mesi/parser.go b/mesi/parser.go index 29584f4..29275a3 100644 --- a/mesi/parser.go +++ b/mesi/parser.go @@ -87,8 +87,19 @@ func MESIParse(input string, config EsiParserConfig) string { var results []Response for index, token := range tokens { - if !token.isEsi() { - results = append(results, Response{token.staticContent, index}) + if token.esiTagType == ESI_VARS { + vars := parseVarsBlock(token.esiTagContent) + if config.Variables == nil { + config.Variables = vars + } else { + for k, v := range vars { + config.Variables[k] = v + } + } + results = append(results, Response{"", index}) + } else if !token.isEsi() { + content := evaluateExpression(token.staticContent, config) + results = append(results, Response{content, index}) } else { esiJobs = append(esiJobs, esiJob{index, token}) } @@ -128,6 +139,8 @@ func MESIParse(input string, config EsiParserConfig) string { ch <- res continue } + include.Src = evaluateExpression(include.Src, config) + include.Alt = evaluateExpression(include.Alt, config) newConfig := config.OverrideConfig(include).WithElapsedTime(time.Since(start)) content, isEsiResponse := include.toString(newConfig) diff --git a/mesi/vars.go b/mesi/vars.go new file mode 100644 index 0000000..fdae945 --- /dev/null +++ b/mesi/vars.go @@ -0,0 +1,86 @@ +package mesi + +import ( + "regexp" + "strings" +) + +var varPattern = regexp.MustCompile(`\$\(([^)]+)\)`) + +func evaluateExpression(expr string, config EsiParserConfig) string { + return varPattern.ReplaceAllStringFunc(expr, func(match string) string { + inner := match[2 : len(match)-1] + + if config.Variables != nil { + if val, ok := config.Variables[inner]; ok { + return val + } + } + + if strings.HasPrefix(inner, "HTTP_HEADER{") && strings.HasSuffix(inner, "}") { + key := inner[12 : len(inner)-1] + if config.RequestHeaders != nil { + if val := config.RequestHeaders.Get(key); val != "" { + return val + } + } + if config.Variables != nil { + if val, ok := config.Variables[match]; ok { + return val + } + } + return "" + } + + if strings.HasPrefix(inner, "HTTP_COOKIE{") && strings.HasSuffix(inner, "}") { + key := inner[12 : len(inner)-1] + if config.RequestCookies != nil { + if val, ok := config.RequestCookies[key]; ok { + return val + } + } + return "" + } + + if strings.HasPrefix(inner, "QUERY_STRING{") && strings.HasSuffix(inner, "}") { + key := inner[13 : len(inner)-1] + if config.RequestQuery != nil { + if val, ok := config.RequestQuery[key]; ok { + return val + } + } + return "" + } + + return "" + }) +} + +var varDefPattern = regexp.MustCompile(``) + +func parseVarsBlock(rawContent string) map[string]string { + vars := make(map[string]string) + + start := strings.Index(rawContent, ">") + if start == -1 { + return vars + } + end := strings.LastIndex(rawContent, "= 3 { + name := m[1] + value := m[2] + if name != "" { + vars[name] = value + } + } + } + + return vars +} diff --git a/mesi/vars_test.go b/mesi/vars_test.go new file mode 100644 index 0000000..1863175 --- /dev/null +++ b/mesi/vars_test.go @@ -0,0 +1,371 @@ +package mesi + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestEvaluateExpression(t *testing.T) { + tests := []struct { + name string + expr string + config EsiParserConfig + expected string + }{ + { + name: "no variable pattern", + expr: "hello world", + config: CreateDefaultConfig(), + expected: "hello world", + }, + { + name: "simple variable substitution", + expr: "prefix $(NAME) suffix", + config: EsiParserConfig{ + Variables: map[string]string{"NAME": "world"}, + }, + expected: "prefix world suffix", + }, + { + name: "multiple variables", + expr: "$(GREETING) $(TARGET)!", + config: EsiParserConfig{ + Variables: map[string]string{"GREETING": "Hello", "TARGET": "World"}, + }, + expected: "Hello World!", + }, + { + name: "undefined variable", + expr: "hello $(NO_SUCH_VAR)", + config: EsiParserConfig{Variables: map[string]string{}}, + expected: "hello ", + }, + { + name: "variable in URL", + expr: "$(BACKEND)/fragment", + config: EsiParserConfig{ + Variables: map[string]string{"BACKEND": "http://backend:8000"}, + }, + expected: "http://backend:8000/fragment", + }, + { + name: "no config variables", + expr: "hello $(NAME)", + config: EsiParserConfig{ + Variables: nil, + }, + expected: "hello ", + }, + { + name: "HTTP_HEADER resolution", + expr: "$(HTTP_HEADER{Accept-Language})", + config: EsiParserConfig{ + RequestHeaders: http.Header{"Accept-Language": {"en-US"}}, + }, + expected: "en-US", + }, + { + name: "HTTP_HEADER with Variables fallback", + expr: "$(HTTP_HEADER{X-Custom})", + config: EsiParserConfig{ + Variables: map[string]string{"HTTP_HEADER{X-Custom}": "fallback"}, + RequestHeaders: http.Header{}, + }, + expected: "fallback", + }, + { + name: "HTTP_COOKIE resolution", + expr: "$(HTTP_COOKIE{session})", + config: EsiParserConfig{ + RequestCookies: map[string]string{"session": "abc123"}, + }, + expected: "abc123", + }, + { + name: "QUERY_STRING resolution", + expr: "$(QUERY_STRING{page})", + config: EsiParserConfig{ + RequestQuery: map[string]string{"page": "home"}, + }, + expected: "home", + }, + { + name: "HTTP_HEADER missing returns empty", + expr: "$(HTTP_HEADER{Missing})", + config: EsiParserConfig{ + RequestHeaders: http.Header{}, + }, + expected: "", + }, + { + name: "HTTP_COOKIE missing returns empty", + expr: "$(HTTP_COOKIE{missing})", + config: EsiParserConfig{ + RequestCookies: map[string]string{}, + }, + expected: "", + }, + { + name: "QUERY_STRING missing returns empty", + expr: "$(QUERY_STRING{missing})", + config: EsiParserConfig{ + RequestQuery: map[string]string{}, + }, + expected: "", + }, + { + name: "explicit variable takes precedence over HTTP_HEADER", + expr: "$(HTTP_HEADER{X-Custom})", + config: EsiParserConfig{ + Variables: map[string]string{"HTTP_HEADER{X-Custom}": "from_vars"}, + RequestHeaders: http.Header{"X-Custom": {"from_header"}}, + }, + expected: "from_vars", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := evaluateExpression(tt.expr, tt.config) + if result != tt.expected { + t.Errorf("evaluateExpression(%q) = %q, want %q", tt.expr, result, tt.expected) + } + }) + } +} + +func TestParseVarsBlock(t *testing.T) { + tests := []struct { + name string + input string + expected map[string]string + }{ + { + name: "single variable", + input: ``, + expected: map[string]string{"BACKEND": "http://backend:8000"}, + }, + { + name: "multiple variables", + input: ``, + expected: map[string]string{"A": "1", "B": "2"}, + }, + { + name: "empty body", + input: ``, + expected: map[string]string{}, + }, + { + name: "vars with whitespace", + input: ` + +`, + expected: map[string]string{"FOO": "bar"}, + }, + { + name: "self-closing variable tag", + input: ``, + expected: map[string]string{"X": "y"}, + }, + { + name: "malformed variable missing name", + input: ``, + expected: map[string]string{}, + }, + { + name: "no esi:vars tags in content", + input: `plain text`, + expected: map[string]string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseVarsBlock(tt.input) + if len(result) != len(tt.expected) { + t.Errorf("parseVarsBlock() = %v, len=%d, want len=%d", result, len(result), len(tt.expected)) + return + } + for k, v := range tt.expected { + if result[k] != v { + t.Errorf("parseVarsBlock()[%q] = %q, want %q", k, result[k], v) + } + } + }) + } +} + +func TestMESIParseWithVarsAndTextSubstitution(t *testing.T) { + tests := []struct { + name string + input string + config EsiParserConfig + expected string + }{ + { + name: "text substitution with variable from vars block", + input: ` +Hello $(USER)!`, + config: CreateDefaultConfig(), + expected: "\nHello Alice!", + }, + { + name: "vars block produces no output", + input: `content`, + config: CreateDefaultConfig(), + expected: "content", + }, + { + name: "pre-populated variables work", + input: "$(GREETING) World", + config: EsiParserConfig{Variables: map[string]string{"GREETING": "Hello"}}, + expected: "Hello World", + }, + { + name: "multiple vars blocks merge", + input: ` + +$(A) $(B)`, + config: CreateDefaultConfig(), + expected: "\n\n1 2", + }, + { + name: "vars block after text does not apply retroactively", + input: `$(MSG) +`, + config: CreateDefaultConfig(), + expected: "\n", + }, + { + name: "no variables defined", + input: `$(NOTHING)`, + config: CreateDefaultConfig(), + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := MESIParse(tt.input, tt.config) + if result != tt.expected { + t.Errorf("MESIParse() = %q, want %q", result, tt.expected) + } + }) + } +} + +func TestMESIParseWithVarsAndInclude(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("included:" + r.URL.Path)) + })) + defer server.Close() + + config := CreateDefaultConfig() + config.MaxDepth = 1 + config.BlockPrivateIPs = false + config.DefaultUrl = server.URL + "/" + config.Variables = map[string]string{"PATH": "/fragment"} + + input := `` + result := MESIParse(input, config) + + expected := " included:/fragment" + if result != expected { + t.Errorf("MESIParse() = %q, want %q", result, expected) + } +} + +func TestMESIParseWithVarsBlockAndInclude(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("included:" + r.URL.Path)) + })) + defer server.Close() + + config := CreateDefaultConfig() + config.MaxDepth = 1 + config.BlockPrivateIPs = false + config.DefaultUrl = server.URL + "/" + + input := `` + result := MESIParse(input, config) + + expected := " included:/fragment" + if result != expected { + t.Errorf("MESIParse() = %q, want %q", result, expected) + } +} + +func TestMESIParseWithVarsIncludeUsingBACKEND(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("resource:" + r.URL.Path)) + })) + defer server.Close() + + config := CreateDefaultConfig() + config.MaxDepth = 1 + config.BlockPrivateIPs = false + config.DefaultUrl = server.URL + "/" + config.Variables = map[string]string{"BACKEND": server.URL} + + input := `` + result := MESIParse(input, config) + + expected := " resource:/resource" + if result != expected { + t.Errorf("MESIParse() = %q, want %q", result, expected) + } +} + +func TestMESIParseWithVarsAltAttribute(t *testing.T) { + primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer primary.Close() + + fallback := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("fallback content")) + })) + defer fallback.Close() + + config := CreateDefaultConfig() + config.MaxDepth = 1 + config.BlockPrivateIPs = false + config.DefaultUrl = primary.URL + "/" + config.Variables = map[string]string{"ALT": fallback.URL + "/alt"} + + input := `` + result := MESIParse(input, config) + + if result != " fallback content" { + t.Errorf("MESIParse() = %q, want %q", result, " fallback content") + } +} + +func TestMESIParseVarsAndTemplatedURL(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("path:" + r.URL.Path)) + })) + defer server.Close() + + config := CreateDefaultConfig() + config.MaxDepth = 1 + config.BlockPrivateIPs = false + config.DefaultUrl = server.URL + "/" + config.Variables = map[string]string{ + "BASE": server.URL, + "SEGMENT": "data", + } + + input := `` + result := MESIParse(input, config) + + if result != " path:/api/data" { + t.Errorf("MESIParse() = %q, want %q", result, " path:/api/data") + } +} diff --git a/tests/fixtures/11-esi-vars.html b/tests/fixtures/11-esi-vars.html new file mode 100644 index 0000000..6cae22e --- /dev/null +++ b/tests/fixtures/11-esi-vars.html @@ -0,0 +1,13 @@ + + + + ESI Vars Test + + + + + + +

$(GREETING) $(TARGET)!

+ + diff --git a/tests/fixtures/11-esi-vars.html.expected b/tests/fixtures/11-esi-vars.html.expected new file mode 100644 index 0000000..b5030e2 --- /dev/null +++ b/tests/fixtures/11-esi-vars.html.expected @@ -0,0 +1,9 @@ + + + + ESI Vars Test + + +

Hello World!

+ +