#![cfg_attr(feature = "fail-on-warnings", deny(warnings))]
#![warn(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)]
#![allow(clippy::multiple_crate_versions)]
use std::{io::Write, marker::PhantomData, sync::Arc};
use async_trait::async_trait;
use bytes::Bytes;
use flate2::{Compression, write::GzEncoder};
use hyperchad_renderer::{Handle, RenderRunner, ToRenderRunner};
use lambda_http::{
Request, Response,
http::header::{CONTENT_ENCODING, CONTENT_TYPE},
service_fn,
};
pub use lambda_http;
pub use lambda_runtime;
pub enum Content {
Html(String),
Raw {
data: Bytes,
content_type: String,
},
#[cfg(feature = "json")]
Json(serde_json::Value),
}
#[async_trait]
pub trait LambdaResponseProcessor<T: Send + Sync + Clone> {
fn prepare_request(
&self,
req: Request,
body: Option<Arc<Bytes>>,
) -> Result<T, lambda_runtime::Error>;
fn headers(&self, content: &hyperchad_renderer::Content) -> Option<Vec<(String, String)>>;
async fn to_response(
&self,
data: T,
) -> Result<Option<(Content, Option<Vec<(String, String)>>)>, lambda_runtime::Error>;
async fn to_body(
&self,
content: hyperchad_renderer::Content,
data: T,
) -> Result<Content, lambda_runtime::Error>;
}
#[derive(Clone)]
pub struct LambdaApp<T: Send + Sync + Clone, R: LambdaResponseProcessor<T> + Send + Sync + Clone> {
pub processor: R,
#[cfg(feature = "assets")]
pub static_asset_routes: Vec<hyperchad_renderer::assets::StaticAssetRoute>,
_phantom: PhantomData<T>,
}
impl<T: Send + Sync + Clone, R: LambdaResponseProcessor<T> + Send + Sync + Clone> LambdaApp<T, R> {
#[must_use]
pub const fn new(to_html: R) -> Self {
Self {
processor: to_html,
#[cfg(feature = "assets")]
static_asset_routes: vec![],
_phantom: PhantomData,
}
}
}
impl<
T: Send + Sync + Clone + 'static,
R: LambdaResponseProcessor<T> + Send + Sync + Clone + 'static,
> ToRenderRunner for LambdaApp<T, R>
{
fn to_runner(
self,
handle: Handle,
) -> Result<Box<dyn RenderRunner>, Box<dyn std::error::Error + Send>> {
Ok(Box::new(LambdaAppRunner { app: self, handle }))
}
}
pub struct LambdaAppRunner<
T: Send + Sync + Clone,
R: LambdaResponseProcessor<T> + Send + Sync + Clone,
> {
pub app: LambdaApp<T, R>,
pub handle: Handle,
}
impl<
T: Send + Sync + Clone + 'static,
R: LambdaResponseProcessor<T> + Send + Sync + Clone + 'static,
> RenderRunner for LambdaAppRunner<T, R>
{
#[allow(clippy::too_many_lines)]
fn run(&mut self) -> Result<(), Box<dyn std::error::Error + Send>> {
log::debug!("run: starting");
let app = self.app.clone();
let func = service_fn(move |event: Request| {
let app = app.clone();
async move {
let body: &[u8] = event.body().as_ref();
let body = Bytes::copy_from_slice(body);
let body = if body.is_empty() {
None
} else {
Some(Arc::new(body))
};
let data = app.processor.prepare_request(event, body)?;
let content = app.processor.to_response(data).await?;
let mut response = Response::builder()
.status(200)
.header(CONTENT_ENCODING, "gzip");
let mut gz = GzEncoder::new(vec![], Compression::default());
if let Some((content, headers)) = content {
if let Some(headers) = headers {
for (key, value) in headers {
response = response.header(key, value);
}
}
match content {
Content::Html(x) => {
log::debug!("run: sending HTML response type");
gz.write_all(x.as_bytes())?;
response = response.header(CONTENT_TYPE, "text/html; charset=utf-8");
}
Content::Raw { data, content_type } => {
log::debug!("run: sending raw response type '{content_type}'");
gz.write_all(&data)?;
response = response.header(CONTENT_TYPE, content_type);
}
#[cfg(feature = "json")]
Content::Json(x) => {
log::debug!("run: sending JSON response type");
gz.write_all(serde_json::to_string(&x)?.as_bytes())?;
response = response.header(CONTENT_TYPE, "application/json");
}
}
}
let gzip = gz.finish()?;
let response = response
.body(lambda_http::Body::Binary(gzip))
.map_err(Box::new)?;
Ok::<_, lambda_runtime::Error>(response)
}
});
self.handle
.block_on(async move { lambda_http::run_with_streaming_response(func).await })
.map_err(|e| e as Box<dyn std::error::Error + Send>)?;
log::debug!("run: finished");
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "assets")]
#[derive(Clone)]
struct TestProcessor;
#[cfg(feature = "assets")]
#[async_trait]
impl LambdaResponseProcessor<String> for TestProcessor {
fn prepare_request(
&self,
_req: Request,
_body: Option<Arc<Bytes>>,
) -> Result<String, lambda_runtime::Error> {
Ok(String::new())
}
fn headers(&self, _content: &hyperchad_renderer::Content) -> Option<Vec<(String, String)>> {
None
}
async fn to_response(
&self,
_data: String,
) -> Result<Option<(Content, Option<Vec<(String, String)>>)>, lambda_runtime::Error>
{
Ok(None)
}
async fn to_body(
&self,
_content: hyperchad_renderer::Content,
_data: String,
) -> Result<Content, lambda_runtime::Error> {
Ok(Content::Html(String::new()))
}
}
#[test_log::test]
fn test_content_html_creation() {
let html = Content::Html("<h1>Test</h1>".to_string());
match html {
Content::Html(s) => assert_eq!(s, "<h1>Test</h1>"),
Content::Raw { .. } => panic!("Expected Html variant"),
#[cfg(feature = "json")]
Content::Json(_) => panic!("Expected Html variant"),
}
}
#[test_log::test]
fn test_content_html_empty() {
let html = Content::Html(String::new());
match html {
Content::Html(s) => assert!(s.is_empty()),
Content::Raw { .. } => panic!("Expected Html variant"),
#[cfg(feature = "json")]
Content::Json(_) => panic!("Expected Html variant"),
}
}
#[test_log::test]
fn test_content_raw_creation() {
let data = Bytes::from_static(b"test data");
let content_type = "application/octet-stream".to_string();
let raw = Content::Raw {
data: data.clone(),
content_type: content_type.clone(),
};
match raw {
Content::Raw {
data: d,
content_type: ct,
} => {
assert_eq!(d, data);
assert_eq!(ct, content_type);
}
Content::Html(_) => panic!("Expected Raw variant"),
#[cfg(feature = "json")]
Content::Json(_) => panic!("Expected Raw variant"),
}
}
#[test_log::test]
fn test_content_raw_with_image_mime_type() {
let data = Bytes::from_static(b"\x89PNG\r\n\x1a\n");
let raw = Content::Raw {
data: data.clone(),
content_type: "image/png".to_string(),
};
match raw {
Content::Raw {
data: d,
content_type: ct,
} => {
assert_eq!(d, data);
assert_eq!(ct, "image/png");
}
Content::Html(_) => panic!("Expected Raw variant"),
#[cfg(feature = "json")]
Content::Json(_) => panic!("Expected Raw variant"),
}
}
#[cfg(feature = "json")]
#[test_log::test]
fn test_content_json_creation() {
let value = serde_json::json!({"key": "value"});
let json = Content::Json(value.clone());
match json {
Content::Json(v) => assert_eq!(v, value),
_ => panic!("Expected Json variant"),
}
}
#[cfg(feature = "json")]
#[test_log::test]
fn test_content_json_array() {
let value = serde_json::json!([1, 2, 3]);
let json = Content::Json(value.clone());
match json {
Content::Json(v) => {
assert!(v.is_array());
assert_eq!(v, value);
}
_ => panic!("Expected Json variant"),
}
}
#[cfg(feature = "json")]
#[test_log::test]
fn test_content_json_null() {
let value = serde_json::json!(null);
let json = Content::Json(value.clone());
match json {
Content::Json(v) => {
assert!(v.is_null());
assert_eq!(v, value);
}
_ => panic!("Expected Json variant"),
}
}
#[cfg(feature = "assets")]
#[test_log::test]
fn test_lambda_app_new() {
let processor = TestProcessor;
let app = LambdaApp::new(processor);
assert!(app.static_asset_routes.is_empty());
}
#[cfg(feature = "assets")]
#[test_log::test]
fn test_lambda_app_with_static_routes() {
let processor = TestProcessor;
let mut app = LambdaApp::new(processor);
app.static_asset_routes
.push(hyperchad_renderer::assets::StaticAssetRoute {
route: "/static/style.css".to_string(),
target: hyperchad_renderer::assets::AssetPathTarget::FileContents(
Bytes::from_static(b"body { margin: 0; }"),
),
not_found_behavior: None,
});
assert_eq!(app.static_asset_routes.len(), 1);
assert_eq!(app.static_asset_routes[0].route, "/static/style.css");
match &app.static_asset_routes[0].target {
hyperchad_renderer::assets::AssetPathTarget::FileContents(bytes) => {
assert_eq!(bytes, &Bytes::from_static(b"body { margin: 0; }"));
}
_ => panic!("Expected FileContents target"),
}
}
}