tiny_dynamo/lib.rs
1//! <div align="center">
2//! 📦 🤏
3//! </div>
4//!
5//! <h1 align="center">
6//! tiny dynamo
7//! </h1>
8//!
9//! <p align="center">
10//! A tinier, simpler, key-value focused interface for AWS DynamoDB
11//! </p>
12//!
13//! <div align="center">
14//! <a href="https://github.com/softprops/tiny-dynamo/actions">
15//! <img src="https://github.com/softprops/tiny-dynamo/workflows/Main/badge.svg"/>
16//! </a>
17//! </div>
18//!
19//! ### Install
20//!
21//! To install tiny dynamo add the following to your `Cargo.toml` file.
22//!
23//! ```toml
24//! [dependencies]
25//! tiny-dynamo = "0.1"
26//! ```
27//!
28//! ### Tiny what now?
29//!
30//! > Amazon DynamoDB is a key-value and document database that delivers single-digit millisecond performance at any scale.
31//!
32//! This quote comes directly from the [Amazon DynamoDB docs](https://aws.amazon.com/dynamodb/). This combinaton has some implications on its client APIs that are less than ideal for very simple key-value applications. These interfaces can be overly complicated and sometimes daunting for the uninitiated to say the least.
33//!
34//! Tiny Dynamo aims to leverge the useful parts of DynamoDB, the performance and scalability, but expose a much smaller and simpler API that you might expect from a key-value database.
35//!
36//! ### Usage
37//!
38//! ```rust ,no_run
39//! use std::{env, error::Error};
40//! use tiny_dynamo::{reqwest_transport::Reqwest, Credentials, Table, DB};
41//!
42//! fn main() -> Result<(), Box<dyn Error>> {
43//! let db = DB::new(
44//! Credentials::new(
45//! env::var("AWS_ACCESS_KEY_ID")?,
46//! env::var("AWS_SECRET_ACCESS_KEY")?,
47//! ),
48//! Table::new(
49//! "table-name",
50//! "key-attr-name",
51//! "value-attr-name",
52//! "us-east-1".parse()?,
53//! None
54//! ),
55//! Reqwest::new(),
56//! );
57//!
58//! println!("{:#?}", db.set("foo", "bar")?);
59//! println!("{:#?}", db.get("foo")?);
60//!
61//! Ok(())
62//! }
63//! ```
64//!
65//! A few notable differences when comparing Tiny Dynamo to traditional DynamoDB clients is that this client assumes a single table, a very common case for most DynamodbDB applications, so you configure your client with that table name so you don't need to redundantly provide it with each request.
66//!
67//! You will also find the interface is reduced to `get(key)` `set(key,value)`. This is intentional as this client is primarily focused on being a more comfortable fit for simple key-value applications.
68//!
69//! ## Features
70//!
71//! ### Tiny
72//!
73//! Tiny Dynamo avoids packing carry-on luggage for anything you don't explicitly need for a simple key-value application. This includes an entire sdk and a transient line of dependencies. This allows it to fit more easily into smaller spaces and to deliver on Rust's zero cost promise of not paying for what you don't use.
74//!
75//! ### Simpler Data Modeling
76//!
77//! A common laborious activity with DynamoDB based applications figuring our your application's data model first and then translating that to a DynamoDB's key space design and catalog of item attribute types. This is fine and expected for applications that require more advanced query access patterns. For simple key-value applications, this is just tax. Tiny DynamoDB assumes a key-value data model.
78//!
79//! ### Just the data plane
80//!
81//! You can think of the DynamoDB API in terms of two planes: The _data_ plane*cation cases, and the *control\* plane, a set of APIs for provisioning the resources that will store your data. Combining these makes its surface area arbitrarily larger that it needs to be. Tiny Dynamo focuses on exposing just the data plane to retain a smaller surface area to learn.
82//!
83//! ### Sans I/O
84//!
85//! Tiny Dynamo takes a [sans I/O](https://sans-io.readthedocs.io/) library approach. It defines a `Transport` trait which allows for any I/O library to implement how requests are transfered over the wire by provides none without an explict cargo feature toggled on
86//!
87//! Below are the current available cargo features
88//!
89//! #### `reqwest`
90//!
91//! the `reqwest` feature provides a `reqwest_transport::Reqwest` backend for sending requests, currently using a blocking client. An async feature is planned for the future
92//!
93//! ```toml
94//! [dependencies]
95//! tiny-dynamo = { version = "0.1", features = ["reqwest"]}
96//! ```
97//!
98//! #### `fastly`
99//!
100//! The `fastly` feature provides a `fastly_transport::Fastly` backend for sending requests suitable for Fastlys Compute@Edge platform
101//!
102//! ```toml
103//! [dependencies]
104//! tiny-dynamo = { version = "0.1", features = ["fastly"]}
105//! ```
106//!
107//! ### BYOIO
108//!
109//! If you would like to bring your own IO implementation you can define an implementation for a custom type
110//!
111//! ```rust
112//! use tiny_dynamo::{Request, Transport};
113//! use std::error::Error;
114//!
115//! struct CustomIO;
116
117//!
118//! impl Transport for CustomIO {
119//! fn send(&self, signed: Request) -> Result<(u16, String), Box<dyn Error>> {
120//! Ok(
121//! (200,"...".into())
122//! )
123//! }
124//! }
125//! ```
126//!
127
128//#![doc = include_str!("../README.md")]
129#[cfg(feature = "fastly")]
130pub mod fastly_transport;
131mod region;
132#[cfg(feature = "reqwest")]
133pub mod reqwest_transport;
134
135use chrono::{DateTime, Utc};
136use hmac::{Hmac, Mac, NewMac};
137use http::{
138 header::{HeaderName, AUTHORIZATION, CONTENT_LENGTH, CONTENT_TYPE, HOST},
139 method::Method,
140 Request as HttpRequest, Uri,
141};
142pub use region::Region;
143use serde::{Deserialize, Serialize};
144use sha2::{Digest, Sha256};
145use std::{collections::HashMap, error::Error, fmt::Display, iter::FromIterator};
146
147const SHORT_DATE: &str = "%Y%m%d";
148const LONG_DATETIME: &str = "%Y%m%dT%H%M%SZ";
149const X_AMZ_CONTENT_SHA256: &[u8] = b"X-Amz-Content-Sha256";
150
151/// A type alias for `http::RequestVec<u8>`
152pub type Request = HttpRequest<Vec<u8>>;
153type HmacSha256 = Hmac<Sha256>;
154
155/// A set of AWS credentials to authenticate requests with
156pub struct Credentials {
157 aws_access_key_id: String,
158 aws_secret_access_key: String,
159}
160
161impl Credentials {
162 pub fn new(
163 aws_access_key_id: impl AsRef<str>,
164 aws_secret_access_key: impl AsRef<str>,
165 ) -> Self {
166 Self {
167 aws_access_key_id: aws_access_key_id.as_ref().to_owned(),
168 aws_secret_access_key: aws_secret_access_key.as_ref().to_owned(),
169 }
170 }
171}
172
173/// Information about your target AWS DynamoDB table
174#[non_exhaustive]
175pub struct Table {
176 /// The name of your DynamoDB
177 pub table_name: String,
178 /// The name of the attribute that will store your key
179 pub key_name: String,
180 /// The name of the attribute that will store your value
181 pub value_name: String,
182 /// The AWS region the table is hosted in.
183 ///
184 /// When `endpoint` is defined, the value of this field is is somewhat arbitrary
185 pub region: Region,
186 /// An Optional, uri to address the DynamoDB api, often times just for dynamodb local
187 pub endpoint: Option<String>,
188}
189
190impl Table {
191 pub fn new(
192 table_name: impl AsRef<str>,
193 key_name: impl AsRef<str>,
194 value_name: impl AsRef<str>,
195 region: Region,
196 endpoint: impl Into<Option<String>>,
197 ) -> Self {
198 Self {
199 table_name: table_name.as_ref().into(),
200 key_name: key_name.as_ref().into(),
201 value_name: value_name.as_ref().into(),
202 region,
203 endpoint: endpoint.into(),
204 }
205 }
206}
207
208/// A trait to implement the behavior for sending requests, often your "IO" layer
209pub trait Transport {
210 /// Accepts a signed `http::Request<Vec<u8>>` and returns a tuple
211 /// representing a response's HTTP status code and body
212 fn send(
213 &self,
214 signed: Request,
215 ) -> Result<(u16, String), Box<dyn Error>>;
216}
217
218#[derive(Serialize, Deserialize)]
219enum Attr {
220 S(String),
221}
222
223#[derive(Serialize)]
224#[serde(rename_all = "PascalCase")]
225struct PutItemInput<'a> {
226 table_name: &'a str,
227 item: HashMap<&'a str, Attr>,
228}
229
230#[derive(Serialize)]
231#[serde(rename_all = "PascalCase")]
232struct GetItemInput<'a> {
233 table_name: &'a str,
234 key: HashMap<&'a str, Attr>,
235 projection_expression: &'a str,
236 expression_attribute_names: HashMap<&'a str, &'a str>,
237}
238
239#[derive(Deserialize)]
240#[serde(rename_all = "PascalCase")]
241struct GetItemOutput {
242 item: HashMap<String, Attr>,
243}
244
245#[derive(Deserialize, Debug)]
246#[serde(rename_all = "PascalCase")]
247struct AWSError {
248 #[serde(alias = "__type")]
249 __type: String,
250 message: String,
251}
252
253impl Display for AWSError {
254 fn fmt(
255 &self,
256 f: &mut std::fmt::Formatter<'_>,
257 ) -> std::fmt::Result {
258 f.write_str(self.__type.as_str())?;
259 f.write_str(": ")?;
260 f.write_str(self.message.as_str())
261 }
262}
263
264impl Error for AWSError {}
265
266#[derive(Debug)]
267struct StrErr(String);
268
269impl Display for StrErr {
270 fn fmt(
271 &self,
272 f: &mut std::fmt::Formatter<'_>,
273 ) -> std::fmt::Result {
274 f.write_str(self.0.as_str())
275 }
276}
277
278impl Error for StrErr {}
279
280/// The central client interface applications will work with
281///
282/// # Example
283///
284/// ```rust ,no_run
285/// # use std::{env, error::Error};
286/// # use tiny_dynamo::{reqwest_transport::Reqwest, Credentials, Table, DB};
287/// # fn main() -> Result<(), Box<dyn Error>> {
288///let db = DB::new(
289/// Credentials::new(
290/// env::var("AWS_ACCESS_KEY_ID")?,
291/// env::var("AWS_SECRET_ACCESS_KEY")?,
292/// ),
293/// Table::new(
294/// "table-name",
295/// "key-attr-name",
296/// "value-attr-name",
297/// "us-east-1".parse()?,
298/// None
299/// ),
300/// Reqwest::new(),
301///);
302/// # Ok(())
303/// # }
304/// ```
305pub struct DB {
306 credentials: Credentials,
307 table_info: Table,
308 transport: Box<dyn Transport>,
309}
310
311impl DB {
312 /// Returns a new instance of a DB
313 pub fn new(
314 credentials: Credentials,
315 table_info: Table,
316 transport: impl Transport + 'static,
317 ) -> Self {
318 Self {
319 credentials,
320 table_info,
321 transport: Box::new(transport),
322 }
323 }
324
325 /// Gets a value by its key
326 pub fn get(
327 &self,
328 key: impl AsRef<str>,
329 ) -> Result<Option<String>, Box<dyn Error>> {
330 let Table { value_name, .. } = &self.table_info;
331 match self.transport.send(self.get_item_req(key)?)? {
332 (200, body) if body.as_str() == "{}" => Ok(None), // not found
333 (200, body) => Ok(serde_json::from_str::<GetItemOutput>(&body)?
334 .item
335 .get(value_name)
336 .iter()
337 .find_map(|attr| match attr {
338 Attr::S(v) => Some(v.clone()),
339 })),
340 (_, body) => Err(Box::new(serde_json::from_str::<AWSError>(&body)?)),
341 }
342 }
343
344 /// Sets a value for a given key
345 pub fn set(
346 &self,
347 key: impl AsRef<str>,
348 value: impl AsRef<str>,
349 ) -> Result<(), Box<dyn Error>> {
350 match self.transport.send(self.put_item_req(key, value)?)? {
351 (200, _) => Ok(()),
352 (_, body) => Err(Box::new(serde_json::from_str::<AWSError>(&body)?)),
353 }
354 }
355
356 #[doc(hidden)]
357 pub fn put_item_req(
358 &self,
359 key: impl AsRef<str>,
360 value: impl AsRef<str>,
361 ) -> Result<Request, Box<dyn Error>> {
362 // https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_PutItem.html
363 let req = http::Request::builder();
364 let Table {
365 table_name,
366 key_name,
367 value_name,
368 region,
369 endpoint,
370 ..
371 } = &self.table_info;
372 let uri: Uri = endpoint
373 .as_deref()
374 .unwrap_or_else(|| region.endpoint())
375 .parse()?;
376 self.sign(
377 req.method(Method::POST)
378 .uri(&uri)
379 .header(HOST, uri.authority().expect("expected host").as_str())
380 .header(CONTENT_TYPE, "application/x-amz-json-1.0")
381 .header("X-Amz-Target", "DynamoDB_20120810.PutItem")
382 .body(serde_json::to_vec(&PutItemInput {
383 table_name,
384 item: HashMap::from_iter([
385 (key_name.as_str(), Attr::S(key.as_ref().to_owned())),
386 (value_name.as_ref(), Attr::S(value.as_ref().to_owned())),
387 ]),
388 })?)?,
389 )
390 }
391
392 #[doc(hidden)]
393 pub fn get_item_req(
394 &self,
395 key: impl AsRef<str>,
396 ) -> Result<Request, Box<dyn Error>> {
397 // https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_GetItem.html
398 let req = http::Request::builder();
399 let Table {
400 table_name,
401 key_name,
402 value_name,
403 region,
404 endpoint,
405 ..
406 } = &self.table_info;
407 let uri: Uri = endpoint
408 .as_deref()
409 .unwrap_or_else(|| region.endpoint())
410 .parse()?;
411 self.sign(
412 req.method(Method::POST)
413 .uri(&uri)
414 .header(HOST, uri.authority().expect("expected host").as_str())
415 .header(CONTENT_TYPE, "application/x-amz-json-1.0")
416 .header("X-Amz-Target", "DynamoDB_20120810.GetItem")
417 .body(serde_json::to_vec(&GetItemInput {
418 table_name,
419 key: HashMap::from_iter([(
420 key_name.as_str(),
421 Attr::S(key.as_ref().to_owned()),
422 )]),
423 // we use #v because https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/ReservedWords.html
424 projection_expression: "#v",
425 expression_attribute_names: HashMap::from_iter([("#v", value_name.as_ref())]),
426 })?)?,
427 )
428 }
429
430 fn sign(
431 &self,
432 mut unsigned: Request,
433 ) -> Result<Request, Box<dyn Error>> {
434 fn hmac(
435 key: &[u8],
436 data: &[u8],
437 ) -> Result<Vec<u8>, Box<dyn Error>> {
438 let mut mac = HmacSha256::new_from_slice(key).map_err(|e| StrErr(e.to_string()))?;
439 mac.update(data);
440 Ok(mac.finalize().into_bytes().to_vec())
441 }
442
443 let body_digest = {
444 let mut sha = Sha256::default();
445 sha.update(unsigned.body());
446 hex::encode(sha.finalize().as_slice())
447 };
448
449 let now = Utc::now();
450 unsigned
451 .headers_mut()
452 .append("X-Amz-Date", now.format(LONG_DATETIME).to_string().parse()?);
453
454 fn signed_header_string(headers: &http::HeaderMap) -> String {
455 let mut keys = headers
456 .keys()
457 .map(|key| key.as_str().to_lowercase())
458 .collect::<Vec<_>>();
459 keys.sort();
460 keys.join(";")
461 }
462
463 fn string_to_sign(
464 datetime: &DateTime<Utc>,
465 region: &str,
466 canonical_req: &str,
467 ) -> String {
468 let mut hasher = Sha256::default();
469 hasher.update(canonical_req.as_bytes());
470 format!(
471 "AWS4-HMAC-SHA256\n{timestamp}\n{scope}\n{canonical_req_hash}",
472 timestamp = datetime.format(LONG_DATETIME),
473 scope = scope_string(datetime, region),
474 canonical_req_hash = hex::encode(hasher.finalize().as_slice())
475 )
476 }
477
478 fn signing_key(
479 datetime: &DateTime<Utc>,
480 secret_key: &str,
481 region: &str,
482 ) -> Result<Vec<u8>, Box<dyn Error>> {
483 [region.as_bytes(), b"dynamodb", b"aws4_request"]
484 .iter()
485 .try_fold::<_, _, Result<_, Box<dyn Error>>>(
486 hmac(
487 &[b"AWS4", secret_key.as_bytes()].concat(),
488 datetime.format(SHORT_DATE).to_string().as_bytes(),
489 )?,
490 |res, next| hmac(&res, next),
491 )
492 }
493
494 fn scope_string(
495 datetime: &DateTime<Utc>,
496 region: &str,
497 ) -> String {
498 format!(
499 "{date}/{region}/dynamodb/aws4_request",
500 date = datetime.format(SHORT_DATE),
501 region = region
502 )
503 }
504
505 fn canonical_header_string(headers: &http::HeaderMap) -> String {
506 let mut keyvalues = headers
507 .iter()
508 .map(|(key, value)| {
509 // Values that are not strings are silently dropped (AWS wouldn't
510 // accept them anyway)
511 key.as_str().to_lowercase() + ":" + value.to_str().unwrap().trim()
512 })
513 .collect::<Vec<_>>();
514 keyvalues.sort();
515 keyvalues.join("\n")
516 }
517
518 fn canonical_request(
519 method: &str,
520 headers: &http::HeaderMap,
521 body_digest: &str,
522 ) -> String {
523 // note: all dynamodb uris are requests to / with no query string so theres no need
524 // to derive those from the request
525 format!(
526 "{method}\n/\n\n{headers}\n\n{signed_headers}\n{body_digest}",
527 method = method,
528 headers = canonical_header_string(headers),
529 signed_headers = signed_header_string(headers),
530 body_digest = body_digest
531 )
532 }
533
534 let canonical_request = canonical_request(
535 unsigned.method().as_str(),
536 unsigned.headers(),
537 body_digest.as_str(),
538 );
539
540 fn authorization_header(
541 access_key: &str,
542 datetime: &DateTime<Utc>,
543 region: &str,
544 signed_headers: &str,
545 signature: &str,
546 ) -> String {
547 format!(
548 "AWS4-HMAC-SHA256 Credential={access_key}/{scope}, SignedHeaders={signed_headers}, Signature={signature}",
549 access_key = access_key,
550 scope = scope_string(datetime, region),
551 signed_headers = signed_headers,
552 signature = signature
553 )
554 }
555
556 let string_to_sign = string_to_sign(&now, self.table_info.region.id(), &canonical_request);
557 let signature = hex::encode(hmac(
558 &signing_key(
559 &now,
560 &self.credentials.aws_secret_access_key,
561 self.table_info.region.id(),
562 )?,
563 string_to_sign.as_bytes(),
564 )?);
565 let headers_string = signed_header_string(unsigned.headers());
566 let content_length = unsigned.body().len();
567 unsigned.headers_mut().extend([
568 (
569 AUTHORIZATION,
570 authorization_header(
571 &self.credentials.aws_access_key_id,
572 &Utc::now(),
573 self.table_info.region.id(),
574 &headers_string,
575 &signature,
576 )
577 .parse()?,
578 ),
579 (CONTENT_LENGTH, content_length.to_string().parse()?),
580 (
581 HeaderName::from_bytes(X_AMZ_CONTENT_SHA256)?,
582 body_digest.parse()?,
583 ),
584 ]);
585
586 Ok(unsigned)
587 }
588}
589
590/// Provides a `Transport` implementation for a constantized response.
591pub struct Const(pub u16, pub String);
592
593impl Transport for Const {
594 fn send(
595 &self,
596 _: Request,
597 ) -> Result<(u16, String), Box<dyn Error>> {
598 let Const(status, body) = self;
599 Ok((*status, body.clone()))
600 }
601}
602
603#[cfg(test)]
604mod tests {
605 use super::*;
606
607 #[test]
608 fn get_item_input_serilizes_as_expected() -> Result<(), Box<dyn Error>> {
609 assert_eq!(
610 serde_json::to_string(&GetItemInput {
611 table_name: "test-table",
612 key: HashMap::from_iter([("key-name", Attr::S("key-value".into()))]),
613 projection_expression: "#v",
614 expression_attribute_names: HashMap::from_iter([("#v", "value-name")]),
615 })?,
616 r##"{"TableName":"test-table","Key":{"key-name":{"S":"key-value"}},"ProjectionExpression":"#v","ExpressionAttributeNames":{"#v":"value-name"}}"##
617 );
618 Ok(())
619 }
620
621 #[test]
622 fn put_item_input_serilizes_as_expected() -> Result<(), Box<dyn Error>> {
623 // assert_eq!(
624 // serde_json::to_string(&PutItemInput {
625 // table_name: "test-table",
626 // item: HashMap::from_iter([
627 // ("key-name", Attr::S("key-value".into())),
628 // ("value-name", Attr::S("value".into())),
629 // ]),
630 // })?,
631 // r##"{"TableName":"test-table","Item":{"key-name":{"S":"key-value"},"value-name":{"S":"value"}}}"##
632 // );
633 Ok(())
634 }
635}