To connect to DynamoDB we have to set up the code in our Rust function to do so, and also allow it to do so from our CDK code. By default our function is not allowed to do much of anything besides run.
cargo add -p pokemon-api aws_config aws_sdk_dynamodb
Ideally we want our DynamoDB connection to get instantiated in the cold start of our lambda function, then get re-used across future invocations.
To do that we’ll set up a static called DDB_CLIENT. Uppercase letters here is a convention enforced by compiler warnings. A static item defined like this is a globally unique constant that lives as long as the program does.
The type of the static is going to be a OnceCell<Client> which we’ll instantiate with OnceCell::const_new().
OnceCell is a data structure from the tokio crate that is only allowed to be set once and Client is a DynamoDB client.
This all comes together to mean that DDB_CLIENT is a globally unique constant that can only be set once, and contains an initialized DynamoDB Client.
use tokio::sync::OnceCell;
use aws_sdk_dynamodb::Client;
static DDB_CLIENT: OnceCell<Client> = OnceCell::const_new();
async fn get_global_client() -> &'static Client {
DDB_CLIENT
.get_or_init(|| async {
let config =
aws_config::load_from_env().await;
let client = Client::new(&config);
client
})
.await
}
We define an extra function here called get_global_client. The name of the function is arbitrary, but it’s async so that we can wait on it before our function runs.
This function returns a shared reference to a Client with a 'static lifetime. The static lifetime means that this value lives until the end of our program.
This is because we’re sharing the Client that we’ve stored in DDB_CLIENT.
This function always calls get_or_init on the DDB_CLIENT constant. This allows us to instantiate the client if one doesn’t exist yet, or return a shared reference to the existing client if it does already.
On the first run of this function, the async block uses aws_config to load our AWS credentials from the environment of the lambda function, then constructs a DynamoDB Client in the same way we did when uploading data.
We then return that client, storing it in the OnceCell and the get_or_init will return a shared reference to the value we just constructed.
The first place we use this is in our main function. Note that we don’t actually use the value here, we’re just making sure it’s initialized in the cold start of our lambda function.
async fn main() -> Result<(), Error> {
get_global_client().await;
let handler_fn = service_fn(handler);
lambda_runtime::run(handler_fn).await?;
Ok(())
}
The second place we use the function is in our handler, where we do actually use the function to talk to DynamoDB.
First we need the pokemon_table name, which we’ll get via an environment variable.
Then we can get a shared reference to the global DynamoDB client using get_global_client.
let pokemon_table = env::var("POKEMON_TABLE")?;
let client = get_global_client().await;
The client itself offers a get_item builder that we can use to set the pk field to the requested Pokemon. Note that we have to wrap our string in the AttributeValue enum here.
We also pass in the table name and initiate the request with send, then immediately await its completion.
let resp = client
.get_item()
.key("pk", AttributeValue::S(pokemon_requested.to_string()))
.table_name(pokemon_table)
.send()
.await?;
resp.item is an Option<HashMap<String, AttributeValue>> which is a type you’ll remember from when we uploaded the data to Dynamo. It’s the key/value pairs we sent up to Dyanmo in the first place.
We can then replace our previous response code, with a match on resp.item to handle the Option.
match resp.item {
Some(item) => {
Ok(ApiGatewayV2httpResponse {
status_code: 200,
headers: HeaderMap::new(),
multi_value_headers: HeaderMap::new(
),
body: Some(Body::Text(
serde_json::to_string(
&json!({
"data": {
"id": item.get("pk").unwrap().as_s().unwrap(),
"name": item.get("name").unwrap().as_s().unwrap(),
"healthPoints": item.get("health_points").unwrap().as_n().unwrap()
},
}),
)?,
)),
is_base64_encoded: Some(false),
cookies: vec![],
})
}
None => Ok(ApiGatewayV2httpResponse {
status_code: 200,
headers: HeaderMap::new(),
multi_value_headers: HeaderMap::new(),
body: Some(Body::Text(
serde_json::to_string(&json!({
"data": {}
}))?,
)),
is_base64_encoded: Some(false),
cookies: vec![],
}),
}
In the None case we return an empty data object. You can choose to do whatever you want here, including returning a 404 or an error in the JSON instead.
In the success case we have a HashMap<String, AttributeValue> that we need to turn into JSON. Unfortunately, since the official aws-sdk-dynamodb is fairly new, we don’t have high level APIs to handle this for us.
AttributeValue, for example, doesn’t implement any traits that would make it work out of the box with serde.
So we’re left with having to handle it manually ourselves for now.
For each field we want to include in the response, we’ll use item.get which returns an Option. Since we know these fields have to exist, we can unwrap them. If we weren’t sure if the field was going to exist, we could handle it another way.
Once we have the underlying AttributeValue, we call as_s, or the appropriate as* function to “unwrap” the AttributeValue back into a String. Of course this can also fail, but we’ve stored the data in a way that should never fail, so we unwrap the Result here as well.
item.get("pk").unwrap().as_s().unwrap(),
unwrapping may not be the most elegant way to handle this code, but as long as the assumption we’re making is “this should never fail”, then it’s perfectly acceptable.
Now build and copy the binary into our pokemon-api directory once again.
cargo zigbuild --target x86_64-unknown-linux-gnu.2.26 --release -p pokemon-api
cp target/x86_64-unknown-linux-gnu/release/pokemon-api lambdas/pokemon-api/bootstrap
CDK
With the Rust code set up, we still need to fixup our CDK code.
Specifically we need to grant access to the DynamoDB table to our lambda. In this case we’ve chosen to give it full access, but we could also restrict it to just being able to run get_item.
We also set the POKEMON_TABLE environment variable in the lambda’s environment, so that our Rust code has access to the DynamoDB table name.
pokemonTable.grantFullAccess(pokemonLambda);
pokemonLambda.addEnvironment("POKEMON_TABLE", pokemonTable.tableName);
Then we can diff again to see the changes
❯ npm run cdk diff -- --profile rust-adventure-playground
> infra@0.1.0 cdk
> cdk "diff" "--profile" "rust-adventure-playground"
Stack InfraStack
IAM Statement Changes
┌───┬──────┬──────┬──────┬──────┬──────┐
│ │ Reso │ Effe │ Acti │ Prin │ Cond │
│ │ urce │ ct │ on │ cipa │ itio │
│ │ │ │ │ l │ n │
├───┼──────┼──────┼──────┼──────┼──────┤
│ + │ ${Po │ Allo │ dyna │ AWS: │ │
│ │ kemo │ w │ modb │ ${Po │ │
│ │ nTab │ │ :* │ kemo │ │
│ │ le.A │ │ │ nHan │ │
│ │ rn} │ │ │ dler │ │
│ │ │ │ │ /Ser │ │
│ │ │ │ │ vice │ │
│ │ │ │ │ Role │ │
│ │ │ │ │ } │ │
└───┴──────┴──────┴──────┴──────┴──────┘
(NOTE: There may be security-related changes not in this list. See https://github.com/aws/aws-cdk/issues/1299)
Resources
[+] AWS::IAM::Policy PokemonHandler/ServiceRole/DefaultPolicy PokemonHandlerServiceRoleDefaultPolicy09C7DA9D
[~] AWS::Lambda::Function PokemonHandler PokemonHandlerC37D7DE3
├─ [+] Environment
│ └─ {"Variables":{"POKEMON_TABLE":{"Ref":"PokemonTable7DFA0E9C"}}}
└─ [~] DependsOn
└─ @@ -1,3 +1,4 @@
[ ] [
[+] "PokemonHandlerServiceRoleDefaultPolicy09C7DA9D",
[ ] "PokemonHandlerServiceRoleF58AC6D6"
[ ] ]
and after deploying, we can curl the url again for bulbasaur, charmander, bidoof, or any other pokemon.
❯ curl https://72i5uisgr5.execute-api.us-east-1.amazonaws.com/pokemon/bulbasaur
{"data":{"healthPoints":"45","id":"bulbasaur","name":"Bulbasaur"}}
❯ curl https://72i5uisgr5.execute-api.us-east-1.amazonaws.com/pokemon/charmander
{"data":{"healthPoints":"39","id":"charmander","name":"Charmander"}}
❯ curl https://72i5uisgr5.execute-api.us-east-1.amazonaws.com/pokemon/bidoof
{"data":{"healthPoints":"59","id":"bidoof","name":"Bidoof"}}