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