From d36932c55ee97e41567eb9c42d49a884388095d0 Mon Sep 17 00:00:00 2001 From: DBL-Lee Date: Fri, 31 May 2019 14:53:06 -0700 Subject: [PATCH 1/5] support automatic persisted query --- handler/graphql.go | 94 ++++++++++++++++++++++++++++++++++++++--- handler/graphql_test.go | 23 ++++++++++ 2 files changed, 111 insertions(+), 6 deletions(-) diff --git a/handler/graphql.go b/handler/graphql.go index a22542225fc..78deb6fb485 100644 --- a/handler/graphql.go +++ b/handler/graphql.go @@ -2,6 +2,8 @@ package handler import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -28,8 +30,23 @@ type params struct { Query string `json:"query"` OperationName string `json:"operationName"` Variables map[string]interface{} `json:"variables"` + Extensions *extensions `json:"extensions"` } +type extensions struct { + PQ *persistedQuery `json:"persistedQuery"` +} + +type persistedQuery struct { + Sha256 string `json:"sha256Hash"` + Version int64 `json:"version"` +} + +const ( + errPersistedQueryNotSupported = "PersistedQueryNotSupported" + errPersistedQueryNotFound = "PersistedQueryNotFound" +) + type Config struct { cacheSize int upgrader websocket.Upgrader @@ -44,6 +61,7 @@ type Config struct { connectionKeepAlivePingInterval time.Duration uploadMaxMemory int64 uploadMaxSize int64 + apqCacheSize int } func (c *Config) newRequestContext(es graphql.ExecutableSchema, doc *ast.QueryDocument, op *ast.OperationDefinition, query string, variables map[string]interface{}) *graphql.RequestContext { @@ -285,6 +303,14 @@ func WebsocketKeepAliveDuration(duration time.Duration) Option { } } +// APQCacheSize sets the maximum size of the automatic persisted query cache. +// If size is less than or equal to 0, the cache is disabled. +func APQCacheSize(size int) Option { + return func(cfg *Config) { + cfg.apqCacheSize = size + } +} + const DefaultCacheSize = 1000 const DefaultConnectionKeepAlivePingInterval = 25 * time.Second @@ -327,10 +353,22 @@ func GraphQL(exec graphql.ExecutableSchema, options ...Option) http.HandlerFunc cfg.tracer = &graphql.NopTracer{} } + var apqCache *lru.Cache + if cfg.apqCacheSize > 0 { + var err error + apqCache, err = lru.New(cfg.apqCacheSize) + if err != nil { + // An error is only returned for non-positive cache size + // and we already checked for that. + panic("unexpected error creating apq cache: " + err.Error()) + } + } + handler := &graphqlHandler{ - cfg: cfg, - cache: cache, - exec: exec, + cfg: cfg, + cache: cache, + exec: exec, + apqCache: apqCache, } return handler.ServeHTTP @@ -339,9 +377,15 @@ func GraphQL(exec graphql.ExecutableSchema, options ...Option) http.HandlerFunc var _ http.Handler = (*graphqlHandler)(nil) type graphqlHandler struct { - cfg *Config - cache *lru.Cache - exec graphql.ExecutableSchema + cfg *Config + cache *lru.Cache + exec graphql.ExecutableSchema + apqCache *lru.Cache +} + +func computeQueryHash(query string) string { + b := sha256.Sum256([]byte(query)) + return hex.EncodeToString(b[:]) } func (gh *graphqlHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -409,6 +453,39 @@ func (gh *graphqlHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ctx := r.Context() + var queryHash string + apq := reqParams.Extensions != nil && reqParams.Extensions.PQ != nil + if apq { + // client has enabled apq + queryHash = reqParams.Extensions.PQ.Sha256 + if gh.apqCache == nil { + // server has disabled apq + sendErrorf(w, http.StatusOK, errPersistedQueryNotSupported) + return + } + if reqParams.Extensions.PQ.Version != 1 { + sendErrorf(w, http.StatusOK, "Unsupported persisted query version") + return + } + if reqParams.Query == "" { + // client sent optimistic query hash without query string + query, ok := gh.apqCache.Get(queryHash) + if !ok { + sendErrorf(w, http.StatusOK, errPersistedQueryNotFound) + return + } + reqParams.Query = query.(string) + } else { + if computeQueryHash(reqParams.Query) != queryHash { + sendErrorf(w, http.StatusOK, "provided sha does not match query") + return + } + } + } else if reqParams.Query == "" { + sendErrorf(w, http.StatusUnprocessableEntity, "Must provide query string") + return + } + var doc *ast.QueryDocument var cacheHit bool if gh.cache != nil { @@ -463,6 +540,11 @@ func (gh *graphqlHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + if apq && gh.apqCache != nil { + // Add to persisted query cache + gh.apqCache.Add(queryHash, reqParams.Query) + } + switch op.Operation { case ast.Query: b, err := json.Marshal(gh.exec.Query(ctx, op)) diff --git a/handler/graphql_test.go b/handler/graphql_test.go index c97c5290ea9..a2627c77197 100644 --- a/handler/graphql_test.go +++ b/handler/graphql_test.go @@ -764,3 +764,26 @@ func TestBytesRead(t *testing.T) { require.Equal(t, "0193456789", string(got)) }) } + +func TestAutomaticPersistedQuery(t *testing.T) { + h := GraphQL(&executableSchemaStub{}, APQCacheSize(1000)) + t.Run("automatic persisted query", func(t *testing.T) { + // normal queries should be unaffected + resp := doRequest(h, "POST", "/graphql", `{"query":"{ me { name } }"}`) + assert.Equal(t, http.StatusOK, resp.Code) + assert.Equal(t, `{"data":{"name":"test"}}`, resp.Body.String()) + + // first pass: optimistic hash without query string + resp = doRequest(h, "POST", "/graphql", `{"extensions":{"persistedQuery":{"sha256Hash":"b8d9506e34c83b0e53c2aa463624fcea354713bc38f95276e6f0bd893ffb5b88","version":1}}}`) + assert.Equal(t, http.StatusOK, resp.Code) + assert.Equal(t, `{"errors":[{"message":"PersistedQueryNotFound"}],"data":null}`, resp.Body.String()) + // second pass: query with query string and query hash + resp = doRequest(h, "POST", "/graphql", `{"query":"{ me { name } }", "extensions":{"persistedQuery":{"sha256Hash":"b8d9506e34c83b0e53c2aa463624fcea354713bc38f95276e6f0bd893ffb5b88","version":1}}}`) + assert.Equal(t, http.StatusOK, resp.Code) + assert.Equal(t, `{"data":{"name":"test"}}`, resp.Body.String()) + // future requests without query string + resp = doRequest(h, "POST", "/graphql", `{"extensions":{"persistedQuery":{"sha256Hash":"b8d9506e34c83b0e53c2aa463624fcea354713bc38f95276e6f0bd893ffb5b88","version":1}}}`) + assert.Equal(t, http.StatusOK, resp.Code) + assert.Equal(t, `{"data":{"name":"test"}}`, resp.Body.String()) + }) +} From 8dc17b470d339f5fcd6e1cf9a3d14dec2df4067e Mon Sep 17 00:00:00 2001 From: DBL-Lee Date: Fri, 31 May 2019 15:30:08 -0700 Subject: [PATCH 2/5] support GET for apq --- handler/graphql.go | 7 +++++++ handler/graphql_test.go | 22 +++++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/handler/graphql.go b/handler/graphql.go index 78deb6fb485..6f965f86000 100644 --- a/handler/graphql.go +++ b/handler/graphql.go @@ -413,6 +413,13 @@ func (gh *graphqlHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } } + + if extensions := r.URL.Query().Get("extensions"); extensions != "" { + if err := jsonDecode(strings.NewReader(extensions), &reqParams.Extensions); err != nil { + sendErrorf(w, http.StatusBadRequest, "extensions could not be decoded") + return + } + } case http.MethodPost: mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) if err != nil { diff --git a/handler/graphql_test.go b/handler/graphql_test.go index a2627c77197..93a257706de 100644 --- a/handler/graphql_test.go +++ b/handler/graphql_test.go @@ -767,7 +767,7 @@ func TestBytesRead(t *testing.T) { func TestAutomaticPersistedQuery(t *testing.T) { h := GraphQL(&executableSchemaStub{}, APQCacheSize(1000)) - t.Run("automatic persisted query", func(t *testing.T) { + t.Run("automatic persisted query POST", func(t *testing.T) { // normal queries should be unaffected resp := doRequest(h, "POST", "/graphql", `{"query":"{ me { name } }"}`) assert.Equal(t, http.StatusOK, resp.Code) @@ -786,4 +786,24 @@ func TestAutomaticPersistedQuery(t *testing.T) { assert.Equal(t, http.StatusOK, resp.Code) assert.Equal(t, `{"data":{"name":"test"}}`, resp.Body.String()) }) + + t.Run("automatic persisted query GET", func(t *testing.T) { + // normal queries should be unaffected + resp := doRequest(h, "GET", "/graphql?query={me{name}}", "") + assert.Equal(t, http.StatusOK, resp.Code) + assert.Equal(t, `{"data":{"name":"test"}}`, resp.Body.String()) + + // first pass: optimistic hash without query string + resp = doRequest(h, "GET", `/graphql?extensions={"persistedQuery":{"version":1,"sha256Hash":"b58723c4fd7ce18043ae53635b304ba6cee765a67009645b04ca01e80ce1c065"}}`, "") + assert.Equal(t, http.StatusOK, resp.Code) + assert.Equal(t, `{"errors":[{"message":"PersistedQueryNotFound"}],"data":null}`, resp.Body.String()) + // second pass: query with query string and query hash + resp = doRequest(h, "GET", `/graphql?query={me{name}}&extensions={"persistedQuery":{"sha256Hash":"b58723c4fd7ce18043ae53635b304ba6cee765a67009645b04ca01e80ce1c065","version":1}}}`, "") + assert.Equal(t, http.StatusOK, resp.Code) + assert.Equal(t, `{"data":{"name":"test"}}`, resp.Body.String()) + // future requests without query string + resp = doRequest(h, "GET", `/graphql?extensions={"persistedQuery":{"version":1,"sha256Hash":"b58723c4fd7ce18043ae53635b304ba6cee765a67009645b04ca01e80ce1c065"}}`, "") + assert.Equal(t, http.StatusOK, resp.Code) + assert.Equal(t, `{"data":{"name":"test"}}`, resp.Body.String()) + }) } From 48292c1020344b7686efa6667ea16ed7706ece14 Mon Sep 17 00:00:00 2001 From: Igor Ivanov Date: Wed, 12 Jun 2019 11:44:26 +0200 Subject: [PATCH 3/5] Support pluggable APQ cache implementations. --- handler/graphql.go | 47 ++++++++++++++++++----------------------- handler/graphql_test.go | 25 +++++++++++++++++++++- 2 files changed, 44 insertions(+), 28 deletions(-) diff --git a/handler/graphql.go b/handler/graphql.go index 6f965f86000..67420950588 100644 --- a/handler/graphql.go +++ b/handler/graphql.go @@ -34,7 +34,7 @@ type params struct { } type extensions struct { - PQ *persistedQuery `json:"persistedQuery"` + PersistedQuery *persistedQuery `json:"persistedQuery"` } type persistedQuery struct { @@ -47,6 +47,11 @@ const ( errPersistedQueryNotFound = "PersistedQueryNotFound" ) +type PersistedQueryCache interface { + Add(ctx context.Context, hash string, query string) + Get(ctx context.Context, hash string) (string, bool) +} + type Config struct { cacheSize int upgrader websocket.Upgrader @@ -61,7 +66,7 @@ type Config struct { connectionKeepAlivePingInterval time.Duration uploadMaxMemory int64 uploadMaxSize int64 - apqCacheSize int + apqCache PersistedQueryCache } func (c *Config) newRequestContext(es graphql.ExecutableSchema, doc *ast.QueryDocument, op *ast.OperationDefinition, query string, variables map[string]interface{}) *graphql.RequestContext { @@ -303,11 +308,10 @@ func WebsocketKeepAliveDuration(duration time.Duration) Option { } } -// APQCacheSize sets the maximum size of the automatic persisted query cache. -// If size is less than or equal to 0, the cache is disabled. -func APQCacheSize(size int) Option { +// Add cache that will hold queries for automatic persisted queries (APQ) +func EnablePersistedQueryCache(cache PersistedQueryCache) Option { return func(cfg *Config) { - cfg.apqCacheSize = size + cfg.apqCache = cache } } @@ -353,22 +357,10 @@ func GraphQL(exec graphql.ExecutableSchema, options ...Option) http.HandlerFunc cfg.tracer = &graphql.NopTracer{} } - var apqCache *lru.Cache - if cfg.apqCacheSize > 0 { - var err error - apqCache, err = lru.New(cfg.apqCacheSize) - if err != nil { - // An error is only returned for non-positive cache size - // and we already checked for that. - panic("unexpected error creating apq cache: " + err.Error()) - } - } - handler := &graphqlHandler{ cfg: cfg, cache: cache, exec: exec, - apqCache: apqCache, } return handler.ServeHTTP @@ -380,7 +372,6 @@ type graphqlHandler struct { cfg *Config cache *lru.Cache exec graphql.ExecutableSchema - apqCache *lru.Cache } func computeQueryHash(query string) string { @@ -461,32 +452,34 @@ func (gh *graphqlHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ctx := r.Context() var queryHash string - apq := reqParams.Extensions != nil && reqParams.Extensions.PQ != nil + apqRegister := false + apq := reqParams.Extensions != nil && reqParams.Extensions.PersistedQuery != nil if apq { // client has enabled apq - queryHash = reqParams.Extensions.PQ.Sha256 - if gh.apqCache == nil { + queryHash = reqParams.Extensions.PersistedQuery.Sha256 + if gh.cfg.apqCache == nil { // server has disabled apq sendErrorf(w, http.StatusOK, errPersistedQueryNotSupported) return } - if reqParams.Extensions.PQ.Version != 1 { + if reqParams.Extensions.PersistedQuery.Version != 1 { sendErrorf(w, http.StatusOK, "Unsupported persisted query version") return } if reqParams.Query == "" { // client sent optimistic query hash without query string - query, ok := gh.apqCache.Get(queryHash) + query, ok := gh.cfg.apqCache.Get(ctx, queryHash) if !ok { sendErrorf(w, http.StatusOK, errPersistedQueryNotFound) return } - reqParams.Query = query.(string) + reqParams.Query = query } else { if computeQueryHash(reqParams.Query) != queryHash { sendErrorf(w, http.StatusOK, "provided sha does not match query") return } + apqRegister = true } } else if reqParams.Query == "" { sendErrorf(w, http.StatusUnprocessableEntity, "Must provide query string") @@ -547,9 +540,9 @@ func (gh *graphqlHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - if apq && gh.apqCache != nil { + if apqRegister && gh.cfg.apqCache != nil { // Add to persisted query cache - gh.apqCache.Add(queryHash, reqParams.Query) + gh.cfg.apqCache.Add(ctx, queryHash, reqParams.Query) } switch op.Operation { diff --git a/handler/graphql_test.go b/handler/graphql_test.go index 93a257706de..bfcc11082c2 100644 --- a/handler/graphql_test.go +++ b/handler/graphql_test.go @@ -15,6 +15,7 @@ import ( "testing" "github.com/99designs/gqlgen/graphql" + lru "github.com/hashicorp/golang-lru" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/vektah/gqlparser/ast" @@ -765,8 +766,30 @@ func TestBytesRead(t *testing.T) { }) } +type memoryPersistedQueryCache struct { + cache *lru.Cache +} + +func newMemoryPersistedQueryCache(size int) (*memoryPersistedQueryCache, error) { + cache, err := lru.New(size) + return &memoryPersistedQueryCache{cache: cache}, err +} + +func (c *memoryPersistedQueryCache) Add(ctx context.Context, hash string, query string) { + c.cache.Add(hash, query) +} + +func (c *memoryPersistedQueryCache) Get(ctx context.Context, hash string) (string, bool) { + val, ok := c.cache.Get(hash) + if !ok { + return "", ok + } + return val.(string), ok +} func TestAutomaticPersistedQuery(t *testing.T) { - h := GraphQL(&executableSchemaStub{}, APQCacheSize(1000)) + cache, err := newMemoryPersistedQueryCache(1000) + require.NoError(t, err) + h := GraphQL(&executableSchemaStub{}, EnablePersistedQueryCache(cache)) t.Run("automatic persisted query POST", func(t *testing.T) { // normal queries should be unaffected resp := doRequest(h, "POST", "/graphql", `{"query":"{ me { name } }"}`) From 9873d998b54009721c81b3c13b23f975463aaf02 Mon Sep 17 00:00:00 2001 From: Igor Ivanov Date: Wed, 12 Jun 2019 11:44:56 +0200 Subject: [PATCH 4/5] Add APQ documentation with example --- docs/content/reference/apq.md | 77 +++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 docs/content/reference/apq.md diff --git a/docs/content/reference/apq.md b/docs/content/reference/apq.md new file mode 100644 index 00000000000..1c978db4a4b --- /dev/null +++ b/docs/content/reference/apq.md @@ -0,0 +1,77 @@ +--- +title: "Automatic persisted queries" +description: +linkTitle: "APQ" +menu: { main: { parent: 'reference' } } +--- + +When you work with GraphQL by default your queries are transferred with every request. That can waste significant +bandwidth. To avoid that you can use Automatic Persisted Queries (APQ). + +With APQ you send only query hash to the server. If hash is not found on a server then client makes a second request +to register query hash with original query on a server. + +## Usage + +In order to enable Automatic Persisted Queries you need to change your client. For more information see +[Automatic Persisted Queries Link](https://github.com/apollographql/apollo-link-persisted-queries) documentation. + +For the server you need to implement `PersistedQueryCache` interface and pass instance to +`handler.EnablePersistedQueryCache` option. + +See example using [go-redis](github.com/go-redis/redis) package below: +```go +import ( + "context" + "time" + + "github.com/go-redis/redis" + "github.com/pkg/errors" +) + +type Cache struct { + client redis.UniversalClient + ttl time.Duration +} + +const apqPrefix = "apq:" + +func NewCache(redisAddress string, password string, ttl time.Duration) (*Cache, error) { + client := redis.NewClient(&redis.Options{ + Addr: redisAddress, + }) + + err := client.Ping().Err() + if err != nil { + return nil, errors.WithStack(err) + } + + return &Cache{client: client, ttl: ttl}, nil +} + +func (c *Cache) Add(ctx context.Context, hash string, query string) { + c.client.Set(apqPrefix + hash, query, c.ttl) +} + +func (c *Cache) Get(ctx context.Context, hash string) (string, bool) { + s, err := c.client.Get(apqPrefix + hash).Result() + if err != nil { + return "", false + } + return s, true +} + +func main() { + cache, err := NewCache(cfg.RedisAddress, 24*time.Hour) + if err != nil { + log.Fatalf("cannot create APQ redis cache: %v", err) + } + + c := Config{ Resolvers: &resolvers{} } + gqlHandler := handler.GraphQL( + blog.NewExecutableSchema(c), + handler.EnablePersistedQueryCache(cache), + ) + http.Handle("/query", gqlHandler) +} +``` From 8fcc186817974f99060f0f815dd3935876607bf0 Mon Sep 17 00:00:00 2001 From: DBL-Lee Date: Wed, 12 Jun 2019 15:13:27 -0700 Subject: [PATCH 5/5] format --- handler/graphql.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/handler/graphql.go b/handler/graphql.go index 67420950588..0c05fd4db13 100644 --- a/handler/graphql.go +++ b/handler/graphql.go @@ -358,9 +358,9 @@ func GraphQL(exec graphql.ExecutableSchema, options ...Option) http.HandlerFunc } handler := &graphqlHandler{ - cfg: cfg, - cache: cache, - exec: exec, + cfg: cfg, + cache: cache, + exec: exec, } return handler.ServeHTTP @@ -369,9 +369,9 @@ func GraphQL(exec graphql.ExecutableSchema, options ...Option) http.HandlerFunc var _ http.Handler = (*graphqlHandler)(nil) type graphqlHandler struct { - cfg *Config - cache *lru.Cache - exec graphql.ExecutableSchema + cfg *Config + cache *lru.Cache + exec graphql.ExecutableSchema } func computeQueryHash(query string) string {