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:
2026-08-13 23:11:23 -06:00
parent bcccfef443
commit a778227fc2
10 changed files with 920 additions and 14 deletions
+26 -6
View File
@@ -22,6 +22,26 @@
// return CreateUserRes{ID: u.ID}, nil
// }))
//
// # Request binding
//
// [Bind] and [BindEmpty] extend the same decode → validate → call → encode pipeline
// to requests that carry more than a JSON body. Each field declares its source with
// a struct tag — path:, query: or json: — and the assembled struct is validated once:
//
// type getRoleReq struct {
// RoleID uuid.UUID `path:"roleID" validate:"required"`
// Expand []string `query:"expand"`
// }
//
// r.Get("/roles/{roleID}", httputil.Bind(v, logger, func(ctx context.Context, req getRoleReq) (RoleRes, error) {
// return svc.GetRole(ctx, req.RoleID)
// }))
//
// A malformed value is a 400 naming the parameter (never a 500), uuid.UUID and
// time.Time bind via [encoding.TextUnmarshaler], and a mis-tagged struct fails at
// wiring rather than on a request. Use these instead of [HandlerFunc] for any route
// with an identifier or a filter.
//
// # Centralized error handler
//
// [Error] is the single point of error processing for all handlers:
@@ -29,16 +49,16 @@
// - 4xx → Warn level (client mistake — not a server failure)
// - 499 → Info level (client cancelled the request intentionally)
//
// Call it directly from [HandlerFunc] when you need path parameters or custom logic:
// Call it directly from [HandlerFunc] for genuinely custom responses — streaming,
// file downloads, non-JSON content types:
//
// r.Get("/users/{id}", httputil.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
// id := chi.URLParam(r, "id")
// u, err := svc.GetUser(r.Context(), id)
// r.Get("/reports/{id}.csv", httputil.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
// rows, err := svc.Export(r.Context(), chi.URLParam(r, "id"))
// if err != nil {
// httputil.Error(logger, w, r, err)
// return nil
// }
// httputil.JSON(w, http.StatusOK, u)
// return nil
// w.Header().Set("Content-Type", "text/csv")
// return csv.NewWriter(w).WriteAll(rows)
// }).ServeHTTP)
package httputil