Files
Pat Whittingslow d05cd14018 httphi router refactor (#176)
* httphi: RouterConfig refactor to enable DefaultRouterConfig

* RequestHeader not case sensitive anymore

* httphi: use DefaultRouterConfig in examples

* httphi: remove gated stage complexity

Misusing Stage methods by calling them once header has been written is totally harmless as far as I can tell. We simplify the codebase on this occasion by removing the headerWritten check for all stage methods

* httphi: improve APIs

* httphi: remove status
2026-08-03 15:33:43 -07:00

490 lines
17 KiB
Go

package httphi
import (
"bytes"
"io"
"strings"
"unsafe"
"github.com/soypat/lneto"
"github.com/soypat/lneto/http/httpraw"
"github.com/soypat/lneto/internal"
)
// Handle is a extremely low-level HTTP handling method used internally in [Router].
// Requires exchange to be acquired and configured. Will panic if any argument is nil.
// Handle does not close the connection on any outcome: the caller owns it.
// backoff can be set for dealing with non-blocking connections. If backoff set to nil
// then a zero-length-read will result in Handle returning [io.ErrNoProgress].
func Handle(exch *Exchange, mux Mux, backoff lneto.BackoffStrategy) error {
if !exch.acquired.Load() {
return lneto.ErrBadState
}
reqhdr := &exch.reqHdr
reqhdr.Reset(nil, 0) // Assume exchange has been configured and reuse memory.
var consecutiveBackoffs uint
for {
n, err := reqhdr.ReadFromLimited(exch.rw, reqhdr.BufferFree())
if err != nil {
exch.readErr = err
exch.handleError(err)
return err
} else if n == 0 {
if backoff == nil {
return io.ErrNoProgress
}
backoff.Do(consecutiveBackoffs)
consecutiveBackoffs++
continue
}
consecutiveBackoffs = 0
const asRequest = false
needMore, err := reqhdr.TryParse(asRequest)
if needMore {
continue // Request header split across reads, accumulate the rest.
} else if err != nil {
exch.handleError(err)
return err
}
break // Done!
}
// Setup Exchange fields necessary for correct functioning.
parsed := reqhdr.BufferParsed()
exch.respRemains = reqhdr.BufferReceived() - parsed
exch.respHeaderOff = uint16(parsed)
exch.respHeaderLen = 0
proto := b2s(reqhdr.Protocol())
if len(proto) == 0 {
// HTTP/0.9 not tolerated RFC 9112 3.
exch.WriteHeader(int(StatusBadRequest))
return errNoRequestProto
} else if proto != "HTTP/1.1" && proto != "HTTP/1.0" {
// RFC 9112 2.6.
exch.WriteHeader(int(StatusHTTPVersionNotSupported))
return errBadRequestProto
}
// Mux on the request path: the query string is the handler's business.
path := reqhdr.RequestPath()
meth := reqhdr.Method()
clear(exch.pathValues)
matchedPattern, handler := mux.LookupHandler(MethodFromBytes(meth), path, exch.pathValues)
if handler != nil {
exch.matchedPattern = matchedPattern
handler(exch)
if !exch.hijacked {
exch.FlushHeader()
}
} else {
exch.WriteHeader(404)
}
return nil
}
func (exch *Exchange) handleError(err error) {
if err == lneto.ErrUnsupported {
// httpraw refused a first line naming a version it does not speak, before
// spending the field loop on it. An empty protocol is a HTTP/0.9
// simple-request, RFC 9112 3: a malformed 1.x request-line rather than a
// version there is any point naming back.
if len(exch.reqHdr.Protocol()) == 0 {
exch.WriteHeader(int(StatusBadRequest))
} else {
exch.WriteHeader(int(StatusHTTPVersionNotSupported))
}
return
}
if err == httpraw.ErrHeaderTooMany || err == httpraw.ErrBufferExhausted || exch.reqHdr.BufferFree() == 0 {
// The peer is owed an answer: no larger buffer is coming, so
// say so instead of dropping the connection, RFC 6585 5.
exch.StageHeader("Content-Length", "0")
exch.WriteHeader(int(StatusRequestHeaderFieldsTooLarge))
}
}
// HandlerFunc serves a single request, playing the part of http.Handler.
// The exchange is only valid for the duration of the call: it is released to
// the router's pool on return, so a handler must not retain it nor any slice it
// handed out.
type HandlerFunc func(ex *Exchange)
// Mux resolves a request to the handler that serves it. [Handle] calls
// LookupHandler with the request-target's path, not the whole target, and
// replies 404 when it returns nil.
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)
// 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
}
// 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.
}
// pathSeparator is shared so [SetPathValues] never converts a literal per call.
var pathSeparator = []byte{'/'}
// SetPathValues matches requestPath against pattern and binds its wildcards
// into dstPathVals, read back with [Exchange.PathValue]. Wildcards are whole
// segments as per http.ServeMux: "{name}" takes one non-empty segment,
// "{name...}" the rest including slashes, "{$}" only the path's end, and a
// trailing slash is an anonymous "{...}". i.e: "/b/{bucket}/o/{obj...}".
//
// 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.
//
// Values are bound while walking, before the match is known, so on failure
// SetPathValues clears what it bound. A [Mux] may then try patterns in turn
// without a matching one inheriting values from one that failed.
func SetPathValues(dstPathVals []PathValue, pattern string, requestPath []byte) (matched, pathValSliceTooShort bool) {
n, matched, pathValSliceTooShort := setPathValues(dstPathVals, pattern, requestPath)
if !matched {
clear(dstPathVals[:n])
}
return matched, pathValSliceTooShort
}
// setPathValues is [SetPathValues] reporting how many values it bound, so its
// caller can discard them when the pattern turns out not to match.
func setPathValues(dstPathVals []PathValue, pattern string, requestPath []byte) (n int, matched, pathValSliceTooShort bool) {
if len(pattern) == 0 || pattern[0] != '/' || len(requestPath) == 0 || requestPath[0] != '/' {
return n, false, false
}
pattern, requestPath = pattern[1:], requestPath[1:]
for {
if len(pattern) == 0 {
// Nothing left after a slash: an anonymous "..." taking the rest,
// which is why "/files/" matches "/files/a/b" and "/" matches all.
return n, true, false
}
patSeg, patRest, patMore := strings.Cut(pattern, "/")
reqSeg, reqRest, reqMore := bytes.Cut(requestPath, pathSeparator)
name, isMulti, isWildcard := pathWildcard(patSeg)
switch {
case isWildcard && name == "$":
// Matches the end of the path and nothing else, so it must be the
// last segment of the pattern and leave no path behind.
return n, !patMore && len(requestPath) == 0, false
case isWildcard && isMulti:
// Takes the remainder including slashes, possibly empty.
if name != "" {
if n >= len(dstPathVals) {
return n, false, true
}
dstPathVals[n] = PathValue{Key: name, Value: requestPath}
n++
}
return n, true, false
case isWildcard:
if len(reqSeg) == 0 {
return n, false, false // One segment means a non-empty one.
}
if n >= len(dstPathVals) {
return n, false, true
}
dstPathVals[n] = PathValue{Key: name, Value: reqSeg}
n++
default:
if b2s(reqSeg) != patSeg {
return n, false, false
}
}
if patMore != reqMore {
// One side has a further segment and the other does not, so
// "/health" misses "/health/" and "/files/" misses "/files".
return n, false, false
} else if !patMore {
return n, true, false // Both spent on the same segment.
}
pattern, requestPath = patRest, reqRest
}
}
// pathWildcard picks apart a "{name}" or "{name...}" pattern segment. It
// reports ok false for a literal segment, so "/b_{bucket}" is literal text and
// not a wildcard, matching ServeMux's rule that wildcards be whole segments.
func pathWildcard(segment string) (name string, isMulti, ok bool) {
if len(segment) < 2 || segment[0] != '{' || segment[len(segment)-1] != '}' {
return "", false, false
}
name = segment[1 : len(segment)-1]
if rest, found := strings.CutSuffix(name, "..."); found {
return rest, true, true
}
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
spec int
}
}
// Reset discards all registered handlers, reusing the backing array and growing
// it to fit capacity registrations.
func (sm *MuxSlice) Reset(capacity int) {
internal.SliceReuse(&sm._handlers, capacity)
}
// LookupHandler returns the handler registered for request path, or nil if none
// matches. The most specific matching registration wins, not the first, so the
// catch-all "/" may be registered alongside the endpoints it backs without
// shadowing them, as in http.ServeMux, see [patternSpecificity]. Registrations
// of equal specificity are resolved in registration order.
//
// 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.RequestMethodBytes].
func (sm *MuxSlice) LookupHandler(method Method, path []byte, dstPathVals []PathValue) (matched string, _ HandlerFunc) {
best := -1
bestSpec := 0
for i, endpoint := range sm._handlers {
if endpoint.method != MethUndefined && endpoint.method != method {
continue
} else if best >= 0 && endpoint.spec <= bestSpec {
continue // Cannot beat the incumbent, so do not pay to match it.
}
// 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].
var ok bool
if isWildcardPattern(endpoint.path) {
// dstPathVals is scratch during the scan: a candidate that matches and
// is then beaten, or one that is beaten and clears on failure, would
// leave the winner's values wrong, so the winner is bound below.
ok, _ = SetPathValues(dstPathVals, endpoint.path, path)
} else {
ok = b2s(path) == endpoint.path
}
if ok {
best, bestSpec = i, endpoint.spec
}
}
if best < 0 {
return "", nil
}
winner := sm._handlers[best]
if isWildcardPattern(winner.path) {
clear(dstPathVals) // The scan may have bound more values than the winner does.
SetPathValues(dstPathVals, winner.path, path)
}
return winner.path, winner.handler
}
// 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)
}
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 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) {
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
}
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.spec = patternSpecificity(url)
v.method = method
v.path = url
v.handler = handler
}
// patternSpecificity scores how tightly pattern pins a path, letting
// [MuxSlice.LookupHandler] prefer the most specific match over the first one
// registered. A literal segment pins harder than a wildcard segment, and a
// pattern left open at the end ("/", "/files/", "/{p...}") pins less than one
// spent on the whole path, so "/cnt" outscores "/" and "/users/me" outscores
// "/users/{id}". Scoring at registration keeps lookup to an integer compare.
//
// The score is a total order over patterns, which the subset relation is not:
// neither of "/a/{x}/c" and "/a/b/{y}" is more specific than the other, and they
// tie here where http.ServeMux rejects the pair as conflicting. A tie is settled
// by registration order rather than by a panic.
func patternSpecificity(pattern string) (spec int) {
if len(pattern) == 0 || pattern[0] != '/' {
return 0
}
pattern = pattern[1:]
for {
if len(pattern) == 0 {
return spec // Nothing after a slash: an anonymous "{...}" taking the rest.
}
segment, rest, more := strings.Cut(pattern, "/")
name, isMulti, isWildcard := pathWildcard(segment)
switch {
case isWildcard && name == "$":
return spec + 1 // Ends the path, so nothing is left open.
case isWildcard && isMulti:
return spec // Takes the remainder, pinning nothing more.
case isWildcard:
spec++
default:
spec += 2
}
if !more {
return spec + 1 // Spent on the last segment: the pattern is exact.
}
pattern = rest
}
}
// 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].
// Method can only take standardized values and is set to [MethUnknown] for non-standard methods.
type Method uint8
const (
// MethUndefined returned by [MethodFrom] on an empty/missing method.
// Used by [MuxSlice] to denote an unset method kind for a request pattern.
MethUndefined Method = iota // undefined
MethGet // GET
// lol.
MethHead // HEAD
MethPost // POST
MethPut // PUT
// RFC 5789
MethPatch // PATCH
MethDelete // DELETE
MethConnect // CONNECT
MethOptions // OPTIONS
MethTrace // TRACE
// MethUnknown returned by [MethodFrom] on an non-standard method kind i.e: "get" and "FROBNICATE".
MethUnknown // unknown
)
// MethodFrom returns the [Method] matching meth, [MethUndefined] if meth is
// empty and [MethUnknown] if it names a method this package does not know.
// Comparison is case sensitive: methods are uppercase, RFC 9110 9.1.
func MethodFrom(meth string) (res Method) {
if len(meth) == 0 {
return MethUndefined
}
switch meth {
case "GET":
res = MethGet
case "HEAD":
res = MethHead
case "POST":
res = MethPost
case "PUT":
res = MethPut
case "PATCH":
res = MethPatch
case "DELETE":
res = MethDelete
case "CONNECT":
res = MethConnect
case "OPTIONS":
res = MethOptions
case "TRACE":
res = MethTrace
default:
res = MethUnknown
}
return res
}
// MethodFromBytes is a [MethodFrom] wrapper with bytes argument instead of string.
func MethodFromBytes(meth []byte) (res Method) {
return MethodFrom(b2s(meth))
}
// b2s converts byte slice to a string without memory allocation.
// See https://groups.google.com/forum/#!msg/Golang-Nuts/ENgbUzYvCuU/90yGx7GUAgAJ .
func b2s(b []byte) string {
return unsafe.String(unsafe.SliceData(b), len(b))
}