Give memory to your API: Build a complete CRUD with DynamoDB and CDK

Give memory to your API: Build a complete CRUD with DynamoDB and CDK

In the previous articles, we gradually introduced you to serverless, starting with the creation and deployment of a Lambda function in the first article, followed by the addition of an API Gateway to trigger the Lambda in the second article. In both cases, the returned data consisted of simple mock responses. You have therefore acquired a solid foundation to move on to the next level: data persistence. As stated in the title, we will use DynamoDB and remain within the same approach: the discovery of AWS CDK.

CThis API encompasses the essential elements for creating modern, robust, and scalable applications.

Prerequisites

Before getting started, it is recommended that:

1. Initialize the CDK project

Create a folder for your project:

BASH
mkdir serverless-rest-api-demo
cd serverless-rest-api-demo
Click to expand and view more

Initialize a TypeScript CDK project:

BASH
cdk init app --language typescript
Click to expand and view more

CDK creates the following structure:

PGSQL
/bin  
/lib    
cdk.json 
tsconfig.json
package.json
Click to expand and view more

2. Creating the DynamoDB table

In our file /lib/serverless-rest-api-demo-stack.ts let’s declare our Tasks Table

TS
import * as cdk from 'aws-cdk-lib/core';
import { Construct } from 'constructs';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';


export class ServerlessRestApiDemoStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const tasksTable = new dynamodb.Table(this, 'TasksTable', {
      partitionKey: { name: 'taskId', type: dynamodb.AttributeType.STRING },
      tableName: 'Tasks',
      removalPolicy: cdk.RemovalPolicy.DESTROY, // NOT recommended for production code
      billingMode: dynamodb.BillingMode.PAY_PER_REQUEST
    });
  }
}
Click to expand and view more

In this section of our CDK Stack, we define our storage table. Here are the details of what each line of code does:

3. Create the Lambda functions

Ceate a /lambda/ folder and the following files:

SH
npm install @aws-sdk/lib-dynamodb
npm install uuid
Click to expand and view more

1. Creating a task

TS
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { PutCommand ,DynamoDBDocumentClient} from "@aws-sdk/lib-dynamodb";
import { v4 as uuidv4 } from 'uuid';

const client= new DynamoDBClient({});
const docClient =  DynamoDBDocumentClient.from(client);
const TABLE_NAME = process.env.TABLE_NAME || 'Tasks';

export const handler = async (event: any) => {
    const requestBody = JSON.parse(event.body);

    const taskItem = {
        taskId: uuidv4(),
        title: requestBody.title,
        taskStatus: requestBody.taskStatus,
        createdAt: new Date().toISOString()
    };

    try {
        await docClient.send(new PutCommand({
            TableName: TABLE_NAME,
            Item: taskItem
        }));

        return {
            statusCode: 201,
            body: JSON.stringify({ message: 'Task created successfully', task: taskItem })
        };
    } catch (error) {
        console.error('Error creating task:', error);
        return {
            statusCode: 500,
            body: JSON.stringify({ message: 'Failed to create task' })
        };
    }
}
Click to expand and view more

2. Tasks’s List

TS
import { DynamoDBClient, ScanCommand } from "@aws-sdk/client-dynamodb";
import {  DynamoDBDocumentClient} from "@aws-sdk/lib-dynamodb";


const client= new DynamoDBClient({});
const docClient =  DynamoDBDocumentClient.from(client);
const TABLE_NAME = process.env.TABLE_NAME || 'Tasks';

export const handler = async (event: any) => {
   try {
       // Scan the DynamoDB table to get all tasks
       const data= await docClient.send( new ScanCommand({
           TableName: TABLE_NAME
       }));

       return {
           statusCode: 200,
           body: JSON.stringify({ tasks: data.Items })
       };
   } catch (error) {
       console.error('Error listing tasks:', error);
       return {
           statusCode: 500,
           body: JSON.stringify({ message: 'Failed to list tasks' })
       };
   }
}
Click to expand and view more

Code explanation: Listing all tasks This function acts as an inventory of your database:

  1. ScanCommand: This is the instruction that tells DynamoDB to read the entire table. It is the most direct method to retrieve all the data at once.
  2. data.Items: The result of the scan contains an array called Items. This is where all our tasks are stored (ID, title, status, etc.).
  3. Error handling: If the table is inaccessible or if the name is incorrect.

3. Retrieving a task

TS
// code for lambda/get-task.ts
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient ,GetCommand} from "@aws-sdk/lib-dynamodb";

const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);
const TABLE_NAME = process.env.TABLE_NAME || 'Tasks';

export const handler = async (event: any) => {
    const taskId = event.pathParameters?.taskId;

    if (!taskId) {
        return {
            statusCode: 400,
            body: JSON.stringify({ message: 'Task ID is required' })
        };
    }

    try {
        const data = await docClient.send(new GetCommand({
            TableName: TABLE_NAME,
            Key: { taskId }
        }));

        if (!data.Item) {
            return {
                statusCode: 404,
                body: JSON.stringify({ message: 'Task not found' })
            };
        }

        return {
            statusCode: 200,
            body: JSON.stringify({ task: data.Item })
        };
    } catch (error) {
        console.error('Error getting task:', error);
        return {
            statusCode: 500,
            body: JSON.stringify({ message: 'Failed to get task' })
        };
    }
}
Click to expand and view more

This function allows us to retrieve a specific item from our database

3. Deleting task

TS
// code for lambda/delete-task.ts
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, DeleteCommand } from "@aws-sdk/lib-dynamodb";

const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);
const TABLE_NAME = process.env.TABLE_NAME || 'Tasks';

export const handler = async (event: any) => {
    const taskId = event.pathParameters?.taskId;

    if (!taskId) {
        return {
            statusCode: 400,
            body: JSON.stringify({ message: 'Task ID is required' })
        };
    }

    try {
        await docClient.send(new DeleteCommand({
            TableName: TABLE_NAME,
            Key: { taskId }
        }));

        return {
            statusCode: 200,
            body: JSON.stringify({ message: 'Task deleted successfully' })
        };
    } catch (error) {
        console.error('Error deleting task:', error);
        return {
            statusCode: 500,
            body: JSON.stringify({ message: 'Failed to delete task' })
        };
    }
}
Click to expand and view more

Explanation Deletion is the final action in our data cycle. It is very similar to retrieving (Get), but with a different purpose.

4. Dynamic Update

TS
// code for lambda/update-task.ts
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, UpdateCommand } from "@aws-sdk/lib-dynamodb";

const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);
const TABLE_NAME = process.env.TABLE_NAME || 'Tasks';

export const handler = async (event: any) => {
    const taskId = event.pathParameters?.taskId;
    const requestBody = JSON.parse(event.body);

    if (!taskId) {
        return {
            statusCode: 400,
            body: JSON.stringify({ message: 'Task ID is required' })
        };
    }

    const updateExpressionParts = [];
    const expressionAttributeValues: any = {};

    if (requestBody.title) {
        updateExpressionParts.push('title = :title');
        expressionAttributeValues[':title'] = requestBody.title;
    }

    if (requestBody.taskStatus) {
        updateExpressionParts.push('taskStatus = :taskStatus');
        expressionAttributeValues[':taskStatus'] = requestBody.taskStatus;
    }

    if (updateExpressionParts.length === 0) {
        return {
            statusCode: 400,
            body: JSON.stringify({ message: 'No valid fields to update' })
        };
    }

    const updateExpression = 'SET ' + updateExpressionParts.join(', ');

    try {
        const data = await docClient.send(new UpdateCommand({
            TableName: TABLE_NAME,
            Key: { taskId },
            UpdateExpression: updateExpression,
            ExpressionAttributeValues: expressionAttributeValues,
            ReturnValues: 'ALL_NEW'
        }));

        return {
            statusCode: 200,
            body: JSON.stringify({ message: 'Task updated successfully', task: data.Attributes })
        };
    } catch (error) {
        console.error('Error updating task:', error);
        return {
            statusCode: 500,
            body: JSON.stringify({ message: 'Failed to update task' })
        };
    }
}   
Click to expand and view more

Explanation Unlike PutCommand, which replaces the entire object, UpdateCommand allows you to modify only the fields provided by the users (such as the title, the status, or both).

4. Creating routes

In this section, we will create our API Gateway and associate each Lambda function with a specific route.

TS
import * as cdk from 'aws-cdk-lib/core';
import { Construct } from 'constructs';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import * as apiGateway from 'aws-cdk-lib/aws-apigateway';
import* as lambdaNodejs from 'aws-cdk-lib/aws-lambda-nodejs';

export class ServerlessRestApiDemoStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const tasksTable = new dynamodb.Table(this, 'TasksTable', {
      partitionKey: { name: 'taskId', type: dynamodb.AttributeType.STRING },
      tableName: 'Tasks',
      removalPolicy: cdk.RemovalPolicy.DESTROY, // NOT recommended for production code
      billingMode: dynamodb.BillingMode.PAY_PER_REQUEST
    });
    
    // Lambda function to create a task
    const createTaskFunction = new lambdaNodejs.NodejsFunction(this, 'CreateTaskFunction', {
      entry: 'lambda/create-task.ts',
      handler: 'handler',
      functionName: 'CreateTaskFunction',
      environment: {
        TABLE_NAME: tasksTable.tableName
      }
    });

    // Grant the Lambda function permissions to write to the DynamoDB table
    tasksTable.grantWriteData(createTaskFunction);
    
    // Lambda function to list tasks
    const listTasksFunction = new lambdaNodejs.NodejsFunction(this, 'ListTasksFunction', {
      entry: 'lambda/list-tasks.ts',
      handler: 'handler',
      functionName: 'ListTasksFunction',
      environment: {
        TABLE_NAME: tasksTable.tableName
      }
    });

    // Grant the Lambda function permissions to read from the DynamoDB table
    tasksTable.grantReadData(listTasksFunction);

    // Lambda function to get a single task
    const getTaskFunction = new lambdaNodejs.NodejsFunction(this, 'GetTaskFunction', {
      entry: 'lambda/get-task.ts',
      handler: 'handler',
      functionName: 'GetTaskFunction',
      environment: {
        TABLE_NAME: tasksTable.tableName
      }
    });

    // Grant the Lambda function permissions to read from the DynamoDB table
    tasksTable.grantReadData(getTaskFunction);

    // Lambda function to delete a task
    const deleteTaskFunction = new lambdaNodejs.NodejsFunction(this, 'DeleteTaskFunction', {
      entry: 'lambda/delete-task.ts',
      handler: 'handler',
      functionName: 'DeleteTaskFunction',
      environment: {
        TABLE_NAME: tasksTable.tableName
      }
    });

    // Grant the Lambda function permissions to delete from the DynamoDB table
    tasksTable.grantWriteData(deleteTaskFunction);

    // Lambda function to update a task
    const updateTaskFunction = new lambdaNodejs.NodejsFunction(this, 'UpdateTaskFunction', {
      entry: 'lambda/update-task.ts',
      handler: 'handler',
      functionName: 'UpdateTaskFunction',
      environment: {
        TABLE_NAME: tasksTable.tableName
      }
    });
    
    // Grant the Lambda function permissions to update the DynamoDB table
    tasksTable.grantWriteData(updateTaskFunction);

    // create Api Gateway and Lambda functions here
    const api = new apiGateway.RestApi(this, 'TasksApi', {
      restApiName: 'Tasks Service',
      description: 'This service serves tasks.'
    });
    // create tasks resource
    const tasks = api.root.addResource('tasks');

    // POST /tasks - create a new task
    tasks.addMethod('POST', new apiGateway.LambdaIntegration(createTaskFunction));

    // GET /tasks - list all tasks
    tasks.addMethod('GET', new apiGateway.LambdaIntegration(listTasksFunction));

    // /tasks/{taskId} resource
    const singleTask = tasks.addResource('{taskId}');

    // GET /tasks/{taskId} - get a single task
    singleTask.addMethod('GET', new apiGateway.LambdaIntegration(getTaskFunction));

    // DELETE /tasks/{taskId} - delete a task
    singleTask.addMethod('DELETE', new apiGateway.LambdaIntegration(deleteTaskFunction));

    // PUT /tasks/{taskId} - update a task
    singleTask.addMethod('PUT', new apiGateway.LambdaIntegration(updateTaskFunction));

    // Output the API endpoint URL
    new cdk.CfnOutput(this, 'ApiUrl', {
      value: api.url,
      description: 'The URL of the Tasks API'
    });
  }
}
Click to expand and view more

Explanation: As a new feature here, we have

TS
 // Lambda function to create a task
    const createTaskFunction = new lambdaNodejs.NodejsFunction(this, 'CreateTaskFunction', {
      entry: 'lambda/create-task.ts',
      handler: 'handler',
      functionName: 'CreateTaskFunction',
      environment: {
        TABLE_NAME: tasksTable.tableName
      }
    });

    // Grant the Lambda function permissions to write to the DynamoDB table
    tasksTable.grantWriteData(createTaskFunction);
Click to expand and view more

Summary whe have

5. Déployer la Lambda

before deploy:

SH
cdk bootstrap
cdk synth  

cdk deploy 
Click to expand and view more

5. Testing functions

After deployment, the console provides a link to invoke the Lambda functions.

Go to the AWS console to explore the API Gateway resource.

Api gateway

Test using tools like Postman

Api gateway Get Task

6. Clean Up

SH
cdk destroy
Click to expand and view more

This deletes the Lambda functions, the API Gateway resources, and the DynamoDB table.

The next step in our journey will be the deployment of a static website. Since we have created a REST API, it is now time to deploy an Angular or React application to interact with our REST API.

Comments

Start searching

Enter keywords to search articles

↑↓
ESC
⌘K Shortcut