The Energy Drink Episodes 4: The Return Of The Lambda Functions

theleepriest

Lee Priest

Posted on December 19, 2023

The Energy Drink Episodes 4: The Return Of The Lambda Functions

This post is the fourth part of a ‘Let’s CDK’ series. You can catch up on part 1 here, part 2 here and part 3 here. This post is part of a series that will introduce you to the AWS Cloud Development Kit (CDK) and how it can be used to create an application. This series will introduce you to the core concepts of AWS CDK, how you can deploy to your AWS account, and how services like AWS Lambda Functions, DynamoDB, Step Functions and more can all be used when working with CDK.

Our Previous Steps

In the last episode, we learned about AWS Step Functions and how we can create them using CDK. We created our own custom L3 construct for an express workflow Step Function and then created the body definition chain. We linked it all together nicely in a stack and then integrated it with our main app stack. Feel free to have a read-through of the previous episode if you need any refresher before you continue on.

There’s nothing wrong with returning!

Now that we’ve refreshed our memories of the last episode, you may be aware that there were a couple of things that we added as placeholders that we promised to return to. These ‘things’ were the Lambda Functions that we use to perform some logic on our payload in our Express Step Function flow.

Let’s take a quick look at how we implemented these Lambda functions via CDK in the previous episode:

const sugarFreeLambdaFunction = new NodejsFunction(this, 'SugarFreeLambdaFunction', {
    entry: path.join(__dirname, '../src/functions/sugarFree/sugarFree.ts'),
    runtime: Runtime.NODEJS_18_X,
    architecture: Architecture.ARM_64,
    handler: 'sugarFree',
    bundling: {
        sourceMap: true,
        minify: true,
        tsconfig: path.join(__dirname, '../tsconfig.json'),
    },
});
Enter fullscreen mode Exit fullscreen mode

We used the NodejsFunction construct to build a Node.js Lambda function bundled using esbuild. However, if we look a little closer at the implementation, there are some things that we will likely keep the same between all our Lambda functions we end up using. Things like the runtime, architecture and bundling probably won’t change. Could we come up with a way to implement something that would handle this for us?

Let’s Construct Something…

An AI generated image of people working on software at wooden desks

Terrible pun aside, you probably guessed that we can create a custom L3 construct for our Lambda functions. In this custom construct we can set sensible defaults that we want all of our Lambda functions to use. We can also make use of a props object so things like the entry, handler and tsconfig can be dynamic.

To get started with our custom Lambda function construct let’s create a LambdaFunctions directory in our constructs directory. Inside there, let’s now create a TSLambdaFunction.ts file to house our construct code. Your constructs directory structure should look a little something like this:

src/
│
└───constructs/
    │
    ├───APIGateway/
    │
    └───LambdaFunctions/
    │
    └───StepFunctions/
Enter fullscreen mode Exit fullscreen mode

In our new TSLambdaFunctions.ts file, let’s take a look at what we’ll need to be importing:

import { Stack } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import { Architecture, Runtime } from "aws-cdk-lib/aws-lambda";
Enter fullscreen mode Exit fullscreen mode

Hopefully, you can see some familiar faces in the imports above. The trusty Stack and Construct imports have been used throughout the series. The NodejsFunction import provides us with the construct we are going to use to create the actual Lambda function. You may be wondering why we don’t just use the Function construct to create our Lambda functions.

As we’re working on a Node.js application, the NodejsFunction construct makes things a lot simpler for us by handling the code bundling. As we’re also working in Typescript, the construct makes things easier for us again by handling the compilation from Typescript to Javascript. In a nutshell, as we’re working in Typescript, using the NodejsFunction construct saves us time and effort and helps us get up and running quicker.

The Architecture and Runtime imports allow us to specify, as you can probably guess, what architecture and runtime our Lambda function should use. Although these two imports aren’t completely necessary, as they are optional props for the NodejsFunction construct, we can specify our own values to override the defaults.

Next, let’s have a think about the props that we will want to be able to pass into our TSLambdaFunction construct:

type LambdaFunctionProps = {
    serviceName: string;
    stage: string;
    entryPath: string;
    handlerName?: string;
    tsConfigPath: string;
}
Enter fullscreen mode Exit fullscreen mode

Breaking down the type above, we can see:

  • serviceName: A string we will use to create the logical ID for our Lambda function
  • stage: A string we will use to create the logical ID for our Lambda function. This could also be used to handle deployment stage-specific needs if needed.
  • entryPath: A string that represents the path to the Lambda function handler code
  • handlerName: An optional string that represents that name of the exported function from the entry file
  • tsConfigPath: A string that represents the path to the tsconfig file. We will pass this explicitly so the construct knows exactly where our tsconfig file is rather than it having to try to work it out itself.

Now we have our imports and our props decided. Let’s get cracking on actually creating the construct. We can make use of the skeleton we’re familiar with:

export class TSLambdaFunction extends Construct {
    public readonly tsLambdaFunction: NodejsFunction;

    constructor(scope: Stack, id: string, props: LambdaFunctionProps) {
        super(scope, id);

         // This is where the fun stuff will live!
    }
}
Enter fullscreen mode Exit fullscreen mode

In the code above, we have the skeleton for our TSLambdaFunction that extends the Construct class. We set a public readonly property called tsLambdaFunction so we are able to access the Lambda function created inside this construct when used inside our stacks.

It’s time to have some fun by adding some meat to the bones of our skeleton. This ‘meat’ looks a little something like this:

const {
    serviceName,
    stage,
    entryPath,
    handlerName = 'handler',
    tsConfigPath
} = props;

this.tsLambdaFunction = new NodejsFunction(this, `${serviceName}-${id}-${stage}`, {
    entry: entryPath,
    runtime: Runtime.NODEJS_18_X,
    architecture: Architecture.ARM_64,
    handler: handlerName,
    bundling: {
        sourceMap: true,
        minify: true,
        tsconfig: tsConfigPath,
    },
});
Enter fullscreen mode Exit fullscreen mode

In the above code, we first destructure parameters from our props object and then use the NodejsFunction construct to create a Lambda function. This Lambda function is then set as the value of the readonly property we created.

For our architecture, we went with Architecture.ARM_64 for the benefits like cost efficiency, performance and energy efficiency over the default of Architecture.X86_64 that the NodejsFunction runs with. We also went with Runtime.NODEJS_18_X as opposed to the default of Runtime.NODEJS_LATEST out of caution, as I’ve been burned by Node version issues in the past. But in reality, for our app, the default would have been fine.

With the snippets above, we have everything we need to be able to successfully create Lambda functions. The above can also be easily extended to include things like log groups, deployment options, alarms and much more. If we combine the above snippets we get this:

import { Stack } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import { Architecture, Runtime } from "aws-cdk-lib/aws-lambda";

type LambdaFunctionProps = {
    serviceName: string;
    stage: string;
    entryPath: string;
    handlerName?: string;
    tsConfigPath: string;
}

export class TSLambdaFunction extends Construct {
    public readonly tsLambdaFunction: NodejsFunction;

    constructor(scope: Stack, id: string, props: LambdaFunctionProps) {
        super(scope, id);

        const {
            serviceName,
            stage,
            entryPath,
            handlerName = 'handler',
            tsConfigPath
        } = props;

        this.tsLambdaFunction = new NodejsFunction(this, `${serviceName}-${id}-${stage}`, {
            entry: entryPath,
            runtime: Runtime.NODEJS_18_X,
            architecture: Architecture.ARM_64,
            handler: handlerName,
            bundling: {
                sourceMap: true,
                minify: true,
                tsconfig: tsConfigPath,
            },
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

Time to Handle Our Handlers

With our custom Lambda function construct now constructed, we can turn our attention to the placeholder handlers we created in the previous episode. As a refresher, this is what we currently have:

export const sugar = async () => {
    return {
        statusCode: 200,
        body: {
          message: "Hello, Sugar World!"
        },
    };
}
Enter fullscreen mode Exit fullscreen mode

This extremely basic placeholder won't fulfil the mission we need it to:

Decide which energy drink we should drink

We will stick to a basic approach in how we’re going to figure out which drink we’ll be given so we don’t get too far away from the mission of this post:

To learn how to create and deploy a Lambda function using CDK

To contain everything to the Lambda handler, we can use a very basic implementation as follows:

type sugarEvent = {
  sugar: boolean
}

export const sugar = async (event: sugarEvent) => {
    const {sugar} = event;

    if(sugar) {
      return {
        drinkName: 'Relentless'
      }
    }

    throw new Error('Sugar boolean is false, should be true');
}
Enter fullscreen mode Exit fullscreen mode

The above snippet shows the Lambda function handler receives an event that contains a sugar parameter that is a boolean. There is then a simple check in the code to detect if the sugar boolean is true or not. If it is true we return the drinkName of Relentless. Otherwise, we throw an error. If you think back to our Step Function, we will have two separate handlers for the sugar and the sugar-free path. This means that if our sugar Lambda Function receives a boolean that is false, something has gone wrong somewhere. For our sugarFree Lambda Function we can use the same structure:

type sugarEvent = {
  sugar: boolean
}

export const sugarFree = async (event: sugarEvent) => {
    const {sugar} = event;

    if(!sugar) {
      return {
        drinkName: 'Relentless Sugar Free'
      }
    }

    throw new Error('Sugar boolean is true, should be false');
}
Enter fullscreen mode Exit fullscreen mode

Step Back to Our Definition

Custom Lambda function construct, check. Updated Lambda handlers, check. We’re making good progress so far, so next up is updating our Step Function definition. Don’t be scared! There isn’t much we actually need to update. Let’s first have a refresher on what our current definition looks like:

const definition = storeRawItem.addCatch(failState).next(
    validatePayload
        .when(Condition.isPresent('$.sugar'), isSugarFree
            .when(Condition.booleanEquals('$.sugar', true), new LambdaInvoke(stack, 'Sugar Logic', {
                lambdaFunction: sugarLambdaFunction,
            }).addCatch(sugarPassState, { errors: ['States.ALL'] }))
            .otherwise(new LambdaInvoke(stack, 'Sugar Free Logic', {
                lambdaFunction: sugarFreeLambdaFunction,
            }).addCatch(sugarFreePassState, { errors: ['States.ALL'] }))
        )
        .otherwise(failState)
);
Enter fullscreen mode Exit fullscreen mode

Looking at the above definition, we can see that the Lambda functions are being invoked depending on the presence of and the value of the sugar parameter in the Step Function input. To work out what updates we need to make to the Step Function definition, let’s consider the updates made to our Lambda Function handlers.

The handlers now return an object with a drinkName parameter. This parameter contains the value of the drink we have been assigned. With this in mind, we should get that drinkName parameter from the Lambda Function so we can return it to the user that triggered the Step Function. We can do this by updating the definition like this:

const definition = storeRawItem.addCatch(failState).next(
    validatePayload
        .when(Condition.isPresent('$.sugar'), isSugarFree
            .when(Condition.booleanEquals('$.sugar', true), new LambdaInvoke(stack, 'Sugar Logic', {
                lambdaFunction: sugarLambdaFunction,
                resultSelector: {
                    drinkName: JsonPath.stringAt('$.Payload.drinkName')
                }
            }).addCatch(sugarPassState, { errors: ['States.ALL'] }))
            .otherwise(new LambdaInvoke(stack, 'Sugar Free Logic', {
                lambdaFunction: sugarFreeLambdaFunction,
                resultSelector: {
                    drinkName: JsonPath.stringAt('$.Payload.drinkName')
                }
            }).addCatch(sugarFreePassState, { errors: ['States.ALL'] }))
        )
        .otherwise(failState)
);
Enter fullscreen mode Exit fullscreen mode

On the whole, the definition hasn’t changed much at all. The use of resultSelector enables us to get the result from the Lambda Function and then set it as the Step Function data. So when we run the Step Function now, the result would be formatted like this:

{
  "drinkName": "Relentless Sugar Free"
}
Enter fullscreen mode Exit fullscreen mode

For our needs, that’s all we need to update when considering the Step Function definition. Not so scary after all, right?

Stack Up Those Updates

An AI generated image of people coding around a central stack of blocks

Our Lambda Function construct is ready to go, the handler code has been updated and the Step Function definition is all good, so what next? Let’s take a look at our EnergyDrinkSelectorStepFunctionStack and see what updates we may need there.

The stack should currently look like this:

import path = require("path");
import { Construct } from "constructs";
import { NestedStack, NestedStackProps } from "aws-cdk-lib";
import { StateMachine } from "aws-cdk-lib/aws-stepfunctions";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import { Architecture, Runtime } from "aws-cdk-lib/aws-lambda";
import { ITable } from "aws-cdk-lib/aws-dynamodb";
import { ExpressStepFunction } from "../constructs/StepFunctions/ExpressStepFunction";
import { energyDrinkSelectorDefinition } from "../src/stepFunctions/definitions/energyDrinkSelector";

type EnergyDrinkSelectorStepFunctionStackProps = NestedStackProps & {
    table: ITable;
}

export class EnergyDrinkSelectorStepFunctionStack extends NestedStack {
    public readonly stepFunction: StateMachine;

    constructor(scope: Construct, id: string, props: EnergyDrinkSelectorStepFunctionStackProps) {
        super(scope, id, props);

        const { table } = props;

        const sugarFreeLambdaFunction = new NodejsFunction(this, 'SugarFreeLambdaFunction', {
            entry: path.join(__dirname, '../src/functions/sugarFree/sugarFree.ts'),
            runtime: Runtime.NODEJS_18_X,
            architecture: Architecture.ARM_64,
            handler: 'sugarFree',
            bundling: {
                sourceMap: true,
                minify: true,
                tsconfig: path.join(__dirname, '../tsconfig.json'),
            },
        });

        const sugarLambdaFunction = new NodejsFunction(this, 'SugarLambdaFunction', {
            entry: path.join(__dirname, '../src/functions/sugar/sugar.ts'),
            runtime: Runtime.NODEJS_18_X,
            architecture: Architecture.ARM_64,
            handler: 'sugar',
            bundling: {
                sourceMap: true,
                minify: true,
                tsconfig: path.join(__dirname, '../tsconfig.json'),
            },
        });

        const energyDrinkSelectorStepFunction = new ExpressStepFunction(this, 'EnergyDrinkSelectorExpress', {
            serviceName: 'energy-drink-selector',
            stage: 'dev',
            definition: energyDrinkSelectorDefinition({
                stack: this,
                energyDrinkTable: table,
                sugarLambdaFunction: sugarLambdaFunction, 
                sugarFreeLambdaFunction: sugarFreeLambdaFunction
            }),
        });

        this.stepFunction = energyDrinkSelectorStepFunction.stateMachine;
    }
};
Enter fullscreen mode Exit fullscreen mode

Hopefully, you can see a few places where we can make some updates to make use of our new TSLambdaFunction construct and maybe a little housekeeping, too.

The first thing we can do is take the tsconfig path we’re creating and store that in a variable before we create our Lambda functions. Let’s add that underneath the destructuring of the table parameter from the props object:

const tsConfigPath = path.join(__dirname, '../tsconfig.json');
Enter fullscreen mode Exit fullscreen mode

Next, we can update how we’re creating the Lambda functions in the stack. We can use our shiny new L3 construct but we’ll first need to import it, let’s add the import at the top of the file with the rest of the imports and remove the NodejsFunction import:

import path = require("path");
import { Construct } from "constructs";
import { NestedStack, NestedStackProps } from "aws-cdk-lib";
import { StateMachine } from "aws-cdk-lib/aws-stepfunctions";
import { ITable } from "aws-cdk-lib/aws-dynamodb";
import { ExpressStepFunction } from "../constructs/StepFunctions/ExpressStepFunction";
import { energyDrinkSelectorDefinition } from "../src/stepFunctions/definitions/energyDrinkSelector";
import { TSLambdaFunction } from "../constructs/LambdaFunction/TSLambdaFunction";
Enter fullscreen mode Exit fullscreen mode

With our imports updated, we can now use the TSLambdaFunction construct in place of the NodejsFunction construct. We can go from this:

const sugarFreeLambdaFunction = new NodejsFunction(this, 'SugarFreeLambdaFunction', {
    entry: path.join(__dirname, '../src/functions/sugarFree/sugarFree.ts'),
    runtime: Runtime.NODEJS_18_X,
    architecture: Architecture.ARM_64,
    handler: 'sugarFree',
    bundling: {
        sourceMap: true,
        minify: true,
        tsconfig: path.join(__dirname, '../tsconfig.json'),
    },
});

const sugarLambdaFunction = new NodejsFunction(this, 'SugarLambdaFunction', {
    entry: path.join(__dirname, '../src/functions/sugar/sugar.ts'),
    runtime: Runtime.NODEJS_18_X,
    architecture: Architecture.ARM_64,
    handler: 'sugar',
    bundling: {
        sourceMap: true,
        minify: true,
        tsconfig: path.join(__dirname, '../tsconfig.json'),
    },
});
Enter fullscreen mode Exit fullscreen mode

To this:

const sugarFreeLambdaFunction = new TSLambdaFunction(this, 'SugarFreeLambdaFunction', {
    serviceName: 'energy-drink-selector',
    stage: 'dev',
    handlerName: 'sugarFree',
    entryPath: path.join(__dirname, '../src/functions/sugarFree/sugarFree.ts'),
    tsConfigPath
});

const sugarLambdaFunction =  new TSLambdaFunction(this, 'SugarLambdaFunction', {
    serviceName: 'energy-drink-selector',
    stage: 'dev',
    handlerName: 'sugar',
    entryPath: path.join(__dirname, '../src/functions/sugar/sugar.ts'),
    tsConfigPath
});
Enter fullscreen mode Exit fullscreen mode

Looks a bit neater and nicer, right? It remains somewhat familiar to the implementation of the NodejsFunction construct but becomes a little easier to read and has our defaults for bundling and other parameters handled. With these updates our EnergyDrinkSelectorStepFunctionStack should look like this:

import path = require("path");
import { Construct } from "constructs";
import { NestedStack, NestedStackProps } from "aws-cdk-lib";
import { StateMachine } from "aws-cdk-lib/aws-stepfunctions";
import { ITable } from "aws-cdk-lib/aws-dynamodb";
import { ExpressStepFunction } from "../constructs/StepFunctions/ExpressStepFunction";
import { energyDrinkSelectorDefinition } from "../src/stepFunctions/definitions/energyDrinkSelector";
import { TSLambdaFunction } from "../constructs/LambdaFunction/TSLambdaFunction";

type EnergyDrinkSelectorStepFunctionStackProps = NestedStackProps & {
    table: ITable;
}

export class EnergyDrinkSelectorStepFunctionStack extends NestedStack {
    public readonly stepFunction: StateMachine;

    constructor(scope: Construct, id: string, props: EnergyDrinkSelectorStepFunctionStackProps) {
        super(scope, id, props);

        const { table } = props;
        const tsConfigPath = path.join(__dirname, '../tsconfig.json');

        const sugarFreeLambdaFunction = new TSLambdaFunction(this, 'SugarFreeLambdaFunction', {
            serviceName: 'energy-drink-selector',
            stage: 'dev',
            handlerName: 'sugarFree',
            entryPath: path.join(__dirname, '../src/functions/sugarFree/sugarFree.ts'),
            tsConfigPath
        });

        const sugarLambdaFunction =  new TSLambdaFunction(this, 'SugarLambdaFunction', {
            serviceName: 'energy-drink-selector',
            stage: 'dev',
            handlerName: 'sugar',
            entryPath: path.join(__dirname, '../src/functions/sugar/sugar.ts'),
            tsConfigPath
        });

        const energyDrinkSelectorStepFunction = new ExpressStepFunction(this, 'EnergyDrinkSelectorExpress', {
            serviceName: 'energy-drink-selector',
            stage: 'dev',
            definition: energyDrinkSelectorDefinition({
                stack: this,
                energyDrinkTable: table,
                sugarLambdaFunction: sugarLambdaFunction.tsLambdaFunction, 
                sugarFreeLambdaFunction: sugarFreeLambdaFunction.tsLambdaFunction
            }),
        });

        this.stepFunction = energyDrinkSelectorStepFunction.stateMachine;
    }
};
Enter fullscreen mode Exit fullscreen mode

Go Forth and Deploy!

With those last updates, we now have everything in place to have the full working end-to-end flow! We should now have a fully working app that can fulfil the mission of suggesting what energy drink we should drink based on a sugar/sugar-free preference.

A diagram showing an Express Step Function that handles the decision of sugar vs sugar free energy drink

Aaaaand relax

An AI generated image showing people relaxing on beanbags and chairs

With this episode, we have covered quite a few things, with our main focus on how we can create Lambda functions in CDK.

We created our own custom L3 construct that contains sensible defaults we want all our Lambda functions to use. Having this custom construct will also help with any updates we may make going forward. We would only have to update this construct rather than track down every use of the NodejsFunction construct we were using in our initial implementation.

We also looked at our Step Function definition and how it needed to be updated to work with our updated Lambda handler code. We used resultSelector to extract the data we needed from the Lambda function and used it in our Step Function data.

Updates to the EnergyDrinkSelectorStepFunctionStack showed us how we could make use of our shiny new custom L3 construct in our stacks and replace the calls to the NodejsFunction construct.

Thank You! Yes, You!

An AI generated image showing a big banner saying thank you with people celebrating and showing thanks

This episode is the last in the series, as we now have a fully working app. Hopefully, you have been able to learn something during the process, even if it’s something small; I’ll take that as a win.

This series was never meant to be the ‘Let’s CDK’ series to end all ‘Let’s CDK’ series. The aim was to introduce you to AWS CDK and guide you through how to provision, create and deploy AWS resources and create a little app in the process. There is plenty of scope to build upon what we have built so far. It would be really cool to see anything you may want to add. Feel free to fork the Github repo and let me know about anything you create/update!

Thank you for taking the time to read this series of posts and for putting up with my terrible puns!

TL;DR

If you want to look at the code and don’t want to read my ramblings, navigate to this Github repo and spy on everything we’ll be creating in the series.

💖 💪 🙅 🚩
theleepriest
Lee Priest

Posted on December 19, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related