diff --git a/.circleci/config.yml b/.circleci/config.yml index 0b4e627a1f4..5c98e190e2c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -11,8 +11,8 @@ run_install_desired_npm: &run_install_desired_npm # Node.js 6 (npm v3.10.10) go poorly and generally causes other problems # with the environment. Since yarn is already available here we can just # use that to work-around the issue. It's possible that npm cleanup might - # prevent this from being necessary, but this can be removed once Node 6 is - # no longer being built below. + # prevent this from being necessary, but this installation can be switched + # to use `npm` (rather than `yarn`) once Node 6 is no longer tested below. name: Install npm@5, but with yarn. command: sudo yarn global add npm@5 @@ -25,8 +25,15 @@ common_test_steps: &common_test_steps steps: - *run_install_desired_npm - checkout + - run: cat ./packages/*/package.json > package-checksum + - restore_cache: + key: dependency-cache-{{ checksum "package.json" }}-{{ checksum "package-checksum" }} - run: npm --version - run: npm install + - save_cache: + key: dependency-cache-{{ checksum "package.json" }}-{{ checksum "package-checksum" }} + paths: + - ./node_modules - run: npm run travis # Important! When adding a new job to `jobs`, make sure to define when it @@ -44,8 +51,8 @@ jobs: docker: [ { image: 'circleci/node:8' } ] <<: *common_test_steps - Node.js 9: - docker: [ { image: 'circleci/node:9' } ] + Node.js 10: + docker: [ { image: 'circleci/node:10' } ] <<: *common_test_steps # Other tests, unrelated to typical code tests. @@ -62,15 +69,31 @@ jobs: steps: # (speed) Intentionally omitted, unnecessary, run_install_desired_npm. - checkout + - restore_cache: + key: dependency-cache-{{ checksum "./docs/package.json" }} # (speed) Ignore scripts to skip the Lerna build stuff for linting. - run: npm install-test --prefix ./docs + - save_cache: + key: dependency-cache-{{ checksum "./docs/package.json" }} + paths: + - ./docs/node_modules + +ignore_doc_branches: &ignore_doc_branches + filters: + branches: + # If 'docs' is found, with word boundaries on either side, skip. + ignore: /.*?\bdocs\b.*/ workflows: version: 2 Build and Test: jobs: - - Node.js 6 - - Node.js 8 - - Node.js 9 - - Linting + - Node.js 6: + <<: *ignore_doc_branches + - Node.js 8: + <<: *ignore_doc_branches + - Node.js 10: + <<: *ignore_doc_branches + - Linting: + <<: *ignore_doc_branches - Docs diff --git a/.vscode/settings.json b/.vscode/settings.json index 274e9fdcd1a..c29cb70c127 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,6 +5,7 @@ "files.trimTrailingWhitespace": true, "files.insertFinalNewline": true, "editor.wordWrapColumn": 110, + "editor.formatOnSave": true, "prettier.singleQuote": true, "prettier.printWidth": 110, "files.exclude": { diff --git a/CHANGELOG.md b/CHANGELOG.md index b69490c4301..b7d2a8c245c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,92 @@ All of the packages in the `apollo-server` repo are released with the same versi ### vNEXT +### rc.7 + +- enable engine reporting from lambda [#1313](https://github.com/apollographql/apollo-server/pull/1313) +- remove flattening of errors [#1288](https://github.com/apollographql/apollo-server/pull/1288) +- dynamic url in datasourece ([#1277](https://github.com/apollographql/apollo-server/pull/1277)) + +### rc.6 + +- BREAKING: errors are passed to user extensions, then engine reporting, and finally `formatError` ([#1272](https://github.com/apollographql/apollo-server/pull/1272)) +- `formatError` only called once on validation errors ([#1272](https://github.com/apollographql/apollo-server/pull/1272)) +- BREAKING: apollo-server-env does place types in global namespace ([#1259](https://github.com/apollographql/apollo-server/pull/1259)) +- export Request from apollo-datasource-rest and graphql-extensions (53d7a75 c525818) +- Use scoped graphql-playground and centralize version (8ea36d8, 84233d2) +- fix dependencies + exports ([#1257](https://github.com/apollographql/apollo-server/pull/1257)) +- fix data source + context cloning (7e35305) +- use fetch instead of Node request for engine-reporting ([#1274](https://github.com/apollographql/apollo-server/pull/1274)) + +### rc.5 + +- fix formatError to keep prototype of Error ([#1235](https://github.com/apollographql/apollo-server/pull/1235)) + +### rc.4 + +- Add trailing slash to data source +- allow body passed to data source +- new apollo-engine-reporting agent + +### rc.3 + +- graphql as peerDependency ([#1232](https://github.com/apollographql/apollo-server/pull/1232)) +- APQ in batches ([#1234](https://github.com/apollographql/apollo-server/pull/1234)) +- APQ hits/misses in traces + +### rc.2 + +- Missing apollo-upload-server dependency ([#1221](https://github.com/apollographql/apollo-server/pull/1221)) +- encode trace report over each request in apollo-engine-reporting + +### rc.1 + +- BREAKING: remove logFunction ([71a403d](https://github.com/apollographql/apollo-server/pull/1125/commits/71a403dfa38ee050606d3fa32630005e0a98016f)), see [this commit](https://github.com/apollographql/apollo-server/blob/8914b135df9840051fe81cc9224b444cfc5b61ab/packages/apollo-server-core/src/logging.ts) for an implementation +- move upload option to constructor ([#1204](https://github.com/apollographql/apollo-server/pull/1204)) +- fixed hapi gui bugs ([#1211](https://github.com/apollographql/apollo-server/pull/1211)) +- remove requirement for exModuleInterop ([#1210](https://github.com/apollographql/apollo-server/pull/1210)) +- change BadUserInputError to UserInputError ([#1208](https://github.com/apollographql/apollo-server/pull/1208)) +- add cache-control headers for CDN integration ([#1138](https://github.com/apollographql/apollo-server/pull/1138)) +- Lambda support (thanks to @adnsio, @bwlt, and @gragio [#1138](https://github.com/apollographql/apollo-server/pull/1138)) + +Data sources + +- add memcache and redis support ([#1191](https://github.com/apollographql/apollo-server/pull/1191)) +- add patch method ([#1190](https://github.com/apollographql/apollo-server/pull/1190)) + +### rc.0 + +- Breaking: `registerServer` changed to `server.applyMiddleware` ([3279991](https://github.com/apollographql/apollo-server/pull/1125/commits/327999174cfbcecaa4e401ffd7b2d7148ba0fd65)) +- Breaking: subscriptions enabled with `installSubscriptionHandlers` +- Add Data Sources ([#1163](https://github.com/apollographql/apollo-server/pull/1163)) + +### beta.4 + +* Bug fix to allow async context ([#1129](https://github.com/apollographql/apollo-server/pull/1129)) +* logFunction is now an extension ([#1128](https://github.com/apollographql/apollo-server/pull/1128)) +* Allow user defined extensions and include engine reporting ([#1105](https://github.com/apollographql/apollo-server/pull/#105)) + +### beta.3 + +* remove registerServer configuration from `apollo-server`'s listen ([#1090](https://github.com/apollographql/apollo-server/pull/1090)) +* move healthcheck into variants ([#1086](https://github.com/apollographql/apollo-server/pull/1086)) +* Add file uploads, **breaking** requires removing `scalar Upload` from the typeDefs ([#1071](https://github.com/apollographql/apollo-server/pull/1071)) +* Add reporting to Engine as apollo-engine-reporting ([#1105](https://github.com/apollographql/apollo-server/pull/1105)) +* Allow users to define extensions ([#1105](https://github.com/apollographql/apollo-server/pull/1105)) + +### beta.2 + +ListenOptions: +* `engine` -> `engineProxy` +* `port`, `host`, and other http options moved under `http` key ([#1080](https://github.com/apollographql/apollo-server/pull/1080)) + +* `subscriptions` moved to `server.listen` ([#1059](https://github.com/apollographql/apollo-server/pull/1059)) +* Add mocks to server constructor ([#1017](https://github.com/apollographql/apollo-server/pull/1017)) +* Add `bodyParserConfig` parameter to `registerServer` in apollo-server ([#1059](https://github.com/apollographql/apollo-server/pull/1059)) [commit](https://github.com/apollographql/apollo-server/pull/1063/commits/d08f862063b60f35d92f903c9ac52702150c10f6) +* Hapi variant ([#1058](https://github.com/apollographql/apollo-server/pull/1058)) ([#1082](https://github.com/apollographql/apollo-server/pull/1082)) +* Remove tests and guaranteed support for Node 4 [PR #1024](https://github.com/apollographql/apollo-server/pull/1024) +* Cleanup docs [PR #1233](https://github.com/apollographql/apollo-server/pull/1233/files) + ### 1.4.0 * [Issue #626] Integrate apollo-fastify plugin. [PR #1013](https://github.com/apollographql/apollo-server/pull/1013) diff --git a/README.md b/README.md index 4cffd37be67..8c34faa81c1 100644 --- a/README.md +++ b/README.md @@ -1,335 +1,220 @@ -# GraphQL Server for Express, Connect, Hapi, Koa, and more - -Also supports: Restify, Micro, Azure Functions, AWS Lambda and Adonis Framework +# GraphQL Server for Express, Connect, Hapi, Cloudflare workers, and more [![npm version](https://badge.fury.io/js/apollo-server-core.svg)](https://badge.fury.io/js/apollo-server-core) -[![Build Status](https://circleci.com/gh/apollographql/apollo-cache-control-js.svg?style=svg)](https://circleci.com/gh/apollographql/apollo-cache-control-js) +[![Build Status](https://circleci.com/gh/apollographql/apollo-server.svg?style=svg)](https://circleci.com/gh/apollographql/apollo-server) [![Get on Slack](https://img.shields.io/badge/slack-join-orange.svg)](https://www.apollographql.com/#slack) Apollo Server is a community-maintained open-source GraphQL server. It works with pretty much all Node.js HTTP server frameworks, and we're happy to take PRs for more! Apollo Server works with any GraphQL schema built with [GraphQL.js](https://github.com/graphql/graphql-js), so you can build your schema with that directly or with a convenience library such as [graphql-tools](https://www.apollographql.com/docs/graphql-tools/). ## Documentation -[Read the docs!](https://www.apollographql.com/docs/apollo-server/) +[Read the docs!](https://www.apollographql.com/docs/apollo-server/v2) ## Principles Apollo Server is built with the following principles in mind: -* **By the community, for the community**: Apollo Server's development is driven by the needs of developers -* **Simplicity**: by keeping things simple, Apollo Server is easier to use, easier to contribute to, and more secure -* **Performance**: Apollo Server is well-tested and production-ready - no modifications needed +- **By the community, for the community**: Apollo Server's development is driven by the needs of developers +- **Simplicity**: by keeping things simple, Apollo Server is easier to use, easier to contribute to, and more secure +- **Performance**: Apollo Server is well-tested and production-ready - no modifications needed Anyone is welcome to contribute to Apollo Server, just read [CONTRIBUTING.md](./CONTRIBUTING.md), take a look at the [roadmap](./ROADMAP.md) and make your first PR! ## Getting started -Apollo Server is super easy to set up. Just `npm install apollo-server-`, write a GraphQL schema, and then use one of the following snippets to get started. For more info, read the [Apollo Server docs](https://www.apollographql.com/docs/apollo-server/). To experiment a live example of Apollo Server, create an [Apollo Launchpad](https://launchpad.graphql.com). Downloading the pad will provide you a local Apollo Server project. +Apollo Server is super easy to set up. Just `npm install apollo-server-`, write a GraphQL schema, and then use one of the following snippets to get started. For more info, read the [Apollo Server docs](https://www.apollographql.com/docs/apollo-server/v2). ### Installation -Just run `npm install --save apollo-server-` and you're good to go! +Run `npm install --save apollo-server-` and you're good to go! -where `` is one of the following: +```js +const { ApolloServer, gql } = require('apollo-server'); -* `express` -* `koa` -* `fastify` -* `hapi` -* `restify` -* `lambda` -* `micro` -* `azure-functions` -* `adonis` +// The GraphQL schema +const typeDefs = gql` + type Query { + "A simple type for getting started!" + hello: String + } +`; -### Express +// A map of functions which return data for the schema. +const resolvers = { + Query: { + hello: () => 'world', + }, +}; -```js -import express from 'express'; -import bodyParser from 'body-parser'; -import { graphqlExpress, graphiqlExpress } from 'apollo-server-express'; +const server = new ApolloServer({ + typeDefs, + resolvers, +}); +``` -const myGraphQLSchema = // ... define or import your schema here! -const PORT = 3000; +## Integrations -const app = express(); +Often times, Apollo Server needs to be run with a particular integration. To start, run `npm install --save apollo-server-` where `` is one of the following: -// bodyParser is needed just for POST. -app.use('/graphql', bodyParser.json(), graphqlExpress({ schema: myGraphQLSchema })); -app.get('/graphiql', graphiqlExpress({ endpointURL: '/graphql' })); // if you want GraphiQL enabled +- `express` +- `hapi` +- `lambda` +- `cloudflare` -app.listen(PORT); -``` +If a framework is not on this list and it should be supported, please open a PR. -### Connect +### Express ```js -import connect from 'connect'; -import bodyParser from 'body-parser'; -import query from 'connect-query'; -import { graphqlConnect } from 'apollo-server-express'; -import http from 'http'; +const express = require('express'); +const { ApolloServer, gql } = require('apollo-server-express'); -const PORT = 3000; +// Construct a schema, using GraphQL schema language +const typeDefs = gql` + type Query { + hello: String + } +`; -const app = connect(); +// Provide resolver functions for your schema fields +const resolvers = { + Query: { + hello: () => 'Hello world!', + }, +}; -// bodyParser is only needed for POST. -app.use('/graphql', bodyParser.json()); -// query is only needed for GET. -app.use('/graphql', query()); -app.use('/graphql', graphqlConnect({ schema: myGraphQLSchema })); +const server = new ApolloServer({ typeDefs, resolvers }); -http.createServer(app).listen(PORT); +const app = express(); +server.applyMiddleware({ app }); + +app.listen({ port: 4000 }, () => + console.log(`🚀 Server ready at http://localhost:4000${server.graphqlPath}`), +); ``` -### Fastify +### Connect -```javascript -import fastify from 'fastify'; -import jsonParser from 'fast-json-body'; -import { graphqlFastify } from 'apollo-server-fastify'; +```js +const connect = require('connect'); +const { ApolloServer, gql } = require('apollo-server-express'); +const query = require('qs-middleware'); + +// Construct a schema, using GraphQL schema language +const typeDefs = gql` + type Query { + hello: String + } +`; -const myGraphQLSchema = // ... define or import your schema here! -const PORT = 3000; +// Provide resolver functions for your schema fields +const resolvers = { + Query: { + hello: () => 'Hello world!', + }, +}; -const app = fastify(); +const server = new ApolloServer({ typeDefs, resolvers }); -// jsonParser is needed for POST. -app.addContentTypeParser('application/json', function(req, done) { - jsonParser(req, function(err, body) { - done(err, body); - }); -}); -app.register(graphqlFastify, { schema: myGraphQLSchema }); +const app = connect(); +const path = '/graphql'; -try { - await app.listen(3007); -} catch (err) { - app.log.error(err); - process.exit(1); -} +server.use(query()); +server.applyMiddleware({ app, path }); + +app.listen({ port: 4000 }, () => + console.log(`🚀 Server ready at http://localhost:4000${server.graphqlPath}`), +); ``` -### Hapi +> Note; `qs-middleware` is only required if running outside of Meteor -Now with the Hapi plugins `graphqlHapi` and `graphiqlHapi` you can pass a route object that includes options to be applied to the route. The example below enables CORS on the `/graphql` route. +### Hapi The code below requires Hapi 17 or higher. ```js -import Hapi from 'hapi'; -import { graphqlHapi } from 'apollo-server-hapi'; - -const HOST = 'localhost'; -const PORT = 3000; +const { ApolloServer, gql } = require('apollo-server-hapi'); +const Hapi = require('hapi'); async function StartServer() { - const server = new Hapi.server({ - host: HOST, - port: PORT, + const server = new ApolloServer({ typeDefs, resolvers }); + + const app = new Hapi.server({ + port: 4000, }); - await server.register({ - plugin: graphqlHapi, - options: { - path: '/graphql', - graphqlOptions: { - schema: myGraphQLSchema, - }, - route: { - cors: true, - }, - }, + await server.applyMiddleware({ + app, }); - try { - await server.start(); - } catch (err) { - console.log(`Error while starting server: ${err.message}`); - } + await server.installSubscriptionHandlers(app.listener); - console.log(`Server running at: ${server.info.uri}`); + await app.start(); } -StartServer(); +StartServer().catch(error => console.log(error)); ``` -### Koa +### Context -```js -import koa from 'koa'; -import koaRouter from 'koa-router'; -import koaBody from 'koa-bodyparser'; -import { graphqlKoa, graphiqlKoa } from 'apollo-server-koa'; - -const app = new koa(); -const router = new koaRouter(); -const PORT = 3000; - -// koaBody is needed just for POST. -router.post('/graphql', koaBody(), graphqlKoa({ schema: myGraphQLSchema })); -router.get('/graphql', graphqlKoa({ schema: myGraphQLSchema })); - -router.get('/graphiql', graphiqlKoa({ endpointURL: '/graphql' })); - -app.use(router.routes()); -app.use(router.allowedMethods()); -app.listen(PORT); -``` - -### Restify +The context is created for each request. The following code snippet shows the creation of a context. The arguments are the `request`, the request, and `h`, the response toolkit. ```js -import restify from 'restify'; -import { graphqlRestify, graphiqlRestify } from 'apollo-server-restify'; - -const PORT = 3000; - -const server = restify.createServer({ - title: 'Apollo Server', -}); - -const graphQLOptions = { schema: myGraphQLSchema }; - -server.use(restify.plugins.bodyParser()); -server.use(restify.plugins.queryParser()); - -server.post('/graphql', graphqlRestify(graphQLOptions)); -server.get('/graphql', graphqlRestify(graphQLOptions)); - -server.get('/graphiql', graphiqlRestify({ endpointURL: '/graphql' })); - -server.listen(PORT, () => console.log(`Listening on ${PORT}`)); +new ApolloServer({ + typeDefs, + resolvers, + context: async ({ request, h }) => { + return { ... }; + }, +}) ``` ### AWS Lambda -Lambda function should be run with [Node.js 4.3 or v6.1](https://docs.aws.amazon.com/lambda/latest/dg/nodejs-prog-model-using-old-runtime.html#nodejs-prog-model-runtime-support-policy). Requires an API Gateway with Lambda Proxy Integration. - -```js -var server = require('apollo-server-lambda'); - -exports.handler = server.graphqlLambda({ schema: myGraphQLSchema }); -``` - -### ZEIT Micro - -Requires the [Micro](https://github.com/zeit/micro) module - -```js -const server = require('apollo-server-micro'); - -module.exports = server.microGraphql({ schema: myGraphQLSchema }); -``` - -### Adonis Framework +Apollo Server can be run on Lambda and deployed with AWS Serverless Application Model (SAM). It requires an API Gateway with Lambda Proxy Integration. ```js -// start/routes.js -const { graphqlAdonis } = require('apollo-server-adonis'); - -const Route = use('Route'); - -Route.post('/graphql', graphqlAdonis({ schema: myGraphQLSchema })); -Route.get('/graphql', graphqlAdonis({ schema: myGraphQLSchema })); -``` - -## Options - -Apollo Server can be configured with an options object with the following fields: - -* **schema**: the GraphQLSchema to be used -* **context**: the context value passed to resolvers during GraphQL execution -* **rootValue**: the value passed to the first resolve function -* **formatError**: a function to apply to every error before sending the response to clients -* **skipValidation**: skip query validation (increase performance, use carefully, only with whitelisting) -* **validationRules**: additional GraphQL validation rules to be applied to client-specified queries -* **formatParams**: a function applied for each query in a batch to format parameters before execution -* **formatResponse**: a function applied to each response after execution -* **tracing**: when set to true, collect and expose trace data in the [Apollo Tracing format](https://github.com/apollographql/apollo-tracing) -* **logFunction**: a function called for logging events such as execution times -* **fieldResolver**: a custom default field resolver -* **debug**: a boolean that will print additional debug logging if execution errors occur -* **cacheControl**: when set to true, enable built-in support for Apollo Cache Control - -All options except for `schema` are optional. +const { ApolloServer, gql } = require('apollo-server-lambda'); -### Whitelisting - -The `formatParams` function can be used in combination with the `OperationStore` to enable whitelisting. -In this case query parsing and validation will be called only once when saving to store. +// Construct a schema, using GraphQL schema language +const typeDefs = gql` + type Query { + hello: String + } +`; -```js -const store = new OperationStore(Schema); -store.put('query testquery{ testString }'); -graphqlOptions = { - schema: Schema, - formatParams(params) { - params['query'] = store.get(params.operationName); - params['skipValidation'] = true; - return params; +// Provide resolver functions for your schema fields +const resolvers = { + Query: { + hello: () => 'Hello world!', }, }; -``` - -## Comparison with `express-graphql` -Both Apollo Server and [`express-graphql`](https://github.com/graphql/express-graphql) are GraphQL servers for Node.js, built on top of the [`graphql-js` reference implementation](https://github.com/graphql/graphql-js), but there are a few key differences: +const server = new ApolloServer({ typeDefs, resolvers }); -* `express-graphql` works with Express and Connect, Apollo Server supports Express, Connect, Hapi, Koa and Restify. -* Compared to `express-graphql`, Apollo Server has a simpler interface and supports exactly one way of passing queries. -* Apollo Server separates serving [GraphiQL](https://github.com/graphql/graphiql) (an in-browser IDE for exploring GraphQL) from responding to GraphQL requests. -* `express-graphql` contains code for parsing HTTP request bodies, Apollo Server leaves that to standard packages like body-parser. -* Apollo Server includes an `OperationStore` to easily manage whitelisting. -* Apollo Server is built with TypeScript. - -### application/graphql requests - -`express-graphql` supports the `application/graphql` Content-Type for requests, which is an alternative to `application/json` request with only the query part sent as text. In the same way that we use `bodyParser.json` to parse `application/json` requests for apollo-server, we can use `bodyParser.text` plus one extra step in order to also parse `application/graphql` requests. Here's an example for Express: - -```js -import express from 'express'; -import bodyParser from 'body-parser'; -import { graphqlExpress } from 'apollo-server-express'; - -const myGraphQLSchema = // ... define or import your schema here! - -const helperMiddleware = [ - bodyParser.json(), - bodyParser.text({ type: 'application/graphql' }), - (req, res, next) => { - if (req.is('application/graphql')) { - req.body = { query: req.body }; - } - next(); - } -]; - -express() - .use('/graphql', ...helperMiddleware, graphqlExpress({ schema: myGraphQLSchema })) -   .listen(3000); +exports.graphqlHandler = server.createHandler(); ``` ## Apollo Server Development If you want to develop Apollo Server locally you must follow the following instructions: -* Fork this repository +- Fork this repository -* Install the Apollo Server project in your computer +- Install the Apollo Server project in your computer ``` git clone https://github.com/[your-user]/apollo-server cd apollo-server npm install -cd packages/apollo-server-/ +cd packages/apollo-server-/ npm link ``` -* Install your local Apollo Server in other App +- Install your local Apollo Server in other App ``` cd ~/myApp -npm link apollo-server- +npm link apollo-server- ``` diff --git a/__mocks__/apollo-server-env.ts b/__mocks__/apollo-server-env.ts new file mode 100644 index 00000000000..bb42272b23a --- /dev/null +++ b/__mocks__/apollo-server-env.ts @@ -0,0 +1,61 @@ +/// + +import { + fetch, + Request, + RequestInit, + Response, + Body, + BodyInit, + Headers, + HeadersInit, + URL, + URLSearchParams, + URLSearchParamsInit, +} from '../packages/apollo-server-env'; + +interface FetchMock extends jest.Mock { + mockResponseOnce(data?: any, headers?: HeadersInit, status?: number); + mockJSONResponseOnce(data?: object, headers?: HeadersInit); +} + +const mockFetch = jest.fn() as FetchMock; + +mockFetch.mockResponseOnce = ( + data?: BodyInit, + headers?: Headers, + status: number = 200, +) => { + return mockFetch.mockImplementationOnce(async () => { + return new Response(data, { + status, + headers, + }); + }); +}; + +mockFetch.mockJSONResponseOnce = ( + data = {}, + headers?: Headers, + status?: number, +) => { + return mockFetch.mockResponseOnce( + JSON.stringify(data), + Object.assign({ 'Content-Type': 'application/json' }, headers), + status, + ); +}; + +export { + mockFetch as fetch, + Request, + RequestInit, + Response, + Body, + BodyInit, + Headers, + HeadersInit, + URL, + URLSearchParams, + URLSearchParamsInit, +}; diff --git a/__mocks__/date.ts b/__mocks__/date.ts new file mode 100644 index 00000000000..0498f880916 --- /dev/null +++ b/__mocks__/date.ts @@ -0,0 +1,32 @@ +const RealDate = global.Date; + +export function mockDate() { + global.Date = new Proxy(RealDate, handler); +} + +export function unmockDate() { + global.Date = RealDate; +} + +let now = Date.now(); + +export function advanceTimeBy(ms: number) { + now += ms; +} + +const handler: ProxyHandler = { + construct(target, args) { + if (args.length === 0) { + return new Date(now); + } else { + return new target(...args); + } + }, + get(target, propKey) { + if (propKey === 'now') { + return () => now; + } else { + return target[propKey]; + } + }, +}; diff --git a/docs/_config.yml b/docs/_config.yml index 3ef3b8513e7..9cccbbf5444 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -2,38 +2,57 @@ title: Apollo Server propertytitle: Using Apollo Server subtitle: Apollo Server description: A guide to using Apollo Server. +version: 2 versions: - '1' - '2' sidebar_categories: null: - index - - example - - setup - - requests - - graphiql - Servers: - - servers/express - - servers/hapi - - servers/koa - - servers/lambda - - servers/micro - - servers/restify - - servers/azure-functions - - servers/adonis - Related: - - title: Monitoring and caching - href: https://www.apollographql.com/docs/engine/setup-node.html - - title: graphql-tools - href: https://www.apollographql.com/docs/graphql-tools/ - - title: GraphQL Subscriptions - href: https://www.apollographql.com/docs/graphql-subscriptions/ - - title: Production deployment - href: https://dev-blog.apollodata.com/graphql-over-rest-with-node-heroku-and-apollo-engine-fb8581f8d77f + - getting-started + - whats-new + Essentials: + - essentials/schema + - essentials/server + - essentials/data + Features: + - features/mocking + - features/errors + - features/data-sources + - features/subscriptions + - features/metrics + - features/graphql-playground + - features/scalars-enums + - features/unions-interfaces + - features/directives + - features/creating-directives + # Schema stitching: + # - features/schema-stitching + # - features/remote-schemas + # - features/schema-delegation + # - features/schema-transforms + Deployment: + # - deployment/index + - deployment/heroku + - deployment/lambda + - deployment/now + Related Guides: + - title: Monitoring + href: https://www.apollographql.com/docs/guides/monitoring.html + - title: Versioning + href: https://www.apollographql.com/docs/guides/versioning.html + - title: Access Control + href: https://www.apollographql.com/docs/guides/access-control.html + - title: Security + href: https://www.apollographql.com/docs/guides/security.html + API Reference: + - api/apollo-server + - title: graphql-subscriptions + href: https://www.apollographql.com/docs/graphql-subscriptions + - api/graphql-tools Migration: - - migration-one-dot - - migration-hapi - - migration + - migration-two-dot + - migration-engine github_repo: apollographql/apollo-server content_root: docs/source @@ -46,5 +65,13 @@ root: /docs/apollo-server/ public_dir: public/docs/apollo-server +redirects: + /docs/apollo-server/v2/features/cdn.html: + docs/guides/performance + /docs/apollo-server/v2/features/apq.html: + docs/guides/performance + /docs/apollo-server/v2/features/file-uploads.html: + docs/guides/file-uploads + versioned-netlify-redirects: netlify_site_id: apollo-server-docs diff --git a/docs/sketch/APQs.sketch b/docs/sketch/APQs.sketch new file mode 100644 index 00000000000..0a30d5f449c Binary files /dev/null and b/docs/sketch/APQs.sketch differ diff --git a/docs/source/api/apollo-server.md b/docs/source/api/apollo-server.md new file mode 100644 index 00000000000..fbcc728fffe --- /dev/null +++ b/docs/source/api/apollo-server.md @@ -0,0 +1,326 @@ +--- +title: "API Reference: apollo-server" +sidebar_title: apollo-server +--- + +This API reference documents the exports from the `apollo-server`. + +## `ApolloServer` + +The core of an Apollo Server implementation. For an example, see the [Building a server](../essentials/server.html) section within "Essentials". + +### `constructor(options)`: <`ApolloServer`> + +#### Parameters + +* `options`: <`Object`> + + * `typeDefs`: <`String` inside [`gql`](#gql) tag> _(required)_ + + String representation of GraphQL schema in the Schema Definition Language (SDL). + + * `resolvers`: <`Object`> _(required)_ + + A map of resolvers for the types defined in `typeDefs`. The key should be the type name and the value should be a `Function` to be executed for that type. + + * `context`: <`Object`> | <`Function`> + + An object or function called with the current request that creates the context shared across all resolvers + +```js +new ApolloServer({ + typeDefs, + resolvers, + context: ({ req }) => ({ + authScope: getScope(req.headers.authorization) + }), +}); +``` + +* `mocks`: <`Object`> | <`Boolean`> + + A boolean enabling the default mocks or object that contains definitions + +* `schemaDirectives`: <`Object`> + + Contains definition of schema directives used in the `typeDefs` + +* `introspection`: <`Boolean`> + + Enables and disables schema introspection + +* `playground`: <`Boolean`> | <`Object`> + + Enables and disables playground and allows configuration of GraphQL Playground. The options can be found on GraphQL Playground's [documentation](https://github.com/prismagraphql/graphql-playground/#usage) + +* `debug`: <`Boolean`> + + Enables and disables development mode helpers. Defaults to `true` + +* `validationRules`: <`Object`> + + Schema validation rules + +* `tracing`, `cacheControl`: <`Boolean`> + + Add tracing or cacheControl meta data to the GraphQL response + +* `formatError`, `formatResponse`: <`Function`> + + Functions to format the errors and response returned from the server, as well as the parameters to graphql execution(`runQuery`) + +* `schema`: <`Object`> + + An executable GraphQL schema that will override the `typeDefs` and `resolvers` provided. If you are using [file uploads](https://www.apollographql.com/docs/guides/file-uploads.html), you will have to add the `Upload` scalar to the schema, as it is not automatically added in case of setting the `schema` manually. + +* `subscriptions`: <`Object`> | <`String`> | false + + String defining the path for subscriptions or an Object to customize the subscriptions server. Set to false to disable subscriptions + + * `path`: <`String`> + * `keepAlive`: <`Number`> + * `onConnect`: <`Function`> + * `onDisconnect`: <`Function`> + +* `engine`: <`EngineReportingOptions`> | boolean + + Provided the `ENGINE_API_KEY` environment variable is set, the engine reporting agent will be started automatically. The API key can also be provided as the `apiKey` field in an object passed as the `engine` field. See the [EngineReportingOptions](#EngineReportingOptions) section for a full description of how to configure the reporting agent, including how to blacklist variables. When using the Engine proxy, this option should be set to false. + +* `persistedQueries`: <`Object`> | false + + The persisted queries option can be set to an object containing a `cache` field, which will be used to store the mapping between hash and query string. + +* `cors`: <`Object` | `boolean`> ([apollo-server](https://github.com/expressjs/cors#cors)) + + Pass the integration-specific CORS options. `false` removes the CORS middleware and `true` uses the defaults. This option is only available to `apollo-server`. For other server integrations, place `cors` inside of `applyMiddleware`. + +#### Returns + +`ApolloServer` + +### `ApolloServer.listen(options)`: `Promise` + +#### Parameters + +In `apollo-server`, the listen call starts the subscription server and passes the arguments directly to an http server Node.js' [`net.Server.listen`](https://nodejs.org/api/net.html#net_server_listen) method are supported. + +#### Returns + +`Promise` that resolves to an object that contains: + + * `url`: <`String`> + * `subscriptionsPath`: <`String`> + * `server`: <[`http.Server`](https://nodejs.org/api/http.html#http_class_http_server)> + +## ApolloServer.applyMiddleware + +The `applyMiddleware` method is provided by the `apollo-server-{integration}` packages that use middleware, such as hapi and express. This function connects ApolloServer to a specific framework. + +### Parameters + +* `options`: <`Object`> + + * `app`: <`HttpServer`> _(required)_ + + Pass an instance of the server integration here. + + * `server`: <`ApolloServer`> _(required)_ + + Pass the instance of Apollo Server + + * `path` : <`String`> + + Specify a custom path. It defaults to `/graphql` if no path is specified. + + * `cors`: <`Object` | `boolean`> ([express](https://github.com/expressjs/cors#cors), [hapi](https://hapijs.com/api#-routeoptionscors)) + + Pass the integration-specific cors options. False removes the cors middleware and true uses the defaults. + + * `bodyParser`: <`Object` | `boolean`> ([express](https://github.com/expressjs/body-parser#body-parser)) + + Pass the body-parser options. False removes the body parser middleware and true uses the defaults. + +### Usage + +The `applyMiddleware` method from `apollo-server-express` registration of middleware as shown in the example below: + +```js +const { ApolloServer } = require('apollo-server-express'); +const { typeDefs, resolvers } = require('./schema'); + +const server = new ApolloServer({ + // These will be defined for both new or existing servers + typeDefs, + resolvers, +}); + +// Additional middleware can be mounted at this point to run before Apollo. +app.use('*', jwtCheck, requireAuth, checkScope); + +server.applyMiddleware({ app, path: '/specialUrl' }); // app is from an existing express app. Mount Apollo middleware here. If no path is specified, it defaults to `/graphql`. +``` + +## `gql` + +The `gql` is a [template literal tag](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals#Tagged_templates). Template literals were introduced in recent versions of ECMAScript to provide embedded expressions (i.e. `` `A string with interpolated ${variables}` ``) and template literal tags exist to provide additional functionality for what would otherwise be a normal template literal. + +In the case of GraphQL, the `gql` tag is used to surround GraphQL operation and schema language (which are represented as `String`s), and makes it easier to differentiate from ordinary strings. This is particularly useful when performing static analysis on GraphQL language (e.g. to enable syntax highlighting, code generation, etc.) and avoids need for tools to "guess" if a string contains GraphQL language. + +### Usage + +Import the `gql` template literal tag into the current context from the `apollo-server` or `apollo-server-{integration}` modules: + +```js +const { gql } = require('apollo-server'); +``` + +Then, place GraphQL schema definitions (SDL), queries or other operations into the `gql` template literal tag. Keep in mind that template literals use the grave accent (`` ` ``) and not normal quotation marks (e.g. not `"` or `'`): + +```js +const typeDefs = gql` + type Author { + name + } +`; +``` + +## `makeExecutableSchema` + +The `makeExecutableSchema` method is re-exported from apollo-server as a convenience. + +### Parameters + +* `options` : <`Object`> + * `typeDefs`: <`GraphQLSchema`> _(required)_ + * `resolvers` : <`Object`> + * `logger` : <`Object`> + * `allowUndefinedInResolve` = false + * `resolverValidationOptions` = {} + * `directiveResolvers` = null + * `schemaDirectives` = null + * `parseOptions` = {} + * `inheritResolversFromInterfaces` = false + +## `addMockFunctionsToSchema(options)` + +The `addMockFunctionsToSchema` method is re-exported from `apollo-server` as a convenience. + +Given an instance of a `GraphQLSchema` and a `mock` object, modifies the schema (in place) to return mock data for any valid query that is sent to the server. + +If preserveResolvers is set to true, existing resolve functions will not be overwritten to provide mock data. This can be used to mock some parts of the server and not others. + +### Parameters + +* `options`: <`Object`> + + * `schema`: <`GraphQLSchema`> _(required)_ + + Pass an executable schema (`GraphQLSchema`) to be mocked. + + * `mocks`: <`Object`> + + Should be a map of types to mock resolver functions, e.g.: + + ```js + { + Type: () => true, + } + ``` + + When `mocks` is not defined, the default scalar types (e.g. `Int`, `Float`, `String`, etc.) will be mocked. + + * `preserveResolvers`: <`Boolean`> + + When `true`, resolvers which were already defined will not be over-written with the mock resolver functions specified with `mocks`. + +### Usage + +```js +const { addMockFunctionsToSchema } = require('apollo-server'); + +// We'll make an assumption that an executable schema +// is already available from the `./schema` file. +const executableSchema = require('./schema'); + +addMockFunctionsToSchema({ + schema: executableSchema, + mocks: { + // Mocks the `Int` scalar type to always return `12345`. + Int: () => 12345, + + // Mocks the `Movies` type to always return 'Titanic'. + Movies: () => 'Titanic', + }, + preserveResolvers: false, +}); +``` + +## `EngineReportingOptions` + +* `apiKey`: string __(required)__ + + API key for the service. Get this from + [Engine](https://engine.apollographql.com) by logging in and creating + a service. You may also specify this with the `ENGINE_API_KEY` + environment variable the option takes precedence. + +* `calculateSignature`: (ast: DocumentNode, operationName: string) => string + + Specify the function for creating a signature for a query. See signature.ts + for details. + +* `reportIntervalMs`: number + + How often to send reports to the Engine server. We'll also send reports + when the report gets big see maxUncompressedReportSize. + +* `maxUncompressedReportSize`: number + + We send a report when the report size will become bigger than this size in + bytes (default: 4MB). (This is a rough limit --- we ignore the size of the + report header and some other top level bytes. We just add up the lengths of + the serialized traces and signatures.) + +* `endpointUrl`: string + + The URL of the Engine report ingress server. + +* `debugPrintReports`: boolean + + If set, prints all reports as JSON when they are sent. + +* `maxAttempts`: number + + Reporting is retried with exponential backoff up to this many times + (including the original request). Defaults to 5. + +* `minimumRetryDelayMs`: number + + Minimum backoff for retries. Defaults to 100ms. + +* `reportErrorFunction`: (err: Error) => void + + By default, errors sending reports to Engine servers will be logged + to standard error. Specify this function to process errors in a different + way. + +* `privateVariables`: Array | boolean + + A case-sensitive list of names of variables whose values should not be sent + to Apollo servers, or 'true' to leave out all variables. In the former + case, the report will indicate that each private variable was redacted in + the latter case, no variables are sent at all. + +* `privateHeaders`: Array | boolean + + A case-insensitive list of names of HTTP headers whose values should not be + sent to Apollo servers, or 'true' to leave out all HTTP headers. Unlike + with privateVariables, names of dropped headers are not reported. + +* `handleSignals`: boolean + + By default, EngineReportingAgent listens for the 'SIGINT' and 'SIGTERM' + signals, stops, sends a final report, and re-sends the signal to + itself. Set this to false to disable. You can manually invoke 'stop()' and + 'sendReport()' on other signals if you'd like. Note that 'sendReport()' + does not run synchronously so it cannot work usefully in an 'exit' handler. diff --git a/docs/source/api/graphql-subscriptions.md b/docs/source/api/graphql-subscriptions.md new file mode 100644 index 00000000000..f0281d8e209 --- /dev/null +++ b/docs/source/api/graphql-subscriptions.md @@ -0,0 +1,4 @@ +--- +title: graphql-subscriptions +description: Bringing real-time to your schema +--- diff --git a/docs/source/api/graphql-tools.md b/docs/source/api/graphql-tools.md new file mode 100644 index 00000000000..bce09496a53 --- /dev/null +++ b/docs/source/api/graphql-tools.md @@ -0,0 +1,504 @@ +--- +title: "API Reference: graphql-tools" +sidebar_title: graphql-tools +--- + +The graphql-tools library enables the creation and manipulation of GraphQL schema. Apollo Server is able to accept a `schema` that has been enabled by `graphql-tools`. Apollo server directly exports all the function from `graphql-tools`, enabling a migration path for more complicated use cases. + +```js +const { makeExecutableSchema } = require('apollo-server'); + +const typeDefs = gql` + type Query { + hello: String + } +`; + +const resolvers = { + Query: { + hello: () => 'Hello world!' + }, +}; + +const schema = makeExecutableSchema({ + typeDefs, + resolvers, +}); + +const rootResolveFunction = (root, args, context, info) => { + //perform action before any other resolvers +}; + +addSchemaLevelResolveFunction(schema, rootResolveFunction) + +const server = new ApolloServer({ schema }); + +// normal ApolloServer listen call but url will contain /graphql +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`) +}); +``` + +

makeExecutableSchema(options)

+ +`makeExecutableSchema` takes a single argument: an object of options. Only the `typeDefs` option is required. + +``` +const { makeExecutableSchema } = require('apollo-server'); + +const jsSchema = makeExecutableSchema({ + typeDefs, + resolvers, // optional + logger, // optional + allowUndefinedInResolve = false, // optional + resolverValidationOptions = {}, // optional + directiveResolvers = null, // optional + schemaDirectives = null, // optional + parseOptions = {}, // optional + inheritResolversFromInterfaces = false // optional +}); +``` + +- `typeDefs` is a required argument and should be an GraphQL schema language string or array of GraphQL schema language strings or a function that takes no arguments and returns an array of GraphQL schema language strings. The order of the strings in the array is not important, but it must include a schema definition. + +- `resolvers` is an optional argument _(empty object by default)_ and should be an object that follows the pattern explained in [documentation on resolvers](../features/resolvers.html). + +- `logger` is an optional argument, which can be used to print errors to the server console that are usually swallowed by GraphQL. The `logger` argument should be an object with a `log` function, eg. `const logger = { log: e => console.log(e) }` + +- `parseOptions` is an optional argument which allows customization of parse when specifying `typeDefs` as a string. + +- `allowUndefinedInResolve` is an optional argument, which is `true` by default. When set to `false`, causes your resolve functions to throw errors if they return undefined, which can help make debugging easier. + +- `resolverValidationOptions` is an optional argument which accepts an `ResolverValidationOptions` object which has the following boolean properties: + - `requireResolversForArgs` will cause `makeExecutableSchema` to throw an error if no resolve function is defined for a field that has arguments. + + - `requireResolversForNonScalar` will cause `makeExecutableSchema` to throw an error if a non-scalar field has no resolver defined. Setting this to `true` can be helpful in catching errors, but defaults to `false` to avoid confusing behavior for those coming from other GraphQL libraries. + + - `requireResolversForAllFields` asserts that *all* fields have a valid resolve function. + + - `requireResolversForResolveType` will require a `resolveType()` method for Interface and Union types. This can be passed in with the field resolvers as `__resolveType()`. False to disable the warning. + + - `allowResolversNotInSchema` turns off the functionality which throws errors when resolvers are found which are not present in the schema. Defaults to `false`, to help catch common errors. + +- `inheritResolversFromInterfaces` GraphQL Objects that implement interfaces will inherit missing resolvers from their interface types defined in the `resolvers` object. + +

addMockFunctionToSchema(options)

+ +```js +const { addMockFunctionsToSchema } = require('apollo-server'); + +addMockFunctionsToSchema({ + schema, + mocks: {}, + preserveResolvers: false, +}); +``` + +Given an instance of GraphQLSchema and a mock object, `addMockFunctionsToSchema` modifies the schema in place to return mock data for any valid query that is sent to the server. If `mocks` is not passed, the defaults will be used for each of the scalar types. If `preserveResolvers` is set to `true`, existing resolve functions will not be overwritten to provide mock data. This can be used to mock some parts of the server and not others. + +

MockList(list, mockFunction)

+ +```js +const { MockList } = require('apollo-server'); + +new MockList(length: number | number[], mockFunction: Function); +``` + +This is an object you can return from your mock resolvers which calls the `mockFunction` once for each list item. The first argument can either be an exact length, or an inclusive range of possible lengths for the list, in case you want to see how your UI responds to varying lists of data. + +

+ addResolveFunctionsToSchema({ schema, resolvers, resolverValidationOptions?, inheritResolversFromInterfaces? }) +

+ +`addResolveFunctionsToSchema` takes an options object of `IAddResolveFunctionsToSchemaOptions` and modifies the schema in place by attaching the resolvers to the relevant types. + + +```js +const { addResolveFunctionsToSchema } = require('apollo-server'); + +const resolvers = { + RootQuery: { + author(obj, { name }, context){ + console.log("RootQuery called with context " + + context + " to find " + name); + return Author.find({ name }); + }, + }, +}; + +addResolveFunctionsToSchema({ schema, resolvers }); +``` + +The `IAddResolveFunctionsToSchemaOptions` object has 4 properties that are described in [`makeExecutableSchema`](/docs/graphql-tools/generate-schema.html#makeExecutableSchema). +```ts +export interface IAddResolveFunctionsToSchemaOptions { + schema: GraphQLSchema; + resolvers: IResolvers; + resolverValidationOptions?: IResolverValidationOptions; + inheritResolversFromInterfaces?: boolean; +} +``` + +

+ addSchemaLevelResolveFunction(schema, rootResolveFunction) +

+ +Some operations, such as authentication, need to be done only once per query. Logically, these operations belong in an obj resolve function, but unfortunately GraphQL-JS does not let you define one. `addSchemaLevelResolveFunction` solves this by modifying the GraphQLSchema that is passed as the first argument. + + +

delegateToSchema

+ +The `delegateToSchema` method can be found on the `info.mergeInfo` object within any resolver function, and should be called with the following named options: + +``` +delegateToSchema(options: { + schema: GraphQLSchema; + operation: 'query' | 'mutation' | 'subscription'; + fieldName: string; + args?: { [key: string]: any }; + context: { [key: string]: any }; + info: GraphQLResolveInfo; + transforms?: Array; +}): Promise +``` + +### schema: GraphQLSchema + +A subschema to delegate to. + +### operation: 'query' | 'mutation' | 'subscription' + +The operation type to use during the delegation. + +### fieldName: string + +A root field in a subschema from which the query should start. + +### args: { [key: string]: any } + +Additional arguments to be passed to the field. Arguments passed to the field that is being resolved will be preserved if the subschema expects them, so you don't have to pass existing arguments explicitly, though you could use the additional arguments to override the existing ones. For example: + +```graphql +# Subschema + +type Booking { + id: ID! +} + +type Query { + bookingsByUser(userId: ID!, limit: Int): [Booking] +} + +# Schema + +type User { + id: ID! + bookings(limit: Int): [Booking] +} + +type Booking { + id: ID! +} +``` + +If we delegate at `User.bookings` to `Query.bookingsByUser`, we want to preserve the `limit` argument and add an `userId` argument by using the `User.id`. So the resolver would look like the following: + +```js +const resolvers = { + User: { + bookings(parent, args, context, info) { + return info.mergeInfo.delegateToSchema({ + schema: subschema, + operation: 'query', + fieldName: 'bookingsByUser', + args: { + userId: parent.id, + }, + context, + info, + }); + }, + ... + }, + ... +}; +``` + +### context: { [key: string]: any } + +GraphQL context that is going to be past to subschema execution or subsciption call. + +### info: GraphQLResolveInfo + +GraphQL resolve info of the current resolver. Provides access to the subquery that starts at the current resolver. + +Also provides the `info.mergeInfo.delegateToSchema` function discussed above. + +### transforms: Array + +[Transforms](../features/schema-transforms.html) to apply to the query and results. Should be the same transforms that were used to transform the schema, if any. After transformation, `transformedSchema.transforms` contains the transforms that were applied. + +

Additional considerations - Aliases

+ +Delegation preserves aliases that are passed from the parent query. However that presents problems, because default GraphQL resolvers retrieve field from parent based on their name, not aliases. This way results with aliases will be missing from the delegated result. `mergeSchemas` and `transformSchemas` go around that by using `src/stitching/defaultMergedResolver` for all fields without explicit resolver. When building new libraries around delegation, one should consider how the aliases will be handled. + + +

mergeSchemas

+ +```ts +mergeSchemas({ + schemas: Array>; + resolvers?: Array | IResolvers; + onTypeConflict?: ( + left: GraphQLNamedType, + right: GraphQLNamedType, + info?: { + left: { + schema?: GraphQLSchema; + }; + right: { + schema?: GraphQLSchema; + }; + }, + ) => GraphQLNamedType; +}) +``` + +This is the main function that implements schema stitching. Read below for a description of each option. + +### schemas + +`schemas` is an array of `GraphQLSchema` objects, schema strings, or lists of `GraphQLNamedType`s. Strings can contain type extensions or GraphQL types, which will be added to resulting schema. Note that type extensions are always applied last, while types are defined in the order in which they are provided. + +### resolvers + +`resolvers` accepts resolvers in same format as [makeExecutableSchema](../features/resolvers.html). It can also take an Array of resolvers. One addition to the resolver format is the possibility to specify a `fragment` for a resolver. The `fragment` must be a GraphQL fragment definition string, specifying which fields from the parent schema are required for the resolver to function properly. + +```js +resolvers: { + Booking: { + property: { + fragment: 'fragment BookingFragment on Booking { propertyId }', + resolve(parent, args, context, info) { + return mergeInfo.delegateToSchema({ + schema: bookingSchema, + operation: 'query', + fieldName: 'propertyById', + args: { + id: parent.propertyId, + }, + context, + info, + }); + }, + }, + }, +} +``` + +### mergeInfo and delegateToSchema + +The `info.mergeInfo` object provides the `delegateToSchema` method: + +```js +type MergeInfo = { + delegateToSchema(options: IDelegateToSchemaOptions): any; +} + +interface IDelegateToSchemaOptions { + schema: GraphQLSchema; + operation: Operation; + fieldName: string; + args?: { + [key: string]: any; + }; + context: TContext; + info: GraphQLResolveInfo; + transforms?: Array; +} +``` + +As described in the documentation above, `info.mergeInfo.delegateToSchema` allows delegating to any `GraphQLSchema` object, optionally applying transforms in the process. See [Schema Delegation](../features/schema-delegation.html) and the [*Using with transforms*](#using-with-transforms) section of this document. + +### onTypeConflict + +```js +type OnTypeConflict = ( + left: GraphQLNamedType, + right: GraphQLNamedType, + info?: { + left: { + schema?: GraphQLSchema; + }; + right: { + schema?: GraphQLSchema; + }; + }, +) => GraphQLNamedType; +``` + +The `onTypeConflict` option to `mergeSchemas` allows customization of type resolving logic. + +The default behavior of `mergeSchemas` is to take the first encountered type of all the types with the same name. If there are conflicts, `onTypeConflict` enables explicit selection of the winning type. + +For example, here's how we could select the last type among multiple types with the same name: + +```js +const onTypeConflict = (left, right) => right; +``` + +And here's how we might select the type whose schema has the latest `version`: + +```js +const onTypeConflict = (left, right, info) => { + if (info.left.schema.version >= info.right.schema.version) { + return left; + } else { + return right; + } +} +``` + +When using schema transforms, `onTypeConflict` is often unnecessary, since transforms can be used to prevent conflicts before merging schemas. However, if you're not using schema transforms, `onTypeConflict` can be a quick way to make `mergeSchemas` produce more desirable results. + +

Transform

+ +```ts +interface Transform = { + transformSchema?: (schema: GraphQLSchema) => GraphQLSchema; + transformRequest?: (request: Request) => Request; + transformResult?: (result: Result) => Result; +}; + +type Request = { + document: DocumentNode; + variables: Record; + extensions?: Record; +}; + +type Result = ExecutionResult & { + extensions?: Record; +}; +``` + +

transformSchema

+ +Given a `GraphQLSchema` and an array of `Transform` objects, produce a new schema with those transforms applied. + +Delegating resolvers will also be generated to map from new schema root fields to old schema root fields. Often these automatic resolvers are sufficient, so you don't have to implement your own. + +

Built-in transforms

+ +Built-in transforms are ready-made classes implementing the `Transform` interface. They are intended to cover many of the most common schema transformation use cases, but they also serve as examples of how to implement transforms for your own needs. + +### Modifying types + +* `FilterTypes(filter: (type: GraphQLNamedType) => boolean)`: Remove all types for which the `filter` function returns `false`. + +* `RenameTypes(renamer, options?)`: Rename types by applying `renamer` to each type name. If `renamer` returns `undefined`, the name will be left unchanged. Options controls whether built-in types and scalars are renamed. Root objects are never renamed by this transform. + +```ts +RenameTypes( + (name: string) => string | void, + options?: { + renameBuiltins: Boolean; + renameScalars: Boolean; + }, +) +``` + +### Modifying root fields + +* `TransformRootFields(transformer: RootTransformer)`: Given a transformer, abritrarily transform root fields. The `transformer` can return a `GraphQLFieldConfig` definition, a object with new `name` and a `field`, `null` to remove the field, or `undefined` to leave the field unchanged. + +```ts +TransformRootFields(transformer: RootTransformer) + +type RootTransformer = ( + operation: 'Query' | 'Mutation' | 'Subscription', + fieldName: string, + field: GraphQLField, +) => + | GraphQLFieldConfig + | { name: string; field: GraphQLFieldConfig } + | null + | void; +``` + +* `FilterRootFields(filter: RootFilter)`: Like `FilterTypes`, removes root fields for which the `filter` function returns `false`. + +```ts +FilterRootFields(filter: RootFilter) + +type RootFilter = ( + operation: 'Query' | 'Mutation' | 'Subscription', + fieldName: string, + field: GraphQLField, +) => boolean; +``` + +* `RenameRootFields(renamer)`: Rename root fields, by applying the `renamer` function to their names. + +```ts +RenameRootFields( + renamer: ( + operation: 'Query' | 'Mutation' | 'Subscription', + name: string, + field: GraphQLField, + ) => string, +) +``` + +### Other + +* `ExractField({ from: Array, to: Array })` - move selection at `from` path to `to` path. + +* `WrapQuery( + path: Array, + wrapper: QueryWrapper, + extractor: (result: any) => any, + )` - wrap a selection at `path` using function `wrapper`. Apply `extractor` at the same path to get the result. This is used to get a result nested inside other result + +```js +transforms: [ + // Wrap document takes a subtree as an AST node + new WrapQuery( + // path at which to apply wrapping and extracting + ['userById'], + (subtree: SelectionSetNode) => ({ + // we create a wrapping AST Field + kind: Kind.FIELD, + name: { + kind: Kind.NAME, + // that field is `address` + value: 'address', + }, + // Inside the field selection + selectionSet: subtree, + }), + // how to process the data result at path + result => result && result.address, + ), +], +``` + +* `ReplaceFieldWithFragment(targetSchema: GraphQLSchema, mapping: FieldToFragmentMapping)`: Replace the given fields with an inline fragment. Used by `mergeSchemas` to handle the `fragment` option. + +```ts +type FieldToFragmentMapping = { + [typeName: string]: { [fieldName: string]: InlineFragmentNode }; +}; +``` + +

delegateToSchema transforms

+ +The following transforms are automatically applied by `delegateToSchema` during schema delegation, to translate between new and old types and fields: + +* `AddArgumentsAsVariables`: Given a schema and arguments passed to a root field, make those arguments document variables. +* `FilterToSchema`: Given a schema and document, remove all fields, variables and fragments for types that don't exist in that schema. +* `AddTypenameToAbstract`: Add `__typename` to all abstract types in the document. +* `CheckResultAndHandleErrors`: Given a result from a subschema, propagate errors so that they match the correct subfield. Also provide the correct key if aliases are used. + +By passing a custom `transforms` array to `delegateToSchema`, it's possible to run additional transforms before these default transforms, though it is currently not possible to disable the default transforms. diff --git a/docs/source/best-practices/authentication.md b/docs/source/best-practices/authentication.md new file mode 100644 index 00000000000..5b826230ea4 --- /dev/null +++ b/docs/source/best-practices/authentication.md @@ -0,0 +1,248 @@ +--- +title: Auth +description: Securing our app and serving our users +--- + +

Background: Authentication vs. Authorization

+ +**Authentication** describes a process where an application proves the identity of a user, meaning they user they are attempting to be from the client is the user making the request on the server. In most systems, a user and server share a handshake and token that uniquely pairs them together, ensuring both sides know they are communicating with their intended target. + +**Authorization** defines what a user, such as admin or user, is allowed to do. Generally a server will authenticate users and provide them an authorization role that permits the user to perform a subset of all possible operations, such as read and not write. + +

Auth in GraphQL

+ +GraphQL offers similar authentication and authorization mechanics as REST and other data fetching solutions with the possibility to control more fine grain access within a single request. There are two common approaches: schema authorization and operation authorization. + +**Schema authorization** follows a similar guidance to REST, where the entire request and response is checked for an authenticated user and authorized to access the servers data. + +**Operation authorization** takes advantage of the flexibility of GraphQL to provide public portions of the schema that don't require any authorization and private portions that require authentication and authorization. + +> Authorization within our GraphQL resolvers is a great first line of defense for securing our application. We recommened having similar authorization patterns within our data fetching models to ensure a user is authorized at every level of data fetching and updating. + +

Authenticating users

+ +All of the approaches require that users be authenticated with the server. If our system already has login method setup to authenticate users and provide credentials that can be used in subsequent requests, we can use this same system to authenticate GraphQL requests. With that said, if we are creating a new infrastructure for user authentication, we can follow the existing best practice to authenticate users. For a full example of authentication, follow [this example](), which uses [passport.js](http://www.passportjs.org/). + +

Schema Authorization

+ +Schema authorization is useful for GraphQL endpoints that require known users and allow access to all fields inside of a GraphQL endpoint. This approach is useful for internal applications, which are used by a group that is known and generally trusted. Additionally it's common to have separate GraphQL services for different features or products that are entirely available to users, meaning if a user is authenticated, they are authorized to access all the data. Since schema authorization does not need to be aware of the GraphQL layer, our server can add a middleware in front of the GraphQL layer to ensure authorization. + +```js +// authenticate for schema usage +const context = ({ req }) => { + const user = myAuthenticationLookupCode(req); + if (!user) { + throw new Error("You need to be authenticated to access this schema!"); + } + + return { user } +}; + +const server = new ApolloServer({ typeDefs, resolvers, context }); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`) +}); +``` + +Currently this server will allow any authenticated user to request all fields in the schema, which means that authorization is all or nothing. While some applications provide a shared view of the data to all users, many use cases require scoping authorizations and limiting what some users can see. The authorization scope is shared across all resolvers, so this code adds the user id and scope to the context. + +```js +const { ForbiddenError } = require("apollo-server"); + +const context = ({ req }) => { + const user = myAuthenticationLookupCode(req); + if (!user) { + throw new ForbiddenError( + "You need to be authenticated to access this schema!" + ); + } + + const scope = lookupScopeForUser(user); + + return { user, scope }; +}; + +const server = new ApolloServer({ + typeDefs, + resolvers, + context +}); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`); +}); +``` + +Now within a resolver, we are able to check the user's scope. If the user is not an administrator and `allTodos` are requested, a GraphQL specific forbidden error is thrown. Apollo Server will handle associate the error with the particular path and return it along with any other data successfully requested, such as `myTodos`, to the client. + +```js +const { ForbiddenError } = require("apollo-server"); + +const resolvers = { + Query: { + allTodos: (source, args, context) => { + if (context.scope !== "ADMIN") { + throw ForbiddenError("Need Administrator Privileges"); + } + return context.Todos.getAll(); + }, + myTodos: (source, args, context) => { + return context.Todos.getById(context.user_id); + } + } +}; +``` + +The major downside to schema authorization is that all requests must be authenticated, which prevents unauthenticated requests to access information that should be publicly accessible, such as a home page. The next approach, partial query authorization, enables a portion of the schema to be public and authorize portions of the schema to authenticated users. + +## Operation Authorization + +Operation authorization removes the catch all portion of our context function that throws an unauthenticated error, moving the authorization check within resolvers. The instantiation of the server becomes: + +```js +const context = ({ req }) => { + const user = myAuthenticationLookupCode(req); + if (!user) { + return { user: null, scope: null } + } + + const scope = lookupScopeForUser(user); + return { user, scope } +}; + +const server = new ApolloServer({ + typeDefs, + resolvers, + context +}); + +server.listen().then(({ url }) => { + console.log(`🚀 Serverready at ${url}`) +}); +``` + +The benefit of doing operation authorization is that private and public data is more easily managed an enforced. Take for example a schema that allows finding `allTodos` in the app (an administratrative action), seeing any `publicTodos` which requires no authorization, and returning just a single users todos via `myTodos`. Using Apollo Server, we can easiliy build complex authorization models like so: + +```js +const { ForbiddenError, AuthenticationError } = require("apollo-server"); + +const resolvers = { + Query: { + allTodos: (source, args, context) => { + if (!context.scope) { + throw AuthenticationError("You must be logged in to see all todos"); + } + + if (context.scope !== "ADMIN") { + throw ForbiddenError("You must be an administrator to see all todos"); + } + + return context.Todos.getAllTodos(); + }, + publicTodos: (source, args, context) => { + return context.Todos.getPublicTodos(); + }, + myTodos: (source, args, context) => { + if (!context.scope) { + throw AuthenticationError("You must be logged in to see all todos"); + } + + return context.Todos.getByUserId(context.user.id); + } + } +}; +``` + +## Should I send a password in a mutation? + +Since GraphQL queries are sent to a server in the same manner as REST requests, the same policies apply to sending sensitive data over the wire. The current best practice is to provide an encrypted connection over https or wss if we are using websockets. Provided we setup this layer, passwords and other sensitive information should be secure. + +## Auth Example + +If you are new setting up new infrastructure or would like to understand an example of how to adapt your existing login system, you can follow this example using passport.js. We will use this example of authentication in the subsequent sections. To skip this section, jump down to the + +```shell +npm install --save express passport body-parser express-session node-uuid passport-local apollo-server graphql +``` + +```js +const bodyParser = require('body-parser'); +const express = require('express'); +const passport = require('passport'); +const session = require('express-session'); +const uuid = require('node-uuid'); +``` + +After installing and importing the necessary packages, this code checks the user's password and attaches their id to the request. + +```js +let LocalStrategy = require('passport-local').Strategy; +const { DB } = require('./schema/db.js'); + +passport.use( + 'local', + new LocalStrategy(function(username, password, done) { + let checkPassword = DB.Users.checkPassword(username, password); + let getUser = checkPassword + .then(is_login_valid => { + if (is_login_valid) return DB.Users.getUserByUsername(username); + else throw new Error('invalid username or password'); + }) + .then(user => done(null, user)) + .catch(err => done(err)); + }), +); + +passport.serializeUser((user, done) => done(null, user.id)); + +passport.deserializeUser((id, done) => + DB.Users.get(id).then((user, err) => done(err, user)) +); +``` + +Now that passport has been setup, we initialize the server application to use the passport middleware, attaching the user id to the request. + +```js +const app = express(); + +//passport's session piggy-backs on express-session +app.use( + session({ + genid: function(req) { + return uuid.v4(); + }, + secret: 'Z3]GJW!?9uP"/Kpe', + }) +); + +//Provide authentication and user information to all routes +app.use(passport.initialize()); +app.use(passport.session()); +``` + +Finally we provide the login route and start Apollo Server. + +```js +const { typeDefs, resolvers } = require('./schema'); + +//login route for passport +app.use('/login', bodyParser.urlencoded({ extended: true })); +app.post( + '/login', + passport.authenticate('local', { + successRedirect: '/', + failureRedirect: '/login', + failureFlash: true, + }), +); + +//Depending on the authorization model choosen, you may include some extra middleware here before you instantiate the server + +//Create and start your apollo server +const server = new ApolloServer({ typeDefs, resolvers, app }); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`) +}); +``` diff --git a/docs/source/best-practices/caching.md b/docs/source/best-practices/caching.md new file mode 100644 index 00000000000..70a62770ef9 --- /dev/null +++ b/docs/source/best-practices/caching.md @@ -0,0 +1,23 @@ +--- +title: Caching +description: Caching operations +--- + +One of the best ways we can speed up our application is to implement caching into it. Apollo Client has a intelligent cache which greatly lowers the work the client needs to do to fetch and manage data, but what about our server? Caching in Apollo Server can be done in a number of ways, but we recommend three in particular that have a good balance between complexity to manage and benefit of use. + +

Whole query caching

+ +GraphQL operations on a client are best when they are statically defined and used in an application. When this is the case, often times there will be operations that could easily be cached as a full result of the the request. We call this *whole query caching* and it is incredibly easy to implement with Apollo Server. Unlike custom REST endpoints, using Apollo Server allows us to define the cacheability of our resources and dynamically calculate the best possible cache timing for any given operation. + +- For more information about setting up Apollo Engine with Apollo Server, [read this guide]() +- For more information about setting up whole query caching with Apollo Engine, [read this guide](https://www.apollographql.com/docs/engine/caching.html) + +

CDN integration

+ +If our application has a lot of public data that doesn’t change very frequently, and it’s important for it to load quickly, we will probably benefit from using a CDN to cache our API results. This can be particularly important for media or content companies like news sites and blogs. + +A CDN will store our API result close to the “edge” of the network — that is, close to the region the user is in — and deliver a cached result much faster than it would have required to do a full round-trip to our actual server. As an added benefit, we get to save on server load since that query doesn’t actually hit our API. + +- Setting up CDN caching with Apollo Server is incredibly easy, simply setup Apollo Engine then follow this [guide](https://www.apollographql.com/docs/engine/cdn.html) +- For more information about using a CDN with Apollo Engine, check out this [article](https://blog.apollographql.com/caching-graphql-results-in-your-cdn-54299832b8e2) + diff --git a/docs/source/best-practices/monitoring.md b/docs/source/best-practices/monitoring.md new file mode 100644 index 00000000000..9882362c1b0 --- /dev/null +++ b/docs/source/best-practices/monitoring.md @@ -0,0 +1,9 @@ +--- +title: Monitoring +--- + +Intro about what to watch for? + +## ENGINE + +## formatError diff --git a/docs/source/best-practices/organization.md b/docs/source/best-practices/organization.md new file mode 100644 index 00000000000..3f7c4ff8f02 --- /dev/null +++ b/docs/source/best-practices/organization.md @@ -0,0 +1,261 @@ +--- +title: Organizing your code +description: Scaling your Apollo Server from a single file to your entire team +--- + +The GraphQL schema defines the api for Apollo Server, providing the single source of truth between client and server. A complete schema contains type definitions and resolvers. Type definitions are written and documented in the [Schema Definition Language(SDL)]() to define the valid server entry points. Corresponding to one to one with type definition fields, resolvers are functions that retrieve the data described by the type definitions. + +To accommodate this tight coupling, type definitions and resolvers should be kept together in the same file. This collocation allows developers to modify fields and resolvers with atomic schema changes without unexpected consequences. At the end to build a complete schema, the type definitions are combined in an array and resolvers are merged together. Throughout all the examples, the resolvers delegate to a data model, as explained in [this section](). + +> Note: This schema separation should be done by product or real-world domain, which create natural boundaries that are easier to reason about. + +## Prerequisites + +* essentials/schema for connection between: + * GraphQL Types + * Resolvers + +

Organizing schema types

+ +With large schema, defining types in different files and merge them to create the complete schema may become necessary. We accomplish this by importing and exporting schema strings, combining them into arrays as necessary. The following example demonstrates separating the type definitions of [this schema](#first-example-schema) found at the end of the page. + +```js +// comment.js +const typeDefs = gql` + type Comment { + id: ID! + message: String + author: String + } +`; + +export typeDefs; +``` + +The `Post` includes a reference to `Comment`, which is added to the array of type definitions and exported: + +```js +// post.js +const typeDefs = ` + type Post { + id: ID! + title: String + content: String + author: String + comments: [Comment] + } +`; + +// Export Post and all dependent types +export typeDefs; +``` + +Finally the root Query type, which uses Post, is created and passed to the server instantiation: + +```js +// schema.js +const Comment = require('./comment'); +const Post = require('./post'); + +const RootQuery = ` + type Query { + post(id: ID!): Post + } +`; + +const server = new ApolloServer({ + typeDefs: [RootQuery, Post.typeDefs, Comment.typeDefs], + resolvers, //defined in next section +}); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`) +}); +``` + +

Organizing resolvers

+ +For the type definitions above, we can accomplish the same modularity with resolvers by combining each type's resolvers together with Lodash's `merge` or another equivalent. The [end of this page](#first-example-resolvers) contains a complete view of the resolver map. + +```js +// comment.js +const CommentModel = require('./models/comment'); + +const resolvers = { + Comment: { + votes: (parent) => CommentModel.getVotesById(parent.id) + } +}; + +export resolvers; +``` + +The `Post` type: + +```js +// post.js +const PostModel = jequire('./models/post'); + +const resolvers = { + Post: { + comments: (parent) => PostModel.getCommentsById(parent.id) + } +}; + +export resolvers; +``` + +Finally, the Query type's resolvers are merged and the result is passed to the server instantiation: + +```js +// schema.js +const { merge } = require('lodash'); +const Post = require('./post'); +const Comment = require('./comment'); + +const PostModel = require('./models/post'); + +// Merge all of the resolver objects together +const resolvers = merge({ + Query: { + post: (_, args) => PostModel.getPostById(args.id) + } +}, Post.resolvers, Comment.resolvers); + +const server = new ApolloServer({ + typeDefs, //defined in previous section + resolvers, +}); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`) +}); +``` + +

Extending types

+ +The `extend` keyword provides the ability to add fields to existing types. Using `extend` is particularly useful in avoiding a large list of fields on root Queries and Mutations. + +```js +//schema.js +const bookTypeDefs = ` +extend type Query { + books: [Bar] +} + +type Book { + id: ID! +} +`; + +// These type definitions are often in a separate file +const authorTypeDefs = ` +extend type Query { + authors: [Author] +} + +type Author { + id: ID +} +`; +export const typeDefs = [bookTypeDefs, authorTypeDefs] +``` + +```js +const {typeDefs, resolvers} = require('./schema'); + +const rootQuery = ` +"Query can and must be defined once per schema to be extended" +type Query { + _empty: String +}`; + +const server = new ApolloServer({ + typeDefs: [RootQuery].concat(typeDefs), + resolvers, +}); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`) +}); +``` + +> Note: In the current version of GraphQL, you can’t have an empty type even if you intend to extend it later. So we need to make sure the Query type has at least one field — in this case we can add a fake `_empty` field. Hopefully in future versions it will be possible to have an empty type to be extended later. + +

Documenting a Schema

+ +In addition to modularization, documentation within the SDL enables the schema to be effective as the single source of truth between client and server. Graphql gui's have built-in support for displaying docstrings with markdown syntax, such as those found in the following schema. + +```graphql +""" +Description for the type +""" +type MyObjectType { + """ + Description for field + Supports multi-line description + """ + myField: String! + + otherField( + """ + Description for argument + """ + arg: Int + ) +} +``` + +

API

+ +Apollo Server pass `typeDefs` and `resolvers` to the `graphql-tools`'s `makeExecutableSchema`. + +TODO point at graphql-tools `makeExecutableSchema` api + +

Example Application Details

+ +

Schema

+ +The full type definitions for the first example: + +```graphql +type Comment { + id: ID! + message: String + author: String + votes: Int +} + +type Post { + id: ID! + title: String + content: String + author: String + comments: [Comment] +} + +type Query { + post(id: ID!): Post +} +``` + +

Resolvers

+ +The full resolver map for the first example: + +```js +const CommentModel = require('./models/comment'); +const PostModel = require('./models/post'); + +const resolvers = { + Comment: { + votes: (parent) => CommentModel.getVotesById(parent.id) + } + Post: { + comments: (parent) => PostModel.getCommentsById(parent.id) + } + Query: { + post: (_, args) => PostModel.getPostById(args.id) + } +} +``` diff --git a/docs/source/best-practices/performance.md b/docs/source/best-practices/performance.md new file mode 100644 index 00000000000..61b7e3dc1c9 --- /dev/null +++ b/docs/source/best-practices/performance.md @@ -0,0 +1,210 @@ +--- +title: Performance +description: Reduce requests and speeding up applications +--- + +GraphQL offers performance benefits for most applications. By reducing round-trips when fetching data, lower the amount of data we are sending back, and make it easier to batch data lookups. Since GraphQL is often built as a stateless request-response pattern, scaling our app horizontally becomes much easier. In this section, we will dive into some benefits that Apollo Server brings to our app, and some patterns for speeding up our service. + +## Prevent over-fetching + +Rest endpoints often return all of the fields for whatever data they are returning. As applications grow, their data needs grow as well, which leads to a lot of unnecessary data being downloaded by our client applications. With GraphQL this isn't a problem because Apollo Server will only return the data that we ask for when making a request! Take for example a screen which shows an avatar of the currently logged in user. In a rest app we may make a request to `/api/v1/currentUser` which would return a response like this: + +```json +{ + "id": 1, + "firstName": "James", + "lastName": "Baxley", + "suffix": "III", + "avatar": "/photos/profile.jpg", + "friendIds": [2, 3, 4, 5, 6, 7], + "homeId": 1, + "occupation": "farmer", + // and so on for every field on this model that our client **could** use +} +``` + +Contrast that to the request a client would send to Apollo Server and the response they would receive: + +```graphql +query GetAvatar { + currentUser { + avatar + } +} +``` + +```json +{ + "data": { + "currentUser": { + "avatar": "/photos/profile.jpg" + } + } +} +``` + +No matter how much our data grows, this query will always only return the smallest bit of data that the client application actually needs! This will make our app faster and our end users data plan much happier! + +## Reducing round-trips + +Applications typically need to fetch multiple resources to load any given screen for a user. When building an app on top of a REST API, screens need to fetch the first round of data, then using that information, make another request to load related information. A common example of this would be to load a user, then load their friends: + +```js +const userAndFriends = fetch("/api/v1/user/currentUser").then(user => { + const friendRequest = Promise.all( + user.friendIds.map(id => fetch(`/api/vi/user/${id}`)) + ); + + return friendRequest.then(friends => { + user.friends = friends; + return user; + }); +}); + +``` + +The above code would make at minimum two requests, one for the logged in user and one for a single friend. With more friends, the number of requests jumps up quite a lot! To get around this, custom endpoints are added into a RESTful API. In this example, a `/api/v1/friends/:userId` may be added to make fetching friends a single request per user instead of one per friend. + +With GraphQL this is easily done in a single request! Given a schema like this: + +```graphql +type User { + id: ID! + name: String! + friends: [User] +} + +type Query { + currentUser: User +} +``` + +We can easily fetch the current user and all of their friends in a single request! + +```graphql +query LoadUserAndFriends { + currentUser { + id + name + friends { + id + name + } + } +} +``` + +## Batching data lookups + +If we take the above query we may think GraphQL simply moves the waterfall of requests from the client to the server. Even if this was true, application speeds would still be improved. However, Apollo Server makes it possible to make applications even faster by batching data requests. + +The most common way to batch requests is by using Facebook's [`dataloader`](https://github.com/facebook/dataloader) library. Let's explore a few options for request batching the previous operation: + +

Custom resolvers for batching

+ +The simplest (and often easiest) way to speed up a GraphQL service is to create resolvers that optimistically fetch the needed data. Often times the best thing to do is to write the simplest resolver possible to look up data, profile it with a tool like Apollo Engine, then improve slow resolvers with logic tuned for the way our schema is used. Take the above query, for example: + +```js +const User = { + friends: (user, args, context) => { + // A simple approach to find each friend. + return user.friendIds.map(id => context.UserModel.findById(id)); + } +} + +``` + +The above resolver will make a database lookup for the initial user and then one lookup for every friend that our user has. This would quickly turn into an expensive resolver to call so lets look at how we could speed it up! First, lets take a simple, but proven technique: + +```js +const User = { + friends: (user, args, context) => { + // a custom model method for looking up multiple users + return context.UserModel.findByIds(user.friendIds); + } +} +``` + +Instead of fetching each user independently, we could fetch all users at once in a single lookup. This would be analogous to `SELECT * FROM users WHERE id IN (1,2,3,4)` vs the previous query would have been multiple versions of `SELECT * FROM users WHERE id = 1`. + +Often times, custom resolvers are enough to speed up our server to the levels we want. However, there may be times where we want to be even more efficient when batching data. Lets say we expanded our operation to include more information: + +```graphql +query LoadUserAndFriends { + currentUser { + id + name + friends { + id + name + } + family { + id + name + } + } +} +``` + +Assuming that `family` returns more `User` types, we now are making at minimum three database calls: 1) the user, 2) the batch of friends, and 3) the batch of family members. If we expand the query deeper: + +``` +query LoadUserAndFriends { + currentUser { + id + name + friends { + id + name + ...peopleTheyCareAbout + } + family { + id + name + ...peopleTheyCareAbout + } + } +} + +fragment peopleTheyCareAbout on User { + family { + id + name + } + friends { + id + name + } +} +``` + +We are now looking at any number of database calls! The more friends and families that are connected in our app, the more expensive this query gets. Using a library like `dataloader`, we can reduce this operation to a maximum of three database lookups. Let's take a look at how to implement it to understand what is happening: + +```js +const DataLoader = require('dataloader'); + +// give this to ApolloServer's context +const UserModelLoader = new DataLoader(UserModel.findByIds); + +// in the User resolvers +const User = { + friends: (user, args, context) => { + return context.UserModelLoader.loadMany(user.friendIds); + }, + family: (user, args, context) => { + return context.UserModelLoader.loadMany(user.familyIds); + } +} +``` + +After the first data request returns with our current user's information, we execute the resolvers for `friends` and `family` within the same "tick" of the event loop, which is technical talk for "pretty much at the same time". DataLoader will delay making a data request (in this case the `UserModel.findByIds` call) long enough for it to capture the request to look up both friends and families at once! It will combine the two arrays of ids into one so our `SELECT * FROM users WHERE id IN ...` request will contain the ids of both friends **and** families! + +The friends and families request will return at the same time so when we select friends and families for all of previously returned users, the same batching can occur across all of the new users requests! So instead of potentially hundreds of data lookups, we can only perform 3 for a query like this! + + +## Scaling our app + +Horizontal scaling is a fantastic way to increase the amount of load that our servers can handle without having to purchase more expensive computing resources to handling it. Apollo Server can scale extremely well like this as long as a couple of concerns are handled: + +- Every request should ensure it has access to the required data source. If we are building on top of a HTTP endpoint this isn't a problem, but when using a database it is a good practice to verify our connection on each request. This helps to make our app more fault tolerant and easily scale up a new service which will connect as soon as requests start! +- Any state should be saved into a shared stateful datastore like redis. By sharing state, we can easily add more and more servers into our infrastructure without fear of loosing any kind of state between scale up and scale down. diff --git a/docs/source/best-practices/schema-design.md b/docs/source/best-practices/schema-design.md new file mode 100644 index 00000000000..08c7a523c8e --- /dev/null +++ b/docs/source/best-practices/schema-design.md @@ -0,0 +1,371 @@ +--- +title: Schema Design +description: The best way to fetch data, update it, and keep things running for a long time +--- + +GraphQL schemas are at their best when they are designed around the need of client applications, instead of the shape of how the data is stored. Often times teams will create schemas that are literal mappings on top of their collections or tables with CRUD like root fields. While this may be a fast way to get up and running, a strong long term GraphQL schema is built around the products usage. + +## Style conventions + +The GraphQL specification is flexible in the style that it dictates and doesn't impose specific naming guidelines. In order to facilitate development and continuity across GraphQL deployments, we suggest the following style conventions : + +- **Fields**: are recommended to be written in `camelCase`, since the majority of consumers will be client applications written in JavaScript. +- **Types**: should be `PascalCase`. +- **Enums**: should have their name in `PascalCase` and their values in `ALL_CAPS` to denote their special meaning. + +## Using interfaces + +Interfaces are a powerful way to build and use GraphQL schemas through the use of _abstract types_. Abstract types can't be used directly in schema, but can be used as building blocks for creating explicit types. + +Consider an example where different types of books share a common set of attributes, such as _text books_ and _coloring books_. A simple foundation for these books might be represented as the following `interface`: + +```graphql +interface Book { + title: String + author: Author +} +``` + +We won't be able to directly use this interface to query for a book, but we can use it to implement concrete types. Imagine a screen within an application which needs to display a feed of all books, without regard to their (more specific) type. To create such functionality, we could define the following: + +```graphql +type TextBook implements Book { + title: String + author: Author + classes: [Class] +} + +type ColoringBook implements Book { + title: String + author: Author + colors: [Color] +} + +type Query { + schoolBooks: [Book] +} +``` + +In this example, we've used the `Book` interface as the foundation for the `TextBook` and `ColoringBook` types. Then, a `schoolBooks` field simply expresses that it returns a list of books (i.e. `[Book]`). + +Implementing the book feed example is now simplified since we've removed the need to worry about what kind of `Book`s will be returned. A query against this schema, which could return _text books_ and _coloring_ books, might look like: + +```graphql +query GetBooks { + schoolBooks { + title + author + } +} +``` + +This is really helpful for feeds of common content, user role systems, and more! + +Furthermore, if we need to return fields which are only provided by either `TextBook`s or `ColoringBook`s (not both) we can request fragments from the abstract types in the query. Those fragments will be filled in only as appropriate; in the case of the example, only coloring books will be returned with `colors`, and only text books will have `classes`: + +```graphql +query GetBooks { + schoolBooks { + title + ... on TextBook { + classes { + name + } + } + ... on ColoringBook { + colors { + name + } + } + } +} +``` + +To see an interface in practice, check out this [example]() + +## A `Node` interface + +A so-called "`Node` interface" is an implementation of a generic interface, on which other types can be built on, which enables the ability to fetch other _types_ in a schema by only providing an `id`. This interface isn't provided automatically by GraphQL (not does it _have_ to be called `Node`), but we highly recommend schemas consider implementing one. + +To understand its value, we'll present an example with two collections: _authors_ and _posts_, though the usefulness of such an interface grows as more collections are introduced. As is common with most database collections, each of these collections have unique `id` columns which uniquely represent the individual documents within the collection. + +To implement a so-called "`Node` interface", we'll add a `Node` interface to the schema, as follows: + +```graphql +interface Node { + id: ID! +} +``` + +This `interface` declaration has the only field it will ever need: an `ID!` field, which is required to be non-null in all operations (as indicated by the `!`). + +To take advantage of this new interface, we can use as the underlying implementation for the other types that our schema will define. For our example, this means we'll use it to build `Post` and `Author` object types: + +```graphql +type Post implements Node { + id: ID! + title: String! + author: Author! +} + +type Author implements Node { + id: ID! + name: String! + posts: [Post] +} +``` + +By implementing the `Node` interface as the foundation for `Post` and `Author`, we know that anytime a client has obtained an `id` (from either type), we can send it back to the server and retrieve that exact piece of data back! + +

Global Ids

+ +When using the `Node` interface, we will want to create schema unique `id` fields. The most common way to do this is to take the `id` from the datasource and join it with the type name where it is being exposed (i.e `Post:1`, `Author:1`). In doing so, even though the database `id` is the same for the first Post and first Author, the client can refetch each sucessfully! + +Global Ids are often encoded into a base64 string after joined together. This is for consistency but also to denote that the client shouldn't try to parse and use the information as the shape of the `id` may change over time with schema revisions, but the uniqueness of it will not. + +

Using the node interface

+ +Now that we have the `Node` interface, we need a way to globally refetch any id that the client can send. To do this, we add a field called `node` to our `Query` which returns a `Node` abstract type: + +```graphql +type Query { + node(id: ID!): Node +} +``` + +Now our client can refetch any type they want to as long as they have an `id` value for it: + + +```graphql +query GetAuthor($authorId: ID!) { + node(id: $authorId) { + id + ... on Author { + name + posts { + id + title + } + } + } +} +``` + +Using the `Node` interface can remove a ton of uneccessary fields on the `Query` type, as well as solve common patterns like data fetching for routing. Say we had a route showing content our user has liked: `/favorites` and then we wanted to drill down into those likes: `/favorites/:id` to show more information. Instead of creating a route for each kind of liked content (i.e `/favories/authors/:id`, `/favorites/posts/:id`), we can use the `node` field to request any type of liked content: + +```graphql +query GetLikedContent($id: ID!){ + favorite: node(id: $id){ + id + ... on Author { + pageTitle: name + } + ... on Post { + pageTitle: title + } + } +} +``` + +Thanks to the `Node` interface and field aliasing, my response data is easily used by my UI no matter what my likes are: + +```json +[ + { id: "Author:1", pageTitle: "Sashko" }, + { id: "Post:1", pageTitle: "GraphQL is great!" } +] +``` + +To see this in practice, check out the following [example]() + +## Mutation responses + +Mutations are an incredibly powerful part of GraphQL as they can easily return both information about the data updating transaction, as well as the actual data that has changed very easily. One pattern that we recommend to make this consistent is to have a `MutationResponse` interface that can be easily implemented for any `Mutation` fields. The `MutationResponse` is designed to allow transactional information alongside returning valuable data to make client side updates automatic! The interface looks like this: + +```graphql +interface MutationResponse { + code: String! + success: Boolean! + message: String! +} +``` + +An implementing type would look like this: + +```graphql +type AddPostMutationResponse { + code: String! + success: Boolean! + message: String! + post: Post +} +``` + +Lets break this down by field: + +- **code** is a string representing a transactional value explaning details about the status of the data change. Think of this like HTTP status codes. +- **success** is a boolean telling the client if the update was successful. It is a coarse check that makes it easy for the client application to respond to failures +- **message** is a string that is meant to be a human readable description of the status of the transaction. It is intended to be used in the UI of the product +- **post** is added by the implementing type `AddPostMutationResponse` to return back the newly created post for the client to use! + +Following this pattern for mutations provides detailed information about the data that has changed and how the operation to change it went! Client developers can easily react to failures and fetch the information they need to update their local cache. + +

Organizing your schema

+ +When schemas get large, we can start to define types in different files and import them to create the complete schema. We accomplish this by importing and exporting schema strings, combining them into arrays as necessary. + +```js +// comment.js +const typeDefs = gql` + type Comment { + id: Int! + message: String + author: String + } +`; + +export typeDefs; +``` + +```js +// post.js +const Comment = require('./comment'); + +const typeDefs = [` + type Post { + id: Int! + title: String + content: String + author: String + comments: [Comment] + } +`].concat(Comment.typeDefs); + +// we export Post and all types it depends on +// in order to make sure we don't forget to include +// a dependency +export typeDefs; +``` + +```js +// schema.js +const Post = require('./post.js'); + +const RootQuery = ` + type RootQuery { + post(id: Int!): Post + } +`; + +const SchemaDefinition = ` + schema { + query: RootQuery + } +`; + +const server = new ApolloServer({ + //we may destructure Post if supported by our Node version + typeDefs: [SchemaDefinition, RootQuery].concat(Post.typeDefs), + resolvers, +}); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`) +}); +``` + +

Extending types

+ +The `extend` keyword provides the ability to add fields to existing types. Using `extend` is particularly useful in avoiding a large list of fields on root Queries and Mutations. + +```js +const barTypeDefs = ` +"Query can and must be defined once per schema to be extended" +type Query { + bars: [Bar] +} + +type Bar { + id: String +} +`; + +const fooTypeDefs = ` +type Foo { + id: String +} + +extend type Query { + foos: [Foo] +} +` + +const typeDefs = [barTypeDefs, fooTypeDefs] +``` + +

Sharing types

+ +Schemas often contain circular dependencies or a shared type that has been hoisted to be referenced in separate files. When exporting array of schema strings with circular dependencies, the array can be wrapped in a function. The Apollo Server will only include each type definition once, even if it is imported multiple times by different types. Preventing deduplication of type definitions means that domains can be self contained and fully functional regardless of how they are combined. + +```js +// author.js +const Book = require('./book'); + +const Author = ` + type Author { + id: Int! + firstName: String + lastName: String + books: [Book] + } +`; + +// we export Author and all types it depends on +// in order to make sure we don't forget to include +// a dependency and we wrap it in a function +// to avoid strings deduplication +export const typeDefs = () => [Author].concat(Book.typeDefs); +``` + +```js +// book.js +const Author = require('./author'); + +const Book = ` + type Book { + title: String + author: Author + } +`; + +export const typeDefs = () => [Book].concat(Author.typeDefs); +``` + +```js +// schema.js +const Author = require('./author.js'); + +const RootQuery = ` + type RootQuery { + author(id: Int!): Author + } +`; + +const SchemaDefinition = ` + schema { + query: RootQuery + } +`; + +const server = new ApolloServer({ + //we may destructure Post if supported by our Node version + typeDefs: [SchemaDefinition, RootQuery].concat(Author.typeDefs), + resolvers, +}); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`) +}); +``` + + diff --git a/docs/source/best-practices/security.md b/docs/source/best-practices/security.md new file mode 100644 index 00000000000..ef1bddc2499 --- /dev/null +++ b/docs/source/best-practices/security.md @@ -0,0 +1,76 @@ +--- +title: Security +--- + +Apollo Server is a safer way to build applications thanks to GraphQL's strong typing and the conversion of raw operations into a trusted syntax tree. By validating each part of an operation, GraphQL is mostly exempt from injection-attacks which are of concern in other data-driven applications. + + This guide will discuss additional security measures which further harden the excellent foundation which GraphQL is already built upon. While Apollo Server will enable some additional protections automatically, others require attention on the part of the developer. + +

Introspection in production

+ +Introspection is a powerful tool to have enabled during development and allows developers to get real-time visibility of a GraphQL server's capabilities. + +In production, such insight might be less desireable unless the server is intended to be a "public" API. + +Therefore, Apollo Server introspection is automatically disabled when the `NODE_ENV` is set to `production` in order to reduce visibility into the API. + +Of course, no system should rely solely on so-called "security through obscurity" and this practice should be combined with other security techniques like open security and security by design. + +

Injection prevention

+ +As we build out our schema, it may be tempting to allow for shortcut arguments to creep in which have security risks. This most commonly happens on filters and on mutation inputs: + +```graphql +query OhNo { + users(filter: "id = 1;' sql injection goes here!") { + id + } +} + +mutation Dang { + updateUser(user: { firstName: "James", id: 1 }) { + success + } +} +``` + +In the first operation we are passing a filter that is a database filter directly as a string. This opens the door for SQL injection since the string is preserved from the client to the server. + +In the second operation we are passing an id value which may let an attacker update information for someone else! This often happens if generic Input Types are created for corresponding data sources: + +```graphql +# used for both creating and updating a user +input UserInput { + id: Int + firstName: String +} + +type Mutation { + createUser(user: UserInput): User + updateUser(user: UserInput): User +} +``` + +The fix for both of these attack vectors is to create more detailed arguments and let the validation step of Apollo Server filter out bad values as well as **never** pass raw values from a client into our datasource. + +

Denial-of-Service (DoS) Protection

+ +Apollo Server is a Node.js application and standard precautions should be taken in order to avoid Denial-of-Service (DoS) attacks. + +Since GraphQL involves the traversal of a graph in which circular relationships of arbitrary depths might be accessible, some additional precautions can be taken to limit the risks of Complexity Denial-of-Service (CDoS) attacks, where a bad actor could craft expensive operations and lock up resources indefinitely. + +There are two common techniques to mitigate CDoS risks, and can be enabled together: + +1. **Operation white-listing** + + By hashing the potential operations a client might send (e.g. based on field names) and storing these "permitted" hashes on the server (or a shared cache), it becomes possible to check incoming operations against the permitted hashes and skip execution if the hash is not allowed. + + Since many consumers of non-public APIs have their operations statically defined within their source code, this technique is often sufficient and is best implemented as an automated deployment step. + +2. **Complexity limits** + + These can be used to limit the use of queries which, for example, request a list of books including the authors of each book, plus the books of those authors, and _their_ authors, and so on. By limiting operations to an application-defined depth of "_n_", these can be easily prevented. + + We suggest implementing complexity limits using community-provided packages like [graphql-depth-limit](https://github.com/stems/graphql-depth-limit) and [graphql-validation-complexity](https://github.com/4Catalyzer/graphql-validation-complexity). + +> For additional information on securing a GraphQL server deployment, check out [Securing your GraphQL API from malicious queries](https://blog.apollographql.com/securing-your-graphql-api-from-malicious-queries-16130a324a6b) by Spectrum co-founder, Max Stoiber. diff --git a/docs/source/best-practices/testing.md b/docs/source/best-practices/testing.md new file mode 100644 index 00000000000..802f256695a --- /dev/null +++ b/docs/source/best-practices/testing.md @@ -0,0 +1,13 @@ +--- +title: Testing +--- + +Intro section about separation of concerns making GraphQL ideal for unit testing as well integration testing + +> (James) Add API for ApolloServer to make it easy to run integration tests against? Dependency injection anyone? + +## Unit testing resolvers + +## Integration testing operations + +## Using your schema to mock data for client testing diff --git a/docs/source/best-practices/versioning.md b/docs/source/best-practices/versioning.md new file mode 100644 index 00000000000..7999c2d1329 --- /dev/null +++ b/docs/source/best-practices/versioning.md @@ -0,0 +1,10 @@ +--- +title: Versioning +description: How to add and remove parts of your schema without breaking your clients +--- + +tl;dr don't. Use a tool like Engine (one day) to help you iterate + +## Why versioning isn't needed + +## Practical examples of field rollovers \ No newline at end of file diff --git a/docs/source/deployment/heroku.md b/docs/source/deployment/heroku.md new file mode 100644 index 00000000000..ed787856b89 --- /dev/null +++ b/docs/source/deployment/heroku.md @@ -0,0 +1,66 @@ +--- +title: Deploying with Heroku +sidebar_title: Heroku +description: Deploying your GraphQL server to Heroku +--- + +Heroku is a common Platform as a Service that allows you to deploy your Apollo Server and have a functional GraphQL endpoint. + + +

1. Create and set up a new Heroku application

+ +Log into the [Heroku dashboard](https://dashboard.heroku.com/apps). Then click “New” > “Create New App” in the top right. The name you choose will be used later in this tutorial as , so be sure to replace it in the later sections. + +
+![New App Screenshot](../images/deployment/heroku/new-app.png) +

+
+ +Name your app and hit “Create app” + +
+![Create App Screenshot](../images/deployment/heroku/create-app.png) +

+
+ +

2. Push project to Heroku

+ +Install the [Heroku Cli](https://devcenter.heroku.com/articles/heroku-cli), then inside of your project, run: + +```shell +$ git init #existing git repositories can skip this +$ heroku git:remote -a + +$ git add . +$ git commit -am "make it better" +$ git push heroku master +``` + +Send a query to your GraphQL service at your Heroku Application at `.herokuapp.com` + +> Note: If you are using a project pushed to GitHub, you may want to setup automatic deployments from your repository, which you can do by following the steps in [this section](#github-deploy). + +

3. Configure environment variables

+ +In order to enable the production mode of Apollo Server, you will need to set the `NODE_ENV` variable to production. To ensure you have visibility into your GraphQL performance in Apollo Server, you'll want to add the `ENGINE_API_KEY` environment variable to Heroku. For the API key, log into the [Engine UI](https://engine.apollographql.com) and navigate to your service or create a new one. + +Then under the “Settings” tab, click “Reveal Config Vars". Next set `NODE_ENV` to `production` and copy your key from the [Engine UI](http://engine.apollographql.com/) as the value for `ENGINE_API_KEY`. + +
+![Add Engine Api Key Screenshot](../images/deployment/heroku/add-env-vars.png) +

+
+ +Send a query to your GraphQL service at your Heroku Application at `.herokuapp.com` and then check out the tracing data in the [Engine UI](http://engine.apollographql.com/). + +> If you would like your GraphQL service to be exposed on a different port, you can also add a PORT environment variable. + +

Deploying directly from GitHub

+ +If you have your project published to github, you are able to setup Heroku to perform automatic deployments from branch. If you have pushed your project GitHub, you may select a branch in your repository that will trigger deploys. + +
+![Add Integration Screenshot](../images/deployment/heroku/add-integration.png) +

+
+ diff --git a/docs/source/deployment/index.md b/docs/source/deployment/index.md new file mode 100644 index 00000000000..144061a7a78 --- /dev/null +++ b/docs/source/deployment/index.md @@ -0,0 +1,13 @@ +--- +title: Deployment Basics +sidebar_title: Basics +description: Deploying your new Apollo Server to the world +--- + + diff --git a/docs/source/deployment/lambda.md b/docs/source/deployment/lambda.md new file mode 100644 index 00000000000..a5fcf960423 --- /dev/null +++ b/docs/source/deployment/lambda.md @@ -0,0 +1,196 @@ +--- +title: Deploying with AWS Lambda +sidebar_title: Lambda +description: How to deploy Apollo Server with AWS Lambda +--- + +AWS Lambda is a service that lets you run code without provisioning or managing servers. You pay only for the compute time you consume-there is no charge when your code is not running. + +Learn how to integrate Apollo Server 2 with AWS Lambda. First, install the `apollo-server-lambda` package: + +```sh +npm install apollo-server-lambda@rc graphql +``` + +## Deploying with AWS Serverless Application Model (SAM) + +To deploy the AWS Lambda function, you must create a Cloudformation Template and a S3 bucket to store the artifact (zip of source code) and template. You'll use the [AWS Command Line Interface](https://aws.amazon.com/cli/). + +#### 1. Write the API handlers + +```js +const { ApolloServer, gql } = require('apollo-server-lambda'); + +// Construct a schema, using GraphQL schema language +const typeDefs = gql` + type Query { + hello: String + } +`; + +// Provide resolver functions for your schema fields +const resolvers = { + Query: { + hello: () => 'Hello world!', + }, +}; + +const server = new ApolloServer({ typeDefs, resolvers }); + +exports.graphqlHandler = server.createHandler(); +``` + +#### 2. Create an S3 bucket + +The bucket name must be universally unique. + +```bash +aws s3 mb s3:// +``` + +#### 3. Create the Template + +This will look for a file called `graphql.js` with the export `graphqlHandler`. It creates one API endpoints: + +* `/graphql` (GET and POST) + +In a file called `template.yaml`: + +```yaml +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Resources: + GraphQL: + Type: AWS::Serverless::Function + Properties: + Handler: graphql.graphqlHandler + Runtime: nodejs8.10 + Events: + GetRequest: + Type: Api + Properties: + Path: /graphql + Method: get + PostRequest: + Type: Api + Properties: + Path: /graphql + Method: post +``` + +#### 4. Package source code and dependencies + +Read and transform the template, created in the previous step. Package and upload the artifact to the S3 bucket and generate another template for the deployment. + +```sh +aws cloudformation package \ + --template-file template.yaml \ + --output-template-file serverless-output.yaml \ + --s3-bucket +``` + +#### 5. Deploy the API + +Create the Lambda Function and API Gateway for GraphQL. In the example below, `prod` stands for production. However, you can use any name to represent it. + +``` +aws cloudformation deploy \ + --template-file serverless-output.yaml \ + --stack-name prod \ + --capabilities CAPABILITY_IAM +``` + +## Getting request info + +To read information about the current request from the API Gateway event `(HTTP headers, HTTP method, body, path, ...)` or the current Lambda Context `(Function Name, Function Version, awsRequestId, time remaning, ...)`, use the options function. This way, they can be passed to your schema resolvers via the context option. + +```js +const { ApolloServer, gql } = require('apollo-server-lambda'); + +// Construct a schema, using GraphQL schema language +const typeDefs = gql` + type Query { + hello: String + } +`; + +// Provide resolver functions for your schema fields +const resolvers = { + Query: { + hello: () => 'Hello world!', + }, +}; + +const server = new ApolloServer({ + typeDefs, + resolvers, + context: ({ event, context }) => ({ + headers: event.headers, + functionName: context.functionName, + event, + context, + }) +}); + +exports.graphqlHandler = server.createHandler(); +``` + +## Modifying the Lambda Response (Enable CORS) + +To enable CORS, the response HTTP headers need to be modified. To accomplish this, use the `cors` options. + +```js +const { ApolloServer, gql } = require('apollo-server-lambda'); + +// Construct a schema, using GraphQL schema language +const typeDefs = gql` + type Query { + hello: String + } +`; + +// Provide resolver functions for your schema fields +const resolvers = { + Query: { + hello: () => 'Hello world!', + }, +}; + +const server = new ApolloServer({ typeDefs, resolvers }); + +exports.graphqlHandler = server.createHandler({ + cors: { + origin: '*', + credentials: true, + }, +}); +``` + +Furthermore, to enable CORS response for requests with credentials (cookies, http authentication), the `allow origin` and `credentials` header must be set to true. + +```js +const { ApolloServer, gql } = require('apollo-server-lambda'); + +// Construct a schema, using GraphQL schema language +const typeDefs = gql` + type Query { + hello: String + } +`; + +// Provide resolver functions for your schema fields +const resolvers = { + Query: { + hello: () => 'Hello world!', + }, +}; + +const server = new ApolloServer({ typeDefs, resolvers }); + +exports.graphqlHandler = server.createHandler({ + cors: { + origin: true, + credentials: true, + }, +}); +``` diff --git a/docs/source/deployment/now.md b/docs/source/deployment/now.md new file mode 100644 index 00000000000..8e65e6dd6e6 --- /dev/null +++ b/docs/source/deployment/now.md @@ -0,0 +1,57 @@ +--- +title: Deploying with Zeit Now +sidebar_title: Now +description: Deploying your GraphQL server to Zeit Now +--- + +Now is a service by Zeit that allows the deployment of an instance of Apollo Server, which provides a functional GraphQL endpoint. + +## Node.js Deployment + +Deployment to Now for Node.js apps simply requires a `package.json` file to be present in your app directory. + +```js +{ + "name": "graphqlservice", + "version": "1.0.0", + "scripts": { + "start": "nodemon index.js" + }, + "dependencies": { + "apollo-server": "^2.0.0-rc.5", + "graphql": "^0.13.2", + "nodemon": "^1.17.5" + } +} +``` + +### Deploy server to Now + +Install the [now CLI](https://zeit.co/download#now-cli), then visit your server directory and run the `now` command: + +```sh +$ now +``` + +The `now` command immediately deploys your server to the cloud and returns the hosted project link. Send a query to your GraphQL server on `now` at `.now.sh`. + +### Deploying directly from GitHub + +If you have your GraphQL server published to GitHub, Now provides the ability to deploy straight from GitHub to the cloud. + +Assuming you'd like to deploy an instance of [apollo](https://github.com/apollographql)'s [graphql-server-example](https://github.com/apollographql/graphql-server-example), this is what you'll do: + +```sh +$ now apollographql/graphql-server-example +``` + +The `now` command deploys right away and gets the server up and running on the cloud. Furthermore, running the following command will automatically start delivering reports to Apollo Engine. + +```sh +$ now -e ENGINE_API_KEY=xxxxxxxxx apollographql/graphql-server-example +``` + +
+![Deployed GraphQL Server](../images/deployment/zeit/zeit-apollo-server.png) +

+
diff --git a/docs/source/essentials/data.md b/docs/source/essentials/data.md new file mode 100644 index 00000000000..300e4b3382f --- /dev/null +++ b/docs/source/essentials/data.md @@ -0,0 +1,242 @@ +--- +title: Fetching data with resolvers +--- + +GraphQL is the best way to work with data from **any** back-end that your product needs. It is not a mapping of your database, but rather a graph of the data sources and shapes your product is made of. Resolvers are the key to this graph. Each resolver represents a single field, and can be used to fetch data from any source(s) you may have. + +Resolvers provide the instructions for turning a GraphQL operation into data. Resolvers are organized into a one to one mapping to the fields in a GraphQL schema. This section describes resolvers' organization, every field's default resolver, and their signature. + +

Resolver map

+ +In order to respond to queries, a schema needs to have resolve functions for all fields. This collection of functions is called the "resolver map". This map relates the schema fields and types to a function. + +```js +const { gql } = require('apollo-server'); + +const schema = gql` +type Book { + title: String + author: Author +} + +type Author { + books: [Book] +} + +type Query { + author: Author +} +`; + +const resolvers = { + Query: { + author(root, args, context, info) { + return find(authors, { id: args.id }); + }, + }, + Author: { + books(author) { + return filter(books, { author: author.name }); + }, + }, +}; +``` + +Note that you don't have to put all of your resolvers in one object. Refer to the ["modularizing the schema"](/docs/graphql-tools/generate-schema.html#modularizing) section to learn how to combine multiple resolver maps into one. + +

Resolver type signature

+ +In addition to the parent resolvers' value, resolvers receive a couple more arguments. The full resolver function signature contains four positional arguments: `(parent, args, context, info)` and can return an object or [Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Using_promises). Once a promise resolves, then the children resolvers will continue executing. This is useful for fetching data from a backend. + +The resolver parameters generally follow this naming convention and are described in detail: + +1. `parent`: The object that contains the result returned from the resolver on the parent field, or, in the case of a top-level `Query` field, the `rootValue` passed from the [server configuration](./server.html). This argument enables the nested nature of GraphQL queries. +2. `args`: An object with the arguments passed into the field in the query. For example, if the field was called with `query{ key(arg: "you meant") }`, the `args` object would be: `{ "arg": "you meant" }`. +3. `context`: This is an object shared by all resolvers in a particular query, and is used to contain per-request state, including authentication information, dataloader instances, and anything else that should be taken into account when resolving the query. Read [this section](#context) for an explanation of when and how to use context. +4. `info`: This argument contains information about the execution state of the query, including the field name, path to the field from the root, and more. It's only documented in the [GraphQL.js source code](https://github.com/graphql/graphql-js/blob/c82ff68f52722c20f10da69c9e50a030a1f218ae/src/type/definition.js#L489-L500), but is extended with additional functionality by other modules, like [`apollo-cache-control`](https://github.com/apollographql/apollo-cache-control-js). + +In addition to returning GraphQL defined [scalars](./schema.html#scalar), you can return [custom scalars](../features/scalars-enums.html) for special use cases, such as JSON or big integers. + +

Resolver results

+ +Resolvers in GraphQL can return different kinds of results which are treated differently: + +1. `null` or `undefined` - this indicates the object could not be found. If your schema says that field is _nullable_, then the result will have a `null` value at that position. If the field is `non-null`, the result will "bubble up" to the nearest nullable field and that result will be set to `null`. This is to ensure that the API consumer never gets a `null` value when they were expecting a result. +2. An array - this is only valid if the schema indicates that the result of a field should be a list. The sub-selection of the query will run once for every item in this array. +3. A promise - resolvers often do asynchronous actions like fetching from a database or backend API, so they can return promises. This can be combined with arrays, so a resolver can return a promise that resolves to an array, or an array of promises, and both are handled correctly. +4. A scalar or object value - a resolver can also return any other kind of value, which doesn't have any special meaning but is simply passed down into any nested resolvers, as described in the next section. + +

Parent argument

+ +The first argument to every resolver, `parent`, can be a bit confusing at first, but it makes sense when you consider what a GraphQL query looks like: + +```graphql +query { + getAuthor(id: 5){ + name + posts { + title + author { + name # this will be the same as the name above + } + } + } +} +``` + +Every GraphQL query is a tree of function calls in the server. So the `obj` contains the result of parent resolver, in this case: + +1. `parent` in `Query.getAuthor` will be whatever the server configuration passed for `rootValue`. +2. `parent` in `Author.name` and `Author.posts` will be the result from `getAuthor`, likely an Author object from the backend. +3. `parent` in `Post.title` and `Post.author` will be one item from the `posts` result array. +4. `parent` in `Author.name` is the result from the above `Post.author` call. + +Every resolver function is called according to the nesting of the query. To understand this transition from query to resolvers from another perspective, read this [blog post](https://blog.apollographql.com/graphql-explained-5844742f195e#.fq5jjdw7t). + +

Context argument

+ +The context is how you access your shared connections and fetchers in resolvers to get data. + +The `context` is the third argument passed to every resolver. It is useful for passing things that any resolver may need, like [authentication scope](https://blog.apollographql.com/authorization-in-graphql-452b1c402a9), database connections, and custom fetch functions. Additionally, if you're using [dataloaders to batch requests](../best-practices/performance.html#Batching-data-lookups) across resolvers, you can attach them to the `context` as well. + +As a best practice, `context` should be the same for all resolvers, no matter the particular query or mutation, and resolvers should never modify it. This ensures consistency across resolvers, and helps increase development velocity. + +To provide a `context` to your resolvers, add a `context` object to the Apollo Server constructor. This constructor gets called with every request, so you can set the context based off the details of the request (like HTTP headers). + +``` +const server = new ApolloServer({ + typeDefs, + resolvers, + context: ({ req }) => ({ + authScope: getScope(req.headers.authorization) + }) +})); + +// resolver +(parent, _, context) => { + if(context.authScope !== ADMIN) throw AuthenticationError('not admin'); + ... +} +``` + +The context can also be created asynchronously, allowing database connections and other operations to complete. + +``` +context: async () => ({ + db: await client.connect(), +}) + +// resolver +(parent, _, context) => { + return context.db.query('SELECT * FROM table_name'); +} +``` + +

Default resolvers

+ +Explicit resolvers are not needed for every type, since Apollo Server provides a [default](https://github.com/graphql/graphql-js/blob/69d90c601ad5a6f49c06b4ebbc8c73d51ef03566/src/execution/execute.js#L1264-L1278) that can perform two actions depending on the contents of `parent`: + +1. Return the property from `parent` with the relevant field name +2. Calls a function on `parent` with the relevant field name and provide the remaining resolver parameters as arguments + +For the following schema, the `title` field of `Book` would not need a resolver if the result of the `books` resolver provided a list of objects that already contained a `title` field. + +```graphql +type Book { + title: String +} + +type Author { + books: [Book] +} +``` + +

Modularizing resolvers

+ +We can accomplish the same modularity with resolvers by passing around multiple resolver objects and combining them together with Lodash's `merge` or other equivalent: + +```js +// comment.js +const resolvers = { + Comment: { ... } +} + +export resolvers; +``` + +```js +// post.js +const { merge } = require('lodash'); + +const Comment = require('./comment'); +const resolvers = merge({ + Post: { ... } +}, Comment.resolvers); + +export resolvers; +``` + +```js +// schema.js +const { merge } = require('lodash'); +const Post = require('./post.js'); + +// Merge all of the resolver objects together +const resolvers = merge({ + Query: { ... } +}, Post.resolvers); + +const server = new ApolloServer({ + typeDefs, + resolvers, +}); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`) +}); +``` + +

Sending queries

+ +Once your resolver map is complete, it's time to start testing out your queries in GraphQL Playground. + +

Naming operations

+ +When sending the queries and mutations in the above examples, we've used either `query { ... }` or `mutation { ... }` respectively. While this is fine, and particularly convenient when running queries by hand, it makes sense to name the operation in order to quickly identify operations during debugging or to aggregate similar operations together for application performance metrics, for example, when using [Apollo Engine](https://engine.apollographql.com/) to monitor an API. + +Operations can be named by placing an identifier after the `query` or `mutation` keyword, as we've done with `HomeBookListing` here: + +```graphql +query HomeBookListing { + getBooks { + title + } +} +``` + +

Queries with variables

+ +In the examples above, we've used static strings as values for both queries and mutations. This is a great shortcut when running "one-off" operations, but GraphQL also provides the ability to pass variables as arguments and avoid the need for clients to dynamically manipulate operations at run-time. + +By defining a map of variables on the root `query` or `mutation` operation, which are sent from the client, variables can be used (and re-used) within the types and fields themselves. + +For example, with a slight change to the mutation we used in the previous step, we enable the client to pass `title` and `author` variables alongside the operation itself. We can also provide defaults for those variables for when they aren't explicitly set: + +```graphql +mutation HomeQuickAddBook($title: String, $author: String = "Anonymous") { + addBook(title: $title, author: $author) { + title + } +} +``` + +GraphQL clients, like [Apollo Client](https://www.apollographql.com/docs/react/), take care of sending the variables to the server separate from the operation itself: + +```json +{ + "query": "...", + "variables": { "title": "Green Eggs and Ham", "author": "Dr. Seuss" } +} +``` + +This functionality is also supported by tools like GraphQL Playground. diff --git a/docs/source/essentials/querying.md b/docs/source/essentials/querying.md new file mode 100644 index 00000000000..6401623511d --- /dev/null +++ b/docs/source/essentials/querying.md @@ -0,0 +1,7 @@ +--- +title: Sending queries +--- + + + + diff --git a/docs/source/essentials/schema.md b/docs/source/essentials/schema.md new file mode 100644 index 00000000000..54f15fc3ada --- /dev/null +++ b/docs/source/essentials/schema.md @@ -0,0 +1,249 @@ +--- +title: Understanding schema concepts +sidebar_title: Writing a schema +--- + +> Estimated time: About 6 minutes. + +A GraphQL schema is at the center of any GraphQL server implementation and describes the functionality available to the clients which connect to it. + +The core building block within a schema is the "type". Types provide a wide-range of functionality within a schema, including the ability to: + +* Create relationships between types (e.g. between a `Book` and an `Author`). +* Define which data-fetching (querying) and data-manipulation (mutating) operations can be executed by the client. +* If desired, self-explain what capabilities are available to the client (via introspection). + +By the end of this page, we hope to have explained the power of types and how they relate to a GraphQL server. + +

Schema Definition Language

+ +To make it easy to understand the capabilities of a server, GraphQL implements a human-readable schema syntax known as its Schema Definition Language, or "SDL". The SDL is used to express the _types_ available within a schema and how those types relate to each other. + +At first glance the SDL may appear to be similar to JavaScript, but this GraphQL-specific syntax must be stored as a `String`. Right now, we'll focus on explaining SDL and then go into examples of using it within JavaScript later on. + +In a simple example involving books and authors, the SDL might declare: + +```graphql +type Book { + title: String + author: Author +} + +type Author { + name: String + books: [Book] +} +``` + +It's important to note that these declarations express the _relationships_ and the _shape_ of the data to return, not where the data comes from or how it might be stored - which will be covered outside the SDL. + +By drawing these logical connections in the schema definition, we can allow the client (which is often a human developer, designing a front-end) to see what data is available and request it in a single optimized query. + +GraphQL clients (such as [Apollo Client](/docs/react)) benefit from the precision of GraphQL operations, especially when compared to traditional REST-based approaches, since they can avoid over-fetching and stitching data, which are particularly costly on slow devices or networks. + +

Scalar types

+ +Scalar types represent the leaves of an operation and always resolve to concrete data. The default scalar types which GraphQL offers are: + +* `Int`: Signed 32‐bit integer +* `Float`: Signed double-precision floating-point value +* `String`: UTF‐8 character sequence +* `Boolean`: true or false +* `ID` (serialized as `String`): A unique identifier, often used to refetch an object or as the key for a cache. While serialized as a String, ID signifies that it is not intended to be human‐readable + +These primitive types cover a majority of use cases. For other use cases, we can create [custom scalar types](../features/scalars-enums.html). + +

Object types

+ +The object type is the most common type used in a schema and represents a group of fields. Each field inside of an object type maps to another type, allowing nested types and circular references. + +```graphql +type TypeName { + fieldA: String + fieldB: Boolean + fieldC: Int + fieldD: CustomType +} + +type CustomType { + circular: TypeName +} +``` + +

The Query type

+ +A GraphQL query is for _fetching_ data and compares to the `GET` verb in REST-based APIs. + +In order to define what queries are possible on a server, the `Query` type is used within the SDL. The `Query` type is one of many root-level types which defines functionality (it doesn't actually trigger a query) for clients and acts as an entry-point to other more specific types within the schema. + +Using the books and author example we created in the SDL example of the last section, we can define multiple independent queries which are available on a server: + +```graphql +type Query { + getBooks: [Book] + getAuthors: [Author] +} +``` + +In this `Query` type, we define two types of queries which are available on this GraphQL server: + +* `getBooks`: which returns a list of `Book` objects. +* `getAuthors`: which returns a list of `Author` objects. + +Those familiar with REST-based APIs would normally find these located on separate end-points (e.g. `/api/books` and `/api/authors`), but GraphQL allows them to be queried at the same time and returned at once. + +As mentioned in the previous section, the structure in which types are organized in the SDL is important because of the relationships it creates. When a client makes a query to the server, the server will return results in a shape that matches that of the query. + +Based on the SDL defined above, a client could request a list of all books _and_ a separate list of all authors by sending a single `query` with exactly what it wishes to receive in return: + +```graphql +query { + getBooks { + title + } + + getAuthors { + name + } +} +``` + +Which would return data to the client as: + +```json +{ + "data": { + "getBooks": [ + { + "title": "..." + }, + ... + ], + "getAuthors": [ + { + "name": "..." + }, + ... + ] + } +} +``` + +While having two separate lists—a list of books and a list of authors—might be useful for some purposes, a separate desire might be to display a single list of books which includes the author for each book. + +Thanks to the relationship between `Book` and `Author`, which is defined in the SDL above, such a `query` could be expressed as: + +```graphql +query { + getBooks { + title + author { + name + } + } +} +``` + +And, without additional effort on its part, the client would receive the information in the same shape as the request: + +```json +{ + "data": { + "getBooks": [ + { + "title": "..." + "author": { + "name": "..." + } + }, + ... + ] + } +} +``` + +

The Mutation type

+ +Mutations are operations sent to the server to create, update or delete data. These are comparable to the `PUT`, `POST`, `PATCH` and `DELETE` verbs on REST-based APIs. + +Much like how the `Query` type defines the entry-points for data-fetching operations on a GraphQL server, the root-level `Mutation` type specifies the entry points for data-manipulation operations. + +For example, when imagining a situation where the API supported adding a new `Book`, the SDL might implement the following `Mutation` type: + +```graphql +type Mutation { + addBook(title: String, author: String): Book +} +``` + +This implements a single `addBook` mutation which accepts `title` and `author` as arguments (both `String` types). We'll go further into arguments (also known as "input types") in [types](../schemas/types.html), but the important thing to note here is that this mutation will return the newly-created `Book` object. + +The `Book` object will match the previously-created `Book` type (from above) and, much like the `Query` type, we specify the fields to include in the return object when sending the `mutation`: + +```graphql +mutation { + addBook(title: "Fox in Socks", author: "Dr. Seuss") { + title + author { + name + } + } +} +``` + +In the above example, we've requested the book's `title` along with the `name` of the `author`. The result returned from this mutation would be: + +```json +{ + "data": { + "addBook": { + { + "title": "Fox in Socks", + "author": { + "name": "Dr. Seuss" + } + } + } + } +} +``` + +Multiple mutations may be sent in the same request, however they will be executed in the order they are provided (in series), in order to avoid race-conditions within the operation. + +

Documenting your schema

+ +

Describing types

+ +GraphQL supports providing markdown-enabled descriptions within the schema, which makes it easy for consumers of the API to discover a field and how to use it. + +For example, the following type definition shows how to use both single-line string literals, as well as multi-line blocks. + +```graphql +"Description for the type" +type MyObjectType { + """ + Description for field + Supports **multi-line** description for your [API](http://example.com)! + """ + myField: String! + + otherField( + "Description for argument" + arg: Int + ) +} +``` + +This makes SDL-generation even easier since many GraphQL tools (like GraphQL Playground) auto-complete field names, along with the descriptions, when available. + +

Introspection

+ +Introspection is an **optional** feature, enabled by default during development, which allows clients (which are frequently developers, building an application) to automatically discover the types implemented within a GraphQL schema. + +By allowing the consumer of the API to see the full possibilities that an API can, developers can easily add new fields to existing queries. + +

Next steps

+ +At this point, we hope to have explained the basic information necessary to understand a GraphQL schema. + +In the [next step](./server.html), we'll show how to start implementing a simple GraphQL server. diff --git a/docs/source/essentials/server.md b/docs/source/essentials/server.md new file mode 100644 index 00000000000..8408c70efb4 --- /dev/null +++ b/docs/source/essentials/server.md @@ -0,0 +1,149 @@ +--- +title: Building a server +--- + +> Estimated time: About 8 minutes. + +Apollo Server provides an easy way for new, or existing, applications to get running quickly. Existing applications can take advantage of middleware and new applications can utilize an integrated web-server. Both of these servers can be configured with minimal configuration and follow industry best-practices. + +

Installation

+ +We need to install two packages to use Apollo Server, and a third package when using Apollo Server as middleware in an existing application: + +* [`apollo-server`](//npm.im/apollo-server): The Apollo Server package, which provides most of the functionality. +* [`graphql`](//npm.im/graphql): A support library, provided by Facebook. It won't be explicitly used in these examples, but is a required module and shared amongst all GraphQL libraries in the project. + +To install, run: + + npm install --save apollo-server@rc graphql + +When adding Apollo Server to an existing application, a corresponding HTTP server support package needs to be installed as well. For example, for Express this is: + + npm install --save apollo-server-express@rc graphql + +> Note: During the release candidate period, it's necessary to use the `rc` npm package, as shown in the above commands. + +

Creating a server

+ +The fastest way to get started with GraphQL is by creating a new server. Apollo Server will set an Express server up for you as long as you provide it with `typeDefs`, which is a string representing your GraphQL schema, and `resolvers`, which is a map of functions that implement your schema. + +In the following examples, we'll import two things from `apollo-server`: + +* The `ApolloServer` class, which we'll use to instantiate and start the server. +* The `gql` template literal tag, used for writing GraphQL within JavaScript code. + +```js +const { ApolloServer, gql } = require('apollo-server'); + +// The GraphQL schema +const typeDefs = gql` + type Query { + "A simple type for getting started!" + hello: String + } +`; + +// A map of functions which return data for the schema. +const resolvers = { + Query: { + hello: () => 'world' + } +}; + +const server = new ApolloServer({ + typeDefs, + resolvers, +}); +``` + +> See the [API Reference](../api/apollo-server.html) for additional options which can be passed to the `ApolloServer` constructor. + +

Starting a server

+ +At this point, we're ready to start accepting connections to the server. This is done by calling the `listen` method on the instance of `ApolloServer` which was created in the previous step: + +```js +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`) +}); +``` + +> By default, Apollo Server listens on port 4000. See the [API reference](../api/apollo-server.html) for additional `listen` options, including how to configure the port. + +

Running the server

+ +With the above configuration complete, we can now start the Node application, with Apollo Server, for the first time. This varies, but assuming a standard `index.js` configuration, might be as simple as `node index.js`. + +After you start the server it should print a message to the console indicating that it's ready: + +```shell +$ node index.js +🚀 Server ready at http://localhost:4000/ +``` + +At this point, if the message isn't printed to the console, it's possible that something went wrong. Double-check the previous steps in this guide, and try comparing the configuration to our [pre-configured example on Glitch](https://glitch.com/edit/#!/apollo-launchpad?path=server.js). + +

GraphQL Playground

+ +To explore the newly created GraphQL server, open a browser to the link shown in the console, http://localhost:4000/graphql. Apollo Server sets up GraphQL Playground for you so that you can start running queries and exploring schemas quickly. + +To run a query, copy the following query and then press the "▶️" button: + +```graphql +query { + hello +} +``` + +And the server should return a simple response: + +```json +{ + "data": { + "hello": "world" + } +} +``` + +Your server itself is hosted at http://localhost:4000/graphql. This would be the endpoint you pass to set up Apollo Client. + +

Server integrations

+ +Depending on whether we are creating a new application or an existing application, the steps will vary slightly since Apollo Server must adapt to the semantics of existing servers (e.g. Express, Hapi, etc.) + +

Middleware

+ +Existing applications generally already have middleware in place and Apollo Server works along with those middleware. To integrate with Apollo Server, we'll pass it into the `server.applyMiddleware` method as `app` to add the Apollo Server's middleware. + +> The existing application is frequently already named `app`, especially when using Express. If the application is identified by a different variable, pass the existing variable in place of `app`. + +The following code uses the `apollo-server-express` package, which can be installed with `npm install apollo-server-express@rc`. + +```js +const { ApolloServer, gql } = require('apollo-server-express'); +const { typeDefs, resolvers } = require('./schema'); + +const server = new ApolloServer({ + // These will be defined for both new or existing servers + typeDefs, + resolvers, +}); + +server.applyMiddleware({ app }); // app is from an existing express app + +app.listen({ port: 4000 }, () => + console.log(`🚀 Server ready at http://localhost:4000${server.graphqlPath}`) +) +``` + +Hapi follows the same pattern with `apollo-server-express` replaced with `apollo-server-hapi` and `app` replaced with Hapi server. `applyMiddleware` registers plugins, so it should be called with `await`. + +> When transition from `apollo-server` to an integration package, running `npm uninstall apollo-server` will remove the extra dependency. + +

Serverless

+ +Apollo Server works great in "serverless" environments such as Amazon Lambda and Microsoft Azure Functions. These implementations have some extra considerations which won't be covered in this guide. + +## Next steps + +Now that the GraphQL server is running, it's time to dive deeper into how we'll fetch data for our types. We'll get started on that in the [next step](./data.html). diff --git a/docs/source/example.md b/docs/source/example.md deleted file mode 100644 index 871f76f5935..00000000000 --- a/docs/source/example.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -title: Quick start -description: Copy and paste this code to have a GraphQL server running in 30 seconds. ---- - -Here's a complete example that sets up a GraphQL server with `apollo-server-express` and `graphql-tools`. First, make sure to install the necessary modules: - -```sh -npm install --save apollo-server-express@1 graphql-tools graphql express body-parser -``` - -Then, run this code: - -```js -const express = require('express'); -const bodyParser = require('body-parser'); -const { graphqlExpress, graphiqlExpress } = require('apollo-server-express'); -const { makeExecutableSchema } = require('graphql-tools'); - -// Some fake data -const books = [ - { - title: "Harry Potter and the Sorcerer's stone", - author: 'J.K. Rowling', - }, - { - title: 'Jurassic Park', - author: 'Michael Crichton', - }, -]; - -// The GraphQL schema in string form -const typeDefs = ` - type Query { books: [Book] } - type Book { title: String, author: String } -`; - -// The resolvers -const resolvers = { - Query: { books: () => books }, -}; - -// Put together a schema -const schema = makeExecutableSchema({ - typeDefs, - resolvers, -}); - -// Initialize the app -const app = express(); - -// The GraphQL endpoint -app.use('/graphql', bodyParser.json(), graphqlExpress({ schema })); - -// GraphiQL, a visual editor for queries -app.use('/graphiql', graphiqlExpress({ endpointURL: '/graphql' })); - -// Start the server -app.listen(3000, () => { - console.log('Go to http://localhost:3000/graphiql to run queries!'); -}); -``` - -To understand the example, read the docs about Apollo Server here, and also learn how to make a GraphQL schema in the [graphql-tools docs](https://www.apollographql.com/docs/graphql-tools/). diff --git a/docs/source/features/creating-directives.md b/docs/source/features/creating-directives.md new file mode 100644 index 00000000000..2979fdf577f --- /dev/null +++ b/docs/source/features/creating-directives.md @@ -0,0 +1,676 @@ +--- +title: Implementing directives +description: Implementing custom directives to transform schema types, fields, and arguments +--- + +Before learning how to implement schema directives, [this section](./directives.html) will provide the necessary background on schema directives and their use. + +## Implementing schema directives + +Since the GraphQL specification does not discuss any specific implementation strategy for directives, it's up to each GraphQL server framework to expose an API for implementing new directives. + +If you're using Apollo Server, you are using the [`graphql-tools`](https://github.com/apollographql/graphql-tools) npm package, which provides a convenient yet powerful tool for implementing directive syntax: the [`SchemaDirectiveVisitor`](https://github.com/apollographql/graphql-tools/blob/wip-schema-directives/src/schemaVisitor.ts) class. + +To implement a schema directive using `SchemaDirectiveVisitor`, simply create a subclass of `SchemaDirectiveVisitor` that overrides one or more of the following visitor methods: + +* `visitSchema(schema: GraphQLSchema)` +* `visitScalar(scalar: GraphQLScalarType)` +* `visitObject(object: GraphQLObjectType)` +* `visitFieldDefinition(field: GraphQLField)` +* `visitArgumentDefinition(argument: GraphQLArgument)` +* `visitInterface(iface: GraphQLInterfaceType)` +* `visitUnion(union: GraphQLUnionType)` +* `visitEnum(type: GraphQLEnumType)` +* `visitEnumValue(value: GraphQLEnumValue)` +* `visitInputObject(object: GraphQLInputObjectType)` +* `visitInputFieldDefinition(field: GraphQLInputField)` + +By overriding methods like `visitObject`, a subclass of `SchemaDirectiveVisitor` expresses interest in certain schema types such as `GraphQLObjectType` (the first parameter type of `visitObject`). + +These method names correspond to all possible [locations](https://github.com/graphql/graphql-js/blob/a62eea88d5844a3bd9725c0f3c30950a78727f3e/src/language/directiveLocation.js#L22-L33) where a directive may be used in a schema. For example, the location `INPUT_FIELD_DEFINITION` is handled by `visitInputFieldDefinition`. + +Here is one possible implementation of the `@deprecated` directive we saw above: + +```js +const { SchemaDirectiveVisitor } = require("apollo-server"); + +class DeprecatedDirective extends SchemaDirectiveVisitor { + public visitFieldDefinition(field: GraphQLField) { + field.isDeprecated = true; + field.deprecationReason = this.args.reason; + } + + public visitEnumValue(value: GraphQLEnumValue) { + value.isDeprecated = true; + value.deprecationReason = this.args.reason; + } +} +``` + +In order to apply this implementation to a schema that contains `@deprecated` directives, simply pass the `DeprecatedDirective` class to Apollo Server's constructor via the `schemaDirectives` option: + +```js +const { ApolloServer, gql } = require("apollo-server"); + +const typeDefs = gql` +type ExampleType { + newField: String + oldField: String @deprecated(reason: "Use \`newField\`.") +}`; + +const server = new ApolloServer({ + typeDefs, + resolvers, + schemaDirectives: { + deprecated: DeprecatedDirective + } +}); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`); +}); +``` + +Alternatively, if you want to modify an existing schema object, you can use the `SchemaDirectiveVisitor.visitSchemaDirectives` interface directly: + +```js +SchemaDirectiveVisitor.visitSchemaDirectives(schema, { + deprecated: DeprecatedDirective +}); +``` + +Note that a subclass of `SchemaDirectiveVisitor` may be instantiated multiple times to visit multiple different occurrences of the `@deprecated` directive. That's why you provide a class rather than an instance of that class. + +If for some reason you have a schema that uses another name for the `@deprecated` directive, but you want to use the same implementation, you can! The same `DeprecatedDirective` class can be passed with a different name, simply by changing its key in the `schemaDirectives` object passed to the Apollo Server constructor. In other words, `SchemaDirectiveVisitor` implementations are effectively anonymous, so it's up to whoever uses them to assign names to them. + +## Examples + +To appreciate the range of possibilities enabled by `SchemaDirectiveVisitor`, let's examine a variety of practical examples. + +### Uppercasing strings + +Suppose you want to ensure a string-valued field is converted to uppercase. Though this use case is simple, it's a good example of a directive implementation that works by wrapping a field's `resolve` function: + +```js +const { ApolloServer, gql, SchemaDirectiveVisitor } = require("apollo-server"); +const { defaultFieldResolver } = require("graphql"); + +const typeDefs = gql` +directive @upper on FIELD_DEFINITION + +type Query { + hello: String @upper +}`; + +class UpperCaseDirective extends SchemaDirectiveVisitor { + visitFieldDefinition(field) { + const { resolve = defaultFieldResolver } = field; + field.resolve = async function (...args) { + const result = await resolve.apply(this, args); + if (typeof result === "string") { + return result.toUpperCase(); + } + return result; + }; + } +} + +const server = new ApolloServer({ + typeDefs, + schemaDirectives: { + upper: UpperCaseDirective, + upperCase: UpperCaseDirective + } +}); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`); +}); +``` + +Notice how easy it is to handle both `@upper` and `@upperCase` with the same `UpperCaseDirective` implementation. + +### Fetching data from a REST API + +Suppose you've defined an object type that corresponds to a [REST](https://en.wikipedia.org/wiki/Representational_state_transfer) resource, and you want to avoid implementing resolver functions for every field: + +```js +const { ApolloServer, gql, SchemaDirectiveVisitor } = require("apollo-server"); + +const typeDefs = gql` +directive @rest(url: String) on FIELD_DEFINITION + +type Query { + people: [Person] @rest(url: "/api/v1/people") +}`; + +class RestDirective extends SchemaDirectiveVisitor { + public visitFieldDefinition(field) { + const { url } = this.args; + field.resolve = () => fetch(url); + } +} + +const server = new ApolloServer({ + typeDefs, + schemaDirectives: { + rest: RestDirective + } +}); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`); +}); +``` + +There are many more issues to consider when implementing a real GraphQL wrapper over a REST endpoint (such as how to do caching or pagination), but this example demonstrates the basic structure. + +### Formatting date strings + +Suppose your resolver returns a `Date` object but you want to return a formatted string to the client: + +```js +const { ApolloServer, gql, SchemaDirectiveVisitor } = require("apollo-server"); + +const typeDefs = gql` +directive @date(format: String) on FIELD_DEFINITION + +scalar Date + +type Post { + published: Date @date(format: "mmmm d, yyyy") +}`; + +class DateFormatDirective extends SchemaDirectiveVisitor { + visitFieldDefinition(field) { + const { resolve = defaultFieldResolver } = field; + const { format } = this.args; + field.resolve = async function (...args) { + const date = await resolve.apply(this, args); + return require('dateformat')(date, format); + }; + // The formatted Date becomes a String, so the field type must change: + field.type = GraphQLString; + } +} + +const server = new ApolloServer({ + typeDefs, + schemaDirectives: { + date: DateFormatDirective + } +}); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`); +}); +``` + +Of course, it would be even better if the schema author did not have decide on a specific `Date` format, but could instead leave that decision to the client. To make this work, the directive just needs to add an additional argument to the field: + +```js +const { ApolloServer, gql, SchemaDirectiveVisitor } = require("apollo-server"); +const formatDate = require("dateformat"); +const { defaultFieldResolver, GraphQLString } = require("graphql"); + +const typeDefs = gql` +directive @date( + defaultFormat: String = "mmmm d, yyyy" +) on FIELD_DEFINITION + +scalar Date + +type Query { + today: Date @date +}`; + +class FormattableDateDirective extends SchemaDirectiveVisitor { + public visitFieldDefinition(field) { + const { resolve = defaultFieldResolver } = field; + const { defaultFormat } = this.args; + + field.args.push({ + name: 'format', + type: GraphQLString + }); + + field.resolve = async function ( + source, + { format, ...otherArgs }, + context, + info, + ) { + const date = await resolve.call(this, source, otherArgs, context, info); + // If a format argument was not provided, default to the optional + // defaultFormat argument taken by the @date directive: + return formatDate(date, format || defaultFormat); + }; + + field.type = GraphQLString; + } +} + +const server = new ApolloServer({ + typeDefs, + schemaDirectives: { + date: FormattableDateDirective + } +}); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`); +}); +``` + +Now the client can specify a desired `format` argument when requesting the `Query.today` field, or omit the argument to use the `defaultFormat` string specified in the schema: + +```js +const { request } = require("graphql-request"); + +server.listen().then(({ url }) => { + request(url, `query { today }`).then(result => { + // Logs with the default "mmmm d, yyyy" format: + console.log(result.data.today); + }); + + request(url, `query { + today(format: "d mmm yyyy") + }`).then(result => { + // Logs with the requested "d mmm yyyy" format: + console.log(result.data.today); + }); +}) +``` + +### Marking strings for internationalization + +Suppose you have a function called `translate` that takes a string, a path identifying that string's role in your application, and a target locale for the translation. + +Here's how you might make sure `translate` is used to localize the `greeting` field of a `Query` type: + +```js +const { ApolloServer, gql, SchemaDirectiveVisitor } = require("apollo-server"); + +const typeDefs = gql` +directive @intl on FIELD_DEFINITION + +type Query { + greeting: String @intl +}`; + +class IntlDirective extends SchemaDirectiveVisitor { + visitFieldDefinition(field, details) { + const { resolve = defaultFieldResolver } = field; + field.resolve = async function (...args) { + const context = args[2]; + const defaultText = await resolve.apply(this, args); + // In this example, path would be ["Query", "greeting"]: + const path = [details.objectType.name, field.name]; + return translate(defaultText, path, context.locale); + }; + } +} + +const server = new ApolloServer({ + typeDefs, + schemaDirectives: { + intl: IntlDirective + } +}); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`); +}); +``` + +GraphQL is great for internationalization, since a GraphQL server can access unlimited translation data, and clients can simply ask for the translations they need. + +### Enforcing access permissions + +Imagine a hypothetical `@auth` directive that takes an argument `requires` of type `Role`, which defaults to `ADMIN`. This `@auth` directive can appear on an `OBJECT` like `User` to set default access permissions for all `User` fields, as well as appearing on individual fields, to enforce field-specific `@auth` restrictions: + +```gql +directive @auth( + requires: Role = ADMIN, +) on OBJECT | FIELD_DEFINITION + +enum Role { + ADMIN + REVIEWER + USER + UNKNOWN +} + +type User @auth(requires: USER) { + name: String + banned: Boolean @auth(requires: ADMIN) + canPost: Boolean @auth(requires: REVIEWER) +} +``` + +What makes this example tricky is that the `OBJECT` version of the directive needs to wrap all fields of the object, even though some of those fields may be individually wrapped by `@auth` directives at the `FIELD_DEFINITION` level, and we would prefer not to rewrap resolvers if we can help it: + +```js +const { ApolloServer, gql, SchemaDirectiveVisitor } = require("apollo-server"); + +class AuthDirective extends SchemaDirectiveVisitor { + visitObject(type) { + this.ensureFieldsWrapped(type); + type._requiredAuthRole = this.args.requires; + } + // Visitor methods for nested types like fields and arguments + // also receive a details object that provides information about + // the parent and grandparent types. + visitFieldDefinition(field, details) { + this.ensureFieldsWrapped(details.objectType); + field._requiredAuthRole = this.args.requires; + } + + ensureFieldsWrapped(objectType) { + // Mark the GraphQLObjectType object to avoid re-wrapping: + if (objectType._authFieldsWrapped) return; + objectType._authFieldsWrapped = true; + + const fields = objectType.getFields(); + + Object.keys(fields).forEach(fieldName => { + const field = fields[fieldName]; + const { resolve = defaultFieldResolver } = field; + field.resolve = async function (...args) { + // Get the required Role from the field first, falling back + // to the objectType if no Role is required by the field: + const requiredRole = + field._requiredAuthRole || + objectType._requiredAuthRole; + + if (! requiredRole) { + return resolve.apply(this, args); + } + + const context = args[2]; + const user = await getUser(context.headers.authToken); + if (! user.hasRole(requiredRole)) { + throw new Error("not authorized"); + } + + return resolve.apply(this, args); + }; + }); + } +} + +const server = new ApolloServer({ + typeDefs, + schemaDirectives: { + auth: AuthDirective, + authorized: AuthDirective, + authenticated: AuthDirective + } +}); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`); +}); +``` + +One drawback of this approach is that it does not guarantee fields will be wrapped if they are added to the schema after `AuthDirective` is applied, and the whole `getUser(context.headers.authToken)` is a made-up API that would need to be fleshed out. In other words, we’ve glossed over some of the details that would be required for a production-ready implementation of this directive, though we hope the basic structure shown here inspires you to find clever solutions to the remaining problems. + +### Enforcing value restrictions + +Suppose you want to enforce a maximum length for a string-valued field: + +```js +const { ApolloServer, gql, SchemaDirectiveVisitor } = require('apollo-server'); +const { GraphQLScalarType, GraphQLNonNull } = require('graphql'); + +const typeDefs = gql` + directive @length(max: Int) on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + + type Query { + books: [Book] + } + + type Book { + title: String @length(max: 50) + } + + type Mutation { + createBook(book: BookInput): Book + } + + input BookInput { + title: String! @length(max: 50) + } +`; + +class LengthDirective extends SchemaDirectiveVisitor { + visitInputFieldDefinition(field) { + this.wrapType(field); + } + + visitFieldDefinition(field) { + this.wrapType(field); + } + + // Replace field.type with a custom GraphQLScalarType that enforces the + // length restriction. + wrapType(field) { + if ( + field.type instanceof GraphQLNonNull && + field.type.ofType instanceof GraphQLScalarType + ) { + field.type = new GraphQLNonNull( + new LimitedLengthType(field.type.ofType, this.args.max), + ); + } else if (field.type instanceof GraphQLScalarType) { + field.type = new LimitedLengthType(field.type, this.args.max); + } else { + throw new Error(`Not a scalar type: ${field.type}`); + } + } +} + +class LimitedLengthType extends GraphQLScalarType { + constructor(type, maxLength) { + super({ + name: `LengthAtMost${maxLength}`, + + // For more information about GraphQLScalar type (de)serialization, + // see the graphql-js implementation: + // https://github.com/graphql/graphql-js/blob/31ae8a8e8312/src/type/definition.js#L425-L446 + + serialize(value) { + value = type.serialize(value); + assert.isAtMost(value.length, maxLength); + return value; + }, + + parseValue(value) { + return type.parseValue(value); + }, + + parseLiteral(ast) { + return type.parseLiteral(ast); + }, + }); + } +} + +const server = new ApolloServer({ + typeDefs, + resolvers, + schemaDirectives: { + length: LengthDirective, + }, +}); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`); +}); +``` + +### Synthesizing unique IDs + +Suppose your database uses incrementing IDs for each resource type, so IDs are not unique across all resource types. Here’s how you might synthesize a field called `uid` that combines the object type with various field values to produce an ID that’s unique across your schema: + +```js +const { ApolloServer, gql, SchemaDirectiveVisitor } = require("apollo-server"); +const { GraphQLID } = require("graphql"); +const { createHash } = require("crypto"); + +const typeDefs = gql` +directive @uniqueID( + # The name of the new ID field, "uid" by default: + name: String = "uid" + + # Which fields to include in the new ID: + from: [String] = ["id"] +) on OBJECT + +# Since this type just uses the default values of name and from, +# we don't have to pass any arguments to the directive: +type Location @uniqueID { + id: Int + address: String +} + +# This type uses both the person's name and the personID field, +# in addition to the "Person" type name, to construct the ID: +type Person @uniqueID(from: ["name", "personID"]) { + personID: Int + name: String +}`; + +class UniqueIdDirective extends SchemaDirectiveVisitor { + visitObject(type) { + const { name, from } = this.args; + const fields = type.getFields(); + if (name in fields) { + throw new Error(`Conflicting field name ${name}`); + } + fields[name] = { + name, + type: GraphQLID, + description: 'Unique ID', + args: [], + resolve(object) { + const hash = createHash("sha1"); + hash.update(type.name); + from.forEach(fieldName => { + hash.update(String(object[fieldName])); + }); + return hash.digest("hex"); + } + }; + } +} + +const server = new ApolloServer({ + typeDefs, + resolvers, + schemaDirectives: { + uniqueID: UniqueIdDirective + } +}); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`); +}); +``` + +## Declaring schema directives + +While the above examples should be sufficient to implement any `@directive` used in your schema, SDL syntax also supports declaring the names, argument types, default argument values, and permissible locations of any available directives: + +```js +directive @auth( + requires: Role = ADMIN, +) on OBJECT | FIELD_DEFINITION + +enum Role { + ADMIN + REVIEWER + USER + UNKNOWN +} + +type User @auth(requires: USER) { + name: String + banned: Boolean @auth(requires: ADMIN) + canPost: Boolean @auth(requires: REVIEWER) +} +``` + +This hypothetical `@auth` directive takes an argument named `requires` of type `Role`, which defaults to `ADMIN` if `@auth` is used without passing an explicit `requires` argument. The `@auth` directive can appear on an `OBJECT` like `User` to set a default access control for all `User` fields, and also on individual fields, to enforce field-specific `@auth` restrictions. + +Enforcing the requirements of the declaration is something a `SchemaDirectiveVisitor` implementation could do itself, in theory, but the SDL syntax is easer to read and write, and provides value even if you're not using the `SchemaDirectiveVisitor` abstraction. + +However, if you're implementing a reusable `SchemaDirectiveVisitor` for public consumption, you will probably not be the person writing the SDL syntax, so you may not have control over which directives the schema author decides to declare, and how. That's why a well-implemented, reusable `SchemaDirectiveVisitor` should consider overriding the `getDirectiveDeclaration` method: + +```js +const { ApolloServer, gql, SchemaDirectiveVisitor } = require("apollo-server"); +const { DirectiveLocation, GraphQLDirective, GraphQLEnumType } = require("graphql"); + +class AuthDirective extends SchemaDirectiveVisitor { + public visitObject(object: GraphQLObjectType) {...} + public visitFieldDefinition(field: GraphQLField) {...} + + public static getDirectiveDeclaration( + directiveName: string, + schema: GraphQLSchema, + ): GraphQLDirective { + const previousDirective = schema.getDirective(directiveName); + if (previousDirective) { + // If a previous directive declaration exists in the schema, it may be + // better to modify it than to return a new GraphQLDirective object. + previousDirective.args.forEach(arg => { + if (arg.name === 'requires') { + // Lower the default minimum Role from ADMIN to REVIEWER. + arg.defaultValue = 'REVIEWER'; + } + }); + + return previousDirective; + } + + // If a previous directive with this name was not found in the schema, + // there are several options: + // + // 1. Construct a new GraphQLDirective (see below). + // 2. Throw an exception to force the client to declare the directive. + // 3. Return null, and forget about declaring this directive. + // + // All three are valid options, since the visitor will still work without + // any declared directives. In fact, unless you're publishing a directive + // implementation for public consumption, you can probably just ignore + // getDirectiveDeclaration altogether. + + return new GraphQLDirective({ + name: directiveName, + locations: [ + DirectiveLocation.OBJECT, + DirectiveLocation.FIELD_DEFINITION, + ], + args: { + requires: { + // Having the schema available here is important for obtaining + // references to existing type objects, such as the Role enum. + type: (schema.getType('Role') as GraphQLEnumType), + // Set the default minimum Role to REVIEWER. + defaultValue: 'REVIEWER', + } + }] + }); + } +} +``` + +Since the `getDirectiveDeclaration` method receives not only the name of the directive but also the `GraphQLSchema` object, it can modify and/or reuse previous declarations found in the schema, as an alternative to returning a totally new `GraphQLDirective` object. Either way, if the visitor returns a non-null `GraphQLDirective` from `getDirectiveDeclaration`, that declaration will be used to check arguments and permissible locations. + +## What about query directives? + +As its name suggests, the `SchemaDirectiveVisitor` abstraction is specifically designed to enable transforming GraphQL schemas based on directives that appear in your SDL text. + +While directive syntax can also appear in GraphQL queries sent from the client, implementing query directives would require runtime transformation of query documents. We have deliberately restricted this implementation to transformations that take place at server construction time. + +We believe confining this logic to your schema is more sustainable than burdening your clients with it, though you can probably imagine a similar sort of abstraction for implementing query directives. If that possibility becomes a desire that becomes a need for you, let us know, and we may consider supporting query directives in a future version of these tools. diff --git a/docs/source/features/data-sources.md b/docs/source/features/data-sources.md new file mode 100644 index 00000000000..c004677707c --- /dev/null +++ b/docs/source/features/data-sources.md @@ -0,0 +1,271 @@ +--- +title: Data sources +description: Caching Partial Query Results +--- + +Data sources are classes that encapsulate fetching data from a particular service, with built-in support for caching, deduplication, and error handling. You write the code that is specific to interacting with your backend, and Apollo Server takes care of the rest. + +## REST Data Source + +A `RESTDataSource` is responsible for fetching data from a given REST API. + +To get started, install the release candidate of the REST data source: + +```bash +npm install apollo-datasource-rest@rc +``` + +To define a data source, extend the `RESTDataSource` class and implement the data fetching methods that your resolvers require. Your implementation of these methods can call on convenience methods built into the `RESTDataSource` class to perform HTTP requests, while making it easy to build up query parameters, parse JSON results, and handle errors. + +```js +const { RESTDataSource } = require('apollo-datasource-rest'); + +class MoviesAPI extends RESTDataSource { + constructor() { + super(); + this.baseURL = 'https://movies-api.example.com/'; + } + + async getMovie(id) { + return this.get(`movies/${id}`); + } + + async getMostViewedMovies(limit = 10) { + const data = await this.get('movies', { + per_page: limit, + order_by: 'most_viewed', + }); + return data.results; + } +} +``` + +### HTTP Methods + +The `get` method on the `RESTDataSource` makes an HTTP `GET` request. Similarly, there are methods built-in to allow for `POST`, `PUT`, `PATCH`, and `DELETE` requests. + +```js +class MoviesAPI extends RESTDataSource { + constructor() { + super(); + this.baseURL = 'https://movies-api.example.com/'; + } + + // an example making an HTTP POST request + async postMovie(movie) { + return this.post( + `movies`, // path + movie, // request body + ); + } + + // an example making an HTTP PUT request + async newMovie(movie) { + return this.put( + `movies`, // path + movie, // request body + ); + } + + // an example making an HTTP PATCH request + async updateMovie(movie) { + return this.patch( + `movies`, // path + { id: movie.id, movie }, // request body + ); + } + + // an example making an HTTP DELETE request + async deleteMovie(movie) { + return this.delete( + `movies/${movie.id}`, // path + ); + } +} +``` + +All of the HTTP helper functions (`get`, `put`, `post`, `patch`, and `delete`) accept a third `options` parameter, which can be used to set things like headers and referrers. For more info on the options available, see MDN's [fetch docs](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters). + +### Intercepting fetches + +Data sources allow you to intercept fetches to set headers, query parameters, or make other changes to the outgoing request. This is most often used for authorization or other common concerns that apply to all requests. Data sources also get access to the GraphQL context, which is a great place to store a user token or other information you need to have available. + +You can easily set a header on every request: + +```js +class PersonalizationAPI extends RESTDataSource { + willSendRequest(request) { + request.headers.set('Authorization', this.context.token); + } +} +``` + +Or add a query parameter: + +```js +class PersonalizationAPI extends RESTDataSource { + willSendRequest(request) { + request.params.set('api_key', this.context.token); + } +} +``` + +If you're using TypeScript, make sure to import the `RequestOptions` type: + +```typescript +import { RESTDataSource, RequestOptions } from 'apollo-datasource-rest'; + +class PersonalizationAPI extends RESTDataSource { + baseURL = 'https://personalization-api.example.com/'; + + willSendRequest(request: RequestOptions) { + request.headers.set('Authorization', this.context.token); + } +} +``` + +### Resolving URLs dynamically + +In some cases, you'll want to set the URL based on the environment or other contextual values. You can use a getter for this: + +```js +get baseURL() { + if (this.context.env === 'development') { + return 'https://movies-api-dev.example.com/'; + } else { + return 'https://movies-api.example.com/'; + } +} +``` + +If you need more customization, including the ability to resolve a URL asynchronously, you can also override `resolveURL`: + +```js +async resolveURL(request: RequestOptions) { + if (!this.baseURL) { + const addresses = await resolveSrv(request.path.split("/")[1] + ".service.consul"); + this.baseURL = addresses[0]; + } + return super.resolveURL(request); +} +``` + +## Accessing data sources from resolvers + +To give resolvers access to data sources, you pass them as options to the `ApolloServer` constructor: + +```js +const server = new ApolloServer({ + typeDefs, + resolvers, + dataSources: () => { + return { + moviesAPI: new MoviesAPI(), + personalizationAPI: new PersonalizationAPI(), + }; + }, + context: () => { + return { + token: 'foo', + }; + }, +}); +``` + +Apollo Server will put the data sources on the context for every request, so you can access them from your resolvers. It will also give your data sources access to the context. (The reason for not having users put data sources on the context directly is because that would lead to a circular dependency.) + +From our resolvers, we can access the data source and return the result: + +```js + Query: { + movie: async (_source, { id }, { dataSources }) => { + return dataSources.moviesAPI.getMovie(id); + }, + mostViewedMovies: async (_source, _args, { dataSources }) => { + return dataSources.moviesAPI.getMostViewedMovies(); + }, + favorites: async (_source, _args, { dataSources }) => { + return dataSources.personalizationAPI.getFavorites(); + }, + }, +``` + +## What about DataLoader? + +[DataLoader](https://github.com/facebook/dataloader) was designed by Facebook with a specific use case in mind: deduplicating and batching object loads from a data store. It provides a memoization cache, which avoids loading the same object multiple times during a single GraphQL request, and it coalesces loads that occur during a single tick of the event loop into a batched request that fetches multiple objects at once. + +Although DataLoader is great for that use case, it’s less helpful when loading data from REST APIs because its primary feature is _batching_, not _caching_. What we’ve found to be far more important when layering GraphQL over REST APIs is having a resource cache that saves data across multiple GraphQL requests, can be shared across multiple GraphQL servers, and has cache management features like expiry and invalidation that leverage standard HTTP cache control headers. + +#### Batching + +Most REST APIs don't support batching, and if they do, using a batched endpoint may actually jeopardize caching. When you fetch data in a batch request, the response you receive is for the exact combination of resources you're requesting. Unless you request that same combination again, future requests for the same resource won't be served from cache. +Our recommendation is to restrict batching to requests that can't be cached. In those cases, you can actually take advantage of DataLoader as a private implementation detail inside your data source. + +```js +class PersonalizationAPI extends RESTDataSource { + constructor() { + super(); + this.baseURL = 'https://personalization-api.example.com/'; + } + + willSendRequest(request) { + request.headers.set('Authorization', this.context.token); + } + + private progressLoader = new DataLoader(async (ids) => { + const progressList = await this.get('progress', { + ids: ids.join(','), + }); + return ids.map(id => + progressList.find((progress) => progress.id === id), + ); + }); + + async getProgressFor(id) { + return this.progressLoader.load(id); + } +``` + +## Using Memcached/Redis as a cache storage backend + +By default, resource caching will use an in memory LRU cache. When running multiple server instances, you'll want to use a shared cache backend instead. That's why Apollo Server also includes support for using [Memcached](../../../packages/apollo-server-memcached) or [Redis](../../../packages/apollo-server-redis) as your backing store. You can specify which one to use by creating an instance and passing it into the Apollo Server constructor: + +```js +const { MemcachedCache } = require('apollo-server-memcached'); + +const server = new ApolloServer({ + typeDefs, + resolvers, + cache: new MemcachedCache( + ['memcached-server-1', 'memcached-server-2', 'memcached-server-3'], + { retries: 10, retry: 10000 }, // Options + ), + dataSources: () => ({ + moviesAPI: new MoviesAPI(), + }), +}); +``` + +For documentation of the options you can pass to the underlying Memcached client, look [here](https://github.com/3rd-Eden/memcached). + +```js +const { RedisCache } = require('apollo-server-redis'); + +const server = new ApolloServer({ + typeDefs, + resolvers, + cache: new RedisCache({ + host: 'redis-server', + // Options are passed through to the Redis client + }), + dataSources: () => ({ + moviesAPI: new MoviesAPI(), + }), +}); +``` + +For documentation of the options you can pass to the underlying Redis client, look [here](https://github.com/NodeRedis/node_redis). + +## Implementing your own cache backend + +Apollo Server exposes a `KeyValueCache` interface that you can use to implement connectors to other data stores, or to optimize for the query characteristics of your application. More information can be found in the package readme for [apollo-server-caching](https://www.npmjs.com/package/apollo-server-caching). diff --git a/docs/source/features/directives.md b/docs/source/features/directives.md new file mode 100644 index 00000000000..c1062603601 --- /dev/null +++ b/docs/source/features/directives.md @@ -0,0 +1,58 @@ +--- +title: Using schema directives +description: Using schema directives to transform schema types, fields, and arguments +--- + +A _directive_ is an identifier preceded by a `@` character, optionally followed by a list of named arguments, which can appear after almost any form of syntax in the GraphQL query or schema languages. Here's an example from the [GraphQL draft specification](http://facebook.github.io/graphql/draft/#sec-Type-System.Directives) that illustrates several of these possibilities: + +```typescript +directive @deprecated( + reason: String = "No longer supported" +) on FIELD_DEFINITION | ENUM_VALUE + +type ExampleType { + newField: String + oldField: String @deprecated(reason: "Use `newField`.") +} +``` + +As you can see, the usage of `@deprecated(reason: ...)` _follows_ the field that it pertains to (`oldField`), though the syntax might remind you of "decorators" in other languages, which usually appear on the line above. Directives are typically _declared_ once, using the `directive @deprecated ... on ...` syntax, and then _used_ zero or more times throughout the schema document, using the `@deprecated(reason: ...)` syntax. + +## Default Directives + +GraphQL provides several default directives: [`@deprecated`](http://facebook.github.io/graphql/draft/#sec--deprecated), [`@skip`](http://facebook.github.io/graphql/draft/#sec--skip), and [`@include`](http://facebook.github.io/graphql/draft/#sec--include). + + * [`@deprecated`](http://facebook.github.io/graphql/draft/#sec--deprecated)`(message: String)` - marks field as deprecated with message + * [`@skip`](http://facebook.github.io/graphql/draft/#sec--skip)`(if: Boolean!)` - GraphQL execution skips the field if true by not calling the resolver + * [`@include`](http://facebook.github.io/graphql/draft/#sec--include)`(if: Boolean!)` - Calls resolver for annotated field if true + +## Using custom schema directives + +Import the implementation of the directive, then pass it to Apollo server via the `schemaDirectives` argument, which is an object that maps directive names to directive implementations: + +```js +const { ApolloServer, gql } = require('apollo-server'); +const { RenameDirective } = require('rename-directive-package'); + +const typeDefs = gql` +type Person @rename(to: "Human") { + name: String! + currentDateMinusDateOfBirth: Int @rename(to: "age") +}`; + +//Create and start your apollo server +const server = new ApolloServer({ + typeDefs, + resolvers, + schemaDirectives: { + rename: RenameDirective, + }, + app, +}); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`); +}); +``` + +The implementation of `RenameDirective` takes care of changing the resolver and modifying the schema if necessary. To learn how to implement your own schema directives, read through [this section](./creating-directives.html). diff --git a/docs/source/features/errors.md b/docs/source/features/errors.md new file mode 100644 index 00000000000..7cddd0eb924 --- /dev/null +++ b/docs/source/features/errors.md @@ -0,0 +1,137 @@ +--- +title: Error handling +description: Making errors actionable on the client and server +--- + +Apollo server provides a couple predefined errors, including +`AuthenticationError`, `ForbiddenError`, `UserInputError` and a generic +`ApolloError`. These errors are designed to enhance errors thrown before and during GraphQL execution. The provided errors focus on debugging a Apollo server as well as enabling the client to take specific action based on an error. + +When an error occurs in Apollo server both inside and outside of resolvers, each error inside of the `errors` array will contain an object at `extensions` that contains the information added by Apollo server. + +## Default information + +The first step to improving the usability of a server is providing the error stack trace by default. The following example demonstrates the response returned from Apollo server with a resolver that throws a node [`SystemError`](https://nodejs.org/api/errors.html#errors_system_errors). + +```js line=14-16 +const { + ApolloServer, + gql, +} = require('apollo-server'); + +const typeDefs = gql` + type Query { + readError: String + } +`; + +const resolvers = { + Query: { + readError: (parent, args, context) => { + fs.readFileSync('/does/not/exist'); + }, + }, +}; +``` + +The response will return: + +![Screenshot demonstrating an error stacktrace and additional](../images/features/error-stacktrace.png) + +> To disable stacktraces for production, pass `debug: false` to the Apollo server constructor or set the `NODE_ENV` environment variable to 'production' or 'test' + +## Codes + +In addition to stacktraces, Apollo Server's exported errors specify a human-readable string in the `code` field of `extensions` that enables the client to perform corrective actions. In addition to improving the client experience, the `code` field allows the server to categorize errors. For example, an `AuthenticationError` sets the code to `UNAUTHENTICATED`, which enables the client to reauthenticate and would generally be ignored as a server anomaly. + +```js line=4,15-17 +const { + ApolloServer, + gql, + AuthenticationError, +} = require('apollo-server'); + +const typeDefs = gql` + type Query { + authenticationError: String + } +`; + +const resolvers = { + Query: { + authenticationError: (parent, args, context) => { + throw new AuthenticationError('must authenticate'); + }, + }, +}; +``` + +The response will return: + +![Screenshot demonstrating unauthenticated error code](../images/features/error-code.png) + +## Augmenting error details + +When clients provide bad input, you may want to return additional information +like a localized message for each field or argument that was invalid. The +following example demonstrates how you can use `UserInputError` to augment +your error messages with additional details. + +```js line=15-21 +const { + ApolloServer, + UserInputError, + gql, +} = require('apollo-server'); + +const typeDefs = gql` + type Mutation { + userInputError(input: String): String + } +`; + +const resolvers = { + Mutation: { + userInputError: (parent, args, context, info) => { + if (args.input !== 'expected') { + throw new UserInputError('Form Arguments invalid', { + invalidArgs: Object.keys(args), + }); + } + }, + }, +}; +``` + +The response will return: + +![Screenshot demonstrating augmented error](../images/features/error-user-input.png) + +## Other errors + +If you need to define other error codes that are specific to your +application, you can use the base `ApolloError` class. + +```js +new ApolloError(message, code, additionalProperties); +``` + +## Masking and logging errors + +The Apollo server constructor accepts a `formatError` function that is run on each error passed back to the client. This can be used to mask errors as well as logging. +This example demonstrates masking + +```js line=4-7 +const server = new ApolloServer({ + typeDefs, + resolvers, + formatError: error => { + console.log(error); + return new Error('Internal server error'); + }, +}); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`); +}); +``` diff --git a/docs/source/features/graphql-playground.md b/docs/source/features/graphql-playground.md new file mode 100644 index 00000000000..429ecfce303 --- /dev/null +++ b/docs/source/features/graphql-playground.md @@ -0,0 +1,55 @@ +--- +title: GraphQL Playground +description: Visually exploring an Apollo Server +--- + +[GraphQL Playground](https://github.com/prismagraphql/graphql-playground) is a graphical, interactive, in-browser GraphQL IDE, created by [Prisma](https://www.prisma.io/) and based on [GraphiQL](https://github.com/graphql/graphiql). + +In development, Apollo Server enables GraphQL Playground on the same URL as the GraphQL server itself (e.g. `http://localhost:4000/graphql`) and automatically serves the GUI to web browsers. When `NODE_ENV` is set to `production`, GraphQL Playground (as well as introspection) is disabled as a production best-practice. + +
+![GraphQL Playground](../images/graphql-playground.png) +
+ +## Configuring Playground + +The Apollo Server constructor contains the ability to configure GraphQL Playground with the `playground` configuration option. The options can be found on GraphQL Playground's [documentation](https://github.com/prismagraphql/graphql-playground/#usage) + +```js +new ApolloServer({ +typeDefs, +resolvers, +playground: { + settings: { + 'editor.theme': 'light', + }, + tabs: [ + { + endpoint, + query: defaultQuery, + }, + ], +}, +}); +``` + +## Enabling GraphQL Playground in production + +To enable GraphQL Playground in production, introspection and the playground can be enabled explicitly in the following manner. + +```js line=7-8 +const { ApolloServer } = require('apollo-server'); +const { typeDefs, resolvers } = require('./schema'); + +const server = new ApolloServer({ + typeDefs, + resolvers, + introspection: true, + playground: true, +}); + + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`); +}); +``` diff --git a/docs/source/features/metrics.md b/docs/source/features/metrics.md new file mode 100644 index 00000000000..9b25e1ce6c9 --- /dev/null +++ b/docs/source/features/metrics.md @@ -0,0 +1,84 @@ +--- +title: Monitoring and metrics +description: How to monitor Apollo Server's performance +--- + +Understanding the behavior of GraphQL execution inside of Apollo Server is critical to developing and running a production GraphQL layer. Apollo Server enables GraphQL monitoring in Apollo Engine and provides more primitive native mechanisms to log each phase of a GraphQL request. + +## Apollo Engine + +Apollo Engine provides an integrated hub for all GraphQL performance data that is free for one million queries per month. With an API key from the [Engine UI](https://engine.apollographql.com/), Apollo Server reports performance and error data out-of-band. Apollo Engine then aggregates and displays information for [queries](https://www.apollographql.com/docs/engine/query-tracking.html), [requests](https://www.apollographql.com/docs/engine/performance.html), the [schema](https://www.apollographql.com/docs/engine/schema-analytics.html), and [errors](https://www.apollographql.com/docs/engine/error-tracking.html). In addition to aggregating data, Apollo Server provides [proactive alerts](https://www.apollographql.com/docs/engine/alerts.html), [daily slack reports](https://www.apollographql.com/docs/engine/reports.html), and [Datadog integration](https://www.apollographql.com/docs/engine/datadog.html). + +To set up Apollo Server with Engine, [click here](https://engine.apollographql.com/) to get an Engine API key. This API key can be passed directly to the Apollo Server constructor. + +```js line=6-8 +const { ApolloServer } = require("apollo-server"); + +const server = new ApolloSever({ + typeDefs, + resolvers, + engine: { + apiKey: "YOUR API KEY HERE" + } +}); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`); +}); +``` + +The API key can also be set with the `ENGINE_API_KEY` environment variable. Setting an environment variable can be done in commandline as seen below or with the [dotenv npm package](https://www.npmjs.com/package/dotenv). + +```bash +#Replace YOUR_API_KEY with the api key for you service in the Engine UI +ENGINE_API_KEY=YOUR_API_KEY node start-server.js +``` + +## Logging + +Apollo Server provides two ways to log a server: per input, response, and errors or periodically throughout a request's lifecycle. Treating the GraphQL execution as a black box by logging the inputs and outputs of the system allows developers to diagnose issues quickly without being mired by lower level logs. Once a problem has been found at a high level, the lower level logs enable accurate tracing of how a request was handled. + +### High Level Logging + +To log, Apollo Server provides: `formatError` and `formatResponse`. This example uses `console.log` to record the information, servers can use other more sophisticated tools. + +```js +const server = new ApolloServer({ + typeDefs, + resolvers, + formatError: error => { + console.log(error); + return error; + }, + formatResponse: response => { + console.log(response); + return response; + }, +}); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`); +}); +``` + +### Granular Logs + +For more advanced cases, Apollo Server provides an experimental api that accepts an array of `graphql-extensions` to the `extensions` field. These extensions receive a variety of lifecycle calls for each phase of a GraphQL request and can keep state, such as the request headers. + +```js +const { ApolloServer } = require('apollo-server'); +const LoggingExtension = require('./logging'); + +const server = new ApolloServer({ + typeDefs, + resolvers, + extensions: [() => new LoggingExtension()] +}); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`); +}); +``` + +For example the `logFunction` from Apollo Server 1 can be implemented as an [extension](https://github.com/apollographql/apollo-server/blob/8914b135df9840051fe81cc9224b444cfc5b61ab/packages/apollo-server-core/src/logging.ts) and could be modified to add additional state or functionality. The example uses a beta of `graphql-extensions`, which can be added to a project with `npm install graphql-extensions@beta`. + diff --git a/docs/source/features/mocking.md b/docs/source/features/mocking.md new file mode 100644 index 00000000000..02c0168909d --- /dev/null +++ b/docs/source/features/mocking.md @@ -0,0 +1,173 @@ +--- +title: Mocking +description: Mock your GraphQL data based on a schema. +--- + +The strongly-typed nature of a GraphQL API lends itself extremely well to mocking. This is an important part of a GraphQL-First development process, because it enables frontend developers to build out UI components and features without having to wait for a backend implementation. + +Even when the UI is already built, it can let you test your UI without waiting on slow database requests, or build out a component harness using a tool like React Storybook without needing to start a real GraphQL server. + +## Default mock example + +This example demonstrates mocking a GraphQL schema with just one line of code, using `apollo-server`'s default mocking logic. + +```js +const { ApolloServer, gql } = require('apollo-server'); + +const typeDefs = gql` +type Query { + hello: String +} +`; + +const server = new ApolloServer({ + typeDefs, + mocks: true, +}); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`) +}); +``` + +> Note: If `typeDefs` has custom scalar types, `resolvers` must still contain the `serialize`, `parseValue`, and `parseLiteral` functions + +Mocking logic simply looks at the type definitions and returns a string where a string is expected, a number for a number, etc. This provides the right shape of result. For more sophisticated testing, mocks can be customized them to a particular data model. + +## Customizing mocks + +In addition to a boolean, `mocks` can be an object that describes custom mocking logic, which is structured similarly to `resolvers` with a few extra features aimed at mocking. Namely `mocks` accepts functions for specific types in the schema that are called when that type is expected. The functions in `mocks` would be used when no resolver in `resolvers` is specified. In this example `hello` will return `'Hello'` and `resolved` will return `'Resolved'`. + +```js line=16-20 +const { ApolloServer } = require('apollo-server'); + +const typeDefs = gql` +type Query { + hello: String + resolved: String +} +`; + +const resolvers = { + Query: { + resolved: () => 'Resolved', + }, +}; + +const mocks = { + Int: () => 6, + Float: () => 22.1, + String: () => 'Hello', +}; + +const server = new ApolloServer({ + typeDefs, + resolvers, + mocks, +}); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`) +}); +``` + +Similarly to `resolvers`, `mocks` allows the description of object types with the fields. Take note that the value corresponding to `Person` is a function that returns an object that contains fields pointing at more functions: + +```js +const mocks = { + Person: () => ({ + name: casual.name, + age: () => casual.integer(0, 120), + }), +}; +``` + +The previous example uses [casual](https://github.com/boo1ean/casual), a fake data generator for JavaScript, which returns a different result every time the field is called. In other scenarios, such as testing, a collection of fake objects or a generator that always uses a consistent seed are often necessary to provide consistent data. + +### Using `MockList` in resolvers + +To automate mocking a list, return an instance of `MockList`: + +```js +const { MockList } = require('apollo-server'); + +const mocks = { + Person: () => ({ + // a list of length between 2 and 6 (inclusive) + friends: () => new MockList([2,6]), + // a list of three lists each with two items: [[1, 1], [2, 2], [3, 3]] + listOfLists: () => new MockList(3, () => new MockList(2)), + }), +}; +``` + +In more complex schemas, `MockList` is helpful for randomizing the number of entries returned in lists. + +For example, this schema: + +```graphql +type Query { + people: [Person] +} + +type Person { + name: String + age: Int +} +``` + +By default, the `people` field will always return 2 entries. To change this, we can add a mock resolver that returns `MockList`: + +```js +const mocks = { + Query: () =>({ + people: () => new MockList([0, 12]), + }), +}; +``` + +Now the mock data will contain between zero and 12 summary entries. + +### Accessing arguments in mock resolvers + +The mock functions on fields are actually just GraphQL resolvers, which can use arguments and `context`: + +```js +const mocks = { + Person: () => ({ + // the number of friends in the list now depends on numPages + paginatedFriends: (root, args, context, info) => new MockList(args.numPages * PAGE_SIZE), + }), +}; +``` + +For some more background and flavor on this approach, read the ["Mocking your server with one line of code"](https://medium.com/apollo-stack/mocking-your-server-with-just-one-line-of-code-692feda6e9cd) article on the Apollo blog. + +## Mocking a schema using introspection + +The GraphQL specification allows clients to introspect the schema with a [special set of types and fields](https://facebook.github.io/graphql/#sec-Introspection) that every schema must include. The results of a [standard introspection query](https://github.com/graphql/graphql-js/blob/master/src/utilities/introspectionQuery.js) can be used to generate an instance of GraphQLSchema which can be mocked as explained above. + +This helps when you need to mock a schema defined in a language other than JS, for example Go, Ruby, or Python. + +To convert an [introspection query](https://github.com/graphql/graphql-js/blob/master/src/utilities/introspectionQuery.js) result to a `GraphQLSchema` object, you can use the `buildClientSchema` utility from the `graphql` package. + +```js +const { buildClientSchema } = require('graphql'); +const introspectionResult = require('schema.json'); +const { ApolloServer } = require('apollo-server'); + +const schema = buildClientSchema(introspectionResult); + +const server = new ApolloServer({ + schema, + mocks: true, +}); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`) +}); +``` + +## API + +Under the hood, Apollo Sever uses a library for building GraphQL servers, called `graphql-tools`. The mocking functionality is provided by the function [`addMockFunctionsToSchema`](../api/graphql-tools.html#addMockFunctionsToSchema). The `mocks` object is passed directly to the function and `preserveResolvers` is always true. [`MockList`](../api/graphql-tools.html#MockList) is exported directly from the `graphql-tools` library. diff --git a/docs/source/features/remote-schemas.md b/docs/source/features/remote-schemas.md new file mode 100644 index 00000000000..10e57c01435 --- /dev/null +++ b/docs/source/features/remote-schemas.md @@ -0,0 +1,64 @@ +--- +title: Remote schemas +description: Generate GraphQL schema objects that delegate to a remote server +--- + +It can be valuable to be able to treat remote GraphQL endpoints as if they were local executable schemas. This is especially useful for [schema stitching](./schema-stitching.html), but there may be other use cases. + +Generally, there are three steps to create a remote schema: + +1. Create a [link](#link) that can retrieve results from that schema +2. Use [`introspectSchema`](#introspectSchema) to get the schema of the remote server +3. Use [`makeRemoteExecutableSchema`](#makeRemoteExecutableSchema) to create a schema that uses the link to delegate requests to the underlying service + + + +A link is a function capable of retrieving GraphQL results. It is the same way that Apollo Client handles fetching data and is used by several `graphql-tools` features to do introspection or fetch results during execution. Using an Apollo Link brings with it a large feature set for common use cases. For instance, adding error handling to your request is super easy using the `apollo-link-error` package. You can set headers, batch requests, and even configure your app to retry on failed attempts all by including new links into your request chain. + +```js +const fetch = require('node-fetch'); + +const link = new HttpLink({ uri: 'http://api.githunt.com/graphql', fetch }); +``` + +To add authentication headers, modify the link to include an authentication header: + +```js +const { setContext } = require('apollo-link-context'); +const { HttpLink } = require('apollo-link-http'); + +const http = new HttpLink({ uri: 'http://api.githunt.com/graphql', fetch }); + +const link = setContext((request, previousContext) => ({ + headers: { + 'Authentication': `Bearer ${previousContext.graphqlContext.authKey}`, + } +})).concat(http); +``` + + + +Since apollo-server supports using a link for the network layer, the API is the same as the client. To learn more about how Apollo Link works, check out the [docs](https://www.apollographql.com/docs/link/); Both GraphQL and Apollo Links have slightly varying concepts of `context`. For ease of use, `makeRemoteExecutableSchema` attaches the GraphQL context used in resolvers onto the link context under `graphqlContext`. The following example combined with the previous link construction shows basic usage: + +```js +const { introspectSchema, makeRemoteExecutableSchema, ApolloServer } = require('apollo-server'); + +const schema = await introspectSchema(link); + +const executableSchema = makeRemoteExecutableSchema({ + schema, + link, +}); + +const server = new ApolloServer({ schema: executableSchema }); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`) +}); +``` + +## API + +Point at `makeRemoteExecutableSchema(options)` and `introspectSchema(fetcher, [context])` diff --git a/docs/source/features/scalars-enums.md b/docs/source/features/scalars-enums.md new file mode 100644 index 00000000000..0a3935aa5d2 --- /dev/null +++ b/docs/source/features/scalars-enums.md @@ -0,0 +1,327 @@ +--- +title: Custom scalars and enums +description: Add custom scalar and enum types to a schema. +--- + +The GraphQL specification includes the following default scalar types: `Int`, `Float`, `String`, `Boolean` and `ID`. While this covers most of the use cases, some need to support custom atomic data types (e.g. `Date`), or add validation an existing type. To enable this, GraphQL allows custom scalar types. Enumerations are similar to custom scalars with the limitation that their values can only be one of a pre-defined list of strings. + +

Custom scalars

+ +To define a custom scalar, add it to the schema string with the following notation: + +```js +scalar MyCustomScalar +``` + +Afterwards, define the behavior of a `MyCustomScalar` custom scalar by passing an instance of the [`GraphQLScalarType`](http://graphql.org/graphql-js/type/#graphqlscalartype) class in the [resolver map](https://www.apollographql.com/docs/graphql-tools/resolvers.html#Resolver-map). This instance can be defined with a [dependency](#Using-a-package) or in [source code](#graphqlscalartype). + +For more information about GraphQL's type system, please refer to the [official documentation](http://graphql.org/graphql-js/type/) or to the [Learning GraphQL](https://github.com/mugli/learning-graphql/blob/master/7.%20Deep%20Dive%20into%20GraphQL%20Type%20System.md) tutorial. + +Note that [Apollo Client does not currently have a way to automatically interpret custom scalars](https://github.com/apollostack/apollo-client/issues/585), so there's no way to automatically reverse the serialization on the client. + +### Using a package + +Here, we'll take the [graphql-type-json](https://github.com/taion/graphql-type-json) package as an example to demonstrate what can be done. This npm package defines a JSON GraphQL scalar type. + +Add the `graphql-type-json` package to the project's dependencies : + +```shell +$ npm install --save graphql-type-json +``` + +In code, require the type defined by in the npm package and use it : + +```js +const { ApolloServer, gql } = require('apollo-server'); +const GraphQLJSON = require('graphql-type-json'); + +const schemaString = gql` + +scalar JSON + +type Foo { + aField: JSON +} + +type Query { + foo: Foo +} + +`; + +const resolveFunctions = { + JSON: GraphQLJSON +}; + +const server = new ApolloServer({ typeDefs: schemaString, resolvers: resolveFunctions }); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`) +}); +``` + +Remark : `GraphQLJSON` is a [`GraphQLScalarType`](http://graphql.org/graphql-js/type/#graphqlscalartype) instance. + +

Custom `GraphQLScalarType` instance

+ +Defining a [GraphQLScalarType](http://graphql.org/graphql-js/type/#graphqlscalartype) instance provides more control over the custom scalar and can be added to Apollo server in the following way: + +```js +const { ApolloServer, gql } = require('apollo-server'); +const { GraphQLScalarType, Kind } = require('graphql'); + +const myCustomScalarType = new GraphQLScalarType({ + name: 'MyCustomScalar', + description: 'Description of my custom scalar type', + serialize(value) { + let result; + // Implement custom behavior by setting the 'result' variable + return result; + }, + parseValue(value) { + let result; + // Implement custom behavior here by setting the 'result' variable + return result; + }, + parseLiteral(ast) { + switch (ast.kind) { + case Kind.Int: + // return a literal value, such as 1 or 'static string' + } + } +}); + +const schemaString = gql` + +scalar MyCustomScalar + +type Foo { + aField: MyCustomScalar +} + +type Query { + foo: Foo +} + +`; + +const resolverFunctions = { + MyCustomScalar: myCustomScalarType +}; + +const server = new ApolloServer({ typeDefs: schemaString, resolvers: resolveFunctions }); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`) +}); +``` + +

Custom scalar examples

+ +Let's look at a couple of examples to demonstrate how a custom scalar type can be defined. + +### Date as a scalar + +The goal is to define a `Date` data type for returning `Date` values from the database. Let's say we're using a MongoDB driver that uses the native JavaScript `Date` data type. The `Date` data type can be easily serialized as a number using the [`getTime()` method](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/getTime). Therefore, we would like our GraphQL server to send and receive `Date`s as numbers when serializing to JSON. This number will be resolved to a `Date` on the server representing the date value. On the client, the user can simply create a new date from the received numeric value. + +The following is the implementation of the `Date` data type. First, the schema: + +```js +const typeDefs = gql`scalar Date + +type MyType { + created: Date +} +` +``` + +Next, the resolver: + +```js +const { GraphQLScalarType } = require('graphql'); +const { Kind } = require('graphql/language'); + +const resolvers = { + Date: new GraphQLScalarType({ + name: 'Date', + description: 'Date custom scalar type', + parseValue(value) { + return new Date(value); // value from the client + }, + serialize(value) { + return value.getTime(); // value sent to the client + }, + parseLiteral(ast) { + if (ast.kind === Kind.INT) { + return parseInt(ast.value, 10); // ast value is always in string format + } + return null; + }, + }), +}; + +const server = new ApolloServer({ typeDefs, resolvers }); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`) +}); +``` + +### Validations + +In this example, we follow the [official GraphQL documentation](http://graphql.org/docs/api-reference-type-system/) for the scalar datatype, which demonstrates how to validate a database field that should only contain odd numbers in GraphQL. First, the schema: + +```js +const typeDefs = gql`scalar Odd + +type MyType { + oddValue: Odd +} +` +``` + +Next, the resolver: + +```js +const { ApolloServer, gql } = require('apollo-server'); +const { GraphQLScalarType } = require('graphql'); +const { Kind } = require('graphql/language'); + +function oddValue(value) { + return value % 2 === 1 ? value : null; +} + +const resolvers = { + Odd: new GraphQLScalarType({ + name: 'Odd', + description: 'Odd custom scalar type', + parseValue: oddValue, + serialize: oddValue, + parseLiteral(ast) { + if (ast.kind === Kind.INT) { + return oddValue(parseInt(ast.value, 10)); + } + return null; + }, + }), +}; + +const server = new ApolloServer({ typeDefs, resolvers }); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`) +}); +``` + +

Enums

+ +An Enum is similar to a scalar type, but it can only be one of several values defined in the schema. Enums are most useful in a situation where the user must pick from a prescribed list of options. Additionally enums improve development velocity, since they will auto-complete in tools like GraphQL Playground. + +In the schema language, an enum looks like this: + +```graphql +enum AllowedColor { + RED + GREEN + BLUE +} +``` + +An enum can be used anywhere a scalar can be: + +```graphql +type Query { + favoriteColor: AllowedColor # As a return value + avatar(borderColor: AllowedColor): String # As an argument +} +``` + +A query might look like this: + +```graphql +query { + avatar(borderColor: RED) +} +``` + +To pass the enum value as a variable, use a string of JSON, like so: + +```graphql +query MyAvatar($color: AllowedColor) { + avatar(borderColor: $color) +} +``` + +```js +{ + "color": "RED" +} +``` + +Putting it all together: + +```js +const { ApolloServer, gql } = require('apollo-server'); + +const typeDefs = gql` + enum AllowedColor { + RED + GREEN + BLUE + } + + type Query { + favoriteColor: AllowedColor # As a return value + avatar(borderColor: AllowedColor): String # As an argument + } +`; + +const resolvers = { + Query: { + favoriteColor: () => 'RED', + avatar: (root, args) => { + // args.favoriteColor is 'RED', 'GREEN', or 'BLUE' + }, + } +}; + +const server = new ApolloServer({ typeDefs, resolvers }); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`) +}); +``` + +

Internal values

+ +Sometimes a backend forces a different value for an enum internally than in the public API. In this exmple the API contains `RED`, however in resolvers we use `#f00` instead. The `resolvers` argument to `ApolloServer` allows the addition custom values to enums that only exist internally: + +```js +const resolvers = { + AllowedColor: { + RED: '#f00', + GREEN: '#0f0', + BLUE: '#00f', + } +}; +``` + +These don't change the public API at all and the resolvers accept these value instead of the schema value, like so: + +```js +const resolvers = { + AllowedColor: { + RED: '#f00', + GREEN: '#0f0', + BLUE: '#00f', + }, + Query: { + favoriteColor: () => '#f00', + avatar: (root, args) => { + // args.favoriteColor is '#f00', '#0f0', or '#00f' + }, + } +}; +``` + +Most of the time, this feature of enums isn't used unless interoperating with another library that expects its values in a different form. diff --git a/docs/source/features/schema-delegation.md b/docs/source/features/schema-delegation.md new file mode 100644 index 00000000000..523771927b4 --- /dev/null +++ b/docs/source/features/schema-delegation.md @@ -0,0 +1,161 @@ +--- +title: Schema delegation +description: Forward queries to other schemas automatically +--- + +Schema delegation is a way to automatically forward a query (or a part of a query) from a parent schema to another schema (called a _subschema_) that is able to execute the query. Delegation is useful when the parent schema shares a significant part of its data model with the subschema. For example, the parent schema might be powering a GraphQL gateway that connects multiple existing endpoints together, each with its own schema. This kind of architecture could be implemented using schema delegation. + +The `graphql-tools` package provides several related tools for managing schema delegation: + +* [Remote schemas](./remote-schemas.html) - turning a remote GraphQL endpoint into a local schema +* [Schema transforms](./schema-transforms.html) - modifying existing schemas to make delegation easier +* [Schema stitching](./schema-stitching.html) - merging multiple schemas into one + +Delegation is performed by one function, `delegateToSchema`, called from within a resolver function of the parent schema. The `delegateToSchema` function sends the query subtree received by the parent resolver to a subschema that knows how to execute it, then returns the result as if the parent resolver had executed the query. + +

Motivational example

+ +Let's consider two schemas, a subschema and a parent schema that reuses parts of a subschema. In this example the parent schema reuses the *definitions* of the subschema. However the implementations separate should be kept separate, so that the subschema can be tested independently or retrieved from a remote service. The subschema: + +```graphql +type Repository { + id: ID! + url: String + issues: [Issue] + userId: ID! +} + +type Issue { + id: ID! + text: String! + repository: Repository! +} + +type Query { + repositoryById(id: ID!): Repository + repositoriesByUserId(id: ID!): [Repository] +} +``` + +Parent Schema: + +```graphql +type Repository { + id: ID! + url: String + issues: [Issue] + userId: ID! + user: User +} + +type Issue { + id: ID! + text: String! + repository: Repository! +} + +type User { + id: ID! + username: String + repositories: [Repository] +} + +type Query { + userById(id: ID!): User +} +``` + +Suppose we want the parent schema to delegate retrieval of repositories to the subschema, in order to execute queries such as this one: + +```graphql +query { + userById(id: "1") { + id + username + repositories { + id + url + user + issues { + text + } + } + } +} +``` + +The resolver function for the `repositories` field of the `User` type would be responsible for the delegation, in this case. While it's possible to call a remote GraphQL endpoint or resolve the data manually, this would require us to transform the query manually, or always fetch all possible fields, which could lead to overfetching. Delegation automatically extracts the appropriate query to send to the subschema: + +```graphql +# To the subschema +query($id: ID!) { + repositoriesByUserId(id: $id) { + id + url + issues { + text + } + } +} +``` + +Delegation also removes the fields that don't exist on the subschema, such as `user`. This field would be retrieved from the parent schema using normal GraphQL resolvers. + +

Example

+ +The `delegateToSchema` method can be found on the `info.mergeInfo` object within any resolver function, and should be called with the following named options: + + +```graphql +# Subschema + +type Booking { + id: ID! +} + +type Query { + bookingsByUser(userId: ID!, limit: Int): [Booking] +} + +# Schema + +type User { + id: ID! + bookings(limit: Int): [Booking] +} + +type Booking { + id: ID! +} +``` + +If we delegate at `User.bookings` to `Query.bookingsByUser`, we want to preserve the `limit` argument and add an `userId` argument by using the `User.id`. So the resolver would look like the following: + +```js +const resolvers = { + User: { + bookings: (parent, args, context, info) => { + return info.mergeInfo.delegateToSchema({ + schema: subschema, + operation: 'query', + fieldName: 'bookingsByUser', + args: { + userId: parent.id, + }, + context, + info, + }); + }, + }, +}; +``` + +

Additional considerations

+ +### Aliases + +Delegation preserves aliases that are passed from the parent query. However that presents problems, because default GraphQL resolvers retrieve field from parent based on their name, not aliases. This way results with aliases will be missing from the delegated result. `mergeSchemas` and `transformSchemas` go around that by using `src/stitching/defaultMergedResolver` for all fields without explicit resolver. When building new libraries around delegation, one should consider how the aliases will be handled. + +## API + +Under the hood, Apollo server uses the `graphql-tools` library, which includes [`delegateToSchema`](../api/graphql-tools.html#delegateToSchema) by default. diff --git a/docs/source/features/schema-stitching.md b/docs/source/features/schema-stitching.md new file mode 100644 index 00000000000..d4cc186796f --- /dev/null +++ b/docs/source/features/schema-stitching.md @@ -0,0 +1,472 @@ +--- +title: Schema stitching +description: Combining multiple GraphQL APIs into one +--- + +Schema stitching is the process of creating a single GraphQL schema from multiple underlying GraphQL APIs. + +One of the main benefits of GraphQL is that we can query all of our data as part of one schema, and get everything we need in one request. But as the schema grows, it might become cumbersome to manage it all as one codebase and deploy everything at once. At that point, you may want to decompose your schema into separate microservices, which can be developed and deployed independently. + +That's exactly what schema stitching is for. We can create references to all of the remote GraphQL APIs we want to use, and pass them into the `mergeSchemas` function to create a single API. + +

Working with remote schemas

+ +While you can use stitching to combine local schema objects, schema stitching is most useful when you use it to combine multiple GraphQL APIs that run as separate services. To do this, we first need to make a GraphQL schema object for each remote API we want to stitch together. + +There are three steps to create a remote schema: + +1. Create an [Apollo Link](#link) that can retrieve results from that schema +2. Use [`introspectSchema`](#introspectSchema) to get the schema of the remote server +3. Use [`makeRemoteExecutableSchema`](#makeRemoteExecutableSchema) to create a schema that uses the link to delegate requests to the underlying service + +We’ve chosen to split this functionality up to give you the flexibility to choose when to do the introspection step. For example, you might already have the remote schema information, allowing you to skip the `introspectSchema` step entirely. Here’s a complete example: + +```js +const { HttpLink } = require('apollo-link-http'); +const fetch = require('node-fetch'); +const { introspectSchema, makeRemoteExecutableSchema } = require('apollo-server'); + +const link = new HttpLink({ uri: 'http://api.githunt.com/graphql', fetch }); + +const schema = await introspectSchema(link); + +const executableSchema = makeRemoteExecutableSchema({ + schema, + link, +}); +``` + +Now, let's break down some of the details. + + + +A Link is a function capable of retrieving GraphQL results. It's the same network layer that Apollo Client uses to handle fetching data. Apollo Link brings with it a large feature set for common use cases. For instance, adding error handling to your request is super easy using the `apollo-link-error` package. You can set headers, batch requests, and even configure your app to retry on failed attempts all by including new links into your request chain. + +Here's the basic setup: + +```js +const HttpLink = require('apollo-link-http'); +const fetch = require('node-fetch'); + +const link = new HttpLink({ uri: 'http://api.githunt.com/graphql', fetch }); +``` + +

GraphQL context

+ +In GraphQL execution, `context` is often used to pass around information about authentication or other secrets. If you need these inside your Link to call the underlying API, it's easy to pass them through, since they will be included on the `graphqlContext` field. + +For example, to add authentication headers, modify the link to include an authentication header: + +```js +const { setContext } = require('apollo-link-context'); +const { HttpLink } = require('apollo-link-http'); + +const http = new HttpLink({ uri: 'http://api.githunt.com/graphql', fetch }); + +const link = setContext((request, previousContext) => ({ + headers: { + 'Authentication': `Bearer ${previousContext.graphqlContext.authKey}`, + } +})).concat(http); +``` + +If you need further details about how to control your requests, read the full details in the [Apollo Link docs](https://www.apollographql.com/docs/link/). + +

Basic example

+ +In this example we'll stitch together two very simple schemas. It doesn't matter whether these are local or proxies created with `makeRemoteExecutableSchema`, because the merging itself would be the same. + +In this case, we're dealing with two schemas that implement a system with users and "chirps"—small snippets of text that users can post. + +```js +const { + makeExecutableSchema, + addMockFunctionsToSchema, + mergeSchemas, + ApolloServer, + gql, +} = require('apollo-server'); + +// Mocked chirp schema +// We don't worry about the schema implementation right now since we're just +// demonstrating schema stitching. +const chirpSchema = makeExecutableSchema({ + typeDefs: gql` + type Chirp { + id: ID! + text: String + authorId: ID! + } + + type Query { + chirpById(id: ID!): Chirp + chirpsByAuthorId(authorId: ID!): [Chirp] + } + ` +}); + +addMockFunctionsToSchema({ schema: chirpSchema }); + +// Mocked author schema +const authorSchema = makeExecutableSchema({ + typeDefs: gql` + type User { + id: ID! + email: String + } + + type Query { + userById(id: ID!): User + } + ` +}); + +addMockFunctionsToSchema({ schema: authorSchema }); + +const server = new ApolloServer({ schema }); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`) +}); +``` + +This gives us a new schema with the root fields on `Query` from both schemas (along with the `User` and `Chirp` types): + +```graphql +type Query { + chirpById(id: ID!): Chirp + chirpsByAuthorId(authorId: ID!): [Chirp] + userById(id: ID!): User +} +``` + +We now have a single schema that supports asking for `userById` and `chirpsByAuthorId` in the same query! + +

Adding resolvers between schemas

+ +Combining existing root fields is a great start, but in practice we will often want to introduce additional fields for working with the relationships between types that came from different subschemas. For example, we might want to go from a particular user to their chirps, or from a chirp to its author. Or we might want to query a `latestChirps` field and then get the author of each of those chirps. If the only way to obtain a chirp's author is to call the `userById(id)` root query field with the `authorId` of a given chirp, and we don't know the chirp's `authorId` until we receive the GraphQL response, then we won't be able to obtain the authors as part of the same query. + +To add this ability to navigate between types, we need to _extend_ existing types with new fields that translate between the types: + +```js +const linkTypeDefs = gql` + extend type User { + chirps: [Chirp] + } + + extend type Chirp { + author: User + } +`; +``` + +We can now merge these three schemas together: + +```js +mergeSchemas({ + schemas: [ + chirpSchema, + authorSchema, + linkTypeDefs, + ], +}); +``` + +We won't be able to query `User.chirps` or `Chirp.author` yet, however, because we still need to define resolvers for these new fields. + +How should these resolvers be implemented? When we resolve `User.chirps` or `Chirp.author`, we want to _delegate_ to the relevant root fields. To get from a user to the user's chirps, for example, we'll want to use the `id` of the user to call `Query.chirpsByAuthorId`. And to get from a chirp to its author, we can use the chirp's `authorId` field to call the existing `Query.userById` field. + +Resolvers for fields in schemas created by `mergeSchema` have access to a handy `delegateToSchema` function (exposed via `info.mergeInfo.delegateToSchema`) that allows forwarding parts of queries (or even whole new queries) to one of the subschemas that was passed to `mergeSchemas`. + +In order to delegate to these root fields, we'll need to make sure we've actually requested the `id` of the user or the `authorId` of the chirp. To avoid forcing users to add these fields to their queries manually, resolvers on a merged schema can define a `fragment` property that specifies the required fields, and they will be added to the query automatically. + +A complete implementation of schema stitching for these schemas might look like this: + +```js +const mergedSchema = mergeSchemas({ + schemas: [ + chirpSchema, + authorSchema, + linkTypeDefs, + ], + resolvers: { + User: { + chirps: { + fragment: `fragment UserFragment on User { id }`, + resolve(user, args, context, info) { + return info.mergeInfo.delegateToSchema({ + schema: chirpSchema, + operation: 'query', + fieldName: 'chirpsByAuthorId', + args: { + authorId: user.id, + }, + context, + info, + }); + }, + }, + }, + Chirp: { + author: { + fragment: `fragment ChirpFragment on Chirp { authorId }`, + resolve(chirp, args, context, info) { + return info.mergeInfo.delegateToSchema({ + schema: authorSchema, + operation: 'query', + fieldName: 'userById', + args: { + id: chirp.authorId, + }, + context, + info, + }); + }, + }, + }, + }, +}); +``` + +

Using with Transforms

+ +Often, when creating a GraphQL gateway that combines multiple existing schemas, we might want to modify one of the schemas. The most common tasks include renaming some of the types, and filtering the root fields. By using [transforms](./schema-transforms.html) with schema stitching, we can easily tweak the subschemas before merging them together. + +Before, when we were simply merging schemas without first transforming them, we would typically delegate directly to one of the merged schemas. Once we add transforms to the mix, there are times when we want to delegate to fields of the new, transformed schemas, and other times when we want to delegate to the original, untransformed schemas. + +For example, suppose we transform the `chirpSchema` by removing the `chirpsByAuthorId` field and add a `Chirp_` prefix to all types and field names, in order to make it very clear which types and fields came from `chirpSchema`: + +```js +const { + makeExecutableSchema, + addMockFunctionsToSchema, + mergeSchemas, + transformSchema, + FilterRootFields, + RenameTypes, + RenameRootFields, +} = require('apollo-server'); + +// Mocked chirp schema; we don't want to worry about the schema +// implementation right now since we're just demonstrating +// schema stitching +const chirpSchema = makeExecutableSchema({ + typeDefs: gql` + type Chirp { + id: ID! + text: String + authorId: ID! + } + + type Query { + chirpById(id: ID!): Chirp + chirpsByAuthorId(authorId: ID!): [Chirp] + } + ` +}); + +addMockFunctionsToSchema({ schema: chirpSchema }); + +// create transform schema + +const transformedChirpSchema = transformSchema(chirpSchema, [ + new FilterRootFields( + (operation: string, rootField: string) => rootField !== 'chirpsByAuthorId' + ), + new RenameTypes((name: string) => `Chirp_${name}`), + new RenameRootFields((name: string) => `Chirp_${name}`), +]); +``` + +Now we have a schema that has all fields and types prefixed with `Chirp_` and has only the `chirpById` root field. Note that the original schema has not been modified, and remains fully functional. We've simply created a new, slightly different schema, which hopefully will be more convenient for merging with our other subschemas. + +Now let's implement the resolvers: + +```js +const mergedSchema = mergeSchemas({ + schemas: [ + transformedChirpSchema, + authorSchema, + linkTypeDefs, + ], + resolvers: { + User: { + chirps: { + fragment: `fragment UserFragment on User { id }`, + resolve(user, args, context, info) { + return info.mergeInfo.delegateToSchema({ + schema: chirpSchema, + operation: 'query', + fieldName: 'chirpsByAuthorId', + args: { + authorId: user.id, + }, + context, + info, + transforms: transformedChirpSchema.transforms, + }); + }, + }, + }, + Chirp_Chirp: { + author: { + fragment: `fragment ChirpFragment on Chirp { authorId }`, + resolve(chirp, args, context, info) { + return info.mergeInfo.delegateToSchema({ + schema: authorSchema, + operation: 'query', + fieldName: 'userById', + args: { + id: chirp.authorId, + }, + context, + info, + }); + }, + }, + }, + }, +}); + +const server = new ApolloServer({ schema: mergedSchema }); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`) +}); +``` + +Notice that `resolvers.Chirp_Chirp` has been renamed from just `Chirp`, but `resolvers.Chirp_Chirp.author.fragment` still refers to the original `Chirp` type and `authorId` field, rather than `Chirp_Chirp` and `Chirp_authorId`. + +Also, when we call `info.mergeInfo.delegateToSchema` in the `User.chirps` resolvers, we can delegate to the original `chirpsByAuthorId` field, even though it has been filtered out of the final schema. That's because we're delegating to the original `chirpSchema`, which has not been modified by the transforms. + +

Complex example

+ +For a more complicated example involving properties and bookings, with implementations of all of the resolvers, check out the Launchpad links below: + +* [Property schema](https://launchpad.graphql.com/v7l45qkw3) +* [Booking schema](https://launchpad.graphql.com/41p4j4309) +* [Merged schema](https://launchpad.graphql.com/q5kq9z15p) + +

API

+ +

mergeSchemas

+ +```ts +mergeSchemas({ + schemas: Array>; + resolvers?: Array | IResolvers; + onTypeConflict?: ( + left: GraphQLNamedType, + right: GraphQLNamedType, + info?: { + left: { + schema?: GraphQLSchema; + }; + right: { + schema?: GraphQLSchema; + }; + }, + ) => GraphQLNamedType; +}) +``` + +This is the main function that implements schema stitching. Read below for a description of each option. + +#### schemas + +`schemas` is an array of `GraphQLSchema` objects, schema strings, or lists of `GraphQLNamedType`s. Strings can contain type extensions or GraphQL types, which will be added to resulting schema. Note that type extensions are always applied last, while types are defined in the order in which they are provided. + +#### resolvers + +`resolvers` accepts resolvers in same format as [makeExecutableSchema](./resolvers.html). It can also take an Array of resolvers. One addition to the resolver format is the possibility to specify a `fragment` for a resolver. The `fragment` must be a GraphQL fragment definition string, specifying which fields from the parent schema are required for the resolver to function properly. + +```js +resolvers: { + Booking: { + property: { + fragment: 'fragment BookingFragment on Booking { propertyId }', + resolve(parent, args, context, info) { + return mergeInfo.delegateToSchema({ + schema: bookingSchema, + operation: 'query', + fieldName: 'propertyById', + args: { + id: parent.propertyId, + }, + context, + info, + }); + }, + }, + }, +} +``` + +#### mergeInfo and delegateToSchema + +The `info.mergeInfo` object provides the `delegateToSchema` method: + +```js +type MergeInfo = { + delegateToSchema(options: IDelegateToSchemaOptions): any; +} + +interface IDelegateToSchemaOptions { + schema: GraphQLSchema; + operation: Operation; + fieldName: string; + args?: { + [key: string]: any; + }; + context: TContext; + info: GraphQLResolveInfo; + transforms?: Array; +} +``` + +As described in the documentation above, `info.mergeInfo.delegateToSchema` allows delegating to any `GraphQLSchema` object, optionally applying transforms in the process. See [Schema Delegation](./schema-delegation.html) and the [*Using with transforms*](#using-with-transforms) section of this document. + +#### onTypeConflict + +```js +type OnTypeConflict = ( + left: GraphQLNamedType, + right: GraphQLNamedType, + info?: { + left: { + schema?: GraphQLSchema; + }; + right: { + schema?: GraphQLSchema; + }; + }, +) => GraphQLNamedType; +``` + +The `onTypeConflict` option to `mergeSchemas` allows customization of type resolving logic. + +The default behavior of `mergeSchemas` is to take the first encountered type of all the types with the same name. If there are conflicts, `onTypeConflict` enables explicit selection of the winning type. + +For example, here's how we could select the last type among multiple types with the same name: + +```js +const onTypeConflict = (left, right) => right; +``` + +And here's how we might select the type whose schema has the latest `version`: + +```js +const onTypeConflict = (left, right, info) => { + if (info.left.schema.version >= info.right.schema.version) { + return left; + } else { + return right; + } +} +``` + +When using schema transforms, `onTypeConflict` is often unnecessary, since transforms can be used to prevent conflicts before merging schemas. However, if you're not using schema transforms, `onTypeConflict` can be a quick way to make `mergeSchemas` produce more desirable results. diff --git a/docs/source/features/schema-transforms.md b/docs/source/features/schema-transforms.md new file mode 100644 index 00000000000..399273a98e7 --- /dev/null +++ b/docs/source/features/schema-transforms.md @@ -0,0 +1,223 @@ +--- +title: Schema transforms +description: Automatically transforming schemas +--- + +Schema transforms are a tool for making modified copies of `GraphQLSchema` objects, while preserving the possibility of delegating back to original schema. + +Transforms are useful when working with [remote schemas](./remote-schemas.html), building GraphQL gateways that combine multiple schemas, and/or using [schema stitching](./schema-stitching.html) to combine schemas together without conflicts between types or fields. + +While it's possible to modify a schema by hand, the manual approach requires a deep understanding of all the relationships between `GraphQLSchema` properties, which makes it error-prone and labor-intensive. Transforms provide a generic abstraction over all those details, which improves code quality and saves time, not only now but also in the future, because transforms are designed to be reused again and again. + +Each `Transform` may define three different kinds of transform functions: + +```ts +interface Transform = { + transformSchema?: (schema: GraphQLSchema) => GraphQLSchema; + transformRequest?: (request: Request) => Request; + transformResult?: (result: Result) => Result; +}; +``` + +The most commonly used transform function is `transformSchema`. However, some transforms require modifying incoming requests and/or outgoing results as well, especially if `transformSchema` adds or removes types or fields, since such changes require mapping new types/fields to the original types/fields at runtime. + +For example, let's consider changing the name of the type in a simple schema. Imagine we've written a function that takes a `GraphQLSchema` and replaces all instances of type `Test` with `NewTest`. + +```graphql +# old schema +type Test { + id: ID! + name: String +} + +type Query { + returnTest: Test +} + +# new schema + +type NewTest { + id: ID! + name: String +} + +type Query { + returnTest: NewTest +} +``` + +At runtime, we want the `NewTest` type to be automatically mapped to the old `Test` type. + +At first glance, it might seem as though most queries work the same way as before: + +```graphql +query { + returnTest { + id + name + } +} +``` + +Since the fields of the type have not changed, delegating to the old schema is relatively easy here. + +However, the new name begins to matter more when fragments and variables are used: + +```graphql +query { + returnTest { + id + ... on NewTest { + name + } + } +} +``` + +Since the `NewTest` type did not exist on old schema, this fragment will not match anything in the old schema, so it will be filtered out during delegation. + +What we need is a `transformRequest` function that knows how to rename any occurrences of `NewTest` to `Test` before delegating to the old schema. + +By the same reasoning, we also need a `transformResult` function, because any results contain a `__typename` field whose value is `Test`, that name needs to be updated to `NewTest` in the final result. + +

API

+ +

Transform

+ +```ts +interface Transform = { + transformSchema?: (schema: GraphQLSchema) => GraphQLSchema; + transformRequest?: (request: Request) => Request; + transformResult?: (result: Result) => Result; +}; + +type Request = { + document: DocumentNode; + variables: Record; + extensions?: Record; +}; + +type Result = ExecutionResult & { + extensions?: Record; +}; +``` + +

transformSchema

+ +Given a `GraphQLSchema` and an array of `Transform` objects, produce a new schema with those transforms applied. + +Delegating resolvers will also be generated to map from new schema root fields to old schema root fields. Often these automatic resolvers are sufficient, so you don't have to implement your own. + +

Built-in transforms

+ +Built-in transforms are ready-made classes implementing the `Transform` interface. They are intended to cover many of the most common schema transformation use cases, but they also serve as examples of how to implement transforms for your own needs. + +### Modifying types + +* `FilterTypes(filter: (type: GraphQLNamedType) => boolean)`: Remove all types for which the `filter` function returns `false`. + +* `RenameTypes(renamer, options?)`: Rename types by applying `renamer` to each type name. If `renamer` returns `undefined`, the name will be left unchanged. Options controls whether built-in types and scalars are renamed. Root objects are never renamed by this transform. + +```ts +RenameTypes( + (name: string) => string | void, + options?: { + renameBuiltins: Boolean; + renameScalars: Boolean; + }, +) +``` + +### Modifying root fields + +* `TransformRootFields(transformer: RootTransformer)`: Given a transformer, abritrarily transform root fields. The `transformer` can return a `GraphQLFieldConfig` definition, a object with new `name` and a `field`, `null` to remove the field, or `undefined` to leave the field unchanged. + +```ts +TransformRootFields(transformer: RootTransformer) + +type RootTransformer = ( + operation: 'Query' | 'Mutation' | 'Subscription', + fieldName: string, + field: GraphQLField, +) => + | GraphQLFieldConfig + | { name: string; field: GraphQLFieldConfig } + | null + | void; +``` + +* `FilterRootFields(filter: RootFilter)`: Like `FilterTypes`, removes root fields for which the `filter` function returns `false`. + +```ts +FilterRootFields(filter: RootFilter) + +type RootFilter = ( + operation: 'Query' | 'Mutation' | 'Subscription', + fieldName: string, + field: GraphQLField, +) => boolean; +``` + +* `RenameRootFields(renamer)`: Rename root fields, by applying the `renamer` function to their names. + +```ts +RenameRootFields( + renamer: ( + operation: 'Query' | 'Mutation' | 'Subscription', + name: string, + field: GraphQLField, + ) => string, +) +``` + +### Other + +* `ExractField({ from: Array, to: Array })` - move selection at `from` path to `to` path. + +* `WrapQuery( + path: Array, + wrapper: QueryWrapper, + extractor: (result: any) => any, + )` - wrap a selection at `path` using function `wrapper`. Apply `extractor` at the same path to get the result. This is used to get a result nested inside other result + +```js +transforms: [ + // Wrap document takes a subtree as an AST node + new WrapQuery( + // path at which to apply wrapping and extracting + ['userById'], + (subtree: SelectionSetNode) => ({ + // we create a wrapping AST Field + kind: Kind.FIELD, + name: { + kind: Kind.NAME, + // that field is `address` + value: 'address', + }, + // Inside the field selection + selectionSet: subtree, + }), + // how to process the data result at path + result => result && result.address, + ), +], +``` + +* `ReplaceFieldWithFragment(targetSchema: GraphQLSchema, mapping: FieldToFragmentMapping)`: Replace the given fields with an inline fragment. Used by `mergeSchemas` to handle the `fragment` option. + +```ts +type FieldToFragmentMapping = { + [typeName: string]: { [fieldName: string]: InlineFragmentNode }; +}; +``` + +

delegateToSchema transforms

+ +The following transforms are automatically applied by `delegateToSchema` during schema delegation, to translate between new and old types and fields: + +* `AddArgumentsAsVariables`: Given a schema and arguments passed to a root field, make those arguments document variables. +* `FilterToSchema`: Given a schema and document, remove all fields, variables and fragments for types that don't exist in that schema. +* `AddTypenameToAbstract`: Add `__typename` to all abstract types in the document. +* `CheckResultAndHandleErrors`: Given a result from a subschema, propagate errors so that they match the correct subfield. Also provide the correct key if aliases are used. + +By passing a custom `transforms` array to `delegateToSchema`, it's possible to run additional transforms before these default transforms, though it is currently not possible to disable the default transforms. diff --git a/docs/source/features/subscriptions.md b/docs/source/features/subscriptions.md new file mode 100644 index 00000000000..e4001801ea9 --- /dev/null +++ b/docs/source/features/subscriptions.md @@ -0,0 +1,242 @@ +--- +title: Subscriptions +subtitle: Adding subscriptions to Apollo Server +--- + +Subscriptions are GraphQL operations that watch events emitted from Apollo Server. +The native Apollo Server supports GraphQL subscriptions without additional configuration. +All integrations that allow HTTP servers, such as express and Hapi, also provide GraphQL subscriptions. + +## Subscriptions Example + +Subscriptions depend on use of a publish and subscribe primitive to generate the events that notify a subscription. `PubSub` is a factory that creates event generators that is provided by all supported packages. `PubSub` is an implementation of the `PubSubEngine` interface, which has been adopted by a variety of additional [event-generating backends](#PubSub-Implementations). + +```js +const { PubSub } = require('apollo-server'); + +const pubsub = new PubSub(); +``` + +Subscriptions are another root level type, similar to Query and Mutation. To start, we need add to add the `Subscription` type to our schema: + +```js line=2-4 +const typeDefs = gql` +type Subscription { + postAdded: Post +} +type Query { + posts: [Post] +} +type Mutation { + addPost(author: String, comment: String): Post +} +type Post { + author: String + comment: String +} +` +``` + +Inside our resolver map, we add a Subscription resolver that returns an `AsyncIterator`, which listens to the events asynchronously. To generate events in the example, we notified the `pubsub` implementation inside of our Mutation resolver with `publish`. This `publish` call can occur outside of a resolver if required. + +```js line=4-9,17 +const POST_ADDED = 'POST_ADDED'; + +const resolvers = { + Subscription: { + postAdded: { + // Additional event labels can be passed to asyncIterator creation + subscribe: () => pubsub.asyncIterator([POST_ADDED]), + }, + }, + Query: { + posts(root: any, args: any, context: any) { + return postController.posts(); + }, + }, + Mutation: { + addPost(root: any, args: any, context: any) { + pubsub.publish(POST_ADDED, { postAdded: args }); + return postController.addPost(args); + }, + }, +}; +``` + +## Context with Subscriptions + +The function to create a context for subscriptions includes `connection`, while the function for Queries and Mutations contains the arguments for the integration, in express's case `req` and `res`. This means that the context creation function needs to check the input. This is especially important, since the auth tokens are handled differently depending on the transport: + +```js +const server = new ApolloServer({ + schema, + context: async ({ req, connection }) => { + if (connection) { + // check connection for metadata + return {}; + } else { + // check from req + const token = req.headers.authorization || ""; + + return { token }; + } + }, +}); +``` + +As you can see Apollo Server 2.0 allows realtime data without invasive changes to existing code. +For a full working example please have a look to [this repo](https://github.com/daniele-zurico/apollo2-subscriptions-how-to) provided by [Daniele Zurico](https://github.com/daniele-zurico/apollo2-subscriptions-how-to) + +## Authentication Over WebSocket + +To support an authenticated transport, Apollo Server provides lifecycle hooks, including `onConnect` to validate the connection. + +On the client, `SubscriptionsClient` supports adding token information to `connectionParams` ([example](/docs/react/advanced/subscriptions.html#authentication)) that will be sent with the first WebSocket message. In the server, all GraphQL subscriptions are delayed until the connection has been fully authenticated and the `onConnect` callback returns a truthy value. + +The `connectionParams` argument in the `onConnect` callback contains the information passed by the client and can be used to validate user credentials. +The GraphQL context can also be extended with the authenticated user data to enable fine grain authorization. + +```js +const { ApolloServer } = require('apollo-server'); +const { resolvers, typeDefs } = require('./schema'); + +const validateToken = authToken => { + // ... validate token and return a Promise, rejects in case of an error +}; + +const findUser = authToken => { + return tokenValidationResult => { + // ... finds user by auth token and return a Promise, rejects in case of an error + }; +}; + +const server = new ApolloServer({ + typeDefs, + resolvers, + subscriptions: { + onConnect: (connectionParams, webSocket) => { + if (connectionParams.authToken) { + return validateToken(connectionParams.authToken) + .then(findUser(connectionParams.authToken)) + .then(user => { + return { + currentUser: user, + }; + }); + } + + throw new Error('Missing auth token!'); + }, + }, +}); + +server.listen().then(({ url, subscriptionsUrl }) => { + console.log(`🚀 Server ready at ${url}`); + console.log(`🚀 Subscriptions ready at ${subscriptionsUrl}`); +}); +``` + +The example above validates the user's token that is sent with the first initialization message on the transport, then it looks up the user and returns the user object as a Promise. The user object found will be available as `context.currentUser` in your GraphQL resolvers. + +In case of an authentication error, the Promise will be rejected, which prevents the client's connection. + +

Subscription Filters

+ +Sometimes a client will want to filter out specific events based on context and arguments. + +To do so, we can use the `withFilter` helper from the `apollo-server` or `apollo-server-{integration}` package to control each publication for each user. Inside of `withFilter`, the `AsyncIterator` created by `PubSub` is wrapped with a filter function. + +Let's see an example - for the `commentAdded` server-side subscription, the client want to subscribe only to comments added to a specific repo: + +``` +subscription($repoName: String!){ + commentAdded(repoFullName: $repoName) { + id + content + } +} +``` + +When using `withFilter`, provide a filter function. The filter is executed with the payload (a published value), variables, context and operation info. This function must return a `boolean` or `Promise` indicating if the payload should be passed to the subscriber. + +The following definition of the subscription resolver will filter out all of the `commentAdded` events that are not associated with the requested repository: + +```js line=8,10-12 +const { withFilter } = require('apollo-server'); + +const resolvers = { + Query: () => { ... }, + Mutation: () => { ... }, + Subscription: { + commentAdded: { + subscribe: withFilter( + () => pubsub.asyncIterator('COMMENT_ADDED'), + (payload, variables) => { + return payload.commentAdded.repository_name === variables.repoFullName; + }, + ), + } + }, +}; +``` + +

Subscriptions with Additional Middleware

+ +With an existing HTTP server (created with `createServer`), we can add subscriptions using the `installSubscriptionHandlers`. Additionally, the subscription-capable integrations export `PubSub` and other subscription functionality. + +For example: with an Express server already running on port 4000 that accepts GraphQL HTTP connections (POST) we can expose the subscriptions: + +```js line=12 +const http = require('http'); +const { ApolloServer } = require('apollo-server-express'); +const express = require('express'); + +const PORT = 4000; +const app = express(); +const server = new ApolloServer({ typeDefs, resolvers }); + +server.applyMiddleware({app}) + +const httpServer = http.createServer(app); +server.installSubscriptionHandlers(httpServer); + +// ⚠️ Pay attention to the fact that we are calling `listen` on the http server variable, and not on `app`. +httpServer.listen(PORT, () => { + console.log(`🚀 Server ready at http://localhost:${PORT}${server.graphqlPath}`) + console.log(`🚀 Subscriptions ready at ws://localhost:${PORT}${server.subscriptionsPath}`) +}) +``` + +## Lifecycle Events + +`ApolloServer` exposes lifecycle hooks you can use to manage subscriptions and clients: + +* `onConnect` - called upon client connection, with the `connectionParams` passed to `SubscriptionsClient` - you can return a Promise and reject the connection by throwing an exception. The resolved return value will be appended to the GraphQL `context` of your subscriptions. +* `onDisconnect` - called when the client disconnects. + +```js +const server = new ApolloServer( + subscriptions: { + onConnect: (connectionParams, webSocket, context) => { + // ... + }, + onDisconnect: (webSocket, context) => { + // ... + }, + }, +); +``` + +## `PubSub` Implementations + +The Apollo Server implementation of `PubSub` can be replaced by another implementations of [PubSubEngine interface](https://github.com/apollographql/graphql-subscriptions/blob/master/src/pubsub-engine.ts). The community has created the following integrations: + +- [Redis](https://github.com/davidyaha/graphql-redis-subscriptions) +- [Google PubSub](https://github.com/axelspringer/graphql-google-pubsub) +- [MQTT enabled broker](https://github.com/davidyaha/graphql-mqtt-subscriptions) +- [RabbitMQ](https://github.com/cdmbase/graphql-rabbitmq-subscriptions) +- [Kafka](https://github.com/ancashoria/graphql-kafka-subscriptions) +- [Postgres](https://github.com/GraphQLCollege/graphql-postgres-subscriptions) +- [Add your implementation...](https://github.com/apollographql/apollo-server/pull/new/master) + +You can implement a `PubSub` of your own, using the exported `PubSubEngine` interface from `apollo-server` or another integration. diff --git a/docs/source/features/unions-interfaces.md b/docs/source/features/unions-interfaces.md new file mode 100644 index 00000000000..e1dcc0636ce --- /dev/null +++ b/docs/source/features/unions-interfaces.md @@ -0,0 +1,149 @@ +--- +title: Unions and interfaces +description: How to write add unions and interfaces to a schema +--- + +Unions and interfaces are great when you have fields that are in common between two types. + +## Union type + +The `Union` type indicates that a field can return more than one object type, but doesn't define specific fields itself. Unions are useful for returning disjoint data types from a single field. The type definitions appear as follows: + +```js +const { gql } = require('apollo-server'); + +const typeDefs = gql` +union Result = Book | Author + +type Book { + title: String +} + +type Author { + name: String +} + +type Query { + search: [Result] +}`; +``` + +Since a query requesting a union field, a query being made on a field which is union-typed must specify the object types containing the fields it wants. This ambiguity is solved by an extra `__resolveType` field in the resolver map. `__resolveType` defines the type of the result is out of the available options to GraphQL execution environment. + +```js +const resolvers = { + Result: { + __resolveType(obj, context, info){ + if(obj.name){ + return 'Author'; + } + + if(obj.title){ + return 'Book'; + } + + return null; + }, + }, + Query: { + search: () => { ... } + }, +}; + +const server = new ApolloServer({ + typeDefs, + resolvers, +}); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`) +}); +``` + +A possible query for these result could appear as follows. This query demonstrates the need for the `__resolveType`, since it requests different data depending on the types, + +```graphql +{ + search(contains: "") { + ... on Book { + title + } + ... on Author { + name + } + } +} +``` + +## Intersection type + +An `Interface` type provides the ability to describe fields that are shared across different types. It is best used to show that all types implementing an interface always contain the interface's fields. In other words, it is the semantic opposite of a union. For example, in this example `Vehicle` interface type is used by members `Airplane` and `Car`: + +``` +interface Vehicle { + maxSpeed: Int +} + +type Airplane implements Vehicle { + maxSpeed: Int + wingspan: Int +} + +type Car implements Vehicle { + maxSpeed: Int + licensePlate: String +} + +type Query { + vehicle: Vehicle +} +``` + +Similarly to the `Union`, `Interface` requires an extra `__resolveType` field in the resolver map. + +```js +const resolvers = { + Vehicle: { + __resolveType(obj, context, info){ + if(obj.wingspan){ + return 'Airplane'; + } + + if(obj.licensePlate){ + return 'Car'; + } + + return null; + }, + }, + Query: { + vehicle: () => { ... } + }, +}; + +const server = new ApolloServer({ + typeDefs, + resolvers, +}); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`) +}); +``` + +A possible query could appear as follows. Notice that `maxSpeed` is shared, so it can be included directly: + +```graphql +{ + vehicle { + maxSpeed + + ... on Car { + licensePlate + } + ... on Airplane { + wingspan + } + } +} +``` diff --git a/docs/source/getting-started.md b/docs/source/getting-started.md new file mode 100644 index 00000000000..5f73c2d20f5 --- /dev/null +++ b/docs/source/getting-started.md @@ -0,0 +1,182 @@ +--- +title: Getting started +--- + +> Estimated time: About 10 minutes. + +In this guide, we'll walk you through the process of creating a GraphQL server in JavaScript. By the end of the guide you should expect to: + +* Have a basic GraphQL server which will work as a foundation for a more complex server. +* Have a basic understanding of the fundamental GraphQL principles. +* Be able to send a query to the new GraphQL server and see the response using GraphQL Playground. + +To be successful, make sure you already have general JavaScript knowledge, a recent version of Node.js installed (6+). + +If you want to skip walking through the steps, the [More information](#More-information) section at the bottom has a link to a GitHub repository which can be cloned and run locally, and a Glitch to play around in your browser! + +## Step 1: Project initialization + +In this step, we'll use your terminal (e.g. Terminal, iTerm, PowerShell) to create a directory called `graphql-server-example` along with a basic Node.js configuration for a simple application. We'll work within this directory for the rest of the steps, though we will switch back and forth between your IDE (editor) + +* First, create a folder called `graphql-server-example` using the `mkdir` command. + + mkdir graphql-server-example + +* Enter the directory, so the remaining work will take place within that directory. + + cd graphql-server-example + +* Initialize the new directory as a Node.js project using the Node.js package manager, `npm`. + + npm init --yes + + > We use `npm`, the default package manager which ships with Node.js. Other package managers, such as [Yarn](http://yarnpkg.com), offer similar functionality, but will not be covered in this guide. + +If the above steps all completed successfully, there should be a new `package.json` file in the directory. You can verify this by running `ls` (list files). + +## Step 2: Install dependencies + +Next, we'll install the two core dependencies which are necessary for responding to GraphQL requests: + +* [`apollo-server`](//npm.im/apollo-server): The Apollo server library which allows you to focus on defining the shape of your data and how to fetch it. +* [`graphql`](//npm.im/graphql): The library used to build a schema and to execute queries on that schema. + > Note: There won't be any usage of the `graphql` package in this guide, but it is required to be installed separately as it's an important "peer dependency" of Apollo Server. + +While you could write all of the necessary code yourself, these two dependencies make it easier to build a GraphQL server and are common in applications of all sizes. + +Run the following command to install both of these dependencies and save them in the project: + + npm install --save apollo-server@rc graphql + +> Important: While `apollo-server` is a release candidate, its necessary to install from the `rc` tag. + +In the next step, we'll use these dependencies to create a server which processes and responds to incoming GraphQL requests. + +## Step 3: Create the server + +In this step, we'll provide a code block which sets up `apollo-server` to respond to an incoming GraphQL request. In order to move along quickly, we'll have you copy and paste the code into an `index.js` file in your project. When looking at the code, we hope you'll find the comments helpful in understanding the core GraphQL concepts. Don't worry if there is something which needs more explanation; we'll point you to the right places for more details at the end of this guide. + +The example code will utilize a static collection of two books. In a more complicated example, the books might be fetched from a web resource (e.g. Amazon or a local library's website) or a database (e.g. MySQL or MongoDB). + +1. Using an IDE/editor, open the `graphql-server-example` directory which we created in the first step. +2. Create a new, blank file called `index.js` in the root of the project directory. +3. "Copy" the following code block, "Paste" it into the `index.js` file you created in the previous step, then "Save" the file: + +```js +const { ApolloServer, gql } = require('apollo-server'); + +// This is a (sample) collection of books we'll be able to query +// the GraphQL server for. A more complete example might fetch +// from an existing data source like a REST API or database. +const books = [ + { + title: 'Harry Potter and the Chamber of Secrets', + author: 'J.K. Rowling', + }, + { + title: 'Jurassic Park', + author: 'Michael Crichton', + }, +]; + +// Type definitions define the "shape" of your data and specify +// which ways the data can be fetched from the GraphQL server. +const typeDefs = gql` + # Comments in GraphQL are defined with the hash (#) symbol. + + # This "Book" type can be used in other type declarations. + type Book { + title: String + author: String + } + + # The "Query" type is the root of all GraphQL queries. + # (A "Mutation" type will be covered later on.) + type Query { + books: [Book] + } +`; + +// Resolvers define the technique for fetching the types in the +// schema. We'll retrieve books from the "books" array above. +const resolvers = { + Query: { + books: () => books, + }, +}; + +// In the most basic sense, the ApolloServer can be started +// by passing type definitions (typeDefs) and the resolvers +// responsible for fetching the data for those types. +const server = new ApolloServer({ typeDefs, resolvers }); + +// This `listen` method launches a web-server. Existing apps +// can utilize middleware options, which we'll discuss later. +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`); +}); +``` + +The code above includes everything that is necessary to get this basic GraphQL server running. In the next step, we'll start the server so it's ready to respond to requests! + +## Step 4: Start the server + +For this step, we'll return to the terminal/console and start the server we defined in the previous steps. + +* Run the `index.js` file we created in the previous step using Node.js + + node index.js + +* You should see the following output from the above command: + + 🚀 Server ready at http://localhost:4000/ + +* Open the address provided in your web browser. +* If everything is working, you should see the GraphQL Playground explorer tool, which we will use in the next step. + +![GraphQL Playground](./images/getting-started/graphql-playground.png) + +In the next step, we'll use the GraphQL Playground tool to send queries to the GraphQL server. + +## Step 5: Running your first query + +At this point, you'll be able to start sending queries to the GraphQL server using GraphQL Playground, which is split into a few parts: + +* The request (on the left) +* The response (on the right) +* The documentation (available using the green "SCHEMA" button on the far right side) + +Since we're trying to obtain books, we can enter the following query on the left side of the window. This query asks for a list of books, including the title and author for each book. + +``` +{ + books { + title + author + } +} +``` + +If we press the play button in the middle, we should see a response on the right that looks something like this: + +![The response from our server shows title and author!](./images/getting-started/graphql-playground-response.png) + +## Next steps + +This application should be a great starting point for any GraphQL server, but the following resources are a great next step in building a GraphQL server: + +* [Adding Apollo Server to an existing app.](./essentials/server.html#integrations) +* [Schema design](./essentials/schema.html) +* [Deploy with Heroku](./deployment/heroku.html) + +## More information + +### GitHub Repository + +The code from the above examples can be accessed in our [getting started example repository](https://github.com/apollographql/graphql-server-example) on GitHub + +### Online Playground + +It's also possible to play with this example on Glitch, by remixing the repository. + +[![Remix on Glitch](https://cdn.glitch.com/2703baf2-b643-4da7-ab91-7ee2a2d00b5b%2Fremix-button.svg)](https://glitch.com/edit/#!/import/github/apollographql/graphql-server-example) diff --git a/docs/source/graphiql.md b/docs/source/graphiql.md deleted file mode 100644 index abe72017645..00000000000 --- a/docs/source/graphiql.md +++ /dev/null @@ -1,85 +0,0 @@ ---- -title: GraphiQL IDE -description: How to set up GraphiQL with Apollo Server to explore your API with docs and auto-completion. ---- - -Apollo Server allows you to easily use [GraphiQL](https://github.com/graphql/graphiql). Here's how: - -

Configuring GraphiQL

- -`graphiql` accepts the following options object: - -```js -const options = { - endpointURL: String, // URL for the GraphQL POST endpoint this instance of GraphiQL serves - query?: String, // optional query to pre-populate the GraphiQL UI with - operationName?: String, // optional operationName to pre-populate the GraphiQL UI with - variables?: Object, // optional variables to pre-populate the GraphiQL UI with - result?: Object, // optional result to pre-populate the GraphiQL UI with - passHeader?: String, // a string that will be added to the outgoing request header object (e.g "'Authorization': 'Bearer lorem ipsum'") - editorTheme?: String, // optional CodeMirror theme to be applied to the GraphiQL UI - rewriteURL?: Boolean, // optionally turn off url rewriting when editing queries -} -``` - -Apollo Server's `graphiql` middleware does not run any query passed to it, it simply renders it in the UI. -To actually execute the query, the user must submit it via the GraphiQL UI, which will -send the request to the GraphQL endpoint specified with `endpointURL`. - -

Using with Express

- -If you are using Express, GraphiQL can be configured as follows: - -```js -import { graphiqlExpress } from 'apollo-server-express'; - -app.use( - '/graphiql', - graphiqlExpress({ - endpointURL: '/graphql', - }), -); -``` - -

Using with Connect

- -If you are using Connect, GraphiQL can be configured as follows: - -```js -import { graphiqlConnect } from 'apollo-server-express'; - -app.use( - '/graphiql', - graphiqlConnect({ - endpointURL: '/graphql', - }), -); -``` - -

Using with Hapi

- -If you are using Hapi, GraphiQL can be configured as follows: - -```js -import { graphiqlHapi } from 'apollo-server-hapi'; - -server.register({ - plugin: graphiqlHapi, - options: { - path: '/graphiql', - graphiqlOptions: { - endpointURL: '/graphql', - }, - }, -}); -``` - -

Using with Koa 2

- -If you are using Koa 2, GraphiQL can be configured as follows: - -```js -import { graphiqlKoa } from 'apollo-server-koa'; - -router.get('/graphiql', graphiqlKoa({ endpointURL: '/graphql' })); -``` diff --git a/docs/source/images/deployment/heroku/add-env-vars.png b/docs/source/images/deployment/heroku/add-env-vars.png new file mode 100644 index 00000000000..924d404de11 Binary files /dev/null and b/docs/source/images/deployment/heroku/add-env-vars.png differ diff --git a/docs/source/images/deployment/heroku/add-integration.png b/docs/source/images/deployment/heroku/add-integration.png new file mode 100644 index 00000000000..680322faebf Binary files /dev/null and b/docs/source/images/deployment/heroku/add-integration.png differ diff --git a/docs/source/images/deployment/heroku/create-app.png b/docs/source/images/deployment/heroku/create-app.png new file mode 100644 index 00000000000..8df6e0e1cac Binary files /dev/null and b/docs/source/images/deployment/heroku/create-app.png differ diff --git a/docs/source/images/deployment/heroku/new-app.png b/docs/source/images/deployment/heroku/new-app.png new file mode 100644 index 00000000000..0fff9b53a9f Binary files /dev/null and b/docs/source/images/deployment/heroku/new-app.png differ diff --git a/docs/source/images/deployment/zeit/zeit-apollo-server.png b/docs/source/images/deployment/zeit/zeit-apollo-server.png new file mode 100644 index 00000000000..d6887ec9019 Binary files /dev/null and b/docs/source/images/deployment/zeit/zeit-apollo-server.png differ diff --git a/docs/source/images/features/error-code.png b/docs/source/images/features/error-code.png new file mode 100644 index 00000000000..e4c27b1961b Binary files /dev/null and b/docs/source/images/features/error-code.png differ diff --git a/docs/source/images/features/error-stacktrace.png b/docs/source/images/features/error-stacktrace.png new file mode 100644 index 00000000000..3007b8acce1 Binary files /dev/null and b/docs/source/images/features/error-stacktrace.png differ diff --git a/docs/source/images/features/error-user-input.png b/docs/source/images/features/error-user-input.png new file mode 100644 index 00000000000..0f4a7ce6ca4 Binary files /dev/null and b/docs/source/images/features/error-user-input.png differ diff --git a/docs/source/images/getting-started/graphql-playground-response.png b/docs/source/images/getting-started/graphql-playground-response.png new file mode 100644 index 00000000000..3a1528948dd Binary files /dev/null and b/docs/source/images/getting-started/graphql-playground-response.png differ diff --git a/docs/source/images/getting-started/graphql-playground.png b/docs/source/images/getting-started/graphql-playground.png new file mode 100644 index 00000000000..1ce257d8b01 Binary files /dev/null and b/docs/source/images/getting-started/graphql-playground.png differ diff --git a/docs/source/graphiql-casual-mocks.png b/docs/source/images/graphiql-casual-mocks.png similarity index 100% rename from docs/source/graphiql-casual-mocks.png rename to docs/source/images/graphiql-casual-mocks.png diff --git a/docs/source/images/graphql-playground.png b/docs/source/images/graphql-playground.png new file mode 100644 index 00000000000..29015cadc29 Binary files /dev/null and b/docs/source/images/graphql-playground.png differ diff --git a/docs/source/images/index-diagram.png b/docs/source/images/index-diagram.png new file mode 100644 index 00000000000..50a2189f522 Binary files /dev/null and b/docs/source/images/index-diagram.png differ diff --git a/docs/source/images/index-diagram.svg b/docs/source/images/index-diagram.svg new file mode 100644 index 00000000000..4dd3e404891 --- /dev/null +++ b/docs/source/images/index-diagram.svg @@ -0,0 +1,115 @@ + + + + server-arch-JM + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Web + + + + + + + iOS + + + + + + + Android + + + + + + + Microservice + + + + + + + Database + + + + + + + REST API + + + + + + + + + + + + + + Client + + + Server + + + + \ No newline at end of file diff --git a/docs/source/images/index-get-started.png b/docs/source/images/index-get-started.png new file mode 100644 index 00000000000..93f53dbc795 Binary files /dev/null and b/docs/source/images/index-get-started.png differ diff --git a/docs/source/images/index-get-started.svg b/docs/source/images/index-get-started.svg new file mode 100644 index 00000000000..486156de3cb --- /dev/null +++ b/docs/source/images/index-get-started.svg @@ -0,0 +1,15 @@ + + + + Group + Created with Sketch. + + + + + + GET STARTED! + + + + \ No newline at end of file diff --git a/docs/source/images/persistedQueries.newPath.png b/docs/source/images/persistedQueries.newPath.png new file mode 100644 index 00000000000..5b0412559f5 Binary files /dev/null and b/docs/source/images/persistedQueries.newPath.png differ diff --git a/docs/source/images/persistedQueries.optPath.png b/docs/source/images/persistedQueries.optPath.png new file mode 100644 index 00000000000..559904de812 Binary files /dev/null and b/docs/source/images/persistedQueries.optPath.png differ diff --git a/docs/source/index.md b/docs/source/index.md index 9af989c3e03..63e95858453 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -1,62 +1,29 @@ --- -title: Build a GraphQL server with Node.js -sidebar_title: Installing -description: Apollo Server is a flexible, community driven, production-ready HTTP GraphQL middleware for Express, Hapi, Koa, and more. +title: Introduction +description: What is Apollo Server and what does it do? --- -> This is the documentation for the 1.x version of Apollo Server. For new projects, we recommend using [Apollo Server 2](/docs/apollo-server/v2/). +Apollo Server is the best way to quickly build a production-ready, self-documenting API for GraphQL clients, using data from any source. -Apollo Server is a library that helps you connect a GraphQL schema to an HTTP server in Node. Apollo Server works with any GraphQL schema built with [GraphQL.js](https://github.com/graphql/graphql-js), so you can build your schema with that directly or with a convenience library such as [graphql-tools](https://www.apollographql.com/docs/graphql-tools/). You can use Apollo Server with all popular JavaScript HTTP servers, including Express, Connect, Hapi, Koa, Restify, and Lambda. +It's open-source and works great as a stand-alone server, an addon to an existing Node.js HTTP server, or in "serverless" environments. -This server can be queried from any GraphQL client, since it supports all of the common semantics for sending GraphQL over HTTP, as [documented on graphql.org](http://graphql.org/learn/serving-over-http/). Apollo Server also supports some small extensions to the protocol, such as sending multiple GraphQL operations in one request. Read more on the [sending requests](./requests.html) page. + -[Contribute to Apollo Server on GitHub.](https://github.com/apollographql/apollo-server) +Apollo Server implements a spec-compliant GraphQL server which can be queried from any GraphQL client, including [Apollo Client](/docs/react), enabling: -

Quick start

+1. **An easy start**, so front-end and back-end developers can start fetching data quickly. +2. **Incremental adoption**, allowing advanced features to be added when they're needed. +3. **Universal compatibility** with any data source, any build tool and any GraphQL client. +4. **Production readiness**, and what you build in development works great in production. -If you want to get started quickly, take a look at the [quick start code snippet](./example.html). This will get you started with a Node.js GraphQL server in about 10 seconds. +These docs will help you go from getting started with Apollo to becoming an expert in no time! -

End-to-end GraphQL server tutorial

- -If you're looking to learn about how to connect to different data sources, check out our recently updated tutorial which walks you through building a server from start to finish: [How To Build a GraphQL Server to talk to SQL, MongoDB, and REST](https://blog.apollographql.com/tutorial-building-a-graphql-server-cddaa023c035) - -

Selecting the right package

- -Apollo Server is actually a family of npm packages, one for each Node.js HTTP server library. - -Pick the one below that suits your needs: - -```bash -# Pick the one that matches your server framework -npm install graphql apollo-server-express # for Express or Connect -npm install graphql apollo-server-hapi -npm install graphql apollo-server-koa -npm install graphql apollo-server-restify -npm install graphql apollo-server-lambda -npm install graphql apollo-server-micro -npm install graphql apollo-server-azure-functions -npm install graphql apollo-server-adonis -``` - -If you don't see your favorite server there, [file a PR](https://github.com/apollographql/apollo-server)! - -

Features

- -At the end of the day, Apollo Server is a simple, production-ready solution without too many features. Here's what you can do with it: - -* Attach a GraphQL schema to your HTTP server to serve requests -* Attach GraphQL and GraphiQL via separate middlewares, on different routes -* Accept queries via GET or POST -* Support HTTP query batching -* Support Apollo Tracing to get performance information about your server -* Support Apollo Cache Control to inform caching gateways such as Apollo Engine - -

Principles

- -Apollo Server is built with the following principles in mind: - -* **By the community, for the community**: Apollo Server's development is driven by the needs of developers using the library. -* **Simplicity**: Keeping things simple, for example supporting a limited set of transports, makes Apollo Server easier to use, easier to contribute to, and more secure. -* **Performance**: Apollo Server is well-tested and production-ready. - -Anyone is welcome to contribute to Apollo Server, just read [CONTRIBUTING.md](https://github.com/apollographql/apollo-server/blob/master/CONTRIBUTING.md), take a look at the [issues](https://github.com/apollographql/apollo-server/issues) and make your first PR! + diff --git a/docs/source/migration-engine.md b/docs/source/migration-engine.md new file mode 100644 index 00000000000..84094067a50 --- /dev/null +++ b/docs/source/migration-engine.md @@ -0,0 +1,100 @@ +--- +title: Using Engine with v2.0 RC +description: How to use Engine with Apollo Server 2.0 RC +--- + +Apollo Server provides reporting, persisted queries, and cache-control headers in native javascript by default, so often times moving to Apollo Server 2 without the Engine proxy is possible. For services that already contain the Engine proxy and depend on its full response caching, Apollo Server continues to support it with first class functionality. With Apollo Server 2, the Engine proxy can be started by the same node process. If the Engine proxy is running in a dedicated machine, Apollo Server 2 supports the cache-control and tracing extensions, used to communicate with the proxy. + +## Stand-alone Apollo Server + +Apollo Server 2 is able to replace all the metrics-reporting functionality which once required the Apollo Engine Proxy. To enable metrics reporting in Apollo Server 2, add `ENGINE_API_KEY` as an environment variable. With this setting enabled, Apollo Server 2 will automatically send execution traces directly to Apollo Engine. In addition, by default, Apollo Server supports [persisted queries](./features/apq.html) without needing the proxy's cache. Apollo Server also sets `Cache-Control` headers for consumption by a CDN. Integrating a CDN provides an alternative to the full response caching inside of Engine proxy. + +```js +const { ApolloServer } = require('apollo-server'); + +const server = new ApolloServer({ + typeDefs, + resolvers, +}); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`); +}); +``` + +> For more information, see the [CDN section of the Performance guide](/docs/guides/performance.html). + +## Starting Engine Proxy + +Some infrastructure already contains the Engine proxy and requires it for full response caching, so it is necessary to run the proxy as a process alongside Apollo Server. If full response caching is not necessary, then the Engine proxy can be completely replaced by Apollo Server 2. The `apollo-engine` package provides integrations with many [node frameworks](/docs/engine/setup-node.html#not-express), including [express](/docs/engine/setup-node.html#setup-guide), and starts the Engine proxy alongside Apollo Server. The following code demonstrates how to start the proxy with Apollo Server 2. It assumes that the `ENGINE_API_KEY` environment variable is set to the api key of the service. + +```js +const { ApolloEngine } = require('apollo-engine'); +const { ApolloServer } = require('apollo-server-express'); +const express = require('express'); + +const app = express(); +const server = new ApolloServer({ + typeDefs, + resolvers, + tracing: true, + cacheControl: true, + // We set `engine` to false, so that the new agent is not used. + engine: false, +}); + +server.applyMiddleware({ app }); + +const engine = new ApolloEngine({ + apiKey: process.env.ENGINE_API_KEY, +}); + +engine.listen({ + port: 4000, + graphqlPaths: ['/api/graphql'], + expressApp: app, + launcherOptions: { + startupTimeout: 3000, + }, +}, () => { + console.log('Listening!'); +}); +``` + +To set the default max age inside of cacheControl, some additional options must be specified: + +```js +const server = new ApolloServer({ + typeDefs, + resolvers, + tracing: true, + cacheControl: { + defaultMaxAge: 5, + stripFormattedExtensions: false, + calculateCacheControlHeaders: false, + }, + // We set `engine` to false, so that the new agent is not used. + engine: false, +}); +``` + +## With a Running Engine Proxy + +If the Engine proxy is already running in a container in front of Apollo Server, then set `tracing` and `cacheControl` to true. These options will provide the extensions information to the proxy to create traces and ensure caching. We set `engine` to false, so that the new metrics reporting pipeline is not activated. + +```js +const { ApolloServer } = require('apollo-server'); + +const server = new ApolloServer({ + typeDefs, + resolvers, + tracing: true, + cacheControl: true, + // We set `engine` to false, so that the new agent is not used. + engine: false, +}); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`); +}); +``` diff --git a/docs/source/migration-hapi.md b/docs/source/migration-hapi.md deleted file mode 100644 index f4be66b7be3..00000000000 --- a/docs/source/migration-hapi.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -title: Migrating to v0.3 -description: How to migrate to Apollo Server 0.3 from 0.2. ---- - -> Note: This guide assumes you were previously up to date with `apollo-server` series `0.2.x`. If you are currently using `0.1`, consult the [previous migration guide](migration.md). - -Version 0.3.0 of Apollo Server contains a couple of breaking changes in the Hapi plugin API. -The most notable changes are: - -* the plugin class has been replaced as a function to be more idiomatic -* the plugin name has been renamed to use camelcase -* the options object has been extended to support additional routing options - -The following code snippet for Hapi Apollo 0.2.x - -```js -import { ApolloHAPI } from 'apollo-server'; -... -server.register({ - register: new ApolloHAPI(), - options: { schema: myGraphQLSchema }, - routes: { prefix: '/graphql' }, -}); -``` - -... should be written as follows for Hapi Apollo 0.3.x - -```js -import { apolloHapi } from 'apollo-server'; -... -server.register({ - register: apolloHapi, - options: { - path: '/graphql', - apolloOptions: { - schema: myGraphQLSchema, - }, - route: { - cors: true - } - }, -}); -``` - -_NOTE:_ That you can now pass additional routing configuration via the route options diff --git a/docs/source/migration-one-dot.md b/docs/source/migration-one-dot.md deleted file mode 100644 index 96873114799..00000000000 --- a/docs/source/migration-one-dot.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -title: Migrating to v1.0 -description: How to migrate to Apollo Server 1.0 ---- - -In July of 2017, we [announced the release of Apollo Server 1.0](https://blog.apollographql.com/apollo-server-1-0-a-graphql-server-for-all-node-js-frameworks-2b37d3342f7c). This was not a major change, except for one thing: All of the packages have been renamed from `graphql-server-*` to `apollo-server-*`. - -All of the options, names, and API are identical to pre-1.0 versions. - -So, if before you were doing: - -```js -import { graphqlExpress, graphiqlExpress } from 'graphql-server-express'; -``` - -Now, you should do: - -```js -import { graphqlExpress, graphiqlExpress } from 'apollo-server-express'; -``` - -We made this change because it was consistent with how developers in the community were referring to the package. With the `graphql-server` name, we wanted to emphasize that this package works with any GraphQL technology, but people called it "Apollo Server" anyway, so we decided that was a good name to use officially as well. diff --git a/docs/source/migration-two-dot.md b/docs/source/migration-two-dot.md new file mode 100644 index 00000000000..fab5548a415 --- /dev/null +++ b/docs/source/migration-two-dot.md @@ -0,0 +1,285 @@ +--- +title: Migrating to v2.0 Release Candidate +description: How to migrate to Apollo Server 2.0 rc +--- + +The Apollo Server 2.0 release candidate dramatically simplifies the API for building a GraphQL server without compromising on features. It's also completely backward compatible, so you don't have to worry about breaking changes when upgrading. + +While it's possible to migrate an existing server to the 2.0 release candidate without any changes, we recommend changing to new patterns we're suggesting in order to take advantage of all the latest Apollo Server features, reduce the boilerplate, and enable future flexibility. To learn how to migrate to the 2.0 release candidate from version 1.0, please read the following guide. + +> **Note:** In the release candidate of Apollo Server 2.0 only Express and Hapi are supported. Additional integrations will be implemented in the official 2.0 release. + +

The `gql` tag

+ +Apollo Server 2.0 ships with the `gql` tag for **editor syntax highlighting** and **auto-formatting** with Prettier. In the future, we will be using it for statically analyzing GraphQL queries, so Apollo Servers requires wrapping your schema with `gql`. + +The `gql` tag parses the query string into an AST and is now exported from the new `apollo-server` package. + +```js line=1,3 +const { ApolloServer, gql } = require('apollo-server'); + +const typeDefs = gql` + type Query { + hello: String + } +`; + +//Some projects use schemas imported from external files +const fs = require('fs'); +const typeDefs = gql`${fs.readFileSync(__dirname.concat('/schema.graphql'), 'utf8')}`; + +//gql can also be used as regular function to convert a string to an AST +const typeDefs = gql(fs.readFileSync(__dirname.concat('/schema.graphql'), 'utf8')) +``` + +

Changes to app dependencies

+ +> Apollo Server 2.0 RC requires Node.js v6 and higher. + +Apollo Server 2.0 simplifies implementing a GraphQL server. Apollo Server 1.0 revolved around providing middleware-based solutions, which had to be added to an application which already existed. These middleware implementations were tied to the HTTP server in use (e.g. `apollo-server-express` for Express implementations, `apollo-server-hapi` for hapi, etc.). + +There is a consideration to be made when following the rest of the guide: + +* [**Middleware option**](#Middleware): If the application being migrated implements Apollo Server alongside other middleware, there are some packages which can be removed, but adding the `apollo-server-{integration}` package and switching to using the new `applyMiddleware` API should still simplify the setup. In this case, check the [Middleware](#Middleware) section. +* [**Stand-alone option**](#Stand-alone): If the application being migrated is only used as a GraphQL server, Apollo Server 2.0 _eliminates the need to run a separate HTTP server_ and allows some dependencies to be removed. In these cases, the [Stand-alone](#Stand-alone) option will reduce the amount of code necessary for running a GraphQL server. + +

Simplified usage

+ +Check out the following changes for Apollo Server 2.0 RC. + +* You no longer need to import `body-parser` to set up `apollo-server-express`. +* You no longer need to import `makeExecutableSchema` from `graphql-tools`. +* You no longer need to import `graphqlExpress` and `graphiqlExpress` from `apollo-server-express`. +* You should pass in `typeDefs` and resolvers as parameters to an instance of Apollo Server. +* If the server is only functioning as a GraphQL server, it's no longer necessary to run your own HTTP server (like `express`). + +

Middleware

+ +With the middleware option used by Apollo Server 1.0 users, it is necessary to install the release candidate version of `apollo-server-express`. To do this, use the `rc` tag when installing: + + npm install --save apollo-server-express@rc graphql + +The changes are best shown by comparing the before and after of the application. + +

Apollo Server 1 (old pattern)

+ +An example of using Apollo Server 1 with the Express framework: + +```js +const express = require('express'); +const bodyParser = require('body-parser'); +const { makeExecutableSchema } = require('graphql-tools'); +const { graphqlExpress } = require('apollo-server-express'); + +const typeDefs = ` + type Query { + hello: String + } +`; + +const resolvers = { + Query: { + hello: () => 'Hello world!' + }, +} + +const myGraphQLSchema = makeExecutableSchema({ + typeDefs, + resolvers, +}); + +const PORT = 3000; + +const app = express(); + +// bodyParser is needed just for POST. +app.use('/graphql', bodyParser.json(), graphqlExpress({ schema: myGraphQLSchema })); + +app.listen(PORT); +``` + +

Apollo Server 2 (new pattern)

+ +Now, you can just do this instead: + +```js +const express = require('express'); +const { ApolloServer, gql } = require('apollo-server-express'); + +const app = express(); + +const typeDefs = gql` + type Query { + hello: String + } +`; + +const resolvers = { + Query: { + hello: () => 'Hello world!' + }, +}; + +const server = new ApolloServer({ typeDefs, resolvers }); +server.applyMiddleware({ app }); + +app.listen({ port: 4000 }, () => + console.log(`🚀 Server ready at http://localhost:4000${server.graphqlPath}`) +) +``` + +

Stand-alone

+ +For starting a production-ready GraphQL server quickly, Apollo Server 2.0 ships with a built-in server, so starting a server (e.g. Express, Koa, etc.) is no longer necessary. + +For these cases, it's possible to remove the existing `apollo-server-{integrations}` package and add the new `apollo-server` release candidate. If using Express, this can be done by running: + + npm uninstall --save apollo-server-express + + npm install --save apollo-server@rc graphql + +An implementation with this pattern would look like: + +```js +const { ApolloServer, gql } = require('apollo-server'); + +// Construct a schema, using GraphQL schema language +const typeDefs = gql` + type Query { + announcement: String + } +`; + +// Provide resolver functions for your schema fields +const resolvers = { + Query: { + announcement: () => + `Say hello to the new Apollo Server! A production ready GraphQL server with an incredible getting started experience.` + } +}; + +const server = new ApolloServer({ typeDefs, resolvers }); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`); +}); +``` + + +

Adding Additional Middleware to Apollo Server 2

+ +For middleware that is collocated with the GraphQL endpoint, Apollo Server 2 allows middleware mounted on the same path before `applyMiddleware` is called. For example, this server runs an authentication middleware before GraphQL execution. + +```js +const express = require('express'); +const { ApolloServer, gql } = require('apollo-server-express'); + +const app = express(); +const path = '/graphql'; + +const server = new ApolloServer({ typeDefs, resolvers }); + +//Mount a jwt or other authentication middleware that is run before the GraphQL execution +app.use(path, jwtCheck); + +server.applyMiddleware({ app, path }); + +app.listen({ port: 4000 }, () => + console.log(`🚀 Server ready at http://localhost:4000${server.graphqlPath}`) +) +``` + +

Using an Existing Schema

+ +For many existing instances of Apollo Server, the schema is created at runtime before server startup, using `makeExecutableSchema` or `mergeSchemas`. Apollo Server 2 stays backwards compatible with these more complex schemas, accepting it as the `schema` field in the server constructor options. Additionally, Apollo Server 2 exports all of `graphql-tools`, so `makeExecutableSchema` and other functions can be imported directly from Apollo Server. + +> Note: the string to create these schema will not use the `gql` tag exported from apollo-server. + +```js +const { ApolloServer, makeExecutableSchema, gql } = require('apollo-server'); + +//For developer tooling, such as autoformatting, use the following workaround +const gql = String.raw; + +const typeDefs = gql` + type Query { + hello: String + } +`; + +const schema = makeExecutableSchema({ + typeDefs, + resolvers, +}); +//mergeSchemas can be imported from apollo-server +//const schema = mergeSchemas(...); + +const server = new ApolloServer({ schema }); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`); +}); +``` + +

Accessing Request Headers

+ +Apollo Server 1 allowed request headers to be used in the construction of the GraphQL options. Apollo Server 2 allows constructor to create the context based upon the request. + +```js +//old way +graphqlExpress((req, res) => ({ + schema: myGraphQLSchema, + context: { + token: req.headers['auth-token'], + }, +})) + +//new way +new ApolloServer({ + schema: myGraphQLSchema, + context: ({req, res}) => ({ + token: req.headers['auth-token'], + }), +}); +``` + +

Replacing `logFunction`

+ +Apollo Server 2 removes the `logFunction` to reduce the exposure of internal implementation details. The experimental, non-public `graphql-extensions` provides a more structured and flexible way of instrumenting Apollo Server. An explanation of to do more granular logging, can be found in the [metrics section](./features/metrics.html). + +

Replacing GraphiQL

+ +Apollo Server 2 ships with GraphQL Playground instead of GraphiQL and collocates the gui with the endpoint. GraphQL playground can be customized in the following manner. + +```js +const { ApolloServer, gql } = require('apollo-server-express'); + +const server = new ApolloServer({ + // These will be defined for both new or existing servers + typeDefs, + resolvers, +}); + +server.applyMiddleware({ + app, // app is from an existing express app + gui: { + endpoint?: string + subscriptionEndpoint?: string + tabs: [ + { + endpoint: string + query: string + variables?: string + responses?: string[] + headers?: { [key: string]: string } + }, + ], + } +}); + +app.listen({ port: 4000 }, () => + console.log(`🚀 Server ready at http://localhost:4000${server.graphqlPath}`) +) +``` + +Some Apollo Server 1 implementations use a custom version of GraphiQL, which can be added to Apollo Server 2 as a middleware or ported to use the [React version of GraphQL Playground](https://www.npmjs.com/package/graphql-playground-react). diff --git a/docs/source/migration.md b/docs/source/migration.md deleted file mode 100644 index 78dec59ea19..00000000000 --- a/docs/source/migration.md +++ /dev/null @@ -1,112 +0,0 @@ ---- -title: Migrating to v0.2 -description: How to migrate from an older version of Apollo Server ---- - -Version 0.2.0 of Apollo Server contains several breaking changes in the API. -The most notable changes are: - -* the `apolloServer` function no longer exists and was replaced with `apolloExpress`. -* `apolloExpress` no longer accepts shorthand type definitions -* `apolloExpress` doesn't have the `resolvers`, `mocks` and `connectors` options. -* `apolloExpress` doesn't include GraphiQL any more -* `context`: if you use connectors in your schema, don't forget to setup default `context` to at least an empty object, it can't be `undefined` in this case -* Apollo Server no longer accepts GET requests or parameters in the URL -* `apolloExpress` no longer parses the HTTP body automatically - -In order to make updating from an older version of Apollo Server easier, this guide -shows how to use `graphql-tools` together with `apolloExpress` and `graphiqlExpress` to -replace the old `apolloServer` function. - -The three main differences between the old and the new approach are: - -1. generating the schema is now done with `graphql-tools`, Apollo Server only uses the finished schema. -2. `bodyParser` has to be used to parse requests before passing them to `expressApollo` -3. GraphiQL now has to be served on a separate path - -The following code snippet in Apollo Server 0.1.x - -```js -import express from 'express'; -import { apolloServer } from 'apollo-server'; -import Schema from './data/schema'; -import Mocks from './data/mocks'; -import Resolvers from './data/resolvers'; -import Connectors from './data/connectors'; - -const GRAPHQL_PORT = 8080; - -const graphQLServer = express(); - -graphQLServer.use( - '/graphql', - apolloServer({ - graphiql: true, - schema: Schema, - resolvers: Resolvers, - connectors: Connectors, - mocks: Mocks, - }), -); - -graphQLServer.listen(GRAPHQL_PORT, () => - console.log( - `Apollo Server is now running on http://localhost:${GRAPHQL_PORT}/graphql`, - ), -); -``` - -... should be written as follows in Apollo Server 0.2.x and above: - -```js -import express from 'express'; - -import Schema from './data/schema'; -import Mocks from './data/mocks'; -import Resolvers from './data/resolvers'; -import Connectors from './data/connectors'; - -// NEW or changed imports: -import { apolloExpress, graphiqlExpress } from 'apollo-server'; -import { makeExecutableSchema, addMockFunctionsToSchema } from 'graphql-tools'; -import bodyParser from 'body-parser'; - -const GRAPHQL_PORT = 8080; - -const graphQLServer = express(); - -const executableSchema = makeExecutableSchema({ - typeDefs: Schema, - resolvers: Resolvers, - connectors: Connectors, -}); - -addMockFunctionsToSchema({ - schema: executableSchema, - mocks: Mocks, - preserveResolvers: true, -}); - -// `context` must be an object and can't be undefined when using connectors -graphQLServer.use( - '/graphql', - bodyParser.json(), - apolloExpress({ - schema: executableSchema, - context: {}, //at least(!) an empty object - }), -); - -graphQLServer.use( - '/graphiql', - graphiqlExpress({ - endpointURL: '/graphql', - }), -); - -graphQLServer.listen(GRAPHQL_PORT, () => - console.log( - `Apollo Server is now running on http://localhost:${GRAPHQL_PORT}/graphql`, - ), -); -``` diff --git a/docs/source/schemas/organization.md b/docs/source/schemas/organization.md new file mode 100644 index 00000000000..461bba781de --- /dev/null +++ b/docs/source/schemas/organization.md @@ -0,0 +1,352 @@ +--- +title: Organization +description: Scaling your Apollo Server from a single file to your entire team +--- + +The schema contains the information to define all requests that the client can request from an instance of Apollo Server along with the resolvers necessary to route the requests to retrieve data. For most applications, the schema type definitions are be placed in a single file along side the resolvers. Placing reolvers in the same file as their accompanying type definitions is the best way to organize the code, since it enables developers to locate and modify the two inter-dependent portions. + +Sometimes production servers contain a typeDefs string of over a thousand lines, which makes it difficult to maintain a file containing the resolvers as well. For applications with multiple teams or product domains, this section describes an example application and methods for organizing types and resolvers to make a large instance more modular. The separation between types should follow real-world domains, for example movies vs books, rather than the backend organization. To facilitate this organization, common practice is to create a data model layer that enables resolvers across domains to request data from a common interface. A data layer further enables the different schema domains to share data, such as a user profile. + +Along with modularizing large schemas, GraphQL enables schemas to include documentation inline that is viewable in GraphQL Playground. + +## Prerequisites + +* Understanding of GraphQL types +* Resolvers + +## Example Application + +The application contains a schema, resolvers with fake data, and the Apollo Server start code. + +### Types + +When using `apollo-server`, the schema is defined as a string in the [GraphQL SDL](). + +```js +const typeDefs = ` + type Author { + id: Int! + firstName: String + lastName: String + """ + the list of Posts by this author + """ + posts: [Post] + } + + type Post { + id: Int! + title: String + author: Author + votes: Int + } + + # the schema allows the following query: + type Query { + posts: [Post] + author(id: Int!): Author + } + + # this schema allows the following mutation: + type Mutation { + upvotePost ( + postId: Int! + ): Post + } +`; +``` + +### Resolvers + +In the same file as the type defintinos, the resolvers are organized as a nested object that maps type and field names to functions: + +```js +const { find, filter } = require('lodash'); + +// example data +const authors = [ + { id: 1, firstName: 'Tom', lastName: 'Coleman' }, + { id: 2, firstName: 'Sashko', lastName: 'Stubailo' }, + { id: 3, firstName: 'Mikhail', lastName: 'Novikov' }, +]; + +const posts = [ + { id: 1, authorId: 1, title: 'Introduction to GraphQL', votes: 2 }, + { id: 2, authorId: 2, title: 'Welcome to Meteor', votes: 3 }, + { id: 3, authorId: 2, title: 'Advanced GraphQL', votes: 1 }, + { id: 4, authorId: 3, title: 'Launchpad is Cool', votes: 7 }, +]; + +const resolvers = { + Query: { + posts: () => posts, + author: (_, { id }) => find(authors, { id }), + }, + + Mutation: { + upvotePost: (_, { postId }) => { + const post = find(posts, { id: postId }); + if (!post) { + throw new Error(`Couldn't find post with id ${postId}`); + } + post.votes += 1; + return post; + }, + }, + + Author: { + posts: author => filter(posts, { authorId: author.id }), + }, + + Post: { + author: post => find(authors, { id: post.authorId }), + }, +}; +``` + +### Server Instantiation + +At the end, Apollo server accepts the schema and resolvers: + +```js +const { ApolloServer, gql } = require('apollo-server'); + +const server = new ApolloServer({ typeDefs, resolvers }); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`) +}); +``` + +For small to medium applications, collocating all type definition in one string and resolvers in one object is ideal, since central storage reduces complexity. Eventually for larger applications and teams, defining types and resolvers in separate files and combining them is ideal. The next section describes how Apollo Server enables this separation. + +

Modularizing the schema types

+ +When schemas get large, we can start to define types in different files and import them to create the complete schema. We accomplish this by importing and exporting schema strings, combining them into arrays as necessary. + +```js +// comment.js +const typeDefs = gql` + type Comment { + id: Int! + message: String + author: String + } +`; + +export typeDefs; +``` + +```js +// post.js +const Comment = require('./comment'); + +const typeDefs = [` + type Post { + id: Int! + title: String + content: String + author: String + comments: [Comment] + } +`].concat(Comment.typeDefs); + +// we export Post and all types it depends on +// in order to make sure we don't forget to include +// a dependency +export typeDefs; +``` + +```js +// schema.js +const Post = require('./post.js'); + +const RootQuery = ` + type RootQuery { + post(id: Int!): Post + } +`; + +const SchemaDefinition = ` + schema { + query: RootQuery + } +`; + +const server = new ApolloServer({ + //we may destructure Post if supported by our Node version + typeDefs: [SchemaDefinition, RootQuery].concat(Post.typeDefs), + resolvers, +}); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`) +}); +``` + +

Modularizing resolvers

+ +We can accomplish the same modularity with resolvers by passing around multiple resolver objects and combining them together with Lodash's `merge` or other equivalent: + +```js +// comment.js +const resolvers = { + Comment: { ... } +} + +export resolvers; +``` + +```js +// post.js +const { merge } = require('lodash'); + +const Comment = require('./comment'); +const resolvers = merge({ + Post: { ... } +}, Comment.resolvers); + +export resolvers; +``` + +```js +// schema.js +const { merge } = require('lodash'); +const Post = require('./post.js'); + +// Merge all of the resolver objects together +const resolvers = merge({ + Query: { ... } +}, Post.resolvers); + +const server = new ApolloServer({ + typeDefs, + resolvers, +}); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`) +}); +``` + +

Sharing types across domains

+ +Schemas often contain circular dependencies or a shared type that has been hoisted to be referenced in separate files. When exporting array of schema strings with circular dependencies, the array can be wrapped in a function. The Apollo Server will only include each type definition once, even if it is imported multiple times by different types. Preventing deduplication of type definitions means that domains can be self contained and fully functional regardless of how they are combined. + +```js +// author.js +const Book = require('./book'); + +const Author = ` + type Author { + id: Int! + firstName: String + lastName: String + books: [Book] + } +`; + +// we export Author and all types it depends on +// in order to make sure we don't forget to include +// a dependency and we wrap it in a function +// to avoid strings deduplication +export const typeDefs = () => [Author].concat(Book.typeDefs); +``` + +```js +// book.js +const Author = require('./author'); + +const Book = ` + type Book { + title: String + author: Author + } +`; + +export const typeDefs = () => [Book].concat(Author.typeDefs); +``` + +```js +// schema.js +const Author = require('./author.js'); + +const RootQuery = ` + type RootQuery { + author(id: Int!): Author + } +`; + +const SchemaDefinition = ` + schema { + query: RootQuery + } +`; + +const server = new ApolloServer({ + //we may destructure Post if supported by our Node version + typeDefs: [SchemaDefinition, RootQuery].concat(Author.typeDefs), + resolvers, +}); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`) +}); +``` + +

Extending Types

+ +The `extend` keyword provides the ability to add fields to existing types. Using `extend` is particularly useful in avoiding a large list of fields on root Queries and Mutations. + +```js +const barTypeDefs = ` +"Query can and must be defined once per schema to be extended" +type Query { + bars: [Bar] +} + +type Bar { + id: String +} +`; + +const fooTypeDefs = ` +type Foo { + id: String +} + +extend type Query { + foos: [Foo] +} +` + +const typeDefs = [barTypeDefs, fooTypeDefs] +``` + +

Documenting your Schema

+ +GraphQL Playground has built-in support for displaying docstrings with markdown syntax. This schema includes docstrings for types, fields and arguments. + +```graphql +""" +Description for the type +""" +type MyObjectType { + """ + Description for field + Supports multi-line description + """ + myField: String! + + otherField( + """ + Description for argument + """ + arg: Int + ) +} +``` + +

API

+ +TODO point at graphql-tools `makeExecutableSchema` api diff --git a/docs/source/schemas/types.md b/docs/source/schemas/types.md new file mode 100644 index 00000000000..0b9b220c29f --- /dev/null +++ b/docs/source/schemas/types.md @@ -0,0 +1,154 @@ +--- +title: Schema types +description: How to write your types, expose your data, and keep it all working great +--- + +GraphQL is a strongly typed language and the concept of "types" is a fundamental part of GraphQL. Types define the capabilities of a GraphQL server and allow GraphQL operations to be validated. + +While in the most basic sense, you could have a GraphQL server return a single, scalar type, combining these types provides the ability to build GraphQL servers of varying complexities. + +## Core scalar types + +Scalar types represent the leaves of an operation and alway resolve to concrete data. The default, scalar types which GraphQL offers are: + +* `Int`: Signed 32‐bit integer +* `Float`: Signed double-precision floating-point value +* `String`: UTF‐8 character sequence +* `Boolean`: true or false +* `ID` (serialized as `String`): A unique identifier, often used to refetch an object or as the key for a cache. While serialized as a String, ID signifies that it is not intended to be human‐readable + +These primitive types cover a majority of use cases. For other use cases, we can create [custom scalar types](../features/scalars-enums.html). + +## Object types + +The object type is the most common type used in a schema and represents a group of fields. Each field inside of an object type maps to another type, allowing nested types and circular references. + +```graphql +type TypeName { + fieldA: String + fieldB: Boolean + fieldC: Int + fieldD: CustomType +} + +type CustomType { + circular: TypeName +} +``` + +### `Query` type + +The `Query` type defines the entry points into Apollo server for fetching data. Since all requests must use one of the Query's fields, the Query serves as the organization point for defining how to access to other fields. It can be referred to as the root query type. This schema is an example of a server that can return a todo list for given user. + +```graphql +type Query { + todos(user: ID): [String] +} +``` + +To write your first Query go [here](). + +### `Mutation` type + +The `Mutation` type or root mutation type defines the entry points into Apollo server for modifying server data. Similar to the `Query`, the root mutation type serves as the organization point for all requests designed to modify the server's data. + +```graphql +type Response { + success: Boolean + error: Error + newTodo: String +} + +type Query { + addTodo(user: ID, todo: String): Response +} +``` + +To implement your first mutation, follow the [... guide](). + +### `Subscription` type + +The `Subscription` type defines entry points into Apollo server for the advanced use case of listening to events over a persistent connection. For more information, see the subscription section. + +## Enum type + +The `Enum` type are a special type of scalar that is restricted to a set of values. + +```graphql +enum Genre { + MYSTERY + SIFI + FANTASY +} +``` + +In Apollo server, a resolver that returns an enum can use the direct string representation. + +```js +const schema = gql` +type Query { + genre: Genre +} +`; + +const resolvers = { + Query: { + genre: () => 'MYSTERY' + } +} +``` + +## List type modifier + +Lists are defined with as type modifier that wraps object types, scalars, and enums. This signals to Apollo server that the resolver should return an array of the wrapped type. In this example `todos` is expected to return a list of strings. + +```js +const schema = gql` +type Query { + todos: [String] +} +`; + +const resolvers = { + Query: { + todos: () => ['reduce', 'reuse', 'gc'] + } +} +``` + +## Non-nullable types + +By default, each of the core scalar types can also be null. That is to say, they can either return a value of the specified type or they can have no value. This default provides the maximum flexibility for schema changes and enables the errors to be returned at the finest granularity. The only time to make a field non-null is if an object cannot exist without the field. + +To override this default and specify that a type _must_ be defined, an exclamation mark (`!`) can be appended to a type to ensure the presence of the value in return results. For example, a `String` which could not be missing a value would be identified as `String!`. If the resolver for a non-nullable field throws an error, then the error is propagated up to the parents on the resolver chain until either the root field or a nullable field is reached. + +Using the exclamation mark to declare a field as non-nullable simplifies the contract with the client, since clients will not have to check to see whether a field contains a value or not. However marking fields as non-nullable means that the field will always be a part of that type, which make it impossible to deprecate from an active schema. Removing a nullability check from a field makes a field optional. Existing client code would need to be made aware of this new requirement, and adjust their logic accordingly. + +## Union type + +The `Union` type indicates that a field can return more than one object type, but doesn't define specific fields itself. Therefore, a query being made on a field which is union-typed must specify the object types containing the fields it wants. + +```graphql +union Result = Book | Author + +type Query { + search: [Result] +} +``` + +The query for these result would appear: + +```graphql +{ + search(contains: "") { + ... on Book { + title + } + ... on Author { + name + } + } +} +``` + + diff --git a/docs/source/setup.md b/docs/source/setup.md deleted file mode 100644 index d2af04e7b3d..00000000000 --- a/docs/source/setup.md +++ /dev/null @@ -1,152 +0,0 @@ ---- -title: Adding a GraphQL endpoint -description: Detailed directions about adding a GraphQL endpoint and passing options. ---- - -Apollo Server has a slightly different API depending on which server integration you are using, but all of the packages share the same core implementation and options format. - -If you want to get started quickly, check out the [complete starter code snippet](./example.html). - -

Passing options

- -Apollo Server accepts a `GraphQLOptions` object as its single argument, like so (for Express): - -```js -app.use( - '/graphql', - bodyParser.json(), - graphqlExpress({ - schema: myGraphQLSchema, - // other options here - }), -); -``` - -

Options as a function

- -If you need to vary the options on a per-request basis, the options can also be passed as a function, in which case you get the `req` object or similar as an argument: - -```js -app.use( - '/graphql', - bodyParser.json(), - graphqlExpress(req => { - return { - schema: myGraphQLSchema, - context: { - value: req.body.something, - }, - // other options here - }; - }), -); -``` - -This is useful if you need to attach objects to your context on a per-request basis, for example to initialize user data, caching tools like `dataloader`, or set up some API keys. - -

Options API

- -The `GraphQLOptions` object has the following properties: - -

schema

- -The GraphQL.js schema object that represents your GraphQL schema. You can create this directly using [GraphQL.js](https://github.com/graphql/graphql-js), the reference GraphQL implementation, or you can use graphql-tools, which makes it simple to combine a schema and resolvers. [See an example.](./example.html) - -

context

- -The context is an object that's accessible in every single resolver as the third argument. This is a great place to pass information that depends on the current request. Read more about resolvers and their arguments in the [graphql-tools docs](https://www.apollographql.com/docs/graphql-tools/resolvers.html#Resolver-function-signature). Here's an example: - -```js -app.use( - '/graphql', - bodyParser.json(), - graphqlExpress(req => { - // Some sort of auth function - const userForThisRequest = getUserFromRequest(req); - - return { - schema: myGraphQLSchema, - context: { - user: userForThisRequest, - }, - // other options here - }; - }), -); -``` - -

Passing context as a function

- -The value passed to `context` can also be a function. [No arguments are passed the function](https://github.com/apollographql/apollo-server/blob/df51fd90dad90c9468a74e2c4ec2a6af69f4188d/packages/apollo-server-core/src/runHttpQuery.ts#L138-L139), but this can be useful for constructing a new context on each request (for example, creating a new instance of a class). - -```js -app.use( - '/graphql', - bodyParser.json(), - graphqlExpress({ - schema: myGraphQLSchema, - context: () => ({ - // Create a new instance of the user class for each request - user: new User(), - }), - // other options here - }), -); -``` - -

rootValue

- -This is the value passed as the `obj` argument into the root resolvers. Read more about resolvers and their arguments in the [graphql-tools docs](https://www.apollographql.com/docs/graphql-tools/resolvers.html#Resolver-function-signature). Note: This feature is not often used, since in most cases `context` is a better option to pass per-request data into resolvers. - -

formatError

- -A function to format errors before they are returned to the client. GraphQL does some processing on errors by default, and this is a great place to customize that. You can also access the original thrown error on the `.originalError` property: - -```js -formatError: err => { - if (err.originalError && err.originalError.error_message) { - err.message = err.originalError.error_message; - } - - return err; -}; -``` - -

Other options

- -The above are the only options you need most of the time. Here are some others that can be useful as workarounds for various situations: - -```js -// options object -const GraphQLOptions = { - // a function applied to the parameters of every invocation of runQuery - formatParams?: Function, - - // * - (optional) validationRules: extra validation rules applied to requests - validationRules?: Array, - - // a function applied to each GraphQL execution result - formatResponse?: Function - - // a custom default field resolver - fieldResolver?: Function - - // a boolean that will print additional debug logging if execution errors occur - debug?: boolean -} -``` - - - - - - -

Docs for specific servers

- -To see how to use the middleware with your particular JavaScript server, check out the docs for those: - -* [Express / Connect](./servers/express.html) -* [Hapi](./servers/hapi.html) -* [Koa](./servers/koa.html) - -And more are being added every day! diff --git a/docs/source/whats-new.md b/docs/source/whats-new.md new file mode 100644 index 00000000000..4257e15bc90 --- /dev/null +++ b/docs/source/whats-new.md @@ -0,0 +1,332 @@ +--- +title: What's new? +--- + +This section of the Apollo Server docs is an announcement page where it is easy to find and share big changes to the ApolloServer package, or the Apollo server side ecosystem. For a more detailed list of changes, check out the [Changelog](https://github.com/apollographql/apollo-server/blob/version-2/CHANGELOG.md). To upgrade from Apollo Server 1, please follow the [migration guide](./migration-two-dot.html) + +## 2.0 + +Apollo Server 2.0 makes building the most powerful and production ready GraphQL app easy. Apollo Server 1.x provided all of the tools necessary to make a great GraphQL backend, allowing the developer to pick and choose from a set of unopinionated tools. Building on 1.x and fully backwards compatible, 2.0's defaults bake in the best practices and patterns gathered from two years of community feedback and iteration. It is an opinionated, production focused, GraphQL server that works with any backend. + +The following code snippet demonstrates the creation of Apollo Server 2.0 and a few of the new features. + +```js +const { ApolloServer, gql } = require('apollo-server'); + +// The GraphQL schema +const typeDefs = gql` + type Query { + hello: String + mockedString: String + } +`; + +// A map of functions which return data for the schema. +const resolvers = { + Query: { + hello: () => fetch('https://fourtonfish.com/hellosalut/?mode=auto').then(res => res.json()).then(data => data.hello) + } +}; + +const server = new ApolloServer({ + typeDefs, + resolvers, + mocks: true, + onHealthCheck: () => fetch('https://fourtonfish.com/hellosalut/?mode=auto'), +}); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`) +}); +``` + +This is just the beginning. We have published a [roadmap](https://github.com/apollographql/apollo-server/blob/master/ROADMAP.md) for all of the features we will be bringing to Apollo Server soon and we would love your help! If you have any interest, you can get involved on [Github](https://github.com/apollographql/apollo-server) or by joining the [Apollo Slack](https://www.apollographql.com/slack) and going to the #apollo-server channel. + +## Automatic Persisted Queries ([guide](https://www.apollographql.com/docs/guides/performance.html#Automatic-Persisted-Queries)) + +A persisted query is an ID or hash that can be sent to the server in place of the GraphQL query string. This smaller signature reduces bandwidth utilization and speeds up client loading times. Apollo Server enables persisted queries without additional server configuration, using an in-memory LRU cache to store the mapping between hash and query string. The persisted query cache can be configured as shown in the following code snippet. To enable persisted queries on the client, follow the [Performance Guide](https://www.apollographql.com/docs/guides/performance.html#Automatic-Persisted-Queries). + +```js line=7-12 +const { ApolloServer } = require("apollo-server"); +const { MemcachedCache } = require('apollo-server-memcached'); + +const server = new ApolloServer({ + typeDefs, + resolvers, + persistedQueries: { + cache: new MemcachedCache( + ['memcached-server-1', 'memcached-server-2', 'memcached-server-3'], + { retries: 10, retry: 10000 }, // Options + ), + }, +}); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`) +}); +``` + +## CDN Integration ([guide](https://www.apollographql.com/docs/guides/performance.html#CDN-Integration)) + +Apollo Server works well with a Content-Distribution Network to cache full GraphQL query results. Apollo Server provides `cache-control` headers that a CDN uses to determine how long a request should be cached. For subsequent requests, the result will be served directly from the CDN's cache. A CDN paired with Apollo Server's persisted queries is especially powerful, since GraphQL operations can be shortened and sent with a HTTP GET request. To enable caching and a CDN in Apollo Server, follow the [Performance Guide](https://www.apollographql.com/docs/guides/performance.html#CDN-Integration). + +## [Apollo Errors](./features/errors.html) + +Apollo Server provides the ability to add error codes to categorize errors that occur within resolvers. In addition to an error code, Apollo Server 2 passes error stack traces in development mode to enable a smoother getting started experience. + +This code snippet shows how the new error could be used. + +```js +const { ApolloError, ForbiddenError, AuthenticationError } = require("apollo-server"); + +const resolvers = { + Query: { + allTodos: (_, _, context) => { + if (!context.scope) { + throw AuthenticationError("You must be logged in to see all todos"); + } + + if (context.scope !== "ADMIN") { + throw ForbiddenError("You must be an administrator to see all todos"); + } + + return context.Todos.getAllTodos(); + }, + } + Mutation: { + addTodo: (_, args, context) => { + if(!context.Todos.idAvailable(args.id)) { + throw new ApolloError('The id is already taken', 'DUPLICATE_KEY', {field: 'id'}); + } + + return context.Todos.addTodo(args.id, args.todo); + } + } +}; +``` + +## [Mocking](features/mocking.html) + +Apollo Server 2 allows mocking of a schema with the `mocks` parameter in the constructor. The `mocks` parameter can be a boolean to enable the default mocking functions or an object to define custom mock functions by type. + +```js +const { ApolloServer, gql } = require('apollo-server'); + +const typeDefs = gql` +type Query { + hello: String + resolved: String +} +`; + +const resolvers = { + Query: { + resolved: () => 'Resolved', + }, +}; + +const mocks = { + Int: () => 6, + Float: () => 22.1, + String: () => 'Hello', +}; + +const server = new ApolloServer({ + typeDefs, + resolvers, + mocks, +}); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`) +}); +``` + +## [Performance Monitoring](./features/metrics.html) + +Apollo Server 2.0 enables GraphQL monitoring out of the box. It reports performance and error data out-of-band to Apollo Engine. And Apollo Engine displays information about every query and schema present in your GraphQL service. + +To set up Apollo Server with Engine, [click here](https://engine.apollographql.com/) to get an Engine API key and provide it to the `ENGINE_API_KEY` environment variable. Setting an environment variable can be done in commandline as seen below or with the [dotenv npm package](https://www.npmjs.com/package/dotenv). + +```bash +#Replace YOUR_API_KEY with the api key for you service in the Engine UI +ENGINE_API_KEY=YOUR_API_KEY node start-server.js +``` + +The simplest option is to pass the Engine API Key directly to the Apollo Server constructor. + +```js line=6-8 +const { ApolloServer } = require("apollo-server"); + +const server = new ApolloSever({ + typeDefs, + resolvers, + engine: { + apiKey: "YOUR API KEY HERE" + } +}); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`); +}); +``` + +## [GraphQL Playground](./features/playground.html) + +Apollo Server 2.0 creates a single GraphQL endpoint that provides data and a gui explorer depending on how the endpoint is accessed. In browser, Apollo Server returns GraphQL playground. For other cases, Apollo server returns the data for GraphQL requests from other clients, such as Apollo Client, curl, Postman, or Insomnia. + +```js +const { ApolloServer, gql } = require('apollo-server'); + +const typeDefs = gql` + type Query { + hello: String + } +`; + +const resolvers = { + Query: { + hello: () => 'hello' + } +}; + +const server = new ApolloServer({ + typeDefs, + resolvers, +}); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`) +}); +``` + +To start production mode, set the NODE_ENV environment variables to `production`. The Apollo Server constructor accepts `introspection` as a boolean, which can overwrite the default for the environment. + +## File Uploads + +For server integrations that support file uploads(express, hapi, koa, etc), Apollo Server enables file uploads by default. To enable file uploads, reference the `Upload` type in the schema passed to the Apollo Server construction. + +```js +const { ApolloServer, gql } = require('apollo-server'); + +const typeDefs = gql` + type File { + filename: String! + mimetype: String! + encoding: String! + } + + type Query { + uploads: [File] + } + + type Mutation { + singleUpload(file: Upload!): File! + } +`; + +const resolvers = { + Query: { + uploads: (parent, args) => {}, + }, + Mutation: { + singleUpload: (parent, args) => { + + return args.file.then(file => { + //Contents of Upload scalar: https://github.com/jaydenseric/apollo-upload-server#upload-scalar + //file.stream is a node stream that contains the contents of the uploaded file + //node stream api: https://nodejs.org/api/stream.html + return file; + }) + }, + }, +}; + +const server = new ApolloServer({ + typeDefs, + resolvers, +}); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`); +}); +``` + +> Note: Apollo Server adds the Upload scalar to the schema, so any existing declaration of `scalar Upload` in the schema should be removed + +## [Subscriptions](/docs/graphql-subscriptions/) + +Subscriptions are enabled by default in integrations that support persistent connections. + +```js +const { ApolloServer, gql, PubSub } = require('apollo-server'); + +const pubsub = new PubSub(); +const SOMETHING_CHANGED_TOPIC = 'something_changed'; + +const typeDefs = gql` + type Query { + hello: String + } + type Subscription { + newMessage: String + } +`; + +const resolvers = { + Query: { + hello: () => 'hello', + }, + Subscription: { + newMessage: { + subscribe: () => pubsub.asyncIterator(SOMETHING_CHANGED_TOPIC), + }, + }, +}; + +const server = new ApolloServer({ + typeDefs, + resolvers, +}); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`); +}); + +//publish events every second +setInterval( + () => + pubsub.publish(SOMETHING_CHANGED_TOPIC, { + newMessage: new Date().toString(), + }), + 1000, +); +``` + +> Note: to disable subscriptions, set `subscriptions` to `false` in the options passed to `listen` + +## Health Checks + +The default Apollo server provides a health check endpoint at `/.well-known/apollo/server-health` that returns a 200 status code by default. If `onHealthCheck` is defined, the promise returned from the callback determines the status code. A successful resolution causes a 200 and rejection causes a 503. Health checks are often used by load balancers to determine if a server is available. + +```js +const { ApolloServer, gql } = require('apollo-server'); + +const typeDefs = gql``; +const resolvers = {}; + +const server = new ApolloServer({ + typeDefs, + resolvers, + //optional parameter + onHealthCheck: () => new Promise((resolve, reject) => { + //database check or other asynchronous action + }), +}); + + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`); + console.log(`Try your health check at: ${url}.well-known/apollo/server-health`); +}); +``` diff --git a/docs/source/why-apollo-server.md b/docs/source/why-apollo-server.md new file mode 100644 index 00000000000..91aa517dcc6 --- /dev/null +++ b/docs/source/why-apollo-server.md @@ -0,0 +1,60 @@ +--- +title: Why Apollo Server? +--- + +Building APIs shouldn't have to be so tricky. If you are concerned about performance, security, or just building a service that will make your product easier to build and maintain, you've found the right place! Through practical examples inspired by real world uses, you'll learn how Apollo Server's schema first design and declarative approach to data loading can help you ship faster while writing less code. Lets build the API of your dreams! 🚀 + +## Schema first design + +We think GraphQL's greatest asset is the schema. Think of it like the Rosetta stone of the data your app needs. Schemas represent the touch point of your frontends with the data that powers them. We recommend using the [schema definition language](./essentials/schema.html#Schema-Definition-Language-SDL), also called the SDL, to easily write out the data and relationships that your app needs to be successful. Unlike REST APIs, GraphQL schemas shouldn't be a one to one mapping of your database, but rather a representation of how your app works with the data it needs. Let's see what this looks like in practice with Apollo Server: + +```js +const { ApolloServer, gql } = require('apollo-server'); + +const typeDefs = gql` + type Author { + name: String + posts: [Post] + } + + type Post { + title: String + author: Author + } + + type Query { + posts(authorId: ID!): [Post] + } +`; + +const resolvers = { + Query: { + posts: (root, { authorId }, { Post }) => Post.findByAuthorId(authorId), + }, +}; + +const server = new ApolloServer({ typesDefs, resolvers }); + +server.listen().then(({ url }) => { + console.log(`Apollo Server is ready at ${url}`) +}); +``` + +In the example above, we are describing the shapes of our data, how they relate to each other, and how to fetch what our client needs from our data source. Apollo Server uses simple functions called [resolvers](./schemas/resolvers.html) to bring to life the schema described in SDL type definitions. When a request comes in to `/graphql`, Apollo Server will translate that request into what it takes to execute the query, will run the resolvers for you to load your data, and return the result in JSON so your app can render it out easily! + +Apollo Server takes care of every step of translating the query your client asks for into the data it needs. It is designed to give you maximum control over how you load the data while taking care of everything else for you! You don't need to worry about parsing the request, validating the query, delivering the response, or even profiling your app. Instead, all you have to do is describe the shape of your data and how to find it; Apollo Server does the rest! 💪 + +Unlike ad-hoc REST endpoints or complex middleware, Apollo Server will make it easy to delete a ton of code needed to build your app. While you may write less code with Apollo Server, you still get the most powerful GraphQL app possible. + +## Works with your data + +Learning and implementing a new way to manage your data can be scary and risky. Instead of waiting on a brand new project or rewriting your app from scratch, Apollo Server makes is simple to get started immediately. Whether you have a REST API you want to build on top of, existing database to connect to, or third party data sources to wrangle, Apollo works with your data from day one. You can easily start a new server or integrate it with your current app in a couple lines of code without sacrificing any of the amazing benefits it can provide. Apollo Server is the fastest way to bring GraphQL to your products out there. + +## Case Studies + +Companies ranging from enterprise to startups trust Apollo Server to power their most critical applications. If you'd like to learn more about how transitioning to GraphQL And Apollo improved their engineer's workflows and improved their products, check out these case studies: + +[Implementing GraphQL at Major League Soccer](https://labs.mlssoccer.com/implementing-graphql-at-major-league-soccer-ff0a002b20ca) +[The New York Times Now on Apollo](https://open.nytimes.com/the-new-york-times-now-on-apollo-b9a78a5038c) + +If your company is using Apollo Server in production, we'd love to feature a case study on the Apollo blog! Please get in touch via Slack so we can learn more about how you're using Apollo. Alternatively, if you already have a blog post or a conference talk that you'd like to feature here, please send a [Pull Request](https://github.com/apollographql/apollo-server/pulls) diff --git a/lerna.json b/lerna.json index d74aa321aff..bc5f57814e5 100644 --- a/lerna.json +++ b/lerna.json @@ -1,6 +1,6 @@ { "lerna": "2.0.0", - "version": "1.4.0", + "version": "independent", "changelog": { "repo": "apollographql/apollo-server", "labels": { @@ -12,7 +12,6 @@ "tag: internal": ":house: Internal" } }, - "packages": [ - "packages/*" - ] + "hoist": true, + "packages": ["packages/*"] } diff --git a/package.json b/package.json index 623b423f1ed..3736097ba48 100644 --- a/package.json +++ b/package.json @@ -6,15 +6,15 @@ "url": "git+https://github.com/apollographql/apollo-server.git" }, "scripts": { - "compile": "lerna exec -- npm run compile", - "lint": "prettier-check --ignore-path .gitignore \"{docs/{,source/**},.,packages/**,test}/{*.js,*.ts,*.md}\"", - "lint-fix": "prettier --write --ignore-path .gitignore \"{docs/{,source/**},.,packages/**,test}/{*.js,*.ts,*.md}\"", + "compile": "lerna run -- compile", + "lint": "prettier-check --ignore-path .gitignore \"{docs/{,source/**},.,packages/**,test}/{*.js,*.ts}\"", + "lint-fix": "prettier --write --ignore-path .gitignore \"{docs/{,source/**},.,packages/**,test}/{*.js,*.ts}\"", "prebootstrap": "npm install", "postinstall": "lerna bootstrap", "pretest": "npm run compile", - "test": "npm run testonly --", + "test": "npm run testonly", "posttest": "npm run lint", - "testonly": "mocha --reporter spec --full-trace --timeout 5000 ./test/tests.js", + "testonly": "lerna run -- test && mocha --reporter spec --full-trace --timeout 5000 ./test/tests.js", "coverage": "istanbul cover -x \"*.test.js\" _mocha -- --timeout 5000 --full-trace --reporter dot ./test/tests.js", "pretravis": "npm run compile", "travis": "istanbul cover -x \"*.test.js\" _mocha -- --timeout 5000 --full-trace ./test/tests.js", @@ -30,30 +30,29 @@ "*.js": [ "prettier --write", "git add" - ], - "*.md": [ - "prettier --write", - "git add" ] }, + "engines": { + "node": ">=6" + }, "devDependencies": { - "@types/chai": "4.1.4", - "@types/mocha": "5.2.4", - "@types/node": "9.6.23", + "@types/chai": "^4.1.4", + "@types/graphql": "^0.13.1", + "@types/mocha": "^5.2.3", + "@types/node": "^10.3.6", "@types/sinon": "5.0.1", "chai": "4.1.2", - "graphql": "0.13.2", - "husky": "0.14.3", - "istanbul": "1.1.0-alpha.1", - "lerna": "2.11.0", - "lint-staged": "6.1.1", - "mocha": "5.2.0", - "prettier": "1.12.1", - "prettier-check": "2.0.0", - "remap-istanbul": "0.11.1", - "sinon": "5.1.1", - "supertest": "3.1.0", - "supertest-as-promised": "4.0.2", - "typescript": "2.8.4" + "graphql": "^0.13.2", + "husky": "^0.14.3", + "istanbul": "^1.1.0-alpha.1", + "lerna": "^2.11.0", + "lint-staged": "^7.2.0", + "mocha": "^5.2.0", + "prettier": "^1.13.6", + "prettier-check": "^2.0.0", + "remap-istanbul": "^0.11.1", + "sinon": "^6.0.1", + "supertest": "^3.1.0", + "typescript": "^2.9.2" } } diff --git a/packages/apollo-server-adonis/.npmignore b/packages/apollo-cache-control/.npmignore similarity index 100% rename from packages/apollo-server-adonis/.npmignore rename to packages/apollo-cache-control/.npmignore diff --git a/packages/apollo-cache-control/CHANGELOG.md b/packages/apollo-cache-control/CHANGELOG.md new file mode 100644 index 00000000000..26db288ce83 --- /dev/null +++ b/packages/apollo-cache-control/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +### v0.1.1 + +* Fix `defaultMaxAge` feature (introduced in 0.1.0) so that `maxAge: 0` overrides the default, as previously documented. + +### v0.1.0 + +* **New feature**: New `defaultMaxAge` constructor option. (`apollo-server-*` will be updated to allow you to pass constructor options to the extension.) + + +### v0.0.10 + +* Update peer dependencies to support `graphql@0.13`. +* Expose `context.cacheControl.cacheHint` to resolvers. + +(Older versions exist but have no CHANGELOG entries.) diff --git a/packages/apollo-cache-control/README.md b/packages/apollo-cache-control/README.md new file mode 100644 index 00000000000..1f730933a30 --- /dev/null +++ b/packages/apollo-cache-control/README.md @@ -0,0 +1,115 @@ +# Apollo Cache Control (for Node.js) + +This package is used to collect and expose cache control data in the [Apollo Cache Control](https://github.com/apollographql/apollo-cache-control) format. + +It relies on instrumenting a GraphQL schema to collect cache control hints, and exposes cache control data for an individual request under `extensions` as part of the GraphQL response. + +This data can be consumed by [Apollo Engine](https://www.apollographql.com/engine/) or any other tool to inform caching and visualize the cache policies that are in effect for a particular request. + +## Usage + +### Apollo Server + +Apollo Server includes built-in support for Apollo Cache Control from version 1.2.0 onwards. + +The only code change required is to add `tracing: true` and `cacheControl: true` to the options passed to the Apollo Server middleware function for your framework of choice. For example, for Express: + +```javascript +app.use('/graphql', bodyParser.json(), graphqlExpress({ + schema, + context: {}, + tracing: true, + cacheControl: true +})); +``` + +> If you are using `express-graphql`, we recommend you switch to Apollo Server. Both `express-graphql` and Apollo Server are based on the [`graphql-js`](https://github.com/graphql/graphql-js) reference implementation, and switching should only require changing a few lines of code. + +### Add cache hints to your schema + +Cache hints can be added to your schema using directives on your types and fields. When executing your query, these hints will be added to the response and interpreted by Engine to compute a cache policy for the response. Hints on fields override hints specified on the target type. + +```graphql +type Post @cacheControl(maxAge: 240) { + id: Int! + title: String + author: Author + votes: Int @cacheControl(maxAge: 30) + readByCurrentUser: Boolean! @cacheControl(scope: PRIVATE) +} +``` + +If you need to add cache hints dynamically, you can use a programmatic API from within your resolvers. + +```javascript +const resolvers = { + Query: { + post: (_, { id }, _, { cacheControl }) => { + cacheControl.setCacheHint({ maxAge: 60 }); + return find(posts, { id }); + } + } +} +``` + +If you're using TypeScript, you need the following: +```javascript +import 'apollo-cache-control'; +``` + +If set up correctly, for this query: + +```graphql +query { + post(id: 1) { + title + votes + readByCurrentUser + } +} +``` + +You should receive cache control data in the `extensions` field of your response: + +```json +"cacheControl": { + "version": 1, + "hints": [ + { + "path": [ + "post" + ], + "maxAge": 240 + }, + { + "path": [ + "post", + "votes" + ], + "maxAge": 30 + }, + { + "path": [ + "post", + "readByCurrentUser" + ], + "scope": "PRIVATE" + } + ] +} +``` + +### Setting a default maxAge + +The power of cache hints comes from being able to set them precisely to different values on different types and fields based on your understanding of your implementation's semantics. But when getting started with Apollo Cache Control, you might just want to apply the same `maxAge` to most of your resolvers. You can specify a default max age when you set up `cacheControl` in your server. This max age will be applied to all resolvers which don't explicitly set `maxAge` via schema hints (including schema hints on the type that they return) or the programmatic API. You can override this for a particular resolver or type by setting `@cacheControl(maxAge: 0)`. For example, for Express: + +```javascript +app.use('/graphql', bodyParser.json(), graphqlExpress({ + schema, + context: {}, + tracing: true, + cacheControl: { + defaultMaxAge: 5, + }, +})); +``` diff --git a/packages/apollo-cache-control/package.json b/packages/apollo-cache-control/package.json new file mode 100644 index 00000000000..ea46efa83f7 --- /dev/null +++ b/packages/apollo-cache-control/package.json @@ -0,0 +1,59 @@ +{ + "name": "apollo-cache-control", + "version": "0.2.0-rc.0", + "description": "A GraphQL extension for cache control", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "clean": "rm -rf dist", + "compile": "tsc", + "prepublish": "npm run clean && npm run compile", + "test": "jest --verbose", + "watch": "tsc -w" + }, + "license": "MIT", + "repository": "apollographql/apollo-cache-control-js", + "author": "Martijn Walraven ", + "engines": { + "node": ">=6.0" + }, + "dependencies": { + "apollo-server-env": "^2.0.0-rc.7", + "graphql-extensions": "0.1.0-beta.0" + }, + "peerDependencies": { + "graphql": "0.10.x - 0.13.x" + }, + "devDependencies": { + "@types/jest": "^23.1.2", + "graphql-tools": "^3.0.4", + "jest": "^23.2.0", + "jest-matcher-utils": "^23.2.0", + "ts-jest": "^22.4.6" + }, + "jest": { + "testEnvironment": "node", + "setupFiles": [ + "/node_modules/apollo-server-env/dist/index.js" + ], + "transform": { + "^.+\\.(ts|js)$": "ts-jest" + }, + "moduleFileExtensions": [ + "ts", + "js", + "json" + ], + "testRegex": "src/__tests__/.*$", + "testPathIgnorePatterns": [ + "/node_modules/", + "/lib/", + "test-utils" + ], + "globals": { + "ts-jest": { + "skipBabel": true + } + } + } +} diff --git a/packages/apollo-cache-control/src/__tests__/cacheControlDirective.ts b/packages/apollo-cache-control/src/__tests__/cacheControlDirective.ts new file mode 100644 index 00000000000..30067c9ed70 --- /dev/null +++ b/packages/apollo-cache-control/src/__tests__/cacheControlDirective.ts @@ -0,0 +1,229 @@ +import { buildSchema } from 'graphql'; + +import { CacheScope } from '../'; +import { collectCacheControlHints } from './test-utils/helpers'; + +describe('@cacheControl directives', () => { + it('should set maxAge: 0 and no scope for a field without cache hints', async () => { + const schema = buildSchema(` + type Query { + droid(id: ID!): Droid + } + + type Droid { + id: ID! + name: String! + } + `); + + const hints = await collectCacheControlHints( + schema, + ` + query { + droid(id: 2001) { + name + } + } + `, + ); + + expect(hints).toContainEqual({ path: ['droid'], maxAge: 0 }); + }); + + it('should set maxAge to the default and no scope for a field without cache hints', async () => { + const schema = buildSchema(` + type Query { + droid(id: ID!): Droid + } + + type Droid { + id: ID! + name: String! + } + `); + + const hints = await collectCacheControlHints( + schema, + ` + query { + droid(id: 2001) { + name + } + } + `, + { defaultMaxAge: 10 }, + ); + + expect(hints).toContainEqual({ path: ['droid'], maxAge: 10 }); + }); + + it('should set the specified maxAge from a cache hint on the field', async () => { + const schema = buildSchema(` + type Query { + droid(id: ID!): Droid @cacheControl(maxAge: 60) + } + + type Droid { + id: ID! + name: String! + } + `); + + const hints = await collectCacheControlHints( + schema, + ` + query { + droid(id: 2001) { + name + } + } + `, + { defaultMaxAge: 10 }, + ); + + expect(hints).toContainEqual({ path: ['droid'], maxAge: 60 }); + }); + + it('should set the specified maxAge for a field from a cache hint on the target type', async () => { + const schema = buildSchema(` + type Query { + droid(id: ID!): Droid + } + + type Droid @cacheControl(maxAge: 60) { + id: ID! + name: String! + } + `); + + const hints = await collectCacheControlHints( + schema, + ` + query { + droid(id: 2001) { + name + } + } + `, + { defaultMaxAge: 10 }, + ); + + expect(hints).toContainEqual({ path: ['droid'], maxAge: 60 }); + }); + + it('should overwrite the default maxAge when maxAge=0 is specified on the type', async () => { + const schema = buildSchema(` + type Query { + droid(id: ID!): Droid + } + + type Droid @cacheControl(maxAge: 0) { + id: ID! + name: String! + } + `); + + const hints = await collectCacheControlHints( + schema, + ` + query { + droid(id: 2001) { + name + } + } + `, + { defaultMaxAge: 10 }, + ); + + expect(hints).toContainEqual({ path: ['droid'], maxAge: 0 }); + }); + + it('should override the maxAge from the target type with that specified on a field', async () => { + const schema = buildSchema(` + type Query { + droid(id: ID!): Droid @cacheControl(maxAge: 120) + } + + type Droid @cacheControl(maxAge: 60) { + id: ID! + name: String! + } + `); + + const hints = await collectCacheControlHints( + schema, + ` + query { + droid(id: 2001) { + name + } + } + `, + { defaultMaxAge: 10 }, + ); + + expect(hints).toContainEqual({ path: ['droid'], maxAge: 120 }); + }); + + it('should override the maxAge from the target type with that specified on a field, keeping the scope', async () => { + const schema = buildSchema(` + type Query { + droid(id: ID!): Droid @cacheControl(maxAge: 120) + } + + type Droid @cacheControl(maxAge: 60, scope: PRIVATE) { + id: ID! + name: String! + } + `); + + const hints = await collectCacheControlHints( + schema, + ` + query { + droid(id: 2001) { + name + } + } + `, + { defaultMaxAge: 10 }, + ); + + expect(hints).toContainEqual({ + path: ['droid'], + maxAge: 120, + scope: CacheScope.Private, + }); + }); + + it('should override the scope from the target type with that specified on a field', async () => { + const schema = buildSchema(` + type Query { + droid(id: ID!): Droid @cacheControl(scope: PRIVATE) + } + + type Droid @cacheControl(maxAge: 60, scope: PUBLIC) { + id: ID! + name: String! + } + `); + + const hints = await collectCacheControlHints( + schema, + ` + query { + droid(id: 2001) { + name + } + } + `, + { defaultMaxAge: 10 }, + ); + + expect(hints).toContainEqual({ + path: ['droid'], + maxAge: 60, + scope: CacheScope.Private, + }); + }); +}); diff --git a/packages/apollo-cache-control/src/__tests__/dynamicCacheControl.ts b/packages/apollo-cache-control/src/__tests__/dynamicCacheControl.ts new file mode 100644 index 00000000000..bca9d65b930 --- /dev/null +++ b/packages/apollo-cache-control/src/__tests__/dynamicCacheControl.ts @@ -0,0 +1,154 @@ +import { + GraphQLScalarType, + GraphQLFieldResolver, + GraphQLTypeResolver, + GraphQLIsTypeOfFn, +} from 'graphql'; +import { makeExecutableSchema } from 'graphql-tools'; + +import { CacheScope } from '../'; +import { collectCacheControlHints } from './test-utils/helpers'; + +export interface GraphQLResolvers { + [fieldName: string]: (() => any) | GraphQLResolverObject | GraphQLScalarType; +} + +export type GraphQLResolverObject = { + [fieldName: string]: GraphQLFieldResolver | GraphQLResolverOptions; +}; + +export interface GraphQLResolverOptions { + resolve?: GraphQLFieldResolver; + subscribe?: GraphQLFieldResolver; + __resolveType?: GraphQLTypeResolver; + __isTypeOf?: GraphQLIsTypeOfFn; +} + +describe('dynamic cache control', () => { + it('should set the maxAge for a field from a dynamic cache hint', async () => { + const typeDefs = ` + type Query { + droid(id: ID!): Droid + } + + type Droid { + id: ID! + name: String! + } + `; + + const resolvers: GraphQLResolvers = { + Query: { + droid: (_source, _args, _context, { cacheControl }) => { + cacheControl.setCacheHint({ maxAge: 60 }); + return { + id: 2001, + name: 'R2-D2', + }; + }, + }, + }; + + const schema = makeExecutableSchema({ typeDefs, resolvers }); + + const hints = await collectCacheControlHints( + schema, + ` + query { + droid(id: 2001) { + name + } + } + `, + { defaultMaxAge: 10 }, + ); + + expect(hints).toContainEqual({ path: ['droid'], maxAge: 60 }); + }); + + it('should set the scope for a field from a dynamic cache hint', async () => { + const typeDefs = ` + type Query { + droid(id: ID!): Droid @cacheControl(maxAge: 60) + } + + type Droid { + id: ID! + name: String! + } + `; + + const resolvers: GraphQLResolvers = { + Query: { + droid: (_source, _args, _context, { cacheControl }) => { + cacheControl.setCacheHint({ scope: CacheScope.Private }); + return { + id: 2001, + name: 'R2-D2', + }; + }, + }, + }; + + const schema = makeExecutableSchema({ typeDefs, resolvers }); + + const hints = await collectCacheControlHints( + schema, + ` + query { + droid(id: 2001) { + name + } + } + `, + { defaultMaxAge: 10 }, + ); + + expect(hints).toContainEqual({ + path: ['droid'], + maxAge: 60, + scope: CacheScope.Private, + }); + }); + + it('should override the maxAge set for a field from a dynamic cache hint', async () => { + const typeDefs = ` + type Query { + droid(id: ID!): Droid @cacheControl(maxAge: 60) + } + + type Droid { + id: ID! + name: String! + } + `; + + const resolvers: GraphQLResolvers = { + Query: { + droid: (_source, _args, _context, { cacheControl }) => { + cacheControl.setCacheHint({ maxAge: 120 }); + return { + id: 2001, + name: 'R2-D2', + }; + }, + }, + }; + + const schema = makeExecutableSchema({ typeDefs, resolvers }); + + const hints = await collectCacheControlHints( + schema, + ` + query { + droid(id: 2001) { + name + } + } + `, + { defaultMaxAge: 10 }, + ); + + expect(hints).toContainEqual({ path: ['droid'], maxAge: 120 }); + }); +}); diff --git a/packages/apollo-cache-control/src/__tests__/test-utils/helpers.ts b/packages/apollo-cache-control/src/__tests__/test-utils/helpers.ts new file mode 100644 index 00000000000..345660c1316 --- /dev/null +++ b/packages/apollo-cache-control/src/__tests__/test-utils/helpers.ts @@ -0,0 +1,33 @@ +import { GraphQLSchema, graphql } from 'graphql'; + +import { + enableGraphQLExtensions, + GraphQLExtensionStack, +} from 'graphql-extensions'; +import { + CacheControlExtension, + CacheHint, + CacheControlExtensionOptions, +} from '../..'; + +export async function collectCacheControlHints( + schema: GraphQLSchema, + source: string, + options?: CacheControlExtensionOptions, +): Promise { + enableGraphQLExtensions(schema); + + const cacheControlExtension = new CacheControlExtension(options); + + const response = await graphql({ + schema, + source, + contextValue: { + _extensionStack: new GraphQLExtensionStack([cacheControlExtension]), + }, + }); + + expect(response.errors).toBeUndefined(); + + return cacheControlExtension.format()[1].hints; +} diff --git a/packages/apollo-cache-control/src/index.ts b/packages/apollo-cache-control/src/index.ts new file mode 100644 index 00000000000..3d9ff794c39 --- /dev/null +++ b/packages/apollo-cache-control/src/index.ts @@ -0,0 +1,180 @@ +import { + DirectiveNode, + getNamedType, + GraphQLInterfaceType, + GraphQLObjectType, + GraphQLResolveInfo, + ResponsePath, + responsePathAsArray, +} from 'graphql'; + +import { GraphQLExtension } from 'graphql-extensions'; + +export interface CacheControlFormat { + version: 1; + hints: ({ path: (string | number)[] } & CacheHint)[]; +} + +export interface CacheHint { + maxAge?: number; + scope?: CacheScope; +} + +export enum CacheScope { + Public = 'PUBLIC', + Private = 'PRIVATE', +} + +export interface CacheControlExtensionOptions { + defaultMaxAge?: number; +} + +declare module 'graphql/type/definition' { + interface GraphQLResolveInfo { + cacheControl: { + setCacheHint: (hint: CacheHint) => void; + cacheHint: CacheHint; + }; + } +} + +export class CacheControlExtension + implements GraphQLExtension { + private defaultMaxAge: number; + + constructor(options: CacheControlExtensionOptions = {}) { + this.defaultMaxAge = options.defaultMaxAge || 0; + } + + private hints: Map = new Map(); + + willResolveField( + _source: any, + _args: { [argName: string]: any }, + _context: TContext, + info: GraphQLResolveInfo, + ) { + let hint: CacheHint = {}; + + // If this field's resolver returns an object or interface, look for hints + // on that return type. + const targetType = getNamedType(info.returnType); + if ( + targetType instanceof GraphQLObjectType || + targetType instanceof GraphQLInterfaceType + ) { + if (targetType.astNode) { + hint = mergeHints( + hint, + cacheHintFromDirectives(targetType.astNode.directives), + ); + } + } + + // If this field is a field on an object, look for hints on the field + // itself, taking precedence over previously calculated hints. + const parentType = info.parentType; + if (parentType instanceof GraphQLObjectType) { + const fieldDef = parentType.getFields()[info.fieldName]; + if (fieldDef.astNode) { + hint = mergeHints( + hint, + cacheHintFromDirectives(fieldDef.astNode.directives), + ); + } + } + + // If this resolver returns an object and we haven't seen an explicit maxAge + // hint, set the maxAge to 0 (uncached) or the default if specified in the + // constructor. (Non-object fields by default are assumed to inherit their + // cacheability from their parents.) + if ( + (targetType instanceof GraphQLObjectType || + targetType instanceof GraphQLInterfaceType) && + hint.maxAge === undefined + ) { + hint.maxAge = this.defaultMaxAge; + } + + if (hint.maxAge !== undefined || hint.scope !== undefined) { + this.addHint(info.path, hint); + } + + info.cacheControl = { + setCacheHint: (hint: CacheHint) => { + this.addHint(info.path, hint); + }, + cacheHint: hint, + }; + } + + addHint(path: ResponsePath, hint: CacheHint) { + const existingCacheHint = this.hints.get(path); + if (existingCacheHint) { + this.hints.set(path, mergeHints(existingCacheHint, hint)); + } else { + this.hints.set(path, hint); + } + } + + format(): [string, CacheControlFormat] { + return [ + 'cacheControl', + { + version: 1, + hints: Array.from(this.hints).map(([path, hint]) => ({ + path: [...responsePathAsArray(path)], + ...hint, + })), + }, + ]; + } +} + +function cacheHintFromDirectives( + directives: ReadonlyArray | undefined, +): CacheHint | undefined { + if (!directives) return undefined; + + const cacheControlDirective = directives.find( + directive => directive.name.value === 'cacheControl', + ); + if (!cacheControlDirective) return undefined; + + if (!cacheControlDirective.arguments) return undefined; + + const maxAgeArgument = cacheControlDirective.arguments.find( + argument => argument.name.value === 'maxAge', + ); + const scopeArgument = cacheControlDirective.arguments.find( + argument => argument.name.value === 'scope', + ); + + // TODO: Add proper typechecking of arguments + return { + maxAge: + maxAgeArgument && + maxAgeArgument.value && + maxAgeArgument.value.kind === 'IntValue' + ? parseInt(maxAgeArgument.value.value) + : undefined, + scope: + scopeArgument && + scopeArgument.value && + scopeArgument.value.kind === 'EnumValue' + ? (scopeArgument.value.value as CacheScope) + : undefined, + }; +} + +function mergeHints( + hint: CacheHint, + otherHint: CacheHint | undefined, +): CacheHint { + if (!otherHint) return hint; + + return { + maxAge: otherHint.maxAge !== undefined ? otherHint.maxAge : hint.maxAge, + scope: otherHint.scope || hint.scope, + }; +} diff --git a/packages/apollo-cache-control/tsconfig.json b/packages/apollo-cache-control/tsconfig.json new file mode 100644 index 00000000000..700fb9c53b1 --- /dev/null +++ b/packages/apollo-cache-control/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "removeComments": true, + "strict": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUnusedParameters": true, + "noUnusedLocals": true, + "types": ["node", "jest"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "**/__tests__/*", "**/__mocks__/*"] +} diff --git a/packages/apollo-server-fastify/.npmignore b/packages/apollo-datasource-rest/.npmignore similarity index 100% rename from packages/apollo-server-fastify/.npmignore rename to packages/apollo-datasource-rest/.npmignore diff --git a/packages/apollo-datasource-rest/package.json b/packages/apollo-datasource-rest/package.json new file mode 100644 index 00000000000..e4429c4514f --- /dev/null +++ b/packages/apollo-datasource-rest/package.json @@ -0,0 +1,60 @@ +{ + "name": "apollo-datasource-rest", + "version": "2.0.0-rc.7", + "author": "opensource@apollographql.com", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/apollographql/apollo-server/tree/master/packages/apollo-datasource-rest" + }, + "homepage": "https://github.com/apollographql/apollo-server#readme", + "bugs": { + "url": "https://github.com/apollographql/apollo-server/issues" + }, + "scripts": { + "clean": "rm -rf dist", + "compile": "tsc", + "prepublish": "npm run clean && npm run compile", + "test": "jest --verbose" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "engines": { + "node": ">=6" + }, + "dependencies": { + "apollo-datasource": "^2.0.0-rc.7", + "apollo-server-caching": "^2.0.0-rc.7", + "apollo-server-env": "^2.0.0-rc.7", + "apollo-server-errors": "^2.0.0-rc.7", + "http-cache-semantics": "^4.0.0" + }, + "devDependencies": { + "@types/jest": "^23.1.2", + "jest": "^23.2.0", + "ts-jest": "^22.4.6" + }, + "jest": { + "testEnvironment": "node", + "setupFiles": [ + "/node_modules/apollo-server-env/dist/index.js" + ], + "transform": { + "^.+\\.(ts|js)$": "ts-jest" + }, + "moduleFileExtensions": [ + "ts", + "js", + "json" + ], + "testRegex": "apollo-datasource-rest/src/__tests__/.*$", + "roots": [ + "../../" + ], + "globals": { + "ts-jest": { + "skipBabel": true + } + } + } +} diff --git a/packages/apollo-datasource-rest/src/HTTPCache.ts b/packages/apollo-datasource-rest/src/HTTPCache.ts new file mode 100644 index 00000000000..bccd341f40e --- /dev/null +++ b/packages/apollo-datasource-rest/src/HTTPCache.ts @@ -0,0 +1,134 @@ +import { + fetch, + Request, + RequestInfo, + RequestInit, + Response, + Headers, +} from 'apollo-server-env'; + +import CachePolicy = require('http-cache-semantics'); + +import { KeyValueCache, InMemoryLRUCache } from 'apollo-server-caching'; + +export class HTTPCache { + constructor(private keyValueCache: KeyValueCache = new InMemoryLRUCache()) {} + + async fetch(input: RequestInfo, init?: RequestInit): Promise { + const request = new Request(input, init); + + const cacheKey = cacheKeyFor(request); + + const entry = await this.keyValueCache.get(cacheKey); + if (!entry) { + const response = await fetch(request); + + const policy = new CachePolicy( + policyRequestFrom(request), + policyResponseFrom(response), + ); + + return this.storeResponseAndReturnClone(request, response, policy); + } + + const { policy: policyRaw, body } = JSON.parse(entry); + + const policy = CachePolicy.fromObject(policyRaw); + + if (policy.satisfiesWithoutRevalidation(policyRequestFrom(request))) { + const headers = policy.responseHeaders(); + return new Response(body, { status: policy._status, headers }); + } else { + const revalidationHeaders = policy.revalidationHeaders( + policyRequestFrom(request), + ); + const revalidationRequest = new Request(request, { + headers: revalidationHeaders, + }); + const revalidationResponse = await fetch(revalidationRequest); + + const { policy: revalidatedPolicy, modified } = policy.revalidatedPolicy( + policyRequestFrom(revalidationRequest), + policyResponseFrom(revalidationResponse), + ); + + return this.storeResponseAndReturnClone( + revalidationRequest, + modified + ? revalidationResponse + : new Response(body, { + status: revalidatedPolicy._status, + headers: revalidatedPolicy.responseHeaders(), + }), + revalidatedPolicy, + ); + } + } + + private async storeResponseAndReturnClone( + request: Request, + response: Response, + policy: CachePolicy, + ): Promise { + if (!response.headers.has('Cache-Control') || !policy.storable()) + return response; + + const cacheKey = cacheKeyFor(request); + + const body = await response.text(); + const entry = JSON.stringify({ policy: policy.toObject(), body }); + + let ttl = Math.round(policy.timeToLive() / 1000); + // If a response can be revalidated, we don't want to remove it from the cache right after it expires. + // We may be able to use better heuristics here, but for now we'll take the max-age times 2. + if (canBeRevalidated(response)) { + ttl *= 2; + } + await this.keyValueCache.set(cacheKey, entry, { ttl }); + + // We have to clone the response before returning it because the + // body can only be used once. + // To avoid https://github.com/bitinn/node-fetch/issues/151, we don't use + // response.clone() but create a new response from the consumed body + return new Response(body, { + status: response.status, + statusText: response.statusText, + headers: policy.responseHeaders(), + }); + } +} + +function canBeRevalidated(response: Response): boolean { + return response.headers.has('ETag'); +} + +function cacheKeyFor(request: Request): string { + // FIXME: Find a way to take Vary header fields into account when computing a cache key + // Although we do validate header fields and don't serve responses from cache when they don't match, + // new reponses overwrite old ones with different vary header fields. + // (I think we have similar heuristics in the Engine proxy) + return `httpcache:${request.url}`; +} + +function policyRequestFrom(request: Request) { + return { + url: request.url, + method: request.method, + headers: headersToObject(request.headers), + }; +} + +function policyResponseFrom(response: Response) { + return { + status: response.status, + headers: headersToObject(response.headers), + }; +} + +function headersToObject(headers: Headers) { + const object = Object.create(null); + for (const [name, value] of headers as Headers) { + object[name] = value; + } + return object; +} diff --git a/packages/apollo-datasource-rest/src/RESTDataSource.ts b/packages/apollo-datasource-rest/src/RESTDataSource.ts new file mode 100644 index 00000000000..d6d1be6d735 --- /dev/null +++ b/packages/apollo-datasource-rest/src/RESTDataSource.ts @@ -0,0 +1,228 @@ +import { + Request, + RequestInit, + Response, + BodyInit, + Headers, + URL, + URLSearchParams, + URLSearchParamsInit, +} from 'apollo-server-env'; + +import { DataSource } from 'apollo-datasource'; + +import { KeyValueCache } from 'apollo-server-caching'; +import { HTTPCache } from './HTTPCache'; + +import { + ApolloError, + AuthenticationError, + ForbiddenError, +} from 'apollo-server-errors'; + +export type RequestOptions = RequestInit & { + path: string; + params: URLSearchParams; + headers: Headers; + body?: Body; +}; + +export type Body = BodyInit | object; +export { Request }; + +type ValueOrPromise = T | Promise; + +export abstract class RESTDataSource extends DataSource { + httpCache!: HTTPCache; + context!: TContext; + + initialize(context: TContext, cache: KeyValueCache): void { + this.context = context; + this.httpCache = new HTTPCache(cache); + } + + baseURL?: string; + + protected willSendRequest?(request: RequestOptions): ValueOrPromise; + + protected resolveURL(request: RequestOptions): ValueOrPromise { + const baseURL = this.baseURL; + if (baseURL) { + const normalizedBaseURL = baseURL.endsWith('/') + ? baseURL + : baseURL.concat('/'); + return new URL(request.path, normalizedBaseURL); + } else { + return new URL(request.path); + } + } + + protected async didReceiveResponse( + response: Response, + _request: Request, + ): Promise { + if (response.ok) { + return (this.parseBody(response) as any) as Promise; + } else { + throw await this.errorFromResponse(response); + } + } + + protected didEncounterError(error: Error, _request: Request) { + throw error; + } + + protected parseBody(response: Response): Promise { + const contentType = response.headers.get('Content-Type'); + if (contentType && contentType.startsWith('application/json')) { + return response.json(); + } else { + return response.text(); + } + } + + protected async errorFromResponse(response: Response) { + const message = `${response.status}: ${response.statusText}`; + + let error: ApolloError; + if (response.status === 401) { + error = new AuthenticationError(message); + } else if (response.status === 403) { + error = new ForbiddenError(message); + } else { + error = new ApolloError(message); + } + + const body = await this.parseBody(response); + + Object.assign(error.extensions, { + response: { + url: response.url, + status: response.status, + statusText: response.statusText, + body, + }, + }); + + return error; + } + + protected async get( + path: string, + params?: URLSearchParamsInit, + init?: RequestInit, + ): Promise { + return this.fetch( + Object.assign({ method: 'GET', path, params }, init), + ); + } + + protected async post( + path: string, + body?: Body, + init?: RequestInit, + ): Promise { + return this.fetch( + Object.assign({ method: 'POST', path, body }, init), + ); + } + + protected async patch( + path: string, + body?: Body, + init?: RequestInit, + ): Promise { + return this.fetch( + Object.assign({ method: 'PATCH', path, body }, init), + ); + } + + protected async put( + path: string, + body?: Body, + init?: RequestInit, + ): Promise { + return this.fetch( + Object.assign({ method: 'PUT', path, body }, init), + ); + } + + protected async delete( + path: string, + params?: URLSearchParamsInit, + init?: RequestInit, + ): Promise { + return this.fetch( + Object.assign({ method: 'DELETE', path, params }, init), + ); + } + + private async fetch( + init: RequestInit & { + path: string; + params?: URLSearchParamsInit; + }, + ): Promise { + if (!(init.params instanceof URLSearchParams)) { + init.params = new URLSearchParams(init.params); + } + + if (!(init.headers && init.headers instanceof Headers)) { + init.headers = new Headers(init.headers); + } + + const options = init as RequestOptions; + + if (this.willSendRequest) { + await this.willSendRequest(options); + } + + const url = await this.resolveURL(options); + + // Append params to existing params in the path + for (const [name, value] of options.params) { + url.searchParams.append(name, value); + } + + // We accept arbitrary objects as body and serialize them as JSON + if ( + options.body !== undefined && + options.body !== null && + (options.body.constructor === Object || + ((options.body as any).toJSON && + typeof (options.body as any).toJSON === 'function')) + ) { + options.body = JSON.stringify(options.body); + options.headers.set('Content-Type', 'application/json'); + } + + const request = new Request(String(url), options); + + return this.trace(`${options.method || 'GET'} ${url}`, async () => { + try { + const response = await this.httpCache.fetch(request); + return this.didReceiveResponse(response, request); + } catch (error) { + this.didEncounterError(error, request); + } + }); + } + + private async trace( + label: string, + fn: () => Promise, + ): Promise { + if (process && process.env && process.env.NODE_ENV === 'development') { + // We're not using console.time because that isn't supported on Cloudflare + const startTime = Date.now(); + try { + return await fn(); + } finally { + const duration = Date.now() - startTime; + console.log(`${label} (${duration}ms)`); + } + } else { + return fn(); + } + } +} diff --git a/packages/apollo-datasource-rest/src/__tests__/HTTPCache.test.ts b/packages/apollo-datasource-rest/src/__tests__/HTTPCache.test.ts new file mode 100644 index 00000000000..3ced0e91461 --- /dev/null +++ b/packages/apollo-datasource-rest/src/__tests__/HTTPCache.test.ts @@ -0,0 +1,262 @@ +import { fetch } from '../../../../__mocks__/apollo-server-env'; + +import { + mockDate, + unmockDate, + advanceTimeBy, +} from '../../../../__mocks__/date'; + +import { HTTPCache } from '../HTTPCache'; + +describe('HTTPCache', () => { + let store: Map; + let httpCache: HTTPCache; + + beforeAll(() => { + mockDate(); + }); + + beforeEach(() => { + fetch.mockReset(); + + store = new Map(); + httpCache = new HTTPCache(store as any); + }); + + afterAll(() => { + unmockDate(); + }); + + it('fetches a response from the origin when not cached', async () => { + fetch.mockJSONResponseOnce({ name: 'Ada Lovelace' }); + + const response = await httpCache.fetch('https://api.example.com/people/1'); + + expect(fetch.mock.calls.length).toEqual(1); + expect(await response.json()).toEqual({ name: 'Ada Lovelace' }); + }); + + it('returns a cached response when not expired', async () => { + fetch.mockJSONResponseOnce( + { name: 'Ada Lovelace' }, + { 'Cache-Control': 'max-age=30' }, + ); + + await httpCache.fetch('https://api.example.com/people/1'); + + advanceTimeBy(10000); + + const response = await httpCache.fetch('https://api.example.com/people/1'); + + expect(fetch.mock.calls.length).toEqual(1); + expect(await response.json()).toEqual({ name: 'Ada Lovelace' }); + expect(response.headers.get('Age')).toEqual('10'); + }); + + it('fetches a fresh response from the origin when expired', async () => { + fetch.mockJSONResponseOnce( + { name: 'Ada Lovelace' }, + { 'Cache-Control': 'max-age=30' }, + ); + + await httpCache.fetch('https://api.example.com/people/1'); + + advanceTimeBy(30000); + + fetch.mockJSONResponseOnce( + { name: 'Alan Turing' }, + { 'Cache-Control': 'max-age=30' }, + ); + + const response = await httpCache.fetch('https://api.example.com/people/1'); + + expect(fetch.mock.calls.length).toEqual(2); + + expect(await response.json()).toEqual({ name: 'Alan Turing' }); + expect(response.headers.get('Age')).toEqual('0'); + }); + + it('does not store a response with a non-success status code', async () => { + fetch.mockResponseOnce( + 'Internal server error', + { 'Cache-Control': 'max-age=30' }, + 500, + ); + + await httpCache.fetch('https://api.example.com/people/1'); + + expect(store.size).toEqual(0); + }); + + it('does not store a response without Cache-Control header', async () => { + fetch.mockJSONResponseOnce({ name: 'Ada Lovelace' }); + + await httpCache.fetch('https://api.example.com/people/1'); + + expect(store.size).toEqual(0); + }); + + it('does not store a private response', async () => { + fetch.mockJSONResponseOnce( + { name: 'Ada Lovelace' }, + { 'Cache-Control': 'private, max-age: 60' }, + ); + + await httpCache.fetch('https://api.example.com/me'); + + expect(store.size).toEqual(0); + }); + + it('returns a cached response when Vary header fields match', async () => { + fetch.mockJSONResponseOnce( + { name: 'Ada Lovelace' }, + { 'Cache-Control': 'max-age=30', Vary: 'Accept-Language' }, + ); + + await httpCache.fetch('https://api.example.com/people/1', { + headers: { 'Accept-Language': 'en' }, + }); + + const response = await httpCache.fetch('https://api.example.com/people/1', { + headers: { 'Accept-Language': 'en' }, + }); + + expect(fetch.mock.calls.length).toEqual(1); + expect(await response.json()).toEqual({ name: 'Ada Lovelace' }); + }); + + it(`does not return a cached response when Vary header fields don't match`, async () => { + fetch.mockJSONResponseOnce( + { name: 'Ada Lovelace' }, + { 'Cache-Control': 'max-age=30', Vary: 'Accept-Language' }, + ); + + await httpCache.fetch('https://api.example.com/people/1', { + headers: { 'Accept-Language': 'en' }, + }); + + fetch.mockJSONResponseOnce( + { name: 'Alan Turing' }, + { 'Cache-Control': 'max-age=30' }, + ); + + const response = await httpCache.fetch('https://api.example.com/people/1', { + headers: { 'Accept-Language': 'fr' }, + }); + + expect(fetch.mock.calls.length).toEqual(2); + expect(await response.json()).toEqual({ name: 'Alan Turing' }); + }); + + it('sets the TTL as max-age when the response does not contain revalidation headers', async () => { + fetch.mockJSONResponseOnce( + { name: 'Ada Lovelace' }, + { 'Cache-Control': 'max-age=30' }, + ); + + const storeSet = jest.spyOn(store, 'set'); + + await httpCache.fetch('https://api.example.com/people/1'); + + expect(storeSet.mock.calls[0][2]).toEqual({ ttl: 30 }); + + storeSet.mockRestore(); + }); + + it('sets the TTL as 2 * max-age when the response contains an ETag header', async () => { + fetch.mockJSONResponseOnce( + { name: 'Ada Lovelace' }, + { 'Cache-Control': 'max-age=30', ETag: 'foo' }, + ); + + const storeSet = jest.spyOn(store, 'set'); + + await httpCache.fetch('https://api.example.com/people/1'); + + expect(storeSet.mock.calls[0][2]).toEqual({ ttl: 60 }); + + storeSet.mockRestore(); + }); + + it('revalidates a cached response when expired and returns the cached response when not modified', async () => { + fetch.mockJSONResponseOnce( + { name: 'Ada Lovelace' }, + { + 'Cache-Control': 'public, max-age=30', + ETag: 'foo', + }, + ); + + await httpCache.fetch('https://api.example.com/people/1'); + + advanceTimeBy(30000); + + fetch.mockResponseOnce( + null, + { + 'Cache-Control': 'public, max-age=30', + ETag: 'foo', + }, + 304, + ); + + const response = await httpCache.fetch('https://api.example.com/people/1'); + + expect(fetch.mock.calls.length).toEqual(2); + expect(fetch.mock.calls[1][0].headers.get('If-None-Match')).toEqual('foo'); + + expect(response.status).toEqual(200); + expect(await response.json()).toEqual({ name: 'Ada Lovelace' }); + expect(response.headers.get('Age')).toEqual('0'); + + advanceTimeBy(10000); + + const response2 = await httpCache.fetch('https://api.example.com/people/1'); + + expect(fetch.mock.calls.length).toEqual(2); + + expect(response2.status).toEqual(200); + expect(await response2.json()).toEqual({ name: 'Ada Lovelace' }); + expect(response2.headers.get('Age')).toEqual('10'); + }); + + it('revalidates a cached response when expired and returns and caches a fresh response when modified', async () => { + fetch.mockJSONResponseOnce( + { name: 'Ada Lovelace' }, + { + 'Cache-Control': 'public, max-age=30', + ETag: 'foo', + }, + ); + + await httpCache.fetch('https://api.example.com/people/1'); + + advanceTimeBy(30000); + + fetch.mockJSONResponseOnce( + { name: 'Alan Turing' }, + { + 'Cache-Control': 'public, max-age=30', + ETag: 'bar', + }, + ); + + const response = await httpCache.fetch('https://api.example.com/people/1'); + + expect(fetch.mock.calls.length).toEqual(2); + expect(fetch.mock.calls[1][0].headers.get('If-None-Match')).toEqual('foo'); + + expect(response.status).toEqual(200); + expect(await response.json()).toEqual({ name: 'Alan Turing' }); + + advanceTimeBy(10000); + + const response2 = await httpCache.fetch('https://api.example.com/people/1'); + + expect(fetch.mock.calls.length).toEqual(2); + + expect(response2.status).toEqual(200); + expect(await response2.json()).toEqual({ name: 'Alan Turing' }); + expect(response2.headers.get('Age')).toEqual('10'); + }); +}); diff --git a/packages/apollo-datasource-rest/src/__tests__/RESTDataSource.test.ts b/packages/apollo-datasource-rest/src/__tests__/RESTDataSource.test.ts new file mode 100644 index 00000000000..d0ef9872139 --- /dev/null +++ b/packages/apollo-datasource-rest/src/__tests__/RESTDataSource.test.ts @@ -0,0 +1,522 @@ +import { fetch } from '../../../../__mocks__/apollo-server-env'; + +import { + ApolloError, + AuthenticationError, + ForbiddenError, +} from 'apollo-server-errors'; +import { RESTDataSource, RequestOptions } from '../RESTDataSource'; + +import { HTTPCache } from '../HTTPCache'; + +describe('RESTDataSource', () => { + const store = new Map(); + let httpCache: HTTPCache; + + beforeAll(() => { + httpCache = new HTTPCache({ + async get(key: string) { + return store.get(key); + }, + async set(key: string, value: string) { + store.set(key, value); + }, + }); + }); + + beforeEach(() => { + fetch.mockReset(); + store.clear(); + }); + + it('returns data as parsed JSON when Content-Type is application/json', async () => { + const dataSource = new class extends RESTDataSource { + baseURL = 'https://api.example.com'; + + getFoo() { + return this.get('foo'); + } + }(); + + dataSource.httpCache = httpCache; + + fetch.mockJSONResponseOnce( + { foo: 'bar' }, + { 'Content-Type': 'application/json' }, + ); + + const data = await dataSource.getFoo(); + + expect(data).toEqual({ foo: 'bar' }); + }); + + it('returns data as a string when Content-Type is text/plain', async () => { + const dataSource = new class extends RESTDataSource { + baseURL = 'https://api.example.com'; + + getFoo() { + return this.get('foo'); + } + }(); + + dataSource.httpCache = httpCache; + + fetch.mockResponseOnce('bar', { 'Content-Type': 'text/plain' }); + + const data = await dataSource.getFoo(); + + expect(data).toEqual('bar'); + }); + + it('attempts to return data as a string when no Content-Type header is returned', async () => { + const dataSource = new class extends RESTDataSource { + baseURL = 'https://api.example.com'; + + getFoo() { + return this.get('foo'); + } + }(); + + dataSource.httpCache = httpCache; + + fetch.mockResponseOnce('bar'); + + const data = await dataSource.getFoo(); + + expect(data).toEqual('bar'); + }); + + it('interprets paths relative to the base URL', async () => { + const dataSource = new class extends RESTDataSource { + baseURL = 'https://api.example.com'; + + getFoo() { + return this.get('foo'); + } + }(); + + dataSource.httpCache = httpCache; + + fetch.mockJSONResponseOnce(); + + await dataSource.getFoo(); + + expect(fetch.mock.calls.length).toEqual(1); + expect(fetch.mock.calls[0][0].url).toEqual('https://api.example.com/foo'); + }); + + it('adds a trailing slash to the base URL if needed', async () => { + const dataSource = new class extends RESTDataSource { + baseURL = 'https://example.com/api'; + + getFoo() { + return this.get('foo'); + } + }(); + + dataSource.httpCache = httpCache; + + fetch.mockJSONResponseOnce(); + + await dataSource.getFoo(); + + expect(fetch.mock.calls.length).toEqual(1); + expect(fetch.mock.calls[0][0].url).toEqual('https://example.com/api/foo'); + }); + + it('allows computing a dynamic base URL', async () => { + const dataSource = new class extends RESTDataSource { + get baseURL() { + if (this.context.env === 'development') { + return 'https://api-dev.example.com'; + } else { + return 'https://api.example.com'; + } + } + + getFoo() { + return this.get('foo'); + } + }(); + + dataSource.context = { env: 'development' }; + dataSource.httpCache = httpCache; + + fetch.mockJSONResponseOnce(); + await dataSource.getFoo(); + + expect(fetch.mock.calls.length).toEqual(1); + expect(fetch.mock.calls[0][0].url).toEqual( + 'https://api-dev.example.com/foo', + ); + }); + + it('allows resolving a base URL asynchronously', async () => { + const dataSource = new class extends RESTDataSource { + async resolveURL(request: RequestOptions) { + if (!this.baseURL) { + this.baseURL = 'https://api.example.com'; + } + return super.resolveURL(request); + } + + getFoo() { + return this.get('foo'); + } + }(); + + dataSource.httpCache = httpCache; + + fetch.mockJSONResponseOnce(); + await dataSource.getFoo(); + + expect(fetch.mock.calls.length).toEqual(1); + expect(fetch.mock.calls[0][0].url).toEqual('https://api.example.com/foo'); + }); + + it('allows passing in query string parameters', async () => { + const dataSource = new class extends RESTDataSource { + baseURL = 'https://api.example.com'; + + getPostsForUser( + username: string, + params: { filter: string; limit: number; offset: number }, + ) { + return this.get('posts', Object.assign({ username }, params)); + } + }(); + + dataSource.httpCache = httpCache; + + fetch.mockJSONResponseOnce(); + + await dataSource.getPostsForUser('beyoncé', { + filter: 'jalapeño', + limit: 10, + offset: 20, + }); + + expect(fetch.mock.calls.length).toEqual(1); + expect(fetch.mock.calls[0][0].url).toEqual( + 'https://api.example.com/posts?username=beyonc%C3%A9&filter=jalape%C3%B1o&limit=10&offset=20', + ); + }); + + it('allows setting default query string parameters', async () => { + const dataSource = new class extends RESTDataSource { + baseURL = 'https://api.example.com'; + + willSendRequest(request: RequestOptions) { + request.params.set('api_key', this.context.token); + } + + getFoo() { + return this.get('foo', { a: 1 }); + } + }(); + + dataSource.context = { token: 'secret' }; + dataSource.httpCache = httpCache; + + fetch.mockJSONResponseOnce(); + + await dataSource.getFoo(); + + expect(fetch.mock.calls.length).toEqual(1); + expect(fetch.mock.calls[0][0].url).toEqual( + 'https://api.example.com/foo?a=1&api_key=secret', + ); + }); + + it('allows setting default fetch options', async () => { + const dataSource = new class extends RESTDataSource { + baseURL = 'https://api.example.com'; + + willSendRequest(request: RequestOptions) { + request.credentials = 'include'; + } + + getFoo() { + return this.get('foo'); + } + }(); + + dataSource.httpCache = httpCache; + + fetch.mockJSONResponseOnce(); + + await dataSource.getFoo(); + + expect(fetch.mock.calls.length).toEqual(1); + // FIXME: request.credentials is not supported by node-fetch + // expect(fetch.mock.calls[0][0].credentials).toEqual('include'); + }); + + it('allows setting request headers', async () => { + const dataSource = new class extends RESTDataSource { + baseURL = 'https://api.example.com'; + + willSendRequest(request: RequestOptions) { + request.headers.set('Authorization', this.context.token); + } + + getFoo() { + return this.get('foo'); + } + }(); + + dataSource.context = { token: 'secret' }; + dataSource.httpCache = httpCache; + + fetch.mockJSONResponseOnce(); + + await dataSource.getFoo(); + + expect(fetch.mock.calls.length).toEqual(1); + expect(fetch.mock.calls[0][0].headers.get('Authorization')).toEqual( + 'secret', + ); + }); + + it('serializes a request body that is an object as JSON', async () => { + const dataSource = new class extends RESTDataSource { + baseURL = 'https://api.example.com'; + + postFoo(foo) { + return this.post('foo', foo); + } + }(); + + dataSource.httpCache = httpCache; + + fetch.mockJSONResponseOnce(); + + await dataSource.postFoo({ foo: 'bar' }); + + expect(fetch.mock.calls.length).toEqual(1); + expect(fetch.mock.calls[0][0].url).toEqual('https://api.example.com/foo'); + expect(fetch.mock.calls[0][0].body).toEqual(JSON.stringify({ foo: 'bar' })); + expect(fetch.mock.calls[0][0].headers.get('Content-Type')).toEqual( + 'application/json', + ); + }); + + it('serializes a request body that has a toJSON method as JSON', async () => { + const dataSource = new class extends RESTDataSource { + baseURL = 'https://api.example.com'; + + postFoo(foo) { + return this.post('foo', foo); + } + }(); + + dataSource.httpCache = httpCache; + + fetch.mockJSONResponseOnce(); + + class Model { + constructor(public baz: any) {} + + toJSON() { + return { + foo: this.baz, + }; + } + } + const model = new Model('bar'); + + await dataSource.postFoo(model); + + expect(fetch.mock.calls.length).toEqual(1); + expect(fetch.mock.calls[0][0].url).toEqual('https://api.example.com/foo'); + expect(fetch.mock.calls[0][0].body).toEqual(JSON.stringify({ foo: 'bar' })); + expect(fetch.mock.calls[0][0].headers.get('Content-Type')).toEqual( + 'application/json', + ); + }); + + it('does not serialize a request body that is not an object', async () => { + const dataSource = new class extends RESTDataSource { + baseURL = 'https://api.example.com'; + + postFoo(foo) { + return this.post('foo', foo); + } + }(); + + dataSource.httpCache = httpCache; + + fetch.mockJSONResponseOnce(); + + class FormData {} + const form = new FormData(); + + await dataSource.postFoo(form); + + expect(fetch.mock.calls.length).toEqual(1); + expect(fetch.mock.calls[0][0].url).toEqual('https://api.example.com/foo'); + expect(fetch.mock.calls[0][0].body).not.toEqual('{}'); + expect(fetch.mock.calls[0][0].headers.get('Content-Type')).not.toEqual( + 'application/json', + ); + }); + + for (const method of ['GET', 'POST', 'PATCH', 'PUT', 'DELETE']) { + const dataSource = new class extends RESTDataSource { + baseURL = 'https://api.example.com'; + + getFoo() { + return this.get('foo'); + } + + postFoo() { + return this.post('foo'); + } + + patchFoo() { + return this.patch('foo'); + } + + putFoo() { + return this.put('foo'); + } + + deleteFoo() { + return this.delete('foo'); + } + }(); + + it(`allows performing ${method} requests`, async () => { + dataSource.httpCache = httpCache; + + fetch.mockJSONResponseOnce({ foo: 'bar' }); + + const data = await dataSource[`${method.toLocaleLowerCase()}Foo`](); + + expect(data).toEqual({ foo: 'bar' }); + + expect(fetch.mock.calls.length).toEqual(1); + expect(fetch.mock.calls[0][0].method).toEqual(method); + }); + } + + describe('error handling', () => { + it('throws an AuthenticationError when the response status is 401', async () => { + const dataSource = new class extends RESTDataSource { + baseURL = 'https://api.example.com'; + + getFoo() { + return this.get('foo'); + } + }(); + + dataSource.httpCache = httpCache; + + fetch.mockResponseOnce('Invalid token', undefined, 401); + + const result = dataSource.getFoo(); + await expect(result).rejects.toThrow(AuthenticationError); + await expect(result).rejects.toMatchObject({ + extensions: { + code: 'UNAUTHENTICATED', + response: { + status: 401, + body: 'Invalid token', + }, + }, + }); + }); + + it('throws a ForbiddenError when the response status is 403', async () => { + const dataSource = new class extends RESTDataSource { + baseURL = 'https://api.example.com'; + + getFoo() { + return this.get('foo'); + } + }(); + + dataSource.httpCache = httpCache; + + fetch.mockResponseOnce('No access', undefined, 403); + + const result = dataSource.getFoo(); + await expect(result).rejects.toThrow(ForbiddenError); + await expect(result).rejects.toMatchObject({ + extensions: { + code: 'FORBIDDEN', + response: { + status: 403, + body: 'No access', + }, + }, + }); + }); + + it('throws an ApolloError when the response status is 500', async () => { + const dataSource = new class extends RESTDataSource { + baseURL = 'https://api.example.com'; + + getFoo() { + return this.get('foo'); + } + }(); + + dataSource.httpCache = httpCache; + + fetch.mockResponseOnce('Oops', undefined, 500); + + const result = dataSource.getFoo(); + await expect(result).rejects.toThrow(ApolloError); + await expect(result).rejects.toMatchObject({ + extensions: { + response: { + status: 500, + body: 'Oops', + }, + }, + }); + }); + + it('puts JSON error responses on the error as an object', async () => { + const dataSource = new class extends RESTDataSource { + baseURL = 'https://api.example.com'; + + getFoo() { + return this.get('foo'); + } + }(); + + dataSource.httpCache = httpCache; + + fetch.mockResponseOnce( + JSON.stringify({ + errors: [ + { + message: 'Houston, we have a problem.', + }, + ], + }), + { 'Content-Type': 'application/json' }, + 500, + ); + + const result = dataSource.getFoo(); + await expect(result).rejects.toThrow(ApolloError); + await expect(result).rejects.toMatchObject({ + extensions: { + response: { + status: 500, + body: { + errors: [ + { + message: 'Houston, we have a problem.', + }, + ], + }, + }, + }, + }); + }); + }); +}); diff --git a/packages/apollo-datasource-rest/src/index.ts b/packages/apollo-datasource-rest/src/index.ts new file mode 100644 index 00000000000..3cd9dd7d5b7 --- /dev/null +++ b/packages/apollo-datasource-rest/src/index.ts @@ -0,0 +1,2 @@ +export { RESTDataSource, RequestOptions } from './RESTDataSource'; +export { HTTPCache } from './HTTPCache'; diff --git a/packages/apollo-datasource-rest/src/types/http-cache-semantics/index.d.ts b/packages/apollo-datasource-rest/src/types/http-cache-semantics/index.d.ts new file mode 100644 index 00000000000..0d5164c26db --- /dev/null +++ b/packages/apollo-datasource-rest/src/types/http-cache-semantics/index.d.ts @@ -0,0 +1,38 @@ +declare module 'http-cache-semantics' { + interface Request { + url: string; + method: string; + headers: Headers; + } + + interface Response { + status: number; + headers: Headers; + } + + type Headers = { [name: string]: string }; + + class CachePolicy { + constructor(request: Request, response: Response); + + storable(): boolean; + + satisfiesWithoutRevalidation(request: Request): boolean; + responseHeaders(): Headers; + + timeToLive(): number; + + revalidationHeaders(request: Request): Headers; + revalidatedPolicy( + request: Request, + response: Response, + ): { policy: CachePolicy; modified: boolean }; + + static fromObject(object: object): CachePolicy; + toObject(): object; + + _status: number; + } + + export = CachePolicy; +} diff --git a/packages/apollo-datasource-rest/tsconfig.json b/packages/apollo-datasource-rest/tsconfig.json new file mode 100644 index 00000000000..700fb9c53b1 --- /dev/null +++ b/packages/apollo-datasource-rest/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "removeComments": true, + "strict": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUnusedParameters": true, + "noUnusedLocals": true, + "types": ["node", "jest"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "**/__tests__/*", "**/__mocks__/*"] +} diff --git a/packages/apollo-server-module-graphiql/.npmignore b/packages/apollo-datasource/.npmignore similarity index 100% rename from packages/apollo-server-module-graphiql/.npmignore rename to packages/apollo-datasource/.npmignore diff --git a/packages/apollo-datasource/package.json b/packages/apollo-datasource/package.json new file mode 100644 index 00000000000..475d13c84c1 --- /dev/null +++ b/packages/apollo-datasource/package.json @@ -0,0 +1,57 @@ +{ + "name": "apollo-datasource", + "version": "2.0.0-rc.7", + "author": "opensource@apollographql.com", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/apollographql/apollo-server/tree/master/packages/apollo-datasource" + }, + "homepage": "https://github.com/apollographql/apollo-server#readme", + "bugs": { + "url": "https://github.com/apollographql/apollo-server/issues" + }, + "scripts": { + "clean": "rm -rf dist", + "compile": "tsc", + "prepublish": "npm run clean && npm run compile", + "test": "jest --verbose" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "engines": { + "node": ">=6" + }, + "dependencies": { + "apollo-server-caching": "^2.0.0-rc.7", + "apollo-server-env": "^2.0.0-rc.7" + }, + "devDependencies": { + "@types/jest": "^23.1.2", + "jest": "^23.2.0", + "ts-jest": "^22.4.6" + }, + "jest": { + "testEnvironment": "node", + "setupFiles": [ + "/node_modules/apollo-server-env/dist/index.js" + ], + "transform": { + "^.+\\.(ts|js)$": "ts-jest" + }, + "moduleFileExtensions": [ + "ts", + "js", + "json" + ], + "testRegex": "apollo-datasource-rest/src/__tests__/.*$", + "roots": [ + "../../" + ], + "globals": { + "ts-jest": { + "skipBabel": true + } + } + } +} diff --git a/packages/apollo-datasource/src/index.ts b/packages/apollo-datasource/src/index.ts new file mode 100644 index 00000000000..a98ddd4d41d --- /dev/null +++ b/packages/apollo-datasource/src/index.ts @@ -0,0 +1,5 @@ +import { KeyValueCache } from 'apollo-server-caching'; + +export abstract class DataSource { + abstract initialize(context: TContext, cache: KeyValueCache): void; +} diff --git a/packages/apollo-datasource/tsconfig.json b/packages/apollo-datasource/tsconfig.json new file mode 100644 index 00000000000..700fb9c53b1 --- /dev/null +++ b/packages/apollo-datasource/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "removeComments": true, + "strict": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUnusedParameters": true, + "noUnusedLocals": true, + "types": ["node", "jest"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "**/__tests__/*", "**/__mocks__/*"] +} diff --git a/packages/apollo-server-module-operation-store/.npmignore b/packages/apollo-engine-reporting/.npmignore similarity index 100% rename from packages/apollo-server-module-operation-store/.npmignore rename to packages/apollo-engine-reporting/.npmignore diff --git a/packages/apollo-engine-reporting/CHANGELOG.md b/packages/apollo-engine-reporting/CHANGELOG.md new file mode 100644 index 00000000000..70a5527e966 --- /dev/null +++ b/packages/apollo-engine-reporting/CHANGELOG.md @@ -0,0 +1,4 @@ +### vNext + +* Initial release. + diff --git a/packages/apollo-engine-reporting/README.md b/packages/apollo-engine-reporting/README.md new file mode 100644 index 00000000000..b34ba15880f --- /dev/null +++ b/packages/apollo-engine-reporting/README.md @@ -0,0 +1,6 @@ +# apollo-engine-reporting + +[![npm version](https://badge.fury.io/js/apollo-engine-reporting.svg)](https://badge.fury.io/js/apollo-engine-reporting) +[![Build Status](https://circleci.com/gh/apollographql/apollo-server.svg?style=svg)](https://circleci.com/gh/apollographql/apollo-server) + +This package is a pure JS implementation of the Apollo Engine reporting feature. It is a work in progress and is not recommended for production use. diff --git a/packages/apollo-engine-reporting/package.json b/packages/apollo-engine-reporting/package.json new file mode 100644 index 00000000000..9df8766e928 --- /dev/null +++ b/packages/apollo-engine-reporting/package.json @@ -0,0 +1,62 @@ +{ + "name": "apollo-engine-reporting", + "version": "0.0.0-rc.1", + "description": "Send reports about your GraphQL services to Apollo Engine", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "clean": "rm -rf dist", + "compile": "tsc", + "watch": "tsc -w", + "prepublish": "npm run clean && npm run compile", + "test": "jest --verbose", + "circle": "jest --verbose --coverage", + "lint": "prettier -l 'src/**/*.{ts,js}' && tslint -p tsconfig.json 'src/**/*.ts'", + "lint-fix": "prettier --write 'src/**/*.{ts,js}' && tslint --fix -p tsconfig.json 'src/**/*.ts'" + }, + "license": "MIT", + "repository": "https://github.com/apollographql/apollo-engine-reporting", + "author": "Apollo ", + "engines": { + "node": ">=6.0" + }, + "dependencies": { + "apollo-engine-reporting-protobuf": "0.0.0-beta.7", + "apollo-server-env": "^2.0.0-rc.7", + "async-retry": "^1.2.1", + "graphql-extensions": "^0.1.0-rc.1", + "lodash": "^4.17.10" + }, + "devDependencies": { + "@types/async-retry": "^1.2.1", + "@types/graphql": "^0.13.1", + "@types/jest": "^23.1.2", + "@types/lodash": "^4.14.110", + "graphql": "^0.13.2", + "graphql-tag": "^2.9.2", + "graphql-tools": "^3.0.4", + "jest": "^23.2.0", + "ts-jest": "^22.4.6", + "tslint": "^5.10.0" + }, + "jest": { + "testEnvironment": "node", + "setupFiles": [ + "/node_modules/apollo-server-env/dist/index.js" + ], + "transform": { + "^.+\\.(ts|js)$": "ts-jest" + }, + "moduleFileExtensions": [ + "ts", + "js", + "json" + ], + "testRegex": "src/__tests__/.*$", + "globals": { + "ts-jest": { + "skipBabel": true + } + } + } +} diff --git a/packages/apollo-engine-reporting/src/__tests__/extension.test.ts b/packages/apollo-engine-reporting/src/__tests__/extension.test.ts new file mode 100644 index 00000000000..a7d06f1921e --- /dev/null +++ b/packages/apollo-engine-reporting/src/__tests__/extension.test.ts @@ -0,0 +1,68 @@ +import { makeExecutableSchema, addMockFunctionsToSchema } from 'graphql-tools'; +import { + GraphQLExtensionStack, + enableGraphQLExtensions, +} from 'graphql-extensions'; +import { Trace } from 'apollo-engine-reporting-protobuf'; +import { graphql } from 'graphql'; +import { Request } from 'node-fetch'; +import { EngineReportingExtension } from '../extension'; + +test('trace construction', async () => { + const typeDefs = ` + type User { + id: Int + name: String + posts(limit: Int): [Post] + } + + type Post { + id: Int + title: String + views: Int + author: User + } + + type Query { + aString: String + aBoolean: Boolean + anInt: Int + author(id: Int): User + topPosts(limit: Int): [Post] + } +`; + + const query = ` + query q { + author(id: 5) { + name + posts(limit: 2) { + id + } + } + aBoolean + } +`; + + const schema = makeExecutableSchema({ typeDefs }); + addMockFunctionsToSchema({ schema }); + enableGraphQLExtensions(schema); + + const traces: Array = []; + function addTrace(signature: string, operationName: string, trace: Trace) { + traces.push({ signature, operationName, trace }); + } + const reportingExtension = new EngineReportingExtension({}, addTrace); + const stack = new GraphQLExtensionStack([reportingExtension]); + const requestDidEnd = stack.requestDidStart({ + request: new Request('http://localhost:123/foo') as any, + queryString: query, + }); + await graphql({ + schema, + source: query, + contextValue: { _extensionStack: stack }, + }); + requestDidEnd(); + // XXX actually write some tests +}); diff --git a/packages/apollo-engine-reporting/src/__tests__/signature.test.ts b/packages/apollo-engine-reporting/src/__tests__/signature.test.ts new file mode 100644 index 00000000000..7e3e7314480 --- /dev/null +++ b/packages/apollo-engine-reporting/src/__tests__/signature.test.ts @@ -0,0 +1,218 @@ +import { DocumentNode } from 'graphql'; +import { default as gql, disableFragmentWarnings } from 'graphql-tag'; + +import { + printWithReducedWhitespace, + hideLiterals, + dropUnusedDefinitions, + sortAST, + removeAliases, +} from '../signature'; + +// The gql duplicate fragment warning feature really is just warnings; nothing +// breaks if you turn it off in tests. +disableFragmentWarnings(); + +describe('printWithReducedWhitespace', () => { + const cases = [ + { + name: 'lots of whitespace', + // Note: there's a tab after "tab->", which prettier wants to keep as a + // literal tab rather than \t. In the output, there should be a literal + // backslash-t. + input: gql` + query Foo($a: Int) { + user( + name: " tab-> yay" + other: """ + apple + bag + cat + """ + ) { + name + } + } + `, + output: + 'query Foo($a:Int){user(name:" tab->\\tyay",other:"apple\\n bag\\ncat"){name}}', + }, + ]; + cases.forEach(({ name, input, output }) => { + test(name, () => { + expect(printWithReducedWhitespace(input)).toEqual(output); + }); + }); +}); + +describe('hideLiterals', () => { + const cases = [ + { + name: 'full test', + input: gql` + query Foo($b: Int, $a: Boolean) { + user(name: "hello", age: 5) { + ...Bar + ... on User { + hello + bee + } + tz + aliased: name + } + } + + fragment Bar on User { + age @skip(if: $a) + ...Nested + } + + fragment Nested on User { + blah + } + `, + output: + 'query Foo($b:Int,$a:Boolean){user(name:"",age:0){...Bar...on User{hello bee}tz aliased:name}}' + + 'fragment Bar on User{age@skip(if:$a)...Nested}fragment Nested on User{blah}', + }, + ]; + cases.forEach(({ name, input, output }) => { + test(name, () => { + expect(printWithReducedWhitespace(hideLiterals(input))).toEqual(output); + }); + }); +}); + +describe('aggressive signature', () => { + function aggressive(ast: DocumentNode, operationName: string): string { + return printWithReducedWhitespace( + removeAliases( + hideLiterals(sortAST(dropUnusedDefinitions(ast, operationName))), + ), + ); + } + + const cases = [ + // Test cases borrowed from optics-agent-js. + { + name: 'basic test', + operationName: '', + input: gql` + { + user { + name + } + } + `, + output: '{user{name}}', + }, + { + name: 'basic test with query', + operationName: '', + input: gql` + query { + user { + name + } + } + `, + output: '{user{name}}', + }, + { + name: 'basic with operation name', + operationName: 'OpName', + input: gql` + query OpName { + user { + name + } + } + `, + output: 'query OpName{user{name}}', + }, + { + name: 'with various inline types', + operationName: 'OpName', + input: gql` + query OpName { + user { + name(apple: [[10]], cat: ENUM_VALUE, bag: { input: "value" }) + } + } + `, + output: 'query OpName{user{name(apple:[],bag:{},cat:ENUM_VALUE)}}', + }, + { + name: 'with various argument types', + operationName: 'OpName', + input: gql` + query OpName($c: Int!, $a: [[Boolean!]!], $b: EnumType) { + user { + name(apple: $a, cat: $c, bag: $b) + } + } + `, + output: + 'query OpName($a:[[Boolean!]!],$b:EnumType,$c:Int!){user{name(apple:$a,bag:$b,cat:$c)}}', + }, + { + name: 'fragment', + operationName: '', + input: gql` + { + user { + name + ...Bar + } + } + + fragment Bar on User { + asd + } + + fragment Baz on User { + jkl + } + `, + output: '{user{name...Bar}}fragment Bar on User{asd}', + }, + { + name: 'full test', + operationName: 'Foo', + input: gql` + query Foo($b: Int, $a: Boolean) { + user(name: "hello", age: 5) { + ...Bar + ... on User { + hello + bee + } + tz + aliased: name + } + } + + fragment Baz on User { + asd + } + + fragment Bar on User { + age @skip(if: $a) + ...Nested + } + + fragment Nested on User { + blah + } + `, + output: + 'query Foo($a:Boolean,$b:Int){user(age:0,name:""){name tz...Bar...on User{bee hello}}}' + + 'fragment Bar on User{age@skip(if:$a)...Nested}fragment Nested on User{blah}', + }, + ]; + cases.forEach(({ name, operationName, input, output }) => { + test(name, () => { + expect(aggressive(input, operationName)).toEqual(output); + }); + }); +}); diff --git a/packages/apollo-engine-reporting/src/agent.ts b/packages/apollo-engine-reporting/src/agent.ts new file mode 100644 index 00000000000..1e3e6a48df1 --- /dev/null +++ b/packages/apollo-engine-reporting/src/agent.ts @@ -0,0 +1,302 @@ +import * as os from 'os'; +import { gzip } from 'zlib'; +import { DocumentNode } from 'graphql'; +import { + FullTracesReport, + ReportHeader, + Traces, + Trace, +} from 'apollo-engine-reporting-protobuf'; + +import { fetch, Response } from 'apollo-server-env'; +import * as retry from 'async-retry'; + +import { EngineReportingExtension } from './extension'; + +// Override the generated protobuf Traces.encode function so that it will look +// for Traces that are already encoded to Buffer as well as unencoded +// Traces. This amortizes the protobuf encoding time over each generated Trace +// instead of bunching it all up at once at sendReport time. In load tests, this +// change improved p99 end-to-end HTTP response times by a factor of 11 without +// a casually noticeable effect on p50 times. This also makes it easier for us +// to implement maxUncompressedReportSize as we know the encoded size of traces +// as we go. +const originalTracesEncode = Traces.encode; +Traces.encode = function(message, originalWriter) { + const writer = originalTracesEncode(message, originalWriter); + const encodedTraces = (message as any).encodedTraces; + if (encodedTraces != null && encodedTraces.length) { + for (let i = 0; i < encodedTraces.length; ++i) { + writer.uint32(/* id 1, wireType 2 =*/ 10); + writer.bytes(encodedTraces[i]); + } + } + return writer; +}; + +export interface EngineReportingOptions { + // API key for the service. Get this from + // [Engine](https://engine.apollographql.com) by logging in and creating + // a service. You may also specify this with the `ENGINE_API_KEY` + // environment variable; the option takes precedence. __Required__. + apiKey?: string; + // Specify the function for creating a signature for a query. See signature.ts + // for details. + calculateSignature?: (ast: DocumentNode, operationName: string) => string; + // How often to send reports to the Engine server. We'll also send reports + // when the report gets big; see maxUncompressedReportSize. + reportIntervalMs?: number; + // We send a report when the report size will become bigger than this size in + // bytes (default: 4MB). (This is a rough limit --- we ignore the size of the + // report header and some other top level bytes. We just add up the lengths of + // the serialized traces and signatures.) + maxUncompressedReportSize?: number; + // The URL of the Engine report ingress server. + endpointUrl?: string; + // If set, prints all reports as JSON when they are sent. + debugPrintReports?: boolean; + // Reporting is retried with exponential backoff up to this many times + // (including the original request). Defaults to 5. + maxAttempts?: number; + // Minimum backoff for retries. Defaults to 100ms. + minimumRetryDelayMs?: number; + // By default, errors sending reports to Engine servers will be logged + // to standard error. Specify this function to process errors in a different + // way. + reportErrorFunction?: (err: Error) => void; + // A case-sensitive list of names of variables whose values should not be sent + // to Apollo servers, or 'true' to leave out all variables. In the former + // case, the report will indicate that each private variable was redacted; in + // the latter case, no variables are sent at all. + privateVariables?: Array | boolean; + // A case-insensitive list of names of HTTP headers whose values should not be + // sent to Apollo servers, or 'true' to leave out all HTTP headers. Unlike + // with privateVariables, names of dropped headers are not reported. + privateHeaders?: Array | boolean; + // By default, EngineReportingAgent listens for the 'SIGINT' and 'SIGTERM' + // signals, stops, sends a final report, and re-sends the signal to + // itself. Set this to false to disable. You can manually invoke 'stop()' and + // 'sendReport()' on other signals if you'd like. Note that 'sendReport()' + // does not run synchronously so it cannot work usefully in an 'exit' handler. + handleSignals?: boolean; + // Sends the trace report immediately. This options is useful for stateless environments + sendReportsImmediately?: boolean; + + // XXX Provide a way to set client_name, client_version, client_address, + // service, and service_version fields. They are currently not revealed in the + // Engine frontend app. +} + +const REPORT_HEADER = new ReportHeader({ + hostname: os.hostname(), + // tslint:disable-next-line no-var-requires + agentVersion: `apollo-engine-reporting@${require('../package.json').version}`, + runtimeVersion: `node ${process.version}`, + // XXX not actually uname, but what node has easily. + uname: `${os.platform()}, ${os.type()}, ${os.release()}, ${os.arch()})`, +}); + +// EngineReportingAgent is a persistent object which creates +// EngineReportingExtensions for each request and sends batches of trace reports +// to the Engine server. +export class EngineReportingAgent { + private options: EngineReportingOptions; + private apiKey: string; + private report!: FullTracesReport; + private reportSize!: number; + private reportTimer: any; // timer typing is weird and node-specific + private sendReportsImmediately?: boolean; + private stopped: boolean = false; + + public constructor(options: EngineReportingOptions = {}) { + this.options = options; + this.apiKey = options.apiKey || process.env.ENGINE_API_KEY || ''; + if (!this.apiKey) { + throw new Error( + 'To use EngineReportingAgent, you must specify an API key via the apiKey option or the ENGINE_API_KEY environment variable.', + ); + } + + this.resetReport(); + + this.sendReportsImmediately = options.sendReportsImmediately; + if (!this.sendReportsImmediately) { + this.reportTimer = setInterval( + () => this.sendReportAndReportErrors(), + this.options.reportIntervalMs || 10 * 1000, + ); + } + + if (this.options.handleSignals !== false) { + const signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM']; + signals.forEach(signal => { + process.once(signal, async () => { + this.stop(); + await this.sendReportAndReportErrors(); + process.kill(process.pid, signal); + }); + }); + } + } + + public newExtension(): EngineReportingExtension { + return new EngineReportingExtension( + this.options, + this.addTrace.bind(this), + ); + } + + public addTrace(signature: string, operationName: string, trace: Trace) { + // Ignore traces that come in after stop(). + if (this.stopped) { + return; + } + + const protobufError = Trace.verify(trace); + if (protobufError) { + throw new Error(`Error encoding trace: ${protobufError}`); + } + const encodedTrace = Trace.encode(trace).finish(); + + const statsReportKey = `# ${operationName || '-'}\n${signature}`; + if (!this.report.tracesPerQuery.hasOwnProperty(statsReportKey)) { + this.report.tracesPerQuery[statsReportKey] = new Traces(); + (this.report.tracesPerQuery[statsReportKey] as any).encodedTraces = []; + } + // See comment on our override of Traces.encode to learn more about this + // strategy. + (this.report.tracesPerQuery[statsReportKey] as any).encodedTraces.push( + encodedTrace, + ); + this.reportSize += encodedTrace.length + Buffer.byteLength(statsReportKey); + + // If the buffer gets big (according to our estimate), send. + if ( + this.sendReportsImmediately || + this.reportSize >= + (this.options.maxUncompressedReportSize || 4 * 1024 * 1024) + ) { + this.sendReportAndReportErrors(); + } + } + + public async sendReport(): Promise { + const report = this.report; + this.resetReport(); + + if (Object.keys(report.tracesPerQuery).length === 0) { + return; + } + + // Send traces asynchronously, so that (eg) addTrace inside a resolver + // doesn't block on it. + await Promise.resolve(); + + if (this.options.debugPrintReports) { + // tslint:disable-next-line no-console + console.log(`Engine sending report: ${JSON.stringify(report.toJSON())}`); + } + + const protobufError = FullTracesReport.verify(report); + if (protobufError) { + throw new Error(`Error encoding report: ${protobufError}`); + } + const message = FullTracesReport.encode(report).finish(); + + const compressed = await new Promise((resolve, reject) => { + // The protobuf library gives us a Uint8Array. Node 8's zlib lets us + // pass it directly; convert for the sake of Node 6. (No support right + // now for Node 4, which lacks Buffer.from.) + const messageBuffer = Buffer.from( + message.buffer as ArrayBuffer, + message.byteOffset, + message.byteLength, + ); + gzip(messageBuffer, (err, compressed) => { + if (err) { + reject(err); + } else { + resolve(compressed); + } + }); + }); + + const endpointUrl = + (this.options.endpointUrl || 'https://engine-report.apollodata.com') + + '/api/ingress/traces'; + + // Wrap fetch with async-retry for automatic retrying + const response: Response = await retry( + // Retry on network errors and 5xx HTTP + // responses. + async () => { + const response = await fetch(endpointUrl, { + method: 'POST', + headers: { + 'user-agent': 'apollo-engine-reporting', + 'x-api-key': this.apiKey, + 'content-encoding': 'gzip', + }, + body: compressed, + }); + + if (response.status >= 500 && response.status < 600) { + throw new Error(`${response.status}: ${response.statusText}`); + } else { + return response; + } + }, + { + retries: this.options.maxAttempts || 5, + minTimeout: this.options.minimumRetryDelayMs || 100, + factor: 2, + }, + ).catch((err: Error) => { + throw new Error(`Error sending report to Engine servers: ${err}`); + }); + + if (response.status < 200 || response.status >= 300) { + // Note that we don't expect to see a 3xx here because request follows + // redirects. + throw new Error( + `Error sending report to Engine servers (HTTP status ${ + response.status + }): ${await response.text()}`, + ); + } + if (this.options.debugPrintReports) { + // tslint:disable-next-line no-console + console.log(`Engine report: status ${response.status}`); + } + } + + // Stop prevents reports from being sent automatically due to time or buffer + // size, and stop buffering new traces. You may still manually send a last + // report by calling sendReport(). + public stop() { + if (this.reportTimer) { + clearInterval(this.reportTimer); + this.reportTimer = undefined; + } + + this.stopped = true; + } + + private sendReportAndReportErrors(): Promise { + return this.sendReport().catch(err => { + // This catch block is primarily intended to catch network errors from + // the retried request itself, which include network errors and non-2xx + // HTTP errors. + if (this.options.reportErrorFunction) { + this.options.reportErrorFunction(err); + } else { + console.error(err.message); + } + }); + } + + private resetReport() { + this.report = new FullTracesReport({ header: REPORT_HEADER }); + this.reportSize = 0; + } +} diff --git a/packages/apollo-engine-reporting/src/extension.ts b/packages/apollo-engine-reporting/src/extension.ts new file mode 100644 index 00000000000..6cb73681a25 --- /dev/null +++ b/packages/apollo-engine-reporting/src/extension.ts @@ -0,0 +1,304 @@ +import { Request } from 'apollo-server-env'; + +import { + GraphQLResolveInfo, + responsePathAsArray, + ResponsePath, + DocumentNode, + ExecutionArgs, + GraphQLError, +} from 'graphql'; +import { + GraphQLExtension, + GraphQLResponse, + EndHandler, +} from 'graphql-extensions'; +import { Trace, google } from 'apollo-engine-reporting-protobuf'; + +import { EngineReportingOptions } from './agent'; +import { defaultSignature } from './signature'; + +// EngineReportingExtension is the per-request GraphQLExtension which creates a +// trace (in protobuf Trace format) for a single request. When the request is +// done, it passes the Trace back to its associated EngineReportingAgent via the +// addTrace callback in its constructor. This class isn't for direct use; its +// constructor is a private API for communicating with EngineReportingAgent. +// Its public methods all implement the GraphQLExtension interface. +export class EngineReportingExtension + implements GraphQLExtension { + public trace = new Trace(); + private nodes = new Map(); + private startHrTime!: [number, number]; + private operationName?: string; + private queryString?: string; + private documentAST?: DocumentNode; + private options: EngineReportingOptions; + private addTrace: ( + signature: string, + operationName: string, + trace: Trace, + ) => void; + + public constructor( + options: EngineReportingOptions, + addTrace: (signature: string, operationName: string, trace: Trace) => void, + ) { + this.options = options; + this.addTrace = addTrace; + const root = new Trace.Node(); + this.trace.root = root; + this.nodes.set(responsePathAsString(undefined), root); + } + + public requestDidStart(o: { + request: Request; + queryString?: string; + parsedQuery?: DocumentNode; + variables: Record; + persistedQueryHit?: boolean; + persistedQueryRegister?: boolean; + }): EndHandler { + this.trace.startTime = dateToTimestamp(new Date()); + this.startHrTime = process.hrtime(); + + // Generally, we'll get queryString here and not parsedQuery; we only get + // parsedQuery if you're using an OperationStore. In normal cases we'll get + // our documentAST in the execution callback after it is parsed. + this.queryString = o.queryString; + this.documentAST = o.parsedQuery; + + this.trace.http = new Trace.HTTP({ + method: + Trace.HTTP.Method[o.request.method as keyof typeof Trace.HTTP.Method] || + Trace.HTTP.Method.UNKNOWN, + // Host and path are not used anywhere on the backend, so let's not bother + // trying to parse request.url to get them, which is a potential + // source of bugs because integrations have different behavior here. + // On Node's HTTP module, request.url only includes the path + // (see https://nodejs.org/api/http.html#http_message_url) + // The same is true on Lambda (where we pass event.path) + // But on environments like Cloudflare we do get a complete URL. + host: null, + path: null, + }); + if (this.options.privateHeaders !== true) { + for (const [key, value] of o.request.headers) { + if ( + this.options.privateHeaders && + typeof this.options.privateHeaders === 'object' && + // We assume that most users only have a few private headers, or will + // just set privateHeaders to true; we can change this linear-time + // operation if it causes real performance issues. + this.options.privateHeaders.includes(key.toLowerCase()) + ) { + break; + } + + switch (key) { + case 'authorization': + case 'cookie': + case 'set-cookie': + break; + default: + this.trace.http!.requestHeaders![key] = new Trace.HTTP.Values({ + value: [value], + }); + } + } + + if (o.persistedQueryHit) { + this.trace.persistedQueryHit = true; + } + if (o.persistedQueryRegister) { + this.trace.persistedQueryRegister = true; + } + } + + if (this.options.privateVariables !== true && o.variables) { + // Note: we explicitly do *not* include the details.rawQuery field. The + // Engine web app currently does nothing with this other than store it in + // the database and offer it up via its GraphQL API, and sending it means + // that using calculateSignature to hide sensitive data in the query + // string is ineffective. + this.trace.details = new Trace.Details(); + Object.keys(o.variables).forEach(name => { + if ( + this.options.privateVariables && + typeof this.options.privateVariables === 'object' && + // We assume that most users will have only a few private variables, + // or will just set privateVariables to true; we can change this + // linear-time operation if it causes real performance issues. + this.options.privateVariables.includes(name) + ) { + // Special case for private variables. Note that this is a different + // representation from a variable containing the empty string, as that + // will be sent as '""'. + this.trace.details!.variablesJson![name] = ''; + } else { + this.trace.details!.variablesJson![name] = JSON.stringify( + o.variables[name], + ); + } + }); + } + + return () => { + this.trace.durationNs = durationHrTimeToNanos( + process.hrtime(this.startHrTime), + ); + this.trace.endTime = dateToTimestamp(new Date()); + + const operationName = this.operationName || ''; + let signature; + if (this.documentAST) { + const calculateSignature = + this.options.calculateSignature || defaultSignature; + signature = calculateSignature(this.documentAST, operationName); + } else if (this.queryString) { + // We didn't get an AST, possibly because of a parse failure. Let's just + // use the full query string. + // + // XXX This does mean that even if you use a calculateSignature which + // hides literals, you might end up sending literals for queries + // that fail parsing or validation. Provide some way to mask them + // anyway? + signature = this.queryString; + } else { + // This shouldn't happen: one of those options must be passed to runQuery. + throw new Error('No queryString or parsedQuery?'); + } + + this.addTrace(signature, operationName, this.trace); + }; + } + + public executionDidStart(o: { executionArgs: ExecutionArgs }) { + // If the operationName is explicitly provided, save it. If there's just one + // named operation, the client doesn't have to provide it, but we still want + // to know the operation name so that the server can identify the query by + // it without having to parse a signature. + // + // Fortunately, in the non-error case, we can just pull this out of + // the first call to willResolveField's `info` argument. In an + // error case (eg, the operationName isn't found, or there are more + // than one operation and no specified operationName) it's OK to continue + // to file this trace under the empty operationName. + if (o.executionArgs.operationName) { + this.operationName = o.executionArgs.operationName; + } + this.documentAST = o.executionArgs.document; + } + + public willResolveField( + _source: any, + _args: { [argName: string]: any }, + _context: TContext, + info: GraphQLResolveInfo, + ): ((error: Error | null, result: any) => void) | void { + if (this.operationName === undefined) { + this.operationName = + (info.operation.name && info.operation.name.value) || ''; + } + + const path = info.path; + const node = this.newNode(path); + node.type = info.returnType.toString(); + node.parentType = info.parentType.toString(); + node.startTime = durationHrTimeToNanos(process.hrtime(this.startHrTime)); + + return () => { + node.endTime = durationHrTimeToNanos(process.hrtime(this.startHrTime)); + // We could save the error into the trace here, but it won't have all + // the information that graphql-js adds to it later, like 'locations'. + }; + } + + public willSendResponse(o: { graphqlResponse: GraphQLResponse }) { + const { errors } = o.graphqlResponse; + if (errors) { + errors.forEach((error: GraphQLError) => { + // By default, put errors on the root node. + let node = this.nodes.get(''); + if (error.path) { + const specificNode = this.nodes.get(error.path.join('.')); + if (specificNode) { + node = specificNode; + } + } + node!.error!.push( + new Trace.Error({ + message: error.message, + location: (error.locations || []).map( + ({ line, column }) => new Trace.Location({ line, column }), + ), + json: JSON.stringify(error), + }), + ); + }); + } + } + + private newNode(path: ResponsePath): Trace.Node { + const node = new Trace.Node(); + const id = path.key; + if (typeof id === 'number') { + node.index = id; + } else { + node.fieldName = id; + } + this.nodes.set(responsePathAsString(path), node); + const parentNode = this.ensureParentNode(path); + parentNode.child.push(node); + return node; + } + + private ensureParentNode(path: ResponsePath): Trace.Node { + const parentPath = responsePathAsString(path.prev); + const parentNode = this.nodes.get(parentPath); + if (parentNode) { + return parentNode; + } + // Because we set up the root path in the constructor, we now know that + // path.prev isn't undefined. + return this.newNode(path.prev!); + } +} + +// Helpers for producing traces. + +// Convert from the linked-list ResponsePath format to a dot-joined +// string. Includes the full path (field names and array indices). +function responsePathAsString(p: ResponsePath | undefined) { + if (p === undefined) { + return ''; + } + return responsePathAsArray(p).join('.'); +} + +// Converts a JS Date into a Timestamp. +function dateToTimestamp(date: Date): google.protobuf.Timestamp { + const totalMillis = +date; + const millis = totalMillis % 1000; + return new google.protobuf.Timestamp({ + seconds: (totalMillis - millis) / 1000, + nanos: millis * 1e6, + }); +} + +// Converts an hrtime array (as returned from process.hrtime) to nanoseconds. +// +// ONLY CALL THIS ON VALUES REPRESENTING DELTAS, NOT ON THE RAW RETURN VALUE +// FROM process.hrtime() WITH NO ARGUMENTS. +// +// The entire point of the hrtime data structure is that the JavaScript Number +// type can't represent all int64 values without loss of precision: +// Number.MAX_SAFE_INTEGER nanoseconds is about 104 days. Calling this function +// on a duration that represents a value less than 104 days is fine. Calling +// this function on an absolute time (which is generally roughly time since +// system boot) is not a good idea. +// +// XXX We should probably use google.protobuf.Duration on the wire instead of +// ever trying to store durations in a single number. +function durationHrTimeToNanos(hrtime: [number, number]) { + return hrtime[0] * 1e9 + hrtime[1]; +} diff --git a/packages/apollo-engine-reporting/src/index.ts b/packages/apollo-engine-reporting/src/index.ts new file mode 100644 index 00000000000..6fc476833b0 --- /dev/null +++ b/packages/apollo-engine-reporting/src/index.ts @@ -0,0 +1,10 @@ +export { + hideLiterals, + dropUnusedDefinitions, + sortAST, + removeAliases, + printWithReducedWhitespace, + defaultSignature, +} from './signature'; + +export { EngineReportingOptions, EngineReportingAgent } from './agent'; diff --git a/packages/apollo-engine-reporting/src/signature.ts b/packages/apollo-engine-reporting/src/signature.ts new file mode 100644 index 00000000000..64ca5389d17 --- /dev/null +++ b/packages/apollo-engine-reporting/src/signature.ts @@ -0,0 +1,242 @@ +// XXX maybe this should just be its own graphql-signature package + +// In Engine, we want to group requests making the same query together, and +// treat different queries distinctly. But what does it mean for two queries to +// be "the same"? And what if you don't want to send the full text of the query +// to Apollo Engine's servers, either because it contains sensitive data or +// because it contains extraneous operations or fragments? +// +// To solve these problems, EngineReportingAgent has the concept of +// "signatures". We don't (by default) send the full query string of queries to +// the Engine servers. Instead, each trace has its query string's "signature". +// +// You can specify any function mapping a GraphQL query AST (DocumentNode) to +// string as your signature algorithm by providing it as the 'signature' option +// to the EngineReportingAgent constructor. Ideally, your signature should be a +// valid GraphQL query, though as of now the Engine servers do not re-parse your +// signature and do not expect it to match the execution tree in the trace. +// +// This file provides several useful building blocks for writing your own +// signature function. These are: +// +// - dropUnusedDefinitions, which removes operations and fragments that +// aren't going to be used in execution +// - hideLiterals, which replaces all numeric and string literals as well +// as list and object input values with "empty" values +// - removeAliases, which removes field aliasing from the query +// - sortAST, which sorts the children of most multi-child nodes +// consistently +// - printWithReducedWhitespace, a variant on graphql-js's 'print' +// which gets rid of unneeded whitespace +// +// defaultSignature consists of applying all of these building blocks. +// +// Historical note: the default signature algorithm of the Go engineproxy +// performed all of the above operations, and the Engine servers then re-ran a +// mostly identical signature implementation on received traces. This was +// primarily to deal with edge cases where some users used literal interpolation +// instead of GraphQL variables, included randomized alias names, etc. In +// addition, the servers relied on the fact that dropUnusedDefinitions had been +// called in order (and that the signature could be parsed as GraphQL) to +// extract the name of the operation for display. This caused confusion, as the +// query document shown in the Engine UI wasn't the same as the one actually +// sent. apollo-engine-reporting uses a new reporting API which requires it to +// explicitly include the operation name with each signature; this means that +// the server no longer needs to parse the signature or run its own signature +// algorithm on it, and the details of the signature algorithm are now up to the +// reporting agent. + +import { sortBy, ListIteratee } from 'lodash'; + +import { + print, + visit, + DocumentNode, + OperationDefinitionNode, + SelectionSetNode, + FieldNode, + FragmentSpreadNode, + InlineFragmentNode, + FragmentDefinitionNode, + DirectiveNode, + IntValueNode, + FloatValueNode, + StringValueNode, + ListValueNode, + ObjectValueNode, + separateOperations, +} from 'graphql'; + +// Replace numeric, string, list, and object literals with "empty" +// values. Leaves enums alone (since there's no consistent "zero" enum). This +// can help combine similar queries if you substitute values directly into +// queries rather than use GraphQL variables, and can hide sensitive data in +// your query (say, a hardcoded API key) from Engine servers, but in general +// avoiding those situations is better than working around them. +export function hideLiterals(ast: DocumentNode): DocumentNode { + return visit(ast, { + IntValue(node: IntValueNode): IntValueNode { + return { ...node, value: '0' }; + }, + FloatValue(node: FloatValueNode): FloatValueNode { + return { ...node, value: '0' }; + }, + StringValue(node: StringValueNode): StringValueNode { + return { ...node, value: '', block: false }; + }, + ListValue(node: ListValueNode): ListValueNode { + return { ...node, values: [] }; + }, + ObjectValue(node: ObjectValueNode): ObjectValueNode { + return { ...node, fields: [] }; + }, + }); +} + +// A GraphQL query may contain multiple named operations, with the operation to +// use specified separately by the client. This transformation drops unused +// operations from the query, as well as any fragment definitions that are not +// referenced. (In general we recommend that unused definitions are dropped on +// the client before sending to the server to save bandwidth and parsing time.) +export function dropUnusedDefinitions( + ast: DocumentNode, + operationName: string, +): DocumentNode { + const separated = separateOperations(ast)[operationName]; + if (!separated) { + // If the given operationName isn't found, just make this whole transform a + // no-op instead of crashing. + return ast; + } + return separated; +} + +// Like lodash's sortBy, but sorted(undefined) === undefined rather than []. It +// is a stable non-in-place sort. +function sorted( + items: ReadonlyArray | undefined, + ...iteratees: Array> +): Array | undefined { + if (items) { + return sortBy(items, ...iteratees); + } + return undefined; +} + +// sortAST sorts most multi-child nodes alphabetically. Using this as part of +// your signature calculation function may make it easier to tell the difference +// between queries that are similar to each other, and if for some reason your +// GraphQL client generates query strings with elements in nondeterministic +// order, it can make sure the queries are treated as identical. +export function sortAST(ast: DocumentNode): DocumentNode { + return visit(ast, { + OperationDefinition( + node: OperationDefinitionNode, + ): OperationDefinitionNode { + return { + ...node, + variableDefinitions: sorted( + node.variableDefinitions, + 'variable.name.value', + ), + }; + }, + SelectionSet(node: SelectionSetNode): SelectionSetNode { + return { + ...node, + // Define an ordering for field names in a SelectionSet. Field first, + // then FragmentSpread, then InlineFragment. By a lovely coincidence, + // the order we want them to appear in is alphabetical by node.kind. + // Use sortBy instead of sorted because 'selections' is not optional. + selections: sortBy(node.selections, 'kind', 'name.value'), + }; + }, + Field(node: FieldNode): FieldNode { + return { + ...node, + arguments: sorted(node.arguments, 'name.value'), + }; + }, + FragmentSpread(node: FragmentSpreadNode): FragmentSpreadNode { + return { ...node, directives: sorted(node.directives, 'name.value') }; + }, + InlineFragment(node: InlineFragmentNode): InlineFragmentNode { + return { ...node, directives: sorted(node.directives, 'name.value') }; + }, + FragmentDefinition(node: FragmentDefinitionNode): FragmentDefinitionNode { + return { + ...node, + directives: sorted(node.directives, 'name.value'), + variableDefinitions: sorted( + node.variableDefinitions, + 'variable.name.value', + ), + }; + }, + Directive(node: DirectiveNode): DirectiveNode { + return { ...node, arguments: sorted(node.arguments, 'name.value') }; + }, + }); +} + +// removeAliases gets rid of GraphQL aliases, a feature by which you can tell a +// server to return a field's data under a different name from the field +// name. Maybe this is useful if somebody somewhere inserts random aliases into +// their queries. +export function removeAliases(ast: DocumentNode): DocumentNode { + return visit(ast, { + Field(node: FieldNode): FieldNode { + return { + ...node, + alias: undefined, + }; + }, + }); +} + +// Like the graphql-js print function, but deleting whitespace wherever +// feasible. Specifically, all whitespace (outside of string literals) is +// reduced to at most one space, and even that space is removed anywhere except +// for between two alphanumerics. +export function printWithReducedWhitespace(ast: DocumentNode): string { + // In a GraphQL AST (which notably does not contain comments), the only place + // where meaningful whitespace (or double quotes) can exist is in + // StringNodes. So to print with reduced whitespace, we: + // - temporarily sanitize strings by replacing their contents with hex + // - use the default GraphQL printer + // - minimize the whitespace with a simple regexp replacement + // - convert strings back to their actual value + // We normalize all strings to non-block strings for simplicity. + + const sanitizedAST = visit(ast, { + StringValue(node: StringValueNode): StringValueNode { + return { + ...node, + value: Buffer.from(node.value, 'utf8').toString('hex'), + block: false, + }; + }, + }); + const withWhitespace = print(sanitizedAST); + const minimizedButStillHex = withWhitespace + .replace(/\s+/g, ' ') + .replace(/([^_a-zA-Z0-9]) /g, (_, c) => c) + .replace(/ ([^_a-zA-Z0-9])/g, (_, c) => c); + return minimizedButStillHex.replace(/"([a-f0-9]+)"/g, (_, hex) => + JSON.stringify(Buffer.from(hex, 'hex').toString('utf8')), + ); +} + +// The default signature function consists of removing unused definitions +// and whitespace. +// XXX consider caching somehow +export function defaultSignature( + ast: DocumentNode, + operationName: string, +): string { + return printWithReducedWhitespace( + sortAST( + removeAliases(hideLiterals(dropUnusedDefinitions(ast, operationName))), + ), + ); +} diff --git a/packages/apollo-engine-reporting/tsconfig.json b/packages/apollo-engine-reporting/tsconfig.json new file mode 100644 index 00000000000..700fb9c53b1 --- /dev/null +++ b/packages/apollo-engine-reporting/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "removeComments": true, + "strict": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUnusedParameters": true, + "noUnusedLocals": true, + "types": ["node", "jest"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "**/__tests__/*", "**/__mocks__/*"] +} diff --git a/packages/apollo-engine-reporting/tslint.json b/packages/apollo-engine-reporting/tslint.json new file mode 100644 index 00000000000..fff5bbb3192 --- /dev/null +++ b/packages/apollo-engine-reporting/tslint.json @@ -0,0 +1,79 @@ +{ + "rules": { + "ban": false, + "class-name": true, + "eofline": true, + "forin": true, + "interface-name": [ + true, + "never-prefix" + ], + "jsdoc-format": true, + "label-position": true, + "member-access": true, + "member-ordering": [ + true, + { + "order": [ + "static-field", + "instance-field", + "constructor", + "public-instance-method", + "protected-instance-method", + "private-instance-method" + ] + } + ], + "no-any": false, + "no-arg": true, + "no-bitwise": true, + "no-conditional-assignment": true, + "no-consecutive-blank-lines": false, + "no-console": [ + true, + "log", + "debug", + "info", + "time", + "timeEnd", + "trace" + ], + "no-construct": true, + "no-debugger": true, + "no-duplicate-variable": true, + "no-empty": true, + "no-eval": true, + "no-inferrable-types": false, + "no-internal-module": true, + "no-null-keyword": false, + "no-require-imports": false, + "no-shadowed-variable": true, + "no-switch-case-fall-through": true, + "no-trailing-whitespace": true, + "no-unused-expression": true, + "no-var-keyword": true, + "no-var-requires": true, + "object-literal-sort-keys": false, + "radix": true, + "switch-default": true, + "triple-equals": [ + true, + "allow-null-check" + ], + "typedef": [ + false, + "call-signature", + "parameter", + "arrow-parameter", + "property-declaration", + "variable-declaration", + "member-variable-declaration" + ], + "variable-name": [ + true, + "check-format", + "allow-leading-underscore", + "ban-keywords" + ] + } +} diff --git a/packages/apollo-server-adonis/README.md b/packages/apollo-server-adonis/README.md deleted file mode 100644 index dfedba1b7f3..00000000000 --- a/packages/apollo-server-adonis/README.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -title: Adonis -description: Setting up Apollo Server with Adonis ---- - -[![npm version](https://badge.fury.io/js/apollo-server-core.svg)](https://badge.fury.io/js/apollo-server-core) [![Build Status](https://circleci.com/gh/apollographql/apollo-cache-control-js.svg?style=svg)](https://circleci.com/gh/apollographql/apollo-cache-control-js) [![Coverage Status](https://coveralls.io/repos/github/apollographql/apollo-server/badge.svg?branch=master)](https://coveralls.io/github/apollographql/apollo-server?branch=master) [![Get on Slack](https://img.shields.io/badge/slack-join-orange.svg)](https://www.apollographql.com/#slack) - -This is the Adonis Framework integration of Apollo Server. Apollo Server is a community-maintained open-source Apollo Server that works with all Node.js HTTP server frameworks: Express, Connect, Hapi, Koa, Adonis Framework, and Restify. [Read the docs](https://www.apollographql.com/docs/apollo-server/). [Read the CHANGELOG.](https://github.com/apollographql/apollo-server/blob/master/CHANGELOG.md) - -```sh -npm install apollo-server-adonis -``` - -## Usage - -```js -// start/routes.js -const { graphqlAdonis } = require('apollo-server-adonis'); -// or using es6 modules -import { graphqlAdonis } from 'apollo-server-adonis'; - -const Route = use('Route'); - -Route.post('/graphql', graphqlAdonis({ schema: myGraphQLSchema })); -Route.get('/graphql', graphqlAdonis({ schema: myGraphQLSchema })); -``` - -### GraphiQL - -You can also use `apollo-server-adonis` for hosting the [GraphiQL](https://github.com/graphql/graphiql) in-browser IDE. Note the difference between `graphqlAdonis` and `graphiqlAdonis`. - -```js -// start/routes.js -const { graphiqlAdonis } = require('apollo-server-adonis'); -// or using es6 modules -import { graphiqlAdonis } from 'apollo-server-adonis'; - -const Route = use('Route'); - -// Setup the /graphiql route to show the GraphiQL UI -Route.get( - '/graphiql', - graphiqlAdonis({ - endpointURL: '/graphql', // a POST endpoint that GraphiQL will make the actual requests to - }), -); -``` - -In case your GraphQL endpoint is protected via authentication, or if you need to pass other custom headers in the request that GraphiQL makes, you can use the [`passHeader`](https://github.com/apollographql/apollo-server/blob/v1.0.2/packages/apollo-server-module-graphiql/src/renderGraphiQL.ts#L17) option – a string that will be added to the request header object. - -For example: - -```js -// start/routes.js -const { graphiqlAdonis } = require('apollo-server-adonis'); -// or using es6 modules -import { graphiqlAdonis } from 'apollo-server-adonis'; - -const Route = use('Route'); - -Route.get( - '/graphiql', - graphiqlAdonis({ - endpointURL: '/graphql', - passHeader: `'Authorization': 'Bearer lorem ipsum'`, - }), -); -``` - -## Principles - -Apollo Server is built with the following principles in mind: - -* **By the community, for the community**: Apollo Server's development is driven by the needs of developers -* **Simplicity**: by keeping things simple, Apollo Server is easier to use, easier to contribute to, and more secure -* **Performance**: Apollo Server is well-tested and production-ready - no modifications needed - -Anyone is welcome to contribute to Apollo Server, just read [CONTRIBUTING.md](https://github.com/apollographql/apollo-server/blob/master/CONTRIBUTING.md), take a look at the [roadmap](https://github.com/apollographql/apollo-server/blob/master/ROADMAP.md) and make your first PR! diff --git a/packages/apollo-server-adonis/package.json b/packages/apollo-server-adonis/package.json deleted file mode 100644 index 86506600f42..00000000000 --- a/packages/apollo-server-adonis/package.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "name": "apollo-server-adonis", - "version": "1.4.0", - "description": "Production-ready Node.js GraphQL server for Adonis Framework", - "main": "dist/index.js", - "scripts": { - "compile": "tsc", - "prepublish": "npm run compile" - }, - "repository": { - "type": "git", - "url": "https://github.com/apollographql/apollo-server/tree/master/packages/apollo-server-adonis" - }, - "keywords": [ - "GraphQL", - "Apollo", - "Adonis", - "Server", - "Javascript" - ], - "author": "Jonas Helfer ", - "license": "MIT", - "bugs": { - "url": "https://github.com/apollographql/apollo-server/issues" - }, - "homepage": "https://github.com/apollographql/apollo-server#readme", - "dependencies": { - "apollo-server-core": "^1.4.0", - "apollo-server-module-graphiql": "^1.4.0" - }, - "devDependencies": { - "@adonisjs/bodyparser": "2.0.3", - "@adonisjs/fold": "4.0.8", - "@adonisjs/framework": "4.0.31", - "@adonisjs/sink": "1.0.16", - "@types/graphql": "0.12.7", - "apollo-server-integration-testsuite": "^1.4.0" - }, - "typings": "dist/index.d.ts", - "typescript": { - "definition": "dist/index.d.ts" - } -} diff --git a/packages/apollo-server-adonis/src/adonisApollo.test.ts b/packages/apollo-server-adonis/src/adonisApollo.test.ts deleted file mode 100644 index 8531ad9af04..00000000000 --- a/packages/apollo-server-adonis/src/adonisApollo.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -// tslint:disable: variable-name no-var-requires -import { ioc, registrar, resolver } from '@adonisjs/fold'; -import { setupResolver, Config } from '@adonisjs/sink'; -import { graphqlAdonis, graphiqlAdonis } from './adonisApollo'; -import { GraphQLOptions } from 'apollo-server-core'; -import { expect } from 'chai'; -import testSuite, { - schema, - CreateAppOptions, -} from 'apollo-server-integration-testsuite'; - -const RouteStore = require('@adonisjs/framework/src/Route/Store'); - -async function createApp(options: CreateAppOptions = {}) { - ioc.restore(); - RouteStore.clear(); - options.graphqlOptions = options.graphqlOptions || { schema }; - const providers = ['@adonisjs/framework/providers/AppProvider']; - if (!options.excludeParser) { - providers.push('@adonisjs/bodyparser/providers/BodyParserProvider'); - } - setupResolver(); - registrar.providers(providers).register(); - ioc.bind('Adonis/Src/Config', () => { - const config = new Config(); - config.set('app', { - logger: { - transport: 'console', - console: { - driver: 'console', - }, - }, - }); - return config; - }); - - const Context = ioc.use('Adonis/Src/HttpContext'); - const Request = ioc.use('Adonis/Src/Request'); - const Response = ioc.use('Adonis/Src/Response'); - const Route = ioc.use('Adonis/Src/Route'); - const Server = ioc.use('Adonis/Src/Server'); - - Context.getter( - 'request', - function() { - return new Request(this.req, this.res, ioc.use('Adonis/Src/Config')); - }, - true, - ); - - Context.getter( - 'response', - function() { - return new Response(this.req, this.res, ioc.use('Adonis/Src/Config')); - }, - true, - ); - - Route.post('/graphql', graphqlAdonis(options.graphqlOptions)); - Route.get('/graphql', graphqlAdonis(options.graphqlOptions)); - if (options.graphiqlOptions) { - Route.get('/graphiql', graphiqlAdonis(options.graphiqlOptions)); - } - if (!options.excludeParser) { - Server.registerGlobal(['Adonis/Middleware/BodyParser']); - } - await new Promise(resolve => Server.listen('localhost', 3333, resolve)); - return Server.getInstance(); -} - -async function destroyApp(app) { - if (!app || !app.close) { - return; - } - await new Promise(resolve => app.close(resolve)); -} - -describe('adonisApollo', () => { - it('throws error if called without schema', function() { - expect(() => graphqlAdonis(undefined as GraphQLOptions)).to.throw( - 'Apollo Server requires options.', - ); - }); - - it('throws an error if called with more than one argument', function() { - expect(() => (graphqlAdonis)({}, 'x')).to.throw( - 'Apollo Server expects exactly one argument, got 2', - ); - }); -}); - -describe('integration:Adonis', () => { - testSuite(createApp, destroyApp); -}); diff --git a/packages/apollo-server-adonis/src/adonisApollo.ts b/packages/apollo-server-adonis/src/adonisApollo.ts deleted file mode 100644 index 00f2b88c893..00000000000 --- a/packages/apollo-server-adonis/src/adonisApollo.ts +++ /dev/null @@ -1,80 +0,0 @@ -import AdonisContext from '@adonisjs/framework/src/Context'; -import { - GraphQLOptions, - HttpQueryError, - runHttpQuery, -} from 'apollo-server-core'; -import * as GraphiQL from 'apollo-server-module-graphiql'; - -export interface AdonisGraphQLOptionsFunction { - (ctx: AdonisContext): GraphQLOptions | Promise; -} - -export interface AdonisHandler { - (req: any, next): void; -} - -export function graphqlAdonis( - options: GraphQLOptions | AdonisGraphQLOptionsFunction, -): AdonisHandler { - if (!options) { - throw new Error('Apollo Server requires options.'); - } - if (arguments.length > 1) { - throw new Error( - `Apollo Server expects exactly one argument, got ${arguments.length}`, - ); - } - - const graphqlHandler = (ctx: AdonisContext): Promise => { - const { request, response } = ctx; - const method = request.method(); - const query = method === 'POST' ? request.post() : request.get(); - return runHttpQuery([ctx], { - method, - options, - query, - }).then( - gqlResponse => { - response.type('application/json'); - response.json(gqlResponse); - }, - (error: HttpQueryError) => { - if ('HttpQueryError' !== error.name) { - throw error; - } - if (error.headers) { - Object.keys(error.headers).forEach(header => { - response.header(header, error.headers[header]); - }); - } - response.status(error.statusCode).send(error.message); - }, - ); - }; - - return graphqlHandler; -} - -export interface AdonisGraphiQLOptionsFunction { - (ctx: AdonisContext): GraphiQL.GraphiQLData | Promise; -} - -export function graphiqlAdonis( - options: GraphiQL.GraphiQLData | AdonisGraphiQLOptionsFunction, -) { - const graphiqlHandler = (ctx: AdonisContext): Promise => { - const { request, response } = ctx; - const query = request.get(); - return GraphiQL.resolveGraphiQLString(query, options, ctx).then( - graphiqlString => { - response.type('text/html').send(graphiqlString); - }, - (error: HttpQueryError) => { - response.status(500).send(error.message); - }, - ); - }; - - return graphiqlHandler; -} diff --git a/packages/apollo-server-adonis/src/index.ts b/packages/apollo-server-adonis/src/index.ts deleted file mode 100644 index a06182d5b5f..00000000000 --- a/packages/apollo-server-adonis/src/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { - AdonisGraphQLOptionsFunction, - AdonisHandler, - AdonisGraphiQLOptionsFunction, - graphqlAdonis, - graphiqlAdonis, -} from './adonisApollo'; diff --git a/packages/apollo-server-adonis/tsconfig.json b/packages/apollo-server-adonis/tsconfig.json deleted file mode 100644 index 8e99768afe9..00000000000 --- a/packages/apollo-server-adonis/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig", - "compilerOptions": { - "rootDir": "./src", - "outDir": "./dist", - "typeRoots": ["node_modules/@types"] - }, - "exclude": ["node_modules", "dist"] -} diff --git a/packages/apollo-server-azure-functions/README.md b/packages/apollo-server-azure-functions/README.md deleted file mode 100755 index e880c91b00a..00000000000 --- a/packages/apollo-server-azure-functions/README.md +++ /dev/null @@ -1,70 +0,0 @@ ---- -title: Azure Functions -description: Setting up Apollo Server with Azure Functions ---- - -[![npm version](https://badge.fury.io/js/apollo-server-core.svg)](https://badge.fury.io/js/apollo-server-core) [![Build Status](https://travis-ci.org/apollographql/apollo-server.svg?branch=master)](https://travis-ci.org/apollographql/apollo-server) [![Coverage Status](https://coveralls.io/repos/github/apollographql/apollo-server/badge.svg?branch=master)](https://coveralls.io/github/apollographql/apollo-server?branch=master) [![Get on Slack](https://img.shields.io/badge/slack-join-orange.svg)](https://www.apollographql.com/#slack) - -This is the Azure Functions integration for the Apollo community GraphQL Server. [Read the docs.](https://www.apollographql.com/docs/apollo-server/) - -## Sample Code - -### GraphQL: - -```javascript -const { graphqlAzureFunctions } = require('apollo-server-azure-functions'); -const { makeExecutableSchema } = require('graphql-tools'); - -const typeDefs = ` - type Random { - id: Int! - rand: String - } - - type Query { - rands: [Random] - rand(id: Int!): Random - } -`; - -const rands = [{ id: 1, rand: 'random' }, { id: 2, rand: 'modnar' }]; - -const resolvers = { - Query: { - rands: () => rands, - rand: (_, { id }) => rands.find(rand => rand.id === id), - }, -}; - -const schema = makeExecutableSchema({ - typeDefs, - resolvers, -}); - -module.exports = function run(context, request) { - graphqlAzureFunctions({ schema })(context, request); -}; -``` - -### GraphiQL - -```javascript -const { graphiqlAzureFunctions } = require('apollo-server-azure-functions'); - -export function run(context, request) { - let query = ` - { - rands { - id - rand - } - } - `; - - // End point points to the path to the GraphQL API function - graphiqlAzureFunctions({ endpointURL: '/api/graphql', query })( - context, - request, - ); -} -``` diff --git a/packages/apollo-server-azure-functions/package.json b/packages/apollo-server-azure-functions/package.json deleted file mode 100644 index 44da6253551..00000000000 --- a/packages/apollo-server-azure-functions/package.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "name": "apollo-server-azure-functions", - "version": "1.4.0", - "description": "Node.js GraphQl server for Azure Functions", - "main": "dist/index.js", - "scripts": { - "compile": "tsc", - "prepublish": "npm run compile" - }, - "repository": { - "type": "git", - "url": "https://github.com/apollographql/apollo-server/tree/master/packages/apollo-server-azure-functions" - }, - "keywords": [ - "GraphQL", - "Apollo", - "Server", - "Azure", - "Functions" - ], - "author": "Ulrik Strid ", - "license": "MIT", - "bugs": { - "url": "https://github.com/apollographql/apollo-server/issues" - }, - "homepage": "https://github.com/apollographql/apollo-server#readme", - "dependencies": { - "apollo-server-core": "^1.4.0", - "apollo-server-module-graphiql": "^1.4.0" - }, - "devDependencies": { - "@types/graphql": "0.12.7", - "apollo-server-integration-testsuite": "^1.4.0", - "azure-functions-typescript": "0.0.1" - }, - "typings": "dist/index.d.ts", - "typescript": { - "definition": "dist/index.d.ts" - } -} diff --git a/packages/apollo-server-azure-functions/src/azureFunctionsApollo.test.ts b/packages/apollo-server-azure-functions/src/azureFunctionsApollo.test.ts deleted file mode 100755 index 89d40660e4c..00000000000 --- a/packages/apollo-server-azure-functions/src/azureFunctionsApollo.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { - AzureFunctionsHandler, - graphqlAzureFunctions, - graphiqlAzureFunctions, -} from './azureFunctionsApollo'; -import testSuite, { - schema as Schema, - CreateAppOptions, -} from 'apollo-server-integration-testsuite'; -import { expect } from 'chai'; -import { GraphQLOptions } from 'apollo-server-core'; -import 'mocha'; -import * as url from 'url'; - -function createFunction(options: CreateAppOptions = {}) { - let route, callback, context; - let handler: AzureFunctionsHandler; - - options.graphqlOptions = options.graphqlOptions || { schema: Schema }; - if (options.graphiqlOptions) { - route = '/graphiql'; - handler = graphiqlAzureFunctions(options.graphiqlOptions); - } else { - route = '/graphql'; - handler = graphqlAzureFunctions(options.graphqlOptions); - } - - return function(req, res) { - if (!req.url.startsWith(route)) { - res.statusCode = 404; - res.end(); - return; - } - - let body = ''; - req.on('data', function(chunk) { - body += chunk; - }); - req.on('end', function() { - let urlObject = url.parse(req.url, true); - const request = { - method: req.method, - originalUrl: req.url, - query: urlObject.query, - headers: req.headers, - body: body, - rawbody: body, - }; - - context = { - done: function(error, result) { - res.statusCode = result.status; - for (let key in result.headers) { - if (result.headers.hasOwnProperty(key)) { - res.setHeader(key, result.headers[key]); - } - } - - if (error) { - res.error = error; - } - - res.write(result.body); - res.end(); - }, - }; - - handler(context, request); - }); - }; -} - -describe('azureFunctionsApollo', () => { - it('throws error if called without schema', function() { - expect(() => graphqlAzureFunctions(undefined as GraphQLOptions)).to.throw( - 'Apollo Server requires options.', - ); - }); - - it('throws an error if called with more than one argument', function() { - expect(() => (graphqlAzureFunctions)({}, {})).to.throw( - 'Apollo Server expects exactly one argument, got 2', - ); - }); -}); - -describe('integration:Azure Functions', () => { - testSuite(createFunction); -}); diff --git a/packages/apollo-server-azure-functions/src/azureFunctionsApollo.ts b/packages/apollo-server-azure-functions/src/azureFunctionsApollo.ts deleted file mode 100755 index 453d864bfee..00000000000 --- a/packages/apollo-server-azure-functions/src/azureFunctionsApollo.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { - IHttpContext, - IFunctionRequest, - HttpStatusCodes, -} from 'azure-functions-typescript'; -import { GraphQLOptions, runHttpQuery } from 'apollo-server-core'; -import { - GraphiQLData, - resolveGraphiQLString, -} from 'apollo-server-module-graphiql'; - -export interface AzureFunctionsGraphQLOptionsFunction { - (context: IHttpContext): GraphQLOptions | Promise; -} - -export interface AzureFunctionsHandler { - (context: IHttpContext, request: IFunctionRequest): void; -} - -export interface IHeaders { - 'content-type'?: string; - 'content-length'?: HttpStatusCodes | number; - 'content-disposition'?: string; - 'content-encoding'?: string; - 'content-language'?: string; - 'content-range'?: string; - 'content-location'?: string; - 'content-md5'?: Buffer; - expires?: Date; - 'last-modified'?: Date; - [header: string]: any; -} - -export interface AzureFunctionsGraphiQLOptionsFunction { - (context: IHttpContext, request: IFunctionRequest): - | GraphiQLData - | Promise; -} - -export function graphqlAzureFunctions( - options: GraphQLOptions | AzureFunctionsGraphQLOptionsFunction, -): AzureFunctionsHandler { - if (!options) { - throw new Error('Apollo Server requires options.'); - } - - if (arguments.length > 1) { - throw new Error( - `Apollo Server expects exactly one argument, got ${arguments.length}`, - ); - } - - const graphqlHandler = ( - httpContext: IHttpContext, - request: IFunctionRequest, - ) => { - const queryRequest = { - method: request.method, - options: options, - query: request.method === 'POST' ? request.body : request.query, - }; - - if (queryRequest.query && typeof queryRequest.query === 'string') { - queryRequest.query = JSON.parse(queryRequest.query); - } - - return runHttpQuery([httpContext, request], queryRequest) - .then(gqlResponse => { - const result = { - status: HttpStatusCodes.OK, - headers: { - 'Content-Type': 'application/json', - }, - body: gqlResponse, - isRaw: true, - }; - httpContext.res = result; - httpContext.done(null, result); - }) - .catch(error => { - const result = { - status: error.statusCode, - headers: error.headers, - body: error.message, - }; - - httpContext.res = result; - httpContext.done(null, result); - }); - }; - - return graphqlHandler; -} - -/* This Azure Functions Handler returns the html for the GraphiQL interactive query UI - * - * GraphiQLData arguments - * - * - endpointURL: the relative or absolute URL for the endpoint which GraphiQL will make queries to - * - (optional) query: the GraphQL query to pre-fill in the GraphiQL UI - * - (optional) variables: a JS object of variables to pre-fill in the GraphiQL UI - * - (optional) operationName: the operationName to pre-fill in the GraphiQL UI - * - (optional) result: the result of the query to pre-fill in the GraphiQL UI - */ - -export function graphiqlAzureFunctions( - options: GraphiQLData | AzureFunctionsGraphiQLOptionsFunction, -) { - const graphiqlHandler = ( - httpContext: IHttpContext, - request: IFunctionRequest, - ) => { - const query = request.query; - - resolveGraphiQLString(query, options, httpContext, request).then( - graphiqlString => { - const result = { - status: HttpStatusCodes.OK, - headers: { - 'Content-Type': 'text/html', - }, - body: graphiqlString, - isRaw: true, - }; - httpContext.res = result; - httpContext.done(null, result); - }, - error => { - httpContext.res = { - status: 500, - body: error.message, - }; - - httpContext.done(null, httpContext.res); - }, - ); - }; - - return graphiqlHandler; -} diff --git a/packages/apollo-server-azure-functions/src/index.ts b/packages/apollo-server-azure-functions/src/index.ts deleted file mode 100755 index 71c5b6da461..00000000000 --- a/packages/apollo-server-azure-functions/src/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { - AzureFunctionsHandler, - IHeaders, - AzureFunctionsGraphQLOptionsFunction, - AzureFunctionsGraphiQLOptionsFunction, - graphqlAzureFunctions, - graphiqlAzureFunctions, -} from './azureFunctionsApollo'; diff --git a/packages/apollo-server-azure-functions/tsconfig.json b/packages/apollo-server-azure-functions/tsconfig.json deleted file mode 100644 index 92f3db51b04..00000000000 --- a/packages/apollo-server-azure-functions/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig", - "compilerOptions": { - "rootDir": "./src", - "outDir": "./dist", - "typeRoots": ["node_modules/@types"], - "types": ["@types/node"] - }, - "exclude": ["node_modules", "dist"] -} diff --git a/packages/apollo-server-caching/.gitignore b/packages/apollo-server-caching/.gitignore new file mode 100644 index 00000000000..723ef36f4e4 --- /dev/null +++ b/packages/apollo-server-caching/.gitignore @@ -0,0 +1 @@ +.idea \ No newline at end of file diff --git a/packages/apollo-server-restify/.npmignore b/packages/apollo-server-caching/.npmignore similarity index 100% rename from packages/apollo-server-restify/.npmignore rename to packages/apollo-server-caching/.npmignore diff --git a/packages/apollo-server-caching/README.md b/packages/apollo-server-caching/README.md new file mode 100644 index 00000000000..4f8cbb50233 --- /dev/null +++ b/packages/apollo-server-caching/README.md @@ -0,0 +1,31 @@ +# apollo-server-caching + +[![npm version](https://badge.fury.io/js/apollo-server-caching.svg)](https://badge.fury.io/js/apollo-server-caching) +[![Build Status](https://circleci.com/gh/apollographql/apollo-server.svg?style=svg)](https://circleci.com/gh/apollographql/apollo-server) + +## Implementing your own Cache + +Internally, Apollo Server uses the `KeyValueCache` interface to provide a caching store for the Data Sources. An in-memory LRU cache is used by default, and we provide connectors for [Memcached](../apollo-server-memcached)/[Redis](../apollo-server-redis) backends. + +Built with extensibility in mind, you can also implement your own cache to use with Apollo Server, in a way that best suits your application needs. It needs to implement the following interface that can be exported from `apollo-server-caching`: + +```typescript +export interface KeyValueCache { + get(key: string): Promise; + set(key: string, value: string, options?: { ttl?: number }): Promise; +} +``` + +## Running test suite + +You can export and run a jest test suite from `apollo-server-caching` to test your implementation: + +```typescript +// ../__tests__/YourKeyValueCache.test.ts + +import YourKeyValueCache from '../src/YourKeyValueCache'; +import { testKeyValueCache } from 'apollo-server-caching'; +testKeyValueCache(new MemcachedCache('localhost')); +``` + +Run tests with `jest --verbose` diff --git a/packages/apollo-server-caching/package.json b/packages/apollo-server-caching/package.json new file mode 100644 index 00000000000..e3760ff575f --- /dev/null +++ b/packages/apollo-server-caching/package.json @@ -0,0 +1,30 @@ +{ + "name": "apollo-server-caching", + "version": "2.0.0-rc.7", + "author": "opensource@apollographql.com", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/apollographql/apollo-server/tree/master/packages/apollo-server-caching" + }, + "homepage": "https://github.com/apollographql/apollo-server#readme", + "bugs": { + "url": "https://github.com/apollographql/apollo-server/issues" + }, + "scripts": { + "clean": "rm -rf dist", + "compile": "tsc", + "prepublish": "npm run clean && npm run compile" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "engines": { + "node": ">=6" + }, + "dependencies": { + "lru-cache": "^4.1.3" + }, + "devDependencies": { + "@types/lru-cache": "^4.1.1" + } +} diff --git a/packages/apollo-server-caching/src/InMemoryLRUCache.ts b/packages/apollo-server-caching/src/InMemoryLRUCache.ts new file mode 100644 index 00000000000..57fb5d1c615 --- /dev/null +++ b/packages/apollo-server-caching/src/InMemoryLRUCache.ts @@ -0,0 +1,21 @@ +import * as LRU from 'lru-cache'; +import { KeyValueCache } from './KeyValueCache'; + +export class InMemoryLRUCache implements KeyValueCache { + private store: LRU.Cache; + + // FIXME: Define reasonable default max size of the cache + constructor({ maxSize = Infinity }: { maxSize?: number } = {}) { + this.store = LRU({ + max: maxSize, + length: item => item.length, + }); + } + + async get(key: string) { + return this.store.get(key); + } + async set(key: string, value: string) { + this.store.set(key, value); + } +} diff --git a/packages/apollo-server-caching/src/KeyValueCache.ts b/packages/apollo-server-caching/src/KeyValueCache.ts new file mode 100644 index 00000000000..5896ce048f9 --- /dev/null +++ b/packages/apollo-server-caching/src/KeyValueCache.ts @@ -0,0 +1,4 @@ +export interface KeyValueCache { + get(key: string): Promise; + set(key: string, value: string, options?: { ttl?: number }): Promise; +} diff --git a/packages/apollo-server-caching/src/__tests__/testsuite.ts b/packages/apollo-server-caching/src/__tests__/testsuite.ts new file mode 100644 index 00000000000..3364399fc21 --- /dev/null +++ b/packages/apollo-server-caching/src/__tests__/testsuite.ts @@ -0,0 +1,44 @@ +import { + advanceTimeBy, + mockDate, + unmockDate, +} from '../../../../__mocks__/date'; + +export function testKeyValueCache(keyValueCache: any) { + describe('KeyValueCache Test Suite', () => { + beforeAll(() => { + mockDate(); + jest.useFakeTimers(); + }); + + beforeEach(() => { + keyValueCache.flush(); + }); + + afterAll(() => { + unmockDate(); + keyValueCache.close(); + }); + + it('can do a basic get and set', async () => { + await keyValueCache.set('hello', 'world'); + expect(await keyValueCache.get('hello')).toBe('world'); + expect(await keyValueCache.get('missing')).not.toBeDefined(); + }); + + it('is able to expire keys based on ttl', async () => { + await keyValueCache.set('short', 's', { ttl: 1 }); + await keyValueCache.set('long', 'l', { ttl: 5 }); + expect(await keyValueCache.get('short')).toBe('s'); + expect(await keyValueCache.get('long')).toBe('l'); + advanceTimeBy(1500); + jest.advanceTimersByTime(1500); + expect(await keyValueCache.get('short')).not.toBeDefined(); + expect(await keyValueCache.get('long')).toBe('l'); + advanceTimeBy(4000); + jest.advanceTimersByTime(4000); + expect(await keyValueCache.get('short')).not.toBeDefined(); + expect(await keyValueCache.get('long')).not.toBeDefined(); + }); + }); +} diff --git a/packages/apollo-server-caching/src/index.ts b/packages/apollo-server-caching/src/index.ts new file mode 100644 index 00000000000..7a1ba0e0bb1 --- /dev/null +++ b/packages/apollo-server-caching/src/index.ts @@ -0,0 +1,2 @@ +export { KeyValueCache } from './KeyValueCache'; +export { InMemoryLRUCache } from './InMemoryLRUCache'; diff --git a/packages/apollo-server-caching/tsconfig.json b/packages/apollo-server-caching/tsconfig.json new file mode 100644 index 00000000000..700fb9c53b1 --- /dev/null +++ b/packages/apollo-server-caching/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "removeComments": true, + "strict": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUnusedParameters": true, + "noUnusedLocals": true, + "types": ["node", "jest"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "**/__tests__/*", "**/__mocks__/*"] +} diff --git a/packages/graphql-server-hapi/package.json b/packages/apollo-server-cloudflare/package.json similarity index 61% rename from packages/graphql-server-hapi/package.json rename to packages/apollo-server-cloudflare/package.json index 67e2db6a360..a4e6553415f 100644 --- a/packages/graphql-server-hapi/package.json +++ b/packages/apollo-server-cloudflare/package.json @@ -1,7 +1,7 @@ { - "name": "graphql-server-hapi", - "version": "1.4.0", - "description": "Production-ready Node.js GraphQL server for Hapi", + "name": "apollo-server-cloudflare", + "version": "2.0.0-rc.7", + "description": "Production-ready Node.js GraphQL server for Cloudflare workers", "main": "dist/index.js", "scripts": { "compile": "tsc", @@ -9,23 +9,26 @@ }, "repository": { "type": "git", - "url": "https://github.com/apollographql/apollo-server/tree/master/packages/graphql-server-hapi" + "url": "https://github.com/apollographql/apollo-server/tree/master/packages/apollo-server-cloudflare-workers" }, "keywords": [ "GraphQL", "Apollo", - "Hapi", "Server", + "Cloudflare", "Javascript" ], - "author": "Jonas Helfer ", "license": "MIT", "bugs": { "url": "https://github.com/apollographql/apollo-server/issues" }, "homepage": "https://github.com/apollographql/apollo-server#readme", "dependencies": { - "apollo-server-hapi": "^1.4.0" + "apollo-server-core": "^2.0.0-rc.7", + "apollo-server-env": "^2.0.0-rc.7" + }, + "peerDependencies": { + "graphql": "^0.12.0 || ^0.13.0 || ^14.0.0" }, "typings": "dist/index.d.ts", "typescript": { diff --git a/packages/apollo-server-cloudflare/src/ApolloServer.ts b/packages/apollo-server-cloudflare/src/ApolloServer.ts new file mode 100644 index 00000000000..bb2ce9fe49a --- /dev/null +++ b/packages/apollo-server-cloudflare/src/ApolloServer.ts @@ -0,0 +1,23 @@ +import { graphqlCloudflare } from './cloudflareApollo'; + +import { ApolloServerBase } from 'apollo-server-core'; +export { GraphQLOptions, GraphQLExtension } from 'apollo-server-core'; +import { GraphQLOptions } from 'apollo-server-core'; +import { Request } from 'apollo-server-env'; + +export class ApolloServer extends ApolloServerBase { + // This translates the arguments from the middleware into graphQL options It + // provides typings for the integration specific behavior, ideally this would + // be propagated with a generic to the super class + async createGraphQLServerOptions(request: Request): Promise { + return super.graphQLServerOptions({ request }); + } + + public async listen() { + const graphql = this.createGraphQLServerOptions.bind(this); + addEventListener('fetch', (event: FetchEvent) => { + event.respondWith(graphqlCloudflare(graphql)(event.request)); + }); + return await { url: '', port: null }; + } +} diff --git a/packages/apollo-server-cloudflare/src/cloudflareApollo.ts b/packages/apollo-server-cloudflare/src/cloudflareApollo.ts new file mode 100644 index 00000000000..ecdfefebc02 --- /dev/null +++ b/packages/apollo-server-cloudflare/src/cloudflareApollo.ts @@ -0,0 +1,65 @@ +import { + GraphQLOptions, + HttpQueryError, + runHttpQuery, +} from 'apollo-server-core'; + +import { Request, Response, URL } from 'apollo-server-env'; + +// Design principles: +// - You can issue a GET or POST with your query. +// - simple, fast and secure +// + +export function graphqlCloudflare(options: GraphQLOptions) { + if (!options) { + throw new Error('Apollo Server requires options.'); + } + + if (arguments.length > 1) { + throw new Error( + `Apollo Server expects exactly one argument, got ${arguments.length}`, + ); + } + + const graphqlHandler = async (req: Request): Promise => { + const url = new URL(req.url); + const query = + req.method === 'POST' + ? await req.json() + : { + query: url.searchParams.get('query'), + variables: url.searchParams.get('variables'), + operationName: url.searchParams.get('operationName'), + extensions: url.searchParams.get('extensions'), + }; + + return runHttpQuery([req], { + method: req.method, + options: options, + query, + request: req as Request, + }).then( + ({ graphqlResponse, responseInit }) => + new Response(graphqlResponse, responseInit), + (error: HttpQueryError) => { + if ('HttpQueryError' !== error.name) throw error; + + const res = new Response(error.message, { + status: error.statusCode, + headers: { 'content-type': 'application/json' }, + }); + + if (error.headers) { + Object.keys(error.headers).forEach(header => { + res.headers[header] = error.headers[header]; + }); + } + + return res; + }, + ); + }; + + return graphqlHandler; +} diff --git a/packages/apollo-server-cloudflare/src/index.ts b/packages/apollo-server-cloudflare/src/index.ts new file mode 100644 index 00000000000..829939cdea9 --- /dev/null +++ b/packages/apollo-server-cloudflare/src/index.ts @@ -0,0 +1,2 @@ +export { ApolloServer } from './ApolloServer'; +export { gql } from 'apollo-server-core'; diff --git a/packages/apollo-server-cloudflare/src/types/cloudflare/index.d.ts b/packages/apollo-server-cloudflare/src/types/cloudflare/index.d.ts new file mode 100644 index 00000000000..020281d9c45 --- /dev/null +++ b/packages/apollo-server-cloudflare/src/types/cloudflare/index.d.ts @@ -0,0 +1,14 @@ +import { Request, Response } from 'apollo-server-env'; + +declare global { + interface FetchEvent { + readonly request: Request; + respondWith(response: Promise): void; + waitUntil(task: Promise): void; + } + + function addEventListener( + type: 'fetch', + listener: (event: FetchEvent) => void, + ): void; +} diff --git a/packages/apollo-server-cloudflare/tsconfig.json b/packages/apollo-server-cloudflare/tsconfig.json new file mode 100644 index 00000000000..2d35d9da46d --- /dev/null +++ b/packages/apollo-server-cloudflare/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"], + "types": [] +} diff --git a/packages/apollo-server-core/CHANGELOG.md b/packages/apollo-server-core/CHANGELOG.md new file mode 100644 index 00000000000..a188c3d0e47 --- /dev/null +++ b/packages/apollo-server-core/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog + +### vNEXT + +* `apollo-server-core`: Add persisted queries [PR#1149](https://github.com/apollographql/apollo-server/pull/1149) +* `apollo-server-core`: added `BadUserInputError` +* `apollo-server-core`: **breaking** gql is exported from gql-tag and ApolloServer requires a DocumentNode [PR#1146](https://github.com/apollographql/apollo-server/pull/1146) +* `apollo-server-core`: accept `Request` object in `runQuery` [PR#1108](https://github.com/apollographql/apollo-server/pull/1108) +* `apollo-server-core`: move query parse into runQuery and no longer accept GraphQL AST over the wire [PR#1097](https://github.com/apollographql/apollo-server/pull/1097) +* `apollo-server-core`: custom errors allow instanceof checks [PR#1074](https://github.com/apollographql/apollo-server/pull/1074) +* `apollo-server-core`: move subscriptions options into listen [PR#1059](https://github.com/apollographql/apollo-server/pull/1059) +* `apollo-server-core`: Replace console.error with logFunction for opt-in logging [PR #1024](https://github.com/apollographql/apollo-server/pull/1024) +* `apollo-server-core`: context creation can be async and errors are formatted to include error code [PR #1024](https://github.com/apollographql/apollo-server/pull/1024) +* `apollo-server-core`: add `mocks` parameter to the base constructor(applies to all variants) [PR#1017](https://github.com/apollographql/apollo-server/pull/1017) +* `apollo-server-core`: Remove printing of stack traces with `debug` option and include response in logging function[PR#1018](https://github.com/apollographql/apollo-server/pull/1018) diff --git a/packages/apollo-server-core/README.md b/packages/apollo-server-core/README.md index 77b9e248c04..741aaec2f35 100644 --- a/packages/apollo-server-core/README.md +++ b/packages/apollo-server-core/README.md @@ -1,4 +1,7 @@ # apollo-server-core +[![npm version](https://badge.fury.io/js/apollo-server-core.svg)](https://badge.fury.io/js/apollo-server-core) +[![Build Status](https://circleci.com/gh/apollographql/apollo-server.svg?style=svg)](https://circleci.com/gh/apollographql/apollo-server) + This is the core module of the Apollo community GraphQL Server. [Read the docs.](https://www.apollographql.com/docs/apollo-server/) [Read the CHANGELOG.](https://github.com/apollographql/apollo-server/blob/master/CHANGELOG.md) diff --git a/packages/apollo-server-core/package.json b/packages/apollo-server-core/package.json index 740abad4a78..edf9fdad924 100644 --- a/packages/apollo-server-core/package.json +++ b/packages/apollo-server-core/package.json @@ -1,6 +1,6 @@ { "name": "apollo-server-core", - "version": "1.4.0", + "version": "2.0.0-rc.7", "description": "Core engine for Apollo GraphQL server", "main": "dist/index.js", "scripts": { @@ -24,23 +24,47 @@ "url": "https://github.com/apollographql/apollo-server/issues" }, "homepage": "https://github.com/apollographql/apollo-server#readme", + "engines": { + "node": ">=6" + }, + "dependencies": { + "@types/ws": "^5.1.2", + "apollo-cache-control": "^0.2.0-rc.0", + "apollo-datasource": "^2.0.0-rc.7", + "apollo-engine-reporting": "^0.0.0-rc.1", + "apollo-server-caching": "^2.0.0-rc.7", + "apollo-server-env": "^2.0.0-rc.7", + "apollo-server-errors": "^2.0.0-rc.7", + "apollo-tracing": "^0.2.0-rc.0", + "apollo-upload-server": "^5.0.0", + "graphql-extensions": "^0.1.0-rc.1", + "graphql-subscriptions": "^0.5.8", + "graphql-tag": "^2.9.2", + "graphql-tools": "^3.0.4", + "hash.js": "^1.1.3", + "lodash": "^4.17.10", + "subscriptions-transport-ws": "^0.9.11", + "ws": "^5.2.0" + }, "devDependencies": { "@types/fibers": "0.0.30", - "@types/graphql": "0.12.7", - "fibers": "1.0.15", + "@types/graphql": "^0.13.1", + "@types/keyv": "^3.0.1", + "@types/quick-lru": "^1.1.0", + "apollo-fetch": "^0.7.0", + "apollo-link": "^1.2.2", + "apollo-link-http": "^1.5.4", + "apollo-link-persisted-queries": "^0.2.1", + "fibers": "2.0.2", + "js-sha256": "^0.9.0", "meteor-promise": "0.8.6", - "typescript": "2.8.4" + "mock-req": "^0.2.0" }, "peerDependencies": { - "graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0" + "graphql": "^0.12.0 || ^0.13.0 || ^14.0.0" }, "typings": "dist/index.d.ts", "typescript": { "definition": "dist/index.d.ts" - }, - "dependencies": { - "apollo-cache-control": "^0.1.0", - "apollo-tracing": "^0.1.0", - "graphql-extensions": "^0.0.x" } } diff --git a/packages/apollo-server-core/src/ApolloServer.ts b/packages/apollo-server-core/src/ApolloServer.ts new file mode 100644 index 00000000000..bb4f891d2f8 --- /dev/null +++ b/packages/apollo-server-core/src/ApolloServer.ts @@ -0,0 +1,368 @@ +import { makeExecutableSchema, addMockFunctionsToSchema } from 'graphql-tools'; +import { Server as HttpServer } from 'http'; +import { + execute, + GraphQLSchema, + subscribe, + ExecutionResult, + GraphQLError, + GraphQLFieldResolver, + ValidationContext, + FieldDefinitionNode, +} from 'graphql'; +import { GraphQLExtension } from 'graphql-extensions'; +import { EngineReportingAgent } from 'apollo-engine-reporting'; +import { InMemoryLRUCache } from 'apollo-server-caching'; + +import { GraphQLUpload } from 'apollo-upload-server'; + +import { + SubscriptionServer, + ExecutionParams, +} from 'subscriptions-transport-ws'; + +import { formatApolloErrors } from 'apollo-server-errors'; +import { + GraphQLServerOptions as GraphQLOptions, + PersistedQueryOptions, +} from './graphqlOptions'; + +import { + Config, + Context, + ContextFunction, + SubscriptionServerOptions, + FileUploadOptions, +} from './types'; + +import { FormatErrorExtension } from './formatters'; + +import { gql } from './index'; + +import { + createPlaygroundOptions, + PlaygroundRenderPageOptions, +} from './playground'; + +const NoIntrospection = (context: ValidationContext) => ({ + Field(node: FieldDefinitionNode) { + if (node.name.value === '__schema' || node.name.value === '__type') { + context.reportError( + new GraphQLError( + 'GraphQL introspection is not allowed by Apollo Server, but the query contained __schema or __type. To enable introspection, pass introspection: true to ApolloServer in production', + [node], + ), + ); + } + }, +}); + +export class ApolloServerBase { + public subscriptionsPath?: string; + public graphqlPath: string = '/graphql'; + public requestOptions: Partial>; + + private schema: GraphQLSchema; + private context?: Context | ContextFunction; + private engineReportingAgent?: EngineReportingAgent; + private extensions: Array<() => GraphQLExtension>; + protected subscriptionServerOptions?: SubscriptionServerOptions; + protected uploadsConfig?: FileUploadOptions; + + // set by installSubscriptionHandlers. + private subscriptionServer?: SubscriptionServer; + + // the default version is specified in playground.ts + protected playgroundOptions?: PlaygroundRenderPageOptions; + + // The constructor should be universal across all environments. All environment specific behavior should be set by adding or overriding methods + constructor(config: Config) { + if (!config) throw new Error('ApolloServer requires options.'); + const { + context, + resolvers, + schema, + schemaDirectives, + typeDefs, + introspection, + mocks, + extensions, + engine, + subscriptions, + uploads, + playground, + ...requestOptions + } = config; + + // While reading process.env is slow, a server should only be constructed + // once per run, so we place the env check inside the constructor. If env + // should be used outside of the constructor context, place it as a private + // or protected field of the class instead of a global. Keeping the read in + // the contructor enables testing of different environments + const isDev = process.env.NODE_ENV !== 'production'; + + // if this is local dev, introspection should turned on + // in production, we can manually turn introspection on by passing { + // introspection: true } to the constructor of ApolloServer + if ( + (typeof introspection === 'boolean' && !introspection) || + (introspection === undefined && !isDev) + ) { + const noIntro = [NoIntrospection]; + requestOptions.validationRules = requestOptions.validationRules + ? requestOptions.validationRules.concat(noIntro) + : noIntro; + } + + if (!requestOptions.cache) { + requestOptions.cache = new InMemoryLRUCache(); + } + + if (requestOptions.persistedQueries !== false) { + if (!requestOptions.persistedQueries) { + requestOptions.persistedQueries = { + cache: requestOptions.cache!, + }; + } + } else { + // the user does not want to use persisted queries, so we remove the field + delete requestOptions.persistedQueries; + } + + this.requestOptions = requestOptions as GraphQLOptions; + this.context = context; + + if (uploads !== false) { + if (this.supportsUploads()) { + if (uploads === true || typeof uploads === 'undefined') { + this.uploadsConfig = {}; + } else { + this.uploadsConfig = uploads; + } + //This is here to check if uploads is requested without support. By + //default we enable them if supported by the integration + } else if (uploads) { + throw new Error( + 'This implementation of ApolloServer does not support file uploads because the environmnet cannot accept multi-part forms', + ); + } + } + + //Add upload resolver + if (this.uploadsConfig) { + if (resolvers && !resolvers.Upload) { + resolvers.Upload = GraphQLUpload; + } + } + + if (schema) { + this.schema = schema; + } else { + if (!typeDefs) { + throw Error( + 'Apollo Server requires either an existing schema or typeDefs', + ); + } + this.schema = makeExecutableSchema({ + // we add in the upload scalar, so that schemas that don't include it + // won't error when we makeExecutableSchema + typeDefs: this.uploadsConfig + ? [ + gql` + scalar Upload + `, + ].concat(typeDefs) + : typeDefs, + schemaDirectives, + resolvers, + }); + } + + if (mocks) { + addMockFunctionsToSchema({ + schema: this.schema, + preserveResolvers: true, + mocks: typeof mocks === 'boolean' ? {} : mocks, + }); + } + + // Note: doRunQuery will add its own extensions if you set tracing, + // or cacheControl. + this.extensions = []; + + // Error formatting should happen after the engine reporting agent, so that + // engine gets the unmasked errors if necessary + if (this.requestOptions.formatError) { + this.extensions.push( + () => + new FormatErrorExtension( + this.requestOptions.formatError!, + this.requestOptions.debug, + ), + ); + } + + if (engine || (engine !== false && process.env.ENGINE_API_KEY)) { + this.engineReportingAgent = new EngineReportingAgent( + engine === true ? {} : engine, + ); + // Let's keep this extension second so it wraps everything, except error formatting + this.extensions.push(() => this.engineReportingAgent!.newExtension()); + } + + if (extensions) { + this.extensions = [...this.extensions, ...extensions]; + } + + if (subscriptions !== false) { + if (this.supportsSubscriptions()) { + if (subscriptions === true || typeof subscriptions === 'undefined') { + this.subscriptionServerOptions = { + path: this.graphqlPath, + }; + } else if (typeof subscriptions === 'string') { + this.subscriptionServerOptions = { path: subscriptions }; + } else { + this.subscriptionServerOptions = { + path: this.graphqlPath, + ...subscriptions, + }; + } + // This is part of the public API. + this.subscriptionsPath = this.subscriptionServerOptions.path; + + //This is here to check if subscriptions are requested without support. By + //default we enable them if supported by the integration + } else if (subscriptions) { + throw new Error( + 'This implementation of ApolloServer does not support GraphQL subscriptions.', + ); + } + } + + this.playgroundOptions = createPlaygroundOptions(playground); + } + + // used by integrations to synchronize the path with subscriptions, some + // integrations do not have paths, such as lambda + public setGraphQLPath(path: string) { + this.graphqlPath = path; + } + + public async stop() { + if (this.subscriptionServer) await this.subscriptionServer.close(); + if (this.engineReportingAgent) { + this.engineReportingAgent.stop(); + await this.engineReportingAgent.sendReport(); + } + } + + public installSubscriptionHandlers(server: HttpServer) { + if (!this.subscriptionServerOptions) { + if (this.supportsSubscriptions()) { + throw Error( + 'Subscriptions are disabled, due to subscriptions set to false in the ApolloServer constructor', + ); + } else { + throw Error( + 'Subscriptions are not supported, choose an integration, such as apollo-server-express that allows persistent connections', + ); + } + } + + const { + onDisconnect, + onConnect, + keepAlive, + path, + } = this.subscriptionServerOptions; + + this.subscriptionServer = SubscriptionServer.create( + { + schema: this.schema, + execute, + subscribe, + onConnect: onConnect + ? onConnect + : (connectionParams: Object) => ({ ...connectionParams }), + onDisconnect: onDisconnect, + onOperation: async (_: string, connection: ExecutionParams) => { + connection.formatResponse = (value: ExecutionResult) => ({ + ...value, + errors: + value.errors && + formatApolloErrors([...value.errors], { + formatter: this.requestOptions.formatError, + debug: this.requestOptions.debug, + }), + }); + let context: Context = this.context ? this.context : { connection }; + + try { + context = + typeof this.context === 'function' + ? await this.context({ connection }) + : context; + } catch (e) { + throw formatApolloErrors([e], { + formatter: this.requestOptions.formatError, + debug: this.requestOptions.debug, + })[0]; + } + + return { ...connection, context }; + }, + keepAlive, + }, + { + server, + path, + }, + ); + } + + protected supportsSubscriptions(): boolean { + return false; + } + + protected supportsUploads(): boolean { + return false; + } + + // This function is used by the integrations to generate the graphQLOptions + // from an object containing the request and other integration specific + // options + protected async graphQLServerOptions( + integrationContextArgument?: Record, + ) { + let context: Context = this.context ? this.context : {}; + + try { + context = + typeof this.context === 'function' + ? await this.context(integrationContextArgument || {}) + : context; + } catch (error) { + // Defer context error resolution to inside of runQuery + context = () => { + throw error; + }; + } + + return { + schema: this.schema, + extensions: this.extensions, + context, + // Allow overrides from options. Be explicit about a couple of them to + // avoid a bad side effect of the otherwise useful noUnusedLocals option + // (https://github.com/Microsoft/TypeScript/issues/21673). + persistedQueries: this.requestOptions + .persistedQueries as PersistedQueryOptions, + fieldResolver: this.requestOptions.fieldResolver as GraphQLFieldResolver< + any, + any + >, + ...this.requestOptions, + } as GraphQLOptions; + } +} diff --git a/packages/apollo-server-core/src/caching.ts b/packages/apollo-server-core/src/caching.ts new file mode 100644 index 00000000000..0ffeca71eeb --- /dev/null +++ b/packages/apollo-server-core/src/caching.ts @@ -0,0 +1,66 @@ +import { ExecutionResult } from 'graphql'; +import { CacheControlFormat } from 'apollo-cache-control'; + +export function calculateCacheControlHeaders( + responses: Array }>, +): Record { + let lowestMaxAge = Number.MAX_VALUE; + let publicOrPrivate = 'public'; + + for (const response of responses) { + const cacheControl: CacheControlFormat = + response.extensions && response.extensions.cacheControl; + + // If there are no extensions or hints, then the headers should not be present + if ( + !cacheControl || + !cacheControl.hints || + cacheControl.hints.length === 0 || + cacheControl.version !== 1 + ) { + if (cacheControl && cacheControl.version !== 1) { + console.warn('Invalid cacheControl version.'); + } + return {}; + } + + const rootHints = new Set(); + for (const hint of cacheControl.hints) { + if (hint.scope && hint.scope.toLowerCase() === 'private') { + publicOrPrivate = 'private'; + } + + // If no maxAge is present, then we ignore the hint + if (hint.maxAge === undefined) { + continue; + } + + // if there is a hint with max age of 0, we don't need to process more + if (hint.maxAge === 0) { + return {}; + } + + if (hint.maxAge < lowestMaxAge) { + lowestMaxAge = hint.maxAge; + } + + // If this is a root path, store that the root is cacheable: + if (hint.path.length === 1) { + rootHints.add(hint.path[0] as string); + } + } + + // If a root field inside of data does not have a cache hint, then we do not + // cache the response + if ( + response.data && + Object.keys(response.data).find(rootKey => !rootHints.has(rootKey)) + ) { + return {}; + } + } + + return { + 'Cache-Control': `max-age=${lowestMaxAge}, ${publicOrPrivate}`, + }; +} diff --git a/packages/apollo-server-core/src/errors.test.ts b/packages/apollo-server-core/src/errors.test.ts new file mode 100644 index 00000000000..6f9d5bca1f7 --- /dev/null +++ b/packages/apollo-server-core/src/errors.test.ts @@ -0,0 +1,197 @@ +/* tslint:disable:no-unused-expression */ +import { expect } from 'chai'; +import { stub } from 'sinon'; +import 'mocha'; + +import { GraphQLError } from 'graphql'; + +import { + ApolloError, + formatApolloErrors, + AuthenticationError, + ForbiddenError, + ValidationError, + UserInputError, + SyntaxError, +} from 'apollo-server-errors'; + +describe('Errors', () => { + describe('ApolloError', () => { + const message = 'message'; + it('defaults code to INTERNAL_SERVER_ERROR', () => { + const error = new ApolloError(message); + expect(error.message).to.equal(message); + expect(error.extensions.code).not.to.exist; + }); + it('allows code setting and additional properties', () => { + const code = 'CODE'; + const key = 'key'; + const error = new ApolloError(message, code, { key }); + expect(error.message).to.equal(message); + expect(error.key).to.equal(key); + expect(error.extensions.code).to.equal(code); + }); + }); + + describe('formatApolloErrors', () => { + type CreateFormatError = + | (( + options: Record, + errors: Error[], + ) => Record[]) + | ((options?: Record) => Record); + const message = 'message'; + const code = 'CODE'; + const key = 'key'; + + const createFormattedError: CreateFormatError = ( + options?: Record, + errors?: Error[], + ) => { + if (errors === undefined) { + const error = new ApolloError(message, code, { key }); + return formatApolloErrors( + [ + new GraphQLError( + error.message, + undefined, + undefined, + undefined, + undefined, + error, + ), + ], + options, + )[0]; + } else { + return formatApolloErrors(errors, options); + } + }; + + it('exposes a stacktrace in debug mode', () => { + const error = createFormattedError({ debug: true }); + expect(error.message).to.equal(message); + expect(error.extensions.exception.key).to.equal(key); + expect(error.extensions.code).to.equal(code); + expect( + error.extensions.exception.stacktrace, + 'stacktrace should exist under exception', + ).to.exist; + }); + it('hides stacktrace by default', () => { + const thrown = new Error(message); + (thrown as any).key = key; + const error = formatApolloErrors([ + new GraphQLError( + thrown.message, + undefined, + undefined, + undefined, + undefined, + thrown, + ), + ])[0]; + expect(error.message).to.equal(message); + expect(error.extensions.code).to.equal('INTERNAL_SERVER_ERROR'); + expect(error.extensions.exception.key).to.equal(key); + expect( + error.extensions.exception.stacktrace, + 'stacktrace should exist under exception', + ).not.to.exist; + }); + it('exposes fields on error under exception field and provides code', () => { + const error = createFormattedError(); + expect(error.message).to.equal(message); + expect(error.extensions.exception.key).to.equal(key); + expect(error.extensions.code).to.equal(code); + expect( + error.extensions.exception.stacktrace, + 'stacktrace should exist under exception', + ).not.to.exist; + }); + it('calls formatter after exposing the code and stacktrace', () => { + const error = new ApolloError(message, code, { key }); + const formatter = stub(); + formatApolloErrors([error], { + formatter, + debug: true, + }); + expect(error.message).to.equal(message); + expect(error.key).to.equal(key); + expect(error.extensions.code).to.equal(code); + expect(error instanceof ApolloError).true; + expect(formatter.calledOnce); + }); + }); + describe('Named Errors', () => { + const message = 'message'; + function verifyError( + error: ApolloError, + { + code, + errorClass, + name, + }: { code: string; errorClass: any; name: string }, + ) { + expect(error.message).to.equal(message); + expect(error.extensions.code).to.equal(code); + expect(error.name).equals(name); + expect(error instanceof ApolloError).true; + expect(error instanceof errorClass).true; + } + + it('provides an authentication error', () => { + verifyError(new AuthenticationError(message), { + code: 'UNAUTHENTICATED', + errorClass: AuthenticationError, + name: 'AuthenticationError', + }); + }); + it('provides a forbidden error', () => { + verifyError(new ForbiddenError(message), { + code: 'FORBIDDEN', + errorClass: ForbiddenError, + name: 'ForbiddenError', + }); + }); + it('provides a syntax error', () => { + verifyError(new SyntaxError(message), { + code: 'GRAPHQL_PARSE_FAILED', + errorClass: SyntaxError, + name: 'SyntaxError', + }); + }); + it('provides a validation error', () => { + verifyError(new ValidationError(message), { + code: 'GRAPHQL_VALIDATION_FAILED', + errorClass: ValidationError, + name: 'ValidationError', + }); + }); + it('provides a user input error', () => { + const error = new UserInputError(message, { + field1: 'property1', + field2: 'property2', + }); + verifyError(error, { + code: 'BAD_USER_INPUT', + errorClass: UserInputError, + name: 'UserInputError', + }); + + const formattedError = formatApolloErrors([ + new GraphQLError( + error.message, + undefined, + undefined, + undefined, + undefined, + error, + ), + ])[0]; + + expect(formattedError.extensions.exception.field1).to.equal('property1'); + expect(formattedError.extensions.exception.field2).to.equal('property2'); + }); + }); +}); diff --git a/packages/apollo-server-core/src/formatters.ts b/packages/apollo-server-core/src/formatters.ts new file mode 100644 index 00000000000..8e708b41393 --- /dev/null +++ b/packages/apollo-server-core/src/formatters.ts @@ -0,0 +1,29 @@ +import { GraphQLExtension, GraphQLResponse } from 'graphql-extensions'; +import { formatApolloErrors } from 'apollo-server-errors'; + +export class FormatErrorExtension extends GraphQLExtension { + private formatError: Function; + private debug: boolean; + + public constructor(formatError: Function, debug: boolean = false) { + super(); + this.formatError = formatError; + this.debug = debug; + } + + public willSendResponse(o: { + graphqlResponse: GraphQLResponse; + }): void | { graphqlResponse: GraphQLResponse } { + if (o.graphqlResponse.errors) { + return { + graphqlResponse: { + ...o.graphqlResponse, + errors: formatApolloErrors(o.graphqlResponse.errors, { + formatter: this.formatError, + debug: this.debug, + }), + }, + }; + } + } +} diff --git a/packages/apollo-server-core/src/graphqlOptions.ts b/packages/apollo-server-core/src/graphqlOptions.ts index 50feb7c8ce1..4f45e08d57e 100644 --- a/packages/apollo-server-core/src/graphqlOptions.ts +++ b/packages/apollo-server-core/src/graphqlOptions.ts @@ -3,9 +3,10 @@ import { ValidationContext, GraphQLFieldResolver, } from 'graphql'; -import { LogFunction } from './runQuery'; import { GraphQLExtension } from 'graphql-extensions'; import { CacheControlExtensionOptions } from 'apollo-cache-control'; +import { KeyValueCache } from 'apollo-server-caching'; +import { DataSource } from 'apollo-datasource'; /* * GraphQLServerOptions @@ -14,41 +15,59 @@ import { CacheControlExtensionOptions } from 'apollo-cache-control'; * - (optional) formatError: Formatting function applied to all errors before response is sent * - (optional) rootValue: rootValue passed to GraphQL execution * - (optional) context: the context passed to GraphQL execution - * - (optional) logFunction: a function called for logging events such as execution times - * - (optional) formatParams: a function applied to the parameters of every invocation of runQuery * - (optional) validationRules: extra validation rules applied to requests * - (optional) formatResponse: a function applied to each graphQL execution result * - (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) * */ -export interface GraphQLServerOptions { +export interface GraphQLServerOptions< + TContext = + | (() => Promise> | Record) + | Record +> { schema: GraphQLSchema; formatError?: Function; rootValue?: any; context?: TContext; - logFunction?: LogFunction; - formatParams?: Function; validationRules?: Array<(context: ValidationContext) => any>; formatResponse?: Function; fieldResolver?: GraphQLFieldResolver; debug?: boolean; tracing?: boolean; - cacheControl?: boolean | CacheControlExtensionOptions; + cacheControl?: + | boolean + | (CacheControlExtensionOptions & { + calculateHttpHeaders?: boolean; + stripFormattedExtensions?: boolean; + }); + extensions?: Array<() => GraphQLExtension>; + dataSources?: () => DataSources; + cache?: KeyValueCache; + persistedQueries?: PersistedQueryOptions; +} + +export type DataSources = { + [name: string]: DataSource; +}; + +export interface PersistedQueryOptions { + cache: KeyValueCache; } export default GraphQLServerOptions; export async function resolveGraphqlOptions( - options: GraphQLServerOptions | Function, - ...args + options: + | GraphQLServerOptions + | (( + ...args: Array + ) => Promise | GraphQLServerOptions), + ...args: Array ): Promise { if (typeof options === 'function') { - try { - return await options(...args); - } catch (e) { - throw new Error(`Invalid options provided to ApolloServer: ${e.message}`); - } + return await options(...args); } else { return options; } diff --git a/packages/apollo-server-core/src/index.ts b/packages/apollo-server-core/src/index.ts index 40ba834a714..668ad93f26a 100644 --- a/packages/apollo-server-core/src/index.ts +++ b/packages/apollo-server-core/src/index.ts @@ -1,12 +1,42 @@ -export { - runQuery, - LogFunction, - LogMessage, - LogStep, - LogAction, -} from './runQuery'; +import 'apollo-server-env'; + +export { runQuery } from './runQuery'; export { runHttpQuery, HttpQueryRequest, HttpQueryError } from './runHttpQuery'; + export { default as GraphQLOptions, resolveGraphqlOptions, + PersistedQueryOptions, } from './graphqlOptions'; + +export { + ApolloError, + toApolloError, + SyntaxError, + ValidationError, + AuthenticationError, + ForbiddenError, + UserInputError, + formatApolloErrors, +} from 'apollo-server-errors'; + +export { convertNodeHttpToRequest } from './nodeHttpToRequest'; + +export { createPlaygroundOptions } from './playground'; + +// ApolloServer Base class +export { ApolloServerBase } from './ApolloServer'; +export * from './types'; + +// This currently provides the ability to have syntax highlighting as well as +// consistency between client and server gql tags +import { DocumentNode } from 'graphql'; +import gqlTag from 'graphql-tag'; +export const gql: ( + template: TemplateStringsArray | string, + ...substitutions: any[] +) => DocumentNode = gqlTag; + +import { GraphQLScalarType } from 'graphql'; +import { GraphQLUpload as UploadScalar } from 'apollo-upload-server'; +export const GraphQLUpload = UploadScalar as GraphQLScalarType; diff --git a/packages/apollo-server-core/src/nodeHttpToRequest.ts b/packages/apollo-server-core/src/nodeHttpToRequest.ts new file mode 100644 index 00000000000..621fc668703 --- /dev/null +++ b/packages/apollo-server-core/src/nodeHttpToRequest.ts @@ -0,0 +1,19 @@ +import { IncomingMessage } from 'http'; +import { Request, Headers } from 'apollo-server-env'; + +export function convertNodeHttpToRequest(req: IncomingMessage): Request { + const headers = new Headers(); + Object.keys(req.headers).forEach(key => { + const values = req.headers[key]!; + if (Array.isArray(values)) { + values.forEach(value => headers.append(key, value)); + } else { + headers.append(key, values); + } + }); + + return new Request(req.url!, { + headers, + method: req.method, + }); +} diff --git a/packages/apollo-server-core/src/playground.ts b/packages/apollo-server-core/src/playground.ts new file mode 100644 index 00000000000..8ae88b75151 --- /dev/null +++ b/packages/apollo-server-core/src/playground.ts @@ -0,0 +1,52 @@ +import { + RenderPageOptions as PlaygroundRenderPageOptions, + Theme, +} from '@apollographql/graphql-playground-html/dist/render-playground-page'; +export { + RenderPageOptions as PlaygroundRenderPageOptions, +} from '@apollographql/graphql-playground-html/dist/render-playground-page'; + +// This specifies the version of GraphQL Playground that will be served +// from graphql-playground-html, and is passed to renderPlaygroundPage +// by the integration subclasses +const playgroundVersion = '1.7.2'; + +export type PlaygroundConfig = Partial | boolean; + +export const defaultPlaygroundOptions = { + version: playgroundVersion, + settings: { + 'general.betaUpdates': false, + 'editor.theme': 'dark' as Theme, + 'editor.reuseHeaders': true, + 'tracing.hideTracingResponse': true, + 'editor.fontSize': 14, + 'editor.fontFamily': `'Source Code Pro', 'Consolas', 'Inconsolata', 'Droid Sans Mono', 'Monaco', monospace`, + 'request.credentials': 'omit', + }, +}; + +export function createPlaygroundOptions( + playground: PlaygroundConfig = {}, +): PlaygroundRenderPageOptions | undefined { + const isDev = process.env.NODE_ENV !== 'production'; + const enabled: boolean = typeof playground === 'boolean' ? playground : isDev; + + if (!enabled) { + return undefined; + } + + const playgroundOverrides = + typeof playground === 'boolean' ? {} : playground || {}; + + const playgroundOptions: PlaygroundRenderPageOptions = { + ...defaultPlaygroundOptions, + ...playgroundOverrides, + settings: { + ...defaultPlaygroundOptions.settings, + ...playgroundOverrides.settings, + }, + }; + + return playgroundOptions; +} diff --git a/packages/apollo-server-core/src/runHttpQuery.test.ts b/packages/apollo-server-core/src/runHttpQuery.test.ts index 0098a6228f6..0be7763b57d 100644 --- a/packages/apollo-server-core/src/runHttpQuery.test.ts +++ b/packages/apollo-server-core/src/runHttpQuery.test.ts @@ -1,14 +1,9 @@ /* tslint:disable:no-unused-expression */ import { expect } from 'chai'; -import { stub } from 'sinon'; import 'mocha'; +import MockReq = require('mock-req'); -import { - GraphQLSchema, - GraphQLObjectType, - GraphQLString, - GraphQLInt, -} from 'graphql'; +import { GraphQLSchema, GraphQLObjectType, GraphQLString } from 'graphql'; import { runHttpQuery, HttpQueryError } from './runHttpQuery'; @@ -38,13 +33,14 @@ describe('runHttpQuery', () => { options: { schema, }, + request: new MockReq(), }; it('raises a 400 error if the query is missing', () => { const noQueryRequest = Object.assign({}, mockQueryRequest, { query: 'foo', }); - return runHttpQuery([], noQueryRequest).catch(err => { + return runHttpQuery([], noQueryRequest).catch((err: HttpQueryError) => { expect(err.statusCode).to.equal(400); expect(err.message).to.equal('Must provide query string.'); }); diff --git a/packages/apollo-server-core/src/runHttpQuery.ts b/packages/apollo-server-core/src/runHttpQuery.ts index f6eb87c9d6e..bbd3f66b7fc 100644 --- a/packages/apollo-server-core/src/runHttpQuery.ts +++ b/packages/apollo-server-core/src/runHttpQuery.ts @@ -1,26 +1,58 @@ -import { - parse, - getOperationAST, - DocumentNode, - formatError, - ExecutionResult, -} from 'graphql'; -import { runQuery } from './runQuery'; +import { ExecutionResult } from 'graphql'; +const sha256 = require('hash.js/lib/hash/sha/256'); + +import { CacheControlExtensionOptions } from 'apollo-cache-control'; + +import { omit } from 'lodash'; + +import { Request } from 'apollo-server-env'; +import { runQuery, QueryOptions } from './runQuery'; import { default as GraphQLOptions, resolveGraphqlOptions, } from './graphqlOptions'; +import { + formatApolloErrors, + PersistedQueryNotSupportedError, + PersistedQueryNotFoundError, +} from 'apollo-server-errors'; +import { calculateCacheControlHeaders } from './caching'; export interface HttpQueryRequest { method: string; - query: Record; - options: GraphQLOptions | Function; + // query is either the POST body or the GET query string map. In the GET + // case, all values are strings and need to be parsed as JSON; in the POST + // case they should already be parsed. query has keys like 'query' (whose + // value should always be a string), 'variables', 'operationName', + // 'extensions', etc. + query: Record | Array>; + options: + | GraphQLOptions + | ((...args: Array) => Promise | GraphQLOptions); + request: Pick; +} + +// The result of a curl does not appear well in the terminal, so we add an extra new line +function prettyJSONStringify(value: any) { + return JSON.stringify(value) + '\n'; +} + +export interface ApolloServerHttpResponse { + headers?: Record; + // ResponseInit contains the follow, which we do not use + // status?: number; + // statusText?: string; +} + +export interface HttpQueryResponse { + graphqlResponse: string; + responseInit: ApolloServerHttpResponse; } export class HttpQueryError extends Error { public statusCode: number; public isGraphQLError: boolean; - public headers: { [key: string]: string }; + public headers?: { [key: string]: string }; constructor( statusCode: number, @@ -36,17 +68,45 @@ export class HttpQueryError extends Error { } } -function isQueryOperation(query: DocumentNode, operationName: string) { - const operationAST = getOperationAST(query, operationName); - return operationAST.operation === 'query'; +/** + * If optionsObject is specified, then the errors array will be formatted + */ +function throwHttpGraphQLError( + statusCode: number, + errors: Array, + optionsObject?: Partial, +): never { + throw new HttpQueryError( + statusCode, + prettyJSONStringify({ + errors: optionsObject + ? formatApolloErrors(errors, { + debug: optionsObject.debug, + formatter: optionsObject.formatError, + }) + : errors, + }), + true, + { + 'Content-Type': 'application/json', + }, + ); } export async function runHttpQuery( handlerArguments: Array, request: HttpQueryRequest, -): Promise { +): Promise { let isGetRequest: boolean = false; let optionsObject: GraphQLOptions; + const debugDefault = + process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'test'; + let cacheControl: + | CacheControlExtensionOptions & { + calculateHttpHeaders: boolean; + stripFormattedExtensions: boolean; + } + | undefined; try { optionsObject = await resolveGraphqlOptions( @@ -54,9 +114,19 @@ export async function runHttpQuery( ...handlerArguments, ); } catch (e) { - throw new HttpQueryError(500, e.message); + // The options can be generated asynchronously, so we don't have access to + // the normal options provided by the user, such as: formatError, + // debug. Therefore, we need to do some unnatural things, such + // as use NODE_ENV to determine the debug settings + e.message = `Invalid options provided to ApolloServer: ${e.message}`; + if (!debugDefault) { + e.warning = `To remove the stacktrace, set the NODE_ENV environment variable to production if the options creation can fail`; + } + return throwHttpGraphQLError(500, [e], { debug: debugDefault }); + } + if (optionsObject.debug === undefined) { + optionsObject.debug = debugDefault; } - const formatErrorFn = optionsObject.formatError || formatError; let requestPayload; switch (request.method) { @@ -98,10 +168,12 @@ export async function runHttpQuery( requestPayload = [requestPayload]; } - const requests: Array = requestPayload.map(requestParams => { + const requests = requestPayload.map(async requestParams => { try { - let query = requestParams.query; + let queryString: string | undefined = requestParams.query; let extensions = requestParams.extensions; + let persistedQueryHit = false; + let persistedQueryRegister = false; if (isGetRequest && extensions) { // For GET requests, we have to JSON-parse extensions. (For POST @@ -114,54 +186,112 @@ export async function runHttpQuery( } } - if (query === undefined && extensions && extensions.persistedQuery) { - // It looks like we've received an Apollo Persisted Query. Apollo Server - // does not support persisted queries out of the box, so we should fail - // fast with a clear error saying that we don't support APQs. (A future - // version of Apollo Server may support APQs directly.) - throw new HttpQueryError( + if (extensions && extensions.persistedQuery) { + // It looks like we've received an Apollo Persisted Query. Check if we + // support them. In an ideal world, we always would, however since the + // middleware options are created every request, it does not make sense + // to create a default cache here and save a referrence to use across + // requests + if ( + !optionsObject.persistedQueries || + !optionsObject.persistedQueries.cache + ) { + if (isBatch) { + // A batch can contain another query that returns data, + // so we don't error out the entire request with an HttpError + throw new PersistedQueryNotSupportedError(); + } // Return 200 to simplify processing: we want this to be intepreted by // the client as data worth interpreting, not an error. - 200, - JSON.stringify({ - errors: [ - { - message: 'PersistedQueryNotSupported', - }, - ], - }), - true, - { - 'Content-Type': 'application/json', - }, - ); - } + return throwHttpGraphQLError( + 200, + [new PersistedQueryNotSupportedError()], + optionsObject, + ); + } else if (extensions.persistedQuery.version !== 1) { + throw new HttpQueryError(400, 'Unsupported persisted query version'); + } - if (isGetRequest) { - if (typeof query === 'string') { - // preparse the query incase of GET so we can assert the operation. - // XXX This makes the type of 'query' in this function confused - // which has led to us accidentally supporting GraphQL AST over - // the wire as a valid query, which confuses users. Refactor to - // not do this. Also, for a GET request, query really shouldn't - // ever be anything other than a string or undefined, so this - // set of conditionals doesn't quite make sense. - query = parse(query); - } else if (!query) { - // Note that we've already thrown a different error if it looks like APQ. - throw new HttpQueryError(400, 'Must provide query string.'); + const sha = extensions.persistedQuery.sha256Hash; + + if (queryString === undefined) { + queryString = + (await optionsObject.persistedQueries.cache.get(`apq:${sha}`)) || + undefined; + if (queryString) { + persistedQueryHit = true; + } else { + if (isBatch) { + // A batch can contain multiple undefined persisted queries, + // so we don't error out the entire request with an HttpError + throw new PersistedQueryNotFoundError(); + } + return throwHttpGraphQLError( + 200, + [new PersistedQueryNotFoundError()], + optionsObject, + ); + } + } else { + const calculatedSha = sha256() + .update(queryString) + .digest('hex'); + if (sha !== calculatedSha) { + throw new HttpQueryError(400, 'provided sha does not match query'); + } + persistedQueryRegister = true; + + // Do the store completely asynchronously + (async () => { + // We do not wait on the cache storage to complete + return ( + optionsObject.persistedQueries && + optionsObject.persistedQueries.cache.set( + `apq:${sha}`, + queryString, + ) + ); + })().catch(error => { + console.warn(error); + }); } + } + + if (!queryString) { + throw new HttpQueryError(400, 'Must provide query string.'); + } - if (!isQueryOperation(query, requestParams.operationName)) { + if (typeof queryString !== 'string') { + // Check for a common error first. + if (queryString && (queryString as any).kind === 'Document') { throw new HttpQueryError( - 405, - `GET supports only query operation`, - false, - { - Allow: 'POST', - }, + 400, + "GraphQL queries must be strings. It looks like you're sending the " + + 'internal graphql-js representation of a parsed query in your ' + + 'request instead of a request in the GraphQL query language. You ' + + 'can convert an AST to a string using the `print` function from ' + + '`graphql`, or use a client like `apollo-client` which converts ' + + 'the internal representation to a string for you.', ); } + throw new HttpQueryError(400, 'GraphQL queries must be strings.'); + } + + // GET operations should only be queries (not mutations). We want to throw + // a particular HTTP error in that case, but we don't actually parse the + // query until we're in runQuery, so we declare the error we want to throw + // here and pass it into runQuery. + // TODO this could/should be added as a validation rule rather than an ad hoc error + let nonQueryError; + if (isGetRequest) { + nonQueryError = new HttpQueryError( + 405, + `GET supports only query operation`, + false, + { + Allow: 'POST', + }, + ); } const operationName = requestParams.operationName; @@ -178,59 +308,187 @@ export async function runHttpQuery( } } - let context = optionsObject.context || {}; - if (typeof context === 'function') { - context = context(); - } else if (isBatch) { + let context = optionsObject.context; + if (!context) { + context = {} as Record; + } else if (typeof context === 'function') { + try { + context = await context(); + } catch (e) { + e.message = `Context creation failed: ${e.message}`; + // For errors that are not internal, such as authentication, we + // should provide a 400 response + if ( + e.extensions && + e.extensions.code && + e.extensions.code !== 'INTERNAL_SERVER_ERROR' + ) { + return throwHttpGraphQLError(400, [e], optionsObject); + } else { + return throwHttpGraphQLError(500, [e], optionsObject); + } + } + } else { + // Always clone the context if it's not a function, because that preserves + // having a fresh context per request. context = Object.assign( Object.create(Object.getPrototypeOf(context)), context, - ); + ) as Record; + } + + if (optionsObject.dataSources) { + const dataSources = optionsObject.dataSources() || {}; + + for (const dataSource of Object.values(dataSources)) { + dataSource.initialize(context, optionsObject.cache!); + } + + if ('dataSources' in context) { + throw new Error( + 'Please use the dataSources config option instead of putting dataSources on the context yourself.', + ); + } + + (context as any).dataSources = dataSources; } - let params = { + if (optionsObject.cacheControl !== false) { + if ( + typeof optionsObject.cacheControl === 'boolean' && + optionsObject.cacheControl === true + ) { + // cacheControl: true means that the user needs the cache-control + // extensions. This means we are running the proxy, so we should not + // strip out the cache control extension and not add cache-control headers + cacheControl = { + stripFormattedExtensions: false, + calculateHttpHeaders: false, + defaultMaxAge: 0, + }; + } else { + // Default behavior is to run default header calculation and return + // no cacheControl extensions + cacheControl = { + stripFormattedExtensions: true, + calculateHttpHeaders: true, + defaultMaxAge: 0, + ...optionsObject.cacheControl, + }; + } + } + + let params: QueryOptions = { schema: optionsObject.schema, - query: query, + queryString, + nonQueryError, variables: variables, context, rootValue: optionsObject.rootValue, operationName: operationName, - logFunction: optionsObject.logFunction, validationRules: optionsObject.validationRules, - formatError: formatErrorFn, + formatError: optionsObject.formatError, formatResponse: optionsObject.formatResponse, fieldResolver: optionsObject.fieldResolver, debug: optionsObject.debug, tracing: optionsObject.tracing, - cacheControl: optionsObject.cacheControl, + cacheControl: cacheControl + ? omit(cacheControl, [ + 'calculateHttpHeaders', + 'stripFormattedExtensions', + ]) + : false, + request: request.request, + extensions: optionsObject.extensions, + persistedQueryHit, + persistedQueryRegister, }; - if (optionsObject.formatParams) { - params = optionsObject.formatParams(params); - } - return runQuery(params); } catch (e) { // Populate any HttpQueryError to our handler which should // convert it to Http Error. if (e.name === 'HttpQueryError') { - return Promise.reject(e); + // async function wraps this in a Promise + throw e; } - return Promise.resolve({ errors: [formatErrorFn(e)] }); + // This error will be uncaught, so we need to wrap it and treat it as an + // internal server error + return { + errors: formatApolloErrors([e], optionsObject), + }; } - }); - const responses = await Promise.all(requests); + }) as Array }>>; - if (!isBatch) { - const gqlResponse = responses[0]; - if (gqlResponse.errors && typeof gqlResponse.data === 'undefined') { - throw new HttpQueryError(400, JSON.stringify(gqlResponse), true, { - 'Content-Type': 'application/json', + let responses; + try { + responses = await Promise.all(requests); + } catch (e) { + if (e.name === 'HttpQueryError') { + throw e; + } + return throwHttpGraphQLError(500, [e], optionsObject); + } + + const responseInit: ApolloServerHttpResponse = { + headers: { + 'Content-Type': 'application/json', + }, + }; + + if (cacheControl) { + if (cacheControl.calculateHttpHeaders) { + const calculatedHeaders = calculateCacheControlHeaders(responses); + + responseInit.headers = { + ...responseInit.headers, + ...calculatedHeaders, + }; + } + + if (cacheControl.stripFormattedExtensions) { + responses.forEach(response => { + if (response.extensions) { + delete response.extensions.cacheControl; + if (Object.keys(response.extensions).length === 0) { + delete response.extensions; + } + } }); } - return JSON.stringify(gqlResponse); } - return JSON.stringify(responses); + if (!isBatch) { + const graphqlResponse = responses[0]; + // This code is run on parse/validation errors and any other error that + // doesn't reach GraphQL execution + if (graphqlResponse.errors && typeof graphqlResponse.data === 'undefined') { + // don't include optionsObject, since the errors have already been formatted + return throwHttpGraphQLError(400, graphqlResponse.errors as any); + } + const stringified = prettyJSONStringify(graphqlResponse); + + responseInit.headers!['Content-Length'] = Buffer.byteLength( + stringified, + 'utf8', + ).toString(); + + return { + graphqlResponse: stringified, + responseInit, + }; + } + + const stringified = prettyJSONStringify(responses); + + responseInit.headers!['Content-Length'] = Buffer.byteLength( + stringified, + 'utf8', + ).toString(); + + return { + graphqlResponse: stringified, + responseInit, + }; } diff --git a/packages/apollo-server-core/src/runQuery.test.ts b/packages/apollo-server-core/src/runQuery.test.ts index a6f0e426e31..8b3073ef89e 100644 --- a/packages/apollo-server-core/src/runQuery.test.ts +++ b/packages/apollo-server-core/src/runQuery.test.ts @@ -1,6 +1,7 @@ /* tslint:disable:no-unused-expression */ import { expect } from 'chai'; import { stub } from 'sinon'; +import MockReq = require('mock-req'); import 'mocha'; import { @@ -12,12 +13,13 @@ import { parse, } from 'graphql'; -import { runQuery, LogAction, LogStep } from './runQuery'; +import { runQuery } from './runQuery'; // Make the global Promise constructor Fiber-aware to simulate a Meteor // environment. import { makeCompatible } from 'meteor-promise'; import Fiber = require('fibers'); +import { GraphQLExtensionStack, GraphQLExtension } from 'graphql-extensions'; makeCompatible(Promise, Fiber); const queryType = new GraphQLObjectType({ @@ -52,13 +54,13 @@ const queryType = new GraphQLObjectType({ }, testContextValue: { type: GraphQLString, - resolve(root, args, context) { - return context + ' works'; + resolve(_root, _args, context) { + return context.s + ' works'; }, }, testArgumentValue: { type: GraphQLInt, - resolve(root, args, context) { + resolve(_root, args) { return args['base'] + 5; }, args: { @@ -67,7 +69,7 @@ const queryType = new GraphQLObjectType({ }, testAwaitedValue: { type: GraphQLString, - resolve(root) { + resolve() { // Calling Promise.await is legal here even though this is // not an async function, because we are guaranteed to be // running in a Fiber. @@ -91,7 +93,11 @@ describe('runQuery', () => { it('returns the right result when query is a string', () => { const query = `{ testString }`; const expected = { testString: 'it works' }; - return runQuery({ schema, query: query }).then(res => { + return runQuery({ + schema, + queryString: query, + request: new MockReq(), + }).then(res => { expect(res.data).to.deep.equal(expected); }); }); @@ -99,7 +105,11 @@ describe('runQuery', () => { it('returns the right result when query is a document', () => { const query = parse(`{ testString }`); const expected = { testString: 'it works' }; - return runQuery({ schema, query: query }).then(res => { + return runQuery({ + schema, + parsedQuery: query, + request: new MockReq(), + }).then(res => { expect(res.data).to.deep.equal(expected); }); }); @@ -109,38 +119,39 @@ describe('runQuery', () => { const expected = /Syntax Error/; return runQuery({ schema, - query: query, + queryString: query, variables: { base: 1 }, + request: new MockReq(), }).then(res => { expect(res.data).to.be.undefined; - expect(res.errors.length).to.equal(1); - expect(res.errors[0].message).to.match(expected); + expect(res.errors!.length).to.equal(1); + expect(res.errors![0].message).to.match(expected); }); }); - it('sends stack trace to error if in an error occurs and debug mode is set', () => { + it('does not call console.error if in an error occurs and debug mode is set', () => { const query = `query { testError }`; - const expected = /at resolveFieldValueOrError/; const logStub = stub(console, 'error'); return runQuery({ schema, - query: query, + queryString: query, debug: true, - }).then(res => { + request: new MockReq(), + }).then(() => { logStub.restore(); - expect(logStub.callCount).to.equal(1); - expect(logStub.getCall(0).args[0]).to.match(expected); + expect(logStub.callCount).to.equal(0); }); }); - it('does not send stack trace if in an error occurs and not in debug mode', () => { + it('does not call console.error if in an error occurs and not in debug mode', () => { const query = `query { testError }`; const logStub = stub(console, 'error'); return runQuery({ schema, - query: query, + queryString: query, debug: false, - }).then(res => { + request: new MockReq(), + }).then(() => { logStub.restore(); expect(logStub.callCount).to.equal(0); }); @@ -152,29 +163,38 @@ describe('runQuery', () => { 'Variable "$base" of type "String" used in position expecting type "Int!".'; return runQuery({ schema, - query: query, + queryString: query, variables: { base: 1 }, + request: new MockReq(), }).then(res => { expect(res.data).to.be.undefined; - expect(res.errors.length).to.equal(1); - expect(res.errors[0].message).to.deep.equal(expected); + expect(res.errors!.length).to.equal(1); + expect(res.errors![0].message).to.deep.equal(expected); }); }); it('correctly passes in the rootValue', () => { const query = `{ testRootValue }`; const expected = { testRootValue: 'it also works' }; - return runQuery({ schema, query: query, rootValue: 'it also' }).then( - res => { - expect(res.data).to.deep.equal(expected); - }, - ); + return runQuery({ + schema, + queryString: query, + rootValue: 'it also', + request: new MockReq(), + }).then(res => { + expect(res.data).to.deep.equal(expected); + }); }); it('correctly passes in the context', () => { const query = `{ testContextValue }`; const expected = { testContextValue: 'it still works' }; - return runQuery({ schema, query: query, context: 'it still' }).then(res => { + return runQuery({ + schema, + queryString: query, + context: { s: 'it still' }, + request: new MockReq(), + }).then(res => { expect(res.data).to.deep.equal(expected); }); }); @@ -184,12 +204,13 @@ describe('runQuery', () => { const expected = { testContextValue: 'it still works' }; return runQuery({ schema, - query: query, - context: 'it still', - formatResponse: (response, { context }) => { - response['extensions'] = context; + queryString: query, + context: { s: 'it still' }, + formatResponse: (response: any, { context }: { context: any }) => { + response['extensions'] = context.s; return response; }, + request: new MockReq(), }).then(res => { expect(res.data).to.deep.equal(expected); expect(res['extensions']).to.equal('it still'); @@ -201,8 +222,9 @@ describe('runQuery', () => { const expected = { testArgumentValue: 6 }; return runQuery({ schema, - query: query, + queryString: query, variables: { base: 1 }, + request: new MockReq(), }).then(res => { expect(res.data).to.deep.equal(expected); }); @@ -214,16 +236,18 @@ describe('runQuery', () => { 'Variable "$base" of required type "Int!" was not provided.'; return runQuery({ schema, - query: query, + queryString: query, + request: new MockReq(), }).then(res => { - expect(res.errors[0].message).to.deep.equal(expected); + expect(res.errors![0].message).to.deep.equal(expected); }); }); it('supports yielding resolver functions', () => { return runQuery({ schema, - query: `{ testAwaitedValue }`, + queryString: `{ testAwaitedValue }`, + request: new MockReq(), }).then(res => { expect(res.data).to.deep.equal({ testAwaitedValue: 'it works', @@ -242,56 +266,13 @@ describe('runQuery', () => { const expected = { testString: 'it works', }; - return runQuery({ schema, query: query, operationName: 'Q1' }).then(res => { - expect(res.data).to.deep.equal(expected); - }); - }); - - it('calls logFunction', () => { - const query = ` - query Q1 { - testString - }`; - const logs = []; - const logFn = obj => logs.push(obj); - const expected = { - testString: 'it works', - }; return runQuery({ schema, - query: query, + queryString: query, operationName: 'Q1', - variables: { test: 123 }, - logFunction: logFn, + request: new MockReq(), }).then(res => { expect(res.data).to.deep.equal(expected); - expect(logs.length).to.equals(11); - expect(logs[0]).to.deep.equals({ - action: LogAction.request, - step: LogStep.start, - }); - expect(logs[1]).to.deep.equals({ - action: LogAction.request, - step: LogStep.status, - key: 'query', - data: query, - }); - expect(logs[2]).to.deep.equals({ - action: LogAction.request, - step: LogStep.status, - key: 'variables', - data: { test: 123 }, - }); - expect(logs[3]).to.deep.equals({ - action: LogAction.request, - step: LogStep.status, - key: 'operationName', - data: 'Q1', - }); - expect(logs[10]).to.deep.equals({ - action: LogAction.request, - step: LogStep.end, - }); }); }); @@ -306,8 +287,9 @@ describe('runQuery', () => { const result1 = await runQuery({ schema, - query: query, + queryString: query, operationName: 'Q1', + request: new MockReq(), }); expect(result1.data).to.deep.equal({ @@ -318,9 +300,10 @@ describe('runQuery', () => { const result2 = await runQuery({ schema, - query: query, + queryString: query, operationName: 'Q1', fieldResolver: () => 'a very testful field resolver string', + request: new MockReq(), }); expect(result2.data).to.deep.equal({ @@ -330,9 +313,62 @@ describe('runQuery', () => { }); }); + describe('graphql extensions', () => { + class CustomExtension implements GraphQLExtension { + format(): [string, any] { + return ['customExtension', { foo: 'bar' }]; + } + } + + it('creates the extension stack', async () => { + const queryString = `{ testString }`; + const extensions = [() => new CustomExtension()]; + return runQuery({ + schema: new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'QueryType', + fields: { + testString: { + type: GraphQLString, + resolve(_root, _args, context) { + expect(context._extensionStack).to.be.instanceof( + GraphQLExtensionStack, + ); + expect( + context._extensionStack.extensions[0], + ).to.be.instanceof(CustomExtension); + }, + }, + }, + }), + }), + queryString, + extensions, + request: new MockReq(), + }); + }); + + it('runs format response from extensions', async () => { + const queryString = `{ testString }`; + const expected = { testString: 'it works' }; + const extensions = [() => new CustomExtension()]; + return runQuery({ + schema, + queryString, + extensions, + request: new MockReq(), + }).then(res => { + expect(res.data).to.deep.equal(expected); + expect(res.extensions).to.deep.equal({ + customExtension: { foo: 'bar' }, + }); + }); + }); + }); + describe('async_hooks', () => { - let asyncHooks; - let asyncHook; + let asyncHooks: typeof import('async_hooks'); + let asyncHook: import('async_hooks').AsyncHook; const ids: number[] = []; try { @@ -342,7 +378,9 @@ describe('runQuery', () => { } before(() => { - asyncHook = asyncHooks.createHook({ init: asyncId => ids.push(asyncId) }); + asyncHook = asyncHooks.createHook({ + init: (asyncId: number) => ids.push(asyncId), + }); asyncHook.enable(); }); @@ -361,8 +399,9 @@ describe('runQuery', () => { await runQuery({ schema, - query: query, + queryString: query, operationName: 'Q1', + request: new MockReq(), }); // this is the only async process so we expect the async ids to be a sequence diff --git a/packages/apollo-server-core/src/runQuery.ts b/packages/apollo-server-core/src/runQuery.ts index fadb6240856..12c33fbbaa1 100644 --- a/packages/apollo-server-core/src/runQuery.ts +++ b/packages/apollo-server-core/src/runQuery.ts @@ -4,15 +4,17 @@ import { ExecutionResult, DocumentNode, parse, - print, validate, execute, + ExecutionArgs, + getOperationAST, GraphQLError, - formatError, specifiedRules, ValidationContext, } from 'graphql'; +import { Request } from 'apollo-server-env'; + import { enableGraphQLExtensions, GraphQLExtension, @@ -24,44 +26,34 @@ import { CacheControlExtensionOptions, } from 'apollo-cache-control'; +import { + fromGraphQLError, + formatApolloErrors, + ValidationError, + SyntaxError, +} from 'apollo-server-errors'; + export interface GraphQLResponse { data?: object; errors?: Array; extensions?: object; } -export enum LogAction { - request, - parse, - validation, - execute, -} - -export enum LogStep { - start, - end, - status, -} - -export interface LogMessage { - action: LogAction; - step: LogStep; - key?: string; - data?: Object; -} - -export interface LogFunction { - (message: LogMessage); -} - export interface QueryOptions { schema: GraphQLSchema; - query: string | DocumentNode; + // Specify exactly one of these. parsedQuery is primarily for use by + // OperationStore. + queryString?: string; + parsedQuery?: DocumentNode; + + // If this is specified and the given GraphQL query is not a "query" (eg, it's + // a mutation), throw this error. + nonQueryError?: Error; + rootValue?: any; context?: any; variables?: { [key: string]: any }; operationName?: string; - logFunction?: LogFunction; validationRules?: Array<(context: ValidationContext) => any>; fieldResolver?: GraphQLFieldResolver; // WARNING: these extra validation rules are only applied to queries @@ -72,7 +64,15 @@ export interface QueryOptions { debug?: boolean; tracing?: boolean; cacheControl?: boolean | CacheControlExtensionOptions; - skipValidation?: boolean; + request: Pick; + extensions?: Array<() => GraphQLExtension>; + persistedQueryHit?: boolean; + persistedQueryRegister?: boolean; +} + +function isQueryOperation(query: DocumentNode, operationName?: string) { + const operationAST = getOperationAST(query, operationName); + return operationAST && operationAST.operation === 'query'; } export function runQuery(options: QueryOptions): Promise { @@ -80,161 +80,206 @@ export function runQuery(options: QueryOptions): Promise { return Promise.resolve().then(() => doRunQuery(options)); } -function printStackTrace(error: Error) { - console.error(error.stack); -} - -function format(errors: Array, formatter?: Function): Array { - return errors.map(error => { - if (formatter !== undefined) { - try { - return formatter(error); - } catch (err) { - console.error('Error in formatError function:', err); - const newError = new Error('Internal server error'); - return formatError(newError); - } - } else { - return formatError(error); - } - }) as Array; -} - function doRunQuery(options: QueryOptions): Promise { - let documentAST: DocumentNode; + if (options.queryString && options.parsedQuery) { + throw new Error('Only supply one of queryString and parsedQuery'); + } + if (!(options.queryString || options.parsedQuery)) { + throw new Error('Must supply one of queryString and parsedQuery'); + } - const logFunction = - options.logFunction || - function() { - return null; - }; const debugDefault = process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'test'; const debug = options.debug !== undefined ? options.debug : debugDefault; - logFunction({ action: LogAction.request, step: LogStep.start }); - const context = options.context || {}; - let extensions = []; + + // If custom extension factories were provided, create per-request extension + // objects. + const extensions = options.extensions ? options.extensions.map(f => f()) : []; + + // If you're running behind an engineproxy, set these options to turn on + // tracing and cache-control extensions. if (options.tracing) { - extensions.push(TracingExtension); + extensions.push(new TracingExtension()); } if (options.cacheControl === true) { - extensions.push(CacheControlExtension); + extensions.push(new CacheControlExtension()); } else if (options.cacheControl) { extensions.push(new CacheControlExtension(options.cacheControl)); } - const extensionStack = - extensions.length > 0 && new GraphQLExtensionStack(extensions); - if (extensionStack) { - context._extensionStack = extensionStack; - enableGraphQLExtensions(options.schema); + const extensionStack = new GraphQLExtensionStack(extensions); - extensionStack.requestDidStart(); + // We unconditionally create an extensionStack, even if there are no + // extensions (so that we don't have to litter the rest of this function with + // `if (extensionStack)`, but we don't instrument the schema unless there + // actually are extensions. We do unconditionally put the stack on the + // context, because if some other call had extensions and the schema is + // already instrumented, that's the only way to get a custom fieldResolver to + // work. + if (extensions.length > 0) { + enableGraphQLExtensions(options.schema); } + context._extensionStack = extensionStack; - const qry = - typeof options.query === 'string' ? options.query : print(options.query); - logFunction({ - action: LogAction.request, - step: LogStep.status, - key: 'query', - data: qry, + const requestDidEnd = extensionStack.requestDidStart({ + // Since the Request interfacess are not the same between node-fetch and + // typescript's lib dom, we should limit the fields that need to be passed + // into requestDidStart to only the ones we need, currently just the + // headers, method, and url + request: options.request as any, + queryString: options.queryString, + parsedQuery: options.parsedQuery, + operationName: options.operationName, + variables: options.variables, + persistedQueryHit: options.persistedQueryHit, + persistedQueryRegister: options.persistedQueryRegister, }); - logFunction({ - action: LogAction.request, - step: LogStep.status, - key: 'variables', - data: options.variables, - }); - logFunction({ - action: LogAction.request, - step: LogStep.status, - key: 'operationName', - data: options.operationName, - }); - - // if query is already an AST, don't parse or validate - // XXX: This refers the operations-store flow. - if (typeof options.query === 'string') { - try { - logFunction({ action: LogAction.parse, step: LogStep.start }); - documentAST = parse(options.query as string); - logFunction({ action: LogAction.parse, step: LogStep.end }); - } catch (syntaxError) { - logFunction({ action: LogAction.parse, step: LogStep.end }); - return Promise.resolve({ - errors: format([syntaxError], options.formatError), - }); - } - } else { - documentAST = options.query as DocumentNode; - } + return Promise.resolve() + .then( + (): Promise => { + // Parse the document. + let documentAST: DocumentNode; + if (options.parsedQuery) { + documentAST = options.parsedQuery; + } else if (!options.queryString) { + throw new Error('Must supply one of queryString and parsedQuery'); + } else { + const parsingDidEnd = extensionStack.parsingDidStart({ + queryString: options.queryString, + }); + let graphqlParseErrors: SyntaxError[] | undefined; + try { + documentAST = parse(options.queryString); + } catch (syntaxError) { + graphqlParseErrors = formatApolloErrors( + [ + fromGraphQLError(syntaxError, { + errorClass: SyntaxError, + }), + ], + { + debug, + }, + ); + return Promise.resolve({ errors: graphqlParseErrors }); + } finally { + parsingDidEnd(...(graphqlParseErrors || [])); + } + } - if (options.skipValidation !== true) { - let rules = specifiedRules; - if (options.validationRules) { - rules = rules.concat(options.validationRules); - } - logFunction({ action: LogAction.validation, step: LogStep.start }); - const validationErrors = validate(options.schema, documentAST, rules); - logFunction({ action: LogAction.validation, step: LogStep.end }); - if (validationErrors.length) { - return Promise.resolve({ - errors: format(validationErrors, options.formatError), - }); - } - } + if ( + options.nonQueryError && + !isQueryOperation(documentAST, options.operationName) + ) { + // XXX this goes to requestDidEnd, is that correct or should it be + // validation? + throw options.nonQueryError; + } - if (extensionStack) { - extensionStack.executionDidStart(); - } + let rules = specifiedRules; + if (options.validationRules) { + rules = rules.concat(options.validationRules); + } + const validationDidEnd = extensionStack.validationDidStart(); + let validationErrors: GraphQLError[] | undefined; + try { + validationErrors = validate( + options.schema, + documentAST, + rules, + ) as GraphQLError[]; // Return type of validate is ReadonlyArray + } catch (validationThrewError) { + // Catch errors thrown by validate, not just those returned by it. + validationErrors = [validationThrewError]; + } finally { + try { + if (validationErrors) { + validationErrors = formatApolloErrors( + validationErrors.map(err => + fromGraphQLError(err, { errorClass: ValidationError }), + ), + { + debug, + }, + ); + } + } finally { + validationDidEnd(...(validationErrors || [])); - try { - logFunction({ action: LogAction.execute, step: LogStep.start }); - return Promise.resolve( - execute( - options.schema, - documentAST, - options.rootValue, - context, - options.variables, - options.operationName, - options.fieldResolver, - ), - ).then(result => { - logFunction({ action: LogAction.execute, step: LogStep.end }); - logFunction({ action: LogAction.request, step: LogStep.end }); - - let response: GraphQLResponse = { - data: result.data, - }; - - if (result.errors) { - response.errors = format(result.errors, options.formatError); - if (debug) { - result.errors.map(printStackTrace); + if (validationErrors && validationErrors.length) { + return Promise.resolve({ + errors: validationErrors, + }); + } + } } - } - if (extensionStack) { - extensionStack.executionDidEnd(); - extensionStack.requestDidEnd(); - response.extensions = extensionStack.format(); - } + const executionArgs: ExecutionArgs = { + schema: options.schema, + document: documentAST, + rootValue: options.rootValue, + contextValue: context, + variableValues: options.variables, + operationName: options.operationName, + fieldResolver: options.fieldResolver, + }; + const executionDidEnd = extensionStack.executionDidStart({ + executionArgs, + }); + return Promise.resolve() + .then(() => execute(executionArgs)) + .catch(executionError => { + return { + // These errors will get passed through formatApolloErrors in the + // `then` below. + // TODO accurate code for this error, which describes this error, which + // can occur when: + // * variables incorrectly typed/null when nonnullable + // * unknown operation/operation name invalid + // * operation type is unsupported + // Options: PREPROCESSING_FAILED, GRAPHQL_RUNTIME_CHECK_FAILED - if (options.formatResponse) { - response = options.formatResponse(response, options); - } + errors: [fromGraphQLError(executionError)], + } as ExecutionResult; + }) + .then(result => { + let response: GraphQLResponse = { + data: result.data, + }; - return response; - }); - } catch (executionError) { - logFunction({ action: LogAction.execute, step: LogStep.end }); - logFunction({ action: LogAction.request, step: LogStep.end }); - return Promise.resolve({ - errors: format([executionError], options.formatError), + if (result.errors) { + response.errors = formatApolloErrors([...result.errors], { + debug, + }); + } + + executionDidEnd(...(result.errors || [])); + + const formattedExtensions = extensionStack.format(); + if (Object.keys(formattedExtensions).length > 0) { + response.extensions = formattedExtensions; + } + + if (options.formatResponse) { + response = options.formatResponse(response, options); + } + + return response; + }); + }, + ) + .catch((err: Error) => { + // Handle the case of an internal server failure (or nonQueryError) --- + // we're not returning a GraphQL response so we don't call + // willSendResponse. + requestDidEnd(err); + throw err; + }) + .then((graphqlResponse: GraphQLResponse) => { + const response = extensionStack.willSendResponse({ graphqlResponse }); + requestDidEnd(); + return response.graphqlResponse; }); - } } diff --git a/packages/apollo-server-core/src/types.ts b/packages/apollo-server-core/src/types.ts new file mode 100644 index 00000000000..0525c5cf815 --- /dev/null +++ b/packages/apollo-server-core/src/types.ts @@ -0,0 +1,75 @@ +import { GraphQLSchema, DocumentNode } from 'graphql'; +import { SchemaDirectiveVisitor, IResolvers, IMocks } from 'graphql-tools'; +import { ConnectionContext } from 'subscriptions-transport-ws'; +import * as WebSocket from 'ws'; +import { GraphQLExtension } from 'graphql-extensions'; +export { GraphQLExtension } from 'graphql-extensions'; + +import { EngineReportingOptions } from 'apollo-engine-reporting'; + +import { PlaygroundConfig } from './playground'; +export { PlaygroundConfig, PlaygroundRenderPageOptions } from './playground'; + +import { + GraphQLServerOptions as GraphQLOptions, + PersistedQueryOptions, +} from './graphqlOptions'; + +export { KeyValueCache } from 'apollo-server-caching'; + +export type Context = T; +export type ContextFunction = ( + context: Context, +) => Promise>; + +export interface SubscriptionServerOptions { + path: string; + keepAlive?: number; + onConnect?: ( + connectionParams: Object, + websocket: WebSocket, + context: ConnectionContext, + ) => any; + onDisconnect?: (websocket: WebSocket, context: ConnectionContext) => any; +} + +// This configuration is shared between all integrations and should include +// fields that are not specific to a single integration +export interface Config + extends Pick< + GraphQLOptions>, + | 'formatError' + | 'debug' + | 'rootValue' + | 'validationRules' + | 'formatResponse' + | 'fieldResolver' + | 'cacheControl' + | 'tracing' + | 'dataSources' + | 'cache' + > { + typeDefs?: DocumentNode | Array; + resolvers?: IResolvers; + schema?: GraphQLSchema; + schemaDirectives?: Record; + context?: Context | ContextFunction; + introspection?: boolean; + mocks?: boolean | IMocks; + engine?: boolean | EngineReportingOptions; + extensions?: Array<() => GraphQLExtension>; + persistedQueries?: PersistedQueryOptions | false; + subscriptions?: Partial | string | false; + //https://github.com/jaydenseric/apollo-upload-server#options + uploads?: boolean | FileUploadOptions; + playground?: PlaygroundConfig; +} + +export interface FileUploadOptions { + //Max allowed non-file multipart form field size in bytes; enough for your queries (default: 1 MB). + maxFieldSize?: number; + //Max allowed file size in bytes (default: Infinity). + maxFileSize?: number; + //Max allowed number of files (default: Infinity). + maxFiles?: number; +} diff --git a/packages/apollo-server-core/src/types/apollo-upload-server.d.ts b/packages/apollo-server-core/src/types/apollo-upload-server.d.ts new file mode 100644 index 00000000000..5276e91c8e3 --- /dev/null +++ b/packages/apollo-server-core/src/types/apollo-upload-server.d.ts @@ -0,0 +1,5 @@ +declare module 'apollo-upload-server' { + import { GraphQLScalarType } from 'graphql'; + + export const GraphQLUpload: GraphQLScalarType; +} diff --git a/packages/apollo-server-core/src/types/mock-req.d.ts b/packages/apollo-server-core/src/types/mock-req.d.ts new file mode 100644 index 00000000000..6f0150ec94c --- /dev/null +++ b/packages/apollo-server-core/src/types/mock-req.d.ts @@ -0,0 +1,12 @@ +declare module 'mock-req' { + import { Request, Headers } from 'apollo-server-env'; + + class MockReq implements Pick { + constructor(); + method: string; + url: string; + headers: Headers; + } + + export = MockReq; +} diff --git a/packages/apollo-server-core/tsconfig.json b/packages/apollo-server-core/tsconfig.json index 564cda66232..a5c2d2b3be2 100644 --- a/packages/apollo-server-core/tsconfig.json +++ b/packages/apollo-server-core/tsconfig.json @@ -1,8 +1,14 @@ { - "extends": "../../tsconfig", + "extends": "../../tsconfig.json", "compilerOptions": { "rootDir": "./src", - "outDir": "./dist" + "outDir": "./dist", + "strict": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUnusedParameters": true, + "noUnusedLocals": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] diff --git a/packages/apollo-server-azure-functions/.npmignore b/packages/apollo-server-env/.npmignore old mode 100755 new mode 100644 similarity index 84% rename from packages/apollo-server-azure-functions/.npmignore rename to packages/apollo-server-env/.npmignore index 0753ad1a379..a165046d359 --- a/packages/apollo-server-azure-functions/.npmignore +++ b/packages/apollo-server-env/.npmignore @@ -1,5 +1,5 @@ * -!dist +!src/**/* !dist/**/* dist/**/*.test.* !package.json diff --git a/packages/apollo-server-env/package.json b/packages/apollo-server-env/package.json new file mode 100644 index 00000000000..1a90548efa3 --- /dev/null +++ b/packages/apollo-server-env/package.json @@ -0,0 +1,28 @@ +{ + "name": "apollo-server-env", + "version": "2.0.0-rc.7", + "author": "opensource@apollographql.com", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/apollographql/apollo-server/tree/master/packages/apollo-server-env" + }, + "homepage": "https://github.com/apollographql/apollo-server#readme", + "bugs": { + "url": "https://github.com/apollographql/apollo-server/issues" + }, + "scripts": { + "clean": "rm -rf dist", + "compile": "tsc && cp src/*.d.ts dist", + "prepublish": "npm run clean && npm run compile" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "engines": { + "node": ">=6" + }, + "dependencies": { + "node-fetch": "^2.1.2", + "util.promisify": "^1.0.0" + } +} diff --git a/packages/apollo-server-env/src/fetch.d.ts b/packages/apollo-server-env/src/fetch.d.ts new file mode 100644 index 00000000000..d40edd9b521 --- /dev/null +++ b/packages/apollo-server-env/src/fetch.d.ts @@ -0,0 +1,101 @@ +export declare function fetch( + input?: RequestInfo, + init?: RequestInit, +): Promise; + +export type RequestInfo = Request | string; + +export declare class Headers implements Iterable<[string, string]> { + constructor(init?: HeadersInit); + + append(name: string, value: string): void; + delete(name: string): void; + get(name: string): string | null; + has(name: string): boolean; + set(name: string, value: string): void; + + entries(): Iterator<[string, string]>; + keys(): Iterator; + values(): Iterator<[string]>; + [Symbol.iterator](): Iterator<[string, string]>; +} + +export type HeadersInit = Headers | string[][] | { [name: string]: string }; + +export declare class Body { + readonly bodyUsed: boolean; + arrayBuffer(): Promise; + json(): Promise; + text(): Promise; +} + +export declare class Request extends Body { + constructor(input: Request | string, init?: RequestInit); + + readonly method: string; + readonly url: string; + readonly headers: Headers; + + clone(): Request; +} + +export interface RequestInit { + method?: string; + headers?: HeadersInit; + body?: BodyInit; + mode?: RequestMode; + credentials?: RequestCredentials; + cache?: RequestCache; + redirect?: RequestRedirect; + referrer?: string; + referrerPolicy?: ReferrerPolicy; + integrity?: string; +} + +export type RequestMode = 'navigate' | 'same-origin' | 'no-cors' | 'cors'; + +export type RequestCredentials = 'omit' | 'same-origin' | 'include'; + +export type RequestCache = + | 'default' + | 'no-store' + | 'reload' + | 'no-cache' + | 'force-cache' + | 'only-if-cached'; + +export type RequestRedirect = 'follow' | 'error' | 'manual'; + +export type ReferrerPolicy = + | '' + | 'no-referrer' + | 'no-referrer-when-downgrade' + | 'same-origin' + | 'origin' + | 'strict-origin' + | 'origin-when-cross-origin' + | 'strict-origin-when-cross-origin' + | 'unsafe-url'; + +export declare class Response extends Body { + constructor(body?: BodyInit, init?: ResponseInit); + static error(): Response; + static redirect(url: string, status?: number): Response; + + readonly url: string; + readonly redirected: boolean; + readonly status: number; + readonly ok: boolean; + readonly statusText: string; + readonly headers: Headers; + + clone(): Response; +} + +export interface ResponseInit { + headers?: HeadersInit; + status?: number; + statusText?: string; +} + +export type BodyInit = ArrayBuffer | ArrayBufferView | string; diff --git a/packages/apollo-server-env/src/index.d.ts b/packages/apollo-server-env/src/index.d.ts new file mode 100644 index 00000000000..e6239128c40 --- /dev/null +++ b/packages/apollo-server-env/src/index.d.ts @@ -0,0 +1,2 @@ +export * from './fetch'; +export * from './url'; diff --git a/packages/apollo-server-env/src/index.ts b/packages/apollo-server-env/src/index.ts new file mode 100644 index 00000000000..eba786b314b --- /dev/null +++ b/packages/apollo-server-env/src/index.ts @@ -0,0 +1,6 @@ +import './polyfills/Object.values'; + +require('util.promisify').shim(); + +export * from './polyfills/fetch'; +export * from './polyfills/url'; diff --git a/packages/apollo-server-env/src/polyfills/Object.values.ts b/packages/apollo-server-env/src/polyfills/Object.values.ts new file mode 100644 index 00000000000..6f3409b2e7e --- /dev/null +++ b/packages/apollo-server-env/src/polyfills/Object.values.ts @@ -0,0 +1,13 @@ +interface ObjectConstructor { + /** + * Returns an array of values of the enumerable properties of an object + * @param o Object that contains the properties and methods. This can be an object that you created or an existing Document Object Model (DOM) object. + */ + values(o: { [s: string]: T } | ArrayLike): T[]; +} + +if (!global.Object.values) { + global.Object.values = function(o) { + return Object.keys(o).map(key => o[key]); + }; +} diff --git a/packages/apollo-server-env/src/polyfills/fetch.js b/packages/apollo-server-env/src/polyfills/fetch.js new file mode 100644 index 00000000000..e0cd18826be --- /dev/null +++ b/packages/apollo-server-env/src/polyfills/fetch.js @@ -0,0 +1 @@ +export { default as fetch, Request, Response, Headers } from 'node-fetch'; diff --git a/packages/apollo-server-env/src/polyfills/url.js b/packages/apollo-server-env/src/polyfills/url.js new file mode 100644 index 00000000000..02061006e99 --- /dev/null +++ b/packages/apollo-server-env/src/polyfills/url.js @@ -0,0 +1 @@ +export { URL, URLSearchParams } from 'url'; diff --git a/packages/apollo-server-env/src/url.d.ts b/packages/apollo-server-env/src/url.d.ts new file mode 100644 index 00000000000..932a74a7bbc --- /dev/null +++ b/packages/apollo-server-env/src/url.d.ts @@ -0,0 +1,41 @@ +export declare class URL { + constructor(input: string, base?: string | URL); + hash: string; + host: string; + hostname: string; + href: string; + readonly origin: string; + password: string; + pathname: string; + port: string; + protocol: string; + search: string; + readonly searchParams: URLSearchParams; + username: string; + toString(): string; + toJSON(): string; +} + +export declare class URLSearchParams implements Iterable<[string, string]> { + constructor(init?: URLSearchParamsInit); + append(name: string, value: string): void; + delete(name: string): void; + entries(): IterableIterator<[string, string]>; + forEach(callback: (value: string, name: string) => void): void; + get(name: string): string | null; + getAll(name: string): string[]; + has(name: string): boolean; + keys(): IterableIterator; + set(name: string, value: string): void; + sort(): void; + toString(): string; + values(): IterableIterator; + [Symbol.iterator](): IterableIterator<[string, string]>; +} + +export type URLSearchParamsInit = + | URLSearchParams + | string + | { [key: string]: Object | Object[] | undefined } + | Iterable<[string, Object]> + | Array<[string, Object]>; diff --git a/packages/apollo-server-env/tsconfig.json b/packages/apollo-server-env/tsconfig.json new file mode 100644 index 00000000000..a7cc54c645b --- /dev/null +++ b/packages/apollo-server-env/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "allowJs": true, + "declaration": false, + "declarationMap": false, + "removeComments": true, + "strict": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUnusedParameters": true, + "noUnusedLocals": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "**/__tests__/*", "**/__mocks__/*"], + "types": [] +} diff --git a/packages/apollo-server-errors/.npmignore b/packages/apollo-server-errors/.npmignore new file mode 100644 index 00000000000..a165046d359 --- /dev/null +++ b/packages/apollo-server-errors/.npmignore @@ -0,0 +1,6 @@ +* +!src/**/* +!dist/**/* +dist/**/*.test.* +!package.json +!README.md diff --git a/packages/apollo-server-errors/package.json b/packages/apollo-server-errors/package.json new file mode 100644 index 00000000000..4d23bcf95f6 --- /dev/null +++ b/packages/apollo-server-errors/package.json @@ -0,0 +1,49 @@ +{ + "name": "apollo-server-errors", + "version": "2.0.0-rc.7", + "author": "opensource@apollographql.com", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/apollographql/apollo-server/tree/master/packages/apollo-server-errors" + }, + "homepage": "https://github.com/apollographql/apollo-server#readme", + "bugs": { + "url": "https://github.com/apollographql/apollo-server/issues" + }, + "scripts": { + "clean": "rm -rf dist", + "compile": "tsc", + "prepublish": "npm run clean && npm run compile" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "engines": { + "node": ">=6" + }, + "devDependencies": { + "@types/jest": "^23.1.2", + "jest": "^23.2.0", + "ts-jest": "^22.4.6" + }, + "peerDependencies": { + "graphql": "^0.12.0 || ^0.13.0 || ^14.0.0" + }, + "jest": { + "testEnvironment": "node", + "transform": { + "^.+\\.(ts|js)$": "ts-jest" + }, + "moduleFileExtensions": [ + "ts", + "js", + "json" + ], + "testRegex": "src/__tests__/.*$", + "globals": { + "ts-jest": { + "skipBabel": true + } + } + } +} diff --git a/packages/apollo-server-errors/src/index.ts b/packages/apollo-server-errors/src/index.ts new file mode 100644 index 00000000000..17c7dd64404 --- /dev/null +++ b/packages/apollo-server-errors/src/index.ts @@ -0,0 +1,266 @@ +import { GraphQLError } from 'graphql'; + +export class ApolloError extends Error implements GraphQLError { + public extensions: Record; + readonly name; + readonly locations; + readonly path; + readonly source; + readonly positions; + readonly nodes; + public originalError; + + [key: string]: any; + + constructor( + message: string, + code?: string, + properties?: Record, + ) { + super(message); + + if (properties) { + Object.keys(properties).forEach(key => { + this[key] = properties[key]; + }); + } + + // if no name provided, use the default. defineProperty ensures that it stays non-enumerable + if (!this.name) { + Object.defineProperty(this, 'name', { value: 'ApolloError' }); + } + + // extensions are flattened to be included in the root of GraphQLError's, so + // don't add properties to extensions + this.extensions = { code }; + } +} + +function enrichError(error: Partial, debug: boolean = false) { + // follows similar structure to https://github.com/graphql/graphql-js/blob/master/src/error/GraphQLError.js#L145-L193 + // with the addition of name + const expanded = Object.create(Object.getPrototypeOf(error), { + name: { + value: error.name, + }, + message: { + value: error.message, + enumerable: true, + writable: true, + }, + locations: { + value: error.locations || undefined, + enumerable: true, + }, + path: { + value: error.path || undefined, + enumerable: true, + }, + nodes: { + value: error.nodes || undefined, + }, + source: { + value: error.source || undefined, + }, + positions: { + value: error.positions || undefined, + }, + originalError: { + value: error.originalError, + }, + }); + + expanded.extensions = { + ...error.extensions, + code: + (error.extensions && error.extensions.code) || 'INTERNAL_SERVER_ERROR', + exception: { + ...(error.extensions && error.extensions.exception), + ...(error.originalError as any), + }, + }; + + // ensure that extensions is not taken from the originalError + // graphql-js ensures that the originalError's extensions are hoisted + // https://github.com/graphql/graphql-js/blob/0bb47b2/src/error/GraphQLError.js#L138 + delete expanded.extensions.exception.extensions; + if (debug && !expanded.extensions.exception.stacktrace) { + expanded.extensions.exception.stacktrace = + (error.originalError && + error.originalError.stack && + error.originalError.stack.split('\n')) || + (error.stack && error.stack.split('\n')); + } + + if (Object.keys(expanded.extensions.exception).length === 0) { + // remove from printing an empty object + delete expanded.extensions.exception; + } + + return expanded as ApolloError; +} + +export function toApolloError( + error: Error & { extensions?: Record }, + code: string = 'INTERNAL_SERVER_ERROR', +): Error & { extensions: Record } { + let err = error; + if (err.extensions) { + err.extensions.code = code; + } else { + err.extensions = { code }; + } + return err as Error & { extensions: Record }; +} + +export interface ErrorOptions { + code?: string; + errorClass?: typeof ApolloError; +} + +export function fromGraphQLError(error: GraphQLError, options?: ErrorOptions) { + const copy: ApolloError = + options && options.errorClass + ? new options.errorClass(error.message) + : new ApolloError(error.message); + + // copy enumerable keys + Object.keys(error).forEach(key => { + copy[key] = error[key]; + }); + + // extensions are non enumerable, so copy them directly + copy.extensions = { + ...copy.extensions, + ...error.extensions, + }; + + // Fallback on default for code + if (!copy.extensions.code) { + copy.extensions.code = (options && options.code) || 'INTERNAL_SERVER_ERROR'; + } + + // copy the original error, while keeping all values non-enumerable, so they + // are not printed unless directly referenced + Object.defineProperty(copy, 'originalError', { value: {} }); + Object.getOwnPropertyNames(error).forEach(key => { + Object.defineProperty(copy.originalError, key, { value: error[key] }); + }); + + return copy; +} + +export class SyntaxError extends ApolloError { + constructor(message: string) { + super(message, 'GRAPHQL_PARSE_FAILED'); + + Object.defineProperty(this, 'name', { value: 'SyntaxError' }); + } +} + +export class ValidationError extends ApolloError { + constructor(message: string) { + super(message, 'GRAPHQL_VALIDATION_FAILED'); + + Object.defineProperty(this, 'name', { value: 'ValidationError' }); + } +} + +export class AuthenticationError extends ApolloError { + constructor(message: string) { + super(message, 'UNAUTHENTICATED'); + + Object.defineProperty(this, 'name', { value: 'AuthenticationError' }); + } +} + +export class ForbiddenError extends ApolloError { + constructor(message: string) { + super(message, 'FORBIDDEN'); + + Object.defineProperty(this, 'name', { value: 'ForbiddenError' }); + } +} + +export class PersistedQueryNotFoundError extends ApolloError { + constructor() { + super('PersistedQueryNotFound', 'PERSISTED_QUERY_NOT_FOUND'); + + Object.defineProperty(this, 'name', { + value: 'PersistedQueryNotFoundError', + }); + } +} + +export class PersistedQueryNotSupportedError extends ApolloError { + constructor() { + super('PersistedQueryNotSupported', 'PERSISTED_QUERY_NOT_SUPPORTED'); + + Object.defineProperty(this, 'name', { + value: 'PersistedQueryNotSupportedError', + }); + } +} + +export class UserInputError extends ApolloError { + constructor(message: string, properties?: Record) { + super(message, 'BAD_USER_INPUT', properties); + + Object.defineProperty(this, 'name', { value: 'UserInputError' }); + } +} + +export function formatApolloErrors( + errors: Array, + options?: { + formatter?: Function; + debug?: boolean; + }, +): Array { + if (!options) { + return errors.map(error => enrichError(error)); + } + const { formatter, debug } = options; + + // Errors that occur in graphql-tools can contain an errors array that contains the errors thrown in a merged schema + // https://github.com/apollographql/graphql-tools/blob/3d53986ca/src/stitching/errors.ts#L104-L107 + // + // They are are wrapped in an extra GraphQL error + // https://github.com/apollographql/graphql-tools/blob/3d53986ca/src/stitching/errors.ts#L109-L113 + // which calls: + // https://github.com/graphql/graphql-js/blob/0a30b62964/src/error/locatedError.js#L18-L37 + // Some processing for these nested errors could be done here: + // + // if (Array.isArray((error as any).errors)) { + // (error as any).errors.forEach(e => flattenedErrors.push(e)); + // } else if ( + // (error as any).originalError && + // Array.isArray((error as any).originalError.errors) + // ) { + // (error as any).originalError.errors.forEach(e => flattenedErrors.push(e)); + // } else { + // flattenedErrors.push(error); + // } + + const enrichedErrors = errors.map(error => enrichError(error, debug)); + + if (!formatter) { + return enrichedErrors; + } + + return enrichedErrors.map(error => { + try { + return formatter(error); + } catch (err) { + if (debug) { + return enrichError(err, debug); + } else { + // obscure error + const newError = fromGraphQLError( + new GraphQLError('Internal server error'), + ); + return enrichError(newError, debug); + } + } + }) as Array; +} diff --git a/packages/apollo-server-errors/tsconfig.json b/packages/apollo-server-errors/tsconfig.json new file mode 100644 index 00000000000..bbb66fb641f --- /dev/null +++ b/packages/apollo-server-errors/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "removeComments": true, + "strict": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUnusedParameters": true, + "noUnusedLocals": true, + "types": ["node", "jest"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "**/__tests__/*", "**/__mocks__/*"] +} diff --git a/packages/apollo-server-express/README.md b/packages/apollo-server-express/README.md index 2a918d27dc5..63b79e94d3d 100644 --- a/packages/apollo-server-express/README.md +++ b/packages/apollo-server-express/README.md @@ -3,51 +3,80 @@ title: Express / Connect description: Setting up Apollo Server with Express.js or Connect --- -[![npm version](https://badge.fury.io/js/apollo-server-core.svg)](https://badge.fury.io/js/apollo-server-core) [![Get on Slack](https://img.shields.io/badge/slack-join-orange.svg)](https://www.apollographql.com/#slack) +[![npm version](https://badge.fury.io/js/apollo-server-express.svg)](https://badge.fury.io/js/apollo-server-express) [![Build Status](https://circleci.com/gh/apollographql/apollo-server.svg?style=svg)](https://circleci.com/gh/apollographql/apollo-server) [![Coverage Status](https://coveralls.io/repos/github/apollographql/apollo-server/badge.svg?branch=master)](https://coveralls.io/github/apollographql/apollo-server?branch=master) [![Get on Slack](https://img.shields.io/badge/slack-join-orange.svg)](https://www.apollographql.com/#slack) -This is the Express and Connect integration of GraphQL Server. Apollo Server is a community-maintained open-source GraphQL server that works with all Node.js HTTP server frameworks: Express, Connect, Hapi, Koa and Restify. [Read the docs](https://www.apollographql.com/docs/apollo-server/). [Read the CHANGELOG.](https://github.com/apollographql/apollo-server/blob/master/CHANGELOG.md) +This is the Express and Connect integration of GraphQL Server. Apollo Server is a community-maintained open-source GraphQL server that works with many Node.js HTTP server frameworks. [Read the docs](https://www.apollographql.com/docs/apollo-server/). [Read the CHANGELOG.](https://github.com/apollographql/apollo-server/blob/master/CHANGELOG.md) ```sh -npm install apollo-server-express +npm install apollo-server-express@rc ``` ## Express ```js -import express from 'express'; -import bodyParser from 'body-parser'; -import { graphqlExpress } from 'apollo-server-express'; +const express = require('express'); +const { ApolloServer, gql } = require('apollo-server-express'); -const myGraphQLSchema = // ... define or import your schema here! -const PORT = 3000; +// Construct a schema, using GraphQL schema language +const typeDefs = gql` + type Query { + hello: String + } +`; -const app = express(); +// Provide resolver functions for your schema fields +const resolvers = { + Query: { + hello: () => 'Hello world!', + }, +}; + +const server = new ApolloServer({ typeDefs, resolvers }); -// bodyParser is needed just for POST. -app.use('/graphql', bodyParser.json(), graphqlExpress({ schema: myGraphQLSchema })); +const app = express(); +server.applyMiddleware({ app }); -app.listen(PORT); +app.listen({ port: 4000 }, () => + console.log(`🚀 Server ready at http://localhost:4000${server.graphqlPath}`) +); ``` ## Connect ```js -import connect from 'connect'; -import bodyParser from 'body-parser'; -import { graphqlConnect } from 'apollo-server-express'; -import http from 'http'; - -const PORT = 3000; +const connect = require('connect'); +const { ApolloServer, gql } = require('apollo-server-express'); +const query = require('qs-middleware'); + +// Construct a schema, using GraphQL schema language +const typeDefs = gql` + type Query { + hello: String + } +`; + +// Provide resolver functions for your schema fields +const resolvers = { + Query: { + hello: () => 'Hello world!', + }, +}; + +const server = new ApolloServer({ typeDefs, resolvers }); const app = connect(); +const path = '/graphql'; -// bodyParser is needed just for POST. -app.use('/graphql', bodyParser.json()); -app.use('/graphql', graphqlConnect({ schema: myGraphQLSchema })); +server.use(query()); +server.applyMiddleware({ app, path }); -http.createServer(app).listen(PORT); +app.listen({ port: 4000 }, () => + console.log(`🚀 Server ready at http://localhost:4000${server.graphqlPath}`) +); ``` +> Note; `qs-middleware` is only required if running outside of Meteor + ## Principles GraphQL Server is built with the following principles in mind: diff --git a/packages/apollo-server-express/package.json b/packages/apollo-server-express/package.json index 7b9c3213544..bd3ecc3930a 100644 --- a/packages/apollo-server-express/package.json +++ b/packages/apollo-server-express/package.json @@ -1,6 +1,6 @@ { "name": "apollo-server-express", - "version": "1.4.0", + "version": "2.0.0-rc.7", "description": "Production-ready Node.js GraphQL server for Express and Connect", "main": "dist/index.js", "scripts": { @@ -19,28 +19,44 @@ "Connect", "Javascript" ], - "author": "Jonas Helfer ", + "author": "opensource@apollographql.com", "license": "MIT", "bugs": { "url": "https://github.com/apollographql/apollo-server/issues" }, "homepage": "https://github.com/apollographql/apollo-server#readme", + "engines": { + "node": ">=6" + }, "dependencies": { - "apollo-server-core": "^1.4.0", - "apollo-server-module-graphiql": "^1.4.0" + "@apollographql/graphql-playground-html": "^1.6.0", + "@types/accepts": "^1.3.5", + "@types/body-parser": "1.17.0", + "@types/cors": "^2.8.4", + "@types/express": "4.16.0", + "accepts": "^1.3.5", + "apollo-server-core": "^2.0.0-rc.7", + "apollo-upload-server": "^5.0.0", + "body-parser": "^1.18.3", + "cors": "^2.8.4", + "graphql-subscriptions": "^0.5.8", + "graphql-tools": "^3.0.4", + "type-is": "^1.6.16" }, "devDependencies": { - "@types/body-parser": "1.17.0", "@types/connect": "3.4.32", - "@types/express": "4.16.0", - "@types/graphql": "0.12.7", - "@types/multer": "1.3.7", - "apollo-server-integration-testsuite": "^1.4.0", - "body-parser": "1.18.3", + "@types/multer": "1.3.6", + "apollo-datasource-rest": "^2.0.0-rc.7", + "apollo-server-integration-testsuite": "^2.0.0-rc.7", "connect": "3.6.6", - "connect-query": "1.0.0", - "express": "4.16.3", - "multer": "1.3.1" + "express": "^4.16.3", + "form-data": "^2.3.2", + "multer": "1.3.0", + "node-fetch": "^2.1.2", + "qs-middleware": "^1.0.3" + }, + "peerDependencies": { + "graphql": "^0.12.0 || ^0.13.0 || ^14.0.0" }, "typings": "dist/index.d.ts", "typescript": { diff --git a/packages/apollo-server-express/src/ApolloServer.test.ts b/packages/apollo-server-express/src/ApolloServer.test.ts new file mode 100644 index 00000000000..a4fca2b1d27 --- /dev/null +++ b/packages/apollo-server-express/src/ApolloServer.test.ts @@ -0,0 +1,847 @@ +import { expect } from 'chai'; +import 'mocha'; +import * as express from 'express'; + +import * as http from 'http'; + +import * as request from 'request'; +import * as FormData from 'form-data'; +import * as fs from 'fs'; +import { createApolloFetch } from 'apollo-fetch'; + +import { gql, AuthenticationError, Config } from 'apollo-server-core'; +import { ApolloServer, ServerRegistration } from './ApolloServer'; + +import { + testApolloServer, + createServerInfo, +} from 'apollo-server-integration-testsuite'; + +const typeDefs = gql` + type Query { + hello: String + } +`; + +const resolvers = { + Query: { + hello: () => 'hi', + }, +}; + +describe('apollo-server-express', () => { + let server; + let httpServer; + testApolloServer( + async options => { + server = new ApolloServer(options); + const app = express(); + server.applyMiddleware({ app }); + httpServer = await new Promise(resolve => { + const s = app.listen({ port: 4000 }, () => resolve(s)); + }); + return createServerInfo(server, httpServer); + }, + async () => { + if (server) await server.stop(); + if (httpServer && httpServer.listening) await httpServer.close(); + }, + ); +}); + +describe('apollo-server-express', () => { + let server: ApolloServer; + + let app: express.Application; + let httpServer: http.Server; + + async function createServer( + serverOptions: Config, + options: Partial = {}, + ) { + server = new ApolloServer(serverOptions); + app = express(); + + server.applyMiddleware({ ...options, app }); + + httpServer = await new Promise(resolve => { + const l = app.listen({ port: 4000 }, () => resolve(l)); + }); + + return createServerInfo(server, httpServer); + } + + afterEach(async () => { + if (server) await server.stop(); + if (httpServer) await httpServer.close(); + }); + + describe('constructor', () => { + it('accepts typeDefs and resolvers', () => { + return createServer({ typeDefs, resolvers }); + }); + }); + + describe('applyMiddleware', () => { + it('can be queried', async () => { + const { url: uri } = await createServer({ + typeDefs, + resolvers, + }); + const apolloFetch = createApolloFetch({ uri }); + const result = await apolloFetch({ query: '{hello}' }); + + expect(result.data).to.deep.equal({ hello: 'hi' }); + expect(result.errors, 'errors should exist').not.to.exist; + }); + + // XXX Unclear why this would be something somebody would want (vs enabling + // introspection without graphql-playground, which seems reasonable, eg you + // have your own graphql-playground setup with a custom link) + it('can enable playground separately from introspection during production', async () => { + const INTROSPECTION_QUERY = ` + { + __schema { + directives { + name + } + } + } +`; + + const { url: uri } = await createServer({ + typeDefs, + resolvers, + introspection: false, + }); + + const apolloFetch = createApolloFetch({ uri }); + const result = await apolloFetch({ query: INTROSPECTION_QUERY }); + + expect(result.errors.length).to.equal(1); + expect(result.errors[0].extensions.code).to.equal( + 'GRAPHQL_VALIDATION_FAILED', + ); + + return new Promise((resolve, reject) => { + request( + { + url: uri, + method: 'GET', + headers: { + accept: + 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8', + }, + }, + (error, response, body) => { + if (error) { + reject(error); + } else { + expect(body).to.contain('GraphQLPlayground'); + expect(response.statusCode).to.equal(200); + resolve(); + } + }, + ); + }); + }); + + it('renders GraphQL playground by default when browser requests', async () => { + const nodeEnv = process.env.NODE_ENV; + delete process.env.NODE_ENV; + + const { url } = await createServer({ + typeDefs, + resolvers, + }); + + return new Promise((resolve, reject) => { + request( + { + url, + method: 'GET', + headers: { + accept: + 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8', + }, + }, + (error, response, body) => { + process.env.NODE_ENV = nodeEnv; + if (error) { + reject(error); + } else { + expect(body).to.contain('GraphQLPlayground'); + expect(response.statusCode).to.equal(200); + resolve(); + } + }, + ); + }); + }); + + it('accepts partial GraphQL Playground Options', async () => { + const nodeEnv = process.env.NODE_ENV; + delete process.env.NODE_ENV; + + const defaultQuery = 'query { foo { bar } }'; + const endpoint = '/fumanchupacabra'; + const { url } = await createServer( + { + typeDefs, + resolvers, + playground: { + // https://github.com/apollographql/graphql-playground/blob/0e452d2005fcd26f10fbdcc4eed3b2e2af935e3a/packages/graphql-playground-html/src/render-playground-page.ts#L16-L24 + // must be made partial + settings: { + 'editor.theme': 'light', + } as any, + tabs: [ + { + query: defaultQuery, + }, + { + endpoint, + } as any, + ], + }, + }, + {}, + ); + + return new Promise((resolve, reject) => { + request( + { + url, + method: 'GET', + headers: { + accept: + 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8', + Folo: 'bar', + }, + }, + (error, response, body) => { + process.env.NODE_ENV = nodeEnv; + if (error) { + reject(error); + } else { + console.log('body', body); + expect(body).to.contain('GraphQLPlayground'); + expect(body).to.contain(`"editor.theme": "light"`); + expect(body).to.contain(defaultQuery); + expect(body).to.contain(endpoint); + expect(response.statusCode).to.equal(200); + resolve(); + } + }, + ); + }); + }); + + it('accepts playground options as a boolean', async () => { + const nodeEnv = process.env.NODE_ENV; + delete process.env.NODE_ENV; + + const { url } = await createServer( + { + typeDefs, + resolvers, + playground: false, + }, + {}, + ); + + return new Promise((resolve, reject) => { + request( + { + url, + method: 'GET', + headers: { + accept: + 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8', + }, + }, + (error, response, body) => { + process.env.NODE_ENV = nodeEnv; + if (error) { + reject(error); + } else { + expect(body).not.to.contain('GraphQLPlayground'); + expect(response.statusCode).not.to.equal(200); + resolve(); + } + }, + ); + }); + }); + + it('accepts cors configuration', async () => { + const { url: uri } = await createServer( + { + typeDefs, + resolvers, + }, + { + cors: { origin: 'apollographql.com' }, + }, + ); + + const apolloFetch = createApolloFetch({ uri }).useAfter( + (response, next) => { + expect( + response.response.headers.get('access-control-allow-origin'), + ).to.equal('apollographql.com'); + next(); + }, + ); + await apolloFetch({ query: '{hello}' }); + }); + + it('accepts body parser configuration', async () => { + const { url: uri } = await createServer( + { + typeDefs, + resolvers, + }, + { + bodyParserConfig: { limit: 0 }, + }, + ); + + const apolloFetch = createApolloFetch({ uri }); + + return new Promise((resolve, reject) => { + apolloFetch({ query: '{hello}' }) + .then(reject) + .catch(error => { + expect(error.response).to.exist; + expect(error.response.status).to.equal(413); + expect(error.toString()).to.contain('Payload Too Large'); + resolve(); + }); + }); + }); + + describe('healthchecks', () => { + afterEach(async () => { + await server.stop(); + }); + + it('creates a healthcheck endpoint', async () => { + const { port } = await createServer({ + typeDefs, + resolvers, + }); + + return new Promise((resolve, reject) => { + request( + { + url: `http://localhost:${port}/.well-known/apollo/server-health`, + method: 'GET', + }, + (error, response, body) => { + if (error) { + reject(error); + } else { + expect(body).to.equal(JSON.stringify({ status: 'pass' })); + expect(response.statusCode).to.equal(200); + resolve(); + } + }, + ); + }); + }); + + it('provides a callback for the healthcheck', async () => { + const { port } = await createServer( + { + typeDefs, + resolvers, + }, + { + onHealthCheck: async () => { + throw Error("can't connect to DB"); + }, + }, + ); + + return new Promise((resolve, reject) => { + request( + { + url: `http://localhost:${port}/.well-known/apollo/server-health`, + method: 'GET', + }, + (error, response, body) => { + if (error) { + reject(error); + } else { + expect(body).to.equal(JSON.stringify({ status: 'fail' })); + expect(response.statusCode).to.equal(503); + resolve(); + } + }, + ); + }); + }); + + it('can disable the healthCheck', async () => { + const { port } = await createServer( + { + typeDefs, + resolvers, + }, + { + disableHealthCheck: true, + }, + ); + + return new Promise((resolve, reject) => { + request( + { + url: `http://localhost:${port}/.well-known/apollo/server-health`, + method: 'GET', + }, + (error, response) => { + if (error) { + reject(error); + } else { + expect(response.statusCode).to.equal(404); + resolve(); + } + }, + ); + }); + }); + }); + describe('file uploads', () => { + it('enabled uploads', async () => { + // XXX This is currently a failing test for node 10 + const NODE_VERSION = process.version.split('.'); + const NODE_MAJOR_VERSION = parseInt(NODE_VERSION[0].replace(/^v/, '')); + if (NODE_MAJOR_VERSION === 10) return; + + const { port } = await createServer({ + typeDefs: gql` + type File { + filename: String! + mimetype: String! + encoding: String! + } + + type Query { + uploads: [File] + } + + type Mutation { + singleUpload(file: Upload!): File! + } + `, + resolvers: { + Query: { + uploads: () => {}, + }, + Mutation: { + singleUpload: async (_, args) => { + expect((await args.file).stream).to.exist; + return args.file; + }, + }, + }, + }); + + const body = new FormData(); + + body.append( + 'operations', + JSON.stringify({ + query: ` + mutation($file: Upload!) { + singleUpload(file: $file) { + filename + encoding + mimetype + } + } + `, + variables: { + file: null, + }, + }), + ); + + body.append('map', JSON.stringify({ 1: ['variables.file'] })); + body.append('1', fs.createReadStream('package.json')); + + try { + const resolved = await fetch(`http://localhost:${port}/graphql`, { + method: 'POST', + body: body as any, + }); + const text = await resolved.text(); + const response = JSON.parse(text); + + expect(response.data.singleUpload).to.deep.equal({ + filename: 'package.json', + encoding: '7bit', + mimetype: 'application/json', + }); + } catch (error) { + // This error began appearing randomly and seems to be a dev dependency bug. + // https://github.com/jaydenseric/apollo-upload-server/blob/18ecdbc7a1f8b69ad51b4affbd986400033303d4/test.js#L39-L42 + if (error.code !== 'EPIPE') throw error; + } + }); + }); + + describe('errors', () => { + it('returns thrown context error as a valid graphql result', async () => { + const nodeEnv = process.env.NODE_ENV; + delete process.env.NODE_ENV; + const typeDefs = gql` + type Query { + hello: String + } + `; + const resolvers = { + Query: { + hello: () => { + throw Error('never get here'); + }, + }, + }; + const { url: uri } = await createServer({ + typeDefs, + resolvers, + context: () => { + throw new AuthenticationError('valid result'); + }, + }); + + const apolloFetch = createApolloFetch({ uri }); + + const result = await apolloFetch({ query: '{hello}' }); + expect(result.errors.length).to.equal(1); + expect(result.data).not.to.exist; + + const e = result.errors[0]; + expect(e.message).to.contain('valid result'); + expect(e.extensions).to.exist; + expect(e.extensions.code).to.equal('UNAUTHENTICATED'); + expect(e.extensions.exception.stacktrace).to.exist; + + process.env.NODE_ENV = nodeEnv; + }); + + it('propogates error codes in dev mode', async () => { + const nodeEnv = process.env.NODE_ENV; + delete process.env.NODE_ENV; + + const { url: uri } = await createServer({ + typeDefs: gql` + type Query { + error: String + } + `, + resolvers: { + Query: { + error: () => { + throw new AuthenticationError('we the best music'); + }, + }, + }, + }); + + const apolloFetch = createApolloFetch({ uri }); + + const result = await apolloFetch({ query: `{error}` }); + expect(result.data).to.exist; + expect(result.data).to.deep.equal({ error: null }); + + expect(result.errors, 'errors should exist').to.exist; + expect(result.errors.length).to.equal(1); + expect(result.errors[0].extensions.code).to.equal('UNAUTHENTICATED'); + expect(result.errors[0].extensions.exception).to.exist; + expect(result.errors[0].extensions.exception.stacktrace).to.exist; + + process.env.NODE_ENV = nodeEnv; + }); + + it('propogates error codes in production', async () => { + const nodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + + const { url: uri } = await createServer({ + typeDefs: gql` + type Query { + error: String + } + `, + resolvers: { + Query: { + error: () => { + throw new AuthenticationError('we the best music'); + }, + }, + }, + }); + + const apolloFetch = createApolloFetch({ uri }); + + const result = await apolloFetch({ query: `{error}` }); + expect(result.data).to.exist; + expect(result.data).to.deep.equal({ error: null }); + + expect(result.errors, 'errors should exist').to.exist; + expect(result.errors.length).to.equal(1); + expect(result.errors[0].extensions.code).to.equal('UNAUTHENTICATED'); + expect(result.errors[0].extensions.exception).not.to.exist; + + process.env.NODE_ENV = nodeEnv; + }); + + it('propogates error codes with null response in production', async () => { + const nodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + + const { url: uri } = await createServer({ + typeDefs: gql` + type Query { + error: String! + } + `, + resolvers: { + Query: { + error: () => { + throw new AuthenticationError('we the best music'); + }, + }, + }, + }); + + const apolloFetch = createApolloFetch({ uri }); + + const result = await apolloFetch({ query: `{error}` }); + expect(result.data).null; + + expect(result.errors, 'errors should exist').to.exist; + expect(result.errors.length).to.equal(1); + expect(result.errors[0].extensions.code).to.equal('UNAUTHENTICATED'); + expect(result.errors[0].extensions.exception).not.to.exist; + + process.env.NODE_ENV = nodeEnv; + }); + }); + }); + + describe('extensions', () => { + const books = [ + { + title: 'H', + author: 'J', + }, + ]; + + const typeDefs = gql` + type Book { + title: String + author: String + } + + type Cook @cacheControl(maxAge: 200) { + title: String + author: String + } + + type Pook @cacheControl(maxAge: 200) { + title: String + books: [Book] @cacheControl(maxAge: 20, scope: PRIVATE) + } + + type Query { + books: [Book] + cooks: [Cook] + pooks: [Pook] + } + `; + + const resolvers = { + Query: { + books: () => books, + cooks: () => books, + pooks: () => [{ title: 'pook', books }], + }, + }; + + describe('Cache Control Headers', () => { + it('applies cacheControl Headers and strips out extension', async () => { + const { url: uri } = await createServer({ typeDefs, resolvers }); + + const apolloFetch = createApolloFetch({ uri }).useAfter( + (response, next) => { + expect(response.response.headers.get('cache-control')).to.equal( + 'max-age=200, public', + ); + next(); + }, + ); + const result = await apolloFetch({ + query: `{ cooks { title author } }`, + }); + expect(result.data).to.deep.equal({ cooks: books }); + expect(result.extensions).not.to.exist; + }); + + it('contains no cacheControl Headers and keeps extension with engine proxy', async () => { + const { url: uri } = await createServer({ + typeDefs, + resolvers, + cacheControl: true, + }); + + const apolloFetch = createApolloFetch({ uri }).useAfter( + (response, next) => { + expect(response.response.headers.get('cache-control')).not.to.exist; + next(); + }, + ); + const result = await apolloFetch({ + query: `{ cooks { title author } }`, + }); + expect(result.data).to.deep.equal({ cooks: books }); + expect(result.extensions).to.exist; + expect(result.extensions.cacheControl).to.exist; + }); + + it('contains no cacheControl Headers when uncachable', async () => { + const { url: uri } = await createServer({ typeDefs, resolvers }); + + const apolloFetch = createApolloFetch({ uri }).useAfter( + (response, next) => { + expect(response.response.headers.get('cache-control')).not.to.exist; + next(); + }, + ); + const result = await apolloFetch({ + query: `{ books { title author } }`, + }); + expect(result.data).to.deep.equal({ books }); + expect(result.extensions).not.to.exist; + }); + + it('contains private cacheControl Headers when scoped', async () => { + const { url: uri } = await createServer({ typeDefs, resolvers }); + + const apolloFetch = createApolloFetch({ uri }).useAfter( + (response, next) => { + expect(response.response.headers.get('cache-control')).to.equal( + 'max-age=20, private', + ); + next(); + }, + ); + const result = await apolloFetch({ + query: `{ pooks { title books { title author } } }`, + }); + expect(result.data).to.deep.equal({ + pooks: [{ title: 'pook', books }], + }); + expect(result.extensions).not.to.exist; + }); + + it('runs when cache-control is false', async () => { + const { url: uri } = await createServer({ + typeDefs, + resolvers, + cacheControl: false, + }); + + const apolloFetch = createApolloFetch({ uri }).useAfter( + (response, next) => { + expect(response.response.headers.get('cache-control')).null; + next(); + }, + ); + const result = await apolloFetch({ + query: `{ pooks { title books { title author } } }`, + }); + expect(result.data).to.deep.equal({ + pooks: [{ title: 'pook', books }], + }); + expect(result.extensions).not.to.exist; + }); + }); + + describe('Tracing', () => { + const typeDefs = gql` + type Book { + title: String + author: String + } + + type Query { + books: [Book] + } + `; + + const resolvers = { + Query: { + books: () => books, + }, + }; + + it('applies tracing extension', async () => { + const { url: uri } = await createServer({ + typeDefs, + resolvers, + tracing: true, + }); + + const apolloFetch = createApolloFetch({ uri }); + const result = await apolloFetch({ + query: `{ books { title author } }`, + }); + expect(result.data).to.deep.equal({ books }); + expect(result.extensions).to.exist; + expect(result.extensions.tracing).to.exist; + }); + + it('applies tracing extension with cache control enabled', async () => { + const { url: uri } = await createServer({ + typeDefs, + resolvers, + tracing: true, + cacheControl: true, + }); + + const apolloFetch = createApolloFetch({ uri }); + const result = await apolloFetch({ + query: `{ books { title author } }`, + }); + expect(result.data).to.deep.equal({ books }); + expect(result.extensions).to.exist; + expect(result.extensions.tracing).to.exist; + }); + + xit('applies tracing extension with engine enabled', async () => { + const { url: uri } = await createServer({ + typeDefs, + resolvers, + tracing: true, + engine: { + apiKey: 'fake', + maxAttempts: 0, + endpointUrl: 'l', + reportErrorFunction: () => {}, + }, + }); + + const apolloFetch = createApolloFetch({ uri }); + const result = await apolloFetch({ + query: `{ books { title author } }`, + }); + expect(result.data).to.deep.equal({ books }); + expect(result.extensions).to.exist; + expect(result.extensions.tracing).to.exist; + }); + }); + }); +}); diff --git a/packages/apollo-server-express/src/ApolloServer.ts b/packages/apollo-server-express/src/ApolloServer.ts new file mode 100644 index 00000000000..888752d5ae5 --- /dev/null +++ b/packages/apollo-server-express/src/ApolloServer.ts @@ -0,0 +1,186 @@ +import * as express from 'express'; +import * as corsMiddleware from 'cors'; +import { json, OptionsJson } from 'body-parser'; +import { + renderPlaygroundPage, + RenderPageOptions as PlaygroundRenderPageOptions, +} from '@apollographql/graphql-playground-html'; +import { + GraphQLOptions, + FileUploadOptions, + ApolloServerBase, + formatApolloErrors, +} from 'apollo-server-core'; +import * as accepts from 'accepts'; +import * as typeis from 'type-is'; + +import { graphqlExpress } from './expressApollo'; + +import { processRequest as processFileUploads } from 'apollo-upload-server'; + +export { GraphQLOptions, GraphQLExtension } from 'apollo-server-core'; + +export interface ServerRegistration { + // Note: You can also pass a connect.Server here. If we changed this field to + // `express.Application | connect.Server`, it would be very hard to get the + // app.use calls to typecheck even though they do work properly. Our + // assumption is that very few people use connect with TypeScript (and in fact + // we suspect the only connect users left writing GraphQL apps are Meteor + // users). + app: express.Application; + path?: string; + cors?: corsMiddleware.CorsOptions | boolean; + bodyParserConfig?: OptionsJson | boolean; + onHealthCheck?: (req: express.Request) => Promise; + disableHealthCheck?: boolean; +} + +const fileUploadMiddleware = ( + uploadsConfig: FileUploadOptions, + server: ApolloServerBase, +) => ( + req: express.Request, + res: express.Response, + next: express.NextFunction, +) => { + // Note: we use typeis directly instead of via req.is for connect support. + if (typeis(req, ['multipart/form-data'])) { + processFileUploads(req, uploadsConfig) + .then(body => { + req.body = body; + next(); + }) + .catch(error => { + if (error.status && error.expose) res.status(error.status); + + next( + formatApolloErrors([error], { + formatter: server.requestOptions.formatError, + debug: server.requestOptions.debug, + }), + ); + }); + } else { + next(); + } +}; + +export class ApolloServer extends ApolloServerBase { + // This translates the arguments from the middleware into graphQL options It + // provides typings for the integration specific behavior, ideally this would + // be propagated with a generic to the super class + async createGraphQLServerOptions( + req: express.Request, + res: express.Response, + ): Promise { + return super.graphQLServerOptions({ req, res }); + } + + protected supportsSubscriptions(): boolean { + return true; + } + + protected supportsUploads(): boolean { + return true; + } + + public applyMiddleware({ + app, + path, + cors, + bodyParserConfig, + disableHealthCheck, + onHealthCheck, + }: ServerRegistration) { + if (!path) path = '/graphql'; + + if (!disableHealthCheck) { + // uses same path as engine proxy, but is generally useful. + app.use('/.well-known/apollo/server-health', (req, res) => { + // Response follows https://tools.ietf.org/html/draft-inadarei-api-health-check-01 + res.type('application/health+json'); + + if (onHealthCheck) { + onHealthCheck(req) + .then(() => { + res.json({ status: 'pass' }); + }) + .catch(() => { + res.status(503).json({ status: 'fail' }); + }); + } else { + res.json({ status: 'pass' }); + } + }); + } + + let uploadsMiddleware; + if (this.uploadsConfig) { + uploadsMiddleware = fileUploadMiddleware(this.uploadsConfig, this); + } + + // XXX multiple paths? + this.graphqlPath = path; + + // Note that we don't just pass all of these handlers to a single app.use call + // for 'connect' compatibility. + if (cors === true) { + app.use(path, corsMiddleware()); + } else if (cors !== false) { + app.use(path, corsMiddleware(cors)); + } + + if (bodyParserConfig === true) { + app.use(path, json()); + } else if (bodyParserConfig !== false) { + app.use(path, json(bodyParserConfig)); + } + + if (uploadsMiddleware) { + app.use(path, uploadsMiddleware); + } + + // Note: if you enable playground in production and expect to be able to see your + // schema, you'll need to manually specify `introspection: true` in the + // ApolloServer constructor; by default, the introspection query is only + // enabled in dev. + app.use(path, (req, res, next) => { + if (this.playgroundOptions && req.method === 'GET') { + // perform more expensive content-type check only if necessary + // XXX We could potentially move this logic into the GuiOptions lambda, + // but I don't think it needs any overriding + const accept = accepts(req); + const types = accept.types() as string[]; + const prefersHTML = + types.find( + (x: string) => x === 'text/html' || x === 'application/json', + ) === 'text/html'; + + if (prefersHTML) { + const playgroundRenderPageOptions: PlaygroundRenderPageOptions = { + endpoint: path, + subscriptionEndpoint: this.subscriptionsPath, + ...this.playgroundOptions, + }; + res.setHeader('Content-Type', 'text/html'); + const playground = renderPlaygroundPage(playgroundRenderPageOptions); + res.write(playground); + res.end(); + next(); + return; + } + } + return graphqlExpress(this.createGraphQLServerOptions.bind(this))( + req, + res, + next, + ); + }); + } +} + +export const registerServer = () => { + throw new Error( + 'Please use server.applyMiddleware instead of registerServer. This warning will be removed in the next release', + ); +}; diff --git a/packages/apollo-server-express/src/apolloServerHttp.test.ts b/packages/apollo-server-express/src/apolloServerHttp.test.ts deleted file mode 100644 index 1255a388249..00000000000 --- a/packages/apollo-server-express/src/apolloServerHttp.test.ts +++ /dev/null @@ -1,541 +0,0 @@ -// tslint:disable -// TODO: enable when you figure out how to automatically fix trailing commas - -// TODO: maybe we should get rid of these tests entirely, and move them to expressApollo.test.ts - -// TODO: wherever possible the tests should be rewritten to make them easily work with Hapi, express, Koa etc. - -/* - * Below are the HTTP tests from express-graphql. We're using them here to make - * sure apolloServer still works if used in the place of express-graphql. - */ - -import { graphqlExpress } from './expressApollo'; - -/** - * Copyright (c) 2015, Facebook, Inc. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. An additional grant - * of patent rights can be found in the PATENTS file in the same directory. - */ - -import { expect } from 'chai'; -import * as zlib from 'zlib'; -import * as multer from 'multer'; -import * as bodyParser from 'body-parser'; -const request = require('supertest'); -const express4 = require('express'); // modern -//import express3 from 'express3'; // old but commonly still used -const express3 = express4; -import { - GraphQLSchema, - GraphQLObjectType, - GraphQLNonNull, - GraphQLString, - GraphQLScalarType, - GraphQLError, - BREAK, -} from 'graphql'; - -const QueryRootType = new GraphQLObjectType({ - name: 'QueryRoot', - fields: { - test: { - type: GraphQLString, - args: { - who: { - type: GraphQLString, - }, - }, - resolve: (root, args) => 'Hello ' + (args['who'] || 'World'), - }, - thrower: { - type: new GraphQLNonNull(GraphQLString), - resolve: () => { - throw new Error('Throws!'); - }, - }, - custom: { - type: GraphQLString, - args: { - foo: { - type: new GraphQLScalarType({ - name: 'Foo', - serialize: v => v, - parseValue: () => { - throw new Error('Something bad happened'); - }, - parseLiteral: () => { - throw new Error('Something bad happened'); - }, - }), - }, - }, - }, - context: { - type: GraphQLString, - resolve: (obj, args, context) => context, - }, - }, -}); - -const TestSchema = new GraphQLSchema({ - query: QueryRootType, - mutation: new GraphQLObjectType({ - name: 'MutationRoot', - fields: { - writeTest: { - type: QueryRootType, - resolve: () => ({}), - }, - }, - }), -}); - -function catchError(p) { - return p.then( - res => { - // workaround for unknown issues with testing against npm package of express-graphql. - // the same code works when testing against the source, I'm not sure why. - if (res && res.error) { - return { response: res }; - } - throw new Error('Expected to catch error.'); - }, - error => { - if (!(error instanceof Error)) { - throw new Error('Expected error to be instanceof Error.'); - } - return error; - }, - ); -} - -function promiseTo(fn) { - return new Promise((resolve, reject) => { - fn((error, result) => (error ? reject(error) : resolve(result))); - }); -} - -describe('test harness', () => { - it('expects to catch errors', async () => { - let caught; - try { - await catchError(Promise.resolve()); - } catch (error) { - caught = error; - } - expect(caught && caught.message).to.equal('Expected to catch error.'); - }); - - it('expects to catch actual errors', async () => { - let caught; - try { - await catchError(Promise.reject('not a real error')); - } catch (error) { - caught = error; - } - expect(caught && caught.message).to.equal( - 'Expected error to be instanceof Error.', - ); - }); - - it('resolves callback promises', async () => { - const resolveValue = {}; - const result = await promiseTo(cb => cb(null, resolveValue)); - expect(result).to.equal(resolveValue); - }); - - it('rejects callback promises with errors', async () => { - const rejectError = new Error(); - let caught; - try { - await promiseTo(cb => cb(rejectError)); - } catch (error) { - caught = error; - } - expect(caught).to.equal(rejectError); - }); -}); - -const express = express4; -const version = 'modern'; -describe(`GraphQL-HTTP (apolloServer) tests for ${version} express`, () => { - describe('POST functionality', () => { - it('allows gzipped POST bodies', async () => { - const app = express(); - - app.use('/graphql', bodyParser.json()); - app.use( - '/graphql', - graphqlExpress(() => ({ - schema: TestSchema, - })), - ); - - const data = { query: '{ test(who: "World") }' }; - const json = JSON.stringify(data); - // TODO had to write "as any as Buffer" to make tsc accept it. Does it matter? - const gzippedJson = await promiseTo(cb => - zlib.gzip((json as any) as Buffer, cb), - ); - - const req = request(app) - .post('/graphql') - .set('Content-Type', 'application/json') - .set('Content-Encoding', 'gzip'); - req.write(gzippedJson); - const response = await req; - - expect(JSON.parse(response.text)).to.deep.equal({ - data: { - test: 'Hello World', - }, - }); - }); - - it('allows deflated POST bodies', async () => { - const app = express(); - - app.use('/graphql', bodyParser.json()); - app.use( - '/graphql', - graphqlExpress(() => ({ - schema: TestSchema, - })), - ); - - const data = { query: '{ test(who: "World") }' }; - const json = JSON.stringify(data); - // TODO had to write "as any as Buffer" to make tsc accept it. Does it matter? - const deflatedJson = await promiseTo(cb => - zlib.deflate((json as any) as Buffer, cb), - ); - - const req = request(app) - .post('/graphql') - .set('Content-Type', 'application/json') - .set('Content-Encoding', 'deflate'); - req.write(deflatedJson); - const response = await req; - - expect(JSON.parse(response.text)).to.deep.equal({ - data: { - test: 'Hello World', - }, - }); - }); - - it('allows for pre-parsed POST bodies', () => { - // Note: this is not the only way to handle file uploads with GraphQL, - // but it is terse and illustrative of using express-graphql and multer - // together. - - // A simple schema which includes a mutation. - const UploadedFileType = new GraphQLObjectType({ - name: 'UploadedFile', - fields: { - originalname: { type: GraphQLString }, - mimetype: { type: GraphQLString }, - }, - }); - - const TestMutationSchema = new GraphQLSchema({ - query: new GraphQLObjectType({ - name: 'QueryRoot', - fields: { - test: { type: GraphQLString }, - }, - }), - mutation: new GraphQLObjectType({ - name: 'MutationRoot', - fields: { - uploadFile: { - type: UploadedFileType, - resolve(rootValue) { - // For this test demo, we're just returning the uploaded - // file directly, but presumably you might return a Promise - // to go store the file somewhere first. - return rootValue.request.file; - }, - }, - }, - }), - }); - - const app = express(); - - // Multer provides multipart form data parsing. - const storage = multer.memoryStorage(); - app.use('/graphql', multer({ storage }).single('file')); - - // Providing the request as part of `rootValue` allows it to - // be accessible from within Schema resolve functions. - app.use( - '/graphql', - graphqlExpress(req => { - return { - schema: TestMutationSchema, - rootValue: { request: req }, - }; - }), - ); - - const req = request(app) - .post('/graphql') - .field( - 'query', - `mutation TestMutation { - uploadFile { originalname, mimetype } - }`, - ) - .attach('file', __filename); - - return req.then(response => { - expect(JSON.parse(response.text)).to.deep.equal({ - data: { - uploadFile: { - originalname: 'apolloServerHttp.test.js', - mimetype: 'application/javascript', - }, - }, - }); - }); - }); - }); - - describe('Error handling functionality', () => { - it('handles field errors caught by GraphQL', async () => { - const app = express(); - - app.use('/graphql', bodyParser.json()); - app.use( - '/graphql', - graphqlExpress({ - schema: TestSchema, - }), - ); - - const response = await request(app) - .post('/graphql') - .send({ - query: '{thrower}', - }); - - // console.log(response.text); - expect(response.status).to.equal(200); - expect(JSON.parse(response.text)).to.deep.equal({ - data: null, - errors: [ - { - message: 'Throws!', - locations: [{ line: 1, column: 2 }], - path: ['thrower'], - }, - ], - }); - }); - - it('handles type validation', async () => { - const app = express(); - - app.use('/graphql', bodyParser.json()); - app.use( - '/graphql', - graphqlExpress({ - schema: TestSchema, - }), - ); - - const response = await request(app) - .post('/graphql') - .send({ - query: '{notExists}', - }); - - expect(response.status).to.equal(400); - expect(JSON.parse(response.text)).to.deep.equal({ - errors: [ - { - message: 'Cannot query field "notExists" on type "QueryRoot".', - locations: [{ line: 1, column: 2 }], - }, - ], - }); - }); - - it('handles type validation (GET)', async () => { - const app = express(); - - app.use( - '/graphql', - require('connect-query')(), - graphqlExpress({ - schema: TestSchema, - }), - ); - - const response = await request(app) - .get('/graphql') - .query({ query: '{notExists}' }); - - expect(response.status).to.equal(400); - expect(JSON.parse(response.text)).to.deep.equal({ - errors: [ - { - message: 'Cannot query field "notExists" on type "QueryRoot".', - locations: [{ line: 1, column: 2 }], - }, - ], - }); - }); - - it('handles errors thrown during custom graphql type handling', async () => { - const app = express(); - - app.use('/graphql', bodyParser.json()); - app.use( - '/graphql', - graphqlExpress({ - schema: TestSchema, - }), - ); - - const response = await request(app) - .post('/graphql') - .send({ - query: '{custom(foo: 123)}', - }); - - expect(response.status).to.equal(400); - }); - - it('allows for custom error formatting to sanitize', async () => { - const app = express(); - - app.use('/graphql', bodyParser.json()); - app.use( - '/graphql', - graphqlExpress({ - schema: TestSchema, - formatError(error) { - return { message: 'Custom error format: ' + error.message }; - }, - }), - ); - - const response = await request(app) - .post('/graphql') - .send({ - query: '{thrower}', - }); - - expect(response.status).to.equal(200); - expect(JSON.parse(response.text)).to.deep.equal({ - data: null, - errors: [ - { - message: 'Custom error format: Throws!', - }, - ], - }); - }); - - it('allows for custom error formatting to elaborate', async () => { - const app = express(); - - app.use('/graphql', bodyParser.json()); - app.use( - '/graphql', - graphqlExpress({ - schema: TestSchema, - formatError(error) { - return { - message: error.message, - locations: error.locations, - stack: 'Stack trace', - }; - }, - }), - ); - - const response = await request(app) - .post('/graphql') - .send({ - query: '{thrower}', - }); - - expect(response.status).to.equal(200); - expect(JSON.parse(response.text)).to.deep.equal({ - data: null, - errors: [ - { - message: 'Throws!', - locations: [{ line: 1, column: 2 }], - stack: 'Stack trace', - }, - ], - }); - }); - - it('handles unsupported HTTP methods', async () => { - const app = express(); - - app.use('/graphql', bodyParser.json()); - app.use('/graphql', graphqlExpress({ schema: TestSchema })); - - const response = await request(app) - .put('/graphql') - .query({ query: '{test}' }); - - expect(response.status).to.equal(405); - expect(response.headers.allow).to.equal('GET, POST'); - expect(response.text).to.contain( - 'Apollo Server supports only GET/POST requests.', - ); - }); - }); - - describe('Custom validation rules', () => { - const AlwaysInvalidRule = function(context) { - return { - enter() { - context.reportError( - new GraphQLError('AlwaysInvalidRule was really invalid!'), - ); - return BREAK; - }, - }; - }; - - it('Do not execute a query if it do not pass the custom validation.', async () => { - const app = express(); - - app.use('/graphql', bodyParser.json()); - app.use( - '/graphql', - graphqlExpress({ - schema: TestSchema, - validationRules: [AlwaysInvalidRule], - }), - ); - - const response = await request(app) - .post('/graphql') - .send({ - query: '{thrower}', - }); - - expect(response.status).to.equal(400); - expect(JSON.parse(response.text)).to.deep.equal({ - errors: [ - { - message: 'AlwaysInvalidRule was really invalid!', - }, - ], - }); - }); - }); -}); diff --git a/packages/apollo-server-express/src/connectApollo.test.ts b/packages/apollo-server-express/src/connectApollo.test.ts index 2419a4d0717..c7ba42beae1 100644 --- a/packages/apollo-server-express/src/connectApollo.test.ts +++ b/packages/apollo-server-express/src/connectApollo.test.ts @@ -1,6 +1,7 @@ import * as connect from 'connect'; -import * as bodyParser from 'body-parser'; -import { graphqlConnect, graphiqlConnect } from './connectApollo'; +import * as query from 'qs-middleware'; +import { ApolloServer } from './ApolloServer'; +import { Config } from 'apollo-server-core'; import 'mocha'; import testSuite, { @@ -10,16 +11,17 @@ import testSuite, { function createConnectApp(options: CreateAppOptions = {}) { const app = connect(); - - options.graphqlOptions = options.graphqlOptions || { schema: Schema }; - if (!options.excludeParser) { - app.use('/graphql', bodyParser.json()); - } - if (options.graphiqlOptions) { - app.use('/graphiql', graphiqlConnect(options.graphiqlOptions)); - } - app.use('/graphql', require('connect-query')()); - app.use('/graphql', graphqlConnect(options.graphqlOptions)); + // We do require users of ApolloServer with connect to use a query middleware + // first. The alternative is to add a 'isConnect' bool to ServerRegistration + // and make qs-middleware be a dependency of this package. However, we don't + // think many folks use connect outside of Meteor anyway, and anyone using + // connect is probably already using connect-query or qs-middleware. + app.use(query()); + const server = new ApolloServer( + (options.graphqlOptions as Config) || { schema: Schema }, + ); + // See comment on ServerRegistration.app for its typing. + server.applyMiddleware({ app: app as any }); return app; } diff --git a/packages/apollo-server-express/src/connectApollo.ts b/packages/apollo-server-express/src/connectApollo.ts index e28d50afe17..94a70cb3b9d 100644 --- a/packages/apollo-server-express/src/connectApollo.ts +++ b/packages/apollo-server-express/src/connectApollo.ts @@ -1,4 +1,3 @@ -import { graphqlExpress, graphiqlExpress } from './expressApollo'; +import { graphqlExpress } from './expressApollo'; export const graphqlConnect = graphqlExpress; -export const graphiqlConnect = graphiqlExpress; diff --git a/packages/apollo-server-express/src/datasource.test.ts b/packages/apollo-server-express/src/datasource.test.ts new file mode 100644 index 00000000000..cd30b2330d1 --- /dev/null +++ b/packages/apollo-server-express/src/datasource.test.ts @@ -0,0 +1,153 @@ +import { expect } from 'chai'; +import 'mocha'; +import * as express from 'express'; + +import * as http from 'http'; + +import { RESTDataSource } from 'apollo-datasource-rest'; + +import { createApolloFetch } from 'apollo-fetch'; +import { ApolloServer } from './ApolloServer'; + +import { createServerInfo } from 'apollo-server-integration-testsuite'; + +const restPort = 4001; + +export class IdAPI extends RESTDataSource { + baseURL = `http://localhost:${restPort}/`; + + async getId(id: string) { + return this.get(`id/${id}`); + } + + async getStringId(id: string) { + return this.get(`str/${id}`); + } +} + +// to remove the circular dependency, we reference it directly +const gql = require('../../apollo-server/dist/index').gql; + +const typeDefs = gql` + type Query { + id: String + stringId: String + } +`; + +const resolvers = { + Query: { + id: async (_source, _args, { dataSources }) => { + return (await dataSources.id.getId('hi')).id; + }, + stringId: async (_source, _args, { dataSources }) => { + return dataSources.id.getStringId('hi'); + }, + }, +}; + +let restCalls = 0; +const restAPI = express(); +restAPI.use('/id/:id', (req, res) => { + const id = req.params.id; + restCalls++; + res.header('Content-Type', 'application/json'); + res.header('Cache-Control', 'max-age=2000, public'); + res.write(JSON.stringify({ id })); + res.end(); +}); + +restAPI.use('/str/:id', (req, res) => { + const id = req.params.id; + restCalls++; + res.header('Content-Type', 'text/plain'); + res.header('Cache-Control', 'max-age=2000, public'); + res.write(id); + res.end(); +}); + +describe('apollo-server-express', () => { + let restServer; + + before(async () => { + await new Promise(resolve => { + restServer = restAPI.listen(restPort, resolve); + }); + }); + + after(async () => { + await restServer.close(); + }); + + let server: ApolloServer; + let httpServer: http.Server; + + beforeEach(() => { + restCalls = 0; + }); + + afterEach(async () => { + await server.stop(); + await httpServer.close(); + }); + + it('uses the cache', async () => { + server = new ApolloServer({ + typeDefs, + resolvers, + dataSources: () => ({ + id: new IdAPI(), + }), + }); + const app = express(); + + server.applyMiddleware({ app }); + httpServer = await new Promise(resolve => { + const s = app.listen({ port: 4000 }, () => resolve(s)); + }); + const { url: uri } = createServerInfo(server, httpServer); + + const apolloFetch = createApolloFetch({ uri }); + const firstResult = await apolloFetch({ query: '{ id }' }); + + expect(firstResult.data).to.deep.equal({ id: 'hi' }); + expect(firstResult.errors, 'errors should exist').not.to.exist; + expect(restCalls).to.deep.equal(1); + + const secondResult = await apolloFetch({ query: '{ id }' }); + + expect(secondResult.data).to.deep.equal({ id: 'hi' }); + expect(secondResult.errors, 'errors should exist').not.to.exist; + expect(restCalls).to.deep.equal(1); + }); + + it('can cache a string from the backend', async () => { + server = new ApolloServer({ + typeDefs, + resolvers, + dataSources: () => ({ + id: new IdAPI(), + }), + }); + const app = express(); + + server.applyMiddleware({ app }); + httpServer = await new Promise(resolve => { + const s = app.listen({ port: 4000 }, () => resolve(s)); + }); + const { url: uri } = createServerInfo(server, httpServer); + + const apolloFetch = createApolloFetch({ uri }); + const firstResult = await apolloFetch({ query: '{ id: stringId }' }); + + expect(firstResult.data).to.deep.equal({ id: 'hi' }); + expect(firstResult.errors, 'errors should exist').not.to.exist; + expect(restCalls).to.deep.equal(1); + + const secondResult = await apolloFetch({ query: '{ id: stringId }' }); + + expect(secondResult.data).to.deep.equal({ id: 'hi' }); + expect(secondResult.errors, 'errors should exist').not.to.exist; + expect(restCalls).to.deep.equal(1); + }); +}); diff --git a/packages/apollo-server-express/src/expressApollo.test.ts b/packages/apollo-server-express/src/expressApollo.test.ts index 08ca595e24a..ed221b5d9b0 100644 --- a/packages/apollo-server-express/src/expressApollo.test.ts +++ b/packages/apollo-server-express/src/expressApollo.test.ts @@ -1,39 +1,27 @@ import * as express from 'express'; -import * as bodyParser from 'body-parser'; -import { graphqlExpress, graphiqlExpress } from './expressApollo'; +import { ApolloServer } from './ApolloServer'; import testSuite, { schema as Schema, CreateAppOptions, } from 'apollo-server-integration-testsuite'; import { expect } from 'chai'; -import { GraphQLOptions } from 'apollo-server-core'; +import { GraphQLOptions, Config } from 'apollo-server-core'; import 'mocha'; function createApp(options: CreateAppOptions = {}) { const app = express(); - options.graphqlOptions = options.graphqlOptions || { schema: Schema }; - if (!options.excludeParser) { - app.use('/graphql', bodyParser.json()); - } - if (options.graphiqlOptions) { - app.use('/graphiql', graphiqlExpress(options.graphiqlOptions)); - } - app.use('/graphql', require('connect-query')()); - app.use('/graphql', graphqlExpress(options.graphqlOptions)); + const server = new ApolloServer( + (options.graphqlOptions as Config) || { schema: Schema }, + ); + server.applyMiddleware({ app }); return app; } describe('expressApollo', () => { it('throws error if called without schema', function() { - expect(() => graphqlExpress(undefined as GraphQLOptions)).to.throw( - 'Apollo Server requires options.', - ); - }); - - it('throws an error if called with more than one argument', function() { - expect(() => (graphqlExpress)({}, 'x')).to.throw( - 'Apollo Server expects exactly one argument, got 2', + expect(() => new ApolloServer(undefined as GraphQLOptions)).to.throw( + 'ApolloServer requires options.', ); }); }); diff --git a/packages/apollo-server-express/src/expressApollo.ts b/packages/apollo-server-express/src/expressApollo.ts index 10c5d23e9a3..1bce88b3bc1 100644 --- a/packages/apollo-server-express/src/expressApollo.ts +++ b/packages/apollo-server-express/src/expressApollo.ts @@ -1,11 +1,10 @@ import * as express from 'express'; -import * as url from 'url'; import { GraphQLOptions, HttpQueryError, runHttpQuery, + convertNodeHttpToRequest, } from 'apollo-server-core'; -import * as GraphiQL from 'apollo-server-module-graphiql'; export interface ExpressGraphQLOptionsFunction { (req?: express.Request, res?: express.Response): @@ -45,14 +44,13 @@ export function graphqlExpress( method: req.method, options: options, query: req.method === 'POST' ? req.body : req.query, + request: convertNodeHttpToRequest(req), }).then( - gqlResponse => { - res.setHeader('Content-Type', 'application/json'); - res.setHeader( - 'Content-Length', - Buffer.byteLength(gqlResponse, 'utf8').toString(), + ({ graphqlResponse, responseInit }) => { + Object.keys(responseInit.headers).forEach(key => + res.setHeader(key, responseInit.headers[key]), ); - res.write(gqlResponse); + res.write(graphqlResponse); res.end(); }, (error: HttpQueryError) => { @@ -75,42 +73,3 @@ export function graphqlExpress( return graphqlHandler; } - -export interface ExpressGraphiQLOptionsFunction { - (req?: express.Request): - | GraphiQL.GraphiQLData - | Promise; -} - -/* This middleware returns the html for the GraphiQL interactive query UI - * - * GraphiQLData arguments - * - * - endpointURL: the relative or absolute URL for the endpoint which GraphiQL will make queries to - * - (optional) query: the GraphQL query to pre-fill in the GraphiQL UI - * - (optional) variables: a JS object of variables to pre-fill in the GraphiQL UI - * - (optional) operationName: the operationName to pre-fill in the GraphiQL UI - * - (optional) result: the result of the query to pre-fill in the GraphiQL UI - */ - -export function graphiqlExpress( - options: GraphiQL.GraphiQLData | ExpressGraphiQLOptionsFunction, -) { - const graphiqlHandler = ( - req: express.Request, - res: express.Response, - next, - ) => { - const query = req.url && url.parse(req.url, true).query; - GraphiQL.resolveGraphiQLString(query, options, req).then( - graphiqlString => { - res.setHeader('Content-Type', 'text/html'); - res.write(graphiqlString); - res.end(); - }, - error => next(error), - ); - }; - - return graphiqlHandler; -} diff --git a/packages/apollo-server-express/src/index.ts b/packages/apollo-server-express/src/index.ts index 45808779a98..102993062bb 100644 --- a/packages/apollo-server-express/src/index.ts +++ b/packages/apollo-server-express/src/index.ts @@ -1,14 +1,26 @@ -// Expose types which can be used by both middleware flavors. -export { GraphQLOptions } from 'apollo-server-core'; +export { + GraphQLUpload, + GraphQLOptions, + gql, + // Errors + ApolloError, + toApolloError, + SyntaxError, + ValidationError, + AuthenticationError, + ForbiddenError, + UserInputError, +} from 'apollo-server-core'; + +export * from 'graphql-tools'; +export * from 'graphql-subscriptions'; -// Express Middleware +// ApolloServer integration. export { - ExpressGraphQLOptionsFunction, - ExpressHandler, - ExpressGraphiQLOptionsFunction, - graphqlExpress, - graphiqlExpress, -} from './expressApollo'; + ApolloServer, + registerServer, + ServerRegistration, +} from './ApolloServer'; -// Connect Middleware -export { graphqlConnect, graphiqlConnect } from './connectApollo'; +export { CorsOptions } from 'cors'; +export { OptionsJson } from 'body-parser'; diff --git a/packages/apollo-server-express/tsconfig.json b/packages/apollo-server-express/tsconfig.json index 8e99768afe9..5ac3c46b1f6 100644 --- a/packages/apollo-server-express/tsconfig.json +++ b/packages/apollo-server-express/tsconfig.json @@ -1,9 +1,9 @@ { - "extends": "../../tsconfig", + "extends": "../../tsconfig.json", "compilerOptions": { "rootDir": "./src", "outDir": "./dist", - "typeRoots": ["node_modules/@types"] + "lib": ["es2017", "esnext.asynciterable", "dom"] }, "exclude": ["node_modules", "dist"] } diff --git a/packages/apollo-server-fastify/README.md b/packages/apollo-server-fastify/README.md deleted file mode 100644 index 359f9eea7b2..00000000000 --- a/packages/apollo-server-fastify/README.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -title: Fastify -description: Setting up Apollo Server with Fastify ---- - -[![Coverage Status](https://coveralls.io/repos/github/apollographql/apollo-server/badge.svg?branch=master)](https://coveralls.io/github/apollographql/apollo-server?branch=master) [![Get on Slack](https://img.shields.io/badge/slack-join-orange.svg)](https://www.apollographql.com/#slack) - -This is the Fastify integration of GraphQL Server. Apollo Server is a community-maintained open-source GraphQL server that works with all Node.js HTTP server frameworks: Express, Connect, Fastify, Hapi, Koa and Restify. [Read the docs](https://www.apollographql.com/docs/apollo-server/). [Read the CHANGELOG.](https://github.com/apollographql/apollo-server/blob/master/CHANGELOG.md) - -```sh -npm install apollo-server-fastify -``` - -## Fastify - -```js -import fastify from 'fastify'; -import jsonParser from 'fast-json-body'; -import { graphqlFastify } from 'apollo-server-fastify'; - -const myGraphQLSchema = // ... define or import your schema here! -const PORT = 3000; - -const app = fastify(); - -// jsonParser is needed for POST. -app.addContentTypeParser('application/json', function(req, done) { - jsonParser(req, function(err, body) { - done(err, body); - }); -}); -app.register(graphqlFastify, { schema: myGraphQLSchema }); - -try { - await app.listen(3007); -} catch (err) { - app.log.error(err); - process.exit(1); -} -``` - -## Principles - -GraphQL Server is built with the following principles in mind: - -* **By the community, for the community**: GraphQL Server's development is driven by the needs of developers -* **Simplicity**: by keeping things simple, GraphQL Server is easier to use, easier to contribute to, and more secure -* **Performance**: GraphQL Server is well-tested and production-ready - no modifications needed - -Anyone is welcome to contribute to GraphQL Server, just read [CONTRIBUTING.md](https://github.com/apollographql/apollo-server/blob/master/CONTRIBUTING.md), take a look at the [roadmap](https://github.com/apollographql/apollo-server/blob/master/ROADMAP.md) and make your first PR! diff --git a/packages/apollo-server-fastify/package.json b/packages/apollo-server-fastify/package.json deleted file mode 100644 index 3919ca6c9dd..00000000000 --- a/packages/apollo-server-fastify/package.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "name": "apollo-server-fastify", - "version": "1.4.0", - "description": "Production-ready Node.js GraphQL server for fastify", - "main": "dist/index.js", - "scripts": { - "compile": "tsc", - "prepublish": "npm run compile" - }, - "repository": { - "type": "git", - "url": "https://github.com/apollographql/apollo-server/tree/master/packages/apollo-server-fastify" - }, - "keywords": [ - "GraphQL", - "Apollo", - "Server", - "Fastify", - "Javascript" - ], - "author": "Aditya pratap Singh ", - "license": "MIT", - "bugs": { - "url": "https://github.com/apollographql/apollo-server/issues" - }, - "homepage": "https://github.com/apollographql/apollo-server#readme", - "dependencies": { - "apollo-server-core": "^1.4.0", - "apollo-server-module-graphiql": "^1.4.0", - "fastify": "1.3.1" - }, - "devDependencies": { - "@types/graphql": "0.12.7", - "apollo-server-integration-testsuite": "^1.4.0", - "fast-json-body": "^1.1.0", - "http2": "^3.3.7" - }, - "typings": "dist/index.d.ts", - "typescript": { - "definition": "dist/index.d.ts" - } -} diff --git a/packages/apollo-server-fastify/src/fastifyApollo.test.ts b/packages/apollo-server-fastify/src/fastifyApollo.test.ts deleted file mode 100644 index 920d52b217b..00000000000 --- a/packages/apollo-server-fastify/src/fastifyApollo.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import * as fastify from 'fastify'; -import { FastifyInstance } from 'fastify'; -const jsonParser = require('fast-json-body'); -import { graphqlFastify, graphiqlFastify } from './fastifyApollo'; -import testSuite, { - schema, - CreateAppOptions, -} from 'apollo-server-integration-testsuite'; -import { expect } from 'chai'; -import { GraphQLOptions } from 'apollo-server-core'; -import 'mocha'; - -async function createApp(options: CreateAppOptions = {}) { - const app = fastify(); - const graphqlOptions = options.graphqlOptions || { schema }; - - if (!options.excludeParser) { - // @ts-ignore: Dynamic addContentTypeParser error - app.addContentTypeParser('application/json', function(req, done) { - jsonParser(req, function(err, body) { - done(err, body); - }); - }); - } - - if (options.graphiqlOptions) { - app.register(graphiqlFastify, options.graphiqlOptions); - } - app.register(graphqlFastify, { graphqlOptions }); - - try { - await app.listen(3007); - } catch (err) { - app.log.error(err); - process.exit(1); - } - - return app.server; -} - -async function destroyApp(app) { - if (!app || !app.close) { - return; - } - await new Promise(cb => app.close(cb)); -} - -describe('Fastify', () => { - describe('fastifyApollo', () => { - it('throws error if called without schema', function() { - expect(() => - graphqlFastify( - {} as FastifyInstance, - undefined as CreateAppOptions, - undefined, - ), - ).to.throw('Apollo Server requires options.'); - }); - - it('throws an error if called with argument not equal to 3', function() { - expect(() => (graphqlFastify)({}, { graphqlOptions: {} })).to.throw( - 'Apollo Server expects exactly 3 argument, got 2', - ); - }); - }); - - describe('integration:Fastify', () => { - testSuite(createApp, destroyApp); - }); -}); diff --git a/packages/apollo-server-fastify/src/fastifyApollo.ts b/packages/apollo-server-fastify/src/fastifyApollo.ts deleted file mode 100644 index 75b6254eea7..00000000000 --- a/packages/apollo-server-fastify/src/fastifyApollo.ts +++ /dev/null @@ -1,136 +0,0 @@ -import * as fastify from 'fastify'; -import { - runHttpQuery, - HttpQueryRequest, - GraphQLOptions, -} from 'apollo-server-core'; -import * as GraphiQL from 'apollo-server-module-graphiql'; -import { - FastifyInstance, - FastifyRequest, - FastifyReply, - Middleware, -} from 'fastify'; -import { IncomingMessage, ServerResponse, Server } from 'http'; - -export function graphqlFastify( - fastify: FastifyInstance, - options: any, - next: (err?: Error) => void, -) { - if (!options || !options.graphqlOptions) { - throw new Error('Apollo Server requires options.'); - } - - if (arguments.length !== 3) { - throw new Error( - `Apollo Server expects exactly 3 argument, got ${arguments.length}`, - ); - } - - async function handler( - request: any, - reply: FastifyReply, - ) { - const { method } = request.raw; - try { - const gqlResponse = await runHttpQuery([request], { - method: method, - options: options.graphqlOptions, - query: method === 'POST' ? request.body : request.query, - }); - reply - .type('application/json') - .code(200) - .header( - 'Content-Length', - Buffer.byteLength(JSON.stringify(gqlResponse), 'utf8'), - ) - .send(JSON.parse(gqlResponse)); - } catch (error) { - if ('HttpQueryError' !== error.name) { - return next(error); - } - - if (error.headers) { - Object.keys(error.headers).forEach(header => { - reply.header(header, error.headers[header]); - }); - } - - let errMessage; - try { - errMessage = JSON.parse(error.message); - } catch { - errMessage = error.message; - } - - reply.code(error.statusCode).send(errMessage); - } - } - - fastify.route({ - method: ['GET', 'POST'], - url: options.url || '/graphql', - handler, - }); - - // This is a workaround because of this issue https://github.com/fastify/fastify/pull/862 - fastify.route({ - method: ['HEAD', 'PUT', 'DELETE', 'OPTIONS', 'PATCH'], - url: options.url || '/graphql', - handler: async (req, reply) => { - reply - .code(405) - .header('allow', 'GET, POST') - .send(); - }, - }); - - next(); - return fastify; -} - -export function graphiqlFastify( - fastify: FastifyInstance, - options: any, - next: (err?: Error) => void, -) { - const handler = async (request, reply) => { - try { - const query = request.query; - const giqlResponse = await GraphiQL.resolveGraphiQLString( - query, - options, - request, - ); - reply - .header('Content-Type', 'text/html') - .code(200) - .send(giqlResponse); - } catch (error) { - reply.code(500).send(error); - } - }; - - fastify.route({ - method: ['GET', 'POST'], - url: options.url || '/graphiql', - handler, - }); - - // This is a workaround because of this issue https://github.com/fastify/fastify/pull/862 - fastify.route({ - method: ['HEAD', 'PUT', 'DELETE', 'OPTIONS', 'PATCH'], - url: options.url || '/graphiql', - handler: async (req, reply) => { - reply - .code(405) - .header('allow', 'GET, POST') - .send(); - }, - }); - - next(); - return fastify; -} diff --git a/packages/apollo-server-fastify/src/index.ts b/packages/apollo-server-fastify/src/index.ts deleted file mode 100644 index bf2f862c957..00000000000 --- a/packages/apollo-server-fastify/src/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -// Expose types which can be used by both middleware flavors. -export { GraphQLOptions } from 'apollo-server-core'; - -// Fastify Middleware -export { graphqlFastify, graphiqlFastify } from './fastifyApollo'; diff --git a/packages/apollo-server-fastify/tsconfig.json b/packages/apollo-server-fastify/tsconfig.json deleted file mode 100644 index 8e99768afe9..00000000000 --- a/packages/apollo-server-fastify/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig", - "compilerOptions": { - "rootDir": "./src", - "outDir": "./dist", - "typeRoots": ["node_modules/@types"] - }, - "exclude": ["node_modules", "dist"] -} diff --git a/packages/apollo-server-hapi/README.md b/packages/apollo-server-hapi/README.md index 14747dc9a21..ac34f839b4a 100644 --- a/packages/apollo-server-hapi/README.md +++ b/packages/apollo-server-hapi/README.md @@ -3,117 +3,53 @@ title: Hapi description: Setting up Apollo Server with Hapi --- -[![npm version](https://badge.fury.io/js/apollo-server-core.svg)](https://badge.fury.io/js/apollo-server-core) [![Build Status](https://circleci.com/gh/apollographql/apollo-cache-control-js.svg?style=svg)](https://circleci.com/gh/apollographql/apollo-cache-control-js) [![Coverage Status](https://coveralls.io/repos/github/apollographql/apollo-server/badge.svg?branch=master)](https://coveralls.io/github/apollographql/apollo-server?branch=master) [![Get on Slack](https://img.shields.io/badge/slack-join-orange.svg)](https://www.apollographql.com/#slack) +[![npm version](https://badge.fury.io/js/apollo-server-hapi.svg)](https://badge.fury.io/js/apollo-server-hapi) [![Build Status](https://circleci.com/gh/apollographql/apollo-server.svg?style=svg)](https://circleci.com/gh/apollographql/apollo-server) [![Coverage Status](https://coveralls.io/repos/github/apollographql/apollo-server/badge.svg?branch=master)](https://coveralls.io/github/apollographql/apollo-server?branch=master) [![Get on Slack](https://img.shields.io/badge/slack-join-orange.svg)](https://www.apollographql.com/#slack) -This is the Hapi integration of Apollo Server. Apollo Server is a community-maintained open-source Apollo Server that works with all Node.js HTTP server frameworks: Express, Connect, Hapi, Koa and Restify. [Read the docs](https://www.apollographql.com/docs/apollo-server/). [Read the CHANGELOG.](https://github.com/apollographql/apollo-server/blob/master/CHANGELOG.md) +This is the Hapi integration of Apollo Server. Apollo Server is a community-maintained open-source Apollo Server that works with many Node.js HTTP server frameworks. [Read the docs](https://www.apollographql.com/docs/apollo-server/). [Read the CHANGELOG.](https://github.com/apollographql/apollo-server/blob/master/CHANGELOG.md) ```sh -npm install apollo-server-hapi +npm install apollo-server-hapi@rc ``` ## Usage -With the Hapi plugins `graphqlHapi` and `graphiqlHapi` you can pass a route object that includes options to be applied to the route. The example below enables CORS on the `/graphql` route. - -The code below requires Hapi 17, See below for Hapi 16 support. +The code below requires Hapi 17 or higher. ```js -import Hapi from 'hapi'; -import { graphqlHapi } from 'apollo-server-hapi'; - -const HOST = 'localhost'; -const PORT = 3000; +const { ApolloServer, gql } = require('apollo-server-hapi'); +const Hapi = require('hapi'); async function StartServer() { - const server = new Hapi.server({ - host: HOST, - port: PORT, + const server = new ApolloServer({ typeDefs, resolvers }); + + const app = new Hapi.server({ + port: 4000 }); - await server.register({ - plugin: graphqlHapi, - options: { - path: '/graphql', - graphqlOptions: { - schema: myGraphQLSchema, - }, - route: { - cors: true, - }, - }, + await server.applyMiddleware({ + app, }); - try { - await server.start(); - } catch (err) { - console.log(`Error while starting server: ${err.message}`); - } + await server.installSubscriptionHandlers(app.listener); - console.log(`Server running at: ${server.info.uri}`); + await app.start(); } -StartServer(); +StartServer().catch(error => console.log(error)); ``` -## Hapi 16 +### Context -Imports must be monkey patched for Hapi 16 support (mainly attributes). +The context is created for each request. The following code snippet shows the creation of a context. The arguments are the `request`, the request, and `h`, the response toolkit. ```js -import { graphqlHapi, graphiqlHapi } from 'apollo-server-hapi'; - -// add attributes for hapi 16 -graphqlHapi.attributes = { - name: graphqlHapi.name, -}; - -// if you happen to use graphiql then add attributes for hapi 16 -graphiqlHapi.attributes = { - name: graphiqlHapi.name, -}; - -async function StartServer() { - const server = new Hapi.server({ - host: HOST, - port: PORT, - }); - - await server.register({ - plugin: graphqlHapi, - options: { - path: '/graphql', - graphqlOptions: { - schema: myGraphQLSchema, - }, - route: { - cors: true, - }, - }, - }); - - await server.register({ - plugin: graphiqlHapi, - options: { - path: '/graphiql', - route: { - cors: true, - }, - graphiqlOptions: { - endpointURL: 'graphql', - }, - }, - }); - - try { - await server.start(); - } catch (err) { - console.log(`Error while starting server: ${err.message}`); - } - - console.log(`Server running at: ${server.info.uri}`); -} - -StartServer(); +new ApolloServer({ + typeDefs, + resolvers, + context: async ({ request, h }) => { + return { ... }; + }, +}) ``` ## Principles diff --git a/packages/apollo-server-hapi/package.json b/packages/apollo-server-hapi/package.json index 0f8a75687c8..d227761f009 100644 --- a/packages/apollo-server-hapi/package.json +++ b/packages/apollo-server-hapi/package.json @@ -1,6 +1,6 @@ { "name": "apollo-server-hapi", - "version": "1.4.0", + "version": "2.0.0-rc.7", "description": "Production-ready Node.js GraphQL server for Hapi", "main": "dist/index.js", "scripts": { @@ -24,15 +24,25 @@ "url": "https://github.com/apollographql/apollo-server/issues" }, "homepage": "https://github.com/apollographql/apollo-server#readme", + "engines": { + "node": ">=6" + }, "dependencies": { - "apollo-server-core": "^1.4.0", - "apollo-server-module-graphiql": "^1.4.0", - "boom": "^7.1.0" + "@apollographql/graphql-playground-html": "^1.6.0", + "accept": "^3.0.2", + "apollo-server-core": "^2.0.0-rc.7", + "apollo-upload-server": "^5.0.0", + "boom": "^7.1.0", + "graphql-subscriptions": "^0.5.8", + "graphql-tools": "^3.0.4" }, "devDependencies": { - "@types/graphql": "0.12.7", - "apollo-server-integration-testsuite": "^1.4.0", - "hapi": "17.5.2" + "@types/hapi": "^17.0.12", + "apollo-server-integration-testsuite": "^2.0.0-rc.7", + "hapi": "17.4.0" + }, + "peerDependencies": { + "graphql": "^0.12.0 || ^0.13.0 || ^14.0.0" }, "typings": "dist/index.d.ts", "typescript": { diff --git a/packages/apollo-server-hapi/src/ApolloServer.test.ts b/packages/apollo-server-hapi/src/ApolloServer.test.ts new file mode 100644 index 00000000000..6b51b5d4c8c --- /dev/null +++ b/packages/apollo-server-hapi/src/ApolloServer.test.ts @@ -0,0 +1,602 @@ +import { expect } from 'chai'; +import 'mocha'; +import { Server } from 'hapi'; +import { + testApolloServer, + createServerInfo, +} from 'apollo-server-integration-testsuite'; + +import http = require('http'); +import request = require('request'); +import FormData = require('form-data'); +import fs = require('fs'); +import { createApolloFetch } from 'apollo-fetch'; + +import { gql, AuthenticationError } from 'apollo-server-core'; +import { ApolloServer } from './ApolloServer'; + +describe('apollo-server-hapi', () => { + let server: ApolloServer; + + let app: Server; + let httpServer: http.Server; + + testApolloServer( + async options => { + server = new ApolloServer(options); + app = new Server({ host: 'localhost', port: 4000 }); + await server.applyMiddleware({ app }); + await app.start(); + const httpServer = app.listener; + return createServerInfo(server, httpServer); + }, + async () => { + if (server) await server.stop(); + if (app) await app.stop(); + if (httpServer && httpServer.listening) await httpServer.close(); + }, + ); + + //Non-integration tests + const typeDefs = gql` + type Query { + hello: String + } + `; + + const resolvers = { + Query: { + hello: () => 'hi', + }, + }; + + afterEach(async () => { + if (server) await server.stop(); + if (httpServer) await httpServer.close(); + }); + + describe('constructor', () => { + it('accepts typeDefs and resolvers', async () => { + const app = new Server(); + const server = new ApolloServer({ typeDefs, resolvers }); + return server.applyMiddleware({ app }); + }); + }); + + describe('applyMiddleware', () => { + it('can be queried', async () => { + server = new ApolloServer({ + typeDefs, + resolvers, + }); + app = new Server({ port: 4000 }); + + await server.applyMiddleware({ app }); + await app.start(); + + httpServer = app.listener; + const uri = app.info.uri + '/graphql'; + + const apolloFetch = createApolloFetch({ uri }); + const result = await apolloFetch({ query: '{hello}' }); + + expect(result.data).to.deep.equal({ hello: 'hi' }); + expect(result.errors, 'errors should exist').not.to.exist; + }); + + // XXX Unclear why this would be something somebody would want (vs enabling + // introspection without graphql-playground, which seems reasonable, eg you + // have your own graphql-playground setup with a custom link) + it('can enable playground separately from introspection during production', async () => { + const INTROSPECTION_QUERY = ` + { + __schema { + directives { + name + } + } + } +`; + + server = new ApolloServer({ + typeDefs, + resolvers, + introspection: false, + }); + app = new Server({ port: 4000 }); + + await server.applyMiddleware({ app }); + await app.start(); + + httpServer = app.listener; + const uri = app.info.uri + '/graphql'; + const url = uri; + + const apolloFetch = createApolloFetch({ uri }); + const result = await apolloFetch({ query: INTROSPECTION_QUERY }); + + expect(result.errors.length).to.equal(1); + expect(result.errors[0].extensions.code).to.equal( + 'GRAPHQL_VALIDATION_FAILED', + ); + + return new Promise((resolve, reject) => { + request( + { + url, + method: 'GET', + headers: { + accept: + 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8', + }, + }, + (error, response, body) => { + if (error) { + reject(error); + } else { + expect(body).to.contain('GraphQLPlayground'); + expect(response.statusCode).to.equal(200); + resolve(); + } + }, + ); + }); + }); + + it('renders GraphQL playground when browser requests', async () => { + const nodeEnv = process.env.NODE_ENV; + delete process.env.NODE_ENV; + + server = new ApolloServer({ + typeDefs, + resolvers, + }); + app = new Server({ port: 4000 }); + + await server.applyMiddleware({ app }); + await app.start(); + + httpServer = app.listener; + const url = app.info.uri + '/graphql'; + + return new Promise((resolve, reject) => { + request( + { + url, + method: 'GET', + headers: { + accept: + 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8', + }, + }, + (error, response, body) => { + process.env.NODE_ENV = nodeEnv; + if (error) { + reject(error); + } else { + expect(body).to.contain('GraphQLPlayground'); + expect(response.statusCode).to.equal(200); + resolve(); + } + }, + ); + }); + }); + + it('accepts cors configuration', async () => { + server = new ApolloServer({ + typeDefs, + resolvers, + }); + app = new Server({ + port: 4000, + }); + + await server.applyMiddleware({ + app, + cors: { + additionalExposedHeaders: ['X-Apollo'], + exposedHeaders: [ + 'Accept', + 'Authorization', + 'Content-Type', + 'If-None-Match', + 'Another-One', + ], + }, + }); + await app.start(); + + httpServer = app.listener; + const uri = app.info.uri + '/graphql'; + + const apolloFetch = createApolloFetch({ uri }).useAfter( + (response, next) => { + expect( + response.response.headers.get('access-control-expose-headers'), + ).to.deep.equal( + 'Accept,Authorization,Content-Type,If-None-Match,Another-One,X-Apollo', + ); + next(); + }, + ); + await apolloFetch({ query: '{hello}' }); + }); + + describe('healthchecks', () => { + afterEach(async () => { + await server.stop(); + }); + + it('creates a healthcheck endpoint', async () => { + server = new ApolloServer({ + typeDefs, + resolvers, + }); + app = new Server({ port: 4000 }); + + await server.applyMiddleware({ app }); + await app.start(); + + httpServer = app.listener; + const { port } = app.info; + + return new Promise((resolve, reject) => { + request( + { + url: `http://localhost:${port}/.well-known/apollo/server-health`, + method: 'GET', + }, + (error, response, body) => { + if (error) { + reject(error); + } else { + expect(body).to.equal(JSON.stringify({ status: 'pass' })); + expect(response.statusCode).to.equal(200); + resolve(); + } + }, + ); + }); + }); + + it('provides a callback for the healthcheck', async () => { + server = new ApolloServer({ + typeDefs, + resolvers, + }); + app = new Server({ port: 4000 }); + + await server.applyMiddleware({ + app, + onHealthCheck: async () => { + throw Error("can't connect to DB"); + }, + }); + await app.start(); + + httpServer = app.listener; + const { port } = app.info; + + return new Promise((resolve, reject) => { + request( + { + url: `http://localhost:${port}/.well-known/apollo/server-health`, + method: 'GET', + }, + (error, response, body) => { + if (error) { + reject(error); + } else { + expect(body).to.equal(JSON.stringify({ status: 'fail' })); + expect(response.statusCode).to.equal(503); + resolve(); + } + }, + ); + }); + }); + + it('can disable the healthCheck', async () => { + server = new ApolloServer({ + typeDefs, + resolvers, + }); + + app = new Server({ port: 4000 }); + + await server.applyMiddleware({ + app, + disableHealthCheck: true, + }); + await app.start(); + + httpServer = app.listener; + const { port } = app.info; + + return new Promise((resolve, reject) => { + request( + { + url: `http://localhost:${port}/.well-known/apollo/server-health`, + method: 'GET', + }, + (error, response) => { + if (error) { + reject(error); + } else { + expect(response.statusCode).to.equal(404); + resolve(); + } + }, + ); + }); + }); + }); + describe('file uploads', () => { + xit('enabled uploads', async () => { + server = new ApolloServer({ + typeDefs: gql` + type File { + filename: String! + mimetype: String! + encoding: String! + } + + type Query { + uploads: [File] + } + + type Mutation { + singleUpload(file: Upload!): File! + } + `, + resolvers: { + Query: { + uploads: () => {}, + }, + Mutation: { + singleUpload: async (_, args) => { + expect((await args.file).stream).to.exist; + return args.file; + }, + }, + }, + }); + app = new Server({ port: 4000 }); + + await server.applyMiddleware({ + app, + disableHealthCheck: true, + }); + await app.start(); + + httpServer = app.listener; + const { port } = app.info; + + const body = new FormData(); + + body.append( + 'operations', + JSON.stringify({ + query: ` + mutation($file: Upload!) { + singleUpload(file: $file) { + filename + encoding + mimetype + } + } + `, + variables: { + file: null, + }, + }), + ); + + body.append('map', JSON.stringify({ 1: ['variables.file'] })); + body.append('1', fs.createReadStream('package.json')); + + try { + const resolved = await fetch(`http://localhost:${port}/graphql`, { + method: 'POST', + body: body as any, + }); + const response = await resolved.json(); + + expect(response.data.singleUpload).to.deep.equal({ + filename: 'package.json', + encoding: '7bit', + mimetype: 'application/json', + }); + } catch (error) { + // This error began appearing randomly and seems to be a dev dependency bug. + // https://github.com/jaydenseric/apollo-upload-server/blob/18ecdbc7a1f8b69ad51b4affbd986400033303d4/test.js#L39-L42 + if (error.code !== 'EPIPE') throw error; + } + }); + }); + + describe('errors', () => { + it('returns thrown context error as a valid graphql result', async () => { + const nodeEnv = process.env.NODE_ENV; + delete process.env.NODE_ENV; + const typeDefs = gql` + type Query { + hello: String + } + `; + const resolvers = { + Query: { + hello: () => { + throw Error('never get here'); + }, + }, + }; + server = new ApolloServer({ + typeDefs, + resolvers, + context: () => { + throw new AuthenticationError('valid result'); + }, + }); + + app = new Server({ port: 4000 }); + + await server.applyMiddleware({ + app, + disableHealthCheck: true, + }); + await app.start(); + + httpServer = app.listener; + const uri = app.info.uri + '/graphql'; + + const apolloFetch = createApolloFetch({ uri }); + + const result = await apolloFetch({ query: '{hello}' }); + expect(result.errors.length).to.equal(1); + expect(result.data).not.to.exist; + + const e = result.errors[0]; + expect(e.message).to.contain('valid result'); + expect(e.extensions).to.exist; + expect(e.extensions.code).to.equal('UNAUTHENTICATED'); + expect(e.extensions.exception.stacktrace).to.exist; + + process.env.NODE_ENV = nodeEnv; + }); + + it('propogates error codes in dev mode', async () => { + const nodeEnv = process.env.NODE_ENV; + delete process.env.NODE_ENV; + + server = new ApolloServer({ + typeDefs: gql` + type Query { + error: String + } + `, + resolvers: { + Query: { + error: () => { + throw new AuthenticationError('we the best music'); + }, + }, + }, + }); + + app = new Server({ port: 4000 }); + + await server.applyMiddleware({ + app, + disableHealthCheck: true, + }); + await app.start(); + + httpServer = app.listener; + const uri = app.info.uri + '/graphql'; + + const apolloFetch = createApolloFetch({ uri }); + + const result = await apolloFetch({ query: `{error}` }); + expect(result.data).to.exist; + expect(result.data).to.deep.equal({ error: null }); + + expect(result.errors, 'errors should exist').to.exist; + expect(result.errors.length).to.equal(1); + expect(result.errors[0].extensions.code).to.equal('UNAUTHENTICATED'); + expect(result.errors[0].extensions.exception).to.exist; + expect(result.errors[0].extensions.exception.stacktrace).to.exist; + + process.env.NODE_ENV = nodeEnv; + }); + + it('propogates error codes in production', async () => { + const nodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + + server = new ApolloServer({ + typeDefs: gql` + type Query { + error: String + } + `, + resolvers: { + Query: { + error: () => { + throw new AuthenticationError('we the best music'); + }, + }, + }, + }); + + app = new Server({ port: 4000 }); + + await server.applyMiddleware({ + app, + disableHealthCheck: true, + }); + await app.start(); + + httpServer = app.listener; + const uri = app.info.uri + '/graphql'; + + const apolloFetch = createApolloFetch({ uri }); + + const result = await apolloFetch({ query: `{error}` }); + expect(result.data).to.exist; + expect(result.data).to.deep.equal({ error: null }); + + expect(result.errors, 'errors should exist').to.exist; + expect(result.errors.length).to.equal(1); + expect(result.errors[0].extensions.code).to.equal('UNAUTHENTICATED'); + expect(result.errors[0].extensions.exception).not.to.exist; + + process.env.NODE_ENV = nodeEnv; + }); + + it('propogates error codes with null response in production', async () => { + const nodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + + server = new ApolloServer({ + typeDefs: gql` + type Query { + error: String! + } + `, + resolvers: { + Query: { + error: () => { + throw new AuthenticationError('we the best music'); + }, + }, + }, + }); + + app = new Server({ port: 4000 }); + + await server.applyMiddleware({ + app, + disableHealthCheck: true, + }); + await app.start(); + + httpServer = app.listener; + const uri = app.info.uri + '/graphql'; + + const apolloFetch = createApolloFetch({ uri }); + + const result = await apolloFetch({ query: `{error}` }); + expect(result.data).null; + + expect(result.errors, 'errors should exist').to.exist; + expect(result.errors.length).to.equal(1); + expect(result.errors[0].extensions.code).to.equal('UNAUTHENTICATED'); + expect(result.errors[0].extensions.exception).not.to.exist; + + process.env.NODE_ENV = nodeEnv; + }); + }); + }); +}); diff --git a/packages/apollo-server-hapi/src/ApolloServer.ts b/packages/apollo-server-hapi/src/ApolloServer.ts new file mode 100644 index 00000000000..a802648bbdc --- /dev/null +++ b/packages/apollo-server-hapi/src/ApolloServer.ts @@ -0,0 +1,148 @@ +import * as hapi from 'hapi'; +import { parseAll } from 'accept'; +import { + renderPlaygroundPage, + RenderPageOptions as PlaygroundRenderPageOptions, +} from '@apollographql/graphql-playground-html'; +import { processRequest as processFileUploads } from 'apollo-upload-server'; + +import { graphqlHapi } from './hapiApollo'; + +export { GraphQLOptions, GraphQLExtension } from 'apollo-server-core'; +import { + ApolloServerBase, + GraphQLOptions, + FileUploadOptions, +} from 'apollo-server-core'; + +function handleFileUploads(uploadsConfig: FileUploadOptions) { + return async (request: hapi.Request) => { + if (request.mime === 'multipart/form-data') { + Object.defineProperty(request, 'payload', { + value: await processFileUploads(request, uploadsConfig), + writable: false, + }); + } + }; +} + +export class ApolloServer extends ApolloServerBase { + // This translates the arguments from the middleware into graphQL options It + // provides typings for the integration specific behavior, ideally this would + // be propagated with a generic to the super class + async createGraphQLServerOptions( + request: hapi.Request, + h: hapi.ResponseToolkit, + ): Promise { + return super.graphQLServerOptions({ request, h }); + } + + protected supportsSubscriptions(): boolean { + return true; + } + + protected supportsUploads(): boolean { + return true; + } + + public async applyMiddleware({ + app, + cors, + path, + disableHealthCheck, + onHealthCheck, + }: ServerRegistration) { + if (!path) path = '/graphql'; + + await app.ext({ + type: 'onRequest', + method: async function(request, h) { + if (request.path !== path) { + return h.continue; + } + + if (this.uploadsConfig) { + await handleFileUploads(this.uploadsConfig)(request); + } + + if (this.playgroundOptions && request.method === 'get') { + // perform more expensive content-type check only if necessary + const accept = parseAll(request.headers); + const types = accept.mediaTypes as string[]; + const prefersHTML = + types.find( + (x: string) => x === 'text/html' || x === 'application/json', + ) === 'text/html'; + + if (prefersHTML) { + const playgroundRenderPageOptions: PlaygroundRenderPageOptions = { + endpoint: path, + subscriptionEndpoint: this.subscriptionsPath, + version: this.playgroundVersion, + ...this.playgroundOptions, + }; + + return h + .response(renderPlaygroundPage(playgroundRenderPageOptions)) + .type('text/html') + .takeover(); + } + } + return h.continue; + }.bind(this), + }); + + if (!disableHealthCheck) { + await app.route({ + method: '*', + path: '/.well-known/apollo/server-health', + options: { + cors: cors !== undefined ? cors : true, + }, + handler: async function(request, h) { + if (onHealthCheck) { + try { + await onHealthCheck(request); + } catch { + const response = h.response({ status: 'fail' }); + response.code(503); + response.type('application/health+json'); + return response; + } + } + const response = h.response({ status: 'pass' }); + response.type('application/health+json'); + return response; + }, + }); + } + + await app.register({ + plugin: graphqlHapi, + options: { + path, + graphqlOptions: this.createGraphQLServerOptions.bind(this), + route: { + cors: cors !== undefined ? cors : true, + }, + }, + }); + + this.graphqlPath = path; + } +} + +export interface ServerRegistration { + app?: hapi.Server; + path?: string; + cors?: boolean | hapi.RouteOptionsCors; + onHealthCheck?: (request: hapi.Request) => Promise; + disableHealthCheck?: boolean; + uploads?: boolean | Record; +} + +export const registerServer = () => { + throw new Error( + 'Please use server.applyMiddleware instead of registerServer. This warning will be removed in the next release', + ); +}; diff --git a/packages/apollo-server-hapi/src/hapiApollo.test.ts b/packages/apollo-server-hapi/src/hapiApollo.test.ts index 27f660d4e7f..f5aebe0e75e 100644 --- a/packages/apollo-server-hapi/src/hapiApollo.test.ts +++ b/packages/apollo-server-hapi/src/hapiApollo.test.ts @@ -1,5 +1,6 @@ import * as hapi from 'hapi'; -import { graphqlHapi, graphiqlHapi } from './hapiApollo'; +import { ApolloServer } from './ApolloServer'; +import { Config } from 'apollo-server-core'; import 'mocha'; import testSuite, { @@ -7,33 +8,22 @@ import testSuite, { CreateAppOptions, } from 'apollo-server-integration-testsuite'; -async function createApp(options: CreateAppOptions) { - const server = new hapi.Server({ +async function createApp(options: CreateAppOptions = {}) { + const app = new hapi.Server({ host: 'localhost', port: 8000, }); - await server.register({ - plugin: graphqlHapi, - options: { - graphqlOptions: (options && options.graphqlOptions) || { schema: Schema }, - path: '/graphql', - }, + const server = new ApolloServer( + (options.graphqlOptions as Config) || { schema: Schema }, + ); + await server.applyMiddleware({ + app, }); - await server.register({ - plugin: graphiqlHapi, - options: { - path: '/graphiql', - graphiqlOptions: (options && options.graphiqlOptions) || { - endpointURL: '/graphql', - }, - }, - }); - - await server.start(); + await app.start(); - return server.listener; + return app.listener; } async function destroyApp(app) { diff --git a/packages/apollo-server-hapi/src/hapiApollo.ts b/packages/apollo-server-hapi/src/hapiApollo.ts index 7e14de1018d..b338f8e6e76 100644 --- a/packages/apollo-server-hapi/src/hapiApollo.ts +++ b/packages/apollo-server-hapi/src/hapiApollo.ts @@ -1,10 +1,9 @@ import * as Boom from 'boom'; -import { Server, Response, Request, ReplyNoContinue } from 'hapi'; -import * as GraphiQL from 'apollo-server-module-graphiql'; +import { Server, Request, RouteOptions } from 'hapi'; import { GraphQLOptions, runHttpQuery, - HttpQueryError, + convertNodeHttpToRequest, } from 'apollo-server-core'; export interface IRegister { @@ -18,13 +17,13 @@ export interface IPlugin { } export interface HapiOptionsFunction { - (req?: Request): GraphQLOptions | Promise; + (request?: Request): GraphQLOptions | Promise; } export interface HapiPluginOptions { path: string; vhost?: string; - route?: any; + route?: RouteOptions; graphqlOptions: GraphQLOptions | HapiOptionsFunction; } @@ -34,22 +33,31 @@ const graphqlHapi: IPlugin = { if (!options || !options.graphqlOptions) { throw new Error('Apollo Server requires options.'); } - server.route({ method: ['GET', 'POST'], path: options.path || '/graphql', vhost: options.vhost || undefined, - config: options.route || {}, + options: options.route || {}, handler: async (request, h) => { try { - const gqlResponse = await runHttpQuery([request], { - method: request.method.toUpperCase(), - options: options.graphqlOptions, - query: request.method === 'post' ? request.payload : request.query, - }); - - const response = h.response(gqlResponse); - response.type('application/json'); + const { graphqlResponse, responseInit } = await runHttpQuery( + [request], + { + method: request.method.toUpperCase(), + options: options.graphqlOptions, + query: + request.method === 'post' + ? // TODO type payload as string or Record + (request.payload as any) + : request.query, + request: convertNodeHttpToRequest(request.raw.req), + }, + ); + + 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) { @@ -82,48 +90,4 @@ const graphqlHapi: IPlugin = { }, }; -export interface HapiGraphiQLOptionsFunction { - (req?: Request): GraphiQL.GraphiQLData | Promise; -} - -export interface HapiGraphiQLPluginOptions { - path: string; - route?: any; - graphiqlOptions: GraphiQL.GraphiQLData | HapiGraphiQLOptionsFunction; -} - -const graphiqlHapi: IPlugin = { - name: 'graphiql', - register: ( - server: Server, - options: HapiGraphiQLPluginOptions, - next?: Function, - ) => { - if (!options || !options.graphiqlOptions) { - throw new Error('Apollo Server GraphiQL requires options.'); - } - - server.route({ - method: 'GET', - path: options.path || '/graphiql', - config: options.route || {}, - handler: async (request, h) => { - const graphiqlString = await GraphiQL.resolveGraphiQLString( - request.query, - options.graphiqlOptions, - request, - ); - - const response = h.response(graphiqlString); - response.type('text/html'); - return response; - }, - }); - - if (next) { - next(); - } - }, -}; - -export { graphqlHapi, graphiqlHapi }; +export { graphqlHapi }; diff --git a/packages/apollo-server-hapi/src/index.ts b/packages/apollo-server-hapi/src/index.ts index 20a147a0afa..839fcafe6d5 100644 --- a/packages/apollo-server-hapi/src/index.ts +++ b/packages/apollo-server-hapi/src/index.ts @@ -1,9 +1,23 @@ export { - IRegister, - HapiOptionsFunction, - HapiPluginOptions, - HapiGraphiQLOptionsFunction, - HapiGraphiQLPluginOptions, - graphqlHapi, - graphiqlHapi, -} from './hapiApollo'; + GraphQLUpload, + GraphQLOptions, + gql, + // Errors + ApolloError, + toApolloError, + SyntaxError, + ValidationError, + AuthenticationError, + ForbiddenError, + UserInputError, +} from 'apollo-server-core'; + +export * from 'graphql-tools'; +export * from 'graphql-subscriptions'; + +// ApolloServer integration. +export { + ApolloServer, + registerServer, + ServerRegistration, +} from './ApolloServer'; diff --git a/packages/apollo-server-hapi/tsconfig.json b/packages/apollo-server-hapi/tsconfig.json index 8e99768afe9..5ac3c46b1f6 100644 --- a/packages/apollo-server-hapi/tsconfig.json +++ b/packages/apollo-server-hapi/tsconfig.json @@ -1,9 +1,9 @@ { - "extends": "../../tsconfig", + "extends": "../../tsconfig.json", "compilerOptions": { "rootDir": "./src", "outDir": "./dist", - "typeRoots": ["node_modules/@types"] + "lib": ["es2017", "esnext.asynciterable", "dom"] }, "exclude": ["node_modules", "dist"] } diff --git a/packages/apollo-server-integration-testsuite/package.json b/packages/apollo-server-integration-testsuite/package.json index 0894af5764e..11e58100e5f 100644 --- a/packages/apollo-server-integration-testsuite/package.json +++ b/packages/apollo-server-integration-testsuite/package.json @@ -1,7 +1,7 @@ { "name": "apollo-server-integration-testsuite", "private": true, - "version": "1.4.0", + "version": "2.0.0-rc.7", "description": "Apollo Server Integrations testsuite", "main": "dist/index.js", "scripts": { @@ -19,14 +19,28 @@ "url": "https://github.com/apollographql/apollo-server/issues" }, "homepage": "https://github.com/apollographql/apollo-server#readme", + "engines": { + "node": ">=6" + }, "dependencies": { - "apollo-server-core": "^1.4.0", - "apollo-server-module-graphiql": "^1.4.0", - "apollo-server-module-operation-store": "^1.3.5", - "supertest": "^3.0.0" + "apollo-server-core": "^2.0.0-rc.7" }, "devDependencies": { - "@types/graphql": "0.12.7" + "@types/body-parser": "1.17.0", + "apollo-engine-reporting-protobuf": "0.0.0-beta.7", + "apollo-fetch": "^0.7.0", + "apollo-link": "^1.2.2", + "apollo-link-http": "^1.5.4", + "apollo-link-persisted-queries": "^0.2.1", + "apollo-server-env": "^2.0.0-rc.7", + "body-parser": "^1.18.3", + "graphql-extensions": "^0.1.0-rc.1", + "graphql-subscriptions": "^0.5.8", + "graphql-tag": "^2.9.2", + "js-sha256": "^0.9.0", + "subscriptions-transport-ws": "^0.9.11", + "ws": "^5.2.0", + "yup": "^0.25.1" }, "typings": "dist/index.d.ts", "typescript": { diff --git a/packages/apollo-server-integration-testsuite/src/ApolloServer.ts b/packages/apollo-server-integration-testsuite/src/ApolloServer.ts new file mode 100644 index 00000000000..573cd37ede6 --- /dev/null +++ b/packages/apollo-server-integration-testsuite/src/ApolloServer.ts @@ -0,0 +1,1157 @@ +/* tslint:disable:no-unused-expression */ +import { expect } from 'chai'; +import { stub } from 'sinon'; +import * as http from 'http'; +import * as net from 'net'; +import 'mocha'; +import { sha256 } from 'js-sha256'; +import express = require('express'); +import bodyParser = require('body-parser'); +import yup = require('yup'); + +import { Trace } from 'apollo-engine-reporting-protobuf'; + +import { + GraphQLSchema, + GraphQLObjectType, + GraphQLString, + GraphQLError, + ValidationContext, + FieldDefinitionNode, +} from 'graphql'; + +import { PubSub } from 'graphql-subscriptions'; +import { SubscriptionClient } from 'subscriptions-transport-ws'; +import * as WebSocket from 'ws'; + +import { execute } from 'apollo-link'; +import { createHttpLink } from 'apollo-link-http'; +import { + createPersistedQueryLink as createPersistedQuery, + VERSION, +} from 'apollo-link-persisted-queries'; + +import { createApolloFetch } from 'apollo-fetch'; +import { + AuthenticationError, + UserInputError, + gql, + Config, + ApolloServerBase, +} from 'apollo-server-core'; +import { GraphQLExtension, GraphQLResponse } from 'graphql-extensions'; + +export function createServerInfo( + server: AS, + httpServer: http.Server, +): ServerInfo { + const serverInfo: any = { + ...(httpServer.address() as net.AddressInfo), + server, + httpServer, + }; + + // Convert IPs which mean "any address" (IPv4 or IPv6) into localhost + // corresponding loopback ip. Note that the url field we're setting is + // primarily for consumption by our test suite. If this heuristic is + // wrong for your use case, explicitly specify a frontend host (in the + // `frontends.host` field in your engine config, or in the `host` + // option to ApolloServer.listen). + let hostForUrl = serverInfo.address; + if (serverInfo.address === '' || serverInfo.address === '::') + hostForUrl = 'localhost'; + + serverInfo.url = require('url').format({ + protocol: 'http', + hostname: hostForUrl, + port: serverInfo.port, + pathname: server.graphqlPath, + }); + + return serverInfo; +} + +const INTROSPECTION_QUERY = ` + { + __schema { + directives { + name + } + } + } +`; + +const TEST_STRING_QUERY = ` + { + testString + } +`; + +const queryType = new GraphQLObjectType({ + name: 'QueryType', + fields: { + testString: { + type: GraphQLString, + resolve() { + return 'test string'; + }, + }, + }, +}); + +const schema = new GraphQLSchema({ + query: queryType, +}); + +export interface ServerInfo { + address: string; + family: string; + url: string; + port: number | string; + server: AS; + httpServer: http.Server; +} + +export interface CreateServerFunc { + (config: Config): Promise>; +} + +export interface StopServerFunc { + (): Promise; +} + +export function testApolloServer( + createApolloServer: CreateServerFunc, + stopServer: StopServerFunc, +) { + describe('ApolloServer', () => { + afterEach(stopServer); + + describe('constructor', () => { + describe('validation rules', () => { + it('accepts additional rules', async () => { + const NoTestString = (context: ValidationContext) => ({ + Field(node: FieldDefinitionNode) { + if (node.name.value === 'testString') { + context.reportError( + new GraphQLError('Not allowed to use', [node]), + ); + } + }, + }); + + const formatError = stub().callsFake(error => { + expect(error instanceof Error).true; + return error; + }); + + const { url: uri } = await createApolloServer({ + schema, + validationRules: [NoTestString], + introspection: false, + formatError, + }); + + const apolloFetch = createApolloFetch({ uri }); + + const introspectionResult = await apolloFetch({ + query: INTROSPECTION_QUERY, + }); + expect(introspectionResult.data, 'data should not exist').not.to + .exist; + expect(introspectionResult.errors, 'errors should exist').to.exist; + expect(introspectionResult.errors[0].message).to.match( + /introspection/, + ); + expect(formatError.callCount).to.equal( + introspectionResult.errors.length, + ); + + const result = await apolloFetch({ query: TEST_STRING_QUERY }); + expect(result.data, 'data should not exist').not.to.exist; + expect(result.errors, 'errors should exist').to.exist; + expect(result.errors[0].message).to.match(/Not allowed/); + expect(formatError.callCount).to.equal( + introspectionResult.errors.length + result.errors.length, + ); + }); + + it('allows introspection by default', async () => { + const nodeEnv = process.env.NODE_ENV; + delete process.env.NODE_ENV; + + const { url: uri } = await createApolloServer({ + schema, + }); + + const apolloFetch = createApolloFetch({ uri }); + + const result = await apolloFetch({ query: INTROSPECTION_QUERY }); + expect(result.data, 'data should not exist').to.exist; + expect(result.errors, 'errors should exist').not.to.exist; + + process.env.NODE_ENV = nodeEnv; + }); + + it('prevents introspection by default during production', async () => { + const nodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + + const { url: uri } = await createApolloServer({ + schema, + }); + + const apolloFetch = createApolloFetch({ uri }); + + const result = await apolloFetch({ query: INTROSPECTION_QUERY }); + expect(result.data, 'data should not exist').not.to.exist; + expect(result.errors, 'errors should exist').to.exist; + expect(result.errors.length).to.equal(1); + expect(result.errors[0].extensions.code).to.equal( + 'GRAPHQL_VALIDATION_FAILED', + ); + + process.env.NODE_ENV = nodeEnv; + }); + + it('allows introspection to be enabled explicitly', async () => { + const nodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + + const { url: uri } = await createApolloServer({ + schema, + introspection: true, + }); + + const apolloFetch = createApolloFetch({ uri }); + + const result = await apolloFetch({ query: INTROSPECTION_QUERY }); + expect(result.data, 'data should not exist').to.exist; + expect(result.errors, 'errors should exist').not.to.exist; + + process.env.NODE_ENV = nodeEnv; + }); + }); + + describe('schema creation', () => { + it('accepts typeDefs and resolvers', async () => { + const typeDefs = gql` + type Query { + hello: String + } + `; + const resolvers = { Query: { hello: () => 'hi' } }; + const { url: uri } = await createApolloServer({ + typeDefs, + resolvers, + }); + + const apolloFetch = createApolloFetch({ uri }); + const result = await apolloFetch({ query: '{hello}' }); + + expect(result.data).to.deep.equal({ hello: 'hi' }); + expect(result.errors, 'errors should exist').not.to.exist; + }); + it('uses schema over resolvers + typeDefs', async () => { + const typeDefs = gql` + type Query { + hello: String + } + `; + const resolvers = { Query: { hello: () => 'hi' } }; + const { url: uri } = await createApolloServer({ + typeDefs, + resolvers, + schema, + }); + + const apolloFetch = createApolloFetch({ uri }); + const typeDefResult = await apolloFetch({ query: '{hello}' }); + + expect(typeDefResult.data, 'data should not exist').not.to.exist; + expect(typeDefResult.errors, 'errors should exist').to.exist; + + const result = await apolloFetch({ query: '{testString}' }); + expect(result.data).to.deep.equal({ testString: 'test string' }); + expect(result.errors, 'errors should exist').not.to.exist; + }); + it('allows mocks as boolean', async () => { + const typeDefs = gql` + type Query { + hello: String + } + `; + const { url: uri } = await createApolloServer({ + typeDefs, + mocks: true, + }); + + const apolloFetch = createApolloFetch({ uri }); + const result = await apolloFetch({ query: '{hello}' }); + expect(result.data).to.deep.equal({ hello: 'Hello World' }); + expect(result.errors, 'errors should exist').not.to.exist; + }); + + it('allows mocks as an object', async () => { + const typeDefs = gql` + type Query { + hello: String + } + `; + const { url: uri } = await createApolloServer({ + typeDefs, + mocks: { String: () => 'mock city' }, + }); + + const apolloFetch = createApolloFetch({ uri }); + const result = await apolloFetch({ query: '{hello}' }); + + expect(result.data).to.deep.equal({ hello: 'mock city' }); + expect(result.errors, 'errors should exist').not.to.exist; + }); + }); + }); + + describe('formatError', () => { + it('wraps thrown error from validation rules', async () => { + const throwError = stub().callsFake(() => { + throw new Error('nope'); + }); + + const formatError = stub().callsFake(error => { + expect(error instanceof Error).true; + expect(error.constructor.name).to.equal('Error'); + return error; + }); + + const { url: uri } = await createApolloServer({ + schema, + validationRules: [throwError], + introspection: true, + formatError, + }); + + const apolloFetch = createApolloFetch({ uri }); + + const introspectionResult = await apolloFetch({ + query: INTROSPECTION_QUERY, + }); + expect(introspectionResult.data, 'data should not exist').not.to.exist; + expect(introspectionResult.errors, 'errors should exist').to.exist; + expect(formatError.calledOnce).true; + expect(throwError.calledOnce).true; + + const result = await apolloFetch({ query: TEST_STRING_QUERY }); + expect(result.data, 'data should not exist').not.to.exist; + expect(result.errors, 'errors should exist').to.exist; + expect(formatError.calledTwice).true; + expect(throwError.calledTwice).true; + }); + + it('works with errors similar to GraphQL errors, such as yup', async () => { + const throwError = stub().callsFake(async () => { + const schema = yup.object().shape({ + email: yup + .string() + .email() + .required('Please enter your email address'), + }); + + await schema.validate({ email: 'lol' }); + }); + + const formatError = stub().callsFake(error => { + expect(error instanceof Error).true; + expect(error.extensions.code).to.equal('INTERNAL_SERVER_ERROR'); + expect(error.extensions.exception.name).to.equal('ValidationError'); + expect(error.extensions.exception.message).to.exist; + const inputError = new UserInputError('User Input Error'); + return { + message: inputError.message, + extensions: inputError.extensions, + }; + }); + + const { url: uri } = await createApolloServer({ + typeDefs: gql` + type Query { + error: String + } + `, + resolvers: { + Query: { + error: () => { + return throwError(); + }, + }, + }, + introspection: true, + debug: true, + formatError, + }); + + const apolloFetch = createApolloFetch({ uri }); + + const result = await apolloFetch({ + query: '{error}', + }); + expect(result.data).to.deep.equal({ error: null }); + expect(result.errors, 'errors should exist').to.exist; + expect(result.errors[0].extensions.code).equals('BAD_USER_INPUT'); + expect(result.errors[0].message).equals('User Input Error'); + expect(formatError.calledOnce).true; + expect(throwError.calledOnce).true; + }); + }); + + describe('lifecycle', () => { + async function startEngineServer({ port, check }) { + const engine = express(); + engine.use((req, _res, next) => { + // body parser requires a content-type + req.headers['content-type'] = 'text/plain'; + next(); + }); + engine.use( + bodyParser.raw({ + inflate: true, + type: '*/*', + }), + ); + engine.use(check); + return await engine.listen(port); + } + + it('validation > engine > extensions > formatError', async () => { + return new Promise(async (resolve, reject) => { + const nodeEnv = process.env.NODE_ENV; + delete process.env.NODE_ENV; + + let listener = await startEngineServer({ + port: 10101, + check: (req, res) => { + const trace = JSON.stringify(Trace.decode(req.body)); + try { + expect(trace).to.match(/nope/); + expect(trace).not.to.match(/masked/); + } catch (e) { + reject(e); + } + res.end(); + listener.close(resolve); + }, + }); + + const throwError = stub().callsFake(() => { + throw new Error('nope'); + }); + + const validationRule = stub().callsFake(() => { + expect( + formatError.notCalled, + 'formatError should be called after validation', + ).true; + expect( + extension.notCalled, + 'extension should be called after validation', + ).true; + return true; + }); + const extension = stub(); + + const formatError = stub().callsFake(error => { + expect(error instanceof Error).true; + expect( + extension.calledOnce, + 'extension should be called before formatError', + ).true; + expect( + validationRule.calledOnce, + 'validationRules should be called before formatError', + ).true; + + error.message = 'masked'; + return error; + }); + + class Extension extends GraphQLExtension { + willSendResponse(o: { graphqlResponse: GraphQLResponse }) { + expect(o.graphqlResponse.errors.length).to.equal(1); + expect( + formatError.notCalled, + 'formatError should be called after extensions', + ).true; + expect( + validationRule.calledOnce, + 'validationRules should be called before extensions', + ).true; + extension(); + } + } + + const { url: uri } = await createApolloServer({ + typeDefs: gql` + type Query { + error: String + } + `, + resolvers: { + Query: { + error: () => { + throwError(); + }, + }, + }, + validationRules: [validationRule], + extensions: [() => new Extension()], + engine: { + endpointUrl: 'http://localhost:10101', + apiKey: 'fake', + maxUncompressedReportSize: 1, + }, + formatError, + debug: true, + }); + + const apolloFetch = createApolloFetch({ uri }); + + const result = await apolloFetch({ + query: `{error}`, + }); + expect(result.data).to.deep.equal({ + error: null, + }); + expect(result.errors, 'errors should exist').to.exist; + expect(result.errors[0].message).to.equal('masked'); + expect(formatError.calledOnce).true; + expect(throwError.calledOnce).true; + + process.env.NODE_ENV = nodeEnv; + }); + }); + + it('errors thrown in extensions call formatError and are wrapped', async () => { + const extension = stub().callsFake(() => { + throw new Error('nope'); + }); + + const formatError = stub().callsFake(error => { + expect(error instanceof Error).true; + expect( + extension.calledOnce, + 'extension should be called before formatError', + ).true; + + error.message = 'masked'; + return error; + }); + + class Extension extends GraphQLExtension { + willSendResponse(_o: { graphqlResponse: GraphQLResponse }) { + expect( + formatError.notCalled, + 'formatError should be called after extensions', + ).true; + extension(); + } + } + + const { url: uri } = await createApolloServer({ + typeDefs: gql` + type Query { + error: String + } + `, + resolvers: { + Query: { + error: () => {}, + }, + }, + extensions: [() => new Extension()], + formatError, + debug: true, + }); + + const apolloFetch = createApolloFetch({ uri }); + + const result = await apolloFetch({ + query: `{error}`, + }); + expect(result.data, 'data should not exist').to.not.exist; + expect(result.errors, 'errors should exist').to.exist; + expect(result.errors[0].message).to.equal('masked'); + expect(result.errors[0].message).to.equal('masked'); + expect(formatError.calledOnce).true; + }); + + it('defers context eval with thunk until after options creation', async () => { + const uniqueContext = { key: 'major' }; + const typeDefs = gql` + type Query { + hello: String + } + `; + const resolvers = { + Query: { + hello: (_parent, _args, context) => { + expect(context).to.equal(Promise.resolve(uniqueContext)); + return 'hi'; + }, + }, + }; + const spy = stub().returns({}); + const { url: uri } = await createApolloServer({ + typeDefs, + resolvers, + context: spy, + }); + + const apolloFetch = createApolloFetch({ uri }); + + expect(spy.notCalled).true; + + await apolloFetch({ query: '{hello}' }); + expect(spy.calledOnce).true; + await apolloFetch({ query: '{hello}' }); + expect(spy.calledTwice).true; + }); + + it('allows context to be async function', async () => { + const uniqueContext = { key: 'major' }; + const spy = stub().returns('hi'); + const typeDefs = gql` + type Query { + hello: String + } + `; + const resolvers = { + Query: { + hello: (_parent, _args, context) => { + expect(context.key).to.equal('major'); + return spy(); + }, + }, + }; + const { url: uri } = await createApolloServer({ + typeDefs, + resolvers, + context: async () => uniqueContext, + }); + + const apolloFetch = createApolloFetch({ uri }); + + expect(spy.notCalled).true; + await apolloFetch({ query: '{hello}' }); + expect(spy.calledOnce).true; + }); + + it('clones the context for every request', async () => { + const uniqueContext = { key: 'major' }; + const spy = stub().returns('hi'); + const typeDefs = gql` + type Query { + hello: String + } + `; + const resolvers = { + Query: { + hello: (_parent, _args, context) => { + expect(context.key).to.equal('major'); + context.key = 'minor'; + return spy(); + }, + }, + }; + const { url: uri } = await createApolloServer({ + typeDefs, + resolvers, + context: uniqueContext, + }); + + const apolloFetch = createApolloFetch({ uri }); + + expect(spy.notCalled).true; + + await apolloFetch({ query: '{hello}' }); + expect(spy.calledOnce).true; + await apolloFetch({ query: '{hello}' }); + expect(spy.calledTwice).true; + }); + + it('returns thrown context error as a valid graphql result', async () => { + const nodeEnv = process.env.NODE_ENV; + delete process.env.NODE_ENV; + const typeDefs = gql` + type Query { + hello: String + } + `; + const resolvers = { + Query: { + hello: () => { + throw Error('never get here'); + }, + }, + }; + const { url: uri } = await createApolloServer({ + typeDefs, + resolvers, + context: () => { + throw new AuthenticationError('valid result'); + }, + }); + + const apolloFetch = createApolloFetch({ uri }); + + const result = await apolloFetch({ query: '{hello}' }); + expect(result.errors.length).to.equal(1); + expect(result.data).not.to.exist; + + const e = result.errors[0]; + expect(e.message).to.contain('valid result'); + expect(e.extensions).to.exist; + expect(e.extensions.code).to.equal('UNAUTHENTICATED'); + expect(e.extensions.exception.stacktrace).to.exist; + + process.env.NODE_ENV = nodeEnv; + }); + + it('propogates error codes in production', async () => { + const nodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + + const { url: uri } = await createApolloServer({ + typeDefs: gql` + type Query { + error: String + } + `, + resolvers: { + Query: { + error: () => { + throw new AuthenticationError('we the best music'); + }, + }, + }, + }); + + const apolloFetch = createApolloFetch({ uri }); + + const result = await apolloFetch({ query: `{error}` }); + expect(result.data).to.exist; + expect(result.data).to.deep.equal({ error: null }); + + expect(result.errors, 'errors should exist').to.exist; + expect(result.errors.length).to.equal(1); + expect(result.errors[0].extensions.code).to.equal('UNAUTHENTICATED'); + expect(result.errors[0].extensions.exception).not.to.exist; + + process.env.NODE_ENV = nodeEnv; + }); + + it('propogates error codes with null response in production', async () => { + const nodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + + const { url: uri } = await createApolloServer({ + typeDefs: gql` + type Query { + error: String! + } + `, + resolvers: { + Query: { + error: () => { + throw new AuthenticationError('we the best music'); + }, + }, + }, + }); + + const apolloFetch = createApolloFetch({ uri }); + + const result = await apolloFetch({ query: `{error}` }); + expect(result.data).null; + + expect(result.errors, 'errors should exist').to.exist; + expect(result.errors.length).to.equal(1); + expect(result.errors[0].extensions.code).to.equal('UNAUTHENTICATED'); + expect(result.errors[0].extensions.exception).not.to.exist; + + process.env.NODE_ENV = nodeEnv; + }); + }); + + describe('subscriptions', () => { + const SOMETHING_CHANGED_TOPIC = 'something_changed'; + const pubsub = new PubSub(); + let subscription; + + function createEvent(num) { + return setTimeout( + () => + pubsub.publish(SOMETHING_CHANGED_TOPIC, { + num, + }), + num + 10, + ); + } + + afterEach(async () => { + if (subscription) { + try { + await subscription.unsubscribe(); + } catch (e) {} + subscription = null; + } + }); + + it('enables subscriptions after creating subscriptions server', done => { + const typeDefs = gql` + type Query { + hi: String + } + + type Subscription { + num: Int + } + `; + + const query = ` + subscription { + num + } + `; + + const resolvers = { + Query: { + hi: () => 'here to placate graphql-js', + }, + Subscription: { + num: { + subscribe: () => { + createEvent(1); + createEvent(2); + createEvent(3); + return pubsub.asyncIterator(SOMETHING_CHANGED_TOPIC); + }, + }, + }, + }; + + createApolloServer({ + typeDefs, + resolvers, + }).then(({ port, server, httpServer }) => { + server.installSubscriptionHandlers(httpServer); + + const client = new SubscriptionClient( + `ws://localhost:${port}${server.subscriptionsPath}`, + {}, + WebSocket, + ); + + const observable = client.request({ query }); + + let i = 1; + subscription = observable.subscribe({ + next: ({ data }) => { + try { + expect(data.num).to.equal(i); + if (i === 3) { + done(); + } + i++; + } catch (e) { + done(e); + } + }, + error: done, + complete: () => { + done(new Error('should not complete')); + }, + }); + }); + }); + it('disables subscritpions when option set to false', done => { + const typeDefs = gql` + type Query { + "graphql-js forces there to be a query type" + hi: String + } + + type Subscription { + num: Int + } + `; + + const query = ` + subscription { + num + } + `; + + const resolvers = { + Query: { + hi: () => 'here to placate graphql-js', + }, + Subscription: { + num: { + subscribe: () => { + createEvent(1); + return pubsub.asyncIterator(SOMETHING_CHANGED_TOPIC); + }, + }, + }, + }; + + createApolloServer({ + typeDefs, + resolvers, + subscriptions: false, + }).then(({ port, server, httpServer }) => { + try { + server.installSubscriptionHandlers(httpServer); + expect.fail(); + } catch (e) { + expect(e.message).to.match(/disabled/); + } + + const client = new SubscriptionClient( + `ws://localhost:${port}${server.subscriptionsPath}`, + {}, + WebSocket, + ); + + const observable = client.request({ query }); + + subscription = observable.subscribe({ + next: () => { + done(new Error('should not call next')); + }, + error: () => { + done(new Error('should not notify of error')); + }, + complete: () => { + done(new Error('should not complete')); + }, + }); + + // Unfortunately the error connection is not propagated to the + // observable. What should happen is we provide a default onError + // function that notifies the returned observable and can cursomize + // the behavior with an option in the client constructor. If you're + // available to make a PR to the following please do! + // https://github.com/apollographql/subscriptions-transport-ws/blob/master/src/client.ts + client.onError((_: Error) => { + done(); + }); + }); + }); + it('accepts subscriptions configuration', done => { + const onConnect = stub().callsFake(connectionParams => ({ + ...connectionParams, + })); + const typeDefs = gql` + type Query { + hi: String + } + + type Subscription { + num: Int + } + `; + + const query = ` + subscription { + num + } + `; + + const resolvers = { + Query: { + hi: () => 'here to placate graphql-js', + }, + Subscription: { + num: { + subscribe: () => { + createEvent(1); + createEvent(2); + createEvent(3); + return pubsub.asyncIterator(SOMETHING_CHANGED_TOPIC); + }, + }, + }, + }; + + const path = '/sub'; + createApolloServer({ + typeDefs, + resolvers, + subscriptions: { onConnect, path }, + }) + .then(({ port, server, httpServer }) => { + server.installSubscriptionHandlers(httpServer); + expect(onConnect.notCalled).true; + + expect(server.subscriptionsPath).to.equal(path); + const client = new SubscriptionClient( + `ws://localhost:${port}${server.subscriptionsPath}`, + {}, + WebSocket, + ); + + const observable = client.request({ query }); + + let i = 1; + subscription = observable.subscribe({ + next: ({ data }) => { + try { + expect(onConnect.calledOnce).true; + expect(data.num).to.equal(i); + if (i === 3) { + done(); + } + i++; + } catch (e) { + done(e); + } + }, + error: done, + complete: () => { + done(new Error('should not complete')); + }, + }); + }) + .catch(done); + }); + }); + + describe('Persisted Queries', () => { + let uri: string; + const query = gql` + ${TEST_STRING_QUERY} + `; + const hash = sha256 + .create() + .update(TEST_STRING_QUERY) + .hex(); + const extensions = { + persistedQuery: { + version: VERSION, + sha256Hash: hash, + }, + }; + + beforeEach(async () => { + const serverInfo = await createApolloServer({ + schema, + introspection: false, + persistedQueries: { + cache: new Map() as any, + }, + }); + uri = serverInfo.url; + }); + + it('returns PersistedQueryNotFound on the first try', async () => { + const apolloFetch = createApolloFetch({ uri }); + + const result = await apolloFetch({ + extensions, + } as any); + + expect(result.data).not.to.exist; + expect(result.errors.length).to.equal(1); + expect(result.errors[0].message).to.equal('PersistedQueryNotFound'); + expect(result.errors[0].extensions.code).to.equal( + 'PERSISTED_QUERY_NOT_FOUND', + ); + }); + it('returns result on the second try', async () => { + const apolloFetch = createApolloFetch({ uri }); + + await apolloFetch({ + extensions, + } as any); + const result = await apolloFetch({ + extensions, + query: TEST_STRING_QUERY, + } as any); + + expect(result.data).to.deep.equal({ testString: 'test string' }); + expect(result.errors).not.to.exist; + }); + + it('returns result on the persisted query', async () => { + const apolloFetch = createApolloFetch({ uri }); + + await apolloFetch({ + extensions, + } as any); + await apolloFetch({ + extensions, + query: TEST_STRING_QUERY, + } as any); + const result = await apolloFetch({ + extensions, + } as any); + + expect(result.data).to.deep.equal({ testString: 'test string' }); + expect(result.errors).not.to.exist; + }); + + // Apollo Fetch's result depends on the server implementation, if the + // statusText of the error is unparsable, then we'll fall into the catch, + // such as with express. If it is parsable, then we'll use the afterware + it('returns error when hash does not match', async () => { + const apolloFetch = createApolloFetch({ uri }).useAfter((res, next) => { + expect(res.response.status).to.equal(400); + expect(res.response.raw).to.match(/does not match query/); + next(); + }); + + try { + await apolloFetch({ + extensions: { + persistedQuery: { + version: VERSION, + sha: + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + }, + }, + query: TEST_STRING_QUERY, + } as any); + } catch (e) { + expect(e.response).to.exist; + expect(e.response.status).to.equal(400); + expect(e.response.raw).to.match(/does not match query/); + } + }); + + it('returns correct result for persisted query link', done => { + const variables = { id: 1 }; + const link = createPersistedQuery().concat( + createHttpLink({ uri, fetch } as any), + ); + + execute(link, { query, variables } as any).subscribe(result => { + expect(result.data).to.deep.equal({ testString: 'test string' }); + done(); + }, done); + }); + + it('returns correct result for persisted query link using get request', done => { + const variables = { id: 1 }; + const link = createPersistedQuery({ + useGETForHashedQueries: true, + }).concat(createHttpLink({ uri, fetch } as any)); + + execute(link, { query, variables } as any).subscribe(result => { + expect(result.data).to.deep.equal({ testString: 'test string' }); + done(); + }, done); + }); + }); + }); +} diff --git a/packages/apollo-server-integration-testsuite/src/index.ts b/packages/apollo-server-integration-testsuite/src/index.ts index 3ca17a45e68..0a4c5bae809 100644 --- a/packages/apollo-server-integration-testsuite/src/index.ts +++ b/packages/apollo-server-integration-testsuite/src/index.ts @@ -2,6 +2,10 @@ import { expect } from 'chai'; import { stub } from 'sinon'; import 'mocha'; +// persisted query tests +import { sha256 } from 'js-sha256'; +import { VERSION } from 'apollo-link-persisted-queries'; + import { GraphQLSchema, GraphQLObjectType, @@ -9,17 +13,72 @@ import { GraphQLInt, GraphQLError, GraphQLNonNull, + GraphQLScalarType, introspectionQuery, BREAK, } from 'graphql'; -// tslint:disable-next-line -const request = require('supertest'); +import request = require('supertest'); + +import { GraphQLOptions, Config } from 'apollo-server-core'; +import gql from 'graphql-tag'; + +export * from './ApolloServer'; + +const QueryRootType = new GraphQLObjectType({ + name: 'QueryRoot', + fields: { + test: { + type: GraphQLString, + args: { + who: { + type: GraphQLString, + }, + }, + resolve: (_, args) => 'Hello ' + (args['who'] || 'World'), + }, + thrower: { + type: new GraphQLNonNull(GraphQLString), + resolve: () => { + throw new Error('Throws!'); + }, + }, + custom: { + type: GraphQLString, + args: { + foo: { + type: new GraphQLScalarType({ + name: 'Foo', + serialize: v => v, + parseValue: () => { + throw new Error('Something bad happened'); + }, + parseLiteral: () => { + throw new Error('Something bad happened'); + }, + }), + }, + }, + }, + context: { + type: GraphQLString, + resolve: (_obj, _args, context) => context, + }, + }, +}); -import { GraphQLOptions } from 'apollo-server-core'; -import * as GraphiQL from 'apollo-server-module-graphiql'; -import { OperationStore } from 'apollo-server-module-operation-store'; -import { LogAction } from '../../apollo-server-core/dist'; +const TestSchema = new GraphQLSchema({ + query: QueryRootType, + mutation: new GraphQLObjectType({ + name: 'MutationRoot', + fields: { + writeTest: { + type: QueryRootType, + resolve: () => ({}), + }, + }, + }), +}); const personType = new GraphQLObjectType({ name: 'PersonType', @@ -53,15 +112,15 @@ const queryType = new GraphQLObjectType({ args: { delay: { type: new GraphQLNonNull(GraphQLInt) }, }, - resolve(root, args) { - return new Promise((resolve, reject) => { + resolve(_, args) { + return new Promise(resolve => { setTimeout(() => resolve('it works'), args['delay']); }); }, }, testContext: { type: GraphQLString, - resolve(_, args, context) { + resolve(_root, _args, context) { if (context.otherField) { return 'unexpected'; } @@ -78,7 +137,7 @@ const queryType = new GraphQLObjectType({ testArgument: { type: GraphQLString, args: { echo: { type: GraphQLString } }, - resolve(root, { echo }) { + resolve(_, { echo }) { return `hello ${echo}`; }, }, @@ -97,7 +156,7 @@ const mutationType = new GraphQLObjectType({ testMutation: { type: GraphQLString, args: { echo: { type: GraphQLString } }, - resolve(root, { echo }) { + resolve(_, { echo }) { return `not really a mutation, but who cares: ${echo}`; }, }, @@ -111,7 +170,7 @@ const mutationType = new GraphQLObjectType({ type: new GraphQLNonNull(GraphQLString), }, }, - resolve(root, args) { + resolve(_, args) { return args; }, }, @@ -127,10 +186,8 @@ export interface CreateAppOptions { excludeParser?: boolean; graphqlOptions?: | GraphQLOptions - | { (): GraphQLOptions | Promise }; - graphiqlOptions?: - | GraphiQL.GraphiQLData - | { (): GraphiQL.GraphiQLData | Promise }; + | { (): GraphQLOptions | Promise } + | Config; } export interface CreateAppFunc { @@ -157,66 +214,8 @@ export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => { }); describe('graphqlHTTP', () => { - it('can be called with an options function', async () => { - app = await createApp({ - graphqlOptions: (): GraphQLOptions => ({ schema }), - }); - const expected = { - testString: 'it works', - }; - const req = request(app) - .post('/graphql') - .send({ - query: 'query test{ testString }', - }); - return req.then(res => { - expect(res.status).to.equal(200); - expect(res.body.data).to.deep.equal(expected); - }); - }); - - it('can be called with an options function that returns a promise', async () => { - app = await createApp({ - graphqlOptions: () => { - return new Promise(resolve => { - resolve({ schema }); - }); - }, - }); - const expected = { - testString: 'it works', - }; - const req = request(app) - .post('/graphql') - .send({ - query: 'query test{ testString }', - }); - return req.then(res => { - expect(res.status).to.equal(200); - expect(res.body.data).to.deep.equal(expected); - }); - }); - - it('throws an error if options promise is rejected', async () => { - app = await createApp({ - graphqlOptions: () => { - return (Promise.reject({}) as any) as GraphQLOptions; - }, - }); - const expected = 'Invalid options'; - const req = request(app) - .post('/graphql') - .send({ - query: 'query test{ testString }', - }); - return req.then(res => { - expect(res.status).to.equal(500); - expect(res.error.text).to.contain(expected); - }); - }); - it('rejects the request if the method is not POST or GET', async () => { - app = await createApp({ excludeParser: true }); + app = await createApp(); const req = request(app) .head('/graphql') .send(); @@ -227,7 +226,7 @@ export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => { }); it('throws an error if POST body is missing', async () => { - app = await createApp({ excludeParser: true }); + app = await createApp(); const req = request(app) .post('/graphql') .send(); @@ -382,7 +381,14 @@ export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => { it('can handle a basic request with cacheControl and defaultMaxAge', async () => { app = await createApp({ - graphqlOptions: { schema, cacheControl: { defaultMaxAge: 5 } }, + graphqlOptions: { + schema, + cacheControl: { + defaultMaxAge: 5, + stripFormattedExtensions: false, + calculateCacheControlHeaders: false, + }, + }, }); const expected = { testPerson: { firstName: 'Jane' }, @@ -404,8 +410,10 @@ export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => { }); }); - it('returns PersistedQueryNotSupported to a GET request', async () => { - app = await createApp(); + it('returns PersistedQueryNotSupported to a GET request if PQs disabled', async () => { + app = await createApp({ + graphqlOptions: { schema, persistedQueries: false }, + }); const req = request(app) .get('/graphql') .query({ @@ -419,13 +427,61 @@ export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => { }); return req.then(res => { expect(res.status).to.equal(200); - expect(res.body).to.deep.equal({ - errors: [{ message: 'PersistedQueryNotSupported' }], + expect(res.body.errors).to.exist; + expect(res.body.errors.length).to.equal(1); + expect(res.body.errors[0].message).to.equal( + 'PersistedQueryNotSupported', + ); + }); + }); + + it('returns PersistedQueryNotSupported to a POST request if PQs disabled', async () => { + app = await createApp({ + graphqlOptions: { schema, persistedQueries: false }, + }); + const req = request(app) + .post('/graphql') + .send({ + extensions: { + persistedQuery: { + version: 1, + sha256Hash: + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + }, + }, + }); + return req.then(res => { + expect(res.status).to.equal(200); + expect(res.body.errors).to.exist; + expect(res.body.errors.length).to.equal(1); + expect(res.body.errors[0].message).to.equal( + 'PersistedQueryNotSupported', + ); + }); + }); + + it('returns PersistedQueryNotFound to a GET request', async () => { + app = await createApp(); + const req = request(app) + .get('/graphql') + .query({ + extensions: JSON.stringify({ + persistedQuery: { + version: 1, + sha256Hash: + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + }, + }), }); + return req.then(res => { + expect(res.status).to.equal(200); + expect(res.body.errors).to.exist; + expect(res.body.errors.length).to.equal(1); + expect(res.body.errors[0].message).to.equal('PersistedQueryNotFound'); }); }); - it('returns PersistedQueryNotSupported to a POST request', async () => { + it('returns PersistedQueryNotFound to a POST request', async () => { app = await createApp(); const req = request(app) .post('/graphql') @@ -440,9 +496,9 @@ export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => { }); return req.then(res => { expect(res.status).to.equal(200); - expect(res.body).to.deep.equal({ - errors: [{ message: 'PersistedQueryNotSupported' }], - }); + expect(res.body.errors).to.exist; + expect(res.body.errors.length).to.equal(1); + expect(res.body.errors[0].message).to.equal('PersistedQueryNotFound'); }); }); @@ -527,6 +583,23 @@ export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => { }); }); + it('does not accept a query AST', async () => { + app = await createApp(); + const req = request(app) + .post('/graphql') + .send({ + query: gql` + query test { + testString + } + `, + }); + return req.then(res => { + expect(res.status).to.equal(400); + expect(res.text).to.contain('GraphQL queries must be strings'); + }); + }); + it('can handle batch requests', async () => { app = await createApp(); const expected = [ @@ -684,7 +757,14 @@ export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => { }, ]); return req.then(res => { - expect(callCount).to.equal(2); + // XXX In AS 1.0 we ran context once per GraphQL operation (so this + // was 2) rather than once per HTTP request. Was this actually + // helpful? Honestly we're not sure why you'd use a function in the + // 1.0 API anyway since the function didn't actually get any useful + // arguments. Right now there's some weirdness where a context + // function is actually evaluated both inside ApolloServer and in + // runHttpQuery. + expect(callCount).to.equal(1); expect(res.status).to.equal(200); expect(res.body).to.deep.equal(expected); }); @@ -791,7 +871,10 @@ export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => { app = await createApp({ graphqlOptions: { schema, - formatError: err => ({ message: expected }), + formatError: error => { + expect(error instanceof Error).true; + return { message: expected }; + }, }, }); const req = request(app) @@ -805,11 +888,94 @@ export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => { }); }); + it('formatError receives error that passes instanceof checks', async () => { + const expected = '--blank--'; + app = await createApp({ + graphqlOptions: { + schema, + formatError: error => { + expect(error instanceof Error).true; + expect(error instanceof GraphQLError).true; + return { message: expected }; + }, + }, + }); + const req = request(app) + .post('/graphql') + .send({ + query: 'query test{ testError }', + }); + return req.then(res => { + expect(res.status).to.equal(200); + expect(res.body.errors[0].message).to.equal(expected); + }); + }); + + it('allows for custom error formatting to sanitize', async () => { + app = await createApp({ + graphqlOptions: { + schema: TestSchema, + formatError(error) { + return { message: 'Custom error format: ' + error.message }; + }, + }, + }); + + const response = await request(app) + .post('/graphql') + .send({ + query: '{thrower}', + }); + + expect(response.status).to.equal(200); + expect(JSON.parse(response.text)).to.deep.equal({ + data: null, + errors: [ + { + message: 'Custom error format: Throws!', + }, + ], + }); + }); + + it('allows for custom error formatting to elaborate', async () => { + app = await createApp({ + graphqlOptions: { + schema: TestSchema, + formatError(error) { + return { + message: error.message, + locations: error.locations, + stack: 'Stack trace', + }; + }, + }, + }); + + const response = await request(app) + .post('/graphql') + .send({ + query: '{thrower}', + }); + + expect(response.status).to.equal(200); + expect(JSON.parse(response.text)).to.deep.equal({ + data: null, + errors: [ + { + message: 'Throws!', + locations: [{ line: 1, column: 2 }], + stack: 'Stack trace', + }, + ], + }); + }); + it('sends internal server error when formatError fails', async () => { app = await createApp({ graphqlOptions: { schema, - formatError: err => { + formatError: () => { throw new Error('I should be caught'); }, }, @@ -826,9 +992,9 @@ export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => { it('sends stack trace to error if debug mode is set', async () => { const expected = /at resolveFieldValueOrError/; - const stackTrace = []; const origError = console.error; - console.error = (...args) => stackTrace.push(args); + const err = stub(); + console.error = err; app = await createApp({ graphqlOptions: { schema, @@ -840,9 +1006,12 @@ export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => { .send({ query: 'query test{ testError }', }); - return req.then(res => { + return req.then(() => { console.error = origError; - expect(stackTrace[0][0]).to.match(expected); + if (err.called) { + expect(err.calledOnce); + expect(err.getCall(0).args[0]).to.match(expected); + } }); }); @@ -860,10 +1029,12 @@ export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => { .send({ query: 'query test{ testError }', }); - return req.then(res => { + return req.then(() => { logStub.restore(); - expect(logStub.callCount).to.equal(1); - expect(logStub.getCall(0).args[0]).to.match(expected); + if (logStub.called) { + expect(logStub.callCount).to.equal(1); + expect(logStub.getCall(0).args[0]).to.match(expected); + } }); }); @@ -895,213 +1066,195 @@ export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => { }); }); - describe('renderGraphiQL', () => { - it('presents GraphiQL when accepting HTML', async () => { - app = await createApp({ - graphiqlOptions: { - endpointURL: '/graphql', - }, - }); + describe('server setup', () => { + it('throws error on 404 routes', async () => { + app = await createApp(); + const query = { + query: '{ testString }', + }; const req = request(app) - .get('/graphiql') - .query('query={test}') - .set('Accept', 'text/html'); - return req.then(response => { - expect(response.status).to.equal(200); - expect(response.type).to.equal('text/html'); - expect(response.text).to.include('{test}'); - expect(response.text).to.include('/graphql'); - expect(response.text).to.include('graphiql.min.js'); + .get('/bogus-route') + .query(query); + return req.then(res => { + expect(res.status).to.equal(404); }); }); + }); - it('allows options to be a function', async () => { - app = await createApp({ - graphiqlOptions: () => ({ - endpointURL: '/graphql', - }), - }); - - const req = request(app) - .get('/graphiql') - .set('Accept', 'text/html'); - return req.then(response => { - expect(response.status).to.equal(200); - }); - }); + describe('Persisted Queries', () => { + const query = '{testString}'; + const query2 = '{ testString }'; + + const hash = sha256 + .create() + .update(query) + .hex(); + const extensions = { + persistedQuery: { + version: VERSION, + sha256Hash: hash, + }, + }; + + const extensions2 = { + persistedQuery: { + version: VERSION, + sha256Hash: sha256 + .create() + .update(query2) + .hex(), + }, + }; - it('handles options function errors', async () => { - app = await createApp({ - graphiqlOptions: () => { - throw new Error('I should be caught'); + beforeEach(async () => { + const map = new Map(); + const cache = { + set: async (key, val) => { + await map.set(key, val); }, - }); - - const req = request(app) - .get('/graphiql') - .set('Accept', 'text/html'); - return req.then(response => { - expect(response.status).to.equal(500); - }); - }); - - it('presents options variables', async () => { + get: async key => map.get(key), + }; app = await createApp({ - graphiqlOptions: { - endpointURL: '/graphql', - variables: { key: 'optionsValue' }, + graphqlOptions: { + schema, + persistedQueries: { + cache, + }, }, }); - - const req = request(app) - .get('/graphiql') - .set('Accept', 'text/html'); - return req.then(response => { - expect(response.status).to.equal(200); - expect(response.text.replace(/\s/g, '')).to.include( - 'variables:"{\\n\\"key\\":\\"optionsValue\\"\\n}"', - ); - }); }); - it('presents query variables over options variables', async () => { - app = await createApp({ - graphiqlOptions: { - endpointURL: '/graphql', - variables: { key: 'optionsValue' }, - }, - }); + it('returns PersistedQueryNotFound on the first try', async () => { + const result = await request(app) + .post('/graphql') + .send({ + extensions, + }); - const req = request(app) - .get('/graphiql?variables={"key":"queryValue"}') - .set('Accept', 'text/html'); - return req.then(response => { - expect(response.status).to.equal(200); - expect(response.text.replace(/\s/g, '')).to.include( - 'variables:"{\\n\\"key\\":\\"queryValue\\"\\n}"', - ); - }); + expect(result.body.data).not.to.exist; + expect(result.body.errors.length).to.equal(1); + expect(result.body.errors[0].message).to.equal( + 'PersistedQueryNotFound', + ); + expect(result.body.errors[0].extensions.code).to.equal( + 'PERSISTED_QUERY_NOT_FOUND', + ); }); - }); - - describe('stored queries', () => { - it('works with formatParams', async () => { - const store = new OperationStore(schema); - store.put('query testquery{ testString }'); - app = await createApp({ - graphqlOptions: { - schema, - formatParams(params) { - params['query'] = store.get(params.operationName); - return params; - }, - }, - }); - const expected = { testString: 'it works' }; - const req = request(app) + it('returns result on the second try', async () => { + await request(app) .post('/graphql') .send({ - operationName: 'testquery', + extensions, }); - return req.then(res => { - expect(res.status).to.equal(200); - expect(res.body.data).to.deep.equal(expected); - }); + const result = await request(app) + .post('/graphql') + .send({ + extensions, + query, + }); + + expect(result.body.data).to.deep.equal({ testString: 'it works' }); + expect(result.body.errors).not.to.exist; }); - it('can reject non-whitelisted queries', async () => { - const store = new OperationStore(schema); - store.put('query testquery{ testString }'); - app = await createApp({ - graphqlOptions: { - schema, - formatParams(params) { - if (params.query) { - throw new Error('Must not provide query, only operationName'); - } - params['query'] = store.get(params.operationName); - return params; + it('returns with batched persisted queries', async () => { + const errors = await request(app) + .post('/graphql') + .send([ + { + extensions, }, - }, - }); - const expected = [ - { - data: { - testString: 'it works', + { + extensions: extensions2, }, - }, - { - errors: [ - { - message: 'Must not provide query, only operationName', - }, - ], - }, - ]; + ]); - const req = request(app) + expect(errors.body[0].data).to.not.exist; + expect(errors.body[1].data).to.not.exist; + expect(errors.body[0].errors[0].message).to.equal( + 'PersistedQueryNotFound', + ); + expect(errors.body[0].errors[0].extensions.code).to.equal( + 'PERSISTED_QUERY_NOT_FOUND', + ); + expect(errors.body[1].errors[0].message).to.equal( + 'PersistedQueryNotFound', + ); + expect(errors.body[1].errors[0].extensions.code).to.equal( + 'PERSISTED_QUERY_NOT_FOUND', + ); + + const result = await request(app) .post('/graphql') .send([ { - operationName: 'testquery', + extensions, + query, }, { - query: '{ testString }', + extensions: extensions2, + query: query2, }, ]); - return req.then(res => { - expect(res.status).to.equal(200); - expect(res.body).to.deep.equal(expected); - }); + + expect(result.body[0].data).to.deep.equal({ testString: 'it works' }); + expect(result.body[0].data).to.deep.equal({ testString: 'it works' }); + expect(result.body.errors).not.to.exist; }); - it('do not validate if query is already an AST', async () => { - const store = new OperationStore(schema); - let validationCalled = false; - store.put('query testquery{ testString }'); - app = await createApp({ - graphqlOptions: { - schema, - formatParams(params) { - params['query'] = store.get(params.operationName); - params['skipValidation'] = true; - return params; - }, - logFunction: ({ action }) => { - if (action == LogAction.validation) { - validationCalled = true; - } - }, - }, - }); - const req = request(app) + it('returns result on the persisted query', async () => { + await request(app) .post('/graphql') .send({ - operationName: 'testquery', + extensions, }); - return req.then(res => { - return expect( - validationCalled, - 'Validation should not be called if skipValidation option provided', - ).to.equal(false); - }); + await request(app) + .post('/graphql') + .send({ + extensions, + query, + }); + const result = await request(app) + .post('/graphql') + .send({ + extensions, + }); + + expect(result.body.data).to.deep.equal({ testString: 'it works' }); + expect(result.body.errors).not.to.exist; }); - }); - describe('server setup', () => { - it('throws error on 404 routes', async () => { - app = await createApp(); + it('returns error when hash does not match', async () => { + const response = await request(app) + .post('/graphql') + .send({ + extensions: { + persistedQuery: { + version: VERSION, + sha: + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + }, + }, + query, + }); + expect(response.status).to.equal(400); + expect(response.error.text).to.match(/does not match query/); + }); - const query = { - query: '{ testString }', - }; - const req = request(app) - .get('/bogus-route') - .query(query); - return req.then(res => { - expect(res.status).to.equal(404); - }); + it('returns correct result using get request', async () => { + await request(app) + .post('/graphql') + .send({ + extensions, + query, + }); + const result = await request(app) + .get('/graphql') + .query({ + extensions: JSON.stringify(extensions), + }); + expect(result.body.data).to.deep.equal({ testString: 'it works' }); }); }); }); diff --git a/packages/apollo-server-integration-testsuite/tsconfig.json b/packages/apollo-server-integration-testsuite/tsconfig.json index 8e99768afe9..5ac3c46b1f6 100644 --- a/packages/apollo-server-integration-testsuite/tsconfig.json +++ b/packages/apollo-server-integration-testsuite/tsconfig.json @@ -1,9 +1,9 @@ { - "extends": "../../tsconfig", + "extends": "../../tsconfig.json", "compilerOptions": { "rootDir": "./src", "outDir": "./dist", - "typeRoots": ["node_modules/@types"] + "lib": ["es2017", "esnext.asynciterable", "dom"] }, "exclude": ["node_modules", "dist"] } diff --git a/packages/apollo-server-koa/README.md b/packages/apollo-server-koa/README.md index a114f917817..862a172f989 100644 --- a/packages/apollo-server-koa/README.md +++ b/packages/apollo-server-koa/README.md @@ -3,75 +3,50 @@ title: Koa description: Setting up Apollo Server with Koa --- -[![npm version](https://badge.fury.io/js/apollo-server-core.svg)](https://badge.fury.io/js/apollo-server-core) [![Build Status](https://circleci.com/gh/apollographql/apollo-cache-control-js.svg?style=svg)](https://circleci.com/gh/apollographql/apollo-cache-control-js) [![Coverage Status](https://coveralls.io/repos/github/apollographql/apollo-server/badge.svg?branch=master)](https://coveralls.io/github/apollographql/apollo-server?branch=master) [![Get on Slack](https://img.shields.io/badge/slack-join-orange.svg)](https://www.apollographql.com/#slack) +[![npm version](https://badge.fury.io/js/apollo-server-koa.svg)](https://badge.fury.io/js/apollo-server-koa) [![Build Status](https://circleci.com/gh/apollographql/apollo-server.svg?style=svg)](https://circleci.com/gh/apollographql/apollo-server) [![Coverage Status](https://coveralls.io/repos/github/apollographql/apollo-server/badge.svg?branch=master)](https://coveralls.io/github/apollographql/apollo-server?branch=master) [![Get on Slack](https://img.shields.io/badge/slack-join-orange.svg)](https://www.apollographql.com/#slack) -This is the Koa integration of Apollo Server. Apollo Server is a community-maintained open-source Apollo Server that works with all Node.js HTTP server frameworks: Express, Connect, Hapi, Koa and Restify. [Read the docs](https://www.apollographql.com/docs/apollo-server/). [Read the CHANGELOG.](https://github.com/apollographql/apollo-server/blob/master/CHANGELOG.md) +This is the Koa integration of GraphQL Server. Apollo Server is a community-maintained open-source GraphQL server that works with many Node.js HTTP server frameworks. [Read the docs](https://www.apollographql.com/docs/apollo-server/). [Read the CHANGELOG.](https://github.com/apollographql/apollo-server/blob/master/CHANGELOG.md) ```sh -npm install apollo-server-koa +npm install apollo-server-koa@rc graphql ``` -## Usage +## Koa ```js -import koa from 'koa'; // koa@2 -import koaRouter from 'koa-router'; -import koaBody from 'koa-bodyparser'; -import { graphqlKoa } from 'apollo-server-koa'; - -const app = new koa(); -const router = new koaRouter(); -const PORT = 3000; - -// koaBody is needed just for POST. -app.use(koaBody()); - -router.post('/graphql', graphqlKoa({ schema: myGraphQLSchema })); -router.get('/graphql', graphqlKoa({ schema: myGraphQLSchema })); - -app.use(router.routes()); -app.use(router.allowedMethods()); -app.listen(PORT); -``` - -### GraphiQL - -You can also use `apollo-server-koa` for hosting the [GraphiQL](https://github.com/graphql/graphiql) in-browser IDE. Note the difference between `graphqlKoa` and `graphiqlKoa`. - -```js -import { graphiqlKoa } from 'apollo-server-koa'; - -// Setup the /graphiql route to show the GraphiQL UI -router.get( - '/graphiql', - graphiqlKoa({ - endpointURL: '/graphql', // a POST endpoint that GraphiQL will make the actual requests to - }), -); -``` - -In case your GraphQL endpoint is protected via authentication, or if you need to pass other custom headers in the request that GraphiQL makes, you can use the [`passHeader`](https://github.com/apollographql/apollo-server/blob/v1.0.2/packages/apollo-server-module-graphiql/src/renderGraphiQL.ts#L17) option – a string that will be added to the request header object. - -For example: - -```js -import { graphiqlKoa } from 'apollo-server-koa'; - -router.get( - '/graphiql', - graphiqlKoa({ - endpointURL: '/graphql', - passHeader: `'Authorization': 'Bearer lorem ipsum'`, - }), +const Koa = require('koa'); +const { ApolloServer, gql } = require('apollo-server-koa'); + +// Construct a schema, using GraphQL schema language +const typeDefs = gql` + type Query { + hello: String + } +`; + +// Provide resolver functions for your schema fields +const resolvers = { + Query: { + hello: () => 'Hello world!', + }, +}; + +const server = new ApolloServer({ typeDefs, resolvers }); + +const app = new Koa(); +server.applyMiddleware({ app }); + +app.listen({ port: 4000 }, () => + console.log(`🚀 Server ready at http://localhost:4000${server.graphqlPath}`), ); ``` ## Principles -Apollo Server is built with the following principles in mind: +GraphQL Server is built with the following principles in mind: -* **By the community, for the community**: Apollo Server's development is driven by the needs of developers -* **Simplicity**: by keeping things simple, Apollo Server is easier to use, easier to contribute to, and more secure -* **Performance**: Apollo Server is well-tested and production-ready - no modifications needed +- **By the community, for the community**: GraphQL Server's development is driven by the needs of developers +- **Simplicity**: by keeping things simple, GraphQL Server is easier to use, easier to contribute to, and more secure +- **Performance**: GraphQL Server is well-tested and production-ready - no modifications needed -Anyone is welcome to contribute to Apollo Server, just read [CONTRIBUTING.md](https://github.com/apollographql/apollo-server/blob/master/CONTRIBUTING.md), take a look at the [roadmap](https://github.com/apollographql/apollo-server/blob/master/ROADMAP.md) and make your first PR! +Anyone is welcome to contribute to GraphQL Server, just read [CONTRIBUTING.md](https://github.com/apollographql/apollo-server/blob/master/CONTRIBUTING.md), take a look at the [roadmap](https://github.com/apollographql/apollo-server/blob/master/ROADMAP.md) and make your first PR! diff --git a/packages/apollo-server-koa/package.json b/packages/apollo-server-koa/package.json index 48192289939..ad867684f8e 100644 --- a/packages/apollo-server-koa/package.json +++ b/packages/apollo-server-koa/package.json @@ -1,6 +1,6 @@ { "name": "apollo-server-koa", - "version": "1.4.0", + "version": "2.0.0-rc.7", "description": "Production-ready Node.js GraphQL server for Koa", "main": "dist/index.js", "scripts": { @@ -14,29 +14,49 @@ "keywords": [ "GraphQL", "Apollo", - "Koa", "Server", + "Koa", "Javascript" ], - "author": "Jonas Helfer ", + "author": "opensource@apollographql.com", "license": "MIT", "bugs": { "url": "https://github.com/apollographql/apollo-server/issues" }, "homepage": "https://github.com/apollographql/apollo-server#readme", + "engines": { + "node": ">=6" + }, "dependencies": { - "apollo-server-core": "^1.4.0", - "apollo-server-module-graphiql": "^1.4.0" + "@apollographql/graphql-playground-html": "^1.6.0", + "@koa/cors": "^2.2.1", + "@types/accepts": "^1.3.5", + "@types/cors": "^2.8.4", + "@types/koa": "^2.0.46", + "@types/koa-bodyparser": "^5.0.1", + "@types/koa-compose": "^3.2.2", + "@types/koa__cors": "^2.2.1", + "accepts": "^1.3.5", + "apollo-server-core": "^2.0.0-rc.7", + "apollo-upload-server": "^5.0.0", + "graphql-subscriptions": "^0.5.8", + "graphql-tools": "^3.0.4", + "koa": "2.5.1", + "koa-bodyparser": "^3.0.0", + "koa-router": "^7.4.0", + "type-is": "^1.6.16" }, "devDependencies": { - "@types/graphql": "0.12.7", - "@types/koa": "2.0.46", - "@types/koa-bodyparser": "4.2.0", - "@types/koa-router": "7.0.30", - "apollo-server-integration-testsuite": "^1.4.0", - "koa": "2.5.2", - "koa-bodyparser": "4.2.1", - "koa-router": "7.4.0" + "@types/koa-multer": "^1.0.0", + "@types/koa-router": "^7.0.30", + "apollo-datasource-rest": "^2.0.0-rc.7", + "apollo-server-integration-testsuite": "^2.0.0-rc.7", + "form-data": "^2.3.2", + "koa-multer": "^1.0.2", + "node-fetch": "^2.1.2" + }, + "peerDependencies": { + "graphql": "^0.12.0 || ^0.13.0 || ^14.0.0" }, "typings": "dist/index.d.ts", "typescript": { diff --git a/packages/apollo-server-koa/src/ApolloServer.test.ts b/packages/apollo-server-koa/src/ApolloServer.test.ts new file mode 100644 index 00000000000..689c340667d --- /dev/null +++ b/packages/apollo-server-koa/src/ApolloServer.test.ts @@ -0,0 +1,759 @@ +import { expect } from 'chai'; +import 'mocha'; +import * as Koa from 'koa'; + +import * as http from 'http'; + +import * as request from 'request'; +import * as FormData from 'form-data'; +import * as fs from 'fs'; +import { createApolloFetch } from 'apollo-fetch'; + +import { gql, AuthenticationError, Config } from 'apollo-server-core'; +import { ApolloServer, ServerRegistration } from './ApolloServer'; + +import { + testApolloServer, + createServerInfo, +} from 'apollo-server-integration-testsuite'; + +const typeDefs = gql` + type Query { + hello: String + } +`; + +const resolvers = { + Query: { + hello: () => 'hi', + }, +}; + +describe('apollo-server-koa', () => { + let server; + let httpServer; + testApolloServer( + async options => { + server = new ApolloServer(options); + const app = new Koa(); + server.applyMiddleware({ app }); + httpServer = await new Promise(resolve => { + const s = app.listen({ port: 4000 }, () => resolve(s)); + }); + return createServerInfo(server, httpServer); + }, + async () => { + if (server) await server.stop(); + if (httpServer && httpServer.listening) await httpServer.close(); + }, + ); +}); + +describe('apollo-server-koa', () => { + let server: ApolloServer; + + let app: Koa; + let httpServer: http.Server; + + async function createServer( + serverOptions: Config, + options: Partial = {}, + ) { + server = new ApolloServer(serverOptions); + app = new Koa(); + + server.applyMiddleware({ ...options, app }); + + httpServer = await new Promise(resolve => { + const l = app.listen({ port: 4000 }, () => resolve(l)); + }); + + return createServerInfo(server, httpServer); + } + + afterEach(async () => { + if (server) await server.stop(); + if (httpServer) await httpServer.close(); + }); + + describe('constructor', () => { + it('accepts typeDefs and resolvers', () => { + return createServer({ typeDefs, resolvers }); + }); + }); + + describe('applyMiddleware', () => { + it('can be queried', async () => { + const { url: uri } = await createServer({ + typeDefs, + resolvers, + }); + const apolloFetch = createApolloFetch({ uri }); + const result = await apolloFetch({ query: '{hello}' }); + + expect(result.data).to.deep.equal({ hello: 'hi' }); + expect(result.errors, 'errors should exist').not.to.exist; + }); + + // XXX Unclear why this would be something somebody would want (vs enabling + // introspection without graphql-playground, which seems reasonable, eg you + // have your own graphql-playground setup with a custom link) + it('can enable playground separately from introspection during production', async () => { + const INTROSPECTION_QUERY = ` + { + __schema { + directives { + name + } + } + } +`; + + const { url: uri } = await createServer({ + typeDefs, + resolvers, + introspection: false, + }); + + const apolloFetch = createApolloFetch({ uri }); + const result = await apolloFetch({ query: INTROSPECTION_QUERY }); + + expect(result.errors.length).to.equal(1); + expect(result.errors[0].extensions.code).to.equal( + 'GRAPHQL_VALIDATION_FAILED', + ); + + return new Promise((resolve, reject) => { + request( + { + url: uri, + method: 'GET', + headers: { + accept: + 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8', + }, + }, + (error, response, body) => { + if (error) { + reject(error); + } else { + expect(body).to.contain('GraphQLPlayground'); + expect(response.statusCode).to.equal(200); + resolve(); + } + }, + ); + }); + }); + + it('renders GraphQL playground when browser requests', async () => { + const nodeEnv = process.env.NODE_ENV; + delete process.env.NODE_ENV; + + const { url } = await createServer({ + typeDefs, + resolvers, + }); + + return new Promise((resolve, reject) => { + request( + { + url, + method: 'GET', + headers: { + accept: + 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8', + }, + }, + (error, response, body) => { + process.env.NODE_ENV = nodeEnv; + if (error) { + reject(error); + } else { + expect(body).to.contain('GraphQLPlayground'); + expect(response.statusCode).to.equal(200); + resolve(); + } + }, + ); + }); + }); + + it('accepts cors configuration', async () => { + const { url: uri } = await createServer( + { + typeDefs, + resolvers, + }, + { + cors: { origin: 'apollographql.com' }, + }, + ); + + const apolloFetch = createApolloFetch({ uri }) + .use(({ options }, next) => { + if (!options.headers) { + options.headers = {}; // Create the headers object if needed. + } + options.headers['origin'] = 'apollographql.com'; + + next(); + }) + .useAfter((response, next) => { + expect( + response.response.headers.get('access-control-allow-origin'), + ).to.equal('apollographql.com'); + next(); + }); + await apolloFetch({ query: '{hello}' }); + }); + + it('accepts body parser configuration', async () => { + const { url: uri } = await createServer( + { + typeDefs, + resolvers, + }, + { + bodyParserConfig: { jsonLimit: '0' }, + }, + ); + + const apolloFetch = createApolloFetch({ uri }); + + return new Promise((resolve, reject) => { + apolloFetch({ query: '{hello}' }) + .then(reject) + .catch(error => { + expect(error.response).to.exist; + expect(error.response.status).to.equal(413); + expect(error.toString()).to.contain('Payload Too Large'); + resolve(); + }); + }); + }); + + describe('healthchecks', () => { + afterEach(async () => { + await server.stop(); + }); + + it('creates a healthcheck endpoint', async () => { + const { port } = await createServer({ + typeDefs, + resolvers, + }); + + return new Promise((resolve, reject) => { + request( + { + url: `http://localhost:${port}/.well-known/apollo/server-health`, + method: 'GET', + }, + (error, response, body) => { + if (error) { + reject(error); + } else { + expect(body).to.equal(JSON.stringify({ status: 'pass' })); + expect(response.statusCode).to.equal(200); + resolve(); + } + }, + ); + }); + }); + + it('provides a callback for the healthcheck', async () => { + const { port } = await createServer( + { + typeDefs, + resolvers, + }, + { + onHealthCheck: async () => { + throw Error("can't connect to DB"); + }, + }, + ); + + return new Promise((resolve, reject) => { + request( + { + url: `http://localhost:${port}/.well-known/apollo/server-health`, + method: 'GET', + }, + (error, response, body) => { + if (error) { + reject(error); + } else { + expect(body).to.equal(JSON.stringify({ status: 'fail' })); + expect(response.statusCode).to.equal(503); + resolve(); + } + }, + ); + }); + }); + + it('can disable the healthCheck', async () => { + const { port } = await createServer( + { + typeDefs, + resolvers, + }, + { + disableHealthCheck: true, + }, + ); + + return new Promise((resolve, reject) => { + request( + { + url: `http://localhost:${port}/.well-known/apollo/server-health`, + method: 'GET', + }, + (error, response) => { + if (error) { + reject(error); + } else { + expect(response.statusCode).to.equal(404); + resolve(); + } + }, + ); + }); + }); + }); + describe('file uploads', () => { + it('enabled uploads', async () => { + // XXX This is currently a failing test for node 10 + const NODE_VERSION = process.version.split('.'); + const NODE_MAJOR_VERSION = parseInt(NODE_VERSION[0].replace(/^v/, '')); + if (NODE_MAJOR_VERSION === 10) return; + + const { port } = await createServer({ + typeDefs: gql` + type File { + filename: String! + mimetype: String! + encoding: String! + } + + type Query { + uploads: [File] + } + + type Mutation { + singleUpload(file: Upload!): File! + } + `, + resolvers: { + Query: { + uploads: () => {}, + }, + Mutation: { + singleUpload: async (_, args) => { + expect((await args.file).stream).to.exist; + return args.file; + }, + }, + }, + }); + + const body = new FormData(); + + body.append( + 'operations', + JSON.stringify({ + query: ` + mutation($file: Upload!) { + singleUpload(file: $file) { + filename + encoding + mimetype + } + } + `, + variables: { + file: null, + }, + }), + ); + + body.append('map', JSON.stringify({ 1: ['variables.file'] })); + body.append('1', fs.createReadStream('package.json')); + + try { + const resolved = await fetch(`http://localhost:${port}/graphql`, { + method: 'POST', + body: body as any, + }); + const text = await resolved.text(); + const response = JSON.parse(text); + + expect(response.data.singleUpload).to.deep.equal({ + filename: 'package.json', + encoding: '7bit', + mimetype: 'application/json', + }); + } catch (error) { + // This error began appearing randomly and seems to be a dev dependency bug. + // https://github.com/jaydenseric/apollo-upload-server/blob/18ecdbc7a1f8b69ad51b4affbd986400033303d4/test.js#L39-L42 + if (error.code !== 'EPIPE') throw error; + } + }); + }); + + describe('errors', () => { + it('returns thrown context error as a valid graphql result', async () => { + const nodeEnv = process.env.NODE_ENV; + delete process.env.NODE_ENV; + const typeDefs = gql` + type Query { + hello: String + } + `; + const resolvers = { + Query: { + hello: () => { + throw Error('never get here'); + }, + }, + }; + const { url: uri } = await createServer({ + typeDefs, + resolvers, + context: () => { + throw new AuthenticationError('valid result'); + }, + }); + + const apolloFetch = createApolloFetch({ uri }); + + const result = await apolloFetch({ query: '{hello}' }); + expect(result.errors.length).to.equal(1); + expect(result.data).not.to.exist; + + const e = result.errors[0]; + expect(e.message).to.contain('valid result'); + expect(e.extensions).to.exist; + expect(e.extensions.code).to.equal('UNAUTHENTICATED'); + expect(e.extensions.exception.stacktrace).to.exist; + + process.env.NODE_ENV = nodeEnv; + }); + + it('propogates error codes in dev mode', async () => { + const nodeEnv = process.env.NODE_ENV; + delete process.env.NODE_ENV; + + const { url: uri } = await createServer({ + typeDefs: gql` + type Query { + error: String + } + `, + resolvers: { + Query: { + error: () => { + throw new AuthenticationError('we the best music'); + }, + }, + }, + }); + + const apolloFetch = createApolloFetch({ uri }); + + const result = await apolloFetch({ query: `{error}` }); + expect(result.data).to.exist; + expect(result.data).to.deep.equal({ error: null }); + + expect(result.errors, 'errors should exist').to.exist; + expect(result.errors.length).to.equal(1); + expect(result.errors[0].extensions.code).to.equal('UNAUTHENTICATED'); + expect(result.errors[0].extensions.exception).to.exist; + expect(result.errors[0].extensions.exception.stacktrace).to.exist; + + process.env.NODE_ENV = nodeEnv; + }); + + it('propogates error codes in production', async () => { + const nodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + + const { url: uri } = await createServer({ + typeDefs: gql` + type Query { + error: String + } + `, + resolvers: { + Query: { + error: () => { + throw new AuthenticationError('we the best music'); + }, + }, + }, + }); + + const apolloFetch = createApolloFetch({ uri }); + + const result = await apolloFetch({ query: `{error}` }); + expect(result.data).to.exist; + expect(result.data).to.deep.equal({ error: null }); + + expect(result.errors, 'errors should exist').to.exist; + expect(result.errors.length).to.equal(1); + expect(result.errors[0].extensions.code).to.equal('UNAUTHENTICATED'); + expect(result.errors[0].extensions.exception).not.to.exist; + + process.env.NODE_ENV = nodeEnv; + }); + + it('propogates error codes with null response in production', async () => { + const nodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + + const { url: uri } = await createServer({ + typeDefs: gql` + type Query { + error: String! + } + `, + resolvers: { + Query: { + error: () => { + throw new AuthenticationError('we the best music'); + }, + }, + }, + }); + + const apolloFetch = createApolloFetch({ uri }); + + const result = await apolloFetch({ query: `{error}` }); + expect(result.data).null; + + expect(result.errors, 'errors should exist').to.exist; + expect(result.errors.length).to.equal(1); + expect(result.errors[0].extensions.code).to.equal('UNAUTHENTICATED'); + expect(result.errors[0].extensions.exception).not.to.exist; + + process.env.NODE_ENV = nodeEnv; + }); + }); + }); + + describe('extensions', () => { + const books = [ + { + title: 'H', + author: 'J', + }, + ]; + + const typeDefs = gql` + type Book { + title: String + author: String + } + + type Cook @cacheControl(maxAge: 200) { + title: String + author: String + } + + type Pook @cacheControl(maxAge: 200) { + title: String + books: [Book] @cacheControl(maxAge: 20, scope: PRIVATE) + } + + type Query { + books: [Book] + cooks: [Cook] + pooks: [Pook] + } + `; + + const resolvers = { + Query: { + books: () => books, + cooks: () => books, + pooks: () => [{ title: 'pook', books }], + }, + }; + + describe('Cache Control Headers', () => { + it('applies cacheControl Headers and strips out extension', async () => { + const { url: uri } = await createServer({ typeDefs, resolvers }); + + const apolloFetch = createApolloFetch({ uri }).useAfter( + (response, next) => { + expect(response.response.headers.get('cache-control')).to.equal( + 'max-age=200, public', + ); + next(); + }, + ); + const result = await apolloFetch({ + query: `{ cooks { title author } }`, + }); + expect(result.data).to.deep.equal({ cooks: books }); + expect(result.extensions).not.to.exist; + }); + + it('contains no cacheControl Headers and keeps extension with engine proxy', async () => { + const { url: uri } = await createServer({ + typeDefs, + resolvers, + cacheControl: true, + }); + + const apolloFetch = createApolloFetch({ uri }).useAfter( + (response, next) => { + expect(response.response.headers.get('cache-control')).not.to.exist; + next(); + }, + ); + const result = await apolloFetch({ + query: `{ cooks { title author } }`, + }); + expect(result.data).to.deep.equal({ cooks: books }); + expect(result.extensions).to.exist; + expect(result.extensions.cacheControl).to.exist; + }); + + it('contains no cacheControl Headers when uncachable', async () => { + const { url: uri } = await createServer({ typeDefs, resolvers }); + + const apolloFetch = createApolloFetch({ uri }).useAfter( + (response, next) => { + expect(response.response.headers.get('cache-control')).not.to.exist; + next(); + }, + ); + const result = await apolloFetch({ + query: `{ books { title author } }`, + }); + expect(result.data).to.deep.equal({ books }); + expect(result.extensions).not.to.exist; + }); + + it('contains private cacheControl Headers when scoped', async () => { + const { url: uri } = await createServer({ typeDefs, resolvers }); + + const apolloFetch = createApolloFetch({ uri }).useAfter( + (response, next) => { + expect(response.response.headers.get('cache-control')).to.equal( + 'max-age=20, private', + ); + next(); + }, + ); + const result = await apolloFetch({ + query: `{ pooks { title books { title author } } }`, + }); + expect(result.data).to.deep.equal({ + pooks: [{ title: 'pook', books }], + }); + expect(result.extensions).not.to.exist; + }); + + it('runs when cache-control is false', async () => { + const { url: uri } = await createServer({ + typeDefs, + resolvers, + cacheControl: false, + }); + + const apolloFetch = createApolloFetch({ uri }).useAfter( + (response, next) => { + expect(response.response.headers.get('cache-control')).null; + next(); + }, + ); + const result = await apolloFetch({ + query: `{ pooks { title books { title author } } }`, + }); + expect(result.data).to.deep.equal({ + pooks: [{ title: 'pook', books }], + }); + expect(result.extensions).not.to.exist; + }); + }); + + describe('Tracing', () => { + const typeDefs = gql` + type Book { + title: String + author: String + } + + type Query { + books: [Book] + } + `; + + const resolvers = { + Query: { + books: () => books, + }, + }; + + it('applies tracing extension', async () => { + const { url: uri } = await createServer({ + typeDefs, + resolvers, + tracing: true, + }); + + const apolloFetch = createApolloFetch({ uri }); + const result = await apolloFetch({ + query: `{ books { title author } }`, + }); + expect(result.data).to.deep.equal({ books }); + expect(result.extensions).to.exist; + expect(result.extensions.tracing).to.exist; + }); + + it('applies tracing extension with cache control enabled', async () => { + const { url: uri } = await createServer({ + typeDefs, + resolvers, + tracing: true, + cacheControl: true, + }); + + const apolloFetch = createApolloFetch({ uri }); + const result = await apolloFetch({ + query: `{ books { title author } }`, + }); + expect(result.data).to.deep.equal({ books }); + expect(result.extensions).to.exist; + expect(result.extensions.tracing).to.exist; + }); + + xit('applies tracing extension with engine enabled', async () => { + const { url: uri } = await createServer({ + typeDefs, + resolvers, + tracing: true, + engine: { + apiKey: 'fake', + maxAttempts: 0, + endpointUrl: 'l', + reportErrorFunction: () => {}, + }, + }); + + const apolloFetch = createApolloFetch({ uri }); + const result = await apolloFetch({ + query: `{ books { title author } }`, + }); + expect(result.data).to.deep.equal({ books }); + expect(result.extensions).to.exist; + expect(result.extensions.tracing).to.exist; + }); + }); + }); +}); diff --git a/packages/apollo-server-koa/src/ApolloServer.ts b/packages/apollo-server-koa/src/ApolloServer.ts new file mode 100644 index 00000000000..ba212bfbed3 --- /dev/null +++ b/packages/apollo-server-koa/src/ApolloServer.ts @@ -0,0 +1,174 @@ +import * as Koa from 'koa'; +import * as corsMiddleware from '@koa/cors'; +import * as bodyParser from 'koa-bodyparser'; +import * as compose from 'koa-compose'; +import { + renderPlaygroundPage, + RenderPageOptions as PlaygroundRenderPageOptions, +} from '@apollographql/graphql-playground-html'; +import { ApolloServerBase, formatApolloErrors } from 'apollo-server-core'; +import * as accepts from 'accepts'; +import * as typeis from 'type-is'; + +import { graphqlKoa } from './koaApollo'; + +import { processRequest as processFileUploads } from 'apollo-upload-server'; + +export { GraphQLOptions, GraphQLExtension } from 'apollo-server-core'; +import { GraphQLOptions, FileUploadOptions } from 'apollo-server-core'; + +export interface ServerRegistration { + app: Koa; + path?: string; + cors?: corsMiddleware.Options | boolean; + bodyParserConfig?: bodyParser.Options | boolean; + onHealthCheck?: (ctx: Koa.Context) => Promise; + disableHealthCheck?: boolean; +} + +const fileUploadMiddleware = ( + uploadsConfig: FileUploadOptions, + server: ApolloServerBase, +) => async (ctx: Koa.Context, next: Function) => { + if (typeis(ctx.req, ['multipart/form-data'])) { + try { + ctx.request.body = await processFileUploads(ctx.req, uploadsConfig); + return next(); + } catch (error) { + if (error.status && error.expose) ctx.status = error.status; + + throw formatApolloErrors([error], { + formatter: server.requestOptions.formatError, + debug: server.requestOptions.debug, + }); + } + } else { + return next(); + } +}; + +const middlewareFromPath = ( + path: string, + middleware: compose.Middleware, +) => (ctx: Koa.Context, next: () => Promise) => { + if (ctx.path === path) { + return middleware(ctx, next); + } else { + return next(); + } +}; + +export class ApolloServer extends ApolloServerBase { + // This translates the arguments from the middleware into graphQL options It + // provides typings for the integration specific behavior, ideally this would + // be propagated with a generic to the super class + async createGraphQLServerOptions(ctx: Koa.Context): Promise { + return super.graphQLServerOptions({ ctx }); + } + + protected supportsSubscriptions(): boolean { + return true; + } + + protected supportsUploads(): boolean { + return true; + } + + public applyMiddleware({ + app, + path, + cors, + bodyParserConfig, + disableHealthCheck, + onHealthCheck, + }: ServerRegistration) { + if (!path) path = '/graphql'; + + if (!disableHealthCheck) { + // uses same path as engine proxy, but is generally useful. + app.use( + middlewareFromPath( + '/.well-known/apollo/server-health', + (ctx: Koa.Context) => { + // Response follows https://tools.ietf.org/html/draft-inadarei-api-health-check-01 + ctx.set('Content-Type', 'application/health+json'); + + if (onHealthCheck) { + return onHealthCheck(ctx) + .then(() => { + ctx.body = { status: 'pass' }; + }) + .catch(() => { + ctx.status = 503; + ctx.body = { status: 'fail' }; + }); + } else { + ctx.body = { status: 'pass' }; + } + }, + ), + ); + } + + let uploadsMiddleware; + if (this.uploadsConfig) { + uploadsMiddleware = fileUploadMiddleware(this.uploadsConfig, this); + } + + this.graphqlPath = path; + + if (cors === true) { + app.use(middlewareFromPath(path, corsMiddleware())); + } else if (cors !== false) { + app.use(middlewareFromPath(path, corsMiddleware(cors))); + } + + if (bodyParserConfig === true) { + app.use(middlewareFromPath(path, bodyParser())); + } else if (bodyParserConfig !== false) { + app.use(middlewareFromPath(path, bodyParser(bodyParserConfig))); + } + + if (uploadsMiddleware) { + app.use(middlewareFromPath(path, uploadsMiddleware)); + } + + app.use( + middlewareFromPath(path, (ctx: Koa.Context, next: Function) => { + if (this.playgroundOptions && ctx.request.method === 'GET') { + // perform more expensive content-type check only if necessary + const accept = accepts(ctx.req); + const types = accept.types() as string[]; + const prefersHTML = + types.find( + (x: string) => x === 'text/html' || x === 'application/json', + ) === 'text/html'; + + if (prefersHTML) { + const playgroundRenderPageOptions: PlaygroundRenderPageOptions = { + endpoint: path, + subscriptionEndpoint: this.subscriptionsPath, + ...this.playgroundOptions, + }; + ctx.set('Content-Type', 'text/html'); + const playground = renderPlaygroundPage( + playgroundRenderPageOptions, + ); + ctx.body = playground; + return next(); + } + } + return graphqlKoa(this.createGraphQLServerOptions.bind(this))( + ctx, + next, + ); + }), + ); + } +} + +export const registerServer = () => { + throw new Error( + 'Please use server.applyMiddleware instead of registerServer. This warning will be removed in the next release', + ); +}; diff --git a/packages/apollo-server-koa/src/datasource.test.ts b/packages/apollo-server-koa/src/datasource.test.ts new file mode 100644 index 00000000000..06015318acf --- /dev/null +++ b/packages/apollo-server-koa/src/datasource.test.ts @@ -0,0 +1,154 @@ +import { expect } from 'chai'; +import 'mocha'; +import * as Koa from 'koa'; +import * as KoaRouter from 'koa-router'; + +import * as http from 'http'; + +import { RESTDataSource } from 'apollo-datasource-rest'; + +import { createApolloFetch } from 'apollo-fetch'; +import { ApolloServer } from './ApolloServer'; + +import { createServerInfo } from 'apollo-server-integration-testsuite'; + +const restPort = 4001; + +export class IdAPI extends RESTDataSource { + baseURL = `http://localhost:${restPort}/`; + + async getId(id: string) { + return this.get(`id/${id}`); + } + + async getStringId(id: string) { + return this.get(`str/${id}`); + } +} + +// to remove the circular dependency, we reference it directly +const gql = require('../../apollo-server/dist/index').gql; + +const typeDefs = gql` + type Query { + id: String + stringId: String + } +`; + +const resolvers = { + Query: { + id: async (_source, _args, { dataSources }) => { + return (await dataSources.id.getId('hi')).id; + }, + stringId: async (_source, _args, { dataSources }) => { + return dataSources.id.getStringId('hi'); + }, + }, +}; + +let restCalls = 0; +const restAPI = new Koa(); +const router = new KoaRouter(); +router.all('/id/:id', ctx => { + const id = ctx.params.id; + restCalls++; + ctx.set('Cache-Control', 'max-age=2000, public'); + ctx.body = { id }; +}); + +router.all('/str/:id', ctx => { + const id = ctx.params.id; + restCalls++; + ctx.set('Cache-Control', 'max-age=2000, public'); + ctx.body = id; +}); + +restAPI.use(router.routes()); +restAPI.use(router.allowedMethods()); + +describe('apollo-server-koa', () => { + let restServer; + + before(async () => { + await new Promise(resolve => { + restServer = restAPI.listen(restPort, resolve); + }); + }); + + after(async () => { + await restServer.close(); + }); + + let server: ApolloServer; + let httpServer: http.Server; + + beforeEach(() => { + restCalls = 0; + }); + + afterEach(async () => { + await server.stop(); + await httpServer.close(); + }); + + it('uses the cache', async () => { + server = new ApolloServer({ + typeDefs, + resolvers, + dataSources: () => ({ + id: new IdAPI(), + }), + }); + const app = new Koa(); + + server.applyMiddleware({ app }); + httpServer = await new Promise(resolve => { + const s = app.listen({ port: 4000 }, () => resolve(s)); + }); + const { url: uri } = createServerInfo(server, httpServer); + + const apolloFetch = createApolloFetch({ uri }); + const firstResult = await apolloFetch({ query: '{ id }' }); + + expect(firstResult.data).to.deep.equal({ id: 'hi' }); + expect(firstResult.errors, 'errors should exist').not.to.exist; + expect(restCalls).to.deep.equal(1); + + const secondResult = await apolloFetch({ query: '{ id }' }); + + expect(secondResult.data).to.deep.equal({ id: 'hi' }); + expect(secondResult.errors, 'errors should exist').not.to.exist; + expect(restCalls).to.deep.equal(1); + }); + + it('can cache a string from the backend', async () => { + server = new ApolloServer({ + typeDefs, + resolvers, + dataSources: () => ({ + id: new IdAPI(), + }), + }); + const app = new Koa(); + + server.applyMiddleware({ app }); + httpServer = await new Promise(resolve => { + const s = app.listen({ port: 4000 }, () => resolve(s)); + }); + const { url: uri } = createServerInfo(server, httpServer); + + const apolloFetch = createApolloFetch({ uri }); + const firstResult = await apolloFetch({ query: '{ id: stringId }' }); + + expect(firstResult.data).to.deep.equal({ id: 'hi' }); + expect(firstResult.errors, 'errors should exist').not.to.exist; + expect(restCalls).to.deep.equal(1); + + const secondResult = await apolloFetch({ query: '{ id: stringId }' }); + + expect(secondResult.data).to.deep.equal({ id: 'hi' }); + expect(secondResult.errors, 'errors should exist').not.to.exist; + expect(restCalls).to.deep.equal(1); + }); +}); diff --git a/packages/apollo-server-koa/src/index.ts b/packages/apollo-server-koa/src/index.ts index 52bfe4f8703..839fcafe6d5 100644 --- a/packages/apollo-server-koa/src/index.ts +++ b/packages/apollo-server-koa/src/index.ts @@ -1,7 +1,23 @@ export { - KoaGraphQLOptionsFunction, - KoaHandler, - KoaGraphiQLOptionsFunction, - graphqlKoa, - graphiqlKoa, -} from './koaApollo'; + GraphQLUpload, + GraphQLOptions, + gql, + // Errors + ApolloError, + toApolloError, + SyntaxError, + ValidationError, + AuthenticationError, + ForbiddenError, + UserInputError, +} from 'apollo-server-core'; + +export * from 'graphql-tools'; +export * from 'graphql-subscriptions'; + +// ApolloServer integration. +export { + ApolloServer, + registerServer, + ServerRegistration, +} from './ApolloServer'; diff --git a/packages/apollo-server-koa/src/koaApollo.test.ts b/packages/apollo-server-koa/src/koaApollo.test.ts index ff73349a6fd..8b8368647cb 100644 --- a/packages/apollo-server-koa/src/koaApollo.test.ts +++ b/packages/apollo-server-koa/src/koaApollo.test.ts @@ -1,49 +1,38 @@ -import * as koa from 'koa'; -import * as koaRouter from 'koa-router'; -import * as koaBody from 'koa-bodyparser'; -import { graphqlKoa, graphiqlKoa } from './koaApollo'; -import { GraphQLOptions } from 'apollo-server-core'; -import { expect } from 'chai'; -import * as http from 'http'; - +import * as Koa from 'koa'; +import { ApolloServer } from './ApolloServer'; import testSuite, { schema as Schema, CreateAppOptions, } from 'apollo-server-integration-testsuite'; +import { expect } from 'chai'; +import { GraphQLOptions, Config } from 'apollo-server-core'; +import 'mocha'; function createApp(options: CreateAppOptions = {}) { - const app = new koa(); - const router = new koaRouter(); + const app = new Koa(); - options.graphqlOptions = options.graphqlOptions || { schema: Schema }; + const server = new ApolloServer( + (options.graphqlOptions as Config) || { schema: Schema }, + ); + server.applyMiddleware({ app }); + return app.listen(); +} - if (!options.excludeParser) { - app.use(koaBody()); - } - if (options.graphiqlOptions) { - router.get('/graphiql', graphiqlKoa(options.graphiqlOptions)); +async function destroyApp(app) { + if (!app || !app.close) { + return; } - router.get('/graphql', graphqlKoa(options.graphqlOptions)); - router.post('/graphql', graphqlKoa(options.graphqlOptions)); - app.use(router.routes()); - app.use(router.allowedMethods()); - return http.createServer(app.callback()); + await new Promise(resolve => app.close(resolve)); } -function destroyApp(app) { - app.close(); -} +describe('integration:Hapi', () => { + testSuite(createApp, destroyApp); +}); describe('koaApollo', () => { it('throws error if called without schema', function() { - expect(() => graphqlKoa(undefined as GraphQLOptions)).to.throw( - 'Apollo Server requires options.', - ); - }); - - it('throws an error if called with more than one argument', function() { - expect(() => (graphqlKoa)({}, 'x')).to.throw( - 'Apollo Server expects exactly one argument, got 2', + expect(() => new ApolloServer(undefined as GraphQLOptions)).to.throw( + 'ApolloServer requires options.', ); }); }); diff --git a/packages/apollo-server-koa/src/koaApollo.ts b/packages/apollo-server-koa/src/koaApollo.ts index 202bef11896..31d33f8f3c0 100644 --- a/packages/apollo-server-koa/src/koaApollo.ts +++ b/packages/apollo-server-koa/src/koaApollo.ts @@ -1,17 +1,17 @@ -import * as koa from 'koa'; +import * as Koa from 'koa'; import { GraphQLOptions, HttpQueryError, runHttpQuery, + convertNodeHttpToRequest, } from 'apollo-server-core'; -import * as GraphiQL from 'apollo-server-module-graphiql'; export interface KoaGraphQLOptionsFunction { - (ctx: koa.Context): GraphQLOptions | Promise; + (ctx: Koa.Context): GraphQLOptions | Promise; } export interface KoaHandler { - (req: any, next): void; + (ctx: Koa.Context, next): void; } export function graphqlKoa( @@ -22,21 +22,28 @@ export function graphqlKoa( } if (arguments.length > 1) { + // TODO: test this throw new Error( `Apollo Server expects exactly one argument, got ${arguments.length}`, ); } - const graphqlHandler = (ctx: koa.Context): Promise => { + const graphqlHandler = (ctx: Koa.Context): Promise => { return runHttpQuery([ctx], { method: ctx.request.method, options: options, query: - ctx.request.method === 'POST' ? ctx.request.body : ctx.request.query, + ctx.request.method === 'POST' + ? // fallback to ctx.req.body for koa-multer support + ctx.request.body || (ctx.req as any).body + : ctx.request.query, + request: convertNodeHttpToRequest(ctx.req), }).then( - gqlResponse => { - ctx.set('Content-Type', 'application/json'); - ctx.body = gqlResponse; + ({ graphqlResponse, responseInit }) => { + Object.keys(responseInit.headers).forEach(key => + ctx.set(key, responseInit.headers[key]), + ); + ctx.body = graphqlResponse; }, (error: HttpQueryError) => { if ('HttpQueryError' !== error.name) { @@ -57,27 +64,3 @@ export function graphqlKoa( return graphqlHandler; } - -export interface KoaGraphiQLOptionsFunction { - (ctx: koa.Context): GraphiQL.GraphiQLData | Promise; -} - -export function graphiqlKoa( - options: GraphiQL.GraphiQLData | KoaGraphiQLOptionsFunction, -) { - const graphiqlHandler = (ctx: koa.Context) => { - const query = ctx.request.query; - return GraphiQL.resolveGraphiQLString(query, options, ctx).then( - graphiqlString => { - ctx.set('Content-Type', 'text/html'); - ctx.body = graphiqlString; - }, - error => { - ctx.status = 500; - ctx.body = error.message; - }, - ); - }; - - return graphiqlHandler; -} diff --git a/packages/apollo-server-koa/tsconfig.json b/packages/apollo-server-koa/tsconfig.json index b50822eaa8c..5ac3c46b1f6 100644 --- a/packages/apollo-server-koa/tsconfig.json +++ b/packages/apollo-server-koa/tsconfig.json @@ -1,9 +1,9 @@ { - "extends": "../../tsconfig", + "extends": "../../tsconfig.json", "compilerOptions": { "rootDir": "./src", "outDir": "./dist", - "types": [] + "lib": ["es2017", "esnext.asynciterable", "dom"] }, "exclude": ["node_modules", "dist"] } diff --git a/packages/apollo-server-lambda/.npmignore b/packages/apollo-server-lambda/.npmignore old mode 100755 new mode 100644 diff --git a/packages/apollo-server-lambda/README.md b/packages/apollo-server-lambda/README.md old mode 100755 new mode 100644 index 4761f31b50b..3e1c48ada9f --- a/packages/apollo-server-lambda/README.md +++ b/packages/apollo-server-lambda/README.md @@ -1,31 +1,44 @@ --- title: Lambda -description: Setting up Apollo Server with Lambda +description: Setting up Apollo Server with AWS Lambda --- -[![npm version](https://badge.fury.io/js/apollo-server-core.svg)](https://badge.fury.io/js/apollo-server-core) [![Build Status](https://circleci.com/gh/apollographql/apollo-cache-control-js.svg?style=svg)](https://circleci.com/gh/apollographql/apollo-cache-control-js) [![Coverage Status](https://coveralls.io/repos/github/apollographql/apollo-server/badge.svg?branch=master)](https://coveralls.io/github/apollographql/apollo-server?branch=master) [![Get on Slack](https://img.shields.io/badge/slack-join-orange.svg)](https://www.apollographql.com/#slack) +[![npm version](https://badge.fury.io/js/apollo-server-lambda.svg)](https://badge.fury.io/js/apollo-server-lambda) [![Build Status](https://circleci.com/gh/apollographql/apollo-server.svg?style=svg)](https://circleci.com/gh/apollographql/apollo-server) [![Coverage Status](https://coveralls.io/repos/github/apollographql/apollo-server/badge.svg?branch=master)](https://coveralls.io/github/apollographql/apollo-server?branch=master) [![Get on Slack](https://img.shields.io/badge/slack-join-orange.svg)](https://www.apollographql.com/#slack) -This is the AWS Lambda integration for the Apollo community GraphQL Server. [Read the docs.](https://www.apollographql.com/docs/apollo-server/) [Read the CHANGELOG.](https://github.com/apollographql/apollo-server/blob/master/CHANGELOG.md) +This is the AWS Lambda integration of GraphQL Server. Apollo Server is a community-maintained open-source GraphQL server that works with many Node.js HTTP server frameworks. [Read the docs](https://www.apollographql.com/docs/apollo-server/v2). [Read the CHANGELOG](https://github.com/apollographql/apollo-server/blob/master/CHANGELOG.md). ```sh -npm install apollo-server-lambda +npm install apollo-server-lambda@rc graphql ``` -

Deploying with AWS Serverless Application Model (SAM)

+## Deploying with AWS Serverless Application Model (SAM) To deploy the AWS Lambda function we must create a Cloudformation Template and a S3 bucket to store the artifact (zip of source code) and template. We will use the [AWS Command Line Interface](https://aws.amazon.com/cli/). #### 1. Write the API handlers +In a file named `graphql.js`, place the following code: + ```js -// graphql.js -var server = require('apollo-server-lambda'), - myGraphQLSchema = require('./schema'); +const { ApolloServer, gql } = require('apollo-server-lambda'); + +// Construct a schema, using GraphQL schema language +const typeDefs = gql` + type Query { + hello: String + } +`; + +// Provide resolver functions for your schema fields +const resolvers = { + Query: { + hello: () => 'Hello world!', + }, +}; -exports.graphqlHandler = server.graphqlLambda({ schema: myGraphQLSchema }); -exports.graphiqlHandler = server.graphiqlLambda({ - endpointURL: '/Prod/graphql', -}); +const server = new ApolloServer({ typeDefs, resolvers }); + +exports.graphqlHandler = server.createHandler(); ``` #### 2. Create an S3 bucket @@ -38,10 +51,9 @@ aws s3 mb s3:// #### 3. Create the Template -This will look for a file called graphql.js with two exports: `graphqlHandler` and `graphiqlHandler`. It creates two API endpoints: +This will look for a file called graphql.js with the export `graphqlHandler`. It creates one API endpoints: * `/graphql` (GET and POST) -* `/graphiql` (GET) In a file called `template.yaml`: @@ -53,7 +65,7 @@ Resources: Type: AWS::Serverless::Function Properties: Handler: graphql.graphqlHandler - Runtime: nodejs6.10 + Runtime: nodejs8.10 Events: GetRequest: Type: Api @@ -65,17 +77,6 @@ Resources: Properties: Path: /graphql Method: post - GraphQLInspector: - Type: AWS::Serverless::Function - Properties: - Handler: graphql.graphiqlHandler - Runtime: nodejs6.10 - Events: - GetRequest: - Type: Api - Properties: - Path: /graphiql - Method: get ``` #### 4. Package source code and dependencies @@ -84,9 +85,9 @@ This will read and transform the template, created in previous step. Package and ```sh aws cloudformation package \ - --template-file template.yaml \ - --output-template-file serverless-output.yaml \ - --s3-bucket + --template-file template.yaml \ + --output-template-file serverless-output.yaml \ + --s3-bucket ``` #### 5. Deploy the API @@ -95,73 +96,112 @@ The will create the Lambda Function and API Gateway for GraphQL. We use the stac ``` aws cloudformation deploy \ - --template-file serverless-output.yaml \ - --stack-name prod \ - --capabilities CAPABILITY_IAM + --template-file serverless-output.yaml \ + --stack-name prod \ + --capabilities CAPABILITY_IAM ``` -

Getting request info

+## Getting request info To read information about the current request from the API Gateway event (HTTP headers, HTTP method, body, path, ...) or the current Lambda Context (Function Name, Function Version, awsRequestId, time remaning, ...) use the options function. This way they can be passed to your schema resolvers using the context option. ```js -var server = require('apollo-server-lambda'), - myGraphQLSchema = require('./schema'); - -exports.graphqlHandler = server.graphqlLambda((event, context) => { - const headers = event.headers, - functionName = context.functionName; - - return { - schema: myGraphQLSchema, - context: { - headers, - functionName, - event, - context, - }, - }; +const { ApolloServer, gql } = require('apollo-server-lambda'); + +// Construct a schema, using GraphQL schema language +const typeDefs = gql` + type Query { + hello: String + } +`; + +// Provide resolver functions for your schema fields +const resolvers = { + Query: { + hello: () => 'Hello world!', + }, +}; + +const server = new ApolloServer({ + typeDefs, + resolvers, + context: ({ event, context }) => ({ + headers: event.headers, + functionName: context.functionName, + event, + context, + }) }); + +exports.graphqlHandler = server.createHandler(); ``` -

Modifying the Lambda Response (Enable CORS)

+## Modifying the Lambda Response (Enable CORS) -To enable CORS the response HTTP headers need to be modified. To accomplish this pass in a callback filter to the generated handler of graphqlLambda. +To enable CORS the response HTTP headers need to be modified. To accomplish this use the `cors` option. ```js -var server = require('apollo-server-lambda'), - myGraphQLSchema = require('./schema'); +const { ApolloServer, gql } = require('apollo-server-lambda'); + +// Construct a schema, using GraphQL schema language +const typeDefs = gql` + type Query { + hello: String + } +`; + +// Provide resolver functions for your schema fields +const resolvers = { + Query: { + hello: () => 'Hello world!', + }, +}; -exports.graphqlHandler = function(event, context, callback) { - const callbackFilter = function(error, output) { - output.headers['Access-Control-Allow-Origin'] = '*'; - callback(error, output); - }; - const handler = server.graphqlLambda({ schema: myGraphQLSchema }); +const server = new ApolloServer({ typeDefs, resolvers }); - return handler(event, context, callbackFilter); -}; +exports.graphqlHandler = server.createHandler({ + cors: { + origin: '*', + credentials: true, + }, +}); ``` To enable CORS response for requests with credentials (cookies, http authentication) the allow origin header must equal the request origin and the allow credential header must be set to true. ```js -const CORS_ORIGIN = 'https://example.com'; - -var server = require('apollo-server-lambda'), - myGraphQLSchema = require('./schema'); - -exports.graphqlHandler = function(event, context, callback) { - const requestOrigin = event.headers.origin, - callbackFilter = function(error, output) { - if (requestOrigin === CORS_ORIGIN) { - output.headers['Access-Control-Allow-Origin'] = CORS_ORIGIN; - output.headers['Access-Control-Allow-Credentials'] = 'true'; - } - callback(error, output); - }; - const handler = server.graphqlLambda({ schema: myGraphQLSchema }); - - return handler(event, context, callbackFilter); +const { ApolloServer, gql } = require('apollo-server-lambda'); + +// Construct a schema, using GraphQL schema language +const typeDefs = gql` + type Query { + hello: String + } +`; + +// Provide resolver functions for your schema fields +const resolvers = { + Query: { + hello: () => 'Hello world!', + }, }; + +const server = new ApolloServer({ typeDefs, resolvers }); + +exports.graphqlHandler = server.createHandler({ + cors: { + origin: true, + credentials: true, + }, +}); ``` + +## Principles + +GraphQL Server is built with the following principles in mind: + +* **By the community, for the community**: GraphQL Server's development is driven by the needs of developers +* **Simplicity**: by keeping things simple, GraphQL Server is easier to use, easier to contribute to, and more secure +* **Performance**: GraphQL Server is well-tested and production-ready - no modifications needed + +Anyone is welcome to contribute to GraphQL Server, just read [CONTRIBUTING.md](https://github.com/apollographql/apollo-server/blob/master/CONTRIBUTING.md), take a look at the [roadmap](https://github.com/apollographql/apollo-server/blob/master/ROADMAP.md) and make your first PR! diff --git a/packages/apollo-server-lambda/package.json b/packages/apollo-server-lambda/package.json index 11117be5782..5aabc04665f 100644 --- a/packages/apollo-server-lambda/package.json +++ b/packages/apollo-server-lambda/package.json @@ -1,16 +1,7 @@ { "name": "apollo-server-lambda", - "version": "1.4.0", + "version": "2.0.0-rc.7", "description": "Production-ready Node.js GraphQL server for AWS Lambda", - "main": "dist/index.js", - "scripts": { - "compile": "tsc", - "prepublish": "npm run compile" - }, - "repository": { - "type": "git", - "url": "https://github.com/apollographql/apollo-server/tree/master/packages/apollo-server-lambda" - }, "keywords": [ "GraphQL", "Apollo", @@ -18,23 +9,37 @@ "Lambda", "Javascript" ], - "author": "Jonas Helfer ", + "author": "opensource@apollographql.com", "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/apollographql/apollo-server/tree/master/packages/apollo-server-lambda" + }, + "homepage": "https://github.com/apollographql/apollo-server#readme", "bugs": { "url": "https://github.com/apollographql/apollo-server/issues" }, - "homepage": "https://github.com/apollographql/apollo-server#readme", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "engines": { + "node": ">=6" + }, + "scripts": { + "clean": "rm -rf dist", + "compile": "tsc", + "prepublish": "npm run clean && npm run compile" + }, "dependencies": { - "apollo-server-core": "^1.4.0", - "apollo-server-module-graphiql": "^1.4.0" + "@apollographql/graphql-playground-html": "^1.6.0", + "apollo-server-core": "^2.0.0-rc.7", + "apollo-server-env": "^2.0.0-rc.7", + "graphql-tools": "^3.0.4" }, "devDependencies": { - "@types/aws-lambda": "8.10.7", - "@types/graphql": "0.12.7", - "apollo-server-integration-testsuite": "^1.4.0" + "@types/aws-lambda": "^8.10.6", + "apollo-server-integration-testsuite": "^2.0.0-rc.7" }, - "typings": "dist/index.d.ts", - "typescript": { - "definition": "dist/index.d.ts" + "peerDependencies": { + "graphql": "^0.12.0 || ^0.13.0 || ^14.0.0" } } diff --git a/packages/apollo-server-lambda/src/ApolloServer.ts b/packages/apollo-server-lambda/src/ApolloServer.ts new file mode 100644 index 00000000000..7a3d4e7ee25 --- /dev/null +++ b/packages/apollo-server-lambda/src/ApolloServer.ts @@ -0,0 +1,149 @@ +import * as lambda from 'aws-lambda'; +import { ApolloServerBase } from 'apollo-server-core'; +export { GraphQLOptions, GraphQLExtension } from 'apollo-server-core'; +import { GraphQLOptions, Config } from 'apollo-server-core'; +import { + renderPlaygroundPage, + RenderPageOptions as PlaygroundRenderPageOptions, +} from '@apollographql/graphql-playground-html'; + +import { graphqlLambda } from './lambdaApollo'; + +export interface CreateHandlerOptions { + cors?: { + origin?: boolean | string | string[]; + methods?: string | string[]; + allowedHeaders?: string | string[]; + exposedHeaders?: string | string[]; + credentials?: boolean; + maxAge?: number; + }; +} + +export class ApolloServer extends ApolloServerBase { + // If you feel tempted to add an option to this constructor. Please consider + // another place, since the documentation becomes much more complicated when + // the constructor is not longer shared between all integration + constructor(options: Config) { + if (process.env.ENGINE_API_KEY || options.engine) { + options.engine = { + sendReportsImmediately: true, + ...(typeof options.engine !== 'boolean' ? options.engine : {}), + }; + } + super(options); + } + + // This translates the arguments from the middleware into graphQL options It + // provides typings for the integration specific behavior, ideally this would + // be propagated with a generic to the super class + createGraphQLServerOptions( + event: lambda.APIGatewayProxyEvent, + context: lambda.Context, + ): Promise { + return super.graphQLServerOptions({ event, context }); + } + + public createHandler({ cors }: CreateHandlerOptions = { cors: undefined }) { + const corsHeaders = {}; + + if (cors) { + if (cors.methods) { + if (typeof cors.methods === 'string') { + corsHeaders['Access-Control-Allow-Methods'] = cors.methods; + } else if (Array.isArray(cors.methods)) { + corsHeaders['Access-Control-Allow-Methods'] = cors.methods.join(','); + } + } + + if (cors.allowedHeaders) { + if (typeof cors.allowedHeaders === 'string') { + corsHeaders['Access-Control-Allow-Headers'] = cors.allowedHeaders; + } else if (Array.isArray(cors.allowedHeaders)) { + corsHeaders[ + 'Access-Control-Allow-Headers' + ] = cors.allowedHeaders.join(','); + } + } + + if (cors.exposedHeaders) { + if (typeof cors.exposedHeaders === 'string') { + corsHeaders['Access-Control-Expose-Headers'] = cors.exposedHeaders; + } else if (Array.isArray(cors.exposedHeaders)) { + corsHeaders[ + 'Access-Control-Expose-Headers' + ] = cors.exposedHeaders.join(','); + } + } + + if (cors.credentials) { + corsHeaders['Access-Control-Allow-Credentials'] = 'true'; + } + if (cors.maxAge) { + corsHeaders['Access-Control-Max-Age'] = cors.maxAge; + } + } + + return ( + event: lambda.APIGatewayProxyEvent, + context: lambda.Context, + callback: lambda.APIGatewayProxyCallback, + ) => { + if (cors && cors.origin) { + if (typeof cors.origin === 'string') { + corsHeaders['Access-Control-Allow-Origin'] = cors.origin; + } else if ( + typeof cors.origin === 'boolean' || + (Array.isArray(cors.origin) && + cors.origin.includes( + event.headers['Origin'] || event.headers['origin'], + )) + ) { + corsHeaders['Access-Control-Allow-Origin'] = + event.headers['Origin'] || event.headers['origin']; + } + } + + if (this.playgroundOptions && event.httpMethod === 'GET') { + const acceptHeader = event.headers['Accept'] || event.headers['accept']; + if (acceptHeader && acceptHeader.includes('text/html')) { + const playgroundRenderPageOptions: PlaygroundRenderPageOptions = { + endpoint: event.requestContext.path, + ...this.playgroundOptions, + }; + + return callback(null, { + body: renderPlaygroundPage(playgroundRenderPageOptions), + statusCode: 200, + headers: { + 'Content-Type': 'text/html', + ...corsHeaders, + }, + }); + } + } + + const callbackFilter: lambda.APIGatewayProxyCallback = ( + error, + result, + ) => { + callback( + error, + result && { + ...result, + headers: { + ...result.headers, + ...corsHeaders, + }, + }, + ); + }; + + graphqlLambda(this.createGraphQLServerOptions.bind(this))( + event, + context, + callbackFilter, + ); + }; + } +} diff --git a/packages/apollo-server-lambda/src/index.ts b/packages/apollo-server-lambda/src/index.ts old mode 100755 new mode 100644 index 4b5e47610e3..39a8f62d1e0 --- a/packages/apollo-server-lambda/src/index.ts +++ b/packages/apollo-server-lambda/src/index.ts @@ -1,8 +1,18 @@ export { - LambdaHandler, - IHeaders, - LambdaGraphQLOptionsFunction, - LambdaGraphiQLOptionsFunction, - graphqlLambda, - graphiqlLambda, -} from './lambdaApollo'; + GraphQLUpload, + GraphQLOptions, + gql, + // Errors + ApolloError, + toApolloError, + SyntaxError, + ValidationError, + AuthenticationError, + ForbiddenError, + UserInputError, +} from 'apollo-server-core'; + +export * from 'graphql-tools'; + +// ApolloServer integration. +export { ApolloServer, CreateHandlerOptions } from './ApolloServer'; diff --git a/packages/apollo-server-lambda/src/lambdaApollo.test.ts b/packages/apollo-server-lambda/src/lambdaApollo.test.ts old mode 100755 new mode 100644 index da6aa3ff2bf..4610fbd6027 --- a/packages/apollo-server-lambda/src/lambdaApollo.test.ts +++ b/packages/apollo-server-lambda/src/lambdaApollo.test.ts @@ -1,46 +1,43 @@ -import { graphqlLambda, graphiqlLambda } from './lambdaApollo'; +import { ApolloServer } from './ApolloServer'; import testSuite, { schema as Schema, CreateAppOptions, } from 'apollo-server-integration-testsuite'; -import { expect } from 'chai'; -import { GraphQLOptions } from 'apollo-server-core'; +import { Config } from 'apollo-server-core'; import 'mocha'; import * as url from 'url'; +import { IncomingMessage, ServerResponse } from 'http'; -function createLambda(options: CreateAppOptions = {}) { - let route, handler, callback, event, context; +const createLambda = (options: CreateAppOptions = {}) => { + const server = new ApolloServer( + (options.graphqlOptions as Config) || { schema: Schema }, + ); - options.graphqlOptions = options.graphqlOptions || { schema: Schema }; - if (options.graphiqlOptions) { - route = '/graphiql'; - handler = graphiqlLambda(options.graphiqlOptions); - } else { - route = '/graphql'; - handler = graphqlLambda(options.graphqlOptions); - } + const handler = server.createHandler(); - return function(req, res) { - if (!req.url.startsWith(route)) { + return (req: IncomingMessage, res: ServerResponse) => { + // return 404 if path is /bogus-route to pass the test, lambda doesn't have paths + if (req.url.includes('/bogus-route')) { res.statusCode = 404; - res.end(); - return; + return res.end(); } let body = ''; - req.on('data', function(chunk) { - body += chunk; - }); - req.on('end', function() { - let urlObject = url.parse(req.url, true); - event = { + req.on('data', chunk => (body += chunk)); + req.on('end', () => { + const urlObject = url.parse(req.url, true); + const event = { httpMethod: req.method, body: body, path: req.url, queryStringParameters: urlObject.query, + requestContext: { + path: urlObject.pathname, + }, + headers: req.headers, }; - context = {}; - callback = function(error, result) { + const callback = (error, result) => { + if (error) throw error; res.statusCode = result.statusCode; for (let key in result.headers) { if (result.headers.hasOwnProperty(key)) { @@ -50,25 +47,10 @@ function createLambda(options: CreateAppOptions = {}) { res.write(result.body); res.end(); }; - - handler(event, context, callback); + handler(event as any, {} as any, callback); }); }; -} - -describe('lambdaApollo', () => { - it('throws error if called without schema', function() { - expect(() => graphqlLambda(undefined as GraphQLOptions)).to.throw( - 'Apollo Server requires options.', - ); - }); - - it('throws an error if called with more than one argument', function() { - expect(() => (graphqlLambda)({}, {})).to.throw( - 'Apollo Server expects exactly one argument, got 2', - ); - }); -}); +}; describe('integration:Lambda', () => { testSuite(createLambda); diff --git a/packages/apollo-server-lambda/src/lambdaApollo.ts b/packages/apollo-server-lambda/src/lambdaApollo.ts old mode 100755 new mode 100644 index e576480c859..ebe1f6bb853 --- a/packages/apollo-server-lambda/src/lambdaApollo.ts +++ b/packages/apollo-server-lambda/src/lambdaApollo.ts @@ -1,29 +1,20 @@ import * as lambda from 'aws-lambda'; -import { GraphQLOptions, runHttpQuery } from 'apollo-server-core'; -import * as GraphiQL from 'apollo-server-module-graphiql'; +import { + GraphQLOptions, + HttpQueryError, + runHttpQuery, +} from 'apollo-server-core'; +import { Headers } from 'apollo-server-env'; export interface LambdaGraphQLOptionsFunction { - (event: any, context: lambda.Context): + (event: lambda.APIGatewayProxyEvent, context: lambda.Context): | GraphQLOptions | Promise; } -// Design principles: -// - there is just one way allowed: POST request with JSON body. Nothing else. -// - simple, fast and secure -// - -export interface LambdaHandler { - (event: any, context: lambda.Context, callback: lambda.Callback): void; -} - -export interface IHeaders { - [header: string]: string | number; -} - export function graphqlLambda( options: GraphQLOptions | LambdaGraphQLOptionsFunction, -): LambdaHandler { +): lambda.APIGatewayProxyHandler { if (!options) { throw new Error('Apollo Server requires options.'); } @@ -34,93 +25,47 @@ export function graphqlLambda( ); } - const graphqlHandler = async ( + const graphqlHandler: lambda.APIGatewayProxyHandler = ( event, - lambdaContext: lambda.Context, - callback: lambda.Callback, - ) => { - let query = - event.httpMethod === 'POST' ? event.body : event.queryStringParameters, - statusCode: number = null, - gqlResponse = null, - headers: { [headerName: string]: string } = {}; - - if (query && typeof query === 'string') { - query = JSON.parse(query); - } - - try { - gqlResponse = await runHttpQuery([event, lambdaContext], { - method: event.httpMethod, - options: options, - query: query, - }); - headers['Content-Type'] = 'application/json'; - statusCode = 200; - } catch (error) { - if ('HttpQueryError' !== error.name) { - throw error; - } - - headers = error.headers; - statusCode = error.statusCode; - gqlResponse = error.message; - } finally { - callback(null, { - statusCode: statusCode, - headers: headers, - body: gqlResponse, + context, + callback, + ): void => { + if (event.httpMethod === 'POST' && !event.body) { + return callback(null, { + body: 'POST body missing.', + statusCode: 500, }); } - }; - - return graphqlHandler; -} - -export interface LambdaGraphiQLOptionsFunction { - (event: any, context: lambda.Context): - | GraphiQL.GraphiQLData - | Promise; -} - -/* This Lambda Function Handler returns the html for the GraphiQL interactive query UI - * - * GraphiQLData arguments - * - * - endpointURL: the relative or absolute URL for the endpoint which GraphiQL will make queries to - * - (optional) query: the GraphQL query to pre-fill in the GraphiQL UI - * - (optional) variables: a JS object of variables to pre-fill in the GraphiQL UI - * - (optional) operationName: the operationName to pre-fill in the GraphiQL UI - * - (optional) result: the result of the query to pre-fill in the GraphiQL UI - */ - -export function graphiqlLambda( - options: GraphiQL.GraphiQLData | LambdaGraphiQLOptionsFunction, -) { - const graphiqlHandler = ( - event, - lambdaContext: lambda.Context, - callback: lambda.Callback, - ) => { - const query = event.queryStringParameters; - GraphiQL.resolveGraphiQLString(query, options, event, lambdaContext).then( - graphiqlString => { + runHttpQuery([event, context], { + method: event.httpMethod, + options: options, + query: + event.httpMethod === 'POST' + ? JSON.parse(event.body) + : (event.queryStringParameters as any), + request: { + url: event.path, + method: event.httpMethod, + headers: new Headers(event.headers), + }, + }).then( + ({ graphqlResponse, responseInit }) => { callback(null, { + body: graphqlResponse, statusCode: 200, - headers: { - 'Content-Type': 'text/html', - }, - body: graphiqlString, + headers: responseInit.headers, }); }, - error => { + (error: HttpQueryError) => { + if ('HttpQueryError' !== error.name) return callback(error); callback(null, { - statusCode: 500, body: error.message, + statusCode: error.statusCode, + headers: error.headers, }); }, ); }; - return graphiqlHandler; + return graphqlHandler; } diff --git a/packages/apollo-server-lambda/tsconfig.json b/packages/apollo-server-lambda/tsconfig.json index 92f3db51b04..5ac3c46b1f6 100644 --- a/packages/apollo-server-lambda/tsconfig.json +++ b/packages/apollo-server-lambda/tsconfig.json @@ -1,10 +1,9 @@ { - "extends": "../../tsconfig", + "extends": "../../tsconfig.json", "compilerOptions": { "rootDir": "./src", "outDir": "./dist", - "typeRoots": ["node_modules/@types"], - "types": ["@types/node"] + "lib": ["es2017", "esnext.asynciterable", "dom"] }, "exclude": ["node_modules", "dist"] } diff --git a/packages/apollo-server-memcached/.gitignore b/packages/apollo-server-memcached/.gitignore new file mode 100644 index 00000000000..723ef36f4e4 --- /dev/null +++ b/packages/apollo-server-memcached/.gitignore @@ -0,0 +1 @@ +.idea \ No newline at end of file diff --git a/packages/apollo-server-memcached/.npmignore b/packages/apollo-server-memcached/.npmignore new file mode 100644 index 00000000000..a165046d359 --- /dev/null +++ b/packages/apollo-server-memcached/.npmignore @@ -0,0 +1,6 @@ +* +!src/**/* +!dist/**/* +dist/**/*.test.* +!package.json +!README.md diff --git a/packages/apollo-server-memcached/README.md b/packages/apollo-server-memcached/README.md new file mode 100644 index 00000000000..4ee668b659c --- /dev/null +++ b/packages/apollo-server-memcached/README.md @@ -0,0 +1,26 @@ +## MemcachedCache + +[![npm version](https://badge.fury.io/js/apollo-server-memcached.svg)](https://badge.fury.io/js/apollo-server-memcached) +[![Build Status](https://circleci.com/gh/apollographql/apollo-server.svg?style=svg)](https://circleci.com/gh/apollographql/apollo-server) + +This package exports an implementation of `KeyValueCache` that allows using Memcached as a backing store for resource caching in [Data Sources](https://www.apollographql.com/docs/apollo-server/v2/features/data-sources.html). + +## Usage + +```js +const { MemcachedCache } = require('apollo-server-memcached'); + +const server = new ApolloServer({ + typeDefs, + resolvers, + cache: new MemcachedCache( + ['memcached-server-1', 'memcached-server-2', 'memcached-server-3'], + { retries: 10, retry: 10000 }, // Options + ), + dataSources: () => ({ + moviesAPI: new MoviesAPI(), + }), +}); +``` + +For documentation of the options you can pass to the underlying memcached client, look [here](https://github.com/3rd-Eden/memcached). diff --git a/packages/apollo-server-memcached/package.json b/packages/apollo-server-memcached/package.json new file mode 100644 index 00000000000..59422051f74 --- /dev/null +++ b/packages/apollo-server-memcached/package.json @@ -0,0 +1,57 @@ +{ + "name": "apollo-server-memcached", + "version": "2.0.0-rc.7", + "author": "opensource@apollographql.com", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/apollographql/apollo-server/tree/master/packages/apollo-server-memcached" + }, + "homepage": "https://github.com/apollographql/apollo-server#readme", + "bugs": { + "url": "https://github.com/apollographql/apollo-server/issues" + }, + "scripts": { + "clean": "rm -rf dist", + "compile": "tsc", + "prepublish": "npm run clean && npm run compile", + "test": "jest --verbose" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "engines": { + "node": ">=6" + }, + "dependencies": { + "apollo-server-caching": "^2.0.0-rc.7", + "apollo-server-env": "^2.0.0-rc.7", + "memcached": "^2.2.2" + }, + "devDependencies": { + "@types/jest": "^23.1.2", + "@types/memcached": "^2.2.5", + "jest": "^23.2.0", + "memcached-mock": "^0.1.0", + "ts-jest": "^22.4.6" + }, + "jest": { + "testEnvironment": "node", + "setupFiles": [ + "/node_modules/apollo-server-env/dist/index.js" + ], + "transform": { + "^.+\\.(ts|js)$": "ts-jest" + }, + "moduleFileExtensions": [ + "ts", + "js", + "json" + ], + "testRegex": "src/__tests__/.*$", + "globals": { + "ts-jest": { + "skipBabel": true + } + } + } +} diff --git a/packages/apollo-server-memcached/src/__tests__/Memcached.test.ts b/packages/apollo-server-memcached/src/__tests__/Memcached.test.ts new file mode 100644 index 00000000000..cc769325efb --- /dev/null +++ b/packages/apollo-server-memcached/src/__tests__/Memcached.test.ts @@ -0,0 +1,7 @@ +// use mock implementations for underlying databases +jest.mock('memcached', () => require('memcached-mock')); + +import { MemcachedCache } from '../index'; +import { testKeyValueCache } from '../../../apollo-server-caching/src/__tests__/testsuite'; + +testKeyValueCache(new MemcachedCache('localhost')); diff --git a/packages/apollo-server-memcached/src/index.ts b/packages/apollo-server-memcached/src/index.ts new file mode 100644 index 00000000000..5bbd834a268 --- /dev/null +++ b/packages/apollo-server-memcached/src/index.ts @@ -0,0 +1,39 @@ +import { KeyValueCache } from 'apollo-server-caching'; +import * as Memcached from 'memcached'; +import { promisify } from 'util'; + +export class MemcachedCache implements KeyValueCache { + readonly client; + readonly defaultSetOptions = { + ttl: 300, + }; + + constructor(serverLocation: Memcached.Location, options?: Memcached.options) { + this.client = new Memcached(serverLocation, options); + // promisify client calls for convenience + this.client.get = promisify(this.client.get).bind(this.client); + this.client.set = promisify(this.client.set).bind(this.client); + this.client.flush = promisify(this.client.flush).bind(this.client); + } + + async set( + key: string, + data: string, + options?: { ttl?: number }, + ): Promise { + const { ttl } = Object.assign({}, this.defaultSetOptions, options); + await this.client.set(key, data, ttl); + } + + async get(key: string): Promise { + return await this.client.get(key); + } + + async flush(): Promise { + await this.client.flush(); + } + + async close(): Promise { + this.client.end(); + } +} diff --git a/packages/apollo-server-memcached/tsconfig.json b/packages/apollo-server-memcached/tsconfig.json new file mode 100644 index 00000000000..bbb66fb641f --- /dev/null +++ b/packages/apollo-server-memcached/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "removeComments": true, + "strict": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUnusedParameters": true, + "noUnusedLocals": true, + "types": ["node", "jest"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "**/__tests__/*", "**/__mocks__/*"] +} diff --git a/packages/apollo-server-micro/README.md b/packages/apollo-server-micro/README.md index c3e3c1f6000..d0719241a8b 100644 --- a/packages/apollo-server-micro/README.md +++ b/packages/apollo-server-micro/README.md @@ -7,29 +7,212 @@ description: Setting up Apollo Server with Micro This is the [Micro](https://github.com/zeit/micro) integration for the Apollo community GraphQL Server. [Read the docs.](https://www.apollographql.com/docs/apollo-server/) [Read the CHANGELOG.](https://github.com/apollographql/apollo-server/blob/master/CHANGELOG.md) +## Basic GraphQL Microservice + +This example demonstrates how to setup a simple microservice, using Micro, that +handles incoming GraphQL requests via the default `/graphql` endpoint. + +1) Package installation. + ```sh -npm install apollo-server-micro +npm install --save micro apollo-server-micro@rc graphql ``` -## Example +2) `index.js` ```js -import { microGraphiql, microGraphql } from 'apollo-server-micro'; -import micro, { send } from 'micro'; -import { get, post, router } from 'microrouter'; -import schema from './schema'; - -const graphqlHandler = microGraphql({ schema }); -const graphiqlHandler = microGraphiql({ endpointURL: '/graphql' }); - -const server = micro( - router( - get('/graphql', graphqlHandler), - post('/graphql', graphqlHandler), - get('/graphiql', graphiqlHandler), - (req, res) => send(res, 404, 'not found'), - ), +const { ApolloServer, gql } = require('apollo-server-micro'); + +const typeDefs = gql` + type Query { + sayHello: String + } +`; + +const resolvers = { + Query: { + sayHello(root, args, context) { + return 'Hello World!'; + }, + }, +}; + +const apolloServer = new ApolloServer({ typeDefs, resolvers }); +module.exports = apolloServer.createHandler(); +``` + +3) `package.json` + +```json +{ + "main": "index.js", + "scripts": { + "start": "micro" + } +} +``` + +4) After an `npm start`, access `http://localhost:3000/graphql` in your +browser to run queries using +[`graphql-playground`](https://github.com/prismagraphql/graphql-playground), +or send GraphQL requests directly to the same URL. + +## CORS Example + +This example demonstrates how to setup a simple Micro + CORS + GraphQL +microservice, using [`micro-cors`](https://github.com/possibilities/micro-cors): + +1) Package installation. + +```sh +npm install --save micro micro-cors apollo-server-micro@rc graphql +``` + +2) `index.js` + +```js line=1,19 +const cors = require('micro-cors')(); +const { ApolloServer, gql } = require('apollo-server-micro'); + +const typeDefs = gql` + type Query { + sayHello: String + } +`; + +const resolvers = { + Query: { + sayHello(root, args, context) { + return 'Hello World!'; + }, + }, +}; + +const apolloServer = new ApolloServer({ typeDefs, resolvers }); +module.exports = cors(apolloServer.createHandler()); +``` + +3) `package.json` + +```json +{ + "main": "index.js", + "scripts": { + "start": "micro" + } +} +``` + +4) After an `npm start`, access `http://localhost:3000/graphql` in your +browser to run queries using +[`graphql-playground`](https://github.com/prismagraphql/graphql-playground), +or send GraphQL requests directly to the same URL. + +## Custom GraphQL Path Example + +This example shows how to setup a simple Micro + GraphQL microservice, that +uses a custom GraphQL endpoint path: + +1) Package installation. + +```sh +npm install --save micro apollo-server-micro@rc graphql +``` + +2) `index.js` + +```js line=18 +const { ApolloServer, gql } = require('apollo-server-micro'); + +const typeDefs = gql` + type Query { + sayHello: String + } +`; + +const resolvers = { + Query: { + sayHello(root, args, context) { + return 'Hello World!'; + }, + }, +}; + +const apolloServer = new ApolloServer({ typeDefs, resolvers }); +module.exports = apolloServer.createHandler({ path: '/data' }); +``` + +3) `package.json` + +```json +{ + "main": "index.js", + "scripts": { + "start": "micro" + } +} +``` + +4) After an `npm start`, access `http://localhost:3000/graphql` in your +browser to run queries using +[`graphql-playground`](https://github.com/prismagraphql/graphql-playground), +or send GraphQL requests directly to the same URL. + +## Fully Custom Routing Example + +This example demonstrates how to setup a simple Micro + GraphQL microservice, +that uses [`micro-router`](https://github.com/pedronauck/micro-router) for +fully custom routing: + +1) Package installation. + +```sh +npm install --save micro microrouter apollo-server-micro@rc graphql +``` + +2) `index.js` + +```js line=1,21-26 +const { router, get, post, options } = require('microrouter'); +const { ApolloServer, gql } = require('apollo-server-micro'); + +const typeDefs = gql` + type Query { + sayHello: String + } +`; + +const resolvers = { + Query: { + sayHello(root, args, context) { + return 'Hello World!'; + }, + }, +}; + +const apolloServer = new ApolloServer({ typeDefs, resolvers }); +const graphqlPath = '/data'; +const graphqlHandler = apolloServer.createHandler({ path: graphqlPath }); +module.exports = router( + get('/', (req, res) => 'Welcome!'), + options(graphqlPath, graphqlHandler), + post(graphqlPath, graphqlHandler), + get(graphqlPath, graphqlHandler), ); +``` + +3) `package.json` -server.listen(3000); +```json +{ + "main": "index.js", + "scripts": { + "start": "micro" + } +} ``` + +4) After an `npm start`, access `http://localhost:3000/graphql` in your +browser to run queries using +[`graphql-playground`](https://github.com/prismagraphql/graphql-playground), +or send GraphQL requests directly to the same URL. diff --git a/packages/apollo-server-micro/package.json b/packages/apollo-server-micro/package.json index cb375849650..31fac501f94 100644 --- a/packages/apollo-server-micro/package.json +++ b/packages/apollo-server-micro/package.json @@ -1,11 +1,12 @@ { "name": "apollo-server-micro", - "version": "1.4.0", + "version": "2.0.0-rc.7", "description": "Production-ready Node.js GraphQL server for Micro", "main": "dist/index.js", "scripts": { "compile": "tsc", - "prepublish": "npm run compile" + "watch": "tsc -w", + "prepare": "npm run compile" }, "repository": { "type": "git", @@ -16,27 +17,27 @@ "Apollo", "Micro", "Server", - "Javascript" + "Javascript", + "ZEIT" ], - "author": "Nick Nance ", + "author": "opensource@apollographql.com", "license": "MIT", "bugs": { "url": "https://github.com/apollographql/apollo-server/issues" }, "homepage": "https://github.com/apollographql/apollo-server#readme", "dependencies": { - "apollo-server-core": "^1.4.0", - "apollo-server-module-graphiql": "^1.4.0" + "accept": "^3.0.2", + "apollo-server-core": "^2.0.0-rc.7", + "apollo-upload-server": "^5.0.0", + "graphql-playground-html": "^1.6.0", + "micro": "^9.3.2" }, "devDependencies": { - "@types/graphql": "0.12.7", - "@types/micro": "7.3.1", - "apollo-server-integration-testsuite": "^1.4.0", - "micro": "8.0.4", - "microrouter": "2.2.3" - }, - "peerDependencies": { - "micro": "^8.0.1 || ^9.0.1" + "@types/micro": "^7.3.1", + "apollo-server-integration-testsuite": "^2.0.0-rc.7", + "request-promise": "^4.2.2", + "test-listen": "^1.1.0" }, "typings": "dist/index.d.ts", "typescript": { diff --git a/packages/apollo-server-micro/src/ApolloServer.test.ts b/packages/apollo-server-micro/src/ApolloServer.test.ts new file mode 100644 index 00000000000..39a2bb67960 --- /dev/null +++ b/packages/apollo-server-micro/src/ApolloServer.test.ts @@ -0,0 +1,233 @@ +import { expect } from 'chai'; +import 'mocha'; +import micro from 'micro'; +import * as listen from 'test-listen'; +import { createApolloFetch } from 'apollo-fetch'; +import { gql } from 'apollo-server-core'; +import * as FormData from 'form-data'; +import * as fs from 'fs'; +import * as rp from 'request-promise'; + +import { ApolloServer } from './ApolloServer'; + +const typeDefs = gql` + type Query { + hello: String + } +`; + +const resolvers = { + Query: { + hello: () => 'hi', + }, +}; + +async function createServer(options: object = {}): Promise { + const apolloServer = new ApolloServer({ typeDefs, resolvers }); + const service = micro(apolloServer.createHandler(options)); + const uri = await listen(service); + return { + service, + uri, + }; +} + +describe('apollo-server-micro', function() { + describe('constructor', function() { + it('should accepts typeDefs and resolvers', function() { + const apolloServer = new ApolloServer({ typeDefs, resolvers }); + expect(apolloServer).to.not.be.undefined; + }); + }); + + describe('#createHandler', function() { + describe('querying', function() { + it( + 'should be queryable using the default /graphql path, if no path ' + + 'is provided', + async function() { + const { service, uri } = await createServer(); + const apolloFetch = createApolloFetch({ uri: `${uri}/graphql` }); + const result = await apolloFetch({ query: '{hello}' }); + expect(result.data.hello).to.equal('hi'); + service.close(); + }, + ); + + it( + 'should only be queryable at the default /graphql path, if no path ' + + 'is provided', + async function() { + const { service, uri } = await createServer(); + const apolloFetch = createApolloFetch({ uri: `${uri}/nopath` }); + let errorThrown = false; + try { + await apolloFetch({ query: '{hello}' }); + } catch (error) { + errorThrown = true; + } + expect(errorThrown).to.be.true; + service.close(); + }, + ); + + it('should be queryable using a custom path', async function() { + const { service, uri } = await createServer({ path: '/data' }); + const apolloFetch = createApolloFetch({ uri: `${uri}/data` }); + const result = await apolloFetch({ query: '{hello}' }); + expect(result.data.hello).to.equal('hi'); + service.close(); + }); + + it( + 'should render a GraphQL playground when a browser sends in a ' + + 'request', + async function() { + const nodeEnv = process.env.NODE_ENV; + delete process.env.NODE_ENV; + + const { service, uri } = await createServer(); + + const body = await rp({ + uri, + method: 'GET', + headers: { + accept: + 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8', + }, + }); + process.env.NODE_ENV = nodeEnv; + expect(body).to.contain('GraphQLPlayground'); + service.close(); + }, + ); + }); + + describe('health checks', function() { + it('should create a healthcheck endpoint', async function() { + const { service, uri } = await createServer(); + const body = await rp(`${uri}/.well-known/apollo/server-health`); + expect(body).to.equal(JSON.stringify({ status: 'pass' })); + service.close(); + }); + + it('should support a health check callback', async function() { + const { service, uri } = await createServer({ + async onHealthCheck() { + throw Error("can't connect to DB"); + }, + }); + + let error; + try { + await rp(`${uri}/.well-known/apollo/server-health`); + } catch (err) { + error = err; + } + expect(error).to.not.be.undefined; + expect(error.statusCode).to.equal(503); + expect(error.error).to.equal(JSON.stringify({ status: 'fail' })); + service.close(); + }); + + it('should be able to disable the health check', async function() { + const { service, uri } = await createServer({ + disableHealthCheck: true, + }); + + let error; + try { + await rp(`${uri}/.well-known/apollo/server-health`); + } catch (err) { + error = err; + } + expect(error).to.not.be.undefined; + expect(error.statusCode).to.equal(404); + service.close(); + }); + }); + + describe('file uploads', function() { + it('should handle file uploads', async function() { + // XXX This is currently a failing test for node 10 + const NODE_VERSION = process.version.split('.'); + const NODE_MAJOR_VERSION = parseInt(NODE_VERSION[0].replace(/^v/, '')); + if (NODE_MAJOR_VERSION === 10) return; + + const apolloServer = new ApolloServer({ + typeDefs: gql` + type File { + filename: String! + mimetype: String! + encoding: String! + } + + type Query { + uploads: [File] + } + + type Mutation { + singleUpload(file: Upload!): File! + } + `, + resolvers: { + Query: { + uploads: () => {}, + }, + Mutation: { + singleUpload: async (_, args) => { + expect((await args.file).stream).to.exist; + return args.file; + }, + }, + }, + }); + const service = micro(apolloServer.createHandler()); + const url = await listen(service); + + const body = new FormData(); + body.append( + 'operations', + JSON.stringify({ + query: ` + mutation($file: Upload!) { + singleUpload(file: $file) { + filename + encoding + mimetype + } + } + `, + variables: { + file: null, + }, + }), + ); + body.append('map', JSON.stringify({ 1: ['variables.file'] })); + body.append('1', fs.createReadStream('package.json')); + + try { + const resolved = await fetch(`${url}/graphql`, { + method: 'POST', + body: body as any, + }); + const text = await resolved.text(); + const response = JSON.parse(text); + + expect(response.data.singleUpload).to.deep.equal({ + filename: 'package.json', + encoding: '7bit', + mimetype: 'application/json', + }); + } catch (error) { + // This error began appearing randomly and seems to be a dev + // dependency bug. + // https://github.com/jaydenseric/apollo-upload-server/blob/18ecdbc7a1f8b69ad51b4affbd986400033303d4/test.js#L39-L42 + if (error.code !== 'EPIPE') throw error; + } + + service.close(); + }); + }); + }); +}); diff --git a/packages/apollo-server-micro/src/ApolloServer.ts b/packages/apollo-server-micro/src/ApolloServer.ts new file mode 100644 index 00000000000..2ba63e00fe9 --- /dev/null +++ b/packages/apollo-server-micro/src/ApolloServer.ts @@ -0,0 +1,168 @@ +import { ApolloServerBase, GraphQLOptions } from 'apollo-server-core'; +import { processRequest as processFileUploads } from 'apollo-upload-server'; +import { ServerResponse } from 'http'; +import { send } from 'micro'; +import { renderPlaygroundPage } from 'graphql-playground-html'; +import { parseAll } from 'accept'; + +import { graphqlMicro } from './microApollo'; +import { MicroRequest } from './types'; + +export interface ServerRegistration { + path?: string; + disableHealthCheck?: boolean; + onHealthCheck?: (req: MicroRequest) => Promise; +} + +export class ApolloServer extends ApolloServerBase { + // Extract Apollo Server options from the request. + async createGraphQLServerOptions( + req: MicroRequest, + res: ServerResponse, + ): Promise { + return super.graphQLServerOptions({ req, res }); + } + + // Prepares and returns an async function that can be used by Micro to handle + // GraphQL requests. + public createHandler({ + path, + disableHealthCheck, + onHealthCheck, + }: ServerRegistration = {}) { + return async (req, res) => { + this.graphqlPath = path || '/graphql'; + + await this.handleFileUploads(req); + + (await this.handleHealthCheck({ + req, + res, + disableHealthCheck, + onHealthCheck, + })) || + this.handleGraphqlRequestsWithPlayground({ req, res }) || + (await this.handleGraphqlRequestsWithServer({ req, res })) || + send(res, 404, null); + }; + } + + // This integration supports file uploads. + protected supportsUploads(): boolean { + return true; + } + + // This integration supports subscriptions. + protected supportsSubscriptions(): boolean { + return true; + } + + // If health checking is enabled, trigger the `onHealthCheck` + // function when the health check URL is requested. + private async handleHealthCheck({ + req, + res, + disableHealthCheck, + onHealthCheck, + }: { + req: MicroRequest; + res: ServerResponse; + disableHealthCheck?: boolean; + onHealthCheck?: (req: MicroRequest) => Promise; + }): Promise { + let handled = false; + + if ( + !disableHealthCheck && + req.url === '/.well-known/apollo/server-health' + ) { + // Response follows + // https://tools.ietf.org/html/draft-inadarei-api-health-check-01 + res.setHeader('Content-Type', 'application/health+json'); + + if (onHealthCheck) { + try { + await onHealthCheck(req); + } catch (error) { + send(res, 503, { status: 'fail' }); + handled = true; + } + } + + if (!handled) { + send(res, 200, { status: 'pass' }); + handled = true; + } + } + + return handled; + } + + // If the `playgroundOptions` are set, register a `graphql-playground` instance + // (not available in production) that is then used to handle all + // incoming GraphQL requests. + private handleGraphqlRequestsWithPlayground({ + req, + res, + }: { + req: MicroRequest; + res: ServerResponse; + }): boolean { + let handled = false; + + if (this.playgroundOptions && req.method === 'GET') { + const accept = parseAll(req.headers); + const types = accept.mediaTypes as string[]; + const prefersHTML = + types.find( + (x: string) => x === 'text/html' || x === 'application/json', + ) === 'text/html'; + + if (prefersHTML) { + const middlewareOptions = { + endpoint: this.graphqlPath, + subscriptionEndpoint: this.subscriptionsPath, + ...this.playgroundOptions, + }; + send(res, 200, renderPlaygroundPage(middlewareOptions)); + handled = true; + } + } + + return handled; + } + + // Handle incoming GraphQL requests using Apollo Server. + private async handleGraphqlRequestsWithServer({ + req, + res, + }: { + req: MicroRequest; + res: ServerResponse; + }): Promise { + let handled = false; + const url = req.url.split('?')[0]; + if (url === this.graphqlPath) { + const graphqlHandler = graphqlMicro( + this.createGraphQLServerOptions.bind(this), + ); + const responseData = await graphqlHandler(req, res); + send(res, 200, responseData); + handled = true; + } + return handled; + } + + // If file uploads are detected, prepare them for easier handling with + // the help of `apollo-upload-server`. + private async handleFileUploads(req: MicroRequest) { + const contentType = req.headers['content-type']; + if ( + this.uploadsConfig && + contentType && + contentType.startsWith('multipart/form-data') + ) { + req.filePayload = await processFileUploads(req, this.uploadsConfig); + } + } +} diff --git a/packages/apollo-server-micro/src/index.ts b/packages/apollo-server-micro/src/index.ts index 84c6c4b7710..69da3a753cb 100644 --- a/packages/apollo-server-micro/src/index.ts +++ b/packages/apollo-server-micro/src/index.ts @@ -1,6 +1,18 @@ export { - MicroGraphQLOptionsFunction, - MicroGraphiQLOptionsFunction, - microGraphql, - microGraphiql, -} from './microApollo'; + GraphQLUpload, + GraphQLOptions, + gql, + // Errors + ApolloError, + toApolloError, + SyntaxError, + ValidationError, + AuthenticationError, + ForbiddenError, + UserInputError, +} from 'apollo-server-core'; + +export * from 'graphql-tools'; + +// ApolloServer integration. +export { ApolloServer } from './ApolloServer'; diff --git a/packages/apollo-server-micro/src/microApollo.test.ts b/packages/apollo-server-micro/src/microApollo.test.ts index 2a019b2e001..37f44da4917 100644 --- a/packages/apollo-server-micro/src/microApollo.test.ts +++ b/packages/apollo-server-micro/src/microApollo.test.ts @@ -1,54 +1,29 @@ -import { microGraphql, microGraphiql } from './microApollo'; -import 'mocha'; - -import micro, { send } from 'micro'; -import { - router, - get, - post, - put, - patch, - del, - head, - options as opts, -} from 'microrouter'; +import micro from 'micro'; import testSuite, { - schema, + schema as Schema, CreateAppOptions, } from 'apollo-server-integration-testsuite'; +import { expect } from 'chai'; +import { GraphQLOptions, Config } from 'apollo-server-core'; +import 'mocha'; -function createApp(options: CreateAppOptions) { - const graphqlOptions = (options && options.graphqlOptions) || { schema }; - const graphiqlOptions = (options && options.graphiqlOptions) || { - endpointURL: '/graphql', - }; - - const graphqlHandler = microGraphql(graphqlOptions); - const graphiqlHandler = microGraphiql(graphiqlOptions); - - return micro( - router( - get('/graphql', graphqlHandler), - post('/graphql', graphqlHandler), - put('/graphql', graphqlHandler), - patch('/graphql', graphqlHandler), - del('/graphql', graphqlHandler), - head('/graphql', graphqlHandler), - opts('/graphql', graphqlHandler), - - get('/graphiql', graphiqlHandler), - post('/graphiql', graphiqlHandler), - put('/graphiql', graphiqlHandler), - patch('/graphiql', graphiqlHandler), - del('/graphiql', graphiqlHandler), - head('/graphiql', graphiqlHandler), - opts('/graphiql', graphiqlHandler), +import { ApolloServer } from './ApolloServer'; - (req, res) => send(res, 404, 'not found'), - ), +function createApp(options: CreateAppOptions = {}) { + const server = new ApolloServer( + (options.graphqlOptions as Config) || { schema: Schema }, ); + return micro(server.createHandler()); } -describe('integration:Micro', () => { +describe('microApollo', function() { + it('should throw an error if called without a schema', function() { + expect(() => new ApolloServer(undefined as GraphQLOptions)).to.throw( + 'ApolloServer requires options.', + ); + }); +}); + +describe('integration:Micro', function() { testSuite(createApp); }); diff --git a/packages/apollo-server-micro/src/microApollo.ts b/packages/apollo-server-micro/src/microApollo.ts index f2db7e56492..fa9fce87289 100644 --- a/packages/apollo-server-micro/src/microApollo.ts +++ b/packages/apollo-server-micro/src/microApollo.ts @@ -1,18 +1,30 @@ import { GraphQLOptions, - HttpQueryError, runHttpQuery, + convertNodeHttpToRequest, } from 'apollo-server-core'; -import * as GraphiQL from 'apollo-server-module-graphiql'; -import { createError, json, RequestHandler } from 'micro'; +import { json, RequestHandler } from 'micro'; import * as url from 'url'; import { IncomingMessage, ServerResponse } from 'http'; +import { MicroRequest } from './types'; + +// Allowed Micro Apollo Server options. export interface MicroGraphQLOptionsFunction { (req?: IncomingMessage): GraphQLOptions | Promise; } -export function microGraphql( +// Utility function used to set multiple headers on a response object. +function setHeaders(res: ServerResponse, headers: Object): void { + Object.keys(headers).forEach((header: string) => { + res.setHeader(header, headers[header]); + }); +} + +// Build and return an async function that passes incoming GraphQL requests +// over to Apollo Server for processing, then fires the results/response back +// using Micro's `send` functionality. +export function graphqlMicro( options: GraphQLOptions | MicroGraphQLOptionsFunction, ): RequestHandler { if (!options) { @@ -25,34 +37,29 @@ export function microGraphql( ); } - const graphqlHandler = async (req: IncomingMessage, res: ServerResponse) => { + const graphqlHandler = async (req: MicroRequest, res: ServerResponse) => { let query; - if (req.method === 'POST') { - try { - query = await json(req); - } catch (err) { - query = undefined; - } - } else { - query = url.parse(req.url, true).query; + try { + query = + req.method === 'POST' + ? req.filePayload || (await json(req)) + : url.parse(req.url, true).query; + } catch (error) { + // Do nothing; `query` stays `undefined` } try { - const gqlResponse = await runHttpQuery([req, res], { + const { graphqlResponse, responseInit } = await runHttpQuery([req, res], { method: req.method, - options: options, - query: query, + options, + query, + request: convertNodeHttpToRequest(req), }); - - res.setHeader('Content-Type', 'application/json'); - return gqlResponse; + setHeaders(res, responseInit.headers); + return graphqlResponse; } catch (error) { - if ('HttpQueryError' === error.name) { - if (error.headers) { - Object.keys(error.headers).forEach(header => { - res.setHeader(header, error.headers[header]); - }); - } + if ('HttpQueryError' === error.name && error.headers) { + setHeaders(res, error.headers); } if (!error.statusCode) { @@ -65,31 +72,3 @@ export function microGraphql( return graphqlHandler; } - -export interface MicroGraphiQLOptionsFunction { - (req?: IncomingMessage): - | GraphiQL.GraphiQLData - | Promise; -} - -export function microGraphiql( - options: GraphiQL.GraphiQLData | MicroGraphiQLOptionsFunction, -): RequestHandler { - const graphiqlHandler = (req: IncomingMessage, res: ServerResponse) => { - const query = (req.url && url.parse(req.url, true).query) || {}; - return GraphiQL.resolveGraphiQLString(query, options, req).then( - graphiqlString => { - res.setHeader('Content-Type', 'text/html'); - res.write(graphiqlString); - res.end(); - }, - error => { - res.statusCode = 500; - res.write(error.message); - res.end(); - }, - ); - }; - - return graphiqlHandler; -} diff --git a/packages/apollo-server-micro/src/types.ts b/packages/apollo-server-micro/src/types.ts new file mode 100644 index 00000000000..75911a45b9d --- /dev/null +++ b/packages/apollo-server-micro/src/types.ts @@ -0,0 +1,5 @@ +import { IncomingMessage } from 'http'; + +export interface MicroRequest extends IncomingMessage { + filePayload?: object; +} diff --git a/packages/apollo-server-micro/tsconfig.json b/packages/apollo-server-micro/tsconfig.json index 8e99768afe9..5ac3c46b1f6 100644 --- a/packages/apollo-server-micro/tsconfig.json +++ b/packages/apollo-server-micro/tsconfig.json @@ -1,9 +1,9 @@ { - "extends": "../../tsconfig", + "extends": "../../tsconfig.json", "compilerOptions": { "rootDir": "./src", "outDir": "./dist", - "typeRoots": ["node_modules/@types"] + "lib": ["es2017", "esnext.asynciterable", "dom"] }, "exclude": ["node_modules", "dist"] } diff --git a/packages/apollo-server-module-graphiql/README.md b/packages/apollo-server-module-graphiql/README.md deleted file mode 100644 index cfc800d4738..00000000000 --- a/packages/apollo-server-module-graphiql/README.md +++ /dev/null @@ -1,4 +0,0 @@ -# apollo-server-module-graphiql - -This is the GraphiQL rendering implementation for the Apollo community GraphQL Server. [Read the docs.](https://www.apollographql.com/docs/apollo-server/) -[Read the CHANGELOG.](https://github.com/apollographql/apollo-server/blob/master/CHANGELOG.md) diff --git a/packages/apollo-server-module-graphiql/package.json b/packages/apollo-server-module-graphiql/package.json deleted file mode 100644 index 9be6964ab40..00000000000 --- a/packages/apollo-server-module-graphiql/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "apollo-server-module-graphiql", - "version": "1.4.0", - "description": "GraphiQL renderer for Apollo GraphQL Server", - "main": "dist/index.js", - "scripts": { - "compile": "tsc", - "prepublish": "npm run compile" - }, - "repository": { - "type": "git", - "url": "https://github.com/apollographql/apollo-server/tree/master/packages/apollo-server-module-graphiql" - }, - "keywords": [ - "GraphQL", - "GraphiQL", - "Apollo", - "Javascript" - ], - "author": "Jonas Helfer ", - "license": "MIT", - "bugs": { - "url": "https://github.com/apollographql/apollo-server/issues" - }, - "homepage": "https://github.com/apollographql/apollo-server#readme", - "typings": "dist/index.d.ts", - "typescript": { - "definition": "dist/index.d.ts" - } -} diff --git a/packages/apollo-server-module-graphiql/src/index.ts b/packages/apollo-server-module-graphiql/src/index.ts deleted file mode 100644 index e809997e9b9..00000000000 --- a/packages/apollo-server-module-graphiql/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { GraphiQLData, renderGraphiQL } from './renderGraphiQL'; -export { resolveGraphiQLString } from './resolveGraphiQLString'; diff --git a/packages/apollo-server-module-graphiql/src/renderGraphiQL.ts b/packages/apollo-server-module-graphiql/src/renderGraphiQL.ts deleted file mode 100644 index 1cfdf95b694..00000000000 --- a/packages/apollo-server-module-graphiql/src/renderGraphiQL.ts +++ /dev/null @@ -1,243 +0,0 @@ -/* - * Mostly taken straight from express-graphql, so see their licence - * (https://github.com/graphql/express-graphql/blob/master/LICENSE) - */ - -// TODO: in the future, build the GraphiQL app on the server, so it does not -// depend on any CDN and can be run offline. - -/* - * Arguments: - * - * - endpointURL: the relative or absolute URL for the endpoint which GraphiQL will make queries to - * - (optional) query: the GraphQL query to pre-fill in the GraphiQL UI - * - (optional) variables: a JS object of variables to pre-fill in the GraphiQL UI - * - (optional) operationName: the operationName to pre-fill in the GraphiQL UI - * - (optional) result: the result of the query to pre-fill in the GraphiQL UI - * - (optional) passHeader: a string that will be added to the header object. - * For example "'Authorization': localStorage['Meteor.loginToken']" for meteor - * - (optional) editorTheme: a CodeMirror theme to be applied to the GraphiQL UI - * - (optional) websocketConnectionParams: an object to pass to the web socket server - */ - -export type GraphiQLData = { - endpointURL: string; - subscriptionsEndpoint?: string; - query?: string; - variables?: Object; - operationName?: string; - result?: Object; - passHeader?: string; - editorTheme?: string; - websocketConnectionParams?: Object; - rewriteURL?: boolean; -}; - -// Current latest version of GraphiQL. -const GRAPHIQL_VERSION = '0.11.11'; -const SUBSCRIPTIONS_TRANSPORT_VERSION = '0.9.9'; - -// Ensures string values are safe to be used within a - - - ${ - usingEditorTheme - ? `` - : '' - } - ${ - usingHttp - ? `` - : '' - } - ${ - usingWs - ? `` - : '' - } - ${ - usingWs && usingHttp - ? '' - : '' - } - - - - - -`; -} diff --git a/packages/apollo-server-module-graphiql/src/resolveGraphiQLString.ts b/packages/apollo-server-module-graphiql/src/resolveGraphiQLString.ts deleted file mode 100644 index 8aeb0c45aa3..00000000000 --- a/packages/apollo-server-module-graphiql/src/resolveGraphiQLString.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { GraphiQLData, renderGraphiQL } from './renderGraphiQL'; - -export type GraphiQLParams = { - query?: string; - variables?: string; - operationName?: string; -}; - -function isOptionsFunction(arg: GraphiQLData | Function): arg is Function { - return typeof arg === 'function'; -} - -async function resolveGraphiQLOptions( - options: GraphiQLData | Function, - ...args -): Promise { - if (isOptionsFunction(options)) { - try { - return await options(...args); - } catch (e) { - throw new Error(`Invalid options provided for GraphiQL: ${e.message}`); - } - } else { - return options; - } -} - -function createGraphiQLParams(query: any): GraphiQLParams { - const queryObject = query || {}; - return { - query: queryObject.query || '', - variables: queryObject.variables, - operationName: queryObject.operationName || '', - }; -} - -function createGraphiQLData( - params: GraphiQLParams, - options: GraphiQLData, -): GraphiQLData { - return { - endpointURL: options.endpointURL, - subscriptionsEndpoint: options.subscriptionsEndpoint, - query: params.query || options.query, - variables: - (params.variables && JSON.parse(params.variables)) || options.variables, - operationName: params.operationName || options.operationName, - passHeader: options.passHeader, - editorTheme: options.editorTheme, - websocketConnectionParams: options.websocketConnectionParams, - rewriteURL: options.rewriteURL, - }; -} - -export async function resolveGraphiQLString( - query: any = {}, - options: GraphiQLData | Function, - ...args -): Promise { - const graphiqlParams = createGraphiQLParams(query); - const graphiqlOptions = await resolveGraphiQLOptions(options, ...args); - const graphiqlData = createGraphiQLData(graphiqlParams, graphiqlOptions); - return renderGraphiQL(graphiqlData); -} diff --git a/packages/apollo-server-module-graphiql/tsconfig.json b/packages/apollo-server-module-graphiql/tsconfig.json deleted file mode 100644 index 8e99768afe9..00000000000 --- a/packages/apollo-server-module-graphiql/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig", - "compilerOptions": { - "rootDir": "./src", - "outDir": "./dist", - "typeRoots": ["node_modules/@types"] - }, - "exclude": ["node_modules", "dist"] -} diff --git a/packages/apollo-server-module-operation-store/README.md b/packages/apollo-server-module-operation-store/README.md deleted file mode 100644 index 64bcc188aba..00000000000 --- a/packages/apollo-server-module-operation-store/README.md +++ /dev/null @@ -1,4 +0,0 @@ -# apollo-server-module-operation-store - -This is the persisted query store implementation for the Apollo community GraphQL Server. [Read the docs.](https://www.apollographql.com/docs/apollo-server/) -[Read the CHANGELOG.](https://github.com/apollographql/apollo-server/blob/master/CHANGELOG.md) diff --git a/packages/apollo-server-module-operation-store/package.json b/packages/apollo-server-module-operation-store/package.json deleted file mode 100644 index b0de47cb615..00000000000 --- a/packages/apollo-server-module-operation-store/package.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "name": "apollo-server-module-operation-store", - "version": "1.3.5", - "description": "Persisted operation store module for Apollo GraphQL Servers", - "main": "dist/index.js", - "scripts": { - "compile": "tsc", - "prepublish": "npm run compile" - }, - "repository": { - "type": "git", - "url": "https://github.com/apollographql/apollo-server/tree/master/packages/apollo-server-module-operation-store" - }, - "keywords": [ - "GraphQL", - "Apollo", - "Operation Store", - "Javascript" - ], - "author": "Jonas Helfer ", - "license": "MIT", - "bugs": { - "url": "https://github.com/apollographql/apollo-server/issues" - }, - "homepage": "https://github.com/apollographql/apollo-server#readme", - "devDependencies": { - "@types/graphql": "0.12.7" - }, - "peerDependencies": { - "graphql": "^0.9.0 || ^0.10.1 || ^0.11.0 || ^0.12.0 || ^0.13.0" - }, - "typings": "dist/index.d.ts", - "typescript": { - "definition": "dist/index.d.ts" - } -} diff --git a/packages/apollo-server-module-operation-store/src/index.ts b/packages/apollo-server-module-operation-store/src/index.ts deleted file mode 100644 index 7c10b8aeba0..00000000000 --- a/packages/apollo-server-module-operation-store/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { OperationStore } from './operationStore'; diff --git a/packages/apollo-server-module-operation-store/src/operationStore.test.ts b/packages/apollo-server-module-operation-store/src/operationStore.test.ts deleted file mode 100644 index be2e4dce129..00000000000 --- a/packages/apollo-server-module-operation-store/src/operationStore.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import 'mocha'; - -import { expect } from 'chai'; - -import { - GraphQLSchema, - GraphQLObjectType, - GraphQLString, - GraphQLInt, - GraphQLNonNull, - print, - parse, -} from 'graphql'; - -import { OperationStore } from './operationStore'; - -const queryType = new GraphQLObjectType({ - name: 'QueryType', - fields: { - testString: { - type: GraphQLString, - /*resolve() { - return 'it works'; - },*/ - }, - testRootValue: { - type: GraphQLString, - /*resolve(root) { - return root + ' works'; - },*/ - }, - testContextValue: { - type: GraphQLString, - /*resolve(root, args, context) { - return context + ' works'; - },*/ - }, - testArgumentValue: { - type: GraphQLInt, - /*resolve(root, args, context) { - return args['base'] + 5; - },*/ - args: { - base: { type: new GraphQLNonNull(GraphQLInt) }, - }, - }, - }, -}); - -const schema = new GraphQLSchema({ - query: queryType, -}); - -describe('operationStore', () => { - it('can store a query and return its ast', () => { - const query = `query testquery{ testString }`; - const expected = `query testquery {\n testString\n}\n`; - - const store = new OperationStore(schema); - store.put(query); - - expect(print(store.get('testquery'))).to.deep.equal(expected); - }); - - it('can store a Document and return its ast', () => { - const query = `query testquery{ testString }`; - const expected = `query testquery {\n testString\n}\n`; - - const store = new OperationStore(schema); - store.put(parse(query)); - - expect(print(store.get('testquery'))).to.deep.equal(expected); - }); - - it('can store queries and return them with getMap', () => { - const query = `query testquery{ testString }`; - const query2 = `query testquery2{ testRootValue }`; - - const store = new OperationStore(schema); - store.put(query); - store.put(query2); - expect(store.getMap().size).to.equal(2); - }); - - it('throws a parse error if the query is invalid', () => { - const query = `query testquery{ testString`; - - const store = new OperationStore(schema); - expect(() => store.put(query)).to.throw(/Syntax Error/); - }); - - it('throws a validation error if the query is invalid', () => { - const query = `query testquery { testStrin }`; - - const store = new OperationStore(schema); - expect(() => store.put(query)).to.throw(/Cannot query field/); - }); - - it('throws an error if there is more than one query or mutation', () => { - const query = ` - query Q1{ testString } - query Q2{ t2: testString } - `; - - const store = new OperationStore(schema); - expect(() => store.put(query)).to.throw( - /OperationDefinitionNode must contain only one definition/, - ); - }); - - it('throws an error if there is no operationDefinition found', () => { - const query = ` - schema { - query: Q - } - `; - - const store = new OperationStore(schema); - - expect(() => store.put(query)).to.throw(/must contain at least/); - }); - - it('can delete stored operations', () => { - const query = `query testquery{ testString }`; - - const store = new OperationStore(schema); - store.put(query); - store.delete('testquery'); - - expect(store.get('testquery')).to.be.undefined; - }); -}); diff --git a/packages/apollo-server-module-operation-store/src/operationStore.ts b/packages/apollo-server-module-operation-store/src/operationStore.ts deleted file mode 100644 index d4393b13f9d..00000000000 --- a/packages/apollo-server-module-operation-store/src/operationStore.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { - parse, - validate, - DocumentNode, - GraphQLSchema, - OperationDefinitionNode, - Kind, -} from 'graphql'; - -export class OperationStore { - private storedOperations: Map; - private schema: GraphQLSchema; - - constructor(schema: GraphQLSchema) { - this.schema = schema; - this.storedOperations = new Map(); - } - - public put(operation: string | DocumentNode): void { - function isOperationDefinition( - definition, - ): definition is OperationDefinitionNode { - return definition.kind === Kind.OPERATION_DEFINITION; - } - - function isString(definition): definition is string { - return typeof definition === 'string'; - } - - const ast = isString(operation) - ? parse(operation as string) - : (operation as DocumentNode); - - const definitions = ast.definitions.filter( - isOperationDefinition, - ) as OperationDefinitionNode[]; - if (definitions.length === 0) { - throw new Error( - 'OperationDefinitionNode must contain at least one definition', - ); - } - if (definitions.length > 1) { - throw new Error( - 'OperationDefinitionNode must contain only one definition', - ); - } - - const validationErrors = validate(this.schema, ast); - if (validationErrors.length > 0) { - const messages = validationErrors.map(e => e.message); - const err = new Error(`Validation Errors:\n${messages.join('\n')}`); - err['originalErrors'] = validationErrors; - throw err; - } - this.storedOperations.set(definitions[0].name.value, ast); - } - - public get(operationName: string): DocumentNode { - return this.storedOperations.get(operationName); - } - - public delete(operationName: string): boolean { - return this.storedOperations.delete(operationName); - } - - public getMap(): Map { - return this.storedOperations; - } -} diff --git a/packages/apollo-server-module-operation-store/tsconfig.json b/packages/apollo-server-module-operation-store/tsconfig.json deleted file mode 100644 index 8e99768afe9..00000000000 --- a/packages/apollo-server-module-operation-store/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig", - "compilerOptions": { - "rootDir": "./src", - "outDir": "./dist", - "typeRoots": ["node_modules/@types"] - }, - "exclude": ["node_modules", "dist"] -} diff --git a/packages/apollo-server-redis/.gitignore b/packages/apollo-server-redis/.gitignore new file mode 100644 index 00000000000..723ef36f4e4 --- /dev/null +++ b/packages/apollo-server-redis/.gitignore @@ -0,0 +1 @@ +.idea \ No newline at end of file diff --git a/packages/apollo-server-redis/.npmignore b/packages/apollo-server-redis/.npmignore new file mode 100644 index 00000000000..a165046d359 --- /dev/null +++ b/packages/apollo-server-redis/.npmignore @@ -0,0 +1,6 @@ +* +!src/**/* +!dist/**/* +dist/**/*.test.* +!package.json +!README.md diff --git a/packages/apollo-server-redis/README.md b/packages/apollo-server-redis/README.md new file mode 100644 index 00000000000..79f5f10eebe --- /dev/null +++ b/packages/apollo-server-redis/README.md @@ -0,0 +1,26 @@ +## RedisCache + +[![npm version](https://badge.fury.io/js/apollo-server-redis.svg)](https://badge.fury.io/js/apollo-server-redis) +[![Build Status](https://circleci.com/gh/apollographql/apollo-server.svg?style=svg)](https://circleci.com/gh/apollographql/apollo-server) + +This package exports an implementation of `KeyValueCache` that allows using Redis as a backing store for resource caching in [Data Sources](https://www.apollographql.com/docs/apollo-server/v2/features/data-sources.html). + +## Usage + +```js +const { RedisCache } = require('apollo-server-redis'); + +const server = new ApolloServer({ + typeDefs, + resolvers, + cache: new RedisCache({ + host: 'redis-server', + // Options are passed through to the Redis client + }), + dataSources: () => ({ + moviesAPI: new MoviesAPI(), + }), +}); +``` + +For documentation of the options you can pass to the underlying redis client, look [here](https://github.com/NodeRedis/node_redis). diff --git a/packages/apollo-server-redis/package.json b/packages/apollo-server-redis/package.json new file mode 100644 index 00000000000..6ee73b54aba --- /dev/null +++ b/packages/apollo-server-redis/package.json @@ -0,0 +1,57 @@ +{ + "name": "apollo-server-redis", + "version": "2.0.0-rc.7", + "author": "opensource@apollographql.com", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/apollographql/apollo-server/tree/master/packages/apollo-server-redis" + }, + "homepage": "https://github.com/apollographql/apollo-server#readme", + "bugs": { + "url": "https://github.com/apollographql/apollo-server/issues" + }, + "scripts": { + "clean": "rm -rf dist", + "compile": "tsc", + "prepublish": "npm run clean && npm run compile", + "test": "jest --verbose" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "engines": { + "node": ">=6" + }, + "dependencies": { + "apollo-server-caching": "^2.0.0-rc.7", + "apollo-server-env": "^2.0.0-rc.7", + "redis": "^2.8.0" + }, + "devDependencies": { + "@types/jest": "^23.1.2", + "@types/redis": "^2.8.6", + "jest": "^23.2.0", + "redis-mock": "^0.27.0", + "ts-jest": "^22.4.6" + }, + "jest": { + "testEnvironment": "node", + "setupFiles": [ + "/node_modules/apollo-server-env/dist/index.js" + ], + "transform": { + "^.+\\.(ts|js)$": "ts-jest" + }, + "moduleFileExtensions": [ + "ts", + "js", + "json" + ], + "testRegex": "src/__tests__/.*$", + "globals": { + "ts-jest": { + "skipBabel": true + } + } + } +} diff --git a/packages/apollo-server-redis/src/__tests__/Redis.test.ts b/packages/apollo-server-redis/src/__tests__/Redis.test.ts new file mode 100644 index 00000000000..4e0ea455af3 --- /dev/null +++ b/packages/apollo-server-redis/src/__tests__/Redis.test.ts @@ -0,0 +1,8 @@ +// use mock implementations for underlying databases +jest.mock('redis', () => require('redis-mock')); +jest.useFakeTimers(); // mocks out setTimeout that is used in redis-mock + +import { RedisCache } from '../index'; +import { testKeyValueCache } from '../../../apollo-server-caching/src/__tests__/testsuite'; + +testKeyValueCache(new RedisCache({ host: 'localhost' })); diff --git a/packages/apollo-server-redis/src/index.ts b/packages/apollo-server-redis/src/index.ts new file mode 100644 index 00000000000..7ae20246185 --- /dev/null +++ b/packages/apollo-server-redis/src/index.ts @@ -0,0 +1,46 @@ +import { KeyValueCache } from 'apollo-server-caching'; +import * as Redis from 'redis'; +import { promisify } from 'util'; + +export class RedisCache implements KeyValueCache { + readonly client; + readonly defaultSetOptions = { + ttl: 300, + }; + + constructor(options: Redis.ClientOpts) { + this.client = Redis.createClient(options); + // promisify client calls for convenience + this.client.get = promisify(this.client.get).bind(this.client); + this.client.set = promisify(this.client.set).bind(this.client); + this.client.flushdb = promisify(this.client.flushdb).bind(this.client); + this.client.quit = promisify(this.client.quit).bind(this.client); + } + + async set( + key: string, + data: string, + options?: { ttl?: number }, + ): Promise { + const { ttl } = Object.assign({}, this.defaultSetOptions, options); + await this.client.set(key, data, 'EX', ttl); + } + + async get(key: string): Promise { + const reply = await this.client.get(key); + // reply is null if key is not found + if (reply !== null) { + return reply; + } + return; + } + + async flush(): Promise { + await this.client.flushdb(); + } + + async close(): Promise { + await this.client.quit(); + return; + } +} diff --git a/packages/apollo-server-redis/tsconfig.json b/packages/apollo-server-redis/tsconfig.json new file mode 100644 index 00000000000..bbb66fb641f --- /dev/null +++ b/packages/apollo-server-redis/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "removeComments": true, + "strict": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUnusedParameters": true, + "noUnusedLocals": true, + "types": ["node", "jest"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "**/__tests__/*", "**/__mocks__/*"] +} diff --git a/packages/apollo-server-restify/README.md b/packages/apollo-server-restify/README.md deleted file mode 100644 index 192c7fafe2a..00000000000 --- a/packages/apollo-server-restify/README.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -title: Restify -description: Setting up Apollo Server with Restify ---- - -[![npm version](https://badge.fury.io/js/apollo-server-core.svg)](https://badge.fury.io/js/apollo-server-core) [![Build Status](https://circleci.com/gh/apollographql/apollo-cache-control-js.svg?style=svg)](https://circleci.com/gh/apollographql/apollo-cache-control-js) [![Coverage Status](https://coveralls.io/repos/github/apollographql/apollo-server/badge.svg?branch=master)](https://coveralls.io/github/apollographql/apollo-server?branch=master) [![Get on Slack](https://img.shields.io/badge/slack-join-orange.svg)](https://www.apollographql.com/#slack) - -This is the Restify integration of Apollo Server. Apollo Server is a community-maintained open-source Apollo Server that works with all Node.js HTTP server frameworks: Express, Connect, Hapi, Koa and Restify. [Read the docs](https://www.apollographql.com/docs/apollo-server/). - -```sh -npm install apollo-server-restify -``` - -## Usage - -```js -import restify from 'restify'; -import { graphqlRestify, graphiqlRestify } from 'apollo-server-restify'; - -const PORT = 3000; - -const server = restify.createServer({ - title: 'Apollo Server', -}); - -const graphQLOptions = { schema: myGraphQLSchema }; - -server.use(restify.plugins.bodyParser()); -server.use(restify.plugins.queryParser()); - -server.post('/graphql', graphqlRestify(graphQLOptions)); -server.get('/graphql', graphqlRestify(graphQLOptions)); - -server.get('/graphiql', graphiqlRestify({ endpointURL: '/graphql' })); - -server.listen(PORT, () => console.log(`Listening on ${PORT}`)); -``` - -## Principles - -Apollo Server is built with the following principles in mind: - -* **By the community, for the community**: Apollo Server's development is driven by the needs of developers -* **Simplicity**: by keeping things simple, Apollo Server is easier to use, easier to contribute to, and more secure -* **Performance**: Apollo Server is well-tested and production-ready - no modifications needed - -Anyone is welcome to contribute to Apollo Server, just read [CONTRIBUTING.md](https://github.com/apollographql/apollo-server/blob/master/CONTRIBUTING.md), take a look at the [roadmap](https://github.com/apollographql/apollo-server/blob/master/ROADMAP.md) and make your first PR! -[Read the CHANGELOG.](https://github.com/apollographql/apollo-server/blob/master/CHANGELOG.md) diff --git a/packages/apollo-server-restify/package.json b/packages/apollo-server-restify/package.json deleted file mode 100644 index 9707b43d286..00000000000 --- a/packages/apollo-server-restify/package.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "name": "apollo-server-restify", - "version": "1.4.0", - "description": "Production-ready Node.js GraphQL server for Restify", - "main": "dist/index.js", - "scripts": { - "compile": "tsc", - "prepublish": "npm run compile" - }, - "repository": { - "type": "git", - "url": "https://github.com/apollographql/apollo-server/tree/master/packages/apollo-server-restify" - }, - "keywords": [ - "GraphQL", - "Apollo", - "Server", - "Restify", - "Javascript" - ], - "author": "Jonas Helfer ", - "license": "MIT", - "bugs": { - "url": "https://github.com/apollographql/apollo-server/issues" - }, - "homepage": "https://github.com/apollographql/apollo-server#readme", - "dependencies": { - "apollo-server-core": "^1.4.0", - "apollo-server-module-graphiql": "^1.4.0" - }, - "devDependencies": { - "@types/graphql": "0.12.7", - "@types/restify": "5.0.9", - "apollo-server-integration-testsuite": "^1.4.0", - "restify": "5.2.1" - }, - "typings": "dist/index.d.ts", - "typescript": { - "definition": "dist/index.d.ts" - } -} diff --git a/packages/apollo-server-restify/src/index.ts b/packages/apollo-server-restify/src/index.ts deleted file mode 100644 index 88ee3195d62..00000000000 --- a/packages/apollo-server-restify/src/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { - RestifyGraphQLOptionsFunction, - RestifyHandler, - RestifyGraphiQLOptionsFunction, - graphqlRestify, - graphiqlRestify, -} from './restifyApollo'; diff --git a/packages/apollo-server-restify/src/restifyApollo.test.ts b/packages/apollo-server-restify/src/restifyApollo.test.ts deleted file mode 100644 index 5bee6a5ed16..00000000000 --- a/packages/apollo-server-restify/src/restifyApollo.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import 'mocha'; -import testSuite, { - schema, - CreateAppOptions, -} from 'apollo-server-integration-testsuite'; -import { expect } from 'chai'; -import { GraphQLOptions } from 'apollo-server-core'; - -let restify, graphiqlRestify, graphqlRestify; - -function createApp(options: CreateAppOptions = {}) { - const server = restify.createServer({ - name: 'Restify Test Server', - }); - - options.graphqlOptions = options.graphqlOptions || { schema }; - if (!options.excludeParser) { - server.use(restify.plugins.bodyParser()); - server.use(restify.plugins.queryParser()); - } - - if (options.graphiqlOptions) { - server.get('/graphiql', graphiqlRestify(options.graphiqlOptions)); - } - - server.get('/graphql', graphqlRestify(options.graphqlOptions)); - server.post('/graphql', graphqlRestify(options.graphqlOptions)); - - return server; -} - -describe('graphqlRestify', () => { - // As was reported in https://github.com/apollographql/apollo-server/pull/921, - // Restify monkey-patches Node internals, which can have adverse affects on - // other environmental participants like Express. Therefore, restify is being - // dynamically loaded, rather than imported at top-level. - before(async () => { - const restifyApollo = await import('./restifyApollo'); - - restify = await import('restify'); - - graphqlRestify = restifyApollo.graphqlRestify; - graphiqlRestify = restifyApollo.graphiqlRestify; - }); - - it('throws error if called without schema', () => { - expect(() => graphqlRestify(undefined as GraphQLOptions)).to.throw( - 'Apollo Server requires options.', - ); - }); - - it('throws an error if called with more than one argument', () => { - expect(() => (graphqlRestify)({}, 'x')).to.throw( - 'Apollo Server expects exactly one argument, got 2', - ); - }); - - it('generates a function if the options are ok', () => { - expect(() => graphqlRestify({ schema })).to.be.a('function'); - }); -}); - -describe('integration:Restify', () => { - testSuite(createApp); -}); diff --git a/packages/apollo-server-restify/src/restifyApollo.ts b/packages/apollo-server-restify/src/restifyApollo.ts deleted file mode 100644 index db12af6ae87..00000000000 --- a/packages/apollo-server-restify/src/restifyApollo.ts +++ /dev/null @@ -1,119 +0,0 @@ -import * as restify from 'restify'; -import * as url from 'url'; -import { - GraphQLOptions, - HttpQueryError, - runHttpQuery, -} from 'apollo-server-core'; -import * as GraphiQL from 'apollo-server-module-graphiql'; - -export interface RestifyGraphQLOptionsFunction { - (req?: restify.Request, res?: restify.Response): - | GraphQLOptions - | Promise; -} - -// Design principles: -// - You can issue a GET or POST with your query. -// - simple, fast and secure -// - -export interface RestifyHandler { - (req: restify.Request, res: restify.Response, next: restify.Next): void; -} - -export function graphqlRestify( - options: GraphQLOptions | RestifyGraphQLOptionsFunction, -): RestifyHandler { - if (!options) { - throw new Error('Apollo Server requires options.'); - } - - if (arguments.length > 1) { - throw new Error( - `Apollo Server expects exactly one argument, got ${arguments.length}`, - ); - } - - const graphqlHandler = ( - req: restify.Request, - res: restify.Response, - next: restify.Next, - ): void => { - runHttpQuery([req, res], { - method: req.method, - options: options, - query: req.method === 'POST' ? req.body : req.query, - }).then( - gqlResponse => { - res.setHeader('Content-Type', 'application/json'); - res.write(gqlResponse); - res.end(); - next(); - }, - (error: HttpQueryError) => { - if ('HttpQueryError' !== error.name) { - throw error; - } - - if (error.headers) { - Object.keys(error.headers).forEach(header => { - res.setHeader(header, error.headers[header]); - }); - } - - res.statusCode = error.statusCode; - res.write(error.message); - res.end(); - next(false); - }, - ); - }; - - return graphqlHandler; -} - -export interface RestifyGraphiQLOptionsFunction { - (req?: restify.Request): - | GraphiQL.GraphiQLData - | Promise; -} - -/* This middleware returns the html for the GraphiQL interactive query UI - * - * GraphiQLData arguments - * - * - endpointURL: the relative or absolute URL for the endpoint which GraphiQL will make queries to - * - (optional) query: the GraphQL query to pre-fill in the GraphiQL UI - * - (optional) variables: a JS object of variables to pre-fill in the GraphiQL UI - * - (optional) operationName: the operationName to pre-fill in the GraphiQL UI - * - (optional) result: the result of the query to pre-fill in the GraphiQL UI - */ - -export function graphiqlRestify( - options: GraphiQL.GraphiQLData | RestifyGraphiQLOptionsFunction, -) { - const graphiqlHandler = ( - req: restify.Request, - res: restify.Response, - next: restify.Next, - ) => { - const query = (req.url && url.parse(req.url, true).query) || {}; - GraphiQL.resolveGraphiQLString(query, options, req).then( - graphiqlString => { - res.setHeader('Content-Type', 'text/html'); - res.write(graphiqlString); - res.end(); - next(); - }, - error => { - res.statusCode = 500; - res.write(error.message); - res.end(); - next(false); - }, - ); - }; - - return graphiqlHandler; -} diff --git a/packages/apollo-server-restify/tsconfig.json b/packages/apollo-server-restify/tsconfig.json deleted file mode 100644 index b50822eaa8c..00000000000 --- a/packages/apollo-server-restify/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig", - "compilerOptions": { - "rootDir": "./src", - "outDir": "./dist", - "types": [] - }, - "exclude": ["node_modules", "dist"] -} diff --git a/packages/apollo-server/.gitignore b/packages/apollo-server/.gitignore new file mode 100644 index 00000000000..b235581f899 --- /dev/null +++ b/packages/apollo-server/.gitignore @@ -0,0 +1 @@ +npm diff --git a/packages/apollo-server/.npmignore b/packages/apollo-server/.npmignore new file mode 100644 index 00000000000..a165046d359 --- /dev/null +++ b/packages/apollo-server/.npmignore @@ -0,0 +1,6 @@ +* +!src/**/* +!dist/**/* +dist/**/*.test.* +!package.json +!README.md diff --git a/packages/apollo-server/CHANGELOG.md b/packages/apollo-server/CHANGELOG.md new file mode 100644 index 00000000000..f6c1bd04b49 --- /dev/null +++ b/packages/apollo-server/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +### vNEXT + +* `apollo-server`: move non-schema related options into listen [PR#1059](https://github.com/apollographql/apollo-server/pull/1059) +* `apollo-server`: add `bodyParserConfig` options [PR#1059](https://github.com/apollographql/apollo-server/pull/1059) +* `apollo-server`: add `/.well-known/apollo/server-health` endpoint with async callback for additional checks, ie database poke [PR#992](https://github.com/apollographql/apollo-server/pull/992) +* `apollo-server`: collocate graphql gui with endpoint and provide gui when accessed from browser [PR#987](https://github.com/apollographql/apollo-server/pull/987) diff --git a/packages/apollo-server/README.md b/packages/apollo-server/README.md new file mode 100644 index 00000000000..1df826e6f26 --- /dev/null +++ b/packages/apollo-server/README.md @@ -0,0 +1,7 @@ +# Apollo Server + +[![npm version](https://badge.fury.io/js/apollo-server.svg)](https://badge.fury.io/js/apollo-server) +[![Build Status](https://circleci.com/gh/apollographql/apollo-server.svg?style=svg)](https://circleci.com/gh/apollographql/apollo-server) + +Apollo Server is a production ready GraphQL Server. [Read the docs.](https://www.apollographql.com/docs/apollo-server/) +[Read the CHANGELOG.](https://github.com/apollographql/apollo-server/blob/master/CHANGELOG.md) diff --git a/packages/apollo-server/package.json b/packages/apollo-server/package.json new file mode 100644 index 00000000000..ba1332e21bd --- /dev/null +++ b/packages/apollo-server/package.json @@ -0,0 +1,42 @@ +{ + "name": "apollo-server", + "version": "2.0.0-rc.7", + "description": "Production ready GraphQL Server", + "author": "opensource@apollographql.com", + "main": "dist/index.js", + "scripts": { + "compile": "tsc", + "prepublish": "npm run compile", + "watch": "tsc -w" + }, + "repository": { + "type": "git", + "url": "https://github.com/apollographql/apollo-server/tree/master/packages/apollo-server" + }, + "keywords": [ + "GraphQL", + "Apollo", + "Server", + "Javascript" + ], + "license": "MIT", + "bugs": { + "url": "https://github.com/apollographql/apollo-server/issues" + }, + "homepage": "https://github.com/apollographql/apollo-server#readme", + "dependencies": { + "apollo-server-core": "^2.0.0-rc.7", + "apollo-server-express": "^2.0.0-rc.7", + "express": "^4.0.0", + "graphql-subscriptions": "^0.5.8", + "graphql-tools": "^3.0.4" + }, + "devDependencies": { + "@types/request": "^2.47.0", + "request": "^2.87.0" + }, + "peerDependencies": { + "graphql": "^0.12.0 || ^0.13.0 || ^14.0.0" + }, + "typings": "dist/index.d.ts" +} diff --git a/packages/apollo-server/scripts/prepare-package.sh b/packages/apollo-server/scripts/prepare-package.sh new file mode 100755 index 00000000000..ac19b4f0730 --- /dev/null +++ b/packages/apollo-server/scripts/prepare-package.sh @@ -0,0 +1,26 @@ +#!/bin/bash -e + +# When we publish to npm, the published files are available in the root +# directory, which allows for a clean include or require of sub-modules. +# +# var language = require('apollo-server/express'); +# + +# Ensure a vanilla package.json before deploying so other tools do not interpret +# The built output as requiring any further transformation. +node -e "var package = require('./package.json'); \ + delete package.scripts; \ + delete package.private; \ + delete package.devDependencies; \ + package.main = 'index.js'; \ + package.module = 'index.js'; \ + package.typings = 'index.d.ts'; \ + var origVersion = 'local'; + var fs = require('fs'); \ + fs.writeFileSync('./npm/package.json', JSON.stringify(package, null, 2)); \ + " + + +# Copy few more files to ./npm +cp README.md npm/ +cp ../../LICENSE npm/ diff --git a/packages/apollo-server/src/exports.ts b/packages/apollo-server/src/exports.ts new file mode 100644 index 00000000000..5f0bc35f590 --- /dev/null +++ b/packages/apollo-server/src/exports.ts @@ -0,0 +1,13 @@ +export * from 'graphql-tools'; +export * from 'graphql-subscriptions'; + +export { + ApolloError, + toApolloError, + SyntaxError, + ValidationError, + AuthenticationError, + ForbiddenError, + UserInputError, + gql, +} from 'apollo-server-core'; diff --git a/packages/apollo-server/src/index.test.ts b/packages/apollo-server/src/index.test.ts new file mode 100644 index 00000000000..b43be73523e --- /dev/null +++ b/packages/apollo-server/src/index.test.ts @@ -0,0 +1,151 @@ +import { expect } from 'chai'; +import 'mocha'; + +import * as request from 'request'; +import { createApolloFetch } from 'apollo-fetch'; + +import { gql, ApolloServer } from './index'; + +const typeDefs = gql` + type Query { + hello: String + } +`; + +const resolvers = { + Query: { + hello: () => 'hi', + }, +}; + +describe('apollo-server', () => { + describe('constructor', () => { + it('accepts typeDefs and resolvers', () => { + expect(() => new ApolloServer({ typeDefs, resolvers })).not.to.throw; + }); + + it('accepts typeDefs and mocks', () => { + expect(() => new ApolloServer({ typeDefs, mocks: true })).not.to.throw; + }); + }); + + describe('without registerServer', () => { + let server: ApolloServer; + afterEach(async () => { + await server.stop(); + }); + + it('can be queried', async () => { + server = new ApolloServer({ + typeDefs, + resolvers, + }); + + const { url: uri } = await server.listen(); + const apolloFetch = createApolloFetch({ uri }); + const result = await apolloFetch({ query: '{hello}' }); + + expect(result.data).to.deep.equal({ hello: 'hi' }); + expect(result.errors, 'errors should exist').not.to.exist; + }); + + it('renders GraphQL playground when browser requests', async () => { + const nodeEnv = process.env.NODE_ENV; + delete process.env.NODE_ENV; + + server = new ApolloServer({ + typeDefs, + resolvers, + }); + + const { url } = await server.listen(); + return new Promise((resolve, reject) => { + request( + { + url, + method: 'GET', + headers: { + accept: + 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8', + }, + }, + (error, response, body) => { + process.env.NODE_ENV = nodeEnv; + if (error) { + reject(error); + } else { + expect(body).to.contain('GraphQLPlayground'); + expect(response.statusCode).to.equal(200); + resolve(); + } + }, + ); + }); + }); + + it('configures cors', async () => { + server = new ApolloServer({ + typeDefs, + resolvers, + }); + + const { url: uri } = await server.listen(); + + const apolloFetch = createApolloFetch({ uri }).useAfter( + (response, next) => { + expect( + response.response.headers.get('access-control-allow-origin'), + ).to.equal('*'); + next(); + }, + ); + await apolloFetch({ query: '{hello}' }); + }); + + it('configures cors', async () => { + server = new ApolloServer({ + typeDefs, + resolvers, + cors: { origin: 'localhost' }, + }); + + const { url: uri } = await server.listen(); + + const apolloFetch = createApolloFetch({ uri }).useAfter( + (response, next) => { + expect( + response.response.headers.get('access-control-allow-origin'), + ).to.equal('localhost'); + next(); + }, + ); + await apolloFetch({ query: '{hello}' }); + }); + + it('creates a healthcheck endpoint', async () => { + server = new ApolloServer({ + typeDefs, + resolvers, + }); + + const { port } = await server.listen(); + return new Promise((resolve, reject) => { + request( + { + url: `http://localhost:${port}/.well-known/apollo/server-health`, + method: 'GET', + }, + (error, response, body) => { + if (error) { + reject(error); + } else { + expect(body).to.equal(JSON.stringify({ status: 'pass' })); + expect(response.statusCode).to.equal(200); + resolve(); + } + }, + ); + }); + }); + }); +}); diff --git a/packages/apollo-server/src/index.ts b/packages/apollo-server/src/index.ts new file mode 100644 index 00000000000..489f52a763a --- /dev/null +++ b/packages/apollo-server/src/index.ts @@ -0,0 +1,132 @@ +// Note: express is only used if you use the ApolloServer.listen API to create +// an express app for you instead of applyMiddleware (which you might not even +// use with express). The dependency is unused otherwise, so don't worry if +// you're not using express or your version doesn't quite match up. +import * as express from 'express'; +import * as http from 'http'; +import * as net from 'net'; +import { + ApolloServer as ApolloServerBase, + CorsOptions, +} from 'apollo-server-express'; +import { Config } from 'apollo-server-core'; + +export { + GraphQLUpload, + GraphQLOptions, + GraphQLExtension, + gql, + Config, +} from 'apollo-server-core'; + +export { CorsOptions } from 'apollo-server-express'; + +export * from './exports'; + +export interface ServerInfo { + address: string; + family: string; + url: string; + subscriptionsUrl: string; + port: number | string; + subscriptionsPath: string; + server: http.Server; +} + +export class ApolloServer extends ApolloServerBase { + private httpServer: http.Server; + private cors?: CorsOptions | boolean; + + constructor(config: Config & { cors?: CorsOptions | boolean }) { + super(config); + this.cors = config && config.cors; + } + + private createServerInfo( + server: http.Server, + subscriptionsPath?: string, + ): ServerInfo { + const serverInfo: any = { + ...(server.address() as net.AddressInfo), + server, + subscriptionsPath, + }; + + // Convert IPs which mean "any address" (IPv4 or IPv6) into localhost + // corresponding loopback ip. Note that the url field we're setting is + // primarily for consumption by our test suite. If this heuristic is + // wrong for your use case, explicitly specify a frontend host (in the + // `frontends.host` field in your engine config, or in the `host` + // option to ApolloServer.listen). + let hostForUrl = serverInfo.address; + if (serverInfo.address === '' || serverInfo.address === '::') + hostForUrl = 'localhost'; + + serverInfo.url = require('url').format({ + protocol: 'http', + hostname: hostForUrl, + port: serverInfo.port, + pathname: this.graphqlPath, + }); + + serverInfo.subscriptionsUrl = require('url').format({ + protocol: 'ws', + hostname: hostForUrl, + port: serverInfo.port, + slashes: true, + pathname: subscriptionsPath, + }); + + return serverInfo; + } + + public applyMiddleware() { + throw new Error( + 'To use Apollo Server with an existing express application, please use apollo-server-express', + ); + } + + // Listen takes the same arguments as http.Server.listen. + public async listen(...opts: Array): Promise { + // This class is the easy mode for people who don't create their own express + // object, so we have to create it. + const app = express(); + + // provide generous values for the getting started experience + super.applyMiddleware({ + app, + path: '/', + bodyParserConfig: { limit: '50mb' }, + cors: + typeof this.cors !== 'undefined' + ? this.cors + : { + origin: '*', + }, + }); + + this.httpServer = http.createServer(app); + + if (this.subscriptionServerOptions) { + this.installSubscriptionHandlers(this.httpServer); + } + + await new Promise(resolve => { + this.httpServer.once('listening', resolve); + // If the user passed a callback to listen, it'll get called in addition + // to our resolver. They won't have the ability to get the ServerInfo + // object unless they use our Promise, though. + this.httpServer.listen(...(opts.length ? opts : [{ port: 4000 }])); + }); + + return this.createServerInfo(this.httpServer, this.subscriptionsPath); + } + + public async stop() { + if (this.httpServer) { + await new Promise(resolve => this.httpServer.close(resolve)); + this.httpServer = null; + } + await super.stop(); + } +} diff --git a/packages/apollo-server/tsconfig.json b/packages/apollo-server/tsconfig.json new file mode 100644 index 00000000000..5e60ba71424 --- /dev/null +++ b/packages/apollo-server/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "noImplicitAny": true, + "lib": ["es2017", "esnext.asynciterable", "dom"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/apollo-tracing/.npmignore b/packages/apollo-tracing/.npmignore new file mode 100644 index 00000000000..a165046d359 --- /dev/null +++ b/packages/apollo-tracing/.npmignore @@ -0,0 +1,6 @@ +* +!src/**/* +!dist/**/* +dist/**/*.test.* +!package.json +!README.md diff --git a/packages/apollo-tracing/README.md b/packages/apollo-tracing/README.md new file mode 100644 index 00000000000..f9cac311535 --- /dev/null +++ b/packages/apollo-tracing/README.md @@ -0,0 +1,25 @@ +# Apollo Tracing (for Node.js) + +This package is used to collect and expose trace data in the [Apollo Tracing](https://github.com/apollographql/apollo-tracing) format. + +It relies on instrumenting a GraphQL schema to collect resolver timings, and exposes trace data for an individual request under `extensions` as part of the GraphQL response. + +This data can be consumed by [Apollo Engine](https://www.apollographql.com/engine/) or any other tool to provide visualization and history of field-by-field execution performance. + +## Usage + +### Apollo Server + +Apollo Server includes built-in support for tracing from version 1.1.0 onwards. + +The only code change required is to add `tracing: true` to the options passed to the Apollo Server middleware function for your framework of choice. For example, for Express: + +```javascript +app.use('/graphql', bodyParser.json(), graphqlExpress({ + schema, + context: {}, + tracing: true, +})); +``` + +> If you are using `express-graphql`, we recommend you switch to Apollo Server. Both `express-graphql` and Apollo Server are based on the [`graphql-js`](https://github.com/graphql/graphql-js) reference implementation, and switching should only require changing a few lines of code. diff --git a/packages/apollo-tracing/package.json b/packages/apollo-tracing/package.json new file mode 100644 index 00000000000..0b27e7322cc --- /dev/null +++ b/packages/apollo-tracing/package.json @@ -0,0 +1,53 @@ +{ + "name": "apollo-tracing", + "version": "0.2.0-rc.0", + "description": "Collect and expose trace data for GraphQL requests", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "clean": "rm -rf dist", + "compile": "tsc", + "prepublish": "npm run compile", + "test": "jest --verbose", + "watch": "tsc -w" + }, + "license": "MIT", + "repository": "apollographql/apollo-tracing-js", + "author": "Martijn Walraven ", + "engines": { + "node": ">=4.0" + }, + "dependencies": { + "apollo-server-env": "^2.0.0-rc.7", + "graphql-extensions": "0.1.0-beta.0" + }, + "peerDependencies": { + "graphql": "0.10.x - 0.13.x" + }, + "devDependencies": { + "@types/jest": "^23.1.2", + "jest": "^23.2.0", + "jest-matcher-utils": "^23.2.0", + "ts-jest": "^22.4.6" + }, + "jest": { + "testEnvironment": "node", + "setupFiles": [ + "/node_modules/apollo-server-env/dist/index.js" + ], + "transform": { + "^.+\\.(ts|js)$": "ts-jest" + }, + "moduleFileExtensions": [ + "ts", + "js", + "json" + ], + "testRegex": "src/__tests__/.*$", + "globals": { + "ts-jest": { + "skipBabel": true + } + } + } +} diff --git a/packages/apollo-tracing/src/__tests__/index.ts b/packages/apollo-tracing/src/__tests__/index.ts new file mode 100644 index 00000000000..ea65c01449e --- /dev/null +++ b/packages/apollo-tracing/src/__tests__/index.ts @@ -0,0 +1,6 @@ +import { TracingExtension } from '../index'; +describe('Apollo Tracing', () => { + it('should construct', () => { + new TracingExtension(); + }); +}); diff --git a/packages/apollo-tracing/src/index.ts b/packages/apollo-tracing/src/index.ts new file mode 100644 index 00000000000..9c31c22ed54 --- /dev/null +++ b/packages/apollo-tracing/src/index.ts @@ -0,0 +1,140 @@ +import { + ResponsePath, + responsePathAsArray, + GraphQLResolveInfo, + GraphQLType, +} from 'graphql'; + +import { GraphQLExtension } from 'graphql-extensions'; + +export interface TracingFormat { + version: 1; + startTime: string; + endTime: string; + duration: number; + execution: { + resolvers: { + path: (string | number)[]; + parentType: string; + fieldName: string; + returnType: string; + startOffset: number; + duration: number; + }[]; + }; +} + +interface ResolverCall { + path: ResponsePath; + fieldName: string; + parentType: GraphQLType; + returnType: GraphQLType; + startOffset: HighResolutionTime; + endOffset?: HighResolutionTime; +} + +export class TracingExtension + implements GraphQLExtension { + private startWallTime?: Date; + private endWallTime?: Date; + private startHrTime?: HighResolutionTime; + private duration?: HighResolutionTime; + + private resolverCalls: ResolverCall[] = []; + + public requestDidStart() { + this.startWallTime = new Date(); + this.startHrTime = process.hrtime(); + } + + public executionDidStart() { + // It's a little odd that we record the end time after execution rather than + // at the end of the whole request, but because we need to include our + // formatted trace in the request itself, we have to record it before the + // request is over! It's also odd that we don't do traces for parse or + // validation errors, but runQuery doesn't currently support that, as + // format() is only invoked after execution. + return () => { + this.duration = process.hrtime(this.startHrTime); + this.endWallTime = new Date(); + }; + } + + public willResolveField( + _source: any, + _args: { [argName: string]: any }, + _context: TContext, + info: GraphQLResolveInfo, + ) { + const resolverCall: ResolverCall = { + path: info.path, + fieldName: info.fieldName, + parentType: info.parentType, + returnType: info.returnType, + startOffset: process.hrtime(this.startHrTime), + }; + + this.resolverCalls.push(resolverCall); + + return () => { + resolverCall.endOffset = process.hrtime(this.startHrTime); + }; + } + + public format(): [string, TracingFormat] | undefined { + // In the event that we are called prior to the initialization of critical + // date metrics, we'll return undefined to signal that the extension did not + // format properly. Any undefined extension results are simply purged by + // the graphql-extensions module. + if ( + typeof this.startWallTime === 'undefined' || + typeof this.endWallTime === 'undefined' || + typeof this.duration === 'undefined' + ) { + return; + } + + return [ + 'tracing', + { + version: 1, + startTime: this.startWallTime.toISOString(), + endTime: this.endWallTime.toISOString(), + duration: durationHrTimeToNanos(this.duration), + execution: { + resolvers: this.resolverCalls.map(resolverCall => { + const startOffset = durationHrTimeToNanos(resolverCall.startOffset); + const duration = resolverCall.endOffset + ? durationHrTimeToNanos(resolverCall.endOffset) - startOffset + : 0; + return { + path: [...responsePathAsArray(resolverCall.path)], + parentType: resolverCall.parentType.toString(), + fieldName: resolverCall.fieldName, + returnType: resolverCall.returnType.toString(), + startOffset, + duration, + }; + }), + }, + }, + ]; + } +} + +type HighResolutionTime = [number, number]; + +// Converts an hrtime array (as returned from process.hrtime) to nanoseconds. +// +// ONLY CALL THIS ON VALUES REPRESENTING DELTAS, NOT ON THE RAW RETURN VALUE +// FROM process.hrtime() WITH NO ARGUMENTS. +// +// The entire point of the hrtime data structure is that the JavaScript Number +// type can't represent all int64 values without loss of precision: +// Number.MAX_SAFE_INTEGER nanoseconds is about 104 days. Calling this function +// on a duration that represents a value less than 104 days is fine. Calling +// this function on an absolute time (which is generally roughly time since +// system boot) is not a good idea. +function durationHrTimeToNanos(hrtime: HighResolutionTime) { + return hrtime[0] * 1e9 + hrtime[1]; +} diff --git a/packages/apollo-tracing/tsconfig.json b/packages/apollo-tracing/tsconfig.json new file mode 100644 index 00000000000..700fb9c53b1 --- /dev/null +++ b/packages/apollo-tracing/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "removeComments": true, + "strict": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUnusedParameters": true, + "noUnusedLocals": true, + "types": ["node", "jest"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "**/__tests__/*", "**/__mocks__/*"] +} diff --git a/packages/graphql-extensions/.npmignore b/packages/graphql-extensions/.npmignore new file mode 100644 index 00000000000..a165046d359 --- /dev/null +++ b/packages/graphql-extensions/.npmignore @@ -0,0 +1,6 @@ +* +!src/**/* +!dist/**/* +dist/**/*.test.* +!package.json +!README.md diff --git a/packages/graphql-extensions/CHANGELOG.md b/packages/graphql-extensions/CHANGELOG.md new file mode 100644 index 00000000000..fa589535e83 --- /dev/null +++ b/packages/graphql-extensions/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +### vNext + +### 0.1.0-beta +- *Backwards-incompatible change*: `fooDidStart` handlers (where foo is `request`, `parsing`, `validation`, and `execution`) now return their end handler; the `fooDidEnd` handlers no longer exist. The end handlers now take errors. There is a new `willSendResponse` handler. The `fooDidStart` handlers take extra options (eg, the `ExecutionArgs` for `executionDidStart`). +- *Backwards-incompatible change*: Previously, the `GraphQLExtensionStack` constructor took either `GraphQLExtension` objects or their constructors. Now you may only pass in `GraphQLExtension` objects. + +### 0.0.10 +- Fix lifecycle method invocations on extensions diff --git a/packages/graphql-extensions/README.md b/packages/graphql-extensions/README.md new file mode 100644 index 00000000000..56441f1d517 --- /dev/null +++ b/packages/graphql-extensions/README.md @@ -0,0 +1,4 @@ +# graphql-extensions + +[![npm version](https://badge.fury.io/js/graphql-extensions.svg)](https://badge.fury.io/js/graphql-extensions) +[![Build Status](https://circleci.com/gh/apollographql/apollo-server.svg?style=svg)](https://circleci.com/gh/apollographql/apollo-server) diff --git a/packages/graphql-extensions/package.json b/packages/graphql-extensions/package.json new file mode 100644 index 00000000000..755c28a04c0 --- /dev/null +++ b/packages/graphql-extensions/package.json @@ -0,0 +1,40 @@ +{ + "name": "graphql-extensions", + "version": "0.1.0-rc.1", + "description": "Add extensions to GraphQL servers", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "clean": "rm -rf dist", + "compile": "tsc", + "watch": "tsc -w", + "prepublish": "npm run clean && npm run compile", + "lint": "prettier -l 'src/**/*.ts' && tslint -p tsconfig.json 'src/**/*.ts'", + "lint-fix": "prettier --write 'src/**/*.ts' && tslint --fix -p tsconfig.json 'src/**/*.ts'" + }, + "repository": { + "type": "git", + "url": "apollographql/graphql-extensions" + }, + "author": "Martijn Walraven ", + "license": "MIT", + "engines": { + "node": ">=6.0" + }, + "dependencies": { + "apollo-server-env": "^2.0.0-rc.7" + }, + "peerDependencies": { + "graphql": "0.10.x - 0.13.x" + }, + "devDependencies": { + "@types/graphql": "^0.13.1", + "@types/jest": "^23.1.2", + "@types/node": "^10.3.6", + "graphql": "^0.13.2", + "jest": "^23.2.0", + "jest-matcher-utils": "^23.2.0", + "ts-jest": "^22.4.6", + "tslint": "^5.10.0" + } +} diff --git a/packages/graphql-extensions/src/index.ts b/packages/graphql-extensions/src/index.ts new file mode 100644 index 00000000000..e09457768db --- /dev/null +++ b/packages/graphql-extensions/src/index.ts @@ -0,0 +1,267 @@ +import { + GraphQLSchema, + GraphQLError, + GraphQLObjectType, + getNamedType, + GraphQLField, + defaultFieldResolver, + GraphQLFieldResolver, + GraphQLResolveInfo, + ExecutionArgs, + DocumentNode, +} from 'graphql'; + +import { Request } from 'apollo-server-env'; +export { Request } from 'apollo-server-env'; + +export type EndHandler = (...errors: Array) => void; +// A StartHandlerInvoker is a function that, given a specific GraphQLExtension, +// finds a specific StartHandler on that extension and calls it with appropriate +// arguments. +type StartHandlerInvoker = ( + ext: GraphQLExtension, +) => void; + +// Copied from runQuery in apollo-server-core. +// XXX Will this work properly if it's an identical interface of the +// same name? +export interface GraphQLResponse { + data?: object; + errors?: Array; + extensions?: object; +} + +export class GraphQLExtension { + public requestDidStart?(o: { + request: Request; + queryString?: string; + parsedQuery?: DocumentNode; + operationName?: string; + variables?: { [key: string]: any }; + persistedQueryHit?: boolean; + persistedQueryRegister?: boolean; + }): EndHandler | void; + public parsingDidStart?(o: { queryString: string }): EndHandler | void; + public validationDidStart?(): EndHandler | void; + public executionDidStart?(o: { + executionArgs: ExecutionArgs; + }): EndHandler | void; + + public willSendResponse?(o: { + graphqlResponse: GraphQLResponse; + }): void | { graphqlResponse: GraphQLResponse }; + + public willResolveField?( + source: any, + args: { [argName: string]: any }, + context: TContext, + info: GraphQLResolveInfo, + ): ((error: Error | null, result?: any) => void) | void; + + public format?(): [string, any] | undefined; +} + +export class GraphQLExtensionStack { + public fieldResolver?: GraphQLFieldResolver; + + private extensions: GraphQLExtension[]; + + constructor(extensions: GraphQLExtension[]) { + this.extensions = extensions; + } + + public requestDidStart(o: { + request: Request; + queryString?: string; + parsedQuery?: DocumentNode; + operationName?: string; + variables?: { [key: string]: any }; + persistedQueryHit?: boolean; + persistedQueryRegister?: boolean; + }): EndHandler { + return this.handleDidStart( + ext => ext.requestDidStart && ext.requestDidStart(o), + ); + } + public parsingDidStart(o: { queryString: string }): EndHandler { + return this.handleDidStart( + ext => ext.parsingDidStart && ext.parsingDidStart(o), + ); + } + public validationDidStart(): EndHandler { + return this.handleDidStart( + ext => ext.validationDidStart && ext.validationDidStart(), + ); + } + public executionDidStart(o: { executionArgs: ExecutionArgs }): EndHandler { + if (o.executionArgs.fieldResolver) { + this.fieldResolver = o.executionArgs.fieldResolver; + } + return this.handleDidStart( + ext => ext.executionDidStart && ext.executionDidStart(o), + ); + } + + public willSendResponse(o: { + graphqlResponse: GraphQLResponse; + }): { graphqlResponse: GraphQLResponse } { + let reference = o; + // Reverse the array, since this is functions as an end handler + [...this.extensions].reverse().forEach(extension => { + if (extension.willSendResponse) { + const result = extension.willSendResponse(reference); + if (result) { + reference = result; + } + } + }); + return reference; + } + + public willResolveField( + source: any, + args: { [argName: string]: any }, + context: TContext, + info: GraphQLResolveInfo, + ) { + const handlers = this.extensions + .map( + extension => + extension.willResolveField && + extension.willResolveField(source, args, context, info), + ) + .filter(x => x) + // Reverse list so that handlers "nest", like in handleDidStart. + .reverse() as ((error: Error | null, result?: any) => void)[]; + + return (error: Error | null, result?: any) => { + for (const handler of handlers) { + handler(error, result); + } + }; + } + + public format() { + return (this.extensions + .map(extension => extension.format && extension.format()) + .filter(x => x) as [string, any][]).reduce( + (extensions, [key, value]) => Object.assign(extensions, { [key]: value }), + {}, + ); + } + + private handleDidStart(startInvoker: StartHandlerInvoker): EndHandler { + const endHandlers: EndHandler[] = []; + this.extensions.forEach(extension => { + // Invoke the start handler, which may return an end handler. + const endHandler = startInvoker(extension); + if (endHandler) { + endHandlers.push(endHandler); + } + }); + return (...errors: Array) => { + // We run end handlers in reverse order of start handlers. That way, the + // first handler in the stack "surrounds" the entire event's process + // (helpful for tracing/reporting!) + endHandlers.reverse(); + endHandlers.forEach(endHandler => endHandler(...errors)); + }; + } +} + +export function enableGraphQLExtensions( + schema: GraphQLSchema & { _extensionsEnabled?: boolean }, +) { + if (schema._extensionsEnabled) { + return schema; + } + schema._extensionsEnabled = true; + + forEachField(schema, wrapField); + + return schema; +} + +function wrapField(field: GraphQLField): void { + const fieldResolver = field.resolve; + + field.resolve = (source, args, context, info) => { + const extensionStack = context && context._extensionStack; + const handler = + (extensionStack && + extensionStack.willResolveField(source, args, context, info)) || + ((_err: Error | null, _result?: any) => { + /* do nothing */ + }); + + // If no resolver has been defined for a field, use the default field resolver + // (which matches the behavior of graphql-js when there is no explicit resolve function defined). + try { + const result = (fieldResolver || + (extensionStack && extensionStack.fieldResolver) || + defaultFieldResolver)(source, args, context, info); + // Call the stack's handlers either immediately (if result is not a + // Promise) or once the Promise is done. Then return that same + // maybe-Promise value. + whenResultIsFinished(result, handler); + return result; + } catch (error) { + // Normally it's a bad sign to see an error both handled and + // re-thrown. But it is useful to allow extensions to track errors while + // still handling them in the normal GraphQL way. + handler(error); + throw error; + } + }; +} + +function isPromise(x: any): boolean { + return x && typeof x.then === 'function'; +} + +// Given result (which may be a Promise or an array some of whose elements are +// promises) Promises, set up 'callback' to be invoked when result is fully +// resolved. +function whenResultIsFinished( + result: any, + callback: (err: Error | null, result?: any) => void, +) { + if (isPromise(result)) { + result.then((r: any) => callback(null, r), (err: Error) => callback(err)); + } else if (Array.isArray(result)) { + if (result.some(isPromise)) { + Promise.all(result).then( + (r: any) => callback(null, r), + (err: Error) => callback(err), + ); + } else { + callback(null, result); + } + } else { + callback(null, result); + } +} + +function forEachField(schema: GraphQLSchema, fn: FieldIteratorFn): void { + const typeMap = schema.getTypeMap(); + Object.keys(typeMap).forEach(typeName => { + const type = typeMap[typeName]; + + if ( + !getNamedType(type).name.startsWith('__') && + type instanceof GraphQLObjectType + ) { + const fields = type.getFields(); + Object.keys(fields).forEach(fieldName => { + const field = fields[fieldName]; + fn(field, typeName, fieldName); + }); + } + }); +} + +export type FieldIteratorFn = ( + fieldDef: GraphQLField, + typeName: string, + fieldName: string, +) => void; diff --git a/packages/graphql-extensions/tsconfig.json b/packages/graphql-extensions/tsconfig.json new file mode 100644 index 00000000000..700fb9c53b1 --- /dev/null +++ b/packages/graphql-extensions/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "removeComments": true, + "strict": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUnusedParameters": true, + "noUnusedLocals": true, + "types": ["node", "jest"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "**/__tests__/*", "**/__mocks__/*"] +} diff --git a/packages/graphql-extensions/tslint.json b/packages/graphql-extensions/tslint.json new file mode 100644 index 00000000000..fff5bbb3192 --- /dev/null +++ b/packages/graphql-extensions/tslint.json @@ -0,0 +1,79 @@ +{ + "rules": { + "ban": false, + "class-name": true, + "eofline": true, + "forin": true, + "interface-name": [ + true, + "never-prefix" + ], + "jsdoc-format": true, + "label-position": true, + "member-access": true, + "member-ordering": [ + true, + { + "order": [ + "static-field", + "instance-field", + "constructor", + "public-instance-method", + "protected-instance-method", + "private-instance-method" + ] + } + ], + "no-any": false, + "no-arg": true, + "no-bitwise": true, + "no-conditional-assignment": true, + "no-consecutive-blank-lines": false, + "no-console": [ + true, + "log", + "debug", + "info", + "time", + "timeEnd", + "trace" + ], + "no-construct": true, + "no-debugger": true, + "no-duplicate-variable": true, + "no-empty": true, + "no-eval": true, + "no-inferrable-types": false, + "no-internal-module": true, + "no-null-keyword": false, + "no-require-imports": false, + "no-shadowed-variable": true, + "no-switch-case-fall-through": true, + "no-trailing-whitespace": true, + "no-unused-expression": true, + "no-var-keyword": true, + "no-var-requires": true, + "object-literal-sort-keys": false, + "radix": true, + "switch-default": true, + "triple-equals": [ + true, + "allow-null-check" + ], + "typedef": [ + false, + "call-signature", + "parameter", + "arrow-parameter", + "property-declaration", + "variable-declaration", + "member-variable-declaration" + ], + "variable-name": [ + true, + "check-format", + "allow-leading-underscore", + "ban-keywords" + ] + } +} diff --git a/packages/graphql-server-core/README.md b/packages/graphql-server-core/README.md deleted file mode 100644 index b28cc0d666b..00000000000 --- a/packages/graphql-server-core/README.md +++ /dev/null @@ -1 +0,0 @@ -The `graphql-server-core` package is now called [`apollo-server-core`](https://www.npmjs.com/package/apollo-server-core). We are continuing to release matching versions of the package under the old name, but we recommend you switch to using the new name. The API is identical. diff --git a/packages/graphql-server-core/package.json b/packages/graphql-server-core/package.json deleted file mode 100644 index c9c1dcdb283..00000000000 --- a/packages/graphql-server-core/package.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "graphql-server-core", - "version": "1.4.0", - "description": "Core engine for Apollo GraphQL server", - "main": "dist/index.js", - "scripts": { - "compile": "tsc", - "prepublish": "npm run compile" - }, - "repository": { - "type": "git", - "url": "https://github.com/apollographql/apollo-server/tree/master/packages/graphql-server-core" - }, - "keywords": [ - "GraphQL", - "Apollo", - "Server", - "Javascript" - ], - "author": "Jonas Helfer ", - "license": "MIT", - "bugs": { - "url": "https://github.com/apollographql/apollo-server/issues" - }, - "homepage": "https://github.com/apollographql/apollo-server#readme", - "dependencies": { - "apollo-server-core": "^1.4.0" - }, - "typings": "dist/index.d.ts", - "typescript": { - "definition": "dist/index.d.ts" - } -} diff --git a/packages/graphql-server-core/src/index.ts b/packages/graphql-server-core/src/index.ts deleted file mode 100644 index e9f9484692e..00000000000 --- a/packages/graphql-server-core/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from 'apollo-server-core'; diff --git a/packages/graphql-server-core/tsconfig.json b/packages/graphql-server-core/tsconfig.json deleted file mode 100644 index c3925d6fb0c..00000000000 --- a/packages/graphql-server-core/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig", - "compilerOptions": { - "rootDir": "./src", - "outDir": "./dist" - }, - "exclude": ["node_modules", "dist"] -} diff --git a/packages/graphql-server-express/README.md b/packages/graphql-server-express/README.md deleted file mode 100644 index 11c71907ba6..00000000000 --- a/packages/graphql-server-express/README.md +++ /dev/null @@ -1 +0,0 @@ -The `graphql-server-express` package is now called [`apollo-server-express`](https://www.npmjs.com/package/apollo-server-express). We are continuing to release matching versions of the package under the old name, but we recommend you switch to using the new name. The API is identical. diff --git a/packages/graphql-server-express/package.json b/packages/graphql-server-express/package.json deleted file mode 100644 index a3673dd54bf..00000000000 --- a/packages/graphql-server-express/package.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "graphql-server-express", - "version": "1.4.0", - "description": "Production-ready Node.js GraphQL server for Express and Connect", - "main": "dist/index.js", - "scripts": { - "compile": "tsc", - "prepublish": "npm run compile" - }, - "repository": { - "type": "git", - "url": "https://github.com/apollographql/apollo-server/tree/master/packages/graphql-server-express" - }, - "keywords": [ - "GraphQL", - "Apollo", - "Server", - "Express", - "Connect", - "Javascript" - ], - "author": "Jonas Helfer ", - "license": "MIT", - "bugs": { - "url": "https://github.com/apollographql/apollo-server/issues" - }, - "homepage": "https://github.com/apollographql/apollo-server#readme", - "dependencies": { - "apollo-server-express": "^1.4.0" - }, - "typings": "dist/index.d.ts", - "typescript": { - "definition": "dist/index.d.ts" - } -} diff --git a/packages/graphql-server-express/src/index.ts b/packages/graphql-server-express/src/index.ts deleted file mode 100644 index 71eeaaa6d17..00000000000 --- a/packages/graphql-server-express/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from 'apollo-server-express'; diff --git a/packages/graphql-server-express/tsconfig.json b/packages/graphql-server-express/tsconfig.json deleted file mode 100644 index c3925d6fb0c..00000000000 --- a/packages/graphql-server-express/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig", - "compilerOptions": { - "rootDir": "./src", - "outDir": "./dist" - }, - "exclude": ["node_modules", "dist"] -} diff --git a/packages/graphql-server-hapi/README.md b/packages/graphql-server-hapi/README.md deleted file mode 100644 index 555fec3dbc9..00000000000 --- a/packages/graphql-server-hapi/README.md +++ /dev/null @@ -1 +0,0 @@ -The `graphql-server-hapi` package is now called [`apollo-server-hapi`](https://www.npmjs.com/package/apollo-server-hapi). We are continuing to release matching versions of the package under the old name, but we recommend you switch to using the new name. The API is identical. diff --git a/packages/graphql-server-hapi/src/index.ts b/packages/graphql-server-hapi/src/index.ts deleted file mode 100644 index 5cd1572cc76..00000000000 --- a/packages/graphql-server-hapi/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from 'apollo-server-hapi'; diff --git a/packages/graphql-server-hapi/tsconfig.json b/packages/graphql-server-hapi/tsconfig.json deleted file mode 100644 index c3925d6fb0c..00000000000 --- a/packages/graphql-server-hapi/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig", - "compilerOptions": { - "rootDir": "./src", - "outDir": "./dist" - }, - "exclude": ["node_modules", "dist"] -} diff --git a/packages/graphql-server-koa/README.md b/packages/graphql-server-koa/README.md deleted file mode 100644 index d9111c35388..00000000000 --- a/packages/graphql-server-koa/README.md +++ /dev/null @@ -1 +0,0 @@ -The `graphql-server-koa` package is now called [`apollo-server-koa`](https://www.npmjs.com/package/apollo-server-koa). We are continuing to release matching versions of the package under the old name, but we recommend you switch to using the new name. The API is identical. diff --git a/packages/graphql-server-koa/package.json b/packages/graphql-server-koa/package.json deleted file mode 100644 index f679809e017..00000000000 --- a/packages/graphql-server-koa/package.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "name": "graphql-server-koa", - "version": "1.4.0", - "description": "Production-ready Node.js GraphQL server for Koa", - "main": "dist/index.js", - "scripts": { - "compile": "tsc", - "prepublish": "npm run compile" - }, - "repository": { - "type": "git", - "url": "https://github.com/apollographql/apollo-server/tree/master/packages/graphql-server-koa" - }, - "keywords": [ - "GraphQL", - "Apollo", - "Koa", - "Server", - "Javascript" - ], - "author": "Jonas Helfer ", - "license": "MIT", - "bugs": { - "url": "https://github.com/apollographql/apollo-server/issues" - }, - "homepage": "https://github.com/apollographql/apollo-server#readme", - "dependencies": { - "apollo-server-koa": "^1.4.0" - }, - "typings": "dist/index.d.ts", - "typescript": { - "definition": "dist/index.d.ts" - } -} diff --git a/packages/graphql-server-koa/src/index.ts b/packages/graphql-server-koa/src/index.ts deleted file mode 100644 index 09bb0a301f9..00000000000 --- a/packages/graphql-server-koa/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from 'apollo-server-koa'; diff --git a/packages/graphql-server-koa/tsconfig.json b/packages/graphql-server-koa/tsconfig.json deleted file mode 100644 index c3925d6fb0c..00000000000 --- a/packages/graphql-server-koa/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig", - "compilerOptions": { - "rootDir": "./src", - "outDir": "./dist" - }, - "exclude": ["node_modules", "dist"] -} diff --git a/packages/graphql-server-lambda/README.md b/packages/graphql-server-lambda/README.md deleted file mode 100644 index 08104efa36f..00000000000 --- a/packages/graphql-server-lambda/README.md +++ /dev/null @@ -1 +0,0 @@ -The `graphql-server-lambda` package is now called [`apollo-server-lambda`](https://www.npmjs.com/package/apollo-server-lambda). We are continuing to release matching versions of the package under the old name, but we recommend you switch to using the new name. The API is identical. diff --git a/packages/graphql-server-lambda/package.json b/packages/graphql-server-lambda/package.json deleted file mode 100644 index 9642daf01fd..00000000000 --- a/packages/graphql-server-lambda/package.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "name": "graphql-server-lambda", - "version": "1.4.0", - "description": "Production-ready Node.js GraphQL server for AWS Lambda", - "main": "dist/index.js", - "scripts": { - "compile": "tsc", - "prepublish": "npm run compile" - }, - "repository": { - "type": "git", - "url": "https://github.com/apollographql/apollo-server/tree/master/packages/graphql-server-lambda" - }, - "keywords": [ - "GraphQL", - "Apollo", - "Server", - "Lambda", - "Javascript" - ], - "author": "Jonas Helfer ", - "license": "MIT", - "bugs": { - "url": "https://github.com/apollographql/apollo-server/issues" - }, - "homepage": "https://github.com/apollographql/apollo-server#readme", - "dependencies": { - "apollo-server-lambda": "^1.4.0" - }, - "typings": "dist/index.d.ts", - "typescript": { - "definition": "dist/index.d.ts" - } -} diff --git a/packages/graphql-server-lambda/src/index.ts b/packages/graphql-server-lambda/src/index.ts deleted file mode 100644 index 4d6f2465495..00000000000 --- a/packages/graphql-server-lambda/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from 'apollo-server-lambda'; diff --git a/packages/graphql-server-lambda/tsconfig.json b/packages/graphql-server-lambda/tsconfig.json deleted file mode 100644 index c3925d6fb0c..00000000000 --- a/packages/graphql-server-lambda/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig", - "compilerOptions": { - "rootDir": "./src", - "outDir": "./dist" - }, - "exclude": ["node_modules", "dist"] -} diff --git a/packages/graphql-server-micro/README.md b/packages/graphql-server-micro/README.md deleted file mode 100644 index c542beebb21..00000000000 --- a/packages/graphql-server-micro/README.md +++ /dev/null @@ -1 +0,0 @@ -The `graphql-server-micro` package is now called [`apollo-server-micro`](https://www.npmjs.com/package/apollo-server-micro). We are continuing to release matching versions of the package under the old name, but we recommend you switch to using the new name. The API is identical. diff --git a/packages/graphql-server-micro/package.json b/packages/graphql-server-micro/package.json deleted file mode 100644 index 1d47f85d66d..00000000000 --- a/packages/graphql-server-micro/package.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "name": "graphql-server-micro", - "version": "1.4.0", - "description": "Production-ready Node.js GraphQL server for Micro", - "main": "dist/index.js", - "scripts": { - "compile": "tsc", - "prepublish": "npm run compile" - }, - "repository": { - "type": "git", - "url": "https://github.com/apollographql/apollo-server/tree/master/packages/graphql-server-micro" - }, - "keywords": [ - "GraphQL", - "Apollo", - "Micro", - "Server", - "Javascript" - ], - "author": "Nick Nance ", - "license": "MIT", - "bugs": { - "url": "https://github.com/apollographql/apollo-server/issues" - }, - "homepage": "https://github.com/apollographql/apollo-server#readme", - "dependencies": { - "apollo-server-micro": "^1.4.0" - }, - "typings": "dist/index.d.ts", - "typescript": { - "definition": "dist/index.d.ts" - } -} diff --git a/packages/graphql-server-micro/src/index.ts b/packages/graphql-server-micro/src/index.ts deleted file mode 100644 index 034e9c25f5c..00000000000 --- a/packages/graphql-server-micro/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from 'apollo-server-micro'; diff --git a/packages/graphql-server-micro/tsconfig.json b/packages/graphql-server-micro/tsconfig.json deleted file mode 100644 index c3925d6fb0c..00000000000 --- a/packages/graphql-server-micro/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig", - "compilerOptions": { - "rootDir": "./src", - "outDir": "./dist" - }, - "exclude": ["node_modules", "dist"] -} diff --git a/packages/graphql-server-module-graphiql/README.md b/packages/graphql-server-module-graphiql/README.md deleted file mode 100644 index ee0da89e16f..00000000000 --- a/packages/graphql-server-module-graphiql/README.md +++ /dev/null @@ -1 +0,0 @@ -The `graphql-server-module-graphiql` package is now called [`apollo-server-module-graphiql`](https://www.npmjs.com/package/apollo-server-module-graphiql). We are continuing to release matching versions of the package under the old name, but we recommend you switch to using the new name. The API is identical. diff --git a/packages/graphql-server-module-graphiql/package.json b/packages/graphql-server-module-graphiql/package.json deleted file mode 100644 index af70abc18a9..00000000000 --- a/packages/graphql-server-module-graphiql/package.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "graphql-server-module-graphiql", - "version": "1.4.0", - "description": "GraphiQL renderer for Apollo GraphQL Server", - "main": "dist/index.js", - "scripts": { - "compile": "tsc", - "prepublish": "npm run compile" - }, - "repository": { - "type": "git", - "url": "https://github.com/apollographql/apollo-server/tree/master/packages/graphql-server-module-graphiql" - }, - "keywords": [ - "GraphQL", - "GraphiQL", - "Apollo", - "Javascript" - ], - "author": "Jonas Helfer ", - "license": "MIT", - "bugs": { - "url": "https://github.com/apollographql/apollo-server/issues" - }, - "homepage": "https://github.com/apollographql/apollo-server#readme", - "dependencies": { - "apollo-server-module-graphiql": "^1.4.0" - }, - "typings": "dist/index.d.ts", - "typescript": { - "definition": "dist/index.d.ts" - } -} diff --git a/packages/graphql-server-module-graphiql/src/index.ts b/packages/graphql-server-module-graphiql/src/index.ts deleted file mode 100644 index cafcbae4c6b..00000000000 --- a/packages/graphql-server-module-graphiql/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from 'apollo-server-module-graphiql'; diff --git a/packages/graphql-server-module-graphiql/tsconfig.json b/packages/graphql-server-module-graphiql/tsconfig.json deleted file mode 100644 index c3925d6fb0c..00000000000 --- a/packages/graphql-server-module-graphiql/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig", - "compilerOptions": { - "rootDir": "./src", - "outDir": "./dist" - }, - "exclude": ["node_modules", "dist"] -} diff --git a/packages/graphql-server-module-operation-store/README.md b/packages/graphql-server-module-operation-store/README.md deleted file mode 100644 index 24e451d834c..00000000000 --- a/packages/graphql-server-module-operation-store/README.md +++ /dev/null @@ -1 +0,0 @@ -The `graphql-server-module-operation-store` package is now called [`apollo-server-module-operation-store`](https://www.npmjs.com/package/apollo-server-module-operation-store). We are continuing to release matching versions of the package under the old name, but we recommend you switch to using the new name. The API is identical. diff --git a/packages/graphql-server-module-operation-store/package.json b/packages/graphql-server-module-operation-store/package.json deleted file mode 100644 index 354cd649797..00000000000 --- a/packages/graphql-server-module-operation-store/package.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "graphql-server-module-operation-store", - "version": "1.3.5", - "description": "Persisted operation store module for Apollo GraphQL Servers", - "main": "dist/index.js", - "scripts": { - "compile": "tsc", - "prepublish": "npm run compile" - }, - "repository": { - "type": "git", - "url": "https://github.com/apollographql/apollo-server/tree/master/packages/graphql-server-module-operation-store" - }, - "keywords": [ - "GraphQL", - "Apollo", - "Operation Store", - "Javascript" - ], - "author": "Jonas Helfer ", - "license": "MIT", - "bugs": { - "url": "https://github.com/apollographql/apollo-server/issues" - }, - "homepage": "https://github.com/apollographql/apollo-server#readme", - "dependencies": { - "apollo-server-module-operation-store": "^1.3.5" - }, - "typings": "dist/index.d.ts", - "typescript": { - "definition": "dist/index.d.ts" - } -} diff --git a/packages/graphql-server-module-operation-store/src/index.ts b/packages/graphql-server-module-operation-store/src/index.ts deleted file mode 100644 index 77d4e9a0d16..00000000000 --- a/packages/graphql-server-module-operation-store/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from 'apollo-server-module-operation-store'; diff --git a/packages/graphql-server-module-operation-store/tsconfig.json b/packages/graphql-server-module-operation-store/tsconfig.json deleted file mode 100644 index c3925d6fb0c..00000000000 --- a/packages/graphql-server-module-operation-store/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig", - "compilerOptions": { - "rootDir": "./src", - "outDir": "./dist" - }, - "exclude": ["node_modules", "dist"] -} diff --git a/packages/graphql-server-restify/README.md b/packages/graphql-server-restify/README.md deleted file mode 100644 index 3ffa22d8965..00000000000 --- a/packages/graphql-server-restify/README.md +++ /dev/null @@ -1 +0,0 @@ -The `graphql-server-restify` package is now called [`apollo-server-restify`](https://www.npmjs.com/package/apollo-server-restify). We are continuing to release matching versions of the package under the old name, but we recommend you switch to using the new name. The API is identical. diff --git a/packages/graphql-server-restify/package.json b/packages/graphql-server-restify/package.json deleted file mode 100644 index b4b9b23c28e..00000000000 --- a/packages/graphql-server-restify/package.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "name": "graphql-server-restify", - "version": "1.4.0", - "description": "Production-ready Node.js GraphQL server for Restify", - "main": "dist/index.js", - "scripts": { - "compile": "tsc", - "prepublish": "npm run compile" - }, - "repository": { - "type": "git", - "url": "https://github.com/apollographql/apollo-server/tree/master/packages/graphql-server-restify" - }, - "keywords": [ - "GraphQL", - "Apollo", - "Server", - "Restify", - "Javascript" - ], - "author": "Jonas Helfer ", - "license": "MIT", - "bugs": { - "url": "https://github.com/apollographql/apollo-server/issues" - }, - "homepage": "https://github.com/apollographql/apollo-server#readme", - "dependencies": { - "apollo-server-restify": "^1.4.0" - }, - "typings": "dist/index.d.ts", - "typescript": { - "definition": "dist/index.d.ts" - } -} diff --git a/packages/graphql-server-restify/src/index.ts b/packages/graphql-server-restify/src/index.ts deleted file mode 100644 index 174888cb4d2..00000000000 --- a/packages/graphql-server-restify/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from 'apollo-server-restify'; diff --git a/packages/graphql-server-restify/tsconfig.json b/packages/graphql-server-restify/tsconfig.json deleted file mode 100644 index c3925d6fb0c..00000000000 --- a/packages/graphql-server-restify/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig", - "compilerOptions": { - "rootDir": "./src", - "outDir": "./dist" - }, - "exclude": ["node_modules", "dist"] -} diff --git a/test/tests.js b/test/tests.js index 6fbeabc75c4..c8e71a79dd2 100644 --- a/test/tests.js +++ b/test/tests.js @@ -3,25 +3,41 @@ const NODE_MAJOR_VERSION = parseInt(NODE_VERSION[0].replace(/^v/, '')); const NODE_MAJOR_REVISION = parseInt(NODE_VERSION[1]); process.env.NODE_ENV = 'test'; -require('../packages/apollo-server-core/dist/runQuery.test.js'); -require('../packages/apollo-server-core/dist/runHttpQuery.test.js'); -require('../packages/apollo-server-module-operation-store/dist/operationStore.test'); -NODE_MAJOR_VERSION >= 7 && - require('../packages/apollo-server-adonis/dist/adonisApollo.test'); +process.on('unhandledRejection', reason => { + console.log('Reason: ' + reason); + console.log('Stack: ' + reason.stack); +}); + +// apollo-server-core +require('../packages/apollo-server-core/dist/runQuery.test'); +require('../packages/apollo-server-core/dist/runHttpQuery.test'); +require('../packages/apollo-server-core/dist/errors.test'); + +// Apollo server 2 tests + +// apollo-server +require('../packages/apollo-server/dist/index.test'); + +// apollo-server-express +require('../packages/apollo-server-express/dist/ApolloServer.test'); require('../packages/apollo-server-express/dist/expressApollo.test'); require('../packages/apollo-server-express/dist/connectApollo.test'); +require('../packages/apollo-server-express/dist/datasource.test'); + +// apollo-server-hapi (NODE_MAJOR_VERSION >= 9 || - (NODE_MAJOR_VERSION >= 8 && NODE_MAJOR_REVISION >= 9)) && - require('../packages/apollo-server-fastify/dist/fastifyApollo.test') && - require('../packages/apollo-server-hapi/dist/hapiApollo.test'); // Hapi 17 and Fastify are 8.9+ -NODE_MAJOR_VERSION >= 6 && - require('../packages/apollo-server-micro/dist/microApollo.test'); -NODE_MAJOR_VERSION >= 7 && - require('../packages/apollo-server-koa/dist/koaApollo.test'); + (NODE_MAJOR_VERSION >= 8 && NODE_MAJOR_REVISION >= 9)) && // Hapi 17 is 8.9+ + require('../packages/apollo-server-hapi/dist/hapiApollo.test') && + require('../packages/apollo-server-hapi/dist/ApolloServer.test'); + +// apollo-server-lambda require('../packages/apollo-server-lambda/dist/lambdaApollo.test'); -require('../packages/apollo-server-azure-functions/dist/azureFunctionsApollo.test'); -require('../packages/apollo-server-express/dist/apolloServerHttp.test'); -// XXX: Running restify last as it breaks http. -// for more info: https://github.com/restify/node-restify/issues/700 -require('../packages/apollo-server-restify/dist/restifyApollo.test'); +//apollo-server-micro +require('../packages/apollo-server-micro/dist/ApolloServer.test'); +require('../packages/apollo-server-micro/dist/microApollo.test'); + +//apollo-server-koa +require('../packages/apollo-server-koa/dist/ApolloServer.test'); +require('../packages/apollo-server-koa/dist/koaApollo.test'); +require('../packages/apollo-server-koa/dist/datasource.test'); diff --git a/tsconfig.json b/tsconfig.json index ee40826e6be..1362a193d09 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,15 +1,16 @@ { "compilerOptions": { - "target": "es5", + "target": "es2016", "module": "commonjs", "moduleResolution": "node", "sourceMap": true, "declaration": true, + "declarationMap": true, "noImplicitAny": false, - "allowSyntheticDefaultImports": false, - "pretty": true, "removeComments": true, - "lib": ["es6", "esnext.asynciterable"], - "types": ["@types/node"] + "noUnusedLocals": true, + "noUnusedParameters": true, + "lib": ["es2017", "esnext.asynciterable"], + "types": ["node", "mocha"] } }