diff --git a/.changeset/chilled-teachers-bathe.md b/.changeset/chilled-teachers-bathe.md new file mode 100644 index 00000000..91aa3ede --- /dev/null +++ b/.changeset/chilled-teachers-bathe.md @@ -0,0 +1,56 @@ +--- +'@as-integrations/aws-lambda': major +--- + +## Why Change? + +In the interest of supporting more event types and allowing user-extensibility, the event parsing has been rearchitected. The goal with v2.0 is to allow customizability at each step in the event pipeline, leading to a higher level of Lambda event coverage (including 100% custom event requests). + +## What changed? + +The second parameter introduces a handler that controls parsing and output generation based on the event type you are consuming. We support 3 event types out-of-the-box: APIGatewayProxyV1/V2 and ALB. Additionally, there is a function for creating your own event parsers in case the pre-defined ones are not sufficient. + +This update also introduces middleware, a great way to modify the request on the way in or update the result on the way out. + +```typescript +startServerAndCreateLambdaHandler( + server, + handlers.createAPIGatewayProxyEventV2RequestHandler(), + { + middleware: [ + async (event) => { + // event updates here + return async (result) => { + // result updates here + }; + }, + ], + }, +); +``` + +## Upgrade Path + +The upgrade from v1.x to v2.0.0 is quite simple, just update your `startServerAndCreateLambdaHandler` with the new request handler parameter. Example: + +```typescript +import { + startServerAndCreateLambdaHandler, + handlers, +} from '@as-integrations/aws-lambda'; + +export default startServerAndCreateLambdaHandler( + server, + handlers.createAPIGatewayProxyEventV2RequestHandler(), +); +``` + +The 3 event handlers provided by the package are: + +- `createAPIGatewayProxyEventV2RequestHandler()` +- `createALBEventRequestHandler()` +- `createAPIGatewayProxyEventRequestHandler()` + +Each of these have an optional type parameter which you can use to extend the base event. This is useful if you are using Lambda functions with custom authorizers and need additional context in your events. + +Creating your own event parsers is now possible with `handlers.createRequestHandler()`. Creation of custom handlers is documented in the README. diff --git a/.prettierignore b/.prettierignore index 77564a5a..f6620dc0 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,7 +1,6 @@ *.json *.json5 *.yml -*.md .volta diff --git a/.prettierrc b/.prettierrc index 6e778b4f..10d40860 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,4 +1,5 @@ { "trailingComma": "all", - "singleQuote": true + "singleQuote": true, + "proseWrap": "preserve" } diff --git a/README.md b/README.md index a41c2ac9..453e142b 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # `@as-integrations/aws-lambda` -## Getting started: Lambda middleware +## Getting started -Apollo Server runs as a part of your Lambda handler, processing GraphQL requests. This package allows you to easily integrate Apollo Server with AWS Lambda. This integration is compatible with Lambda's API Gateway V1 (REST) and V2 (HTTP). It doesn't currently claim support for any other flavors of Lambda, though PRs are welcome! +Apollo Server runs as a part of your Lambda handler, processing GraphQL requests. This package allows you to easily integrate Apollo Server with AWS Lambda. This integration comes with built-in request handling functionality for ProxyV1, ProxyV2, and ALB events [with extensible typing](#event-extensions). You can also create your own integrations via a [Custom Handler](#custom-request-handlers) and submitted as a PR if others might find them valuable. First, install Apollo Server, graphql-js, and the Lambda handler package: @@ -13,8 +13,11 @@ npm install @apollo/server graphql @as-integrations/aws-lambda Then, write the following to `server.mjs`. (By using the .mjs extension, Node treats the file as a module, allowing us to use ESM `import` syntax.) ```js -import { ApolloServer } from "@apollo/server"; -import { startServerAndCreateLambdaHandler } from "@as-integrations/aws-lambda"; +import { ApolloServer } from '@apollo/server'; +import { + startServerAndCreateLambdaHandler, + handlers, +} from '@as-integrations/aws-lambda'; // The GraphQL schema const typeDefs = `#graphql @@ -26,7 +29,7 @@ const typeDefs = `#graphql // A map of functions which return data for the schema. const resolvers = { Query: { - hello: () => "world", + hello: () => 'world', }, }; @@ -36,5 +39,255 @@ const server = new ApolloServer({ resolvers, }); -export default startServerAndCreateLambdaHandler(server); -``` \ No newline at end of file +export default startServerAndCreateLambdaHandler( + server, + handlers.createAPIGatewayProxyEventV2RequestHandler(), +); +``` + +## Middleware + +For mutating the event before passing off to `@apollo/server` or mutating the result right before returning, middleware can be utilized. + +> Note, this middleware is strictly for event and result mutations and should not be used for any GraphQL modification. For that, [plugins](https://www.apollographql.com/docs/apollo-server/builtin-plugins) from `@apollo/server` would be much better suited. + +For example, if you need to set cookie headers with a V2 Proxy Result, see the following code example: + +```typescript +import { + startServerAndCreateLambdaHandler, + handlers, +} from '@as-integrations/aws-lambda'; +import type { APIGatewayProxyEventV2 } from 'aws-lambda'; +import { server } from './server'; + +async function regenerateCookie(event: APIGatewayProxyEventV2) { + // ... + return 'NEW_COOKIE'; +} + +export default startServerAndCreateLambdaHandler( + server, + handlers.createAPIGatewayProxyEventV2RequestHandler(), + { + middleware: [ + // Both event and result are intended to be mutable + async (event) => { + const cookie = await regenerateCookie(event); + return (result) => { + result.cookies.push(cookie); + }; + }, + ], + }, +); +``` + +If you want to define strictly typed middleware outside of the middleware array, the easiest way would be to extract your request handler into a variable and utilize the `typeof` keyword from Typescript. You could also manually use the `RequestHandler` type and fill in the event and result values yourself. + +```typescript +import { + startServerAndCreateLambdaHandler, + middleware, + handlers, +} from '@as-integrations/aws-lambda'; +import type { + APIGatewayProxyEventV2, + APIGatewayProxyStructuredResultV2, +} from 'aws-lambda'; +import { server } from './server'; + +const requestHandler = handlers.createAPIGatewayProxyEventV2RequestHandler(); + +// Utilizing typeof +const cookieMiddleware: middleware.MiddlewareFn = ( + event, +) => { + // ... + return (result) => { + // ... + }; +}; + +// Manual event filling +const otherMiddleware: middleware.MiddlewareFn< + RequestHandler +> = (event) => { + // ... + return (result) => { + // ... + }; +}; + +export default startServerAndCreateLambdaHandler(server, requestHandler, { + middleware: [ + // cookieMiddleware will always work here as its signature is + // tied to the `requestHandler` above + cookieMiddleware, + + // otherMiddleware will error if the event and result types do + // not sufficiently overlap, meaning it is your responsibility + // to keep the event types in sync, but the compiler may help + otherMiddleware, + ], +}); +``` + +## Event Extensions + +Each of the provided request handler factories has a generic for you to pass a manually extended event type if you have custom authorizers, or if the event type you need has a generic you must pass yourself. For example, here is a request that allows access to the lambda authorizer: + +```typescript +import { + startServerAndCreateLambdaHandler, + middleware, + handlers, +} from '@as-integrations/aws-lambda'; +import type { APIGatewayProxyEventV2WithLambdaAuthorizer } from 'aws-lambda'; +import { server } from './server'; + +export default startServerAndCreateLambdaHandler( + server, + handlers.createAPIGatewayProxyEventV2RequestHandler< + APIGatewayProxyEventV2WithLambdaAuthorizer<{ + myAuthorizerContext: string; + }> + >(), // This event will also be applied to the MiddlewareFn type +); +``` + +## Custom Request Handlers + +When invoking a lambda manually, or when using an event source we don't currently support (feel free to create a PR), a custom request handler might be necessary. A request handler is created using the `handlers.createHandler` function which takes two function arguments `eventParser` and `resultGenerator`, and two type arguments `EventType` and `ResultType`. + +### `eventParser` Argument + +There are two type signatures available for parsing events: + +#### Method A: Helper Object + +This helper object has 4 properties that will complete a full parsing chain, and abstracts some of the work required to coerce the incoming event into a `HTTPGraphQLRequest`. This is the recommended way of parsing events. + +##### `parseHttpMethod(event: EventType): string` + +Returns the HTTP verb from the request. + +Example return value: `GET` + +##### `parseQueryParams(event: EventType): string` + +Returns the raw query param string from the request. If the request comes in as a pre-mapped type, you may need to use `URLSearchParams` to re-stringify it. + +Example return value: `foo=1&bar=2` + +##### `parseHeaders(event: EventType): HeaderMap` + +Import from here: `import {HeaderMap} from "@apollo/server"`; + +Return an Apollo Server header map from the event. `HeaderMap` automatically normalizes casing for you. + +##### `parseBody(event: EventType, headers: HeaderMap): string` + +Return a plaintext body. Be sure to parse out any base64 or charset encoding. Headers are provided here for convenience as some body parsing might be dependent on `content-type` + +#### Method B: Parser Function + +If the helper object is too restrictive for your use-case, the other option is to create a function with `(event: EventType): HTTPGraphQLRequest` as the signature. Here you can do any parsing and it is your responsibility to create a valid `HTTPGraphQLRequest`. + +### `resultGenerator` Argument + +There are two possible result types, `success` and `error`, and they are to be defined as function properties on an object. Middleware will _always_ run, regardless if the generated result was from a success or error. The properties have the following signatures: + +##### `success(response: HTTPGraphQLResponse): ResultType` + +Given a complete response, generate the desired result type. + +##### `error(e: unknown): ResultType` + +Given an unknown type error, generate a result. If you want to create a basic parser that captures everything, utilize the instanceof type guard from Typescript. + +```typescript +error(e) { + if(e instanceof Error) { + return { + ... + } + } + // If error cannot be determined, panic and use lambda's default error handler + // Might be advantageous to add extra logging here so unexpected errors can be properly handled later + throw e; +} +``` + +### Custom Handler Example + +```typescript +import { + startServerAndCreateLambdaHandler, + handlers, +} from '@as-integrations/aws-lambda'; +import type { APIGatewayProxyEventV2 } from 'aws-lambda'; +import { HeaderMap } from '@apollo/server'; +import { server } from './server'; + +type CustomInvokeEvent = { + httpMethod: string; + queryParams: string; + headers: Record; + body: string; +}; + +type CustomInvokeResult = + | { + success: true; + body: string; + } + | { + success: false; + error: string; + }; + +const requestHandler = handlers.createRequestHandler< + CustomInvokeEvent, + CustomInvokeResult +>( + { + parseHttpMethod(event) { + return event.httpMethod; + }, + parseHeaders(event) { + const headerMap = new HeaderMap(); + for (const [key, value] of Object.entries(event.headers)) { + headerMap.set(key, value); + } + return headerMap; + }, + parseQueryParams(event) { + return event.queryParams; + }, + parseBody(event) { + return event.body; + }, + }, + { + success({ body }) { + return { + success: true, + body: body.string, + }; + }, + error(e) { + if (e instanceof Error) { + return { + success: false, + error: e.toString(), + }; + } + console.error('Unknown error type encountered!', e); + throw e; + }, + }, +); + +export default startServerAndCreateLambdaHandler(server, requestHandler); +``` diff --git a/cspell-dict.txt b/cspell-dict.txt index fa1571f4..508e7a83 100644 --- a/cspell-dict.txt +++ b/cspell-dict.txt @@ -10,3 +10,5 @@ testsuite unawaited vendia withrequired +typeof +instanceof \ No newline at end of file diff --git a/src/__tests__/defineLambdaTestSuite.ts b/src/__tests__/defineLambdaTestSuite.ts index 8a3710cc..cda432e7 100644 --- a/src/__tests__/defineLambdaTestSuite.ts +++ b/src/__tests__/defineLambdaTestSuite.ts @@ -3,14 +3,24 @@ import { CreateServerForIntegrationTestsOptions, defineIntegrationTestSuite, } from '@apollo/server-integration-testsuite'; -import type { Handler } from 'aws-lambda'; import { createServer, IncomingMessage, ServerResponse } from 'http'; -import { startServerAndCreateLambdaHandler } from '..'; +import { + LambdaHandler, + startServerAndCreateLambdaHandler, + middleware, + handlers, +} from '..'; import { urlForHttpServer } from './mockServer'; -export function defineLambdaTestSuite( +export function defineLambdaTestSuite< + RH extends handlers.RequestHandler, +>( + options: { + requestHandler: RH; + middleware?: Array>; + }, mockServerFactory: ( - handler: Handler, + handler: LambdaHandler, shouldBase64Encode: boolean, ) => (req: IncomingMessage, res: ServerResponse) => void, ) { @@ -29,15 +39,16 @@ export function defineLambdaTestSuite( const handler = startServerAndCreateLambdaHandler( server, - testOptions, + options.requestHandler, + { + ...testOptions, + middleware: options.middleware, + }, ); httpServer.addListener( 'request', - mockServerFactory( - handler as Handler, - shouldBase64Encode, - ), + mockServerFactory(handler, shouldBase64Encode), ); await new Promise((resolve) => { diff --git a/src/__tests__/integrationALB.test.ts b/src/__tests__/integrationALB.test.ts index b7ef2a73..9a43a520 100644 --- a/src/__tests__/integrationALB.test.ts +++ b/src/__tests__/integrationALB.test.ts @@ -1,6 +1,10 @@ import { createMockALBServer } from './mockALBServer'; import { defineLambdaTestSuite } from './defineLambdaTestSuite'; +import { handlers } from '..'; describe('lambdaHandlerALB', () => { - defineLambdaTestSuite(createMockALBServer); + defineLambdaTestSuite( + { requestHandler: handlers.createALBEventRequestHandler() }, + createMockALBServer, + ); }); diff --git a/src/__tests__/integrationV1.test.ts b/src/__tests__/integrationV1.test.ts index 9e2d5e04..0d6f2bf0 100644 --- a/src/__tests__/integrationV1.test.ts +++ b/src/__tests__/integrationV1.test.ts @@ -1,6 +1,10 @@ +import { handlers } from '..'; import { defineLambdaTestSuite } from './defineLambdaTestSuite'; import { createMockV1Server } from './mockAPIGatewayV1Server'; describe('lambdaHandlerV1', () => { - defineLambdaTestSuite(createMockV1Server); + defineLambdaTestSuite( + { requestHandler: handlers.createAPIGatewayProxyEventRequestHandler() }, + createMockV1Server, + ); }); diff --git a/src/__tests__/integrationV2.test.ts b/src/__tests__/integrationV2.test.ts index f83a3052..3b0a3941 100644 --- a/src/__tests__/integrationV2.test.ts +++ b/src/__tests__/integrationV2.test.ts @@ -1,6 +1,10 @@ +import { handlers } from '..'; import { defineLambdaTestSuite } from './defineLambdaTestSuite'; import { createMockV2Server } from './mockAPIGatewayV2Server'; describe('lambdaHandlerV2', () => { - defineLambdaTestSuite(createMockV2Server); + defineLambdaTestSuite( + { requestHandler: handlers.createAPIGatewayProxyEventV2RequestHandler() }, + createMockV2Server, + ); }); diff --git a/src/__tests__/middleware.test.ts b/src/__tests__/middleware.test.ts new file mode 100644 index 00000000..009a58c8 --- /dev/null +++ b/src/__tests__/middleware.test.ts @@ -0,0 +1,85 @@ +import { ApolloServer } from '@apollo/server'; +import type { APIGatewayProxyEventV2 } from 'aws-lambda'; +import { handlers, startServerAndCreateLambdaHandler } from '..'; + +const event: APIGatewayProxyEventV2 = { + version: '2', + headers: { + 'content-type': 'application/json', + }, + isBase64Encoded: false, + rawQueryString: '', + requestContext: { + http: { + method: 'POST', + }, + // Other requestContext properties omitted for brevity + } as any, + rawPath: '/', + routeKey: '/', + body: '{"operationName": null, "variables": null, "query": "{ hello }"}', +}; + +const typeDefs = `#graphql + type Query { + hello: String + } +`; + +const resolvers = { + Query: { + hello: () => 'world', + }, +}; + +const server = new ApolloServer({ + typeDefs, + resolvers, +}); + +describe('Request mutation', () => { + it('updates incoming event headers', async () => { + const headerAdditions = { + 'x-injected-header': 'foo', + }; + const lambdaHandler = startServerAndCreateLambdaHandler( + server, + handlers.createAPIGatewayProxyEventV2RequestHandler(), + { + middleware: [ + async (event) => { + Object.assign(event.headers, headerAdditions); + }, + ], + }, + ); + await lambdaHandler(event, {} as any, () => {}); + for (const [key, value] of Object.entries(headerAdditions)) { + expect(event.headers[key]).toBe(value); + } + }); +}); + +describe('Response mutation', () => { + it('adds cookie values to emitted result', async () => { + const cookieValue = 'foo=bar'; + const lambdaHandler = startServerAndCreateLambdaHandler( + server, + handlers.createAPIGatewayProxyEventV2RequestHandler(), + { + middleware: [ + async () => { + return async (result) => { + if (!result.cookies) { + result.cookies = []; + } + result.cookies.push(cookieValue); + }; + }, + ], + }, + ); + const result = await lambdaHandler(event, {} as any, () => {})!; + expect(result.cookies).toContain(cookieValue); + }); +}); diff --git a/src/__tests__/mockAPIGatewayV1Server.ts b/src/__tests__/mockAPIGatewayV1Server.ts index 33621d0a..eb754ac4 100644 --- a/src/__tests__/mockAPIGatewayV1Server.ts +++ b/src/__tests__/mockAPIGatewayV1Server.ts @@ -29,8 +29,6 @@ function v1EventFromRequest(shouldBase64Encode: boolean) { // simplify the V1 event down to what our integration actually cares about const event: Partial = { - // @ts-expect-error (version actually can exist on v1 events, this seems to be a typing error) - version: '1.0', httpMethod: req.method!, headers: Object.fromEntries( Object.entries(req.headers).map(([name, value]) => { diff --git a/src/__tests__/mockServer.ts b/src/__tests__/mockServer.ts index fac764d7..64672c8c 100644 --- a/src/__tests__/mockServer.ts +++ b/src/__tests__/mockServer.ts @@ -1,30 +1,16 @@ import type { IncomingMessage, Server, ServerResponse } from 'http'; -import type { - ALBResult, - APIGatewayProxyEvent, - APIGatewayProxyEventV2, - APIGatewayProxyResult, - APIGatewayProxyStructuredResultV2, - Context as LambdaContext, - Handler, -} from 'aws-lambda'; +import type { Context as LambdaContext, Handler } from 'aws-lambda'; import { format } from 'url'; import type { AddressInfo } from 'net'; -import type { IncomingEvent } from '..'; - -type LambdaHandler = Handler< - T, - T extends APIGatewayProxyEvent - ? APIGatewayProxyResult - : T extends APIGatewayProxyEventV2 - ? APIGatewayProxyStructuredResultV2 - : ALBResult ->; +import type { RequestHandler } from '../request-handlers/_create'; // Returns a Node http handler that invokes a Lambda handler (v1 / v2) -export function createMockServer( - handler: LambdaHandler, - eventFromRequest: (req: IncomingMessage, body: string) => T, +export function createMockServer>( + handler: Handler, + eventFromRequest: ( + req: IncomingMessage, + body: string, + ) => RH extends RequestHandler ? EventType : never, ) { return (req: IncomingMessage, res: ServerResponse) => { let body = ''; @@ -42,7 +28,7 @@ export function createMockServer( )!; res.statusCode = result.statusCode!; Object.entries(result.headers ?? {}).forEach(([key, value]) => { - res.setHeader(key, value.toString()); + res.setHeader(key, String(value)); }); res.write(result.body); res.end(); diff --git a/src/index.ts b/src/index.ts index 44625f3b..6cdcd129 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,179 +1,3 @@ -import type { - ApolloServer, - BaseContext, - ContextFunction, - HTTPGraphQLRequest, -} from '@apollo/server'; -import { HeaderMap } from '@apollo/server'; -import type { WithRequired } from '@apollo/utils.withrequired'; -import type { - ALBEvent, - ALBResult, - APIGatewayProxyEvent, - APIGatewayProxyEventV2, - APIGatewayProxyResult, - APIGatewayProxyStructuredResultV2, - Context, - Handler, -} from 'aws-lambda'; - -export type IncomingEvent = - | APIGatewayProxyEvent - | APIGatewayProxyEventV2 - | ALBEvent; - -/** - * @deprecated Use {IncomingEvent} instead - */ -export type GatewayEvent = IncomingEvent; - -export interface LambdaContextFunctionArgument { - event: IncomingEvent; - context: Context; -} - -export interface LambdaHandlerOptions { - context?: ContextFunction<[LambdaContextFunctionArgument], TContext>; -} - -export type HandlerResult = - | APIGatewayProxyStructuredResultV2 - | APIGatewayProxyResult - | ALBResult; - -type LambdaHandler = Handler; - -export function startServerAndCreateLambdaHandler( - server: ApolloServer, - options?: LambdaHandlerOptions, -): LambdaHandler; -export function startServerAndCreateLambdaHandler( - server: ApolloServer, - options: WithRequired, 'context'>, -): LambdaHandler; -export function startServerAndCreateLambdaHandler( - server: ApolloServer, - options?: LambdaHandlerOptions, -): LambdaHandler { - server.startInBackgroundHandlingStartupErrorsByLoggingAndFailingAllRequests(); - - // This `any` is safe because the overload above shows that context can - // only be left out if you're using BaseContext as your context, and {} is a - // valid BaseContext. - const defaultContext: ContextFunction< - [LambdaContextFunctionArgument], - any - > = async () => ({}); - - const contextFunction: ContextFunction< - [LambdaContextFunctionArgument], - TContext - > = options?.context ?? defaultContext; - - return async function (event, context) { - try { - const normalizedEvent = normalizeIncomingEvent(event); - - const { body, headers, status } = await server.executeHTTPGraphQLRequest({ - httpGraphQLRequest: normalizedEvent, - context: () => contextFunction({ event, context }), - }); - - if (body.kind === 'chunked') { - throw Error('Incremental delivery not implemented'); - } - - return { - statusCode: status || 200, - headers: { - ...Object.fromEntries(headers), - 'content-length': Buffer.byteLength(body.string).toString(), - }, - body: body.string, - }; - } catch (e) { - return { - statusCode: 400, - body: (e as Error).message, - }; - } - }; -} - -function normalizeIncomingEvent(event: IncomingEvent): HTTPGraphQLRequest { - let httpMethod: string; - if ('httpMethod' in event) { - httpMethod = event.httpMethod; - } else { - httpMethod = event.requestContext.http.method; - } - const headers = normalizeHeaders(event.headers); - let search: string; - if ('rawQueryString' in event) { - search = event.rawQueryString; - } else if ('queryStringParameters' in event) { - search = normalizeQueryStringParams( - event.queryStringParameters, - event.multiValueQueryStringParameters, - ).toString(); - } else { - throw new Error('Search params not parsable from event'); - } - - const body = event.body ?? ''; - - return { - method: httpMethod, - headers, - search, - body: parseBody(body, headers.get('content-type'), event.isBase64Encoded), - }; -} - -function parseBody( - body: string | null | undefined, - contentType: string | undefined, - isBase64: boolean, -): object | string { - if (body) { - const parsedBody = isBase64 - ? Buffer.from(body, 'base64').toString('utf8') - : body; - if (contentType?.startsWith('application/json')) { - return JSON.parse(parsedBody); - } - if (contentType?.startsWith('text/plain')) { - return parsedBody; - } - } - return ''; -} - -function normalizeHeaders(headers: IncomingEvent['headers']): HeaderMap { - const headerMap = new HeaderMap(); - for (const [key, value] of Object.entries(headers ?? {})) { - headerMap.set(key, value ?? ''); - } - return headerMap; -} - -function normalizeQueryStringParams( - queryStringParams: Record | null | undefined, - multiValueQueryStringParameters: - | Record - | null - | undefined, -): URLSearchParams { - const params = new URLSearchParams(); - for (const [key, value] of Object.entries(queryStringParams ?? {})) { - params.append(key, value ?? ''); - } - for (const [key, value] of Object.entries( - multiValueQueryStringParameters ?? {}, - )) { - for (const v of value ?? []) { - params.append(key, v); - } - } - return params; -} +export * from './lambdaHandler'; +export * as handlers from './request-handlers/_index'; +export * as middleware from './middleware'; diff --git a/src/lambdaHandler.ts b/src/lambdaHandler.ts new file mode 100644 index 00000000..49accbf4 --- /dev/null +++ b/src/lambdaHandler.ts @@ -0,0 +1,108 @@ +import type { + ApolloServer, + BaseContext, + ContextFunction, +} from '@apollo/server'; +import type { WithRequired } from '@apollo/utils.withrequired'; +import type { Context, Handler } from 'aws-lambda'; +import type { LambdaResponse, MiddlewareFn } from './middleware'; +import type { + RequestHandler, + RequestHandlerEvent, + RequestHandlerResult, +} from './request-handlers/_create'; + +export interface LambdaContextFunctionArgument< + RH extends RequestHandler, +> { + event: RH extends RequestHandler ? EventType : never; + context: Context; +} + +export interface LambdaHandlerOptions< + RH extends RequestHandler, + TContext extends BaseContext, +> { + middleware?: Array>; + context?: ContextFunction<[LambdaContextFunctionArgument], TContext>; +} + +export type LambdaHandler> = Handler< + RequestHandlerEvent, + RequestHandlerResult +>; + +export function startServerAndCreateLambdaHandler< + RH extends RequestHandler, +>( + server: ApolloServer, + handler: RH, + options?: LambdaHandlerOptions, +): LambdaHandler; +export function startServerAndCreateLambdaHandler< + RH extends RequestHandler, + TContext extends BaseContext, +>( + server: ApolloServer, + handler: RH, + options: WithRequired, 'context'>, +): LambdaHandler; +export function startServerAndCreateLambdaHandler< + RH extends RequestHandler, + TContext extends BaseContext, +>( + server: ApolloServer, + handler: RH, + options?: LambdaHandlerOptions, +): LambdaHandler { + server.startInBackgroundHandlingStartupErrorsByLoggingAndFailingAllRequests(); + + // This `any` is safe because the overload above shows that context can + // only be left out if you're using BaseContext as your context, and {} is a + // valid BaseContext. + const defaultContext: ContextFunction< + [LambdaContextFunctionArgument], + any + > = async () => ({}); + + const contextFunction: ContextFunction< + [LambdaContextFunctionArgument], + TContext + > = options?.context ?? defaultContext; + + return async function (event, context) { + const resultMiddlewareFns: Array>> = + []; + try { + for (const middlewareFn of options?.middleware ?? []) { + const resultCallback = await middlewareFn(event); + if (resultCallback) { + resultMiddlewareFns.push(resultCallback); + } + } + + const httpGraphQLRequest = handler.fromEvent(event); + + const response = await server.executeHTTPGraphQLRequest({ + httpGraphQLRequest, + context: () => contextFunction({ event, context }), + }); + + const result = handler.toSuccessResult(response); + + for (const resultMiddlewareFn of resultMiddlewareFns) { + await resultMiddlewareFn(result); + } + + return result; + } catch (e) { + const result = handler.toErrorResult(e); + + for (const resultMiddlewareFn of resultMiddlewareFns) { + await resultMiddlewareFn(result); + } + + return result; + } + }; +} diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 00000000..bd739624 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,12 @@ +import type { RequestHandler } from './request-handlers/_create'; + +export type LambdaResponse = (result: ResultType) => Promise; + +export type LambdaRequest = ( + event: EventType, +) => Promise | void>; + +export type MiddlewareFn> = + RH extends RequestHandler + ? LambdaRequest + : never; diff --git a/src/request-handlers/ALBEventRequestHandler.ts b/src/request-handlers/ALBEventRequestHandler.ts new file mode 100644 index 00000000..95039eb4 --- /dev/null +++ b/src/request-handlers/ALBEventRequestHandler.ts @@ -0,0 +1,75 @@ +import { HeaderMap } from '@apollo/server'; +import type { ALBEvent, ALBResult } from 'aws-lambda'; +import { createRequestHandler } from './_create'; + +export const createALBEventRequestHandler = < + Event extends ALBEvent = ALBEvent, +>() => { + return createRequestHandler( + { + parseHttpMethod(event) { + return event.httpMethod; + }, + parseHeaders(event) { + const headerMap = new HeaderMap(); + for (const [key, value] of Object.entries(event.headers ?? {})) { + headerMap.set(key, value ?? ''); + } + return headerMap; + }, + parseBody(event, headers) { + if (event.body) { + const contentType = headers.get('content-type'); + const parsedBody = event.isBase64Encoded + ? Buffer.from(event.body, 'base64').toString('utf8') + : event.body; + if (contentType?.startsWith('application/json')) { + return JSON.parse(parsedBody); + } + if (contentType?.startsWith('text/plain')) { + return parsedBody; + } + } + return ''; + }, + parseQueryParams(event) { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries( + event.queryStringParameters ?? {}, + )) { + params.append(key, value ?? ''); + } + for (const [key, value] of Object.entries( + event.multiValueQueryStringParameters ?? {}, + )) { + for (const v of value ?? []) { + params.append(key, v); + } + } + return params.toString(); + }, + }, + { + success({ body, headers, status }) { + if (body.kind !== 'complete') { + throw new Error('Only complete body type supported'); + } + + return { + statusCode: status ?? 200, + headers: { + ...Object.fromEntries(headers), + 'content-length': Buffer.byteLength(body.string).toString(), + }, + body: body.string, + }; + }, + error(error) { + return { + statusCode: 400, + body: (error as Error).message, + }; + }, + }, + ); +}; diff --git a/src/request-handlers/APIGatewayProxyEventRequestHandler.ts b/src/request-handlers/APIGatewayProxyEventRequestHandler.ts new file mode 100644 index 00000000..2eb5b817 --- /dev/null +++ b/src/request-handlers/APIGatewayProxyEventRequestHandler.ts @@ -0,0 +1,75 @@ +import { HeaderMap } from '@apollo/server'; +import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import { createRequestHandler } from './_create'; + +export const createAPIGatewayProxyEventRequestHandler = < + Event extends APIGatewayProxyEvent = APIGatewayProxyEvent, +>() => { + return createRequestHandler( + { + parseHttpMethod(event) { + return event.httpMethod; + }, + parseHeaders(event) { + const headerMap = new HeaderMap(); + for (const [key, value] of Object.entries(event.headers ?? {})) { + headerMap.set(key, value ?? ''); + } + return headerMap; + }, + parseBody(event, headers) { + if (event.body) { + const contentType = headers.get('content-type'); + const parsedBody = event.isBase64Encoded + ? Buffer.from(event.body, 'base64').toString('utf8') + : event.body; + if (contentType?.startsWith('application/json')) { + return JSON.parse(parsedBody); + } + if (contentType?.startsWith('text/plain')) { + return parsedBody; + } + } + return ''; + }, + parseQueryParams(event) { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries( + event.queryStringParameters ?? {}, + )) { + params.append(key, value ?? ''); + } + for (const [key, value] of Object.entries( + event.multiValueQueryStringParameters ?? {}, + )) { + for (const v of value ?? []) { + params.append(key, v); + } + } + return params.toString(); + }, + }, + { + success({ body, headers, status }) { + if (body.kind !== 'complete') { + throw new Error('Only complete body type supported'); + } + + return { + statusCode: status ?? 200, + headers: { + ...Object.fromEntries(headers), + 'content-length': Buffer.byteLength(body.string).toString(), + }, + body: body.string, + }; + }, + error(error) { + return { + statusCode: 400, + body: (error as Error).message, + }; + }, + }, + ); +}; diff --git a/src/request-handlers/APIGatewayProxyEventV2RequestHandler.ts b/src/request-handlers/APIGatewayProxyEventV2RequestHandler.ts new file mode 100644 index 00000000..89e0731a --- /dev/null +++ b/src/request-handlers/APIGatewayProxyEventV2RequestHandler.ts @@ -0,0 +1,65 @@ +import { HeaderMap } from '@apollo/server'; +import type { + APIGatewayProxyEventV2, + APIGatewayProxyStructuredResultV2, +} from 'aws-lambda'; +import { createRequestHandler } from './_create'; + +export const createAPIGatewayProxyEventV2RequestHandler = < + Event extends APIGatewayProxyEventV2 = APIGatewayProxyEventV2, +>() => { + return createRequestHandler( + { + parseHttpMethod(event) { + return event.requestContext.http.method; + }, + parseHeaders(event) { + const headerMap = new HeaderMap(); + for (const [key, value] of Object.entries(event.headers ?? {})) { + headerMap.set(key, value ?? ''); + } + return headerMap; + }, + parseBody(event, headers) { + if (event.body) { + const contentType = headers.get('content-type'); + const parsedBody = event.isBase64Encoded + ? Buffer.from(event.body, 'base64').toString('utf8') + : event.body; + if (contentType?.startsWith('application/json')) { + return JSON.parse(parsedBody); + } + if (contentType?.startsWith('text/plain')) { + return parsedBody; + } + } + return ''; + }, + parseQueryParams(event) { + return event.rawQueryString; + }, + }, + { + success({ body, headers, status }) { + if (body.kind !== 'complete') { + throw new Error('Only complete body type supported'); + } + + return { + statusCode: status ?? 200, + headers: { + ...Object.fromEntries(headers), + 'content-length': Buffer.byteLength(body.string).toString(), + }, + body: body.string, + }; + }, + error(error) { + return { + statusCode: 400, + body: (error as Error).message, + }; + }, + }, + ); +}; diff --git a/src/request-handlers/_create.ts b/src/request-handlers/_create.ts new file mode 100644 index 00000000..945b97e3 --- /dev/null +++ b/src/request-handlers/_create.ts @@ -0,0 +1,53 @@ +import type { + HeaderMap, + HTTPGraphQLRequest, + HTTPGraphQLResponse, +} from '@apollo/server'; + +export interface RequestHandler { + fromEvent: (event: EventType) => HTTPGraphQLRequest; + toSuccessResult: (response: HTTPGraphQLResponse) => ResultType; + toErrorResult: (error: unknown) => ResultType; +} + +export type RequestHandlerEvent> = + T extends RequestHandler ? EventType : never; + +export type RequestHandlerResult> = + T extends RequestHandler ? ResultType : never; + +export type EventParser = + | { + parseHttpMethod: (event: EventType) => string; + parseQueryParams: (event: EventType) => string; + parseHeaders: (event: EventType) => HeaderMap; + parseBody: (event: EventType, headers: HeaderMap) => string; + } + | ((event: EventType) => HTTPGraphQLRequest); + +export type ResultGenerator = { + success: (response: HTTPGraphQLResponse) => ResultType; + error: (error: unknown) => ResultType; +}; + +export function createRequestHandler( + eventParser: EventParser, + resultGenerator: ResultGenerator, +): RequestHandler { + return { + fromEvent(event) { + if (typeof eventParser === 'function') { + return eventParser(event); + } + const headers = eventParser.parseHeaders(event); + return { + method: eventParser.parseHttpMethod(event), + headers, + search: eventParser.parseQueryParams(event), + body: eventParser.parseBody(event, headers), + }; + }, + toSuccessResult: resultGenerator.success, + toErrorResult: resultGenerator.error, + }; +} diff --git a/src/request-handlers/_index.ts b/src/request-handlers/_index.ts new file mode 100644 index 00000000..9372f10f --- /dev/null +++ b/src/request-handlers/_index.ts @@ -0,0 +1,4 @@ +export { createALBEventRequestHandler } from './ALBEventRequestHandler'; +export { createAPIGatewayProxyEventRequestHandler } from './APIGatewayProxyEventRequestHandler'; +export { createAPIGatewayProxyEventV2RequestHandler } from './APIGatewayProxyEventV2RequestHandler'; +export * from './_create';