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"}}