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