Compare commits

...
2 Commits
Author SHA1 Message Date
Hein 20c67166d0 fix(handler): support implicit updates from request body 2026-09-21 11:18:10 +02:00
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
3 changed files with 152 additions and 2 deletions
+55 -2
View File
@@ -233,8 +233,18 @@ func (h *Handler) Handle(w common.ResponseWriter, r common.Request, params map[s
return
}
validId, _ := strconv.ParseInt(id, 10, 64)
if validId > 0 {
h.handleUpdate(ctx, w, id, nil, data, options)
updateID := id
isUpdate := validId > 0
if !isUpdate {
// No valid /:id in the URL - check whether the body itself carries
// a valid primary key value and treat this as an update if so.
if pkID, ok := h.extractPrimaryKeyFromBody(model, data); ok && pkID != "0" {
updateID = pkID
isUpdate = true
}
}
if isUpdate {
h.handleUpdate(ctx, w, updateID, nil, data, options)
} else {
h.handleCreate(ctx, w, data, options)
}
@@ -271,6 +281,49 @@ func (h *Handler) Handle(w common.ResponseWriter, r common.Request, params map[s
}
}
// extractPrimaryKeyFromBody looks for a valid primary key value inside a
// decoded (single-record) POST body, keyed by the model's primary key column
// or its JSON equivalent. It returns the string form of that value and true
// if one was found and is non-empty/non-zero; otherwise ("", false).
func (h *Handler) extractPrimaryKeyFromBody(model interface{}, data interface{}) (string, bool) {
dataMap, ok := data.(map[string]interface{})
if !ok {
// Batch payloads (slices) aren't eligible for this implicit-update detection.
return "", false
}
pkCol := reflection.GetPrimaryKeyName(model)
if pkCol == "" {
return "", false
}
val, exists := dataMap[pkCol]
if !exists {
modelType := reflection.GetPointerElement(reflect.TypeOf(model))
for jsonKey, col := range reflection.BuildJSONToDBColumnMap(modelType) {
if col == pkCol {
val, exists = dataMap[jsonKey]
break
}
}
}
if !exists || val == nil || reflection.IsEmptyValue(val) {
return "", false
}
switch v := val.(type) {
case float64:
if v <= 0 {
return "", false
}
return strconv.FormatInt(int64(v), 10), true
case string:
return v, true
default:
return fmt.Sprintf("%v", v), true
}
}
// HandleGet processes GET requests for metadata
func (h *Handler) HandleGet(w common.ResponseWriter, r common.Request, params map[string]string) {
// Capture panics and return error response
+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}