Using RDS Data API with AWS Lambda Functions and Amazon Aurora Serverless v2
The RDS Data API is a service that allows us to interact with databases hosted on Amazon RDS using HTTP REST API calls. It provides a secure and easy-to-use interface for executing SQL queries, managing transactions, and fetching results without requiring direct access to the underlying database instances.
RDS Data API offers several advantages and disadvantages, which are essential to consider based on our specific use case and requirements. Here's a summary of the pros and cons:
Pros:
Simplified Access: Provides a simple HTTP-based interface for interacting with our databases, eliminating the need for managing connections in our application code.
Security: Integration with AWS IAM and AWS Secrets Manager for authentication and authorization.
Scalability: Handles database connections and connection pooling, enabling our applications to scale efficiently without being limited by the number of available connections.
Cross-Region Access: We can access our databases from anywhere using the RDS Data API, making it suitable for globally distributed applications.
Cons:
Latency: Since the RDS Data API is an HTTP-based interface, there may be additional latency compared to direct database connections.
Limited Functionality: RDS Data API may not support all database features or operations available through database connections.
Vendor Lock-In: Using the RDS Data API may introduce vendor lock-in to the AWS ecosystem.
Cost: The first 1 million requests each month are free. However, there are charges for additional requests and data transfer. Also, using AWS Secrets Manager and AWS CloudTrail (if activated) will result in extra costs.
While the RDS Data API provides several benefits for working with RDS databases, there are situations where it might not be the ideal choice, especially if our application requires high throughput, large data transfers, and/or bulk operations. On the other hand, serverless applications that don't need the features mentioned earlier could be a perfect fit. With the announcement of the Data API for Amazon Aurora Serverless v2, new possibilities have opened up for our AWS Lambda function applications.
In the rest of this article, we will write an AWS Lambda function to demonstrate how to use the AWS SDK for .NET to interact with the RDS Data API.
Pre-requisites
Have an IAM User with programmatic access.
Install the AWS CLI.
Install the Amazon Lambda Templates (
dotnet new -i Amazon.Lambda.Templates
)Install the Amazon Lambda Tools (
dotnet tool install -g Amazon.Lambda.Tools
)Install AWS SAM CLI.
The Database
In a previous article, we saw how to create an Amazon Aurora serverless v2 using AWS SAM. We will use the script presented there as a starting point. Create a template.yml
file with the following content:
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
SAM Template
Resources:
DBSubnetGroup:
Type: AWS::RDS::DBSubnetGroup
Properties:
DBSubnetGroupDescription: Aurora DB subnet group
SubnetIds:
- <MY_SUBNET_1>
- <MY_SUBNET_2>
DBSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupName: Aurora DB SG
GroupDescription: Ingress rules for Aurora DB
VpcId: <MY_VPC>
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 5432
ToPort: 5432
CidrIp: 0.0.0.0/0
DBCluster:
Type: AWS::RDS::DBCluster
DeletionPolicy: Delete
Properties:
DatabaseName: mydatabase
DBClusterIdentifier: my-dbcluster
DBSubnetGroupName: !Ref DBSubnetGroup
Engine: aurora-postgresql
EngineVersion: 15.4
MasterUsername: <MY_USER>
ManageMasterUserPassword: True
Port: 5432
EnableHttpEndpoint: true
ServerlessV2ScalingConfiguration:
MaxCapacity: 1.0
MinCapacity: 0.5
VpcSecurityGroupIds:
- !Ref DBSecurityGroup
DBInstance:
Type: AWS::RDS::DBInstance
Properties:
DBClusterIdentifier: !Ref DBCluster
DBInstanceIdentifier: my-dbinstance
DBInstanceClass: db.serverless
Engine: aurora-postgresql
Outputs:
DBSecret:
Description: Secret arn
Value: !GetAtt DBCluster.MasterUserSecret.SecretArn
DBCluster:
Description: Cluster arn
Value: !GetAtt DBCluster.DBClusterArn
In this script, at the cluster level, we set ManageMasterUserPassword
to true
to manage the password using AWS Secrets Manager. We also enable the RDS Data API by setting the property EnableHttpEndpoint
to true
. We are adding two outputs to display the ARNs for the cluster and the secret. Run the following commands to create the database:
sam build
sam deploy --guided
Let's create a table using the AWS CLI by running the following commands:
aws rds-data execute-statement --resource-arn "<MY_CLUSTER_ARN>" --database "mydatabase" --secret-arn "<MY_SECRET_ARN>" --sql "CREATE TABLE Tasks (Id VARCHAR(255) PRIMARY KEY, Name VARCHAR(50), Description VARCHAR(255));"
The Lambda Function
Run the following commands to set up our project:
dotnet new lambda.EmptyFunction -n MyApi-o .
dotnet add src/MyApi package Amazon.Lambda.APIGatewayEvents
dotnet add src/MyApi package AWSSDK.RDSDataService
dotnet new sln -n MyApi
dotnet sln add --in-root src/MyApi
Open the solution and modify the Function.cs
file as follows:
using Amazon.Lambda.APIGatewayEvents;
using Amazon.Lambda.Core;
using Amazon.RDSDataService;
using Amazon.RDSDataService.Model;
using System.Text.Json;
// Assembly attribute to enable the Lambda function's JSON input to be converted into a .NET class.
[assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer))]
namespace MyApi;
public class Function
{
private readonly IAmazonRDSDataService _client;
private readonly string _secretArn;
private readonly string _clusterArn;
private readonly string _database;
public Function()
{
_client = new AmazonRDSDataServiceClient();
_secretArn = Environment.GetEnvironmentVariable("SECRET_ARN")!;
_clusterArn = Environment.GetEnvironmentVariable("CLUSTER_ARN")!;
_database = Environment.GetEnvironmentVariable("DATABASE")!;
}
public async Task<APIGatewayHttpApiV2ProxyResponse> Register(APIGatewayHttpApiV2ProxyRequest input, ILambdaContext context)
{
var registerTaskrequest = JsonSerializer.Deserialize<RegisterTaskRequest>(input.Body)!;
var taskId = Guid.NewGuid().ToString();
var request = new ExecuteStatementRequest()
{
Sql = "insert into tasks(Id, Name, Description) VALUES(:id, :name, :description)",
ResourceArn = _clusterArn,
SecretArn = _secretArn,
Parameters = new List<SqlParameter>()
{
new SqlParameter(){ Name = "id", Value = new Field(){ StringValue=taskId } },
new SqlParameter(){ Name = "name", Value = new Field(){ StringValue=registerTaskrequest.Name } },
new SqlParameter(){ Name = "description", Value = new Field(){ StringValue=registerTaskrequest.Description } }
},
Database = _database,
};
try
{
var response = await _client.ExecuteStatementAsync(request);
var registerTaskResponse = JsonSerializer.Serialize(new RegisterTaskResponse(taskId));
return new APIGatewayHttpApiV2ProxyResponse
{
Body = registerTaskResponse,
StatusCode = 200,
Headers = new Dictionary<string, string> { { "Content-Type", "application/json" } }
};
}
catch (DatabaseErrorException)
{
return new APIGatewayHttpApiV2ProxyResponse
{
StatusCode = 500,
Headers = new Dictionary<string, string> { { "Content-Type", "application/json" } }
};
}
}
public async Task<APIGatewayHttpApiV2ProxyResponse> Get(APIGatewayHttpApiV2ProxyRequest input, ILambdaContext context)
{
var taskId = input.PathParameters["taskId"];
var request = new ExecuteStatementRequest()
{
Sql = "select Id, Name, Description from tasks where id = :id",
ResourceArn = _clusterArn,
SecretArn = _secretArn,
Parameters = new List<SqlParameter>()
{
new SqlParameter(){ Name = "id", Value = new Field(){ StringValue=taskId } },
},
Database = _database,
FormatRecordsAs = RecordsFormatType.JSON
};
try
{
var response = await _client.ExecuteStatementAsync(request);
var tasks = JsonSerializer.Deserialize<@Task[]>(response.FormattedRecords, new JsonSerializerOptions() { PropertyNameCaseInsensitive = true })!;
if(!tasks.Any())
{
return new APIGatewayHttpApiV2ProxyResponse
{
StatusCode = 404,
Headers = new Dictionary<string, string> { { "Content-Type", "application/json" } }
};
}
var task = JsonSerializer.Serialize(tasks.First());
return new APIGatewayHttpApiV2ProxyResponse
{
Body = task,
StatusCode = 200,
Headers = new Dictionary<string, string> { { "Content-Type", "application/json" } }
};
}
catch (DatabaseErrorException)
{
return new APIGatewayHttpApiV2ProxyResponse
{
StatusCode = 500,
Headers = new Dictionary<string, string> { { "Content-Type", "application/json" } }
};
}
}
public async Task<APIGatewayHttpApiV2ProxyResponse> List(APIGatewayHttpApiV2ProxyRequest input, ILambdaContext context)
{
var request = new ExecuteStatementRequest()
{
Sql = "select Id, Name, Description from tasks",
ResourceArn = _clusterArn,
SecretArn = _secretArn,
Database = _database,
FormatRecordsAs = RecordsFormatType.JSON
};
try
{
var response = await _client.ExecuteStatementAsync(request);
var tasks = JsonSerializer.Deserialize<@Task[]>(response.FormattedRecords, new JsonSerializerOptions() { PropertyNameCaseInsensitive = true })!;
return new APIGatewayHttpApiV2ProxyResponse
{
Body = JsonSerializer.Serialize(tasks),
StatusCode = 200,
Headers = new Dictionary<string, string> { { "Content-Type", "application/json" } }
};
}
catch (DatabaseErrorException)
{
return new APIGatewayHttpApiV2ProxyResponse
{
StatusCode = 500,
Headers = new Dictionary<string, string> { { "Content-Type", "application/json" } }
};
}
}
}
public record RegisterTaskRequest(string Name, string Description);
public record RegisterTaskResponse(string Id);
public record @Task(string Id, string Name, string Description);
We define functions to register, retrieve, and list all the task records from the database. In all cases, we use the ExecuteStatementAsync
method, but the complete list includes:
ExecuteStatementAsync
: Runs a SQL statement on a database.BatchExecuteStatementAsync
: Runs a batch SQL statement over an array of data for bulk update and insert operations.BeginTransactionAsync
: Starts a SQL transaction.CommitTransactionAsync
: Ends a SQL transaction and commits the changes.RollbackTransactionAsync
: Performs a rollback of a transaction.
All these operations share the following properties:
ResourceArn
: The ARN of the Aurora DB cluster.SecretArn
: The name or ARN of the secret that enables access to the DB cluster.Database
: The name of the database.
When using the ExecuteStatementAsync
operation, we can get the query results in JSON format by setting the FormatRecordsAs
property. Let's update the template.yml
to include the definition of the AWS Lambda functions:
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
SAM Template
Globals:
Function:
Runtime: dotnet6
Timeout: 30
MemorySize: 512
Architectures:
- x86_64
Environment:
Variables:
SECRET_ARN: !GetAtt DBCluster.MasterUserSecret.SecretArn
CLUSTER_ARN: !GetAtt DBCluster.DBClusterArn
DATABASE: mydatabase
Resources:
DBSubnetGroup:
Type: AWS::RDS::DBSubnetGroup
Properties:
DBSubnetGroupDescription: Aurora DB subnet group
SubnetIds:
- <MY_SUBNET_1>
- <MY_SUBNET_2>
DBSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupName: Aurora DB SG
GroupDescription: Ingress rules for Aurora DB
VpcId: <MY_VPC>
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 5432
ToPort: 5432
CidrIp: 0.0.0.0/0
DBCluster:
Type: AWS::RDS::DBCluster
DeletionPolicy: Delete
Properties:
DatabaseName: mydatabase
DBClusterIdentifier: my-dbcluster
DBSubnetGroupName: !Ref DBSubnetGroup
Engine: aurora-postgresql
EngineVersion: 15.4
MasterUsername: <MY_USER>
ManageMasterUserPassword: True
Port: 5432
EnableHttpEndpoint: true
ServerlessV2ScalingConfiguration:
MaxCapacity: 1.0
MinCapacity: 0.5
VpcSecurityGroupIds:
- !Ref DBSecurityGroup
DBInstance:
Type: AWS::RDS::DBInstance
Properties:
DBClusterIdentifier: !Ref DBCluster
DBInstanceIdentifier: my-dbinstance
DBInstanceClass: db.serverless
Engine: aurora-postgresql
RegisterFunction:
Type: AWS::Serverless::Function
Properties:
Handler: MyApi::MyApi.Function::Register
CodeUri: ./src/MyApi/
Policies:
- Statement:
- Effect: Allow
Action:
- rds-data:BatchExecuteStatement
- rds-data:BeginTransaction
- rds-data:CommitTransaction
- rds-data:RollbackTransaction
- rds-data:ExecuteStatement
Resource: !GetAtt DBCluster.DBClusterArn
- Effect: Allow
Action:
- secretsmanager:GetSecretValue
Resource: !GetAtt DBCluster.MasterUserSecret.SecretArn
Events:
RegisterTask:
Type: Api
Properties:
Path: /tasks
Method: post
GetFunction:
Type: AWS::Serverless::Function
Properties:
Handler: MyApi::MyApi.Function::Get
CodeUri: ./src/MyApi/
Policies:
- Statement:
- Effect: Allow
Action:
- rds-data:BatchExecuteStatement
- rds-data:BeginTransaction
- rds-data:CommitTransaction
- rds-data:RollbackTransaction
- rds-data:ExecuteStatement
Resource: !GetAtt DBCluster.DBClusterArn
- Effect: Allow
Action:
- secretsmanager:GetSecretValue
Resource: !GetAtt DBCluster.MasterUserSecret.SecretArn
Events:
ListTask:
Type: Api
Properties:
Path: /tasks/{taskId}
Method: get
ListFunction:
Type: AWS::Serverless::Function
Properties:
Handler: MyApi::MyApi.Function::List
CodeUri: ./src/MyApi/
Policies:
- Statement:
- Effect: Allow
Action:
- rds-data:BatchExecuteStatement
- rds-data:BeginTransaction
- rds-data:CommitTransaction
- rds-data:RollbackTransaction
- rds-data:ExecuteStatement
Resource: !GetAtt DBCluster.DBClusterArn
- Effect: Allow
Action:
- secretsmanager:GetSecretValue
Resource: !GetAtt DBCluster.MasterUserSecret.SecretArn
Events:
ListTask:
Type: Api
Properties:
Path: /tasks
Method: get
Outputs:
DBSecret:
Description: Secret arn
Value: !GetAtt DBCluster.MasterUserSecret.SecretArn
DBCluster:
Description: Cluster arn
Value: !GetAtt DBCluster.DBClusterArn
Api:
Description: "API Gateway endpoint URL"
Value:
Fn::Sub: "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/tasks/"
Here, we define three AWS Lambda functions, each with the necessary permissions to access the RDS Data API operations mentioned earlier and to read the secret that contains the database credentials. Run the following commands to deploy the AWS Lambda functions:
sam build
sam deploy
Copy the output URL and try out the functions. Integrating the RDS Data API with AWS Lambda functions is a good way to avoid dealing with connections or VPC restrictions. However, it does come with the downside of increased latency. Therefore, carefully consider if it's the right choice for your needs. All the code can be found here. Thanks, and happy coding.
Subscribe to my newsletter
Read articles from Raul Naupari directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Raul Naupari
Raul Naupari
Somebody who likes to code