mirror of
https://github.com/vale981/apollo-server
synced 2025-03-06 02:01:40 -05:00
Merge pull request #1795 from apollographql/abernix/re-new-request-pipeline
New Request Pipeline
This commit is contained in:
commit
db8fdc8d9d
46 changed files with 1402 additions and 937 deletions
31
jest.config.js
Normal file
31
jest.config.js
Normal 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
60
package-lock.json
generated
|
@ -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"
|
||||
|
|
40
package.json
40
package.json
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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}`,
|
||||
};
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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> = {
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import 'apollo-server-env';
|
||||
|
||||
export { runQuery } from './runQuery';
|
||||
export { runHttpQuery, HttpQueryRequest, HttpQueryError } from './runHttpQuery';
|
||||
|
||||
export {
|
||||
|
|
418
packages/apollo-server-core/src/requestPipeline.ts
Normal file
418
packages/apollo-server-core/src/requestPipeline.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
64
packages/apollo-server-core/src/requestPipelineAPI.ts
Normal file
64
packages/apollo-server-core/src/requestPipelineAPI.ts
Normal 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 {}
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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>;
|
||||
}
|
|
@ -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;
|
||||
}
|
60
packages/apollo-server-core/src/utils/dispatcher.ts
Normal file
60
packages/apollo-server-core/src/utils/dispatcher.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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" }
|
||||
]
|
||||
|
|
11
packages/apollo-server-core/tsconfig.requestPipelineAPI.json
Normal file
11
packages/apollo-server-core/tsconfig.requestPipelineAPI.json
Normal file
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"files": ["src/requestPipelineAPI.ts"],
|
||||
"references": [
|
||||
{ "path": "../apollo-server-caching" }
|
||||
]
|
||||
}
|
|
@ -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"
|
||||
},
|
||||
|
|
|
@ -841,7 +841,7 @@ describe('apollo-server-express', () => {
|
|||
resolvers,
|
||||
tracing: true,
|
||||
engine: {
|
||||
apiKey: 'fake',
|
||||
apiKey: 'service:my-app:secret',
|
||||
maxAttempts: 0,
|
||||
endpointUrl: 'l',
|
||||
reportErrorFunction: () => {},
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -3,8 +3,6 @@
|
|||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"noImplicitAny": false,
|
||||
"strictNullChecks": false
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["**/__tests__", "**/__mocks__"],
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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__"],
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -737,7 +737,7 @@ describe('apollo-server-koa', () => {
|
|||
resolvers,
|
||||
tracing: true,
|
||||
engine: {
|
||||
apiKey: 'fake',
|
||||
apiKey: 'service:my-app:secret',
|
||||
maxAttempts: 0,
|
||||
endpointUrl: 'l',
|
||||
reportErrorFunction: () => {},
|
||||
|
|
6
packages/apollo-server-plugin-base/.npmignore
Normal file
6
packages/apollo-server-plugin-base/.npmignore
Normal file
|
@ -0,0 +1,6 @@
|
|||
*
|
||||
!src/**/*
|
||||
!dist/**/*
|
||||
dist/**/*.test.*
|
||||
!package.json
|
||||
!README.md
|
4
packages/apollo-server-plugin-base/CHANGELOG.md
Normal file
4
packages/apollo-server-plugin-base/CHANGELOG.md
Normal file
|
@ -0,0 +1,4 @@
|
|||
# Change Log
|
||||
|
||||
### vNEXT
|
||||
|
1
packages/apollo-server-plugin-base/README.md
Normal file
1
packages/apollo-server-plugin-base/README.md
Normal file
|
@ -0,0 +1 @@
|
|||
# `apollo-server-plugin-base`
|
20
packages/apollo-server-plugin-base/package.json
Normal file
20
packages/apollo-server-plugin-base/package.json
Normal 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"
|
||||
}
|
||||
}
|
41
packages/apollo-server-plugin-base/src/index.ts
Normal file
41
packages/apollo-server-plugin-base/src/index.ts
Normal 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>;
|
||||
}
|
12
packages/apollo-server-plugin-base/tsconfig.json
Normal file
12
packages/apollo-server-plugin-base/tsconfig.json
Normal 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" },
|
||||
]
|
||||
}
|
|
@ -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": {
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,5 +7,6 @@
|
|||
"include": ["src/**/*"],
|
||||
"exclude": ["**/__tests__", "**/__mocks__"],
|
||||
"references": [
|
||||
{ "path": "../apollo-server-core/tsconfig.requestPipelineAPI.json" },
|
||||
]
|
||||
}
|
||||
|
|
|
@ -16,6 +16,10 @@
|
|||
"noUnusedParameters": true,
|
||||
"noUnusedLocals": true,
|
||||
"lib": ["es2017", "esnext.asynciterable"],
|
||||
"types": ["node", "jest"]
|
||||
"types": ["node"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"*" : ["types/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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" }
|
||||
]
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
{
|
||||
"extends": "./tsconfig.base"
|
||||
"extends": "./tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"types": ["node", "jest"],
|
||||
}
|
||||
}
|
||||
|
|
25
types/@apollographql/apollo-upload-server/index.d.ts
vendored
Normal file
25
types/@apollographql/apollo-upload-server/index.d.ts
vendored
Normal 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
10
types/mock-req/index.d.ts
vendored
Normal 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;
|
Loading…
Add table
Reference in a new issue