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}