Skip to content

Commit 7b6a982

Browse files
Merge pull request #5 from buildwithgo/feature/optimize-trie-router-4971304667675017701
Optimize TrieRouter for Performance and Correctness
2 parents e1fe6bf + 3f42dc6 commit 7b6a982

22 files changed

Lines changed: 1691 additions & 341 deletions

‎.gitignore‎

263 Bytes
Binary file not shown.

‎.local/hello.txt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Hello Local

‎addons/cache/middleware.go‎

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,22 @@ package cache
22

33
import (
44
"bytes"
5+
"encoding/gob"
6+
"fmt"
57
"net/http"
68
"time"
79

810
"github.com/buildwithgo/amaro"
911
)
1012

11-
// responseRecorder captures the response status and body for caching.
13+
// CachedResponse stores the response data.
14+
type CachedResponse struct {
15+
StatusCode int
16+
Headers http.Header
17+
Body []byte
18+
}
19+
20+
// responseRecorder captures the response status, headers, and body for caching.
1221
type responseRecorder struct {
1322
http.ResponseWriter
1423
statusCode int
@@ -25,25 +34,48 @@ func (r *responseRecorder) Write(b []byte) (int, error) {
2534
return r.ResponseWriter.Write(b)
2635
}
2736

28-
// CachePage returns a middleware that caches the response body for a given duration.
37+
// KeyGenerator allows customizing the cache key.
38+
type KeyGenerator func(c *amaro.Context) string
39+
40+
func DefaultKeyGenerator(c *amaro.Context) string {
41+
return "route_cache:" + c.Request.URL.String()
42+
}
43+
44+
// CachePage returns a middleware that caches the response for a given duration.
2945
// It uses the Cache interface.
30-
func CachePage(store Cache, ttl time.Duration) amaro.Middleware {
46+
func CachePage(store Cache, ttl time.Duration, keyGen ...KeyGenerator) amaro.Middleware {
47+
getKey := DefaultKeyGenerator
48+
if len(keyGen) > 0 {
49+
getKey = keyGen[0]
50+
}
51+
3152
return func(next amaro.Handler) amaro.Handler {
3253
return func(c *amaro.Context) error {
3354
// Only cache GET requests
3455
if c.Request.Method != http.MethodGet {
3556
return next(c)
3657
}
3758

38-
key := "route_cache:" + c.Request.URL.String()
59+
key := getKey(c)
3960

4061
// Check cache
4162
if val, ok := store.Get(key); ok {
42-
// Hit - We must assert to []byte
43-
if bodyBytes, ok := val.([]byte); ok {
44-
c.Writer.Header().Set("X-Cache", "HIT")
45-
c.Writer.Write(bodyBytes)
46-
return nil
63+
if cachedBytes, ok := val.([]byte); ok {
64+
var cached CachedResponse
65+
// Use Gob for simple serialization of struct with headers
66+
buf := bytes.NewBuffer(cachedBytes)
67+
if err := gob.NewDecoder(buf).Decode(&cached); err == nil {
68+
// Replay headers
69+
for k, v := range cached.Headers {
70+
for _, h := range v {
71+
c.Writer.Header().Add(k, h)
72+
}
73+
}
74+
c.Writer.Header().Set("X-Cache", "HIT")
75+
c.Writer.WriteHeader(cached.StatusCode)
76+
c.Writer.Write(cached.Body)
77+
return nil
78+
}
4779
}
4880
}
4981

@@ -59,8 +91,20 @@ func CachePage(store Cache, ttl time.Duration) amaro.Middleware {
5991
err := next(c)
6092

6193
// If successful, cache the result
62-
if err == nil && recorder.statusCode == http.StatusOK {
63-
store.Set(key, recorder.body.Bytes(), ttl)
94+
if err == nil && recorder.statusCode < 400 {
95+
// Create cached response
96+
resp := CachedResponse{
97+
StatusCode: recorder.statusCode,
98+
Headers: recorder.Header().Clone(), // Copy headers
99+
Body: recorder.body.Bytes(),
100+
}
101+
102+
var buf bytes.Buffer
103+
if err := gob.NewEncoder(&buf).Encode(resp); err == nil {
104+
store.Set(key, buf.Bytes(), ttl)
105+
} else {
106+
fmt.Println("Cache encode error:", err)
107+
}
64108
}
65109

66110
return err

‎addons/oauth2/oauth2.go‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package oauth2
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net/http"
7+
8+
"github.com/buildwithgo/amaro"
9+
"golang.org/x/oauth2"
10+
)
11+
12+
// Config holds OAuth2 configuration.
13+
type Config struct {
14+
oauth2.Config
15+
16+
// SuccessHandler is called after successful token exchange.
17+
// It should handle session creation or token response.
18+
SuccessHandler func(c *amaro.Context, token *oauth2.Token) error
19+
20+
// ErrorHandler handles errors during the flow.
21+
ErrorHandler func(c *amaro.Context, err error) error
22+
23+
// StateGenerator generates the state string.
24+
StateGenerator func(c *amaro.Context) string
25+
26+
// StateValidator validates the state string.
27+
StateValidator func(c *amaro.Context, state string) bool
28+
}
29+
30+
// LoginHandler returns a handler that redirects to the OAuth2 provider.
31+
func LoginHandler(config *Config) amaro.Handler {
32+
return func(c *amaro.Context) error {
33+
state := ""
34+
if config.StateGenerator != nil {
35+
state = config.StateGenerator(c)
36+
}
37+
url := config.AuthCodeURL(state)
38+
return c.Redirect(http.StatusTemporaryRedirect, url)
39+
}
40+
}
41+
42+
// CallbackHandler returns a handler that processes the OAuth2 callback.
43+
func CallbackHandler(config *Config) amaro.Handler {
44+
return func(c *amaro.Context) error {
45+
code := c.QueryParam("code")
46+
state := c.QueryParam("state")
47+
48+
if config.StateValidator != nil {
49+
if !config.StateValidator(c, state) {
50+
return config.ErrorHandler(c, fmt.Errorf("invalid state"))
51+
}
52+
}
53+
54+
token, err := config.Exchange(context.Background(), code)
55+
if err != nil {
56+
if config.ErrorHandler != nil {
57+
return config.ErrorHandler(c, err)
58+
}
59+
return err
60+
}
61+
62+
if config.SuccessHandler != nil {
63+
return config.SuccessHandler(c, token)
64+
}
65+
66+
return c.JSON(http.StatusOK, token)
67+
}
68+
}

‎amaro.go‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,11 @@ func (a *App) StaticFS(pathPrefix string, fs fs.FS) {
9191
a.router.StaticFS(pathPrefix, fs)
9292
}
9393

94+
// Static serves files from the local filesystem.
95+
func (a *App) Static(pathPrefix, root string) {
96+
a.StaticFS(pathPrefix, os.DirFS(root))
97+
}
98+
9499
func (a *App) Find(method, path string) (*Route, error) {
95100
return a.router.Find(method, path, nil)
96101
}
@@ -110,6 +115,15 @@ func New(options ...AppOption) *App {
110115
},
111116
},
112117
errorHandler: func(c *Context, err error, code int) {
118+
if he, ok := err.(*HTTPError); ok {
119+
code = he.Code
120+
if msg, ok := he.Message.(string); ok {
121+
http.Error(c.Writer, msg, code)
122+
} else {
123+
http.Error(c.Writer, http.StatusText(code), code)
124+
}
125+
return
126+
}
113127
http.Error(c.Writer, err.Error(), code)
114128
},
115129
}

‎errors.go‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package amaro
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
)
7+
8+
// HTTPError represents an error with an associated HTTP status code.
9+
type HTTPError struct {
10+
Code int
11+
Message interface{}
12+
Internal error
13+
}
14+
15+
func (e *HTTPError) Error() string {
16+
return fmt.Sprintf("code=%d, message=%v", e.Code, e.Message)
17+
}
18+
19+
// NewHTTPError creates a new HTTPError.
20+
func NewHTTPError(code int, message ...interface{}) *HTTPError {
21+
he := &HTTPError{Code: code, Message: http.StatusText(code)}
22+
if len(message) > 0 {
23+
he.Message = message[0]
24+
}
25+
return he
26+
}
27+
28+
// SetInternal sets the internal error.
29+
func (e *HTTPError) SetInternal(err error) *HTTPError {
30+
e.Internal = err
31+
return e
32+
}
33+
34+
// Unwrap returns the internal error.
35+
func (e *HTTPError) Unwrap() error {
36+
return e.Internal
37+
}

‎go.mod‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,5 @@ go 1.25
55
require github.com/golang-jwt/jwt/v5 v5.3.0
66

77
require golang.org/x/net v0.48.0
8+
9+
require golang.org/x/oauth2 v0.34.0 // indirect

‎go.sum‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,5 @@ github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9v
22
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
33
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
44
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
5+
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
6+
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=

‎middlewares/auth_test.go‎

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package middlewares
2+
3+
import (
4+
"net/http"
5+
"testing"
6+
7+
"github.com/buildwithgo/amaro"
8+
"github.com/buildwithgo/amaro/routers"
9+
)
10+
11+
func TestBasicAuth(t *testing.T) {
12+
app := amaro.New(amaro.WithRouter(routers.NewTrieRouter()))
13+
14+
// Middleware
15+
mw := BasicAuth(func(username, password string, c *amaro.Context) (bool, error) {
16+
if username == "admin" && password == "secret" {
17+
return true, nil
18+
}
19+
return false, nil
20+
})
21+
22+
app.GET("/protected", func(c *amaro.Context) error {
23+
return c.String(http.StatusOK, "Allowed")
24+
}, mw)
25+
26+
// Case 1: No Auth
27+
req, _ := http.NewRequest("GET", "/protected", nil)
28+
w := &mockWriter{}
29+
app.ServeHTTP(w, req)
30+
if w.code != http.StatusUnauthorized {
31+
t.Errorf("Expected 401, got %d", w.code)
32+
}
33+
34+
// Case 2: Invalid Auth
35+
req, _ = http.NewRequest("GET", "/protected", nil)
36+
req.SetBasicAuth("admin", "wrong")
37+
w = &mockWriter{}
38+
app.ServeHTTP(w, req)
39+
if w.code != http.StatusUnauthorized {
40+
t.Errorf("Expected 401, got %d", w.code)
41+
}
42+
43+
// Case 3: Valid Auth
44+
req, _ = http.NewRequest("GET", "/protected", nil)
45+
req.SetBasicAuth("admin", "secret")
46+
w = &mockWriter{}
47+
app.ServeHTTP(w, req)
48+
if w.code != http.StatusOK {
49+
t.Errorf("Expected 200, got %d", w.code)
50+
}
51+
if w.body != "Allowed" {
52+
t.Errorf("Expected 'Allowed', got '%s'", w.body)
53+
}
54+
}
55+
56+
func TestKeyAuth(t *testing.T) {
57+
app := amaro.New(amaro.WithRouter(routers.NewTrieRouter()))
58+
59+
mw := KeyAuth(func(key string, c *amaro.Context) (bool, error) {
60+
return key == "valid-api-key", nil
61+
})
62+
63+
app.GET("/api", func(c *amaro.Context) error {
64+
return c.String(http.StatusOK, "Success")
65+
}, mw)
66+
67+
// Case 1: Missing Key
68+
req, _ := http.NewRequest("GET", "/api", nil)
69+
w := &mockWriter{}
70+
app.ServeHTTP(w, req)
71+
if w.code != http.StatusUnauthorized {
72+
t.Errorf("Expected 401, got %d", w.code)
73+
}
74+
75+
// Case 2: Invalid Key
76+
req, _ = http.NewRequest("GET", "/api", nil)
77+
req.Header.Set("X-API-Key", "bad-key")
78+
w = &mockWriter{}
79+
app.ServeHTTP(w, req)
80+
if w.code != http.StatusUnauthorized {
81+
t.Errorf("Expected 401, got %d", w.code)
82+
}
83+
84+
// Case 3: Valid Key
85+
req, _ = http.NewRequest("GET", "/api", nil)
86+
req.Header.Set("X-API-Key", "valid-api-key")
87+
w = &mockWriter{}
88+
app.ServeHTTP(w, req)
89+
if w.code != http.StatusOK {
90+
t.Errorf("Expected 200, got %d", w.code)
91+
}
92+
}
93+
94+
// Mock Writer
95+
type mockWriter struct {
96+
code int
97+
body string
98+
header http.Header
99+
}
100+
func (m *mockWriter) Header() http.Header {
101+
if m.header == nil { m.header = make(http.Header) }
102+
return m.header
103+
}
104+
func (m *mockWriter) Write(b []byte) (int, error) {
105+
m.body = string(b)
106+
return len(b), nil
107+
}
108+
func (m *mockWriter) WriteHeader(statusCode int) {
109+
m.code = statusCode
110+
}

0 commit comments

Comments
 (0)