private async storeResponseAndReturnClone( response: Response, request: Request, policy: CachePolicy, cacheKey: string, cacheOptions?: | CacheOptions | ((response: Response, request: Request) => CacheOptions | undefined), ): Promise<Response> { if (cacheOptions && typeof cacheOptions === 'function') { cacheOptions = cacheOptions(response, request); } let ttlOverride = cacheOptions && cacheOptions.ttl; if ( // With a TTL override, only cache succesful responses but otherwise ignore method and response headers !(ttlOverride && (policy._status >= 200 && policy._status <= 299)) && // Without an override, we only cache GET requests and respect standard HTTP cache semantics !(request.method === 'GET' && policy.storable()) ) { return response; } let ttl = ttlOverride === undefined ? Math.round(policy.timeToLive() / 1000) : ttlOverride; if (ttl <= 0) return response; // If a response can be revalidated, we don't want to remove it from the cache right after it expires. // We may be able to use better heuristics here, but for now we'll take the max-age times 2. if (canBeRevalidated(response)) { ttl *= 2; } const body = await response.text(); const entry = JSON.stringify({ policy: policy.toObject(), ttlOverride, body, }); await this.keyValueCache.set(cacheKey, entry, { ttl, }); // We have to clone the response before returning it because the // body can only be used once. // To avoid https://github.com/bitinn/node-fetch/issues/151, we don't use // response.clone() but create a new response from the consumed body return new Response(body, { url: response.url, status: response.status, statusText: response.statusText, headers: response.headers, }); }
async fetch( request: Request, options: { cacheKey?: string; cacheOptions?: | CacheOptions | ((response: Response, request: Request) => CacheOptions | undefined); } = {}, ): Promise<Response> { const cacheKey = options.cacheKey ? options.cacheKey : request.url; const entry = await this.keyValueCache.get(cacheKey); if (!entry) { const response = await fetch(request); const policy = new CachePolicy( policyRequestFrom(request), policyResponseFrom(response), ); return this.storeResponseAndReturnClone( response, request, policy, cacheKey, options.cacheOptions, ); } const { policy: policyRaw, ttlOverride, body } = JSON.parse(entry); const policy = CachePolicy.fromObject(policyRaw); // Remove url from the policy, because otherwise it would never match a request with a custom cache key policy._url = undefined; if ( (ttlOverride && policy.age() < ttlOverride) || (!ttlOverride && policy.satisfiesWithoutRevalidation(policyRequestFrom(request))) ) { const headers = policy.responseHeaders(); return new Response(body, { url: policy._url, status: policy._status, headers, }); } else { const revalidationHeaders = policy.revalidationHeaders( policyRequestFrom(request), ); const revalidationRequest = new Request(request, { headers: revalidationHeaders, }); const revalidationResponse = await fetch(revalidationRequest); const { policy: revalidatedPolicy, modified } = policy.revalidatedPolicy( policyRequestFrom(revalidationRequest), policyResponseFrom(revalidationResponse), ); return this.storeResponseAndReturnClone( new Response(modified ? await revalidationResponse.text() : body, { url: revalidatedPolicy._url, status: revalidatedPolicy._status, headers: revalidatedPolicy.responseHeaders(), }), request, revalidatedPolicy, cacheKey, options.cacheOptions, ); } }
export async function processGraphQLRequest<TContext>( config: GraphQLRequestPipelineConfig<TContext>, requestContext: Mutable<GraphQLRequestContext<TContext>>, ): Promise<GraphQLResponse> { let cacheControlExtension: CacheControlExtension | undefined; const extensionStack = initializeExtensionStack(); (requestContext.context as any)._extensionStack = extensionStack; const dispatcher = initializeRequestListenerDispatcher(); initializeDataSources(); const request = requestContext.request; let { query, extensions } = request; let queryHash: string; let persistedQueryCache: KeyValueCache | undefined; let persistedQueryHit = false; let persistedQueryRegister = false; if (extensions && extensions.persistedQuery) { // It looks like we've received a persisted query. Check if we // support them. if (!config.persistedQueries || !config.persistedQueries.cache) { throw new PersistedQueryNotSupportedError(); } else if (extensions.persistedQuery.version !== 1) { throw new InvalidGraphQLRequestError( 'Unsupported persisted query version', ); } // We'll store a reference to the persisted query cache so we can actually // do the write at a later point in the request pipeline processing. persistedQueryCache = config.persistedQueries.cache; // This is a bit hacky, but if `config` came from direct use of the old // apollo-server 1.0-style middleware (graphqlExpress etc, not via the // ApolloServer class), it won't have been converted to // PrefixingKeyValueCache yet. if (!(persistedQueryCache instanceof PrefixingKeyValueCache)) { persistedQueryCache = new PrefixingKeyValueCache( persistedQueryCache, APQ_CACHE_PREFIX, ); } queryHash = extensions.persistedQuery.sha256Hash; if (query === undefined) { query = await persistedQueryCache.get(queryHash); if (query) { persistedQueryHit = true; } else { throw new PersistedQueryNotFoundError(); } } else { const computedQueryHash = computeQueryHash(query); if (queryHash !== computedQueryHash) { throw new InvalidGraphQLRequestError( 'provided sha does not match query', ); } // We won't write to the persisted query cache until later. // Defering 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). persistedQueryRegister = true; } } else if (query) { // FIXME: We'll compute the APQ query hash to use as our cache key for // now, but this should be replaced with the new operation ID algorithm. queryHash = computeQueryHash(query); } else { throw new InvalidGraphQLRequestError('Must provide query string.'); } requestContext.queryHash = queryHash; const requestDidEnd = extensionStack.requestDidStart({ request: request.http!, queryString: request.query, operationName: request.operationName, variables: request.variables, extensions: request.extensions, persistedQueryHit, persistedQueryRegister, context: requestContext.context, requestContext, }); try { // 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 (config.documentStore) { try { requestContext.document = await config.documentStore.get(queryHash); } catch (err) { console.warn( 'An error occurred while attempting to read from the documentStore.', err, ); } } // 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 dispatcher.invokeDidStartHook( 'parsingDidStart', requestContext, ); try { requestContext.document = parse(query, config.parseOptions); parsingDidEnd(); } catch (syntaxError) { parsingDidEnd(syntaxError); return sendErrorResponse(syntaxError, SyntaxError); } const validationDidEnd = await dispatcher.invokeDidStartHook( 'validationDidStart', requestContext as WithRequired<typeof requestContext, 'document'>, ); const validationErrors = validate(requestContext.document); if (validationErrors.length === 0) { validationDidEnd(); } else { validationDidEnd(validationErrors); return sendErrorResponse(validationErrors, ValidationError); } if (config.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( config.documentStore.set(queryHash, requestContext.document), ).catch(err => console.warn('Could not store validated document.', err), ); } } // FIXME: 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 && operation.name && operation.name.value) || null; await dispatcher.invokeHookAsync( 'didResolveOperation', requestContext as WithRequired< typeof requestContext, 'document' | 'operation' | 'operationName' >, ); // 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 (persistedQueryRegister && persistedQueryCache) { Promise.resolve(persistedQueryCache.set(queryHash, query)).catch( console.warn, ); } const executionDidEnd = await dispatcher.invokeDidStartHook( 'executionDidStart', requestContext as WithRequired< typeof requestContext, 'document' | 'operation' | 'operationName' >, ); let response: GraphQLResponse; try { response = (await execute( requestContext.document, request.operationName, request.variables, )) as GraphQLResponse; executionDidEnd(); } catch (executionError) { executionDidEnd(executionError); return sendErrorResponse(executionError); } const formattedExtensions = extensionStack.format(); if (Object.keys(formattedExtensions).length > 0) { response.extensions = formattedExtensions; } if (config.formatResponse) { response = config.formatResponse(response, { context: requestContext.context, }); } return sendResponse(response); } finally { requestDidEnd(); } function parse( query: string, parseOptions?: GraphQLParseOptions, ): DocumentNode { const parsingDidEnd = extensionStack.parsingDidStart({ queryString: query, }); try { return graphql.parse(query, parseOptions); } finally { parsingDidEnd(); } } function validate(document: DocumentNode): ReadonlyArray<GraphQLError> { let rules = specifiedRules; if (config.validationRules) { rules = rules.concat(config.validationRules); } const validationDidEnd = extensionStack.validationDidStart(); try { return graphql.validate(config.schema, document, rules); } finally { validationDidEnd(); } } async function execute( document: DocumentNode, operationName: GraphQLRequest['operationName'], variables: GraphQLRequest['variables'], ): Promise<ExecutionResult> { const executionArgs: ExecutionArgs = { schema: config.schema, document, rootValue: typeof config.rootValue === 'function' ? config.rootValue(document) : config.rootValue, contextValue: requestContext.context, variableValues: variables, operationName, fieldResolver: config.fieldResolver, }; const executionDidEnd = extensionStack.executionDidStart({ executionArgs, }); try { return await graphql.execute(executionArgs); } finally { executionDidEnd(); } } async function sendResponse( response: GraphQLResponse, ): Promise<GraphQLResponse> { // We override errors, data, and extensions with the passed in response, // but keep other properties (like http) requestContext.response = extensionStack.willSendResponse({ graphqlResponse: { ...requestContext.response, errors: response.errors, data: response.data, extensions: response.extensions, }, context: requestContext.context, }).graphqlResponse; await dispatcher.invokeHookAsync( 'willSendResponse', requestContext as WithRequired<typeof requestContext, 'response'>, ); return requestContext.response!; } function sendErrorResponse( errorOrErrors: ReadonlyArray<GraphQLError> | GraphQLError, errorClass?: typeof ApolloError, ) { // If a single error is passed, it should still be encapsulated in an array. const errors = Array.isArray(errorOrErrors) ? errorOrErrors : [errorOrErrors]; return sendResponse({ errors: errors.map(err => fromGraphQLError( err, errorClass && { errorClass, }, ), ), }); } function initializeRequestListenerDispatcher(): Dispatcher< GraphQLRequestListener > { const requestListeners: GraphQLRequestListener<TContext>[] = []; if (config.plugins) { for (const plugin of config.plugins) { if (!plugin.requestDidStart) continue; const listener = plugin.requestDidStart(requestContext); if (listener) { requestListeners.push(listener); } } } return new Dispatcher(requestListeners); } function initializeExtensionStack(): GraphQLExtensionStack<TContext> { enableGraphQLExtensions(config.schema); // If custom extension factories were provided, create per-request extension // objects. const extensions = config.extensions ? config.extensions.map(f => f()) : []; if (config.tracing) { extensions.push(new TracingExtension()); } if (config.cacheControl) { cacheControlExtension = new CacheControlExtension(config.cacheControl); extensions.push(cacheControlExtension); } return new GraphQLExtensionStack(extensions); } function initializeDataSources() { if (config.dataSources) { const context = requestContext.context; const dataSources = config.dataSources(); for (const dataSource of Object.values(dataSources)) { if (dataSource.initialize) { dataSource.initialize({ context, cache: requestContext.cache, }); } } if ('dataSources' in context) { throw new Error( 'Please use the dataSources config option instead of putting dataSources on the context yourself.', ); } (context as any).dataSources = dataSources; } } }