runtime/router/router.go (117 lines of code) (raw):
// Copyright (c) 2023 Uber Technologies, Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
package router
import (
"context"
"fmt"
"net/http"
"sort"
"strings"
)
// Router dispatches http requests to a registered http.Handler.
// It implements a similar interface to the one in github.com/julienschmidt/httprouter,
// the main differences are:
// 1. this router does not treat "/a/:b" and "/a/b/c" as conflicts (https://github.com/julienschmidt/httprouter/issues/175)
// 2. this router does not treat "/a/:b" and "/a/:c" as different routes and therefore does not allow them to be registered at the same time (https://github.com/julienschmidt/httprouter/issues/6)
// 3. this router does not treat "/a" and "/a/" as different routes
// 4. this router treats "/a" and "/:b" as different paths for whitelisted paths
// Also the `*` pattern is greedy, if a handler is register for `/a/*`, then no handler
// can be further registered for any path that starts with `/a/`
type Router struct {
tries map[string]*Trie
// If enabled, the router checks if another method is allowed for the
// current route, if the current request can not be routed.
// If this is the case, the request is answered with 'Method Not Allowed'
// and HTTP status code 405.
// If no other Method is allowed, the request is delegated to the NotFound
// handler.
HandleMethodNotAllowed bool
// Configurable http.Handler which is called when a request
// cannot be routed and HandleMethodNotAllowed is true.
// If it is not set, http.Error with http.StatusMethodNotAllowed is used.
// The "Allow" header with allowed request methods is set before the handler
// is called.
MethodNotAllowed http.Handler
// Configurable http.Handler which is called when no matching route is
// found. If it is not set, http.NotFound is used.
NotFound http.Handler
// Function to handle panics recovered from http handlers.
// It should be used to generate a error page and return the http error code
// 500 (Internal Server Error).
// The handler can be used to keep your server from crashing because of
// unrecovered panics.
PanicHandler func(http.ResponseWriter, *http.Request, interface{})
// Used for special behavior using which different handlers can configured
// for paths such as /a and /:b in router.
WhitelistedPaths []string
// TODO: (clu) maybe support OPTIONS
}
type paramsKey string
// urlParamsKey is the request context key under which URL params are stored.
const urlParamsKey = paramsKey("urlParamsKey")
// ParamsFromContext pulls the URL parameters from a request context,
// or returns nil if none are present.
func ParamsFromContext(ctx context.Context) []Param {
p, _ := ctx.Value(urlParamsKey).([]Param)
return p
}
// Handle registers a http.Handler for given method and path.
func (r *Router) Handle(method, path string, handler http.Handler) error {
if r.tries == nil {
r.tries = make(map[string]*Trie)
}
trie, ok := r.tries[method]
if !ok {
trie = NewTrie()
r.tries[method] = trie
}
err := trie.Set(path, handler, r.isWhitelistedPath(path))
if err == errExist {
return &urlFailure{url: path, method: method}
}
return err
}
// urlFailure captures errors for conflicting paths
type urlFailure struct {
method string
url string
}
// Error returns the error string
func (e *urlFailure) Error() string {
return fmt.Sprintf("panic: path: %q method: %q conflicts with an existing path", e.url, e.method)
}
// ServeHTTP dispatches the request to a register handler to handle.
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
if r.PanicHandler != nil {
defer func(w http.ResponseWriter, req *http.Request) {
if recovered := recover(); recovered != nil {
r.PanicHandler(w, req, recovered)
}
}(w, req)
}
reqPath := req.URL.Path
isWhitelisted := r.isWhitelistedPath(reqPath)
if trie, ok := r.tries[req.Method]; ok {
if handler, params, err := trie.Get(reqPath, isWhitelisted); err == nil {
ctx := context.WithValue(req.Context(), urlParamsKey, params)
req = req.WithContext(ctx)
handler.ServeHTTP(w, req)
return
}
}
if r.HandleMethodNotAllowed {
if allowed := r.allowed(reqPath, req.Method, isWhitelisted); allowed != "" {
w.Header().Set("Allow", allowed)
if r.MethodNotAllowed != nil {
r.MethodNotAllowed.ServeHTTP(w, req)
} else {
http.Error(w,
http.StatusText(http.StatusMethodNotAllowed),
http.StatusMethodNotAllowed,
)
}
return
}
}
if r.NotFound != nil {
r.NotFound.ServeHTTP(w, req)
} else {
http.NotFound(w, req)
}
}
func (r *Router) allowed(path, reqMethod string, isWhitelisted bool) string {
var allow []string
for method, trie := range r.tries {
if method == reqMethod || method == http.MethodOptions {
continue
}
if _, _, err := trie.Get(path, isWhitelisted); err == nil {
allow = append(allow, method)
}
}
sort.Slice(allow, func(i, j int) bool {
return allow[i] < allow[j]
})
return strings.Join(allow, ", ")
}
func (r *Router) isWhitelistedPath(path string) bool {
for _, whitelistedPath := range r.WhitelistedPaths {
whitelistedPathTokens := strings.Split(whitelistedPath, "/")
pathTokens := strings.Split(path, "/")
if len(whitelistedPathTokens) != len(pathTokens) {
continue
}
isMatched := true
for i, token := range whitelistedPathTokens {
if pathTokens[i] != token && token[0] != ':' {
isMatched = false
break
}
}
if isMatched {
return true
}
}
return false
}