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-integration-testsuite/src/ApolloServer.ts b/packages/apollo-server-integration-testsuite/src/ApolloServer.ts index 3ac0d1aecfb..f77c3eed1c6 100644 --- a/packages/apollo-server-integration-testsuite/src/ApolloServer.ts +++ b/packages/apollo-server-integration-testsuite/src/ApolloServer.ts @@ -182,6 +182,7 @@ export interface StopServerFunc { export function testApolloServer( createApolloServer: CreateServerFunc, stopServer: StopServerFunc, + options: { serverlessFramework?: boolean } = {}, ) { describe('ApolloServer', () => { afterEach(stopServer); @@ -387,31 +388,33 @@ export function testApolloServer( expect(executor).toHaveBeenCalled(); }); - it('rejected load promise is thrown by server.start', async () => { - const { gateway, triggers } = makeGatewayMock(); + if (!options.serverlessFramework) { + // You don't have to call start on serverless frameworks (or in + // `apollo-server` which does not currently use this test suite). + it('rejected load promise is thrown by server.start', async () => { + const { gateway, triggers } = makeGatewayMock(); - const loadError = new Error( - 'load error which should be be thrown by start', - ); - triggers.rejectLoad(loadError); + const loadError = new Error( + 'load error which should be be thrown by start', + ); + triggers.rejectLoad(loadError); - expect( - createApolloServer({ - gateway, - }), - ).rejects.toThrowError(loadError); - }); + await expect( + createApolloServer({ + gateway, + }), + ).rejects.toThrowError(loadError); + }); - it('not calling start causes a clear error', async () => { - // Note that this test suite is not used by `apollo-server` or - // serverless frameworks, so this is legit. - expect( - createApolloServer( - { typeDefs: 'type Query{x: ID}' }, - { suppressStartCall: true }, - ), - ).rejects.toThrow('You must `await server.start()`'); - }); + it('not calling start causes a clear error', async () => { + await expect( + createApolloServer( + { typeDefs: 'type Query{x: ID}' }, + { suppressStartCall: true }, + ), + ).rejects.toThrow('You must `await server.start()`'); + }); + } it('uses schema over resolvers + typeDefs', async () => { const typeDefs = gql` 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..52b132008b9 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,55 @@ 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(); + }, + { serverlessFramework: true }, + ); + 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 +78,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 +98,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" }, ] }