From e9945962de597d7547bbca174bdcc63f2c98260d Mon Sep 17 00:00:00 2001 From: Arda TANRIKULU Date: Thu, 12 Dec 2024 04:17:42 +0300 Subject: [PATCH 1/7] feat: APQ between subgraph and gateway --- .changeset/wise-seahorses-rhyme.md | 27 ++ e2e/apq-subgraphs/apq-subgraphs.e2e.ts | 86 ++++ e2e/apq-subgraphs/mesh.config.ts | 37 ++ e2e/apq-subgraphs/package.json | 10 + e2e/apq-subgraphs/services/greetings.ts | 25 ++ packages/executors/http/package.json | 1 + .../http/src/createFormDataFromVariables.ts | 35 +- packages/executors/http/src/index.ts | 375 +++++++++++------- packages/executors/http/src/prepareGETUrl.ts | 28 +- packages/executors/http/src/utils.ts | 18 +- packages/executors/http/tests/apq.test.ts | 93 +++++ packages/transports/http/src/index.ts | 2 +- yarn.lock | 14 +- 13 files changed, 554 insertions(+), 197 deletions(-) create mode 100644 .changeset/wise-seahorses-rhyme.md create mode 100644 e2e/apq-subgraphs/apq-subgraphs.e2e.ts create mode 100644 e2e/apq-subgraphs/mesh.config.ts create mode 100644 e2e/apq-subgraphs/package.json create mode 100644 e2e/apq-subgraphs/services/greetings.ts create mode 100644 packages/executors/http/tests/apq.test.ts diff --git a/.changeset/wise-seahorses-rhyme.md b/.changeset/wise-seahorses-rhyme.md new file mode 100644 index 00000000..11af3fa4 --- /dev/null +++ b/.changeset/wise-seahorses-rhyme.md @@ -0,0 +1,27 @@ +--- +'@graphql-tools/executor-http': minor +'@graphql-mesh/transport-http': patch +--- + +Automatic Persisted Queries support for upstream requests + +For HTTP Executor; +```ts +buildHTTPExecutor({ + // ... + apq: true, +}) +``` + +For Gateway Configuration; +```ts +export const gatewayConfig = defineConfig({ + transportEntries: { + '*': { + options: { + apq: true + } + } + }, +}) +``` diff --git a/e2e/apq-subgraphs/apq-subgraphs.e2e.ts b/e2e/apq-subgraphs/apq-subgraphs.e2e.ts new file mode 100644 index 00000000..832fa054 --- /dev/null +++ b/e2e/apq-subgraphs/apq-subgraphs.e2e.ts @@ -0,0 +1,86 @@ +import { createTenv } from '@internal/e2e'; +import { stripIgnoredCharacters } from 'graphql'; +import { describe, expect, it } from 'vitest'; +import { hashSHA256 } from '../../packages/executors/http/src/utils'; + +const { service, gateway } = createTenv(__dirname); + +describe('APQ to the upstream', () => { + it('works', async () => { + await using gw = await gateway({ + supergraph: { + with: 'mesh', + services: [await service('greetings')], + }, + }); + const query = stripIgnoredCharacters(/* GraphQL */ ` + { + __typename + hello + } + `); + const sha256Hash = await hashSHA256(query); + await expect(gw.execute({ query })).resolves.toEqual({ + data: { + __typename: 'Query', + hello: 'world', + }, + }); + // First it sends the request with query + expect(gw.getStd('both')).toContain( + `fetch 1 ${JSON.stringify({ + extensions: { + persistedQuery: { + version: 1, + sha256Hash, + }, + }, + })}`, + ); + // Then it sends the query with the hash + // In the following requests the query won't be needed + expect(gw.getStd('both')).toContain( + `fetch 2 ${JSON.stringify({ + query, + extensions: { + persistedQuery: { + version: 1, + sha256Hash, + }, + }, + })}`, + ); + + await expect(gw.execute({ query })).resolves.toEqual({ + data: { + __typename: 'Query', + hello: 'world', + }, + }); + + // The query is not sent again + expect(gw.getStd('both')).toContain( + `fetch 3 ${JSON.stringify({ + extensions: { + persistedQuery: { + version: 1, + sha256Hash, + }, + }, + })}`, + ); + + // The query is not sent again + expect(gw.getStd('both')).not.toContain( + `fetch 4 ${JSON.stringify({ + query, + extensions: { + persistedQuery: { + version: 1, + sha256Hash, + }, + }, + })}`, + ); + }); +}); diff --git a/e2e/apq-subgraphs/mesh.config.ts b/e2e/apq-subgraphs/mesh.config.ts new file mode 100644 index 00000000..3d758bf4 --- /dev/null +++ b/e2e/apq-subgraphs/mesh.config.ts @@ -0,0 +1,37 @@ +import { defineConfig as defineGatewayConfig } from '@graphql-hive/gateway'; +import { + defineConfig as defineComposeConfig, + loadGraphQLHTTPSubgraph, +} from '@graphql-mesh/compose-cli'; +import { Opts } from '@internal/testing'; + +const opts = Opts(process.argv); + +export const composeConfig = defineComposeConfig({ + subgraphs: [ + { + sourceHandler: loadGraphQLHTTPSubgraph('greetings', { + endpoint: `http://localhost:${opts.getServicePort('greetings')}/graphql`, + }), + }, + ], +}); + +let fetchCnt = 0; +export const gatewayConfig = defineGatewayConfig({ + transportEntries: { + greetings: { + options: { + apq: true, + }, + }, + }, + plugins: (ctx) => [ + { + onFetch({ options }) { + fetchCnt++; + ctx.logger.info('fetch', fetchCnt, options.body); + }, + }, + ], +}); diff --git a/e2e/apq-subgraphs/package.json b/e2e/apq-subgraphs/package.json new file mode 100644 index 00000000..5ab1c976 --- /dev/null +++ b/e2e/apq-subgraphs/package.json @@ -0,0 +1,10 @@ +{ + "name": "@e2e/apq-subgraphs", + "private": true, + "devDependencies": { + "@apollo/server": "^4.11.2", + "@graphql-mesh/compose-cli": "^1.2.13", + "graphql": "^16.9.0", + "tslib": "^2.8.1" + } +} diff --git a/e2e/apq-subgraphs/services/greetings.ts b/e2e/apq-subgraphs/services/greetings.ts new file mode 100644 index 00000000..abafb3e0 --- /dev/null +++ b/e2e/apq-subgraphs/services/greetings.ts @@ -0,0 +1,25 @@ +import { ApolloServer } from '@apollo/server'; +import { startStandaloneServer } from '@apollo/server/standalone'; +import { Opts } from '@internal/testing'; + +const opts = Opts(process.argv); + +const apolloServer = new ApolloServer({ + typeDefs: /* GraphQL */ ` + type Query { + hello: String + } + `, + resolvers: { + Query: { + hello: () => 'world', + }, + }, +}); + +startStandaloneServer(apolloServer, { + listen: { port: opts.getServicePort('greetings') }, +}).catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/packages/executors/http/package.json b/packages/executors/http/package.json index 74e9a87c..2cdb10cc 100644 --- a/packages/executors/http/package.json +++ b/packages/executors/http/package.json @@ -49,6 +49,7 @@ "value-or-promise": "^1.0.12" }, "devDependencies": { + "@apollo/server": "^4.11.2", "@types/extract-files": "8.1.3", "@whatwg-node/disposablestack": "^0.0.5", "graphql": "^16.9.0", diff --git a/packages/executors/http/src/createFormDataFromVariables.ts b/packages/executors/http/src/createFormDataFromVariables.ts index 6af0f414..aa1a8c13 100644 --- a/packages/executors/http/src/createFormDataFromVariables.ts +++ b/packages/executors/http/src/createFormDataFromVariables.ts @@ -9,6 +9,7 @@ import { FormData as DefaultFormData, } from '@whatwg-node/fetch'; import { extractFiles, isExtractableFile } from 'extract-files'; +import { SerializedRequest } from './index.js'; import { isGraphQLUpload } from './isGraphQLUpload.js'; function collectAsyncIterableValues( @@ -30,18 +31,8 @@ function collectAsyncIterableValues( return iterate(); } -export function createFormDataFromVariables( - { - query, - variables, - operationName, - extensions, - }: { - query: string; - variables: TVariables; - operationName?: string; - extensions?: any; - }, +export function createFormDataFromVariables( + body: SerializedRequest, { File: FileCtor = DefaultFile, FormData: FormDataCtor = DefaultFormData, @@ -50,7 +41,10 @@ export function createFormDataFromVariables( FormData?: typeof DefaultFormData; }, ) { - const vars = Object.assign({}, variables); + if (!body.variables) { + return JSON.stringify(body); + } + const vars = Object.assign({}, body.variables); const { clone, files } = extractFiles( vars, 'variables', @@ -62,16 +56,7 @@ export function createFormDataFromVariables( typeof v?.arrayBuffer === 'function') as any, ); if (files.size === 0) { - return JSON.stringify( - { - query, - variables, - operationName, - extensions, - }, - null, - 2, - ); + return JSON.stringify(body); } const map: Record = {}; const uploads: any[] = []; @@ -85,10 +70,8 @@ export function createFormDataFromVariables( form.append( 'operations', JSON.stringify({ - query, + ...body, variables: clone, - operationName, - extensions, }), ); form.append('map', JSON.stringify(map)); diff --git a/packages/executors/http/src/index.ts b/packages/executors/http/src/index.ts index 77d54897..36c558c1 100644 --- a/packages/executors/http/src/index.ts +++ b/packages/executors/http/src/index.ts @@ -8,6 +8,7 @@ import { Executor, getOperationASTFromRequest, mapMaybePromise, + MaybePromise, } from '@graphql-tools/utils'; import { DisposableSymbols } from '@whatwg-node/disposablestack'; import { fetch as defaultFetch } from '@whatwg-node/fetch'; @@ -23,6 +24,7 @@ import { createAbortErrorReason, createGraphQLErrorForAbort, createResultForAbort, + hashSHA256, } from './utils.js'; export type SyncFetchFn = ( @@ -94,6 +96,7 @@ export interface HTTPExecutorOptions { * Print function for DocumentNode */ print?: (doc: DocumentNode) => string; + apq?: boolean; /** * Enable [Explicit Resource Management](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-2.html#using-declarations-and-explicit-resource-management) * @deprecated The executors are always disposable, and this option will be removed in the next major version, there is no need to have a flag for this. @@ -101,6 +104,13 @@ export interface HTTPExecutorOptions { disposable?: boolean; } +export type SerializedRequest = { + query?: string; + variables?: Record; + operationName?: string; + extensions?: any; +}; + export type HeadersConfig = Record; // To prevent event listener warnings @@ -151,6 +161,7 @@ export function buildHTTPExecutor( const sharedSignal = createSignalWrapper(disposeCtrl.signal); const baseExecutor = ( request: ExecutionRequest, + excludeQuery?: boolean, ) => { if (sharedSignal.aborted) { return createResultForAbort(sharedSignal.reason); @@ -200,8 +211,6 @@ export function buildHTTPExecutor( request.extensions = restExtensions; } - const query = printFn(request.document); - let signal = sharedSignal; if (options?.timeout) { signal = AbortSignal.any([ @@ -217,186 +226,248 @@ export function buildHTTPExecutor( response: {}, }; - return new ValueOrPromise(() => { - switch (method) { - case 'GET': { - const finalUrl = prepareGETUrl({ - baseUrl: endpoint, - query, - variables: request.variables, - operationName: request.operationName, - extensions: request.extensions, + const query = printFn(request.document); + + let serializeFn = function serialize(): MaybePromise { + return { + query: excludeQuery ? undefined : printFn(request.document), + variables: + (request.variables && Object.keys(request.variables).length) > 0 + ? request.variables + : undefined, + operationName: request.operationName + ? request.operationName + : undefined, + extensions: + request.extensions && Object.keys(request.extensions).length > 0 + ? request.extensions + : undefined, + }; + }; + + if (options?.apq) { + serializeFn = + function serializeWithAPQ(): MaybePromise { + return mapMaybePromise(hashSHA256(query), (sha256Hash) => { + const extensions: Record = request.extensions || {}; + extensions['persistedQuery'] = { + version: 1, + sha256Hash, + }; + return { + query: excludeQuery ? undefined : query, + variables: + (request.variables && Object.keys(request.variables).length) > 0 + ? request.variables + : undefined, + operationName: request.operationName + ? request.operationName + : undefined, + extensions, + }; }); - const fetchOptions: RequestInit = { - method: 'GET', - headers, - signal, - }; - if (options?.credentials != null) { - fetchOptions.credentials = options.credentials; + }; + } + + return mapMaybePromise(serializeFn(), (body: SerializedRequest) => + new ValueOrPromise(() => { + switch (method) { + case 'GET': { + const finalUrl = prepareGETUrl({ + baseUrl: endpoint, + body, + }); + const fetchOptions: RequestInit = { + method: 'GET', + headers, + signal, + }; + if (options?.credentials != null) { + fetchOptions.credentials = options.credentials; + } + upstreamErrorExtensions.request.url = finalUrl; + return fetchFn( + finalUrl, + fetchOptions, + request.context, + request.info, + ); + } + case 'POST': { + upstreamErrorExtensions.request.body = body; + return mapMaybePromise( + createFormDataFromVariables(body, { + File: options?.File, + FormData: options?.FormData, + }), + (body) => { + if (typeof body === 'string' && !headers['content-type']) { + upstreamErrorExtensions.request.body = body; + headers['content-type'] = 'application/json'; + } + const fetchOptions: RequestInit = { + method: 'POST', + body, + headers, + signal, + }; + if (options?.credentials != null) { + fetchOptions.credentials = options.credentials; + } + return fetchFn( + endpoint, + fetchOptions, + request.context, + request.info, + ) as any; + }, + ); } - upstreamErrorExtensions.request.url = finalUrl; - return fetchFn(finalUrl, fetchOptions, request.context, request.info); } - case 'POST': { - const body = { - query, - variables: request.variables, - operationName: request.operationName, - extensions: request.extensions, - }; - upstreamErrorExtensions.request.body = body; - return mapMaybePromise( - createFormDataFromVariables(body, { - File: options?.File, - FormData: options?.FormData, - }), - (body) => { - if (typeof body === 'string' && !headers['content-type']) { - upstreamErrorExtensions.request.body = body; - headers['content-type'] = 'application/json'; - } - const fetchOptions: RequestInit = { - method: 'POST', - body, - headers, - signal, - }; - if (options?.credentials != null) { - fetchOptions.credentials = options.credentials; - } - return fetchFn( - endpoint, - fetchOptions, - request.context, - request.info, - ) as any; + }) + .then((fetchResult: Response): any => { + upstreamErrorExtensions.response.status = fetchResult.status; + upstreamErrorExtensions.response.statusText = fetchResult.statusText; + Object.defineProperty(upstreamErrorExtensions.response, 'headers', { + get() { + return Object.fromEntries(fetchResult.headers.entries()); }, - ); - } - } - }) - .then((fetchResult: Response): any => { - upstreamErrorExtensions.response.status = fetchResult.status; - upstreamErrorExtensions.response.statusText = fetchResult.statusText; - Object.defineProperty(upstreamErrorExtensions.response, 'headers', { - get() { - return Object.fromEntries(fetchResult.headers.entries()); - }, - }); + }); - // Retry should respect HTTP Errors - if ( - options?.retry != null && - !fetchResult.status.toString().startsWith('2') - ) { - throw new Error( - fetchResult.statusText || - `Upstream HTTP Error: ${fetchResult.status}`, - ); - } + // Retry should respect HTTP Errors + if ( + options?.retry != null && + !fetchResult.status.toString().startsWith('2') + ) { + throw new Error( + fetchResult.statusText || + `Upstream HTTP Error: ${fetchResult.status}`, + ); + } - const contentType = fetchResult.headers.get('content-type'); - if (contentType?.includes('text/event-stream')) { - return handleEventStreamResponse(signal, fetchResult); - } else if (contentType?.includes('multipart/mixed')) { - return handleMultipartMixedResponse(fetchResult); - } + const contentType = fetchResult.headers.get('content-type'); + if (contentType?.includes('text/event-stream')) { + return handleEventStreamResponse(signal, fetchResult); + } else if (contentType?.includes('multipart/mixed')) { + return handleMultipartMixedResponse(fetchResult); + } - return fetchResult.text(); - }) - .then((result) => { - if (typeof result === 'string') { - upstreamErrorExtensions.response.body = result; - if (result) { - try { - const parsedResult = JSON.parse(result); - upstreamErrorExtensions.response.body = parsedResult; - if ( - parsedResult.data == null && - (parsedResult.errors == null || - parsedResult.errors.length === 0) - ) { + return fetchResult.text(); + }) + .then((result) => { + if (typeof result === 'string') { + upstreamErrorExtensions.response.body = result; + if (result) { + try { + const parsedResult = JSON.parse(result); + upstreamErrorExtensions.response.body = parsedResult; + if ( + parsedResult.data == null && + (parsedResult.errors == null || + parsedResult.errors.length === 0) + ) { + return { + errors: [ + createGraphQLError( + 'Unexpected empty "data" and "errors" fields in result: ' + + result, + { + extensions: upstreamErrorExtensions, + }, + ), + ], + }; + } + if (Array.isArray(parsedResult.errors)) { + return { + ...parsedResult, + errors: parsedResult.errors.map( + ({ + message, + ...options + }: { + message: string; + extensions: Record; + }) => + createGraphQLError(message, { + ...options, + extensions: { + code: 'DOWNSTREAM_SERVICE_ERROR', + ...(options.extensions || {}), + }, + }), + ), + }; + } + return parsedResult; + } catch (e: any) { return { errors: [ createGraphQLError( - 'Unexpected empty "data" and "errors" fields in result: ' + - result, + `Unexpected response: ${JSON.stringify(result)}`, { extensions: upstreamErrorExtensions, + originalError: e, }, ), ], }; } - if (Array.isArray(parsedResult.errors)) { - return { - ...parsedResult, - errors: parsedResult.errors.map( - ({ - message, - ...options - }: { - message: string; - extensions: Record; - }) => - createGraphQLError(message, { - ...options, - extensions: { - code: 'DOWNSTREAM_SERVICE_ERROR', - ...(options.extensions || {}), - }, - }), - ), - }; - } - return parsedResult; - } catch (e: any) { - return { - errors: [ - createGraphQLError( - `Unexpected response: ${JSON.stringify(result)}`, - { - extensions: upstreamErrorExtensions, - originalError: e, - }, - ), - ], - }; } + } else { + return result; + } + }) + .catch((e: any) => { + if (e.name === 'AggregateError') { + return { + errors: e.errors.map((e: any) => + coerceFetchError(e, { + signal, + endpoint, + upstreamErrorExtensions, + }), + ), + }; } - } else { - return result; - } - }) - .catch((e: any) => { - if (e.name === 'AggregateError') { return { - errors: e.errors.map((e: any) => + errors: [ coerceFetchError(e, { signal, endpoint, upstreamErrorExtensions, }), - ), + ], }; - } - return { - errors: [ - coerceFetchError(e, { - signal, - endpoint, - upstreamErrorExtensions, - }), - ], - }; - }) - .resolve(); + }) + .resolve(), + ); }; let executor: Executor = baseExecutor; + if (options?.apq != null) { + executor = function apqExecutor(request: ExecutionRequest) { + return mapMaybePromise( + baseExecutor(request, true), + (res: ExecutionResult) => { + if ( + res.errors?.some( + (error) => + error.extensions['code'] === 'PERSISTED_QUERY_NOT_FOUND' || + error.message === 'PersistedQueryNotFound', + ) + ) { + return baseExecutor(request, false); + } + return res; + }, + ); + }; + } + if (options?.retry != null) { + const prevExecutor = executor as typeof baseExecutor; executor = function retryExecutor(request: ExecutionRequest) { let result: ExecutionResult | undefined; let attempt = 0; @@ -415,7 +486,7 @@ export function buildHTTPExecutor( errors: [createGraphQLError('No response returned from fetch')], }; } - return mapMaybePromise(baseExecutor(request), (res) => { + return mapMaybePromise(prevExecutor(request), (res) => { result = res; if (result?.errors?.length) { return retryAttempt(); diff --git a/packages/executors/http/src/prepareGETUrl.ts b/packages/executors/http/src/prepareGETUrl.ts index 8635e92d..5559a970 100644 --- a/packages/executors/http/src/prepareGETUrl.ts +++ b/packages/executors/http/src/prepareGETUrl.ts @@ -1,17 +1,11 @@ -import { stripIgnoredCharacters } from 'graphql'; +import { SerializedRequest } from '.'; export function prepareGETUrl({ baseUrl = '', - query, - variables, - operationName, - extensions, + body, }: { baseUrl: string; - query: string; - variables?: any; - operationName?: string; - extensions?: any; + body: SerializedRequest; }) { const dummyHostname = 'https://dummyhostname.com'; const validUrl = baseUrl.startsWith('http') @@ -20,15 +14,17 @@ export function prepareGETUrl({ ? `${dummyHostname}${baseUrl}` : `${dummyHostname}/${baseUrl}`; const urlObj = new URL(validUrl); - urlObj.searchParams.set('query', stripIgnoredCharacters(query)); - if (variables && Object.keys(variables).length > 0) { - urlObj.searchParams.set('variables', JSON.stringify(variables)); + if (body.query) { + urlObj.searchParams.set('query', body.query); } - if (operationName) { - urlObj.searchParams.set('operationName', operationName); + if (body.variables && Object.keys(body.variables).length > 0) { + urlObj.searchParams.set('variables', JSON.stringify(body.variables)); } - if (extensions) { - urlObj.searchParams.set('extensions', JSON.stringify(extensions)); + if (body.operationName) { + urlObj.searchParams.set('operationName', body.operationName); + } + if (body.extensions) { + urlObj.searchParams.set('extensions', JSON.stringify(body.extensions)); } const finalUrl = urlObj.toString().replace(dummyHostname, ''); return finalUrl; diff --git a/packages/executors/http/src/utils.ts b/packages/executors/http/src/utils.ts index 8bb81bc6..ea300303 100644 --- a/packages/executors/http/src/utils.ts +++ b/packages/executors/http/src/utils.ts @@ -1,4 +1,5 @@ -import { createGraphQLError } from '@graphql-tools/utils'; +import { createGraphQLError, mapMaybePromise } from '@graphql-tools/utils'; +import { crypto, TextEncoder } from '@whatwg-node/fetch'; export function createAbortErrorReason() { return new Error('Executor was disposed.'); @@ -21,3 +22,18 @@ export function createResultForAbort( errors: [createGraphQLErrorForAbort(reason, extensions)], }; } + +export function hashSHA256(str: string) { + const textEncoder = new TextEncoder(); + const utf8 = textEncoder.encode(str); + return mapMaybePromise( + crypto.subtle.digest('SHA-256', utf8), + (hashBuffer) => { + let hashHex = ''; + for (const bytes of new Uint8Array(hashBuffer)) { + hashHex += bytes.toString(16).padStart(2, '0'); + } + return hashHex; + }, + ); +} diff --git a/packages/executors/http/tests/apq.test.ts b/packages/executors/http/tests/apq.test.ts new file mode 100644 index 00000000..893a910f --- /dev/null +++ b/packages/executors/http/tests/apq.test.ts @@ -0,0 +1,93 @@ +import { ApolloServer } from '@apollo/server'; +import { startStandaloneServer } from '@apollo/server/standalone'; +import { buildHTTPExecutor } from '@graphql-tools/executor-http'; +import { fetch } from '@whatwg-node/fetch'; +import { parse } from 'graphql'; +import { afterEach, describe, expect, it, vitest } from 'vitest'; +import { defaultPrintFn } from '../src/defaultPrintFn'; +import { hashSHA256 } from '../src/utils'; + +describe('APQ to the upstream', () => { + let apolloServer: ApolloServer | undefined; + afterEach(() => apolloServer?.stop()); + it('works', async () => { + apolloServer = new ApolloServer({ + typeDefs: /* GraphQL */ ` + type Query { + hello: String + } + `, + resolvers: { + Query: { + hello: () => 'world', + }, + }, + }); + const { url } = await startStandaloneServer(apolloServer, { + listen: { port: 0 }, + }); + const tracedFetch = vitest.fn(fetch); + await using executor = buildHTTPExecutor({ + endpoint: url, + apq: true, + fetch: tracedFetch, + }); + const document = parse(/* GraphQL */ ` + query { + hello + } + `); + await expect( + executor({ + document, + }), + ).resolves.toEqual({ + data: { hello: 'world' }, + }); + // First it checks whether server has the query, then it sends the query + expect(tracedFetch.mock.calls).toHaveLength(2); + const query = defaultPrintFn(document); + const sha256Hash = await hashSHA256(query); + expect(tracedFetch.mock.calls[0]?.[1]?.body).toBe( + JSON.stringify({ + extensions: { + persistedQuery: { + version: 1, + sha256Hash, + }, + }, + }), + ); + expect(tracedFetch.mock.calls[1]?.[1]?.body).toBe( + JSON.stringify({ + query, + extensions: { + persistedQuery: { + version: 1, + sha256Hash, + }, + }, + }), + ); + tracedFetch.mockClear(); + // On the following requests, it should only send the hash + await expect( + executor({ + document, + }), + ).resolves.toEqual({ + data: { hello: 'world' }, + }); + expect(tracedFetch.mock.calls).toHaveLength(1); + expect(tracedFetch.mock.calls[0]?.[1]?.body).toBe( + JSON.stringify({ + extensions: { + persistedQuery: { + version: 1, + sha256Hash, + }, + }, + }), + ); + }); +}); diff --git a/packages/transports/http/src/index.ts b/packages/transports/http/src/index.ts index 07b3ec0a..06e4488b 100644 --- a/packages/transports/http/src/index.ts +++ b/packages/transports/http/src/index.ts @@ -24,7 +24,7 @@ export type HTTPTransportOptions< >, > = Pick< HTTPExecutorOptions, - 'useGETForQueries' | 'method' | 'timeout' | 'credentials' | 'retry' + 'useGETForQueries' | 'method' | 'timeout' | 'credentials' | 'retry' | 'apq' > & { subscriptions?: TransportEntry; }; diff --git a/yarn.lock b/yarn.lock index 9ec2bcca..3691b841 100644 --- a/yarn.lock +++ b/yarn.lock @@ -296,7 +296,7 @@ __metadata: languageName: node linkType: hard -"@apollo/server@npm:^4.10.3": +"@apollo/server@npm:^4.10.3, @apollo/server@npm:^4.11.2": version: 4.11.2 resolution: "@apollo/server@npm:4.11.2" dependencies: @@ -2267,6 +2267,17 @@ __metadata: languageName: node linkType: hard +"@e2e/apq-subgraphs@workspace:e2e/apq-subgraphs": + version: 0.0.0-use.local + resolution: "@e2e/apq-subgraphs@workspace:e2e/apq-subgraphs" + dependencies: + "@apollo/server": "npm:^4.11.2" + "@graphql-mesh/compose-cli": "npm:^1.2.13" + graphql: "npm:^16.9.0" + tslib: "npm:^2.8.1" + languageName: unknown + linkType: soft + "@e2e/auto-type-merging@workspace:e2e/auto-type-merging": version: 0.0.0-use.local resolution: "@e2e/auto-type-merging@workspace:e2e/auto-type-merging" @@ -3859,6 +3870,7 @@ __metadata: version: 0.0.0-use.local resolution: "@graphql-tools/executor-http@workspace:packages/executors/http" dependencies: + "@apollo/server": "npm:^4.11.2" "@graphql-tools/utils": "npm:^10.6.2" "@repeaterjs/repeater": "npm:^3.0.4" "@types/extract-files": "npm:8.1.3" From 2cb17846e8032699183000b3e0c8b018527f30ea Mon Sep 17 00:00:00 2001 From: Arda TANRIKULU Date: Thu, 12 Dec 2024 04:26:45 +0300 Subject: [PATCH 2/7] Use stdout for Docker --- e2e/apq-subgraphs/mesh.config.ts | 4 +-- packages/executors/http/src/index.ts | 31 ++++++++++++++++++----- packages/executors/http/tests/apq.test.ts | 4 +-- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/e2e/apq-subgraphs/mesh.config.ts b/e2e/apq-subgraphs/mesh.config.ts index 3d758bf4..1fc269b8 100644 --- a/e2e/apq-subgraphs/mesh.config.ts +++ b/e2e/apq-subgraphs/mesh.config.ts @@ -26,11 +26,11 @@ export const gatewayConfig = defineGatewayConfig({ }, }, }, - plugins: (ctx) => [ + plugins: () => [ { onFetch({ options }) { fetchCnt++; - ctx.logger.info('fetch', fetchCnt, options.body); + process.stdout.write(`fetch ${fetchCnt} ${options.body}\n`); }, }, ], diff --git a/packages/executors/http/src/index.ts b/packages/executors/http/src/index.ts index 36c558c1..edb2ed94 100644 --- a/packages/executors/http/src/index.ts +++ b/packages/executors/http/src/index.ts @@ -53,10 +53,21 @@ export type AsyncImportFn = (moduleName: string) => PromiseLike; export type SyncImportFn = (moduleName: string) => any; export interface HTTPExecutorOptions { + /** + * The endpoint to use when querying the upstream API + * + * @default '/graphql' + */ endpoint?: string; + /** + * The WHATWG compatible fetch implementation to use + * @see https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API + * @default globalThis.fetch + */ fetch?: FetchFn; /** * Whether to use the GET HTTP method for queries when querying the original schema + * @default false */ useGETForQueries?: boolean; /** @@ -66,7 +77,8 @@ export interface HTTPExecutorOptions { | HeadersConfig | ((executorRequest?: ExecutionRequest) => HeadersConfig); /** - * HTTP method to use when querying the original schema. + * HTTP method to use when querying the original schema.x + * @default 'POST' */ method?: 'GET' | 'POST'; /** @@ -74,7 +86,8 @@ export interface HTTPExecutorOptions { */ timeout?: number; /** - * Request Credentials (default: 'same-origin') + * Request Credentials + * @default 'same-origin' * @see https://developer.mozilla.org/en-US/docs/Web/API/Request/credentials */ credentials?: RequestCredentials; @@ -83,22 +96,28 @@ export interface HTTPExecutorOptions { */ retry?: number; /** - * WHATWG compatible File implementation + * WHATWG compatible `File` implementation * @see https://developer.mozilla.org/en-US/docs/Web/API/File */ File?: typeof File; /** - * WHATWG compatible FormData implementation + * WHATWG compatible `FormData` implementation * @see https://developer.mozilla.org/en-US/docs/Web/API/FormData */ FormData?: typeof FormData; /** - * Print function for DocumentNode + * Print function for `DocumentNode` + * Useful when you want to memoize the print function or use a different implementation to minify the query etc. */ print?: (doc: DocumentNode) => string; + /** + * Enable Automatic Persisted Queries + * @see https://www.apollographql.com/docs/apollo-server/performance/apq/ + */ apq?: boolean; /** - * Enable [Explicit Resource Management](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-2.html#using-declarations-and-explicit-resource-management) + * Enable Explicit Resource Management + * @see https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-2.html#using-declarations-and-explicit-resource-management * @deprecated The executors are always disposable, and this option will be removed in the next major version, there is no need to have a flag for this. */ disposable?: boolean; diff --git a/packages/executors/http/tests/apq.test.ts b/packages/executors/http/tests/apq.test.ts index 893a910f..ac7ae65b 100644 --- a/packages/executors/http/tests/apq.test.ts +++ b/packages/executors/http/tests/apq.test.ts @@ -3,7 +3,7 @@ import { startStandaloneServer } from '@apollo/server/standalone'; import { buildHTTPExecutor } from '@graphql-tools/executor-http'; import { fetch } from '@whatwg-node/fetch'; import { parse } from 'graphql'; -import { afterEach, describe, expect, it, vitest } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { defaultPrintFn } from '../src/defaultPrintFn'; import { hashSHA256 } from '../src/utils'; @@ -26,7 +26,7 @@ describe('APQ to the upstream', () => { const { url } = await startStandaloneServer(apolloServer, { listen: { port: 0 }, }); - const tracedFetch = vitest.fn(fetch); + const tracedFetch = vi.fn(fetch); await using executor = buildHTTPExecutor({ endpoint: url, apq: true, From 8dc23051c78b7dd42ed0dec7fc0df78cbe571bff Mon Sep 17 00:00:00 2001 From: Arda TANRIKULU Date: Thu, 12 Dec 2024 04:27:02 +0300 Subject: [PATCH 3/7] Format' --- packages/executors/http/src/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/executors/http/src/index.ts b/packages/executors/http/src/index.ts index edb2ed94..a2ee5bec 100644 --- a/packages/executors/http/src/index.ts +++ b/packages/executors/http/src/index.ts @@ -55,7 +55,6 @@ export type SyncImportFn = (moduleName: string) => any; export interface HTTPExecutorOptions { /** * The endpoint to use when querying the upstream API - * * @default '/graphql' */ endpoint?: string; From fbfa6b8f39f2e3c2590151a9a7ed4fc154b3d3d3 Mon Sep 17 00:00:00 2001 From: Arda TANRIKULU Date: Thu, 12 Dec 2024 04:36:43 +0300 Subject: [PATCH 4/7] Get Mesh or GW config --- internal/e2e/src/tenv.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/e2e/src/tenv.ts b/internal/e2e/src/tenv.ts index 9117ae7c..455534f7 100644 --- a/internal/e2e/src/tenv.ts +++ b/internal/e2e/src/tenv.ts @@ -401,7 +401,9 @@ export function createTenv(cwd: string): Tenv { subgraph = await handleDockerHostName(subgraph, volumes); } - for (const configfile of await glob('gateway.config.*', { cwd })) { + for (const configfile of await glob('@(mesh|gateway).config.*', { + cwd, + })) { volumes.push({ host: configfile, container: `/gateway/${path.basename(configfile)}`, From 52e7c9192bd3022251be5c3cabe546398300b30d Mon Sep 17 00:00:00 2001 From: Arda TANRIKULU Date: Thu, 12 Dec 2024 05:16:45 +0300 Subject: [PATCH 5/7] Avoid uploading Mesh configuration --- e2e/apq-subgraphs/gateway.config.ts | 20 ++++++++++++++++++++ e2e/apq-subgraphs/mesh.config.ts | 24 ++---------------------- internal/e2e/src/tenv.ts | 2 +- 3 files changed, 23 insertions(+), 23 deletions(-) create mode 100644 e2e/apq-subgraphs/gateway.config.ts diff --git a/e2e/apq-subgraphs/gateway.config.ts b/e2e/apq-subgraphs/gateway.config.ts new file mode 100644 index 00000000..4f1488f3 --- /dev/null +++ b/e2e/apq-subgraphs/gateway.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from '@graphql-hive/gateway'; + +let fetchCnt = 0; +export const gatewayConfig = defineConfig({ + transportEntries: { + greetings: { + options: { + apq: true, + }, + }, + }, + plugins: () => [ + { + onFetch({ options }) { + fetchCnt++; + process.stdout.write(`fetch ${fetchCnt} ${options.body}\n`); + }, + }, + ], +}); diff --git a/e2e/apq-subgraphs/mesh.config.ts b/e2e/apq-subgraphs/mesh.config.ts index 1fc269b8..7c7660f0 100644 --- a/e2e/apq-subgraphs/mesh.config.ts +++ b/e2e/apq-subgraphs/mesh.config.ts @@ -1,13 +1,12 @@ -import { defineConfig as defineGatewayConfig } from '@graphql-hive/gateway'; import { - defineConfig as defineComposeConfig, + defineConfig, loadGraphQLHTTPSubgraph, } from '@graphql-mesh/compose-cli'; import { Opts } from '@internal/testing'; const opts = Opts(process.argv); -export const composeConfig = defineComposeConfig({ +export const composeConfig = defineConfig({ subgraphs: [ { sourceHandler: loadGraphQLHTTPSubgraph('greetings', { @@ -16,22 +15,3 @@ export const composeConfig = defineComposeConfig({ }, ], }); - -let fetchCnt = 0; -export const gatewayConfig = defineGatewayConfig({ - transportEntries: { - greetings: { - options: { - apq: true, - }, - }, - }, - plugins: () => [ - { - onFetch({ options }) { - fetchCnt++; - process.stdout.write(`fetch ${fetchCnt} ${options.body}\n`); - }, - }, - ], -}); diff --git a/internal/e2e/src/tenv.ts b/internal/e2e/src/tenv.ts index 455534f7..425f9b08 100644 --- a/internal/e2e/src/tenv.ts +++ b/internal/e2e/src/tenv.ts @@ -401,7 +401,7 @@ export function createTenv(cwd: string): Tenv { subgraph = await handleDockerHostName(subgraph, volumes); } - for (const configfile of await glob('@(mesh|gateway).config.*', { + for (const configfile of await glob('gateway.config.*', { cwd, })) { volumes.push({ From 285830d2dda4e04bd60b170ac7aa29fd56671110 Mon Sep 17 00:00:00 2001 From: Arda TANRIKULU Date: Thu, 12 Dec 2024 05:13:39 +0300 Subject: [PATCH 6/7] enhance(http): improve json serialization --- .../http/src/createFormDataFromVariables.ts | 11 +++--- packages/executors/http/src/index.ts | 10 +---- packages/executors/http/src/prepareGETUrl.ts | 2 +- packages/executors/http/src/utils.ts | 39 +++++++++++++++++++ 4 files changed, 47 insertions(+), 15 deletions(-) diff --git a/packages/executors/http/src/createFormDataFromVariables.ts b/packages/executors/http/src/createFormDataFromVariables.ts index aa1a8c13..1b2d07c1 100644 --- a/packages/executors/http/src/createFormDataFromVariables.ts +++ b/packages/executors/http/src/createFormDataFromVariables.ts @@ -9,8 +9,8 @@ import { FormData as DefaultFormData, } from '@whatwg-node/fetch'; import { extractFiles, isExtractableFile } from 'extract-files'; -import { SerializedRequest } from './index.js'; import { isGraphQLUpload } from './isGraphQLUpload.js'; +import { jsonStringifyBody, SerializedRequest } from './utils.js'; function collectAsyncIterableValues( asyncIterable: AsyncIterable, @@ -42,11 +42,10 @@ export function createFormDataFromVariables( }, ) { if (!body.variables) { - return JSON.stringify(body); + return jsonStringifyBody(body); } - const vars = Object.assign({}, body.variables); const { clone, files } = extractFiles( - vars, + body.variables, 'variables', ((v: any) => isExtractableFile(v) || @@ -56,7 +55,7 @@ export function createFormDataFromVariables( typeof v?.arrayBuffer === 'function') as any, ); if (files.size === 0) { - return JSON.stringify(body); + return jsonStringifyBody(body); } const map: Record = {}; const uploads: any[] = []; @@ -69,7 +68,7 @@ export function createFormDataFromVariables( const form = new FormDataCtor(); form.append( 'operations', - JSON.stringify({ + jsonStringifyBody({ ...body, variables: clone, }), diff --git a/packages/executors/http/src/index.ts b/packages/executors/http/src/index.ts index a2ee5bec..e3037b33 100644 --- a/packages/executors/http/src/index.ts +++ b/packages/executors/http/src/index.ts @@ -25,6 +25,7 @@ import { createGraphQLErrorForAbort, createResultForAbort, hashSHA256, + SerializedRequest, } from './utils.js'; export type SyncFetchFn = ( @@ -122,13 +123,6 @@ export interface HTTPExecutorOptions { disposable?: boolean; } -export type SerializedRequest = { - query?: string; - variables?: Record; - operationName?: string; - extensions?: any; -}; - export type HeadersConfig = Record; // To prevent event listener warnings @@ -257,7 +251,7 @@ export function buildHTTPExecutor( ? request.operationName : undefined, extensions: - request.extensions && Object.keys(request.extensions).length > 0 + (request.extensions && Object.keys(request.extensions).length > 0) ? request.extensions : undefined, }; diff --git a/packages/executors/http/src/prepareGETUrl.ts b/packages/executors/http/src/prepareGETUrl.ts index 5559a970..8e71191d 100644 --- a/packages/executors/http/src/prepareGETUrl.ts +++ b/packages/executors/http/src/prepareGETUrl.ts @@ -1,4 +1,4 @@ -import { SerializedRequest } from '.'; +import { SerializedRequest } from './utils.js'; export function prepareGETUrl({ baseUrl = '', diff --git a/packages/executors/http/src/utils.ts b/packages/executors/http/src/utils.ts index ea300303..d9c78dea 100644 --- a/packages/executors/http/src/utils.ts +++ b/packages/executors/http/src/utils.ts @@ -37,3 +37,42 @@ export function hashSHA256(str: string) { }, ); } + +export type SerializedRequest = { + query?: string; + variables?: Record; + operationName?: string; + extensions?: any; +}; + +// For faster serialization instead of JSON.stringify overhead +export function jsonStringifyBody(body: SerializedRequest) { + let str = '{'; + let prev = false; + if (body.query) { + str += `"query":"${body.query.replaceAll('"', '\\"')}"`; + prev = true; + } + if (body.variables) { + if (prev) { + str += ','; + } + str += `"variables":${JSON.stringify(body.variables)}`; + prev = true; + } + if (body.operationName) { + if (prev) { + str += ','; + } + str += `"operationName":"${body.operationName}"`; + prev = true; + } + if (body.extensions) { + if (prev) { + str += ','; + } + str += `"extensions":${JSON.stringify(body.extensions)}`; + } + str += '}'; + return str; +} \ No newline at end of file From 66d4b92ec6c93cfa4098b6caf98cffd89e210038 Mon Sep 17 00:00:00 2001 From: Arda TANRIKULU Date: Thu, 12 Dec 2024 05:29:16 +0300 Subject: [PATCH 7/7] Format --- packages/executors/http/src/index.ts | 2 +- packages/executors/http/src/utils.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/executors/http/src/index.ts b/packages/executors/http/src/index.ts index e3037b33..b35b0ce6 100644 --- a/packages/executors/http/src/index.ts +++ b/packages/executors/http/src/index.ts @@ -251,7 +251,7 @@ export function buildHTTPExecutor( ? request.operationName : undefined, extensions: - (request.extensions && Object.keys(request.extensions).length > 0) + request.extensions && Object.keys(request.extensions).length > 0 ? request.extensions : undefined, }; diff --git a/packages/executors/http/src/utils.ts b/packages/executors/http/src/utils.ts index d9c78dea..e86a0b47 100644 --- a/packages/executors/http/src/utils.ts +++ b/packages/executors/http/src/utils.ts @@ -75,4 +75,4 @@ export function jsonStringifyBody(body: SerializedRequest) { } str += '}'; return str; -} \ No newline at end of file +}