Compare commits

...
2 Commits
Author SHA1 Message Date
Hein 749dad4ed1 fix(quickproxy): ensure request body is preserved on fallback
Tests / Unit Tests (push) Failing after 26s
Tests / Integration Tests (push) Failing after 41s
Build , Vet Test, and Lint / Build (push) Successful in 4m26s
Build , Vet Test, and Lint / Lint Code (push) Successful in 4m58s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in 5m1s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 5m3s
2026-09-21 09:21:58 +02:00
warkanum d6c5740f9c fix(handler): add operation type to hook context
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Failing after 1s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Failing after 1s
Build , Vet Test, and Lint / Lint Code (push) Failing after 1s
Build , Vet Test, and Lint / Build (push) Failing after 1s
Tests / Unit Tests (push) Failing after 0s
Tests / Integration Tests (push) Failing after 10s
2026-09-20 16:12:44 +02:00
4 changed files with 218 additions and 102 deletions
+113 -102
View File
@@ -306,15 +306,16 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
txErr := h.db.RunInTransaction(ctx, func(tx common.Database) error {
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
ID: id,
Writer: w,
Tx: tx,
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Operation: "read",
Options: options,
ID: id,
Writer: w,
Tx: tx,
}
if err := h.hooks.ExecuteBeforeOp(BeforeRead, hookCtx); err != nil {
statusCode, errCode, errMsg = http.StatusInternalServerError, "hook_error", "BeforeRead hook failed"
@@ -722,15 +723,16 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
var nestedResult *common.ProcessResult
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
Data: v,
Writer: w,
Tx: tx,
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Operation: "create",
Options: options,
Data: v,
Writer: w,
Tx: tx,
}
if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
return fmt.Errorf("BeforeCreate hook failed: %w", err)
@@ -769,15 +771,16 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
var responseData interface{} = v
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
Data: v,
Writer: w,
Tx: tx,
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Operation: "create",
Options: options,
Data: v,
Writer: w,
Tx: tx,
}
if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
return fmt.Errorf("BeforeCreate hook failed: %w", err)
@@ -851,15 +854,16 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
for _, item := range v {
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
Data: item,
Writer: w,
Tx: tx,
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Operation: "create",
Options: options,
Data: item,
Writer: w,
Tx: tx,
}
if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
return fmt.Errorf("BeforeCreate hook failed: %w", err)
@@ -898,15 +902,16 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
for _, item := range v {
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
Data: item,
Writer: w,
Tx: tx,
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Operation: "create",
Options: options,
Data: item,
Writer: w,
Tx: tx,
}
if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
return fmt.Errorf("BeforeCreate hook failed: %w", err)
@@ -982,15 +987,16 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
for _, item := range v {
if itemMap, ok := item.(map[string]interface{}); ok {
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
Data: itemMap,
Writer: w,
Tx: tx,
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Operation: "create",
Options: options,
Data: itemMap,
Writer: w,
Tx: tx,
}
if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
return fmt.Errorf("BeforeCreate hook failed: %w", err)
@@ -1035,15 +1041,16 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
}
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
Data: itemMap,
Writer: w,
Tx: tx,
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Operation: "create",
Options: options,
Data: itemMap,
Writer: w,
Tx: tx,
}
if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
return fmt.Errorf("BeforeCreate hook failed: %w", err)
@@ -1166,16 +1173,17 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
// they must run before the existence-check select so that select is
// also subject to RLS on this connection/transaction.
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
ID: urlID,
Data: updates,
Writer: w,
Tx: tx,
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Operation: "update",
Options: options,
ID: urlID,
Data: updates,
Writer: w,
Tx: tx,
}
if err := h.hooks.ExecuteBeforeOp(BeforeUpdate, hookCtx); err != nil {
@@ -1387,16 +1395,17 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
// Execute BeforeUpdate hooks inside transaction
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
ID: itemIDStr,
Data: item,
Writer: w,
Tx: tx,
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Operation: "update",
Options: options,
ID: itemIDStr,
Data: item,
Writer: w,
Tx: tx,
}
if err := h.hooks.ExecuteBeforeOp(BeforeUpdate, hookCtx); err != nil {
@@ -1543,16 +1552,17 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
// Execute BeforeUpdate hooks inside transaction
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
ID: itemIDStr,
Data: itemMap,
Writer: w,
Tx: tx,
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Operation: "update",
Options: options,
ID: itemIDStr,
Data: itemMap,
Writer: w,
Tx: tx,
}
if err := h.hooks.ExecuteBeforeOp(BeforeUpdate, hookCtx); err != nil {
@@ -1648,15 +1658,16 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
// Execute BeforeDelete hooks (covers model-rule checks before any deletion)
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
ID: id,
Data: data,
Writer: w,
Tx: h.db,
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Operation: "delete",
ID: id,
Data: data,
Writer: w,
Tx: h.db,
}
if err := h.hooks.ExecuteBeforeOp(BeforeDelete, hookCtx); err != nil {
logger.Error("BeforeDelete hook failed: %v", err)
+8
View File
@@ -379,6 +379,7 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
Entity: entity,
TableName: tableName,
Model: model,
Operation: "read",
Options: options,
ID: id,
Writer: w,
@@ -1236,6 +1237,7 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
Entity: entity,
TableName: tableName,
Model: model,
Operation: "create",
Options: options,
Data: data,
Writer: w,
@@ -1335,6 +1337,7 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
Entity: entity,
TableName: tableName,
Model: model,
Operation: "create",
Options: options,
Data: modelValue,
Writer: w,
@@ -1489,6 +1492,7 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
TableName: tableName,
Tx: tx,
Model: model,
Operation: "update",
Options: options,
ID: id,
Data: dataMap,
@@ -1686,6 +1690,7 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
Entity: entity,
TableName: tableName,
Model: model,
Operation: "delete",
ID: itemID,
Writer: w,
Tx: tx,
@@ -1760,6 +1765,7 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
Entity: entity,
TableName: tableName,
Model: model,
Operation: "delete",
ID: itemIDStr,
Writer: w,
Tx: tx,
@@ -1818,6 +1824,7 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
Entity: entity,
TableName: tableName,
Model: model,
Operation: "delete",
ID: itemIDStr,
Writer: w,
Tx: tx,
@@ -1902,6 +1909,7 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
Entity: entity,
TableName: tableName,
Model: model,
Operation: "delete",
ID: id,
Writer: w,
Tx: h.db,
+27
View File
@@ -5,8 +5,10 @@
package quickproxy
import (
"bytes"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httputil"
@@ -191,6 +193,15 @@ func (s *Service) Handler(fallback http.Handler) http.Handler {
for i := range s.rules {
s.rules[i].proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, _ error) {
// ReverseProxy consumes and closes r.Body while attempting the
// upstream request, even when that attempt fails (per the
// http.RoundTripper contract). Restore a fresh copy from
// r.GetBody, set below, before handing the request to fallback.
if r.GetBody != nil {
if body, err := r.GetBody(); err == nil {
r.Body = body
}
}
fallback.ServeHTTP(w, r)
}
}
@@ -201,6 +212,22 @@ func (s *Service) Handler(fallback http.Handler) http.Handler {
fallback.ServeHTTP(w, r)
return
}
// Buffer the body so it can be replayed to fallback if the upstream
// attempt fails; see ErrorHandler above.
if r.Body != nil && r.Body != http.NoBody {
bodyBytes, err := io.ReadAll(r.Body)
r.Body.Close()
if err != nil {
http.Error(w, "failed to read request body", http.StatusInternalServerError)
return
}
r.Body = io.NopCloser(bytes.NewReader(bodyBytes))
r.GetBody = func() (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(bodyBytes)), nil
}
}
rule.proxy.ServeHTTP(w, r)
})
}
+70
View File
@@ -4,6 +4,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
@@ -130,6 +131,75 @@ func TestHandler_UnreachableUpstreamFallsBack(t *testing.T) {
}
}
func TestHandler_UnreachableUpstreamFallsBackWithBody(t *testing.T) {
// A closed listener address: nothing is listening, so dialing fails and
// ReverseProxy invokes ErrorHandler. The fallback handler must still see
// the original request body, even though ReverseProxy consumed and
// closed it while attempting (and failing) the upstream request.
unreachable := "http://127.0.0.1:1"
svc, err := NewService([]Rule{{URLPrefix: "/", Target: unreachable}}, WithTimeout(500*time.Millisecond))
if err != nil {
t.Fatalf("NewService: %v", err)
}
echoBody := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("fallback reading body: %v", err)
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
})
handler := svc.Handler(echoBody)
req := httptest.NewRequest(http.MethodPost, "/submit", strings.NewReader("payload=1"))
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
if got := rr.Body.String(); got != "payload=1" {
t.Fatalf("body = %q, want payload=1", got)
}
}
func TestHandler_404FallsBackWithBody(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer upstream.Close()
svc, err := NewService([]Rule{{URLPrefix: "/", Target: upstream.URL}})
if err != nil {
t.Fatalf("NewService: %v", err)
}
echoBody := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("fallback reading body: %v", err)
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
})
handler := svc.Handler(echoBody)
req := httptest.NewRequest(http.MethodPut, "/missing", strings.NewReader("payload=2"))
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
if got := rr.Body.String(); got != "payload=2" {
t.Fatalf("body = %q, want payload=2", got)
}
}
func TestHandler_NonNotFoundErrorsPassThrough(t *testing.T) {
codes := []int{http.StatusOK, http.StatusForbidden, http.StatusBadRequest, http.StatusInternalServerError}