feat: etag cache fingerprinting (#4)

* feat: etag cache fingerprinting

* fix: logrus sirupsen package dependency
This commit is contained in:
Frederik Löffert 2019-10-02 17:28:43 +02:00 committed by josenner
parent 279978eb89
commit d36eb1837e
19 changed files with 341 additions and 104 deletions

View File

@ -5,6 +5,7 @@ import (
"github.com/foomo/neosproxy/cache/content/store"
"github.com/foomo/neosproxy/client/cms"
"golang.org/x/sync/singleflight"
)
// New will return a newly created content cache
@ -15,7 +16,8 @@ func New(cacheLifetime time.Duration, store store.CacheStore, loader cms.Content
store: store,
// invalidationChannel: make(chan InvalidationRequest),
lifetime: cacheLifetime,
invalidationRequestGroup: &singleflight.Group{},
lifetime: cacheLifetime,
}
return c
}

View File

@ -26,3 +26,11 @@ func (c *Cache) Len() int {
}
return counter
}
func (c *Cache) GetAllEtags(workspace string) (etags map[string]string) {
return c.store.GetAllEtags(workspace)
}
func (c *Cache) GetEtag(hash string) (etag string, e error) {
return c.store.GetEtag(hash)
}

View File

@ -1,6 +1,7 @@
package content
import (
"strings"
"time"
"github.com/foomo/neosproxy/cache/content/store"
@ -11,8 +12,7 @@ func (c *Cache) RemoveAll() (err error) {
return c.store.RemoveAll()
}
// Invalidate cache item
func (c *Cache) Invalidate(id, dimension, workspace string) (item store.CacheItem, err error) {
func (c *Cache) invalidator(id, dimension, workspace string) (item store.CacheItem, err error) {
// timer
start := time.Now()
@ -40,7 +40,23 @@ func (c *Cache) Invalidate(id, dimension, workspace string) (item store.CacheIte
Duration: time.Since(start),
})
// done
return
}
// Invalidate cache item
func (c *Cache) Invalidate(id, dimension, workspace string) (item store.CacheItem, err error) {
groupName := strings.Join([]string{"invalidate", id, dimension, workspace}, "-")
itemInterfaced, errThrottled, _ := c.invalidationRequestGroup.Do(groupName, func() (i interface{}, e error) {
return c.invalidator(id, dimension, workspace)
})
if errThrottled != nil {
err = errThrottled
return
}
item = itemInterfaced.(store.CacheItem)
return
}

View File

@ -7,6 +7,9 @@ type CacheStore interface {
Get(hash string) (item CacheItem, e error)
GetAll() (item []CacheItem, e error)
GetEtag(hash string) (etag string, e error)
GetAllEtags(workspace string) (etags map[string]string)
Count() (int, error)
Remove(hash string) (e error)

View File

@ -6,6 +6,7 @@ import (
"os"
"strings"
"sync"
"time"
"github.com/foomo/neosproxy/cache/content"
"github.com/foomo/neosproxy/cache/content/store"
@ -23,6 +24,9 @@ type fsCacheStore struct {
lock sync.Mutex
rw map[string]*sync.RWMutex
l logging.Entry
lockEtags sync.RWMutex
etags map[string]string
}
//------------------------------------------------------------------
@ -38,14 +42,21 @@ func NewCacheStore(cacheDir string) store.CacheStore {
l.WithError(err).Fatal("failed creating cache directory")
}
s := &fsCacheStore{
f := &fsCacheStore{
CacheDir: cacheDir,
lock: sync.Mutex{},
rw: make(map[string]*sync.RWMutex),
l: l,
l: l,
lock: sync.Mutex{},
rw: make(map[string]*sync.RWMutex),
lockEtags: sync.RWMutex{},
etags: make(map[string]string),
}
return s
go f.initEtagCache()
return f
}
//------------------------------------------------------------------
@ -56,6 +67,11 @@ func (f *fsCacheStore) Upsert(item store.CacheItem) (e error) {
// key
key := f.getItemKey(item)
// validate etag
if item.Etag == "" {
item.Etag = item.GetEtag()
}
// serialize
bytes, errMarshall := json.Marshal(item)
if errMarshall != nil {
@ -64,10 +80,54 @@ func (f *fsCacheStore) Upsert(item store.CacheItem) (e error) {
// lock
cacheFile := f.Lock(key)
defer f.Unlock(key)
// write to file
return ioutil.WriteFile(cacheFile, bytes, 0644)
errWrite := ioutil.WriteFile(cacheFile, bytes, 0644)
if errWrite != nil {
f.Unlock(key)
e = errWrite
return
}
f.Unlock(key)
// update etag
f.upsertEtag(item.Hash, item.Etag)
return nil
}
func (f *fsCacheStore) GetAllEtags(workspace string) (etags map[string]string) {
f.lockEtags.RLock()
etags = make(map[string]string)
for hash, etag := range f.etags {
if !strings.HasPrefix(hash, workspace) {
continue
}
etags[hash] = etag
}
f.lockEtags.RUnlock()
return
}
func (f *fsCacheStore) GetEtag(hash string) (etag string, e error) {
f.lockEtags.RLock()
if value, ok := f.etags[hash]; ok {
etag = value
f.lockEtags.RUnlock()
return
}
f.lockEtags.RUnlock()
item, errGet := f.Get(hash)
if errGet != nil {
e = errGet
return
}
etag = item.GetEtag()
f.upsertEtag(hash, etag)
return
}
func (f *fsCacheStore) Get(hash string) (item store.CacheItem, e error) {
@ -146,7 +206,17 @@ func (f *fsCacheStore) Remove(hash string) (e error) {
cacheFile := f.Lock(key)
defer f.Unlock(key)
return os.Remove(cacheFile)
errRemove := os.Remove(cacheFile)
if errRemove != nil {
e = errRemove
return
}
f.lockEtags.Lock()
delete(f.etags, hash)
f.lockEtags.Unlock()
return nil
}
func (f *fsCacheStore) createCacheDir() error {
@ -163,6 +233,10 @@ func (f *fsCacheStore) RemoveAll() (e error) {
return errRemoveAll
}
f.lockEtags.Lock()
f.etags = make(map[string]string)
f.lockEtags.Unlock()
errCreateCache := f.createCacheDir()
if errCreateCache != nil {
f.l.WithError(errCreateCache).Error("unable to re-create cache directory")
@ -176,6 +250,44 @@ func (f *fsCacheStore) RemoveAll() (e error) {
// ~ PRIVATE METHODS
//------------------------------------------------------------------
func (f *fsCacheStore) initEtagCache() {
start := time.Now()
l := f.l.WithField(logging.FieldFunction, "initEtagCache")
files, errReadDir := ioutil.ReadDir(f.CacheDir)
if errReadDir != nil {
l.WithError(errReadDir).Error("failed reading cache dir")
return
}
l.Debug("init etag cache")
counter := 0
for _, file := range files {
if !file.IsDir() {
filename := file.Name()
index := strings.Index(filename, ".")
if index >= 0 {
filename = filename[0:index]
}
item, errGet := f.Get(filename)
if errGet != nil {
l.WithError(errGet).Warn("could not load cache item")
continue
}
counter++
f.upsertEtag(item.Hash, item.GetEtag())
}
}
l.WithField("len", counter).WithDuration(start).Debug("etag cache initialized")
return
}
func (f *fsCacheStore) upsertEtag(hash, etag string) {
f.lockEtags.Lock()
f.etags[hash] = etag
f.lockEtags.Unlock()
}
func (f *fsCacheStore) getItemKey(item store.CacheItem) string {
return f.getKey(item.Hash)
}

View File

@ -1,6 +1,8 @@
package store
import (
"crypto/sha256"
"encoding/hex"
"strings"
"time"
)
@ -19,6 +21,7 @@ type CacheItem struct {
validUntil time.Time
HTML string
Etag string // hashed fingerprint of html content
}
// NewCacheItem will create a new cache item
@ -31,10 +34,25 @@ func NewCacheItem(id string, dimension string, workspace string, html string, va
created: time.Now(),
validUntil: validUntil,
HTML: html,
Etag: generateFingerprint(html),
}
}
// GetEtag returns an etag
func (item *CacheItem) GetEtag() string {
if item.Etag != "" {
return item.Etag
}
return generateFingerprint(item.HTML)
}
// GetHash will return a cache item hash
func GetHash(id, dimension, workspace string) string {
return strings.Join([]string{workspace, dimension, id}, "_")
}
func generateFingerprint(data string) string {
sha := sha256.New()
sha.Write([]byte(data))
return hex.EncodeToString(sha.Sum(nil))
}

10
cache/content/vo.go vendored
View File

@ -5,14 +5,16 @@ import (
"github.com/foomo/neosproxy/cache/content/store"
"github.com/foomo/neosproxy/client/cms"
"golang.org/x/sync/singleflight"
)
// Cache workspace items
type Cache struct {
observer Observer
loader cms.ContentLoader
store store.CacheStore
invalidationChannel chan InvalidationRequest
observer Observer
loader cms.ContentLoader
store store.CacheStore
invalidationChannel chan InvalidationRequest
invalidationRequestGroup *singleflight.Group
lifetime time.Duration // time until an item must be re-invalidated (< 0 === never)
}

View File

@ -5,9 +5,9 @@ import (
"os"
"time"
"github.com/Sirupsen/logrus"
"github.com/cloudfoundry/bytefmt"
"github.com/foomo/neosproxy/logging"
"github.com/sirupsen/logrus"
)
// Invalidate cache maybe invalidates cache, but skips requests if invalidation queue is already full

View File

@ -5,7 +5,7 @@ import (
"path/filepath"
"time"
"github.com/Sirupsen/logrus"
"github.com/sirupsen/logrus"
"github.com/foomo/neosproxy/cache/content/store/fs"
"github.com/foomo/neosproxy/client/cms"
"github.com/foomo/neosproxy/config"

54
glide.lock generated
View File

@ -1,54 +0,0 @@
hash: c8774276cbc27688b992f05c04cf2e330655e27c5a22bc9b3cad5dae8fd66b60
updated: 2018-12-24T02:41:18.010901+01:00
imports:
- name: github.com/auth0/go-jwt-middleware
version: 5493cabe49f7bfa6e2ec444a09d334d90cd4e2bd
- name: github.com/cloudfoundry/bytefmt
version: 2aa6f33b730c79971cfc3c742f279195b0abc627
- name: github.com/dgrijalva/jwt-go
version: 06ea1031745cb8b3dab3f6a236daf2b0aa468b7e
- name: github.com/foomo/shop
version: e55de455dd26d50c2033008174da3bff0125e218
subpackages:
- persistence
- name: github.com/gorilla/context
version: 51ce91d2eaddeca0ef29a71d766bb3634dadf729
- name: github.com/gorilla/mux
version: e3702bed27f0d39777b0b37b664b6280e8ef8fbf
- name: github.com/konsorten/go-windows-terminal-sequences
version: 5c8c8bd35d3832f5d134ae1e1e375b69a4d25242
- name: github.com/pkg/errors
version: 059132a15dd08d6704c67711dae0cf35ab991756
- name: github.com/Sirupsen/logrus
version: bcd833dfe83d3cebad139e4a29ed79cb2318bf95
- name: github.com/stretchr/testify
version: f35b8ab0b5a2cef36673838d662e249dd9c94686
subpackages:
- assert
- name: golang.org/x/crypto
version: 81e90905daefcd6fd217b62423c0908922eadb30
subpackages:
- ssh/terminal
- name: golang.org/x/sys
version: b05ddf57801d2239d6ab0ee35f9d981e0420f4ac
subpackages:
- unix
- windows
- name: gopkg.in/mgo.v2
version: 9856a29383ce1c59f308dd1cf0363a79b5bef6b5
subpackages:
- bson
- internal/json
- internal/sasl
- internal/scram
- name: gopkg.in/yaml.v2
version: 51d6538a90f86fe93ac480b35f37b2be17fef232
testImports:
- name: github.com/davecgh/go-spew
version: d8f796af33cc11cb798c1aaeb27a4ebc5099927d
subpackages:
- spew
- name: github.com/pmezard/go-difflib
version: 792786c7400a136282c1664665ae0a8db921c6c2
subpackages:
- difflib

View File

@ -1,22 +0,0 @@
package: github.com/foomo/neosproxy
import:
- package: github.com/Sirupsen/logrus
version: ~1.2.0
- package: gopkg.in/yaml.v2
version: ~2.2.2
- package: golang.org/x/crypto
subpackages:
- ssh/terminal
- package: golang.org/x/sys
subpackages:
- unix
- package: github.com/stretchr/testify
version: ~1.2.2
subpackages:
- assert
- package: github.com/gorilla/mux
version: ~1.6.2
- package: github.com/auth0/go-jwt-middleware
- package: github.com/dgrijalva/jwt-go
version: ~3.2.0
- package: github.com/cloudfoundry/bytefmt

19
go.mod Normal file
View File

@ -0,0 +1,19 @@
module github.com/foomo/neosproxy
go 1.12
require (
github.com/auth0/go-jwt-middleware v0.0.0-20170425171159-5493cabe49f7
github.com/cloudfoundry/bytefmt v0.0.0-20180906201452-2aa6f33b730c
github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/foomo/shop v0.0.0-20190306093145-644b0b683ba1
github.com/gorilla/mux v1.6.2
github.com/pkg/errors v0.0.0-20181023235946-059132a15dd0
github.com/sirupsen/logrus v1.2.0
github.com/stretchr/testify v1.2.2
golang.org/x/crypto v0.0.0-20190103213133-ff983b9c42bc
golang.org/x/sync v0.0.0-20170517211232-f52d1811a629
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce
gopkg.in/yaml.v2 v2.2.2
)

30
go.sum Normal file
View File

@ -0,0 +1,30 @@
github.com/auth0/go-jwt-middleware v0.0.0-20170425171159-5493cabe49f7 h1:irR1cO6eek3n5uquIVaRAsQmZnlsfPuHNz31cXo4eyk=
github.com/auth0/go-jwt-middleware v0.0.0-20170425171159-5493cabe49f7/go.mod h1:LWMyo4iOLWXHGdBki7NIht1kHru/0wM179h+d3g8ATM=
github.com/cloudfoundry/bytefmt v0.0.0-20180906201452-2aa6f33b730c h1:zE9z4EZZwJTjOi9Q9WYM/81BuwOKyjhHagiNUDhDdnI=
github.com/cloudfoundry/bytefmt v0.0.0-20180906201452-2aa6f33b730c/go.mod h1:4oo6ExqTPaBVBwSm814h6UO5Fels1kN2KvpNscaCcS0=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/foomo/shop v0.0.0-20190306093145-644b0b683ba1/go.mod h1:+Y2nUdyvktarq9+F2B0tvk/BM7y+jwNJI1CW/KgM7as=
github.com/gorilla/mux v1.6.2 h1:Pgr17XVTNXAk3q/r4CpKzC5xBM/qW1uVLV+IhRZpIIk=
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/pkg/errors v0.0.0-20181023235946-059132a15dd0 h1:R+lX9nKwNd1n7UE5SQAyoorREvRn3aLF6ZndXBoIWqY=
github.com/pkg/errors v0.0.0-20181023235946-059132a15dd0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sirupsen/logrus v1.2.0 h1:juTguoYk5qI21pwyTXY3B3Y5cOTH3ZUyZCg1v/mihuo=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190103213133-ff983b9c42bc h1:F5tKCVGp+MUAHhKp5MZtGqAlGX3+oCsiL1Q629FL90M=
golang.org/x/crypto v0.0.0-20190103213133-ff983b9c42bc/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/sync v0.0.0-20170517211232-f52d1811a629 h1:wqoYUzeICxRnvJCvfHTh0OY0VQ6xern7nYq+ccc19e4=
golang.org/x/sync v0.0.0-20170517211232-f52d1811a629/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20171031081856-95c657629925/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33 h1:I6FyU15t786LL7oL/hn43zqTuEGr4PN7F4XJ1p4E3Y8=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

View File

@ -5,7 +5,7 @@ import (
"strings"
"time"
"github.com/Sirupsen/logrus"
"github.com/sirupsen/logrus"
"github.com/pkg/errors"
)

View File

@ -3,7 +3,7 @@ package logging
import (
"errors"
"github.com/Sirupsen/logrus"
"github.com/sirupsen/logrus"
)
func GetDefaultLogEntry() Entry {

View File

@ -9,7 +9,7 @@ import (
"sync"
"time"
"github.com/Sirupsen/logrus"
"github.com/sirupsen/logrus"
)
var Logger *logrus.Logger

View File

@ -3,7 +3,7 @@ package notifier
import (
"sync"
"github.com/Sirupsen/logrus"
"github.com/sirupsen/logrus"
"github.com/foomo/neosproxy/cache"
"github.com/foomo/neosproxy/logging"

View File

@ -7,7 +7,9 @@ import (
"strings"
"time"
"github.com/Sirupsen/logrus"
"github.com/foomo/neosproxy/cache/content/store"
"github.com/sirupsen/logrus"
"github.com/cloudfoundry/bytefmt"
"github.com/foomo/neosproxy/cache"
"github.com/foomo/neosproxy/client/cms"
@ -50,6 +52,17 @@ func (p *Proxy) getContent(w http.ResponseWriter, r *http.Request) {
logging.FieldID: id,
})
// etag cache handling
headerEtag := r.Header.Get("ETag")
if headerEtag != "" {
etag, errEtag := p.contentCache.GetEtag(store.GetHash(id, dimension, workspace))
if errEtag == nil && etag != "" && etag == headerEtag {
w.WriteHeader(http.StatusNotModified)
log.WithDuration(start).Debug("content not modified")
return
}
}
// try cache hit, invalidate in case of item not found
item, errCacheGet := p.contentCache.Get(id, dimension, workspace)
if errCacheGet != nil {
@ -78,6 +91,8 @@ func (p *Proxy) getContent(w http.ResponseWriter, r *http.Request) {
HTML: item.HTML,
}
w.Header().Set("ETag", item.GetEtag())
// stream json response
encoder := json.NewEncoder(w)
errEncode := encoder.Encode(data)
@ -88,7 +103,7 @@ func (p *Proxy) getContent(w http.ResponseWriter, r *http.Request) {
}
// done
log.WithDuration(start).Debug("served content")
log.WithDuration(start).Debug("content served")
return
}
@ -241,12 +256,87 @@ func (p *Proxy) streamStatus(w http.ResponseWriter, r *http.Request) {
// error handling
if errEncode != nil {
log.WithError(errEncode).WithField("content-negotiation", contentNegotioation).Error("failed streaming status")
w.WriteHeader(http.StatusInternalServerError)
http.Error(w, "failed streaming status", http.StatusInternalServerError)
return
}
}
func (p *Proxy) getAllEtags(w http.ResponseWriter, r *http.Request) {
// extract request data
workspace := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("workspace")))
// validate workspace
if workspace == "" {
workspace = cms.WorkspaceLive
}
// logger
log := p.setupLogger(r, "getAllEtags").WithField(logging.FieldWorkspace, workspace)
etags := p.contentCache.GetAllEtags(workspace)
w.Header().Set("Content-Type", string(mimeApplicationJSON))
encoder := json.NewEncoder(w)
errEncode := encoder.Encode(etags)
// error handling
if errEncode != nil {
log.WithError(errEncode).Error("failed encoding etags")
http.Error(w, "failed encoding etags", http.StatusInternalServerError)
return
}
return
}
func (p *Proxy) getEtag(w http.ResponseWriter, r *http.Request, hash string) {
// logger
log := p.setupLogger(r, "getEtag").WithField(logging.FieldID, hash)
etag, errEtag := p.contentCache.GetEtag(hash)
// error handling
if errEtag != nil {
log.WithError(errEtag).Error("failed getting etag")
http.Error(w, "failed getting etag", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", string(mimeTextPlain))
w.Header().Set("ETag", etag)
w.Write([]byte(etag))
return
}
func (p *Proxy) getEtagByID(w http.ResponseWriter, r *http.Request) {
// extract request data
workspace := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("workspace")))
// validate workspace
if workspace == "" {
workspace = cms.WorkspaceLive
}
// extract request data
id := getRequestParameter(r, "id")
dimension := getRequestParameter(r, "dimension")
hash := store.GetHash(id, dimension, workspace)
p.getEtag(w, r, hash)
}
func (p *Proxy) getEtagByHash(w http.ResponseWriter, r *http.Request) {
// extract request data
hash := getRequestParameter(r, "hash")
p.getEtag(w, r, hash)
}
// ------------------------------------------------------------------------------------------------
// ~ Private methods
// ------------------------------------------------------------------------------------------------

View File

@ -16,12 +16,25 @@ const routeContentServerExport = "/contentserver/export"
func (p *Proxy) setupRoutes() {
// hijack content server export routes
// content tree / sitemap
p.router.HandleFunc(routeContentServerExport, p.streamCachedNeosContentServerExport)
p.router.HandleFunc(routeContentServerExport, p.streamCachedNeosContentServerExport).Queries("workspace", "{workspace}")
// /contentserver/export/de/571fd1ae-c8e4-4d91-a708-d97025fb015c?workspace=stage
p.router.HandleFunc(routeContentServerExport+"/{dimension}/{id}", p.getContent)
p.router.HandleFunc(routeContentServerExport+"/{dimension}/{id}", p.getContent).Queries("workspace", "{workspace}")
// etag
p.router.HandleFunc(routeContentServerExport+"/etag/{dimension}/{id}", p.getEtagByID).Methods(http.MethodGet)
p.router.HandleFunc(routeContentServerExport+"/etag/{dimension}/{id}", p.getEtagByID).Methods(http.MethodGet).Queries("workspace", "{workspace}")
p.router.HandleFunc(routeContentServerExport+"/etag/{hash}", p.getEtagByHash).Methods(http.MethodGet)
p.router.HandleFunc(routeContentServerExport+"/etags", p.getAllEtags).Methods(http.MethodGet)
p.router.HandleFunc(routeContentServerExport+"/etags", p.getAllEtags).Methods(http.MethodGet).Queries("workspace", "{workspace}")
// documents => /contentserver/export/de/571fd1ae-c8e4-4d91-a708-d97025fb015c?workspace=stage
p.router.HandleFunc(routeContentServerExport+"/{dimension}/{id}", p.getContent).Methods(http.MethodGet)
p.router.HandleFunc(routeContentServerExport+"/{dimension}/{id}", p.getContent).Methods(http.MethodGet).Queries("workspace", "{workspace}")
p.router.HandleFunc(routeContentServerExport+"/{dimension}/{id}", p.getEtagByID).Methods(http.MethodHead)
p.router.HandleFunc(routeContentServerExport+"/{dimension}/{id}", p.getEtagByID).Methods(http.MethodHead).Queries("workspace", "{workspace}")
// api
// neosproxy/cache/%s?workspace=%s