From 48839d7efe6af2a280fe87a210904a56bb0421da Mon Sep 17 00:00:00 2001 From: Liliana Matos Date: Thu, 3 Jan 2019 18:29:05 -0500 Subject: [PATCH] add support for @defer directive --- .gitignore | 2 + LICENSE | 6 + docs/source/defer-support.md | 246 +++ package-lock.json | 9 +- packages/apollo-server-core/package.json | 1 + .../apollo-server-core/src/ApolloServer.ts | 24 +- .../src/GraphQLDeferDirective.ts | 17 + .../src/__tests__/graphql.ts | 210 +++ .../src/__tests__/runQuery.test.ts | 96 +- .../src/__tests__/starWarsData.ts | 176 ++ .../src/__tests__/starWarsDefer-test.ts | 967 +++++++++++ .../__tests__/starWarsIntrospection-test.ts | 417 +++++ .../src/__tests__/starWarsQuery-test.ts | 511 ++++++ .../src/__tests__/starWarsSchema.ts | 327 ++++ packages/apollo-server-core/src/execute.ts | 1450 +++++++++++++++++ .../apollo-server-core/src/graphqlOptions.ts | 2 + packages/apollo-server-core/src/index.ts | 2 + .../apollo-server-core/src/requestPipeline.ts | 155 +- .../src/requestPipelineAPI.ts | 29 +- .../apollo-server-core/src/runHttpQuery.ts | 90 +- .../CannotDeferNonNullableFields.ts | 33 + packages/apollo-server-core/tsconfig.json | 5 +- packages/apollo-server-express/package.json | 1 + .../src/expressApollo.ts | 48 +- packages/apollo-server-hapi/package.json | 3 +- packages/apollo-server-hapi/src/hapiApollo.ts | 49 +- packages/apollo-server-koa/package.json | 1 + packages/apollo-server-koa/src/koaApollo.ts | 41 +- .../apollo-server-lambda/src/lambdaApollo.ts | 7 +- packages/apollo-server-micro/package.json | 1 + .../apollo-server-micro/src/microApollo.ts | 41 +- packages/apollo-server/src/exports.ts | 1 + 32 files changed, 4887 insertions(+), 81 deletions(-) create mode 100644 docs/source/defer-support.md create mode 100644 packages/apollo-server-core/src/GraphQLDeferDirective.ts create mode 100644 packages/apollo-server-core/src/__tests__/graphql.ts create mode 100644 packages/apollo-server-core/src/__tests__/starWarsData.ts create mode 100644 packages/apollo-server-core/src/__tests__/starWarsDefer-test.ts create mode 100644 packages/apollo-server-core/src/__tests__/starWarsIntrospection-test.ts create mode 100644 packages/apollo-server-core/src/__tests__/starWarsQuery-test.ts create mode 100644 packages/apollo-server-core/src/__tests__/starWarsSchema.ts create mode 100644 packages/apollo-server-core/src/execute.ts create mode 100644 packages/apollo-server-core/src/validationRules/CannotDeferNonNullableFields.ts diff --git a/.gitignore b/.gitignore index e1ccdbfbe6c..6e703896eae 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,5 @@ node_modules/ # Mac OS .DS_Store + +.idea diff --git a/LICENSE b/LICENSE index 1558a68a8a8..a41a34ab1fe 100644 --- a/LICENSE +++ b/LICENSE @@ -19,3 +19,9 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Attributions: + +The execution phase for GraphQL was modified from the source code in graphql.js, +which also uses the MIT License. The repository can be found at: +https://github.com/graphql/graphql-js diff --git a/docs/source/defer-support.md b/docs/source/defer-support.md new file mode 100644 index 00000000000..fecc2a41393 --- /dev/null +++ b/docs/source/defer-support.md @@ -0,0 +1,246 @@ +--- +title: Spec for @defer Directive +--- + +## Spec for @defer Directive + +Apollo Server supports the `@defer` directive out of the box, allowing declarative control of when individual fields in a single GraphQL query get fulfilled and sent to the client. The GraphQL execution phase does not wait for deferred fields to resolve, instead returning `null` as a placeholder, and sending patches to the client as those fields get resolved asynchronously. + +This document describes the implementation of `@defer` support in Apollo Server, and how it interoperates with Apollo Client. + +## The `@defer` Directive + +This is how the directive is defined using GraphQL DSL: + +```graphql +directive @defer(if: Boolean = true) on FIELD +``` + +The built-in `@include` and `@skip` directives should take precedence over `@defer`. + +In Apollo Server, `@defer` is defined by default, so the user does not have to add it to their schema to use it. + +### Caveats regarding `@defer` usage + +- Mutations: Not supported. Would love to hear from the community if there are any use cases for this. + +- Non-Nullable Types: Not allowed and should throw a GraphQL validation error. This is because deferred fields are returned as `null` in the initial response, and we want deferred queries to work with existing type generation tools. Deferring non-nullable types may also lead to unexpected behavior when errors occur, since errors will propagate up to the nearest nullable parent as per the GraphQL spec. We want to avoid letting errors on deferred fields clobber the initial data that was loaded already. + +- Nesting: `@defer` can be nested arbitrarily. For example, we can defer a list type, and defer a field on an object in the list. During execution, we ensure that the patch for a parent field will be sent before its children, even if the child object resolves first. This will simplify the logic for merging patches. + +### Runtime Behavior +- In our implementation, we did not suspend executing the resolver functions of deferred fields, but rather, chose not to wait on them before sending early results to the client. This decision was made with the assumption that resolvers spend most of its time waiting on I/O, rather than actual computation. However, implementors may choose either approach. + +- `@defer` should apply regardless of data availability. Even if the deferred fields are available in memory immediately, it should not be sent with the initial response. For example, even if the entire `Story` object is queried from the database as a single object, we still defer sending the `comments` field. The reason that this behavior is useful is because some fields can incur high bandwidth to transfer, slowing down initial load. + +- Resolver level errors are returned in the `errors` field of its **nearest deferred parent**. For example, if the `text` field on `comments` throws an resolver error, it gets sent with the patch for `comments`, rather than with the initial response. + ```graphql + query { + newsFeed { + stories { + text + comments @defer { + text <- throws error + } + } + } + } + ``` + These errors will be merged in the `graphQLErrors` array on Apollo Client. + + - If there are multiple declarations of a field within the query, **all** of them have to contain `@defer` for the field to be deferred. This could happen if we have use a fragment like this: + ```graphql + fragment StoryDetail on Story { + id + text + } + query { + newsFeed { + stories { + text @defer + ...StoryDetail + } + } + } + ``` + In this case, `text` will not be deferred since `@defer` was not applied in the fragment definition. + + A common pattern around fragments is to bind it to a component and reuse them across different parts of your UI. This is why it would be ideal to make sure that the `@defer` behavior of fields in a fragment is not overridden. + +## Transport + +To provide the easiest upgrade path for a majority of users using Apollo Client, we opted for using Multipart HTTP as the default transport. This is more lightweight than other streaming methods like websockets, with no additional overhead for clients that do not send queries with `@defer`. + +One drawback of using Multipart HTTP is that there is generally a finite browser timeout for a pending request. This is usually not an issue for `@defer`'s intended use case, but if there is a need to use `@defer` on long-lived requests, a different transport is required. + +We are working on refactoring the request pipeline in Apollo Server to make it easier to add support for other transport modules. + +## Apollo Server Variants +In order to support `@defer`, Apollo Server variants like Koa, Hapi etc must explicitly support and enable it. This is done by passing in an `enableDefer` flag to `runHttpQuery`. For illustration, this is how it looks like on `apollo-server-express`. Without this flag, the `@defer` directive will be ignored. + +```typescript +const graphqlHandler = async ( + req: express.Request, + res: express.Response, + next, + ) => { + const a = runHttpQuery([req, res], { + method: req.method, + options: options, + query: req.method === 'POST' ? req.body : req.query, + request: convertNodeHttpToRequest(req), + enableDefer: true, + }).then(() => {}) + } +``` + +## Response Specification + +Apollo Client is able to read from a Multipart HTTP response stream (using `apollo-link-http`) and merge patches with the intial payload. + +```graphql +{ + query { + newsFeed { + stories { + id + text + comments { + text + } + } + recommendedForYou { + story { + id + text + } + matchScore + } + } + } +} +``` + +For the sample query above, Apollo Client expects a response following this specification. + +- The HTTP response should adhere to the [HTTP Multipart Content-Type](https://www.w3.org/Protocols/rfc1341/7_2_Multipart.html) format. + +- Each part of the multipart response should have `Content-Type` set to `application/json`. `Content-Length` should also be set for each part. + +- Since the body of each part is JSON, it is safe to use `-` as the simplest boundary for each part. Therefore, each delimiter looks like `\r\n---\r\n` and the terminating delimiter looks like `\r\n-----\r\n`. + +- The first part of the multipart response should contain the requested data, with the values of the deferred fields set to `null`. It looks like a typical GraphQL response. + + ``` + { + data?: {} + errors?: [GraphQLError] + } + ``` + +- Subsequent parts should contain patches that have the following fields: + ``` + { + path: [string | number] + data?: {} + errors?: [GraphQLError] + } + ``` + where `path` is the path to the field where the patch should be merged with the initial response. +- The server should ensure that patches are ordered according to its hierachy in the data tree. A patch for a deferred field that is a parent of other deferred fields should come first. + +- The server should write data/patches to the response stream as soon as it is ready. + +- Sample HTTP Multipart Response + + ``` + HTTP/1.1 200 OK + Connection: keep-alive + Content-Type: multipart/mixed; boundary="-" + Transfer-Encoding: chunked + + + --- + Content-Type: application/json + Content-Length: 999 + + { + "data": { + "newsFeed": { + "stories": [ + {"id":"1","text":"Breaking news: Apollo Project lands first human on the moon","comments":null}, + {"id":"2","text":"China's super-sized space plans may involve help from Russia","comments":null}, + {"id":"3","text":"Astronauts' snapshots from space light up the Twitterverse","comments":null} + ], + "recommendedForYou":null + } + } + } + + --- + Content-Type: application/json + Content-Length: 999 + + { + "path":["newsFeed","stories",0,"comments"], + "data":[{"text":"Wow! Incredible stuff!"},{"text":"This is awesome!"}] + } + + --- + Content-Type: application/json + Content-Length: 999 + + { + "path":["newsFeed","stories",1,"comments"], + "data":[{"text":"Fake news!"},{"text":"This is awesome!"}] + } + + --- + Content-Type: application/json + Content-Length: 999 + + { + "path":["newsFeed","stories",2,"comments"], + "data":[{"text":"Unbelievable!"},{"text":"Wow! Incredible stuff!"}] + } + + --- + Content-Type: application/json + Content-Length: 999 + + { + "path":["newsFeed","recommendedForYou"], + "data":[ + { + "story":{"id":"4","text":"Young Star May Be Devouring a Planet"}, + "matchScore":89 + }, + { + "story":{"id":"5","text":"Watch Astronauts Set Foot on the Moon in Historic NASA Footage"}, + "matchScore":92 + } + ] + } + + ----- + ``` + +## Other ideas + +These are features that may be nice to have that are not implemented in Apollo Server. + +- Having fields stream in continuously and cause a re-render may result in reflow or "UI jankyness". One way to manage this is to take an optional `waitFor` argument: + ``` + query { + asset { + title + # Always defer and send multiple responses + reviews @defer(waitFor: 0) + # If we can get the data within 200ms, send just one response + related @defer(waitFor: 200) + } + } + ``` + This could have potentially nice tie-ins with React Suspense. + +- It may also make sense to batch or throttle when updates are pushed through to the UI. \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index b9488b208af..3da36c30e4a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "apollo-server-monorepo", + "name": "dibs-apollo-server-monorepo", "requires": true, "lockfileVersion": 1, "dependencies": { @@ -2199,6 +2199,7 @@ "graphql-tag": "^2.9.2", "graphql-tools": "^4.0.0", "graphql-upload": "^8.0.2", + "iterall": "^1.2.2", "lodash": "^4.17.10", "subscriptions-transport-ws": "^0.9.11", "ws": "^6.0.0" @@ -2228,6 +2229,7 @@ "cors": "^2.8.4", "graphql-subscriptions": "^1.0.0", "graphql-tools": "^4.0.0", + "iterall": "^1.2.2", "type-is": "^1.6.16" } }, @@ -2239,7 +2241,8 @@ "apollo-server-core": "file:packages/apollo-server-core", "boom": "^7.1.0", "graphql-subscriptions": "^1.0.0", - "graphql-tools": "^4.0.0" + "graphql-tools": "^4.0.0", + "iterall": "^1.2.2" } }, "apollo-server-integration-testsuite": { @@ -2263,6 +2266,7 @@ "apollo-server-core": "file:packages/apollo-server-core", "graphql-subscriptions": "^1.0.0", "graphql-tools": "^4.0.0", + "iterall": "^1.2.2", "koa": "2.6.2", "koa-bodyparser": "^3.0.0", "koa-router": "^7.4.0", @@ -2284,6 +2288,7 @@ "@apollographql/graphql-playground-html": "^1.6.6", "accept": "^3.0.2", "apollo-server-core": "file:packages/apollo-server-core", + "iterall": "^1.2.2", "micro": "^9.3.2" } }, diff --git a/packages/apollo-server-core/package.json b/packages/apollo-server-core/package.json index c02ce0fff65..4c1ad132154 100644 --- a/packages/apollo-server-core/package.json +++ b/packages/apollo-server-core/package.json @@ -41,6 +41,7 @@ "graphql-tag": "^2.9.2", "graphql-tools": "^4.0.0", "graphql-upload": "^8.0.2", + "iterall": "^1.2.2", "lodash": "^4.17.10", "subscriptions-transport-ws": "^0.9.11", "ws": "^6.0.0" diff --git a/packages/apollo-server-core/src/ApolloServer.ts b/packages/apollo-server-core/src/ApolloServer.ts index 8d81e2f0794..a3c8f680f4d 100644 --- a/packages/apollo-server-core/src/ApolloServer.ts +++ b/packages/apollo-server-core/src/ApolloServer.ts @@ -9,6 +9,7 @@ import { GraphQLFieldResolver, ValidationContext, FieldDefinitionNode, + GraphQLSchemaConfig, } from 'graphql'; import { GraphQLExtension } from 'graphql-extensions'; import { EngineReportingAgent } from 'apollo-engine-reporting'; @@ -44,6 +45,7 @@ import { createPlaygroundOptions, PlaygroundRenderPageOptions, } from './playground'; +import GraphQLDeferDirective from './GraphQLDeferDirective'; import { generateSchemaHash } from './utils/schemaHash'; import { @@ -228,7 +230,20 @@ export class ApolloServerBase { } if (schema) { - this.schema = schema; + + // TODO: @defer directive should be added by default + const newDirectives = schema.getDirectives().slice(); + newDirectives.push(GraphQLDeferDirective); + const newSchemaConfig: GraphQLSchemaConfig = { + query: schema.getQueryType(), + mutation: schema.getMutationType(), + subscription: schema.getSubscriptionType(), + types: Object.values(schema.getTypeMap()), + directives: newDirectives, + astNode: schema.astNode, + }; + this.schema = new GraphQLSchema(newSchemaConfig); + } else if (modules) { const { schema, errors } = buildServiceDefinition(modules); if (errors && errors.length > 0) { @@ -241,7 +256,6 @@ export class ApolloServerBase { 'Apollo Server requires either an existing schema, modules or typeDefs', ); } - let augmentedTypeDefs = Array.isArray(typeDefs) ? typeDefs : [typeDefs]; // We augment the typeDefs with the @cacheControl directive and associated @@ -260,6 +274,12 @@ export class ApolloServerBase { `, ); + const deferDirectiveDef = gql` + directive @defer(if: Boolean = true) on FIELD + `; + + augmentedTypeDefs.push(deferDirectiveDef); + if (this.uploadsConfig) { const { GraphQLUpload } = require('graphql-upload'); if (resolvers && !resolvers.Upload) { diff --git a/packages/apollo-server-core/src/GraphQLDeferDirective.ts b/packages/apollo-server-core/src/GraphQLDeferDirective.ts new file mode 100644 index 00000000000..8efb26992e4 --- /dev/null +++ b/packages/apollo-server-core/src/GraphQLDeferDirective.ts @@ -0,0 +1,17 @@ +import { GraphQLDirective } from 'graphql/type/directives'; +import { GraphQLBoolean } from 'graphql/type/scalars'; +import { DirectiveLocation } from 'graphql/language/directiveLocation'; + +const GraphQLDeferDirective = new GraphQLDirective({ + name: 'defer', + description: 'Defers this field if the `if` argument is true', + locations: [DirectiveLocation.FIELD], + args: { + if: { + type: GraphQLBoolean, + description: 'Deferred when true.', + }, + }, +}); + +export default GraphQLDeferDirective; diff --git a/packages/apollo-server-core/src/__tests__/graphql.ts b/packages/apollo-server-core/src/__tests__/graphql.ts new file mode 100644 index 00000000000..f69e18c2162 --- /dev/null +++ b/packages/apollo-server-core/src/__tests__/graphql.ts @@ -0,0 +1,210 @@ +import { validateSchema } from 'graphql/type/validate'; +import { parse } from 'graphql/language/parser'; +import { validate } from 'graphql/validation/validate'; +import { execute, DeferredExecutionResult } from '../execute'; +import { ObjMap } from 'graphql/jsutils/ObjMap'; +import { Source } from 'graphql/language/source'; +import { GraphQLFieldResolver } from 'graphql/type/definition'; +import { GraphQLSchema } from 'graphql/type/schema'; +import { ExecutionResult } from 'graphql/execution/execute'; +import { MaybePromise } from 'graphql/jsutils/MaybePromise'; + +/** + * This is the primary entry point function for fulfilling GraphQL operations + * by parsing, validating, and executing a GraphQL document along side a + * GraphQL schema. + * + * More sophisticated GraphQL servers, such as those which persist queries, + * may wish to separate the validation and execution phases to a static time + * tooling step, and a server runtime step. + * + * Accepts either an object with named arguments, or individual arguments: + * + * schema: + * The GraphQL type system to use when validating and executing a query. + * source: + * A GraphQL language formatted string representing the requested operation. + * rootValue: + * The value provided as the first argument to resolver functions on the top + * level type (e.g. the query object type). + * variableValues: + * A mapping of variable name to runtime value to use for all variables + * defined in the requestString. + * operationName: + * The name of the operation to use if requestString contains multiple + * possible operations. Can be omitted if requestString contains only + * one operation. + * fieldResolver: + * A resolver function to use when one is not provided by the schema. + * If not provided, the default field resolver is used (which looks for a + * value or method on the source value with the field's name). + */ +export interface GraphQLArgs { + schema: GraphQLSchema; + source: string | Source; + enableDefer?: boolean; + rootValue?: {}; + contextValue?: {}; + variableValues?: ObjMap<{}>; + operationName?: string; + fieldResolver?: GraphQLFieldResolver; +} +export function graphql(GraphQLArgs, ..._: any[]): Promise; +/* eslint-disable no-redeclare */ +export function graphql( + schema: GraphQLSchema, + source: Source | string, + enableDefer?: boolean, + rootValue?: {}, + contextValue?: {}, + variableValues?: ObjMap<{}>, + operationName?: string, + fieldResolver?: GraphQLFieldResolver, +): Promise; +export function graphql( + argsOrSchema, + source, + enableDefer, + rootValue, + contextValue, + variableValues, + operationName, + fieldResolver, +) { + /* eslint-enable no-redeclare */ + // Always return a Promise for a consistent API. + return new Promise(resolve => + resolve( + // Extract arguments from object args if provided. + arguments.length === 1 + ? graphqlImpl( + argsOrSchema.schema, + argsOrSchema.source, + argsOrSchema.enableDefer, + argsOrSchema.rootValue, + argsOrSchema.contextValue, + argsOrSchema.variableValues, + argsOrSchema.operationName, + argsOrSchema.fieldResolver, + ) + : graphqlImpl( + argsOrSchema, + source, + enableDefer, + rootValue, + contextValue, + variableValues, + operationName, + fieldResolver, + ), + ), + ); +} + +/** + * The graphqlSync function also fulfills GraphQL operations by parsing, + * validating, and executing a GraphQL document along side a GraphQL schema. + * However, it guarantees to complete synchronously (or throw an error) assuming + * that all field resolvers are also synchronous. + */ +export function graphqlSync(GraphQLArgs, ..._: any[]): ExecutionResult; +/* eslint-disable no-redeclare */ +export function graphqlSync( + schema: GraphQLSchema, + source: Source | string, + enableDefer?: boolean, + rootValue?: {}, + contextValue?: {}, + variableValues?: ObjMap<{}>, + operationName?: string, + fieldResolver?: GraphQLFieldResolver, +): ExecutionResult; +export function graphqlSync( + argsOrSchema, + source, + enableDefer, + rootValue, + contextValue, + variableValues, + operationName, + fieldResolver, +) { + // Extract arguments from object args if provided. + const result = + arguments.length === 1 + ? graphqlImpl( + argsOrSchema.schema, + argsOrSchema.source, + argsOrSchema.enableDefer, + argsOrSchema.rootValue, + argsOrSchema.contextValue, + argsOrSchema.variableValues, + argsOrSchema.operationName, + argsOrSchema.fieldResolver, + ) + : graphqlImpl( + argsOrSchema, + source, + enableDefer, + rootValue, + contextValue, + variableValues, + operationName, + fieldResolver, + ); + + // Assert that the execution was synchronous. + if ((result as Promise).then) { + throw new Error('GraphQL execution failed to complete synchronously.'); + } + + return result; +} + +function graphqlImpl( + schema, + source, + enableDefer, + rootValue, + contextValue, + variableValues, + operationName, + fieldResolver, +): MaybePromise { + // Validate Schema + const schemaValidationErrors = validateSchema(schema); + if (schemaValidationErrors.length > 0) { + return { errors: schemaValidationErrors }; + } + + // Enable defer by default for tests + if (enableDefer === undefined || enableDefer === null) { + enableDefer = true; + } + + // Parse + let document; + try { + document = parse(source); + } catch (syntaxError) { + return { errors: [syntaxError] }; + } + + // Validate + const validationErrors = validate(schema, document); + if (validationErrors.length > 0) { + return { errors: validationErrors }; + } + + // Execute + return execute( + schema, + document, + rootValue, + contextValue, + variableValues, + operationName, + fieldResolver, + enableDefer, + ); +} diff --git a/packages/apollo-server-core/src/__tests__/runQuery.test.ts b/packages/apollo-server-core/src/__tests__/runQuery.test.ts index d18f1b2517e..b50386351fc 100644 --- a/packages/apollo-server-core/src/__tests__/runQuery.test.ts +++ b/packages/apollo-server-core/src/__tests__/runQuery.test.ts @@ -17,9 +17,13 @@ import { GraphQLResponse, } from 'graphql-extensions'; -import { processGraphQLRequest, GraphQLRequest } from '../requestPipeline'; +import { isDeferredGraphQLResponse, processGraphQLRequest, GraphQLRequest } from '../requestPipeline'; import { Request } from 'apollo-server-env'; import { GraphQLOptions, Context as GraphQLContext } from 'apollo-server-core'; +import GraphQLDeferDirective from '../GraphQLDeferDirective'; + +import { DeferredGraphQLResponse } from '../../dist/requestPipelineAPI'; +import { forAwaitEach } from 'iterall'; // This is a temporary kludge to ensure we preserve runQuery behavior with the // GraphQLRequestProcessor refactoring. @@ -138,6 +142,7 @@ describe('runQuery', () => { queryString: query, request: new MockReq(), }).then(res => { + expect(isDeferredGraphQLResponse(res)).toEqual(false); expect(res.data).toEqual(expected); }); }); @@ -150,6 +155,7 @@ describe('runQuery', () => { parsedQuery: query, request: new MockReq(), }).then(res => { + expect(isDeferredGraphQLResponse(res)).toEqual(false); expect(res.data).toEqual(expected); }); }); @@ -163,6 +169,7 @@ describe('runQuery', () => { variables: { base: 1 }, request: new MockReq(), }).then(res => { + expect(isDeferredGraphQLResponse(res)).toEqual(false); expect(res.data).toBeUndefined(); expect(res.errors!.length).toEqual(1); expect(res.errors![0].message).toMatch(expected); @@ -207,6 +214,7 @@ describe('runQuery', () => { variables: { base: 1 }, request: new MockReq(), }).then(res => { + expect(isDeferredGraphQLResponse(res)).toEqual(false); expect(res.data).toBeUndefined(); expect(res.errors!.length).toEqual(1); expect(res.errors![0].message).toEqual(expected); @@ -222,6 +230,7 @@ describe('runQuery', () => { rootValue: 'it also', request: new MockReq(), }).then(res => { + expect(isDeferredGraphQLResponse(res)).toEqual(false); expect(res.data).toEqual(expected); }); }); @@ -238,6 +247,7 @@ describe('runQuery', () => { }, request: new MockReq(), }).then(res => { + expect(isDeferredGraphQLResponse(res)).toEqual(false); expect(res.data).toEqual(expected); }); }); @@ -251,6 +261,7 @@ describe('runQuery', () => { context: { s: 'it still' }, request: new MockReq(), }).then(res => { + expect(isDeferredGraphQLResponse(res)).toEqual(false); expect(res.data).toEqual(expected); }); }); @@ -268,6 +279,7 @@ describe('runQuery', () => { }, request: new MockReq(), }).then(res => { + expect(isDeferredGraphQLResponse(res)).toEqual(false); expect(res.data).toEqual(expected); expect(res['extensions']).toEqual('it still'); }); @@ -282,6 +294,7 @@ describe('runQuery', () => { variables: { base: 1 }, request: new MockReq(), }).then(res => { + expect(isDeferredGraphQLResponse(res)).toEqual(false); expect(res.data).toEqual(expected); }); }); @@ -295,6 +308,7 @@ describe('runQuery', () => { queryString: query, request: new MockReq(), }).then(res => { + expect(isDeferredGraphQLResponse(res)).toEqual(false); expect(res.errors![0].message).toEqual(expected); }); }); @@ -305,6 +319,7 @@ describe('runQuery', () => { queryString: `{ testAwaitedValue }`, request: new MockReq(), }).then(res => { + expect(isDeferredGraphQLResponse(res)).toEqual(false); expect(res.data).toEqual({ testAwaitedValue: 'it works', }); @@ -328,6 +343,7 @@ describe('runQuery', () => { operationName: 'Q1', request: new MockReq(), }).then(res => { + expect(isDeferredGraphQLResponse(res)).toEqual(false); expect(res.data).toEqual(expected); }); }); @@ -348,6 +364,7 @@ describe('runQuery', () => { request: new MockReq(), }); + expect(isDeferredGraphQLResponse(result1)).toEqual(false); expect(result1.data).toEqual({ testObject: { testString: 'a very test string', @@ -362,6 +379,7 @@ describe('runQuery', () => { request: new MockReq(), }); + expect(isDeferredGraphQLResponse(result1)).toEqual(false); expect(result2.data).toEqual({ testObject: { testString: 'a very testful field resolver string', @@ -369,6 +387,82 @@ describe('runQuery', () => { }); }); + describe('@defer support', () => { + it('fails if defer directive not declared in schema', async () => { + const query = ` + query Q1 { + testObject { + testString @defer + } + } + `; + + const result1 = await runQuery({ + schema, + queryString: query, + operationName: 'Q1', + request: new MockReq(), + enableDefer: true, + }); + + expect(isDeferredGraphQLResponse(result1)).toEqual(false); + expect(result1.errors[0].message).toEqual('Unknown directive "defer".'); + }); + + it('takes option to enable @defer', async done => { + const schema = new GraphQLSchema({ + query: queryType, + directives: [GraphQLDeferDirective], + }); + + const query = ` + query Q1 { + testObject { + testString @defer + } + } + `; + + const result1 = await runQuery({ + schema, + queryString: query, + operationName: 'Q1', + request: new MockReq(), + }); + + expect(isDeferredGraphQLResponse(result1)).toEqual(false); + expect(result1).toEqual({ + data: { testObject: { testString: 'a very test string' } }, + }); + + const result2 = await runQuery({ + schema, + queryString: query, + operationName: 'Q1', + request: new MockReq(), + enableDefer: true, + }); + expect(isDeferredGraphQLResponse(result2)).toEqual(true); + expect((result2 as DeferredGraphQLResponse).initialResponse).toEqual({ + data: { testObject: { testString: null } }, + }); + const patches = []; + await forAwaitEach( + (result2 as DeferredGraphQLResponse).deferredPatches, + value => { + patches.push(value); + }, + ); + expect(patches).toEqual([ + { + path: ['testObject', 'testString'], + data: 'a very test string', + }, + ]); + done(); + }); + }); + describe('graphql extensions', () => { class CustomExtension implements GraphQLExtension { format(): [string, any] { diff --git a/packages/apollo-server-core/src/__tests__/starWarsData.ts b/packages/apollo-server-core/src/__tests__/starWarsData.ts new file mode 100644 index 00000000000..9e3084b300a --- /dev/null +++ b/packages/apollo-server-core/src/__tests__/starWarsData.ts @@ -0,0 +1,176 @@ +/** + * This defines a basic set of data for our Star Wars Schema. + * + * This data is hard coded for the sake of the demo, but you could imagine + * fetching this data from a backend service rather than from hardcoded + * JSON objects in a more complex demo. + */ + +function delay(result, delay) { + return new Promise(resolve => { + setTimeout(() => { + resolve(result); + }, delay); + }); +} + +const vader = { + type: 'Human', + id: '1001', + name: 'Darth Vader', + friends: ['1004'], + appearsIn: [4, 5, 6], + homePlanet: 'Tatooine', +}; + +const luke = { + type: 'Human', + id: '1000', + name: 'Luke Skywalker', + friends: ['1002', '1003', '2000', '2001'], + appearsIn: [4, 5, 6], + homePlanet: 'Tatooine', + soulmate: vader, + weapon: { + name: () => delay('Light Saber', 10), + strength: () => delay('High', 20), + }, +}; + +const han = { + type: 'Human', + id: '1002', + name: 'Han Solo', + friends: ['1000', '1003', '2001'], + appearsIn: [4, 5, 6], + soulmate: { + type: 'Human', + id: () => + new Promise(() => { + throw new Error('Han Solo only goes solo'); + }), + name: () => + new Promise(() => { + throw new Error('Han Solo only goes solo'); + }), + }, +}; + +const leia = { + type: 'Human', + id: '1003', + name: 'Leia Organa', + friends: ['1000', '1002', '2000', '2001'], + appearsIn: [4, 5, 6], + homePlanet: 'Alderaan', +}; + +const tarkin = { + type: 'Human', + id: '1004', + name: 'Wilhuff Tarkin', + friends: ['1001'], + appearsIn: [4], +}; + +const humanData = { + '1000': luke, + '1001': vader, + '1002': han, + '1003': leia, + '1004': tarkin, +}; + +const threepio = { + type: 'Droid', + id: '2000', + name: 'C-3PO', + friends: ['1000', '1002', '1003', '2001'], + appearsIn: [4, 5, 6], + primaryFunction: 'Protocol', +}; + +const artoo = { + type: 'Droid', + id: '2001', + name: 'R2-D2', + friends: ['1000', '1002', '1003'], + appearsIn: [4, 5, 6], + primaryFunction: 'Astromech', +}; + +const droidData = { + '2000': threepio, + '2001': artoo, +}; + +/** + * These are Flow types which correspond to the schema. + * They represent the shape of the data visited during field resolution. + */ +export type Character = { + id: string; + name: string; + friends: Array; + appearsIn: Array; +}; + +export type Human = { + type: 'Human'; + id: string; + name: string; + friends: Array; + appearsIn: Array; + homePlanet: string; +}; + +export type Droid = { + type: 'Droid'; + id: string; + name: string; + friends: Array; + appearsIn: Array; + primaryFunction: string; +}; + +/** + * Helper function to get a character by ID. + */ +function getCharacter(id) { + // Returning a promise just to illustrate GraphQL.js's support. + return Promise.resolve(humanData[id] || droidData[id]); +} + +/** + * Allows us to query for a character's friends. + */ +export function getFriends(character: Character): Array> { + // Notice that GraphQL accepts Arrays of Promises. + return character.friends.map(id => getCharacter(id)); +} + +/** + * Allows us to fetch the undisputed hero of the Star Wars trilogy, R2-D2. + */ +export function getHero(episode: number): Character { + if (episode === 5) { + // Luke is the hero of Episode V. + return luke; + } + // Artoo is the hero otherwise. + return artoo; +} + +/** + * Allows us to query for the human with the given id. + */ +export function getHuman(id: string): Human { + return humanData[id]; +} + +/** + * Allows us to query for the droid with the given id. + */ +export function getDroid(id: string): Droid { + return droidData[id]; +} diff --git a/packages/apollo-server-core/src/__tests__/starWarsDefer-test.ts b/packages/apollo-server-core/src/__tests__/starWarsDefer-test.ts new file mode 100644 index 00000000000..19cc0f93bc2 --- /dev/null +++ b/packages/apollo-server-core/src/__tests__/starWarsDefer-test.ts @@ -0,0 +1,967 @@ +import { isDeferredExecutionResult } from '../execute'; +import { forAwaitEach } from 'iterall'; +import { StarWarsSchema } from './starWarsSchema'; +import { graphql } from './graphql'; +import { validate } from 'graphql'; +import gql from 'graphql-tag'; +import { CannotDeferNonNullableFields } from '../validationRules/CannotDeferNonNullableFields'; + +describe('@defer Directive tests', () => { + describe('Compatibility', () => { + it('Can disable @defer', async done => { + const query = ` + query HeroNameQuery { + hero { + id + name @defer + } + } + `; + try { + const result = await graphql(StarWarsSchema, query, false); + expect(isDeferredExecutionResult(result)).toBe(false); + expect(result).toEqual({ + data: { + hero: { + id: '2001', + name: 'R2-D2', + }, + }, + }); + done(); + } catch (error) { + done(error); + } + }); + }); + + describe('Basic Queries', () => { + it('Can @defer on scalar types', async done => { + const query = ` + query HeroNameQuery { + hero { + id + name @defer + } + } + `; + try { + const result = await graphql(StarWarsSchema, query); + expect(isDeferredExecutionResult(result)).toBe(true); + if (isDeferredExecutionResult(result)) { + expect(result.initialResult).toEqual({ + data: { + hero: { + id: '2001', + name: null, + }, + }, + }); + + const patches = []; + await forAwaitEach(result.deferredPatches, patch => { + patches.push(patch); + }); + expect(patches.length).toBe(1); + expect(patches).toContainEqual({ + path: ['hero', 'name'], + data: 'R2-D2', + }); + done(); + } + } catch (error) { + done(error); + } + }); + + it('Can @defer on object types', async done => { + const query = ` + query HeroNameQuery { + human(id: "1000") { + id + weapon @defer { + name + strength + } + } + } + `; + try { + const result = await graphql(StarWarsSchema, query); + expect(isDeferredExecutionResult(result)).toBe(true); + if (isDeferredExecutionResult(result)) { + expect(result.initialResult).toEqual({ + data: { + human: { + id: '1000', + weapon: null, + }, + }, + }); + const patches = []; + await forAwaitEach(result.deferredPatches, patch => { + patches.push(patch); + }); + expect(patches.length).toBe(1); + expect(patches).toContainEqual({ + data: { name: 'Light Saber', strength: 'High' }, + path: ['human', 'weapon'], + }); + done(); + } + } catch (error) { + done(error); + } + }); + + it('Can @defer on a field on a list type', async done => { + const query = ` + query HeroNameAndFriendsQuery { + hero { + id + name + friends { + name @defer + } + } + } + `; + try { + const result = await graphql(StarWarsSchema, query); + expect(isDeferredExecutionResult(result)).toBe(true); + if (isDeferredExecutionResult(result)) { + expect(result.initialResult).toEqual({ + data: { + hero: { + id: '2001', + name: 'R2-D2', + friends: [{ name: null }, { name: null }, { name: null }], + }, + }, + }); + const patches = []; + await forAwaitEach(result.deferredPatches, patch => { + patches.push(patch); + }); + expect(patches.length).toBe(3); + expect(patches).toContainEqual({ + path: ['hero', 'friends', 0, 'name'], + data: 'Luke Skywalker', + }); + expect(patches).toContainEqual({ + path: ['hero', 'friends', 1, 'name'], + data: 'Han Solo', + }); + expect(patches).toContainEqual({ + path: ['hero', 'friends', 2, 'name'], + data: 'Leia Organa', + }); + done(); + } + } catch (error) { + done(error); + } + }); + + it('Can @defer on list type', async done => { + const query = ` + query HeroNameAndFriendsQuery { + hero { + id + name + friends @defer { + name + } + } + } + `; + try { + const result = await graphql(StarWarsSchema, query); + expect(isDeferredExecutionResult(result)).toBe(true); + if (isDeferredExecutionResult(result)) { + expect(result.initialResult).toEqual({ + data: { + hero: { + id: '2001', + name: 'R2-D2', + friends: null, + }, + }, + }); + const patches = []; + await forAwaitEach(result.deferredPatches, patch => { + patches.push(patch); + }); + expect(patches.length).toBe(1); + expect(patches).toContainEqual({ + path: ['hero', 'friends'], + data: [ + { name: 'Luke Skywalker' }, + { name: 'Han Solo' }, + { name: 'Leia Organa' }, + ], + }); + done(); + } + } catch (error) { + done(error); + } + }); + }); + describe('Nested Queries', () => { + it('Can @defer on nested queries', async done => { + const query = ` + query NestedQuery { + hero { + name + appearsIn @defer + friends { + name + appearsIn + friends { + name @defer + } + } + } + } + `; + try { + const result = await graphql(StarWarsSchema, query); + expect(isDeferredExecutionResult(result)).toBe(true); + if (isDeferredExecutionResult(result)) { + expect(result.initialResult).toEqual({ + data: { + hero: { + name: 'R2-D2', + appearsIn: null, + friends: [ + { + name: 'Luke Skywalker', + appearsIn: ['NEWHOPE', 'EMPIRE', 'JEDI'], + friends: [ + { name: null }, + { name: null }, + { name: null }, + { name: null }, + ], + }, + { + name: 'Han Solo', + appearsIn: ['NEWHOPE', 'EMPIRE', 'JEDI'], + friends: [{ name: null }, { name: null }, { name: null }], + }, + { + name: 'Leia Organa', + appearsIn: ['NEWHOPE', 'EMPIRE', 'JEDI'], + friends: [ + { name: null }, + { name: null }, + { name: null }, + { name: null }, + ], + }, + ], + }, + }, + }); + const patches = []; + await forAwaitEach(result.deferredPatches, patch => { + patches.push(patch); + }); + expect(patches.length).toBe(12); + expect(patches).toContainEqual({ + path: ['hero', 'friends', 0, 'friends', 0, 'name'], + data: 'Han Solo', + }); + expect(patches).toContainEqual({ + path: ['hero', 'appearsIn'], + data: ['NEWHOPE', 'EMPIRE', 'JEDI'], + }); + done(); + } + } catch (error) { + done(error); + } + }); + + it('Can @defer on fields nested within deferred fields, ensuring ordering', async done => { + const query = ` + query NestedQuery { + human(id: "1000") { + name + weapon @defer { + name @defer + strength + } + } + } + `; + try { + const result = await graphql(StarWarsSchema, query); + expect(isDeferredExecutionResult(result)).toBe(true); + if (isDeferredExecutionResult(result)) { + expect(result.initialResult).toEqual({ + data: { + human: { + name: 'Luke Skywalker', + weapon: null, + }, + }, + }); + const patches = []; + await forAwaitEach(result.deferredPatches, patch => { + patches.push(patch); + }); + // Ensure that ordering constraint is met: parent patches should + // be returned before child patches. + expect(patches).toEqual([ + { + path: ['human', 'weapon'], + data: { + strength: 'High', + name: null, + }, + }, + { + path: ['human', 'weapon', 'name'], + data: 'Light Saber', + }, + ]); + done(); + } + } catch (error) { + done(error); + } + }); + + it('Can @defer on fields nested within deferred lists', async done => { + const query = ` + query NestedQuery { + human(id: "1002") { + name + friends @defer { + id + name @defer + } + } + } + `; + try { + const result = await graphql(StarWarsSchema, query); + expect(isDeferredExecutionResult(result)).toBe(true); + if (isDeferredExecutionResult(result)) { + expect(result.initialResult).toEqual({ + data: { + human: { + name: 'Han Solo', + friends: null, + }, + }, + }); + const patches = []; + await forAwaitEach(result.deferredPatches, patch => { + patches.push(patch); + }); + expect(patches.length).toBe(4); + expect(patches).toContainEqual({ + path: ['human', 'friends'], + data: [ + { + id: '1000', + name: null, + }, + { + id: '1003', + name: null, + }, + { + id: '2001', + name: null, + }, + ], + }); + expect(patches).toContainEqual({ + path: ['human', 'friends', 0, 'name'], + data: 'Luke Skywalker', + }); + done(); + } + } catch (error) { + done(error); + } + }); + + it('Can @defer on more nested queries', async done => { + const query = ` + query NestedQuery { + hero { + name + friends @defer { + id + name @defer + friends @defer { + name + } + } + } + } + `; + try { + const result = await graphql(StarWarsSchema, query); + expect(isDeferredExecutionResult(result)).toBe(true); + if (isDeferredExecutionResult(result)) { + expect(result.initialResult).toEqual({ + data: { + hero: { + name: 'R2-D2', + friends: null, + }, + }, + }); + const patches = []; + await forAwaitEach(result.deferredPatches, patch => { + patches.push(patch); + }); + expect(patches.length).toBe(7); + expect(patches).toContainEqual({ + path: ['hero', 'friends'], + data: [ + { + id: '1000', + name: null, + friends: null, + }, + { + id: '1002', + name: null, + friends: null, + }, + { + id: '1003', + name: null, + friends: null, + }, + ], + }); + expect(patches).toContainEqual({ + path: ['hero', 'friends', 0, 'name'], + data: 'Luke Skywalker', + }); + expect(patches).toContainEqual({ + path: ['hero', 'friends', 0, 'friends'], + data: [ + { + name: 'Han Solo', + }, + { + name: 'Leia Organa', + }, + { + name: 'C-3PO', + }, + { + name: 'R2-D2', + }, + ], + }); + done(); + } + } catch (error) { + done(error); + } + }); + }); + + describe('Error Handling', () => { + it('Errors on a deferred field returned in the patch', async done => { + const query = ` + query HeroNameQuery { + hero { + name + secretBackstory @defer + } + } + `; + try { + const result = await graphql(StarWarsSchema, query); + expect(isDeferredExecutionResult(result)).toBe(true); + if (isDeferredExecutionResult(result)) { + expect(result.initialResult).toEqual({ + data: { + hero: { + name: 'R2-D2', + secretBackstory: null, + }, + }, + }); + const patches = []; + await forAwaitEach(result.deferredPatches, patch => { + patches.push(patch); + }); + expect(patches.length).toBe(1); + expect(JSON.stringify(patches[0])).toBe( + JSON.stringify({ + path: ['hero', 'secretBackstory'], + data: null, + errors: [ + { + message: 'secretBackstory is secret.', + locations: [{ line: 5, column: 13 }], + path: ['hero', 'secretBackstory'], + }, + ], + }), + ); + done(); + } + } catch (error) { + done(error); + } + }); + + it('Errors inside deferred field returned with patch for the deferred field', async done => { + const query = ` + query HeroNameQuery { + hero { + name + friends @defer { + name + secretBackstory + } + } + } + `; + try { + const result = await graphql(StarWarsSchema, query); + expect(isDeferredExecutionResult(result)).toBe(true); + if (isDeferredExecutionResult(result)) { + expect(result.initialResult).toEqual({ + data: { + hero: { + name: 'R2-D2', + friends: null, + }, + }, + }); + const patches = []; + await forAwaitEach(result.deferredPatches, patch => { + patches.push(patch); + }); + expect(patches.length).toBe(1); + expect(JSON.stringify(patches[0])).toBe( + JSON.stringify({ + path: ['hero', 'friends'], + data: [ + { + name: 'Luke Skywalker', + secretBackstory: null, + }, + { + name: 'Han Solo', + secretBackstory: null, + }, + { + name: 'Leia Organa', + secretBackstory: null, + }, + ], + errors: [ + { + message: 'secretBackstory is secret.', + locations: [ + { + line: 7, + column: 15, + }, + ], + path: ['hero', 'friends', 0, 'secretBackstory'], + }, + { + message: 'secretBackstory is secret.', + locations: [ + { + line: 7, + column: 15, + }, + ], + path: ['hero', 'friends', 1, 'secretBackstory'], + }, + { + message: 'secretBackstory is secret.', + locations: [ + { + line: 7, + column: 15, + }, + ], + path: ['hero', 'friends', 2, 'secretBackstory'], + }, + ], + }), + ); + done(); + } + } catch (error) { + done(error); + } + }); + }); + + describe('Non-nullable fields', () => { + it('Throws validation error if @defer used on non-nullable field', () => { + const query = gql` + query HeroIdQuery { + hero { + id @defer + name + } + } + `; + const validationErrors = validate(StarWarsSchema, query, [ + CannotDeferNonNullableFields, + ]); + expect(validationErrors.toString()).toEqual( + '@defer cannot be applied on non-nullable field "Character.id".', + ); + }); + + // Failing validation, a runtime error is still thrown + + it('Throws error if @defer used on non-nullable field', async done => { + const query = ` + query HeroIdQuery { + hero { + id @defer + name + } + } + `; + try { + const result = await graphql(StarWarsSchema, query); + expect(isDeferredExecutionResult(result)).toBe(false); + expect(JSON.stringify(result)).toBe( + JSON.stringify({ + errors: [ + { + message: + '@defer cannot be applied on non-nullable field Droid.id', + locations: [ + { + line: 4, + column: 13, + }, + ], + path: ['hero', 'id'], + }, + ], + data: { + hero: null, + }, + }), + ); + done(); + } catch (error) { + done(error); + } + }); + + it('Can @defer on parent of a non-nullable field', async done => { + const query = ` + query HeroNonNullQuery { + human(id: "1001") @defer { + id + name + nonNullField + } + } + `; + try { + const result = await graphql(StarWarsSchema, query); + expect(isDeferredExecutionResult(result)).toBe(true); + if (isDeferredExecutionResult(result)) { + expect(result.initialResult).toEqual({ + data: { human: null }, + }); + const patches = []; + await forAwaitEach(result.deferredPatches, patch => { + patches.push(patch); + }); + expect(patches.length).toBe(1); + expect(JSON.stringify(patches[0])).toBe( + JSON.stringify({ + path: ['human'], + data: { + id: '1001', + name: 'Darth Vader', + nonNullField: null, + }, + errors: [ + { + message: + 'Cannot return null for non-nullable field Human.nonNullField.', + locations: [ + { + line: 6, + column: 13, + }, + ], + path: ['human', 'nonNullField'], + }, + ], + }), + ); + done(); + } + } catch (error) { + done(error); + } + }); + + it('Can @defer on child of a non-nullable field', async done => { + const query = ` + query HeroSoulmateQuery { + human(id: "1000") { + id + name + soulmate { + name @defer + } + } + } + `; + try { + const result = await graphql(StarWarsSchema, query); + expect(isDeferredExecutionResult(result)).toBe(true); + if (isDeferredExecutionResult(result)) { + expect(result.initialResult).toEqual({ + data: { + human: { + id: '1000', + name: 'Luke Skywalker', + soulmate: { name: null }, + }, + }, + }); + const patches = []; + await forAwaitEach(result.deferredPatches, patch => { + patches.push(patch); + }); + expect(patches.length).toBe(1); + expect(JSON.stringify(patches[0])).toBe( + JSON.stringify({ + path: ['human', 'soulmate', 'name'], + data: 'Darth Vader', + }), + ); + done(); + } + } catch (error) { + done(error); + } + }); + + it('Throws error if @defer used on nested non-nullable field', async done => { + const query = ` + query HeroSoulmateQuery { + human(id: "1002") { + id + name + soulmate { + id @defer + } + } + } + `; + try { + const result = await graphql(StarWarsSchema, query); + expect(isDeferredExecutionResult(result)).toBe(false); + expect(result).toEqual({ + errors: [ + { + message: + '@defer cannot be applied on non-nullable field Human.id', + locations: [ + { + line: 7, + column: 15, + }, + ], + path: ['human', 'soulmate', 'id'], + }, + ], + data: { + human: null, + }, + }); + done(); + } catch (error) { + done(error); + } + }); + }); + describe('With Fragments', () => { + it('Can @defer fields in fragment', async done => { + const query = ` + query HeroNameQuery { + hero { + ...BasicInfo + } + } + fragment BasicInfo on Character { + id + name @defer + } + `; + try { + const result = await graphql(StarWarsSchema, query); + expect(isDeferredExecutionResult(result)).toBe(true); + if (isDeferredExecutionResult(result)) { + expect(result.initialResult).toEqual({ + data: { + hero: { + id: '2001', + name: null, + }, + }, + }); + + const patches = []; + await forAwaitEach(result.deferredPatches, patch => { + patches.push(patch); + }); + expect(patches.length).toBe(1); + expect(patches).toContainEqual({ + path: ['hero', 'name'], + data: 'R2-D2', + }); + done(); + } + } catch (error) { + done(error); + } + }); + + it('All copies of a field need to specify defer', async done => { + const query = ` + query HeroNameQuery { + hero { + name + ...BasicInfo + } + } + fragment BasicInfo on Character { + id + name @defer + } + `; + try { + const result = await graphql(StarWarsSchema, query); + expect(result).toEqual({ + data: { + hero: { + name: 'R2-D2', + id: '2001', + }, + }, + }); + done(); + } catch (error) { + done(error); + } + }); + + it('All copies of a field need to specify defer', async done => { + const query = ` + query HeroNameQuery { + hero { + name @defer + ...BasicInfo + } + } + fragment BasicInfo on Character { + id + name @defer + } + `; + try { + const result = await graphql(StarWarsSchema, query); + expect(isDeferredExecutionResult(result)).toBe(true); + if (isDeferredExecutionResult(result)) { + expect(result.initialResult).toEqual({ + data: { + hero: { + id: '2001', + name: null, + }, + }, + }); + + const patches = []; + await forAwaitEach(result.deferredPatches, patch => { + patches.push(patch); + }); + expect(patches.length).toBe(1); + expect(patches).toContainEqual({ + path: ['hero', 'name'], + data: 'R2-D2', + }); + done(); + } + } catch (error) { + done(error); + } + }); + + it('Can @defer fields in a fragment on a list type', async done => { + const query = ` + query HeroNameAndFriendsQuery { + hero { + id + name + friends { + ...Name + } + } + } + fragment Name on Character { + name @defer + } + `; + try { + const result = await graphql(StarWarsSchema, query); + expect(isDeferredExecutionResult(result)).toBe(true); + if (isDeferredExecutionResult(result)) { + expect(result.initialResult).toEqual({ + data: { + hero: { + id: '2001', + name: 'R2-D2', + friends: [{ name: null }, { name: null }, { name: null }], + }, + }, + }); + const patches = []; + await forAwaitEach(result.deferredPatches, patch => { + patches.push(patch); + }); + expect(patches.length).toBe(3); + expect(patches).toContainEqual({ + path: ['hero', 'friends', 0, 'name'], + data: 'Luke Skywalker', + }); + expect(patches).toContainEqual({ + path: ['hero', 'friends', 1, 'name'], + data: 'Han Solo', + }); + expect(patches).toContainEqual({ + path: ['hero', 'friends', 2, 'name'], + data: 'Leia Organa', + }); + done(); + } + } catch (error) { + done(error); + } + }); + }); +}); diff --git a/packages/apollo-server-core/src/__tests__/starWarsIntrospection-test.ts b/packages/apollo-server-core/src/__tests__/starWarsIntrospection-test.ts new file mode 100644 index 00000000000..9bbd17696e5 --- /dev/null +++ b/packages/apollo-server-core/src/__tests__/starWarsIntrospection-test.ts @@ -0,0 +1,417 @@ +import { StarWarsSchema } from './starWarsSchema'; +import { graphqlSync } from './graphql'; + +describe('Star Wars Introspection Tests', () => { + describe('Basic Introspection', () => { + it('Allows querying the schema for types', () => { + const query = ` + query IntrospectionTypeQuery { + __schema { + types { + name + } + } + } + `; + const expected = { + __schema: { + types: [ + { + name: 'Query', + }, + { + name: 'Episode', + }, + { + name: 'Character', + }, + { + name: 'String', + }, + { + name: 'Human', + }, + { + name: 'Weapon', + }, + { + name: 'Droid', + }, + { + name: '__Schema', + }, + { + name: '__Type', + }, + { + name: '__TypeKind', + }, + { + name: 'Boolean', + }, + { + name: '__Field', + }, + { + name: '__InputValue', + }, + { + name: '__EnumValue', + }, + { + name: '__Directive', + }, + { + name: '__DirectiveLocation', + }, + ], + }, + }; + const result = graphqlSync(StarWarsSchema, query); + expect(result).toEqual({ data: expected }); + }); + + it('Allows querying the schema for query type', () => { + const query = ` + query IntrospectionQueryTypeQuery { + __schema { + queryType { + name + } + } + } + `; + const expected = { + __schema: { + queryType: { + name: 'Query', + }, + }, + }; + const result = graphqlSync(StarWarsSchema, query); + expect(result).toEqual({ data: expected }); + }); + + it('Allows querying the schema for a specific type', () => { + const query = ` + query IntrospectionDroidTypeQuery { + __type(name: "Droid") { + name + } + } + `; + const expected = { + __type: { + name: 'Droid', + }, + }; + const result = graphqlSync(StarWarsSchema, query); + expect(result).toEqual({ data: expected }); + }); + + it('Allows querying the schema for an object kind', () => { + const query = ` + query IntrospectionDroidKindQuery { + __type(name: "Droid") { + name + kind + } + } + `; + const expected = { + __type: { + name: 'Droid', + kind: 'OBJECT', + }, + }; + const result = graphqlSync(StarWarsSchema, query); + expect(result).toEqual({ data: expected }); + }); + + it('Allows querying the schema for an interface kind', () => { + const query = ` + query IntrospectionCharacterKindQuery { + __type(name: "Character") { + name + kind + } + } + `; + const expected = { + __type: { + name: 'Character', + kind: 'INTERFACE', + }, + }; + const result = graphqlSync(StarWarsSchema, query); + expect(result).toEqual({ data: expected }); + }); + + it('Allows querying the schema for object fields', () => { + const query = ` + query IntrospectionDroidFieldsQuery { + __type(name: "Droid") { + name + fields { + name + type { + name + kind + } + } + } + } + `; + const expected = { + __type: { + name: 'Droid', + fields: [ + { + name: 'id', + type: { + name: null, + kind: 'NON_NULL', + }, + }, + { + name: 'name', + type: { + name: 'String', + kind: 'SCALAR', + }, + }, + { + name: 'friends', + type: { + name: null, + kind: 'LIST', + }, + }, + { + name: 'appearsIn', + type: { + name: null, + kind: 'LIST', + }, + }, + { + name: 'secretBackstory', + type: { + name: 'String', + kind: 'SCALAR', + }, + }, + { + name: 'primaryFunction', + type: { + name: 'String', + kind: 'SCALAR', + }, + }, + ], + }, + }; + + const result = graphqlSync(StarWarsSchema, query); + expect(result).toEqual({ data: expected }); + }); + + it('Allows querying the schema for nested object fields', () => { + const query = ` + query IntrospectionDroidNestedFieldsQuery { + __type(name: "Droid") { + name + fields { + name + type { + name + kind + ofType { + name + kind + } + } + } + } + } + `; + const expected = { + __type: { + name: 'Droid', + fields: [ + { + name: 'id', + type: { + name: null, + kind: 'NON_NULL', + ofType: { + name: 'String', + kind: 'SCALAR', + }, + }, + }, + { + name: 'name', + type: { + name: 'String', + kind: 'SCALAR', + ofType: null, + }, + }, + { + name: 'friends', + type: { + name: null, + kind: 'LIST', + ofType: { + name: 'Character', + kind: 'INTERFACE', + }, + }, + }, + { + name: 'appearsIn', + type: { + name: null, + kind: 'LIST', + ofType: { + name: 'Episode', + kind: 'ENUM', + }, + }, + }, + { + name: 'secretBackstory', + type: { + name: 'String', + kind: 'SCALAR', + ofType: null, + }, + }, + { + name: 'primaryFunction', + type: { + name: 'String', + kind: 'SCALAR', + ofType: null, + }, + }, + ], + }, + }; + const result = graphqlSync(StarWarsSchema, query); + expect(result).toEqual({ data: expected }); + }); + + it('Allows querying the schema for field args', () => { + const query = ` + query IntrospectionQueryTypeQuery { + __schema { + queryType { + fields { + name + args { + name + description + type { + name + kind + ofType { + name + kind + } + } + defaultValue + } + } + } + } + } + `; + const expected = { + __schema: { + queryType: { + fields: [ + { + name: 'hero', + args: [ + { + defaultValue: null, + description: + 'If omitted, returns the hero of the whole ' + + 'saga. If provided, returns the hero of ' + + 'that particular episode.', + name: 'episode', + type: { + kind: 'ENUM', + name: 'Episode', + ofType: null, + }, + }, + ], + }, + { + name: 'human', + args: [ + { + name: 'id', + description: 'id of the human', + type: { + kind: 'NON_NULL', + name: null, + ofType: { + kind: 'SCALAR', + name: 'String', + }, + }, + defaultValue: null, + }, + ], + }, + { + name: 'droid', + args: [ + { + name: 'id', + description: 'id of the droid', + type: { + kind: 'NON_NULL', + name: null, + ofType: { + kind: 'SCALAR', + name: 'String', + }, + }, + defaultValue: null, + }, + ], + }, + ], + }, + }, + }; + + const result = graphqlSync(StarWarsSchema, query); + expect(result).toEqual({ data: expected }); + }); + + it('Allows querying the schema for documentation', () => { + const query = ` + query IntrospectionDroidDescriptionQuery { + __type(name: "Droid") { + name + description + } + } + `; + const expected = { + __type: { + name: 'Droid', + description: 'A mechanical creature in the Star Wars universe.', + }, + }; + const result = graphqlSync(StarWarsSchema, query); + expect(result).toEqual({ data: expected }); + }); + }); +}); diff --git a/packages/apollo-server-core/src/__tests__/starWarsQuery-test.ts b/packages/apollo-server-core/src/__tests__/starWarsQuery-test.ts new file mode 100644 index 00000000000..ebd47e20ccc --- /dev/null +++ b/packages/apollo-server-core/src/__tests__/starWarsQuery-test.ts @@ -0,0 +1,511 @@ +import { StarWarsSchema } from './starWarsSchema'; +import { graphql } from './graphql'; + +describe('Star Wars Query Tests', () => { + describe('Basic Queries', () => { + it('Correctly identifies R2-D2 as the hero of the Star Wars Saga', async () => { + const query = ` + query HeroNameQuery { + hero { + name + } + } + `; + const result = await graphql(StarWarsSchema, query); + expect(result).toEqual({ + data: { + hero: { + name: 'R2-D2', + }, + }, + }); + }); + + it('Accepts an object with named properties to graphql()', async () => { + const query = ` + query HeroNameQuery { + hero { + name + } + } + `; + const result = await graphql({ + schema: StarWarsSchema, + source: query, + }); + expect(result).toEqual({ + data: { + hero: { + name: 'R2-D2', + }, + }, + }); + }); + + it('Allows us to query for the ID and friends of R2-D2', async () => { + const query = ` + query HeroNameAndFriendsQuery { + hero { + id + name + friends { + name + } + } + } + `; + const result = await graphql(StarWarsSchema, query); + expect(result).toEqual({ + data: { + hero: { + id: '2001', + name: 'R2-D2', + friends: [ + { + name: 'Luke Skywalker', + }, + { + name: 'Han Solo', + }, + { + name: 'Leia Organa', + }, + ], + }, + }, + }); + }); + }); + + describe('Nested Queries', () => { + it('Allows us to query for the friends of friends of R2-D2', async () => { + const query = ` + query NestedQuery { + hero { + name + friends { + name + appearsIn + friends { + name + } + } + } + } + `; + const result = await graphql(StarWarsSchema, query); + expect(result).toEqual({ + data: { + hero: { + name: 'R2-D2', + friends: [ + { + name: 'Luke Skywalker', + appearsIn: ['NEWHOPE', 'EMPIRE', 'JEDI'], + friends: [ + { + name: 'Han Solo', + }, + { + name: 'Leia Organa', + }, + { + name: 'C-3PO', + }, + { + name: 'R2-D2', + }, + ], + }, + { + name: 'Han Solo', + appearsIn: ['NEWHOPE', 'EMPIRE', 'JEDI'], + friends: [ + { + name: 'Luke Skywalker', + }, + { + name: 'Leia Organa', + }, + { + name: 'R2-D2', + }, + ], + }, + { + name: 'Leia Organa', + appearsIn: ['NEWHOPE', 'EMPIRE', 'JEDI'], + friends: [ + { + name: 'Luke Skywalker', + }, + { + name: 'Han Solo', + }, + { + name: 'C-3PO', + }, + { + name: 'R2-D2', + }, + ], + }, + ], + }, + }, + }); + }); + }); + + describe('Using IDs and query parameters to refetch objects', () => { + it('Allows us to query for Luke Skywalker directly, using his ID', async () => { + const query = ` + query FetchLukeQuery { + human(id: "1000") { + name + } + } + `; + const result = await graphql(StarWarsSchema, query); + expect(result).toEqual({ + data: { + human: { + name: 'Luke Skywalker', + }, + }, + }); + }); + + it('Allows us to create a generic query, then use it to fetch Luke Skywalker using his ID', async () => { + const query = ` + query FetchSomeIDQuery($someId: String!) { + human(id: $someId) { + name + } + } + `; + const params = { someId: '1000' }; + const result = await graphql( + StarWarsSchema, + query, + null, + null, + null, + params, + ); + expect(result).toEqual({ + data: { + human: { + name: 'Luke Skywalker', + }, + }, + }); + }); + + it('Allows us to create a generic query, then use it to fetch Han Solo using his ID', async () => { + const query = ` + query FetchSomeIDQuery($someId: String!) { + human(id: $someId) { + name + } + } + `; + const params = { someId: '1002' }; + const result = await graphql( + StarWarsSchema, + query, + null, + null, + null, + params, + ); + expect(result).toEqual({ + data: { + human: { + name: 'Han Solo', + }, + }, + }); + }); + + it('Allows us to create a generic query, then pass an invalid ID to get null back', async () => { + const query = ` + query humanQuery($id: String!) { + human(id: $id) { + name + } + } + `; + const params = { id: 'not a valid id' }; + const result = await graphql( + StarWarsSchema, + query, + null, + null, + null, + params, + ); + expect(result).toEqual({ + data: { + human: null, + }, + }); + }); + }); + + describe('Using aliases to change the key in the response', () => { + it('Allows us to query for Luke, changing his key with an alias', async () => { + const query = ` + query FetchLukeAliased { + luke: human(id: "1000") { + name + } + } + `; + const result = await graphql(StarWarsSchema, query); + expect(result).toEqual({ + data: { + luke: { + name: 'Luke Skywalker', + }, + }, + }); + }); + + it('Allows us to query for both Luke and Leia, using two root fields and an alias', async () => { + const query = ` + query FetchLukeAndLeiaAliased { + luke: human(id: "1000") { + name + } + leia: human(id: "1003") { + name + } + } + `; + const result = await graphql(StarWarsSchema, query); + expect(result).toEqual({ + data: { + luke: { + name: 'Luke Skywalker', + }, + leia: { + name: 'Leia Organa', + }, + }, + }); + }); + }); + + describe('Uses fragments to express more complex queries', () => { + it('Allows us to query using duplicated content', async () => { + const query = ` + query DuplicateFields { + luke: human(id: "1000") { + name + homePlanet + } + leia: human(id: "1003") { + name + homePlanet + } + } + `; + const result = await graphql(StarWarsSchema, query); + expect(result).toEqual({ + data: { + luke: { + name: 'Luke Skywalker', + homePlanet: 'Tatooine', + }, + leia: { + name: 'Leia Organa', + homePlanet: 'Alderaan', + }, + }, + }); + }); + + it('Allows us to use a fragment to avoid duplicating content', async () => { + const query = ` + query UseFragment { + luke: human(id: "1000") { + ...HumanFragment + } + leia: human(id: "1003") { + ...HumanFragment + } + } + + fragment HumanFragment on Human { + name + homePlanet + } + `; + const result = await graphql(StarWarsSchema, query); + expect(result).toEqual({ + data: { + luke: { + name: 'Luke Skywalker', + homePlanet: 'Tatooine', + }, + leia: { + name: 'Leia Organa', + homePlanet: 'Alderaan', + }, + }, + }); + }); + }); + + describe('Using __typename to find the type of an object', () => { + it('Allows us to verify that R2-D2 is a droid', async () => { + const query = ` + query CheckTypeOfR2 { + hero { + __typename + name + } + } + `; + const result = await graphql(StarWarsSchema, query); + expect(result).toEqual({ + data: { + hero: { + __typename: 'Droid', + name: 'R2-D2', + }, + }, + }); + }); + + it('Allows us to verify that Luke is a human', async () => { + const query = ` + query CheckTypeOfLuke { + hero(episode: EMPIRE) { + __typename + name + } + } + `; + const result = await graphql(StarWarsSchema, query); + expect(result).toEqual({ + data: { + hero: { + __typename: 'Human', + name: 'Luke Skywalker', + }, + }, + }); + }); + }); + + describe('Reporting errors raised in resolvers', () => { + it('Correctly reports error on accessing secretBackstory', async () => { + const query = ` + query HeroNameQuery { + hero { + name + secretBackstory + } + } + `; + const result = await graphql(StarWarsSchema, query); + expect(result).toEqual({ + data: { + hero: { + name: 'R2-D2', + secretBackstory: null, + }, + }, + errors: [ + { + message: 'secretBackstory is secret.', + locations: [{ line: 5, column: 13 }], + path: ['hero', 'secretBackstory'], + }, + ], + }); + }); + + it('Correctly reports error on accessing secretBackstory in a list', async () => { + const query = ` + query HeroNameQuery { + hero { + name + friends { + name + secretBackstory + } + } + } + `; + const result = await graphql(StarWarsSchema, query); + expect(result).toEqual({ + data: { + hero: { + name: 'R2-D2', + friends: [ + { + name: 'Luke Skywalker', + secretBackstory: null, + }, + { + name: 'Han Solo', + secretBackstory: null, + }, + { + name: 'Leia Organa', + secretBackstory: null, + }, + ], + }, + }, + errors: [ + { + message: 'secretBackstory is secret.', + locations: [{ line: 7, column: 15 }], + path: ['hero', 'friends', 0, 'secretBackstory'], + }, + { + message: 'secretBackstory is secret.', + locations: [{ line: 7, column: 15 }], + path: ['hero', 'friends', 1, 'secretBackstory'], + }, + { + message: 'secretBackstory is secret.', + locations: [{ line: 7, column: 15 }], + path: ['hero', 'friends', 2, 'secretBackstory'], + }, + ], + }); + }); + + it('Correctly reports error on accessing through an alias', async () => { + const query = ` + query HeroNameQuery { + mainHero: hero { + name + story: secretBackstory + } + } + `; + const result = await graphql(StarWarsSchema, query); + expect(result).toEqual({ + data: { + mainHero: { + name: 'R2-D2', + story: null, + }, + }, + errors: [ + { + message: 'secretBackstory is secret.', + locations: [{ line: 5, column: 13 }], + path: ['mainHero', 'story'], + }, + ], + }); + }); + }); +}); diff --git a/packages/apollo-server-core/src/__tests__/starWarsSchema.ts b/packages/apollo-server-core/src/__tests__/starWarsSchema.ts new file mode 100644 index 00000000000..9bf83eabeb5 --- /dev/null +++ b/packages/apollo-server-core/src/__tests__/starWarsSchema.ts @@ -0,0 +1,327 @@ +import { + GraphQLEnumType, + GraphQLInterfaceType, + GraphQLObjectType, + GraphQLList, + GraphQLNonNull, + GraphQLString, + GraphQLSchema, +} from 'graphql/type'; + +import { getFriends, getHero, getHuman, getDroid } from './starWarsData'; +import GraphQLDeferDirective from '../GraphQLDeferDirective'; + +/** + * This is designed to be an end-to-end test, demonstrating + * the full GraphQL stack. + * + * We will create a GraphQL schema that describes the major + * characters in the original Star Wars trilogy. + * + * NOTE: This may contain spoilers for the original Star + * Wars trilogy. + */ + +/** + * Using our shorthand to describe type systems, the type system for our + * Star Wars example is: + * + * enum Episode { NEWHOPE, EMPIRE, JEDI } + * + * interface Character { + * id: String! + * name: String + * friends: [Character] + * appearsIn: [Episode] + * } + * + * type Human implements Character { + * id: String! + * name: String + * nonNullField: String! + * friends: [Character] + * appearsIn: [Episode] + * homePlanet: String + * soulmate: Character! # Everyone has a soulmate <3 + * weapon: Weapon + * } + * + * type Weapon { + * name: String + * strength: String + * } + * + * type Droid implements Character { + * id: String! + * name: String + * friends: [Character] + * appearsIn: [Episode] + * primaryFunction: String + * } + * + * type Query { + * hero(episode: Episode): Character + * human(id: String!): Human + * droid(id: String!): Droid + * } + * + * We begin by setting up our schema. + */ + +/** + * The original trilogy consists of three movies. + * + * This implements the following type system shorthand: + * enum Episode { NEWHOPE, EMPIRE, JEDI } + */ +const episodeEnum = new GraphQLEnumType({ + name: 'Episode', + description: 'One of the films in the Star Wars Trilogy', + values: { + NEWHOPE: { + value: 4, + description: 'Released in 1977.', + }, + EMPIRE: { + value: 5, + description: 'Released in 1980.', + }, + JEDI: { + value: 6, + description: 'Released in 1983.', + }, + }, +}); + +/** + * Characters in the Star Wars trilogy are either humans or droids. + * + * This implements the following type system shorthand: + * interface Character { + * id: String! + * name: String + * friends: [Character] + * appearsIn: [Episode] + * secretBackstory: String + * } + */ +const characterInterface = new GraphQLInterfaceType({ + name: 'Character', + description: 'A character in the Star Wars Trilogy', + fields: () => ({ + id: { + type: new GraphQLNonNull(GraphQLString), + description: 'The id of the character.', + }, + name: { + type: GraphQLString, + description: 'The name of the character.', + }, + friends: { + type: new GraphQLList(characterInterface), + description: + 'The friends of the character, or an empty list if they ' + + 'have none.', + }, + appearsIn: { + type: new GraphQLList(episodeEnum), + description: 'Which movies they appear in.', + }, + secretBackstory: { + type: GraphQLString, + description: 'All secrets about their past.', + }, + }), + resolveType(character) { + if (character.type === 'Human') { + return humanType; + } + if (character.type === 'Droid') { + return droidType; + } + }, +}); + +/** + * We define our human type, which implements the character interface. + * + * This implements the following type system shorthand: + * type Human : Character { + * id: String! + * name: String + * friends: [Character] + * appearsIn: [Episode] + * secretBackstory: String + * } + */ +const humanType = new GraphQLObjectType({ + name: 'Human', + description: 'A humanoid creature in the Star Wars universe.', + fields: () => ({ + id: { + type: new GraphQLNonNull(GraphQLString), + description: 'The id of the human.', + }, + name: { + type: GraphQLString, + description: 'The name of the human.', + }, + nonNullField: { + type: new GraphQLNonNull(GraphQLString), + description: 'This field cannot be null.', + }, + friends: { + type: new GraphQLList(characterInterface), + description: + 'The friends of the human, or an empty list if they have none.', + resolve: human => getFriends(human), + }, + appearsIn: { + type: new GraphQLList(episodeEnum), + description: 'Which movies they appear in.', + }, + homePlanet: { + type: GraphQLString, + description: 'The home planet of the human, or null if unknown.', + }, + secretBackstory: { + type: GraphQLString, + description: 'Where are they from and how they came to be who they are.', + resolve() { + throw new Error('secretBackstory is secret.'); + }, + }, + soulmate: { + type: new GraphQLNonNull(characterInterface), + description: 'Everyone has a soulmate and should error otherwise.', + }, + weapon: { + type: new GraphQLObjectType({ + name: 'Weapon', + fields: { + name: { + type: GraphQLString, + description: 'Name of the weapon', + }, + strength: { + type: GraphQLString, + description: 'Strength of weapon', + }, + }, + }), + }, + }), + interfaces: [characterInterface], +}); + +/** + * The other type of character in Star Wars is a droid. + * + * This implements the following type system shorthand: + * type Droid : Character { + * id: String! + * name: String + * friends: [Character] + * appearsIn: [Episode] + * secretBackstory: String + * primaryFunction: String + * } + */ +const droidType = new GraphQLObjectType({ + name: 'Droid', + description: 'A mechanical creature in the Star Wars universe.', + fields: () => ({ + id: { + type: new GraphQLNonNull(GraphQLString), + description: 'The id of the droid.', + }, + name: { + type: GraphQLString, + description: 'The name of the droid.', + }, + friends: { + type: new GraphQLList(characterInterface), + description: + 'The friends of the droid, or an empty list if they have none.', + resolve: droid => getFriends(droid), + }, + appearsIn: { + type: new GraphQLList(episodeEnum), + description: 'Which movies they appear in.', + }, + secretBackstory: { + type: GraphQLString, + description: 'Construction date and the name of the designer.', + resolve() { + throw new Error('secretBackstory is secret.'); + }, + }, + primaryFunction: { + type: GraphQLString, + description: 'The primary function of the droid.', + }, + }), + interfaces: [characterInterface], +}); + +/** + * This is the type that will be the root of our query, and the + * entry point into our schema. It gives us the ability to fetch + * objects by their IDs, as well as to fetch the undisputed hero + * of the Star Wars trilogy, R2-D2, directly. + * + * This implements the following type system shorthand: + * type Query { + * hero(episode: Episode): Character + * human(id: String!): Human + * droid(id: String!): Droid + * } + * + */ +const queryType = new GraphQLObjectType({ + name: 'Query', + fields: () => ({ + hero: { + type: characterInterface, + args: { + episode: { + description: + 'If omitted, returns the hero of the whole saga. If ' + + 'provided, returns the hero of that particular episode.', + type: episodeEnum, + }, + }, + resolve: (root, { episode }) => getHero(episode), + }, + human: { + type: humanType, + args: { + id: { + description: 'id of the human', + type: new GraphQLNonNull(GraphQLString), + }, + }, + resolve: (root, { id }) => getHuman(id), + }, + droid: { + type: droidType, + args: { + id: { + description: 'id of the droid', + type: new GraphQLNonNull(GraphQLString), + }, + }, + resolve: (root, { id }) => getDroid(id), + }, + }), +}); + +/** + * Finally, we construct our schema (whose starting query type is the query + * type we defined above) and export it. + */ +export const StarWarsSchema = new GraphQLSchema({ + query: queryType, + types: [humanType, droidType], + directives: [GraphQLDeferDirective], +}); diff --git a/packages/apollo-server-core/src/execute.ts b/packages/apollo-server-core/src/execute.ts new file mode 100644 index 00000000000..78cbbd1e582 --- /dev/null +++ b/packages/apollo-server-core/src/execute.ts @@ -0,0 +1,1450 @@ +/** + * Adding @defer Support + * The execution phase has been modified to enable @defer support, with + * changes starting from `executeOperation()`. Utility functions are + * exported from `graphql.js` where possible. + * + * Within `completeValueCatchingError()`, we check if the current field should + * be deferred. If it is, `null` is returned to its parent instead of a promise + * for the field. The promise is then queued to be sent as a patch once it + * resolves. + * + * Deferred fields are returned to the caller in the form of an + * AsyncIterable. AsyncIterables are supported natively + * in Node 10, otherwise the 'iterall' package provides support for all + * versions. + */ + +import { $$asyncIterator, forEach, isCollection } from 'iterall'; +import { GraphQLError, locatedError } from 'graphql/error'; +import invariant from 'graphql/jsutils/invariant'; +import isInvalid from 'graphql/jsutils/isInvalid'; +import isNullish from 'graphql/jsutils/isNullish'; +import memoize3 from 'graphql/jsutils/memoize3'; +import promiseForObject from 'graphql/jsutils/promiseForObject'; +import promiseReduce from 'graphql/jsutils/promiseReduce'; +import { getDirectiveValues } from 'graphql/execution/values'; +import { + isObjectType, + isAbstractType, + isLeafType, + isListType, + isNonNullType, + GraphQLType, +} from 'graphql/type/definition'; +import { + GraphQLObjectType, + GraphQLOutputType, + GraphQLLeafType, + GraphQLAbstractType, + GraphQLFieldResolver, + GraphQLResolveInfo, + ResponsePath, + GraphQLList, +} from 'graphql/type/definition'; +import { GraphQLSchema } from 'graphql/type/schema'; +import { + DocumentNode, + OperationDefinitionNode, + FieldNode, + FragmentSpreadNode, + InlineFragmentNode, + FragmentDefinitionNode, + VariableDefinitionNode, +} from 'graphql/language/ast'; +import { + ExecutionResult, + responsePathAsArray, + addPath, + assertValidExecutionArguments, + collectFields, + buildResolveInfo, + resolveFieldValueOrError, + getFieldDef, + defaultFieldResolver, +} from 'graphql/execution/execute'; +import { getVariableValues } from 'graphql/execution/values'; +import GraphQLDeferDirective from './GraphQLDeferDirective'; +import Maybe from 'graphql/tsutils/Maybe'; +import { Kind } from 'graphql'; + +/** + * Rewrite flow types in typescript + */ +export type MaybePromise = Promise | T; + +export type ExecutionArgs = { + schema: GraphQLSchema; + document: DocumentNode; + rootValue?: any; + contextValue?: any; + variableValues?: Maybe<{ [key: string]: any }>; + operationName?: Maybe; + fieldResolver?: Maybe>; + enableDefer?: boolean; +}; + +function isPromise( + maybePromise: MaybePromise, +): maybePromise is Promise { + return maybePromise && typeof maybePromise.then === 'function'; +} + +// Valid types a GraphQL field can take +type FieldValue = + | Record + | Array + | string + | number + | boolean + | null; + +type PatchBundle = Promise<{ + patch: ExecutionPatchResult; + dependentPatches?: PatchBundle[]; +}>; + +/** + * Data that must be available at all points during query execution. + * + * Namely, schema of the type system that is currently executing, + * and the fragments defined in the query document. + * + * To enable defer support, the ExecutionContext is also used to store + * promises to patches, and deferred errors. + */ +export type ExecutionContext = { + schema: GraphQLSchema; + fragments: Record; + rootValue: {}; + contextValue: {}; + operation: OperationDefinitionNode; + variableValues: { [variable: string]: {} }; + fieldResolver: GraphQLFieldResolver; + errors: GraphQLError[]; + enableDefer?: boolean; + patchDispatcher?: PatchDispatcher; + deferredDependents?: Record< + string, + { + patches: PatchBundle[]; + errors: GraphQLError[]; + } + >; +}; + +export function buildExecutionContext( + schema: GraphQLSchema, + document: DocumentNode, + rootValue: {}, + contextValue: {}, + rawVariableValues: Record | null, + operationName: string | null, + fieldResolver: GraphQLFieldResolver | null, + enableDefer?: boolean, +): GraphQLError[] | ExecutionContext { + const errors: Array = []; + let operation: OperationDefinitionNode | undefined; + let hasMultipleAssumedOperations = false; + const fragments: Record = Object.create(null); + for (let i = 0; i < document.definitions.length; i++) { + const definition = document.definitions[i]; + switch (definition.kind) { + case Kind.OPERATION_DEFINITION: + if (!operationName && operation) { + hasMultipleAssumedOperations = true; + } else if ( + !operationName || + (definition.name && definition.name.value === operationName) + ) { + operation = definition; + } + break; + case Kind.FRAGMENT_DEFINITION: + fragments[definition.name.value] = definition; + break; + } + } + + if (!operation) { + if (operationName) { + errors.push( + new GraphQLError(`Unknown operation named "${operationName}".`), + ); + } else { + errors.push(new GraphQLError('Must provide an operation.')); + } + } else if (hasMultipleAssumedOperations) { + errors.push( + new GraphQLError( + 'Must provide operation name if query contains ' + + 'multiple operations.', + ), + ); + } + + let variableValues; + if (operation) { + const coercedVariableValues = getVariableValues( + schema, + (operation.variableDefinitions as VariableDefinitionNode[]) || [], + rawVariableValues || {}, + ); + + if (coercedVariableValues.errors) { + errors.push(...coercedVariableValues.errors); + } else { + variableValues = coercedVariableValues.coerced; + } + } + + if (errors.length !== 0) { + return errors; + } + + invariant(operation, 'Has operation if no errors.'); + invariant(variableValues, 'Has variables if no errors.'); + + return { + schema, + fragments, + rootValue, + contextValue, + operation: operation as OperationDefinitionNode, + variableValues, + fieldResolver: fieldResolver || defaultFieldResolver, + errors, + enableDefer, + }; +} + +/** + * Determines if a field should be deferred. @skip and @include has higher + * precedence than @defer. + */ +function shouldDeferNode( + exeContext: ExecutionContext, + node: FragmentSpreadNode | FieldNode | InlineFragmentNode, +): boolean { + if (!exeContext.enableDefer) { + return false; + } + const defer = getDirectiveValues( + GraphQLDeferDirective, + node, + exeContext.variableValues, + ); + return defer !== undefined ? !defer.if : false; // default value for "if" is true +} + +/** + * Define a new type for patches that are sent as a result of using defer. + * Its is basically the same as ExecutionResult, except that it has a "path" + * field that keeps track of the where the patch is to be merged with the + * original result. + */ +export interface ExecutionPatchResult { + data?: FieldValue; + errors?: ReadonlyArray; + path: ReadonlyArray; +} + +/** + * Define a return type from execute() that is a wraps over the initial + * result that is returned from a deferred query. Alongside the initial + * response, an array of promises to the deferred patches is returned. + */ +export interface DeferredExecutionResult { + initialResult: ExecutionResult; + deferredPatches: AsyncIterable; +} + +/** + * Type guard for DeferredExecutionResult + */ +export function isDeferredExecutionResult( + result: any, +): result is DeferredExecutionResult { + return ( + (result).initialResult !== undefined && + (result).deferredPatches !== undefined + ); +} + +/** + * Build a ExecutionPatchResult from supplied arguments + */ +function formatDataAsPatch( + path: ResponsePath, + data: FieldValue, + errors: ReadonlyArray, +): ExecutionPatchResult { + return { + path: responsePathAsArray(path), + data, + errors: errors && errors.length > 0 ? errors : undefined, + }; +} + +/** + * Utlity functions to store patches or errors that should be returned with + * its parent. These patches/errors are added here by child nodes, and retrieved + * by the parent. + */ +function initializeDependentStore( + exeContext: ExecutionContext, + parentPath: string, +) { + if (!exeContext.deferredDependents) { + exeContext.deferredDependents = {}; + } + if (!exeContext.deferredDependents[parentPath]) { + exeContext.deferredDependents[parentPath] = { + errors: [] as GraphQLError[], + patches: [] as PatchBundle[], + }; + } +} + +function deferErrorToParent( + exeContext: ExecutionContext, + parentPath: string, + error: GraphQLError, +) { + initializeDependentStore(exeContext, parentPath); + exeContext.deferredDependents![parentPath].errors.push(error); +} + +function deferPatchToParent( + exeContext: ExecutionContext, + parentPath: string, + patch: PatchBundle, +) { + initializeDependentStore(exeContext, parentPath); + exeContext.deferredDependents![parentPath].patches.push(patch); +} + +/** + * Calls dispatch on the PatchDispatcher, creating it if it is not already + * instantiated. + */ +function dispatchPatch(exeContext: ExecutionContext, patch: PatchBundle): void { + if (!exeContext.patchDispatcher) { + exeContext.patchDispatcher = new PatchDispatcher(); + } + exeContext.patchDispatcher.dispatch(patch); +} + +/** + * Helper class that allows us to dispatch patches dynamically, and obtain an + * AsyncIterable that yields each patch in the order that they get resolved. + */ +class PatchDispatcher { + private resolvers: (( + { value, done }: { value: ExecutionPatchResult; done: boolean }, + ) => void)[] = []; + + private resultPromises: Promise<{ + value: ExecutionPatchResult; + done: boolean; + }>[] = []; + + public dispatch(patch: PatchBundle): void { + patch.then(({ patch, dependentPatches }) => { + // Queue patches for dependent fields before resolving parent + if (dependentPatches) { + for (const patch of dependentPatches) { + this.dispatch(patch); + } + } + const resolver = this.resolvers.shift(); + if (resolver) resolver({ value: patch, done: false }); + }); + this.resultPromises.push( + new Promise<{ value: ExecutionPatchResult; done: boolean }>(resolve => { + this.resolvers.push(resolve); + }), + ); + } + + public getAsyncIterable(): AsyncIterable { + const self = this; + return { + [$$asyncIterator]() { + return { + next() { + return ( + self.resultPromises.shift() || Promise.resolve({ done: true }) + ); + }, + }; + }, + } as any; // Typescript does not handle $$asyncIterator correctly + } +} + +/** + * Unchanged + */ +export function execute( + ExecutionArgs: ExecutionArgs, + ..._: any[] +): MaybePromise; +/* eslint-disable no-redeclare */ +export function execute( + schema: GraphQLSchema, + document: DocumentNode, + rootValue?: {}, + contextValue?: {}, + variableValues?: { [variable: string]: {} }, + operationName?: string, + fieldResolver?: GraphQLFieldResolver, + enableDefer?: boolean, +): MaybePromise; +export function execute( + argsOrSchema, + document, + rootValue, + contextValue, + variableValues, + operationName, + fieldResolver, + enableDefer, +): MaybePromise { + /* eslint-enable no-redeclare */ + // Extract arguments from object args if provided. + return arguments.length === 1 + ? executeImpl( + argsOrSchema.schema, + argsOrSchema.document, + argsOrSchema.rootValue, + argsOrSchema.contextValue, + argsOrSchema.variableValues, + argsOrSchema.operationName, + argsOrSchema.fieldResolver, + argsOrSchema.enableDefer, + ) + : executeImpl( + argsOrSchema, + document, + rootValue, + contextValue, + variableValues, + operationName, + fieldResolver, + enableDefer, + ); +} + +/** + * Unchanged + */ +function executeImpl( + schema, + document, + rootValue, + contextValue, + variableValues, + operationName, + fieldResolver, + enableDefer, +): MaybePromise { + // If arguments are missing or incorrect, throw an error. + assertValidExecutionArguments(schema, document, variableValues); + + // If a valid context cannot be created due to incorrect arguments, + // a "Response" with only errors is returned. + const context = buildExecutionContext( + schema, + document, + rootValue, + contextValue, + variableValues, + operationName, + fieldResolver, + enableDefer, + ); + + // Return early errors if execution context failed. + if (Array.isArray(context)) { + return { errors: context }; + } + + // Return a Promise that will eventually resolve to the data described by + // The "Response" section of the GraphQL specification. + // + // If errors are encountered while executing a GraphQL field, only that + // field and its descendants will be omitted, and sibling fields will still + // be executed. An execution which encounters errors will still result in a + // resolved Promise. + const data = executeOperation( + context as ExecutionContext, + (context as ExecutionContext).operation, + rootValue, + ); + return buildResponse( + context as ExecutionContext, + data as MaybePromise | null>, + ); +} + +/** + * Given a completed execution context and data, build the { errors, data } + * response defined by the "Response" section of the GraphQL specification. + * Checks to see if there are any deferred fields, returning a + * DeferredExecutionResult if so. + */ +function buildResponse( + context: ExecutionContext, + data: MaybePromise | null>, +): MaybePromise { + if (isPromise(data)) { + return data.then(resolved => buildResponse(context, resolved)); + } + const result = + context.errors.length === 0 ? { data } : { errors: context.errors, data }; + + // Return a DeferredExecutionResult if there are deferred fields + if (context.patchDispatcher) { + return { + initialResult: result, + deferredPatches: context.patchDispatcher.getAsyncIterable(), + } as DeferredExecutionResult; + } else { + return result as ExecutionResult; + } +} + +/** + * Unchanged + */ +function executeOperation( + exeContext: ExecutionContext, + operation: OperationDefinitionNode, + rootValue: {}, +): MaybePromise { + const type = getOperationRootType(exeContext.schema, operation); + const fields = collectFields( + exeContext, + type, + operation.selectionSet, + Object.create(null), + Object.create(null), + ); + + const path = undefined; + + // Errors from sub-fields of a NonNull type may propagate to the top level, + // at which point we still log the error and null the parent field, which + // in this case is the entire response. + // + // Similar to completeValueCatchingError. + try { + const result = + operation.operation === 'mutation' + ? executeFieldsSerially(exeContext, type, rootValue, path, fields) + : executeFields(exeContext, type, rootValue, path, fields); + if (isPromise(result)) { + return result.then(undefined, error => { + exeContext.errors.push(error); + return Promise.resolve(null); + }); + } + return result; + } catch (error) { + exeContext.errors.push(error); + return null; + } +} + +/** + * Unchanged but not exported in @types/graphql + */ +export function getOperationRootType( + schema: GraphQLSchema, + operation: OperationDefinitionNode, +): GraphQLObjectType { + switch (operation.operation) { + case 'query': + const queryType = schema.getQueryType(); + if (!queryType) { + throw new GraphQLError( + 'Schema does not define the required query root type.', + [operation], + ); + } + return queryType; + case 'mutation': + const mutationType = schema.getMutationType(); + if (!mutationType) { + throw new GraphQLError('Schema is not configured for mutations.', [ + operation, + ]); + } + return mutationType; + case 'subscription': + const subscriptionType = schema.getSubscriptionType(); + if (!subscriptionType) { + throw new GraphQLError('Schema is not configured for subscriptions.', [ + operation, + ]); + } + return subscriptionType; + default: + throw new GraphQLError( + 'Can only execute queries, mutations and subscriptions.', + [operation], + ); + } +} + +/** + * Unchanged + */ +function executeFieldsSerially( + exeContext: ExecutionContext, + parentType: GraphQLObjectType, + sourceValue: {}, + path: ResponsePath | undefined, + fields: Record>, +): MaybePromise { + return promiseReduce( + Object.keys(fields), + (results, responseName) => { + const fieldNodes = fields[responseName]; + const fieldPath = addPath(path, responseName); + const result = resolveField( + exeContext, + parentType, + sourceValue, + fieldNodes, + fieldPath, + ); + if (result === undefined) { + return results; + } + if (isPromise(result)) { + return result.then(resolvedResult => { + results[responseName] = resolvedResult; + return results; + }); + } + results[responseName] = result; + return results; + }, + Object.create(null), + ); +} + +/** + * Implements the "Evaluating selection sets" section of the spec + * for "read" mode. + */ +function executeFields( + exeContext: ExecutionContext, + parentType: GraphQLObjectType, + sourceValue: FieldValue, + path: ResponsePath | undefined, + fields: Record>, + closestDeferredParent?: string, +): MaybePromise { + const results = Object.create(null); + let containsPromise = false; + + for (let i = 0, keys = Object.keys(fields); i < keys.length; ++i) { + const responseName = keys[i]; + const fieldNodes = fields[responseName]; + const fieldPath = addPath(path, responseName); + + const result = resolveField( + exeContext, + parentType, + sourceValue, + fieldNodes, + fieldPath, + closestDeferredParent, + ); + + if (result !== undefined) { + results[responseName] = result; + if (!containsPromise && isPromise(result)) { + containsPromise = true; + } + } + } + + // If there are no promises, we can just return the object + if (!containsPromise) { + return results; + } + + // Otherwise, results is a map from field name to the result + // of resolving that field, which is possibly a promise. Return + // a promise that will return this same map, but with any + // promises replaced with the values they resolved to. + return promiseForObject(results); +} + +/** + * Resolves the field on the given source object. In particular, this + * figures out the value that the field returns by calling its resolve function, + * then calls completeValue to complete promises, serialize scalars, or execute + * the sub-selection-set for objects. + */ +function resolveField( + exeContext: ExecutionContext, + parentType: GraphQLObjectType, + source: FieldValue, + fieldNodes: ReadonlyArray, + path: ResponsePath, + closestDeferredParent?: string, +): MaybePromise | undefined { + const fieldNode = fieldNodes[0]; + const fieldName = fieldNode.name.value; + + const fieldDef = getFieldDef(exeContext.schema, parentType, fieldName); + if (!fieldDef) { + return; + } + + const resolveFn = fieldDef.resolve || exeContext.fieldResolver; + + const info = buildResolveInfo( + exeContext, + fieldDef, + fieldNodes, + parentType, + path, + ); + + // Get the resolve function, regardless of if its result is normal + // or abrupt (error). + const result = resolveFieldValueOrError( + exeContext, + fieldDef, + fieldNodes, + resolveFn, + source, + info, + ); + + return completeValueCatchingError( + exeContext, + fieldDef.type, + fieldNodes, + info, + path, + result, + closestDeferredParent, + ); +} + +/** + * Unchanged but not exported from graphql.js + */ +function asErrorInstance(error: any): Error { + return error instanceof Error ? error : new Error(error || undefined); +} + +/** + * Creates a bundle of patches, in a recursive structure that expresses the + * dependencies between patches. We want to ensure that patches of child fields + * get returned only after patches for its parent deferred field returns. + */ +function makePatchBundle( + exeContext: ExecutionContext, + path: ResponsePath, + data: MaybePromise, +): PatchBundle { + if (isPromise(data)) { + return data.then(resolvedData => + makePatchBundle(exeContext, path, resolvedData), + ); + } + const dependent = exeContext.deferredDependents + ? exeContext.deferredDependents[responsePathAsArray(path).toString()] + : undefined; + + return Promise.resolve({ + patch: formatDataAsPatch(path, data, dependent ? dependent.errors : []), + dependentPatches: dependent ? dependent.patches : (dependent as undefined), + }); +} + +/* This is a small wrapper around completeValue which detects and logs errors + * in the execution context. + * + * If the field should be deferred, store a promise that resolves to a patch + * containing the result, and return null to its parent immediately. + * + * If an error occurs while completing a value, it should be returned within + * the patch of the closest deferred parent node. The ExecutionContext is used + * to store a mapping to errors for each deferred field. + */ +function completeValueCatchingError( + exeContext: ExecutionContext, + returnType: GraphQLOutputType, + fieldNodes: ReadonlyArray, + info: GraphQLResolveInfo, + path: ResponsePath, + result: {}, + closestDeferredParent?: string, +): MaybePromise { + // Items in a list inherit the @defer directive applied on the list type, + // but we do not need to defer the item itself. + const pathArray = responsePathAsArray(path); + const isListItem = typeof pathArray[pathArray.length - 1] === 'number'; + const shouldDefer = + fieldNodes.every(node => shouldDeferNode(exeContext, node)) && !isListItem; + + // Throw error if @defer is applied to a non-nullable field, + // this is already caught in the validation phase. + if (isNonNullType(returnType) && shouldDefer) { + throw locatedError( + new Error( + `@defer cannot be applied on non-nullable field ${ + info.parentType.name + }.${info.fieldName}`, + ), + fieldNodes, + responsePathAsArray(path), + ); + } + + // Update closestDeferredParent if the current node is deferred + const curClosestDeferredParent = shouldDefer + ? responsePathAsArray(path).toString() + : closestDeferredParent; + + try { + let completed; + if (isPromise(result)) { + completed = result.then(resolved => + completeValue( + exeContext, + returnType, + fieldNodes, + info, + path, + resolved, + curClosestDeferredParent, + ), + ); + } else { + completed = completeValue( + exeContext, + returnType, + fieldNodes, + info, + path, + result, + curClosestDeferredParent, + ); + } + + if (shouldDefer) { + // PatchBundle ensures the ordering of patches from nested deferred fields + let promisedPatch: PatchBundle = makePatchBundle( + exeContext, + path, + completed, + ); + if (closestDeferredParent) { + // If this field is a child of a deferred field, let the parent + // dispatch it. + deferPatchToParent(exeContext, closestDeferredParent, promisedPatch); + } else { + dispatchPatch(exeContext, promisedPatch); + } + + // Return null instead of a Promise so execution does not wait for + // this field to be resolved. + return null; + } + + // If field is not deferred, execution proceeds normally. + if (isPromise(completed)) { + // Note: we don't rely on a `catch` method, but we do expect "thenable" + // to take a second callback for the error case. + return completed.then(undefined, error => { + if (closestDeferredParent) { + // If this field is a child of a deferred field, return errors from it + // with the appropriate patch. + handleDeferredFieldError( + error, + fieldNodes, + path, + returnType, + exeContext, + closestDeferredParent, + ); + return null; + } else { + // Otherwise handle error normally + return handleFieldError( + error, + fieldNodes, + path, + returnType, + exeContext, + ); + } + }); + } + return completed; + } catch (error) { + if (closestDeferredParent || shouldDefer) { + handleDeferredFieldError( + error, + fieldNodes, + path, + returnType, + exeContext, + closestDeferredParent, + ); + return null; + } else { + return handleFieldError(error, fieldNodes, path, returnType, exeContext); + } + } +} + +/** + * This helper function actually comes from v14 of graphql.js. + * Using it because its much more readable, and will make merging easier when + * we upgrade. + */ +function handleFieldError( + rawError: Error, + fieldNodes: ReadonlyArray, + path: ResponsePath, + returnType: GraphQLOutputType, + context: ExecutionContext, +) { + const error = locatedError( + asErrorInstance(rawError), + fieldNodes, + responsePathAsArray(path), + ); + + // If the field type is non-nullable, then it is resolved without any + // protection from errors, however it still properly locates the error. + if (isNonNullType(returnType)) { + throw error; + } + + // Otherwise, error protection is applied, logging the error and resolving + // a null value for this field if one is encountered. + context.errors.push(error); + return null; +} + +/** + * This method provides field level error handling for deferred fields, or + * child nodes of a deferred field. Throw error if return type is + * non-nullable. + * + * - If it is a deferred field, the error should be sent with the patch for the + * field. + * - If it is a child node of a deferred field, store the errors on exeContext + * to be retrieved by that parent deferred field. + */ +function handleDeferredFieldError( + rawError: Error, + fieldNodes: ReadonlyArray, + path: ResponsePath, + _: GraphQLOutputType, + exeContext: ExecutionContext, + closestDeferredParent?: string, +): void { + const error = locatedError( + asErrorInstance(rawError), + fieldNodes, + responsePathAsArray(path), + ); + + const dependent = exeContext.deferredDependents + ? exeContext.deferredDependents[responsePathAsArray(path).toString()] + : undefined; + + const shouldDefer = fieldNodes.every(node => + shouldDeferNode(exeContext, node), + ); + if (shouldDefer) { + // If this node is itself deferred, then send errors with this patch + const patch = formatDataAsPatch(path, null, [error]); + const promisedPatch = Promise.resolve({ + patch, + dependentPatches: dependent ? dependent.patches : dependent, + }); + if (closestDeferredParent) { + deferPatchToParent(exeContext, closestDeferredParent, promisedPatch); + } else { + dispatchPatch(exeContext, promisedPatch); + } + } + + // If it is its parent that is deferred, errors should be returned with the + // parent's patch, so store it on ExecutionContext first. + if (closestDeferredParent) { + deferErrorToParent(exeContext, closestDeferredParent, error); + } +} + +/** + * Implements the instructions for completeValue as defined in the + * "Field entries" section of the spec. + * + * If the field type is Non-Null, then this recursively completes the value + * for the inner type. It throws a field error if that completion returns null, + * as per the "Nullability" section of the spec. + * + * If the field type is a List, then this recursively completes the value + * for the inner type on each item in the list. + * + * If the field type is a Scalar or Enum, ensures the completed value is a legal + * value of the type by calling the `serialize` method of GraphQL type + * definition. + * + * If the field is an abstract type, determine the runtime type of the value + * and then complete based on that type + * + * Otherwise, the field type expects a sub-selection set, and will complete the + * value by evaluating all sub-selections. + */ +function completeValue( + exeContext: ExecutionContext, + returnType: GraphQLOutputType, + fieldNodes: ReadonlyArray, + info: GraphQLResolveInfo, + path: ResponsePath, + result: MaybePromise, + closestDeferredParent?: string, +): MaybePromise { + // If result is an Error, throw a located error. + if (result instanceof Error) { + throw result; + } + + // If field type is NonNull, complete for inner type, and throw field error + // if result is null. + if (isNonNullType(returnType)) { + const completed = completeValue( + exeContext, + returnType.ofType, + fieldNodes, + info, + path, + result, + closestDeferredParent, + ); + if (completed === null) { + throw new Error( + `Cannot return null for non-nullable field ${info.parentType.name}.${ + info.fieldName + }.`, + ); + } + return completed; + } + + // If result value is null-ish (null, undefined, or NaN) then return null. + if (isNullish(result)) { + return null; + } + + // If field type is List, complete each item in the list with the inner type + if (isListType(returnType)) { + return completeListValue( + exeContext, + returnType, + fieldNodes, + info, + path, + result, + closestDeferredParent, + ); + } + + // If field type is a leaf type, Scalar or Enum, serialize to a valid value, + // returning null if serialization is not possible. + if (isLeafType(returnType)) { + return completeLeafValue(returnType, result); + } + + // If field type is an abstract type, Interface or Union, determine the + // runtime Object type and complete for that type. + if (isAbstractType(returnType)) { + return completeAbstractValue( + exeContext, + returnType, + fieldNodes, + info, + path, + result, + closestDeferredParent, + ); + } + + // If field type is Object, execute and complete all sub-selections. + if (isObjectType(returnType)) { + return completeObjectValue( + exeContext, + returnType, + fieldNodes, + info, + path, + result, + closestDeferredParent, + ); + } + + // Not reachable. All possible output types have been considered. + /* istanbul ignore next */ + throw new Error( + `Cannot complete value of unexpected type "${String(returnType as any)}".`, + ); +} + +/** + * Complete a list value by completing each item in the list with the + * inner type + */ +function completeListValue( + exeContext: ExecutionContext, + returnType: GraphQLList, + fieldNodes: ReadonlyArray, + info: GraphQLResolveInfo, + path: ResponsePath, + result: MaybePromise, + closestDeferredParent?: string, +): MaybePromise { + invariant( + isCollection(result), + `Expected Iterable, but did not find one for field ${ + info.parentType.name + }.${info.fieldName}.`, + ); + + // This is specified as a simple map, however we're optimizing the path + // where the list contains no Promises by avoiding creating another Promise. + const itemType = returnType.ofType; + let containsPromise = false; + const completedResults: MaybePromise[] = []; + forEach(result as any, (item, index) => { + // No need to modify the info object containing the path, + // since from here on it is not ever accessed by resolver functions. + const fieldPath = addPath(path, index); + const completedItem = completeValueCatchingError( + exeContext, + itemType, + fieldNodes, + info, + fieldPath, + item, + closestDeferredParent, + ); + + if (!containsPromise && isPromise(completedItem)) { + containsPromise = true; + } + completedResults.push(completedItem); + }); + + return containsPromise ? Promise.all(completedResults) : completedResults; +} + +/** + * Complete a Scalar or Enum by serializing to a valid value, returning + * null if serialization is not possible. + */ +function completeLeafValue( + returnType: GraphQLLeafType, + result: MaybePromise, +): FieldValue { + invariant(returnType.serialize, 'Missing serialize method on type'); + const serializedResult = returnType.serialize(result); + if (isInvalid(serializedResult)) { + throw new Error( + `Expected a value of type "${String(returnType)}" but ` + + `received: ${String(result)}`, + ); + } + return serializedResult; +} + +/** + * Complete a value of an abstract type by determining the runtime object type + * of that value, then complete the value for that type. + */ +function completeAbstractValue( + exeContext: ExecutionContext, + returnType: GraphQLAbstractType, + fieldNodes: ReadonlyArray, + info: GraphQLResolveInfo, + path: ResponsePath, + result: MaybePromise, + closestDeferredParent?: string, +): MaybePromise { + const runtimeType = returnType.resolveType + ? returnType.resolveType(result, exeContext.contextValue, info) + : defaultResolveTypeFn( + result as { __typename?: string }, + exeContext.contextValue, + info, + returnType, + ); + + if (isPromise(runtimeType)) { + return runtimeType.then(resolvedRuntimeType => + completeObjectValue( + exeContext, + ensureValidRuntimeType( + resolvedRuntimeType as string | GraphQLObjectType, + exeContext, + returnType, + fieldNodes, + info, + result as FieldValue, + ), + fieldNodes, + info, + path, + result, + closestDeferredParent, + ), + ); + } + + return completeObjectValue( + exeContext, + ensureValidRuntimeType( + runtimeType as string | GraphQLObjectType, + exeContext, + returnType, + fieldNodes, + info, + result as FieldValue, + ), + fieldNodes, + info, + path, + result, + closestDeferredParent, + ); +} + +/** + * Unchanged but not exported from graphql.js + */ +function ensureValidRuntimeType( + runtimeTypeOrName: GraphQLObjectType | string, + exeContext: ExecutionContext, + returnType: GraphQLAbstractType, + fieldNodes: ReadonlyArray, + info: GraphQLResolveInfo, + result: FieldValue, +): GraphQLObjectType { + const runtimeType = + typeof runtimeTypeOrName === 'string' + ? exeContext.schema.getType(runtimeTypeOrName) + : runtimeTypeOrName; + + if (!isObjectType(runtimeType as GraphQLType)) { + throw new GraphQLError( + `Abstract type ${returnType.name} must resolve to an Object type at ` + + `runtime for field ${info.parentType.name}.${info.fieldName} with ` + + `value "${String(result)}", received "${String(runtimeType)}". ` + + `Either the ${returnType.name} type should provide a "resolveType" ` + + 'function or each possible types should provide an ' + + '"isTypeOf" function.', + fieldNodes, + ); + } + + if ( + !exeContext.schema.isPossibleType( + returnType, + runtimeType as GraphQLObjectType, + ) + ) { + throw new GraphQLError( + `Runtime Object type "${ + (runtimeType as GraphQLObjectType).name + }" is not a possible type ` + `for "${returnType.name}".`, + fieldNodes, + ); + } + + return runtimeType as GraphQLObjectType; +} + +/** + * Complete an Object value by executing all sub-selections. + */ +function completeObjectValue( + exeContext: ExecutionContext, + returnType: GraphQLObjectType, + fieldNodes: ReadonlyArray, + info: GraphQLResolveInfo, + path: ResponsePath, + result: MaybePromise, + closestDeferredParent?: string, +): MaybePromise { + // If there is an isTypeOf predicate function, call it with the + // current result. If isTypeOf returns false, then raise an error rather + // than continuing execution. + if (returnType.isTypeOf) { + const isTypeOf = returnType.isTypeOf(result, exeContext.contextValue, info); + + if (isPromise(isTypeOf)) { + return isTypeOf.then(resolvedIsTypeOf => { + if (!resolvedIsTypeOf) { + throw invalidReturnTypeError( + returnType, + result as FieldValue, + fieldNodes, + ); + } + return collectAndExecuteSubfields( + exeContext, + returnType, + fieldNodes, + info, + path, + result as FieldValue, + closestDeferredParent, + ); + }); + } + + if (!isTypeOf) { + throw invalidReturnTypeError( + returnType, + result as FieldValue, + fieldNodes, + ); + } + } + + return collectAndExecuteSubfields( + exeContext, + returnType, + fieldNodes, + info, + path, + result as FieldValue, + closestDeferredParent, + ); +} + +function invalidReturnTypeError( + returnType: GraphQLObjectType, + result: FieldValue, + fieldNodes: ReadonlyArray, +): GraphQLError { + return new GraphQLError( + `Expected value of type "${returnType.name}" but got: ${String(result)}.`, + fieldNodes, + ); +} + +function collectAndExecuteSubfields( + exeContext: ExecutionContext, + returnType: GraphQLObjectType, + fieldNodes: ReadonlyArray, + _: GraphQLResolveInfo, + path: ResponsePath, + result: FieldValue, + closestDeferredParent?: string, +): MaybePromise { + // Collect sub-fields to execute to complete this value. + const subFieldNodes = collectSubfields(exeContext, returnType, fieldNodes); + return executeFields( + exeContext, + returnType, + result, + path, + subFieldNodes, + closestDeferredParent, + ); +} + +/** + * Unchanged but not exported from graphql.js + */ +const collectSubfields = memoize3(_collectSubfields); +function _collectSubfields( + exeContext: ExecutionContext, + returnType: GraphQLObjectType, + fieldNodes: ReadonlyArray, +): Record> { + let subFieldNodes = Object.create(null); + const visitedFragmentNames = Object.create(null); + for (let i = 0; i < fieldNodes.length; i++) { + const selectionSet = fieldNodes[i].selectionSet; + if (selectionSet) { + subFieldNodes = collectFields( + exeContext, + returnType, + selectionSet, + subFieldNodes, + visitedFragmentNames, + ); + } + } + return subFieldNodes; +} + +/** + * Unchanged but not exported from graphql.js + */ +function defaultResolveTypeFn( + value: { __typename?: string }, + context: {}, + info: GraphQLResolveInfo, + abstractType: GraphQLAbstractType, +): + | GraphQLObjectType + | string + | undefined + | Promise { + // First, look for `__typename`. + if ( + value !== null && + typeof value === 'object' && + typeof value.__typename === 'string' + ) { + return value.__typename; + } + + // Otherwise, test each possible type. + const possibleTypes = info.schema.getPossibleTypes(abstractType); + const promisedIsTypeOfResults: Promise[] = []; + + for (let i = 0; i < possibleTypes.length; i++) { + const type = possibleTypes[i]; + + if (type.isTypeOf) { + const isTypeOfResult = type.isTypeOf(value, context, info); + + if (isPromise(isTypeOfResult)) { + promisedIsTypeOfResults[i] = isTypeOfResult; + } else if (isTypeOfResult) { + return type; + } + } + } + + if (promisedIsTypeOfResults.length) { + return Promise.all(promisedIsTypeOfResults).then(isTypeOfResults => { + for (let i = 0; i < isTypeOfResults.length; i++) { + if (isTypeOfResults[i]) { + return possibleTypes[i]; + } + } + return; + }); + } + return; +} diff --git a/packages/apollo-server-core/src/graphqlOptions.ts b/packages/apollo-server-core/src/graphqlOptions.ts index afed78ab98a..b90d05eb762 100644 --- a/packages/apollo-server-core/src/graphqlOptions.ts +++ b/packages/apollo-server-core/src/graphqlOptions.ts @@ -22,6 +22,7 @@ import { ApolloServerPlugin } from 'apollo-server-plugin-base'; * - (optional) fieldResolver: a custom default field resolver * - (optional) debug: a boolean that will print additional debug logging if execution errors occur * - (optional) extensions: an array of functions which create GraphQLExtensions (each GraphQLExtension object is used for one request) + * - (optional) enableDefer: a boolean that will enable deferred responses * */ export interface GraphQLServerOptions< @@ -43,6 +44,7 @@ export interface GraphQLServerOptions< cache?: KeyValueCache; persistedQueries?: PersistedQueryOptions; plugins?: ApolloServerPlugin[]; + enableDefer?: boolean; } export type DataSources = { diff --git a/packages/apollo-server-core/src/index.ts b/packages/apollo-server-core/src/index.ts index a53b0e669e7..2d3c41a2bb7 100644 --- a/packages/apollo-server-core/src/index.ts +++ b/packages/apollo-server-core/src/index.ts @@ -1,6 +1,8 @@ import 'apollo-server-env'; export { runHttpQuery, HttpQueryRequest, HttpQueryError } from './runHttpQuery'; +export { default as GraphQLDeferDirective } from './GraphQLDeferDirective'; +export { ExecutionPatchResult } from './execute'; export { default as GraphQLOptions, diff --git a/packages/apollo-server-core/src/requestPipeline.ts b/packages/apollo-server-core/src/requestPipeline.ts index 7c4429c3f60..9192ed26422 100644 --- a/packages/apollo-server-core/src/requestPipeline.ts +++ b/packages/apollo-server-core/src/requestPipeline.ts @@ -4,7 +4,6 @@ import { specifiedRules, DocumentNode, getOperationAST, - ExecutionArgs, ExecutionResult, GraphQLError, } from 'graphql'; @@ -33,6 +32,7 @@ import { createHash } from 'crypto'; import { GraphQLRequest, GraphQLResponse, + DeferredGraphQLResponse, GraphQLRequestContext, InvalidGraphQLRequestError, ValidationRule, @@ -44,20 +44,39 @@ import { } from 'apollo-server-plugin-base'; import { Dispatcher } from './utils/dispatcher'; +import { CannotDeferNonNullableFields } from './validationRules/CannotDeferNonNullableFields'; export { GraphQLRequest, GraphQLResponse, + DeferredGraphQLResponse, GraphQLRequestContext, InvalidGraphQLRequestError, }; +import { + execute as executeWithDefer, + ExecutionArgs, + isDeferredExecutionResult, + ExecutionPatchResult, + DeferredExecutionResult, +} from './execute'; + function computeQueryHash(query: string) { return createHash('sha256') .update(query) .digest('hex'); } +export function isDeferredGraphQLResponse( + result: any, +): result is DeferredGraphQLResponse { + return ( + (result).initialResponse !== undefined && + (result).deferredPatches !== undefined + ); +} + export interface GraphQLRequestPipelineConfig { schema: GraphQLSchema; @@ -76,6 +95,7 @@ export interface GraphQLRequestPipelineConfig { formatResponse?: Function; plugins?: ApolloServerPlugin[]; + enableDefer?: boolean; } export type DataSources = { @@ -87,7 +107,8 @@ type Mutable = { -readonly [P in keyof T]: T[P] }; export async function processGraphQLRequest( config: GraphQLRequestPipelineConfig, requestContext: Mutable>, -): Promise { +): Promise { + let cacheControlExtension: CacheControlExtension | undefined; const extensionStack = initializeExtensionStack(); (requestContext.context as any)._extensionStack = extensionStack; @@ -167,8 +188,11 @@ export async function processGraphQLRequest( requestContext, ); + let isDeferred = false; + try { let document: DocumentNode; + try { document = parse(query); parsingDidEnd(); @@ -221,33 +245,66 @@ export async function processGraphQLRequest( ); let response: GraphQLResponse; + let result: ExecutionResult | DeferredExecutionResult; + let patches: AsyncIterable | undefined; + let isDeferred = false; try { - response = (await execute( + result = (await execute( document, request.operationName, request.variables, - )) as GraphQLResponse; - executionDidEnd(); - } catch (executionError) { - executionDidEnd(executionError); - return sendErrorResponse(executionError); - } + )); - const formattedExtensions = extensionStack.format(); - if (Object.keys(formattedExtensions).length > 0) { - response.extensions = formattedExtensions; - } + isDeferred = isDeferredExecutionResult(result); - if (config.formatResponse) { - response = config.formatResponse(response, { + if (isDeferred) { + response = ((result as DeferredExecutionResult).initialResult) as GraphQLResponse; + patches = (result as DeferredExecutionResult).deferredPatches; + } else { + response = result as GraphQLResponse; + } + + const formattedExtensions = extensionStack.format(); + if (Object.keys(formattedExtensions).length > 0) { + response.extensions = formattedExtensions; + } + + // `formatResponse` format fallback for TS2722: Cannot invoke an object which is possibly 'undefined'. + const formatResponse = config.formatResponse || ((x: GraphQLResponse):GraphQLResponse => x); + + response = formatResponse(response, { context: requestContext.context, }); + + let output: GraphQLResponse | DeferredGraphQLResponse; + + if (isDeferred) { + executionDidEnd(); + output = { + initialResponse: response, + deferredPatches: patches!, + requestDidEnd, + }; + + } else { + + executionDidEnd(); + output = response; + } + + return sendResponse(output); + + } catch (executionError) { + executionDidEnd(executionError); + return sendErrorResponse(executionError); } - return sendResponse(response); } finally { - requestDidEnd(); + + if (!isDeferred) { + requestDidEnd(); + } } function parse(query: string): DocumentNode { @@ -263,7 +320,7 @@ export async function processGraphQLRequest( } function validate(document: DocumentNode): ReadonlyArray { - let rules = specifiedRules; + let rules = specifiedRules.concat([CannotDeferNonNullableFields]); if (config.validationRules) { rules = rules.concat(config.validationRules); } @@ -281,7 +338,7 @@ export async function processGraphQLRequest( document: DocumentNode, operationName: GraphQLRequest['operationName'], variables: GraphQLRequest['variables'], - ): Promise { + ): Promise { const executionArgs: ExecutionArgs = { schema: config.schema, document, @@ -293,6 +350,7 @@ export async function processGraphQLRequest( variableValues: variables, operationName, fieldResolver: config.fieldResolver, + enableDefer: config.enableDefer, }; const executionDidEnd = extensionStack.executionDidStart({ @@ -300,30 +358,53 @@ export async function processGraphQLRequest( }); try { - return graphql.execute(executionArgs); + return executeWithDefer(executionArgs); } finally { executionDidEnd(); } } async function sendResponse( - response: GraphQLResponse, - ): Promise { - // 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, - ); + response: GraphQLResponse | DeferredGraphQLResponse, + ): Promise { + + if (isDeferredGraphQLResponse(response)) { + const initialResponse = (response as DeferredGraphQLResponse).initialResponse; + const requestContextInitialResponse = requestContext.response ? + (requestContext.response as DeferredGraphQLResponse).initialResponse : undefined; + + const r = extensionStack.willSendResponse({ + graphqlResponse: { + ...requestContextInitialResponse, + errors: initialResponse.errors, + data: initialResponse.data, + extensions: initialResponse.extensions, + }, + context: requestContext.context, + }); + + requestContext.response = { + ...(response as DeferredGraphQLResponse), + initialResponse: r.graphqlResponse, + } as DeferredGraphQLResponse; + + } else { + // 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, + ); + } return requestContext.response!; } diff --git a/packages/apollo-server-core/src/requestPipelineAPI.ts b/packages/apollo-server-core/src/requestPipelineAPI.ts index a4d00fc8999..5508fcf47f4 100644 --- a/packages/apollo-server-core/src/requestPipelineAPI.ts +++ b/packages/apollo-server-core/src/requestPipelineAPI.ts @@ -13,6 +13,27 @@ import { } from 'graphql'; import { KeyValueCache } from 'apollo-server-caching'; + +// TODO: Get FieldValue and ExecutionPatchResult from execute +// Copying these types over from ./execute for now, because this compiles as a separate TypeScript +// project it can't import these types from a relative file path or through /dist? There is +// probably some config magic that needs to be done here to get this to work... + +// Valid types a GraphQL field can take +type FieldValue = +| Record +| Array +| string +| number +| boolean +| null; + +export interface ExecutionPatchResult { + data?: FieldValue; + errors?: ReadonlyArray; + path: ReadonlyArray; +} + export interface GraphQLServiceContext { schema: GraphQLSchema; schemaHash: string; @@ -39,9 +60,15 @@ export interface GraphQLResponse { http?: Pick; } +export interface DeferredGraphQLResponse { + initialResponse: GraphQLResponse; + deferredPatches: AsyncIterable; + requestDidEnd: () => void; +} + export interface GraphQLRequestContext> { readonly request: GraphQLRequest; - readonly response?: GraphQLResponse; + readonly response?: GraphQLResponse | DeferredGraphQLResponse; readonly context: TContext; readonly cache: KeyValueCache; diff --git a/packages/apollo-server-core/src/runHttpQuery.ts b/packages/apollo-server-core/src/runHttpQuery.ts index 977593bcdd3..ebbb2ac0834 100644 --- a/packages/apollo-server-core/src/runHttpQuery.ts +++ b/packages/apollo-server-core/src/runHttpQuery.ts @@ -1,3 +1,4 @@ +import { $$asyncIterator, createAsyncIterator } from 'iterall'; import { Request, Headers } from 'apollo-server-env'; import { default as GraphQLOptions, @@ -5,6 +6,7 @@ import { } from './graphqlOptions'; import { formatApolloErrors, + fromGraphQLError, PersistedQueryNotSupportedError, PersistedQueryNotFoundError, } from 'apollo-server-errors'; @@ -14,10 +16,14 @@ import { InvalidGraphQLRequestError, GraphQLRequestContext, GraphQLResponse, + DeferredGraphQLResponse, + isDeferredGraphQLResponse, } from './requestPipeline'; + import { CacheControlExtensionOptions } from 'apollo-cache-control'; import { ApolloServerPlugin, WithRequired } from 'apollo-server-plugin-base'; + export interface HttpQueryRequest { method: string; // query is either the POST body or the GET query string map. In the GET @@ -30,6 +36,7 @@ export interface HttpQueryRequest { | GraphQLOptions | ((...args: Array) => Promise | GraphQLOptions); request: Pick; + enableDefer?: boolean; } export interface ApolloServerHttpResponse { @@ -43,7 +50,8 @@ 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; + graphqlResponse?: string; + graphqlResponses?: AsyncIterable; responseInit: ApolloServerHttpResponse; } @@ -169,6 +177,7 @@ export async function runHttpQuery( debug: options.debug, plugins: options.plugins || [], + enableDefer: options.enableDefer, }; return processHTTPRequest(config, request); @@ -248,6 +257,8 @@ export async function processHTTPRequest( }; let body: string; + let isDeferred = false; + let response; try { if (Array.isArray(requestPayload)) { @@ -271,29 +282,33 @@ export async function processHTTPRequest( }), ); - body = prettyJSONStringify(responses.map(serializeGraphQLResponse)); + body = prettyJSONStringify(responses.map(response => serializeGraphQLResponse(response as GraphQLResponse) )); } else { // We're processing a normal request const request = parseGraphQLRequest(httpRequest.request, requestPayload); try { const requestContext = buildRequestContext(request); - const response = await processGraphQLRequest(options, requestContext); + response = await processGraphQLRequest(options, requestContext); + isDeferred = isDeferredGraphQLResponse(response); + const initialResponse = isDeferred ? (response as DeferredGraphQLResponse).initialResponse : undefined; + const graphqlResponse: GraphQLResponse = initialResponse || (response as GraphQLResponse); // 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') { + if (graphqlResponse.errors && typeof graphqlResponse.data === 'undefined') { // don't include options, since the errors have already been formatted - return throwHttpGraphQLError(400, response.errors as any); + return throwHttpGraphQLError(400, graphqlResponse.errors as any); } - if (response.http) { - for (const [name, value] of response.http.headers) { + if (graphqlResponse.http) { + for (const [name, value] of graphqlResponse.http.headers) { responseInit.headers![name] = value; } } - body = prettyJSONStringify(serializeGraphQLResponse(response)); + body = prettyJSONStringify(serializeGraphQLResponse(graphqlResponse)); + } catch (error) { if (error instanceof InvalidGraphQLRequestError) { throw new HttpQueryError(400, error.message); @@ -314,15 +329,23 @@ export async function processHTTPRequest( return throwHttpGraphQLError(500, [error], options); } - responseInit.headers!['Content-Length'] = Buffer.byteLength( - body, - 'utf8', - ).toString(); + if (!isDeferred) { + responseInit.headers!['Content-Length'] = Buffer.byteLength( + body, + 'utf8', + ).toString(); - return { - graphqlResponse: body, - responseInit, - }; + return { + graphqlResponse: body, + responseInit, + }; + } else { + return { + graphqlResponse: undefined, + graphqlResponses: graphqlResponseToAsyncIterable(response as DeferredGraphQLResponse), + responseInit + } + } } function parseGraphQLRequest( @@ -426,3 +449,38 @@ function prettyJSONStringify(value: any) { function cloneObject(object: T): T { return Object.assign(Object.create(Object.getPrototypeOf(object)), object); } + +/** + * Note: We can use async generators directly when it is supported in node 8/6 + */ +function graphqlResponseToAsyncIterable( + result: DeferredGraphQLResponse, +): AsyncIterable { + const initialResponse = prettyJSONStringify(result.initialResponse); + let initialResponseSent = false; + const patchIterator = createAsyncIterator(result.deferredPatches); + + return { + [$$asyncIterator]() { + return { + next() { + if (!initialResponseSent) { + initialResponseSent = true; + return Promise.resolve({ value: initialResponse, done: false }); + } else { + return patchIterator.next().then(({ value, done }) => { + if (value && value.errors) { + value.errors = value.errors.map(error => + fromGraphQLError(error), + ); + } + // Call requestDidEnd when the last patch is resolved + if (done) result.requestDidEnd(); + return { value: prettyJSONStringify(value), done }; + }); + } + }, + }; + }, + } as any; // Typescript does not handle $$asyncIterator correctly +} diff --git a/packages/apollo-server-core/src/validationRules/CannotDeferNonNullableFields.ts b/packages/apollo-server-core/src/validationRules/CannotDeferNonNullableFields.ts new file mode 100644 index 00000000000..b70d1f05be0 --- /dev/null +++ b/packages/apollo-server-core/src/validationRules/CannotDeferNonNullableFields.ts @@ -0,0 +1,33 @@ +import { + ValidationContext, + ASTVisitor, + GraphQLError, + isNonNullType, + DirectiveNode, +} from 'graphql'; + +export function cannotDeferOnNonNullMessage(fieldName: string): string { + return `@defer cannot be applied on non-nullable field "${fieldName}".`; +} + +export function CannotDeferNonNullableFields( + context: ValidationContext, +): ASTVisitor { + return { + Directive(node: DirectiveNode) { + const fieldDef = context.getFieldDef(); + if (fieldDef) { + if (node.name.value === 'defer' && isNonNullType(fieldDef.type)) { + context.reportError( + new GraphQLError( + cannotDeferOnNonNullMessage( + `${context.getParentType()}.${fieldDef.name}`, + ), + [node], + ), + ); + } + } + }, + }; +} diff --git a/packages/apollo-server-core/tsconfig.json b/packages/apollo-server-core/tsconfig.json index f1346e95f4d..533524da513 100644 --- a/packages/apollo-server-core/tsconfig.json +++ b/packages/apollo-server-core/tsconfig.json @@ -2,10 +2,11 @@ "extends": "../../tsconfig.base", "compilerOptions": { "rootDir": "./src", - "outDir": "./dist" + "outDir": "./dist", + "noImplicitAny": false }, "include": ["src/**/*"], - "exclude": ["**/__tests__", "**/__mocks__", "src/requestPipelineAPI.ts"], + "exclude": ["**/__tests__", "**/__mocks__", "src/requestPipelineAPI.ts", "src/runQuery.ts"], "references": [ { "path": "./tsconfig.requestPipelineAPI.json" }, { "path": "../apollo-cache-control" }, diff --git a/packages/apollo-server-express/package.json b/packages/apollo-server-express/package.json index 06ff254a40a..aed9059db0b 100644 --- a/packages/apollo-server-express/package.json +++ b/packages/apollo-server-express/package.json @@ -37,6 +37,7 @@ "cors": "^2.8.4", "graphql-subscriptions": "^1.0.0", "graphql-tools": "^4.0.0", + "iterall": "^1.2.2", "type-is": "^1.6.16" }, "devDependencies": { diff --git a/packages/apollo-server-express/src/expressApollo.ts b/packages/apollo-server-express/src/expressApollo.ts index 607c699f70e..938e1712858 100644 --- a/packages/apollo-server-express/src/expressApollo.ts +++ b/packages/apollo-server-express/src/expressApollo.ts @@ -5,6 +5,7 @@ import { runHttpQuery, convertNodeHttpToRequest, } from 'apollo-server-core'; +import { forAwaitEach } from 'iterall'; export interface ExpressGraphQLOptionsFunction { (req?: express.Request, res?: express.Response): @@ -31,21 +32,52 @@ export function graphqlExpress( ); } - return (req, res, next): void => { + const graphqlHandler = async ( + req: express.Request, + res: express.Response, + next: express.NextFunction, + ) => { runHttpQuery([req, res], { method: req.method, options: options, query: req.method === 'POST' ? req.body : req.query, request: convertNodeHttpToRequest(req), + enableDefer: true, }).then( - ({ graphqlResponse, responseInit }) => { + async ({ graphqlResponse, graphqlResponses, responseInit }) => { if (responseInit.headers) { - for (const [name, value] of Object.entries(responseInit.headers)) { - res.setHeader(name, value); - } + for (const [name, value] of Object.entries(responseInit.headers)) { + res.setHeader(name, value); + } + } + if (graphqlResponse) { + res.write(graphqlResponse); + res.end(); + } else if (graphqlResponses) { + // This is a deferred response, so send it as patches become ready. + // Update the content type to be able to send multipart data + // See: https://www.w3.org/Protocols/rfc1341/7_2_Multipart.html + // Note that we are sending JSON strings, so we can use a simple + // "-" as the boundary delimiter. + res.setHeader('Content-Type', 'multipart/mixed; boundary="-"'); + const contentTypeHeader = 'Content-Type: application/json\r\n'; + const boundary = '\r\n---\r\n'; + const terminatingBoundary = '\r\n-----\r\n'; + await forAwaitEach(graphqlResponses, data => { + // Format each message as a proper multipart HTTP part + const contentLengthHeader = `Content-Length: ${Buffer.byteLength( + data as string, + 'utf8', + ).toString()}\r\n\r\n`; + res.write( + boundary + contentTypeHeader + contentLengthHeader + data, + ); + }); + + // Finish up multipart with the last encapsulation boundary + res.write(terminatingBoundary); + res.end(); } - res.write(graphqlResponse); - res.end(); }, (error: HttpQueryError) => { if ('HttpQueryError' !== error.name) { @@ -64,4 +96,6 @@ export function graphqlExpress( }, ); }; + + return graphqlHandler; } diff --git a/packages/apollo-server-hapi/package.json b/packages/apollo-server-hapi/package.json index b04b20513b8..424dba7a5a0 100644 --- a/packages/apollo-server-hapi/package.json +++ b/packages/apollo-server-hapi/package.json @@ -30,7 +30,8 @@ "apollo-server-core": "file:../apollo-server-core", "boom": "^7.1.0", "graphql-subscriptions": "^1.0.0", - "graphql-tools": "^4.0.0" + "graphql-tools": "^4.0.0", + "iterall": "^1.2.2" }, "devDependencies": { "apollo-server-integration-testsuite": "file:../apollo-server-integration-testsuite" diff --git a/packages/apollo-server-hapi/src/hapiApollo.ts b/packages/apollo-server-hapi/src/hapiApollo.ts index c85e8f15058..6481b324cc8 100644 --- a/packages/apollo-server-hapi/src/hapiApollo.ts +++ b/packages/apollo-server-hapi/src/hapiApollo.ts @@ -5,6 +5,8 @@ import { runHttpQuery, convertNodeHttpToRequest, } from 'apollo-server-core'; +import { PassThrough } from 'stream'; +import { forAwaitEach } from 'iterall'; export interface IRegister { (server: Server, options: any, next?: Function): void; @@ -40,7 +42,7 @@ const graphqlHapi: IPlugin = { options: options.route || {}, handler: async (request, h) => { try { - const { graphqlResponse, responseInit } = await runHttpQuery( + const { graphqlResponse, graphqlResponses, responseInit, } = await runHttpQuery( [request, h], { method: request.method.toUpperCase(), @@ -51,14 +53,49 @@ const graphqlHapi: IPlugin = { (request.payload as any) : request.query, request: convertNodeHttpToRequest(request.raw.req), + enableDefer: true, }, ); - const response = h.response(graphqlResponse); - Object.keys(responseInit.headers).forEach(key => - response.header(key, responseInit.headers[key]), - ); - return response; + if (graphqlResponses) { + // This is a deferred response, so send it as patches become ready. + // Update the content type to be able to send multipart data + // See: https://www.w3.org/Protocols/rfc1341/7_2_Multipart.html + // Note that we are sending JSON strings, so we can use a simple + // "-" as the boundary delimiter. + const contentTypeHeader = 'Content-Type: application/json\r\n'; + const boundary = '\r\n---\r\n'; + const terminatingBoundary = '\r\n-----\r\n'; + + const responseStream = new PassThrough(); + const response = h + .response(responseStream) + .header('Content-Type', 'multipart/mixed; boundary="-"'); + + forAwaitEach(graphqlResponses, data => { + const contentLengthHeader = `Content-Length: ${Buffer.byteLength( + data as string, + 'utf8', + ).toString()}\r\n\r\n`; + responseStream.write( + boundary + contentTypeHeader + contentLengthHeader + data, + ); + }).then(() => { + // Finish up multipart with the last encapsulation boundary + responseStream.write(terminatingBoundary); + responseStream.end(); + }); + + return response; + } else { + const response = h.response(graphqlResponse); + Object.keys(responseInit.headers).forEach(key => + response.header(key, responseInit.headers[key]), + ); + + return response; + } + } catch (error) { if ('HttpQueryError' !== error.name) { throw Boom.boomify(error); diff --git a/packages/apollo-server-koa/package.json b/packages/apollo-server-koa/package.json index d5daa7a25e1..395d3ed2ffc 100644 --- a/packages/apollo-server-koa/package.json +++ b/packages/apollo-server-koa/package.json @@ -37,6 +37,7 @@ "apollo-server-core": "file:../apollo-server-core", "graphql-subscriptions": "^1.0.0", "graphql-tools": "^4.0.0", + "iterall": "^1.2.2", "koa": "2.6.2", "koa-bodyparser": "^3.0.0", "koa-router": "^7.4.0", diff --git a/packages/apollo-server-koa/src/koaApollo.ts b/packages/apollo-server-koa/src/koaApollo.ts index b25aba2d3f8..0d5151062b7 100644 --- a/packages/apollo-server-koa/src/koaApollo.ts +++ b/packages/apollo-server-koa/src/koaApollo.ts @@ -5,6 +5,7 @@ import { runHttpQuery, convertNodeHttpToRequest, } from 'apollo-server-core'; +import { forAwaitEach } from 'iterall'; export interface KoaGraphQLOptionsFunction { (ctx: Koa.Context): GraphQLOptions | Promise; @@ -38,12 +39,48 @@ export function graphqlKoa( ctx.request.body || (ctx.req as any).body : ctx.request.query, request: convertNodeHttpToRequest(ctx.req), + enableDefer: true, }).then( - ({ graphqlResponse, responseInit }) => { + async ({ graphqlResponse, graphqlResponses, responseInit }) => { Object.keys(responseInit.headers).forEach(key => ctx.set(key, responseInit.headers[key]), ); - ctx.body = graphqlResponse; + + if (graphqlResponse) { + ctx.body = graphqlResponse; + } else if (graphqlResponses) { + // This is a deferred response, so send it as patches become ready. + // Update the content type to be able to send multipart data + // See: https://www.w3.org/Protocols/rfc1341/7_2_Multipart.html + // Note that we are sending JSON strings, so we can use a simple + // "-" as the boundary delimiter. + + // According to the Koa docs: https://koajs.com/#response, + // bypassing Koa's response handling is not supported, so res.write() + // may not be working as expected. + + ctx.set('Content-Type', 'multipart/mixed; boundary="-"'); + const contentTypeHeader = 'Content-Type: application/json\r\n'; + const boundary = '\r\n---\r\n'; + const terminatingBoundary = '\r\n-----\r\n'; + + ctx.res.writeHead(200); + + await forAwaitEach(graphqlResponses, data => { + const contentLengthHeader = `Content-Length: ${Buffer.byteLength( + data as string, + 'utf8', + ).toString()}\r\n\r\n`; + + ctx.res.write( + boundary + contentTypeHeader + contentLengthHeader + data, + ); + }); + + // Finish up multipart with the last encapsulation boundary + ctx.res.write(terminatingBoundary); + ctx.res.end(); + } }, (error: HttpQueryError) => { if ('HttpQueryError' !== error.name) { diff --git a/packages/apollo-server-lambda/src/lambdaApollo.ts b/packages/apollo-server-lambda/src/lambdaApollo.ts index 836e7d282cf..57653e8e0bb 100644 --- a/packages/apollo-server-lambda/src/lambdaApollo.ts +++ b/packages/apollo-server-lambda/src/lambdaApollo.ts @@ -52,10 +52,13 @@ export function graphqlLambda( }, }).then( ({ graphqlResponse, responseInit }) => { + const body = graphqlResponse || ''; + const headers = responseInit.headers || undefined; + callback(null, { - body: graphqlResponse, + body, statusCode: 200, - headers: responseInit.headers, + headers, }); }, (error: HttpQueryError) => { diff --git a/packages/apollo-server-micro/package.json b/packages/apollo-server-micro/package.json index 66f687638e5..a62b9508be8 100644 --- a/packages/apollo-server-micro/package.json +++ b/packages/apollo-server-micro/package.json @@ -26,6 +26,7 @@ "@apollographql/graphql-playground-html": "^1.6.6", "accept": "^3.0.2", "apollo-server-core": "file:../apollo-server-core", + "iterall": "^1.2.2", "micro": "^9.3.2" }, "devDependencies": { diff --git a/packages/apollo-server-micro/src/microApollo.ts b/packages/apollo-server-micro/src/microApollo.ts index 2aad76747de..2e252059789 100644 --- a/packages/apollo-server-micro/src/microApollo.ts +++ b/packages/apollo-server-micro/src/microApollo.ts @@ -6,6 +6,7 @@ import { import { send, json, RequestHandler } from 'micro'; import url from 'url'; import { IncomingMessage, ServerResponse } from 'http'; +import { forAwaitEach } from 'iterall'; import { MicroRequest } from './types'; @@ -49,14 +50,50 @@ export function graphqlMicro( } try { - const { graphqlResponse, responseInit } = await runHttpQuery([req, res], { + const { + graphqlResponse, + graphqlResponses, + responseInit, + } = await runHttpQuery([req, res], { method: req.method, options, query, request: convertNodeHttpToRequest(req), + enableDefer: true, }); setHeaders(res, responseInit.headers); - return graphqlResponse; + + if (graphqlResponses) { + // This is a deferred response, so send it as patches become ready. + // Update the content type to be able to send multipart data + // See: https://www.w3.org/Protocols/rfc1341/7_2_Multipart.html + // Note that we are sending JSON strings, so we can use a simple + // "-" as the boundary delimiter. + res.setHeader('Content-Type', 'multipart/mixed; boundary="-"'); + const contentTypeHeader = 'Content-Type: application/json\r\n'; + const boundary = '\r\n---\r\n'; + const terminatingBoundary = '\r\n-----\r\n'; + + res.writeHead(200); + + await forAwaitEach(graphqlResponses, data => { + const contentLengthHeader = `Content-Length: ${Buffer.byteLength( + data as string, + 'utf8', + ).toString()}\r\n\r\n`; + + res.write(boundary + contentTypeHeader + contentLengthHeader + data); + }); + + // Finish up multipart with the last encapsulation boundary + res.write(terminatingBoundary); + res.end(); + return undefined; + + } else { + return graphqlResponse; + } + } catch (error) { if ('HttpQueryError' === error.name && error.headers) { setHeaders(res, error.headers); diff --git a/packages/apollo-server/src/exports.ts b/packages/apollo-server/src/exports.ts index 7a625e2cbe4..31765895ca6 100644 --- a/packages/apollo-server/src/exports.ts +++ b/packages/apollo-server/src/exports.ts @@ -16,6 +16,7 @@ export { AuthenticationError, ForbiddenError, UserInputError, + GraphQLDeferDirective, // playground defaultPlaygroundOptions, PlaygroundConfig,