UNPKG

9.64 kBPlain TextView Raw
1import {
2 APIGatewayProxyCallback,
3 APIGatewayProxyEvent,
4 Context as LambdaContext,
5} from 'aws-lambda';
6import {
7 formatApolloErrors,
8 processFileUploads,
9 FileUploadOptions,
10 ApolloServerBase,
11 GraphQLOptions,
12} from 'apollo-server-core';
13import {
14 renderPlaygroundPage,
15 RenderPageOptions as PlaygroundRenderPageOptions,
16} from '@apollographql/graphql-playground-html';
17import {
18 ServerResponse,
19 IncomingHttpHeaders,
20 IncomingMessage,
21} from 'http';
22
23import { graphqlLambda } from './lambdaApollo';
24import { Headers } from 'apollo-server-env';
25import { Readable, Writable } from 'stream';
26
27export interface CreateHandlerOptions {
28 cors?: {
29 origin?: boolean | string | string[];
30 methods?: string | string[];
31 allowedHeaders?: string | string[];
32 exposedHeaders?: string | string[];
33 credentials?: boolean;
34 maxAge?: number;
35 };
36 uploadsConfig?: FileUploadOptions;
37 onHealthCheck?: (req: APIGatewayProxyEvent) => Promise<any>;
38}
39
40export class FileUploadRequest extends Readable {
41 headers!: IncomingHttpHeaders;
42}
43
44export class ApolloServer extends ApolloServerBase {
45 protected serverlessFramework(): boolean {
46 return true;
47 }
48
49 // Uploads are supported in this integration
50 protected supportsUploads(): boolean {
51 return true;
52 }
53
54 // This translates the arguments from the middleware into graphQL options It
55 // provides typings for the integration specific behavior, ideally this would
56 // be propagated with a generic to the super class
57 createGraphQLServerOptions(
58 event: APIGatewayProxyEvent,
59 context: LambdaContext,
60 ): Promise<GraphQLOptions> {
61 return super.graphQLServerOptions({ event, context });
62 }
63
64 public createHandler({ cors, onHealthCheck }: CreateHandlerOptions = { cors: undefined, onHealthCheck: undefined }) {
65 // We will kick off the `willStart` event once for the server, and then
66 // await it before processing any requests by incorporating its `await` into
67 // the GraphQLServerOptions function which is called before each request.
68 const promiseWillStart = this.willStart();
69
70 const corsHeaders = new Headers();
71
72 if (cors) {
73 if (cors.methods) {
74 if (typeof cors.methods === 'string') {
75 corsHeaders.set('access-control-allow-methods', cors.methods);
76 } else if (Array.isArray(cors.methods)) {
77 corsHeaders.set(
78 'access-control-allow-methods',
79 cors.methods.join(','),
80 );
81 }
82 }
83
84 if (cors.allowedHeaders) {
85 if (typeof cors.allowedHeaders === 'string') {
86 corsHeaders.set('access-control-allow-headers', cors.allowedHeaders);
87 } else if (Array.isArray(cors.allowedHeaders)) {
88 corsHeaders.set(
89 'access-control-allow-headers',
90 cors.allowedHeaders.join(','),
91 );
92 }
93 }
94
95 if (cors.exposedHeaders) {
96 if (typeof cors.exposedHeaders === 'string') {
97 corsHeaders.set('access-control-expose-headers', cors.exposedHeaders);
98 } else if (Array.isArray(cors.exposedHeaders)) {
99 corsHeaders.set(
100 'access-control-expose-headers',
101 cors.exposedHeaders.join(','),
102 );
103 }
104 }
105
106 if (cors.credentials) {
107 corsHeaders.set('access-control-allow-credentials', 'true');
108 }
109 if (typeof cors.maxAge === 'number') {
110 corsHeaders.set('access-control-max-age', cors.maxAge.toString());
111 }
112 }
113
114 return (
115 event: APIGatewayProxyEvent,
116 context: LambdaContext,
117 callback: APIGatewayProxyCallback,
118 ) => {
119 // We re-load the headers into a Fetch API-compatible `Headers`
120 // interface within `graphqlLambda`, but we still need to respect the
121 // case-insensitivity within this logic here, so we'll need to do it
122 // twice since it's not accessible to us otherwise, right now.
123 const eventHeaders = new Headers(event.headers);
124
125 // Make a request-specific copy of the CORS headers, based on the server
126 // global CORS headers we've set above.
127 const requestCorsHeaders = new Headers(corsHeaders);
128
129 if (cors && cors.origin) {
130 const requestOrigin = eventHeaders.get('origin');
131 if (typeof cors.origin === 'string') {
132 requestCorsHeaders.set('access-control-allow-origin', cors.origin);
133 } else if (
134 requestOrigin &&
135 (typeof cors.origin === 'boolean' ||
136 (Array.isArray(cors.origin) &&
137 requestOrigin &&
138 cors.origin.includes(requestOrigin)))
139 ) {
140 requestCorsHeaders.set('access-control-allow-origin', requestOrigin);
141 }
142
143 const requestAccessControlRequestHeaders = eventHeaders.get(
144 'access-control-request-headers',
145 );
146 if (!cors.allowedHeaders && requestAccessControlRequestHeaders) {
147 requestCorsHeaders.set(
148 'access-control-allow-headers',
149 requestAccessControlRequestHeaders,
150 );
151 }
152 }
153
154 // Convert the `Headers` into an object which can be spread into the
155 // various headers objects below.
156 // Note: while Object.fromEntries simplifies this code, it's only currently
157 // supported in Node 12 (we support >=6)
158 const requestCorsHeadersObject = Array.from(requestCorsHeaders).reduce<
159 Record<string, string>
160 >((headersObject, [key, value]) => {
161 headersObject[key] = value;
162 return headersObject;
163 }, {});
164
165 if (event.httpMethod === 'OPTIONS') {
166 context.callbackWaitsForEmptyEventLoop = false;
167 return callback(null, {
168 body: '',
169 statusCode: 204,
170 headers: {
171 ...requestCorsHeadersObject,
172 },
173 });
174 }
175
176 if (event.path === '/.well-known/apollo/server-health') {
177 const successfulResponse = {
178 body: JSON.stringify({ status: 'pass' }),
179 statusCode: 200,
180 headers: {
181 'Content-Type': 'application/json',
182 ...requestCorsHeadersObject,
183 },
184 };
185 if (onHealthCheck) {
186 onHealthCheck(event)
187 .then(() => {
188 return callback(null, successfulResponse);
189 })
190 .catch(() => {
191 return callback(null, {
192 body: JSON.stringify({ status: 'fail' }),
193 statusCode: 503,
194 headers: {
195 'Content-Type': 'application/json',
196 ...requestCorsHeadersObject,
197 },
198 });
199 });
200 } else {
201 return callback(null, successfulResponse);
202 }
203 }
204
205 if (this.playgroundOptions && event.httpMethod === 'GET') {
206 const acceptHeader = event.headers['Accept'] || event.headers['accept'];
207 if (acceptHeader && acceptHeader.includes('text/html')) {
208 const path =
209 event.path ||
210 (event.requestContext && event.requestContext.path) ||
211 '/';
212
213 const playgroundRenderPageOptions: PlaygroundRenderPageOptions = {
214 endpoint: path,
215 ...this.playgroundOptions,
216 };
217
218 return callback(null, {
219 body: renderPlaygroundPage(playgroundRenderPageOptions),
220 statusCode: 200,
221 headers: {
222 'Content-Type': 'text/html',
223 ...requestCorsHeadersObject,
224 },
225 });
226 }
227 }
228
229 const response = new Writable() as ServerResponse;
230 const callbackFilter: APIGatewayProxyCallback = (error, result) => {
231 response.end();
232 callback(
233 error,
234 result && {
235 ...result,
236 headers: {
237 ...result.headers,
238 ...requestCorsHeadersObject,
239 },
240 },
241 );
242 };
243
244 const fileUploadHandler = (next: Function) => {
245 const contentType =
246 event.headers["content-type"] || event.headers["Content-Type"];
247 if (contentType && contentType.startsWith("multipart/form-data")
248 && typeof processFileUploads === "function") {
249 const request = new FileUploadRequest() as IncomingMessage;
250 request.push(
251 Buffer.from(
252 <any>event.body,
253 event.isBase64Encoded ? "base64" : "ascii"
254 )
255 );
256 request.push(null);
257 request.headers = event.headers;
258 processFileUploads(request, response, this.uploadsConfig || {})
259 .then(body => {
260 event.body = body as any;
261 return next();
262 })
263 .catch(error => {
264 throw formatApolloErrors([error], {
265 formatter: this.requestOptions.formatError,
266 debug: this.requestOptions.debug,
267 });
268 });
269 } else {
270 return next();
271 }
272 };
273
274 fileUploadHandler(() => graphqlLambda(async () => {
275 // In a world where this `createHandler` was async, we might avoid this
276 // but since we don't want to introduce a breaking change to this API
277 // (by switching it to `async`), we'll leverage the
278 // `GraphQLServerOptions`, which are dynamically built on each request,
279 // to `await` the `promiseWillStart` which we kicked off at the top of
280 // this method to ensure that it runs to completion (which is part of
281 // its contract) prior to processing the request.
282 await promiseWillStart;
283 return this.createGraphQLServerOptions(event, context);
284 })(event, context, callbackFilter));
285 };
286 }
287}