Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 77 additions & 9 deletions bind.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"reflect"
"strconv"
"strings"
"sync"
"time"
)

Expand Down Expand Up @@ -136,6 +137,73 @@ func (b *DefaultBinder) Bind(c *Context, target any) error {
return BindBody(c, target)
}

// bindFieldMeta is the cached, type-level reflection metadata for a single struct field. Reading struct
// tags (reflect.StructTag.Get) parses the tag string on every call, so for binding-heavy endpoints we
// compute it once per struct type and reuse it across requests (see bindStructMeta). Only type-level data
// is cached here; per-request, per-instance reflect.Value operations still happen in bindData.
type bindFieldMeta struct {
index int // field index within the struct
// fieldKind is the DECLARED field kind (typeField.Type.Kind()), used only for unmarshal dispatch.
// It is intentionally not the post-anonymous-pointer-deref live kind; bindData computes that
// separately as structFieldKind where needed.
fieldKind reflect.Kind
anonymous bool // reflect.StructField.Anonymous
formatTag string // value of the `format` struct tag
// binding-source tag values. bindData is only ever called with one of these four tags (see the
// callers BindPathValues/BindQueryParams/BindBody/BindHeaders). Keep these fields, the four
// f.Tag.Get(...) lines in bindMetaFor, and the tagName switch in sync if a source is ever added.
param, query, form, header string
}

// tagName returns the field's tag value for the given binding source tag.
// Keep in sync with the tag fields above and the f.Tag.Get calls in bindMetaFor.
func (m *bindFieldMeta) tagName(tag string) string {
switch tag {
case "param":
return m.param
case "query":
return m.query
case "form":
return m.form
case "header":
return m.header
default:
return ""
}
}

// bindStructMeta is the cached field metadata for a whole struct type, in declaration order.
type bindStructMeta struct {
fields []bindFieldMeta
}

// bindStructCache memoizes bindStructMeta keyed by struct reflect.Type. Concurrent double-computation is
// harmless because the result is deterministic and idempotent.
var bindStructCache sync.Map // map[reflect.Type]*bindStructMeta

func bindMetaFor(typ reflect.Type) *bindStructMeta {
if cached, ok := bindStructCache.Load(typ); ok {
return cached.(*bindStructMeta)
}
n := typ.NumField()
meta := &bindStructMeta{fields: make([]bindFieldMeta, n)}
for i := 0; i < n; i++ {
f := typ.Field(i)
meta.fields[i] = bindFieldMeta{
index: i,
anonymous: f.Anonymous,
fieldKind: f.Type.Kind(),
formatTag: f.Tag.Get("format"),
param: f.Tag.Get("param"),
query: f.Tag.Get("query"),
form: f.Tag.Get("form"),
header: f.Tag.Get("header"),
}
}
bindStructCache.Store(typ, meta)
return meta
}

// bindData will bind data ONLY fields in destination struct that have EXPLICIT tag
func bindData(destination any, data map[string][]string, tag string, dataFiles map[string][]*multipart.FileHeader) error {
if destination == nil || (len(data) == 0 && len(dataFiles) == 0) {
Expand Down Expand Up @@ -185,10 +253,11 @@ func bindData(destination any, data map[string][]string, tag string, dataFiles m
return errors.New("binding element must be a struct")
}

for i := 0; i < typ.NumField(); i++ { // iterate over all destination fields
typeField := typ.Field(i)
structField := val.Field(i)
if typeField.Anonymous {
meta := bindMetaFor(typ)
for fi := range meta.fields { // iterate over all destination fields
fm := &meta.fields[fi]
structField := val.Field(fm.index)
if fm.anonymous {
if structField.Kind() == reflect.Pointer {
structField = structField.Elem()
}
Expand All @@ -197,8 +266,8 @@ func bindData(destination any, data map[string][]string, tag string, dataFiles m
continue
}
structFieldKind := structField.Kind()
inputFieldName := typeField.Tag.Get(tag)
if typeField.Anonymous && structFieldKind == reflect.Struct && inputFieldName != "" {
inputFieldName := fm.tagName(tag)
if fm.anonymous && structFieldKind == reflect.Struct && inputFieldName != "" {
// if anonymous struct with query/param/form tags, report an error
return errors.New("query/param/form tags are not allowed with anonymous struct field")
}
Expand Down Expand Up @@ -248,15 +317,14 @@ func bindData(destination any, data map[string][]string, tag string, dataFiles m
// but it is smart enough to handle niche cases like `*int`,`*[]string`,`[]*int` .

// try unmarshalling first, in case we're dealing with an alias to an array type
if ok, err := unmarshalInputsToField(typeField.Type.Kind(), inputValue, structField); ok {
if ok, err := unmarshalInputsToField(fm.fieldKind, inputValue, structField); ok {
if err != nil {
return fmt.Errorf("%s: %w", inputFieldName, err)
}
continue
}

formatTag := typeField.Tag.Get("format")
if ok, err := unmarshalInputToField(typeField.Type.Kind(), inputValue[0], structField, formatTag); ok {
if ok, err := unmarshalInputToField(fm.fieldKind, inputValue[0], structField, fm.formatTag); ok {
if err != nil {
return fmt.Errorf("%s: %w", inputFieldName, err)
}
Expand Down
31 changes: 31 additions & 0 deletions bind_cache_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: © 2015 LabStack LLC and Echo contributors

package echo

import (
"net/http"
"net/http/httptest"
"testing"

"github.com/stretchr/testify/assert"
)

// TestBindCachedMetaPreservesFieldNameError ensures the per-type bind metadata cache preserves the
// field-name prefix in conversion errors on BOTH the cold (first) and warm (cached) bind of a type.
// DTO is declared locally so its reflect.Type is independent of suite ordering, making the second
// bind a deterministic cache hit (the bindMetaFor Load branch).
func TestBindCachedMetaPreservesFieldNameError(t *testing.T) {
type DTO struct {
Number int `query:"number"`
}
bind := func() error {
e := New()
req := httptest.NewRequest(http.MethodGet, "/?number=10a", nil)
var dto DTO
return e.NewContext(req, httptest.NewRecorder()).Bind(&dto)
}

assert.ErrorContains(t, bind(), "number", "cold cache: error must carry field name")
assert.ErrorContains(t, bind(), "number", "warm cache: error must still carry field name")
}
114 changes: 98 additions & 16 deletions context.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,30 @@ import (
"path/filepath"
"strings"
"sync"
"unsafe"
)

// stringToBytes returns a []byte view over s without copying, avoiding the allocation+copy of []byte(s)
// on the response write path (the zero-copy technique used by fasthttp/fiber).
//
// Contract — all of the following must hold at every call site:
// - The result is read-only: writing through it is undefined behaviour.
// - The callee must NOT retain the slice beyond the call. It is only passed to the response
// Writer's Write, whose io.Writer contract forbids retaining/mutating the argument. Note the
// concrete writer may be a wrapping ResponseWriter (e.g. gzip); such writers must copy, not alias.
// - s must stay reachable for as long as the slice is used: the slice aliases s's backing array.
func stringToBytes(s string) []byte {
if s == "" {
return nil
}
return unsafe.Slice(unsafe.StringData(s), len(s))
}

// jsonpOpen and jsonpClose are the constant byte wrappers for JSONP payloads, kept as package-level
// slices to avoid allocating them on every JSONP response.
var (
jsonpOpen = []byte("(")
jsonpClose = []byte(");")
)

const (
Expand Down Expand Up @@ -49,6 +73,16 @@ type Context struct {
route *RouteInfo
pathValues *PathValues

// handler is the route handler resolved during routing. It is invoked by the terminal of the global
// middleware chain (see Echo.buildRouterChains) so that the chain can be compiled once and reused.
handler HandlerFunc

// dsw is reused by json() so that each JSON response does not heap-allocate a delayedStatusWriter.
// It lives on the pooled Context; &c.dsw is a stable, allocation-free pointer. Only json() may point
// the response at &c.dsw, and only via the nested-call guard there — aliasing it to itself (wrapping
// &c.dsw around &c.dsw) would make the response writer reference itself.
dsw delayedStatusWriter

store map[string]any
echo *Echo
logger *slog.Logger
Expand All @@ -73,9 +107,9 @@ func NewContext(r *http.Request, w http.ResponseWriter, opts ...any) *Context {
}

func newContext(r *http.Request, w http.ResponseWriter, e *Echo) *Context {
// store is created lazily by Set and cleared (not freed) by Reset, so we deliberately do not allocate a map here.
c := &Context{
pathValues: nil,
store: make(map[string]any),
echo: e,
logger: nil,
}
Expand Down Expand Up @@ -109,10 +143,14 @@ func (c *Context) Reset(r *http.Request, w http.ResponseWriter) {
c.orgResponse.reset(w)
c.response = c.orgResponse
c.query = nil
c.store = nil
// clear (rather than nil) keeps the map allocated on the pooled Context so that requests using Set
// do not allocate a fresh map each time. clear(nil) is a no-op.
clear(c.store)
c.logger = c.echo.Logger

c.route = nil
c.handler = nil
c.dsw = delayedStatusWriter{}
c.path = ""
// NOTE: empty by setting length to 0. PathValues has to have capacity of c.echo.contextPathParamAllocSize at all times
*c.pathValues = (*c.pathValues)[:0]
Expand Down Expand Up @@ -297,10 +335,38 @@ func (c *Context) setPathValues(pv *PathValues) {

// QueryParam returns the query param for the provided name.
func (c *Context) QueryParam(name string) string {
if c.query == nil {
c.query = c.request.URL.Query()
// If the full query map was already built (e.g. by QueryParams), use it. Otherwise look the single
// key up directly from the raw query, avoiding the url.Values map allocation for the common case of
// reading only a few params. The result is identical to url.Values.Get on the parsed query.
if c.query != nil {
return c.query.Get(name)
}
return getRawQueryParam(c.request.URL.RawQuery, name)
}

// getRawQueryParam returns the first value for name parsed directly from a raw URL query string. It
// matches url.Values.Get over url.ParseQuery output: first match wins, '+' decodes to space, percent
// escapes are decoded, segments containing ';' are skipped, and pairs whose key or value fail to
// unescape are skipped. It avoids allocating the full url.Values map for single-key lookups.
func getRawQueryParam(query, name string) string {
for query != "" {
var seg string
seg, query, _ = strings.Cut(query, "&")
if seg == "" || strings.Contains(seg, ";") {
continue
}
key, value, _ := strings.Cut(seg, "=")
k, err := url.QueryUnescape(key)
if err != nil || k != name {
continue
}
v, err := url.QueryUnescape(value)
if err != nil {
continue
}
return v
}
return c.query.Get(name)
return ""
}

// QueryParamOr returns the query param or default value for the provided name.
Expand Down Expand Up @@ -390,20 +456,21 @@ func (c *Context) Cookies() []*http.Cookie {
// Get retrieves data from the context.
// Method returns any(nil) when key does not exist which is different from typed nil (eg. []byte(nil)).
func (c *Context) Get(key string) any {
// Unlock without defer to avoid the deferred-call overhead on this hot path.
c.lock.RLock()
defer c.lock.RUnlock()
return c.store[key]
v := c.store[key]
c.lock.RUnlock()
return v
}

// Set saves data in the context.
func (c *Context) Set(key string, val any) {
c.lock.Lock()
defer c.lock.Unlock()

if c.store == nil {
c.store = make(map[string]any)
}
c.store[key] = val
c.lock.Unlock()
}

// Bind binds path params, query params and the request body into provided type `i`. The default binder
Expand Down Expand Up @@ -445,7 +512,7 @@ func (c *Context) Render(code int, name string, data any) (err error) {

// HTML sends an HTTP response with status code.
func (c *Context) HTML(code int, html string) (err error) {
return c.HTMLBlob(code, []byte(html))
return c.HTMLBlob(code, stringToBytes(html))
}

// HTMLBlob sends an HTTP blob response with status code.
Expand All @@ -455,19 +522,22 @@ func (c *Context) HTMLBlob(code int, b []byte) (err error) {

// String sends a string response with status code.
func (c *Context) String(code int, s string) (err error) {
return c.Blob(code, MIMETextPlainCharsetUTF8, []byte(s))
return c.Blob(code, MIMETextPlainCharsetUTF8, stringToBytes(s))
}

func (c *Context) jsonPBlob(code int, callback string, i any) (err error) {
c.writeContentType(MIMEApplicationJavaScriptCharsetUTF8)
c.response.WriteHeader(code)
if _, err = c.response.Write([]byte(callback + "(")); err != nil {
if _, err = c.response.Write(stringToBytes(callback)); err != nil {
return
}
if _, err = c.response.Write(jsonpOpen); err != nil {
return
}
if err = c.echo.JSONSerializer.Serialize(c, i, ""); err != nil {
return
}
if _, err = c.response.Write([]byte(");")); err != nil {
if _, err = c.response.Write(jsonpClose); err != nil {
return
}
return
Expand All @@ -480,7 +550,16 @@ func (c *Context) json(code int, i any, indent string) error {
// (global) error handler decides correct status code for the error to be sent to the client.
// For that we need to use writer that can store the proposed status code until the first Write is called.
resp := c.Response()
c.SetResponse(&delayedStatusWriter{ResponseWriter: resp, status: code})
// Reuse the Context-owned delayedStatusWriter to avoid heap-allocating one per JSON response.
// If we are already nested inside a delayed write (rare: a serializer or handler calling c.JSON
// re-entrantly), allocate a fresh writer so the outer call's writer (which is &c.dsw) is not
// clobbered — reusing c.dsw here would make it reference itself.
if _, nested := resp.(*delayedStatusWriter); nested {
c.SetResponse(&delayedStatusWriter{ResponseWriter: resp, status: code})
} else {
c.dsw = delayedStatusWriter{ResponseWriter: resp, status: code}
c.SetResponse(&c.dsw)
}
defer c.SetResponse(resp)

return c.echo.JSONSerializer.Serialize(c, i, indent)
Expand Down Expand Up @@ -512,13 +591,16 @@ func (c *Context) JSONP(code int, callback string, i any) (err error) {
func (c *Context) JSONPBlob(code int, callback string, b []byte) (err error) {
c.writeContentType(MIMEApplicationJavaScriptCharsetUTF8)
c.response.WriteHeader(code)
if _, err = c.response.Write([]byte(callback + "(")); err != nil {
if _, err = c.response.Write(stringToBytes(callback)); err != nil {
return
}
if _, err = c.response.Write(jsonpOpen); err != nil {
return
}
if _, err = c.response.Write(b); err != nil {
return
}
_, err = c.response.Write([]byte(");"))
_, err = c.response.Write(jsonpClose)
return
}

Expand Down
Loading
Loading