@subsquid/apollo-server-core
Version:
Core engine for Apollo GraphQL server
657 lines (578 loc) • 22.7 kB
text/typescript
import {
GraphQLSchema,
GraphQLFieldResolver,
specifiedRules,
DocumentNode,
getOperationAST,
ExecutionArgs,
GraphQLError,
GraphQLFormattedError,
validate as graphqlValidate,
parse as graphqlParse,
execute as graphqlExecute,
Kind,
ParseOptions,
} from 'graphql';
import type { DataSource } from 'apollo-datasource';
import type { PersistedQueryOptions, ValidateOptions } from './graphqlOptions';
import {
symbolExecutionDispatcherWillResolveField,
enablePluginsForSchemaResolvers,
symbolUserFieldResolver,
} from './utils/schemaInstrumentation';
import {
ApolloError,
fromGraphQLError,
SyntaxError,
ValidationError,
PersistedQueryNotSupportedError,
PersistedQueryNotFoundError,
formatApolloErrors,
UserInputError,
} from 'apollo-server-errors';
import type {
GraphQLRequest,
GraphQLResponse,
GraphQLRequestContext,
GraphQLExecutor,
GraphQLExecutionResult,
ValidationRule,
BaseContext,
} from 'apollo-server-types';
import type {
ApolloServerPlugin,
GraphQLRequestListener,
GraphQLRequestContextDidResolveSource,
GraphQLRequestContextExecutionDidStart,
GraphQLRequestContextResponseForOperation,
GraphQLRequestContextDidResolveOperation,
GraphQLRequestContextParsingDidStart,
GraphQLRequestContextValidationDidStart,
GraphQLRequestContextWillSendResponse,
GraphQLRequestContextDidEncounterErrors,
GraphQLRequestExecutionListener,
} from 'apollo-server-plugin-base';
import { Dispatcher } from './utils/dispatcher';
import {
KeyValueCache,
PrefixingKeyValueCache,
} from '@apollo/utils.keyvaluecache';
export { GraphQLRequest, GraphQLResponse, GraphQLRequestContext };
import createSHA from './utils/createSHA';
import { HttpQueryError } from './runHttpQuery';
import type { DocumentStore } from './types';
import { Headers } from 'apollo-server-env';
export const APQ_CACHE_PREFIX = 'apq:';
function computeQueryHash(query: string) {
return createSHA('sha256').update(query).digest('hex');
}
export interface GraphQLRequestPipelineConfig<TContext> {
schema: GraphQLSchema;
rootValue?: ((document: DocumentNode) => any) | any;
validationRules?: ValidationRule[];
executor?: GraphQLExecutor;
fieldResolver?: GraphQLFieldResolver<any, TContext>;
dataSources?: () => DataSources<TContext>;
persistedQueries?: PersistedQueryOptions;
formatError?: (error: GraphQLError) => GraphQLFormattedError;
formatResponse?: (
response: GraphQLResponse,
requestContext: GraphQLRequestContext<TContext>,
) => GraphQLResponse | null;
plugins?: ApolloServerPlugin[];
dangerouslyDisableValidation?: boolean;
documentStore?: DocumentStore | null;
parseOptions?: ParseOptions;
validateOptions?: ValidateOptions;
}
export type DataSources<TContext> = {
[name: string]: DataSource<TContext>;
};
type Mutable<T> = { -readonly [P in keyof T]: T[P] };
function isBadUserInputGraphQLError(error: GraphQLError): Boolean {
return (
error.nodes?.length === 1 &&
error.nodes[0].kind === Kind.VARIABLE_DEFINITION &&
(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 `,
))
);
}
export async function processGraphQLRequest<TContext extends BaseContext>(
config: GraphQLRequestPipelineConfig<TContext>,
requestContext: Mutable<GraphQLRequestContext<TContext>>,
): Promise<GraphQLResponse> {
// For legacy reasons, this exported method may exist without a `logger` on
// the context. We'll need to make sure we account for that, even though
// all of our own machinery will certainly set it now.
const logger = requestContext.logger || console;
// If request context's `metrics` already exists, preserve it, but _ensure_ it
// exists there and shorthand it for use throughout this function.
const metrics = (requestContext.metrics =
requestContext.metrics || Object.create(null));
const dispatcher = await initializeRequestListenerDispatcher();
await initializeDataSources();
const request = requestContext.request;
let { query, extensions } = request;
let queryHash: string;
let persistedQueryCache: KeyValueCache | undefined;
metrics.persistedQueryHit = false;
metrics.persistedQueryRegister = false;
if (extensions?.persistedQuery) {
// It looks like we've received a persisted query. Check if we
// support them.
if (!config.persistedQueries || !config.persistedQueries.cache) {
return await sendErrorResponse(new PersistedQueryNotSupportedError());
} else if (extensions.persistedQuery.version !== 1) {
return await sendErrorResponse(
new GraphQLError('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) {
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'),
);
}
// 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).
metrics.persistedQueryRegister = true;
}
} else if (query) {
// TODO: 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 {
return await sendErrorResponse(
new GraphQLError(
'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 dispatcher.invokeHook(
'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 (config.documentStore) {
try {
requestContext.document = await config.documentStore.get(queryHash);
} catch (err) {
logger.warn(
'An error occurred while attempting to read from the documentStore. ' +
(err as Error)?.message || 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 as GraphQLRequestContextParsingDidStart<TContext>,
);
try {
requestContext.document = parse(query, config.parseOptions);
await parsingDidEnd();
} catch (syntaxError) {
await parsingDidEnd(syntaxError as Error);
// XXX: This cast is pretty sketchy, as other error types can be thrown
// by parsingDidEnd!
return await sendErrorResponse(syntaxError as GraphQLError, SyntaxError);
}
if (config.dangerouslyDisableValidation !== true) {
const validationDidEnd = await dispatcher.invokeDidStartHook(
'validationDidStart',
requestContext as GraphQLRequestContextValidationDidStart<TContext>,
);
const validationErrors = validate(requestContext.document);
if (validationErrors.length === 0) {
await validationDidEnd();
} else {
await validationDidEnd(validationErrors);
return await 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) =>
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;
try {
await dispatcher.invokeHook(
'didResolveOperation',
requestContext as GraphQLRequestContextDidResolveOperation<TContext>,
);
} catch (err) {
// XXX: This cast is pretty sketchy, as other error types can be thrown
// by didResolveOperation!
return await sendErrorResponse(err as GraphQLError);
}
// 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 (metrics.persistedQueryRegister && persistedQueryCache) {
// 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(
persistedQueryCache.set(
queryHash,
query,
config.persistedQueries &&
typeof config.persistedQueries.ttl !== 'undefined'
? {
ttl: config.persistedQueries.ttl,
}
: Object.create(null),
),
).catch(logger.warn);
}
let response: GraphQLResponse | null =
await dispatcher.invokeHooksUntilNonNull(
'responseForOperation',
requestContext as GraphQLRequestContextResponseForOperation<TContext>,
);
if (response == null) {
// This execution dispatcher code is duplicated in `pluginTestHarness`
// right now.
const executionListeners: GraphQLRequestExecutionListener<TContext>[] = [];
(
await dispatcher.invokeHook(
'executionDidStart',
requestContext as GraphQLRequestContextExecutionDidStart<TContext>,
)
).forEach((executionListener) => {
if (executionListener) {
executionListeners.push(executionListener);
}
});
executionListeners.reverse();
const executionDispatcher = new Dispatcher(executionListeners);
if (executionDispatcher.hasHook('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) =>
executionDispatcher.invokeSyncDidStartHook(
'willResolveField',
...args,
);
Object.defineProperty(
requestContext.context,
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 (config.fieldResolver) {
Object.defineProperty(requestContext.context, symbolUserFieldResolver, {
value: config.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(config.schema);
}
try {
const result = await execute(
requestContext as GraphQLRequestContextExecutionDidStart<TContext>,
);
// 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.
//
// 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)) {
return fromGraphQLError(e, {
errorClass: UserInputError,
});
}
return e;
});
if (resultErrors) {
await didEncounterErrors(resultErrors);
}
response = {
...result,
errors: resultErrors ? formatErrors(resultErrors) : undefined,
};
await executionDispatcher.invokeHook('executionDidEnd');
} catch (executionError) {
await executionDispatcher.invokeHook(
'executionDidEnd',
executionError as Error,
);
// XXX: This cast is pretty sketchy, as other error types can be thrown
// in the try block!
return await sendErrorResponse(executionError as GraphQLError);
}
}
if (config.formatResponse) {
const formattedResponse: GraphQLResponse | null = config.formatResponse(
response,
requestContext,
);
if (formattedResponse != null) {
response = formattedResponse;
}
}
return sendResponse(response);
function parse(query: string, parseOptions?: ParseOptions): DocumentNode {
return graphqlParse(query, parseOptions);
}
function validate(document: DocumentNode): ReadonlyArray<GraphQLError> {
let rules = specifiedRules;
if (config.validationRules) {
rules = rules.concat(config.validationRules);
}
return graphqlValidate(config.schema, document, rules, undefined, config.validateOptions);
}
async function execute(
requestContext: GraphQLRequestContextExecutionDidStart<TContext>,
): Promise<GraphQLExecutionResult> {
const { request, document } = requestContext;
const executionArgs: ExecutionArgs = {
schema: config.schema,
document,
rootValue:
typeof config.rootValue === 'function'
? config.rootValue(document)
: config.rootValue,
contextValue: requestContext.context,
variableValues: request.variables,
operationName: request.operationName,
fieldResolver: config.fieldResolver,
};
if (config.executor) {
// XXX Nothing guarantees that the only errors thrown or returned
// in result.errors are GraphQLErrors, even though other code
// (eg usage reporting) assumes that.
return await config.executor(requestContext);
} else {
return await graphqlExecute(executionArgs);
}
}
async function sendResponse(
response: GraphQLResponse,
): Promise<GraphQLResponse> {
requestContext.response = {
...requestContext.response,
errors: response.errors,
data: response.data,
extensions: response.extensions,
};
if (response.http) {
if (!requestContext.response.http) {
requestContext.response.http = {
headers: new Headers(),
};
}
if (response.http.status) {
requestContext.response.http.status = response.http.status;
}
for (const [name, value] of response.http.headers) {
requestContext.response.http.headers.set(name, value);
}
}
await dispatcher.invokeHook(
'willSendResponse',
requestContext as GraphQLRequestContextWillSendResponse<TContext>,
);
return requestContext.response;
}
// 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 dispatcher.invokeHook(
'didEncounterErrors',
requestContext as GraphQLRequestContextDidEncounterErrors<TContext>,
);
}
async 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];
await didEncounterErrors(errors);
const response: GraphQLResponse = {
errors: formatErrors(
errors.map((err) =>
err instanceof ApolloError && !errorClass
? err
: fromGraphQLError(
err,
errorClass && {
errorClass,
},
),
),
),
};
// Persisted query errors (especially "not found") need to be uncached,
// because hopefully we're about to fill in the APQ cache and the same
// request will succeed next time. We also want a 200 response to avoid any
// error handling that may mask the contents of an error response.
if (
errors.every(
(err) =>
err instanceof PersistedQueryNotSupportedError ||
err instanceof PersistedQueryNotFoundError,
)
) {
response.http = {
status: 200,
headers: new Headers({
'Cache-Control': 'private, no-cache, must-revalidate',
}),
};
} else if (errors.length === 1 && errors[0] instanceof HttpQueryError) {
response.http = {
status: errors[0].statusCode,
headers: new Headers(errors[0].headers),
};
}
return sendResponse(response);
}
function formatErrors(
errors: ReadonlyArray<GraphQLError>,
): ReadonlyArray<GraphQLFormattedError> {
return formatApolloErrors(errors, {
formatter: config.formatError,
debug: requestContext.debug,
});
}
async function initializeRequestListenerDispatcher(): Promise<
Dispatcher<GraphQLRequestListener<TContext>>
> {
const requestListeners: GraphQLRequestListener<TContext>[] = [];
if (config.plugins) {
for (const plugin of config.plugins) {
if (!plugin.requestDidStart) continue;
const listener = await plugin.requestDidStart(requestContext);
if (listener) {
requestListeners.push(listener);
}
}
}
return new Dispatcher(requestListeners);
}
async function initializeDataSources() {
if (config.dataSources) {
const context = requestContext.context;
const dataSources = config.dataSources();
const initializers: any[] = [];
for (const dataSource of Object.values(dataSources)) {
if (dataSource.initialize) {
initializers.push(
dataSource.initialize({
context,
cache: requestContext.cache,
}),
);
}
}
await Promise.all(initializers);
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;
}
}
}