How to create redirects with AWS Lambda and Amazon API Gateway

How to create redirects with AWS Lambda and Amazon API Gateway

When moving your site from one domain name to another you may need to configure redirects from an old domain to a new one. While trivial if you run the site on your hosting task may become not so easy if you move a site running on some solution like Shopify or Squarespace.

The simplest solution you may consider is to create a small virtual machine on some cloud like AWS, Azure, or Google Cloud. Install nginx or haproxy there, write redirect rules, and point your old domain name to this server. While this solution is easy to implement, one downside is that you will need to pay for the server to run 24/7 even if the traffic on your website might be relatively low.

So when I was asked to help configure redirects from an old site to a new one I decided to use serverless services to implement this task. In addition to low cost and maintenance, this approach gave me the required flexibility as I need not only to return 301 redirects for a root page but also had to customize some URL paths as the structure of the new site was different from the old one.

For my solution, I decided to go with an AWS Lambda function in TypeScript. As the number of redirects was relatively low and for sure they won’t change in the future I decided to put them directly into the function and not to use any external storage like Amazon DynamoDB.

As I need to use a particular domain name with my solution I had to use Amazon API Gateway for that as you can’t assign a domain name to the Lambda function URL without using Amazon Cloudfront and in my case, I did not need CDN. I used HTTP API as I only required basic functionality.

The domain was already managed by Amazon Route53 so I only needed to switch the resolver to the new API Gateway endpoint.

To manage the resources I used AWS SAM.

Let’s look at how to implement this solution.

I like to follow the TDD approach, so I started writing unit and integration tests for my function.

My unit test looked like this

import { APIGatewayProxyEventV2, APIGatewayProxyResultV2, APIGatewayProxyStructuredResultV2 } from 'aws-lambda';
import { lambdaHandler } from '../../app';
import { expect, describe, it } from '@jest/globals';

const event: APIGatewayProxyEventV2 = {
    version: '',
    routeKey: '',
    rawPath: '',
    rawQueryString: '',
    cookies: [],
    headers: {},
    queryStringParameters: {},
    requestContext: {
        accountId: '',
        apiId: '',
        domainName: '',
        domainPrefix: '',
        http: {
            method: '',
            path: '',
            protocol: '',
            sourceIp: '',
            userAgent: '',
        },
        requestId: '',
        routeKey: '',
        stage: '',
        time: '',
        timeEpoch: 0,
    },
    body: '',
    pathParameters: {},
    isBase64Encoded: false,
    stageVariables: {},
};

const urlPathMap: { [key: string]: string } = {
    '/portfolio': '<https://www.newsite.com/styling-and-photo>',
    '/shop': '<https://www.newsite.com/edits/my-home-edit>',
    '/feedback': '<https://www.newsite.com/styling-and-photo>',
    '/about': '<https://www.newsite.com/about>',
    '/contact': '<https://www.newsite.com/about>',
    '/listsandthoughts/layered-soft-warm-livable-habitable-minimalism-habitable-minimalism-so-what-is-it-exactly':
        '<https://www.newsite.com/journal/habitable-minimalism>',
    '/listsandthoughts/3-interior-design-interior-styling-books-i-can-recommend':
        '<https://www.newsite.com/journal/3-interior-design-interior-styling-books>',
    '/listsandthoughts/5-interior-design-amp-styling-blogs-check-every-week':
        '<https://www.newsite.com/journal/5-interior-design-amp-lifestyle-blogs-i-check-regularly>',
    '/listsandthoughts/5-big-ikea-products-i-have':
        '<https://www.newsite.com/journal/4-ikea-products-i-have-at-home-and-really-love>',
};

describe('Unit test for app handler', function () {
    it('verifies correct redirect for root path with get method', async () => {
        event.rawPath = '/';
        event.requestContext.http.method = 'GET';
        const result: APIGatewayProxyResultV2 = (await lambdaHandler(event)) as APIGatewayProxyStructuredResultV2;
        expect(result.statusCode).toEqual(301);
        expect(result.headers?.Location).toEqual('<https://www.welldesignedegg.com/>');
    });

    it('verifies correct for post method', async () => {
        event.rawPath = '/';
        event.requestContext.http.method = 'POST';
        const result: APIGatewayProxyResultV2 = (await lambdaHandler(event)) as APIGatewayProxyStructuredResultV2;
        expect(result.statusCode).toEqual(301);
        expect(result.headers?.Location).toEqual('<https://www.welldesignedegg.com/>');
    });
    it('verifies correct redirect for worng path and get method', async () => {
        event.rawPath = '/qwerty';
        event.requestContext.http.method = 'GET';
        const result: APIGatewayProxyResultV2 = (await lambdaHandler(event)) as APIGatewayProxyStructuredResultV2;
        expect(result.statusCode).toEqual(301);
        expect(result.headers?.Location).toEqual('<https://www.welldesignedegg.com/>');
    });
    it('verifies all redirecds from urlPathMap', async () => {
        for (const [key, value] of Object.entries(urlPathMap)) {
            event.rawPath = key;
            event.requestContext.http.method = 'GET';
            const result: APIGatewayProxyResultV2 = (await lambdaHandler(event)) as APIGatewayProxyStructuredResultV2;
            expect(result.statusCode).toEqual(301);
            expect(result.headers?.Location).toEqual(value);
        }
    });
});

As you see in this unit test I had to redirect the root path and also to implement some path rewrites for particular paths.

I also implemented a simple integration test to run against the actual endpoint once it is live.

import request, { Response } from 'supertest';
import { expect, describe, it, beforeAll } from '@jest/globals';

describe('Integration tests for redirect app', function () {
    let baseUrl: string;
    beforeAll(() => {
        baseUrl = process.env.BASE_URL || '<https://www.oldsite.eu>';
    });

    it('verifies correct redirect for root path with get method', async () => {
        const result = await request(baseUrl).get('/');
        expect(result.status).toEqual(301);
        expect(result.headers.location).toEqual('<https://www.newsite.com/>');
    });
    it('verifies correct redirect for root path with post method', async () => {
        const result = await request(baseUrl).post('/');
        expect(result.status).toEqual(301);
        expect(result.headers.location).toEqual('<https://www.newsite.com/>');
    });

    it('verifies correct redirect for worng path and get method', async () => {
        const result = await request(baseUrl).get('/qwerty');
        expect(result.status).toEqual(301);
        expect(result.headers.location).toEqual('<https://www.newsite.com/>');
    });
    it('verifies correct redirect for /portfolio path and get method', async () => {
        const result = await request(baseUrl).get('/portfolio');
        expect(result.status).toEqual(301);
        expect(result.headers.location).toEqual('<https://www.newsite.com/styling-and-photo>');
    });
    it('verifies correct redirect for /shop path and get method', async () => {
        const result = await request(baseUrl).get('/shop');
        expect(result.status).toEqual(301);
        expect(result.headers.location).toEqual('<https://www.newsite.com/edits/my-home-edit>');
    });
    it('verifies correct redirect for /feedback path and get method', async () => {
        const result = await request(baseUrl).get('/feedback');
        expect(result.status).toEqual(301);
        expect(result.headers.location).toEqual('<https://www.newsite.com/styling-and-photo>');
    });
    it('verifies correct redirect for /about path and get method', async () => {
        const result = await request(baseUrl).get('/about');
        expect(result.status).toEqual(301);
        expect(result.headers.location).toEqual('<https://www.newsite.com/about>');
    });
    it('verifies correct redirect for /contact path and get method', async () => {
        const result = await request(baseUrl).get('/contact');
        expect(result.status).toEqual(301);
        expect(result.headers.location).toEqual('<https://www.newsite.com/about>');
    });
    it('verifies correct redirect for /listsandthoughts/layered-soft-warm-livable-habitable-minimalism-habitable-minimalism-so-what-is-it-exactly path and get method', async () => {
        const result = await request(baseUrl).get(
            '/listsandthoughts/layered-soft-warm-livable-habitable-minimalism-habitable-minimalism-so-what-is-it-exactly',
        );
        expect(result.status).toEqual(301);
        expect(result.headers.location).toEqual('<https://www.newsite.com/journal/habitable-minimalism>');
    });
    it('verifies correct redirect for /listsandthoughts/3-interior-design-interior-styling-books-i-can-recommend path and get method', async () => {
        const result = await request(baseUrl).get(
            '/listsandthoughts/3-interior-design-interior-styling-books-i-can-recommend',
        );
        expect(result.status).toEqual(301);
        expect(result.headers.location).toEqual(
            '<https://www.newsite.com/journal/3-interior-design-interior-styling-books>',
        );
    });
    it('verifies correct redirect for /listsandthoughts/5-interior-design-amp-styling-blogs-check-every-week path and get method', async () => {
        const result = await request(baseUrl).get(
            '/listsandthoughts/5-interior-design-amp-styling-blogs-check-every-week',
        );
        expect(result.status).toEqual(301);
        expect(result.headers.location).toEqual(
            '<https://www.newsite.com/journal/5-interior-design-amp-lifestyle-blogs-i-check-regularly>',
        );
    });
    it('verifies correct redirect for /listsandthoughts/5-big-ikea-products-i-have path and get method', async () => {
        const result = await request(baseUrl).get('/listsandthoughts/5-big-ikea-products-i-have');
        expect(result.status).toEqual(301);
        expect(result.headers.location).toEqual(
            '<https://www.newsite.com/journal/4-ikea-products-i-have-at-home-and-really-love>',
        );
    });
});

Now I am ready to implement the actual function code

import { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda';

const urlPathMap: { [key: string]: string } = {
    '/portfolio': '<https://www.nesite.com/styling-and-photo>',
    '/shop': '<https://www.nesite.com/edits/my-home-edit>',
    '/feedback': '<https://www.nesite.com/styling-and-photo>',
    '/about': '<https://www.nesite.com/about>',
    '/contact': '<https://www.nesite.com/about>',
    '/listsandthoughts/layered-soft-warm-livable-habitable-minimalism-habitable-minimalism-so-what-is-it-exactly':
        '<https://www.nesite.com/journal/habitable-minimalism>',
    '/listsandthoughts/3-interior-design-interior-styling-books-i-can-recommend':
        '<https://www.nesite.com/journal/3-interior-design-interior-styling-books>',
    '/listsandthoughts/5-interior-design-amp-styling-blogs-check-every-week':
        '<https://www.nesite.com/journal/5-interior-design-amp-lifestyle-blogs-i-check-regularly>',
    '/listsandthoughts/5-big-ikea-products-i-have':
        '<https://www.nesite.com/journal/4-ikea-products-i-have-at-home-and-really-love>',
};

export const lambdaHandler = async (event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> => {
    console.log(event);
    if (event.requestContext.http.method === 'GET' && event.rawPath in urlPathMap) {
        return {
            statusCode: 301,
            isBase64Encoded: false,
            headers: {
                Location: urlPathMap[event.rawPath],
            },
        };
    }
    return {
        statusCode: 301,
        isBase64Encoded: false,
        headers: {
            Location: '<https://www.nesite.com/>',
        },
    };
};

As you see the logic is pretty simple if I receive a GET request to a path in my map I will return a redirect to a new path on a new site, all other requests I redirect to the root of the new site.

As I mentioned I used a SAM template to deploy my solution

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
  sample-redirect

  Sample SAM Template for sample-redirect

Globals:
  Function:
    Timeout: 10

Resources:
  RedirectHttpApi:
    Type: AWS::Serverless::HttpApi
    Properties:
      Domain:
        DomainName: www.oldsite.eu
        CertificateArn: #Your ACM certificate for the domain
        EndpointConfiguration: REGIONAL
        Route53:
          HostedZoneId: #Your hosted zone id for the domain name
      AccessLogSettings:
        DestinationArn: !GetAtt AccessLogs.Arn
        Format: $context.requestId

  AccessLogs:
    Type: AWS::Logs::LogGroup
  RedirectFunction:
    Type: AWS::Serverless::Function 
    Properties:
      CodeUri: redirect/
      Handler: app.lambdaHandler
      Runtime: nodejs18.x
      Architectures:
        - x86_64
      Events:
        Redirect:
          Type: HttpApi 
          Properties:
            ApiId: !Ref RedirectHttpApi
            Path: /{proxy+}
            Method: any
    Metadata: 
      BuildMethod: esbuild
      BuildProperties:
        Minify: true
        Target: "es2020"
        Sourcemap: true
        EntryPoints: 
        - app.ts

Outputs:
  RedirectHttpApi:
    Description: "API Gateway endpoint URL for Prod stage for Redirect function"
    Value: 
      Fn::Sub: '<https://$>{RedirectHttpApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}/'
  RedirectFunction:
    Description: "Redirect Lambda Function ARN"
    Value: !GetAtt RedirectFunction.Arn
  RedirectFunctionIamRole:
    Description: "Implicit IAM Role created for Redirect function"
    Value: !GetAtt RedirectFunctionRole.Arn

You may find the full sample in this GitHub repository - https://github.com/roman-boiko/sample-redirect

Conclusion

With a Serverless stack, I could create the required functionality quickly and easily. Of course, this was quite a simple project and I can see a couple of improvements to implement in the future like not hard-coding the rewrite map into the function code but using a DynamoDB table or introducing a custom metric to track the number of redirects for each path. But for now, the application solves its purpose well and it costs me nothing to run as the traffic is pretty low.