Merge pull request #1795 from apollographql/abernix/re-new-request-pipeline

New Request Pipeline
This commit is contained in:
Jesse Rosenberger 2018-10-10 21:47:34 +03:00 committed by GitHub
commit db8fdc8d9d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
46 changed files with 1402 additions and 937 deletions

31
jest.config.js Normal file
View file

@ -0,0 +1,31 @@
const { defaults } = require("jest-config");
const { pathsToModuleNameMapper } = require("ts-jest/utils");
const { compilerOptions } = require("./tsconfig.base");
module.exports = {
testEnvironment: "node",
setupFiles: [
"<rootDir>/packages/apollo-server-env/dist/index.js"
],
preset: "ts-jest",
testMatch: null,
testRegex: "/__tests__/.*\\.test\\.(js|ts)$",
testPathIgnorePatterns: [
"/node_modules/",
"/dist/"
],
moduleFileExtensions: [...defaults.moduleFileExtensions, "ts", "tsx"],
// FIXME: Specifying a `moduleNameMapper` based on the `paths` option
// in `tsconfig.base.json` doesn't currently work because we only
// want to use this for types and still import modules from `node_modules`
// otherwise.
// moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths),
clearMocks: true,
globals: {
"ts-jest": {
tsConfig: "tsconfig.test.json",
diagnostics: false
}
}
};

60
package-lock.json generated
View file

@ -1451,10 +1451,20 @@
"integrity": "sha512-MDQLxNFRLasqS4UlkWMSACMKeSm1x4Q3TxzUC7KQUsh6RK1ZrQ0VEyE3yzXcBu+K8ejVj4wuX32eUG02yNp+YQ==",
"dev": true
},
"@types/type-is": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/@types/type-is/-/type-is-1.6.2.tgz",
"integrity": "sha512-q8d51ZdF/D8xebrtNDsZH+4XBUFdz8xEgWhE4U4F4WWmcBZ8+i/r/qs9DmjAprYh5qQTYlY4BxaVKDrWIwNQ9w==",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@types/ws": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-5.1.2.tgz",
"integrity": "sha512-NkTXUKTYdXdnPE2aUUbGOXE1XfMK527SCvU/9bj86kyFF6kZ9ZnOQ3mK5jADn98Y2vEUD/7wKDgZa7Qst2wYOg==",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-6.0.1.tgz",
"integrity": "sha512-EzH8k1gyZ4xih/MaZTXwT2xOkPiIMSrhQ9b8wrlX88L0T02eYsddatQlwVFlEPyEqV0ChpdpNnE51QPH6NVT4Q==",
"dev": true,
"requires": {
"@types/events": "*",
"@types/node": "*"
@ -1990,7 +2000,7 @@
"apollo-server-core": "file:packages/apollo-server-core",
"apollo-server-express": "file:packages/apollo-server-express",
"express": "^4.0.0",
"graphql-subscriptions": "^0.5.8",
"graphql-subscriptions": "^1.0.0",
"graphql-tools": "^4.0.0"
}
},
@ -2035,17 +2045,25 @@
"apollo-server-caching": "file:packages/apollo-server-caching",
"apollo-server-env": "file:packages/apollo-server-env",
"apollo-server-errors": "file:packages/apollo-server-errors",
"apollo-server-plugin-base": "file:packages/apollo-server-plugin-base",
"apollo-tracing": "file:packages/apollo-tracing",
"graphql-extensions": "file:packages/graphql-extensions",
"graphql-subscriptions": "^0.5.8",
"graphql-subscriptions": "^1.0.0",
"graphql-tag": "^2.9.2",
"graphql-tools": "^4.0.0",
"hash.js": "^1.1.3",
"lodash": "^4.17.10",
"subscriptions-transport-ws": "^0.9.11",
"ws": "^5.2.0"
},
"dependencies": {
"@types/ws": {
"version": "5.1.2",
"bundled": true,
"requires": {
"@types/events": "*",
"@types/node": "*"
}
},
"ws": {
"version": "5.2.2",
"bundled": true,
@ -2078,7 +2096,7 @@
"apollo-server-core": "file:packages/apollo-server-core",
"body-parser": "^1.18.3",
"cors": "^2.8.4",
"graphql-subscriptions": "^0.5.8",
"graphql-subscriptions": "^1.0.0",
"graphql-tools": "^4.0.0",
"type-is": "^1.6.16"
}
@ -2091,7 +2109,7 @@
"accept": "^3.0.2",
"apollo-server-core": "file:packages/apollo-server-core",
"boom": "^7.1.0",
"graphql-subscriptions": "^0.5.8",
"graphql-subscriptions": "^1.0.0",
"graphql-tools": "^4.0.0"
}
},
@ -2115,7 +2133,7 @@
"@types/koa__cors": "^2.2.1",
"accepts": "^1.3.5",
"apollo-server-core": "file:packages/apollo-server-core",
"graphql-subscriptions": "^0.5.8",
"graphql-subscriptions": "^1.0.0",
"graphql-tools": "^4.0.0",
"koa": "2.5.3",
"koa-bodyparser": "^3.0.0",
@ -2181,6 +2199,9 @@
"micro": "^9.3.2"
}
},
"apollo-server-plugin-base": {
"version": "file:packages/apollo-server-plugin-base"
},
"apollo-tracing": {
"version": "file:packages/apollo-tracing",
"requires": {
@ -6094,15 +6115,12 @@
}
},
"graphql-extensions": {
"version": "file:packages/graphql-extensions",
"requires": {
"apollo-server-env": "file:packages/apollo-server-env"
}
"version": "file:packages/graphql-extensions"
},
"graphql-subscriptions": {
"version": "0.5.8",
"resolved": "https://registry.npmjs.org/graphql-subscriptions/-/graphql-subscriptions-0.5.8.tgz",
"integrity": "sha512-0CaZnXKBw2pwnIbvmVckby5Ge5e2ecmjofhYCdyeACbCly2j3WXDP/pl+s+Dqd2GQFC7y99NB+53jrt55CKxYQ==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/graphql-subscriptions/-/graphql-subscriptions-1.0.0.tgz",
"integrity": "sha512-+ytmryoHF1LVf58NKEaNPRUzYyXplm120ntxfPcgOBC7TnK7Tv/4VRHeh4FAR9iL+O1bqhZs4nkibxQ+OA5cDQ==",
"requires": {
"iterall": "^1.2.1"
}
@ -6292,6 +6310,7 @@
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.5.tgz",
"integrity": "sha512-eWI5HG9Np+eHV1KQhisXWwM+4EPPYe5dFX1UZZH7k/E3JzDEazVH+VGlZi6R94ZqImq+A3D1mCEtrFIfg/E7sA==",
"dev": true,
"requires": {
"inherits": "^2.0.3",
"minimalistic-assert": "^1.0.1"
@ -9226,7 +9245,8 @@
"minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"dev": true
},
"minimatch": {
"version": "3.0.4",
@ -12969,9 +12989,9 @@
}
},
"ws": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-6.0.0.tgz",
"integrity": "sha512-c2UlYcAZp1VS8AORtpq6y4RJIkJ9dQz18W32SpR/qXGfLDZ2jU4y4wKvvZwqbi7U6gxFQTeE+urMbXU/tsDy4w==",
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-6.1.0.tgz",
"integrity": "sha512-H3dGVdGvW2H8bnYpIDc3u3LH8Wue3Qh+Zto6aXXFzvESkTVT6rAfKR6tR/+coaUvxs8yHtmNV0uioBF62ZGSTg==",
"dev": true,
"requires": {
"async-limiter": "~1.0.0"

View file

@ -6,12 +6,14 @@
"scripts": {
"clean": "git clean -dfqX -- ./node_modules **/{dist,node_modules}/",
"compile": "tsc --build",
"compile:clean": "tsc --build --clean",
"watch": "tsc --build --watch",
"release": "npm run clean && npm ci && lerna publish --exact",
"postinstall": "lerna run prepare && npm run compile",
"lint": "prettier-check '**/*.{js,ts}'",
"lint-fix": "prettier '**/*.{js,ts}' --write",
"test": "jest --verbose",
"posttest": "npm run lint",
"test:watch": "jest --verbose --watch",
"testonly": "npm test",
"test:ci": "npm run coverage -- --ci --maxWorkers=2 --reporters=default --reporters=jest-junit",
"coverage": "npm test -- --coverage",
@ -50,6 +52,7 @@
"apollo-server-koa": "file:packages/apollo-server-koa",
"apollo-server-lambda": "file:packages/apollo-server-lambda",
"apollo-server-micro": "file:packages/apollo-server-micro",
"apollo-server-plugin-base": "file:packages/apollo-server-plugin-base",
"apollo-tracing": "file:packages/apollo-tracing",
"graphql-extensions": "file:packages/graphql-extensions"
},
@ -72,6 +75,8 @@
"@types/node": "10.11.6",
"@types/redis": "2.8.7",
"@types/request": "2.47.1",
"@types/type-is": "^1.6.2",
"@types/ws": "^6.0.1",
"apollo-fetch": "0.7.0",
"apollo-link": "1.2.3",
"apollo-link-http": "1.5.5",
@ -83,7 +88,7 @@
"fibers": "3.0.0",
"form-data": "2.3.2",
"graphql": "14.0.2",
"graphql-subscriptions": "0.5.8",
"graphql-subscriptions": "1.0.0",
"graphql-tag": "2.10.0",
"graphql-tools": "4.0.0",
"hapi": "17.6.0",
@ -113,36 +118,7 @@
"ts-jest": "23.10.4",
"tslint": "5.11.0",
"typescript": "3.1.2",
"ws": "6.0.0",
"ws": "6.1.0",
"yup": "0.26.5"
},
"jest": {
"testEnvironment": "node",
"setupFiles": [
"<rootDir>/packages/apollo-server-env/dist/index.js"
],
"preset": "ts-jest",
"testMatch": null,
"testRegex": "/__tests__/.*\\.test\\.(js|ts)$",
"moduleFileExtensions": [
"ts",
"js"
],
"testPathIgnorePatterns": [
"/node_modules/",
"/dist/"
],
"clearMocks": true,
"globals": {
"ts-jest": {
"tsConfig": "tsconfig.test.json",
"diagnostics": false
}
}
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
}
}

View file

@ -8,7 +8,7 @@ import {
responsePathAsArray,
} from 'graphql';
import { GraphQLExtension } from 'graphql-extensions';
import { GraphQLExtension, GraphQLResponse } from 'graphql-extensions';
export interface CacheControlFormat {
version: 1;
@ -27,6 +27,10 @@ export enum CacheScope {
export interface CacheControlExtensionOptions {
defaultMaxAge?: number;
// FIXME: We should replace these with
// more appropriately named options.
calculateHttpHeaders?: boolean;
stripFormattedExtensions?: boolean;
}
declare module 'graphql/type/definition' {
@ -42,7 +46,7 @@ export class CacheControlExtension<TContext = any>
implements GraphQLExtension<TContext> {
private defaultMaxAge: number;
constructor(options: CacheControlExtensionOptions = {}) {
constructor(public options: CacheControlExtensionOptions = {}) {
this.defaultMaxAge = options.defaultMaxAge || 0;
}
@ -117,7 +121,9 @@ export class CacheControlExtension<TContext = any>
}
}
format(): [string, CacheControlFormat] {
format(): [string, CacheControlFormat] | undefined {
if (this.options.stripFormattedExtensions) return;
return [
'cacheControl',
{
@ -129,6 +135,44 @@ export class CacheControlExtension<TContext = any>
},
];
}
public willSendResponse?(o: { graphqlResponse: GraphQLResponse }) {
if (this.options.calculateHttpHeaders && o.graphqlResponse.http) {
const overallCachePolicy = this.computeOverallCachePolicy();
if (overallCachePolicy) {
o.graphqlResponse.http.headers.set(
'Cache-Control',
`max-age=${
overallCachePolicy.maxAge
}, ${overallCachePolicy.scope.toLowerCase()}`,
);
}
}
}
computeOverallCachePolicy(): Required<CacheHint> | undefined {
let lowestMaxAge: number | undefined = undefined;
let scope: CacheScope = CacheScope.Public;
for (const hint of this.hints.values()) {
if (hint.maxAge) {
lowestMaxAge = lowestMaxAge
? Math.min(lowestMaxAge, hint.maxAge)
: hint.maxAge;
}
if (hint.scope === CacheScope.Private) {
scope = CacheScope.Private;
}
}
return lowestMaxAge
? {
maxAge: lowestMaxAge,
scope,
}
: undefined;
}
}
function cacheHintFromDirectives(

View file

@ -32,12 +32,12 @@
"apollo-server-caching": "file:../apollo-server-caching",
"apollo-server-env": "file:../apollo-server-env",
"apollo-server-errors": "file:../apollo-server-errors",
"apollo-server-plugin-base": "file:../apollo-server-plugin-base",
"apollo-tracing": "file:../apollo-tracing",
"graphql-extensions": "file:../graphql-extensions",
"graphql-subscriptions": "^0.5.8",
"graphql-subscriptions": "^1.0.0",
"graphql-tag": "^2.9.2",
"graphql-tools": "^4.0.0",
"hash.js": "^1.1.3",
"lodash": "^4.17.10",
"subscriptions-transport-ws": "^0.9.11",
"ws": "^5.2.0"

View file

@ -13,6 +13,7 @@ import {
import { GraphQLExtension } from 'graphql-extensions';
import { EngineReportingAgent } from 'apollo-engine-reporting';
import { InMemoryLRUCache } from 'apollo-server-caching';
import { ApolloServerPlugin } from 'apollo-server-plugin-base';
import {
SubscriptionServer,
@ -31,6 +32,7 @@ import {
ContextFunction,
SubscriptionServerOptions,
FileUploadOptions,
PluginDefinition,
} from './types';
import { FormatErrorExtension } from './formatters';
@ -55,14 +57,37 @@ const NoIntrospection = (context: ValidationContext) => ({
},
});
function getEngineServiceId(engine: Config['engine']): string | undefined {
const keyFromEnv = process.env.ENGINE_API_KEY || '';
if (!(engine || (engine !== false && keyFromEnv))) {
return;
}
let engineApiKey: string = '';
if (typeof engine === 'object' && engine.apiKey) {
engineApiKey = engine.apiKey;
} else if (keyFromEnv) {
engineApiKey = keyFromEnv;
}
if (engineApiKey) {
return engineApiKey.split(':', 2)[1];
}
return;
}
export class ApolloServerBase {
public subscriptionsPath?: string;
public graphqlPath: string = '/graphql';
public requestOptions: Partial<GraphQLOptions<any>>;
public requestOptions: Partial<GraphQLOptions<any>> = Object.create(null);
private context?: Context | ContextFunction;
private engineReportingAgent?: EngineReportingAgent;
private engineServiceId?: string;
private extensions: Array<() => GraphQLExtension>;
protected plugins: ApolloServerPlugin[] = [];
protected schema: GraphQLSchema;
protected subscriptionServerOptions?: SubscriptionServerOptions;
@ -91,9 +116,14 @@ export class ApolloServerBase {
subscriptions,
uploads,
playground,
plugins,
...requestOptions
} = config;
// Plugins will be instantiated if they aren't already, and this.plugins
// is populated accordingly.
this.ensurePluginInstantiation(plugins);
// While reading process.env is slow, a server should only be constructed
// once per run, so we place the env check inside the constructor. If env
// should be used outside of the constructor context, place it as a private
@ -114,6 +144,31 @@ export class ApolloServerBase {
: noIntro;
}
if (requestOptions.cacheControl !== false) {
if (
typeof requestOptions.cacheControl === 'boolean' &&
requestOptions.cacheControl === true
) {
// cacheControl: true means that the user needs the cache-control
// extensions. This means we are running the proxy, so we should not
// strip out the cache control extension and not add cache-control headers
requestOptions.cacheControl = {
stripFormattedExtensions: false,
calculateHttpHeaders: false,
defaultMaxAge: 0,
};
} else {
// Default behavior is to run default header calculation and return
// no cacheControl extensions
requestOptions.cacheControl = {
stripFormattedExtensions: true,
calculateHttpHeaders: true,
defaultMaxAge: 0,
...requestOptions.cacheControl,
};
}
}
if (!requestOptions.cache) {
requestOptions.cache = new InMemoryLRUCache();
}
@ -215,21 +270,26 @@ export class ApolloServerBase {
// or cacheControl.
this.extensions = [];
const debugDefault =
process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'test';
const debug =
requestOptions.debug !== undefined ? requestOptions.debug : debugDefault;
// Error formatting should happen after the engine reporting agent, so that
// engine gets the unmasked errors if necessary
if (this.requestOptions.formatError) {
this.extensions.push(
() =>
new FormatErrorExtension(
this.requestOptions.formatError!,
this.requestOptions.debug,
),
() => new FormatErrorExtension(requestOptions.formatError, debug),
);
}
if (engine || (engine !== false && process.env.ENGINE_API_KEY)) {
// In an effort to avoid over-exposing the API key itself, extract the
// service ID from the API key for plugins which only needs service ID.
// The truthyness of this value can also be used in other forks of logic
// related to Engine, as is the case with EngineReportingAgent just below.
this.engineServiceId = getEngineServiceId(engine);
if (this.engineServiceId) {
this.engineReportingAgent = new EngineReportingAgent(
engine === true ? {} : engine,
typeof engine === 'object' ? engine : Object.create(null),
);
// Let's keep this extension second so it wraps everything, except error formatting
this.extensions.push(() => this.engineReportingAgent!.newExtension());
@ -274,6 +334,22 @@ export class ApolloServerBase {
this.graphqlPath = path;
}
protected async willStart() {
await Promise.all(
this.plugins.map(
plugin =>
plugin.serverWillStart &&
plugin.serverWillStart({
schema: this.schema,
engine: {
serviceID: this.engineServiceId,
},
persistedQueries: this.requestOptions.persistedQueries,
}),
),
);
}
public async stop() {
if (this.subscriptionServer) await this.subscriptionServer.close();
if (this.engineReportingAgent) {
@ -357,6 +433,23 @@ export class ApolloServerBase {
return false;
}
private ensurePluginInstantiation(plugins?: PluginDefinition[]): void {
if (!plugins || !plugins.length) {
return;
}
// FIXME: We also want to support default exports and possibly module names
// but this requires adjustments to typing (see PluginDefinition type), and
// I had to give up on that for now.
this.plugins = plugins.map(plugin => {
if (typeof plugin === 'function') {
return new plugin();
} else {
return plugin as ApolloServerPlugin;
}
});
}
// This function is used by the integrations to generate the graphQLOptions
// from an object containing the request and other integration specific
// options
@ -379,6 +472,7 @@ export class ApolloServerBase {
return {
schema: this.schema,
plugins: this.plugins,
extensions: this.extensions,
context,
// Allow overrides from options. Be explicit about a couple of them to

View file

@ -9,11 +9,76 @@ import {
GraphQLNonNull,
parse,
DocumentNode,
ValidationContext,
GraphQLFieldResolver,
} from 'graphql';
import { runQuery } from '../runQuery';
import {
GraphQLExtensionStack,
GraphQLExtension,
GraphQLResponse,
} from 'graphql-extensions';
import { GraphQLExtensionStack, GraphQLExtension } from 'graphql-extensions';
import { CacheControlExtensionOptions } from 'apollo-cache-control';
import { GraphQLRequest, GraphQLRequestPipeline } from '../requestPipeline';
import { Request } from 'apollo-server-env';
// This is a temporary kludge to ensure we preserve runQuery behavior with the
// GraphQLRequestProcessor refactoring.
// These tests will be rewritten as GraphQLRequestProcessor tests after the
// refactoring is complete.
function runQuery(options: QueryOptions): Promise<GraphQLResponse> {
const requestPipeline = new GraphQLRequestPipeline({
schema: options.schema,
rootValue: options.rootValue,
validationRules: options.validationRules,
fieldResolver: options.fieldResolver,
extensions: options.extensions,
tracing: options.tracing,
cacheControl: options.cacheControl,
formatResponse: options.formatResponse,
});
const request: GraphQLRequest = {
query: options.queryString,
operationName: options.operationName,
variables: options.variables,
extensions: options.extensions,
http: options.request,
};
return requestPipeline.processRequest({
request,
context: options.context || {},
debug: options.debug,
cache: {} as any,
});
}
interface QueryOptions {
schema: GraphQLSchema;
queryString?: string;
parsedQuery?: DocumentNode;
rootValue?: any;
context?: any;
variables?: { [key: string]: any };
operationName?: string;
validationRules?: Array<(context: ValidationContext) => any>;
fieldResolver?: GraphQLFieldResolver<any, any>;
formatError?: Function;
formatResponse?: Function;
debug?: boolean;
tracing?: boolean;
cacheControl?: CacheControlExtensionOptions;
request: Pick<Request, 'url' | 'method' | 'headers'>;
extensions?: Array<() => GraphQLExtension>;
}
const queryType = new GraphQLObjectType({
name: 'QueryType',
@ -92,7 +157,7 @@ describe('runQuery', () => {
});
});
it('returns the right result when query is a document', () => {
it.skip('returns the right result when query is a document', () => {
const query = parse(`{ testString }`);
const expected = { testString: 'it works' };
return runQuery({

View file

@ -1,66 +0,0 @@
import { ExecutionResult } from 'graphql';
import { CacheControlFormat } from 'apollo-cache-control';
export function calculateCacheControlHeaders(
responses: Array<ExecutionResult & { extensions?: Record<string, any> }>,
): Record<string, string> {
let lowestMaxAge = Number.MAX_VALUE;
let publicOrPrivate = 'public';
for (const response of responses) {
const cacheControl: CacheControlFormat =
response.extensions && response.extensions.cacheControl;
// If there are no extensions or hints, then the headers should not be present
if (
!cacheControl ||
!cacheControl.hints ||
cacheControl.hints.length === 0 ||
cacheControl.version !== 1
) {
if (cacheControl && cacheControl.version !== 1) {
console.warn('Invalid cacheControl version.');
}
return {};
}
const rootHints = new Set<string>();
for (const hint of cacheControl.hints) {
if (hint.scope && hint.scope.toLowerCase() === 'private') {
publicOrPrivate = 'private';
}
// If no maxAge is present, then we ignore the hint
if (hint.maxAge === undefined) {
continue;
}
// if there is a hint with max age of 0, we don't need to process more
if (hint.maxAge === 0) {
return {};
}
if (hint.maxAge < lowestMaxAge) {
lowestMaxAge = hint.maxAge;
}
// If this is a root path, store that the root is cacheable:
if (hint.path.length === 1) {
rootHints.add(hint.path[0] as string);
}
}
// If a root field inside of data does not have a cache hint, then we do not
// cache the response
if (
response.data &&
Object.keys(response.data).find(rootKey => !rootHints.has(rootKey))
) {
return {};
}
}
return {
'Cache-Control': `max-age=${lowestMaxAge}, ${publicOrPrivate}`,
};
}

View file

@ -2,10 +2,10 @@ import { GraphQLExtension, GraphQLResponse } from 'graphql-extensions';
import { formatApolloErrors } from 'apollo-server-errors';
export class FormatErrorExtension<TContext = any> extends GraphQLExtension {
private formatError: Function;
private formatError?: Function;
private debug: boolean;
public constructor(formatError: Function, debug: boolean = false) {
public constructor(formatError?: Function, debug: boolean = false) {
super();
this.formatError = formatError;
this.debug = debug;

View file

@ -8,6 +8,7 @@ import { GraphQLExtension } from 'graphql-extensions';
import { CacheControlExtensionOptions } from 'apollo-cache-control';
import { KeyValueCache } from 'apollo-server-caching';
import { DataSource } from 'apollo-datasource';
import { ApolloServerPlugin } from 'apollo-server-plugin-base';
/*
* GraphQLServerOptions
@ -24,30 +25,24 @@ import { DataSource } from 'apollo-datasource';
*
*/
export interface GraphQLServerOptions<
TContext =
| (() => Promise<Record<string, any>> | Record<string, any>)
| Record<string, any>,
TRootVal = ((parsedQuery: DocumentNode) => any) | any
TContext = Record<string, any>,
TRootValue = any
> {
schema: GraphQLSchema;
formatError?: Function;
rootValue?: TRootVal;
context?: TContext;
rootValue?: ((parsedQuery: DocumentNode) => TRootValue) | TRootValue;
context?: TContext | (() => never);
validationRules?: Array<(context: ValidationContext) => any>;
formatResponse?: Function;
fieldResolver?: GraphQLFieldResolver<any, TContext>;
debug?: boolean;
tracing?: boolean;
cacheControl?:
| boolean
| (CacheControlExtensionOptions & {
calculateHttpHeaders?: boolean;
stripFormattedExtensions?: boolean;
});
cacheControl?: CacheControlExtensionOptions;
extensions?: Array<() => GraphQLExtension>;
dataSources?: () => DataSources<TContext>;
cache?: KeyValueCache;
persistedQueries?: PersistedQueryOptions;
plugins?: ApolloServerPlugin[];
}
export type DataSources<TContext> = {

View file

@ -1,6 +1,5 @@
import 'apollo-server-env';
export { runQuery } from './runQuery';
export { runHttpQuery, HttpQueryRequest, HttpQueryError } from './runHttpQuery';
export {

View file

@ -0,0 +1,418 @@
import {
GraphQLSchema,
GraphQLFieldResolver,
specifiedRules,
DocumentNode,
getOperationAST,
ExecutionArgs,
ExecutionResult,
GraphQLError,
} from 'graphql';
import * as graphql from 'graphql';
import {
GraphQLExtension,
GraphQLExtensionStack,
enableGraphQLExtensions,
} from 'graphql-extensions';
import { DataSource } from 'apollo-datasource';
import { PersistedQueryOptions } from '.';
import {
CacheControlExtension,
CacheControlExtensionOptions,
} from 'apollo-cache-control';
import { TracingExtension } from 'apollo-tracing';
import {
fromGraphQLError,
SyntaxError,
ValidationError,
PersistedQueryNotSupportedError,
PersistedQueryNotFoundError,
} from 'apollo-server-errors';
import { createHash } from 'crypto';
import {
GraphQLRequest,
GraphQLResponse,
GraphQLRequestContext,
InvalidGraphQLRequestError,
ValidationRule,
} from './requestPipelineAPI';
import {
ApolloServerPlugin,
GraphQLRequestListener,
WithRequired,
} from 'apollo-server-plugin-base';
import { Dispatcher } from './utils/dispatcher';
export {
GraphQLRequest,
GraphQLResponse,
GraphQLRequestContext,
InvalidGraphQLRequestError,
};
function computeQueryHash(query: string) {
return createHash('sha256')
.update(query)
.digest('hex');
}
export interface GraphQLRequestPipelineConfig<TContext> {
schema: GraphQLSchema;
rootValue?: ((document: DocumentNode) => any) | any;
validationRules?: ValidationRule[];
fieldResolver?: GraphQLFieldResolver<any, TContext>;
dataSources?: () => DataSources<TContext>;
extensions?: Array<() => GraphQLExtension>;
tracing?: boolean;
persistedQueries?: PersistedQueryOptions;
cacheControl?: CacheControlExtensionOptions;
formatError?: Function;
formatResponse?: Function;
plugins?: ApolloServerPlugin[];
}
export type DataSources<TContext> = {
[name: string]: DataSource<TContext>;
};
type Mutable<T> = { -readonly [P in keyof T]: T[P] };
export class GraphQLRequestPipeline<TContext> {
plugins: ApolloServerPlugin[];
constructor(private config: GraphQLRequestPipelineConfig<TContext>) {
enableGraphQLExtensions(config.schema);
this.plugins = config.plugins || [];
}
async processRequest(
requestContext: Mutable<GraphQLRequestContext<TContext>>,
): Promise<GraphQLResponse> {
const config = this.config;
const requestListeners: GraphQLRequestListener<TContext>[] = [];
for (const plugin of this.plugins) {
if (!plugin.requestDidStart) continue;
const listener = plugin.requestDidStart(requestContext);
if (listener) {
requestListeners.push(listener);
}
}
const dispatcher = new Dispatcher(requestListeners);
const extensionStack = this.initializeExtensionStack();
(requestContext.context as any)._extensionStack = extensionStack;
this.initializeDataSources(requestContext);
const request = requestContext.request;
let { query, extensions } = request;
let queryHash: string;
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 (
!this.config.persistedQueries ||
!this.config.persistedQueries.cache
) {
throw new PersistedQueryNotSupportedError();
} else if (extensions.persistedQuery.version !== 1) {
throw new InvalidGraphQLRequestError(
'Unsupported persisted query version',
);
}
queryHash = extensions.persistedQuery.sha256Hash;
if (query === undefined) {
query = await this.config.persistedQueries.cache.get(
`apq:${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',
);
}
persistedQueryRegister = true;
// Store the query asynchronously so we don't block.
(async () => {
return (
this.config.persistedQueries &&
this.config.persistedQueries.cache.set(`apq:${queryHash}`, query)
);
})().catch(error => {
console.warn(error);
});
}
} 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,
});
const parsingDidEnd = await dispatcher.invokeDidStartHook(
'parsingDidStart',
requestContext,
);
try {
let document: DocumentNode;
try {
document = parse(query);
parsingDidEnd();
} catch (syntaxError) {
parsingDidEnd(syntaxError);
return sendResponse({
errors: [
fromGraphQLError(syntaxError, {
errorClass: SyntaxError,
}),
],
});
}
requestContext.document = document;
const validationDidEnd = await dispatcher.invokeDidStartHook(
'validationDidStart',
requestContext as WithRequired<typeof requestContext, 'document'>,
);
const validationErrors = validate(document);
if (validationErrors.length > 0) {
validationDidEnd(validationErrors);
return sendResponse({
errors: validationErrors.map(validationError =>
fromGraphQLError(validationError, {
errorClass: ValidationError,
}),
),
});
}
validationDidEnd();
// 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(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'
>,
);
const executionDidEnd = await dispatcher.invokeDidStartHook(
'executionDidStart',
requestContext as WithRequired<
typeof requestContext,
'document' | 'operation' | 'operationName'
>,
);
let response: GraphQLResponse;
try {
response = (await execute(
document,
request.operationName,
request.variables,
)) as GraphQLResponse;
executionDidEnd();
} catch (executionError) {
executionDidEnd(executionError);
return sendResponse({
errors: [fromGraphQLError(executionError)],
});
}
const formattedExtensions = extensionStack.format();
if (Object.keys(formattedExtensions).length > 0) {
response.extensions = formattedExtensions;
}
if (this.config.formatResponse) {
response = this.config.formatResponse(response, {
context: requestContext.context,
});
}
return sendResponse(response);
} finally {
requestDidEnd();
}
function parse(query: string): DocumentNode {
const parsingDidEnd = extensionStack.parsingDidStart({
queryString: query,
});
try {
return graphql.parse(query);
} 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 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!;
}
}
private initializeExtensionStack(): GraphQLExtensionStack<TContext> {
// If custom extension factories were provided, create per-request extension
// objects.
const extensions = this.config.extensions
? this.config.extensions.map(f => f())
: [];
if (this.config.tracing) {
extensions.push(new TracingExtension());
}
let cacheControlExtension;
if (this.config.cacheControl) {
cacheControlExtension = new CacheControlExtension(
this.config.cacheControl,
);
extensions.push(cacheControlExtension);
}
return new GraphQLExtensionStack(extensions);
}
private initializeDataSources(
requestContext: GraphQLRequestContext<TContext>,
) {
if (this.config.dataSources) {
const context = requestContext.context;
const dataSources = this.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;
}
}
}

View file

@ -0,0 +1,64 @@
// This file is compiled as a separate TypeScript project to avoid
// circular dependency issues from the `apollo-server-plugin-base` package
// depending on the types in it.
import { Request, Response } from 'apollo-server-env';
import {
GraphQLSchema,
ValidationContext,
ASTVisitor,
GraphQLError,
OperationDefinitionNode,
DocumentNode,
} from 'graphql';
import { KeyValueCache } from 'apollo-server-caching';
export interface GraphQLServiceContext {
schema: GraphQLSchema;
engine: {
serviceID?: string;
};
persistedQueries?: {
cache: KeyValueCache;
};
}
export interface GraphQLRequest {
query?: string;
operationName?: string;
variables?: { [name: string]: any };
extensions?: Record<string, any>;
http?: Pick<Request, 'url' | 'method' | 'headers'>;
}
export interface GraphQLResponse {
data?: Record<string, any>;
errors?: GraphQLError[];
extensions?: Record<string, any>;
http?: Pick<Response, 'headers'>;
}
export interface GraphQLRequestContext<TContext = Record<string, any>> {
readonly request: GraphQLRequest;
readonly response?: GraphQLResponse;
readonly context: TContext;
readonly cache: KeyValueCache;
// This will be replaced with the `operationID`.
readonly queryHash?: string;
readonly document?: DocumentNode;
// `operationName` is set based on the operation AST, so it is defined
// even if no `request.operationName` was passed in.
// It will be set to `null` for an anonymous operation.
readonly operationName?: string | null;
readonly operation?: OperationDefinitionNode;
debug?: boolean;
}
export type ValidationRule = (context: ValidationContext) => ASTVisitor;
export class InvalidGraphQLRequestError extends Error {}

View file

@ -1,12 +1,4 @@
import { ExecutionResult } from 'graphql';
const sha256 = require('hash.js/lib/hash/sha/256');
import { CacheControlExtensionOptions } from 'apollo-cache-control';
import { omit } from 'lodash';
import { Request } from 'apollo-server-env';
import { runQuery, QueryOptions } from './runQuery';
import { Request, Headers } from 'apollo-server-env';
import {
default as GraphQLOptions,
resolveGraphqlOptions,
@ -16,7 +8,14 @@ import {
PersistedQueryNotSupportedError,
PersistedQueryNotFoundError,
} from 'apollo-server-errors';
import { calculateCacheControlHeaders } from './caching';
import {
GraphQLRequestPipeline,
GraphQLRequest,
InvalidGraphQLRequestError,
GraphQLRequestContext,
GraphQLResponse,
} from './requestPipeline';
import { CacheControlExtensionOptions } from 'apollo-cache-control';
export interface HttpQueryRequest {
method: string;
@ -32,11 +31,6 @@ export interface HttpQueryRequest {
request: Pick<Request, 'url' | 'method' | 'headers'>;
}
// The result of a curl does not appear well in the terminal, so we add an extra new line
function prettyJSONStringify(value: any) {
return JSON.stringify(value) + '\n';
}
export interface ApolloServerHttpResponse {
headers?: Record<string, string>;
// ResponseInit contains the follow, which we do not use
@ -45,6 +39,9 @@ export interface ApolloServerHttpResponse {
}
export interface HttpQueryResponse {
// FIXME: This isn't actually an individual GraphQL response, but the body
// of the HTTP response, which could contain multiple GraphQL responses
// when using batching.
graphqlResponse: string;
responseInit: ApolloServerHttpResponse;
}
@ -69,20 +66,20 @@ export class HttpQueryError extends Error {
}
/**
* If optionsObject is specified, then the errors array will be formatted
* If options is specified, then the errors array will be formatted
*/
function throwHttpGraphQLError<E extends Error>(
statusCode: number,
errors: Array<E>,
optionsObject?: Partial<GraphQLOptions>,
options?: Pick<GraphQLOptions, 'debug' | 'formatError'>,
): never {
throw new HttpQueryError(
statusCode,
prettyJSONStringify({
errors: optionsObject
errors: options
? formatApolloErrors(errors, {
debug: optionsObject.debug,
formatter: optionsObject.formatError,
debug: options.debug,
formatter: options.formatError,
})
: errors,
}),
@ -97,22 +94,12 @@ export async function runHttpQuery(
handlerArguments: Array<any>,
request: HttpQueryRequest,
): Promise<HttpQueryResponse> {
let isGetRequest: boolean = false;
let optionsObject: GraphQLOptions;
let options: GraphQLOptions;
const debugDefault =
process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'test';
let cacheControl:
| CacheControlExtensionOptions & {
calculateHttpHeaders: boolean;
stripFormattedExtensions: boolean;
}
| undefined;
try {
optionsObject = await resolveGraphqlOptions(
request.options,
...handlerArguments,
);
options = await resolveGraphqlOptions(request.options, ...handlerArguments);
} catch (e) {
// The options can be generated asynchronously, so we don't have access to
// the normal options provided by the user, such as: formatError,
@ -124,29 +111,94 @@ export async function runHttpQuery(
}
return throwHttpGraphQLError(500, [e], { debug: debugDefault });
}
if (optionsObject.debug === undefined) {
optionsObject.debug = debugDefault;
if (options.debug === undefined) {
options.debug = debugDefault;
}
// FIXME: Errors thrown while resolving the context in
// ApolloServer#graphQLServerOptions are currently converted to
// a throwing function, which we invoke here to rethrow an HTTP error.
// When we refactor the integration between ApolloServer, the middleware and
// runHttpQuery, we should pass the original context function through,
// so we can resolve it on every GraphQL request (as opposed to once per HTTP
// request, which could be a batch).
if (typeof options.context === 'function') {
try {
(options.context as () => never)();
} catch (e) {
e.message = `Context creation failed: ${e.message}`;
// For errors that are not internal, such as authentication, we
// should provide a 400 response
if (
e.extensions &&
e.extensions.code &&
e.extensions.code !== 'INTERNAL_SERVER_ERROR'
) {
return throwHttpGraphQLError(400, [e], options);
} else {
return throwHttpGraphQLError(500, [e], options);
}
}
}
const config = {
schema: options.schema,
rootValue: options.rootValue,
context: options.context || {},
validationRules: options.validationRules,
fieldResolver: options.fieldResolver,
// FIXME: Use proper option types to ensure this
// The cache is guaranteed to be initialized in ApolloServer, and
// cacheControl defaults will also have been set if a boolean argument is
// passed in.
cache: options.cache!,
cacheControl: options.cacheControl as
| CacheControlExtensionOptions
| undefined,
dataSources: options.dataSources,
extensions: options.extensions,
persistedQueries: options.persistedQueries,
tracing: options.tracing,
formatError: options.formatError,
formatResponse: options.formatResponse,
debug: options.debug,
plugins: options.plugins,
};
return processHTTPRequest(config, request);
}
export async function processHTTPRequest<TContext>(
options: GraphQLOptions<TContext> & {
context: TContext;
cache: NonNullable<GraphQLOptions<TContext>['cache']>;
},
httpRequest: HttpQueryRequest,
): Promise<HttpQueryResponse> {
let requestPayload;
switch (request.method) {
switch (httpRequest.method) {
case 'POST':
if (!request.query || Object.keys(request.query).length === 0) {
if (!httpRequest.query || Object.keys(httpRequest.query).length === 0) {
throw new HttpQueryError(
500,
'POST body missing. Did you forget use body-parser middleware?',
);
}
requestPayload = request.query;
requestPayload = httpRequest.query;
break;
case 'GET':
if (!request.query || Object.keys(request.query).length === 0) {
if (!httpRequest.query || Object.keys(httpRequest.query).length === 0) {
throw new HttpQueryError(400, 'GET query missing.');
}
isGetRequest = true;
requestPayload = request.query;
requestPayload = httpRequest.query;
break;
default:
@ -160,22 +212,148 @@ export async function runHttpQuery(
);
}
let isBatch = true;
// TODO: do something different here if the body is an array.
// Throw an error if body isn't either array or object.
if (!Array.isArray(requestPayload)) {
isBatch = false;
requestPayload = [requestPayload];
const requestPipeline = new GraphQLRequestPipeline<TContext>(options);
// GET operations should only be queries (not mutations). We want to throw
// a particular HTTP error in that case.
requestPipeline.plugins.push({
requestDidStart() {
return {
didResolveOperation({ request, operation }) {
if (!request.http) return;
if (
request.http.method === 'GET' &&
operation.operation !== 'query'
) {
throw new HttpQueryError(
405,
`GET supports only query operation`,
false,
{
Allow: 'POST',
},
);
}
},
};
},
});
function buildRequestContext(
request: GraphQLRequest,
): GraphQLRequestContext<TContext> {
// FIXME: We currently shallow clone the context for every request,
// but that's unlikely to be what people want.
// We allow passing in a function for `context` to ApolloServer,
// but this only runs once for a batched request (because this is resolved
// in ApolloServer#graphQLServerOptions, before runHttpQuery is invoked).
const context = cloneObject(options.context);
return {
request,
response: {
http: {
headers: new Headers(),
},
},
context,
cache: options.cache,
debug: options.debug,
};
}
const requests = requestPayload.map(async requestParams => {
const responseInit: ApolloServerHttpResponse = {
headers: {
'Content-Type': 'application/json',
},
};
let body: string;
try {
if (Array.isArray(requestPayload)) {
// We're processing a batch request
const requests = requestPayload.map(requestParams =>
parseGraphQLRequest(httpRequest.request, requestParams),
);
const responses = await Promise.all(
requests.map(async request => {
try {
const requestContext = buildRequestContext(request);
return await requestPipeline.processRequest(requestContext);
} catch (error) {
// A batch can contain another query that returns data,
// so we don't error out the entire request with an HttpError
return {
errors: formatApolloErrors([error], options),
};
}
}),
);
body = prettyJSONStringify(responses.map(serializeGraphQLResponse));
} else {
// We're processing a normal request
const request = parseGraphQLRequest(httpRequest.request, requestPayload);
try {
const requestContext = buildRequestContext(request);
const response = await requestPipeline.processRequest(requestContext);
// This code is run on parse/validation errors and any other error that
// doesn't reach GraphQL execution
if (response.errors && typeof response.data === 'undefined') {
// don't include options, since the errors have already been formatted
return throwHttpGraphQLError(400, response.errors as any);
}
if (response.http) {
for (const [name, value] of response.http.headers) {
responseInit.headers![name] = value;
}
}
body = prettyJSONStringify(serializeGraphQLResponse(response));
} catch (error) {
if (error instanceof InvalidGraphQLRequestError) {
throw new HttpQueryError(400, error.message);
} else if (
error instanceof PersistedQueryNotSupportedError ||
error instanceof PersistedQueryNotFoundError
) {
return throwHttpGraphQLError(200, [error], options);
} else {
throw error;
}
}
}
} catch (error) {
if (error instanceof HttpQueryError) {
throw error;
}
return throwHttpGraphQLError(500, [error], options);
}
responseInit.headers!['Content-Length'] = Buffer.byteLength(
body,
'utf8',
).toString();
return {
graphqlResponse: body,
responseInit,
};
}
function parseGraphQLRequest(
httpRequest: Pick<Request, 'url' | 'method' | 'headers'>,
requestParams: Record<string, any>,
): GraphQLRequest {
let queryString: string | undefined = requestParams.query;
let extensions = requestParams.extensions;
let persistedQueryHit = false;
let persistedQueryRegister = false;
if (isGetRequest && extensions) {
if (typeof extensions === 'string') {
// For GET requests, we have to JSON-parse extensions. (For POST
// requests they get parsed as part of parsing the larger body they're
// inside.)
@ -186,84 +364,9 @@ export async function runHttpQuery(
}
}
if (extensions && extensions.persistedQuery) {
// It looks like we've received an Apollo Persisted Query. Check if we
// support them. In an ideal world, we always would, however since the
// middleware options are created every request, it does not make sense
// to create a default cache here and save a referrence to use across
// requests
if (
!optionsObject.persistedQueries ||
!optionsObject.persistedQueries.cache
) {
if (isBatch) {
// A batch can contain another query that returns data,
// so we don't error out the entire request with an HttpError
throw new PersistedQueryNotSupportedError();
}
// Return 200 to simplify processing: we want this to be intepreted by
// the client as data worth interpreting, not an error.
return throwHttpGraphQLError(
200,
[new PersistedQueryNotSupportedError()],
optionsObject,
);
} else if (extensions.persistedQuery.version !== 1) {
throw new HttpQueryError(400, 'Unsupported persisted query version');
}
const sha = extensions.persistedQuery.sha256Hash;
if (queryString === undefined) {
queryString =
(await optionsObject.persistedQueries.cache.get(`apq:${sha}`)) ||
undefined;
if (queryString) {
persistedQueryHit = true;
} else {
if (isBatch) {
// A batch can contain multiple undefined persisted queries,
// so we don't error out the entire request with an HttpError
throw new PersistedQueryNotFoundError();
}
return throwHttpGraphQLError(
200,
[new PersistedQueryNotFoundError()],
optionsObject,
);
}
} else {
const calculatedSha = sha256()
.update(queryString)
.digest('hex');
if (sha !== calculatedSha) {
throw new HttpQueryError(400, 'provided sha does not match query');
}
persistedQueryRegister = true;
// Do the store completely asynchronously
(async () => {
// We do not wait on the cache storage to complete
return (
optionsObject.persistedQueries &&
optionsObject.persistedQueries.cache.set(
`apq:${sha}`,
queryString,
)
);
})().catch(error => {
console.warn(error);
});
}
}
if (!queryString) {
throw new HttpQueryError(400, 'Must provide query string.');
}
if (typeof queryString !== 'string') {
if (queryString && typeof queryString !== 'string') {
// Check for a common error first.
if (queryString && (queryString as any).kind === 'Document') {
if ((queryString as any).kind === 'Document') {
throw new HttpQueryError(
400,
"GraphQL queries must be strings. It looks like you're sending the " +
@ -273,25 +376,9 @@ export async function runHttpQuery(
'`graphql`, or use a client like `apollo-client` which converts ' +
'the internal representation to a string for you.',
);
}
} else {
throw new HttpQueryError(400, 'GraphQL queries must be strings.');
}
// GET operations should only be queries (not mutations). We want to throw
// a particular HTTP error in that case, but we don't actually parse the
// query until we're in runQuery, so we declare the error we want to throw
// here and pass it into runQuery.
// TODO this could/should be added as a validation rule rather than an ad hoc error
let nonQueryError;
if (isGetRequest) {
nonQueryError = new HttpQueryError(
405,
`GET supports only query operation`,
false,
{
Allow: 'POST',
},
);
}
const operationName = requestParams.operationName;
@ -308,193 +395,32 @@ export async function runHttpQuery(
}
}
let context = optionsObject.context;
if (!context) {
context = {} as Record<string, any>;
} else if (typeof context === 'function') {
try {
context = await (context as Function)();
} catch (e) {
e.message = `Context creation failed: ${e.message}`;
// For errors that are not internal, such as authentication, we
// should provide a 400 response
if (
e.extensions &&
e.extensions.code &&
e.extensions.code !== 'INTERNAL_SERVER_ERROR'
) {
return throwHttpGraphQLError(400, [e], optionsObject);
} else {
return throwHttpGraphQLError(500, [e], optionsObject);
}
}
} else {
// Always clone the context if it's not a function, because that preserves
// having a fresh context per request.
context = Object.assign(
Object.create(Object.getPrototypeOf(context)),
context,
) as Record<string, any>;
}
if (optionsObject.dataSources) {
const dataSources = optionsObject.dataSources() || {};
for (const dataSource of Object.values(dataSources)) {
if (dataSource.initialize) {
dataSource.initialize({
context: context!,
cache: optionsObject.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;
}
if (optionsObject.cacheControl !== false) {
if (
typeof optionsObject.cacheControl === 'boolean' &&
optionsObject.cacheControl === true
) {
// cacheControl: true means that the user needs the cache-control
// extensions. This means we are running the proxy, so we should not
// strip out the cache control extension and not add cache-control headers
cacheControl = {
stripFormattedExtensions: false,
calculateHttpHeaders: false,
defaultMaxAge: 0,
};
} else {
// Default behavior is to run default header calculation and return
// no cacheControl extensions
cacheControl = {
stripFormattedExtensions: true,
calculateHttpHeaders: true,
defaultMaxAge: 0,
...optionsObject.cacheControl,
};
}
}
let params: QueryOptions = {
schema: optionsObject.schema,
queryString,
nonQueryError,
variables: variables,
context,
rootValue: optionsObject.rootValue,
operationName: operationName,
validationRules: optionsObject.validationRules,
formatError: optionsObject.formatError,
formatResponse: optionsObject.formatResponse,
fieldResolver: optionsObject.fieldResolver,
debug: optionsObject.debug,
tracing: optionsObject.tracing,
cacheControl: cacheControl
? omit(cacheControl, [
'calculateHttpHeaders',
'stripFormattedExtensions',
])
: false,
request: request.request,
extensions: optionsObject.extensions,
queryExtensions: extensions,
persistedQueryHit,
persistedQueryRegister,
};
return runQuery(params);
} catch (e) {
// Populate any HttpQueryError to our handler which should
// convert it to Http Error.
if (e.name === 'HttpQueryError') {
// async function wraps this in a Promise
throw e;
}
// This error will be uncaught, so we need to wrap it and treat it as an
// internal server error
return {
errors: formatApolloErrors([e], optionsObject),
};
}
}) as Array<Promise<ExecutionResult & { extensions?: Record<string, any> }>>;
let responses;
try {
responses = await Promise.all(requests);
} catch (e) {
if (e.name === 'HttpQueryError') {
throw e;
}
return throwHttpGraphQLError(500, [e], optionsObject);
}
const responseInit: ApolloServerHttpResponse = {
headers: {
'Content-Type': 'application/json',
},
};
if (cacheControl) {
if (cacheControl.calculateHttpHeaders) {
const calculatedHeaders = calculateCacheControlHeaders(responses);
responseInit.headers = {
...responseInit.headers,
...calculatedHeaders,
};
}
if (cacheControl.stripFormattedExtensions) {
responses.forEach(response => {
if (response.extensions) {
delete response.extensions.cacheControl;
if (Object.keys(response.extensions).length === 0) {
delete response.extensions;
}
}
});
}
}
if (!isBatch) {
const graphqlResponse = responses[0];
// This code is run on parse/validation errors and any other error that
// doesn't reach GraphQL execution
if (graphqlResponse.errors && typeof graphqlResponse.data === 'undefined') {
// don't include optionsObject, since the errors have already been formatted
return throwHttpGraphQLError(400, graphqlResponse.errors as any);
}
const stringified = prettyJSONStringify(graphqlResponse);
responseInit.headers!['Content-Length'] = Buffer.byteLength(
stringified,
'utf8',
).toString();
return {
graphqlResponse: stringified,
responseInit,
};
}
const stringified = prettyJSONStringify(responses);
responseInit.headers!['Content-Length'] = Buffer.byteLength(
stringified,
'utf8',
).toString();
return {
graphqlResponse: stringified,
responseInit,
query: queryString,
operationName,
variables,
extensions,
http: httpRequest,
};
}
function serializeGraphQLResponse(
response: GraphQLResponse,
): Pick<GraphQLResponse, 'errors' | 'data' | 'extensions'> {
// See https://github.com/facebook/graphql/pull/384 for why
// errors comes first.
return {
errors: response.errors,
data: response.data,
extensions: response.extensions,
};
}
// The result of a curl does not appear well in the terminal, so we add an extra new line
function prettyJSONStringify(value: any) {
return JSON.stringify(value) + '\n';
}
function cloneObject<T extends Object>(object: T): T {
return Object.assign(Object.create(Object.getPrototypeOf(object)), object);
}

View file

@ -1,294 +0,0 @@
import {
GraphQLSchema,
GraphQLFieldResolver,
ExecutionResult,
DocumentNode,
parse,
validate,
execute,
ExecutionArgs,
getOperationAST,
GraphQLError,
specifiedRules,
ValidationContext,
} from 'graphql';
import { Request } from 'apollo-server-env';
import {
enableGraphQLExtensions,
GraphQLExtension,
GraphQLExtensionStack,
} from 'graphql-extensions';
import { TracingExtension } from 'apollo-tracing';
import {
CacheControlExtension,
CacheControlExtensionOptions,
} from 'apollo-cache-control';
import {
fromGraphQLError,
formatApolloErrors,
ValidationError,
SyntaxError,
} from 'apollo-server-errors';
export interface GraphQLResponse {
data?: object;
errors?: Array<GraphQLError & object>;
extensions?: object;
}
export interface QueryOptions {
schema: GraphQLSchema;
// Specify exactly one of these. parsedQuery is primarily for use by
// OperationStore.
queryString?: string;
parsedQuery?: DocumentNode;
// If this is specified and the given GraphQL query is not a "query" (eg, it's
// a mutation), throw this error.
nonQueryError?: Error;
rootValue?: ((parsedQuery: DocumentNode) => any) | any;
context?: any;
variables?: { [key: string]: any };
operationName?: string;
validationRules?: Array<(context: ValidationContext) => any>;
fieldResolver?: GraphQLFieldResolver<any, any>;
// WARNING: these extra validation rules are only applied to queries
// submitted as string, not those submitted as Document!
formatError?: Function;
formatResponse?: Function;
debug?: boolean;
tracing?: boolean;
cacheControl?: boolean | CacheControlExtensionOptions;
request: Pick<Request, 'url' | 'method' | 'headers'>;
extensions?: Array<() => GraphQLExtension>;
queryExtensions?: Record<string, any>;
persistedQueryHit?: boolean;
persistedQueryRegister?: boolean;
}
function isQueryOperation(query: DocumentNode, operationName?: string) {
const operationAST = getOperationAST(query, operationName);
return operationAST && operationAST.operation === 'query';
}
export function runQuery(options: QueryOptions): Promise<GraphQLResponse> {
// Fiber-aware Promises run their .then callbacks in Fibers.
return Promise.resolve().then(() => doRunQuery(options));
}
function doRunQuery(options: QueryOptions): Promise<GraphQLResponse> {
if (options.queryString && options.parsedQuery) {
throw new Error('Only supply one of queryString and parsedQuery');
}
if (!(options.queryString || options.parsedQuery)) {
throw new Error('Must supply one of queryString and parsedQuery');
}
const debugDefault =
process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'test';
const debug = options.debug !== undefined ? options.debug : debugDefault;
const context = options.context || {};
// If custom extension factories were provided, create per-request extension
// objects.
const extensions = options.extensions ? options.extensions.map(f => f()) : [];
// If you're running behind an engineproxy, set these options to turn on
// tracing and cache-control extensions.
if (options.tracing) {
extensions.push(new TracingExtension());
}
if (options.cacheControl === true) {
extensions.push(new CacheControlExtension());
} else if (options.cacheControl) {
extensions.push(new CacheControlExtension(options.cacheControl));
}
const extensionStack = new GraphQLExtensionStack(extensions);
// We unconditionally create an extensionStack, even if there are no
// extensions (so that we don't have to litter the rest of this function with
// `if (extensionStack)`, but we don't instrument the schema unless there
// actually are extensions. We do unconditionally put the stack on the
// context, because if some other call had extensions and the schema is
// already instrumented, that's the only way to get a custom fieldResolver to
// work.
if (extensions.length > 0) {
enableGraphQLExtensions(options.schema);
}
context._extensionStack = extensionStack;
const requestDidEnd = extensionStack.requestDidStart({
// Since the Request interfacess are not the same between node-fetch and
// typescript's lib dom, we should limit the fields that need to be passed
// into requestDidStart to only the ones we need, currently just the
// headers, method, and url
request: options.request as any,
queryString: options.queryString,
parsedQuery: options.parsedQuery,
operationName: options.operationName,
variables: options.variables,
persistedQueryHit: options.persistedQueryHit,
persistedQueryRegister: options.persistedQueryRegister,
context,
extensions: options.queryExtensions,
});
return Promise.resolve()
.then(
(): Promise<GraphQLResponse> => {
// Parse the document.
let documentAST: DocumentNode;
if (options.parsedQuery) {
documentAST = options.parsedQuery;
} else if (!options.queryString) {
throw new Error('Must supply one of queryString and parsedQuery');
} else {
const parsingDidEnd = extensionStack.parsingDidStart({
queryString: options.queryString,
});
let graphqlParseErrors: SyntaxError[] | undefined;
try {
documentAST = parse(options.queryString);
} catch (syntaxError) {
graphqlParseErrors = formatApolloErrors(
[
fromGraphQLError(syntaxError, {
errorClass: SyntaxError,
}),
],
{
debug,
},
);
return Promise.resolve({ errors: graphqlParseErrors });
} finally {
parsingDidEnd(...(graphqlParseErrors || []));
}
}
if (
options.nonQueryError &&
!isQueryOperation(documentAST, options.operationName)
) {
// XXX this goes to requestDidEnd, is that correct or should it be
// validation?
throw options.nonQueryError;
}
let rules = specifiedRules;
if (options.validationRules) {
rules = rules.concat(options.validationRules);
}
const validationDidEnd = extensionStack.validationDidStart();
let validationErrors: GraphQLError[] | undefined;
try {
validationErrors = validate(
options.schema,
documentAST,
rules,
) as GraphQLError[]; // Return type of validate is ReadonlyArray<GraphQLError>
} catch (validationThrewError) {
// Catch errors thrown by validate, not just those returned by it.
validationErrors = [validationThrewError];
} finally {
try {
if (validationErrors) {
validationErrors = formatApolloErrors(
validationErrors.map(err =>
fromGraphQLError(err, { errorClass: ValidationError }),
),
{
debug,
},
);
}
} finally {
validationDidEnd(...(validationErrors || []));
if (validationErrors && validationErrors.length) {
return Promise.resolve({
errors: validationErrors,
});
}
}
}
const executionArgs: ExecutionArgs = {
schema: options.schema,
document: documentAST,
rootValue:
typeof options.rootValue === 'function'
? options.rootValue(documentAST)
: options.rootValue,
contextValue: context,
variableValues: options.variables,
operationName: options.operationName,
fieldResolver: options.fieldResolver,
};
const executionDidEnd = extensionStack.executionDidStart({
executionArgs,
});
return Promise.resolve()
.then(() => execute(executionArgs))
.catch(executionError => {
return {
// These errors will get passed through formatApolloErrors in the
// `then` below.
// TODO accurate code for this error, which describes this error, which
// can occur when:
// * variables incorrectly typed/null when nonnullable
// * unknown operation/operation name invalid
// * operation type is unsupported
// Options: PREPROCESSING_FAILED, GRAPHQL_RUNTIME_CHECK_FAILED
errors: [fromGraphQLError(executionError)],
} as ExecutionResult;
})
.then(result => {
let response: GraphQLResponse = {
data: result.data,
};
if (result.errors) {
response.errors = formatApolloErrors([...result.errors], {
debug,
});
}
executionDidEnd(...(result.errors || []));
const formattedExtensions = extensionStack.format();
if (Object.keys(formattedExtensions).length > 0) {
response.extensions = formattedExtensions;
}
if (options.formatResponse) {
response = options.formatResponse(response, options);
}
return response;
});
},
)
.catch((err: Error) => {
// Handle the case of an internal server failure (or nonQueryError) ---
// we're not returning a GraphQL response so we don't call
// willSendResponse.
requestDidEnd(err);
throw err;
})
.then((graphqlResponse: GraphQLResponse) => {
const response = extensionStack.willSendResponse({
graphqlResponse,
context,
});
requestDidEnd();
return response.graphqlResponse;
});
}

View file

@ -14,6 +14,8 @@ import {
GraphQLServerOptions as GraphQLOptions,
PersistedQueryOptions,
} from './graphqlOptions';
import { CacheControlExtensionOptions } from 'apollo-cache-control';
import { ApolloServerPlugin } from 'apollo-server-plugin-base';
export { KeyValueCache } from 'apollo-server-caching';
@ -44,7 +46,6 @@ export interface Config
| 'validationRules'
| 'formatResponse'
| 'fieldResolver'
| 'cacheControl'
| 'tracing'
| 'dataSources'
| 'cache'
@ -59,6 +60,8 @@ export interface Config
mockEntireSchema?: boolean;
engine?: boolean | EngineReportingOptions;
extensions?: Array<() => GraphQLExtension>;
cacheControl?: CacheControlExtensionOptions | boolean;
plugins?: PluginDefinition[];
persistedQueries?: PersistedQueryOptions | false;
subscriptions?: Partial<SubscriptionServerOptions> | string | false;
//https://github.com/jaydenseric/apollo-upload-server#options
@ -66,6 +69,10 @@ export interface Config
playground?: PlaygroundConfig;
}
export type PluginDefinition =
| ApolloServerPlugin
| (new () => ApolloServerPlugin);
export interface FileUploadOptions {
//Max allowed non-file multipart form field size in bytes; enough for your queries (default: 1 MB).
maxFieldSize?: number;

View file

@ -1,27 +0,0 @@
declare module '@apollographql/apollo-upload-server' {
import { GraphQLScalarType } from 'graphql';
export const GraphQLUpload: GraphQLScalarType;
export interface ApolloUploadOptions {
/**
* Max allowed non-file multipart form field size in bytes; enough for your queries (default: 1 MB)
*/
maxFieldSize?: number;
/**
* Max allowed file size in bytes (default: Infinity)
*/
maxFileSize?: number;
/**
* Max allowed number of files (default: Infinity)
*/
maxFiles?: number;
}
export type Request = any;
export function processRequest(
request: Request,
options?: ApolloUploadOptions,
): Promise<any>;
}

View file

@ -1,12 +0,0 @@
declare module 'mock-req' {
import { Request, Headers } from 'apollo-server-env';
class MockReq implements Pick<Request, 'method' | 'url' | 'headers'> {
constructor();
method: string;
url: string;
headers: Headers;
}
export = MockReq;
}

View file

@ -0,0 +1,60 @@
type AnyFunction = (...args: any[]) => any;
type Args<F> = F extends (...args: infer A) => any ? A : never;
type FunctionPropertyNames<T, F extends AnyFunction = AnyFunction> = {
[K in keyof T]: T[K] extends F ? K : never
}[keyof T];
type AsFunction<F> = F extends AnyFunction ? F : never;
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type DidEndHook<TArgs extends any[]> = (...args: TArgs) => void;
export class Dispatcher<T> {
constructor(protected targets: T[]) {}
public async invokeHookAsync<
TMethodName extends FunctionPropertyNames<Required<T>>
>(
methodName: TMethodName,
...args: Args<T[TMethodName]>
): Promise<UnwrapPromise<ReturnType<AsFunction<T[TMethodName]>>>[]> {
return await Promise.all(
this.targets.map(target => {
const method = target[methodName];
if (method && typeof method === 'function') {
return method(...args);
}
}),
);
}
public invokeDidStartHook<
TMethodName extends FunctionPropertyNames<
Required<T>,
((...args: any[]) => AnyFunction | void)
>,
TEndHookArgs extends Args<ReturnType<AsFunction<T[TMethodName]>>>
>(
methodName: TMethodName,
...args: Args<T[TMethodName]>
): DidEndHook<TEndHookArgs> {
const didEndHooks: DidEndHook<TEndHookArgs>[] = [];
for (const target of this.targets) {
const method = target[methodName];
if (method && typeof method === 'function') {
const didEndHook = method(...args);
if (didEndHook) {
didEndHooks.push(didEndHook);
}
}
}
return (...args: TEndHookArgs) => {
didEndHooks.reverse();
for (const didEndHook of didEndHooks) {
didEndHook(...args);
}
};
}
}

View file

@ -5,13 +5,15 @@
"outDir": "./dist"
},
"include": ["src/**/*"],
"exclude": ["**/__tests__", "**/__mocks__"],
"exclude": ["**/__tests__", "**/__mocks__", "src/requestPipelineAPI.ts"],
"references": [
{ "path": "./tsconfig.requestPipelineAPI.json" },
{ "path": "../apollo-cache-control" },
{ "path": "../apollo-datasource" },
{ "path": "../apollo-engine-reporting" },
{ "path": "../apollo-server-caching" },
{ "path": "../apollo-server-errors" },
{ "path": "../apollo-server-plugin-base" },
{ "path": "../apollo-tracing" },
{ "path": "../graphql-extensions" }
]

View file

@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist"
},
"files": ["src/requestPipelineAPI.ts"],
"references": [
{ "path": "../apollo-server-caching" }
]
}

View file

@ -36,7 +36,7 @@
"apollo-server-core": "file:../apollo-server-core",
"body-parser": "^1.18.3",
"cors": "^2.8.4",
"graphql-subscriptions": "^0.5.8",
"graphql-subscriptions": "^1.0.0",
"graphql-tools": "^4.0.0",
"type-is": "^1.6.16"
},

View file

@ -841,7 +841,7 @@ describe('apollo-server-express', () => {
resolvers,
tracing: true,
engine: {
apiKey: 'fake',
apiKey: 'service:my-app:secret',
maxAttempts: 0,
endpointUrl: 'l',
reportErrorFunction: () => {},

View file

@ -17,13 +17,9 @@ export interface ExpressGraphQLOptionsFunction {
// - simple, fast and secure
//
export interface ExpressHandler {
(req: express.Request, res: express.Response, next): void;
}
export function graphqlExpress(
options: GraphQLOptions | ExpressGraphQLOptionsFunction,
): ExpressHandler {
): express.Handler {
if (!options) {
throw new Error('Apollo Server requires options.');
}
@ -35,11 +31,7 @@ export function graphqlExpress(
);
}
const graphqlHandler = (
req: express.Request,
res: express.Response,
next,
): void => {
return (req, res, next): void => {
runHttpQuery([req, res], {
method: req.method,
options: options,
@ -47,9 +39,11 @@ export function graphqlExpress(
request: convertNodeHttpToRequest(req),
}).then(
({ graphqlResponse, responseInit }) => {
Object.keys(responseInit.headers).forEach(key =>
res.setHeader(key, responseInit.headers[key]),
);
if (responseInit.headers) {
for (const [name, value] of Object.entries(responseInit.headers)) {
res.setHeader(name, value);
}
}
res.write(graphqlResponse);
res.end();
},
@ -59,9 +53,9 @@ export function graphqlExpress(
}
if (error.headers) {
Object.keys(error.headers).forEach(header => {
res.setHeader(header, error.headers[header]);
});
for (const [name, value] of Object.entries(error.headers)) {
res.setHeader(name, value);
}
}
res.statusCode = error.statusCode;
@ -70,6 +64,4 @@ export function graphqlExpress(
},
);
};
return graphqlHandler;
}

View file

@ -3,8 +3,6 @@
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"noImplicitAny": false,
"strictNullChecks": false
},
"include": ["src/**/*"],
"exclude": ["**/__tests__", "**/__mocks__"],

View file

@ -30,7 +30,7 @@
"accept": "^3.0.2",
"apollo-server-core": "file:../apollo-server-core",
"boom": "^7.1.0",
"graphql-subscriptions": "^0.5.8",
"graphql-subscriptions": "^1.0.0",
"graphql-tools": "^4.0.0"
},
"devDependencies": {

View file

@ -6,7 +6,7 @@ import express = require('express');
import bodyParser = require('body-parser');
import yup = require('yup');
import { Trace } from 'apollo-engine-reporting-protobuf';
import { FullTracesReport, ITrace } from 'apollo-engine-reporting-protobuf';
import {
GraphQLSchema,
@ -480,28 +480,46 @@ export function testApolloServer<AS extends ApolloServerBase>(
});
describe('lifecycle', () => {
async function startEngineServer({ port, check }) {
const engine = express();
engine.use((req, _res, next) => {
describe('with Engine server', () => {
let nodeEnv: string;
beforeEach(() => {
nodeEnv = process.env.NODE_ENV;
delete process.env.NODE_ENV;
});
let engineServer: http.Server;
function startEngineServer({ check }): Promise<void> {
return new Promise(resolve => {
const app = express();
app.use((req, _res, next) => {
// body parser requires a content-type
req.headers['content-type'] = 'text/plain';
next();
});
engine.use(
app.use(
bodyParser.raw({
inflate: true,
type: '*/*',
}),
);
engine.use(check);
return await engine.listen(port);
app.use(check);
engineServer = app.listen(0, resolve);
});
}
it('validation > engine > extensions > formatError', async () => {
return new Promise(async (resolve, reject) => {
const nodeEnv = process.env.NODE_ENV;
delete process.env.NODE_ENV;
afterEach(done => {
process.env.NODE_ENV = nodeEnv;
if (engineServer) {
engineServer.close(done);
} else {
done();
}
});
it('validation > engine > extensions > formatError', async () => {
const throwError = jest.fn(() => {
throw new Error('nope');
});
@ -510,20 +528,23 @@ export function testApolloServer<AS extends ApolloServerBase>(
// formatError should be called after validation
expect(formatError).not.toBeCalled();
// extension should be called after validation
expect(extension).not.toBeCalled();
expect(willSendResponseInExtension).not.toBeCalled();
return true;
});
const extension = jest.fn();
const willSendResponseInExtension = jest.fn();
const formatError = jest.fn(error => {
expect(error instanceof Error).toBe(true);
try {
expect(error).toBeInstanceOf(Error);
// extension should be called before formatError
expect(extension).toHaveBeenCalledTimes(1);
expect(willSendResponseInExtension).toHaveBeenCalledTimes(1);
// validationRules should be called before formatError
expect(validationRule).toHaveBeenCalledTimes(1);
} finally {
error.message = 'masked';
return error;
}
});
class Extension<TContext = any> extends GraphQLExtension {
@ -536,11 +557,24 @@ export function testApolloServer<AS extends ApolloServerBase>(
expect(formatError).not.toBeCalled();
// validationRules should be called before extensions
expect(validationRule).toHaveBeenCalledTimes(1);
extension();
willSendResponseInExtension();
}
}
const port = Math.floor(Math.random() * (65535 - 1025)) + 1025;
let engineServerDidStart: Promise<void>;
const didReceiveTrace = new Promise<ITrace>(resolve => {
engineServerDidStart = startEngineServer({
check: (req, res) => {
const report = FullTracesReport.decode(req.body);
const trace = Object.values(report.tracesPerQuery)[0].trace[0];
resolve(trace);
res.end();
},
});
});
await engineServerDidStart;
const { url: uri } = await createApolloServer({
typeDefs: gql`
@ -558,29 +592,16 @@ export function testApolloServer<AS extends ApolloServerBase>(
validationRules: [validationRule],
extensions: [() => new Extension()],
engine: {
endpointUrl: `http://localhost:${port}`,
apiKey: 'fake',
endpointUrl: `http://localhost:${
(engineServer.address() as net.AddressInfo).port
}`,
apiKey: 'service:my-app:secret',
maxUncompressedReportSize: 1,
},
formatError,
debug: true,
});
let listener = await startEngineServer({
port,
check: (req, res) => {
const trace = JSON.stringify(Trace.decode(req.body));
try {
expect(trace).toMatch(/nope/);
expect(trace).not.toMatch(/masked/);
} catch (e) {
reject(e);
}
res.end();
listener.close(resolve);
},
});
const apolloFetch = createApolloFetch({ uri });
const result = await apolloFetch({
@ -591,10 +612,16 @@ export function testApolloServer<AS extends ApolloServerBase>(
});
expect(result.errors).toBeDefined();
expect(result.errors[0].message).toEqual('masked');
expect(formatError).toHaveBeenCalledTimes(1);
expect(throwError).toHaveBeenCalledTimes(1);
process.env.NODE_ENV = nodeEnv;
expect(validationRule).toHaveBeenCalledTimes(1);
expect(throwError).toHaveBeenCalledTimes(1);
expect(formatError).toHaveBeenCalledTimes(1);
expect(willSendResponseInExtension).toHaveBeenCalledTimes(1);
const trace = await didReceiveTrace;
expect(trace.root!.child![0].error![0].message).toMatch(/nope/);
expect(trace.root!.child![0].error![0].message).not.toMatch(/masked/);
});
});

View file

@ -5,7 +5,8 @@
"outDir": "./dist",
"noImplicitAny": false,
"strictNullChecks": false,
"lib": ["es2017", "esnext.asynciterable", "dom"]
"lib": ["es2017", "esnext.asynciterable", "dom"],
"types": ["node", "jest"]
},
"include": ["src/**/*"],
"exclude": ["**/__tests__", "**/__mocks__"],

View file

@ -36,7 +36,7 @@
"@types/koa__cors": "^2.2.1",
"accepts": "^1.3.5",
"apollo-server-core": "file:../apollo-server-core",
"graphql-subscriptions": "^0.5.8",
"graphql-subscriptions": "^1.0.0",
"graphql-tools": "^4.0.0",
"koa": "2.5.3",
"koa-bodyparser": "^3.0.0",

View file

@ -737,7 +737,7 @@ describe('apollo-server-koa', () => {
resolvers,
tracing: true,
engine: {
apiKey: 'fake',
apiKey: 'service:my-app:secret',
maxAttempts: 0,
endpointUrl: 'l',
reportErrorFunction: () => {},

View file

@ -0,0 +1,6 @@
*
!src/**/*
!dist/**/*
dist/**/*.test.*
!package.json
!README.md

View file

@ -0,0 +1,4 @@
# Change Log
### vNEXT

View file

@ -0,0 +1 @@
# `apollo-server-plugin-base`

View file

@ -0,0 +1,20 @@
{
"name": "apollo-server-plugin-base",
"version": "0.0.1",
"description": "Apollo Server plugin base classes",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"keywords": [],
"author": "Apollo <opensource@apollographql.com>",
"license": "MIT",
"engines": {
"node": ">=6"
},
"devDependencies": {
"apollo-server-core": "file:../apollo-server-core",
"apollo-server-env": "file:../apollo-server-env"
},
"peerDependencies": {
"graphql": "^0.12.0 || ^0.13.0 || ^14.0.0"
}
}

View file

@ -0,0 +1,41 @@
import {
GraphQLServiceContext,
GraphQLRequestContext,
} from 'apollo-server-core/dist/requestPipelineAPI';
export { GraphQLServiceContext, GraphQLRequestContext };
type ValueOrPromise<T> = T | Promise<T>;
export abstract class ApolloServerPlugin {
serverWillStart?(service: GraphQLServiceContext): ValueOrPromise<void>;
requestDidStart?<TContext>(
requestContext: GraphQLRequestContext<TContext>,
): GraphQLRequestListener<TContext> | void;
}
export type WithRequired<T, K extends keyof T> = T & Required<Pick<T, K>>;
export type DidEndHook<TArgs extends any[]> = (...args: TArgs) => void;
export interface GraphQLRequestListener<TContext = Record<string, any>> {
parsingDidStart?(
requestContext: GraphQLRequestContext<TContext>,
): DidEndHook<[Error?]> | void;
validationDidStart?(
requestContext: WithRequired<GraphQLRequestContext<TContext>, 'document'>,
): DidEndHook<[ReadonlyArray<Error>?]> | void;
didResolveOperation?(
requestContext: WithRequired<
GraphQLRequestContext<TContext>,
'document' | 'operationName' | 'operation'
>,
): ValueOrPromise<void>;
executionDidStart?(
requestContext: WithRequired<
GraphQLRequestContext<TContext>,
'document' | 'operationName' | 'operation'
>,
): DidEndHook<[Error?]> | void;
willSendResponse?(
requestContext: WithRequired<GraphQLRequestContext<TContext>, 'response'>,
): ValueOrPromise<void>;
}

View file

@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist"
},
"include": ["src/**/*"],
"exclude": ["**/__tests__", "**/__mocks__"],
"references": [
{ "path": "../apollo-server-core/tsconfig.requestPipelineAPI.json" },
]
}

View file

@ -24,7 +24,7 @@
"apollo-server-core": "file:../apollo-server-core",
"apollo-server-express": "file:../apollo-server-express",
"express": "^4.0.0",
"graphql-subscriptions": "^0.5.8",
"graphql-subscriptions": "^1.0.0",
"graphql-tools": "^4.0.0"
},
"peerDependencies": {

View file

@ -78,6 +78,8 @@ export class ApolloServer extends ApolloServerBase {
// Listen takes the same arguments as http.Server.listen.
public async listen(...opts: Array<any>): Promise<ServerInfo> {
await this.willStart();
// This class is the easy mode for people who don't create their own express
// object, so we have to create it.
const app = express();

View file

@ -13,7 +13,8 @@
"engines": {
"node": ">=6.0"
},
"dependencies": {
"devDependencies": {
"apollo-server-core": "file:../apollo-server-core",
"apollo-server-env": "file:../apollo-server-env"
},
"peerDependencies": {

View file

@ -1,6 +1,5 @@
import {
GraphQLSchema,
GraphQLError,
GraphQLObjectType,
getNamedType,
GraphQLField,
@ -14,6 +13,9 @@ import {
import { Request } from 'apollo-server-env';
export { Request } from 'apollo-server-env';
import { GraphQLResponse } from 'apollo-server-core/dist/requestPipelineAPI';
export { GraphQLResponse };
export type EndHandler = (...errors: Array<Error>) => void;
// A StartHandlerInvoker is a function that, given a specific GraphQLExtension,
// finds a specific StartHandler on that extension and calls it with appropriate
@ -22,18 +24,9 @@ type StartHandlerInvoker<TContext = any> = (
ext: GraphQLExtension<TContext>,
) => EndHandler | void;
// Copied from runQuery in apollo-server-core.
// XXX Will this work properly if it's an identical interface of the
// same name?
export interface GraphQLResponse {
data?: object;
errors?: Array<GraphQLError & object>;
extensions?: object;
}
export class GraphQLExtension<TContext = any> {
public requestDidStart?(o: {
request: Request;
request: Pick<Request, 'url' | 'method' | 'headers'>;
queryString?: string;
parsedQuery?: DocumentNode;
operationName?: string;
@ -73,7 +66,7 @@ export class GraphQLExtensionStack<TContext = any> {
}
public requestDidStart(o: {
request: Request;
request: Pick<Request, 'url' | 'method' | 'headers'>;
queryString?: string;
parsedQuery?: DocumentNode;
operationName?: string;
@ -159,17 +152,27 @@ export class GraphQLExtensionStack<TContext = any> {
const endHandlers: EndHandler[] = [];
this.extensions.forEach(extension => {
// Invoke the start handler, which may return an end handler.
try {
const endHandler = startInvoker(extension);
if (endHandler) {
endHandlers.push(endHandler);
}
} catch (error) {
console.error(error);
}
});
return (...errors: Array<Error>) => {
// We run end handlers in reverse order of start handlers. That way, the
// first handler in the stack "surrounds" the entire event's process
// (helpful for tracing/reporting!)
endHandlers.reverse();
endHandlers.forEach(endHandler => endHandler(...errors));
for (const endHandler of endHandlers) {
try {
endHandler(...errors);
} catch (error) {
console.error(error);
}
}
};
}
}

View file

@ -7,5 +7,6 @@
"include": ["src/**/*"],
"exclude": ["**/__tests__", "**/__mocks__"],
"references": [
{ "path": "../apollo-server-core/tsconfig.requestPipelineAPI.json" },
]
}

View file

@ -16,6 +16,10 @@
"noUnusedParameters": true,
"noUnusedLocals": true,
"lib": ["es2017", "esnext.asynciterable"],
"types": ["node", "jest"]
"types": ["node"],
"baseUrl": ".",
"paths": {
"*" : ["types/*"]
}
}
}

View file

@ -18,6 +18,7 @@
{ "path": "./packages/apollo-server-koa" },
{ "path": "./packages/apollo-server-lambda" },
{ "path": "./packages/apollo-server-micro" },
{ "path": "./packages/apollo-server-plugin-base" },
{ "path": "./packages/apollo-tracing" },
{ "path": "./packages/graphql-extensions" }
]

View file

@ -1,3 +1,6 @@
{
"extends": "./tsconfig.base"
"extends": "./tsconfig.base",
"compilerOptions": {
"types": ["node", "jest"],
}
}

View file

@ -0,0 +1,25 @@
import { GraphQLScalarType } from 'graphql';
export const GraphQLUpload: GraphQLScalarType;
export interface ApolloUploadOptions {
/**
* Max allowed non-file multipart form field size in bytes; enough for your queries (default: 1 MB)
*/
maxFieldSize?: number;
/**
* Max allowed file size in bytes (default: Infinity)
*/
maxFileSize?: number;
/**
* Max allowed number of files (default: Infinity)
*/
maxFiles?: number;
}
export type Request = any;
export function processRequest(
request: Request,
options?: ApolloUploadOptions,
): Promise<any>;

10
types/mock-req/index.d.ts vendored Normal file
View file

@ -0,0 +1,10 @@
import { Request, Headers } from 'apollo-server-env';
declare class MockReq implements Pick<Request, 'method' | 'url' | 'headers'> {
constructor();
method: string;
url: string;
headers: Headers;
}
export = MockReq;