diff --git a/Makefile b/Makefile index d1eb2c99..cb00545c 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,10 @@ INTEG_EXTENSIONS := extension-fn extension-trait logs-trait # Using musl to run extensions on both AL1 and AL2 INTEG_ARCH := x86_64-unknown-linux-musl +define uppercase +$(shell sed -r 's/(^|-)(\w)/\U\2/g' <<< $(1)) +endef + pr-check: cargo +1.54.0 check --all cargo +stable fmt --all -- --check @@ -15,7 +19,7 @@ pr-check: integration-tests: # Build Integration functions - cross build --release --target $(INTEG_ARCH) -p lambda_integration_tests + cargo zigbuild --release --target $(INTEG_ARCH) -p lambda_integration_tests rm -rf ./build mkdir -p ./build ${MAKE} ${MAKEOPTS} $(foreach function,${INTEG_FUNCTIONS_BUILD}, build-integration-function-${function}) @@ -37,7 +41,7 @@ build-integration-function-%: build-integration-extension-%: mkdir -p ./build/$*/extensions - cp -v ./target/$(INTEG_ARCH)/release/$* ./build/$*/extensions/$* + cp -v ./target/$(INTEG_ARCH)/release/$* ./build/$*/extensions/$(call uppercase,$*) invoke-integration-function-%: aws lambda invoke --function-name $$(aws cloudformation describe-stacks --stack-name $(INTEG_STACK_NAME) \ @@ -56,4 +60,3 @@ invoke-integration-api-%: curl -X POST -d '{"command": "hello"}' $(API_URL)/trait/post curl -X POST -d '{"command": "hello"}' $(API_URL)/al2/post curl -X POST -d '{"command": "hello"}' $(API_URL)/al2-trait/post - \ No newline at end of file diff --git a/examples/http-basic-lambda/src/main.rs b/examples/http-basic-lambda/src/main.rs index cf15fec9..df15ae6c 100644 --- a/examples/http-basic-lambda/src/main.rs +++ b/examples/http-basic-lambda/src/main.rs @@ -1,10 +1,10 @@ -use lambda_http::{run, service_fn, Error, IntoResponse, Request, Response}; +use lambda_http::{run, service_fn, Body, Error, Request, Response}; /// This is the main body for the function. /// Write your code inside it. /// There are some code examples in the Runtime repository: /// - https://github.com/awslabs/aws-lambda-rust-runtime/tree/main/examples -async fn function_handler(_event: Request) -> Result { +async fn function_handler(_event: Request) -> Result, Error> { // Extract some useful information from the request // Return something that implements IntoResponse. @@ -12,7 +12,7 @@ async fn function_handler(_event: Request) -> Result { let resp = Response::builder() .status(200) .header("content-type", "text/html") - .body("Hello AWS Lambda HTTP request") + .body("Hello AWS Lambda HTTP request".into()) .map_err(Box::new)?; Ok(resp) } diff --git a/examples/http-cors/src/main.rs b/examples/http-cors/src/main.rs index 459dc6f3..7e1b21fa 100644 --- a/examples/http-cors/src/main.rs +++ b/examples/http-cors/src/main.rs @@ -29,7 +29,7 @@ async fn main() -> Result<(), Error> { async fn func(event: Request) -> Result, Error> { Ok(match event.query_string_parameters().first("first_name") { - Some(first_name) => format!("Hello, {}!", first_name).into_response(), + Some(first_name) => format!("Hello, {}!", first_name).into_response().await, _ => Response::builder() .status(400) .body("Empty first name".into()) diff --git a/examples/http-query-parameters/src/main.rs b/examples/http-query-parameters/src/main.rs index 1e499948..03e4b939 100644 --- a/examples/http-query-parameters/src/main.rs +++ b/examples/http-query-parameters/src/main.rs @@ -7,7 +7,7 @@ use lambda_http::{run, service_fn, Error, IntoResponse, Request, RequestExt, Res async fn function_handler(event: Request) -> Result { // Extract some useful information from the request Ok(match event.query_string_parameters().first("first_name") { - Some(first_name) => format!("Hello, {}!", first_name).into_response(), + Some(first_name) => format!("Hello, {}!", first_name).into_response().await, _ => Response::builder() .status(400) .body("Empty first name".into()) diff --git a/examples/http-raw-path/src/main.rs b/examples/http-raw-path/src/main.rs index 1caafeab..f88b7b64 100644 --- a/examples/http-raw-path/src/main.rs +++ b/examples/http-raw-path/src/main.rs @@ -15,7 +15,9 @@ async fn main() -> Result<(), Error> { } async fn func(event: Request) -> Result { - let res = format!("The raw path for this request is: {}", event.raw_http_path()).into_response(); + let res = format!("The raw path for this request is: {}", event.raw_http_path()) + .into_response() + .await; Ok(res) } diff --git a/examples/http-shared-resource/src/main.rs b/examples/http-shared-resource/src/main.rs index 4491ac75..48bab471 100644 --- a/examples/http-shared-resource/src/main.rs +++ b/examples/http-shared-resource/src/main.rs @@ -29,9 +29,12 @@ async fn main() -> Result<(), Error> { // Define a closure here that makes use of the shared client. let handler_func_closure = move |event: Request| async move { Result::, Error>::Ok(match event.query_string_parameters().first("first_name") { - Some(first_name) => shared_client_ref - .response(event.lambda_context().request_id, first_name) - .into_response(), + Some(first_name) => { + shared_client_ref + .response(event.lambda_context().request_id, first_name) + .into_response() + .await + } _ => Response::builder() .status(400) .body("Empty first name".into()) diff --git a/lambda-http/Cargo.toml b/lambda-http/Cargo.toml index 57a43f55..92955045 100644 --- a/lambda-http/Cargo.toml +++ b/lambda-http/Cargo.toml @@ -24,11 +24,14 @@ base64 = "0.13.0" bytes = "1" http = "0.2" http-body = "0.4" +hyper = "0.14" lambda_runtime = { path = "../lambda-runtime", version = "0.5" } serde = { version = "^1", features = ["derive"] } serde_json = "^1" serde_urlencoded = "0.7.0" query_map = { version = "0.5", features = ["url-query"] } +mime = "0.3.16" +encoding_rs = "0.8.31" [dependencies.aws_lambda_events] version = "^0.6.3" diff --git a/lambda-http/src/lib.rs b/lambda-http/src/lib.rs index 62de0f13..dd02ab4d 100644 --- a/lambda-http/src/lib.rs +++ b/lambda-http/src/lib.rs @@ -67,6 +67,8 @@ extern crate maplit; pub use http::{self, Response}; use lambda_runtime::LambdaEvent; pub use lambda_runtime::{self, service_fn, tower, Context, Error, Service}; +use request::RequestFuture; +use response::ResponseFuture; pub mod ext; pub mod request; @@ -91,9 +93,9 @@ pub type Request = http::Request; /// /// This is used by the `Adapter` wrapper and is completely internal to the `lambda_http::run` function. #[doc(hidden)] -pub struct TransformResponse<'a, R, E> { - request_origin: RequestOrigin, - fut: Pin> + 'a>>, +pub enum TransformResponse<'a, R, E> { + Request(RequestOrigin, RequestFuture<'a, R, E>), + Response(RequestOrigin, ResponseFuture), } impl<'a, R, E> Future for TransformResponse<'a, R, E> @@ -103,11 +105,19 @@ where type Output = Result; fn poll(mut self: Pin<&mut Self>, cx: &mut TaskContext) -> Poll { - match self.fut.as_mut().poll(cx) { - Poll::Ready(result) => Poll::Ready( - result.map(|resp| LambdaResponse::from_response(&self.request_origin, resp.into_response())), - ), - Poll::Pending => Poll::Pending, + match *self { + TransformResponse::Request(ref mut origin, ref mut request) => match request.as_mut().poll(cx) { + Poll::Ready(Ok(resp)) => { + *self = TransformResponse::Response(origin.clone(), resp.into_response()); + self.poll(cx) + } + Poll::Ready(Err(err)) => Poll::Ready(Err(err)), + Poll::Pending => Poll::Pending, + }, + TransformResponse::Response(ref mut origin, ref mut response) => match response.as_mut().poll(cx) { + Poll::Ready(resp) => Poll::Ready(Ok(LambdaResponse::from_response(origin, resp))), + Poll::Pending => Poll::Pending, + }, } } } @@ -153,7 +163,8 @@ where let request_origin = req.payload.request_origin(); let event: Request = req.payload.into(); let fut = Box::pin(self.service.call(event.with_lambda_context(req.context))); - TransformResponse { request_origin, fut } + + TransformResponse::Request(request_origin, fut) } } diff --git a/lambda-http/src/request.rs b/lambda-http/src/request.rs index c6327b7e..77eb5913 100644 --- a/lambda-http/src/request.rs +++ b/lambda-http/src/request.rs @@ -17,6 +17,8 @@ use http::header::HeaderName; use query_map::QueryMap; use serde::Deserialize; use serde_json::error::Error as JsonError; +use std::future::Future; +use std::pin::Pin; use std::{io::Read, mem}; /// Internal representation of an Lambda http event from @@ -56,9 +58,12 @@ impl LambdaRequest { } } +/// RequestFuture type +pub type RequestFuture<'a, R, E> = Pin> + 'a>>; + /// Represents the origin from which the lambda was requested from. #[doc(hidden)] -#[derive(Debug)] +#[derive(Debug, Clone)] pub enum RequestOrigin { /// API Gateway request origin #[cfg(feature = "apigw_rest")] diff --git a/lambda-http/src/response.rs b/lambda-http/src/response.rs index 0c46ae68..adfe3528 100644 --- a/lambda-http/src/response.rs +++ b/lambda-http/src/response.rs @@ -8,11 +8,20 @@ use aws_lambda_events::event::alb::AlbTargetGroupResponse; use aws_lambda_events::event::apigw::ApiGatewayProxyResponse; #[cfg(feature = "apigw_http")] use aws_lambda_events::event::apigw::ApiGatewayV2httpResponse; +use encoding_rs::Encoding; +use http::header::CONTENT_ENCODING; +use http::HeaderMap; use http::{ header::{CONTENT_TYPE, SET_COOKIE}, Response, }; +use http_body::Body as HttpBody; +use hyper::body::to_bytes; +use mime::{Mime, CHARSET}; use serde::Serialize; +use std::borrow::Cow; +use std::future::ready; +use std::{fmt, future::Future, pin::Pin}; /// Representation of Lambda response #[doc(hidden)] @@ -27,14 +36,11 @@ pub enum LambdaResponse { Alb(AlbTargetGroupResponse), } -/// tranformation from http type to internal type +/// Transformation from http type to internal type impl LambdaResponse { - pub(crate) fn from_response(request_origin: &RequestOrigin, value: Response) -> Self - where - T: Into, - { + pub(crate) fn from_response(request_origin: &RequestOrigin, value: Response) -> Self { let (parts, bod) = value.into_parts(); - let (is_base64_encoded, body) = match bod.into() { + let (is_base64_encoded, body) = match bod { Body::Empty => (false, None), b @ Body::Text(_) => (false, Some(b)), b @ Body::Binary(_) => (true, Some(b)), @@ -98,71 +104,152 @@ impl LambdaResponse { } } -/// A conversion of self into a `Response` for various types. +/// Trait for generating responses /// -/// Implementations for `Response where B: Into`, -/// `B where B: Into` and `serde_json::Value` are provided -/// by default. -/// -/// # Example -/// -/// ```rust -/// use lambda_http::{Body, IntoResponse, Response}; -/// -/// assert_eq!( -/// "hello".into_response().body(), -/// Response::new(Body::from("hello")).body() -/// ); -/// ``` +/// Types that implement this trait can be used as return types for handler functions. pub trait IntoResponse { - /// Return a translation of `self` into a `Response` - fn into_response(self) -> Response; + /// Transform into a Response Future + fn into_response(self) -> ResponseFuture; } impl IntoResponse for Response where - B: Into, + B: ConvertBody + 'static, { - fn into_response(self) -> Response { + fn into_response(self) -> ResponseFuture { let (parts, body) = self.into_parts(); - Response::from_parts(parts, body.into()) + let headers = parts.headers.clone(); + + let fut = async { Response::from_parts(parts, body.convert(headers).await) }; + + Box::pin(fut) } } impl IntoResponse for String { - fn into_response(self) -> Response { - Response::new(Body::from(self)) + fn into_response(self) -> ResponseFuture { + Box::pin(ready(Response::new(Body::from(self)))) } } impl IntoResponse for &str { - fn into_response(self) -> Response { - Response::new(Body::from(self)) + fn into_response(self) -> ResponseFuture { + Box::pin(ready(Response::new(Body::from(self)))) + } +} + +impl IntoResponse for &[u8] { + fn into_response(self) -> ResponseFuture { + Box::pin(ready(Response::new(Body::from(self)))) + } +} + +impl IntoResponse for Vec { + fn into_response(self) -> ResponseFuture { + Box::pin(ready(Response::new(Body::from(self)))) } } impl IntoResponse for serde_json::Value { - fn into_response(self) -> Response { - Response::builder() - .header(CONTENT_TYPE, "application/json") - .body( - serde_json::to_string(&self) - .expect("unable to serialize serde_json::Value") - .into(), - ) - .expect("unable to build http::Response") + fn into_response(self) -> ResponseFuture { + Box::pin(async move { + Response::builder() + .header(CONTENT_TYPE, "application/json") + .body( + serde_json::to_string(&self) + .expect("unable to serialize serde_json::Value") + .into(), + ) + .expect("unable to build http::Response") + }) } } +pub type ResponseFuture = Pin>>>; + +pub trait ConvertBody { + fn convert(self, parts: HeaderMap) -> BodyFuture; +} + +impl ConvertBody for B +where + B: HttpBody + Unpin + 'static, + B::Error: fmt::Debug, +{ + fn convert(self, headers: HeaderMap) -> BodyFuture { + if headers.get(CONTENT_ENCODING).is_some() { + return convert_to_binary(self); + } + + let content_type = if let Some(value) = headers.get(http::header::CONTENT_TYPE) { + value.to_str().unwrap_or_default() + } else { + // Content-Type and Content-Encoding not set, passthrough as utf8 text + return convert_to_text(self, "utf-8"); + }; + + if content_type.starts_with("text") + || content_type.starts_with("application/json") + || content_type.starts_with("application/javascript") + || content_type.starts_with("application/xml") + { + return convert_to_text(self, content_type); + } + + convert_to_binary(self) + } +} + +fn convert_to_binary(body: B) -> BodyFuture +where + B: HttpBody + Unpin + 'static, + B::Error: fmt::Debug, +{ + Box::pin(async move { Body::from(to_bytes(body).await.expect("unable to read bytes from body").to_vec()) }) +} + +fn convert_to_text(body: B, content_type: &str) -> BodyFuture +where + B: HttpBody + Unpin + 'static, + B::Error: fmt::Debug, +{ + let mime_type = content_type.parse::(); + + let encoding = match mime_type.as_ref() { + Ok(mime) => mime.get_param(CHARSET).unwrap_or(mime::UTF_8), + Err(_) => mime::UTF_8, + }; + + let label = encoding.as_ref().as_bytes(); + let encoding = Encoding::for_label(label).unwrap_or(encoding_rs::UTF_8); + + // assumes utf-8 + Box::pin(async move { + let bytes = to_bytes(body).await.expect("unable to read bytes from body"); + let (content, _, _) = encoding.decode(&bytes); + + match content { + Cow::Borrowed(content) => Body::from(content), + Cow::Owned(content) => Body::from(content), + } + }) +} + +pub type BodyFuture = Pin>>; + #[cfg(test)] mod tests { use super::{Body, IntoResponse, LambdaResponse, RequestOrigin}; - use http::{header::CONTENT_TYPE, Response}; + use http::{ + header::{CONTENT_ENCODING, CONTENT_TYPE}, + Response, + }; + use hyper::Body as HyperBody; use serde_json::{self, json}; - #[test] - fn json_into_response() { - let response = json!({ "hello": "lambda"}).into_response(); + #[tokio::test] + async fn json_into_response() { + let response = json!({ "hello": "lambda"}).into_response().await; match response.body() { Body::Text(json) => assert_eq!(json, r#"{"hello":"lambda"}"#), _ => panic!("invalid body"), @@ -176,15 +263,95 @@ mod tests { ) } - #[test] - fn text_into_response() { - let response = "text".into_response(); + #[tokio::test] + async fn text_into_response() { + let response = "text".into_response().await; match response.body() { Body::Text(text) => assert_eq!(text, "text"), _ => panic!("invalid body"), } } + #[tokio::test] + async fn bytes_into_response() { + let response = "text".as_bytes().into_response().await; + match response.body() { + Body::Binary(data) => assert_eq!(data, "text".as_bytes()), + _ => panic!("invalid body"), + } + } + + #[tokio::test] + async fn content_encoding_header() { + // Drive the implementation by using `hyper::Body` instead of + // of `aws_lambda_events::encodings::Body` + let response = Response::builder() + .header(CONTENT_ENCODING, "gzip") + .body(HyperBody::from("000000".as_bytes())) + .expect("unable to build http::Response"); + let response = response.into_response().await; + let response = LambdaResponse::from_response(&RequestOrigin::ApiGatewayV2, response); + + let json = serde_json::to_string(&response).expect("failed to serialize to json"); + assert_eq!( + json, + r#"{"statusCode":200,"headers":{"content-encoding":"gzip"},"multiValueHeaders":{"content-encoding":["gzip"]},"body":"MDAwMDAw","isBase64Encoded":true,"cookies":[]}"# + ) + } + + #[tokio::test] + async fn content_type_header() { + // Drive the implementation by using `hyper::Body` instead of + // of `aws_lambda_events::encodings::Body` + let response = Response::builder() + .header(CONTENT_TYPE, "application/json") + .body(HyperBody::from("000000".as_bytes())) + .expect("unable to build http::Response"); + let response = response.into_response().await; + let response = LambdaResponse::from_response(&RequestOrigin::ApiGatewayV2, response); + + let json = serde_json::to_string(&response).expect("failed to serialize to json"); + assert_eq!( + json, + r#"{"statusCode":200,"headers":{"content-type":"application/json"},"multiValueHeaders":{"content-type":["application/json"]},"body":"000000","isBase64Encoded":false,"cookies":[]}"# + ) + } + + #[tokio::test] + async fn charset_content_type_header() { + // Drive the implementation by using `hyper::Body` instead of + // of `aws_lambda_events::encodings::Body` + let response = Response::builder() + .header(CONTENT_TYPE, "application/json; charset=utf-16") + .body(HyperBody::from("000000".as_bytes())) + .expect("unable to build http::Response"); + let response = response.into_response().await; + let response = LambdaResponse::from_response(&RequestOrigin::ApiGatewayV2, response); + + let json = serde_json::to_string(&response).expect("failed to serialize to json"); + assert_eq!( + json, + r#"{"statusCode":200,"headers":{"content-type":"application/json; charset=utf-16"},"multiValueHeaders":{"content-type":["application/json; charset=utf-16"]},"body":"〰〰〰","isBase64Encoded":false,"cookies":[]}"# + ) + } + + #[tokio::test] + async fn content_headers_unset() { + // Drive the implementation by using `hyper::Body` instead of + // of `aws_lambda_events::encodings::Body` + let response = Response::builder() + .body(HyperBody::from("000000".as_bytes())) + .expect("unable to build http::Response"); + let response = response.into_response().await; + let response = LambdaResponse::from_response(&RequestOrigin::ApiGatewayV2, response); + + let json = serde_json::to_string(&response).expect("failed to serialize to json"); + assert_eq!( + json, + r#"{"statusCode":200,"headers":{},"multiValueHeaders":{},"body":"000000","isBase64Encoded":false,"cookies":[]}"# + ) + } + #[test] fn serialize_multi_value_headers() { let res = LambdaResponse::from_response( diff --git a/lambda-integration-tests/src/bin/http-fn.rs b/lambda-integration-tests/src/bin/http-fn.rs index b411b77f..4170d29f 100644 --- a/lambda-integration-tests/src/bin/http-fn.rs +++ b/lambda-integration-tests/src/bin/http-fn.rs @@ -1,11 +1,14 @@ -use lambda_http::{service_fn, Error, IntoResponse, Request, RequestExt, Response}; +use lambda_http::{service_fn, Body, Error, IntoResponse, Request, RequestExt, Response}; use tracing::info; async fn handler(event: Request) -> Result { let _context = event.lambda_context(); info!("[http-fn] Received event {} {}", event.method(), event.uri().path()); - Ok(Response::builder().status(200).body("Hello, world!").unwrap()) + Ok(Response::builder() + .status(200) + .body(Body::from("Hello, world!")) + .unwrap()) } #[tokio::main] diff --git a/lambda-integration-tests/src/bin/http-trait.rs b/lambda-integration-tests/src/bin/http-trait.rs index 091aec8e..67cc9fc5 100644 --- a/lambda-integration-tests/src/bin/http-trait.rs +++ b/lambda-integration-tests/src/bin/http-trait.rs @@ -1,4 +1,4 @@ -use lambda_http::{Error, Request, RequestExt, Response, Service}; +use lambda_http::{Body, Error, Request, RequestExt, Response, Service}; use std::{ future::{ready, Future}, pin::Pin, @@ -13,7 +13,7 @@ struct MyHandler { impl Service for MyHandler { type Error = Error; type Future = Pin> + Send>>; - type Response = Response<&'static str>; + type Response = Response; fn poll_ready(&mut self, _cx: &mut core::task::Context<'_>) -> core::task::Poll> { core::task::Poll::Ready(Ok(())) @@ -25,7 +25,7 @@ impl Service for MyHandler { info!("[http-trait] Lambda context: {:?}", request.lambda_context()); Box::pin(ready(Ok(Response::builder() .status(200) - .body("Hello, World!") + .body(Body::from("Hello, World!")) .unwrap()))) } }