exanubes
Q&A

Create API Gateway with custom domain

Previously, we’ve learned how API Gateway works – focusing on microservice architecture. However, API Gateway also works well with serverless and in this article we’ll go over how to build an api gateway with a custom domain and a lambda integration using aws cdk. Finished code is available on github

Setup

Aside from aws-cdk I have used an additional tool that combines Serverless Application Model (SAM) with aws-cdk.

You can install it with homebrew:

brew install aws-sam-cli-beta-cdk

In order to deploy this infrastructure to aws, you will also need a hosted zone in AWS Route 53 and a certificate in us-east-1 region.

Infrastructure

In order to create an API with a custom domain we’re going to need the following:

  1. API Gateway
  2. Lambda function
  3. Assign Lambda function to API Gateway resource
  4. SSL Certificate
  5. Domain name
  6. CName record

API Gateway

// /lib/api-gateway.stack.ts
export class ApiGatewayStack extends cdk.Stack {
  constructor(app: cdk.App) {
    super(app, ApiGatewayStack.name);
    const api = new RestApi(this, 'api-gateway', {
      defaultCorsPreflightOptions: {
        allowOrigins: ['http://localhost:3000'],
      },
    });
  }
}

This short snippet of code will create an API Gateway that will have CORS configured to only allow requests from http://localhost:3000. You can also leave it empty or change it to Cors.ALL_ORIGINS to explicitly allow all origins to call your api. In a production scenario maybe you’d like only your private website to be able to call you API. This is where you can set this up.

Lambda function

To set up a lambda we need to first create the lambda function and then create a lambda AWS Resource for our infrastructure.

// /lambda/hello-world/hello-world.ts
export async function main(
  event: APIGatewayProxyEvent,
  context: Context,
  callback: Callback
) {
  const name = event?.queryStringParameters?.name;
  if (!name) {
    return callback(new Error('Name cannot be undefined'));
  }
  return {
    statusCode: 200,
    body: 'Hello ' + name,
    headers: {
      'Access-Control-Allow-Origin': 'http://localhost:3000',
      'Content-Type': 'text/plain',
    },
  };
}

All this lambda does is take out a name query string parameter and return a greeting message. Important to note that headers are especially important as we have configured CORS to only allow certain origins. Without Access-Control-Allow-Origin the OPTIONS http request would return an error. So if you’re making a request from the correct origin and everything seems to be working fine but you get an error anyway, check headers on your lambda function.

// /lib/lambda.stack.ts
export class LambdaStack extends cdk.Stack {
  lambdaFunction: IFunction;
  constructor(app: cdk.App) {
    super(app, LambdaStack.name);
    this.lambdaFunction = new Function(this, 'hello-world', {
      memorySize: 128,
      timeout: cdk.Duration.seconds(5),
      runtime: Runtime.NODEJS_14_X,
      handler: 'hello-world.main',
      code: Code.fromAsset(path.join(__dirname, '/../lambda/', 'hello-world')),
    });
  }
}

To create a lambda resource we need to pass a handler, which is the file and function names and then load the code from that file by passing a path to it. Last but not least we defined the runtime and timeout. This basically sets a hard stop for the lambda if it doesn’t finish its job in 5 seconds in this case. As this is not a very computationally heavy lambda, we can also set a low memorySize of 128Mb. This is also the default setting, however, I prefer to have it explicitly set.

Local lambda invocation

One very cool feature of SAM is the ability to run lambdas locally. In the file structure, there is a events directory that holds an API Gateway proxy event that can be used when triggering the lambda with

sam-beta-cdk local invoke LambdaStack/hello-world -e events/hello-world.json

Keep in mind that LambdaStack is the name of the stack I used and hello-world is the id for the Function resource inside that stack. These could be different for you.

Assign Lambda function to API Gateway resource

// /lib/api-gateway.stack.ts
constructor(app: cdk.App, lambdaFunction: IFunction){
 // ...
  const helloIntegration = new LambdaIntegration(lambdaFunction)
  const hello = api.root.addResource('hello')
  hello.addMethod("GET", helloIntegration)
}

First we need to create a lambda integration which basically tells API Gateway with which service it is going to be integrated as it does not have to be a Lambda. It could be an SQS or even a DynamoDB.

Creating a resource basically means creating a rest endpoint. Let’s say this resource was users instead of hello and I’d like to have an endpoint to fetch a user by id. I would then take the users resource and call addResource in order to create another part of the REST endpoint. Wrapping the resource string in curly brackets {} sets it as a dynamic path parameter.

declare const users: Resource;
const user = users.addResource('{userId}');

Last but not least, unless we define an HTTP Method and assign the lambda integration to it, we will not be able to receive data from our resource.

Certificate

In order to create a domain name, we will need an SSL certificate which can be requested in AWS Certificate Manager if you don’t already have one.

If you want to optimise your API for performance, you will use an EDGE endpoint type – meaning you want to use Cloudfront and its Edge Locations – and have to have your certificate in N. Virginia us-east-1 region.

Otherwise, if you don’t need that kind of optimisation, you will use a REGIONAL endpoint type and have to have your certificate in the same region as your API Gateway.

Once you have the certificate, paste the arn into the project

// /lib/api-gateway.stack.ts
const certificateArn =
  'arn:aws:acm:us-east-1:12345678901234:certificate/xxx-yyy-zzzz-aaaa-bbbbbb';

Domain name

API Gateway provides us with a standardized aws url, however, for the most part we’d probably prefer to use our own domain address. To achieve that we’re going to create a domain name, set a mapping to that domain from the API Gateway and then create a CName record to redirect to the cloudfront distribution.

// /lib/api-gateway.stack.ts
const domain = new DomainName(this, 'api-gw-domain-name', {
  domainName: 'custom.example.com',
  certificate,
  securityPolicy: SecurityPolicy.TLS_1_2,
  endpointType: EndpointType.EDGE,
});

Most likely, example.com would be the domain for our website, that’s why I opted for a custom subdomain. Depending on the region you requested a certificate in, you can setup either EDGE or REGIONAL endpoint type. There is also a PRIVATE option which would only accept requests from your VPC.

// /lib/api-gateway.stack.ts
new BasePathMapping(this, 'api-gw-base-path-mapping', {
  domainName: domain,
  restApi: api,
});

BasePathMapping is exactly what it sounds like, it creates a base path that user has to use when calling the api. It’s very similar to a mechanism used in frontend applications. In this case we’re only setting a domain name, but by using a basePath property we could enforce a base path after the domain name as well e.g., custom.example.com/required-base-path. This could be an alternative to using a subdomain for API Gateway.

// /lib/api-gateway.stack.ts
const hostedZone = HostedZone.fromHostedZoneAttributes(this, 'hosted-zone', {
  hostedZoneId: 'Z00971403R2PNG6AZVK4K',
  zoneName: 'exanubes.com',
});

new CnameRecord(this, 'api-gw-custom-domain-cname-record', {
  recordName: 'custom',
  zone: hostedZone,
  domainName: domain.domainNameAliasDomainName,
});

At last, we have to create a CName record to map the custom subdomain to the Cloudfront distribution in our Route53 hosted zone. Keep in mind that should you use a different hosting service, like Netlify, you will need to add the record there manually. It will not work otherwise.

Deployment

Now that everything’s ready we can build and deploy the application

npm run build
npm run cdk:deploy

It should be a secure API that is not be accessible from different origins than defined. When you’re finished testing don’t forget to tear it all down

npm run cdk:destroy

Summary

In this article we’ve gone over setting up an API Gateway with a lambda integration using aws cdk and how to invoke lambdas locally – without deployment. Then we created a custom domain and mapped it to the API Gateway using BasePathMapping. To finish it off and point users to API Gateway when calling the new domain, we had to create a CName record that will resolve to the Cloudfront distribution.