diff --git a/Cargo.lock b/Cargo.lock index 2bb0f9f3..b3d850f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -58,6 +58,14 @@ dependencies = [ "safemem 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "base64" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "byteorder 1.2.6 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "bitflags" version = "1.0.4" @@ -205,6 +213,11 @@ dependencies = [ "winapi 0.3.6 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "dtoa" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" + [[package]] name = "failure" version = "0.1.3" @@ -405,6 +418,24 @@ dependencies = [ "winapi-build 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "lambda_http" +version = "0.1.0" +dependencies = [ + "base64 0.10.0 (registry+https://github.com/rust-lang/crates.io-index)", + "failure 0.1.3 (registry+https://github.com/rust-lang/crates.io-index)", + "failure_derive 0.1.3 (registry+https://github.com/rust-lang/crates.io-index)", + "http 0.1.13 (registry+https://github.com/rust-lang/crates.io-index)", + "lambda_runtime 0.1.0", + "log 0.4.5 (registry+https://github.com/rust-lang/crates.io-index)", + "serde 1.0.79 (registry+https://github.com/rust-lang/crates.io-index)", + "serde_derive 1.0.79 (registry+https://github.com/rust-lang/crates.io-index)", + "serde_json 1.0.31 (registry+https://github.com/rust-lang/crates.io-index)", + "serde_urlencoded 0.5.4 (registry+https://github.com/rust-lang/crates.io-index)", + "simple_logger 1.0.1 (registry+https://github.com/rust-lang/crates.io-index)", + "tokio 0.1.11 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "lambda_runtime" version = "0.1.0" @@ -898,6 +929,17 @@ dependencies = [ "serde 1.0.79 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "serde_urlencoded" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "dtoa 0.4.3 (registry+https://github.com/rust-lang/crates.io-index)", + "itoa 0.4.3 (registry+https://github.com/rust-lang/crates.io-index)", + "serde 1.0.79 (registry+https://github.com/rust-lang/crates.io-index)", + "url 1.7.1 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "sha2" version = "0.7.1" @@ -1280,6 +1322,7 @@ dependencies = [ "checksum arrayvec 0.4.7 (registry+https://github.com/rust-lang/crates.io-index)" = "a1e964f9e24d588183fcb43503abda40d288c8657dfc27311516ce2f05675aef" "checksum backtrace 0.3.9 (registry+https://github.com/rust-lang/crates.io-index)" = "89a47830402e9981c5c41223151efcced65a0510c13097c769cede7efb34782a" "checksum backtrace-sys 0.1.24 (registry+https://github.com/rust-lang/crates.io-index)" = "c66d56ac8dabd07f6aacdaf633f4b8262f5b3601a810a0dcddffd5c22c69daa0" +"checksum base64 0.10.0 (registry+https://github.com/rust-lang/crates.io-index)" = "621fc7ecb8008f86d7fb9b95356cd692ce9514b80a86d85b397f32a22da7b9e2" "checksum base64 0.9.3 (registry+https://github.com/rust-lang/crates.io-index)" = "489d6c0ed21b11d038c31b6ceccca973e65d73ba3bd8ecb9a2babf5546164643" "checksum bitflags 1.0.4 (registry+https://github.com/rust-lang/crates.io-index)" = "228047a76f468627ca71776ecdebd732a3423081fcf5125585bcd7c49886ce12" "checksum blake2-rfc 0.2.18 (registry+https://github.com/rust-lang/crates.io-index)" = "5d6d530bdd2d52966a6d03b7a964add7ae1a288d25214066fd4b600f0f796400" @@ -1300,6 +1343,7 @@ dependencies = [ "checksum crypto-mac 0.5.2 (registry+https://github.com/rust-lang/crates.io-index)" = "0999b4ff4d3446d4ddb19a63e9e00c1876e75cd7000d20e57a693b4b3f08d958" "checksum digest 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)" = "03b072242a8cbaf9c145665af9d250c59af3b958f83ed6824e13533cf76d5b90" "checksum dirs 1.0.4 (registry+https://github.com/rust-lang/crates.io-index)" = "88972de891f6118092b643d85a0b28e0678e0f948d7f879aa32f2d5aafe97d2a" +"checksum dtoa 0.4.3 (registry+https://github.com/rust-lang/crates.io-index)" = "6d301140eb411af13d3115f9a562c85cc6b541ade9dfa314132244aaee7489dd" "checksum failure 0.1.3 (registry+https://github.com/rust-lang/crates.io-index)" = "6dd377bcc1b1b7ce911967e3ec24fa19c3224394ec05b54aa7b083d498341ac7" "checksum failure_derive 0.1.3 (registry+https://github.com/rust-lang/crates.io-index)" = "64c2d913fe8ed3b6c6518eedf4538255b989945c14c2a7d5cbff62a5e2120596" "checksum fake-simd 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)" = "e88a8acf291dafb59c2d96e8f59828f3838bb1a70398823ade51a84de6a6deed" @@ -1376,6 +1420,7 @@ dependencies = [ "checksum serde 1.0.79 (registry+https://github.com/rust-lang/crates.io-index)" = "84257ccd054dc351472528c8587b4de2dbf0dc0fe2e634030c1a90bfdacebaa9" "checksum serde_derive 1.0.79 (registry+https://github.com/rust-lang/crates.io-index)" = "31569d901045afbff7a9479f793177fe9259819aff10ab4f89ef69bbc5f567fe" "checksum serde_json 1.0.31 (registry+https://github.com/rust-lang/crates.io-index)" = "bb47a3d5c84320222f66d7db21157c4a7407755de41798f9b4c1c40593397b1a" +"checksum serde_urlencoded 0.5.4 (registry+https://github.com/rust-lang/crates.io-index)" = "d48f9f99cd749a2de71d29da5f948de7f2764cc5a9d7f3c97e3514d4ee6eabf2" "checksum sha2 0.7.1 (registry+https://github.com/rust-lang/crates.io-index)" = "9eb6be24e4c23a84d7184280d2722f7f2731fcdd4a9d886efbfe4413e4847ea0" "checksum simple_logger 1.0.1 (registry+https://github.com/rust-lang/crates.io-index)" = "25111f1d77db1ac3ee11b62ba4b7a162e6bb3be43e28273f0d3935cc8d3ff7fb" "checksum slab 0.4.1 (registry+https://github.com/rust-lang/crates.io-index)" = "5f9776d6b986f77b35c6cf846c11ad986ff128fe0b2b63a3628e3755e8d3102d" diff --git a/Cargo.toml b/Cargo.toml index 53c4bf12..2aae614b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ "lambda-runtime-client", - "lambda-runtime" + "lambda-runtime", + "lambda-http" ] \ No newline at end of file diff --git a/README.md b/README.md index f2b120c9..d8b1a444 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,11 @@ [![Build Status](https://travis-ci.org/awslabs/aws-lambda-rust-runtime.svg?branch=master)](https://travis-ci.org/awslabs/aws-lambda-rust-runtime) -This package makes it easy to run AWS Lambda Functions written in Rust. This workspace includes two crates: +This package makes it easy to run AWS Lambda Functions written in Rust. This workspace includes multiple crates: * **`lambda-runtime-client`** is a client SDK for the Lambda Runtime APIs. You probably don't need to use this crate directly! * **`lambda-runtime`** is a library that makes it easy to write Lambda functions in Rust. +* **`lambda-http`** is a library that makes it easy to write API Gateway proxy event focused Lambda functions in Rust. ## Example function @@ -87,7 +88,7 @@ $ cat output.json # Prints: {"message":"Hello, world!"} ## lambda-runtime-client -Defines the `RuntimeClient` trait and provides its `HttpRuntimeClient` implementation. The client fetches events and returns output as `Vec`. +Defines the `RuntimeClient` trait and provides its `HttpRuntimeClient` implementation. The client fetches events and returns output as `Vec`. For error reporting to the runtime APIs the library defines the `RuntimeApiError` trait and the `ErrorResponse` object. Custom errors for the APIs should implement the `to_response() -> ErrorResponse` method of the `RuntimeApiError` trait. diff --git a/lambda-http/Cargo.toml b/lambda-http/Cargo.toml new file mode 100644 index 00000000..a6ec26f0 --- /dev/null +++ b/lambda-http/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "lambda_http" +version = "0.1.0" +authors = ["Doug Tangren"] +description = "Rust API Gateway proxy event interfaces for AWS Lambda" +keywords = ["AWS", "Lambda", "APIGateway", "Rust", "API"] +license = "Apache-2.0" +homepage = "https://github.com/awslabs/aws-lambda-rust-runtime" +repository = "https://github.com/awslabs/aws-lambda-rust-runtime" +documentation = "https://docs.rs/lambda_runtime" + +[dependencies] +http = "0.1" +serde = "^1" +serde_json = "^1" +serde_derive = "^1" +lambda_runtime = { path = "../lambda-runtime", version = "^0.1" } +tokio = "^0.1" +base64 = "0.10" +failure = "0.1" +failure_derive = "0.1" +serde_urlencoded = "0.5" + +[dev-dependencies] +log = "^0.4" +simple_logger = "^1" diff --git a/lambda-http/examples/basic.rs b/lambda-http/examples/basic.rs new file mode 100644 index 00000000..e3ab42b5 --- /dev/null +++ b/lambda-http/examples/basic.rs @@ -0,0 +1,30 @@ +extern crate lambda_http as http; +extern crate lambda_runtime as runtime; +extern crate log; +extern crate simple_logger; + +use http::{lambda, Body, Request, RequestExt, Response}; +use runtime::{error::HandlerError, Context}; + +use log::error; +use std::error::Error; + +fn main() -> Result<(), Box> { + simple_logger::init_with_level(log::Level::Debug).unwrap(); + lambda!(my_handler); + + Ok(()) +} + +fn my_handler(e: Request, c: Context) -> Result, HandlerError> { + Ok(match e.query_string_parameters().get("first_name") { + Some(first_name) => Response::new(format!("Hello, {}!", first_name).into()), + _ => { + error!("Empty first name in request {}", c.aws_request_id); + Response::builder() + .status(400) + .body::("Empty first name".into()) + .expect("failed to render response") + } + }) +} diff --git a/lambda-http/src/body.rs b/lambda-http/src/body.rs new file mode 100644 index 00000000..baf954e0 --- /dev/null +++ b/lambda-http/src/body.rs @@ -0,0 +1,238 @@ +//! Provides an API Gateway oriented request and response body entity interface + +use std::{borrow::Cow, ops::Deref}; + +use base64::display::Base64Display; +use serde::ser::{Error as SerError, Serialize, Serializer}; + +/// Representation of http request and response bodies as supported +/// by API Gateway. +/// +/// These come in three flavors +/// * `Empty` ( no body ) +/// * `Text` ( text data ) +/// * `Binary` ( binary data ) +/// +/// Body types can be `Deref` and `AsRef`'d into `[u8]` types much like the `hyper` crate +/// +/// # Examples +/// +/// Body types are inferred with `From` implementations. +/// +/// ## Text +/// +/// Types like `String`, `str` whose type reflects +/// text produce `Body::Text` variants +/// +/// ``` +/// assert!(match lambda_http::Body::from("text") { +/// lambda_http::Body::Text(_) => true, +/// _ => false +/// }) +/// ``` +/// +/// ## Binary +/// +/// Types like `Vec` and `&[u8]` whose types reflect raw bytes produce `Body::Binary` variants +/// +/// ``` +/// assert!(match lambda_http::Body::from("text".as_bytes()) { +/// lambda_http::Body::Binary(_) => true, +/// _ => false +/// }) +/// ``` +/// +/// `Binary` responses bodies will automatically get based64 encoded to meet API Gateway's response expectations. +/// +/// ## Empty +/// +/// The unit type ( `()` ) whose type represents an empty value produces `Body::Empty` variants +/// +/// ``` +/// assert!(match lambda_http::Body::from(()) { +/// lambda_http::Body::Empty => true, +/// _ => false +/// }) +/// ``` +/// +/// +/// For more information about API Gateway's body types, +/// refer to [this documentation](https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-payload-encodings.html). +#[derive(Debug, PartialEq)] +pub enum Body { + /// An empty body + Empty, + /// A body containing string data + Text(String), + /// A body containing binary data + Binary(Vec), +} + +impl Default for Body { + fn default() -> Self { + Body::Empty + } +} + +impl From<()> for Body { + fn from(_: ()) -> Self { + Body::Empty + } +} + +impl<'a> From<&'a str> for Body { + fn from(s: &'a str) -> Self { + Body::Text(s.into()) + } +} + +impl From for Body { + fn from(b: String) -> Self { + Body::Text(b) + } +} + +impl From> for Body { + #[inline] + fn from(cow: Cow<'static, str>) -> Body { + match cow { + Cow::Borrowed(b) => Body::from(b.to_owned()), + Cow::Owned(o) => Body::from(o), + } + } +} + +impl From> for Body { + #[inline] + fn from(cow: Cow<'static, [u8]>) -> Body { + match cow { + Cow::Borrowed(b) => Body::from(b), + Cow::Owned(o) => Body::from(o), + } + } +} + +impl From> for Body { + fn from(b: Vec) -> Self { + Body::Binary(b) + } +} + +impl<'a> From<&'a [u8]> for Body { + fn from(b: &'a [u8]) -> Self { + Body::Binary(b.to_vec()) + } +} + +impl Deref for Body { + type Target = [u8]; + + #[inline] + fn deref(&self) -> &Self::Target { + self.as_ref() + } +} + +impl AsRef<[u8]> for Body { + #[inline] + fn as_ref(&self) -> &[u8] { + match self { + Body::Empty => &[], + Body::Text(ref bytes) => bytes.as_ref(), + Body::Binary(ref bytes) => bytes.as_ref(), + } + } +} + +impl<'a> Serialize for Body { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self { + Body::Text(data) => { + serializer.serialize_str(::std::str::from_utf8(data.as_ref()).map_err(S::Error::custom)?) + } + Body::Binary(data) => serializer.collect_str(&Base64Display::with_config(data, base64::STANDARD)), + Body::Empty => serializer.serialize_unit(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json; + use std::collections::HashMap; + + #[test] + fn body_has_default() { + assert_eq!(Body::default(), Body::Empty); + } + + #[test] + fn from_unit() { + assert_eq!(Body::from(()), Body::Empty); + } + + #[test] + fn from_str() { + match Body::from(String::from("foo").as_str()) { + Body::Text(_) => (), + not => assert!(false, "expected Body::Text(...) got {:?}", not), + } + } + + #[test] + fn from_string() { + match Body::from(String::from("foo")) { + Body::Text(_) => (), + not => assert!(false, "expected Body::Text(...) got {:?}", not), + } + } + + #[test] + fn from_cow_str() { + match Body::from(Cow::from("foo")) { + Body::Text(_) => (), + not => assert!(false, "expected Body::Text(...) got {:?}", not), + } + } + + #[test] + fn from_cow_bytes() { + match Body::from(Cow::from("foo".as_bytes())) { + Body::Binary(_) => (), + not => assert!(false, "expected Body::Binary(...) got {:?}", not), + } + } + + #[test] + fn from_bytes() { + match Body::from("foo".as_bytes()) { + Body::Binary(_) => (), + not => assert!(false, "expected Body::Binary(...) got {:?}", not), + } + } + + #[test] + fn serialize_text() { + let mut map = HashMap::new(); + map.insert("foo", Body::from("bar")); + assert_eq!(serde_json::to_string(&map).unwrap(), r#"{"foo":"bar"}"#); + } + + #[test] + fn serialize_binary() { + let mut map = HashMap::new(); + map.insert("foo", Body::from("bar".as_bytes())); + assert_eq!(serde_json::to_string(&map).unwrap(), r#"{"foo":"YmFy"}"#); + } + + #[test] + fn serialize_empty() { + let mut map = HashMap::new(); + map.insert("foo", Body::Empty); + assert_eq!(serde_json::to_string(&map).unwrap(), r#"{"foo":null}"#); + } +} diff --git a/lambda-http/src/ext.rs b/lambda-http/src/ext.rs new file mode 100644 index 00000000..f8814807 --- /dev/null +++ b/lambda-http/src/ext.rs @@ -0,0 +1,249 @@ +//! API Gateway extension methods for `http::Request` types + +use http::{header::CONTENT_TYPE, Request as HttpRequest}; +use serde::{de::value::Error as SerdeError, Deserialize}; +use serde_json; +use serde_urlencoded; + +use request::RequestContext; +use strmap::StrMap; + +/// API gateway pre-parsed http query string parameters +pub(crate) struct QueryStringParameters(pub(crate) StrMap); + +/// API gateway pre-extracted url path parameters +pub(crate) struct PathParameters(pub(crate) StrMap); + +/// API gateway configured +/// [stage variables](https://docs.aws.amazon.com/apigateway/latest/developerguide/stage-variables.html) +pub(crate) struct StageVariables(pub(crate) StrMap); + +/// Payload deserialization errors +#[derive(Debug, Fail)] +pub enum PayloadError { + /// Returned when `application/json` bodies fail to deserialize a payload + #[fail(display = "failed to parse payload from application/json")] + Json(serde_json::Error), + /// Returned when `application/x-www-form-urlencoded` bodies fail to deserialize a payload + #[fail(display = "failed to parse payload application/x-www-form-urlencoded")] + WwwFormUrlEncoded(SerdeError), +} + +/// Extentions for `lambda_http::Request` structs that +/// provide access to [API gateway features](https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-input-format) +/// +/// In addition, you can also access a request's body in deserialized format +/// for payloads sent in `application/x-www-form-urlencoded` or +/// `application/x-www-form-urlencoded` format +/// +/// ```rust,no_run +/// #[macro_use] extern crate lambda_http; +/// extern crate lambda_runtime as lambda; +/// #[macro_use] extern crate serde_derive; +/// +/// use lambda::{Context, HandlerError}; +/// use lambda_http::{Body, Request, Response, RequestExt}; +/// +/// #[derive(Debug,Deserialize,Default)] +/// struct Args { +/// #[serde(default)] +/// x: usize, +/// #[serde(default)] +/// y: usize +/// } +/// +/// fn main() { +/// lambda!(handler) +/// } +/// +/// fn handler( +/// request: Request, +/// ctx: lambda::Context +/// ) -> Result, lambda::HandlerError> { +/// let args: Args = request.payload() +/// .unwrap_or_else(|_parse_err| None) +/// .unwrap_or_default(); +/// Ok( +/// Response::new( +/// format!( +/// "{} + {} = {}", +/// args.x, +/// args.y, +/// args.x + args.y +/// ).into() +/// ) +/// ) +/// } +/// ``` +pub trait RequestExt { + /// Return pre-parsed http query string parameters, parameters + /// provided after the `?` portion of a url, + /// associated with the API gateway request. No query parameters + /// will yield an empty `StrMap`. + fn query_string_parameters(&self) -> StrMap; + /// Return pre-extracted path parameters, parameter provided in url placeholders + /// `/foo/{bar}/baz/{boom}`, + /// associated with the API gateway request. No path parameters + /// will yield an empty `StrMap` + fn path_parameters(&self) -> StrMap; + /// Return [stage variables](https://docs.aws.amazon.com/apigateway/latest/developerguide/stage-variables.html) + /// associated with the API gateway request. No stage parameters + /// will yield an empty `StrMap` + fn stage_variables(&self) -> StrMap; + /// Return request context data assocaited with the API gateway request + fn request_context(&self) -> RequestContext; + + /// Return the Result of a payload parsed into a serde Deserializeable + /// type + /// + /// Currently only `application/x-www-form-urlencoded` + /// and `application/json` flavors of content type + /// are supported + /// + /// A [PayloadError](enum.PayloadError.html) will be returned for undeserializable + /// payloads. If no body is provided, `Ok(None)` will be returned. + fn payload(&self) -> Result, PayloadError> + where + for<'de> D: Deserialize<'de>; +} + +impl RequestExt for HttpRequest { + fn query_string_parameters(&self) -> StrMap { + self.extensions() + .get::() + .map(|ext| ext.0.clone()) + .unwrap_or_default() + } + fn path_parameters(&self) -> StrMap { + self.extensions() + .get::() + .map(|ext| ext.0.clone()) + .unwrap_or_default() + } + fn stage_variables(&self) -> StrMap { + self.extensions() + .get::() + .map(|ext| ext.0.clone()) + .unwrap_or_default() + } + + fn request_context(&self) -> RequestContext { + self.extensions().get::().cloned().unwrap_or_default() + } + + fn payload(&self) -> Result, PayloadError> + where + for<'de> D: Deserialize<'de>, + { + self.headers() + .get(CONTENT_TYPE) + .map(|ct| match ct.to_str() { + Ok("application/x-www-form-urlencoded") => serde_urlencoded::from_bytes::(self.body().as_ref()) + .map_err(PayloadError::WwwFormUrlEncoded) + .map(Some), + Ok("application/json") => serde_json::from_slice::(self.body().as_ref()) + .map_err(PayloadError::Json) + .map(Some), + _ => Ok(None), + }) + .unwrap_or_else(|| Ok(None)) + } +} + +#[cfg(test)] +mod tests { + use http::{HeaderMap, Request as HttpRequest}; + use std::collections::HashMap; + use GatewayRequest; + use RequestExt; + use StrMap; + + #[test] + fn requests_have_query_string_ext() { + let mut headers = HeaderMap::new(); + headers.insert("Host", "www.rust-lang.org".parse().unwrap()); + let mut query = HashMap::new(); + query.insert("foo".to_owned(), "bar".to_owned()); + let gwr: GatewayRequest = GatewayRequest { + path: "/foo".into(), + headers, + query_string_parameters: StrMap(query.clone().into()), + ..GatewayRequest::default() + }; + let actual = HttpRequest::from(gwr); + assert_eq!(actual.query_string_parameters(), StrMap(query.clone().into())); + } + + #[test] + fn requests_have_form_post_parseable_payloads() { + let mut headers = HeaderMap::new(); + headers.insert("Host", "www.rust-lang.org".parse().unwrap()); + headers.insert("Content-Type", "application/x-www-form-urlencoded".parse().unwrap()); + #[derive(Deserialize, PartialEq, Debug)] + struct Payload { + foo: String, + baz: usize, + } + let gwr: GatewayRequest = GatewayRequest { + path: "/foo".into(), + headers, + body: Some("foo=bar&baz=2".into()), + ..GatewayRequest::default() + }; + let actual = HttpRequest::from(gwr); + let payload: Option = actual.payload().unwrap_or_default(); + assert_eq!( + payload, + Some(Payload { + foo: "bar".into(), + baz: 2 + }) + ) + } + + #[test] + fn requests_have_form_post_parseable_payloads_for_hashmaps() { + let mut headers = HeaderMap::new(); + headers.insert("Host", "www.rust-lang.org".parse().unwrap()); + headers.insert("Content-Type", "application/x-www-form-urlencoded".parse().unwrap()); + let gwr: GatewayRequest = GatewayRequest { + path: "/foo".into(), + headers, + body: Some("foo=bar&baz=2".into()), + ..GatewayRequest::default() + }; + let actual = HttpRequest::from(gwr); + let mut expected = HashMap::new(); + expected.insert("foo".to_string(), "bar".to_string()); + expected.insert("baz".to_string(), "2".to_string()); + let payload: Option> = actual.payload().unwrap_or_default(); + assert_eq!(payload, Some(expected)) + } + + #[test] + fn requests_have_json_parseable_payloads() { + let mut headers = HeaderMap::new(); + headers.insert("Host", "www.rust-lang.org".parse().unwrap()); + headers.insert("Content-Type", "application/json".parse().unwrap()); + #[derive(Deserialize, PartialEq, Debug)] + struct Payload { + foo: String, + baz: usize, + } + let gwr: GatewayRequest = GatewayRequest { + path: "/foo".into(), + headers, + body: Some(r#"{"foo":"bar", "baz": 2}"#.into()), + ..GatewayRequest::default() + }; + let actual = HttpRequest::from(gwr); + let payload: Option = actual.payload().unwrap_or_default(); + assert_eq!( + payload, + Some(Payload { + foo: "bar".into(), + baz: 2 + }) + ) + } +} diff --git a/lambda-http/src/lib.rs b/lambda-http/src/lib.rs new file mode 100644 index 00000000..473a1a85 --- /dev/null +++ b/lambda-http/src/lib.rs @@ -0,0 +1,109 @@ +#![warn(missing_docs)] +//#![deny(warnings)] +//! Enriches `lambda_runtime` with `http` types targeting API Gateway proxy events +//! +//! # Example +//! +//! ```rust,no_run +//! #[macro_use] extern crate lambda_http; +//! extern crate lambda_runtime as lambda; +//! +//! use lambda::{Context, HandlerError}; +//! use lambda_http::{Body, Request, Response, RequestExt}; +//! +//! fn main() { +//! lambda!(handler) +//! } +//! +//! fn handler( +//! request: Request, +//! ctx: Context +//! ) -> Result, HandlerError> { +//! Ok( +//! Response::new( +//! format!( +//! "hello {}", +//! request.query_string_parameters() +//! .get("name") +//! .unwrap_or_else(|| "stranger") +//! ).into() +//! ) +//! ) +//! } +//! ``` +extern crate base64; +extern crate failure; +#[macro_use] +extern crate failure_derive; +/// re-export for convenient access in consumer crates +pub extern crate http; +extern crate lambda_runtime; +extern crate serde; +#[macro_use] +extern crate serde_derive; +extern crate serde_json; +extern crate serde_urlencoded; +extern crate tokio; + +use http::Request as HttpRequest; +pub use http::Response; +use lambda_runtime::{self as lambda, error::HandlerError, Context}; +use tokio::runtime::Runtime as TokioRuntime; + +mod body; +mod ext; +pub mod request; +mod response; +mod strmap; + +pub use body::Body; +pub use ext::RequestExt; +use request::GatewayRequest; +use response::GatewayResponse; +pub use strmap::StrMap; + +/// Type alias for `http::Request`s with a fixed `lambda_http::Body` body +pub type Request = HttpRequest; + +/// Functions acting as API Gateway handlers must conform to this type. +pub trait Handler { + /// Run the handler. + fn run(&mut self, event: Request, ctx: Context) -> Result, HandlerError>; +} + +impl Handler for F +where + F: FnMut(Request, Context) -> Result, HandlerError>, +{ + fn run(&mut self, event: Request, ctx: Context) -> Result, HandlerError> { + (*self)(event, ctx) + } +} + +/// Creates a new `lambda_runtime::Runtime` and begins polling for API Gateway events +/// +/// # Arguments +/// +/// * `f` A type that conforms to the `Handler` interface. +/// +/// # Panics +/// The function panics if the Lambda environment variables are not set. +pub fn start(f: impl Handler, runtime: Option) { + // handler requires a mutable ref + let mut func = f; + lambda::start( + |req: GatewayRequest, ctx: Context| func.run(req.into(), ctx).map(GatewayResponse::from), + runtime, + ) +} + +/// A macro for starting new handler's poll for API Gateway events +#[macro_export] +macro_rules! lambda { + ($handler:ident) => { + $crate::start($handler, None) + }; + ($handler:ident, $runtime:expr) => { + $crate::start($handler, Some($runtime)) + }; +} diff --git a/lambda-http/src/request.rs b/lambda-http/src/request.rs new file mode 100644 index 00000000..e95856e4 --- /dev/null +++ b/lambda-http/src/request.rs @@ -0,0 +1,253 @@ +//! API Gateway request types. Typically these are exposed via the `request_context` +//! request extension method provided by [lambda_http::RequestExt](trait.RequestExt.html) + +use std::{borrow::Cow, collections::HashMap, fmt, mem}; + +use http::{ + self, + header::{HeaderValue, HOST}, + HeaderMap, Method, Request as HttpRequest, +}; +use serde::{ + de::{Error as DeError, MapAccess, Visitor}, + Deserialize, Deserializer, +}; +use serde_json::Value; + +use body::Body; +use ext::{PathParameters, QueryStringParameters, StageVariables}; +use strmap::StrMap; + +/// Representation of an API Gateway proxy event data +#[doc(hidden)] +#[derive(Deserialize, Debug, Default)] +#[serde(rename_all = "camelCase")] +pub(crate) struct GatewayRequest<'a> { + pub(crate) path: Cow<'a, str>, + #[serde(deserialize_with = "deserialize_method")] + pub(crate) http_method: Method, + #[serde(deserialize_with = "deserialize_headers")] + pub(crate) headers: HeaderMap, + #[serde(deserialize_with = "nullable_default")] + pub(crate) query_string_parameters: StrMap, + #[serde(deserialize_with = "nullable_default")] + pub(crate) path_parameters: StrMap, + #[serde(deserialize_with = "nullable_default")] + pub(crate) stage_variables: StrMap, + pub(crate) body: Option>, + #[serde(default)] + pub(crate) is_base64_encoded: bool, + pub(crate) request_context: RequestContext, +} + +/// API Gateway request context +#[derive(Deserialize, Debug, Default, Clone)] +#[serde(rename_all = "camelCase")] +pub struct RequestContext { + //pub path: String, + pub account_id: String, + pub resource_id: String, + pub stage: String, + pub request_id: String, + pub resource_path: String, + pub http_method: String, + #[serde(default)] + pub authorizer: HashMap, + pub api_id: String, + pub identity: Identity, +} + +/// Identity assoicated with request +#[derive(Deserialize, Debug, Default, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Identity { + pub source_ip: String, + pub cognito_identity_id: Option, + pub cognito_identity_pool_id: Option, + pub cognito_authentication_provider: Option, + pub cognito_authentication_type: Option, + pub account_id: Option, + pub caller: Option, + pub api_key: Option, + pub user: Option, + pub user_agent: Option, + pub user_arn: Option, +} + +fn deserialize_method<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + struct MethodVisitor; + + impl<'de> Visitor<'de> for MethodVisitor { + type Value = Method; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + write!(formatter, "a Method") + } + + fn visit_str(self, v: &str) -> Result + where + E: DeError, + { + v.parse().map_err(E::custom) + } + } + + deserializer.deserialize_str(MethodVisitor) +} + +fn deserialize_headers<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + struct HeaderVisitor; + + impl<'de> Visitor<'de> for HeaderVisitor { + type Value = HeaderMap; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + write!(formatter, "a HeaderMap") + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut headers = http::HeaderMap::new(); + while let Some((key, value)) = map.next_entry::, Cow>()? { + let header_name = key.parse::().map_err(A::Error::custom)?; + let header_value = + http::header::HeaderValue::from_shared(value.into_owned().into()).map_err(A::Error::custom)?; + headers.append(header_name, header_value); + } + Ok(headers) + } + } + + deserializer.deserialize_map(HeaderVisitor) +} + +/// deserializes (json) null values to their default values +// https://github.com/serde-rs/serde/issues/1098 +fn nullable_default<'de, T, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, + T: Default + Deserialize<'de>, +{ + let opt = Option::deserialize(deserializer)?; + Ok(opt.unwrap_or_else(T::default)) +} + +impl<'a> From> for HttpRequest { + fn from(value: GatewayRequest) -> Self { + let GatewayRequest { + path, + http_method, + headers, + query_string_parameters, + path_parameters, + stage_variables, + body, + is_base64_encoded, + request_context, + } = value; + + // build an http::Request from a lambda_http::GatewayRequest + let mut builder = HttpRequest::builder(); + builder.method(http_method); + builder.uri({ + format!( + "https://{}{}", + headers + .get(HOST) + .map(|val| val.to_str().unwrap_or_default()) + .unwrap_or_default(), + path + ) + }); + + builder.extension(QueryStringParameters(query_string_parameters)); + builder.extension(PathParameters(path_parameters)); + builder.extension(StageVariables(stage_variables)); + builder.extension(request_context); + + let mut req = builder + .body(match body { + Some(b) => { + if is_base64_encoded { + // todo: document failure behavior + Body::from(::base64::decode(b.as_ref()).unwrap_or_default()) + } else { + Body::from(b.into_owned()) + } + } + _ => Body::from(()), + }) + .expect("failed to build request"); + + // no builder method that sets headers in batch + mem::replace(req.headers_mut(), headers); + + req + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json; + use std::collections::HashMap; + + #[test] + fn requests_convert() { + let mut headers = HeaderMap::new(); + headers.insert("Host", "www.rust-lang.org".parse().unwrap()); + let gwr: GatewayRequest = GatewayRequest { + path: "/foo".into(), + headers, + ..GatewayRequest::default() + }; + let expected = HttpRequest::get("https://www.rust-lang.org/foo").body(()).unwrap(); + let actual = HttpRequest::from(gwr); + assert_eq!(expected.method(), actual.method()); + assert_eq!(expected.uri(), actual.uri()); + assert_eq!(expected.method(), actual.method()); + } + + #[test] + fn deserializes_request_events() { + // from the docs + // https://docs.aws.amazon.com/lambda/latest/dg/eventsources.html#eventsources-api-gateway-request + let input = include_str!("../tests/data/proxy_request.json"); + assert!(serde_json::from_str::(&input).is_ok()) + } + + #[test] + fn implements_default() { + assert_eq!( + GatewayRequest { + path: "/foo".into(), + ..GatewayRequest::default() + } + .path, + "/foo" + ) + } + + #[test] + fn deserialize_with_null() { + #[derive(Debug, PartialEq, Deserialize)] + struct Test { + #[serde(deserialize_with = "nullable_default")] + foo: HashMap, + } + + assert_eq!( + serde_json::from_str::(r#"{"foo":null}"#).expect("failed to deserialize"), + Test { foo: HashMap::new() } + ) + } + +} diff --git a/lambda-http/src/response.rs b/lambda-http/src/response.rs new file mode 100644 index 00000000..f8567837 --- /dev/null +++ b/lambda-http/src/response.rs @@ -0,0 +1,101 @@ +//! Response types + +// Std +use std::ops::Not; + +use http::{ + header::{HeaderMap, HeaderValue}, + Response as HttpResponse, +}; +use serde::{ + ser::{Error as SerError, SerializeMap}, + Serializer, +}; + +use body::Body; + +/// Representation of API Gateway response +#[derive(Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub(crate) struct GatewayResponse { + pub status_code: u16, + #[serde(skip_serializing_if = "HeaderMap::is_empty", serialize_with = "serialize_headers")] + pub headers: HeaderMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub body: Option, + #[serde(skip_serializing_if = "Not::not")] + pub is_base64_encoded: bool, +} + +impl Default for GatewayResponse { + fn default() -> Self { + Self { + status_code: 200, + headers: Default::default(), + body: Default::default(), + is_base64_encoded: Default::default(), + } + } +} + +fn serialize_headers(headers: &HeaderMap, serializer: S) -> Result +where + S: Serializer, +{ + let mut map = serializer.serialize_map(Some(headers.keys_len()))?; + for key in headers.keys() { + let map_value = headers[key].to_str().map_err(S::Error::custom)?; + map.serialize_entry(key.as_str(), map_value)?; + } + map.end() +} + +impl From> for GatewayResponse +where + T: Into, +{ + fn from(value: HttpResponse) -> Self { + let (parts, bod) = value.into_parts(); + let (is_base64_encoded, body) = match bod.into() { + Body::Empty => (false, None), + b @ Body::Text(_) => (false, Some(b)), + b @ Body::Binary(_) => (true, Some(b)), + }; + GatewayResponse { + status_code: parts.status.as_u16(), + body, + headers: parts.headers, + is_base64_encoded, + } + } +} + +#[cfg(test)] +mod tests { + + use super::GatewayResponse; + use serde_json; + + #[test] + fn default_response() { + assert_eq!(GatewayResponse::default().status_code, 200) + } + + #[test] + fn serialize_default() { + assert_eq!( + serde_json::to_string(&GatewayResponse::default()).expect("failed to serialize response"), + r#"{"statusCode":200}"# + ); + } + + #[test] + fn serialize_body() { + let mut resp = GatewayResponse::default(); + resp.body = Some("foo".into()); + assert_eq!( + serde_json::to_string(&resp).expect("failed to serialize response"), + r#"{"statusCode":200,"body":"foo"}"# + ); + } +} diff --git a/lambda-http/src/strmap.rs b/lambda-http/src/strmap.rs new file mode 100644 index 00000000..252c9b3c --- /dev/null +++ b/lambda-http/src/strmap.rs @@ -0,0 +1,122 @@ +use std::{ + collections::{hash_map::Keys, HashMap}, + fmt, + sync::Arc, +}; + +use serde::{ + de::{MapAccess, Visitor}, + Deserialize, Deserializer, +}; + +/// A read-only view into a map of string data +#[derive(Default, Debug, PartialEq)] +pub struct StrMap(pub(crate) Arc>); + +impl StrMap { + /// Return a named value where available + pub fn get(&self, key: &str) -> Option<&str> { + self.0.get(key).map(|value| value.as_ref()) + } + + /// Return true if the underlying map is empty + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// Return an iterator over keys and values + pub fn iter(&self) -> StrMapIter { + StrMapIter { + data: self, + keys: self.0.keys(), + } + } +} + +impl Clone for StrMap { + fn clone(&self) -> Self { + // only clone the inner data + StrMap(self.0.clone()) + } +} +impl From> for StrMap { + fn from(inner: HashMap) -> Self { + StrMap(Arc::new(inner)) + } +} + +/// A read only reference to `StrMap` key and value slice pairings +pub struct StrMapIter<'a> { + data: &'a StrMap, + keys: Keys<'a, String, String>, +} + +impl<'a> Iterator for StrMapIter<'a> { + type Item = (&'a str, &'a str); + + #[inline] + fn next(&mut self) -> Option<(&'a str, &'a str)> { + self.keys.next().and_then(|k| self.data.get(k).map(|v| (k.as_str(), v))) + } +} + +impl<'de> Deserialize<'de> for StrMap { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct StrMapVisitor; + + impl<'de> Visitor<'de> for StrMapVisitor { + type Value = StrMap; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + write!(formatter, "a StrMap") + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut inner = HashMap::new(); + while let Some((key, value)) = map.next_entry()? { + inner.insert(key, value); + } + Ok(StrMap(Arc::new(inner))) + } + } + + deserializer.deserialize_map(StrMapVisitor) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + #[test] + fn str_map_default_is_empty() { + assert!(StrMap::default().is_empty()) + } + + #[test] + fn str_map_get() { + let mut data = HashMap::new(); + data.insert("foo".into(), "bar".into()); + let strmap = StrMap(data.into()); + assert_eq!(strmap.get("foo"), Some("bar")); + assert_eq!(strmap.get("bar"), None); + } + + #[test] + fn str_map_iter() { + let mut data = HashMap::new(); + data.insert("foo".into(), "bar".into()); + data.insert("baz".into(), "boom".into()); + let strmap = StrMap(data.into()); + let mut values = strmap.iter().map(|(_, v)| v).collect::>(); + values.sort(); + assert_eq!(values, vec!["bar", "boom"]); + } +} diff --git a/lambda-http/tests/data/proxy_request.json b/lambda-http/tests/data/proxy_request.json new file mode 100644 index 00000000..f76d2b8a --- /dev/null +++ b/lambda-http/tests/data/proxy_request.json @@ -0,0 +1,55 @@ +{ + "path": "/test/hello", + "headers": { + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", + "Accept-Encoding": "gzip, deflate, lzma, sdch, br", + "Accept-Language": "en-US,en;q=0.8", + "CloudFront-Forwarded-Proto": "https", + "CloudFront-Is-Desktop-Viewer": "true", + "CloudFront-Is-Mobile-Viewer": "false", + "CloudFront-Is-SmartTV-Viewer": "false", + "CloudFront-Is-Tablet-Viewer": "false", + "CloudFront-Viewer-Country": "US", + "Host": "wt6mne2s9k.execute-api.us-west-2.amazonaws.com", + "Upgrade-Insecure-Requests": "1", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.82 Safari/537.36 OPR/39.0.2256.48", + "Via": "1.1 fb7cca60f0ecd82ce07790c9c5eef16c.cloudfront.net (CloudFront)", + "X-Amz-Cf-Id": "nBsWBOrSHMgnaROZJK1wGCZ9PcRcSpq_oSXZNQwQ10OTZL4cimZo3g==", + "X-Forwarded-For": "192.168.100.1, 192.168.1.1", + "X-Forwarded-Port": "443", + "X-Forwarded-Proto": "https" + }, + "pathParameters": { + "proxy": "hello" + }, + "requestContext": { + "accountId": "123456789012", + "resourceId": "us4z18", + "stage": "test", + "requestId": "41b45ea3-70b5-11e6-b7bd-69b5aaebc7d9", + "identity": { + "cognitoIdentityPoolId": "", + "accountId": "", + "cognitoIdentityId": "", + "caller": "", + "apiKey": "", + "sourceIp": "192.168.100.1", + "cognitoAuthenticationType": "", + "cognitoAuthenticationProvider": "", + "userArn": "", + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.82 Safari/537.36 OPR/39.0.2256.48", + "user": "" + }, + "resourcePath": "/{proxy+}", + "httpMethod": "GET", + "apiId": "wt6mne2s9k" + }, + "resource": "/{proxy+}", + "httpMethod": "GET", + "queryStringParameters": { + "name": "me" + }, + "stageVariables": { + "stageVarName": "stageVarValue" + } +} \ No newline at end of file diff --git a/lambda-runtime/src/lib.rs b/lambda-runtime/src/lib.rs index 36448d00..13480276 100644 --- a/lambda-runtime/src/lib.rs +++ b/lambda-runtime/src/lib.rs @@ -52,6 +52,7 @@ extern crate tokio; mod context; mod env; pub mod error; +pub use error::HandlerError; mod runtime; pub use context::*;