Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(imagor): ETag and Modified headers from result storage #221

Merged
merged 22 commits into from
Oct 17, 2022
Merged
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
21 changes: 20 additions & 1 deletion blob.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"net/http"
"os"
"sync"
"time"
)

type BlobType int
Expand Down Expand Up @@ -41,6 +42,15 @@ type Blob struct {
filepath string
contentType string
memory *memory

Stat *Stat
}

// Stat blob stat attributes
type Stat struct {
ModifiedTime time.Time
ETag string
Size int64
}

func NewBlob(newReader func() (reader io.ReadCloser, size int64, err error)) *Blob {
Expand All @@ -62,7 +72,7 @@ func NewBlobFromFile(filepath string, checks ...func(os.FileInfo) error) *Blob {
}
}
}
return &Blob{
blob := &Blob{
err: err,
filepath: filepath,
fanout: true,
Expand All @@ -74,6 +84,15 @@ func NewBlobFromFile(filepath string, checks ...func(os.FileInfo) error) *Blob {
return reader, stat.Size(), err
},
}
if err == nil && stat != nil {
size := stat.Size()
modTime := stat.ModTime()
blob.Stat = &Stat{
Size: size,
ModifiedTime: modTime,
}
}
return blob
}

func NewBlobFromJsonMarshal(v any) *Blob {
Expand Down
16 changes: 0 additions & 16 deletions context.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ type imagorContextKey struct{}
type imagorContextRef struct {
funcs []func()
l sync.Mutex
Cache sync.Map
}

func (r *imagorContextRef) Defer(fn func()) {
Expand Down Expand Up @@ -51,18 +50,3 @@ func mustContextValue(ctx context.Context) *imagorContextRef {
func Defer(ctx context.Context, fn func()) {
mustContextValue(ctx).Defer(fn)
}

// ContextCachePut put cache within the imagor request context lifetime
func ContextCachePut(ctx context.Context, key any, val any) {
if r, ok := ctx.Value(imagorContextKey{}).(*imagorContextRef); ok && r != nil {
r.Cache.Store(key, val)
}
}

// ContextCacheGet get cache within the imagor request context lifetime
func ContextCacheGet(ctx context.Context, key any) (any, bool) {
if r, ok := ctx.Value(imagorContextKey{}).(*imagorContextRef); ok && r != nil {
return r.Cache.Load(key)
}
return nil, false
}
15 changes: 0 additions & 15 deletions context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,3 @@ func TestDefer(t *testing.T) {
})
assert.Equal(t, 2, called, "should count all defers before cancel")
}

func TestContextCache(t *testing.T) {
ctx := context.Background()
assert.NotPanics(t, func() {
ContextCachePut(ctx, "foo", "bar")
})
ctx = WithContext(ctx)
s, ok := ContextCacheGet(ctx, "foo")
assert.False(t, ok)
assert.Nil(t, s)
ContextCachePut(ctx, "foo", "bar")
s, ok = ContextCacheGet(ctx, "foo")
assert.True(t, ok)
assert.Equal(t, "bar", s)
}
62 changes: 46 additions & 16 deletions imagor.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,6 @@ type Processor interface {
Shutdown(ctx context.Context) error
}

// Stat image attributes
type Stat struct {
ModifiedTime time.Time
Size int64
}

// Imagor image resize HTTP handler
type Imagor struct {
Unsafe bool
Expand Down Expand Up @@ -191,10 +185,14 @@ func (app *Imagor) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if isBlobEmpty(blob) {
return
}
reader, size, _ := blob.NewReader()
w.Header().Set("Content-Type", blob.ContentType())
w.Header().Set("Content-Disposition", getContentDisposition(p, blob))
setCacheHeaders(w, app.CacheHeaderTTL, app.CacheHeaderSWR, app.Debug)
setCacheHeaders(w, r, app.CacheHeaderTTL, app.CacheHeaderSWR)
if checkStatNotModified(w, r, blob.Stat) {
w.WriteHeader(http.StatusNotModified)
return
}
reader, size, _ := blob.NewReader()
writeBody(w, r, reader, size)
return
}
Expand Down Expand Up @@ -383,12 +381,10 @@ func (app *Imagor) loadResult(r *http.Request, resultKey, imageKey string) *Blob
ctx := r.Context()
blob, origin, err := fromStorages(r, app.ResultStorages, resultKey)
if err == nil && !isBlobEmpty(blob) {
if app.ModifiedTimeCheck && origin != nil {
if resStat, err1 := origin.Stat(ctx, resultKey); resStat != nil && err1 == nil {
if sourceStat, err2 := app.storageStat(ctx, imageKey); sourceStat != nil && err2 == nil {
if !resStat.ModifiedTime.Before(sourceStat.ModifiedTime) {
return blob
}
if app.ModifiedTimeCheck && origin != nil && blob.Stat != nil {
if sourceStat, err2 := app.storageStat(ctx, imageKey); sourceStat != nil && err2 == nil {
if !blob.Stat.ModifiedTime.Before(sourceStat.ModifiedTime) {
return blob
}
}
} else {
Expand Down Expand Up @@ -595,8 +591,42 @@ func (app *Imagor) debugLog() {
)
}

func setCacheHeaders(w http.ResponseWriter, ttl, swr time.Duration, isDebug bool) {
if isDebug {
func checkStatNotModified(w http.ResponseWriter, r *http.Request, stat *Stat) bool {
if stat == nil || strings.Contains(r.Header.Get("Cache-Control"), "no-cache") {
return false
}
var isETagMatch, isNotModified bool
var etag = stat.ETag
if etag == "" && stat.Size > 0 && !stat.ModifiedTime.IsZero() {
etag = fmt.Sprintf(
"%x-%x", int(stat.ModifiedTime.Unix()), int(stat.Size))
}
if etag != "" {
w.Header().Set("ETag", etag)
if inm := r.Header.Get("If-None-Match"); inm == etag {
isETagMatch = true
}
}
if mTime := stat.ModifiedTime; !mTime.IsZero() {
w.Header().Set("Last-Modified", mTime.Format(http.TimeFormat))
if ims := r.Header.Get("If-Modified-Since"); ims != "" {
if imsTime, err := time.Parse(http.TimeFormat, ims); err == nil {
isNotModified = mTime.Before(imsTime)
}
}
if !isNotModified {
if ius := r.Header.Get("If-Unmodified-Since"); ius != "" {
if iusTime, err := time.Parse(http.TimeFormat, ius); err == nil {
isNotModified = mTime.After(iusTime)
}
}
}
}
return isETagMatch || isNotModified
}

func setCacheHeaders(w http.ResponseWriter, r *http.Request, ttl, swr time.Duration) {
if strings.Contains(r.Header.Get("Cache-Control"), "no-cache") {
ttl = 0
}
expires := time.Now().Add(ttl)
Expand Down
112 changes: 92 additions & 20 deletions imagor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -328,21 +328,6 @@ func TestWithCacheHeaderTTL(t *testing.T) {
assert.Equal(t, 200, w.Code)
assert.Equal(t, "public, s-maxage=169, max-age=169, no-transform", w.Header().Get("Cache-Control"))
})
t.Run("custom ttl debug no cache", func(t *testing.T) {
app := New(
WithDebug(true),
WithLogger(zap.NewExample()),
WithCacheHeaderSWR(time.Second*169),
WithCacheHeaderTTL(time.Second*169),
WithLoaders(loader),
WithUnsafe(true))
w := httptest.NewRecorder()
app.ServeHTTP(w, httptest.NewRequest(
http.MethodGet, "https://example.com/unsafe/foo.jpg", nil))
assert.Equal(t, 200, w.Code)
assert.NotEmpty(t, w.Header().Get("Expires"))
assert.Equal(t, "private, no-cache, no-store, must-revalidate", w.Header().Get("Cache-Control"))
})
t.Run("no cache", func(t *testing.T) {
app := New(
WithLoaders(loader),
Expand Down Expand Up @@ -423,7 +408,7 @@ func TestParams(t *testing.T) {
var clock time.Time

type mapStore struct {
l sync.Mutex
l sync.RWMutex
Map map[string]*Blob
ModTime map[string]time.Time
LoadCnt map[string]int
Expand All @@ -439,12 +424,13 @@ func newMapStore() *mapStore {
}

func (s *mapStore) Get(r *http.Request, image string) (*Blob, error) {
s.l.Lock()
defer s.l.Unlock()
s.l.RLock()
defer s.l.RUnlock()
buf, ok := s.Map[image]
if !ok {
return nil, ErrNotFound
}
buf.Stat, _ = s.Stat(r.Context(), image)
s.LoadCnt[image] = s.LoadCnt[image] + 1
return buf, nil
}
Expand All @@ -468,14 +454,19 @@ func (s *mapStore) Delete(ctx context.Context, image string) error {
}

func (s *mapStore) Stat(ctx context.Context, image string) (*Stat, error) {
s.l.Lock()
defer s.l.Unlock()
s.l.RLock()
defer s.l.RUnlock()
t, ok := s.ModTime[image]
if !ok {
return nil, ErrNotFound
}
b, ok := s.Map[image]
if !ok {
return nil, ErrNotFound
}
return &Stat{
ModifiedTime: t,
Size: b.Size(),
}, nil
}

Expand Down Expand Up @@ -681,6 +672,87 @@ func TestWithLoadersStoragesProcessors(t *testing.T) {
}
}

func TestWithResultStorageNotModified(t *testing.T) {
resultStore := newMapStore()
app := New(
WithDebug(true),
WithLogger(zap.NewExample()),
WithResultStorages(resultStore),
WithLoaders(loaderFunc(func(r *http.Request, image string) (*Blob, error) {
return NewBlobFromBytes([]byte(image)), nil
})),
WithUnsafe(true),
)
w := httptest.NewRecorder()
r := httptest.NewRequest(
http.MethodGet, "https://example.com/unsafe/foo", nil)
app.ServeHTTP(w, r)
time.Sleep(time.Millisecond * 10) // make sure storage reached
assert.Equal(t, 200, w.Code)
assert.Equal(t, "foo", w.Body.String())
assert.Empty(t, w.Header().Get("ETag"))

w = httptest.NewRecorder()
r = httptest.NewRequest(
http.MethodGet, "https://example.com/unsafe/foo", nil)
app.ServeHTTP(w, r)
assert.Equal(t, 200, w.Code)
assert.Equal(t, "foo", w.Body.String())
etag := w.Header().Get("ETag")
lastModified := w.Header().Get("Last-Modified")
assert.NotEmpty(t, etag)
assert.NotEmpty(t, lastModified)

w = httptest.NewRecorder()
r = httptest.NewRequest(
http.MethodGet, "https://example.com/unsafe/foo", nil)
r.Header.Set("If-None-Match", etag)
app.ServeHTTP(w, r)
assert.Equal(t, 304, w.Code)
assert.Empty(t, w.Body.String())

w = httptest.NewRecorder()
r = httptest.NewRequest(
http.MethodGet, "https://example.com/unsafe/foo", nil)
r.Header.Set("If-None-Match", etag)
r.Header.Set("Cache-Control", "no-cache")
app.ServeHTTP(w, r)
assert.Equal(t, 200, w.Code)
assert.Equal(t, "foo", w.Body.String())

w = httptest.NewRecorder()
r = httptest.NewRequest(
http.MethodGet, "https://example.com/unsafe/foo", nil)
r.Header.Set("If-None-Match", "abcd")
app.ServeHTTP(w, r)
assert.Equal(t, 200, w.Code)
assert.Equal(t, "foo", w.Body.String())

w = httptest.NewRecorder()
r = httptest.NewRequest(
http.MethodGet, "https://example.com/unsafe/foo", nil)
r.Header.Set("If-Modified-Since", clock.Add(time.Hour).Format(http.TimeFormat))
app.ServeHTTP(w, r)
assert.Equal(t, 304, w.Code)
assert.Empty(t, w.Body.String())

w = httptest.NewRecorder()
r = httptest.NewRequest(
http.MethodGet, "https://example.com/unsafe/foo", nil)
r.Header.Set("If-Modified-Since", time.Time{}.Format(http.TimeFormat))
app.ServeHTTP(w, r)
assert.Equal(t, 200, w.Code)
assert.Equal(t, "foo", w.Body.String())

w = httptest.NewRecorder()
r = httptest.NewRequest(
http.MethodGet, "https://example.com/unsafe/foo", nil)
r.Header.Set("If-Unmodified-Since", time.Time{}.Format(http.TimeFormat))
app.ServeHTTP(w, r)
assert.Equal(t, 304, w.Code)
assert.Empty(t, w.Body.String())
}

type storageKeyFunc func(img string) string

func (fn storageKeyFunc) Hash(img string) string {
Expand Down
Loading