Skip to content

Commit 8342790

Browse files
Initial Lambda implementation (#3)
1 parent c90a863 commit 8342790

File tree

7 files changed

+960
-2222
lines changed

7 files changed

+960
-2222
lines changed

jest.config.base.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const config: Config.InitialOptions = {
55
preset: "ts-jest",
66
testEnvironment: "node",
77
roots: ["src"],
8+
testMatch: ["**/*.test.ts"],
89
globals: {
910
"ts-jest": {
1011
tsconfig: "<rootDir>/src/__tests__/tsconfig.json",

package-lock.json

Lines changed: 676 additions & 2211 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,12 @@
2828
"packages/*"
2929
],
3030
"devDependencies": {
31-
"@apollo/server-integration-testsuite": "^4.0.0-alpha.1",
31+
"@apollo/server-integration-testsuite": "4.0.0-alpha.1",
32+
"@apollo/utils.withrequired": "1.0.0",
3233
"@jest/types": "28.1.3",
33-
"@types/aws-lambda": "^8.10.101",
34+
"@types/aws-lambda": "8.10.101",
3435
"@types/jest": "28.1.5",
35-
"fastify": "^4.3.0",
36+
"fastify": "4.3.0",
3637
"graphql": "16.5.0",
3738
"jest": "28.1.3",
3839
"jest-junit": "14.0.0",

packages/lambda/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"node": ">=16"
2424
},
2525
"peerDependencies": {
26-
"@apollo/server": "^4.0.0-alpha.1"
26+
"@apollo/server": "^4.0.0-alpha.1",
27+
"graphql": "^16.5.0"
2728
}
2829
}
Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,65 @@
1+
import { ApolloServer, ApolloServerOptions, BaseContext } from "@apollo/server";
2+
import { ApolloServerPluginDrainHttpServer } from "@apollo/server/plugin/drainHttpServer";
3+
import {
4+
CreateServerForIntegrationTestsOptions,
5+
defineIntegrationTestSuite,
6+
} from "@apollo/server-integration-testsuite";
7+
import { createServer, Server } from "http";
8+
import type { AddressInfo } from "net";
9+
import { format } from "url";
110
import { lambdaHandler } from "..";
11+
import { createMockServer as createAPIGatewayMockServer } from "./mockAPIGatewayServer";
212

313
describe("lambdaHandler", () => {
4-
it("integration", () => {
5-
lambdaHandler();
6-
expect(true).toBeTruthy();
7-
});
14+
defineIntegrationTestSuite(
15+
async function (
16+
serverOptions: ApolloServerOptions<BaseContext>,
17+
testOptions?: CreateServerForIntegrationTestsOptions,
18+
) {
19+
const httpServer = createServer();
20+
const server = new ApolloServer({
21+
...serverOptions,
22+
plugins: [
23+
...(serverOptions.plugins ?? []),
24+
ApolloServerPluginDrainHttpServer({
25+
httpServer,
26+
}),
27+
],
28+
});
29+
30+
const handler = testOptions
31+
? lambdaHandler(server, testOptions)
32+
: lambdaHandler(server);
33+
34+
httpServer.addListener("request", createAPIGatewayMockServer(handler));
35+
36+
await new Promise<void>((resolve) => {
37+
httpServer.listen({ port: 0 }, resolve);
38+
});
39+
40+
return { server, url: urlForHttpServer(httpServer) };
41+
},
42+
{
43+
serverIsStartedInBackground: true,
44+
},
45+
);
846
});
47+
48+
// Stolen from apollo server integration tests
49+
export function urlForHttpServer(httpServer: Server): string {
50+
const { address, port } = httpServer.address() as AddressInfo;
51+
52+
// Convert IPs which mean "any address" (IPv4 or IPv6) into localhost
53+
// corresponding loopback ip. Note that the url field we're setting is
54+
// primarily for consumption by our test suite. If this heuristic is wrong for
55+
// your use case, explicitly specify a frontend host (in the `host` option
56+
// when listening).
57+
const hostname = address === "" || address === "::" ? "localhost" : address;
58+
59+
return format({
60+
protocol: "http",
61+
hostname,
62+
port,
63+
pathname: "/",
64+
});
65+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import url from "url";
2+
import type { IncomingMessage, ServerResponse } from "http";
3+
import type {
4+
APIGatewayProxyEventV2,
5+
APIGatewayProxyStructuredResultV2,
6+
Context as LambdaContext,
7+
Handler,
8+
} from "aws-lambda";
9+
10+
// Returns a Node http handler that invokes a Lambda handler as if via
11+
// APIGatewayProxy with payload version 2.0.
12+
export function createMockServer(
13+
handler: Handler<APIGatewayProxyEventV2, APIGatewayProxyStructuredResultV2>,
14+
) {
15+
return (req: IncomingMessage, res: ServerResponse) => {
16+
let body = "";
17+
req.on("data", (chunk) => (body += chunk));
18+
// this is an unawaited async function, but anything that causes it to
19+
// reject should cause a test to fail
20+
req.on("end", async () => {
21+
const event = eventFromRequest(req, body);
22+
const result = (await handler(
23+
event,
24+
{ functionName: "someFunc" } as LambdaContext, // we don't bother with all the fields
25+
() => {
26+
throw Error("we don't use callback");
27+
},
28+
)) as APIGatewayProxyStructuredResultV2;
29+
res.statusCode = result.statusCode!;
30+
Object.entries(result.headers ?? {}).forEach(([key, value]) => {
31+
res.setHeader(key, value.toString());
32+
});
33+
res.write(result.body);
34+
res.end();
35+
});
36+
};
37+
}
38+
39+
// Create an APIGatewayProxy V2 event from a Node request. Note that
40+
// `@vendia/serverless-express` supports a bunch of different kinds of events
41+
// including gateway V1, but for now we're just testing with this one. Based on
42+
// https://github.com/vendia/serverless-express/blob/mainline/jest-helpers/api-gateway-v2-event.js
43+
function eventFromRequest(
44+
req: IncomingMessage,
45+
body: string,
46+
): APIGatewayProxyEventV2 {
47+
const urlObject = url.parse(req.url || "", false);
48+
return {
49+
version: "2.0",
50+
routeKey: "$default",
51+
rawQueryString: urlObject.search?.replace(/^\?/, "") ?? "",
52+
headers: Object.fromEntries(
53+
Object.entries(req.headers).map(([name, value]) => {
54+
if (Array.isArray(value)) {
55+
return [name, value.join(",")];
56+
} else {
57+
return [name, value];
58+
}
59+
}),
60+
),
61+
// as of now, @vendia/serverless-express's v2
62+
// getRequestValuesFromApiGatewayEvent only looks at rawQueryString and
63+
// not queryStringParameters; for the sake of tests this is good enough.
64+
queryStringParameters: {},
65+
requestContext: {
66+
accountId: "347971939225",
67+
apiId: "6bwvllq3t2",
68+
domainName: "6bwvllq3t2.execute-api.us-east-1.amazonaws.com",
69+
domainPrefix: "6bwvllq3t2",
70+
http: {
71+
method: req.method!,
72+
path: req.url!,
73+
protocol: "HTTP/1.1",
74+
sourceIp: "203.123.103.37",
75+
userAgent:
76+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36",
77+
},
78+
requestId: "YuSJQjZfoAMESbg=",
79+
routeKey: "$default",
80+
stage: "$default",
81+
time: "06/Jan/2021:10:55:03 +0000",
82+
timeEpoch: 1609930503973,
83+
},
84+
isBase64Encoded: false,
85+
rawPath: urlObject.pathname!,
86+
body,
87+
};
88+
}

packages/lambda/src/index.ts

Lines changed: 128 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,130 @@
1-
import { ApolloServer } from "@apollo/server";
1+
import type {
2+
ApolloServer,
3+
BaseContext,
4+
ContextFunction,
5+
HTTPGraphQLRequest,
6+
} from "@apollo/server";
7+
import type { WithRequired } from "@apollo/utils.withrequired";
8+
import type {
9+
Handler,
10+
Context,
11+
APIGatewayProxyStructuredResultV2,
12+
APIGatewayProxyEventV2,
13+
} from "aws-lambda";
14+
export interface LambdaContextFunctionArgument {
15+
event: APIGatewayProxyEventV2;
16+
context: Context;
17+
}
18+
19+
export interface LambdaHandlerOptions<TContext extends BaseContext> {
20+
context?: ContextFunction<[LambdaContextFunctionArgument], TContext>;
21+
}
22+
23+
type LambdaHandler = Handler<
24+
APIGatewayProxyEventV2,
25+
APIGatewayProxyStructuredResultV2
26+
>;
27+
28+
export function lambdaHandler(
29+
server: ApolloServer<BaseContext>,
30+
options?: LambdaHandlerOptions<BaseContext>,
31+
): LambdaHandler;
32+
export function lambdaHandler<TContext extends BaseContext>(
33+
server: ApolloServer<TContext>,
34+
options: WithRequired<LambdaHandlerOptions<TContext>, "context">,
35+
): LambdaHandler;
36+
export function lambdaHandler<TContext extends BaseContext>(
37+
server: ApolloServer<TContext>,
38+
options?: LambdaHandlerOptions<TContext>,
39+
): LambdaHandler {
40+
server.startInBackgroundHandlingStartupErrorsByLoggingAndFailingAllRequests();
41+
42+
// This `any` is safe because the overload above shows that context can
43+
// only be left out if you're using BaseContext as your context, and {} is a
44+
// valid BaseContext.
45+
const defaultContext: ContextFunction<
46+
[LambdaContextFunctionArgument],
47+
any
48+
> = async () => ({});
49+
50+
const contextFunction: ContextFunction<
51+
[LambdaContextFunctionArgument],
52+
TContext
53+
> = options?.context ?? defaultContext;
54+
55+
return async function (event, context) {
56+
let parsedBody: object | string | undefined = undefined;
57+
try {
58+
if (!event.body) {
59+
// assert there's a query string?
60+
} else if (event.headers["content-type"] === "application/json") {
61+
try {
62+
parsedBody = JSON.parse(event.body);
63+
} catch (e: unknown) {
64+
return {
65+
statusCode: 400,
66+
body: (e as Error).message,
67+
};
68+
}
69+
} else if (event.headers["content-type"] === "text/plain") {
70+
parsedBody = event.body;
71+
}
72+
} catch (error: unknown) {
73+
// The json body-parser *always* sets req.body to {} if it's unset (even
74+
// if the Content-Type doesn't match), so if it isn't set, you probably
75+
// forgot to set up body-parser. (Note that this may change in the future
76+
// body-parser@2.)
77+
// return {
78+
// statusCode: 500,
79+
// body:
80+
// '`event.body` is not set; this probably means you forgot to set up the ' +
81+
// '`body-parser` middleware before the Apollo Server middleware.',
82+
// };
83+
throw error;
84+
}
85+
86+
const headers = new Map<string, string>();
87+
for (const [key, value] of Object.entries(event.headers)) {
88+
if (value !== undefined) {
89+
// Node/Express headers can be an array or a single value. We join
90+
// multi-valued headers with `, ` just like the Fetch API's `Headers`
91+
// does. We assume that keys are already lower-cased (as per the Node
92+
// docs on IncomingMessage.headers) and so we don't bother to lower-case
93+
// them or combine across multiple keys that would lower-case to the
94+
// same value.
95+
headers.set(key, Array.isArray(value) ? value.join(", ") : value);
96+
}
97+
}
98+
99+
const httpGraphQLRequest: HTTPGraphQLRequest = {
100+
method: event.requestContext.http.method,
101+
headers,
102+
search: event.rawQueryString,
103+
body: parsedBody,
104+
};
105+
106+
try {
107+
const httpGraphQLResponse = await server.executeHTTPGraphQLRequest({
108+
httpGraphQLRequest,
109+
context: () => contextFunction({ event, context }),
110+
});
111+
112+
if (httpGraphQLResponse.completeBody === null) {
113+
throw Error("Incremental delivery not implemented");
114+
}
2115

3-
export function lambdaHandler() {
4-
new ApolloServer({ typeDefs: `type Query { hello: String! }` });
116+
return {
117+
statusCode: httpGraphQLResponse.statusCode || 200,
118+
headers: {
119+
...Object.fromEntries(httpGraphQLResponse.headers),
120+
"content-length": Buffer.byteLength(
121+
httpGraphQLResponse.completeBody,
122+
).toString(),
123+
},
124+
body: httpGraphQLResponse.completeBody,
125+
};
126+
} catch (error) {
127+
throw error;
128+
}
129+
};
5130
}

0 commit comments

Comments
 (0)