Skip to content

Commit

Permalink
Merge pull request #38 from 2manymws/extended-rules
Browse files Browse the repository at this point in the history
Support extended rules like proxy_cache_valid of NGINX
  • Loading branch information
k1LoW authored Dec 20, 2023
2 parents 4ad3acb + 80b6ad4 commit 45b3316
Show file tree
Hide file tree
Showing 2 changed files with 132 additions and 5 deletions.
58 changes: 54 additions & 4 deletions rfc9111/shared.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@ type Shared struct {
understoodStatusCodes []int
heuristicallyCacheableStatusCodes []int
heuristicExpirationRatio float64
extendedRules []ExtendedRule
}

// ExtendedRule is an extended rule.
// Like proxy_cache_valid of NGINX.
// Rules are applied only when there is no Cache-Control header and the expiration time cannot be calculated.
// THIS IS NOT RFC 9111.
type ExtendedRule interface { //nostyle:ifacenames
// Cacheable returns true and and the expiration time if the response is cacheable.
Cacheable(req *http.Request, res *http.Response) (ok bool, age time.Duration)
}

// SharedOption is an option for Shared.
Expand Down Expand Up @@ -55,6 +65,14 @@ func HeuristicExpirationRatio(ratio float64) SharedOption {
}
}

// ExtendedRules sets the extended rules.
func ExtendedRules(rules []ExtendedRule) SharedOption {
return func(s *Shared) error {
s.extendedRules = rules
return nil
}
}

// NewShared returns a new Shared cache handler.
func NewShared(opts ...SharedOption) (*Shared, error) {
s := &Shared{
Expand Down Expand Up @@ -87,7 +105,7 @@ func (s *Shared) Storable(req *http.Request, res *http.Response, now time.Time)
// 3. Storing Responses in Caches (https://httpwg.org/specs/rfc9111.html#rfc.section.3)
// - the request method is understood by the cache;
if !contains(req.Method, s.understoodMethods) {
return false, time.Time{}
return s.storableWithExtendedRules(req, res, now)
}

// - the response status code is final (see https://httpwg.org/specs/rfc9110.html#rfc.section.15);
Expand All @@ -97,7 +115,7 @@ func (s *Shared) Storable(req *http.Request, res *http.Response, now time.Time)
http.StatusProcessing,
http.StatusEarlyHints,
}) {
return false, time.Time{}
return s.storableWithExtendedRules(req, res, now)
}

rescc := ParseResponseCacheControlHeader(res.Header.Values("Cache-Control"))
Expand All @@ -107,7 +125,7 @@ func (s *Shared) Storable(req *http.Request, res *http.Response, now time.Time)
http.StatusPartialContent,
http.StatusNotModified,
}) || (rescc.MustUnderstand && !contains(res.StatusCode, s.understoodStatusCodes)) {
return false, time.Time{}
return s.storableWithExtendedRules(req, res, now)
}

// - the no-store cache directive is not present in the response (see https://httpwg.org/specs/rfc9111.html#rfc.section.5.2.2.5);
Expand All @@ -128,6 +146,9 @@ func (s *Shared) Storable(req *http.Request, res *http.Response, now time.Time)

expires := CalclateExpires(rescc, res.Header, s.heuristicExpirationRatio, now)
if expires.Sub(now) <= 0 {
if expires.Sub(time.Time{}) == 0 {
return s.storableWithExtendedRules(req, res, now)
}
return false, time.Time{}
}

Expand Down Expand Up @@ -276,6 +297,34 @@ func (s *Shared) Handle(req *http.Request, cachedReq *http.Request, cachedRes *h
return false, res, err
}

// storableWithExtendedRules returns true if the response is storable with extended rules.
func (s *Shared) storableWithExtendedRules(req *http.Request, res *http.Response, now time.Time) (bool, time.Time) {
if res.Header.Get("Cache-Control") != "" {
return false, time.Time{}
}

for _, rule := range s.extendedRules {
ok, age := rule.Cacheable(req, res)
if ok {
// Add Expires header field
var expires time.Time
if res.Header.Get("Date") != "" {
date, err := http.ParseTime(res.Header.Get("Date"))
if err == nil {
expires = date.Add(age)
} else {
expires = now.Add(age)
}
} else {
expires = now.Add(age)
}
res.Header.Set("Expires", expires.UTC().Format(http.TimeFormat))
return true, expires
}
}
return false, time.Time{}
}

func CalclateExpires(d *ResponseDirectives, header http.Header, heuristicExpirationRatio float64, now time.Time) time.Time {
// 4.2.1. Calculating Freshness Lifetime
// A cache can calculate the freshness lifetime (denoted as freshness_lifetime) of a response by evaluating the following rules and using the first match:
Expand Down Expand Up @@ -319,7 +368,8 @@ func CalclateExpires(d *ResponseDirectives, header http.Header, heuristicExpirat
}
}

return now
// Can't calculate expires
return time.Time{}
}

func contains[T comparable](v T, vv []T) bool {
Expand Down
79 changes: 78 additions & 1 deletion rfc9111/shared_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,33 @@ import (
"github.com/google/go-cmp/cmp"
)

type testRule struct {
cacheableMethods []string
cacheableStatus []int
age time.Duration
}

func (r *testRule) Cacheable(req *http.Request, res *http.Response) (bool, time.Duration) {
for _, m := range r.cacheableMethods {
if req.Method == m {
for _, s := range r.cacheableStatus {
if res.StatusCode == s {
return true, r.age
}
}
}
}
return false, 0
}

func TestShared_Storable(t *testing.T) {
now := time.Date(2024, 12, 13, 14, 15, 16, 00, time.UTC)

tests := []struct {
name string
req *http.Request
res *http.Response
rules []ExtendedRule
wantOK bool
wantExpires time.Time
}{
Expand All @@ -30,6 +50,7 @@ func TestShared_Storable(t *testing.T) {
"Cache-Control": []string{"s-maxage=10"},
},
},
nil,
true,
time.Date(2024, 12, 13, 14, 15, 26, 00, time.UTC),
},
Expand All @@ -44,6 +65,7 @@ func TestShared_Storable(t *testing.T) {
"Cache-Control": []string{"max-age=15"},
},
},
nil,
true,
time.Date(2024, 12, 13, 14, 15, 31, 00, time.UTC),
},
Expand All @@ -58,6 +80,7 @@ func TestShared_Storable(t *testing.T) {
"Expires": []string{"Mon, 13 Dec 2024 14:15:20 GMT"},
},
},
nil,
true,
time.Date(2024, 12, 13, 14, 15, 20, 00, time.UTC),
},
Expand All @@ -73,6 +96,7 @@ func TestShared_Storable(t *testing.T) {
"Date": []string{"Mon, 13 Dec 2024 13:15:20 GMT"},
},
},
nil,
true,
time.Date(2024, 12, 13, 15, 15, 16, 00, time.UTC),
},
Expand All @@ -88,6 +112,7 @@ func TestShared_Storable(t *testing.T) {
"Date": []string{"Mon, 13 Dec 2024 14:15:20 GMT"},
},
},
nil,
true,
time.Date(2024, 12, 13, 14, 15, 21, 00, time.UTC),
},
Expand All @@ -102,6 +127,7 @@ func TestShared_Storable(t *testing.T) {
"Last-Modified": []string{"Mon, 13 Dec 2024 14:15:06 GMT"},
},
},
nil,
true,
time.Date(2024, 12, 13, 14, 15, 17, 00, time.UTC),
},
Expand All @@ -116,6 +142,7 @@ func TestShared_Storable(t *testing.T) {
"Last-Modified": []string{"Mon, 13 Dec 2024 14:15:06 GMT"},
},
},
nil,
false,
time.Time{},
},
Expand All @@ -130,6 +157,7 @@ func TestShared_Storable(t *testing.T) {
"Date": []string{"Mon, 13 Dec 2024 14:15:10 GMT"},
},
},
nil,
false,
time.Time{},
},
Expand All @@ -144,6 +172,7 @@ func TestShared_Storable(t *testing.T) {
"Cache-Control": []string{"max-age=15"},
},
},
nil,
false,
time.Time{},
},
Expand All @@ -158,6 +187,7 @@ func TestShared_Storable(t *testing.T) {
"Cache-Control": []string{"max-age=15"},
},
},
nil,
false,
time.Time{},
},
Expand All @@ -172,6 +202,7 @@ func TestShared_Storable(t *testing.T) {
"Cache-Control": []string{"max-age=15"},
},
},
nil,
false,
time.Time{},
},
Expand All @@ -186,6 +217,7 @@ func TestShared_Storable(t *testing.T) {
"Cache-Control": []string{"no-store"},
},
},
nil,
false,
time.Time{},
},
Expand All @@ -200,6 +232,7 @@ func TestShared_Storable(t *testing.T) {
"Cache-Control": []string{"private"},
},
},
nil,
false,
time.Time{},
},
Expand All @@ -215,6 +248,7 @@ func TestShared_Storable(t *testing.T) {
"Cache-Control": []string{"public"},
},
},
nil,
true,
time.Date(2024, 12, 13, 14, 15, 17, 00, time.UTC),
},
Expand All @@ -230,6 +264,7 @@ func TestShared_Storable(t *testing.T) {
"Cache-Control": []string{"public"},
},
},
nil,
false,
time.Time{},
},
Expand All @@ -244,6 +279,7 @@ func TestShared_Storable(t *testing.T) {
"Cache-Control": []string{"public"},
},
},
nil,
false,
time.Time{},
},
Expand All @@ -261,15 +297,56 @@ func TestShared_Storable(t *testing.T) {
"Cache-Control": []string{"max-age=15"},
},
},
nil,
false,
time.Time{},
},
{
"ExtendedRule(+15s) GET 200 -> +15s",
&http.Request{
Method: http.MethodGet,
},
&http.Response{
StatusCode: http.StatusOK,
Header: http.Header{
"Date": []string{"Mon, 13 Dec 2024 14:15:10 GMT"},
},
},
[]ExtendedRule{
&testRule{
cacheableMethods: []string{http.MethodGet},
cacheableStatus: []int{http.StatusOK},
age: 15 * time.Second,
},
},
true,
time.Date(2024, 12, 13, 14, 15, 25, 00, time.UTC),
},
{
"ExtendedRule(+15s) POST 201 -> +15s",
&http.Request{
Method: http.MethodPost,
},
&http.Response{
StatusCode: http.StatusCreated,
Header: http.Header{},
},
[]ExtendedRule{
&testRule{
cacheableMethods: []string{http.MethodPost},
cacheableStatus: []int{http.StatusCreated},
age: 15 * time.Second,
},
},
true,
time.Date(2024, 12, 13, 14, 15, 31, 00, time.UTC),
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
s, err := NewShared()
s, err := NewShared(ExtendedRules(tt.rules))
if err != nil {
t.Errorf("Shared.Storable() error = %v", err)
return
Expand Down

0 comments on commit 45b3316

Please sign in to comment.