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

Automatic Persisted Queries #734

Merged
merged 6 commits into from
Jun 24, 2019
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
77 changes: 77 additions & 0 deletions docs/content/reference/apq.md
Original file line number Diff line number Diff line change
@@ -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)
}
```
82 changes: 82 additions & 0 deletions handler/graphql.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package handler

import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
Expand All @@ -28,6 +30,26 @@ type params struct {
Query string `json:"query"`
OperationName string `json:"operationName"`
Variables map[string]interface{} `json:"variables"`
Extensions *extensions `json:"extensions"`
}

type extensions struct {
PersistedQuery *persistedQuery `json:"persistedQuery"`
}

type persistedQuery struct {
Sha256 string `json:"sha256Hash"`
Version int64 `json:"version"`
}

const (
errPersistedQueryNotSupported = "PersistedQueryNotSupported"
errPersistedQueryNotFound = "PersistedQueryNotFound"
)

type PersistedQueryCache interface {
Add(ctx context.Context, hash string, query string)
Get(ctx context.Context, hash string) (string, bool)
}

type Config struct {
Expand All @@ -44,6 +66,7 @@ type Config struct {
connectionKeepAlivePingInterval time.Duration
uploadMaxMemory int64
uploadMaxSize int64
apqCache PersistedQueryCache
}

func (c *Config) newRequestContext(es graphql.ExecutableSchema, doc *ast.QueryDocument, op *ast.OperationDefinition, query string, variables map[string]interface{}) *graphql.RequestContext {
Expand Down Expand Up @@ -285,6 +308,13 @@ func WebsocketKeepAliveDuration(duration time.Duration) Option {
}
}

// Add cache that will hold queries for automatic persisted queries (APQ)
func EnablePersistedQueryCache(cache PersistedQueryCache) Option {
return func(cfg *Config) {
cfg.apqCache = cache
}
}

const DefaultCacheSize = 1000
const DefaultConnectionKeepAlivePingInterval = 25 * time.Second

Expand Down Expand Up @@ -344,6 +374,11 @@ type graphqlHandler struct {
exec graphql.ExecutableSchema
}

func computeQueryHash(query string) string {
b := sha256.Sum256([]byte(query))
return hex.EncodeToString(b[:])
}

func (gh *graphqlHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodOptions {
w.Header().Set("Allow", "OPTIONS, GET, POST")
Expand All @@ -369,6 +404,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 {
Expand Down Expand Up @@ -409,6 +451,41 @@ func (gh *graphqlHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {

ctx := r.Context()

var queryHash string
apqRegister := false
apq := reqParams.Extensions != nil && reqParams.Extensions.PersistedQuery != nil
if apq {
// client has enabled apq
queryHash = reqParams.Extensions.PersistedQuery.Sha256
if gh.cfg.apqCache == nil {
// server has disabled apq
sendErrorf(w, http.StatusOK, errPersistedQueryNotSupported)
return
}
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.cfg.apqCache.Get(ctx, queryHash)
if !ok {
sendErrorf(w, http.StatusOK, errPersistedQueryNotFound)
return
}
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")
return
}

var doc *ast.QueryDocument
var cacheHit bool
if gh.cache != nil {
Expand Down Expand Up @@ -463,6 +540,11 @@ func (gh *graphqlHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}

if apqRegister && gh.cfg.apqCache != nil {
// Add to persisted query cache
gh.cfg.apqCache.Add(ctx, queryHash, reqParams.Query)
}

switch op.Operation {
case ast.Query:
b, err := json.Marshal(gh.exec.Query(ctx, op))
Expand Down
66 changes: 66 additions & 0 deletions handler/graphql_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -764,3 +765,68 @@ func TestBytesRead(t *testing.T) {
require.Equal(t, "0193456789", string(got))
})
}

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) {
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 } }"}`)
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())
})

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())
})
}