mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
A composer that mounts Base had one address to give it -- a wildcard -- so
every route Base serves arrived as one opaque claim, and the composed
router could say nothing about what was behind it. BuildApp registers one
zip route per registered route instead, so `app.Use(baseApp)` puts Base's
real addresses in the composed router.
It reads the same configuration BuildMux reads. loadMux became walk, one
visit over the route tree, and the two builders now differ in what they
register and never in what they read.
WHICH route answers is still http.ServeMux's decision, one pattern at a
time: the method, the case folding, the trailing slash, the path values a
handler reads back, and the redirect a path needing cleaning earns. zip's
router matches a superset of any one pattern, so a request it carries to a
route is offered to that route's own pattern first and handed on when the
pattern does not name it -- which is the walk ServeMux performs internally.
This does NOT remove the net/http adapter, and cannot: every handler in
this router is an http.HandlerFunc, so the adapter moves from one wildcard
to one per route rather than disappearing. What it removes is the opacity.
Two patterns have no address and are refused rather than approximated:
{$}, which ends where zip reads "/x" and "/x/" as one address, and a
pattern naming a host.
361 lines
9.1 KiB
Go
361 lines
9.1 KiB
Go
package router
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
|
|
"github.com/hanzoai/base/tools/hook"
|
|
)
|
|
|
|
type EventCleanupFunc func()
|
|
|
|
// EventFactoryFunc defines the function responsible for creating a Route specific event
|
|
// based on the provided request handler ServeHTTP data.
|
|
//
|
|
// Optionally return a clean up function that will be invoked right after the route execution.
|
|
type EventFactoryFunc[T hook.Resolver] func(w http.ResponseWriter, r *http.Request) (T, EventCleanupFunc)
|
|
|
|
// Router defines a thin wrapper around the standard Go [http.ServeMux] by
|
|
// adding support for routing sub-groups, middlewares and other common utils.
|
|
//
|
|
// Example:
|
|
//
|
|
// r := NewRouter[*MyEvent](eventFactory)
|
|
//
|
|
// // middlewares
|
|
// r.BindFunc(m1, m2)
|
|
//
|
|
// // routes
|
|
// r.GET("/test", handler1)
|
|
//
|
|
// // sub-routers/groups
|
|
// api := r.Group("/api")
|
|
// api.GET("/admins", handler2)
|
|
//
|
|
// // generate a http.ServeMux instance based on the router configurations
|
|
// mux, _ := r.BuildMux()
|
|
//
|
|
// http.ListenAndServe("localhost:8090", mux)
|
|
type Router[T hook.Resolver] struct {
|
|
// @todo consider renaming the type to just Group and replace the embed type
|
|
// with an alias after Go 1.24 adds support for generic type aliases
|
|
*RouterGroup[T]
|
|
|
|
eventFactory EventFactoryFunc[T]
|
|
}
|
|
|
|
// NewRouter creates a new Router instance with the provided event factory function.
|
|
func NewRouter[T hook.Resolver](eventFactory EventFactoryFunc[T]) *Router[T] {
|
|
return &Router[T]{
|
|
RouterGroup: &RouterGroup[T]{},
|
|
eventFactory: eventFactory,
|
|
}
|
|
}
|
|
|
|
// BuildMux constructs a new mux [http.Handler] instance from the current router configurations.
|
|
func (r *Router[T]) BuildMux() (http.Handler, error) {
|
|
r.bindCatchAll()
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
err := r.walk(r.RouterGroup, nil, func(method, path string, serve http.HandlerFunc) error {
|
|
pattern := path
|
|
if method != "" {
|
|
pattern = method + " " + pattern
|
|
}
|
|
mux.HandleFunc(pattern, serve)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return mux, nil
|
|
}
|
|
|
|
// bindCatchAll registers the fallback route every build depends on.
|
|
//
|
|
// Note that some of the default std Go handlers like the [http.NotFoundHandler]
|
|
// cannot be currently extended and requires defining a custom "catch-all" route
|
|
// so that the group middlewares could be executed.
|
|
//
|
|
// https://github.com/golang/go/issues/65648
|
|
func (r *Router[T]) bindCatchAll() {
|
|
if r.HasRoute("", "/") {
|
|
return
|
|
}
|
|
|
|
r.Route("", "/", func(e T) error {
|
|
return NewNotFoundError("", nil)
|
|
})
|
|
}
|
|
|
|
// walk visits every route in the tree in registration order, handing the visitor
|
|
// the method and path its pattern is composed from and the handler that answers
|
|
// it — the route's hook chain over the event factory.
|
|
//
|
|
// It is the one reading of the router configuration, so [Router.BuildMux] and
|
|
// [Router.BuildApp] differ in what they register and never in what they read.
|
|
func (r *Router[T]) walk(group *RouterGroup[T], parents []*RouterGroup[T], visit func(method, path string, serve http.HandlerFunc) error) error {
|
|
for _, child := range group.children {
|
|
switch v := child.(type) {
|
|
case *RouterGroup[T]:
|
|
if err := r.walk(v, append(parents, group), visit); err != nil {
|
|
return err
|
|
}
|
|
case *Route[T]:
|
|
routeHook := &hook.Hook[T]{}
|
|
|
|
var path string
|
|
|
|
// add parent groups middlewares
|
|
for _, p := range parents {
|
|
path += p.Prefix
|
|
for _, h := range p.Middlewares {
|
|
if _, ok := p.excludedMiddlewares[h.Id]; !ok {
|
|
if _, ok = group.excludedMiddlewares[h.Id]; !ok {
|
|
if _, ok = v.excludedMiddlewares[h.Id]; !ok {
|
|
routeHook.Bind(h)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// add current groups middlewares
|
|
path += group.Prefix
|
|
for _, h := range group.Middlewares {
|
|
if _, ok := group.excludedMiddlewares[h.Id]; !ok {
|
|
if _, ok = v.excludedMiddlewares[h.Id]; !ok {
|
|
routeHook.Bind(h)
|
|
}
|
|
}
|
|
}
|
|
|
|
// add current route middlewares
|
|
path += v.Path
|
|
// The route records the pattern it is registered under, which is the
|
|
// path with its method in front — the one spelling both builders
|
|
// compose, kept here so the route carries it whichever one ran.
|
|
v.pattern = path
|
|
if v.Method != "" {
|
|
v.pattern = v.Method + " " + path
|
|
}
|
|
for _, h := range v.Middlewares {
|
|
if _, ok := v.excludedMiddlewares[h.Id]; !ok {
|
|
routeHook.Bind(h)
|
|
}
|
|
}
|
|
|
|
err := visit(v.Method, path, func(resp http.ResponseWriter, req *http.Request) {
|
|
// wrap the response to add write and status tracking
|
|
resp = &ResponseWriter{ResponseWriter: resp}
|
|
|
|
// wrap the request body to allow multiple reads
|
|
body := &RereadableReadCloser{ReadCloser: req.Body}
|
|
defer body.Close()
|
|
|
|
req.Body = body
|
|
|
|
event, cleanupFunc := r.eventFactory(resp, req)
|
|
|
|
// trigger the handler hook chain
|
|
err := routeHook.Trigger(event, v.Action)
|
|
if err != nil {
|
|
ErrorHandler(resp, req, err)
|
|
}
|
|
|
|
if cleanupFunc != nil {
|
|
cleanupFunc()
|
|
}
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
default:
|
|
return errors.New("invalid Group item type")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func ErrorHandler(resp http.ResponseWriter, req *http.Request, err error) {
|
|
if err == nil {
|
|
return
|
|
}
|
|
|
|
if ok, _ := getWritten(resp); ok {
|
|
return // a response was already written (aka. already handled)
|
|
}
|
|
|
|
header := resp.Header()
|
|
if header.Get("Content-Type") == "" {
|
|
header.Set("Content-Type", "application/json")
|
|
}
|
|
|
|
apiErr := ToApiError(err)
|
|
|
|
resp.WriteHeader(apiErr.Status)
|
|
|
|
if req.Method != http.MethodHead {
|
|
if jsonErr := json.NewEncoder(resp).Encode(apiErr); jsonErr != nil {
|
|
log.Println(jsonErr) // truly rare case, log to stderr only for dev purposes
|
|
}
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
|
|
type WriteTracker interface {
|
|
// Written reports whether a write operation has occurred.
|
|
Written() bool
|
|
}
|
|
|
|
type StatusTracker interface {
|
|
// Status reports the written response status code.
|
|
Status() int
|
|
}
|
|
|
|
type flushErrorer interface {
|
|
FlushError() error
|
|
}
|
|
|
|
var (
|
|
_ WriteTracker = (*ResponseWriter)(nil)
|
|
_ StatusTracker = (*ResponseWriter)(nil)
|
|
_ http.Flusher = (*ResponseWriter)(nil)
|
|
_ http.Hijacker = (*ResponseWriter)(nil)
|
|
_ http.Pusher = (*ResponseWriter)(nil)
|
|
_ io.ReaderFrom = (*ResponseWriter)(nil)
|
|
_ flushErrorer = (*ResponseWriter)(nil)
|
|
)
|
|
|
|
// ResponseWriter wraps a http.ResponseWriter to track its write state.
|
|
type ResponseWriter struct {
|
|
http.ResponseWriter
|
|
|
|
written bool
|
|
status int
|
|
}
|
|
|
|
func (rw *ResponseWriter) WriteHeader(status int) {
|
|
if rw.written {
|
|
return
|
|
}
|
|
|
|
rw.written = true
|
|
rw.status = status
|
|
rw.ResponseWriter.WriteHeader(status)
|
|
}
|
|
|
|
func (rw *ResponseWriter) Write(b []byte) (int, error) {
|
|
if !rw.written {
|
|
rw.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
return rw.ResponseWriter.Write(b)
|
|
}
|
|
|
|
// Written implements [WriteTracker] and returns whether the current response body has been already written.
|
|
func (rw *ResponseWriter) Written() bool {
|
|
return rw.written
|
|
}
|
|
|
|
// Written implements [StatusTracker] and returns the written status code of the current response.
|
|
func (rw *ResponseWriter) Status() int {
|
|
return rw.status
|
|
}
|
|
|
|
// Flush implements [http.Flusher] and allows an HTTP handler to flush buffered data to the client.
|
|
// This method is no-op if the wrapped writer doesn't support it.
|
|
func (rw *ResponseWriter) Flush() {
|
|
_ = rw.FlushError()
|
|
}
|
|
|
|
// FlushError is similar to [Flush] but returns [http.ErrNotSupported]
|
|
// if the wrapped writer doesn't support it.
|
|
func (rw *ResponseWriter) FlushError() error {
|
|
err := http.NewResponseController(rw.ResponseWriter).Flush()
|
|
if err == nil || !errors.Is(err, http.ErrNotSupported) {
|
|
rw.written = true
|
|
}
|
|
return err
|
|
}
|
|
|
|
// Hijack implements [http.Hijacker] and allows an HTTP handler to take over the current connection.
|
|
func (rw *ResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
|
return http.NewResponseController(rw.ResponseWriter).Hijack()
|
|
}
|
|
|
|
// Pusher implements [http.Pusher] to indicate HTTP/2 server push support.
|
|
func (rw *ResponseWriter) Push(target string, opts *http.PushOptions) error {
|
|
w := rw.ResponseWriter
|
|
for {
|
|
switch p := w.(type) {
|
|
case http.Pusher:
|
|
return p.Push(target, opts)
|
|
case RWUnwrapper:
|
|
w = p.Unwrap()
|
|
default:
|
|
return http.ErrNotSupported
|
|
}
|
|
}
|
|
}
|
|
|
|
// ReaderFrom implements [io.ReaderFrom] by checking if the underlying writer supports it.
|
|
// Otherwise calls [io.Copy].
|
|
func (rw *ResponseWriter) ReadFrom(r io.Reader) (n int64, err error) {
|
|
if !rw.written {
|
|
rw.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
w := rw.ResponseWriter
|
|
for {
|
|
switch rf := w.(type) {
|
|
case io.ReaderFrom:
|
|
return rf.ReadFrom(r)
|
|
case RWUnwrapper:
|
|
w = rf.Unwrap()
|
|
default:
|
|
return io.Copy(rw.ResponseWriter, r)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Unwrap returns the underlying ResponseWritter instance (usually used by [http.ResponseController]).
|
|
func (rw *ResponseWriter) Unwrap() http.ResponseWriter {
|
|
return rw.ResponseWriter
|
|
}
|
|
|
|
func getWritten(rw http.ResponseWriter) (bool, error) {
|
|
for {
|
|
switch w := rw.(type) {
|
|
case WriteTracker:
|
|
return w.Written(), nil
|
|
case RWUnwrapper:
|
|
rw = w.Unwrap()
|
|
default:
|
|
return false, http.ErrNotSupported
|
|
}
|
|
}
|
|
}
|
|
|
|
func getStatus(rw http.ResponseWriter) (int, error) {
|
|
for {
|
|
switch w := rw.(type) {
|
|
case StatusTracker:
|
|
return w.Status(), nil
|
|
case RWUnwrapper:
|
|
rw = w.Unwrap()
|
|
default:
|
|
return 0, http.ErrNotSupported
|
|
}
|
|
}
|
|
}
|