Skip to content

Commit 752c73d

Browse files
authored
Feat/cache (#72)
1 parent 7b70679 commit 752c73d

File tree

3 files changed

+180
-0
lines changed

3 files changed

+180
-0
lines changed

ext/cache/cache.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Package cache provides HTTP caching middleware for xun web applications.
2+
// It allows setting Cache-Control headers based on configurable rules and paths.
3+
package cache
4+
5+
import (
6+
"strconv"
7+
8+
"github.com/yaitoo/xun"
9+
)
10+
11+
// New creates a xun middleware that applies caching rules to HTTP responses.
12+
// It accepts optional configurations through Option functions and returns
13+
// a middleware that sets Cache-Control headers based on request URL paths.
14+
func New(opts ...Option) xun.Middleware { // skipcq: GO-R1005
15+
options := &Options{}
16+
17+
for _, opt := range opts {
18+
opt(options)
19+
}
20+
21+
return func(next xun.HandleFunc) xun.HandleFunc {
22+
return func(c *xun.Context) error {
23+
// Apply caching rules based on request path
24+
for _, rule := range options.Rules {
25+
if rule.Match(c.Request.URL.Path) {
26+
c.WriteHeader("Cache-Control", "public, max-age="+strconv.Itoa(int(rule.Duration.Seconds())))
27+
break
28+
}
29+
}
30+
31+
return next(c)
32+
}
33+
}
34+
}

ext/cache/cache_test.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package cache
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"testing"
7+
"time"
8+
9+
"github.com/stretchr/testify/require"
10+
"github.com/yaitoo/xun"
11+
)
12+
13+
func TestCache(t *testing.T) {
14+
mux := http.NewServeMux()
15+
srv := httptest.NewServer(mux)
16+
defer srv.Close()
17+
18+
app := xun.New(xun.WithMux(mux))
19+
20+
app.Use(New(Match("/starts", "", 1*time.Second),
21+
Match("", "banner.jpg", 2*time.Second),
22+
Match("/assets", "app.js", 3*time.Second)))
23+
24+
app.Get("/starts", func(c *xun.Context) error {
25+
return c.View(nil)
26+
})
27+
28+
app.Get("/banner.jpg", func(c *xun.Context) error {
29+
return c.View(nil)
30+
})
31+
32+
app.Get("/assets/app.js", func(c *xun.Context) error {
33+
return c.View(nil)
34+
})
35+
36+
app.Get("/assets/app.css", func(c *xun.Context) error {
37+
return c.View(nil)
38+
})
39+
40+
app.Get("/logo", func(c *xun.Context) error {
41+
return c.View(nil)
42+
})
43+
44+
resp, err := http.Get(srv.URL + "/starts")
45+
require.NoError(t, err)
46+
require.Equal(t, http.StatusOK, resp.StatusCode)
47+
48+
cacheControl := resp.Header.Get("Cache-Control")
49+
require.Equal(t, "public, max-age=1", cacheControl)
50+
resp.Body.Close()
51+
52+
resp, err = http.Get(srv.URL + "/banner.jpg")
53+
require.NoError(t, err)
54+
require.Equal(t, http.StatusOK, resp.StatusCode)
55+
cacheControl = resp.Header.Get("Cache-Control")
56+
require.Equal(t, "public, max-age=2", cacheControl)
57+
resp.Body.Close()
58+
59+
resp, err = http.Get(srv.URL + "/assets/app.js")
60+
require.NoError(t, err)
61+
require.Equal(t, http.StatusOK, resp.StatusCode)
62+
cacheControl = resp.Header.Get("Cache-Control")
63+
require.Equal(t, "public, max-age=3", cacheControl)
64+
resp.Body.Close()
65+
66+
resp, err = http.Get(srv.URL + "/assets/app.css")
67+
require.NoError(t, err)
68+
require.Equal(t, http.StatusOK, resp.StatusCode)
69+
cacheControl = resp.Header.Get("Cache-Control")
70+
require.Empty(t, cacheControl)
71+
resp.Body.Close()
72+
73+
resp, err = http.Get(srv.URL + "/logo")
74+
require.NoError(t, err)
75+
require.Equal(t, http.StatusOK, resp.StatusCode)
76+
cacheControl = resp.Header.Get("Cache-Control")
77+
require.Empty(t, cacheControl)
78+
resp.Body.Close()
79+
}

ext/cache/option.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package cache
2+
3+
import (
4+
"strings"
5+
"time"
6+
)
7+
8+
// Rule defines a caching rule with path matching criteria and cache duration.
9+
// It can match request paths by prefix, suffix, or both.
10+
type Rule struct {
11+
StartsWith string // Path prefix to match
12+
EndsWith string // Path suffix to match
13+
Duration time.Duration // Cache duration to apply
14+
}
15+
16+
// Match checks if the provided path matches the rule's criteria.
17+
// A path matches when it satisfies both StartsWith and EndsWith conditions.
18+
func (r *Rule) Match(path string) bool {
19+
if r.StartsWith != "" && !hasPrefix(path, r.StartsWith) {
20+
return false
21+
}
22+
23+
if r.EndsWith != "" && !hasSuffix(path, r.EndsWith) {
24+
return false
25+
}
26+
27+
return true
28+
}
29+
30+
// Options stores configuration for the cache middleware.
31+
type Options struct {
32+
Rules []Rule // Collection of caching rules to apply
33+
}
34+
35+
// Option is a function that configures the cache Options.
36+
type Option func(o *Options)
37+
38+
// Match creates an Option that adds a new caching rule.
39+
// The rule matches paths that start with 'startsWith' and end with 'endsWith',
40+
// applying the specified cache duration.
41+
func Match(startsWith, endsWith string, duration time.Duration) Option {
42+
return func(o *Options) {
43+
if (startsWith != "" || endsWith != "") && duration > 0 {
44+
o.Rules = append(o.Rules, Rule{
45+
StartsWith: startsWith,
46+
EndsWith: endsWith,
47+
Duration: duration,
48+
})
49+
}
50+
}
51+
}
52+
53+
// hasPrefix checks if string s starts with prefix, ignoring case.
54+
func hasPrefix(s string, prefix string) bool {
55+
if len(s) < len(prefix) {
56+
return false
57+
}
58+
return strings.EqualFold(s[:len(prefix)], prefix)
59+
}
60+
61+
// hasSuffix checks if string s ends with suffix, ignoring case.
62+
func hasSuffix(s string, suffix string) bool {
63+
if len(s) < len(suffix) {
64+
return false
65+
}
66+
return strings.EqualFold(s[len(s)-len(suffix):], suffix)
67+
}

0 commit comments

Comments
 (0)