mirror of
https://github.com/soypat/lneto.git
synced 2026-08-09 09:23:40 +00:00
MuxSlice more method muxing improvements
This commit is contained in:
@@ -100,7 +100,7 @@ func homepage(exch *httphi.Exchange) {
|
||||
n += copy(page[n:], htmlTail)
|
||||
|
||||
exch.StageHeader("Content-Type", "text/html")
|
||||
exch.StageHeaderInt("Content-Length", int64(n), 10)
|
||||
exch.StageHeaderInt("Content-Length", int64(n))
|
||||
exch.WriteHeader(int(httphi.StatusOK))
|
||||
exch.WriteBody(page[:n])
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ func BenchmarkHandle(b *testing.B) {
|
||||
request: "GET /?abc=123 HTTP/1.1\r\nHost: tinygo.org\r\nUser-Agent: bench\r\nAccept: */*\r\nConnection: close\r\n\r\n",
|
||||
handler: func(ex *Exchange) {
|
||||
ex.StageHeader("Content-Type", "text/plain")
|
||||
ex.StageHeaderInt("Content-Length", int64(len(benchBody)), 10)
|
||||
ex.StageHeaderIntBase("Content-Length", int64(len(benchBody)), 10)
|
||||
data, present := ex.RequestQueryAppend(buf[:0], "abc", true)
|
||||
if !present || !internal.BytesEqual(data, expect) {
|
||||
panic("invalid result")
|
||||
|
||||
+76
-17
@@ -53,24 +53,28 @@ type Exchange struct {
|
||||
// ExchangeConfig is the memory an [Exchange] is fixed to for the rest of its
|
||||
// life by [Exchange.Configure]. A [Router] derives one per exchange from its
|
||||
// [RouterConfig], which is what bounds the router's memory.
|
||||
//
|
||||
// Fields open with Required, Conditional or Optional and the constraint in
|
||||
// brackets, as in [RouterConfig].
|
||||
type ExchangeConfig struct {
|
||||
// RawBuf is the single buffer holding the request header, the response
|
||||
// Required [non-empty] single buffer holding the request header, the response
|
||||
// header and any surplus body. See [Exchange.UnsafeRawBuffer].
|
||||
RawBuf []byte
|
||||
// RequestBufferLim reserves the first bytes of RawBuf for the request
|
||||
// header, the rest being the response. Configure panics if it exceeds RawBuf.
|
||||
// Required [<=len(RawBuf)] bytes of RawBuf reserved for the request header,
|
||||
// the rest being the response. Configure panics if it exceeds RawBuf.
|
||||
RequestBufferLim int
|
||||
// NumHeaderKVCap is how many request header fields may be parsed. A request
|
||||
// carrying more is answered 431, see [httpraw.ErrHeaderTooMany].
|
||||
// Required [>0] request header fields that may be parsed. A request carrying
|
||||
// more is answered 431, see [httpraw.ErrHeaderTooMany].
|
||||
NumHeaderKVCap int
|
||||
// NormalizeOutgoingKeys normalizes staged response header keys as they are
|
||||
// Optional [any] normalization of staged response header keys as they are
|
||||
// written, i.e: "content-type" becomes "Content-Type".
|
||||
NormalizeOutgoingKeys bool
|
||||
// NoRequestBufferGrowth holds the request header to RequestBufferLim rather
|
||||
// than growing it. A header outgrowing it is answered 431, see [httpraw.ErrBufferExhausted].
|
||||
// Optional [any] cap holding the request header to RequestBufferLim rather than
|
||||
// growing it. A header outgrowing it is answered 431, see [httpraw.ErrBufferExhausted].
|
||||
NoRequestBufferGrowth bool
|
||||
// MaxPathValues is how many wildcards a single pattern may bind, read back with
|
||||
// [Exchange.PathValue]. A pattern with more never matches, see [SetPathValues].
|
||||
// Conditional [>=the most wildcards any one registered pattern binds] number of
|
||||
// path values bindable, read back with [Exchange.PathValue]. A pattern binding
|
||||
// more never matches, see [SetPathValues]. Zero suits a mux of literal patterns.
|
||||
MaxPathValues int
|
||||
}
|
||||
|
||||
@@ -201,11 +205,23 @@ func (exch *Exchange) StageHeader(key, value string) (enoughMemory bool) {
|
||||
return true
|
||||
}
|
||||
|
||||
// StageHeaderInt is [Exchange.StageHeader] with an integer value, i.e: Content-Length.
|
||||
// StageHeaderBytes is [Exchange.StageHeader] with a byte slice value, i.e: a
|
||||
// field copied out of the request. The value is not retained.
|
||||
func (exch *Exchange) StageHeaderBytes(key string, value []byte) (enoughMemory bool) {
|
||||
return exch.StageHeader(key, b2s(value))
|
||||
}
|
||||
|
||||
// StageHeaderInt is [Exchange.StageHeaderIntBase] in base 10, which is the base
|
||||
// every HTTP field value carrying a number uses, i.e: Content-Length.
|
||||
func (exch *Exchange) StageHeaderInt(key string, value int64) (enoughMemory bool) {
|
||||
return exch.StageHeaderIntBase(key, value, 10)
|
||||
}
|
||||
|
||||
// StageHeaderIntBase is [Exchange.StageHeader] with an integer value, i.e: Content-Length.
|
||||
// It formats the value directly into the response buffer without allocating.
|
||||
// base must be in the range 10..36; lower bases are dropped, no HTTP header
|
||||
// field value is written below base 10.
|
||||
func (exch *Exchange) StageHeaderInt(key string, value int64, base int) (enoughMemory bool) {
|
||||
func (exch *Exchange) StageHeaderIntBase(key string, value int64, base int) (enoughMemory bool) {
|
||||
if exch.headerWritten || base < 10 || base > 36 {
|
||||
return false
|
||||
}
|
||||
@@ -261,6 +277,42 @@ func (exch *Exchange) WriteHeader(code int) (n int, err error) {
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Respond writes a complete response in one call: Content-Type, a Content-Length
|
||||
// taken from len(body), the status line and the body. An empty contentType
|
||||
// stages no Content-Type field, for a code that carries no entity.
|
||||
//
|
||||
// It also stages "Connection: close", the router serving one exchange per
|
||||
// connection, so a peer never waits on a response that is not coming.
|
||||
//
|
||||
// Returns [Exchange.ResponseError]: staged fields that did not fit and failed
|
||||
// writes are both reported there, so a truncated response cannot pass silently.
|
||||
func (exch *Exchange) Respond(code int, contentType string, body []byte) error {
|
||||
exch.stageResponse(code, contentType, len(body))
|
||||
exch.WriteBody(body) // Reports through respErr, checked below.
|
||||
return exch.respErr
|
||||
}
|
||||
|
||||
// RespondString is [Exchange.Respond] with a string body, saving the conversion.
|
||||
func (exch *Exchange) RespondString(code int, contentType, body string) error {
|
||||
exch.stageResponse(code, contentType, len(body))
|
||||
exch.WriteBodyString(body) // Reports through respErr, checked below.
|
||||
return exch.respErr
|
||||
}
|
||||
|
||||
// stageResponse stages the fields and status line a complete response needs.
|
||||
// Drops are recorded on respErr by the Stage* calls, so [Exchange.WriteBody]
|
||||
// declines to write a partial header afterwards.
|
||||
func (exch *Exchange) stageResponse(code int, contentType string, bodyLen int) {
|
||||
if contentType != "" {
|
||||
exch.StageHeader("Content-Type", contentType)
|
||||
}
|
||||
exch.StageHeaderInt("Content-Length", int64(bodyLen))
|
||||
// One exchange per connection today, so the peer is told not to wait for a
|
||||
// second response on it. Revisit once the router loops exchanges.
|
||||
exch.StageHeader("Connection", "close")
|
||||
exch.StageStatus(code)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -368,7 +420,7 @@ func (exch *Exchange) WriteBodyString(buf string) (int, error) {
|
||||
return exch.WriteBody(unsafe.Slice(unsafe.StringData(buf), len(buf)))
|
||||
}
|
||||
|
||||
// Write writes response body bytes, flushing the header first if the handler
|
||||
// WriteBody writes response body bytes, flushing the header first if the handler
|
||||
// has not written it yet. Once a write to the connection fails the response is
|
||||
// unrecoverable and every later write returns that same error, so a body never
|
||||
// reaches the wire without its header.
|
||||
@@ -464,11 +516,18 @@ func (exch *Exchange) RequestContentLength() (_ int64, present bool, _ error) {
|
||||
// [Exchange.RequestQuery] or [Exchange.RequestQueryAppend]. The body is consumed, so
|
||||
// call this before [Exchange.ReadBody].
|
||||
//
|
||||
// A request with no Content-Length has no body, RFC 9112 6.3, and yields an
|
||||
// empty form. Use [Exchange.RequestContentLength] to tell that apart from a body
|
||||
// that arrived empty.
|
||||
// A request with no Content-Length has no body, RFC 9112 6.3, and a request with
|
||||
// no Content-Type declares no encoding to parse, RFC 9110 8.3. Both yield an
|
||||
// empty form and a nil error, a bodiless POST being legal. Use
|
||||
// [Exchange.RequestContentLength] and [Exchange.RequestContentType] to tell
|
||||
// either apart from a body that arrived empty. A Content-Type that is present
|
||||
// and not form encoded is [errNotFormEncoded], an absent one never is.
|
||||
func (exch *Exchange) RequestParseForm(dst *httpraw.Form, buf []byte) error {
|
||||
if !httpraw.MediaTypeIs(exch.RequestContentType(), "application/x-www-form-urlencoded") {
|
||||
contentType := exch.RequestContentType()
|
||||
if contentType == nil {
|
||||
dst.Reset(nil, 0)
|
||||
return nil // No declared encoding is no form, as no length is no body.
|
||||
} else if !httpraw.MediaTypeIs(contentType, "application/x-www-form-urlencoded") {
|
||||
return errNotFormEncoded
|
||||
} else if exch.RequestHeaderRaw().GetFold("Transfer-Encoding") != nil {
|
||||
// Chunked bodies are framed, so reading Content-Length bytes off the
|
||||
|
||||
@@ -582,7 +582,7 @@ func TestExchangeSetHeaderInt(t *testing.T) {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
conn := newConn("")
|
||||
exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 2*256), RequestBufferLim: 256})
|
||||
exch.StageHeaderInt("N", test.value, test.base)
|
||||
exch.StageHeaderIntBase("N", test.value, test.base)
|
||||
exch.WriteHeader(200)
|
||||
got, _ := strings.CutPrefix(conn.ViewWritten(), "HTTP/1.1 200 OK\r\n")
|
||||
if got != test.want {
|
||||
@@ -596,7 +596,7 @@ func TestExchangeSetHeaderInt(t *testing.T) {
|
||||
func TestExchangeSetHeaderIntNoAlloc(t *testing.T) {
|
||||
exch := newExchange(t, newConn(""), ExchangeConfig{RawBuf: make([]byte, 2*256), RequestBufferLim: 256})
|
||||
allocs := testing.AllocsPerRun(100, func() {
|
||||
exch.StageHeaderInt("Content-Length", 1234567890, 10)
|
||||
exch.StageHeaderIntBase("Content-Length", 1234567890, 10)
|
||||
})
|
||||
if allocs != 0 {
|
||||
t.Errorf("SetHeaderInt allocated %v times, want 0", allocs)
|
||||
@@ -810,10 +810,13 @@ func TestExchangeRequestParseForm(t *testing.T) {
|
||||
contentType: "text/plain",
|
||||
wantErr: errNotFormEncoded,
|
||||
}, {
|
||||
// An absent field is not a wrong one: no media type is no body, the
|
||||
// same answer "no content length" gets above. Only a type that is
|
||||
// present and not form encoded is an error.
|
||||
name: "no media type",
|
||||
formVals: []formPair{{key: "a", value: "1"}},
|
||||
noContentType: true,
|
||||
wantErr: errNotFormEncoded,
|
||||
wantVals: []formPair{},
|
||||
}, {
|
||||
// The coding is refused on the field alone, so the body stays off.
|
||||
name: "chunked",
|
||||
@@ -1472,3 +1475,104 @@ func TestExchangeRequestParseFormFoldedTransferEncoding(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// StageHeaderInt defaults to base 10, the only base an HTTP field value uses,
|
||||
// so callers do not repeat it at every site.
|
||||
func TestExchangeStageHeaderIntDefaultsBase10(t *testing.T) {
|
||||
conn := newConn("")
|
||||
exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 256), RequestBufferLim: 128})
|
||||
if !exch.StageHeaderInt("Content-Length", 1234567890) {
|
||||
t.Fatal("want field staged")
|
||||
}
|
||||
exch.WriteHeader(200)
|
||||
const want = "HTTP/1.1 200 OK\r\nContent-Length:1234567890\r\n\r\n"
|
||||
if got := conn.ViewWritten(); got != want {
|
||||
t.Errorf("want %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// StageHeaderBytes stages a value already held as bytes without the caller
|
||||
// converting it to a string first.
|
||||
func TestExchangeStageHeaderBytes(t *testing.T) {
|
||||
conn := newConn("")
|
||||
exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 256), RequestBufferLim: 128})
|
||||
value := []byte("text/html")
|
||||
if !exch.StageHeaderBytes("Content-Type", value) {
|
||||
t.Fatal("want field staged")
|
||||
}
|
||||
// The value must be copied, not aliased: mutating it after staging must not
|
||||
// change what reaches the wire.
|
||||
value[0] = 'X'
|
||||
exch.WriteHeader(200)
|
||||
const want = "HTTP/1.1 200 OK\r\nContent-Type:text/html\r\n\r\n"
|
||||
if got := conn.ViewWritten(); got != want {
|
||||
t.Errorf("want %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Respond replaces the stage/stage/stage/write boilerplate every handler paid,
|
||||
// deriving Content-Length from the body so it cannot disagree with what is sent.
|
||||
func TestExchangeRespond(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
code int
|
||||
contentType string
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "html", code: 200, contentType: "text/html", body: "<h1>hi</h1>",
|
||||
want: "HTTP/1.1 200 OK\r\nContent-Type:text/html\r\nContent-Length:11\r\nConnection:close\r\n\r\n<h1>hi</h1>",
|
||||
},
|
||||
{
|
||||
name: "empty body still declares zero length", code: 200, contentType: "text/plain", body: "",
|
||||
want: "HTTP/1.1 200 OK\r\nContent-Type:text/plain\r\nContent-Length:0\r\nConnection:close\r\n\r\n",
|
||||
},
|
||||
{
|
||||
name: "no content type staged when empty", code: 204, contentType: "", body: "",
|
||||
want: "HTTP/1.1 204 No Content\r\nContent-Length:0\r\nConnection:close\r\n\r\n",
|
||||
},
|
||||
{
|
||||
name: "error code carries a body", code: 500, contentType: "text/plain", body: "boom",
|
||||
want: "HTTP/1.1 500 Internal Server Error\r\nContent-Type:text/plain\r\nContent-Length:4\r\nConnection:close\r\n\r\nboom",
|
||||
},
|
||||
} {
|
||||
t.Run(test.name+"/bytes", func(t *testing.T) {
|
||||
conn := newConn("")
|
||||
exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 512), RequestBufferLim: 256})
|
||||
if err := exch.Respond(test.code, test.contentType, []byte(test.body)); err != nil {
|
||||
t.Fatalf("Respond: %s", err)
|
||||
}
|
||||
if got := conn.ViewWritten(); got != test.want {
|
||||
t.Errorf("want %q, got %q", test.want, got)
|
||||
}
|
||||
})
|
||||
t.Run(test.name+"/string", func(t *testing.T) {
|
||||
conn := newConn("")
|
||||
exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 512), RequestBufferLim: 256})
|
||||
if err := exch.RespondString(test.code, test.contentType, test.body); err != nil {
|
||||
t.Fatalf("RespondString: %s", err)
|
||||
}
|
||||
if got := conn.ViewWritten(); got != test.want {
|
||||
t.Errorf("want %q, got %q", test.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A response that does not fit must be reported, not shipped truncated: the
|
||||
// whole point of folding the boilerplate into one call.
|
||||
func TestExchangeRespondReportsOverflow(t *testing.T) {
|
||||
conn := newConn("")
|
||||
exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 64), RequestBufferLim: 32})
|
||||
err := exch.Respond(200, strings.Repeat("t", 200), []byte("body"))
|
||||
if err == nil {
|
||||
t.Fatal("want an error for a response header that cannot fit")
|
||||
}
|
||||
if got := conn.ViewWritten(); got != "" {
|
||||
t.Errorf("nothing must reach the wire, got %q", got)
|
||||
}
|
||||
if exch.ResponseError() == nil {
|
||||
t.Error("want the failure recorded on the exchange too")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ func FuzzHandleRequest(f *testing.F) {
|
||||
// escaping, not this package's framing.
|
||||
for i := range stage {
|
||||
exch.StageHeader("X-Fuzz", "value")
|
||||
exch.StageHeaderInt("X-Fuzz-Int", int64(i), 10)
|
||||
exch.StageHeaderIntBase("X-Fuzz-Int", int64(i), 10)
|
||||
}
|
||||
}
|
||||
if ops&opReadBody != 0 {
|
||||
|
||||
+78
-4
@@ -104,6 +104,8 @@ type Mux interface {
|
||||
// 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)
|
||||
// MaxPathValues specifies the required size of dstPathVals in a call to [Mux.LookupHandler].
|
||||
// MaxPathValues should return -1 if no paths have been configured to catch situation
|
||||
// where the Mux has been passed to a [Router.Configuration] before registering paths.
|
||||
MaxPathValues() int
|
||||
}
|
||||
|
||||
@@ -230,6 +232,11 @@ 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.
|
||||
//
|
||||
// Every method this package does not name is [MethUnknown], so a request with an
|
||||
// extension method matches a bare-path registration and any registration naming
|
||||
// an extension method, whichever it names. Tell PROPFIND from MKCOL inside the
|
||||
// handler with [Exchange.RequestMethodRaw].
|
||||
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 {
|
||||
@@ -238,7 +245,7 @@ func (sm *MuxSlice) LookupHandler(method Method, path []byte, dstPathVals []Path
|
||||
// Method matches. A pattern ending in '/' is a wildcard despite binding no
|
||||
// values: the trailing slash is an anonymous "{...}", so it must go
|
||||
// through the matcher and not a literal compare, see [SetPathValues].
|
||||
if endpoint.pathVals > 0 || strings.HasSuffix(endpoint.path, "/") {
|
||||
if isWildcardPattern(endpoint.path) {
|
||||
if ok, _ := SetPathValues(dstPathVals, endpoint.path, path); ok {
|
||||
return endpoint.path, endpoint.handler
|
||||
}
|
||||
@@ -251,6 +258,9 @@ func (sm *MuxSlice) LookupHandler(method Method, path []byte, dstPathVals []Path
|
||||
|
||||
// MaxPathValues returns the maximum number of path values any endpoint could have.
|
||||
func (sm *MuxSlice) MaxPathValues() (maxPathValues int) {
|
||||
if len(sm._handlers) == 0 {
|
||||
return -1 // Signal no handlers registered.
|
||||
}
|
||||
for _, endpoint := range sm._handlers {
|
||||
maxPathValues = max(maxPathValues, endpoint.pathVals)
|
||||
}
|
||||
@@ -259,22 +269,86 @@ func (sm *MuxSlice) MaxPathValues() (maxPathValues int) {
|
||||
|
||||
// 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.
|
||||
//
|
||||
// Handle panics on a registration that could never serve a request: a method
|
||||
// token carrying lowercase (methods are case sensitive and uppercase, RFC 9110
|
||||
// 9.1, so "Get" matches no GET request), a path not rooted at '/', or an exact
|
||||
// duplicate of an earlier registration, which the first one always shadows.
|
||||
// Registration is program startup, so a fault belongs there and not in a
|
||||
// permanent silent 404.
|
||||
func (sm *MuxSlice) Handle(optMethodAndPath string, handler HandlerFunc) {
|
||||
v := internal.SliceReclaim(&sm._handlers)
|
||||
method := MethUndefined
|
||||
methodOrURL, url, methodFound := strings.Cut(optMethodAndPath, " ")
|
||||
if methodFound {
|
||||
if hasLowerASCII(methodOrURL) {
|
||||
panic("httphi: method must be uppercase in registration " + optMethodAndPath)
|
||||
}
|
||||
method = MethodFrom(methodOrURL)
|
||||
} else {
|
||||
url = methodOrURL
|
||||
}
|
||||
v.pathVals = strings.Count(optMethodAndPath, "{")
|
||||
if len(url) == 0 || url[0] != '/' {
|
||||
panic("httphi: path must begin with '/' in registration " + optMethodAndPath)
|
||||
}
|
||||
for _, endpoint := range sm._handlers {
|
||||
if endpoint.method == method && endpoint.path == url {
|
||||
if method == MethUnknown {
|
||||
// Two extension methods are both MethUnknown, so the second is
|
||||
// unreachable. Register one and branch in the handler, see
|
||||
// [MuxSlice.LookupHandler].
|
||||
panic("httphi: extension method already registered on path in " + optMethodAndPath)
|
||||
}
|
||||
panic("httphi: duplicate registration " + optMethodAndPath)
|
||||
}
|
||||
}
|
||||
v := internal.SliceReclaim(&sm._handlers)
|
||||
v.pathVals = countPathValues(url)
|
||||
v.method = method
|
||||
v.path = url
|
||||
v.handler = handler
|
||||
}
|
||||
|
||||
// countPathValues is how many values pattern can bind, which is what sizes the
|
||||
// slice [SetPathValues] writes into. Only a named wildcard segment binds: "{$}"
|
||||
// marks the path's end, an anonymous "{...}" has no name to bind under, and a
|
||||
// brace inside a literal segment is not a wildcard at all.
|
||||
func countPathValues(pattern string) (n int) {
|
||||
if len(pattern) == 0 || pattern[0] != '/' {
|
||||
return 0
|
||||
}
|
||||
pattern = pattern[1:]
|
||||
for len(pattern) > 0 {
|
||||
segment, rest, more := strings.Cut(pattern, "/")
|
||||
if name, _, ok := pathWildcard(segment); ok && name != "" && name != "$" {
|
||||
n++
|
||||
}
|
||||
if !more {
|
||||
break
|
||||
}
|
||||
pattern = rest
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// isWildcardPattern reports whether pattern must go through [SetPathValues]
|
||||
// rather than a literal comparison. Distinct from the value count: "{$}" and a
|
||||
// trailing slash match by walking segments while binding nothing.
|
||||
func isWildcardPattern(pattern string) bool {
|
||||
return strings.IndexByte(pattern, '{') >= 0 || strings.HasSuffix(pattern, "/")
|
||||
}
|
||||
|
||||
// hasLowerASCII reports whether s carries an ASCII lowercase letter, which a
|
||||
// method token registered by mistake ("Get") does and a legal extension method
|
||||
// ("PROPFIND") does not.
|
||||
func hasLowerASCII(s string) bool {
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] >= 'a' && s[i] <= 'z' {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Method is a HTTP request method, parsed by [MethodFrom].
|
||||
type Method uint8
|
||||
|
||||
|
||||
@@ -308,3 +308,129 @@ func TestMuxSliceTrailingSlashPattern(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A malformed registration is a programming error, and one that otherwise costs
|
||||
// a permanent silent 404 at runtime: "Get /x" parses to MethUnknown, which no
|
||||
// GET request ever matches but every extension-method request does. Fail at
|
||||
// registration, where the stack points at the offending line.
|
||||
func TestMuxSliceHandlePanicsOnBadRegistration(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
reg string
|
||||
}{
|
||||
{name: "lowercase method", reg: "Get /x"},
|
||||
{name: "all lower method", reg: "get /x"},
|
||||
{name: "mixed case method", reg: "pOsT /x"},
|
||||
{name: "no leading slash", reg: "GET x"},
|
||||
{name: "bare path no slash", reg: "x"},
|
||||
{name: "empty path after method", reg: "GET "},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var sm MuxSlice
|
||||
sm.Reset(1)
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("want panic registering %q", test.reg)
|
||||
}
|
||||
}()
|
||||
sm.Handle(test.reg, func(ex *Exchange) {})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// An exact duplicate is unreachable code: the first registration always wins.
|
||||
func TestMuxSliceHandlePanicsOnDuplicate(t *testing.T) {
|
||||
var sm MuxSlice
|
||||
sm.Reset(2)
|
||||
sm.Handle("GET /x", func(ex *Exchange) {})
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Error("want panic registering the same method and path twice")
|
||||
}
|
||||
}()
|
||||
sm.Handle("GET /x", func(ex *Exchange) {})
|
||||
}
|
||||
|
||||
// Extension methods are legal and uppercase, so they must still register: only
|
||||
// the case-mangled forms are rejected.
|
||||
func TestMuxSliceHandleAllowsExtensionMethod(t *testing.T) {
|
||||
var sm MuxSlice
|
||||
sm.Reset(2)
|
||||
sm.Handle("PROPFIND /dav", func(ex *Exchange) {})
|
||||
sm.Handle("/any-method", func(ex *Exchange) {}) // Bare path matches any method.
|
||||
if sm.MaxPathValues() != 0 {
|
||||
t.Errorf("want 0 path values, got %d", sm.MaxPathValues())
|
||||
}
|
||||
}
|
||||
|
||||
// pathVals sizes the slice SetPathValues writes into, so it must count exactly
|
||||
// the wildcards that bind. Counting braces over-reports: "{$}" marks the path's
|
||||
// end, an anonymous "{...}" has no name, and a brace inside a literal segment is
|
||||
// not a wildcard at all. Each of those binds nothing.
|
||||
func TestMuxSliceMaxPathValuesCountsOnlyBindingWildcards(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
pattern string
|
||||
want int
|
||||
}{
|
||||
{pattern: "/health", want: 0},
|
||||
{pattern: "/", want: 0},
|
||||
{pattern: "/files/", want: 0}, // Anonymous trailing wildcard.
|
||||
{pattern: "/{$}", want: 0}, // End-of-path marker.
|
||||
{pattern: "/a/{$}", want: 0}, //
|
||||
{pattern: "/b_{bucket}", want: 0}, // Literal: brace not a whole segment.
|
||||
{pattern: "/{...}", want: 0}, // Multi wildcard with no name.
|
||||
{pattern: "/users/{id}", want: 1}, //
|
||||
{pattern: "/files/{p...}", want: 1}, //
|
||||
{pattern: "/{a}/{b}", want: 2}, //
|
||||
{pattern: "/b/{bucket}/o/{obj...}", want: 2},
|
||||
} {
|
||||
t.Run(test.pattern, func(t *testing.T) {
|
||||
var sm MuxSlice
|
||||
sm.Reset(1)
|
||||
sm.Handle(test.pattern, func(ex *Exchange) {})
|
||||
if got := sm.MaxPathValues(); got != test.want {
|
||||
t.Errorf("want %d path values, got %d", test.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sizing and routing are different questions: "{$}" binds no values yet still
|
||||
// needs the matcher, so an exact pathVals count must not send it to the literal
|
||||
// comparison instead.
|
||||
func TestMuxSliceZeroValueWildcardStillMatches(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
pattern string
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{pattern: "/{$}", path: "/", want: true},
|
||||
{pattern: "/{$}", path: "/x", want: false},
|
||||
{pattern: "/a/{$}", path: "/a/", want: true},
|
||||
{pattern: "/a/{$}", path: "/a/b", want: false},
|
||||
{pattern: "/{...}", path: "/any/thing", want: true},
|
||||
} {
|
||||
t.Run(test.pattern+"__"+test.path, func(t *testing.T) {
|
||||
var sm MuxSlice
|
||||
sm.Reset(1)
|
||||
var served bool
|
||||
sm.Handle(test.pattern, func(ex *Exchange) { served = true; ex.WriteHeader(200) })
|
||||
exch := new(Exchange)
|
||||
exch.Configure(ExchangeConfig{
|
||||
RawBuf: make([]byte, 2048), RequestBufferLim: 1024,
|
||||
NumHeaderKVCap: defaultNumHeaderKVCap, MaxPathValues: sm.MaxPathValues(),
|
||||
})
|
||||
conn := newConn("GET " + test.path + " HTTP/1.1\r\nHost: h\r\n\r\n")
|
||||
conn.Hangup()
|
||||
if !exch.Acquire(conn) {
|
||||
t.Fatal("acquire")
|
||||
}
|
||||
if err := Handle(exch, &sm, nopBackoff); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if served != test.want {
|
||||
t.Errorf("want served=%v, got %v", test.want, served)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+22
-16
@@ -70,32 +70,35 @@ type job struct {
|
||||
}
|
||||
|
||||
// RouterConfig configures a [Router]. See [Router.Configure].
|
||||
//
|
||||
// Each field below opens with Required, Conditional or Optional followed by the
|
||||
// constraint in brackets, that being what [RouterConfig.Validate] rejects on.
|
||||
type RouterConfig struct {
|
||||
// FixedNumGoroutines must be set to either -1 (freely allocate new goroutines) or to the number of goroutines
|
||||
// to spawn on [Router.Configure] being called.
|
||||
// Required [-1 or >0] number of goroutines to spawn on [Router.Configure],
|
||||
// -1 meaning allocate them freely per connection instead.
|
||||
FixedNumGoroutines int
|
||||
// RequestHeaderBufferSize determines the buffer allocated
|
||||
// for processing request HTTP headers including request-target (URI), protocol and key/value pairs.
|
||||
// Required [>=32, sum with ResponseHeaderMinBufferSize <=65535] buffer for the
|
||||
// request header: request-target (URI), protocol and key/value pairs.
|
||||
RequestHeaderBufferSize int
|
||||
// ResponseHeaderMinBufferSize determines buffer allocated for processing response headers.
|
||||
// Response buffer will reuse unused request memory so this is not a strict limit.
|
||||
// "HTTP/1.1 200 OK\r\n" does not count towards this memory, only actual Headers key/value pairs use this memory.
|
||||
// After memory is fully consumed [Exchange.StageHeader] will not append more headers.
|
||||
// Required [>=2, <=65535] buffer for response headers. Reuses unused request
|
||||
// memory so it is not a strict limit, and the status line does not count
|
||||
// towards it. Once consumed [Exchange.StageHeader] appends no more fields.
|
||||
ResponseHeaderMinBufferSize int
|
||||
// Number of request header key/value pairs to parse before failing and returning [StatusRequestHeaderFieldsTooLarge].
|
||||
// Required [>0] request header key/value pairs to parse before failing with
|
||||
// [StatusRequestHeaderFieldsTooLarge].
|
||||
RequestNumHeaderKVCap int
|
||||
|
||||
// NormalizeOutgoingKeys normalizes response header field keys as they are
|
||||
// Optional [any] normalization of response header field keys as they are
|
||||
// staged, i.e: "content-type" becomes "Content-Type".
|
||||
NormalizeOutgoingKeys bool
|
||||
// MaxAwaitingConns is the depth of the queue connections wait in for a free
|
||||
// goroutine. [Router.Handle] drops connections once it is full. Required and
|
||||
// must be non-zero when running a fixed number of goroutines, unused otherwise.
|
||||
// Conditional [>0 when FixedNumGoroutines>0, unused otherwise] depth of the
|
||||
// queue connections wait in. [Router.Handle] drops connections once it is full.
|
||||
MaxAwaitingConns int
|
||||
|
||||
// Mux resolves each request's method and path to the handler serving it. Required.
|
||||
// Required [non-nil] resolver of each request's method and path to the handler
|
||||
// serving it. Routes must be registered before Configure, see [Mux.MaxPathValues].
|
||||
Mux Mux
|
||||
// Logger receives failed exchanges. Optional, nil disables logging.
|
||||
// Optional [nil disables] sink for failed exchanges.
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
@@ -185,6 +188,9 @@ func (r *Router) Configure(cfg RouterConfig) error {
|
||||
r.mux = cfg.Mux
|
||||
r.log = cfg.Logger
|
||||
maxPathValues := cfg.Mux.MaxPathValues()
|
||||
if maxPathValues < 0 {
|
||||
return errors.New("Mux paths must be registered before configuring Router")
|
||||
}
|
||||
r.maxPathValues = maxPathValues
|
||||
r.normalizeKeys = cfg.NormalizeOutgoingKeys
|
||||
// Freelist entries were sized by the outgoing configuration: recycling one
|
||||
@@ -306,7 +312,7 @@ func (r *Router) goroWorker(gen uint32, queue chan job, mux Mux) {
|
||||
for job := range queue {
|
||||
exch := job.exch
|
||||
if exch == nil {
|
||||
panic("httplo: unreachable nil job")
|
||||
panic("httphi: unreachable nil job")
|
||||
} else if gen != r.gen.Load() {
|
||||
// Not released with freeExch since generation torn down,
|
||||
// new buffer may have been allocated for Exchanges.
|
||||
|
||||
Reference in New Issue
Block a user