Skip to content

Commit

Permalink
Persisted Queries Plugin
Browse files Browse the repository at this point in the history
  • Loading branch information
ardatan committed Jun 6, 2022
1 parent c08cac3 commit ab65da9
Show file tree
Hide file tree
Showing 6 changed files with 340 additions and 2 deletions.
5 changes: 5 additions & 0 deletions .changeset/long-ears-brake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@graphql-yoga/plugin-persisted-queries': major
---

New Persisted Queries Plugin
2 changes: 1 addition & 1 deletion packages/common/src/plugins/requestParser/GET.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { GraphQLParams } from '../../types'
import { parseURLSearchParams } from './utils'

export function isGETRequest(request: Request) {
return request.method === 'GET'
return request.method === 'GET' && request.url.includes('?')
}

export function parseGETRequest(request: Request): GraphQLParams {
Expand Down
49 changes: 49 additions & 0 deletions packages/plugins/persisted-queries/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
{
"name": "@graphql-yoga/plugin-persisted-queries",
"version": "0.0.0",
"description": "",
"repository": {
"type": "git",
"url": "https://github.com/dotansimha/graphql-yoga.git",
"directory": "packages/plugins/persisted-queries"
},
"main": "dist/index.js",
"module": "dist/index.mjs",
"scripts": {
"prepack": "bob prepack",
"check": "tsc --pretty --noEmit"
},
"author": "Arda TANRIKULU <[email protected]>",
"license": "MIT",
"buildOptions": {
"input": "./src/index.ts"
},
"exports": {
".": {
"require": "./dist/index.js",
"import": "./dist/index.mjs"
},
"./*": {
"require": "./dist/*.js",
"import": "./dist/*.mjs"
}
},
"typings": "dist/index.d.ts",
"typescript": {
"definition": "dist/index.d.ts"
},
"publishConfig": {
"directory": "dist",
"access": "public"
},
"dependencies": {
"tiny-lru": "^8.0.2",
"tslib": "^2.3.1"
},
"peerDependencies": {
"@graphql-yoga/common": "^2.3.0"
},
"devDependencies": {
"bob-the-bundler": "^1.5.1"
}
}
93 changes: 93 additions & 0 deletions packages/plugins/persisted-queries/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { GraphQLYogaError, Plugin, PromiseOrValue } from '@graphql-yoga/common'
import lru from 'tiny-lru'

export interface PersistedQueriesStoreOptions {
max?: number
ttl?: number
}

export function createInMemoryPersistedQueriesStore(
options: PersistedQueriesStoreOptions = {},
) {
return lru<string>(options.max ?? 1000, options.ttl ?? 36000)
}

export interface PersistedQueriesOptions {
store?: PersistedQueriesStore
mode?: PersistedQueriesMode
hash?: (str: string) => PromiseOrValue<string>
}

export enum PersistedQueriesMode {
AUTOMATIC = 'AUTOMATIC',
MANUAL = 'MANUAL',
PERSISTED_ONLY = 'PERSISTED_ONLY',
}

export interface PersistedQueriesStore {
get(key: string): PromiseOrValue<string | null | undefined>
set?(key: string, query: string): PromiseOrValue<any>
}

export interface PersistedQueryExtension {
version: 1
sha256Hash: string
}

export function usePersistedQueries<TPluginContext>(
options: PersistedQueriesOptions = {},
): Plugin<TPluginContext> {
const {
mode = PersistedQueriesMode.AUTOMATIC,
store = createInMemoryPersistedQueriesStore(),
hash,
} = options
if (mode === PersistedQueriesMode.AUTOMATIC && store.set == null) {
throw new Error(
`Automatic Persisted Queries require "set" method to be implemented`,
)
}
return {
onRequestParse() {
return {
onRequestParseDone: async function persistedQueriesOnRequestParseDone({
params,
setParams,
}) {
const persistedQueryData: PersistedQueryExtension =
params.extensions?.persistedQuery
if (
mode === PersistedQueriesMode.PERSISTED_ONLY &&
persistedQueryData == null
) {
throw new GraphQLYogaError('PersistedQueryOnly')
}
if (persistedQueryData?.version === 1) {
if (params.query == null) {
const persistedQuery = await store.get(
persistedQueryData.sha256Hash,
)
if (persistedQuery == null) {
throw new GraphQLYogaError('PersistedQueryNotFound')
}
setParams({
...params,
query: persistedQuery,
})
} else {
if (hash != null) {
const expectedHash = await hash(params.query)
if (persistedQueryData.sha256Hash !== expectedHash) {
throw new GraphQLYogaError('PersistedQueryMismatch')
}
}
if (mode === PersistedQueriesMode.AUTOMATIC) {
await store.set!(persistedQueryData.sha256Hash, params.query)
}
}
}
},
}
},
}
}
191 changes: 191 additions & 0 deletions packages/plugins/persisted-queries/tests/persisted-queries.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { YogaServerInstance } from '@graphql-yoga/common'
import { createServer } from 'graphql-yoga'
import request from 'supertest'
import {
createInMemoryPersistedQueriesStore,
PersistedQueriesMode,
PersistedQueriesStore,
usePersistedQueries,
} from '../src'

describe('Persisted Queries', () => {
let yoga: ReturnType<typeof createServer>
let store: ReturnType<typeof createInMemoryPersistedQueriesStore>
beforeAll(async () => {
store = createInMemoryPersistedQueriesStore()
yoga = createServer({
plugins: [
usePersistedQueries({
store,
}),
],
})
})
afterAll(() => {
store.clear()
})
it('should return not found error if persisted query is missing', async () => {
const response = await request(yoga)
.post('/graphql')
.send({
extensions: {
persistedQuery: {
version: 1,
sha256Hash:
'ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38',
},
},
})

const body = JSON.parse(response.text)
expect(body.errors).toBeDefined()
expect(body.errors[0].message).toBe('PersistedQueryNotFound')
})
it('should load the persisted query when stored', async () => {
const persistedQueryEntry = {
version: 1,
sha256Hash:
'ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38',
}
store.set(persistedQueryEntry.sha256Hash, '{__typename}')
const response = await request(yoga)
.post('/graphql')
.send({
extensions: {
persistedQuery: persistedQueryEntry,
},
})

const body = JSON.parse(response.text)
expect(body.errors).toBeUndefined()
expect(body.data.__typename).toBe('Query')
})
describe('Automatic', () => {
beforeAll(async () => {
store = createInMemoryPersistedQueriesStore()
yoga = createServer({
plugins: [
usePersistedQueries({
store,
mode: PersistedQueriesMode.AUTOMATIC,
}),
],
})
})
it('should save the persisted query', async () => {
const persistedQueryEntry = {
version: 1,
sha256Hash:
'ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38',
}
const query = `{__typename}`
const response = await request(yoga)
.post('/graphql')
.send({
query,
extensions: {
persistedQuery: persistedQueryEntry,
},
})

const entry = store.get(persistedQueryEntry.sha256Hash)
expect(entry).toBe(query)

const body = JSON.parse(response.text)
expect(body.errors).toBeUndefined()
expect(body.data.__typename).toBe('Query')
})
})
describe('Persisted Only', () => {
beforeAll(async () => {
store = createInMemoryPersistedQueriesStore()
yoga = createServer({
plugins: [
usePersistedQueries({
store,
mode: PersistedQueriesMode.PERSISTED_ONLY,
}),
],
})
})
it('should not allow regular queries', async () => {
const query = `{__typename}`
const response = await request(yoga).post('/graphql').send({
query,
})

const body = JSON.parse(response.text)
expect(body.errors).toBeDefined()
expect(body.errors[0].message).toBe('PersistedQueryOnly')
})
it('should not save the persisted query', async () => {
const persistedQueryEntry = {
version: 1,
sha256Hash:
'ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38',
}
const query = `{__typename}`
const response = await request(yoga)
.post('/graphql')
.send({
query,
extensions: {
persistedQuery: persistedQueryEntry,
},
})

const entry = store.get(persistedQueryEntry.sha256Hash)
expect(entry).toBeFalsy()

const body = JSON.parse(response.text)
expect(body.errors).toBeUndefined()
expect(body.data.__typename).toBe('Query')
})
})
describe('Manual', () => {
beforeAll(async () => {
store = createInMemoryPersistedQueriesStore()
yoga = createServer({
plugins: [
usePersistedQueries({
store,
mode: PersistedQueriesMode.MANUAL,
}),
],
})
})
it('should allow regular queries', async () => {
const query = `{__typename}`
const response = await request(yoga).post('/graphql').send({
query,
})

const body = JSON.parse(response.text)
expect(body.errors).toBeUndefined()
expect(body.data.__typename).toBe('Query')
})
it('should not save the persisted query', async () => {
const persistedQueryEntry = {
version: 1,
sha256Hash:
'ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38',
}
const query = `{__typename}`
const response = await request(yoga)
.post('/graphql')
.send({
query,
extensions: {
persistedQuery: persistedQueryEntry,
},
})

const entry = store.get(persistedQueryEntry.sha256Hash)
expect(entry).toBeFalsy()

const body = JSON.parse(response.text)
expect(body.errors).toBeUndefined()
expect(body.data.__typename).toBe('Query')
})
})
})
2 changes: 1 addition & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -21135,7 +21135,7 @@ [email protected], tiny-lru@^7.0.0:
resolved "https://registry.yarnpkg.com/tiny-lru/-/tiny-lru-7.0.6.tgz#b0c3cdede1e5882aa2d1ae21cb2ceccf2a331f24"
integrity sha512-zNYO0Kvgn5rXzWpL0y3RS09sMK67eGaQj9805jlK9G6pSadfriTczzLHFXa/xcW4mIRfmlB9HyQ/+SgL0V1uow==

[email protected]:
[email protected], tiny-lru@^8.0.2:
version "8.0.2"
resolved "https://registry.yarnpkg.com/tiny-lru/-/tiny-lru-8.0.2.tgz#812fccbe6e622ded552e3ff8a4c3b5ff34a85e4c"
integrity sha512-ApGvZ6vVvTNdsmt676grvCkUCGwzG9IqXma5Z07xJgiC5L7akUMof5U8G2JTI9Rz/ovtVhJBlY6mNhEvtjzOIg==
Expand Down

0 comments on commit ab65da9

Please sign in to comment.