diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..51cb461 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,11 @@ +name: CI +on: [push, pull_request] +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.21" + - run: go test ./... -race -count=2 diff --git a/chi/context.go b/chi/context.go new file mode 100644 index 0000000..47ed188 --- /dev/null +++ b/chi/context.go @@ -0,0 +1,42 @@ +package chi + +import ( + "context" + "net/http" +) + +type ctxKey int + +const routeCtxKey ctxKey = iota + +// Context holds routing state for a request. +type Context struct { + RoutePath string + RouteMethod string + URLParams map[string]string +} + +// NewRouteContext creates an empty route context. +func NewRouteContext() *Context { + return &Context{URLParams: map[string]string{}} +} + +// RouteContext returns the chi Context from a request context. +func RouteContext(ctx context.Context) *Context { + if v := ctx.Value(routeCtxKey); v != nil { + return v.(*Context) + } + return nil +} + +// URLParam returns a path parameter by name. +func URLParam(r *http.Request, key string) string { + if rctx := RouteContext(r.Context()); rctx != nil { + return rctx.URLParams[key] + } + return "" +} + +func withRouteContext(r *http.Request, rctx *Context) *http.Request { + return r.WithContext(context.WithValue(r.Context(), routeCtxKey, rctx)) +} diff --git a/chi/mux.go b/chi/mux.go new file mode 100644 index 0000000..054ac6e --- /dev/null +++ b/chi/mux.go @@ -0,0 +1,145 @@ +package chi + +import ( + "net/http" + "strings" +) + +// Mux is a lightweight chi-like router. +type Mux struct { + middlewares []func(http.Handler) http.Handler + routes []route + notFound http.Handler +} + +type route struct { + method string + pattern string + handler http.Handler +} + +// NewRouter creates a new Mux. +func NewRouter() *Mux { + return &Mux{ + notFound: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + }), + } +} + +// Use appends middleware. +func (m *Mux) Use(mw func(http.Handler) http.Handler) { + m.middlewares = append(m.middlewares, mw) +} + +// Get registers a GET route. +func (m *Mux) Get(pattern string, h http.HandlerFunc) { + m.routes = append(m.routes, route{method: http.MethodGet, pattern: pattern, handler: h}) +} + +// Handle registers a route for any method. +func (m *Mux) Handle(pattern string, h http.Handler) { + m.routes = append(m.routes, route{method: "", pattern: pattern, handler: h}) +} + +// ServeHTTP implements http.Handler. +func (m *Mux) ServeHTTP(w http.ResponseWriter, r *http.Request) { + rctx := RouteContext(r.Context()) + if rctx == nil { + rctx = NewRouteContext() + r = withRouteContext(r, rctx) + } + // Initialize RoutePath from the request path on first entry. + if rctx.RoutePath == "" { + rctx.RoutePath = r.URL.Path + } + + h := http.Handler(http.HandlerFunc(m.routeHTTP)) + // apply middleware in reverse so first Use runs first + for i := len(m.middlewares) - 1; i >= 0; i-- { + h = m.middlewares[i](h) + } + h.ServeHTTP(w, r) +} + +func (m *Mux) routeHTTP(w http.ResponseWriter, r *http.Request) { + rctx := RouteContext(r.Context()) + if rctx == nil { + rctx = NewRouteContext() + r = withRouteContext(r, rctx) + } + + // CRITICAL FIX: if middleware rewrote r.URL.Path, resync RoutePath so + // matching and URLParam extraction use the updated path. + if r.URL.Path != rctx.RoutePath { + rctx.RoutePath = r.URL.Path + // clear stale params from the pre-rewrite path + rctx.URLParams = map[string]string{} + } + + path := rctx.RoutePath + if path == "" { + path = r.URL.Path + } + method := r.Method + + for _, rt := range m.routes { + if rt.method != "" && rt.method != method { + continue + } + params, ok := match(rt.pattern, path) + if !ok { + continue + } + for k, v := range params { + rctx.URLParams[k] = v + } + rt.handler.ServeHTTP(w, r) + return + } + m.notFound.ServeHTTP(w, r) +} + +// match supports patterns like /users/{id} and /files/* +func match(pattern, path string) (map[string]string, bool) { + pp := splitPath(pattern) + ap := splitPath(path) + params := map[string]string{} + + i, j := 0, 0 + for i < len(pp) { + if j >= len(ap) { + // allow trailing optional nothing + return nil, false + } + seg := pp[i] + if seg == "*" { + params["*"] = strings.Join(ap[j:], "/") + return params, true + } + if strings.HasPrefix(seg, "{") && strings.HasSuffix(seg, "}") { + key := seg[1 : len(seg)-1] + params[key] = ap[j] + i++ + j++ + continue + } + if seg != ap[j] { + return nil, false + } + i++ + j++ + } + if j != len(ap) { + return nil, false + } + return params, true +} + +func splitPath(p string) []string { + p = strings.Trim(p, "/") + if p == "" { + return nil + } + return strings.Split(p, "/") +} diff --git a/chi/mux_test.go b/chi/mux_test.go new file mode 100644 index 0000000..d6d0ba7 --- /dev/null +++ b/chi/mux_test.go @@ -0,0 +1,84 @@ +package chi + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestMiddlewarePathRewriteURLParams(t *testing.T) { + r := NewRouter() + + // Middleware rewrites path before routing + r.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if req.URL.Path == "/legacy/123" { + req.URL.Path = "/users/123" + } + next.ServeHTTP(w, req) + }) + }) + + r.Get("/users/{id}", func(w http.ResponseWriter, req *http.Request) { + id := URLParam(req, "id") + if id != "123" { + t.Errorf("expected URL param 'id' to be '123', got %q", id) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) + + req := httptest.NewRequest(http.MethodGet, "/legacy/123", nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%q", rec.Code, rec.Body.String()) + } + body, _ := io.ReadAll(rec.Result().Body) + if string(body) != "ok" { + t.Fatalf("body %q", body) + } +} + +func TestNestedWildcardAfterRewrite(t *testing.T) { + r := NewRouter() + r.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if req.URL.Path == "/old/docs/a/b" { + req.URL.Path = "/files/a/b" + } + next.ServeHTTP(w, req) + }) + }) + r.Get("/files/*", func(w http.ResponseWriter, req *http.Request) { + rest := URLParam(req, "*") + if rest != "a/b" { + t.Errorf("expected *, got %q", rest) + } + w.WriteHeader(http.StatusOK) + }) + req := httptest.NewRequest(http.MethodGet, "/old/docs/a/b", nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("code %d", rec.Code) + } +} + +func TestNoRewriteStillWorks(t *testing.T) { + r := NewRouter() + r.Get("/users/{id}", func(w http.ResponseWriter, req *http.Request) { + if URLParam(req, "id") != "9" { + t.Errorf("id=%q", URLParam(req, "id")) + } + w.WriteHeader(http.StatusOK) + }) + req := httptest.NewRequest(http.MethodGet, "/users/9", nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("code %d", rec.Code) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e2c9bea --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/madalynerlge2/chi + +go 1.21 diff --git a/main.go b/main.go index 49f4dee..ff7d43b 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,25 @@ package main -import "fmt" +import ( + "fmt" + "net/http" + + "github.com/madalynerlge2/chi/chi" +) func main() { - fmt.Println("Hello, Bounty Hunter!") + r := chi.NewRouter() + r.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if req.URL.Path == "/legacy/123" { + req.URL.Path = "/users/123" + } + next.ServeHTTP(w, req) + }) + }) + r.Get("/users/{id}", func(w http.ResponseWriter, req *http.Request) { + fmt.Fprintf(w, "id=%s", chi.URLParam(req, "id")) + }) + fmt.Println("chi demo listening on :8080") + _ = http.ListenAndServe(":8080", r) }