diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 1021264..42d5abf 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
- go-version: ['1.23', '1.24']
+ go-version: ['1.27']
steps:
- name: Checkout code
@@ -48,7 +48,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
- go-version: '1.24'
+ go-version-file: go.mod
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v9
diff --git a/CLAUDE.md b/CLAUDE.md
index cc39b47..0d6631e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -24,6 +24,9 @@ sql-http-proxy/
## Development Commands
+Requires Go 1.27 or later: the project uses `encoding/json/v2` / `encoding/json/jsontext`
+and generic methods.
+
```bash
# Build (all drivers)
go build ./cmd/sql-http-proxy
diff --git a/README.md b/README.md
index c589f86..328669a 100644
--- a/README.md
+++ b/README.md
@@ -62,6 +62,8 @@ Or download manually from [GitHub Releases](https://github.com/mpyw/sql-http-pro
## Go Install
+Requires Go 1.27 or later.
+
```bash
go install github.com/mpyw/sql-http-proxy/cmd/sql-http-proxy@latest
```
diff --git a/e2e/common_test.go b/e2e/common_test.go
index 6c3220e..174bf95 100644
--- a/e2e/common_test.go
+++ b/e2e/common_test.go
@@ -1,6 +1,8 @@
package e2e
import (
+ json "encoding/json/v2"
+ "io"
"net/http"
"testing"
@@ -68,3 +70,15 @@ func createMutationHandler(t *testing.T, db *sqlx.DB, cfg config.Config) http.Ha
require.NoError(t, err)
return handler
}
+
+// decodeJSON decodes a response body as T, failing the test if it does not
+// parse. Decoding uses encoding/json/v2 defaults, so every assertion made
+// through this helper also asserts that the response is RFC 7493 JSON: one
+// complete value, no duplicate object members, valid UTF-8 throughout.
+func decodeJSON[T any](t *testing.T, body io.Reader) T {
+ t.Helper()
+
+ var v T
+ require.NoError(t, json.UnmarshalRead(body, &v))
+ return v
+}
diff --git a/e2e/content_type_multipart_test.go b/e2e/content_type_multipart_test.go
index e0abf3e..cdd6608 100644
--- a/e2e/content_type_multipart_test.go
+++ b/e2e/content_type_multipart_test.go
@@ -2,7 +2,6 @@ package e2e
import (
"bytes"
- "encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
@@ -36,8 +35,7 @@ func TestContentTypeMultipart(t *testing.T) {
assert.Equal(t, http.StatusOK, rec.Code)
- var result map[string]any
- require.NoError(t, json.NewDecoder(rec.Body).Decode(&result))
+ result := decodeJSON[map[string]any](t, rec.Body)
assert.Equal(t, float64(42), result["id"])
assert.Equal(t, "Multipart User", result["name"])
})
diff --git a/e2e/csv_value_parser_file_test.go b/e2e/csv_value_parser_file_test.go
index 998ea6a..6125ab2 100644
--- a/e2e/csv_value_parser_file_test.go
+++ b/e2e/csv_value_parser_file_test.go
@@ -1,7 +1,6 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"testing"
@@ -26,8 +25,7 @@ func TestCSVValueParserFile(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result []map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[[]map[string]any](t, w.Body)
require.Len(t, result, 2)
// Custom parser uppercases strings
diff --git a/e2e/csv_value_parser_test.go b/e2e/csv_value_parser_test.go
index 327acad..c9452dd 100644
--- a/e2e/csv_value_parser_test.go
+++ b/e2e/csv_value_parser_test.go
@@ -1,7 +1,6 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"testing"
@@ -27,8 +26,7 @@ func TestCSVValueParser(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result []map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[[]map[string]any](t, w.Body)
require.Len(t, result, 2)
// Row 1: 1,Alice,true,95.5,2024-01-15,
diff --git a/e2e/database_init_test.go b/e2e/database_init_test.go
index cbdcb72..8a24e38 100644
--- a/e2e/database_init_test.go
+++ b/e2e/database_init_test.go
@@ -1,7 +1,6 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"os"
@@ -37,8 +36,7 @@ func TestDatabaseInit_InlineSQL(t *testing.T) {
require.Equal(t, http.StatusOK, w.Code)
- var result []map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[[]map[string]any](t, w.Body)
// Verify init SQL created table and inserted data
require.Len(t, result, 2)
@@ -104,8 +102,7 @@ queries:
require.Equal(t, http.StatusOK, w.Code)
- var result []map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[[]map[string]any](t, w.Body)
// Verify init SQL file created table and inserted data
require.Len(t, result, 2)
diff --git a/e2e/delay_test.go b/e2e/delay_test.go
index 14fc352..29fdfd0 100644
--- a/e2e/delay_test.go
+++ b/e2e/delay_test.go
@@ -4,6 +4,7 @@ import (
"net/http"
"net/http/httptest"
"testing"
+ "testing/synctest"
"time"
"github.com/stretchr/testify/require"
@@ -12,6 +13,10 @@ import (
"github.com/mpyw/sql-http-proxy/internal/server"
)
+// configuredDelay matches the `delay:` value on the delayed endpoints in
+// delay_test.yaml.
+const configuredDelay = 50 * time.Millisecond
+
func TestDelay(t *testing.T) {
cfg, err := config.ParseFile("delay_test.yaml")
require.NoError(t, err)
@@ -19,73 +24,36 @@ func TestDelay(t *testing.T) {
mux, err := server.NewServeMux(nil, cfg, ".")
require.NoError(t, err)
- t.Run("object with delay", func(t *testing.T) {
- req := httptest.NewRequest(http.MethodGet, "/slow-user", nil)
- rec := httptest.NewRecorder()
-
- start := time.Now()
- mux.ServeHTTP(rec, req)
- elapsed := time.Since(start)
-
- require.Equal(t, http.StatusOK, rec.Code)
- require.JSONEq(t, `{"id":1,"name":"Slow User"}`, rec.Body.String())
- // Should take at least 50ms
- require.GreaterOrEqual(t, elapsed.Milliseconds(), int64(40), "expected delay of at least 40ms")
- })
-
- t.Run("array with delay", func(t *testing.T) {
- req := httptest.NewRequest(http.MethodGet, "/slow-users", nil)
- rec := httptest.NewRecorder()
-
- start := time.Now()
- mux.ServeHTTP(rec, req)
- elapsed := time.Since(start)
-
- require.Equal(t, http.StatusOK, rec.Code)
- require.JSONEq(t, `[{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}]`, rec.Body.String())
- // Should take at least 50ms
- require.GreaterOrEqual(t, elapsed.Milliseconds(), int64(40), "expected delay of at least 40ms")
- })
-
- t.Run("filtered array with delay", func(t *testing.T) {
- req := httptest.NewRequest(http.MethodGet, "/slow-filtered-user?id=1", nil)
- rec := httptest.NewRecorder()
-
- start := time.Now()
- mux.ServeHTTP(rec, req)
- elapsed := time.Since(start)
-
- require.Equal(t, http.StatusOK, rec.Code)
- require.JSONEq(t, `{"id":1,"name":"Alice"}`, rec.Body.String())
- // Should take at least 50ms
- require.GreaterOrEqual(t, elapsed.Milliseconds(), int64(40), "expected delay of at least 40ms")
- })
-
- t.Run("JS source with delay", func(t *testing.T) {
- req := httptest.NewRequest(http.MethodGet, "/slow-js-user?id=42", nil)
- rec := httptest.NewRecorder()
-
- start := time.Now()
- mux.ServeHTTP(rec, req)
- elapsed := time.Since(start)
-
- require.Equal(t, http.StatusOK, rec.Code)
- require.JSONEq(t, `{"id":42,"name":"JS User"}`, rec.Body.String())
- // Should take at least 50ms
- require.GreaterOrEqual(t, elapsed.Milliseconds(), int64(40), "expected delay of at least 40ms")
- })
-
- t.Run("no delay is fast", func(t *testing.T) {
- req := httptest.NewRequest(http.MethodGet, "/no-delay-user", nil)
- rec := httptest.NewRecorder()
-
- start := time.Now()
- mux.ServeHTTP(rec, req)
- elapsed := time.Since(start)
-
- require.Equal(t, http.StatusOK, rec.Code)
- require.JSONEq(t, `{"id":1,"name":"Fast User"}`, rec.Body.String())
- // Should complete quickly (less than 30ms typically)
- require.Less(t, elapsed.Milliseconds(), int64(30), "expected fast response without delay")
- })
+ // Each case runs inside a synctest bubble, so the handler's time.Sleep is
+ // served by a fake clock: the assertions can require the exact delay
+ // instead of a tolerance window, and the test does not actually wait.
+ tests := []struct {
+ name string
+ path string
+ body string
+ delay time.Duration
+ }{
+ {"object with delay", "/slow-user", `{"id":1,"name":"Slow User"}`, configuredDelay},
+ {"array with delay", "/slow-users", `[{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}]`, configuredDelay},
+ {"filtered array with delay", "/slow-filtered-user?id=1", `{"id":1,"name":"Alice"}`, configuredDelay},
+ {"JS source with delay", "/slow-js-user?id=42", `{"id":42,"name":"JS User"}`, configuredDelay},
+ {"no delay", "/no-delay-user", `{"id":1,"name":"Fast User"}`, 0},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodGet, tt.path, nil)
+ rec := httptest.NewRecorder()
+
+ start := time.Now()
+ mux.ServeHTTP(rec, req)
+ elapsed := time.Since(start)
+
+ require.Equal(t, http.StatusOK, rec.Code)
+ require.JSONEq(t, tt.body, rec.Body.String())
+ require.Equal(t, tt.delay, elapsed)
+ })
+ })
+ }
}
diff --git a/e2e/global_helpers_file_test.go b/e2e/global_helpers_file_test.go
index 363fdfe..5184511 100644
--- a/e2e/global_helpers_file_test.go
+++ b/e2e/global_helpers_file_test.go
@@ -1,7 +1,6 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"testing"
@@ -27,8 +26,7 @@ func TestGlobalHelpersFile(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[map[string]any](t, w.Body)
assert.Equal(t, float64(10), result["doubled"]) // double(5) from inline
assert.Equal(t, float64(15), result["tripled"]) // triple(5) from file
assert.Equal(t, "Hello, Test!", result["greeting"]) // greet() from file
diff --git a/e2e/global_helpers_test.go b/e2e/global_helpers_test.go
index 90c8e3f..1e64197 100644
--- a/e2e/global_helpers_test.go
+++ b/e2e/global_helpers_test.go
@@ -1,7 +1,6 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"testing"
@@ -28,8 +27,7 @@ func TestGlobalHelpers(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[map[string]any](t, w.Body)
assert.Equal(t, float64(10), result["input_doubled"]) // double(5) = 10
})
@@ -40,8 +38,7 @@ func TestGlobalHelpers(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[map[string]any](t, w.Body)
assert.Equal(t, float64(20), result["value"]) // double(10) = 20
})
@@ -52,8 +49,7 @@ func TestGlobalHelpers(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[map[string]any](t, w.Body)
assert.Equal(t, "John Doe", result["fullName"])
})
@@ -64,8 +60,7 @@ func TestGlobalHelpers(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result []map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[[]map[string]any](t, w.Body)
assert.Len(t, result, 2)
assert.Equal(t, float64(10), result[0]["doubled"]) // double(5) = 10
assert.Equal(t, float64(20), result[1]["doubled"]) // double(10) = 20
diff --git a/e2e/mock_dsn_null_test.go b/e2e/mock_dsn_null_test.go
index 09e47ff..5ae9080 100644
--- a/e2e/mock_dsn_null_test.go
+++ b/e2e/mock_dsn_null_test.go
@@ -1,13 +1,11 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func TestMockDsnNull(t *testing.T) {
@@ -20,8 +18,7 @@ func TestMockDsnNull(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[map[string]any](t, w.Body)
assert.Equal(t, float64(1), result["id"])
assert.Equal(t, "Mock User", result["name"])
}
diff --git a/e2e/mock_error_test.go b/e2e/mock_error_test.go
index 78cf125..f9cad06 100644
--- a/e2e/mock_error_test.go
+++ b/e2e/mock_error_test.go
@@ -1,13 +1,11 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func TestMockError(t *testing.T) {
@@ -21,8 +19,7 @@ func TestMockError(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[map[string]any](t, w.Body)
assert.Equal(t, float64(1), result["id"])
})
@@ -33,8 +30,7 @@ func TestMockError(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, w.Code)
- var result map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[map[string]any](t, w.Body)
assert.Equal(t, "id is required", result["message"])
})
}
diff --git a/e2e/mock_file_test.go b/e2e/mock_file_test.go
index 384f28a..bf62d9c 100644
--- a/e2e/mock_file_test.go
+++ b/e2e/mock_file_test.go
@@ -1,7 +1,6 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"testing"
@@ -27,8 +26,7 @@ func TestMockFile(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result []map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[[]map[string]any](t, w.Body)
assert.Len(t, result, 2)
assert.Equal(t, "CSV User 1", result[0]["name"])
assert.Equal(t, true, result[0]["active"])
@@ -41,8 +39,7 @@ func TestMockFile(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result []map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[[]map[string]any](t, w.Body)
assert.Len(t, result, 2)
assert.Equal(t, "JSON User 1", result[0]["name"])
})
@@ -54,8 +51,7 @@ func TestMockFile(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result []map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[[]map[string]any](t, w.Body)
assert.Len(t, result, 2)
assert.Equal(t, "JSONL User 1", result[0]["name"])
})
diff --git a/e2e/mock_filter_by_test.go b/e2e/mock_filter_by_test.go
index 3e1d455..6335211 100644
--- a/e2e/mock_filter_by_test.go
+++ b/e2e/mock_filter_by_test.go
@@ -1,7 +1,6 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"testing"
@@ -26,9 +25,7 @@ func TestMockFilterBy(t *testing.T) {
require.Equal(t, http.StatusOK, rec.Code)
- var result map[string]any
- err := json.NewDecoder(rec.Body).Decode(&result)
- require.NoError(t, err)
+ result := decodeJSON[map[string]any](t, rec.Body)
require.Equal(t, float64(2), result["id"])
require.Equal(t, "Bob", result["name"])
})
@@ -48,9 +45,7 @@ func TestMockFilterBy(t *testing.T) {
require.Equal(t, http.StatusOK, rec.Code)
- var result []map[string]any
- err := json.NewDecoder(rec.Body).Decode(&result)
- require.NoError(t, err)
+ result := decodeJSON[[]map[string]any](t, rec.Body)
require.Len(t, result, 2)
require.Equal(t, "Alice", result[0]["name"])
require.Equal(t, "Charlie", result[1]["name"])
@@ -63,9 +58,7 @@ func TestMockFilterBy(t *testing.T) {
require.Equal(t, http.StatusOK, rec.Code)
- var result []map[string]any
- err := json.NewDecoder(rec.Body).Decode(&result)
- require.NoError(t, err)
+ result := decodeJSON[[]map[string]any](t, rec.Body)
require.Len(t, result, 0)
})
@@ -76,9 +69,7 @@ func TestMockFilterBy(t *testing.T) {
require.Equal(t, http.StatusOK, rec.Code)
- var result []map[string]any
- err := json.NewDecoder(rec.Body).Decode(&result)
- require.NoError(t, err)
+ result := decodeJSON[[]map[string]any](t, rec.Body)
require.Len(t, result, 2)
})
}
diff --git a/e2e/mock_handle_not_found_test.go b/e2e/mock_handle_not_found_test.go
index 51a5539..0a25961 100644
--- a/e2e/mock_handle_not_found_test.go
+++ b/e2e/mock_handle_not_found_test.go
@@ -1,13 +1,11 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func TestMockHandleNotFound(t *testing.T) {
@@ -21,8 +19,7 @@ func TestMockHandleNotFound(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[map[string]any](t, w.Body)
assert.Equal(t, true, result["found"])
assert.Equal(t, float64(1), result["id"])
})
@@ -34,8 +31,7 @@ func TestMockHandleNotFound(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[map[string]any](t, w.Body)
assert.Equal(t, false, result["found"])
assert.Equal(t, "guest", result["default"])
})
diff --git a/e2e/mock_json_string_test.go b/e2e/mock_json_string_test.go
index ae5aa72..7472fba 100644
--- a/e2e/mock_json_string_test.go
+++ b/e2e/mock_json_string_test.go
@@ -1,7 +1,6 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"testing"
@@ -25,8 +24,7 @@ func TestMockJSONString(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result []map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[[]map[string]any](t, w.Body)
assert.Len(t, result, 2)
assert.Equal(t, float64(1), result[0]["id"])
assert.Equal(t, "Alice", result[0]["name"])
@@ -44,8 +42,7 @@ func TestMockJSONString(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[map[string]any](t, w.Body)
assert.Equal(t, float64(42), result["id"])
assert.Equal(t, "Charlie", result["name"])
})
diff --git a/e2e/mock_many_post_each_test.go b/e2e/mock_many_post_each_test.go
index 92bda27..7ea1065 100644
--- a/e2e/mock_many_post_each_test.go
+++ b/e2e/mock_many_post_each_test.go
@@ -1,13 +1,11 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func TestMockManyPostEach(t *testing.T) {
@@ -20,8 +18,7 @@ func TestMockManyPostEach(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result []map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[[]map[string]any](t, w.Body)
assert.Len(t, result, 2)
assert.Equal(t, "ALICE", result[0]["name"])
assert.Equal(t, "BOB", result[1]["name"])
diff --git a/e2e/mock_many_test.go b/e2e/mock_many_test.go
index a54a352..e669e27 100644
--- a/e2e/mock_many_test.go
+++ b/e2e/mock_many_test.go
@@ -1,13 +1,11 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func TestMockMany(t *testing.T) {
@@ -20,8 +18,7 @@ func TestMockMany(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result []map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[[]map[string]any](t, w.Body)
assert.Len(t, result, 3)
assert.Equal(t, float64(1), result[0]["id"])
assert.Equal(t, "User 1", result[0]["name"])
diff --git a/e2e/mock_mixed_test.go b/e2e/mock_mixed_test.go
index d17a9eb..9e801c0 100644
--- a/e2e/mock_mixed_test.go
+++ b/e2e/mock_mixed_test.go
@@ -1,7 +1,6 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"testing"
@@ -27,8 +26,7 @@ func TestMockMixed(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[map[string]any](t, w.Body)
assert.Equal(t, float64(1), result["id"])
assert.Equal(t, "John", result["first_name"])
assert.NotContains(t, result, "source")
@@ -44,8 +42,7 @@ func TestMockMixed(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[map[string]any](t, w.Body)
assert.Equal(t, float64(1), result["id"])
assert.Equal(t, "Mock User", result["name"])
assert.Equal(t, "mock", result["source"])
diff --git a/e2e/mock_no_dsn_test.go b/e2e/mock_no_dsn_test.go
index f7bd2c0..29cb28b 100644
--- a/e2e/mock_no_dsn_test.go
+++ b/e2e/mock_no_dsn_test.go
@@ -1,13 +1,11 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func TestMockNoDsn(t *testing.T) {
@@ -20,8 +18,7 @@ func TestMockNoDsn(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[map[string]any](t, w.Body)
assert.Equal(t, float64(1), result["id"])
assert.Equal(t, "Mock User", result["name"])
}
diff --git a/e2e/mock_one_not_found_test.go b/e2e/mock_one_not_found_test.go
index e6af904..2b9a78e 100644
--- a/e2e/mock_one_not_found_test.go
+++ b/e2e/mock_one_not_found_test.go
@@ -1,13 +1,11 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func TestMockOneNotFound(t *testing.T) {
@@ -21,8 +19,7 @@ func TestMockOneNotFound(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[map[string]any](t, w.Body)
assert.Equal(t, float64(1), result["id"])
assert.Equal(t, "Mock User", result["name"])
})
diff --git a/e2e/mock_one_test.go b/e2e/mock_one_test.go
index daaf1ab..de24aaf 100644
--- a/e2e/mock_one_test.go
+++ b/e2e/mock_one_test.go
@@ -1,13 +1,11 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func TestMockOne(t *testing.T) {
@@ -20,8 +18,7 @@ func TestMockOne(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[map[string]any](t, w.Body)
assert.Equal(t, float64(42), result["id"])
assert.Equal(t, "Mock User", result["name"])
assert.Equal(t, "mock@example.com", result["email"])
diff --git a/e2e/mock_shorthand_test.go b/e2e/mock_shorthand_test.go
index 34a8cc6..2d60907 100644
--- a/e2e/mock_shorthand_test.go
+++ b/e2e/mock_shorthand_test.go
@@ -1,7 +1,6 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"testing"
@@ -27,8 +26,7 @@ func TestMockShorthand(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result []map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[[]map[string]any](t, w.Body)
assert.Len(t, result, 2)
assert.Equal(t, float64(1), result[0]["id"])
assert.Equal(t, "Alice", result[0]["name"])
@@ -43,8 +41,7 @@ func TestMockShorthand(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[map[string]any](t, w.Body)
assert.Equal(t, float64(42), result["id"])
assert.Equal(t, "Charlie", result["name"])
})
diff --git a/e2e/mock_with_ctx_test.go b/e2e/mock_with_ctx_test.go
index a328252..84e060b 100644
--- a/e2e/mock_with_ctx_test.go
+++ b/e2e/mock_with_ctx_test.go
@@ -1,13 +1,11 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func TestMockWithCtx(t *testing.T) {
@@ -20,8 +18,7 @@ func TestMockWithCtx(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[map[string]any](t, w.Body)
assert.Equal(t, float64(42), result["id"])
assert.Equal(t, "42", result["requestedId"])
assert.Equal(t, true, result["hadMockTime"])
diff --git a/e2e/mock_with_post_test.go b/e2e/mock_with_post_test.go
index 9c5faf7..182c186 100644
--- a/e2e/mock_with_post_test.go
+++ b/e2e/mock_with_post_test.go
@@ -1,13 +1,11 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func TestMockWithPost(t *testing.T) {
@@ -20,8 +18,7 @@ func TestMockWithPost(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[map[string]any](t, w.Body)
assert.Equal(t, float64(1), result["id"])
assert.Equal(t, "Mock User", result["fullName"])
}
diff --git a/e2e/mutation_ctx_sharing_test.go b/e2e/mutation_ctx_sharing_test.go
index 8f322a9..e41e77b 100644
--- a/e2e/mutation_ctx_sharing_test.go
+++ b/e2e/mutation_ctx_sharing_test.go
@@ -1,14 +1,12 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func TestMutationCtxSharing(t *testing.T) {
@@ -25,7 +23,6 @@ func TestMutationCtxSharing(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[map[string]any](t, w.Body)
assert.Equal(t, "alice@example.com", result["inputEmail"])
}
diff --git a/e2e/mutation_dynamic_sql_test.go b/e2e/mutation_dynamic_sql_test.go
index 95d4227..b03bf6f 100644
--- a/e2e/mutation_dynamic_sql_test.go
+++ b/e2e/mutation_dynamic_sql_test.go
@@ -1,14 +1,12 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func TestMutationDynamicSql(t *testing.T) {
@@ -26,8 +24,7 @@ func TestMutationDynamicSql(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[map[string]any](t, w.Body)
assert.Equal(t, "Updated", result["first_name"])
assert.Equal(t, "Doe", result["last_name"]) // unchanged
})
@@ -41,8 +38,7 @@ func TestMutationDynamicSql(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[map[string]any](t, w.Body)
assert.Equal(t, "Janet", result["first_name"])
assert.Equal(t, "Smith", result["last_name"]) // unchanged
assert.Equal(t, "janet@example.com", result["email"])
diff --git a/e2e/mutation_many_post_all_test.go b/e2e/mutation_many_post_all_test.go
index c3e17dc..5b3a37f 100644
--- a/e2e/mutation_many_post_all_test.go
+++ b/e2e/mutation_many_post_all_test.go
@@ -1,14 +1,12 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func TestMutationManyPostAll(t *testing.T) {
@@ -25,8 +23,7 @@ func TestMutationManyPostAll(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result []map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[[]map[string]any](t, w.Body)
assert.Len(t, result, 2)
// DB uppercased, then post.all lowercased
assert.Equal(t, "john@example.com", result[0]["email"])
diff --git a/e2e/mutation_many_post_combined_test.go b/e2e/mutation_many_post_combined_test.go
index fcdc2e2..03a7e7c 100644
--- a/e2e/mutation_many_post_combined_test.go
+++ b/e2e/mutation_many_post_combined_test.go
@@ -1,14 +1,12 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func TestMutationManyPostCombined(t *testing.T) {
@@ -25,8 +23,7 @@ func TestMutationManyPostCombined(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[map[string]any](t, w.Body)
assert.Equal(t, float64(2), result["total"])
items := result["items"].([]any)
diff --git a/e2e/mutation_many_post_each_test.go b/e2e/mutation_many_post_each_test.go
index afd19ec..0123915 100644
--- a/e2e/mutation_many_post_each_test.go
+++ b/e2e/mutation_many_post_each_test.go
@@ -1,14 +1,12 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func TestMutationManyPostEach(t *testing.T) {
@@ -25,8 +23,7 @@ func TestMutationManyPostEach(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result []map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[[]map[string]any](t, w.Body)
assert.Len(t, result, 2)
// Check transformed fields
diff --git a/e2e/mutation_many_test.go b/e2e/mutation_many_test.go
index 8a0379f..222275a 100644
--- a/e2e/mutation_many_test.go
+++ b/e2e/mutation_many_test.go
@@ -1,14 +1,12 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func TestMutationMany(t *testing.T) {
@@ -25,7 +23,6 @@ func TestMutationMany(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result []map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[[]map[string]any](t, w.Body)
assert.Len(t, result, 2)
}
diff --git a/e2e/mutation_mock_many_test.go b/e2e/mutation_mock_many_test.go
index 803ec42..4a247d5 100644
--- a/e2e/mutation_mock_many_test.go
+++ b/e2e/mutation_mock_many_test.go
@@ -1,14 +1,12 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func TestMutationMockMany(t *testing.T) {
@@ -23,8 +21,7 @@ func TestMutationMockMany(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result []map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[[]map[string]any](t, w.Body)
assert.Len(t, result, 2)
assert.Equal(t, float64(1), result[0]["id"])
assert.Equal(t, "mock1@example.com", result[0]["email"])
diff --git a/e2e/mutation_mock_test.go b/e2e/mutation_mock_test.go
index 9df0e52..982e782 100644
--- a/e2e/mutation_mock_test.go
+++ b/e2e/mutation_mock_test.go
@@ -1,14 +1,12 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func TestMutationMock(t *testing.T) {
@@ -23,8 +21,7 @@ func TestMutationMock(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[map[string]any](t, w.Body)
assert.Equal(t, float64(999), result["id"])
assert.Equal(t, "Mock", result["first_name"])
assert.Equal(t, "User", result["last_name"])
diff --git a/e2e/mutation_one_last_insert_id_test.go b/e2e/mutation_one_last_insert_id_test.go
index 1c4a48b..31a2c14 100644
--- a/e2e/mutation_one_last_insert_id_test.go
+++ b/e2e/mutation_one_last_insert_id_test.go
@@ -1,7 +1,6 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"strings"
@@ -27,9 +26,7 @@ func TestMutationOneLastInsertId(t *testing.T) {
mux.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
- var result map[string]any
- err = json.NewDecoder(rec.Body).Decode(&result)
- require.NoError(t, err)
+ result := decodeJSON[map[string]any](t, rec.Body)
// In mock mode, lastInsertId and rowsAffected are not available
require.Equal(t, false, result["hasLastInsertId"])
diff --git a/e2e/mutation_one_null_result_test.go b/e2e/mutation_one_null_result_test.go
index b1af69b..2a84406 100644
--- a/e2e/mutation_one_null_result_test.go
+++ b/e2e/mutation_one_null_result_test.go
@@ -1,14 +1,12 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func TestMutationOneNullResult(t *testing.T) {
@@ -26,8 +24,7 @@ func TestMutationOneNullResult(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[map[string]any](t, w.Body)
assert.Equal(t, float64(1), result["id"])
assert.Equal(t, "John", result["first_name"])
})
diff --git a/e2e/mutation_one_test.go b/e2e/mutation_one_test.go
index 0e3585a..c6d55ba 100644
--- a/e2e/mutation_one_test.go
+++ b/e2e/mutation_one_test.go
@@ -1,14 +1,12 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func TestMutationOne(t *testing.T) {
@@ -25,8 +23,7 @@ func TestMutationOne(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[map[string]any](t, w.Body)
assert.Equal(t, "Alice", result["first_name"])
assert.Equal(t, "Brown", result["last_name"])
assert.Equal(t, "alice@example.com", result["email"])
diff --git a/e2e/mutation_post_transform_test.go b/e2e/mutation_post_transform_test.go
index 33a6aec..9c8f385 100644
--- a/e2e/mutation_post_transform_test.go
+++ b/e2e/mutation_post_transform_test.go
@@ -1,14 +1,12 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func TestMutationPostTransform(t *testing.T) {
@@ -25,8 +23,7 @@ func TestMutationPostTransform(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[map[string]any](t, w.Body)
assert.Equal(t, "Alice Brown", result["fullName"])
assert.Equal(t, "alice@example.com", result["email"])
assert.Equal(t, true, result["created"])
diff --git a/e2e/mutation_pre_transform_test.go b/e2e/mutation_pre_transform_test.go
index 14c3f69..3be958c 100644
--- a/e2e/mutation_pre_transform_test.go
+++ b/e2e/mutation_pre_transform_test.go
@@ -1,14 +1,12 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func TestMutationPreTransform(t *testing.T) {
@@ -26,8 +24,7 @@ func TestMutationPreTransform(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[map[string]any](t, w.Body)
assert.Equal(t, "Alice", result["first_name"])
assert.Equal(t, "Brown", result["last_name"])
assert.Equal(t, "alice@example.com", result["email"])
@@ -48,7 +45,6 @@ func TestMutationPreTransformValidationError(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, w.Code)
- var result map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[map[string]any](t, w.Body)
assert.Equal(t, "invalid email", result["message"])
}
diff --git a/e2e/not_found_handler_test.go b/e2e/not_found_handler_test.go
index 893be03..5d0427d 100644
--- a/e2e/not_found_handler_test.go
+++ b/e2e/not_found_handler_test.go
@@ -1,7 +1,6 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"testing"
@@ -35,8 +34,7 @@ func TestNotFoundHandler(t *testing.T) {
assert.Equal(t, http.StatusNotFound, w.Code)
- var result map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[map[string]any](t, w.Body)
assert.Equal(t, "not found", result["error"])
})
diff --git a/e2e/query_many_test.go b/e2e/query_many_test.go
index 01ada1c..3d114e4 100644
--- a/e2e/query_many_test.go
+++ b/e2e/query_many_test.go
@@ -1,13 +1,11 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func TestQueryMany(t *testing.T) {
@@ -22,7 +20,6 @@ func TestQueryMany(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result []map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[[]map[string]any](t, w.Body)
assert.Len(t, result, 3)
}
diff --git a/e2e/query_many_with_limit_test.go b/e2e/query_many_with_limit_test.go
index 6046bd3..ee2a1b0 100644
--- a/e2e/query_many_with_limit_test.go
+++ b/e2e/query_many_with_limit_test.go
@@ -1,13 +1,11 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func TestQueryManyWithLimit(t *testing.T) {
@@ -22,7 +20,6 @@ func TestQueryManyWithLimit(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result []map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[[]map[string]any](t, w.Body)
assert.Len(t, result, 2)
}
diff --git a/e2e/query_one_test.go b/e2e/query_one_test.go
index 724af0d..b052be8 100644
--- a/e2e/query_one_test.go
+++ b/e2e/query_one_test.go
@@ -1,13 +1,11 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func TestQueryOne(t *testing.T) {
@@ -22,8 +20,7 @@ func TestQueryOne(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[map[string]any](t, w.Body)
assert.Equal(t, "John", result["first_name"])
}
diff --git a/e2e/transform_ctx_sharing_test.go b/e2e/transform_ctx_sharing_test.go
index 5b5391f..8760b02 100644
--- a/e2e/transform_ctx_sharing_test.go
+++ b/e2e/transform_ctx_sharing_test.go
@@ -1,13 +1,11 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func TestTransformCtxSharing(t *testing.T) {
@@ -22,7 +20,6 @@ func TestTransformCtxSharing(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[map[string]any](t, w.Body)
assert.Equal(t, "1", result["requestedId"])
}
diff --git a/e2e/transform_dynamic_sql_test.go b/e2e/transform_dynamic_sql_test.go
index 3fb471b..0eb2987 100644
--- a/e2e/transform_dynamic_sql_test.go
+++ b/e2e/transform_dynamic_sql_test.go
@@ -1,13 +1,11 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func TestTransformDynamicSql(t *testing.T) {
@@ -23,8 +21,7 @@ func TestTransformDynamicSql(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result []map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[[]map[string]any](t, w.Body)
assert.Len(t, result, 3)
})
@@ -35,8 +32,7 @@ func TestTransformDynamicSql(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result []map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[[]map[string]any](t, w.Body)
assert.Len(t, result, 1)
assert.Equal(t, "john@example.com", result[0]["email"])
})
@@ -48,8 +44,7 @@ func TestTransformDynamicSql(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result []map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[[]map[string]any](t, w.Body)
assert.Len(t, result, 1)
assert.Equal(t, "jane@example.com", result[0]["email"])
})
diff --git a/e2e/transform_input_in_post_test.go b/e2e/transform_input_in_post_test.go
index 1d55b21..6094a77 100644
--- a/e2e/transform_input_in_post_test.go
+++ b/e2e/transform_input_in_post_test.go
@@ -1,13 +1,11 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func TestTransformInputInPost(t *testing.T) {
@@ -22,7 +20,6 @@ func TestTransformInputInPost(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[map[string]any](t, w.Body)
assert.Equal(t, "1", result["queriedId"])
}
diff --git a/e2e/transform_many_post_all_test.go b/e2e/transform_many_post_all_test.go
index 1a61f40..a62f7b4 100644
--- a/e2e/transform_many_post_all_test.go
+++ b/e2e/transform_many_post_all_test.go
@@ -1,13 +1,11 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func TestTransformManyPostAll(t *testing.T) {
@@ -22,8 +20,7 @@ func TestTransformManyPostAll(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result []map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[[]map[string]any](t, w.Body)
assert.Len(t, result, 2)
assert.Equal(t, "JOHN@EXAMPLE.COM", result[0]["email"])
}
diff --git a/e2e/transform_many_post_combined_test.go b/e2e/transform_many_post_combined_test.go
index 186c7c7..fec3efb 100644
--- a/e2e/transform_many_post_combined_test.go
+++ b/e2e/transform_many_post_combined_test.go
@@ -1,13 +1,11 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func TestTransformManyPostCombined(t *testing.T) {
@@ -22,8 +20,7 @@ func TestTransformManyPostCombined(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[map[string]any](t, w.Body)
assert.Equal(t, float64(2), result["total"])
items := result["items"].([]any)
diff --git a/e2e/transform_many_post_test.go b/e2e/transform_many_post_test.go
index 1194525..c48fde9 100644
--- a/e2e/transform_many_post_test.go
+++ b/e2e/transform_many_post_test.go
@@ -1,13 +1,11 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func TestTransformManyPost(t *testing.T) {
@@ -22,8 +20,7 @@ func TestTransformManyPost(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result []map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[[]map[string]any](t, w.Body)
assert.Len(t, result, 2)
assert.Equal(t, "JOHN@EXAMPLE.COM", result[0]["email"])
}
diff --git a/e2e/transform_one_not_found_with_post_test.go b/e2e/transform_one_not_found_with_post_test.go
index c7d2f37..b9246af 100644
--- a/e2e/transform_one_not_found_with_post_test.go
+++ b/e2e/transform_one_not_found_with_post_test.go
@@ -1,13 +1,11 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func TestTransformOneNotFoundWithPost(t *testing.T) {
@@ -23,8 +21,7 @@ func TestTransformOneNotFoundWithPost(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[map[string]any](t, w.Body)
assert.Equal(t, true, result["found"])
assert.Equal(t, float64(1), result["id"])
})
@@ -36,8 +33,7 @@ func TestTransformOneNotFoundWithPost(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[map[string]any](t, w.Body)
assert.Equal(t, false, result["found"])
assert.Equal(t, "guest", result["default"])
})
diff --git a/e2e/transform_post_error_test.go b/e2e/transform_post_error_test.go
index 8f48d9b..52d672f 100644
--- a/e2e/transform_post_error_test.go
+++ b/e2e/transform_post_error_test.go
@@ -1,13 +1,11 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func TestTransformPostError(t *testing.T) {
@@ -23,8 +21,7 @@ func TestTransformPostError(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[map[string]any](t, w.Body)
assert.Equal(t, float64(2), result["id"])
})
@@ -35,8 +32,7 @@ func TestTransformPostError(t *testing.T) {
assert.Equal(t, http.StatusForbidden, w.Code)
- var result map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[map[string]any](t, w.Body)
assert.Equal(t, "access denied", result["message"])
})
}
diff --git a/e2e/transform_post_test.go b/e2e/transform_post_test.go
index b81b277..cec22fe 100644
--- a/e2e/transform_post_test.go
+++ b/e2e/transform_post_test.go
@@ -1,13 +1,11 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func TestTransformPost(t *testing.T) {
@@ -22,8 +20,7 @@ func TestTransformPost(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[map[string]any](t, w.Body)
assert.Equal(t, "John Doe", result["fullName"])
assert.NotContains(t, result, "first_name")
}
diff --git a/e2e/transform_pre_error_test.go b/e2e/transform_pre_error_test.go
index 7f6f8cb..dc944a1 100644
--- a/e2e/transform_pre_error_test.go
+++ b/e2e/transform_pre_error_test.go
@@ -1,13 +1,11 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func TestTransformPreError(t *testing.T) {
@@ -23,8 +21,7 @@ func TestTransformPreError(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[map[string]any](t, w.Body)
assert.Equal(t, float64(1), result["id"])
})
@@ -35,8 +32,7 @@ func TestTransformPreError(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, w.Code)
- var result map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[map[string]any](t, w.Body)
assert.Equal(t, "id is required", result["message"])
})
}
diff --git a/e2e/transform_pre_test.go b/e2e/transform_pre_test.go
index 65a83f3..f288cd6 100644
--- a/e2e/transform_pre_test.go
+++ b/e2e/transform_pre_test.go
@@ -1,13 +1,11 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func TestTransformPre(t *testing.T) {
@@ -22,7 +20,6 @@ func TestTransformPre(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
- var result []map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[[]map[string]any](t, w.Body)
assert.Len(t, result, 2)
}
diff --git a/e2e/transform_throw_array_test.go b/e2e/transform_throw_array_test.go
index 8f749b7..c26297d 100644
--- a/e2e/transform_throw_array_test.go
+++ b/e2e/transform_throw_array_test.go
@@ -1,13 +1,11 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func TestTransformThrowArray(t *testing.T) {
@@ -22,8 +20,7 @@ func TestTransformThrowArray(t *testing.T) {
assert.Equal(t, http.StatusUnprocessableEntity, w.Code)
- var result []map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[[]map[string]any](t, w.Body)
assert.Len(t, result, 2)
assert.Equal(t, "id", result[0]["field"])
assert.Equal(t, "required", result[0]["message"])
diff --git a/e2e/transform_throw_boolean_test.go b/e2e/transform_throw_boolean_test.go
index 603b037..5226ee4 100644
--- a/e2e/transform_throw_boolean_test.go
+++ b/e2e/transform_throw_boolean_test.go
@@ -1,13 +1,11 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func TestTransformThrowBoolean(t *testing.T) {
@@ -22,7 +20,6 @@ func TestTransformThrowBoolean(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, w.Code)
- var result bool
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[bool](t, w.Body)
assert.Equal(t, false, result)
}
diff --git a/e2e/transform_throw_default_post_test.go b/e2e/transform_throw_default_post_test.go
index 477621e..da5d56b 100644
--- a/e2e/transform_throw_default_post_test.go
+++ b/e2e/transform_throw_default_post_test.go
@@ -1,13 +1,11 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func TestTransformThrowDefaultPost(t *testing.T) {
@@ -22,7 +20,6 @@ func TestTransformThrowDefaultPost(t *testing.T) {
assert.Equal(t, http.StatusInternalServerError, w.Code)
- var result map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[map[string]any](t, w.Body)
assert.Equal(t, "no status specified in post", result["message"])
}
diff --git a/e2e/transform_throw_default_pre_test.go b/e2e/transform_throw_default_pre_test.go
index a30f2d6..6665bd8 100644
--- a/e2e/transform_throw_default_pre_test.go
+++ b/e2e/transform_throw_default_pre_test.go
@@ -1,13 +1,11 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func TestTransformThrowDefaultPre(t *testing.T) {
@@ -22,7 +20,6 @@ func TestTransformThrowDefaultPre(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, w.Code)
- var result map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[map[string]any](t, w.Body)
assert.Equal(t, "no status specified in pre", result["message"])
}
diff --git a/e2e/transform_throw_number_test.go b/e2e/transform_throw_number_test.go
index e692765..47fefae 100644
--- a/e2e/transform_throw_number_test.go
+++ b/e2e/transform_throw_number_test.go
@@ -1,13 +1,11 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func TestTransformThrowNumber(t *testing.T) {
@@ -22,7 +20,6 @@ func TestTransformThrowNumber(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, w.Code)
- var result float64
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[float64](t, w.Body)
assert.Equal(t, float64(42), result)
}
diff --git a/e2e/transform_throw_object_test.go b/e2e/transform_throw_object_test.go
index f76d9fa..419a246 100644
--- a/e2e/transform_throw_object_test.go
+++ b/e2e/transform_throw_object_test.go
@@ -1,13 +1,11 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func TestTransformThrowObject(t *testing.T) {
@@ -22,8 +20,7 @@ func TestTransformThrowObject(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, w.Code)
- var result map[string]any
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[map[string]any](t, w.Body)
assert.Equal(t, "object error", result["message"])
assert.Equal(t, "INVALID", result["code"])
}
diff --git a/e2e/transform_throw_raw_string_post_test.go b/e2e/transform_throw_raw_string_post_test.go
index 0d78071..0505cd6 100644
--- a/e2e/transform_throw_raw_string_post_test.go
+++ b/e2e/transform_throw_raw_string_post_test.go
@@ -1,13 +1,11 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func TestTransformThrowRawStringPost(t *testing.T) {
@@ -23,7 +21,6 @@ func TestTransformThrowRawStringPost(t *testing.T) {
assert.Equal(t, http.StatusInternalServerError, w.Code)
- var result string
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[string](t, w.Body)
assert.Equal(t, "string error", result)
}
diff --git a/e2e/transform_throw_raw_string_pre_test.go b/e2e/transform_throw_raw_string_pre_test.go
index 330e6e0..7a23dce 100644
--- a/e2e/transform_throw_raw_string_pre_test.go
+++ b/e2e/transform_throw_raw_string_pre_test.go
@@ -1,13 +1,11 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func TestTransformThrowRawStringPre(t *testing.T) {
@@ -23,7 +21,6 @@ func TestTransformThrowRawStringPre(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, w.Code)
- var result string
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[string](t, w.Body)
assert.Equal(t, "string error", result)
}
diff --git a/e2e/transform_throw_string_test.go b/e2e/transform_throw_string_test.go
index df03be1..10ff0ff 100644
--- a/e2e/transform_throw_string_test.go
+++ b/e2e/transform_throw_string_test.go
@@ -1,13 +1,11 @@
package e2e
import (
- "encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func TestTransformThrowString(t *testing.T) {
@@ -22,7 +20,6 @@ func TestTransformThrowString(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, w.Code)
- var result string
- require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
+ result := decodeJSON[string](t, w.Body)
assert.Equal(t, "string error message", result)
}
diff --git a/go.mod b/go.mod
index bcd818d..c53bd03 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
module github.com/mpyw/sql-http-proxy
-go 1.24.0
+go 1.27.0
require (
github.com/a8m/envsubst v1.4.3
diff --git a/internal/cli/commands/app.go b/internal/cli/commands/app.go
index b3db5d3..6902615 100644
--- a/internal/cli/commands/app.go
+++ b/internal/cli/commands/app.go
@@ -23,6 +23,22 @@ import (
// ShutdownTimeout is the maximum time to wait for graceful shutdown.
const ShutdownTimeout = 30 * time.Second
+// Request header limits. These are stricter than the net/http defaults
+// (1MiB / 500 values) because every endpoint here is a SQL query keyed off a
+// small set of parameters - no legitimate client needs a large header block.
+const (
+ // MaxHeaderBytes caps the total size of the request header block.
+ MaxHeaderBytes = 64 * 1024
+ // MaxHeaderValueCount caps the number of header values in a request,
+ // bounding the per-connection allocation a client can force.
+ MaxHeaderValueCount = 100
+)
+
+// ReadHeaderTimeout is the maximum time allowed to read request headers.
+// Only the header phase is bounded: bodies and responses are left untimed so
+// that large uploads and slow queries are not cut off mid-flight.
+const ReadHeaderTimeout = 10 * time.Second
+
// Version is set by goreleaser via ldflags.
var Version = "dev"
@@ -99,8 +115,11 @@ func action(ctx context.Context, cmd *cli.Command) error {
}
srv := &http.Server{
- Addr: listen,
- Handler: mux,
+ Addr: listen,
+ Handler: mux,
+ ReadHeaderTimeout: ReadHeaderTimeout,
+ MaxHeaderBytes: MaxHeaderBytes,
+ MaxHeaderValueCount: MaxHeaderValueCount,
}
// Channel to receive server errors
diff --git a/internal/config/config.go b/internal/config/config.go
index e860088..2d2daf4 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -240,7 +240,7 @@ func Parse(data []byte) (Config, error) {
// Validate against JSON Schema
if err := configSchema.Validate(raw); err != nil {
- if validationErr := (*jsonschema.ValidationError)(nil); errors.As(err, &validationErr) {
+ if validationErr, ok := errors.AsType[*jsonschema.ValidationError](err); ok {
return Config{}, fmt.Errorf("config validation failed: %s", formatValidationError(validationErr))
}
return Config{}, fmt.Errorf("config validation failed: %w", err)
diff --git a/internal/config/errors.go b/internal/config/errors.go
index 7277870..6ae2b6c 100644
--- a/internal/config/errors.go
+++ b/internal/config/errors.go
@@ -201,8 +201,8 @@ func isMockPath(path string) bool {
}
func getParentPath(path string) string {
- if lastSlash := strings.LastIndex(path, "/"); lastSlash > 0 {
- return path[:lastSlash]
+ if parent, _, found := strings.CutLast(path, "/"); found && parent != "" {
+ return parent
}
return path
}
diff --git a/internal/js/apply.go b/internal/js/apply.go
index b3ba206..4a0ae96 100644
--- a/internal/js/apply.go
+++ b/internal/js/apply.go
@@ -96,19 +96,12 @@ func (t *Transformer) runCallable(vm *goja.Runtime, fnName string, args ...goja.
return nil, fmt.Errorf("%s is not a function", fnName)
}
- // Set up timeout to prevent infinite loops
- timer := time.AfterFunc(JSTimeout, func() {
- vm.Interrupt(ErrJSTimeout)
+ result, err := runWithTimeout(vm, func() (goja.Value, error) {
+ return callable(goja.Undefined(), args...)
})
- defer timer.Stop()
-
- result, err := callable(goja.Undefined(), args...)
if err != nil {
- // Check if error was due to timeout interrupt
- if interrupted := (*goja.InterruptedError)(nil); errors.As(err, &interrupted) {
- if timeoutErr, ok := interrupted.Value().(error); ok && errors.Is(timeoutErr, ErrJSTimeout) {
- return nil, ErrJSTimeout
- }
+ if errors.Is(err, ErrJSTimeout) {
+ return nil, err
}
return nil, parseJSError(err)
}
@@ -116,21 +109,13 @@ func (t *Transformer) runCallable(vm *goja.Runtime, fnName string, args ...goja.
return result, nil
}
-// readBackCtx reads ctx from globalThis, returning fallback if not found.
-func readBackCtx(globalThis *goja.Object, fallback map[string]any) map[string]any {
- if ctxVal := globalThis.Get("ctx"); ctxVal != nil && !goja.IsUndefined(ctxVal) {
- if c, ok := ctxVal.Export().(map[string]any); ok {
- return c
- }
- }
- return fallback
-}
-
-// readBackSQL reads sql from globalThis, returning fallback if not found.
-func readBackSQL(globalThis *goja.Object, fallback string) string {
- if sqlVal := globalThis.Get("sql"); sqlVal != nil && !goja.IsUndefined(sqlVal) {
- if s, ok := sqlVal.Export().(string); ok {
- return s
+// readBack reads the global name from globalThis, returning fallback unless it
+// is set and exports to T. Transforms are free to reassign or clobber the
+// globals they were handed, so anything unexpected falls back to the input.
+func readBack[T any](globalThis *goja.Object, name string, fallback T) T {
+ if val := globalThis.Get(name); val != nil && !goja.IsUndefined(val) {
+ if v, ok := val.Export().(T); ok {
+ return v
}
}
return fallback
@@ -163,8 +148,8 @@ func (t *Transformer) ApplyPre(ctx map[string]any, sql string, input map[string]
return &PreTransformResult{
Output: output,
- SQL: readBackSQL(globalThis, sql),
- Ctx: readBackCtx(globalThis, ctx),
+ SQL: readBack(globalThis, "sql", sql),
+ Ctx: readBack(globalThis, "ctx", ctx),
}, nil
}
@@ -185,7 +170,7 @@ func (t *Transformer) ApplyMock(ctx map[string]any, sql string, input map[string
return &MockResult{
Output: result.Export(),
- Ctx: readBackCtx(globalThis, ctx),
+ Ctx: readBack(globalThis, "ctx", ctx),
}, nil
}
@@ -211,7 +196,7 @@ func (t *Transformer) ApplyPost(ctx map[string]any, input map[string]any, output
return &PostTransformResult{
Output: result.Export(),
- Ctx: readBackCtx(globalThis, ctx),
+ Ctx: readBack(globalThis, "ctx", ctx),
}, nil
}
diff --git a/internal/js/apply_test.go b/internal/js/apply_test.go
index f6e2f4d..8c1ceac 100644
--- a/internal/js/apply_test.go
+++ b/internal/js/apply_test.go
@@ -290,6 +290,8 @@ func TestApplyPostToAllRows(t *testing.T) {
}
func TestJSTimeout(t *testing.T) {
+ t.Parallel() // JSTimeout is 5s; overlap with the other slow timeout test.
+
t.Run("infinite loop times out", func(t *testing.T) {
// This test verifies that infinite loops are interrupted
// Note: JSTimeout is 5 seconds by default
diff --git a/internal/js/error.go b/internal/js/error.go
index bfad9fc..09dd437 100644
--- a/internal/js/error.go
+++ b/internal/js/error.go
@@ -47,8 +47,8 @@ func toHTTPStatus(v any) int {
// Supports Lambda-style format: throw { status: 400, body: "message" } or throw { status: 400, body: { message: "error" } }
// Native Error objects (throw new Error("...")) always return 500.
func parseJSError(err error) error {
- var jsErr *goja.Exception
- if !errors.As(err, &jsErr) {
+ jsErr, ok := errors.AsType[*goja.Exception](err)
+ if !ok {
return fmt.Errorf("transform error: %w", err)
}
diff --git a/internal/js/pool.go b/internal/js/pool.go
new file mode 100644
index 0000000..3aaac16
--- /dev/null
+++ b/internal/js/pool.go
@@ -0,0 +1,168 @@
+package js
+
+import (
+ "errors"
+ "fmt"
+ "sync"
+ "time"
+
+ "github.com/dop251/goja"
+)
+
+// PooledVM reuses goja runtimes across calls to a single compiled function.
+//
+// It exists for the hot paths - CSV value parsing and mock row filtering - that
+// invoke the same short function once per row. Building a runtime and injecting
+// helpers per row dominates the cost there, so runtimes are pooled instead.
+type PooledVM struct {
+ name string
+ program *goja.Program
+ helpers *CompiledHelpers
+ pool sync.Pool
+}
+
+// pooledCallable is a runtime paired with the callable compiled into it.
+// A callable is only ever invoked on the runtime it came from.
+type pooledCallable struct {
+ vm *goja.Runtime
+ callable goja.Callable
+}
+
+// NewPooledVM creates a PooledVM for a program that evaluates to a function.
+// name is used in error messages.
+func NewPooledVM(name string, program *goja.Program, helpers *CompiledHelpers) *PooledVM {
+ p := &PooledVM{name: name, program: program, helpers: helpers}
+ p.pool.New = func() any {
+ // sync.Pool.New cannot report failure. Return nil and let Call surface
+ // the real error by building a runtime directly.
+ pc, err := p.newCallable()
+ if err != nil {
+ return nil
+ }
+ return pc
+ }
+ return p
+}
+
+// newCallable creates a runtime with helpers injected and the program's
+// function resolved.
+func (p *PooledVM) newCallable() (*pooledCallable, error) {
+ vm := goja.New()
+
+ if p.helpers != nil {
+ if err := p.helpers.InjectInto(vm); err != nil {
+ return nil, fmt.Errorf("failed to inject helpers into %s: %w", p.name, err)
+ }
+ }
+
+ fn, err := vm.RunProgram(p.program)
+ if err != nil {
+ return nil, fmt.Errorf("failed to load %s: %w", p.name, err)
+ }
+
+ callable, ok := goja.AssertFunction(fn)
+ if !ok {
+ return nil, fmt.Errorf("%s is not a function", p.name)
+ }
+
+ return &pooledCallable{vm: vm, callable: callable}, nil
+}
+
+// Call invokes the pooled function and converts its result with convert.
+//
+// convert runs while the runtime is still checked out, because a goja.Value
+// belongs to the runtime that produced it and must not outlive its return to
+// the pool. That is why the result type is a parameter of this method rather
+// than the caller unwrapping a goja.Value afterwards: passing
+// goja.Value.ToBoolean or goja.Value.Export pins the conversion to the right
+// scope. globals are set on the runtime before the call; pass nil for none.
+//
+// Execution is bounded by JSTimeout; exceeding it returns ErrJSTimeout.
+func (p *PooledVM) Call[T any](globals map[string]any, convert func(goja.Value) T, args ...any) (T, error) {
+ var zero T
+
+ pc, ok := p.pool.Get().(*pooledCallable)
+ if !ok {
+ // pool.New failed; rebuild directly so the error can be reported.
+ var err error
+ if pc, err = p.newCallable(); err != nil {
+ return zero, err
+ }
+ }
+
+ // A runtime is only pooled again once this call is known to have left it in
+ // a good state: a timeout aborted the script mid-execution, and a panic
+ // says nothing about what the runtime is holding.
+ reusable := false
+ defer func() {
+ if reusable {
+ p.pool.Put(pc)
+ }
+ }()
+
+ for name, value := range globals {
+ if err := pc.vm.Set(name, value); err != nil {
+ return zero, fmt.Errorf("failed to set %s in %s: %w", name, p.name, err)
+ }
+ }
+
+ jsArgs := make([]goja.Value, len(args))
+ for i, arg := range args {
+ jsArgs[i] = pc.vm.ToValue(arg)
+ }
+
+ result, err := runWithTimeout(pc.vm, func() (goja.Value, error) {
+ return pc.callable(goja.Undefined(), jsArgs...)
+ })
+ if err != nil {
+ reusable = !errors.Is(err, ErrJSTimeout)
+ return zero, err
+ }
+
+ reusable = true
+ return convert(result), nil
+}
+
+// runWithTimeout runs fn with a watchdog that interrupts vm after JSTimeout,
+// translating the interrupt into ErrJSTimeout.
+//
+// The watchdog is disarmed under a mutex and the interrupt flag is cleared
+// before returning. Both matter for reuse: goja.Runtime.Interrupt sets a flag
+// that stays set if it lands after the script already finished, and a runtime
+// carrying a stale flag aborts the very next call made on it. time.Timer.Stop
+// alone does not close that window because it does not wait for an
+// already-running timer function.
+func runWithTimeout(vm *goja.Runtime, fn func() (goja.Value, error)) (goja.Value, error) {
+ var mu sync.Mutex
+ done := false
+
+ timer := time.AfterFunc(JSTimeout, func() {
+ mu.Lock()
+ defer mu.Unlock()
+ if done {
+ return
+ }
+ vm.Interrupt(ErrJSTimeout)
+ })
+
+ // Deferred so the runtime is left usable even if fn panics.
+ defer func() {
+ timer.Stop()
+ mu.Lock()
+ done = true
+ mu.Unlock()
+ vm.ClearInterrupt()
+ }()
+
+ result, err := fn()
+ if err != nil {
+ if interrupted, ok := errors.AsType[*goja.InterruptedError](err); ok {
+ if timeoutErr, ok := interrupted.Value().(error); ok && errors.Is(timeoutErr, ErrJSTimeout) {
+ return nil, ErrJSTimeout
+ }
+ }
+ return nil, err
+ }
+
+ return result, nil
+}
diff --git a/internal/js/pool_test.go b/internal/js/pool_test.go
new file mode 100644
index 0000000..72ea896
--- /dev/null
+++ b/internal/js/pool_test.go
@@ -0,0 +1,100 @@
+package js
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/dop251/goja"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func newTestPool(t *testing.T, name, src string) *PooledVM {
+ t.Helper()
+
+ program, err := goja.Compile(name, src, true)
+ require.NoError(t, err)
+ return NewPooledVM(name, program, nil)
+}
+
+func TestPooledVM_Call(t *testing.T) {
+ t.Run("converts to the type the caller asked for", func(t *testing.T) {
+ p := newTestPool(t, "double", `(function(n) { return n * 2 })`)
+
+ n, err := p.Call(nil, goja.Value.ToInteger, 21)
+ require.NoError(t, err)
+ assert.Equal(t, int64(42), n)
+
+ s, err := p.Call(nil, goja.Value.String, 21)
+ require.NoError(t, err)
+ assert.Equal(t, "42", s)
+
+ v, err := p.Call(nil, goja.Value.Export, 21)
+ require.NoError(t, err)
+ assert.Equal(t, int64(42), v)
+ })
+
+ t.Run("reads globals set per call", func(t *testing.T) {
+ p := newTestPool(t, "readCtx", `(function() { return ctx.n })`)
+
+ // The second call reuses the pooled runtime, so ctx must be refreshed
+ // rather than left over from the first call.
+ for _, want := range []int64{1, 2, 3} {
+ got, err := p.Call(map[string]any{"ctx": map[string]any{"n": want}}, goja.Value.ToInteger)
+ require.NoError(t, err)
+ assert.Equal(t, want, got)
+ }
+ })
+
+ t.Run("reports a program that is not a function", func(t *testing.T) {
+ p := newTestPool(t, "notFn", `42`)
+
+ _, err := p.Call(nil, goja.Value.Export)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "not a function")
+ })
+
+ t.Run("propagates a thrown error", func(t *testing.T) {
+ p := newTestPool(t, "thrower", `(function() { throw new Error("boom") })`)
+
+ _, err := p.Call(nil, goja.Value.Export)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "boom")
+ })
+}
+
+func TestRunWithTimeout_ClearsLateInterrupt(t *testing.T) {
+ // goja.Runtime.Interrupt leaves its flag set if it lands when no script is
+ // running, and the flag then aborts the next call made on that runtime.
+ // The watchdog can lose that race with a script that finishes right at the
+ // deadline, so runWithTimeout must hand back a runtime with a clear flag -
+ // otherwise a single near-timeout call would poison a pooled runtime for
+ // every request that followed.
+ vm := goja.New()
+ vm.Interrupt(ErrJSTimeout)
+
+ _, err := runWithTimeout(vm, func() (goja.Value, error) {
+ return goja.Undefined(), nil
+ })
+ require.NoError(t, err)
+
+ v, err := vm.RunString(`1 + 1`)
+ require.NoError(t, err, "runtime still carries an interrupt flag")
+ assert.Equal(t, int64(2), v.ToInteger())
+}
+
+func TestPooledVM_Call_Timeout(t *testing.T) {
+ t.Parallel() // JSTimeout is 5s; overlap with the other slow timeout test.
+
+ p := newTestPool(t, "maybeSpin", `(function(spin) { if (spin) { while (true) {} } return 42 })`)
+
+ _, err := p.Call(nil, goja.Value.ToInteger, true)
+ require.Error(t, err)
+ assert.True(t, errors.Is(err, ErrJSTimeout), "expected timeout error, got: %v", err)
+
+ // The timed-out runtime is discarded rather than pooled, so later calls
+ // must still succeed instead of inheriting the interrupt.
+ n, err := p.Call(nil, goja.Value.ToInteger, false)
+ require.NoError(t, err)
+ assert.Equal(t, int64(42), n)
+}
diff --git a/internal/js/request.go b/internal/js/request.go
index 0dcf76e..e666f1a 100644
--- a/internal/js/request.go
+++ b/internal/js/request.go
@@ -17,10 +17,12 @@ type Request struct {
}
// NewRequest creates a new Request object from an HTTP request.
+// The URL is cloned so the value JS observes is a snapshot, independent of any
+// later rewriting of r.URL by middleware or the router.
func NewRequest(r *http.Request) *Request {
return &Request{
method: r.Method,
- url: r.URL,
+ url: r.URL.Clone(),
headers: NewReadonlyHeaders(r.Header),
}
}
diff --git a/internal/mock/csv.go b/internal/mock/csv.go
index b55568b..63d9bc3 100644
--- a/internal/mock/csv.go
+++ b/internal/mock/csv.go
@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"log/slog"
+ "maps"
"os"
"strings"
@@ -43,11 +44,7 @@ func parseCSVFileWithOptions(path string, opts parseCSVOptions) (*csvSource, err
if err != nil {
return nil, err
}
- defer func() {
- if err := f.Close(); err != nil {
- slog.Debug("Failed to close file", "error", err)
- }
- }()
+ defer closeQuietly(f)
return parseCSVReaderWithOptions(f, opts)
}
@@ -135,9 +132,7 @@ func (s *csvSource) Data(_ map[string]any, _ string, _ map[string]any, _ *js.Tra
result := make([]map[string]any, len(s.rows))
for i, row := range s.rows {
rowCopy := make(map[string]any, len(row))
- for k, v := range row {
- rowCopy[k] = v
- }
+ maps.Copy(rowCopy, row)
result[i] = rowCopy
}
return result, nil, nil
diff --git a/internal/mock/filter.go b/internal/mock/filter.go
index cd54448..bb45664 100644
--- a/internal/mock/filter.go
+++ b/internal/mock/filter.go
@@ -1,10 +1,6 @@
package mock
import (
- "errors"
- "sync"
- "time"
-
"github.com/dop251/goja"
"github.com/samber/lo"
@@ -13,16 +9,8 @@ import (
// jsFilteredSource wraps a Source and filters data using JavaScript.
type jsFilteredSource struct {
- source Source
- program *goja.Program
- helpers *js.CompiledHelpers
- pool sync.Pool
-}
-
-// filterVM holds a cached VM and callable for reuse.
-type filterVM struct {
- vm *goja.Runtime
- callable goja.Callable
+ source Source
+ vms *js.PooledVM
}
// newJSFilteredSource creates a jsFilteredSource that filters by JavaScript code.
@@ -36,42 +24,10 @@ func newJSFilteredSource(source Source, filterJS string, helpers *js.CompiledHel
return nil, err
}
- f := &jsFilteredSource{
- source: source,
- program: program,
- helpers: helpers,
- }
- f.pool.New = func() any {
- vm, callable, err := f.createVM()
- if err != nil {
- return nil
- }
- return &filterVM{vm: vm, callable: callable}
- }
- return f, nil
-}
-
-// createVM creates a new VM with helpers and callable.
-func (f *jsFilteredSource) createVM() (*goja.Runtime, goja.Callable, error) {
- vm := goja.New()
-
- if f.helpers != nil {
- if err := f.helpers.InjectInto(vm); err != nil {
- return nil, nil, err
- }
- }
-
- val, err := vm.RunProgram(f.program)
- if err != nil {
- return nil, nil, err
- }
-
- callable, ok := goja.AssertFunction(val)
- if !ok {
- return nil, nil, nil
- }
-
- return vm, callable, nil
+ return &jsFilteredSource{
+ source: source,
+ vms: js.NewPooledVM("filter", program, helpers),
+ }, nil
}
// Data returns filtered data based on JavaScript filter function.
@@ -129,50 +85,12 @@ func (f *jsFilteredSource) filterArray(arr []any, input map[string]any, ctx map[
}
// evaluateFilter evaluates the filter function for a single row.
-// Execution is limited to JSTimeout to prevent infinite loops.
+// Execution is limited to js.JSTimeout to prevent infinite loops.
func (f *jsFilteredSource) evaluateFilter(row any, input map[string]any, ctx map[string]any) (bool, error) {
- // Get or create a VM from pool
- fvm := f.pool.Get()
- if fvm == nil {
- // Pool.New failed, create directly
- vm, callable, err := f.createVM()
- if err != nil {
- return false, err
- }
- if callable == nil {
- return false, nil
- }
- fvm = &filterVM{vm: vm, callable: callable}
- }
- cached := fvm.(*filterVM)
- defer f.pool.Put(cached)
-
- // Set ctx as a free variable (can be updated on reused VM)
+ // ctx is a free variable, refreshed per call because the pooled runtime may
+ // still hold the previous caller's value.
if ctx == nil {
ctx = make(map[string]any)
}
- if err := cached.vm.Set("ctx", ctx); err != nil {
- return false, err
- }
-
- // Set up timeout to prevent infinite loops
- timer := time.AfterFunc(js.JSTimeout, func() {
- cached.vm.Interrupt(js.ErrJSTimeout)
- })
- defer timer.Stop()
-
- // Call the function with row and input
- result, err := cached.callable(goja.Undefined(), cached.vm.ToValue(row), cached.vm.ToValue(input))
- if err != nil {
- // Check if error was due to timeout interrupt
- if interrupted := (*goja.InterruptedError)(nil); errors.As(err, &interrupted) {
- if timeoutErr, ok := interrupted.Value().(error); ok && errors.Is(timeoutErr, js.ErrJSTimeout) {
- return false, js.ErrJSTimeout
- }
- }
- return false, err
- }
-
- // Convert result to boolean
- return result.ToBoolean(), nil
+ return f.vms.Call(map[string]any{"ctx": ctx}, goja.Value.ToBoolean, row, input)
}
diff --git a/internal/mock/json.go b/internal/mock/json.go
index dbb1ff5..d477fec 100644
--- a/internal/mock/json.go
+++ b/internal/mock/json.go
@@ -1,8 +1,9 @@
package mock
import (
- "bufio"
- "encoding/json"
+ "encoding/json/jsontext"
+ json "encoding/json/v2"
+ "errors"
"io"
"log/slog"
"os"
@@ -22,11 +23,7 @@ type jsonSource struct {
func newJSON(data any) (*jsonSource, error) {
// If data is a string, parse it as JSON
if s, ok := data.(string); ok {
- var parsed any
- if err := json.Unmarshal([]byte(s), &parsed); err != nil {
- return nil, err
- }
- return &jsonSource{data: parsed}, nil
+ return parseJSONString(s)
}
return &jsonSource{data: data}, nil
}
@@ -46,14 +43,10 @@ func parseJSONFile(path string) (*jsonSource, error) {
if err != nil {
return nil, err
}
- defer func() {
- if err := f.Close(); err != nil {
- slog.Debug("Failed to close file", "error", err)
- }
- }()
+ defer closeQuietly(f)
var data any
- if err := json.NewDecoder(f).Decode(&data); err != nil {
+ if err := json.UnmarshalRead(f, &data); err != nil {
return nil, err
}
@@ -72,38 +65,39 @@ func parseJSONLFile(path string) (*jsonSource, error) {
if err != nil {
return nil, err
}
- defer func() {
- if err := f.Close(); err != nil {
- slog.Debug("Failed to close file", "error", err)
- }
- }()
+ defer closeQuietly(f)
return parseJSONLReader(f)
}
+// parseJSONLReader decodes a stream of whitespace-separated JSON values.
+//
+// A jsontext.Decoder consumes the reader incrementally, so rows are never
+// buffered as whole lines. This also removes the bufio.Scanner token limit that
+// previously made any JSONL row larger than 64KiB fail to parse.
func parseJSONLReader(r io.Reader) (*jsonSource, error) {
var rows []any
- scanner := bufio.NewScanner(r)
-
- for scanner.Scan() {
- line := strings.TrimSpace(scanner.Text())
- if line == "" {
- continue // Skip empty lines
- }
-
- var obj any
- if err := json.Unmarshal([]byte(line), &obj); err != nil {
+ dec := jsontext.NewDecoder(r)
+
+ for {
+ var row any
+ if err := json.UnmarshalDecode(dec, &row); err != nil {
+ if errors.Is(err, io.EOF) {
+ break
+ }
return nil, err
}
- rows = append(rows, obj)
- }
-
- if err := scanner.Err(); err != nil {
- return nil, err
+ rows = append(rows, row)
}
return &jsonSource{data: rows}, nil
}
+func closeQuietly(f *os.File) {
+ if err := f.Close(); err != nil {
+ slog.Debug("Failed to close file", "error", err)
+ }
+}
+
// Data returns the parsed JSON data.
// ctx, sql, and tc are ignored for static data sources.
func (s *jsonSource) Data(_ map[string]any, _ string, _ map[string]any, _ *js.TransformContext) (any, map[string]any, error) {
diff --git a/internal/mock/json_test.go b/internal/mock/json_test.go
index 3a73393..539d0cc 100644
--- a/internal/mock/json_test.go
+++ b/internal/mock/json_test.go
@@ -1,8 +1,10 @@
package mock
import (
+ "fmt"
"os"
"path/filepath"
+ "strings"
"testing"
"github.com/samber/lo"
@@ -257,3 +259,38 @@ func TestParseJSONLFile(t *testing.T) {
require.Error(t, err)
})
}
+
+func TestParseJSONL_LargeRow(t *testing.T) {
+ // The previous bufio.Scanner-based reader capped a line at 64KiB and failed
+ // with "token too long". A jsontext.Decoder has no such limit.
+ name := strings.Repeat("x", 128*1024)
+ source, err := parseJSONL(fmt.Sprintf(`{"name":%q}`+"\n", name))
+ require.NoError(t, err)
+
+ rows := source.data.([]any)
+ require.Len(t, rows, 1)
+ assert.Equal(t, name, rows[0].(map[string]any)["name"])
+}
+
+func TestParseJSONL_RejectsIllFormed(t *testing.T) {
+ tests := []struct {
+ name string
+ data string
+ }{
+ {"duplicate object member", `{"id":1,"id":2}`},
+ {"invalid UTF-8", "{\"name\":\"\xff\xfe\"}"},
+ {"truncated value", `{"id":1`},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ _, err := parseJSONL(tt.data)
+ require.Error(t, err)
+ })
+ }
+}
+
+func TestParseJSONString_RejectsDuplicateMember(t *testing.T) {
+ _, err := parseJSONString(`{"id":1,"id":2}`)
+ require.Error(t, err)
+}
diff --git a/internal/mock/value_parser.go b/internal/mock/value_parser.go
index 1795703..e798f04 100644
--- a/internal/mock/value_parser.go
+++ b/internal/mock/value_parser.go
@@ -1,10 +1,7 @@
package mock
import (
- "errors"
"fmt"
- "sync"
- "time"
"github.com/dop251/goja"
@@ -13,15 +10,7 @@ import (
// ValueParser parses CSV cell values using custom JavaScript.
type ValueParser struct {
- program *goja.Program
- helpers *js.CompiledHelpers
- pool sync.Pool
-}
-
-// valueParserVM holds a cached VM and callable for reuse.
-type valueParserVM struct {
- vm *goja.Runtime
- callable goja.Callable
+ vms *js.PooledVM
}
// CompileValueParser compiles a custom value parser from inline JavaScript code.
@@ -37,73 +26,11 @@ func CompileValueParser(jsCode string, helpers *js.CompiledHelpers) (*ValueParse
return nil, fmt.Errorf("failed to compile value_parser: %w", err)
}
- p := &ValueParser{program: program, helpers: helpers}
- p.pool.New = func() any {
- vm, callable, err := p.createVM()
- if err != nil {
- return nil
- }
- return &valueParserVM{vm: vm, callable: callable}
- }
- return p, nil
-}
-
-// createVM creates a new VM with helpers and callable.
-func (p *ValueParser) createVM() (*goja.Runtime, goja.Callable, error) {
- vm := goja.New()
-
- // Inject helpers first
- if p.helpers != nil {
- if err := p.helpers.InjectInto(vm); err != nil {
- return nil, nil, fmt.Errorf("failed to inject helpers: %w", err)
- }
- }
-
- fn, err := vm.RunProgram(p.program)
- if err != nil {
- return nil, nil, fmt.Errorf("failed to load value_parser: %w", err)
- }
-
- callable, ok := goja.AssertFunction(fn)
- if !ok {
- return nil, nil, fmt.Errorf("value_parser is not a function")
- }
-
- return vm, callable, nil
+ return &ValueParser{vms: js.NewPooledVM("value_parser", program, helpers)}, nil
}
// Parse parses a single cell value using the custom JS.
-// Execution is limited to JSTimeout to prevent infinite loops.
+// Execution is limited to js.JSTimeout to prevent infinite loops.
func (p *ValueParser) Parse(value string) (any, error) {
- // Get or create a VM from pool
- vpvm := p.pool.Get()
- if vpvm == nil {
- // Pool.New failed, create directly
- vm, callable, err := p.createVM()
- if err != nil {
- return nil, err
- }
- vpvm = &valueParserVM{vm: vm, callable: callable}
- }
- cached := vpvm.(*valueParserVM)
- defer p.pool.Put(cached)
-
- // Set up timeout to prevent infinite loops
- timer := time.AfterFunc(js.JSTimeout, func() {
- cached.vm.Interrupt(js.ErrJSTimeout)
- })
- defer timer.Stop()
-
- result, err := cached.callable(goja.Undefined(), cached.vm.ToValue(value))
- if err != nil {
- // Check if error was due to timeout interrupt
- if interrupted := (*goja.InterruptedError)(nil); errors.As(err, &interrupted) {
- if timeoutErr, ok := interrupted.Value().(error); ok && errors.Is(timeoutErr, js.ErrJSTimeout) {
- return nil, js.ErrJSTimeout
- }
- }
- return nil, err
- }
-
- return result.Export(), nil
+ return p.vms.Call(nil, goja.Value.Export, value)
}
diff --git a/internal/server/body/json.go b/internal/server/body/json.go
index 1cc8cb4..9e39c8b 100644
--- a/internal/server/body/json.go
+++ b/internal/server/body/json.go
@@ -1,7 +1,7 @@
package body
import (
- "encoding/json"
+ json "encoding/json/v2"
"fmt"
"io"
)
@@ -17,7 +17,14 @@ func parseJSON(body io.Reader, charsetName string) (map[string]any, error) {
}
// parseJSONBytes parses JSON from bytes.
-// Returns empty map if data is empty (common for DELETE requests).
+// Returns empty map if data is empty (common for DELETE/GET requests).
+//
+// Decoding uses encoding/json/v2 defaults, which follow RFC 7493 (I-JSON):
+// duplicate object member names and invalid UTF-8 are rejected rather than
+// silently accepted. Rejecting duplicates matters here because the result is
+// used directly as SQL bind parameters - under v1, `{"id":1,"id":2}` would
+// silently bind the last occurrence, letting a later member override an
+// earlier one that a caller (or an intermediary) believed it had set.
func parseJSONBytes(data []byte) (map[string]any, error) {
// Empty body is valid - return empty map (common for DELETE/GET requests)
if len(data) == 0 {
diff --git a/internal/server/body/parser_test.go b/internal/server/body/parser_test.go
index e422e36..32004b6 100644
--- a/internal/server/body/parser_test.go
+++ b/internal/server/body/parser_test.go
@@ -71,6 +71,47 @@ func TestParser_Parse_JSON(t *testing.T) {
})
}
+func TestParser_Parse_JSON_RejectsIllFormed(t *testing.T) {
+ // encoding/json/v2 enforces RFC 7493 by default. Both cases below were
+ // silently accepted by encoding/json v1, which mattered because the parsed
+ // map becomes the SQL bind parameters.
+ tests := []struct {
+ name string
+ body string
+ }{
+ // v1 kept the last "id", letting a duplicate member override a
+ // parameter the caller had already set.
+ {"duplicate object member", `{"id":1,"id":2}`},
+ {"duplicate member in nested object", `{"a":{"id":1,"id":2}}`},
+ // v1 replaced invalid bytes with U+FFFD and carried on.
+ {"invalid UTF-8 in value", "{\"name\":\"\xff\xfe\"}"},
+ {"invalid UTF-8 in member name", "{\"\xff\xfe\":1}"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ p := NewParser([]config.AcceptType{config.AcceptJSON})
+ req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(tt.body))
+ req.Header.Set("Content-Type", "application/json")
+
+ _, err := p.Parse(req)
+ require.Error(t, err)
+ assert.ErrorIs(t, err, ErrBadRequest)
+ assert.Contains(t, err.Error(), "invalid JSON")
+ })
+ }
+}
+
+func TestParser_Parse_JSON_RejectsTrailingContent(t *testing.T) {
+ p := NewParser([]config.AcceptType{config.AcceptJSON})
+ req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"a":1} {"b":2}`))
+ req.Header.Set("Content-Type", "application/json")
+
+ _, err := p.Parse(req)
+ require.Error(t, err)
+ assert.ErrorIs(t, err, ErrBadRequest)
+}
+
func TestParser_Parse_FormURLEncoded(t *testing.T) {
t.Run("valid form data", func(t *testing.T) {
p := NewParser([]config.AcceptType{config.AcceptForm})
diff --git a/internal/server/handler.go b/internal/server/handler.go
index 5955637..15d341c 100644
--- a/internal/server/handler.go
+++ b/internal/server/handler.go
@@ -143,7 +143,7 @@ func (h *baseHandler[R]) handleError(res *responder, err error) {
res.Error(http.StatusNotFound, errors.New("not found"))
return
}
- if transformErr := (*js.TransformError)(nil); errors.As(err, &transformErr) {
+ if transformErr, ok := errors.AsType[*js.TransformError](err); ok {
status := lo.CoalesceOrEmpty(transformErr.Status, defaultStatusForPhase(err))
res.Respond(status, transformErr.Body)
return
@@ -180,7 +180,7 @@ func (h *baseHandler[R]) applyResponseHeaders(w http.ResponseWriter, headers htt
}
func defaultStatusForPhase(err error) int {
- if phaseErr := (*executor.PhaseError)(nil); errors.As(err, &phaseErr) && phaseErr.Phase == "pre" {
+ if phaseErr, ok := errors.AsType[*executor.PhaseError](err); ok && phaseErr.Phase == "pre" {
return http.StatusBadRequest
}
return http.StatusInternalServerError
diff --git a/internal/server/responder.go b/internal/server/responder.go
index d40a553..2bfe4f3 100644
--- a/internal/server/responder.go
+++ b/internal/server/responder.go
@@ -1,7 +1,8 @@
package server
import (
- "encoding/json"
+ "encoding/json/jsontext"
+ json "encoding/json/v2"
"fmt"
"log/slog"
"net/http"
@@ -9,6 +10,29 @@ import (
"github.com/samber/lo"
)
+// marshalOptions pins encoding/json/v2 to the response bytes v1 produced.
+// Responses are this project's public API, so v2's stricter or tidier defaults
+// are opted out of one by one rather than accepted wholesale:
+//
+// - Deterministic sorts map keys. v1 always sorted them, and rows arrive as
+// map[string]any, so without this the column order in a response would
+// vary between identical requests.
+// - AllowInvalidUTF8 keeps v1's lenient handling of column values that are
+// not valid UTF-8: invalid bytes are emitted as U+FFFD instead of failing.
+// v2 rejects them by default, which would turn rows that serve fine today
+// (latin1 or binary columns) into a 500.
+// - FormatNilSliceAsNull and FormatNilMapAsNull keep v1's `null` for a nil
+// slice or map. v2 renders them as `[]` and `{}`.
+//
+// The one difference left is deliberate: v2 does not escape <, > and & , and
+// responses are served as application/json where v1's escaping bought nothing.
+var marshalOptions = json.JoinOptions(
+ json.Deterministic(true),
+ jsontext.AllowInvalidUTF8(true),
+ json.FormatNilSliceAsNull(true),
+ json.FormatNilMapAsNull(true),
+)
+
type responder struct {
w http.ResponseWriter
}
@@ -31,7 +55,9 @@ func (r *responder) Respond(status int, payload any) {
return
}
- msg, err := json.Marshal(payload)
+ // Marshal before writing the header so a marshal failure can still be
+ // reported as a 500 instead of truncating an already-committed response.
+ msg, err := json.Marshal(payload, marshalOptions)
if err != nil {
r.Error(http.StatusInternalServerError, err)
return
@@ -50,7 +76,9 @@ func (r *responder) Error(status int, err error) {
if status >= 500 {
slog.Error("Internal server error", "status", status, "error", err)
}
- msg := lo.Must(json.Marshal(err.Error()))
+ // Marshaling a string with AllowInvalidUTF8 cannot fail, so lo.Must is safe
+ // even for error messages carrying non-UTF-8 bytes from a driver.
+ msg := lo.Must(json.Marshal(err.Error(), marshalOptions))
r.w.Header().Set("Content-Type", "application/json")
r.w.WriteHeader(status)
if _, err = fmt.Fprintf(r.w, "{\"error\":%s}", msg); err != nil {
diff --git a/internal/server/responder_test.go b/internal/server/responder_test.go
new file mode 100644
index 0000000..84d41c1
--- /dev/null
+++ b/internal/server/responder_test.go
@@ -0,0 +1,144 @@
+package server
+
+import (
+ "errors"
+ "math"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestResponder_Respond_V1Compatible pins the response bytes that
+// encoding/json v1 produced. Responses are this project's public API, so each
+// case here is a v2 default that marshalOptions deliberately opts out of; a
+// failure means a v2 default leaked into the wire format.
+func TestResponder_Respond_V1Compatible(t *testing.T) {
+ ts := time.Date(2026, 8, 22, 12, 34, 56, 789000000, time.UTC)
+
+ tests := []struct {
+ name string
+ payload any
+ want string
+ }{
+ {
+ // v2 preserves insertion order; v1 always sorted.
+ name: "map keys are sorted",
+ payload: map[string]any{"z": 1, "m": 2, "a": 3},
+ want: `{"a":3,"m":2,"z":1}`,
+ },
+ {
+ // v2 renders these as [] and {}.
+ name: "nil slice and nil map are null",
+ payload: map[string]any{"rows": []map[string]any(nil), "obj": map[string]any(nil)},
+ want: `{"obj":null,"rows":null}`,
+ },
+ {
+ name: "empty non-nil slice stays an empty array",
+ payload: []map[string]any{},
+ want: `[]`,
+ },
+ {
+ // v2 rejects invalid UTF-8 outright, which would 500 a row that
+ // serves fine today.
+ name: "invalid UTF-8 becomes U+FFFD",
+ payload: map[string]any{"name": "a\xff\xfeb"},
+ want: "{\"name\":\"a��b\"}",
+ },
+ {
+ name: "byte slices are base64",
+ payload: map[string]any{"blob": []byte{0x00, 0xff}},
+ want: `{"blob":"AP8="}`,
+ },
+ {
+ name: "time is RFC 3339",
+ payload: map[string]any{"ts": ts},
+ want: `{"ts":"2026-08-22T12:34:56.789Z"}`,
+ },
+ {
+ name: "SQL NULL stays null",
+ payload: map[string]any{"n": nil},
+ want: `{"n":null}`,
+ },
+ {
+ // The one intentional difference from v1, which escaped these.
+ // Responses are application/json, so escaping bought nothing.
+ name: "HTML metacharacters are not escaped",
+ payload: map[string]any{"html": "&"},
+ want: `{"html":"&"}`,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ w := httptest.NewRecorder()
+ (&responder{w: w}).Respond(http.StatusOK, tt.payload)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+ assert.Equal(t, "application/json", w.Header().Get("Content-Type"))
+ assert.Equal(t, tt.want, w.Body.String())
+ })
+ }
+}
+
+func TestResponder_Respond_NoContent(t *testing.T) {
+ w := httptest.NewRecorder()
+ (&responder{w: w}).Respond(http.StatusNoContent, map[string]any{"ignored": true})
+
+ assert.Equal(t, http.StatusNoContent, w.Code)
+ assert.Empty(t, w.Body.String())
+}
+
+func TestResponder_Respond_UnmarshalableValue(t *testing.T) {
+ // v2 rejects NaN and Inf just as v1 did. Marshaling happens before the
+ // header is written, so this surfaces as a 500 rather than a truncated body.
+ w := httptest.NewRecorder()
+ (&responder{w: w}).Respond(http.StatusOK, map[string]any{"n": math.Inf(1)})
+
+ assert.Equal(t, http.StatusInternalServerError, w.Code)
+ assert.Contains(t, w.Body.String(), "error")
+}
+
+func TestResponder_Error(t *testing.T) {
+ t.Run("wraps the message as JSON", func(t *testing.T) {
+ w := httptest.NewRecorder()
+ (&responder{w: w}).Error(http.StatusBadRequest, errors.New(`bad "input"`))
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+ assert.Equal(t, "application/json", w.Header().Get("Content-Type"))
+ assert.JSONEq(t, `{"error":"bad \"input\""}`, w.Body.String())
+ })
+
+ t.Run("survives a non-UTF-8 message", func(t *testing.T) {
+ // responder.Error marshals with lo.Must, so a driver error carrying raw
+ // bytes must not be able to panic the handler.
+ w := httptest.NewRecorder()
+ require.NotPanics(t, func() {
+ (&responder{w: w}).Error(http.StatusInternalServerError, errors.New("driver\xff\xfe"))
+ })
+
+ assert.Equal(t, http.StatusInternalServerError, w.Code)
+ assert.Equal(t, "{\"error\":\"driver��\"}", w.Body.String())
+ })
+}
+
+func TestValidateStatus(t *testing.T) {
+ tests := []struct {
+ in, want int
+ }{
+ {200, 200},
+ {100, 100},
+ {599, 599},
+ {99, http.StatusInternalServerError},
+ {600, http.StatusInternalServerError},
+ {0, http.StatusInternalServerError},
+ {-1, http.StatusInternalServerError},
+ }
+
+ for _, tt := range tests {
+ assert.Equal(t, tt.want, validateStatus(tt.in), "validateStatus(%d)", tt.in)
+ }
+}