feat(httputil): add Bind/BindEmpty request binding from path and query; v1.6.0
Bind and BindEmpty fill Req from path/query/json struct tags and validate once, extending the typed decode->validate->call->encode pipeline to routes with identifiers and filters. Conversion via builtins + encoding.TextUnmarshaler (uuid.UUID, time.Time); malformed value -> 400 naming the parameter; default: applies only when absent; repeated query -> slice; mis-tagged struct panics at wiring. Purely additive; existing adapters unchanged. Coordinated lockstep v1.6.0.
This commit is contained in:
@@ -0,0 +1,343 @@
|
||||
package httputil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"code.nochebuena.dev/einherjar/core/valid"
|
||||
)
|
||||
|
||||
// withPath attaches a chi route context carrying the given key/value path params,
|
||||
// mirroring what the router injects before a handler runs.
|
||||
func withPath(r *http.Request, kv ...string) *http.Request {
|
||||
rctx := chi.NewRouteContext()
|
||||
for i := 0; i+1 < len(kv); i += 2 {
|
||||
rctx.URLParams.Add(kv[i], kv[i+1])
|
||||
}
|
||||
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
|
||||
}
|
||||
|
||||
// AC1 — Bind fills path, query and json fields on one struct.
|
||||
func TestBind_FillsAllThreeSources(t *testing.T) {
|
||||
type req struct {
|
||||
RoleID string `path:"roleID"`
|
||||
Page int `query:"page"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
var got req
|
||||
h := Bind(valid.New(), discardLogger(), func(_ context.Context, r req) (tRes, error) {
|
||||
got = r
|
||||
return tRes{ID: r.RoleID}, nil
|
||||
})
|
||||
rec := httptest.NewRecorder()
|
||||
r := withPath(httptest.NewRequest(http.MethodPatch, "/roles/abc?page=7", strings.NewReader(`{"name":"turno"}`)), "roleID", "abc")
|
||||
h(rec, r)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body: %s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got.RoleID != "abc" || got.Page != 7 || got.Name != "turno" {
|
||||
t.Fatalf("bound = %+v, want {abc 7 turno}", got)
|
||||
}
|
||||
}
|
||||
|
||||
// AC1 + AC3 — BindEmpty writes a body-less success and needs no request body.
|
||||
func TestBindEmpty_PathOnly_NoBody(t *testing.T) {
|
||||
type req struct {
|
||||
RoleID string `path:"roleID"`
|
||||
}
|
||||
called := ""
|
||||
h := BindEmpty(valid.New(), discardLogger(), func(_ context.Context, r req) error {
|
||||
called = r.RoleID
|
||||
return nil
|
||||
})
|
||||
rec := httptest.NewRecorder()
|
||||
// DELETE with a nil body — the HandleEmpty io.EOF trap must not fire.
|
||||
h(rec, withPath(httptest.NewRequest(http.MethodDelete, "/roles/xyz", nil), "roleID", "xyz"))
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204", rec.Code)
|
||||
}
|
||||
if rec.Body.Len() != 0 {
|
||||
t.Errorf("expected empty body, got %q", rec.Body.String())
|
||||
}
|
||||
if called != "xyz" {
|
||||
t.Errorf("path not bound: got %q", called)
|
||||
}
|
||||
}
|
||||
|
||||
// AC3 — a bodiless GET succeeds through Bind (query only, no io.EOF).
|
||||
func TestBind_NoBody_QueryOnly(t *testing.T) {
|
||||
type req struct {
|
||||
Q string `query:"q"`
|
||||
}
|
||||
h := Bind(valid.New(), discardLogger(), func(_ context.Context, r req) (tRes, error) {
|
||||
return tRes{ID: r.Q}, nil
|
||||
})
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, httptest.NewRequest(http.MethodGet, "/roles?q=hola", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body: %s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := strings.TrimSpace(rec.Body.String()); got != `{"id":"hola"}` {
|
||||
t.Errorf("body = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// AC2 — a field with two source tags fails at wiring (Bind panics at registration).
|
||||
func TestBind_TwoSourceTags_PanicsAtWiring(t *testing.T) {
|
||||
type req struct {
|
||||
Bad string `path:"id" query:"id"`
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("Bind did not panic on a two-source-tag field")
|
||||
}
|
||||
}()
|
||||
_ = Bind(valid.New(), discardLogger(), func(_ context.Context, _ req) (tRes, error) {
|
||||
return tRes{}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// AC2 (companion) — an unsupported field type and a bad default also fail at wiring.
|
||||
func TestBind_UnsupportedType_PanicsAtWiring(t *testing.T) {
|
||||
type req struct {
|
||||
Ch chan int `query:"ch"`
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("Bind did not panic on an unsupported field type")
|
||||
}
|
||||
}()
|
||||
_ = Bind(valid.New(), discardLogger(), func(_ context.Context, _ req) (tRes, error) { return tRes{}, nil })
|
||||
}
|
||||
|
||||
func TestBind_BadDefault_PanicsAtWiring(t *testing.T) {
|
||||
type req struct {
|
||||
Page int `query:"page" default:"not-a-number"`
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("Bind did not panic on an invalid default tag")
|
||||
}
|
||||
}()
|
||||
_ = Bind(valid.New(), discardLogger(), func(_ context.Context, _ req) (tRes, error) { return tRes{}, nil })
|
||||
}
|
||||
|
||||
// AC4 — uuid.UUID and time.Time bind from path and query via TextUnmarshaler.
|
||||
func TestBind_TextUnmarshaler_UUIDAndTime(t *testing.T) {
|
||||
type req struct {
|
||||
ID uuid.UUID `path:"id"`
|
||||
From time.Time `query:"from"`
|
||||
}
|
||||
id := uuid.New()
|
||||
var got req
|
||||
h := Bind(valid.New(), discardLogger(), func(_ context.Context, r req) (tRes, error) {
|
||||
got = r
|
||||
return tRes{ID: r.ID.String()}, nil
|
||||
})
|
||||
rec := httptest.NewRecorder()
|
||||
r := withPath(httptest.NewRequest(http.MethodGet, "/x/"+id.String()+"?from=2026-01-02T03:04:05Z", nil), "id", id.String())
|
||||
h(rec, r)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body: %s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got.ID != id {
|
||||
t.Errorf("uuid = %s, want %s", got.ID, id)
|
||||
}
|
||||
if !got.From.Equal(time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)) {
|
||||
t.Errorf("time = %s, want 2026-01-02T03:04:05Z", got.From)
|
||||
}
|
||||
}
|
||||
|
||||
// AC5 — a malformed value answers 400 and names the parameter, never 500.
|
||||
func TestBind_MalformedParam_400WithName(t *testing.T) {
|
||||
type req struct {
|
||||
Page int `query:"page"`
|
||||
}
|
||||
h := Bind(valid.New(), discardLogger(), func(_ context.Context, _ req) (tRes, error) {
|
||||
return tRes{}, nil
|
||||
})
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, httptest.NewRequest(http.MethodGet, "/roles?page=abc", nil))
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "page") {
|
||||
t.Errorf("error body %q does not name the parameter", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// AC4/AC5 — a malformed uuid path parameter is also a 400, not a 500.
|
||||
func TestBind_MalformedUUID_400(t *testing.T) {
|
||||
type req struct {
|
||||
ID uuid.UUID `path:"id"`
|
||||
}
|
||||
h := Bind(valid.New(), discardLogger(), func(_ context.Context, _ req) (tRes, error) {
|
||||
return tRes{}, nil
|
||||
})
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, withPath(httptest.NewRequest(http.MethodGet, "/x/nope", nil), "id", "nope"))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// AC6 — default applies when the parameter is absent, and NOT when present-and-empty.
|
||||
func TestBind_Default_AbsentOnly(t *testing.T) {
|
||||
type req struct {
|
||||
Page int `query:"page" default:"1"`
|
||||
Q string `query:"q" default:"all"`
|
||||
}
|
||||
|
||||
// Absent → defaults applied.
|
||||
var absent req
|
||||
h := Bind(valid.New(), discardLogger(), func(_ context.Context, r req) (tRes, error) {
|
||||
absent = r
|
||||
return tRes{}, nil
|
||||
})
|
||||
h(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/roles", nil))
|
||||
if absent.Page != 1 || absent.Q != "all" {
|
||||
t.Fatalf("absent defaults = %+v, want {1 all}", absent)
|
||||
}
|
||||
|
||||
// Present-but-empty (?q=) → the caller is clearing the filter; default must NOT win.
|
||||
var present req
|
||||
h2 := Bind(valid.New(), discardLogger(), func(_ context.Context, r req) (tRes, error) {
|
||||
present = r
|
||||
return tRes{}, nil
|
||||
})
|
||||
rec := httptest.NewRecorder()
|
||||
h2(rec, httptest.NewRequest(http.MethodGet, "/roles?q=", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body: %s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if present.Q != "" {
|
||||
t.Errorf("present-empty q = %q, want \"\" (default must not override)", present.Q)
|
||||
}
|
||||
}
|
||||
|
||||
// AC7 — repeated query parameters bind to a slice; a comma inside a scalar survives.
|
||||
func TestBind_RepeatedQuery_Slice(t *testing.T) {
|
||||
type req struct {
|
||||
Kind []string `query:"kind"`
|
||||
Q string `query:"q"`
|
||||
}
|
||||
var got req
|
||||
h := Bind(valid.New(), discardLogger(), func(_ context.Context, r req) (tRes, error) {
|
||||
got = r
|
||||
return tRes{}, nil
|
||||
})
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, httptest.NewRequest(http.MethodGet, "/x?kind=POS&kind=KDS&q=a,b,c", nil))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body: %s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(got.Kind) != 2 || got.Kind[0] != "POS" || got.Kind[1] != "KDS" {
|
||||
t.Errorf("kind = %v, want [POS KDS]", got.Kind)
|
||||
}
|
||||
if got.Q != "a,b,c" {
|
||||
t.Errorf("q = %q, want verbatim a,b,c (no comma splitting)", got.Q)
|
||||
}
|
||||
}
|
||||
|
||||
// AC7 (companion) — a typed slice ([]uuid.UUID) binds each repeated value.
|
||||
func TestBind_RepeatedQuery_TypedSlice(t *testing.T) {
|
||||
type req struct {
|
||||
IDs []uuid.UUID `query:"id"`
|
||||
}
|
||||
a, b := uuid.New(), uuid.New()
|
||||
var got req
|
||||
h := Bind(valid.New(), discardLogger(), func(_ context.Context, r req) (tRes, error) {
|
||||
got = r
|
||||
return tRes{}, nil
|
||||
})
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, httptest.NewRequest(http.MethodGet, "/x?id="+a.String()+"&id="+b.String(), nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body: %s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(got.IDs) != 2 || got.IDs[0] != a || got.IDs[1] != b {
|
||||
t.Errorf("ids = %v, want [%s %s]", got.IDs, a, b)
|
||||
}
|
||||
}
|
||||
|
||||
// WithStatus composes with Bind exactly as with Handle (201 on create).
|
||||
func TestBind_WithStatus(t *testing.T) {
|
||||
type req struct {
|
||||
Name string `json:"name" validate:"required"`
|
||||
}
|
||||
h := Bind(valid.New(), discardLogger(), func(_ context.Context, r req) (tRes, error) {
|
||||
return tRes{ID: r.Name}, nil
|
||||
}, WithStatus(http.StatusCreated))
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, httptest.NewRequest(http.MethodPost, "/roles", strings.NewReader(`{"name":"cajero"}`)))
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Validation runs on the assembled struct — a query bound value is validated too.
|
||||
func TestBind_ValidatesAssembledStruct(t *testing.T) {
|
||||
type req struct {
|
||||
PerPage int `query:"per_page" default:"50" validate:"min=1,max=200"`
|
||||
}
|
||||
h := Bind(valid.New(), discardLogger(), func(_ context.Context, _ req) (tRes, error) {
|
||||
return tRes{}, nil
|
||||
})
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, httptest.NewRequest(http.MethodGet, "/roles?per_page=99999", nil))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400 (per_page over max should fail validation)", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// AC9 — the per-type plan is reflected once and cached (same pointer across calls).
|
||||
func TestBind_PlanCachedPerType(t *testing.T) {
|
||||
type req struct {
|
||||
A string `query:"a"`
|
||||
B int `query:"b"`
|
||||
}
|
||||
rt := reflect.TypeOf((*req)(nil)).Elem()
|
||||
if planFor(rt) != planFor(rt) {
|
||||
t.Fatal("planFor returned a different plan for the same type — cache not effective")
|
||||
}
|
||||
}
|
||||
|
||||
// AC9 — per-request work does not re-reflect the type; the plan metadata is
|
||||
// parsed once and reused. Run with -benchmem to see allocations stay flat
|
||||
// regardless of how many tagged fields the struct declares.
|
||||
func BenchmarkBind_ManyFields(b *testing.B) {
|
||||
type req struct {
|
||||
ID uuid.UUID `path:"id"`
|
||||
Page int `query:"page" default:"1"`
|
||||
PerPage int `query:"per_page" default:"50"`
|
||||
Q string `query:"q"`
|
||||
Sort string `query:"sort" default:"name"`
|
||||
Order string `query:"order" default:"asc"`
|
||||
Kind []string `query:"kind"`
|
||||
}
|
||||
id := uuid.New()
|
||||
h := Bind(valid.New(), discardLogger(), func(_ context.Context, _ req) (tRes, error) {
|
||||
return tRes{}, nil
|
||||
})
|
||||
target := "/x/" + id.String() + "?page=2&per_page=25&q=turno&sort=name&order=desc&kind=POS&kind=KDS"
|
||||
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, withPath(httptest.NewRequest(http.MethodGet, target, nil), "id", id.String()))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user