From fece2923edf82330898e17963b054c88d881beb2 Mon Sep 17 00:00:00 2001 From: David Glasser Date: Wed, 12 May 2021 16:00:34 -0700 Subject: [PATCH] apollo-server-lambda: outsource Lambda expertise Before this change, apollo-server-lambda contained a lot of code that tried to understand the formats of Lambda event (request) and result (response) formats. It tried to work with APIGatewayProxy messages of payloadFormatVersion 1.0 and 2.0 as well as ALB. But understanding the intricacies of the various Lambda message formats isn't really what the Apollo Server project is about. A pretty large fraction of all maintenance on Apollo Server in 2021 has gone into tweaking details of the Lambda event parsing and making the Lambda handler support all AS features without the help of a library supporting composable middleware. This PR (targeted for AS3) throws away the laboriously constructed bespoke Lambda parsing and faux-middleware implementation and replaces it with two packages that solve these problems for us: `@vendia/serverless-express` which understands a variety of Lambda input and output formats and converts them to Express format, and `express`, the most popular Node library for defining HTTP server behavior. (Note that `@vendia/serverless-express` is not related to the `serverless` CLI/framework.) Now `apollo-server-lambda` is just a convenience wrapper around `apollo-server-express`. It has to deal with the difference in startup logic between serverless and non-serverless environments, but it doesn't have to reimplement all of the Lambda and HTTP logic from scratch. As an added advantage, you can now optionally provide your own express app to `apollo-server-lambda`. Previously, `apollo-server-lambda` gave no real way to customize any of its behavior past the particular options we define, because Lambda doesn't have a built-in middleware framework. Since we are removing some built-in features like `graphql-upload` integration in AS3, it's important that we continue a way to add custom behavior to your Lambda server. Letting you define that custom behavior with a standard Express app seems reasonable. We recognize that Lambda users generally care strongly about bundle size, so adding two new dependencies may seem problematic. That said, we don't currently have a principled way of evaluating Lambda bundle sizes when we make choices in this project, and compared to other dependencies of Apollo Server, these new dependencies are not very large. For now, the improvement in maintainability and flexibility seems worth the bundle size increase. If `apollo-server-lambda` users want to help out with a new project of focusing on Lambda bundle size optimization, we can work together to define benchmarks based on realistic build/bundler conditions, and find other ways to reduce bundle size (eg, there is a fair amount of low hanging fruit inside `apollo-reporting-protobuf`). Fixes #5078. Fixes #4951 (because that API is just "Express"). --- docs/source/deployment/lambda.md | 1 + package-lock.json | 70 ++-- package.json | 1 + .../apollo-server-core/src/ApolloServer.ts | 11 +- packages/apollo-server-lambda/README.md | 2 + packages/apollo-server-lambda/package.json | 6 +- .../apollo-server-lambda/src/ApolloServer.ts | 309 ++---------------- .../src/__tests__/ApolloServer.test.ts | 77 +++-- .../src/__tests__/mockServer.ts | 105 +++--- packages/apollo-server-lambda/tsconfig.json | 2 +- 10 files changed, 188 insertions(+), 396 deletions(-) diff --git a/docs/source/deployment/lambda.md b/docs/source/deployment/lambda.md index 96f73fe376e..b81cdfacc19 100644 --- a/docs/source/deployment/lambda.md +++ b/docs/source/deployment/lambda.md @@ -20,6 +20,7 @@ The following must be done before following this guide: --- +FIXME see what needs to be improved ## Setting up your project Setting up a project to work with Lambda isn't that different from a typical NodeJS project. diff --git a/package-lock.json b/package-lock.json index 2a15dd16592..608ffe0ce1a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -71,6 +71,7 @@ "@types/type-is": "1.6.3", "@types/uuid": "8.3.0", "@types/ws": "7.4.1", + "@vendia/serverless-express": "4.3.7", "apollo-link": "1.2.14", "apollo-link-http": "1.5.17", "apollo-link-persisted-queries": "0.2.2", @@ -5105,6 +5106,14 @@ "integrity": "sha512-wBlsw+8n21e6eTd4yVv8YD/E3xq0O6nNnJIquutAsFGE7EyMKz7W6RNT6BRu1SmdgmlCZ9tb0X+j+D6HGr8pZw==", "dev": true }, + "node_modules/@vendia/serverless-express": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/@vendia/serverless-express/-/serverless-express-4.3.7.tgz", + "integrity": "sha512-B1mHMSjo46c3i/7WURQrNb/BBOsJ0Gdby9whwCYa5Az4g807OenAYh3x3uCC2Hr8A0ebk0+PjJ0X4El3A6VnKA==", + "engines": { + "node": ">=12" + } + }, "node_modules/@wry/equality": { "version": "0.1.9", "resolved": "https://registry.npmjs.org/@wry/equality/-/equality-0.1.9.tgz", @@ -19606,11 +19615,11 @@ "version": "3.0.0-alpha.4", "license": "MIT", "dependencies": { - "@apollographql/graphql-playground-html": "1.6.27", "@types/aws-lambda": "^8.10.76", + "@vendia/serverless-express": "^4.3.7", "apollo-server-core": "file:../apollo-server-core", - "apollo-server-env": "file:../apollo-server-env", - "apollo-server-types": "file:../apollo-server-types" + "apollo-server-express": "file:../apollo-server-express", + "express": "^4.17.1" }, "devDependencies": { "apollo-server-integration-testsuite": "file:../apollo-server-integration-testsuite" @@ -19622,29 +19631,6 @@ "graphql": "^15.3.0" } }, - "packages/apollo-server-lambda/node_modules/@apollographql/graphql-playground-html": { - "version": "1.6.27", - "resolved": "https://registry.npmjs.org/@apollographql/graphql-playground-html/-/graphql-playground-html-1.6.27.tgz", - "integrity": "sha512-tea2LweZvn6y6xFV11K0KC8ETjmm52mQrW+ezgB2O/aTQf8JGyFmMcRPFgUaQZeHbWdm8iisDC6EjOKsXu0nfw==", - "dependencies": { - "xss": "^1.0.8" - } - }, - "packages/apollo-server-lambda/node_modules/@apollographql/graphql-playground-html/node_modules/xss": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/xss/-/xss-1.0.8.tgz", - "integrity": "sha512-3MgPdaXV8rfQ/pNn16Eio6VXYPTkqwa0vc7GkiymmY/DqR1SE/7VPAAVZz1GJsJFrllMYO3RHfEaiUGjab6TNw==", - "dependencies": { - "commander": "^2.20.3", - "cssfilter": "0.0.10" - }, - "bin": { - "xss": "bin/xss" - }, - "engines": { - "node": ">= 0.10.0" - } - }, "packages/apollo-server-micro": { "version": "3.0.0-alpha.4", "license": "MIT", @@ -24243,6 +24229,11 @@ "integrity": "sha512-wBlsw+8n21e6eTd4yVv8YD/E3xq0O6nNnJIquutAsFGE7EyMKz7W6RNT6BRu1SmdgmlCZ9tb0X+j+D6HGr8pZw==", "dev": true }, + "@vendia/serverless-express": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/@vendia/serverless-express/-/serverless-express-4.3.7.tgz", + "integrity": "sha512-B1mHMSjo46c3i/7WURQrNb/BBOsJ0Gdby9whwCYa5Az4g807OenAYh3x3uCC2Hr8A0ebk0+PjJ0X4El3A6VnKA==" + }, "@wry/equality": { "version": "0.1.9", "resolved": "https://registry.npmjs.org/@wry/equality/-/equality-0.1.9.tgz", @@ -25170,33 +25161,12 @@ "apollo-server-lambda": { "version": "file:packages/apollo-server-lambda", "requires": { - "@apollographql/graphql-playground-html": "1.6.27", "@types/aws-lambda": "^8.10.76", + "@vendia/serverless-express": "^4.3.7", "apollo-server-core": "file:../apollo-server-core", - "apollo-server-env": "file:../apollo-server-env", + "apollo-server-express": "file:../apollo-server-express", "apollo-server-integration-testsuite": "file:../apollo-server-integration-testsuite", - "apollo-server-types": "file:../apollo-server-types" - }, - "dependencies": { - "@apollographql/graphql-playground-html": { - "version": "1.6.27", - "resolved": "https://registry.npmjs.org/@apollographql/graphql-playground-html/-/graphql-playground-html-1.6.27.tgz", - "integrity": "sha512-tea2LweZvn6y6xFV11K0KC8ETjmm52mQrW+ezgB2O/aTQf8JGyFmMcRPFgUaQZeHbWdm8iisDC6EjOKsXu0nfw==", - "requires": { - "xss": "^1.0.8" - }, - "dependencies": { - "xss": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/xss/-/xss-1.0.8.tgz", - "integrity": "sha512-3MgPdaXV8rfQ/pNn16Eio6VXYPTkqwa0vc7GkiymmY/DqR1SE/7VPAAVZz1GJsJFrllMYO3RHfEaiUGjab6TNw==", - "requires": { - "commander": "^2.20.3", - "cssfilter": "0.0.10" - } - } - } - } + "express": "^4.17.1" } }, "apollo-server-micro": { diff --git a/package.json b/package.json index 5a54bd4579c..783cec2abd2 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "@types/type-is": "1.6.3", "@types/uuid": "8.3.0", "@types/ws": "7.4.1", + "@vendia/serverless-express": "4.3.7", "apollo-link": "1.2.14", "apollo-link-http": "1.5.17", "apollo-link-persisted-queries": "0.2.2", diff --git a/packages/apollo-server-core/src/ApolloServer.ts b/packages/apollo-server-core/src/ApolloServer.ts index 7f39675422b..840c5faf024 100644 --- a/packages/apollo-server-core/src/ApolloServer.ts +++ b/packages/apollo-server-core/src/ApolloServer.ts @@ -305,8 +305,9 @@ export class ApolloServerBase { // server.start()` before calling it. So we kick off the start // asynchronously from the constructor, and failures are logged and cause // later requests to fail (in `ensureStarted`, called by - // `graphQLServerOptions`). There's no way to make "the whole server fail" - // separately from making individual requests fail, but that's not entirely + // `graphQLServerOptions` and sometimes earlier by serverless integrations + // where helpful). There's no way to make "the whole server fail" separately + // from making individual requests fail, but that's not entirely // unreasonable for a "serverless" model. if (this.serverlessFramework()) { this._start().catch((e) => this.logStartupError(e)); @@ -436,8 +437,10 @@ export class ApolloServerBase { // verify that) and so the only cases for non-serverless frameworks that this // should hit are 'started', 'stopping', and 'stopped'. For serverless // frameworks, this lets the server wait until fully started before serving - // operations. - private async ensureStarted(): Promise { + // operations. While it will be called by `graphQLServerOptions`, serverless + // integrations may want to also call it earlier in a request if that is + // helpful. + protected async ensureStarted(): Promise { while (true) { switch (this.state.phase) { case 'initialized with gateway': diff --git a/packages/apollo-server-lambda/README.md b/packages/apollo-server-lambda/README.md index b1bf5a2f898..2ef007e1a35 100644 --- a/packages/apollo-server-lambda/README.md +++ b/packages/apollo-server-lambda/README.md @@ -8,6 +8,8 @@ This is the AWS Lambda integration of GraphQL Server. Apollo Server is a communi npm install apollo-server-lambda graphql ``` +FIXME see what needs to be improved + ## 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/). diff --git a/packages/apollo-server-lambda/package.json b/packages/apollo-server-lambda/package.json index b1d2c91017d..152d6a25f65 100644 --- a/packages/apollo-server-lambda/package.json +++ b/packages/apollo-server-lambda/package.json @@ -26,11 +26,11 @@ "node": ">=12.0" }, "dependencies": { - "@apollographql/graphql-playground-html": "1.6.27", + "@vendia/serverless-express": "^4.3.7", "@types/aws-lambda": "^8.10.76", "apollo-server-core": "file:../apollo-server-core", - "apollo-server-env": "file:../apollo-server-env", - "apollo-server-types": "file:../apollo-server-types" + "apollo-server-express": "file:../apollo-server-express", + "express": "^4.17.1" }, "devDependencies": { "apollo-server-integration-testsuite": "file:../apollo-server-integration-testsuite" diff --git a/packages/apollo-server-lambda/src/ApolloServer.ts b/packages/apollo-server-lambda/src/ApolloServer.ts index b38a3defc7e..9036642bcf1 100644 --- a/packages/apollo-server-lambda/src/ApolloServer.ts +++ b/packages/apollo-server-lambda/src/ApolloServer.ts @@ -1,289 +1,46 @@ +import type { Handler } from 'aws-lambda'; import { - APIGatewayProxyEvent, - APIGatewayProxyEventV2, - APIGatewayProxyResult, - Context as LambdaContext, -} from 'aws-lambda'; -import { - ApolloServerBase, - GraphQLOptions, - runHttpQuery, - HttpQueryError, -} from 'apollo-server-core'; -import { - renderPlaygroundPage, - RenderPageOptions as PlaygroundRenderPageOptions, -} from '@apollographql/graphql-playground-html'; - -import { Headers } from 'apollo-server-env'; - -// We try to support payloadFormatEvent 1.0 and 2.0. See -// https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html -// for a bit of documentation as to what is in these objects. You can determine -// which one you have by checking `'path' in event` (V1 has path, V2 doesn't). -export type APIGatewayProxyEventV1OrV2 = - | APIGatewayProxyEvent - | APIGatewayProxyEventV2; - -function eventHttpMethod(event: APIGatewayProxyEventV1OrV2): string { - return 'httpMethod' in event - ? event.httpMethod - : event.requestContext.http.method; + ApolloServer as ApolloServerExpress, + GetMiddlewareOptions, +} from 'apollo-server-express'; +import express from 'express'; +import serverlessExpress from '@vendia/serverless-express'; + +export interface CreateHandlerOptions { + expressAppFromMiddleware?: ( + middleware: express.RequestHandler, + ) => express.Application; + expressGetMiddlewareOptions?: GetMiddlewareOptions; } -function eventPath(event: APIGatewayProxyEventV1OrV2): string { - // Note: it's unclear if the V2 version should use `event.rawPath` or - // `event.requestContext.http.path`; I can't find any documentation about the - // distinction between the two. I'm choosing rawPath because that's what - // @vendia/serverless-express does (though it also looks at a `requestPath` - // field that doesn't exist in the docs or typings). - return 'path' in event ? event.path : event.rawPath; +function defaultExpressAppFromMiddleware( + middleware: express.RequestHandler, +): express.Application { + const app = express(); + app.use(middleware); + return app; } -export interface CreateHandlerOptions< - EventT extends APIGatewayProxyEventV1OrV2 = APIGatewayProxyEventV1OrV2 -> { - cors?: { - origin?: boolean | string | string[]; - methods?: string | string[]; - allowedHeaders?: string | string[]; - exposedHeaders?: string | string[]; - credentials?: boolean; - maxAge?: number; - }; - onHealthCheck?: (req: EventT) => Promise; -} -export class ApolloServer< - EventT extends APIGatewayProxyEventV1OrV2 = APIGatewayProxyEventV1OrV2 -> extends ApolloServerBase { +export class ApolloServer extends ApolloServerExpress { protected serverlessFramework(): boolean { return true; } - // 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: EventT, - context: LambdaContext, - ): Promise { - return super.graphQLServerOptions({ event, context }); - } - - public createHandler( - { cors, onHealthCheck }: CreateHandlerOptions = { - cors: undefined, - onHealthCheck: undefined, - }, - ) { - const corsHeaders = new Headers(); - - if (cors) { - if (cors.methods) { - if (typeof cors.methods === 'string') { - corsHeaders.set('access-control-allow-methods', cors.methods); - } else if (Array.isArray(cors.methods)) { - corsHeaders.set( - 'access-control-allow-methods', - cors.methods.join(','), - ); - } - } - - if (cors.allowedHeaders) { - if (typeof cors.allowedHeaders === 'string') { - corsHeaders.set('access-control-allow-headers', cors.allowedHeaders); - } else if (Array.isArray(cors.allowedHeaders)) { - corsHeaders.set( - 'access-control-allow-headers', - cors.allowedHeaders.join(','), - ); - } - } - - if (cors.exposedHeaders) { - if (typeof cors.exposedHeaders === 'string') { - corsHeaders.set('access-control-expose-headers', cors.exposedHeaders); - } else if (Array.isArray(cors.exposedHeaders)) { - corsHeaders.set( - 'access-control-expose-headers', - cors.exposedHeaders.join(','), - ); - } - } - - if (cors.credentials) { - corsHeaders.set('access-control-allow-credentials', 'true'); - } - if (typeof cors.maxAge === 'number') { - corsHeaders.set('access-control-max-age', cors.maxAge.toString()); - } - } - - return async ( - event: EventT, - context: LambdaContext, - ): Promise => { - // https://github.com/DefinitelyTyped/DefinitelyTyped/pull/50224 changed - // the typing of event.headers to be effectively Record but there should be no actual undefineds as values. - const eventHeaders = new Headers(event.headers as Record); - - // Make a request-specific copy of the CORS headers, based on the server - // global CORS headers we've set above. - const requestCorsHeaders = new Headers(corsHeaders); - - if (cors && cors.origin) { - const requestOrigin = eventHeaders.get('origin'); - if (typeof cors.origin === 'string') { - requestCorsHeaders.set('access-control-allow-origin', cors.origin); - } else if ( - requestOrigin && - (typeof cors.origin === 'boolean' || - (Array.isArray(cors.origin) && - requestOrigin && - cors.origin.includes(requestOrigin))) - ) { - requestCorsHeaders.set('access-control-allow-origin', requestOrigin); - } - - const requestAccessControlRequestHeaders = eventHeaders.get( - 'access-control-request-headers', - ); - if (!cors.allowedHeaders && requestAccessControlRequestHeaders) { - requestCorsHeaders.set( - 'access-control-allow-headers', - requestAccessControlRequestHeaders, - ); - } - } - - // Convert the `Headers` into an object which can be spread into the - // various headers objects below. - // Note: while Object.fromEntries simplifies this code, it's only currently - // supported in Node 12 (we support >=6) - const requestCorsHeadersObject = Array.from(requestCorsHeaders).reduce< - Record - >((headersObject, [key, value]) => { - headersObject[key] = value; - return headersObject; - }, {}); - - if (eventHttpMethod(event) === 'OPTIONS') { - return { - body: '', - statusCode: 204, - headers: { - ...requestCorsHeadersObject, - }, - }; - } - - if (eventPath(event).endsWith('/.well-known/apollo/server-health')) { - if (onHealthCheck) { - try { - await onHealthCheck(event); - } catch (_) { - return { - body: JSON.stringify({ status: 'fail' }), - statusCode: 503, - headers: { - 'Content-Type': 'application/json', - ...requestCorsHeadersObject, - }, - }; - } - } - return { - body: JSON.stringify({ status: 'pass' }), - statusCode: 200, - headers: { - 'Content-Type': 'application/json', - ...requestCorsHeadersObject, - }, - }; - } - - if (this.playgroundOptions && eventHttpMethod(event) === 'GET') { - const acceptHeader = event.headers['Accept'] || event.headers['accept']; - if (acceptHeader && acceptHeader.includes('text/html')) { - const path = eventPath(event) || '/'; - - const playgroundRenderPageOptions: PlaygroundRenderPageOptions = { - endpoint: path, - ...this.playgroundOptions, - }; - - return { - body: renderPlaygroundPage(playgroundRenderPageOptions), - statusCode: 200, - headers: { - 'Content-Type': 'text/html', - ...requestCorsHeadersObject, - }, - }; - } - } - - let { body, isBase64Encoded } = event; - let query: Record | Record[]; - - if (body && isBase64Encoded) { - body = Buffer.from(body, 'base64').toString(); - } - - if (eventHttpMethod(event) === 'POST' && !body) { - return { - body: 'POST body missing.', - statusCode: 500, - }; - } - - if (body && eventHttpMethod(event) === 'POST') { - query = JSON.parse(body); - } else { - // XXX Note that if a parameter is included multiple times, this only - // includes the first version for payloadFormatVersion 1.0 but - // contains all of them joined with commas for payloadFormatVersion - // 2.0. - query = event.queryStringParameters || {}; - } - - try { - const { graphqlResponse, responseInit } = await runHttpQuery( - [event, context], - { - method: eventHttpMethod(event), - options: async () => { - return this.createGraphQLServerOptions(event, context); - }, - query, - request: { - url: eventPath(event), - method: eventHttpMethod(event), - headers: eventHeaders, - }, - }, + public createHandler( + options?: CreateHandlerOptions, + ): Handler { + let realHandler: Handler; + return async (...args) => { + await this.ensureStarted(); + if (!realHandler) { + const middleware = this.getMiddleware( + options?.expressGetMiddlewareOptions, ); - return { - body: graphqlResponse, - statusCode: 200, - headers: { - ...responseInit.headers, - ...requestCorsHeadersObject, - }, - }; - } catch (error) { - if (error.name !== 'HttpQueryError') throw error; - const httpQueryError = error as HttpQueryError; - return { - body: httpQueryError.message, - statusCode: httpQueryError.statusCode, - headers: { - ...httpQueryError.headers, - ...requestCorsHeadersObject, - }, - }; + const app = ( + options?.expressAppFromMiddleware ?? defaultExpressAppFromMiddleware + )(middleware); + realHandler = serverlessExpress({ app }); } + return (await realHandler(...args)) as TResult; }; } } diff --git a/packages/apollo-server-lambda/src/__tests__/ApolloServer.test.ts b/packages/apollo-server-lambda/src/__tests__/ApolloServer.test.ts index 64d8d85b2df..942544b5caa 100644 --- a/packages/apollo-server-lambda/src/__tests__/ApolloServer.test.ts +++ b/packages/apollo-server-lambda/src/__tests__/ApolloServer.test.ts @@ -1,10 +1,13 @@ +import http from 'http'; import request from 'supertest'; -import {createMockServer} from './mockServer'; -import { gql } from 'apollo-server-core'; +import { createMockServer } from './mockServer'; +import { Config, gql } from 'apollo-server-core'; +import { ApolloServer } from '../ApolloServer'; +import type { GetMiddlewareOptions } from 'apollo-server-express'; import { - ApolloServer, - CreateHandlerOptions -} from '../ApolloServer'; + createServerInfo, + testApolloServer, +} from 'apollo-server-integration-testsuite'; const typeDefs = gql` type Query { @@ -19,29 +22,54 @@ const resolvers = { }; describe('apollo-server-lambda', () => { + let server: ApolloServer; + let httpServer: http.Server; + testApolloServer( + async (config: Config) => { + server = new ApolloServer(config); + // Ignore suppressStartCall because serverless ApolloServers don't + // get `start`ed. + const lambdaHandler = server.createHandler(); + const httpHandler = createMockServer(lambdaHandler); + httpServer = new http.Server(httpHandler); + await new Promise((resolve) => { + httpServer.listen({ port: 0 }, () => resolve()); + }); + return createServerInfo(server, httpServer); + }, + async () => { + if (httpServer && httpServer.listening) { + await new Promise((resolve) => { + httpServer.close(() => resolve()); + }); + } + if (server) await server.stop(); + }, + ); + const createLambda = ( - options: Partial = {}, + expressGetMiddlewareOptions: Partial = {}, ) => { const server = new ApolloServer({ typeDefs, - resolvers + resolvers, }); - const handler = server.createHandler(options); + const handler = server.createHandler({ expressGetMiddlewareOptions }); return createMockServer(handler); - } + }; describe('healthchecks', () => { - it('creates a healthcheck endpoint', async () => { const app = createLambda(); - const req = request(app) - .get('/.well-known/apollo/server-health'); + const req = request(app).get('/.well-known/apollo/server-health'); return req.then((res: any) => { expect(res.status).toEqual(200); expect(res.body).toEqual({ status: 'pass' }); - expect(res.headers['content-type']).toEqual('application/json'); + expect(res.headers['content-type']).toEqual( + 'application/health+json; charset=utf-8', + ); }); }); @@ -49,18 +77,19 @@ describe('apollo-server-lambda', () => { const app = createLambda({ onHealthCheck: async () => { return new Promise((resolve) => { - return resolve("Success!"); + return resolve('Success!'); }); - } + }, }); - const req = request(app) - .get('/.well-known/apollo/server-health'); + const req = request(app).get('/.well-known/apollo/server-health'); return req.then((res: any) => { expect(res.status).toEqual(200); expect(res.body).toEqual({ status: 'pass' }); - expect(res.headers['content-type']).toEqual('application/json'); + expect(res.headers['content-type']).toEqual( + 'application/health+json; charset=utf-8', + ); }); }); @@ -68,20 +97,20 @@ describe('apollo-server-lambda', () => { const app = createLambda({ onHealthCheck: async () => { return new Promise(() => { - throw new Error("Failed to connect!"); + throw new Error('Failed to connect!'); }); - } + }, }); - const req = request(app) - .get('/.well-known/apollo/server-health'); + const req = request(app).get('/.well-known/apollo/server-health'); return req.then((res: any) => { expect(res.status).toEqual(503); expect(res.body).toEqual({ status: 'fail' }); - expect(res.headers['content-type']).toEqual('application/json'); + expect(res.headers['content-type']).toEqual( + 'application/health+json; charset=utf-8', + ); }); }); }); - }); diff --git a/packages/apollo-server-lambda/src/__tests__/mockServer.ts b/packages/apollo-server-lambda/src/__tests__/mockServer.ts index 9d71e935265..cb565eb0228 100644 --- a/packages/apollo-server-lambda/src/__tests__/mockServer.ts +++ b/packages/apollo-server-lambda/src/__tests__/mockServer.ts @@ -1,59 +1,88 @@ import url from 'url'; import { IncomingMessage, ServerResponse } from 'http'; -import { - APIGatewayProxyEvent, +import type { + APIGatewayProxyEventV2, + APIGatewayProxyStructuredResultV2, Context as LambdaContext, - APIGatewayProxyResult, + Handler, } from 'aws-lambda'; +// Returns a Node http handler that invokes a Lambda handler as if via +// APIGatewayProxy with payload version 2.0. export function createMockServer( - handler: ( - event: APIGatewayProxyEvent, - context: LambdaContext, - ) => Promise, + handler: Handler, ) { return (req: IncomingMessage, res: ServerResponse) => { - // return 404 if path is /bogus-route to pass the test, lambda doesn't have paths - if (req.url && req.url.includes('/bogus-route')) { - res.statusCode = 404; - return res.end(); - } - let body = ''; req.on('data', (chunk) => (body += chunk)); // this is an unawaited async function, but anything that causes it to // reject should cause a test to fail req.on('end', async () => { - 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, - } as APIGatewayProxyEvent; // cast because we don't bother with all the fields - + const event = eventFromRequest(req, body); const result = await handler( event, {} as LambdaContext, // we don't bother with all the fields - ); - res.statusCode = result.statusCode; - for (let key in result.headers) { - if (result.headers.hasOwnProperty(key)) { - if (typeof result.headers[key] === 'boolean') { - res.setHeader(key, result.headers[key].toString()); - } else { - // Without casting this to `any`, TS still believes `boolean` - // is possible. - res.setHeader(key, result.headers[key] as any); - } - } - } + () => { + throw Error("we don't use callback"); + }, + ) as APIGatewayProxyStructuredResultV2; + res.statusCode = result.statusCode!; + Object.entries(result.headers ?? {}).forEach(([key, value]) => { + res.setHeader(key, value.toString()); + }); res.write(result.body); res.end(); }); }; } + +// Create an APIGatewayProxy V2 event from a Node request. Note that +// `@vendia/serverless-express` supports a bunch of different kinds of events +// including gateway V1, but for now we're just testing with this one. Based on +// https://github.com/vendia/serverless-express/blob/mainline/jest-helpers/api-gateway-v2-event.js +function eventFromRequest( + req: IncomingMessage, + body: string, +): APIGatewayProxyEventV2 { + const urlObject = url.parse(req.url || '', false); + return { + version: '2.0', + routeKey: '$default', + rawQueryString: urlObject.search?.replace(/^\?/, '') ?? '', + headers: Object.fromEntries( + Object.entries(req.headers).map(([name, value]) => { + if (Array.isArray(value)) { + return [name, value.join(',')]; + } else { + return [name, value]; + } + }), + ), + // as of now, @vendia/serverless-express's v2 + // getRequestValuesFromApiGatewayEvent only looks at rawQueryString and + // not queryStringParameters; for the sake of tests this is good enough. + queryStringParameters: {}, + requestContext: { + accountId: '347971939225', + apiId: '6bwvllq3t2', + domainName: '6bwvllq3t2.execute-api.us-east-1.amazonaws.com', + domainPrefix: '6bwvllq3t2', + http: { + method: req.method!, + path: req.url!, + protocol: 'HTTP/1.1', + sourceIp: '203.123.103.37', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36', + }, + requestId: 'YuSJQjZfoAMESbg=', + routeKey: '$default', + stage: '$default', + time: '06/Jan/2021:10:55:03 +0000', + timeEpoch: 1609930503973, + }, + isBase64Encoded: false, + rawPath: urlObject.pathname!, + body, + }; +} diff --git a/packages/apollo-server-lambda/tsconfig.json b/packages/apollo-server-lambda/tsconfig.json index e34feba8eb6..3f81d738ded 100644 --- a/packages/apollo-server-lambda/tsconfig.json +++ b/packages/apollo-server-lambda/tsconfig.json @@ -8,6 +8,6 @@ "exclude": ["**/__tests__"], "references": [ { "path": "../apollo-server-core" }, - { "path": "../apollo-server-types" }, + { "path": "../apollo-server-express" }, ] }