diff --git a/.changeset/tricky-forks-attack.md b/.changeset/tricky-forks-attack.md new file mode 100644 index 0000000000..993c4bdc1a --- /dev/null +++ b/.changeset/tricky-forks-attack.md @@ -0,0 +1,5 @@ +--- +'@graphql-yoga/common': minor +--- + +Persisted Queries diff --git a/benchmark/hello-world/start-server.js b/benchmark/hello-world/start-server.js index 143e5a2319..3a629c5643 100644 --- a/benchmark/hello-world/start-server.js +++ b/benchmark/hello-world/start-server.js @@ -3,7 +3,6 @@ const { createServer } = require('@graphql-yoga/node') const server = createServer({ logging: false, hostname: '127.0.0.1', - healthCheckPath: false, }) server.start() diff --git a/examples/error-handling/package.json b/examples/error-handling/package.json index 1bb6ef64a0..3e6a23b72d 100644 --- a/examples/error-handling/package.json +++ b/examples/error-handling/package.json @@ -8,7 +8,7 @@ }, "dependencies": { "@graphql-yoga/node": "2.3.0", - "cross-undici-fetch": "^0.2.5", + "cross-undici-fetch": "^0.3.0", "graphql": "^16.1.0", "ts-node": "10.4.0", "typescript": "^4.4.4" diff --git a/package.json b/package.json index b0f64ce05c..90ec3ddaad 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,9 @@ "contributors": [ "Johannes Schickling ", "Saihajpreet Singh (https://github.com/saihaj)", - "Dotan Simha " + "Dotan Simha ", + "Arda Tanrikulu ", + "Laurin Quast " ], "license": "MIT", "bugs": { diff --git a/packages/common/package.json b/packages/common/package.json index da920b7668..9bb03a9094 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -54,6 +54,7 @@ "@graphql-yoga/subscription": "2.0.0", "cross-undici-fetch": "^0.3.0", "dset": "^3.1.1", + "tiny-lru": "^8.0.1", "tslib": "^2.3.1" }, "devDependencies": { diff --git a/packages/common/src/getGraphQLParameters.ts b/packages/common/src/getGraphQLParameters.ts index d6f53df7e9..d7b9c5ef04 100644 --- a/packages/common/src/getGraphQLParameters.ts +++ b/packages/common/src/getGraphQLParameters.ts @@ -1,15 +1,9 @@ import { dset } from 'dset' - -type GraphQLRequestPayload = { - operationName?: string - query?: string - variables?: Record - extensions?: Record -} +import { GraphQLParams } from './types' type RequestParser = { is: (request: Request) => boolean - parse: (request: Request) => Promise + parse: (request: Request) => Promise } export const GETRequestParser: RequestParser = { @@ -72,7 +66,7 @@ export const POSTMultipartFormDataRequestParser: RequestParser = { export function buildGetGraphQLParameters(parsers: Array) { return async function getGraphQLParameters( request: Request, - ): Promise { + ): Promise { for (const parser of parsers) { if (parser.is(request)) { return parser.parse(request) diff --git a/packages/common/src/processRequest.ts b/packages/common/src/processRequest.ts index ad93f3dbd4..c0871927dc 100644 --- a/packages/common/src/processRequest.ts +++ b/packages/common/src/processRequest.ts @@ -5,10 +5,12 @@ import { ExecutionArgs, ExecutionResult, GraphQLError, + print, } from 'graphql' import { isAsyncIterable } from '@graphql-tools/utils' import { ExecutionPatchResult, RequestProcessContext } from './types' import { encodeString } from './encodeString' +import { crypto } from 'cross-undici-fetch' interface ErrorResponseParams { status?: number @@ -34,7 +36,7 @@ function getExecutableOperation( return operation } -export async function processRequest({ +export async function processRequest({ contextFactory, execute, operationName, @@ -46,9 +48,11 @@ export async function processRequest({ validate, variables, extraHeaders, + extensions, + persistedQueryStore, Response, ReadableStream, -}: RequestProcessContext): Promise { +}: RequestProcessContext): Promise { function getErrorResponse({ status = 500, headers, @@ -207,21 +211,71 @@ export async function processRequest({ }) } - if (query == null) { + if (extensions?.persistedQuery != null && persistedQueryStore == null) { return getErrorResponse({ - status: 400, - errors: [new GraphQLError('Must provide query string.')], + status: 500, + errors: [new GraphQLError('PersistedQueryNotSupported')], isEventStream, headers: extraHeaders, }) } - try { - if (typeof query !== 'string' && query.kind === 'Document') { - document = query + if (query == null) { + if ( + extensions?.persistedQuery?.version === 1 && + extensions?.persistedQuery?.sha256Hash != null && + persistedQueryStore != null + ) { + const persistedQuery = await persistedQueryStore.get( + extensions?.persistedQuery?.sha256Hash, + ) + if (persistedQuery == null) { + return getErrorResponse({ + status: 404, + errors: [new GraphQLError('PersistedQueryNotFound')], + isEventStream, + headers: extraHeaders, + }) + } else { + query = persistedQuery + } } else { - document = parse(query) + return getErrorResponse({ + status: 400, + errors: [new GraphQLError('Must provide query string.')], + isEventStream, + headers: extraHeaders, + }) } + } else if ( + extensions?.persistedQuery?.version === 1 && + extensions?.persistedQuery?.sha256Hash != null && + persistedQueryStore != null + ) { + if (crypto) { + const encodedQuery = encodeString(query) + const hashArrayBuffer = await crypto.subtle.digest( + 'SHA-256', + encodedQuery, + ) + const hashTypedArray = new Uint8Array(hashArrayBuffer) + const expectedHashString = [...hashTypedArray] + .map((b) => b.toString(16).padStart(2, '0')) + .join('') + if (extensions.persistedQuery.sha256Hash !== expectedHashString) { + return getErrorResponse({ + status: 400, + errors: [new GraphQLError('PersistedQueryInvalidHash')], + isEventStream, + headers: extraHeaders, + }) + } + } + await persistedQueryStore.set(extensions.persistedQuery.sha256Hash, query) + } + + try { + document = parse(query) } catch (e: unknown) { return getErrorResponse({ status: 400, @@ -259,29 +313,13 @@ export async function processRequest({ }) } - let variableValues: { [name: string]: any } | undefined - - try { - if (variables) { - variableValues = - typeof variables === 'string' ? JSON.parse(variables) : variables - } - } catch (_error) { - return getErrorResponse({ - errors: [new GraphQLError('Variables are invalid JSON.')], - status: 400, - isEventStream, - headers: extraHeaders, - }) - } - contextValue = await contextFactory() const executionArgs: ExecutionArgs = { schema, document, contextValue, - variableValues, + variableValues: variables, operationName, } diff --git a/packages/common/src/server.ts b/packages/common/src/server.ts index 6898683c4d..a022fedac8 100644 --- a/packages/common/src/server.ts +++ b/packages/common/src/server.ts @@ -1,4 +1,4 @@ -import { GraphQLSchema, isSchema, print } from 'graphql' +import { GraphQLError, GraphQLSchema, isSchema, print } from 'graphql' import { Plugin, GetEnvelopedFn, @@ -25,6 +25,7 @@ import { YogaInitialContext, FetchEvent, FetchAPI, + PersistedQueriesCache, } from './types' import { GraphiQLOptions, @@ -35,6 +36,7 @@ import * as crossUndiciFetch from 'cross-undici-fetch' import { getGraphQLParameters } from './getGraphQLParameters' import { processRequest } from './processRequest' import { defaultYogaLogger, YogaLogger } from './logger' +import lru from 'tiny-lru' interface OptionsWithPlugins { /** @@ -128,6 +130,13 @@ export type YogaServerOptions< parserCache?: boolean | ParserCacheOptions validationCache?: boolean | ValidationCache fetchAPI?: FetchAPI + persistedQueries?: + | boolean + | PersistedQueriesCache + | { + max?: number + ttl?: number + } } & Partial< OptionsWithPlugins > @@ -202,6 +211,7 @@ export class YogaServer< fetch: typeof fetch ReadableStream: typeof ReadableStream } + protected persistedQueryStore?: PersistedQueriesCache renderGraphiQL: (options?: GraphiQLOptions) => PromiseOrValue @@ -237,8 +247,6 @@ export class YogaServer< } : logger - const maskedErrors = options?.maskedErrors ?? true - this.getEnveloped = envelop({ plugins: [ // Use the schema provided by the user @@ -297,9 +305,11 @@ export class YogaServer< ), ...(options?.plugins ?? []), enableIf( - !!maskedErrors, + options?.maskedErrors !== false, useMaskedErrors( - typeof maskedErrors === 'object' ? maskedErrors : undefined, + typeof options?.maskedErrors === 'object' + ? options?.maskedErrors + : undefined, ), ), ], @@ -329,6 +339,24 @@ export class YogaServer< this.renderGraphiQL = options?.renderGraphiQL || renderGraphiQL this.endpoint = options?.endpoint + + if (options?.persistedQueries !== false) { + if (typeof options?.persistedQueries === 'object') { + if ('get' in options.persistedQueries) { + this.persistedQueryStore = options.persistedQueries + } else if ( + 'max' in options.persistedQueries || + 'ttl' in options.persistedQueries + ) { + this.persistedQueryStore = lru( + options.persistedQueries.max ?? 1000, + options.persistedQueries.ttl ?? 36000, + ) + } + } else { + this.persistedQueryStore = lru(1000, 36000) + } + } } getCORSResponseHeaders( @@ -519,7 +547,9 @@ export class YogaServer< this.logger.debug(`Processing Request`) - const corsHeaders = this.getCORSResponseHeaders(request, initialContext) + const corsHeaders = request.headers.get('origin') + ? this.getCORSResponseHeaders(request, initialContext) + : {} const response = await processRequest({ request, query, @@ -532,17 +562,29 @@ export class YogaServer< contextFactory, schema, extraHeaders: corsHeaders, + extensions, + persistedQueryStore: this.persistedQueryStore, Response: this.fetchAPI.Response, ReadableStream: this.fetchAPI.ReadableStream, }) return response - } catch (err: any) { - this.logger.error(err.message, err.stack, err) - const response = new this.fetchAPI.Response(err.message, { + } catch (error: any) { + if (error instanceof GraphQLError) { + return new this.fetchAPI.Response( + JSON.stringify({ + errors: [error], + }), + { + status: 200, + }, + ) + } + const errorMessage = error.stack || JSON.stringify(error) + this.logger.error(errorMessage) + return new this.fetchAPI.Response(errorMessage, { status: 500, statusText: 'Internal Server Error', }) - return response } } @@ -573,9 +615,7 @@ export class YogaServer< method: 'POST', headers, body: JSON.stringify({ - query: - document && - (typeof document === 'string' ? document : print(document)), + query: typeof document === 'string' ? document : print(document), variables, operationName, }), diff --git a/packages/common/src/types.ts b/packages/common/src/types.ts index ec84831ad9..94ebf9a03c 100644 --- a/packages/common/src/types.ts +++ b/packages/common/src/types.ts @@ -20,10 +20,14 @@ export interface ExecutionPatchResult< extensions?: TExtensions } -export interface GraphQLParams> { +export interface GraphQLParams< + TVariables = Record, + TExtensions = Record, +> { operationName?: string query?: string - variables?: string | TVariables + variables?: TVariables + extensions?: TExtensions } export interface FormatPayloadParams { @@ -38,7 +42,7 @@ export interface YogaInitialContext { /** * A Document containing GraphQL Operations and Fragments to execute. */ - query?: string | DocumentNode + query?: string /** * An object describing the HTTP request. */ @@ -50,15 +54,14 @@ export interface YogaInitialContext { /** * Values for any Variables defined by the Operation. */ - variables?: string | Record + variables?: Record /** * Additional extensions object sent by the client. */ extensions?: Record } -export interface RequestProcessContext - extends YogaInitialContext { +export interface RequestProcessContext extends YogaInitialContext { /** * The GraphQL schema used to process the request. */ @@ -87,6 +90,10 @@ export interface RequestProcessContext * The extra headers server will send in the request */ extraHeaders: Record + /** + * Persisted Queries Cache + */ + persistedQueryStore?: PersistedQueriesCache /** * WHATWG compliant Response constructor */ @@ -162,3 +169,8 @@ export type FetchAPI = { */ ReadableStream?: typeof ReadableStream } + +export type PersistedQueriesCache = { + get(key: string): PromiseOrValue + set(key: string, query: string): PromiseOrValue +} diff --git a/packages/node/__tests__/integration.spec.ts b/packages/node/__tests__/integration.spec.ts index d31ab11473..8d645a562b 100644 --- a/packages/node/__tests__/integration.spec.ts +++ b/packages/node/__tests__/integration.spec.ts @@ -1,4 +1,4 @@ -import { getIntrospectionQuery } from 'graphql' +import { getIntrospectionQuery, parse } from 'graphql' import { useDisableIntrospection } from '@envelop/disable-introspection' import EventSource from 'eventsource' import request from 'supertest' @@ -422,7 +422,7 @@ describe('Incremental Delivery', () => { }) }) -describe('health checks', () => { +describe('Health Checks', () => { const yogaApp = createServer({ logging: false, }) @@ -444,36 +444,115 @@ describe('health checks', () => { }) }) -it('should expose Node req and res objects in the context', async () => { - const yoga = createServer({ - schema: { - typeDefs: /* GraphQL */ ` - type Query { - isNode: Boolean! - } - `, - resolvers: { - Query: { - isNode: (_, __, { req, res }) => !!req && !!res, +describe('Context Building', () => { + it('should expose Node req and res objects in the context', async () => { + const yoga = createServer({ + schema: { + typeDefs: /* GraphQL */ ` + type Query { + isNode: Boolean! + } + `, + resolvers: { + Query: { + isNode: (_, __, { req, res }) => !!req && !!res, + }, }, }, - }, - logging: false, - }) - const response = await request(yoga) - .post('/graphql') - .send({ - query: /* GraphQL */ ` - query { - isNode - } - `, + logging: false, }) + const response = await request(yoga) + .post('/graphql') + .send({ + query: /* GraphQL */ ` + query { + isNode + } + `, + }) - expect(response.statusCode).toBe(200) - const body = JSON.parse(response.text) - expect(body.errors).toBeUndefined() - expect(body.data.isNode).toBe(true) + expect(response.statusCode).toBe(200) + const body = JSON.parse(response.text) + expect(body.errors).toBeUndefined() + expect(body.data.isNode).toBe(true) + }) +}) + +describe('Persisted Queries', () => { + const persistedQueryStore = new Map() + const yoga = createServer({ schema, persistedQueryStore, logging: false }) + // TODO: Need to find a way to test using fastify inject + beforeAll(async () => { + await yoga.start() + }) + + afterEach(async () => { + persistedQueryStore.clear() + }) + + afterAll(async () => { + await yoga.stop() + }) + + 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 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 = persistedQueryStore.get(persistedQueryEntry.sha256Hash) + expect(entry).toBe(query) + + const body = JSON.parse(response.text) + expect(body.errors).toBeUndefined() + expect(body.data.__typename).toBe('Query') + }) + it('should load the persisted query when stored', async () => { + const persistedQueryEntry = { + version: 1, + sha256Hash: + 'ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38', + } + persistedQueryStore.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('Browser', () => { @@ -689,12 +768,7 @@ describe('Browser', () => { ) const resultJson = JSON.parse(resultContents) - expect(resultJson).toEqual({ - data: { - liveCounter: 1, - }, - isLive: true, - }) + expect(resultJson?.data?.liveCounter).toBe(1) liveQueryStore.invalidate('Query.liveCounter') const watchDog = await page.waitForFunction(() => { @@ -716,12 +790,7 @@ describe('Browser', () => { ] }, playButtonSelector) const resultJson1 = JSON.parse(resultContents1) - expect(resultJson1).toEqual({ - data: { - liveCounter: 2, - }, - isLive: true, - }) + expect(resultJson1?.data?.liveCounter).toBe(2) }) }) diff --git a/yarn.lock b/yarn.lock index 9bfbd16ff7..ad2294dfc4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7281,7 +7281,7 @@ cross-spawn@^7.0.0, cross-spawn@^7.0.1, cross-spawn@^7.0.2, cross-spawn@^7.0.3: shebang-command "^2.0.0" which "^2.0.1" -cross-undici-fetch@0.3.0, cross-undici-fetch@^0.1.19, cross-undici-fetch@^0.2.4, cross-undici-fetch@^0.2.5, cross-undici-fetch@^0.3.0: +cross-undici-fetch@0.3.0, cross-undici-fetch@^0.1.19, cross-undici-fetch@^0.2.4, cross-undici-fetch@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/cross-undici-fetch/-/cross-undici-fetch-0.3.0.tgz#d03764f0a1bcb745855bba8464c231d46ee14cfa" integrity sha512-as3gHg3EJrc4QMS11/GdHtyY+m3LnIf8GrziHQRe/dGxSHqEP4RcONJ/3UVaPeA1j687aYvwzWMPWKgqsdXbtA== @@ -16397,6 +16397,11 @@ tiny-lru@7.0.6, tiny-lru@^7.0.0, tiny-lru@^7.0.6: resolved "https://registry.yarnpkg.com/tiny-lru/-/tiny-lru-7.0.6.tgz#b0c3cdede1e5882aa2d1ae21cb2ceccf2a331f24" integrity sha512-zNYO0Kvgn5rXzWpL0y3RS09sMK67eGaQj9805jlK9G6pSadfriTczzLHFXa/xcW4mIRfmlB9HyQ/+SgL0V1uow== +tiny-lru@^8.0.1: + version "8.0.2" + resolved "https://registry.yarnpkg.com/tiny-lru/-/tiny-lru-8.0.2.tgz#812fccbe6e622ded552e3ff8a4c3b5ff34a85e4c" + integrity sha512-ApGvZ6vVvTNdsmt676grvCkUCGwzG9IqXma5Z07xJgiC5L7akUMof5U8G2JTI9Rz/ovtVhJBlY6mNhEvtjzOIg== + title-case@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/title-case/-/title-case-3.0.3.tgz#bc689b46f02e411f1d1e1d081f7c3deca0489982"