Skip to content

Commit b5cfac8

Browse files
authored
feat: apq should persist queries only when inside an apq flow (#1124)
1 parent a45fd77 commit b5cfac8

File tree

6 files changed

+88
-17
lines changed

6 files changed

+88
-17
lines changed

docs/api/options.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,14 @@
8080
- `onlyPersisted`: Boolean. Flag to control whether to allow graphql queries other than persisted. When `true`, it'll make the server reject any queries that are not present in the `persistedQueries` option above. It will also disable any ide available (graphiql). Requires `persistedQueries` to be set, and overrides `persistedQueryProvider`.
8181
- `persistedQueryProvider`
8282
- `isPersistedQuery: (request: object) => boolean`: Return true if a given request matches the desired persisted query format.
83+
- `isPersistedQueryRetry: (request: object) => boolean`: Return true if a given request matches the desired persisted query retry format.
8384
- `getHash: (request: object) => string`: Return the hash from a given request, or falsy if this request format is not supported.
8485
- `getQueryFromHash: async (hash: string) => string`: Return the query for a given hash.
8586
- `getHashForQuery?: (query: string) => string`: Return the hash for a given query string. Do not provide if you want to skip saving new queries.
8687
- `saveQuery?: async (hash: string, query: string) => void`: Save a query, given its hash.
8788
- `notFoundError?: string`: An error message to return when `getQueryFromHash` returns no result. Defaults to `Bad Request`.
8889
- `notSupportedError?: string`: An error message to return when a query matches `isPersistedQuery`, but returns no valid hash from `getHash`. Defaults to `Bad Request`.
90+
- `mismatchError?: string`: An error message to return when the hash provided in the request does not match the calculated hash. Defaults to `Bad Request`.
8991
- `allowBatchedQueries`: Boolean. Flag to control whether to allow batched queries. When `true`, the server supports recieving an array of queries and returns an array of results.
9092

9193
- `compilerOptions`: Object. Configurable options for the graphql-jit compiler. For more details check https://github.com/zalando-incubator/graphql-jit

index.d.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -622,6 +622,10 @@ declare namespace mercurius {
622622
* Return true if a given request matches the desired persisted query format.
623623
*/
624624
isPersistedQuery: (r: QueryRequest) => boolean;
625+
/**
626+
* Return true if a given request matches the desire persisted query retry format.
627+
*/
628+
isPersistedQueryRetry: (r: QueryRequest) => boolean;
625629
/**
626630
* Return the hash from a given request, or falsy if this request format is not supported.
627631
*/
@@ -646,6 +650,10 @@ declare namespace mercurius {
646650
* An error message to return when a query matches isPersistedQuery, but fasly from getHash. Defaults to 'Bad Request'.
647651
*/
648652
notSupportedError?: string;
653+
/**
654+
* An error message to return when the hash provided in the request does not match the calculated hash. Defaults to 'Bad Request'.
655+
*/
656+
mismatchError?: string;
649657
}
650658

651659
/**

lib/errors.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,11 @@ const errors = {
147147
'%s',
148148
400
149149
),
150+
MER_ERR_GQL_PERSISTED_QUERY_MISMATCH: createError(
151+
'MER_ERR_GQL_PERSISTED_QUERY_MISMATCH',
152+
'%s',
153+
400
154+
),
150155
/**
151156
* Subscription errors
152157
*/

lib/persistedQueryDefaults.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const persistedQueryDefaults = {
2020
const cache = LRU(maxSize || 1024)
2121
return ({
2222
isPersistedQuery: (request) => !request.query && (request.extensions || {}).persistedQuery,
23+
isPersistedQueryRetry: (request) => request.query && (request.extensions || {}).persistedQuery,
2324
getHash: (request) => {
2425
const { version, sha256Hash } = request.extensions.persistedQuery
2526
return version === 1 ? sha256Hash : false
@@ -28,7 +29,8 @@ const persistedQueryDefaults = {
2829
getHashForQuery: (query) => crypto.createHash('sha256').update(query, 'utf8').digest('hex'),
2930
saveQuery: async (hash, query) => cache.set(hash, query),
3031
notFoundError: 'PersistedQueryNotFound',
31-
notSupportedError: 'PersistedQueryNotSupported'
32+
notSupportedError: 'PersistedQueryNotSupported',
33+
mismatchError: 'provided sha does not match query'
3234
})
3335
}
3436
}

lib/routes.js

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const {
99
defaultErrorFormatter,
1010
MER_ERR_GQL_PERSISTED_QUERY_NOT_FOUND,
1111
MER_ERR_GQL_PERSISTED_QUERY_NOT_SUPPORTED,
12+
MER_ERR_GQL_PERSISTED_QUERY_MISMATCH,
1213
MER_ERR_GQL_VALIDATION,
1314
toGraphQLError
1415
} = require('./errors')
@@ -207,12 +208,14 @@ module.exports = async function (app, opts) {
207208
// Load the persisted query settings
208209
const {
209210
isPersistedQuery,
211+
isPersistedQueryRetry,
210212
getHash,
211213
getQueryFromHash,
212214
getHashForQuery,
213215
saveQuery,
214216
notFoundError,
215-
notSupportedError
217+
notSupportedError,
218+
mismatchError,
216219
} = persistedQueryProvider || {}
217220

218221
const normalizedRouteOptions = { ...additionalRouteOptions }
@@ -249,8 +252,7 @@ module.exports = async function (app, opts) {
249252
const { operationName, variables } = body
250253

251254
// Verify if a query matches the persisted format
252-
const persisted = isPersistedQuery(body)
253-
if (persisted) {
255+
if (isPersistedQuery(body)) {
254256
// This is a peristed query, so we use the hash in the request
255257
// to load the full query string.
256258

@@ -276,16 +278,21 @@ module.exports = async function (app, opts) {
276278
// Execute the query
277279
const result = await executeQuery(query, variables, operationName, request, reply)
278280

279-
// Only save queries which are not yet persisted
280-
if (!persisted && query) {
281-
// If provided the getHashForQuery, saveQuery settings we save this query
282-
const hash = getHashForQuery && getHashForQuery(query)
283-
if (hash) {
284-
try {
285-
await saveQuery(hash, query)
286-
} catch (err) {
287-
request.log.warn({ err, hash, query }, 'Failed to persist query')
288-
}
281+
// Only save queries which are not yet persisted and if this is a persisted query retry
282+
if (isPersistedQueryRetry && isPersistedQueryRetry(body) && query) {
283+
// Extract the hash from the request
284+
const hash = getHash && getHash(body)
285+
286+
const hashForQuery = getHashForQuery && getHashForQuery(query)
287+
if (hash && hashForQuery !== hash) {
288+
// The calculated hash does not match the provided one, tell the client
289+
throw new MER_ERR_GQL_PERSISTED_QUERY_MISMATCH(mismatchError)
290+
}
291+
292+
try {
293+
await saveQuery(hashForQuery, query)
294+
} catch (err) {
295+
request.log.warn({ err, hash, query }, 'Failed to persist query')
289296
}
290297
}
291298

test/persisted.js

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,13 @@ test('automatic POST new query, error on saveQuery is handled', async (t) => {
194194
query: `
195195
query AddQuery ($x: Int!, $y: Int!) {
196196
add(x: $x, y: $y)
197-
}`
197+
}`,
198+
extensions: {
199+
persistedQuery: {
200+
version: 1,
201+
sha256Hash: '14b859faf7e656329f24f7fdc7a33a3402dbd8b43f4f57364e15e096143927a9'
202+
}
203+
}
198204
}
199205
})
200206

@@ -340,7 +346,7 @@ test('automatic POST invalid extension without persistedQueries and error', asyn
340346
t.same(JSON.parse(res.body), { data: null, errors: [{ message: 'PersistedQueryNotSupported' }] })
341347
})
342348

343-
test('automatic POST persisted query after priming', async (t) => {
349+
test('avoid persisting POST query', async (t) => {
344350
const app = Fastify()
345351

346352
const schema = `
@@ -389,7 +395,7 @@ test('automatic POST persisted query after priming', async (t) => {
389395
}
390396
})
391397

392-
t.same(JSON.parse(res.body), { data: { add: 3 } })
398+
t.same(JSON.parse(res.body), { data: null, errors: [{ message: 'PersistedQueryNotFound' }] })
393399
})
394400

395401
test('automatic POST persisted query after priming, with extension set in both payloads', async (t) => {
@@ -450,6 +456,47 @@ test('automatic POST persisted query after priming, with extension set in both p
450456
t.same(JSON.parse(res.body), { data: { add: 3 } })
451457
})
452458

459+
test('avoid persisting query if hashes mismatch', async (t) => {
460+
const app = Fastify()
461+
462+
const schema = `
463+
type Query {
464+
add(x: Int, y: Int): Int
465+
}
466+
`
467+
468+
const resolvers = {
469+
add: async ({ x, y }) => x + y
470+
}
471+
472+
app.register(GQL, {
473+
schema,
474+
resolvers,
475+
persistedQueryProvider: GQL.persistedQueryDefaults.automatic()
476+
})
477+
478+
const res = await app.inject({
479+
method: 'POST',
480+
url: '/graphql',
481+
body: {
482+
operationName: 'AddQuery',
483+
variables: { x: 1, y: 2 },
484+
query: `
485+
query AddQuery ($x: Int!, $y: Int!) {
486+
add(x: $x, y: $y)
487+
}`,
488+
extensions: {
489+
persistedQuery: {
490+
version: 1,
491+
sha256Hash: 'foobar'
492+
}
493+
}
494+
}
495+
})
496+
497+
t.same(JSON.parse(res.body), { data: null, errors: [{ message: 'provided sha does not match query' }] })
498+
})
499+
453500
// persistedQueryProvider
454501

455502
test('GET route with query, variables & persisted', async (t) => {

0 commit comments

Comments
 (0)