Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
42 changes: 42 additions & 0 deletions chi/context.go
Original file line number Diff line number Diff line change
@@ -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))
}
145 changes: 145 additions & 0 deletions chi/mux.go
Original file line number Diff line number Diff line change
@@ -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, "/")
}
84 changes: 84 additions & 0 deletions chi/mux_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module github.com/madalynerlge2/chi

go 1.21
22 changes: 20 additions & 2 deletions main.go
Original file line number Diff line number Diff line change
@@ -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)
}