From 5d7d595df3300385ada26db0679f689e29fc621a Mon Sep 17 00:00:00 2001 From: "Alexander \"Arav\" Andreev" Date: Fri, 26 May 2023 04:06:35 +0400 Subject: [PATCH] Init repo! --- .gitignore | 23 ------- README.md | 20 +++++- go.mod | 3 + httpr.go | 190 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 212 insertions(+), 24 deletions(-) create mode 100644 go.mod create mode 100644 httpr.go diff --git a/.gitignore b/.gitignore index adf8f72..e69de29 100644 --- a/.gitignore +++ b/.gitignore @@ -1,23 +0,0 @@ -# ---> Go -# If you prefer the allow list template instead of the deny list, see community template: -# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore -# -# Binaries for programs and plugins -*.exe -*.exe~ -*.dll -*.so -*.dylib - -# Test binary, built with `go test -c` -*.test - -# Output of the go coverage tool, specifically when used with LiteIDE -*.out - -# Dependency directories (remove the comment below to include it) -# vendor/ - -# Go workspace file -go.work - diff --git a/README.md b/README.md index accf917..86150cf 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,21 @@ # httpr -Yet another HTTP router. But this one supports paths like /:a and /:a/:b being under the same method, unlike julienschmidt's httprouter. :) But I need to admit that it is written in an inefficient way. \ No newline at end of file +It is an implementation of yet another HTTP router. + +The reason why this router was made is to be able to have pretty paths with +parameters and standard endpoints at the same level. + +As an example here is a structure used in my another project +(dwelling-upload): + + GET / + POST / + GET /:hash/:name + POST /delete + DELETE /:hash + GET /assets/*filepath + GET /robots.txt + GET /favicon.svg + +Previously I used httprouter and I had to have `/f/:hash/:name` route +instead of just `/:hash/:name` because of collisions. \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1d53afa --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.arav.su/Arav/httpr + +go 1.17 diff --git a/httpr.go b/httpr.go new file mode 100644 index 0000000..fc8f54b --- /dev/null +++ b/httpr.go @@ -0,0 +1,190 @@ +package httpr + +import ( + "context" + "errors" + "net/http" + "strings" +) + +type path []string + +// newPath splits a path and ensures that it starts with a slash (/). +func newPath(path string) (path, error) { + parts := strings.Split(strings.TrimSuffix(path, "/"), "/") + if parts[0] != "" { + return nil, errors.New("path should start with a slash (/) symbol") + } + parts[0] = "/" + return parts, nil +} + +// Params holds path parameters that are set as :key. +type Params map[string]string + +type paramsKey struct{} + +// ParamsKey is used as a key for Params in a request's Context. +var ParamsKey paramsKey = paramsKey{} + +type node struct { + endpoint string + children []*node + handler http.HandlerFunc +} + +func (n *node) get(path path, idx int) (http.HandlerFunc, Params) { + // Check if this node is a catch-all endpoint. + if n.endpoint[0] == '*' { + var p Params = Params{} + p[n.endpoint[1:]] = strings.Join(path[idx:], "/") + return n.handler, p + } + + // If this endpoint is a parameter, then add its name to a path's part. + // This will be used further to fill Params. + if n.endpoint[0] == ':' { + path[idx] = n.endpoint + ":" + path[idx] + } + + if len(path) == idx+1 { + var params Params = make(Params) + + for _, part := range path { + if part[0] == ':' { + param := strings.Split(part[1:], ":") + params[param[0]] = param[1] + } + } + + return n.handler, params + } + + if len(path) > idx+1 { + var wildcardOrParam *node + for _, next := range n.children { + if next.endpoint == path[idx+1] { + return next.get(path, idx+1) + } + + if next.endpoint[0] == ':' || next.endpoint[0] == '*' { + wildcardOrParam = next + } + } + + if wildcardOrParam != nil { + return wildcardOrParam.get(path, idx+1) + } + } + + return nil, nil +} + +func (n *node) add(path path, idx int, handler http.HandlerFunc) error { + // If it is a last part of path, then set a handler to this node. + if len(path) == idx+1 { + n.endpoint = path[idx] + n.handler = handler + return nil + } + + // Check if next part is a parameter and if it is, then look for + // an already existing endpoint with a different key. + if path[idx+1][0] == '*' || path[idx+1][0] == ':' { + for _, child := range n.children { + if (child.endpoint[0] == '*' || child.endpoint[0] == ':') && path[idx+1] != child.endpoint { + return errors.New("there is already a catch-all or regular param in there! You cannot add a second one") + } + } + } + + // Check for an already existing endpoint. + for _, child := range n.children { + if child.endpoint == path[idx+1] { + child.add(path, idx+1, handler) + return nil + } + } + + // No endpoint was found. + new_child := &node{endpoint: path[idx+1]} + new_child.add(path, idx+1, handler) + n.children = append(n.children, new_child) + + return nil +} + +type Router struct { + tree map[string]*node + NotFoundHandler http.HandlerFunc +} + +func New() *Router { + return &Router{tree: make(map[string]*node)} +} + +func (rr *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if tree, ok := rr.tree[r.Method]; ok { + if r.URL.Path[0] != '/' { + panic("first element of path should be a slash (/) symbol") + } + + path, _ := newPath(r.URL.Path) + + if handler, params := tree.get(path, 0); handler != nil { + if params != nil { + r = r.WithContext(context.WithValue(r.Context(), ParamsKey, params)) + } + handler(w, r) + } else { + if rr.NotFoundHandler != nil { + rr.NotFoundHandler(w, r) + } else { + http.Error(w, "Not Found", http.StatusNotFound) + } + } + } else { + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + } +} + +// Handler registers a handler for provided pattern for a given HTTP method. +func (rr *Router) Handler(method, pattern string, handler http.HandlerFunc) { + if pattern[0] != '/' { + panic("first element of path should be a slash (/) symbol") + } + + if strings.Count(pattern, "*") > 1 { + panic("there can be only one wildcard (*) symbol in path") + } + + if rr.tree[method] == nil { + rr.tree[method] = &node{endpoint: "/"} + } + + path, _ := newPath(pattern) + + rr.tree[method].add(path, 0, handler) +} + +// ServeStatic serves a given file system. +// +// Path should end with /*filepath to work. +func (rr *Router) ServeStatic(path string, root http.FileSystem) { + fileServer := http.FileServer(root) + + rr.Handler(http.MethodGet, path, func(w http.ResponseWriter, r *http.Request) { + r.URL.Path = Param(r, "filepath") + fileServer.ServeHTTP(w, r) + }) +} + +// Param returns a parameter (that is set like `/a/b/:key/d`) inside a path +// with a key or empty string if no such parameter found. +func Param(r *http.Request, key string) string { + if params := r.Context().Value(ParamsKey).(Params); params != nil { + return params[key] + } + + return "" +}