UNPKG

@apollo/server

Version:
713 lines (646 loc) 25.7 kB
import { createHash } from '@apollo/utils.createhash'; import { specifiedRules, getOperationAST, GraphQLError, validate, parse, Kind, type ExecutionResult, } from 'graphql'; import { symbolExecutionDispatcherWillResolveField, enablePluginsForSchemaResolvers, symbolUserFieldResolver, } from './utils/schemaInstrumentation.js'; import { PersistedQueryNotSupportedError, PersistedQueryNotFoundError, UserInputError, BadRequestError, ValidationError, SyntaxError, OperationResolutionError, } from './internalErrorClasses.js'; import { ensureError, normalizeAndFormatErrors, ensureGraphQLError, } from './errorNormalize.js'; import type { GraphQLRequestContext, GraphQLRequestContextDidResolveSource, GraphQLRequestContextExecutionDidStart, GraphQLRequestContextResponseForOperation, GraphQLRequestContextDidResolveOperation, GraphQLRequestContextParsingDidStart, GraphQLRequestContextValidationDidStart, GraphQLRequestContextWillSendResponse, GraphQLRequestContextDidEncounterErrors, GraphQLRequestExecutionListener, BaseContext, GraphQLResponse, GraphQLExperimentalFormattedSubsequentIncrementalExecutionResult, } from './externalTypes/index.js'; import { invokeDidStartHook, invokeHooksUntilDefinedAndNonNull, invokeSyncDidStartHook, } from './utils/invokeHooks.js'; import { makeGatewayGraphQLRequestContext } from './utils/makeGatewayGraphQLRequestContext.js'; import { mergeHTTPGraphQLHead, newHTTPGraphQLHead } from './runHttpQuery.js'; import type { ApolloServer, ApolloServerInternals, SchemaDerivedData, } from './ApolloServer.js'; import { isDefined } from './utils/isDefined.js'; import type { GraphQLRequestContextDidEncounterSubsequentErrors, GraphQLRequestContextWillSendSubsequentPayload, } from './externalTypes/requestPipeline.js'; import { executeIncrementally, type GraphQLExperimentalInitialIncrementalExecutionResult, type GraphQLExperimentalSubsequentIncrementalExecutionResult, } from './incrementalDeliveryPolyfill.js'; import { HeaderMap } from './utils/HeaderMap.js'; export const APQ_CACHE_PREFIX = 'apq:'; function computeQueryHash(query: string) { return createHash('sha256').update(query).digest('hex'); } type Mutable<T> = { -readonly [P in keyof T]: T[P] }; // Once GraphQL-JS v17 is released and we make a version of Apollo Server that // requires it, we can drop this hack, because it lets us break the `execute` // API into two steps and validate user input explicitly first. function isBadUserInputGraphQLError(error: GraphQLError): boolean { return ( error.nodes?.length === 1 && error.nodes[0].kind === Kind.VARIABLE_DEFINITION && // GraphQL-JS v17 alpha wording (error.message.startsWith( `Variable "$${error.nodes[0].variable.name.value}" has invalid value`, ) || // GraphQL-JS v16 wording error.message.startsWith( `Variable "$${error.nodes[0].variable.name.value}" got invalid value `, ) || error.message.startsWith( `Variable "$${error.nodes[0].variable.name.value}" of required type `, ) || error.message.startsWith( `Variable "$${error.nodes[0].variable.name.value}" of non-null type `, )) ); } // This is "semi-formatted" because the initial result has not yet been // formatted but the subsequent results "have been" --- in the sense that they // are an async iterable that will format them as they come in. type SemiFormattedExecuteIncrementallyResults = | { singleResult: ExecutionResult; } | { initialResult: GraphQLExperimentalInitialIncrementalExecutionResult; subsequentResults: AsyncIterable<GraphQLExperimentalFormattedSubsequentIncrementalExecutionResult>; }; export async function processGraphQLRequest<TContext extends BaseContext>( schemaDerivedData: SchemaDerivedData, server: ApolloServer<TContext>, internals: ApolloServerInternals<TContext>, requestContext: Mutable<GraphQLRequestContext<TContext>>, ): Promise<GraphQLResponse> { const requestListeners = ( await Promise.all( internals.plugins.map((p) => p.requestDidStart?.(requestContext)), ) ).filter(isDefined); const request = requestContext.request; let { query, extensions } = request; let queryHash: string; requestContext.metrics.persistedQueryHit = false; requestContext.metrics.persistedQueryRegister = false; if (extensions?.persistedQuery) { // It looks like we've received a persisted query. Check if we // support them. if (!internals.persistedQueries) { return await sendErrorResponse([new PersistedQueryNotSupportedError()]); } else if (extensions.persistedQuery.version !== 1) { return await sendErrorResponse([ new GraphQLError('Unsupported persisted query version', { extensions: { http: newHTTPGraphQLHead(400) }, }), ]); } queryHash = extensions.persistedQuery.sha256Hash; if (query === undefined) { query = await internals.persistedQueries.cache.get(queryHash); if (query) { requestContext.metrics.persistedQueryHit = true; } else { return await sendErrorResponse([new PersistedQueryNotFoundError()]); } } else { const computedQueryHash = computeQueryHash(query); // The provided hash must exactly match the SHA-256 hash of // the query string. This prevents hash hijacking, where a // new and potentially malicious query is associated with // an existing hash. if (queryHash !== computedQueryHash) { return await sendErrorResponse([ new GraphQLError('provided sha does not match query', { extensions: { http: newHTTPGraphQLHead(400) }, }), ]); } // We won't write to the persisted query cache until later. // Deferring the writing gives plugins the ability to "win" from use of // the cache, but also have their say in whether or not the cache is // written to (by interrupting the request with an error). requestContext.metrics.persistedQueryRegister = true; } } else if (query) { queryHash = computeQueryHash(query); } else { return await sendErrorResponse([ new BadRequestError( 'GraphQL operations must contain a non-empty `query` or a `persistedQuery` extension.', ), ]); } requestContext.queryHash = queryHash; requestContext.source = query; // Let the plugins know that we now have a STRING of what we hope will // parse and validate into a document we can execute on. Unless we have // retrieved this from our APQ cache, there's no guarantee that it is // syntactically correct, so this string should not be trusted as a valid // document until after it's parsed and validated. await Promise.all( requestListeners.map((l) => l.didResolveSource?.( requestContext as GraphQLRequestContextDidResolveSource<TContext>, ), ), ); // If we're configured with a document store (by default, we are), we'll // utilize the operation's hash to lookup the AST from the previously // parsed-and-validated operation. Failure to retrieve anything from the // cache just means we're committed to doing the parsing and validation. if (schemaDerivedData.documentStore) { try { requestContext.document = await schemaDerivedData.documentStore.get( schemaDerivedData.documentStoreKeyPrefix + queryHash, ); } catch (err: unknown) { server.logger.warn( 'An error occurred while attempting to read from the documentStore. ' + ensureError(err).message, ); } } // If we still don't have a document, we'll need to parse and validate it. // With success, we'll attempt to save it into the store for future use. if (!requestContext.document) { const parsingDidEnd = await invokeDidStartHook( requestListeners, async (l) => l.parsingDidStart?.( requestContext as GraphQLRequestContextParsingDidStart<TContext>, ), ); try { requestContext.document = parse(query, internals.parseOptions); } catch (syntaxMaybeError: unknown) { const error = ensureError(syntaxMaybeError); await parsingDidEnd(error); return await sendErrorResponse([ new SyntaxError(ensureGraphQLError(error)), ]); } await parsingDidEnd(); if (internals.dangerouslyDisableValidation !== true) { const validationDidEnd = await invokeDidStartHook( requestListeners, async (l) => l.validationDidStart?.( requestContext as GraphQLRequestContextValidationDidStart<TContext>, ), ); let validationErrors = validate( schemaDerivedData.schema, requestContext.document, [...specifiedRules, ...internals.validationRules], ); if (validationErrors.length === 0 && internals.laterValidationRules) { validationErrors = validate( schemaDerivedData.schema, requestContext.document, internals.laterValidationRules, ); } if (validationErrors.length === 0) { await validationDidEnd(); } else { await validationDidEnd(validationErrors); return await sendErrorResponse( validationErrors.map((error) => new ValidationError(error)), ); } } if (schemaDerivedData.documentStore) { // The underlying cache store behind the `documentStore` returns a // `Promise` which is resolved (or rejected), eventually, based on the // success or failure (respectively) of the cache save attempt. While // it's certainly possible to `await` this `Promise`, we don't care about // whether or not it's successful at this point. We'll instead proceed // to serve the rest of the request and just hope that this works out. // If it doesn't work, the next request will have another opportunity to // try again. Errors will surface as warnings, as appropriate. // // While it shouldn't normally be necessary to wrap this `Promise` in a // `Promise.resolve` invocation, it seems that the underlying cache store // is returning a non-native `Promise` (e.g. Bluebird, etc.). Promise.resolve( schemaDerivedData.documentStore.set( schemaDerivedData.documentStoreKeyPrefix + queryHash, requestContext.document, ), ).catch((err) => server.logger.warn( 'Could not store validated document. ' + err?.message || err, ), ); } } // TODO: If we want to guarantee an operation has been set when invoking // `willExecuteOperation` and executionDidStart`, we need to throw an // error here and not leave this to `buildExecutionContext` in // `graphql-js`. const operation = getOperationAST( requestContext.document, request.operationName, ); requestContext.operation = operation || undefined; // We'll set `operationName` to `null` for anonymous operations. requestContext.operationName = operation?.name?.value || null; // Special case: GET operations should only be queries (not mutations). We // want to throw a particular HTTP error in that case. (This matters because // it's generally how HTTP requests should work, and additionally it makes us // less vulnerable to mutations running over CSRF, if you turn off our CSRF // prevention feature.) if ( request.http?.method === 'GET' && operation?.operation && operation.operation !== 'query' ) { return await sendErrorResponse([ new BadRequestError( `GET requests only support query operations, not ${operation.operation} operations`, { extensions: { http: { status: 405, headers: new HeaderMap([['allow', 'POST']]) }, }, }, ), ]); } try { await Promise.all( requestListeners.map((l) => l.didResolveOperation?.( requestContext as GraphQLRequestContextDidResolveOperation<TContext>, ), ), ); } catch (err: unknown) { // Note that we explicitly document throwing `GraphQLError`s from // `didResolveOperation` as a good way to do validation that depends on the // validated operation and the request context. (It will have status 500 by // default.) return await sendErrorResponse([ensureGraphQLError(err)]); } // Now that we've gone through the pre-execution phases of the request // pipeline, and given plugins appropriate ability to object (by throwing // an error) and not actually write, we'll write to the cache if it was // determined earlier in the request pipeline that we should do so. if ( requestContext.metrics.persistedQueryRegister && internals.persistedQueries ) { // While it shouldn't normally be necessary to wrap this `Promise` in a // `Promise.resolve` invocation, it seems that the underlying cache store // is returning a non-native `Promise` (e.g. Bluebird, etc.). const ttl = internals.persistedQueries?.ttl; Promise.resolve( internals.persistedQueries.cache.set( queryHash, query, // Explicitly checking for `undefined` which means "not set" vs 0 or // null which means "no TTL". ttl !== undefined ? { ttl: internals.persistedQueries?.ttl } : undefined, ), ).catch(server.logger.warn.bind(server.logger)); } const responseFromPlugin = await invokeHooksUntilDefinedAndNonNull( requestListeners, async (l) => await l.responseForOperation?.( requestContext as GraphQLRequestContextResponseForOperation<TContext>, ), ); if (responseFromPlugin !== null) { requestContext.response.body = responseFromPlugin.body; mergeHTTPGraphQLHead(requestContext.response.http, responseFromPlugin.http); } else { const executionListeners = ( await Promise.all( requestListeners.map((l) => l.executionDidStart?.( requestContext as GraphQLRequestContextExecutionDidStart<TContext>, ), ), ) ).filter(isDefined); executionListeners.reverse(); if (executionListeners.some((l) => l.willResolveField)) { // Create a callback that will trigger the execution dispatcher's // `willResolveField` hook. We will attach this to the context on a // symbol so it can be invoked by our `wrapField` method during execution. const invokeWillResolveField: GraphQLRequestExecutionListener<TContext>['willResolveField'] = (...args) => invokeSyncDidStartHook(executionListeners, (l) => l.willResolveField?.(...args), ); Object.defineProperty( requestContext.contextValue, symbolExecutionDispatcherWillResolveField, { value: invokeWillResolveField }, ); // If the user has provided a custom field resolver, we will attach // it to the context so we can still invoke it after we've wrapped the // fields with `wrapField` within `enablePluginsForSchemaResolvers` of // the `schemaInstrumentation` module. if (internals.fieldResolver) { Object.defineProperty( requestContext.contextValue, symbolUserFieldResolver, { value: internals.fieldResolver, }, ); } // If the schema is already enabled, this is a no-op. Otherwise, the // schema will be augmented so it is able to invoke willResolveField. Note // that if we never see a plugin with willResolveField then we will never // need to instrument the schema, which might be a small performance gain. // (For example, this can happen if you pass `fieldLevelInstrumentation: // () => false` to the usage reporting plugin and disable the cache // control plugin. We can consider changing the cache control plugin to // have a "static cache control only" mode that doesn't use // willResolveField too if this proves to be helpful in practice.) enablePluginsForSchemaResolvers(schemaDerivedData.schema); } try { const fullResult = await execute( requestContext as GraphQLRequestContextExecutionDidStart<TContext>, ); const result = 'singleResult' in fullResult ? fullResult.singleResult : fullResult.initialResult; // If we don't have an operation, there's no reason to go further. We know // `result` will consist of one error (returned by `graphql-js`'s // `buildExecutionContext`). if (!requestContext.operation) { if (!result.errors?.length) { throw new Error( 'Unexpected error: Apollo Server did not resolve an operation but execute did not return errors', ); } throw new OperationResolutionError(result.errors[0]); } // The first thing that execution does is coerce the request's variables // to the types declared in the operation, which can lead to errors if // they are of the wrong type. It also makes sure that all non-null // variables are required and get non-null values. If any of these things // lead to errors, we change them into UserInputError so that their code // doesn't end up being INTERNAL_SERVER_ERROR, since these are client // errors. (But if the error already has a code, perhaps because the // original error was thrown from a custom scalar parseValue, we leave it // alone. We check that here instead of as part of // isBadUserInputGraphQLError since perhaps that function will one day be // changed to something we can get directly from graphql-js, but the // `code` check is AS-specific.) // // This is hacky! Hopefully graphql-js will give us a way to separate // variable resolution from execution later; see // https://github.com/graphql/graphql-js/issues/3169 const resultErrors = result.errors?.map((e) => { if (isBadUserInputGraphQLError(e) && e.extensions?.code == null) { return new UserInputError(e); } return e; }); if (resultErrors) { await didEncounterErrors(resultErrors); } const { formattedErrors, httpFromErrors } = resultErrors ? formatErrors(resultErrors) : { formattedErrors: undefined, httpFromErrors: newHTTPGraphQLHead() }; // TODO(AS6): remove `status400ForVariableCoercionErrors` if ( internals.status400ForVariableCoercionErrors && resultErrors?.length && result.data === undefined && !httpFromErrors.status ) { httpFromErrors.status = 400; } mergeHTTPGraphQLHead(requestContext.response.http, httpFromErrors); if ('singleResult' in fullResult) { requestContext.response.body = { kind: 'single', singleResult: { ...result, errors: formattedErrors, }, }; } else { requestContext.response.body = { kind: 'incremental', initialResult: { ...fullResult.initialResult, errors: formattedErrors, }, subsequentResults: fullResult.subsequentResults, }; } } catch (executionMaybeError: unknown) { const executionError = ensureError(executionMaybeError); await Promise.all( executionListeners.map((l) => l.executionDidEnd?.(executionError)), ); return await sendErrorResponse([ensureGraphQLError(executionError)]); } await Promise.all(executionListeners.map((l) => l.executionDidEnd?.())); } await invokeWillSendResponse(); if (!requestContext.response.body) { throw Error('got to end of processGraphQLRequest without setting body?'); } return requestContext.response as GraphQLResponse; // cast checked on previous line async function execute( requestContext: GraphQLRequestContextExecutionDidStart<TContext>, ): Promise<SemiFormattedExecuteIncrementallyResults> { const { request, document } = requestContext; if (internals.__testing_incrementalExecutionResults) { return internals.__testing_incrementalExecutionResults; } else if (internals.gatewayExecutor) { const result = await internals.gatewayExecutor( makeGatewayGraphQLRequestContext(requestContext, server, internals), ); return { singleResult: result }; } else { const resultOrResults = await executeIncrementally({ schema: schemaDerivedData.schema, document, rootValue: typeof internals.rootValue === 'function' ? internals.rootValue(document) : internals.rootValue, contextValue: requestContext.contextValue, variableValues: request.variables, operationName: request.operationName, fieldResolver: internals.fieldResolver, }); if ('initialResult' in resultOrResults) { return { initialResult: resultOrResults.initialResult, subsequentResults: formatErrorsInSubsequentResults( resultOrResults.subsequentResults, ), }; } else { return { singleResult: resultOrResults }; } } } async function* formatErrorsInSubsequentResults( results: AsyncIterable<GraphQLExperimentalSubsequentIncrementalExecutionResult>, ): AsyncIterable<GraphQLExperimentalFormattedSubsequentIncrementalExecutionResult> { for await (const result of results) { const payload: GraphQLExperimentalFormattedSubsequentIncrementalExecutionResult = result.incremental ? { ...result, incremental: await seriesAsyncMap( result.incremental, async (incrementalResult) => { const { errors } = incrementalResult; if (errors) { await Promise.all( requestListeners.map((l) => l.didEncounterSubsequentErrors?.( requestContext as GraphQLRequestContextDidEncounterSubsequentErrors<TContext>, errors, ), ), ); return { ...incrementalResult, // Note that any `http` extensions in errors have no // effect, because we've already sent the status code // and response headers. errors: formatErrors(errors).formattedErrors, }; } return incrementalResult; }, ), } : result; // Invoke hook, which is allowed to mutate payload if it really wants to. await Promise.all( requestListeners.map((l) => l.willSendSubsequentPayload?.( requestContext as GraphQLRequestContextWillSendSubsequentPayload<TContext>, payload, ), ), ); yield payload; } } async function invokeWillSendResponse() { await Promise.all( requestListeners.map((l) => l.willSendResponse?.( requestContext as GraphQLRequestContextWillSendResponse<TContext>, ), ), ); } // Note that we ensure that all calls to didEncounterErrors are followed by // calls to willSendResponse. (The usage reporting plugin depends on this.) async function didEncounterErrors(errors: ReadonlyArray<GraphQLError>) { requestContext.errors = errors; return await Promise.all( requestListeners.map((l) => l.didEncounterErrors?.( requestContext as GraphQLRequestContextDidEncounterErrors<TContext>, ), ), ); } // This function "sends" a response that contains errors and no data (not even // `data: null`) because the pipeline does not make it to a successful // `execute` call. (It is *not* called for execution that happens to return // some errors.) In this case "send" means "update requestContext.response and // invoke willSendResponse hooks". // // If any errors have `extensions.http` set, it sets the response's status code // and errors from them. // // Then, if the HTTP status code is not yet set, it sets it to 500. async function sendErrorResponse( errors: ReadonlyArray<GraphQLError>, ): Promise<GraphQLResponse> { await didEncounterErrors(errors); const { formattedErrors, httpFromErrors } = formatErrors(errors); requestContext.response.body = { kind: 'single', singleResult: { errors: formattedErrors, }, }; mergeHTTPGraphQLHead(requestContext.response.http, httpFromErrors); if (!requestContext.response.http.status) { requestContext.response.http.status = 500; } await invokeWillSendResponse(); // cast safe because we assigned to `body` above return requestContext.response as GraphQLResponse; } function formatErrors( errors: ReadonlyArray<GraphQLError>, ): ReturnType<typeof normalizeAndFormatErrors> { return normalizeAndFormatErrors(errors, { formatError: internals.formatError, includeStacktraceInErrorResponses: internals.includeStacktraceInErrorResponses, }); } } async function seriesAsyncMap<T, U>( ts: readonly T[], fn: (value: T) => Promise<U>, ): Promise<U[]> { const us: U[] = []; for (const t of ts) { const u = await fn(t); us.push(u); } return us; }