UNPKG

@apollo/gateway

Version:
332 lines (293 loc) 11.7 kB
import { isObject } from '../utilities/predicates'; import { GraphQLDataSource, GraphQLDataSourceProcessOptions, GraphQLDataSourceRequestKind } from './types'; import { createHash } from '@apollo/utils.createhash'; import { ResponsePath } from '@apollo/query-planner'; import { parseCacheControlHeader } from './parseCacheControlHeader'; import fetcher from 'make-fetch-happen'; import { Headers as NodeFetchHeaders, Request as NodeFetchRequest } from 'node-fetch'; import { Fetcher, FetcherRequestInit, FetcherResponse } from '@apollo/utils.fetcher'; import { GraphQLError, GraphQLErrorExtensions } from 'graphql'; import { GatewayCacheHint, GatewayCachePolicy, GatewayGraphQLRequest, GatewayGraphQLRequestContext, GatewayGraphQLResponse } from '@apollo/server-gateway-interface'; export class RemoteGraphQLDataSource< TContext extends Record<string, any> = Record<string, any>, > implements GraphQLDataSource<TContext> { fetcher: Fetcher; constructor( config?: Partial<RemoteGraphQLDataSource<TContext>> & object & ThisType<RemoteGraphQLDataSource<TContext>>, ) { this.fetcher = fetcher.defaults({ // Allow an arbitrary number of sockets per subgraph. This is the default // behavior of Node's http.Agent as well as the npm package agentkeepalive // which wraps it, but is not the default behavior of make-fetch-happen // which wraps agentkeepalive (that package sets this to 15 by default). maxSockets: Infinity, // although this is the default, we want to take extra care and be very // explicity to ensure that mutations cannot be retried. please leave this // intact. retry: false, }); if (config) { return Object.assign(this, config); } } url!: string; /** * Whether the downstream request should be made with automated persisted * query (APQ) behavior enabled. * * @remarks When enabled, the request to the downstream service will first be * attempted using a SHA-256 hash of the operation rather than including the * operation itself. If the downstream server supports APQ and has this * operation registered in its APQ storage, it will be able to complete the * request without the entirety of the operation document being transmitted. * * In the event that the downstream service is unaware of the operation, it * will respond with an `PersistedQueryNotFound` error and it will be resent * with the full operation body for fulfillment. * * Generally speaking, when the downstream server is processing similar * operations repeatedly, APQ can offer substantial network savings in terms * of bytes transmitted over the wire between gateways and downstream servers. */ apq: boolean = false; /** * Should cache-control response headers from subgraphs affect the operation's * cache policy? If it shouldn't, set this to false. */ honorSubgraphCacheControlHeader: boolean = true; async process( options: GraphQLDataSourceProcessOptions<TContext>, ): Promise<GatewayGraphQLResponse> { const { request, context: originalContext } = options; const pathInIncomingRequest = options.kind === GraphQLDataSourceRequestKind.INCOMING_OPERATION ? options.pathInIncomingRequest : undefined; // Deal with a bit of a hairy situation in typings: when doing health checks // and schema checks we always pass in `{}` as the context even though it's // not really guaranteed to be a `TContext`, and then we pass it to various // methods on this object. The reason this "works" is that the DataSourceMap // and Service types aren't generic-ized on TContext at all (so `{}` is in // practice always legal there)... ie, the genericness of this class is // questionable in the first place. const context = originalContext as TContext; // Respect incoming http headers (eg, apollo-federation-include-trace). const headers = new NodeFetchHeaders(); if (request.http?.headers) { for (const [name, value] of request.http.headers) { headers.append(name, value); } } headers.set('Content-Type', 'application/json'); request.http = { method: 'POST', url: this.url, headers, }; if (this.willSendRequest) { await this.willSendRequest(options); } if (!request.query) { throw new Error('Missing query'); } const { query, ...requestWithoutQuery } = request; // Special handling of cache-control headers in response. Requires // Apollo Server 3, so we check to make sure the method we want is // there. const overallCachePolicy = this.honorSubgraphCacheControlHeader && options.kind === GraphQLDataSourceRequestKind.INCOMING_OPERATION && options.incomingRequestContext.overallCachePolicy && 'restrict' in options.incomingRequestContext.overallCachePolicy ? options.incomingRequestContext.overallCachePolicy : null; if (this.apq) { const apqHash = createHash('sha256').update(request.query).digest('hex'); // Take the original extensions and extend them with // the necessary "extensions" for APQ handshaking. requestWithoutQuery.extensions = { ...request.extensions, persistedQuery: { version: 1, sha256Hash: apqHash, }, }; const apqOptimisticResponse = await this.sendRequest( requestWithoutQuery, context, ); // If we didn't receive notice to retry with APQ, then let's // assume this is the best result we'll get and return it! if ( !apqOptimisticResponse.errors || !apqOptimisticResponse.errors.find( (error) => error.message === 'PersistedQueryNotFound', ) ) { return this.respond({ response: apqOptimisticResponse, request: requestWithoutQuery, context, overallCachePolicy, pathInIncomingRequest }); } } // If APQ was enabled, we'll run the same request again, but add in the // previously omitted `query`. If APQ was NOT enabled, this is the first // request (non-APQ, all the way). const requestWithQuery: GatewayGraphQLRequest = { query, ...requestWithoutQuery, }; const response = await this.sendRequest(requestWithQuery, context); return this.respond({ response, request: requestWithQuery, context, overallCachePolicy, pathInIncomingRequest }); } private async sendRequest( request: GatewayGraphQLRequest, context: TContext, ): Promise<GatewayGraphQLResponse> { // This would represent an internal programming error since this shouldn't // be possible in the way that this method is invoked right now. if (!request.http) { throw new Error("Internal error: Only 'http' requests are supported."); } // We don't want to serialize the `http` properties into the body that is // being transmitted. Instead, we want those to be used to indicate what // we're accessing (e.g. url) and what we access it with (e.g. headers). const { http, ...requestWithoutHttp } = request; const stringifiedRequestWithoutHttp = JSON.stringify(requestWithoutHttp); const requestInit: FetcherRequestInit = { method: http.method, headers: Object.fromEntries(http.headers), body: stringifiedRequestWithoutHttp, }; // Note that we don't actually send this Request object to the fetcher; it // is merely sent to methods on this object that might be overridden by users. // We are careful to only send data to the overridable fetcher function that uses // plain JS objects --- some fetch implementations don't know how to handle // Request or Headers objects created by other fetch implementations. const fetchRequest = new NodeFetchRequest(http.url, requestInit); let fetchResponse: FetcherResponse | undefined; try { // Use our local `fetcher` to allow for fetch injection // Use the fetcher's `Request` implementation for compatibility fetchResponse = await this.fetcher(http.url, requestInit); if (!fetchResponse.ok) { throw await this.errorFromResponse(fetchResponse); } const body = await this.parseBody(fetchResponse, fetchRequest, context); if (!isObject(body)) { throw new Error(`Expected JSON response body, but received: ${body}`); } return { ...body, http: fetchResponse, }; } catch (error) { this.didEncounterError(error, fetchRequest, fetchResponse, context, request); throw error; } } public willSendRequest?( options: GraphQLDataSourceProcessOptions<TContext>, ): void | Promise<void>; private async respond({ response, request, context, overallCachePolicy, pathInIncomingRequest }: { response: GatewayGraphQLResponse; request: GatewayGraphQLRequest; context: TContext; overallCachePolicy: GatewayCachePolicy | null; pathInIncomingRequest?: ResponsePath }): Promise<GatewayGraphQLResponse> { const processedResponse = typeof this.didReceiveResponse === 'function' ? await this.didReceiveResponse({ response, request, context, pathInIncomingRequest }) : response; if (overallCachePolicy) { const parsed = parseCacheControlHeader( response.http?.headers.get('cache-control'), ); // If the subgraph does not specify a max-age, we assume its response (and // thus the overall response) is uncacheable. (If you don't like this, you // can tweak the `cache-control` header in your `didReceiveResponse` // method.) const hint: GatewayCacheHint = { maxAge: 0 }; const maxAge = parsed['max-age']; if (typeof maxAge === 'string' && maxAge.match(/^[0-9]+$/)) { hint.maxAge = +maxAge; } if (parsed['private'] === true) { hint.scope = 'PRIVATE'; } if (parsed['public'] === true) { hint.scope = 'PUBLIC'; } overallCachePolicy.restrict(hint); } return processedResponse; } public didReceiveResponse?( requestContext: Required< Pick<GatewayGraphQLRequestContext<TContext>, 'request' | 'response' | 'context'> > & { pathInIncomingRequest?: ResponsePath } ): GatewayGraphQLResponse | Promise<GatewayGraphQLResponse>; public didEncounterError( error: Error, _fetchRequest: NodeFetchRequest, _fetchResponse?: FetcherResponse, _context?: TContext, _request?: GatewayGraphQLRequest, ) { throw error; } public parseBody( fetchResponse: FetcherResponse, _fetchRequest?: NodeFetchRequest, _context?: TContext, ): Promise<object | string> { const contentType = fetchResponse.headers.get('Content-Type'); if ( contentType && (contentType.startsWith('application/json') || contentType.startsWith('application/graphql-response+json')) ) { return fetchResponse.json(); } else { return fetchResponse.text(); } } public async errorFromResponse(response: FetcherResponse) { const body = await this.parseBody(response); const extensions: GraphQLErrorExtensions = { response: { url: response.url, status: response.status, statusText: response.statusText, body, }, }; if (response.status === 401) { extensions.code = 'UNAUTHENTICATED'; } else if (response.status === 403) { extensions.code = 'FORBIDDEN'; } return new GraphQLError(`${response.status}: ${response.statusText}`, { extensions, }); } }