From bacdeae80de05bc570343b348552a8a209b647b8 Mon Sep 17 00:00:00 2001 From: Remy Korrelboom Date: Thu, 15 Nov 2018 08:35:52 +0100 Subject: [PATCH] feat(fastify) Apollo Fastify server integration resolve #626 --- CHANGELOG.md | 1 + README.md | 23 + package.json | 2 + packages/apollo-server-fastify/.npmignore | 6 + packages/apollo-server-fastify/README.md | 45 + packages/apollo-server-fastify/jest.config.js | 3 + packages/apollo-server-fastify/package.json | 42 + .../apollo-server-fastify/src/ApolloServer.ts | 145 +++ .../src/__tests__/ApolloServer.test.ts | 835 ++++++++++++++++++ .../src/__tests__/datasource.test.ts | 143 +++ .../src/__tests__/fastifyApollo.test.ts | 38 + .../src/__tests__/tsconfig.json | 8 + .../src/fastifyApollo.ts | 77 ++ packages/apollo-server-fastify/src/index.ts | 29 + packages/apollo-server-fastify/tsconfig.json | 12 + tsconfig.build.json | 1 + tsconfig.test.json | 1 + 17 files changed, 1411 insertions(+) create mode 100644 packages/apollo-server-fastify/.npmignore create mode 100644 packages/apollo-server-fastify/README.md create mode 100644 packages/apollo-server-fastify/jest.config.js create mode 100644 packages/apollo-server-fastify/package.json create mode 100644 packages/apollo-server-fastify/src/ApolloServer.ts create mode 100644 packages/apollo-server-fastify/src/__tests__/ApolloServer.test.ts create mode 100644 packages/apollo-server-fastify/src/__tests__/datasource.test.ts create mode 100644 packages/apollo-server-fastify/src/__tests__/fastifyApollo.test.ts create mode 100644 packages/apollo-server-fastify/src/__tests__/tsconfig.json create mode 100644 packages/apollo-server-fastify/src/fastifyApollo.ts create mode 100644 packages/apollo-server-fastify/src/index.ts create mode 100644 packages/apollo-server-fastify/tsconfig.json diff --git a/CHANGELOG.md b/CHANGELOG.md index f58fce271a9..73f9a9ee656 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ client reference ID, Apollo Server will now default to the values present in the of the request (`apollographql-client-name`, `apollographql-client-reference-id` and `apollographql-client-version` respectively). As a last resort, when those headers are not set, the query extensions' `clientInfo` values will be used. [PR #1960](https://github.com/apollographql/apollo-server/pull/1960) +- Added `apollo-server-fastify` integration ([@rkorrelboom](https://github.com/rkorrelboom) in [#1971](https://github.com/apollostack/apollo-server/pull/1971)) ### v2.2.2 diff --git a/README.md b/README.md index a5c413a7b95..955eaf46fc8 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,29 @@ new ApolloServer({ }) ``` +## Fastify + +```js +const { ApolloServer, gql } = require('apollo-server-fastify'); +const fastify = require('fastify'); + +async function StartServer() { + const server = new ApolloServer({ typeDefs, resolvers }); + + const app = fastify(); + + await server.applyMiddleware({ + app, + }); + + await server.installSubscriptionHandlers(app.server); + + await app.listen(3000); +} + +StartServer().catch(error => console.log(error)); +``` + ### AWS Lambda Apollo Server can be run on Lambda and deployed with AWS Serverless Application Model (SAM). It requires an API Gateway with Lambda Proxy Integration. diff --git a/package.json b/package.json index e9ad092f411..566d1a541a8 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "apollo-server-env": "file:packages/apollo-server-env", "apollo-server-errors": "file:packages/apollo-server-errors", "apollo-server-express": "file:packages/apollo-server-express", + "apollo-server-fastify": "file:packages/apollo-server-fastify", "apollo-server-hapi": "file:packages/apollo-server-hapi", "apollo-server-integration-testsuite": "file:packages/apollo-server-integration-testsuite", "apollo-server-koa": "file:packages/apollo-server-koa", @@ -94,6 +95,7 @@ "codecov": "3.1.0", "connect": "3.6.6", "express": "4.16.4", + "fastify": "1.13.0", "fibers": "3.1.1", "form-data": "2.3.3", "graphql": "14.0.2", diff --git a/packages/apollo-server-fastify/.npmignore b/packages/apollo-server-fastify/.npmignore new file mode 100644 index 00000000000..a165046d359 --- /dev/null +++ b/packages/apollo-server-fastify/.npmignore @@ -0,0 +1,6 @@ +* +!src/**/* +!dist/**/* +dist/**/*.test.* +!package.json +!README.md diff --git a/packages/apollo-server-fastify/README.md b/packages/apollo-server-fastify/README.md new file mode 100644 index 00000000000..ec61b495c58 --- /dev/null +++ b/packages/apollo-server-fastify/README.md @@ -0,0 +1,45 @@ +--- +title: Fastify +description: Setting up Apollo Server with Fastify +--- + +[![npm version](https://badge.fury.io/js/apollo-server-fastify.svg)](https://badge.fury.io/js/apollo-server-fastify) [![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 Fastify 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-fastify +``` + +## Fastify + +```js +const { ApolloServer, gql } = require('apollo-server-fastify'); +const fastify = require('fastify'); + +async function StartServer() { + const server = new ApolloServer({ typeDefs, resolvers }); + + const app = fastify(); + + await server.applyMiddleware({ + app, + }); + + await server.installSubscriptionHandlers(app.server); + + await app.listen(3000); +} + +StartServer().catch(error => console.log(error)); +``` + +## 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/jest.config.js b/packages/apollo-server-fastify/jest.config.js new file mode 100644 index 00000000000..a383fbc925f --- /dev/null +++ b/packages/apollo-server-fastify/jest.config.js @@ -0,0 +1,3 @@ +const config = require('../../jest.config.base'); + +module.exports = Object.assign(Object.create(null), config); diff --git a/packages/apollo-server-fastify/package.json b/packages/apollo-server-fastify/package.json new file mode 100644 index 00000000000..dd7abeb687b --- /dev/null +++ b/packages/apollo-server-fastify/package.json @@ -0,0 +1,42 @@ +{ + "name": "apollo-server-fastify", + "version": "2.2.2", + "description": "Production-ready Node.js GraphQL server for Fastify", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "repository": { + "type": "git", + "url": "https://github.com/apollographql/apollo-server/tree/master/packages/apollo-server-fastify" + }, + "keywords": [ + "GraphQL", + "Apollo", + "Server", + "Fastify", + "Javascript" + ], + "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": { + "@apollographql/apollo-upload-server": "^5.0.3", + "@apollographql/graphql-playground-html": "^1.6.4", + "apollo-server-core": "file:../apollo-server-core", + "fastify-accepts": "^0.5.0", + "fastify-cors": "^0.2.0", + "graphql-subscriptions": "^1.0.0", + "graphql-tools": "^4.0.0" + }, + "devDependencies": { + "apollo-server-integration-testsuite": "file:../apollo-server-integration-testsuite" + }, + "peerDependencies": { + "graphql": "^0.12.0 || ^0.13.0 || ^14.0.0" + } +} diff --git a/packages/apollo-server-fastify/src/ApolloServer.ts b/packages/apollo-server-fastify/src/ApolloServer.ts new file mode 100644 index 00000000000..3a9fa3650c1 --- /dev/null +++ b/packages/apollo-server-fastify/src/ApolloServer.ts @@ -0,0 +1,145 @@ +import { renderPlaygroundPage } from '@apollographql/graphql-playground-html'; +import { Accepts } from 'accepts'; +import { + ApolloServerBase, + PlaygroundRenderPageOptions, +} from 'apollo-server-core'; +import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { IncomingMessage, OutgoingMessage } from 'http'; +import { processRequest as processFileUploads } from '@apollographql/apollo-upload-server'; +import { graphqlFastify } from './fastifyApollo'; + +const fastJson = require('fast-json-stringify'); + +export interface ServerRegistration { + app: FastifyInstance; + path?: string; + cors?: object | boolean; + onHealthCheck?: (req: FastifyRequest) => Promise; + disableHealthCheck?: boolean; +} + +const stringifyHealthCheck = fastJson({ + type: 'object', + properties: { + status: { + type: 'string', + }, + }, +}); + +export class ApolloServer extends ApolloServerBase { + protected supportsSubscriptions(): boolean { + return true; + } + + protected supportsUploads(): boolean { + return true; + } + + public async applyMiddleware({ + app, + path, + cors, + disableHealthCheck, + onHealthCheck, + }: ServerRegistration) { + await this.willStart(); + + if (!path) path = '/graphql'; + + this.graphqlPath = path; + + app.register(require('fastify-accepts')); + + if (!disableHealthCheck) { + app.get('/.well-known/apollo/server-health', async (req, res) => { + // Response follows https://tools.ietf.org/html/draft-inadarei-api-health-check-01 + res.type('application/health+json'); + + if (onHealthCheck) { + try { + await onHealthCheck(req); + res.send(stringifyHealthCheck({ status: 'pass' })); + } catch (e) { + res.status(503).send(stringifyHealthCheck({ status: 'fail' })); + } + } else { + res.send(stringifyHealthCheck({ status: 'pass' })); + } + }); + } + + if (cors === true) { + app.register(require('fastify-cors')); + } else if (cors !== false) { + app.register(require('fastify-cors'), cors); + } + + app.register( + async instance => { + instance.setNotFoundHandler((_request, reply) => { + reply.code(405); + reply.header('allow', 'GET, POST'); + reply.send(); + }); + + instance.addContentTypeParser( + 'multipart', + async (request: IncomingMessage) => + processFileUploads(request, this.uploadsConfig), + ); + + instance.register(graphqlFastify, { + route: { + beforeHandler: ( + req: FastifyRequest, + reply: FastifyReply, + done: () => void, + ) => { + // 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. + if (this.playgroundOptions && req.req.method === 'GET') { + // perform more expensive content-type check only if necessary + const accept = (req as any).accepts() as Accepts; + 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, + }; + reply.type('text/html'); + const playground = renderPlaygroundPage( + playgroundRenderPageOptions, + ); + reply.send(playground); + return; + } + } + done(); + }, + }, + graphqlOptions: this.graphQLServerOptions.bind(this), + }); + }, + { + prefix: path, + }, + ); + } +} + +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-fastify/src/__tests__/ApolloServer.test.ts b/packages/apollo-server-fastify/src/__tests__/ApolloServer.test.ts new file mode 100644 index 00000000000..069405f1a45 --- /dev/null +++ b/packages/apollo-server-fastify/src/__tests__/ApolloServer.test.ts @@ -0,0 +1,835 @@ +import { FastifyInstance } from 'fastify'; +import fastify from 'fastify'; + +import http from 'http'; + +import request from 'request'; +import FormData from 'form-data'; +import fs from 'fs'; +import { createApolloFetch } from 'apollo-fetch'; + +import { gql, AuthenticationError, Config } from 'apollo-server-core'; +import { ApolloServer, ServerRegistration } from '../ApolloServer'; + +import { + atLeastMajorNodeVersion, + testApolloServer, + createServerInfo, +} from 'apollo-server-integration-testsuite'; + +const typeDefs = gql` + type Query { + hello: String + } +`; + +const resolvers = { + Query: { + hello: () => 'hi', + }, +}; + +const port = 8888; + +describe('apollo-server-fastify', () => { + let server: ApolloServer; + let httpServer: http.Server; + let app: FastifyInstance; + + testApolloServer( + async options => { + server = new ApolloServer(options); + app = fastify(); + await server.applyMiddleware({ app }); + await app.listen(port); + return createServerInfo(server, app.server); + }, + async () => { + if (server) await server.stop(); + if (app) await new Promise(resolve => app.close(() => resolve())); + if (httpServer && httpServer.listening) await httpServer.close(); + }, + ); +}); + +describe('apollo-server-fastify', () => { + let server: ApolloServer; + let app: FastifyInstance; + let httpServer: http.Server; + + async function createServer( + serverOptions: Config, + options: Partial = {}, + ) { + server = new ApolloServer(serverOptions); + app = fastify(); + + await server.applyMiddleware({ ...options, app }); + await app.listen(port); + + return createServerInfo(server, app.server); + } + + afterEach(async () => { + if (server) await server.stop(); + if (app) await new Promise(resolve => app.close(() => resolve())); + if (httpServer) await httpServer.close(); + }); + + describe('constructor', async () => { + it('accepts typeDefs and resolvers', () => { + return createServer({ typeDefs, resolvers }); + }); + }); + + describe('applyMiddleware', async () => { + 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).toEqual({ hello: 'hi' }); + expect(result.errors).toBeUndefined(); + }); + + // 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).toEqual(1); + expect(result.errors[0].extensions.code).toEqual( + '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).toMatch('GraphQLPlayground'); + expect(response.statusCode).toEqual(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).toMatch('GraphQLPlayground'); + expect(body).not.toMatch('settings'); + expect(response.statusCode).toEqual(200); + resolve(); + } + }, + ); + }); + }); + + const playgroundPartialOptionsTest = async () => { + 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) => { + if (error) { + reject(error); + } else { + expect(body).toMatch('GraphQLPlayground'); + expect(body).toMatch(`"editor.theme": "light"`); + expect(body).toMatch(defaultQuery); + expect(body).toMatch(endpoint); + expect(response.statusCode).toEqual(200); + resolve(); + } + }, + ); + }); + }; + + it('accepts partial GraphQL Playground Options in production', async () => { + const nodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + await playgroundPartialOptionsTest(); + process.env.NODE_ENV = nodeEnv; + }); + + it( + 'accepts partial GraphQL Playground Options when an environment is ' + + 'not specified', + async () => { + const nodeEnv = process.env.NODE_ENV; + delete process.env.NODE_ENV; + await playgroundPartialOptionsTest(); + process.env.NODE_ENV = nodeEnv; + }, + ); + + 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.toMatch('GraphQLPlayground'); + expect(response.statusCode).not.toEqual(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'), + ).toEqual('apollographql.com'); + next(); + }, + ); + await apolloFetch({ query: '{hello}' }); + }); + + 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).toEqual(JSON.stringify({ status: 'pass' })); + expect(response.statusCode).toEqual(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).toEqual(JSON.stringify({ status: 'fail' })); + expect(response.statusCode).toEqual(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).toEqual(404); + resolve(); + } + }, + ); + }); + }); + }); + // NODE: Intentionally skip file upload tests on Node.js 10 or higher. + (atLeastMajorNodeVersion(10) ? describe.skip : describe)( + 'file uploads', + () => { + it('enabled uploads', async () => { + 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).toBeDefined(); + 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).toEqual({ + 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).toEqual(1); + expect(result.data).toBeUndefined(); + + const e = result.errors[0]; + expect(e.message).toMatch('valid result'); + expect(e.extensions).toBeDefined(); + expect(e.extensions.code).toEqual('UNAUTHENTICATED'); + expect(e.extensions.exception.stacktrace).toBeDefined(); + + 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).toBeDefined(); + expect(result.data).toEqual({ error: null }); + + expect(result.errors).toBeDefined(); + expect(result.errors.length).toEqual(1); + expect(result.errors[0].extensions.code).toEqual('UNAUTHENTICATED'); + expect(result.errors[0].extensions.exception).toBeDefined(); + expect(result.errors[0].extensions.exception.stacktrace).toBeDefined(); + + 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).toBeDefined(); + expect(result.data).toEqual({ error: null }); + + expect(result.errors).toBeDefined(); + expect(result.errors.length).toEqual(1); + expect(result.errors[0].extensions.code).toEqual('UNAUTHENTICATED'); + expect(result.errors[0].extensions.exception).toBeUndefined(); + + 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).toBeNull(); + + expect(result.errors).toBeDefined(); + expect(result.errors.length).toEqual(1); + expect(result.errors[0].extensions.code).toEqual('UNAUTHENTICATED'); + expect(result.errors[0].extensions.exception).toBeUndefined(); + + 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')).toEqual( + 'max-age=200, public', + ); + next(); + }, + ); + const result = await apolloFetch({ + query: `{ cooks { title author } }`, + }); + expect(result.data).toEqual({ cooks: books }); + expect(result.extensions).toBeUndefined(); + }); + + 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')).toBeNull(); + next(); + }, + ); + const result = await apolloFetch({ + query: `{ cooks { title author } }`, + }); + expect(result.data).toEqual({ cooks: books }); + expect(result.extensions).toBeDefined(); + expect(result.extensions.cacheControl).toBeDefined(); + }); + + 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')).toBeNull(); + next(); + }, + ); + const result = await apolloFetch({ + query: `{ books { title author } }`, + }); + expect(result.data).toEqual({ books }); + expect(result.extensions).toBeUndefined(); + }); + + 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')).toEqual( + 'max-age=20, private', + ); + next(); + }, + ); + const result = await apolloFetch({ + query: `{ pooks { title books { title author } } }`, + }); + expect(result.data).toEqual({ + pooks: [{ title: 'pook', books }], + }); + expect(result.extensions).toBeUndefined(); + }); + + 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')).toBeNull(); + next(); + }, + ); + const result = await apolloFetch({ + query: `{ pooks { title books { title author } } }`, + }); + expect(result.data).toEqual({ + pooks: [{ title: 'pook', books }], + }); + expect(result.extensions).toBeUndefined(); + }); + }); + + 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).toEqual({ books }); + expect(result.extensions).toBeDefined(); + expect(result.extensions.tracing).toBeDefined(); + }); + + 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).toEqual({ books }); + expect(result.extensions).toBeDefined(); + expect(result.extensions.tracing).toBeDefined(); + }); + + xit('applies tracing extension with engine enabled', async () => { + const { url: uri } = await createServer({ + typeDefs, + resolvers, + tracing: true, + engine: { + apiKey: 'service:my-app:secret', + maxAttempts: 0, + endpointUrl: 'l', + reportErrorFunction: () => {}, + }, + }); + + const apolloFetch = createApolloFetch({ uri }); + const result = await apolloFetch({ + query: `{ books { title author } }`, + }); + expect(result.data).toEqual({ books }); + expect(result.extensions).toBeDefined(); + expect(result.extensions.tracing).toBeDefined(); + }); + }); + }); +}); diff --git a/packages/apollo-server-fastify/src/__tests__/datasource.test.ts b/packages/apollo-server-fastify/src/__tests__/datasource.test.ts new file mode 100644 index 00000000000..943c43aff69 --- /dev/null +++ b/packages/apollo-server-fastify/src/__tests__/datasource.test.ts @@ -0,0 +1,143 @@ +import fastify, { FastifyInstance } from 'fastify'; + +import { RESTDataSource } from 'apollo-datasource-rest'; + +import { createApolloFetch } from 'apollo-fetch'; +import { ApolloServer } from '../ApolloServer'; + +import { createServerInfo } from 'apollo-server-integration-testsuite'; +import { gql } from '../index'; + +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}`); + } +} + +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 = fastify(); + +restAPI.get('/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()); + res.send({ id }); +}); + +restAPI.get('/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.send(id); +}); + +describe('apollo-server-fastify', () => { + let restServer: FastifyInstance; + let app: FastifyInstance; + + beforeAll(async () => { + await restAPI.listen(restPort); + }); + + afterAll(async () => { + await new Promise(resolve => restServer.close(() => resolve())); + }); + + let server: ApolloServer; + + beforeEach(() => { + restCalls = 0; + }); + + afterEach(async () => { + await server.stop(); + // await httpServer.close(); + await new Promise(resolve => app.close(() => resolve())); + }); + + it('uses the cache', async () => { + server = new ApolloServer({ + typeDefs, + resolvers, + dataSources: () => ({ + id: new IdAPI(), + }), + }); + app = fastify(); + + await server.applyMiddleware({ app }); + await app.listen(6667); + const { url: uri } = createServerInfo(server, app.server); + + const apolloFetch = createApolloFetch({ uri }); + const firstResult = await apolloFetch({ query: '{ id }' }); + + expect(firstResult.data).toEqual({ id: 'hi' }); + expect(firstResult.errors).toBeUndefined(); + expect(restCalls).toEqual(1); + + const secondResult = await apolloFetch({ query: '{ id }' }); + + expect(secondResult.data).toEqual({ id: 'hi' }); + expect(secondResult.errors).toBeUndefined(); + expect(restCalls).toEqual(1); + }); + + it('can cache a string from the backend', async () => { + server = new ApolloServer({ + typeDefs, + resolvers, + dataSources: () => ({ + id: new IdAPI(), + }), + }); + app = fastify(); + + server.applyMiddleware({ app }); + await app.listen(6668); + const { url: uri } = createServerInfo(server, app.server); + + const apolloFetch = createApolloFetch({ uri }); + const firstResult = await apolloFetch({ query: '{ id: stringId }' }); + + expect(firstResult.data).toEqual({ id: 'hi' }); + expect(firstResult.errors).toBeUndefined(); + expect(restCalls).toEqual(1); + + const secondResult = await apolloFetch({ query: '{ id: stringId }' }); + + expect(secondResult.data).toEqual({ id: 'hi' }); + expect(secondResult.errors).toBeUndefined(); + expect(restCalls).toEqual(1); + }); +}); diff --git a/packages/apollo-server-fastify/src/__tests__/fastifyApollo.test.ts b/packages/apollo-server-fastify/src/__tests__/fastifyApollo.test.ts new file mode 100644 index 00000000000..1d1c0dfbc1e --- /dev/null +++ b/packages/apollo-server-fastify/src/__tests__/fastifyApollo.test.ts @@ -0,0 +1,38 @@ +import fastify from 'fastify'; +import { Server } from 'http'; +import { ApolloServer } from '../ApolloServer'; +import testSuite, { + schema as Schema, + CreateAppOptions, +} from 'apollo-server-integration-testsuite'; +import { GraphQLOptions, Config } from 'apollo-server-core'; + +async function createApp(options: CreateAppOptions = {}) { + const app = fastify(); + + const server = new ApolloServer( + (options.graphqlOptions as Config) || { schema: Schema }, + ); + await server.applyMiddleware({ app }); + await app.listen(); + return app.server; +} + +async function destroyApp(app: Server) { + if (!app || !app.close) { + return; + } + await new Promise(resolve => app.close(resolve)); +} + +describe('fastifyApollo', () => { + it('throws error if called without schema', function() { + expect(() => new ApolloServer(undefined as GraphQLOptions)).toThrow( + 'ApolloServer requires options.', + ); + }); +}); + +describe('integration:Fastify', () => { + testSuite(createApp, destroyApp); +}); diff --git a/packages/apollo-server-fastify/src/__tests__/tsconfig.json b/packages/apollo-server-fastify/src/__tests__/tsconfig.json new file mode 100644 index 00000000000..86b8a49b265 --- /dev/null +++ b/packages/apollo-server-fastify/src/__tests__/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../../tsconfig.test.base", + "include": ["**/*"], + "references": [ + { "path": "../../" }, + { "path": "../../../apollo-server-integration-testsuite" } + ] +} diff --git a/packages/apollo-server-fastify/src/fastifyApollo.ts b/packages/apollo-server-fastify/src/fastifyApollo.ts new file mode 100644 index 00000000000..37fc2327521 --- /dev/null +++ b/packages/apollo-server-fastify/src/fastifyApollo.ts @@ -0,0 +1,77 @@ +import { + convertNodeHttpToRequest, + GraphQLOptions, + runHttpQuery, +} from 'apollo-server-core'; +import { + FastifyInstance, + FastifyReply, + FastifyRequest, + RegisterOptions, + RouteOptions, +} from 'fastify'; +import { IncomingMessage, OutgoingMessage, Server } from 'http'; + +export interface FastifyGraphQLOptionsFunction + extends RegisterOptions { + route: Partial>; + graphqlOptions: ( + req?: FastifyRequest, + res?: FastifyReply, + ) => GraphQLOptions | Promise; +} + +export async function graphqlFastify( + fastify: FastifyInstance, + options: FastifyGraphQLOptionsFunction, +): Promise { + if (!options) { + throw new Error('Apollo Server requires options.'); + } + + fastify.route({ + method: ['GET', 'POST'], + url: '/', + handler: async ( + request: FastifyRequest, + reply: FastifyReply, + ) => { + try { + const { graphqlResponse, responseInit } = await runHttpQuery( + [request, reply], + { + method: request.req.method as string, + options: options.graphqlOptions, + query: request.req.method === 'POST' ? request.body : request.query, + request: convertNodeHttpToRequest(request.raw), + }, + ); + + if (responseInit.headers) { + for (const [name, value] of Object.entries( + responseInit.headers, + )) { + reply.header(name, value); + } + } + reply.serializer((payload: string) => payload); + reply.send(graphqlResponse); + } catch (error) { + if ('HttpQueryError' !== error.name) { + throw error; + } + + if (error.headers) { + Object.keys(error.headers).forEach(header => { + reply.header(header, error.headers[header]); + }); + } + + reply.code(error.statusCode); + reply.serializer((payload: string) => payload); + reply.send(error.message); + } + }, + ...options.route, + }); +} diff --git a/packages/apollo-server-fastify/src/index.ts b/packages/apollo-server-fastify/src/index.ts new file mode 100644 index 00000000000..38374b22e5f --- /dev/null +++ b/packages/apollo-server-fastify/src/index.ts @@ -0,0 +1,29 @@ +export { + GraphQLUpload, + GraphQLOptions, + GraphQLExtension, + Config, + gql, + // Errors + ApolloError, + toApolloError, + SyntaxError, + ValidationError, + AuthenticationError, + ForbiddenError, + UserInputError, + // playground + defaultPlaygroundOptions, + PlaygroundConfig, + PlaygroundRenderPageOptions, +} 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-fastify/tsconfig.json b/packages/apollo-server-fastify/tsconfig.json new file mode 100644 index 00000000000..71b94f32842 --- /dev/null +++ b/packages/apollo-server-fastify/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + }, + "include": ["src/**/*"], + "exclude": ["**/__tests__", "**/__mocks__"], + "references": [ + { "path": "../apollo-server-core" }, + ] +} diff --git a/tsconfig.build.json b/tsconfig.build.json index 24a42b86b9e..cd6cd85dea1 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -19,6 +19,7 @@ { "path": "./packages/apollo-server-core" }, { "path": "./packages/apollo-server-errors" }, { "path": "./packages/apollo-server-express" }, + { "path": "./packages/apollo-server-fastify" }, { "path": "./packages/apollo-server-hapi" }, { "path": "./packages/apollo-server-koa" }, { "path": "./packages/apollo-server-lambda" }, diff --git a/tsconfig.test.json b/tsconfig.test.json index 1d906e03b7a..aa6f00ceeb8 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -16,6 +16,7 @@ { "path": "./packages/apollo-server-cloud-functions/src/__tests__/" }, { "path": "./packages/apollo-server-core/src/__tests__/" }, { "path": "./packages/apollo-server-express/src/__tests__/" }, + { "path": "./packages/apollo-server-fastify/src/__tests__/" }, { "path": "./packages/apollo-server-hapi/src/__tests__/" }, { "path": "./packages/apollo-server-koa/src/__tests__/" }, { "path": "./packages/apollo-server-lambda/src/__tests__/" },