Skip to content

Commit

Permalink
Persisted Queries Plugin
Browse files Browse the repository at this point in the history
  • Loading branch information
ardatan committed Apr 26, 2022
1 parent 25cdcb4 commit 25d464f
Show file tree
Hide file tree
Showing 7 changed files with 394 additions and 15 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/processRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export async function processRequest<TContext, TRootValue = {}>({
ReadableStream,
}: RequestProcessContext<TContext, TRootValue>): Promise<Response> {
function getErrorResponse({
status = 500,
status = 500, // default status code
headers,
errors,
isEventStream,
Expand Down
8 changes: 8 additions & 0 deletions packages/common/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
FetchEvent,
FetchAPI,
GraphQLParams,
GraphQLYogaError,
} from './types'
import {
OnRequestParseDoneHook,
Expand Down Expand Up @@ -600,6 +601,13 @@ export class YogaServer<
})
return response
} catch (error: any) {
if (error instanceof GraphQLYogaError) {
return new this.fetchAPI.Response(
JSON.stringify({
errors: [error],
}),
)
}
this.logger.error(error.stack || error.message || error)
const response = new this.fetchAPI.Response(error.message, {
status: 500,
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,
setGraphQLParams,
}) {
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')
}
setGraphQLParams({
...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')
})
})
})
Loading

0 comments on commit 25d464f

Please sign in to comment.