Skip to content

Commit

Permalink
apollo-server-lambda: outsource Lambda expertise (#5211)
Browse files Browse the repository at this point in the history
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`).

Fix inconsistency in the content-type returned from health checks (the old
Lambda used `application/json` instead of `application/health+json` like most
other integrations).

This new version passes all of the existing integration tests (plus the
`testApolloServer` suite from
`apollo-server-integration-testsuite/src/ApolloServer.ts` which wasn't being run
previously!) essentially out of the box. (I fleshed out the "mock server"
implementation which converts from Node http requests to Lambda events a bit
more, and changed, but no "core" code or test changes were needed other than
fixing the health check `content-type`.)

Fixes #5078. Fixes #4951 (because that API is just "Express").
  • Loading branch information
glasser committed May 21, 2021
1 parent aee0ec4 commit a65396f
Show file tree
Hide file tree
Showing 12 changed files with 375 additions and 454 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ The version headers in this history reflect the versions of Apollo Server itself
- The `mocks` option to `new ApolloSchema` is now powered by `@graphql-tools/mock` v7 instead of `graphql-tools` v4. This contains some [breaking changes](https://www.graphql-tools.com/docs/mocking#migration-from-v7-and-below); for example, mock functions no longer receive arguments and cannot return `Promise`s. Note that some of the suggestions in the v7 migration guide suggest using the `resolvers` argument to `addMocksToSchema`. Apollo Server does not support this option, but you're welcome to call `addMocksToSchema` yourself and pass the result to the `schema` option to `new ApolloSchema`.
- `apollo-server-express`: We no longer officially support using this package with the `connect` framework. We have not actively removed any `connect` compatibility code and do still test that it works with `connect`, but we reserve the right to break that compatibility without a major version bump of this package (though it will certainly be noted in the CHANGELOG if we do so).
- `apollo-server-lambda`: The handler returned by `createHandler` can now only be called as an async function returning a `Promise` (it no longer optionally accepts a callback as the third argument). All current Lambda Node runtimes support this invocation mode (so `exports.handler = server.createHandler()` will keep working without any changes), but if you've written your own handler which calls the handler returned by `createHandler` with a callback, you'll need to handle its `Promise` return value instead.
- `apollo-server-lambda`: This package is now implemented as a wrapper around `apollo-server-express`. `createHandler`'s argument now has different options: `expressGetMiddlewareOptions` which includes things like `cors` that is passed through to `apollo-server-express`'s `getMiddleware`, and `expressAppFromMiddleware` which lets you customize HTTP processing. The `context` function now receives `lambda: { event, context }` and `express: { req, res }` options instead of `event` and `context`.
- The `tracing` option to `new ApolloServer` has been removed, and the `apollo-server-tracing` package has been deprecated and is no longer being published. This package implemented an inefficient JSON format for execution traces returned on the `tracing` GraphQL response extension; it was only consumed by the deprecated `engineproxy` and Playground. If you really need this format, the old version of `apollo-server-tracing` should still work (`new ApolloServer({plugins: [require('apollo-server-tracing').plugin()]})`).
- The `cacheControl` option to `new ApolloServer` has been removed. The functionality provided by `cacheControl: true` or `cacheControl: {stripFormattedExtensions: false}` (which included a `cacheControl` extension in the GraphQL response, for use by the deprecated `engineproxy`) has been entirely removed. The default behavior of Apollo Server continues to be calculating an overall cache policy and setting the `Cache-Control` HTTP header, but this is now implemented directly inside `apollo-server-core` rather than a separate `apollo-cache-control` package (this package has been deprecated and is no longer being published). Tweaking cache control settings like `defaultMaxAge` is now done via the newly exported `ApolloServerPluginCacheControl` plugin rather than as a top-level constructor option. This follows the same pattern as the other built-in plugins like usage reporting. The `CacheHint` and `CacheScope` types are now exported from `apollo-server-types`.
- When using a non-serverless framework integration (Express, Fastify, Hapi, Koa, Micro, or Cloudflare), you now *must* `await server.start()` before attaching the server to your framework. (This method was introduced in v2.22 but was optional before Apollo Server 3.) This does not apply to the batteries-included `apollo-server` or to serverless framework integrations.
Expand Down
49 changes: 37 additions & 12 deletions docs/source/deployment/lambda.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,9 +133,29 @@ The resulting S3 buckets and Lambda functions can be viewed and managed after lo
- To find the created S3 bucket, search the listed services for S3. For this example, the bucket created by Serverless was named `apollo-lambda-dev-serverlessdeploymentbucket-1s10e00wvoe5f`
- To find the created Lambda function, search the listed services for `Lambda`. If the list of Lambda functions is empty, or missing the newly created function, double check the region at the top right of the screen. The default region for Serverless deployments is `us-east-1` (N. Virginia)

## Customizing HTTP serving

`apollo-server-lambda` is built on top of `apollo-server-express`. It combines the HTTP server framework `express` with a package called `@vendia/serverless-express` that translates between Lambda events and Express requests. By default, this is entirely behind the scenes, but you can also provide your own express app with the `expressAppFromMiddleware` option to `createHandler`:

```js
const { ApolloServer } = require('apollo-server-lambda');
const express = require('express');

exports.handler = server.createHandler({
expressAppFromMiddleware(middleware) {
const app = express();
app.use(someOtherMiddleware);
app.use(middleware);
return app;
}
});
```

## Getting request info

To read information about the current request from the API Gateway event `(HTTP headers, HTTP method, body, path, ...)` or the current Lambda Context `(Function Name, Function Version, awsRequestId, time remaining, ...)`, use the options function. This way, they can be passed to your schema resolvers via the context option.
Your ApolloServer's `context` function can read information about the current operation from both the original Lambda data structures and the Express request and response created by `@vendia/serverless-express`. These are provided to your `context` function on the `lambda` and `express` options respectively.

`lambda` contains an `event` field which contains the API Gateway event (HTTP headers, HTTP method, body, path, ...) and a `context` field with the current Lambda Context (Function Name, Function Version, awsRequestId, time remaining, ...). `express` contains `req` and `res` fields with the Express request and response. The object returned from your `context` function is provided to all of your schema resolvers in the third `context` argument.

```js
const { ApolloServer, gql } = require('apollo-server-lambda');
Expand All @@ -157,11 +177,12 @@ const resolvers = {
const server = new ApolloServer({
typeDefs,
resolvers,
context: ({ event, context }) => ({
headers: event.headers,
functionName: context.functionName,
event,
context,
context: ({ lambda, express }) => ({
headers: lambda.event.headers,
functionName: lambda.context.functionName,
event: lambda.event,
context: lambda.context,
expressRequest: express.req,
}),
});

Expand Down Expand Up @@ -192,9 +213,11 @@ const resolvers = {
const server = new ApolloServer({ typeDefs, resolvers });

exports.graphqlHandler = server.createHandler({
cors: {
origin: '*',
credentials: true,
expressGetMiddlewareOptions: {
cors: {
origin: '*',
credentials: true,
},
},
});
```
Expand All @@ -221,9 +244,11 @@ const resolvers = {
const server = new ApolloServer({ typeDefs, resolvers });

exports.graphqlHandler = server.createHandler({
cors: {
origin: true,
credentials: true,
expressGetMiddlewareOptions: {
cors: {
origin: true,
credentials: true,
},
},
});
```
Expand Down
70 changes: 20 additions & 50 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 7 additions & 4 deletions packages/apollo-server-core/src/ApolloServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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<SchemaDerivedData> {
// 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<SchemaDerivedData> {
while (true) {
switch (this.state.phase) {
case 'initialized with gateway':
Expand Down
47 changes: 25 additions & 22 deletions packages/apollo-server-integration-testsuite/src/ApolloServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ export interface StopServerFunc {
export function testApolloServer<AS extends ApolloServerBase>(
createApolloServer: CreateServerFunc<AS>,
stopServer: StopServerFunc,
options: { serverlessFramework?: boolean } = {},
) {
describe('ApolloServer', () => {
afterEach(stopServer);
Expand Down Expand Up @@ -391,31 +392,33 @@ export function testApolloServer<AS extends ApolloServerBase>(
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`
Expand Down
52 changes: 39 additions & 13 deletions packages/apollo-server-lambda/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ Resources:
Type: AWS::Serverless::Function
Properties:
Handler: graphql.handler
Runtime: nodejs12.x
Runtime: nodejs14.x
Events:
AnyRequest:
Type: Api
Expand Down Expand Up @@ -104,9 +104,30 @@ aws cloudformation deploy \
--capabilities CAPABILITY_IAM
```


## Customizing HTTP serving

`apollo-server-lambda` is built on top of `apollo-server-express`. It combines the HTTP server framework `express` with a package called `@vendia/serverless-express` that translates between Lambda events and Express requests. By default, this is entirely behind the scenes, but you can also provide your own express app with the `expressAppFromMiddleware` option to `createHandler`:

```js
const { ApolloServer } = require('apollo-server-lambda');
const express = require('express');

exports.handler = server.createHandler({
expressAppFromMiddleware(middleware) {
const app = express();
app.use(someOtherMiddleware);
app.use(middleware);
return app;
}
});
```

## Getting request info

To read information about the current request from the API Gateway event (HTTP headers, HTTP method, body, path, ...) or the current Lambda Context (Function Name, Function Version, awsRequestId, time remaining, ...) use the options function. This way they can be passed to your schema resolvers using the context option.
Your ApolloServer's `context` function can read information about the current operation from both the original Lambda data structures and the Express request and response created by `@vendia/serverless-express`. These are provided to your `context` function on the `lambda` and `express` options respectively.

`lambda` contains an `event` field which contains the API Gateway event (HTTP headers, HTTP method, body, path, ...) and a `context` field with the current Lambda Context (Function Name, Function Version, awsRequestId, time remaining, ...). `express` contains `req` and `res` fields with the Express request and response. The object returned from your `context` function is provided to all of your schema resolvers in the third `context` argument.

```js
const { ApolloServer, gql } = require('apollo-server-lambda');
Expand All @@ -128,11 +149,12 @@ const resolvers = {
const server = new ApolloServer({
typeDefs,
resolvers,
context: ({ event, context }) => ({
headers: event.headers,
functionName: context.functionName,
event,
context,
context: ({ lambda, express }) => ({
headers: lambda.event.headers,
functionName: lambda.context.functionName,
event: lambda.event,
context: lambda.context,
expressRequest: express.req,
}),
});

Expand Down Expand Up @@ -166,9 +188,11 @@ const server = new ApolloServer({
});

exports.handler = server.createHandler({
cors: {
origin: '*',
credentials: true,
expressGetMiddlewareOptions: {
cors: {
origin: '*',
credentials: true,
}
},
});
```
Expand Down Expand Up @@ -198,9 +222,11 @@ const server = new ApolloServer({
});

exports.handler = server.createHandler({
cors: {
origin: true,
credentials: true,
expressGetMiddlewareOptions: {
cors: {
origin: true,
credentials: true,
}
},
});
```
Expand Down
Loading

0 comments on commit a65396f

Please sign in to comment.