From cd1c19d033bca1cf2d5806ca15bbb32890c3d5a3 Mon Sep 17 00:00:00 2001 From: Reyad Attiyat Date: Wed, 14 Dec 2016 14:51:56 -0600 Subject: [PATCH] Add support for AWS Lambda Create an integration that will return a lambda handler for a graphql or graphiql server. This integration requires an API Gateway with Lambda Proxy Integration. The test runner expects req and res objects for testing. We have to mock these for the tests to execute properly. --- packages/graphql-server-lambda/.npmignore | 5 + packages/graphql-server-lambda/README.md | 3 + packages/graphql-server-lambda/package.json | 43 +++++++ packages/graphql-server-lambda/src/index.ts | 6 + .../src/lambdaApollo.test.ts | 64 +++++++++++ .../graphql-server-lambda/src/lambdaApollo.ts | 106 ++++++++++++++++++ packages/graphql-server-lambda/tsconfig.json | 17 +++ test/tests.js | 1 + 8 files changed, 245 insertions(+) create mode 100755 packages/graphql-server-lambda/.npmignore create mode 100755 packages/graphql-server-lambda/README.md create mode 100644 packages/graphql-server-lambda/package.json create mode 100755 packages/graphql-server-lambda/src/index.ts create mode 100755 packages/graphql-server-lambda/src/lambdaApollo.test.ts create mode 100755 packages/graphql-server-lambda/src/lambdaApollo.ts create mode 100644 packages/graphql-server-lambda/tsconfig.json diff --git a/packages/graphql-server-lambda/.npmignore b/packages/graphql-server-lambda/.npmignore new file mode 100755 index 00000000..063364e2 --- /dev/null +++ b/packages/graphql-server-lambda/.npmignore @@ -0,0 +1,5 @@ +* +!dist +!dist/**/* +dist/**/*.test.* +!package.json diff --git a/packages/graphql-server-lambda/README.md b/packages/graphql-server-lambda/README.md new file mode 100755 index 00000000..ba4979c5 --- /dev/null +++ b/packages/graphql-server-lambda/README.md @@ -0,0 +1,3 @@ +# graphql-server-lambda + +This is the AWS Lambda integration for the Apollo community GraphQL Server. [Read the docs.](http://dev.apollodata.com/tools/apollo-server/index.html) diff --git a/packages/graphql-server-lambda/package.json b/packages/graphql-server-lambda/package.json new file mode 100644 index 00000000..f5b8b9a8 --- /dev/null +++ b/packages/graphql-server-lambda/package.json @@ -0,0 +1,43 @@ +{ + "name": "graphql-server-lambda", + "version": "0.5.1", + "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/apollostack/graphql-server/tree/master/packages/graphql-server-lambda" + }, + "keywords": [ + "GraphQL", + "Apollo", + "Server", + "Lambda", + "Javascript" + ], + "author": "Jonas Helfer ", + "license": "MIT", + "bugs": { + "url": "https://github.com/apollostack/graphql-server/issues" + }, + "homepage": "https://github.com/apollostack/graphql-server#readme", + "dependencies": { + "graphql-server-core": "^0.5.1", + "graphql-server-module-graphiql": "^0.4.4" + }, + "devDependencies": { + "@types/aws-lambda": "0.0.5", + "@types/graphql": "^0.8.6", + "graphql-server-integration-testsuite": "^0.5.1" + }, + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.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 new file mode 100755 index 00000000..10f81ef3 --- /dev/null +++ b/packages/graphql-server-lambda/src/index.ts @@ -0,0 +1,6 @@ +export { + LambdaHandler, + IHeaders, + graphqlLambda, + graphiqlLambda +} from './lambdaApollo'; diff --git a/packages/graphql-server-lambda/src/lambdaApollo.test.ts b/packages/graphql-server-lambda/src/lambdaApollo.test.ts new file mode 100755 index 00000000..f775d26d --- /dev/null +++ b/packages/graphql-server-lambda/src/lambdaApollo.test.ts @@ -0,0 +1,64 @@ +import { graphqlLambda, graphiqlLambda } from './lambdaApollo'; +import testSuite, { schema as Schema, CreateAppOptions } from 'graphql-server-integration-testsuite'; +import { expect } from 'chai'; +import { GraphQLOptions } from 'graphql-server-core'; +import 'mocha'; +import * as url from 'url'; + +function createLambda(options: CreateAppOptions = {}) { + let handler, + callback, + event, + context; + + options.graphqlOptions = options.graphqlOptions || { schema: Schema }; + if (options.graphiqlOptions ) { + handler = graphiqlLambda( options.graphiqlOptions ); + } else { + handler = graphqlLambda( options.graphqlOptions ); + } + + return function(req, res) { + let body = ''; + req.on('data', function (chunk) { + body += chunk; + }); + req.on('end', function() { + let urlObject = url.parse(req.url, true); + event = { + httpMethod: req.method, + body: body, + path: req.url, + queryStringParameters: urlObject.query, + }; + context = {}; + callback = function(error, result) { + res.statusCode = result.statusCode; + for (let key in result.headers) { + if (result.headers.hasOwnProperty(key)) { + res.setHeader(key, result.headers[key]); + } + } + res.write(result.body); + res.end(); + }; + + handler(event, context, 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/graphql-server-lambda/src/lambdaApollo.ts b/packages/graphql-server-lambda/src/lambdaApollo.ts new file mode 100755 index 00000000..21e4d012 --- /dev/null +++ b/packages/graphql-server-lambda/src/lambdaApollo.ts @@ -0,0 +1,106 @@ +import * as lambda from 'aws-lambda'; +import { GraphQLOptions, runHttpQuery } from 'graphql-server-core'; +import * as GraphiQL from 'graphql-server-module-graphiql'; + +export interface LambdaGraphQLOptionsFunction { + (event: any, 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 { + 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}`); + } + + return async (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, + }, + ); + } + }; +} + +/* 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) { + return (event, lambdaContext: lambda.Context, callback: lambda.Callback) => { + const q = event.queryStringParameters || {}; + const query = q.query || ''; + const variables = q.variables || '{}'; + const operationName = q.operationName || ''; + + const graphiQLString = GraphiQL.renderGraphiQL({ + endpointURL: options.endpointURL, + query: query || options.query, + variables: q.variables && JSON.parse(variables) || options.variables, + operationName: operationName || options.operationName, + passHeader: options.passHeader, + }); + callback( + null, + { + 'statusCode': 200, + 'headers': { + 'Content-Type': 'text/html', + }, + 'body': graphiQLString, + }, + ); + }; +} diff --git a/packages/graphql-server-lambda/tsconfig.json b/packages/graphql-server-lambda/tsconfig.json new file mode 100644 index 00000000..dcab1f88 --- /dev/null +++ b/packages/graphql-server-lambda/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "typeRoots": [ + "node_modules/@types" + ], + "types": [ + "@types/node" + ] + }, + "exclude": [ + "node_modules", + "dist" + ] +} diff --git a/test/tests.js b/test/tests.js index 4a9e1007..c1795c9d 100644 --- a/test/tests.js +++ b/test/tests.js @@ -7,4 +7,5 @@ require('../packages/graphql-server-express/dist/connectApollo.test'); require('../packages/graphql-server-hapi/dist/hapiApollo.test'); require('../packages/graphql-server-koa/dist/koaApollo.test'); require('../packages/graphql-server-restify/dist/restifyApollo.test'); +require('../packages/graphql-server-lambda/dist/lambdaApollo.test'); require('../packages/graphql-server-express/dist/apolloServerHttp.test');