diff --git a/.golangci.yml b/.golangci.yml
index b54e1a5..db03a26 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -114,6 +114,9 @@ linters:
- path: responder(_test)?\.go
linters:
- wrapcheck
+ - path: binder\.go
+ linters:
+ - wrapcheck
formatters:
enable:
- gofmt
diff --git a/README.md b/README.md
index 9e5ffc1..7db4e93 100644
--- a/README.md
+++ b/README.md
@@ -30,6 +30,8 @@ native performance untouched. Simple, not simplistic.
- Response helpers: `JSON` (with `EscapeForHTML` / `Indented` options),
`XML`, `Text`, `Bytes`, `Stream` for chunked streaming, and
`Attachment` for file downloads
+- Request binding into structs with `query`, `form`, `path`, `header`
+ and `JSON`/`XML` tags
- Graceful shutdown with `Run`
**Built-in wrappers**
@@ -42,6 +44,18 @@ native performance untouched. Simple, not simplistic.
`Default` bundles all three wrappers, ready to use with no configuration.
+**Request binding**
+
+- Bind requests into your own structs: `BindJSON`, `BindXML`,
+ `BindQuery`, `BindForm`, `BindPath` and `BindHeader`
+- Struct tags with `default=` values, embedded structs, multipart file
+ uploads and map targets
+- Validation via `Validator`, custom formats via `Decoder`
+- Read the request body more than once with `BufferBody`
+
+See the [package documentation](https://pkg.go.dev/github.com/qm012/sim)
+for the full struct-tag rules.
+
## Installation
Requires Go 1.26+.
@@ -141,17 +155,36 @@ func auth(next http.Handler) http.Handler {
})
}
-func listUsers(w http.ResponseWriter, _ *http.Request) {
- _, _ = fmt.Fprintln(w, "list users")
+func listUsers(w http.ResponseWriter, r *http.Request) {
+ // BindQuery fills a struct from the URL query; page defaults to 1.
+ q, err := sim.BindQuery[struct {
+ Page int `query:"page,default=1"`
+ }](r)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ _, _ = fmt.Fprintf(w, "list users, page %d\n", q.Page)
}
func getUser(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintf(w, "user %s", r.PathValue("id"))
}
-func createUser(w http.ResponseWriter, _ *http.Request) {
+// user is the payload createUser decodes from the request body.
+type user struct {
+ Name string `json:"name"`
+ Age int `json:"age"`
+}
+
+func createUser(w http.ResponseWriter, r *http.Request) {
+ u, err := sim.BindJSON[user](r)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
w.WriteHeader(http.StatusCreated)
- _, _ = fmt.Fprintln(w, "user created")
+ _, _ = fmt.Fprintf(w, "user %s created\n", u.Name)
}
func updateUser(w http.ResponseWriter, r *http.Request) {
@@ -173,8 +206,14 @@ Save it as `main.go` and run it:
go run main.go
```
-Open http://localhost:8080/ to see "welcome", and http://localhost:8080/api/users
-for the user list.
+Open http://localhost:8080/ to see "welcome", and
+http://localhost:8080/api/users for the user list. The endpoints speak
+HTTP, so try binding too:
+
+```bash
+curl 'localhost:8080/api/users?page=2'
+curl -X POST localhost:8080/api/users -d '{"name":"alice","age":30}'
+```
## Contributing
diff --git a/binder.go b/binder.go
new file mode 100644
index 0000000..15dd8ef
--- /dev/null
+++ b/binder.go
@@ -0,0 +1,779 @@
+// Copyright (c) 2026 The Sim Authors
+// Use of this source code is governed by a MIT license
+// that can be found in the LICENSE file.
+
+package sim
+
+import (
+ "bytes"
+ "context"
+ "encoding"
+ "encoding/json"
+ "encoding/xml"
+ "errors"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "net/url"
+ "reflect"
+ "slices"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+)
+
+var (
+ // ErrBindTarget is returned when a bind target is not a non-nil
+ // pointer to a struct.
+ ErrBindTarget = errors.New("sim: bind target must be a non-nil struct pointer")
+ // ErrUnsupportedKind is returned when a field's kind cannot be bound
+ // from strings.
+ ErrUnsupportedKind = errors.New("sim: unsupported field kind")
+ // ErrDecodeNil is returned when a decoder returns a nil value without an error.
+ ErrDecodeNil = errors.New("sim: decoder returned nil value")
+)
+
+// Validator defines the interface for validating a decoded request payload.
+// Bind calls Validate automatically when the decoded value implements it.
+type Validator interface {
+ // Validate validates the decoded request data.
+ Validate(ctx context.Context) error
+}
+
+// Decoder decodes an HTTP request into a *T.
+// A decoder must return a non-nil value when the error is nil;
+// otherwise, Bind returns ErrDecodeNil.
+// BindJSON, BindXML, BindQuery, BindForm, BindPath and
+// BindHeader use the built-in decoders; custom formats plug in through
+// this interface.
+type Decoder[T any] interface {
+ Decode(r *http.Request) (*T, error)
+}
+
+// DecoderFunc adapts an ordinary function to the Decoder interface.
+type DecoderFunc[T any] func(r *http.Request) (*T, error)
+
+// Decode implements Decoder.
+func (f DecoderFunc[T]) Decode(r *http.Request) (*T, error) {
+ return f(r)
+}
+
+// Bind decodes the request with src and returns the decoded value.
+// If the value implements Validator, Bind validates it against the
+// request context before returning. It returns ErrDecodeNil if the
+// decoder returns a nil value without an error.
+func Bind[T any](r *http.Request, src Decoder[T]) (*T, error) {
+ v, err := src.Decode(r)
+ if err != nil {
+ return nil, err
+ }
+ if v == nil {
+ return nil, ErrDecodeNil
+ }
+ if vd, ok := any(v).(Validator); ok {
+ if err := vd.Validate(r.Context()); err != nil {
+ return nil, err
+ }
+ }
+ return v, nil
+}
+
+// BindJSON decodes the request body as JSON into a *T and validates it
+// when T implements Validator. Only the first JSON value is decoded;
+// trailing content is not rejected.
+func BindJSON[T any](r *http.Request, opts ...JSONDecoderOption) (*T, error) {
+ return Bind(r, jsonSource[T](opts...))
+}
+
+// BindXML decodes the request body as XML into a *T and validates it
+// when T implements Validator.
+func BindXML[T any](r *http.Request) (*T, error) {
+ return Bind(r, xmlSource[T]())
+}
+
+// BindQuery binds the URL query into a *T using `query` struct tags and
+// validates it when T implements Validator. A malformed query string is
+// rejected instead of silently dropping the affected keys. T may also be
+// map[string]string or map[string][]string, which receive the query
+// values directly. The Binding section of the package documentation
+// describes the shared struct-tag rules.
+func BindQuery[T any](r *http.Request) (*T, error) {
+ return Bind(r, querySource[T]())
+}
+
+// BindForm binds form values — the URL query plus the request body form —
+// into a *T using `form` struct tags and validates it when T implements
+// Validator. T may also be map[string]string or map[string][]string,
+// which receive the form values directly. The Binding section of the
+// package documentation describes the shared struct-tag rules.
+//
+// Both urlencoded and multipart bodies are parsed with a fixed 32 MiB
+// memory cap; multipart file parts bind into *multipart.FileHeader or
+// []*multipart.FileHeader fields. A body buffered with BufferBody is
+// parsed from the cached copy. Parsing populates r.Form, r.PostForm
+// and r.MultipartForm in place.
+// To reject oversized uploads, limit the body with http.MaxBytesHandler —
+// globally in a wrapper or per handler — before the request reaches
+// BindForm.
+func BindForm[T any](r *http.Request) (*T, error) {
+ return Bind(r, formSource[T]())
+}
+
+// BindPath binds path values into a *T using `path` struct tags and
+// validates it when T implements Validator. A wildcard that is missing
+// or that matched an empty value counts as absent. Map targets are not
+// supported, because path wildcards cannot be enumerated. The Binding
+// section of the package documentation describes the shared struct-tag
+// rules.
+func BindPath[T any](r *http.Request) (*T, error) {
+ return Bind(r, pathSource[T]())
+}
+
+// BindHeader binds request headers into a *T using `header` struct tags
+// and validates it when T implements Validator. Header names are matched
+// case-insensitively; map targets are not supported. The Binding section
+// of the package documentation describes the shared struct-tag rules.
+func BindHeader[T any](r *http.Request) (*T, error) {
+ return Bind(r, headerSource[T]())
+}
+
+type ctxBodyKey struct{}
+
+// BufferBody reads the request body and returns a shallow copy of the
+// request whose Body is restored for subsequent reads and whose context
+// carries a copy of the body for repeated reads via BodyFromContext. The
+// whole body is buffered in memory; to cap it, limit the request body
+// beforehand — globally with http.MaxBytesHandler in a wrapper, or per
+// handler:
+//
+// app.Post("/upload", http.MaxBytesHandler(h, 10<<20))
+func BufferBody(r *http.Request) (*http.Request, error) {
+ body, err := readBody(r)
+ if err != nil {
+ return nil, err
+ }
+
+ ctx := context.WithValue(r.Context(), ctxBodyKey{}, body)
+ nr := r.WithContext(ctx)
+ nr.Body = io.NopCloser(bytes.NewReader(body))
+ return nr, nil
+}
+
+var bodyPool = sync.Pool{
+ New: func() any { return new(bytes.Buffer) },
+}
+
+// readBody copies the request body into a scratch buffer from bodyPool
+// and returns an owned clone.
+func readBody(r *http.Request) ([]byte, error) {
+ buf, ok := bodyPool.Get().(*bytes.Buffer)
+ if !ok {
+ buf = new(bytes.Buffer)
+ }
+ buf.Reset()
+ defer bodyPool.Put(buf)
+ if _, err := io.Copy(buf, r.Body); err != nil {
+ return nil, err
+ }
+ return bytes.Clone(buf.Bytes()), nil
+}
+
+// BodyFromContext returns the body cached by BufferBody and reports
+// whether the context carried one.
+func BodyFromContext(ctx context.Context) ([]byte, bool) {
+ v, ok := ctx.Value(ctxBodyKey{}).([]byte)
+ return v, ok
+}
+
+// requestBody returns the reader a body decoder should consume: the cached
+// context body when present, otherwise the raw request body.
+func requestBody(r *http.Request) io.Reader {
+ if body, ok := BodyFromContext(r.Context()); ok {
+ return bytes.NewReader(body)
+ }
+ return r.Body
+}
+
+// JSONDecoderOption configures the JSON decoder.
+type JSONDecoderOption func(*json.Decoder)
+
+// UseNumber parses JSON numbers into json.Number instead of float64.
+func UseNumber() JSONDecoderOption {
+ return func(d *json.Decoder) { d.UseNumber() }
+}
+
+// DisallowUnknownFields makes decoding fail when the JSON contains fields
+// that do not match any field of T.
+func DisallowUnknownFields() JSONDecoderOption {
+ return func(d *json.Decoder) { d.DisallowUnknownFields() }
+}
+
+// jsonSource returns a Decoder that decodes the request body as JSON.
+func jsonSource[T any](opts ...JSONDecoderOption) Decoder[T] {
+ return DecoderFunc[T](func(r *http.Request) (*T, error) {
+ var t T
+ d := json.NewDecoder(requestBody(r))
+ for _, opt := range opts {
+ opt(d)
+ }
+ if err := d.Decode(&t); err != nil {
+ return nil, err
+ }
+ return &t, nil
+ })
+}
+
+// xmlSource returns a Decoder that decodes the request body as XML.
+func xmlSource[T any]() Decoder[T] {
+ return DecoderFunc[T](func(r *http.Request) (*T, error) {
+ var t T
+ //nolint:gosec // decoding the untrusted request body is BindXML's purpose
+ if err := xml.NewDecoder(requestBody(r)).Decode(&t); err != nil {
+ return nil, err
+ }
+ return &t, nil
+ })
+}
+
+// querySource returns a Decoder that binds the URL query into T using
+// `query` struct tags. The query is parsed with url.ParseQuery rather
+// than http.Request.URL.Query, which drops malformed pairs and their
+// error, so a bad query fails the bind the same way a bad form body
+// does.
+func querySource[T any]() Decoder[T] {
+ return DecoderFunc[T](func(r *http.Request) (*T, error) {
+ values, err := url.ParseQuery(r.URL.RawQuery)
+ if err != nil {
+ return nil, err
+ }
+ var t T
+ if err = bindValues(&t, "query", values, nil); err != nil {
+ return nil, err
+ }
+ return &t, nil
+ })
+}
+
+// defaultMaxFormMemory caps in-memory multipart file parts at 32 MiB,
+// matching the default used by http.Request.FormValue; larger parts
+// spill to temporary files. See BindForm for the full binding behavior
+// and oversized-upload guidance.
+const defaultMaxFormMemory = 32 << 20 // 32 MiB
+
+// formSource returns a Decoder that binds form values (query plus request
+// body form) into T using `form` struct tags. Both urlencoded and multipart
+// bodies are parsed; multipart file parts bind into *multipart.FileHeader
+// or []*multipart.FileHeader fields.
+func formSource[T any]() Decoder[T] {
+ return DecoderFunc[T](func(r *http.Request) (*T, error) {
+ if err := parseForm(r); err != nil {
+ return nil, err
+ }
+ var files func(name string) ([]*multipart.FileHeader, bool)
+ if mf := r.MultipartForm; mf != nil {
+ files = func(name string) ([]*multipart.FileHeader, bool) {
+ fhs, ok := mf.File[name]
+ return fhs, ok && len(fhs) > 0
+ }
+ }
+ var t T
+ if err := bindValues(&t, "form", r.Form, files); err != nil {
+ return nil, err
+ }
+ return &t, nil
+ })
+}
+
+// parseForm parses both urlencoded and multipart form bodies, populating
+// r.Form with the text fields of either. ParseForm runs first so its
+// errors (e.g. an oversized urlencoded body) stop the bind, where
+// ParseMultipartForm alone would keep parsing the body alongside a
+// ParseForm failure; the media type match is then left to
+// ParseMultipartForm, which honors case-insensitive Content-Type
+// values.
+func parseForm(r *http.Request) error {
+ // The form parsers read r.Body directly instead of going through
+ // requestBody, so restore a buffered body for them; without this a
+ // request whose body was already consumed binds empty values.
+ if body, ok := BodyFromContext(r.Context()); ok && r.PostForm == nil {
+ r.Body = io.NopCloser(bytes.NewReader(body))
+ }
+ if err := r.ParseForm(); err != nil {
+ return err
+ }
+ //nolint:gosec // memory is capped by defaultMaxFormMemory; oversized bodies are rejected upstream
+ if err := r.ParseMultipartForm(defaultMaxFormMemory); err != nil {
+ if errors.Is(err, http.ErrNotMultipart) {
+ return nil
+ }
+ return err
+ }
+ return nil
+}
+
+// pathSource returns a Decoder that binds path values (r.PathValue) into T
+// using `path` struct tags. r.PathValue returns "" both for a missing
+// wildcard and for one matching an empty value (e.g. {rest...}), so an
+// empty path value is treated as absent and triggers default=.
+func pathSource[T any]() Decoder[T] {
+ return DecoderFunc[T](func(r *http.Request) (*T, error) {
+ var t T
+ lookup := func(name string) ([]string, bool) {
+ v := r.PathValue(name)
+ return []string{v}, v != ""
+ }
+ if err := bindParams(&t, "path", lookup, nil); err != nil {
+ return nil, err
+ }
+ return &t, nil
+ })
+}
+
+// headerSource returns a Decoder that binds request headers into T using
+// `header` struct tags. Header names are matched case-insensitively.
+func headerSource[T any]() Decoder[T] {
+ return DecoderFunc[T](func(r *http.Request) (*T, error) {
+ var t T
+ lookup := func(name string) ([]string, bool) {
+ vs := r.Header.Values(name)
+ return vs, len(vs) > 0
+ }
+ if err := bindParams(&t, "header", lookup, nil); err != nil {
+ return nil, err
+ }
+ return &t, nil
+ })
+}
+
+// bindValues binds values into dst using the given struct tag: map targets
+// take the values directly, struct targets go through bindParams.
+func bindValues(
+ dst any,
+ tag string,
+ values url.Values,
+ files func(name string) ([]*multipart.FileHeader, bool),
+) error {
+ if tryBindMap(dst, values) {
+ return nil
+ }
+ return bindParams(dst, tag, urlValuesLookup(values), files)
+}
+
+// bindParams fills the exported fields of dst (a non-nil pointer to a
+// struct) from values supplied by lookup, keyed by the given struct tag.
+// lookup reports whether the key is present; a present key may still
+// carry empty values, which count as absent for default=. When files is
+// non-nil, *multipart.FileHeader and []*multipart.FileHeader fields bind
+// from it instead; when it is nil they stay unset.
+func bindParams(
+ dst any,
+ tag string,
+ lookup func(name string) ([]string, bool),
+ files func(name string) ([]*multipart.FileHeader, bool),
+) error {
+ v := reflect.ValueOf(dst)
+ if v.Kind() != reflect.Pointer || v.IsNil() || v.Elem().Kind() != reflect.Struct {
+ return ErrBindTarget
+ }
+ _, err := bindStruct(v.Elem(), tag, lookup, files)
+ return err
+}
+
+// bindStruct fills the exported fields of s, reporting whether any field
+// was set.
+func bindStruct(
+ s reflect.Value,
+ tag string,
+ lookup func(name string) ([]string, bool),
+ files func(name string) ([]*multipart.FileHeader, bool),
+) (bool, error) {
+ plan := planFor(s.Type(), tag)
+ var isSet bool
+ for i := range plan.fields {
+ // Plans are immutable once built, so fields bind through a
+ // pointer instead of copying the plan per field.
+ fp := &plan.fields[i]
+ set, err := bindField(s.Field(fp.index), fp, tag, lookup, files)
+ if err != nil {
+ return false, err
+ }
+ isSet = isSet || set
+ }
+ return isSet, nil
+}
+
+// bindField binds one field from its cached plan and reports whether it
+// was set.
+func bindField(
+ fv reflect.Value,
+ fp *fieldPlan,
+ tag string,
+ lookup func(name string) ([]string, bool),
+ files func(name string) ([]*multipart.FileHeader, bool),
+) (bool, error) {
+ if fp.embedded {
+ // Recurse; a nil embedded pointer stays nil unless a field
+ // inside it binds, which includes binding from default=.
+ ptr := fv
+ if fp.embPtr {
+ if fv.IsNil() {
+ ptr = reflect.New(fv.Type().Elem())
+ }
+ } else {
+ ptr = fv.Addr()
+ }
+ set, err := bindStruct(ptr.Elem(), tag, lookup, files)
+ if err != nil {
+ return false, err
+ }
+ if fp.embPtr && fv.IsNil() && set {
+ fv.Set(ptr)
+ }
+ return set, nil
+ }
+ if fp.isFile || fp.isFileSlice {
+ if files == nil {
+ return false, nil
+ }
+ fhs, ok := files(fp.name)
+ if !ok {
+ return false, nil
+ }
+ if fp.isFile {
+ fv.Set(reflect.ValueOf(fhs[0]))
+ } else {
+ fv.Set(reflect.ValueOf(fhs))
+ }
+ return true, nil
+ }
+ raw, ok := lookup(fp.name)
+ if !ok || emptyValues(raw) {
+ if fp.hasDef {
+ raw = []string{fp.def}
+ } else if !ok || len(raw) == 0 {
+ return false, nil
+ }
+ // Present but empty values without a default bind as-is, so
+ // non-string fields report a conversion error.
+ }
+ if err := setField(fv, fp, raw); err != nil {
+ return false, fmt.Errorf("sim: bind field %s: %w", fp.goName, err)
+ }
+ return true, nil
+}
+
+// emptyValues reports whether raw carries nothing to bind: no values at
+// all, or only empty strings. Such a result counts as absent, so a
+// default= option applies to "?k=" and "?k=&k=" alike.
+func emptyValues(raw []string) bool {
+ for _, s := range raw {
+ if s != "" {
+ return false
+ }
+ }
+ return true
+}
+
+var (
+ textUnmarshalerType = reflect.TypeFor[encoding.TextUnmarshaler]()
+ durationType = reflect.TypeFor[time.Duration]()
+ fileHeaderType = reflect.TypeFor[*multipart.FileHeader]()
+ fileHeaderSliceType = reflect.TypeFor[[]*multipart.FileHeader]()
+
+ // plans caches parsed field layouts per (type, tag); entries are
+ // bounded by the distinct (struct type, tag) pairs the program binds.
+ plansMu sync.RWMutex
+ plans = make(map[planKey]*structPlan)
+)
+
+// planFor returns the plan for typ and tag, building and caching it on
+// first use.
+func planFor(typ reflect.Type, tag string) *structPlan {
+ key := planKey{typ, tag}
+ plansMu.RLock()
+ p, ok := plans[key]
+ plansMu.RUnlock()
+ if ok {
+ return p
+ }
+ plansMu.Lock()
+ defer plansMu.Unlock()
+ if p, ok = plans[key]; ok {
+ return p
+ }
+ p = buildPlan(typ, tag)
+ plans[key] = p
+ return p
+}
+
+// buildPlan computes the bind layout of typ for tag: exported fields,
+// their bind keys and defaults, and their conversion plans.
+func buildPlan(typ reflect.Type, tag string) *structPlan {
+ plan := &structPlan{fields: make([]fieldPlan, 0, typ.NumField())}
+ for i := range typ.NumField() {
+ if fp, ok := planField(typ.Field(i), i, tag); ok {
+ plan.fields = append(plan.fields, fp)
+ }
+ }
+ return plan
+}
+
+// planField computes the bind layout of one struct field and reports
+// whether the field binds at all.
+func planField(sf reflect.StructField, index int, tag string) (fieldPlan, bool) {
+ // Unexported fields never bind; for an anonymous field this also
+ // skips a struct whose type name is unexported.
+ if !sf.IsExported() {
+ return fieldPlan{}, false
+ }
+ name, opts, _ := strings.Cut(sf.Tag.Get(tag), ",")
+ if name == "-" {
+ // A "-" field never binds, embedded or not.
+ return fieldPlan{}, false
+ }
+ // Conversion plans are computed on the dereferenced field: a field and
+ // a pointer to it bind the same way.
+ t := sf.Type
+ if t.Kind() == reflect.Pointer {
+ t = t.Elem()
+ }
+ // A type that decodes itself from text binds as a single value, even
+ // when it is anonymous or a slice of bytes, as net.IP is.
+ selfDecoding := reflect.PointerTo(t).Implements(textUnmarshalerType)
+ // Other anonymous struct fields recurse with the same tag; a tag name
+ // on them is ignored.
+ if sf.Anonymous && !selfDecoding && isEmbeddable(sf.Type) {
+ return fieldPlan{
+ index: index,
+ goName: sf.Name,
+ embedded: true,
+ embPtr: sf.Type.Kind() == reflect.Pointer,
+ }, true
+ }
+ if name == "" {
+ name = sf.Name
+ }
+ fp := fieldPlan{index: index, goName: sf.Name, name: name}
+ if def, ok := tagDefault(opts); ok {
+ fp.def, fp.hasDef = def, true
+ }
+ switch {
+ case sf.Type == fileHeaderType:
+ fp.isFile = true
+ case sf.Type == fileHeaderSliceType:
+ fp.isFileSlice = true
+ case !selfDecoding && t.Kind() == reflect.Slice:
+ planSlice(&fp, t.Elem())
+ default:
+ fp.scalar = buildScalar(t)
+ }
+ return fp, true
+}
+
+// planSlice fills the slice part of fp for a slice field with the given
+// element type.
+func planSlice(fp *fieldPlan, et reflect.Type) {
+ fp.isSlice = true
+ if et.Kind() == reflect.Uint8 {
+ // []byte binds the raw value instead of one element per value.
+ fp.byteSlice = true
+ return
+ }
+ // Pointer elements (e.g. []*int) are allocated and parsed from their
+ // string form, one per value.
+ if et.Kind() == reflect.Pointer {
+ fp.elemPtr = true
+ et = et.Elem()
+ }
+ fp.scalar = buildScalar(et)
+}
+
+// isEmbeddable reports whether an anonymous field recurses into the
+// struct it contains.
+func isEmbeddable(t reflect.Type) bool {
+ return t.Kind() == reflect.Struct ||
+ t.Kind() == reflect.Pointer && t.Elem().Kind() == reflect.Struct
+}
+
+// urlValuesLookup adapts url.Values to the lookup function used by
+// bindParams.
+func urlValuesLookup(values url.Values) func(name string) ([]string, bool) {
+ return func(name string) ([]string, bool) {
+ vs, ok := values[name]
+ return vs, ok && len(vs) > 0
+ }
+}
+
+// tagDefault extracts the `default=value` option from struct tag
+// options; the value keeps everything after the first "=" up to the next
+// option, so it cannot contain a comma.
+func tagDefault(opts string) (string, bool) {
+ for opts != "" {
+ var opt string
+ opt, opts, _ = strings.Cut(opts, ",")
+ k, v, _ := strings.Cut(opt, "=")
+ if k == "default" {
+ return v, true
+ }
+ }
+ return "", false
+}
+
+// tryBindMap fills dst when it points to a map[string]string or
+// map[string][]string and reports whether it did: string maps take the
+// last value per key, slice maps copy every value. Other types are left
+// untouched.
+func tryBindMap(dst any, values url.Values) bool {
+ switch m := dst.(type) {
+ case *map[string]string:
+ if *m == nil {
+ *m = make(map[string]string, len(values))
+ }
+ for k, v := range values {
+ if len(v) > 0 {
+ (*m)[k] = v[len(v)-1]
+ }
+ }
+ return true
+ case *map[string][]string:
+ if *m == nil {
+ *m = make(map[string][]string, len(values))
+ }
+ for k, v := range values {
+ (*m)[k] = slices.Clone(v)
+ }
+ return true
+ }
+ return false
+}
+
+// setField assigns raw to the field; scalars and []byte take the last
+// value, other slices take every value.
+func setField(f reflect.Value, fp *fieldPlan, raw []string) error {
+ if f.Kind() == reflect.Pointer {
+ if f.IsNil() {
+ f.Set(reflect.New(f.Type().Elem()))
+ }
+ f = f.Elem()
+ }
+ if fp.isSlice {
+ // []byte binds the raw string instead of parsing it per element.
+ if fp.byteSlice {
+ f.SetBytes([]byte(raw[len(raw)-1]))
+ return nil
+ }
+ slice := reflect.MakeSlice(f.Type(), len(raw), len(raw))
+ for i, s := range raw {
+ elem := slice.Index(i)
+ if fp.elemPtr {
+ // MakeSlice leaves pointer elements nil; allocate first.
+ elem.Set(reflect.New(elem.Type().Elem()))
+ elem = elem.Elem()
+ }
+ if err := fp.scalar.parse(elem, s); err != nil {
+ return err
+ }
+ }
+ f.Set(slice)
+ return nil
+ }
+ return fp.scalar.parse(f, raw[len(raw)-1])
+}
+
+// planKey identifies a cached bind plan by struct type and tag.
+type planKey struct {
+ typ reflect.Type
+ tag string
+}
+
+// structPlan is the cached bind layout of one struct type and tag.
+type structPlan struct {
+ fields []fieldPlan
+}
+
+// fieldPlan is the cached bind layout of one exported struct field.
+type fieldPlan struct {
+ index int // field index in the struct
+ goName string // Go field name, used in errors
+ name string // bind key: tag name or field name
+ def string // default= option value
+ hasDef bool // default= option present
+ embedded bool // anonymous struct or *struct field: recurse
+ embPtr bool // embedded field is a pointer
+ isFile bool // *multipart.FileHeader
+ isFileSlice bool // []*multipart.FileHeader
+ isSlice bool // binds every value into a slice
+ byteSlice bool // []byte fast path
+ elemPtr bool // slice element is a pointer: allocate per element
+ scalar scalarPlan
+}
+
+// scalarPlan is the cached conversion plan of one scalar type.
+type scalarPlan struct {
+ kind reflect.Kind
+ isDuration bool // converts via time.ParseDuration
+ textUnmarshal bool // *T implements encoding.TextUnmarshaler
+}
+
+// buildScalar computes the conversion plan of one scalar type.
+func buildScalar(t reflect.Type) scalarPlan {
+ return scalarPlan{
+ kind: t.Kind(),
+ isDuration: t == durationType,
+ textUnmarshal: reflect.PointerTo(t).Implements(textUnmarshalerType),
+ }
+}
+
+// parse assigns s to f, which must be settable and of the plan's type.
+// Types whose pointer implements encoding.TextUnmarshaler decode
+// themselves; the remaining kinds parse their string form.
+func (sp scalarPlan) parse(f reflect.Value, s string) error {
+ if sp.textUnmarshal {
+ // buildScalar verified *T implements encoding.TextUnmarshaler,
+ // so the assertion cannot fail.
+ u, _ := reflect.TypeAssert[encoding.TextUnmarshaler](f.Addr())
+ return u.UnmarshalText([]byte(s))
+ }
+ //nolint:exhaustive // the remaining kinds fall through to the unsupported-kind error
+ switch sp.kind {
+ case reflect.String:
+ f.SetString(s)
+ case reflect.Bool:
+ b, err := strconv.ParseBool(s)
+ if err != nil {
+ return err
+ }
+ f.SetBool(b)
+ case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
+ if sp.isDuration {
+ d, err := time.ParseDuration(s)
+ if err != nil {
+ return err
+ }
+ f.SetInt(int64(d))
+ return nil
+ }
+ n, err := strconv.ParseInt(s, 10, f.Type().Bits())
+ if err != nil {
+ return err
+ }
+ f.SetInt(n)
+ case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
+ n, err := strconv.ParseUint(s, 10, f.Type().Bits())
+ if err != nil {
+ return err
+ }
+ f.SetUint(n)
+ case reflect.Float32, reflect.Float64:
+ n, err := strconv.ParseFloat(s, f.Type().Bits())
+ if err != nil {
+ return err
+ }
+ f.SetFloat(n)
+ default:
+ return fmt.Errorf("%w %s", ErrUnsupportedKind, sp.kind)
+ }
+ return nil
+}
diff --git a/binder_test.go b/binder_test.go
new file mode 100644
index 0000000..e3ec19c
--- /dev/null
+++ b/binder_test.go
@@ -0,0 +1,708 @@
+// Copyright (c) 2026 The Sim Authors
+// Use of this source code is governed by a MIT license
+// that can be found in the LICENSE file.
+
+package sim_test
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "mime/multipart"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "net/netip"
+ "reflect"
+ "slices"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/qm012/sim"
+)
+
+func queryRequest(t *testing.T, rawQuery string) *http.Request {
+ t.Helper()
+ return httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/?"+rawQuery, nil)
+}
+
+func formRequest(t *testing.T, body string) *http.Request {
+ t.Helper()
+ r := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", strings.NewReader(body))
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ return r
+}
+
+type EmbedPage struct {
+ Page int `query:"embeddedPage"`
+}
+
+// queryReq covers every BindQuery scenario in one struct: untagged,
+// scalar, pointer, slice, []byte, TextUnmarshaler, default= and "-"
+// fields, plus an embedded pointer struct and an embedded self-decoding
+// type.
+type queryReq struct {
+ Name string // untagged: binds by field name
+ OK bool `query:"ok"`
+ Count uint `query:"count"`
+ Rate float64 `query:"rate"`
+ Page *int `query:"page,default=1"`
+ IDs []int `query:"id"`
+ PtrIDs []*int `query:"pid"`
+ Start time.Time `query:"start"`
+ Addr netip.Addr `query:"addr"`
+ Host net.IP `query:"host"` // self-decoding slice of bytes
+ Timeout time.Duration `query:"timeout"`
+ Tags []time.Time `query:"tags"`
+ Data []byte `query:"data"`
+ Secret string `query:"-"`
+ *EmbedPage
+ time.Time // embedded self-decoding type: binds by field name
+}
+
+func TestBindQuery(t *testing.T) {
+ start := time.Date(2026, 5, 1, 10, 0, 0, 0, time.UTC)
+ tag1 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+ tag2 := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)
+ unmarshalURL := "start=2026-05-01T10:00:00Z&addr=192.168.1.1&timeout=1h30m"
+ tagsURL := "tags=2026-01-01T00:00:00Z&tags=2026-02-01T00:00:00Z"
+ tests := []struct {
+ name string
+ url string
+ want queryReq
+ }{
+ {"untagged field", "Name=alice", queryReq{Name: "alice", Page: new(1)}},
+ {
+ "scalar kinds", "ok=true&count=7&rate=1.5&page=3",
+ queryReq{OK: true, Count: 7, Rate: 1.5, Page: new(3)},
+ },
+ {"int slice", "id=1&id=2&id=3", queryReq{Page: new(1), IDs: []int{1, 2, 3}}},
+ {"pointer slice", "pid=1&pid=2", queryReq{Page: new(1), PtrIDs: []*int{new(1), new(2)}}},
+ {
+ "text unmarshalers", unmarshalURL,
+ queryReq{
+ Page: new(1),
+ Start: start,
+ Addr: netip.MustParseAddr("192.168.1.1"),
+ Timeout: 90 * time.Minute,
+ },
+ },
+ // A byte slice that decodes itself parses the whole value
+ // instead of taking its raw bytes.
+ {"self-decoding byte slice", "host=192.168.1.1", queryReq{Page: new(1), Host: net.ParseIP("192.168.1.1")}},
+ {"embedded self-decoding type", "Time=2026-05-01T10:00:00Z", queryReq{Page: new(1), Time: start}},
+ {"unmarshaler slice", tagsURL, queryReq{Page: new(1), Tags: []time.Time{tag1, tag2}}},
+ {"byte slice", "data=hello", queryReq{Page: new(1), Data: []byte("hello")}},
+ {"defaults on missing keys", "", queryReq{Page: new(1)}},
+ {"defaults on empty values", "page=", queryReq{Page: new(1)}},
+ {"defaults on repeated empty values", "page=&page=", queryReq{Page: new(1)}},
+ // Scalars take the last value, which here beats the default.
+ {"last value wins over empty", "page=&page=3", queryReq{Page: new(3)}},
+ {"embedded pointer allocated", "embeddedPage=3", queryReq{Page: new(1), EmbedPage: &EmbedPage{Page: 3}}},
+ {"embedded pointer stays nil", "ok=true", queryReq{OK: true, Page: new(1)}},
+ // A "-" tag skips the field entirely.
+ {"dash tag skipped", "Secret=x&Name=alice", queryReq{Name: "alice", Page: new(1)}},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := sim.BindQuery[queryReq](queryRequest(t, tt.url))
+ if err != nil {
+ t.Fatalf("BindQuery() error = %v", err)
+ }
+ if !reflect.DeepEqual(*got, tt.want) {
+ t.Errorf("BindQuery() = %#v, want %#v", *got, tt.want)
+ }
+ })
+ }
+}
+
+func TestBindQueryDashTag(t *testing.T) {
+ got, err := sim.BindQuery[struct {
+ EmbedPage `query:"-"`
+ Name string `query:"name"`
+ }](queryRequest(t, "embeddedPage=3&name=alice"))
+ if err != nil {
+ t.Fatalf("BindQuery() error = %v", err)
+ }
+ if got.Page != 0 || got.Name != "alice" {
+ t.Errorf("BindQuery() = %+v, want skipped embedded and name=alice", *got)
+ }
+}
+
+type DefaultPage struct {
+ Size int `query:"size,default=10"`
+}
+
+func TestBindQueryEmbeddedDefault(t *testing.T) {
+ got, err := sim.BindQuery[struct{ *DefaultPage }](queryRequest(t, ""))
+ if err != nil {
+ t.Fatalf("BindQuery() error = %v", err)
+ }
+ if got.DefaultPage == nil || got.Size != 10 {
+ t.Errorf("BindQuery() DefaultPage = %+v, want allocated with size=10", got.DefaultPage)
+ }
+}
+
+// default= applies even when other options precede it in the tag.
+func TestBindQueryDefaultAfterOption(t *testing.T) {
+ got, err := sim.BindQuery[struct {
+ Page int `query:"page,opt,default=7"`
+ }](queryRequest(t, ""))
+ if err != nil {
+ t.Fatalf("BindQuery() error = %v", err)
+ }
+ if got.Page != 7 {
+ t.Errorf("BindQuery() Page = %d, want 7", got.Page)
+ }
+}
+
+func TestBindMap(t *testing.T) {
+ tests := []struct {
+ name string
+ bind func(t *testing.T) (any, error)
+ want any
+ }{
+ {
+ name: "query into string map",
+ bind: func(t *testing.T) (any, error) {
+ t.Helper()
+ return sim.BindQuery[map[string]string](queryRequest(t, "a=1&a=2&b=3"))
+ },
+ want: &map[string]string{"a": "2", "b": "3"},
+ },
+ {
+ name: "form into slice map",
+ bind: func(t *testing.T) (any, error) {
+ t.Helper()
+ return sim.BindForm[map[string][]string](formRequest(t, "a=1&a=2&b=3"))
+ },
+ want: &map[string][]string{"a": {"1", "2"}, "b": {"3"}},
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := tt.bind(t)
+ if err != nil {
+ t.Fatalf("bind error = %v", err)
+ }
+ if !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("bind = %#v, want %#v", got, tt.want)
+ }
+ })
+ }
+}
+
+type bodyReq struct {
+ Name string `json:"name" xml:"name"`
+ Age int `json:"age" xml:"age"`
+}
+
+func TestBindBody(t *testing.T) {
+ tests := []struct {
+ name string
+ body string
+ bind func(r *http.Request) (*bodyReq, error)
+ want bodyReq
+ wantErr bool
+ }{
+ {
+ name: "json decodes",
+ body: `{"name":"alice","age":30}`,
+ bind: func(r *http.Request) (*bodyReq, error) { return sim.BindJSON[bodyReq](r) },
+ want: bodyReq{Name: "alice", Age: 30},
+ },
+ {
+ name: "json rejects malformed body",
+ body: `{"name":`,
+ bind: func(r *http.Request) (*bodyReq, error) { return sim.BindJSON[bodyReq](r) },
+ wantErr: true,
+ },
+ {
+ name: "json rejects unknown fields on request",
+ body: `{"name":"alice","extra":true}`,
+ bind: func(r *http.Request) (*bodyReq, error) {
+ return sim.BindJSON[bodyReq](r, sim.DisallowUnknownFields())
+ },
+ wantErr: true,
+ },
+ {
+ name: "xml decodes",
+ body: `alice30`,
+ bind: sim.BindXML[bodyReq],
+ want: bodyReq{Name: "alice", Age: 30},
+ },
+ {
+ name: "xml rejects malformed body",
+ body: `alice`,
+ bind: sim.BindXML[bodyReq],
+ wantErr: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ r := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", strings.NewReader(tt.body))
+ got, err := tt.bind(r)
+ if tt.wantErr {
+ if err == nil {
+ t.Fatal("bind error = nil, want error")
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("bind error = %v", err)
+ }
+ if *got != tt.want {
+ t.Errorf("bind = %#v, want %#v", *got, tt.want)
+ }
+ })
+ }
+}
+
+func TestBindJSONUseNumber(t *testing.T) {
+ r := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/",
+ strings.NewReader(`{"n":9007199254740993}`))
+ got, err := sim.BindJSON[map[string]any](r, sim.UseNumber())
+ if err != nil {
+ t.Fatalf("BindJSON() error = %v", err)
+ }
+ if n := (*got)["n"]; n != json.Number("9007199254740993") {
+ t.Errorf("BindJSON() n = %#v, want json.Number", n)
+ }
+}
+
+type headerReq struct {
+ ContentType string `header:"content-type"`
+ RequestID string `header:"x-request-id"`
+ Languages []string `header:"accept-language"`
+ Missing string `header:"x-missing"`
+ DefaultVal string `header:"x-default,default=xx"`
+}
+
+func TestBindHeader(t *testing.T) {
+ r := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
+ r.Header.Set("Content-Type", "application/json")
+ r.Header.Set("X-Request-ID", "req-42")
+ r.Header.Add("Accept-Language", "zh-CN")
+ r.Header.Add("Accept-Language", "en-US")
+
+ got, err := sim.BindHeader[headerReq](r)
+ if err != nil {
+ t.Fatalf("BindHeader() error = %v", err)
+ }
+ want := headerReq{
+ ContentType: "application/json",
+ RequestID: "req-42",
+ Languages: []string{"zh-CN", "en-US"},
+ DefaultVal: "xx",
+ }
+ if !reflect.DeepEqual(*got, want) {
+ t.Errorf("BindHeader() = %#v, want %#v", *got, want)
+ }
+}
+
+type pathReq struct {
+ ID int `path:"id"`
+ Name string `path:"name,default=anon"`
+}
+
+func TestBindPath(t *testing.T) {
+ tests := []struct {
+ name string
+ setup func(r *http.Request)
+ want pathReq
+ }{
+ {
+ name: "explicit value",
+ setup: func(r *http.Request) { r.SetPathValue("id", "42") },
+ want: pathReq{ID: 42, Name: "anon"},
+ },
+ {
+ name: "empty wildcard takes the default",
+ setup: func(r *http.Request) { r.SetPathValue("name", "") },
+ want: pathReq{Name: "anon"},
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ r := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/users/42", nil)
+ tt.setup(r)
+ got, err := sim.BindPath[pathReq](r)
+ if err != nil {
+ t.Fatalf("BindPath() error = %v", err)
+ }
+ if *got != tt.want {
+ t.Errorf("BindPath() = %#v, want %#v", *got, tt.want)
+ }
+ })
+ }
+}
+
+type formReq struct {
+ Name string `form:"name"`
+ Bio string `form:"bio,default=anon"`
+ Avatar *multipart.FileHeader `form:"avatar"`
+ Docs []*multipart.FileHeader `form:"docs"`
+}
+
+type formFile struct {
+ field, name string
+}
+
+// multipartRequest builds a multipart POST request holding the "name"
+// text field plus the given file parts. The generated media type is
+// replaced with ct so callers can vary its case.
+func multipartRequest(t *testing.T, ct string, files []formFile) *http.Request {
+ t.Helper()
+ var body bytes.Buffer
+ w := multipart.NewWriter(&body)
+ if err := w.WriteField("name", "alice"); err != nil {
+ t.Fatalf("WriteField() error = %v", err)
+ }
+ for _, f := range files {
+ fw, err := w.CreateFormFile(f.field, f.name)
+ if err != nil {
+ t.Fatalf("CreateFormFile() error = %v", err)
+ }
+ if _, err = fw.Write([]byte("data")); err != nil {
+ t.Fatalf("Write() error = %v", err)
+ }
+ }
+ if err := w.Close(); err != nil {
+ t.Fatalf("Close() error = %v", err)
+ }
+ r := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", &body)
+ r.Header.Set("Content-Type",
+ strings.Replace(w.FormDataContentType(), "multipart/form-data", ct, 1))
+ return r
+}
+
+func TestBindForm(t *testing.T) {
+ tests := []struct {
+ name string
+ ct string // multipart media type; empty binds a urlencoded body
+ files []formFile
+ wantAvatar string
+ wantDocs []string
+ }{
+ {name: "urlencoded"},
+ {name: "multipart", ct: "multipart/form-data"},
+ // Media types are case-insensitive, so a client sending
+ // "Multipart/Form-Data" must bind the same way.
+ {name: "multipart mixed-case content type", ct: "Multipart/Form-Data"},
+ {
+ name: "multipart with files",
+ ct: "multipart/form-data",
+ files: []formFile{{"avatar", "me.png"}, {"docs", "a.txt"}, {"docs", "b.txt"}},
+ wantAvatar: "me.png",
+ wantDocs: []string{"a.txt", "b.txt"},
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ r := formRequest(t, "name=alice")
+ if tt.ct != "" {
+ r = multipartRequest(t, tt.ct, tt.files)
+ }
+ got, err := sim.BindForm[formReq](r)
+ if err != nil {
+ t.Fatalf("BindForm() error = %v", err)
+ }
+ // The absent bio field takes its default.
+ if got.Name != "alice" || got.Bio != "anon" {
+ t.Errorf("BindForm() = %+v, want name=alice and bio=anon", *got)
+ }
+ var avatar string
+ if got.Avatar != nil {
+ avatar = got.Avatar.Filename
+ }
+ if avatar != tt.wantAvatar {
+ t.Errorf("BindForm() Avatar = %q, want %q", avatar, tt.wantAvatar)
+ }
+ docs := make([]string, 0, len(got.Docs))
+ for _, fh := range got.Docs {
+ docs = append(docs, fh.Filename)
+ }
+ if !slices.Equal(docs, tt.wantDocs) {
+ t.Errorf("BindForm() Docs = %q, want %q", docs, tt.wantDocs)
+ }
+ })
+ }
+}
+
+func TestBindFormQueryMerge(t *testing.T) {
+ r := formRequest(t, "name=alice")
+ r.URL.RawQuery = "q=1"
+ got, err := sim.BindForm[struct {
+ Name string `form:"name"`
+ Q string `form:"q"`
+ }](r)
+ if err != nil {
+ t.Fatalf("BindForm() error = %v", err)
+ }
+ if got.Name != "alice" || got.Q != "1" {
+ t.Errorf("BindForm() = %+v, want name=alice and q=1", *got)
+ }
+}
+
+func TestBindFormOversizedURLEncoded(t *testing.T) {
+ r := formRequest(t, "name="+strings.Repeat("a", 10<<20))
+ if _, err := sim.BindForm[formReq](r); err == nil {
+ t.Fatal("BindForm() error = nil, want error for oversized urlencoded body")
+ }
+}
+
+func TestBindCustomDecoder(t *testing.T) {
+ src := sim.DecoderFunc[formReq](func(r *http.Request) (*formReq, error) {
+ return &formReq{Name: r.URL.Query().Get("name")}, nil
+ })
+ got, err := sim.Bind(queryRequest(t, "name=alice"), src)
+ if err != nil {
+ t.Fatalf("Bind() error = %v", err)
+ }
+ if got.Name != "alice" {
+ t.Errorf("Bind() Name = %q, want %q", got.Name, "alice")
+ }
+
+ failing := sim.DecoderFunc[formReq](func(*http.Request) (*formReq, error) {
+ return nil, errDecode
+ })
+ if _, err = sim.Bind(queryRequest(t, ""), failing); !errors.Is(err, errDecode) {
+ t.Errorf("Bind() error = %v, want errDecode", err)
+ }
+}
+
+type badKindReq struct {
+ M map[string]string `query:"m"`
+}
+
+func TestBindNilValue(t *testing.T) {
+ src := sim.DecoderFunc[int](func(*http.Request) (*int, error) {
+ //nolint:nilnil // a nil value from a decoder is the scenario under test
+ return nil, nil
+ })
+ got, err := sim.Bind(queryRequest(t, ""), src)
+ if !errors.Is(err, sim.ErrDecodeNil) || got != nil {
+ t.Errorf("Bind() = %v, %v, want nil, nil", got, err)
+ }
+}
+
+type alwaysInvalid struct {
+ Name string `query:"name"`
+}
+
+func (alwaysInvalid) Validate(context.Context) error { return errInvalid }
+
+type failReader struct{}
+
+func (failReader) Read([]byte) (int, error) { return 0, errRead }
+
+var (
+ errInvalid = errors.New("invalid")
+ errDecode = errors.New("decode failed")
+ errRead = errors.New("read failed")
+)
+
+// TestBindTargetErrors covers the exported sentinels: a target that
+// cannot bind at all, and a field kind that cannot be bound from
+// strings.
+func TestBindTargetErrors(t *testing.T) {
+ tests := []struct {
+ name string
+ bind func(t *testing.T) error
+ want error
+ }{
+ {
+ name: "unsupported field kind",
+ bind: func(t *testing.T) error {
+ t.Helper()
+ _, err := sim.BindQuery[badKindReq](queryRequest(t, "m=x"))
+ return err
+ },
+ want: sim.ErrUnsupportedKind,
+ },
+ {
+ name: "non-struct target",
+ bind: func(t *testing.T) error {
+ t.Helper()
+ _, err := sim.BindQuery[int](queryRequest(t, "x=1"))
+ return err
+ },
+ want: sim.ErrBindTarget,
+ },
+ {
+ // Only BindQuery and BindForm accept map targets.
+ name: "map target on header",
+ bind: func(t *testing.T) error {
+ t.Helper()
+ r := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
+ _, err := sim.BindHeader[map[string]string](r)
+ return err
+ },
+ want: sim.ErrBindTarget,
+ },
+ {
+ name: "map target on path",
+ bind: func(t *testing.T) error {
+ t.Helper()
+ r := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
+ _, err := sim.BindPath[map[string]string](r)
+ return err
+ },
+ want: sim.ErrBindTarget,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if err := tt.bind(t); !errors.Is(err, tt.want) {
+ t.Errorf("bind error = %v, want %v", err, tt.want)
+ }
+ })
+ }
+}
+
+func TestBindQueryValueErrors(t *testing.T) {
+ tests := []struct {
+ name string
+ url string
+ }{
+ {"malformed query", "ok=%zz"},
+ // An empty value with no default binds as-is, so a non-string
+ // field reports a conversion error.
+ {"empty value without default", "embeddedPage="},
+ {"bad scalar value", "count=many"},
+ {"bad slice element", "id=one"},
+ {"bad time value", "start=not-a-time"},
+ {"bad duration value", "timeout=soon"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if _, err := sim.BindQuery[queryReq](queryRequest(t, tt.url)); err == nil {
+ t.Errorf("BindQuery(%q) error = nil, want error", tt.url)
+ }
+ })
+ }
+}
+
+func TestBindDecoderErrors(t *testing.T) {
+ tests := []struct {
+ name string
+ bind func(t *testing.T) error
+ }{
+ {
+ name: "path",
+ bind: func(t *testing.T) error {
+ t.Helper()
+ r := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/users/abc", nil)
+ r.SetPathValue("id", "abc")
+ _, err := sim.BindPath[pathReq](r)
+ return err
+ },
+ },
+ {
+ name: "header",
+ bind: func(t *testing.T) error {
+ t.Helper()
+ r := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
+ r.Header.Set("X-Retry", "soon")
+ _, err := sim.BindHeader[struct {
+ Retry int `header:"x-retry"`
+ }](r)
+ return err
+ },
+ },
+ {
+ name: "form",
+ bind: func(t *testing.T) error {
+ t.Helper()
+ _, err := sim.BindForm[struct {
+ Age int `form:"age"`
+ }](formRequest(t, "age=old"))
+ return err
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if err := tt.bind(t); err == nil {
+ t.Error("bind error = nil, want error")
+ }
+ })
+ }
+}
+
+func TestBindQueryValidates(t *testing.T) {
+ _, err := sim.BindQuery[alwaysInvalid](queryRequest(t, "name=x"))
+ if !errors.Is(err, errInvalid) {
+ t.Errorf("BindQuery() error = %v, want errInvalid", err)
+ }
+}
+
+func TestBufferBodyReadError(t *testing.T) {
+ r := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", failReader{})
+ if _, err := sim.BufferBody(r); !errors.Is(err, errRead) {
+ t.Errorf("BufferBody() error = %v, want errRead", err)
+ }
+}
+
+func TestBufferBody(t *testing.T) {
+ const payload = `{"name":"alice","age":30}`
+ r := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", strings.NewReader(payload))
+ nr, err := sim.BufferBody(r)
+ if err != nil {
+ t.Fatalf("BufferBody() error = %v", err)
+ }
+ body, ok := sim.BodyFromContext(nr.Context())
+ if !ok || string(body) != payload {
+ t.Fatalf("BodyFromContext() = %q, %t, want %q", body, ok, payload)
+ }
+
+ for range 5 {
+ got, err := sim.BindJSON[bodyReq](nr)
+ if err != nil {
+ t.Fatalf("BindJSON() error = %v", err)
+ }
+ if got.Name != "alice" {
+ t.Errorf("BindJSON() Name = %q, want %q", got.Name, "alice")
+ }
+ }
+}
+
+func TestBufferBodyForm(t *testing.T) {
+ nr, err := sim.BufferBody(formRequest(t, "name=alice"))
+ if err != nil {
+ t.Fatalf("BufferBody() error = %v", err)
+ }
+ if _, err = io.Copy(io.Discard, nr.Body); err != nil {
+ t.Fatalf("Copy() error = %v", err)
+ }
+ got, err := sim.BindForm[formReq](nr)
+ if err != nil {
+ t.Fatalf("BindForm() error = %v", err)
+ }
+ if got.Name != "alice" {
+ t.Errorf("BindForm() Name = %q, want %q", got.Name, "alice")
+ }
+}
+
+func BenchmarkBufferBody(b *testing.B) {
+ for _, size := range []int{1 << 10, 64 << 10, 1 << 20} {
+ b.Run(strconv.Itoa(size), func(b *testing.B) {
+ body := bytes.Repeat([]byte("x"), size)
+ rd := bytes.NewReader(body)
+ r := httptest.NewRequestWithContext(b.Context(), http.MethodPost, "/", rd)
+ b.ReportAllocs()
+ b.SetBytes(int64(size))
+ for b.Loop() {
+ rd.Reset(body)
+ r.Body = io.NopCloser(rd)
+ if _, err := sim.BufferBody(r); err != nil {
+ b.Fatal(err)
+ }
+ }
+ })
+ }
+}
diff --git a/example_test.go b/example_test.go
new file mode 100644
index 0000000..688c49e
--- /dev/null
+++ b/example_test.go
@@ -0,0 +1,82 @@
+// Copyright (c) 2026 The Sim Authors
+// Use of this source code is governed by a MIT license
+// that can be found in the LICENSE file.
+
+package sim_test
+
+import (
+ "context"
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+
+ "github.com/qm012/sim"
+)
+
+// errBadSignature is returned when X-Signature does not match the body's HMAC.
+var errBadSignature = errors.New("bad signature")
+
+// webhookEvent is the payload carried by a signed JSON webhook.
+type webhookEvent struct {
+ EventID string `json:"event_id"`
+ Type string `json:"type"`
+ // A real webhook event carries more fields (timestamp, actor, data, ...);
+ // this example decodes only the two it uses, and JSON ignores the rest.
+}
+
+// ExampleDecoderFunc plugs a custom format into Bind: a JSON webhook whose
+// X-Signature header is verified over the raw body bytes before the payload
+// is decoded. The decoder reads the body once and reuses those bytes for both
+// the HMAC check and the JSON decode.
+func ExampleDecoderFunc() {
+ secret := []byte("s3cret")
+
+ // verify yields an event only when X-Signature matches the HMAC-SHA256 of
+ // the raw body; a mismatch fails before any JSON is parsed. The Decoder is
+ // stateless per request, so one value serves every call.
+ verify := sim.DecoderFunc[webhookEvent](func(r *http.Request) (*webhookEvent, error) {
+ raw, err := io.ReadAll(r.Body)
+ if err != nil {
+ return nil, fmt.Errorf("read body: %w", err)
+ }
+ mac := hmac.New(sha256.New, secret)
+ _, _ = mac.Write(raw)
+ if !hmac.Equal([]byte(r.Header.Get("X-Signature")), []byte(hex.EncodeToString(mac.Sum(nil)))) {
+ return nil, errBadSignature
+ }
+ var e webhookEvent
+ if err := json.Unmarshal(raw, &e); err != nil {
+ return nil, fmt.Errorf("decode event: %w", err)
+ }
+ return &e, nil
+ })
+
+ body := `{"event_id":"evt_9f3a","type":"order.paid"}`
+ mac := hmac.New(sha256.New, secret)
+ _, _ = mac.Write([]byte(body))
+ sig := hex.EncodeToString(mac.Sum(nil))
+ ctx := context.Background()
+
+ // A correctly signed webhook decodes into the event.
+ ok := httptest.NewRequestWithContext(ctx, http.MethodPost, "/webhook", strings.NewReader(body))
+ ok.Header.Set("X-Signature", sig)
+ e, _ := sim.Bind(ok, verify)
+ fmt.Println("valid:", e.Type, e.EventID)
+
+ // A tampered signature is rejected before the payload is used.
+ bad := httptest.NewRequestWithContext(ctx, http.MethodPost, "/webhook", strings.NewReader(body))
+ bad.Header.Set("X-Signature", "deadbeef")
+ _, err := sim.Bind(bad, verify)
+ fmt.Println("tampered:", err)
+
+ // Output:
+ // valid: order.paid evt_9f3a
+ // tampered: bad signature
+}
diff --git a/sim.go b/sim.go
index 477005b..cddf42f 100644
--- a/sim.go
+++ b/sim.go
@@ -71,6 +71,42 @@
// as [App.Get]. [Default] returns an App with the standard wrappers
// already registered.
//
+// # Binding
+//
+// [BindJSON] and [BindXML] decode the request body, while [BindQuery],
+// [BindForm], [BindPath] and [BindHeader] fill a value from request
+// strings; [Bind] does the same with a custom [Decoder]. Every helper
+// validates the decoded value when it implements [Validator], and
+// [BufferBody] makes a request body readable more than once.
+//
+// The string binders share one set of rules, keyed by the struct tag
+// named after the binder — "query", "form", "path" or "header":
+//
+// - The bind key is the tag name, or the field name when the tag
+// carries none. A tag name of "-" skips the field, and unexported
+// fields never bind, including anonymous fields whose type name is
+// unexported.
+// - Anonymous struct fields recurse with the same tag; a nil embedded
+// pointer is allocated only when a field inside it binds, which
+// includes binding from default=. Named struct fields do not
+// recurse. An anonymous self-decoding field binds as a single
+// value instead of recursing.
+// - Bindable types are strings, bools, ints, uints, floats,
+// [time.Duration], any type whose pointer implements
+// [encoding.TextUnmarshaler], slices of those or of pointers to
+// them (as []*int or []*string), []byte, and pointers to any of
+// them. A self-decoding type binds as a single value even when it
+// is a slice of bytes, as net.IP is.
+// - Scalar and []byte fields take the last value of a repeated key;
+// slice fields take every value.
+// - The tag option default=value applies when the key is absent or
+// all of its values are empty. The value runs to the next option,
+// so it cannot contain a comma.
+//
+// [BindQuery] and [BindForm] also accept map[string]string and
+// map[string][]string as the target type; the other binders require a
+// struct.
+//
// See the documentation of [App] for the full routing API.
package sim