diff --git a/http/httphi/README.md b/http/httphi/README.md index 1b6ac35..d91b6c7 100644 --- a/http/httphi/README.md +++ b/http/httphi/README.md @@ -28,7 +28,6 @@ err := router.Configure(httphi.RouterConfig{ RequestHeaderBufferSize: 1024, ResponseHeaderMinBufferSize: 32, // Shares the request buffer. RequestNumHeaderKVCap: 32, - Backoff: func(uint) time.Duration { return time.Millisecond }, Mux: &mux, }) if err != nil { diff --git a/http/httphi/exchange.go b/http/httphi/exchange.go index 124cf77..9814cd0 100644 --- a/http/httphi/exchange.go +++ b/http/httphi/exchange.go @@ -35,7 +35,7 @@ type Exchange struct { respHeaderOff uint16 respHeaderLen uint16 reqHdr httpraw.Header - pathValues []pathValue + pathValues []PathValue hijacked bool rw conn @@ -253,11 +253,18 @@ func (exch *Exchange) StageStatus(code int) { // WriteHeader sends the status line for code along with the staged header // fields. Only the first call reaches the wire, as in http.ResponseWriter. -func (exch *Exchange) WriteHeader(code int) { +func (exch *Exchange) WriteHeader(code int) (n int, err error) { if !exch.headerWritten { exch.StageStatus(code) - exch.FlushHeader() + n, err = exch.FlushHeader() } + return n, err +} + +// ResponseError returns any error encountered during staging of headers or during writing of response. +// Provides an ergonomic way of checking if one ran out of buffer space after staging all headers with [Exchange.StageHeader]. +func (exch *Exchange) ResponseError() error { + return exch.respErr } // FlushHeader writes the status line and staged header fields to the connection @@ -709,9 +716,13 @@ func (exch *Exchange) PathValueAppend(dst []byte, key string, decoded bool) ([]b return dst[:base+n], nil } -// RequestMethod returns the request line's method, i.e: "GET". See -// [MethodFromBytes] to compare it against a [Method]. -func (exch *Exchange) RequestMethod() []byte { +// RequestMethod returns the request's [Method] enum. +func (exch *Exchange) RequestMethod() Method { + return MethodFromBytes(exch.RequestMethodRaw()) +} + +// RequestMethod returns the request line's method as a []byte view, i.e: "GET". +func (exch *Exchange) RequestMethodRaw() []byte { return exch.RequestHeaderRaw().Method() } diff --git a/http/httphi/exchange_test.go b/http/httphi/exchange_test.go index cf908f2..26824ea 100644 --- a/http/httphi/exchange_test.go +++ b/http/httphi/exchange_test.go @@ -214,7 +214,7 @@ func TestHandleRequestFields(t *testing.T) { var sm MuxSlice route, _, _ := strings.Cut(test.wantURI, "?") // Mux matches on path. sm.Handle(route, func(ex *Exchange) { - gotMethod = string(ex.RequestMethod()) + gotMethod = string(ex.RequestMethodRaw()) gotURI = string(ex.RequestTarget()) gotHost = string(ex.RequestHeader("Host")) ex.WriteHeader(200) diff --git a/http/httphi/mux.go b/http/httphi/mux.go index 7ca7e6f..30de2e9 100644 --- a/http/httphi/mux.go +++ b/http/httphi/mux.go @@ -101,22 +101,13 @@ type Mux interface { // LookupHandler matches the requestPath and method to a handler and returns it and the // pattern it matched. dstPathVals are set to non-zero values by Mux and can later be accessed by [Exchange.PathValue] // requestPath is a buffer owned by the [Exchange] usually and should not be held after LookupHandler returns. - LookupHandler(get Method, requestPath []byte, dstPathVals []pathValue) (matchedPattern string, handler HandlerFunc) + LookupHandler(get Method, requestPath []byte, dstPathVals []PathValue) (matchedPattern string, handler HandlerFunc) + // MaxPathValues specifies the required size of dstPathVals in a call to [Mux.LookupHandler]. + MaxPathValues() int } -// MuxSlice is a [Mux] backed by a slice of registered endpoints, matched by -// exact path. Lookup is linear in the number of registrations. -type MuxSlice struct { - // TODO: binary search worth it? - _handlers []struct { - method Method - path string - handler HandlerFunc - setPathVal bool - } -} - -type pathValue struct { +// PathValue used to implement [Mux] interface. Stores http.Request.PathValue-like values. +type PathValue struct { Key string // owned by mux. Value []byte // points to raw exchange buffer. } @@ -133,7 +124,7 @@ var pathSeparator = []byte{'/'} // Unlike ServeMux, segments are compared and bound raw, so "/users/{id}" binds // "x%2Fy" and not "x/y". Which paths match is unaffected. Bound values alias // requestPath rather than copy it. -func SetPathValues(dstPathVals []pathValue, pattern string, requestPath []byte) (matched, pathValSliceTooShort bool) { +func SetPathValues(dstPathVals []PathValue, pattern string, requestPath []byte) (matched, pathValSliceTooShort bool) { if len(pattern) == 0 || pattern[0] != '/' || len(requestPath) == 0 || requestPath[0] != '/' { return false, false } @@ -160,7 +151,7 @@ func SetPathValues(dstPathVals []pathValue, pattern string, requestPath []byte) if n >= len(dstPathVals) { return false, true } - dstPathVals[n] = pathValue{Key: name, Value: requestPath} + dstPathVals[n] = PathValue{Key: name, Value: requestPath} n++ } return true, false @@ -172,7 +163,7 @@ func SetPathValues(dstPathVals []pathValue, pattern string, requestPath []byte) if n >= len(dstPathVals) { return false, true } - dstPathVals[n] = pathValue{Key: name, Value: reqSeg} + dstPathVals[n] = PathValue{Key: name, Value: reqSeg} n++ default: @@ -205,6 +196,18 @@ func pathWildcard(segment string) (name string, isMulti, ok bool) { return name, false, true } +// MuxSlice is a [Mux] implementation backed by a slice of registered endpoints, matched by +// exact path. Lookup is linear in the number of registrations. +type MuxSlice struct { + // TODO: binary search worth it? + _handlers []struct { + method Method + path string + handler HandlerFunc + pathVals int + } +} + // Reset discards all registered handlers, reusing the backing array and growing // it to fit capacity registrations. func (sm *MuxSlice) Reset(capacity int) { @@ -213,13 +216,13 @@ func (sm *MuxSlice) Reset(capacity int) { // LookupHandler returns the handler registered for request path, or nil if none matches. // The first registration matching both method and uri wins. -func (sm *MuxSlice) LookupHandler(method Method, path []byte, dstPathVals []pathValue) (matched string, _ HandlerFunc) { +func (sm *MuxSlice) LookupHandler(method Method, path []byte, dstPathVals []PathValue) (matched string, _ HandlerFunc) { for _, endpoint := range sm._handlers { if endpoint.method != MethUndefined && endpoint.method != method { continue } // Method matches. - if endpoint.setPathVal { + if endpoint.pathVals > 0 { if ok, _ := SetPathValues(dstPathVals, endpoint.path, path); ok { return endpoint.path, endpoint.handler } @@ -230,6 +233,14 @@ func (sm *MuxSlice) LookupHandler(method Method, path []byte, dstPathVals []path return "", nil } +// MaxPathValues returns the maximum number of path values any endpoint could have. +func (sm *MuxSlice) MaxPathValues() (maxPathValues int) { + for _, endpoint := range sm._handlers { + maxPathValues = max(maxPathValues, endpoint.pathVals) + } + return maxPathValues +} + // Handle registers handler for reg, either a bare path matching any method or a // method and path separated by a space, i.e: "/health" or "GET /health". // Handle does not check for duplicate registrations: the first one added wins. @@ -242,6 +253,7 @@ func (sm *MuxSlice) Handle(optMethodAndPath string, handler HandlerFunc) { } else { url = methodOrURL } + v.pathVals = strings.Count(optMethodAndPath, "{") v.method = method v.path = url v.handler = handler diff --git a/http/httphi/mux_test.go b/http/httphi/mux_test.go index 23d5135..e2b61f9 100644 --- a/http/httphi/mux_test.go +++ b/http/httphi/mux_test.go @@ -54,7 +54,7 @@ func TestSetPathValues(t *testing.T) { {pattern: "/a/{x}/b", path: "/a//b", match: false}, } { t.Run(test.pattern+"__"+test.path, func(t *testing.T) { - vals := make([]pathValue, 8) + vals := make([]PathValue, 8) match, tooShort := SetPathValues(vals, test.pattern, []byte(test.path)) if tooShort { t.Fatal("8 slots must be enough for these patterns") @@ -76,7 +76,7 @@ func TestSetPathValues(t *testing.T) { // exchange owns that memory and a copy would allocate per request. func TestSetPathValuesAliasesRequestBuffer(t *testing.T) { path := []byte("/users/42/edit") - vals := make([]pathValue, 4) + vals := make([]PathValue, 4) match, _ := SetPathValues(vals, "/users/{id}/edit", path) if !match { t.Fatal("want match") @@ -94,7 +94,7 @@ func TestSetPathValuesAliasesRequestBuffer(t *testing.T) { // A destination too small to hold every wildcard must say so rather than bind a // partial set or write out of range. func TestSetPathValuesSliceTooShort(t *testing.T) { - match, tooShort := SetPathValues(make([]pathValue, 1), "/{a}/{b}", []byte("/x/y")) + match, tooShort := SetPathValues(make([]PathValue, 1), "/{a}/{b}", []byte("/x/y")) if !tooShort { t.Error("want pathValSliceTooShort for 2 wildcards in 1 slot") } @@ -112,11 +112,11 @@ func TestSetPathValuesSliceTooShort(t *testing.T) { // segment, so "/users/x%2Fy" binds id="x/y" there and id="x%2Fy" here. Matching // agrees either way; only the bound bytes differ. func TestSetPathValuesEscaping(t *testing.T) { - vals := make([]pathValue, 4) + vals := make([]PathValue, 4) if match, _ := SetPathValues(vals, "/a%2Fb/{x}", []byte("/a%2Fb/v")); !match { t.Error("want literal escape in pattern to match the same bytes in path") } - vals = make([]pathValue, 4) + vals = make([]PathValue, 4) match, _ := SetPathValues(vals, "/users/{id}", []byte("/users/x%2Fy")) if !match { t.Fatal("want match") @@ -128,7 +128,7 @@ func TestSetPathValuesEscaping(t *testing.T) { // renderPathValues joins the bound pairs for comparison, stopping at the first // unused slot. -func renderPathValues(vals []pathValue) string { +func renderPathValues(vals []PathValue) string { var sb strings.Builder for _, v := range vals { if v.Key == "" { @@ -147,7 +147,7 @@ func renderPathValues(vals []pathValue) string { // Matching a request must not allocate: keys alias the mux's pattern and values // alias the request buffer, so nothing is copied per request. func TestSetPathValuesNoAlloc(t *testing.T) { - vals := make([]pathValue, 8) + vals := make([]PathValue, 8) path := []byte("/b/bk/o/a/b/c") allocs := testing.AllocsPerRun(100, func() { SetPathValues(vals, "/b/{bucket}/o/{obj...}", path) @@ -164,7 +164,11 @@ type pathValueMux struct { handler HandlerFunc } -func (m *pathValueMux) LookupHandler(method Method, path []byte, dst []pathValue) (string, HandlerFunc) { +func (m *pathValueMux) MaxPathValues() int { + return -1 +} + +func (m *pathValueMux) LookupHandler(method Method, path []byte, dst []PathValue) (string, HandlerFunc) { if ok, _ := SetPathValues(dst, m.pattern, path); ok { return m.pattern, m.handler } diff --git a/http/httphi/router.go b/http/httphi/router.go index bed1f06..d72b361 100644 --- a/http/httphi/router.go +++ b/http/httphi/router.go @@ -84,8 +84,6 @@ type RouterConfig struct { ResponseHeaderMinBufferSize int // Number of request header key/value pairs to parse before failing and returning [StatusRequestHeaderFieldsTooLarge]. RequestNumHeaderKVCap int - // Sets maximum number of PathValue pairs that can be set on an exchange. Accessed via [Exchange.PathValue]. - MaxPathValues int // NormalizeOutgoingKeys normalizes response header field keys as they are // staged, i.e: "content-type" becomes "Content-Type". @@ -186,7 +184,7 @@ func (r *Router) Configure(cfg RouterConfig) error { r.respBuf = cfg.ResponseHeaderMinBufferSize r.mux = cfg.Mux r.log = cfg.Logger - r.maxPathValues = cfg.MaxPathValues + r.maxPathValues = cfg.Mux.MaxPathValues() r.normalizeKeys = cfg.NormalizeOutgoingKeys // Freelist entries were sized by the outgoing configuration: recycling one // would serve a request with buffer limits cfg never asked for. @@ -210,6 +208,7 @@ func (r *Router) Configure(cfg RouterConfig) error { r.exchs = r.exchs[:numgoro] rawBuflen := cfg.RequestHeaderBufferSize + cfg.ResponseHeaderMinBufferSize internal.SliceReuse(&r.globbuf, numgoro*rawBuflen) + maxPathValues := cfg.Mux.MaxPathValues() for i := range numgoro { // TODO exchange buffer alloc goff := i * rawBuflen @@ -220,7 +219,7 @@ func (r *Router) Configure(cfg RouterConfig) error { NumHeaderKVCap: cfg.RequestNumHeaderKVCap, NormalizeOutgoingKeys: cfg.NormalizeOutgoingKeys, NoRequestBufferGrowth: true, // Hard memory limit. - MaxPathValues: cfg.MaxPathValues, + MaxPathValues: maxPathValues, }) go r.goroWorker(gen, jobqueue, cfg.Mux) } diff --git a/http/httphi/router_test.go b/http/httphi/router_test.go index db78e2e..b797e63 100644 --- a/http/httphi/router_test.go +++ b/http/httphi/router_test.go @@ -196,8 +196,10 @@ func TestRouterRequestVisibleToHandler(t *testing.T) { router Router ) var gotMethod, gotURI, gotHost string + var gotMethodEnum Method sm.Handle("GET /index.html", func(ex *Exchange) { - gotMethod = string(ex.RequestMethod()) + gotMethod = string(ex.RequestMethodRaw()) + gotMethodEnum = ex.RequestMethod() gotURI = string(ex.RequestTarget()) gotHost = string(ex.RequestHeader("Host")) ex.WriteHeader(200) @@ -213,6 +215,9 @@ func TestRouterRequestVisibleToHandler(t *testing.T) { if gotMethod != "GET" { t.Errorf("want method %q, got %q", "GET", gotMethod) } + if gotMethodEnum != MethGet { + t.Errorf("want method enum %q, got %q", MethGet, gotMethodEnum) + } if gotURI != "/index.html" { t.Errorf("want URI %q, got %q", "/index.html", gotURI) } diff --git a/http/httpraw/kvbuffer.go b/http/httpraw/kvbuffer.go index b709c77..260929c 100644 --- a/http/httpraw/kvbuffer.go +++ b/http/httpraw/kvbuffer.go @@ -285,17 +285,17 @@ func (kvb *kvBuffer) getIdx(key string) int { func (kvb *kvBuffer) getFoldIdx(key string) int { for i, pair := range kvb.kvs { - if pair.isValid() && asciiEqualFold(key, b2s(kvb.AtKey(i))) { + if pair.isValid() && EqualFoldASCII(key, b2s(kvb.AtKey(i))) { return i } } return -1 } -// asciiEqualFold reports whether a and b are equal under ASCII case folding. +// EqualFoldASCII reports whether a and b are equal under ASCII case folding. // Unlike strings.EqualFold it does not fold non-ASCII runes, so no multi-byte // rune such as U+212A KELVIN SIGN can alias a header key. -func asciiEqualFold(a, b string) bool { +func EqualFoldASCII(a, b string) bool { if len(a) != len(b) { return false }