Skip to content

Commit

Permalink
feat(persisted-operations): ability to support different specificatio…
Browse files Browse the repository at this point in the history
…ns (#1610)

* feat(persisted-operations): ability to support different specifications

* test

* Go

* Rename to extractPersistedOperationId
  • Loading branch information
ardatan committed Sep 6, 2022
1 parent 098e139 commit f4b2338
Show file tree
Hide file tree
Showing 7 changed files with 167 additions and 49 deletions.
5 changes: 5 additions & 0 deletions .changeset/curvy-oranges-heal.md
@@ -0,0 +1,5 @@
---
'graphql-yoga': minor
---

Pass the parsed request as-is and validate the final GraphQLParams in useCheckGraphQLParams
17 changes: 17 additions & 0 deletions packages/graphql-yoga/__tests__/requests.spec.ts
Expand Up @@ -258,4 +258,21 @@ describe('requests', () => {
expect(body.errors).toBeUndefined()
expect(body.data.ping).toBe('pong')
})

it('errors if there is an invalid parameter in the request body', async () => {
const response = await yoga.fetch(`http://yoga/test-graphql`, {
method: 'POST',
headers: {
'content-type': 'application/graphql+json',
},
body: JSON.stringify({ query: '{ ping }', test: 'a' }),
})

expect(response.status).toBe(400)
const body = JSON.parse(await response.text())
expect(body.data).toBeUndefined()
expect(body.errors?.[0].message).toBe(
'Unexpected parameter "test" in the request body.',
)
})
})
Expand Up @@ -2,6 +2,27 @@ import { createGraphQLError } from '@graphql-tools/utils'
import { GraphQLParams } from '../../types'
import { Plugin } from '../types'

const EXPECTED_PARAMS = ['query', 'variables', 'operationName', 'extensions']

export function assertInvalidParams(
params: any,
): asserts params is GraphQLParams {
for (const paramKey in params) {
if (!EXPECTED_PARAMS.includes(paramKey)) {
throw createGraphQLError(
`Unexpected parameter "${paramKey}" in the request body.`,
{
extensions: {
http: {
status: 400,
},
},
},
)
}
}
}

export function checkGraphQLQueryParams(params: unknown): GraphQLParams {
if (!isObject(params)) {
throw createGraphQLError(
Expand All @@ -19,6 +40,8 @@ export function checkGraphQLQueryParams(params: unknown): GraphQLParams {
)
}

assertInvalidParams(params)

if (params.query == null) {
throw createGraphQLError('Must provide query string.', {
extensions: {
Expand Down
File renamed without changes.
@@ -1,4 +1,4 @@
import { createYoga, createSchema } from 'graphql-yoga'
import { createYoga, createSchema, GraphQLParams } from 'graphql-yoga'
import request from 'supertest'
import { usePersistedOperations } from '@graphql-yoga/plugin-persisted-operations'

Expand All @@ -10,13 +10,14 @@ const schema = createSchema({
`,
})

describe('Automatic Persisted Queries', () => {
describe('Persisted Operations', () => {
it('should return not found error if persisted query is missing', async () => {
const store = new Map<string, string>()
const yoga = createYoga({
plugins: [
usePersistedOperations({
store,
getPersistedOperation() {
return null
},
}),
],
schema,
Expand All @@ -42,7 +43,9 @@ describe('Automatic Persisted Queries', () => {
const yoga = createYoga({
plugins: [
usePersistedOperations({
store,
getPersistedOperation(key: string) {
return store.get(key) || null
},
}),
],
schema,
Expand Down Expand Up @@ -71,7 +74,9 @@ describe('Automatic Persisted Queries', () => {
const yoga = createYoga({
plugins: [
usePersistedOperations({
store,
getPersistedOperation(key: string) {
return store.get(key) || null
},
}),
],
schema,
Expand All @@ -97,7 +102,9 @@ describe('Automatic Persisted Queries', () => {
const yoga = createYoga({
plugins: [
usePersistedOperations({
store,
getPersistedOperation(key: string) {
return store.get(key) || null
},
allowArbitraryOperations: true,
}),
],
Expand All @@ -124,7 +131,9 @@ describe('Automatic Persisted Queries', () => {
const yoga = createYoga({
plugins: [
usePersistedOperations({
store,
getPersistedOperation(key: string) {
return store.get(key) || null
},
allowArbitraryOperations: (request) =>
request.headers.get('foo') === 'bar',
}),
Expand All @@ -149,4 +158,31 @@ describe('Automatic Persisted Queries', () => {
expect(body.errors).toBeUndefined()
expect(body.data).toEqual({ __typename: 'Query' })
})
it('should respect the custom getPersistedQueryKey implementation (Relay)', async () => {
const store = new Map<string, string>()
const yoga = createYoga({
plugins: [
usePersistedOperations({
getPersistedOperation(key: string) {
return store.get(key) || null
},
extractPersistedOperationId(
params: GraphQLParams & { doc_id?: string },
) {
return params.doc_id ?? null
},
}),
],
schema,
})
const persistedOperationKey = 'my-persisted-operation'
store.set(persistedOperationKey, '{__typename}')
const response = await request(yoga).post('/graphql').send({
doc_id: persistedOperationKey,
})

const body = JSON.parse(response.text)
expect(body.errors).toBeUndefined()
expect(body.data.__typename).toBe('Query')
})
})
61 changes: 31 additions & 30 deletions packages/plugins/persisted-operations/src/index.ts
@@ -1,25 +1,22 @@
import { Plugin, PromiseOrValue } from 'graphql-yoga'
import { GraphQLParams, Plugin, PromiseOrValue } from 'graphql-yoga'
import { GraphQLError } from 'graphql'

export interface PersistedOperationsStore {
get(key: string): PromiseOrValue<string | null | undefined>
}

export interface PersistedOperationExtension {
version: 1
sha256Hash: string
}
export type ExtractPersistedOperationId = (
params: GraphQLParams,
) => null | string

function decodePersistedOperationsExtension(
input: Record<string, any> | null | undefined,
): null | PersistedOperationExtension {
export const defaultExtractPersistedOperationId: ExtractPersistedOperationId = (
params: GraphQLParams,
): null | string => {
if (
input != null &&
typeof input === 'object' &&
input?.version === 1 &&
typeof input?.sha256Hash === 'string'
params.extensions != null &&
typeof params.extensions === 'object' &&
params.extensions?.persistedQuery != null &&
typeof params.extensions?.persistedQuery === 'object' &&
params.extensions?.persistedQuery.version === 1 &&
typeof params.extensions?.persistedQuery.sha256Hash === 'string'
) {
return input as PersistedOperationExtension
return params.extensions?.persistedQuery.sha256Hash
}
return null
}
Expand All @@ -30,21 +27,26 @@ type AllowArbitraryOperationsHandler = (

export interface UsePersistedOperationsOptions {
/**
* Store for reading persisted operations.
* A function that fetches the persisted operation
*/
store: PersistedOperationsStore
getPersistedOperation(key: string): PromiseOrValue<string | null>
/**
* Whether to allow execution of arbitrary GraphQL operations aside from persisted operations.
*/
allowArbitraryOperations?: boolean | AllowArbitraryOperationsHandler
/**
* The path to the persisted operation id
*/
extractPersistedOperationId?: ExtractPersistedOperationId
}

export function usePersistedOperations<TPluginContext>(
args: UsePersistedOperationsOptions,
): Plugin<TPluginContext> {
const allowArbitraryOperations = args.allowArbitraryOperations ?? false
export function usePersistedOperations<TPluginContext>({
getPersistedOperation,
allowArbitraryOperations = false,
extractPersistedOperationId = defaultExtractPersistedOperationId,
}: UsePersistedOperationsOptions): Plugin<TPluginContext> {
return {
async onParams({ params, request, setParams }) {
async onParams({ request, params, setParams }) {
if (params.query) {
if (
(typeof allowArbitraryOperations === 'boolean'
Expand All @@ -56,21 +58,20 @@ export function usePersistedOperations<TPluginContext>(
return
}

const persistedQueryData = decodePersistedOperationsExtension(
params.extensions?.persistedQuery,
)
const persistedOperationKey = extractPersistedOperationId(params)

if (persistedQueryData == null) {
if (persistedOperationKey == null) {
throw new GraphQLError('PersistedQueryNotFound')
}

const persistedQuery = await args.store.get(persistedQueryData.sha256Hash)
const persistedQuery = await getPersistedOperation(persistedOperationKey)
if (persistedQuery == null) {
throw new GraphQLError('PersistedQueryNotFound')
}
setParams({
...params,
query: persistedQuery,
variables: params.variables,
extensions: params.extensions,
})
},
}
Expand Down
58 changes: 47 additions & 11 deletions website/v3/docs/features/persisted-operations.mdx
Expand Up @@ -5,7 +5,8 @@ sidebar_label: Persisted Operations
---

Persisted operations is a mechanism for preventing the execution of arbitary GraphQL operation documents.
The persisted operations plugin follows the [the APQ Specification of Apollo](https://github.com/apollographql/apollo-link-persisted-queries#apollo-engine) for **SENDING** hashes to the server.
By default, the persisted operations plugin follows the [the APQ Specification of Apollo](https://github.com/apollographql/apollo-link-persisted-queries#apollo-engine) for **SENDING** hashes to the server.
However, you can change this behavior by overriding the `getPersistedOperationKey` option to support Relay's specification for example.

## Installation

Expand All @@ -18,15 +19,19 @@ import { createYoga } from 'graphql-yoga'
import { createServer } from 'node:http'
import { usePersistedOperations } from '@graphql-yoga/plugin-persisted-operations'

const store = new Map<string, string>()

store.set(
'ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38',
'{__typename}',
)
const store = {
ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38:
'{__typename}',
}

const yoga = createYoga({
plugins: [usePersistedOperations()],
plugins: [
usePersistedOperations({
getPersistedOperation(sha256Hash: string) {
return store[sha256Hash]
},
}),
],
})

const server = createServer(yoga)
Expand Down Expand Up @@ -65,10 +70,14 @@ import { createServer } from 'node:http'
import { usePersistedOperations } from '@graphql-yoga/plugin-persisted-operations'
import persistedOperations from './persistedOperations.json'

const store = new Map<string, string>(Object.entries(persistedOperations))

const yoga = createYoga({
plugins: [usePersistedOperations()],
plugins: [
usePersistedOperations({
getPersistedOperation(key: string) {
return persistedOperations[key]
},
}),
],
})

const server = createServer(yoga)
Expand Down Expand Up @@ -101,3 +110,30 @@ usePersistedOperations({
```

Use this option with caution!

## Using Relay's Persisted Queries Specification

If you are using [Relay's Persisted Queries specification](https://relay.dev/docs/guides/persisted-queries/#example-implemetation-of-relaylocalpersistingjs), you can configure the plugin like below;

```ts
import { createYoga } from 'graphql-yoga'
import { createServer } from 'node:http'
import { usePersistedOperations } from '@graphql-yoga/plugin-persisted-operations'
import persistedOperations from './persistedOperations.json'

const yoga = createYoga({
plugins: [
usePersistedOperations({
getPersistedOperationKey(params: GraphQLParams & { doc_id: string }) {
return params.doc_id
}
getPersistedOperation(key: string) {
return persistedOperations[key]
},
}),
],
})

const server = createServer(yoga)
server.listen(4000)
```

0 comments on commit f4b2338

Please sign in to comment.