Sunday, April 24, 2022

Deploy AWS Lambda HTTP APIs

AWS Lambda is a simple platform for building and running scalable and cost effective HTTP APIs. Depending on the features required, we can expose these APIs with API Gateway1 or the with the new function URLs2.

Building API Lambdas

AWS Lambda is event based, meaning every call to the Lambda function is in response to an event. For example, when integrating Lambda with API Gateway, an API Gateway Proxy event3 is delivered with the expectation of a response[^lambda-proxy-response-schema] that instructs API Gateway what to send to the client.

Below is an example of a simple echo server for AWS Lambda:

export const handler = async (event) => {
  return {
    statusCode: 200,
    body: event.body,
  };
};

The only issue with this is most languages ship with a HTTP server library with a mature feature set and an existing ecosystem that we wish to continue to use, e.g. Express4 and ASP.NET Core5. Thankfully, there are adapters that enables existing servers to be used in the Lambda runtime and respond to the aforementioned events, e.g. serverless-express6 and Amazon.Lambda.Core7.

import express from "express";
import serverlessExpress from "@vendia/serverless-express";

const getExpressApp = () => {
  const app = express();

  app.use(cors());
  app.use(json());

  app.post(
    "/api/v1/echo",
    promisifyHandler(async (req, res) => {
      res.status(200).send(req.body);
    })
  );

  return app;
};

export const handler = async (event, context) => {
  const app = getExpressApp();
  const serverlessExpressInstance = serverlessExpress({ app });
  return serverlessExpressInstance(event, context);
};

Deploying Lambdas

Typically we would need to zip and upload our files to S3 for use by Lambda. We can do this manually using the AWS console, but that can be fragile and error prone. Instead we can leverage infrastructure-as-code to automate the deployment while also tracking changes. The following examples will be authored with the easy-to-use AWS CDK 8 library.

We can declaritively create a Lambda function with the following:

const fn = new Function(this, "Function", {
  runtime: Runtime.NODEJS_14_X,
  code: Code.fromAsset(path.resolve(__dirname, "../../../api/dist")), // path to folder containing Lambda code with `index.js`
  handler: "index.handler", // use the exported `handler` function
  memorySize: 128,
  retryAttempts: 0,
  reservedConcurrentExecutions: 1,
  timeout: Duration.seconds(30),
  logRetention: RetentionDays.ONE_DAY,
});

So far we have only deployed the Lambda itself, but without an event provider it will do nothing. It needs to be integrated with a service that can provide a URL for clients to make requests and for those requests to be delivered as HTTP events to the Lambda.

Integrate HTTP

We have a few options which vary in feature set and cost.

FeaturesFunction URLHTTP APIREST APIApplication Load Balancer
Cost⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
Custom domain
Custom authorizers
Private VPC

All the options integrate with Lambda in the same way, that is they send and receive an API Gateway Proxy event3 and response[^lambda-proxy-response-schema], respectively.

AWS Lambda is event based, meaning every call to the Lambda function is in response to an event. For example, when integrating Lambda with API Gateway, an API Gateway Proxy event3 is delivered with the expectation of a response[^lambda-proxy-response-schema] that instructs API Gateway what to send to the client.

Function URLs

When configured, this provides a HTTPS URL that can be used to invoke the Lambda function2. There is support for configuring authentication9 (e.g. AWS IAM) as well as CORS10. If these features are insufficient, there is always the option to implement it as part of the Lambda function itself. With such a limited feature set, it is the most cost-effective option, with no additional cost11.

Given a Lambda function fn, we can deploy a function URL as follows:

const fnUrl = new FunctionUrl(this, "FunctionUrl", {
  function: fn,
  authType: FunctionUrlAuthType.NONE,
  cors: {
    allowedMethods: [HttpMethod.ALL],
    allowedOrigins: Cors.ALL_ORIGINS,
  },
});

REST API

REST API is the full featured service provided by API Gateway. It includes support for custom domains12, various auth mechanisms 13 (e.g. API keys14, custom), private VPC traffic15, etc. There is a per request cost16.

Given a Lambda function fn, we can deploy a REST API as follows with a custom domain:

const restApi = new LambdaRestApi(this, "RestApi", {
  handler: fn,
  defaultCorsPreflightOptions: {
    allowOrigins: Cors.ALL_ORIGINS,
    allowMethods: Cors.ALL_METHODS,
  },
});

const certificate = new Certificate(this, "Certificate", {
  domainName: "example.com",
  validation: CertificateValidation.fromDns(),
});

api.addDomainName("Domain", {
  domainName,
  certificate,
});

HTTP API

HTTP APIs[^http-apis] are a simpler and cheaper option provided by API Gateway when compared to REST APIs. It still includes some of the most important features such as custom domains and auth. There is still a per request cost16.

Given a Lambda function fn, we can deploy a HTTP API as follows with a custom domain:

const certificate = new Certificate(this, "Certificate", {
  domainName: "example.com",
  validation: CertificateValidation.fromDns(),
});

const restApi = new HttpApi(this, "RestApi", {
  defaultIntegration: new HttpLambdaIntegration("DefaultIntegration", fn, {
    payloadFormatVersion: PayloadFormatVersion.VERSION_1_0,
  }),
  corsPreflight: {
    allowOrigins: Cors.ALL_ORIGINS,
    allowMethods: [CorsHttpMethod.ANY],
    allowHeaders: Cors.DEFAULT_HEADERS,
  },
  createDefaultStage: true,
  defaultDomainMapping: {
    domainName: new DomainName(scope, "DomainName", {
      domainName: "example.com"
      certificate,
    }),
  },
});

Load Balancer

It is possible to associate a Lambda target17 in an AWS Application Load Balancer. There are few good use cases for this, the only one that comes to mind is better integration with AWS network primitives (VPC, security groups, etc.).

Conclusion

It is relatively simple to build and deploy AWS Lambda HTTP APIs. When it comes to integration - be sure to evaluate the requirements and cost of each option for your use case. Going forward I will be using the function URL integration more and more as I rarely need the additional functionality provided by the other options.

Footnotes

  1. https://docs.aws.amazon.com/lambda/latest/dg/services-apigateway.html

  2. https://docs.aws.amazon.com/lambda/latest/dg/lambda-urls.html 2

  3. https://github.com/DefinitelyTyped/DefinitelyTyped/blob/781a723cc716c7dba279defa14977e3998255523/types/aws-lambda/trigger/api-gateway-proxy.d.ts#L53[^lambda-proxy-response-schema]: https://github.com/DefinitelyTyped/DefinitelyTyped/blob/781a723cc716c7dba279defa14977e3998255523/types/aws-lambda/trigger/api-gateway-proxy.d.ts#L128 2 3

  4. https://expressjs.com/

  5. https://docs.microsoft.com/en-us/aspnet/core/introduction-to-aspnet-core?view=aspnetcore-6.0

  6. https://github.com/vendia/serverless-express

  7. https://github.com/aws/aws-lambda-dotnet/tree/master/Libraries/src/Amazon.Lambda.Core

  8. https://docs.aws.amazon.com/cdk/v2/guide/home.html

  9. https://docs.aws.amazon.com/lambda/latest/dg/urls-auth.html

  10. https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS

  11. https://aws.amazon.com/blogs/aws/announcing-aws-lambda-function-urls-built-in-https-endpoints-for-single-function-microservices/#:~:text=Function%20URL%20Pricing

  12. https://docs.aws.amazon.com/apigateway/latest/developerguide/how-to-custom-domains.html

  13. https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-control-access-to-api.html

  14. https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-setup-api-key-with-restapi.html

  15. https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-private-apis.html

  16. https://aws.amazon.com/api-gateway/pricing/ 2

  17. https://docs.aws.amazon.com/lambda/latest/dg/services-alb.html