Skip to content

Commit

Permalink
Add a dedicated lambda bin (#218)
Browse files Browse the repository at this point in the history
  • Loading branch information
rustworthy authored Feb 9, 2025
1 parent 97993ce commit f08963a
Show file tree
Hide file tree
Showing 14 changed files with 517 additions and 462 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ To deploy server:
```console
cd server
cargo lambda build --release --arm64
cargo lambda deploy --env-var RUST_LOG=info,tower_http=debug,wewerewondering_api=trace --profile qa
cargo lambda deploy --env-var RUST_LOG=info,tower_http=debug,wewerewondering_api=trace,lambda=trace --profile qa
```

To deploy client:
Expand Down
9 changes: 7 additions & 2 deletions infra/lambda.tf
Original file line number Diff line number Diff line change
Expand Up @@ -75,16 +75,21 @@ resource "aws_iam_role" "www" {
}
}

// To build for AWS Lambda runtime, run:
// ```console
// $ cargo lambda build --release --arm64
// ```
// The artifact will be located in <project_root>/server/target/lambda/lambda/bootstrap,
check "lambda-built" {
assert {
condition = fileexists("${path.module}/../server/target/lambda/wewerewondering-api/bootstrap")
condition = fileexists("${path.module}/../server/target/lambda/lambda/bootstrap")
error_message = "Run `cargo lambda build --release --arm64` in ../server"
}
}

data "archive_file" "lambda" {
type = "zip"
source_file = "${path.module}/../server/target/lambda/wewerewondering-api/bootstrap"
source_file = "${path.module}/../server/target/lambda/lambda/bootstrap"
output_path = "lambda_function_payload.zip"
}

Expand Down
5 changes: 5 additions & 0 deletions server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
name = "wewerewondering-api"
version = "0.1.0"
edition = "2021"
default-run = "wewerewondering-api"

[dependencies]
aws-config = { version = "1.5.0", features = ["behavior-version-latest"] }
Expand All @@ -25,3 +26,7 @@ tower-service = "0.3"
tracing = { version = "0.1", features = ["log"] }
tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "env-filter"] }
ulid = { version = "1.0.0", features = ["serde"] }

[[bin]]
name = "lambda"
path = "./src/lambda.rs"
20 changes: 20 additions & 0 deletions server/CargoLambda.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# https://www.cargo-lambda.info/guide/configuration.html#global-configuration-files
#
# This allows us to offload the `cargo build` command, so we can run
# `cargo lambda build --release --arm64` on CI only specifying profile and
# architecture. If we decide to change the binary's name, we will be no need to
# go and adjust configuration neither on the CI workflows, nor in the SAM's
# `template.yaml` that we are using to run a local instance of API Gateway
# for testing purposes.
#
# NB! IF we decide to add more build parameters here, let's not forget to check
# that sam local is still building and running ok with:
# ```console
# $ sam build
# S sam local start-api
# ```
[build]
bin = ["lambda"]

[deploy]
binary_name = "lambda"
16 changes: 8 additions & 8 deletions server/src/ask.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use super::{Backend, Local};
use crate::{to_dynamo_timestamp, QUESTIONS_TTL};
use crate::{utils, QUESTIONS_TTL};
use aws_sdk_dynamodb::{
error::SdkError,
operation::put_item::{PutItemError, PutItemOutput},
Expand All @@ -16,7 +16,7 @@ use ulid::Ulid;
use tracing::{debug, error, info, trace, warn};

impl Backend {
pub(super) async fn ask(
pub async fn ask(
&self,
eid: &Ulid,
qid: &Ulid,
Expand All @@ -27,10 +27,10 @@ impl Backend {
("eid", AttributeValue::S(eid.to_string())),
("votes", AttributeValue::N(1.to_string())),
("text", AttributeValue::S(q.body)),
("when", to_dynamo_timestamp(SystemTime::now())),
("when", utils::to_dynamo_timestamp(SystemTime::now())),
(
"expire",
to_dynamo_timestamp(SystemTime::now() + QUESTIONS_TTL),
utils::to_dynamo_timestamp(SystemTime::now() + QUESTIONS_TTL),
),
("hidden", AttributeValue::Bool(false)),
];
Expand Down Expand Up @@ -70,12 +70,12 @@ impl Backend {
}

#[derive(Deserialize, Debug)]
pub(super) struct Question {
pub(super) body: String,
pub(super) asker: Option<String>,
pub struct Question {
pub body: String,
pub asker: Option<String>,
}

pub(super) async fn ask(
pub async fn ask(
Path(eid): Path<Ulid>,
State(dynamo): State<Backend>,
q: Json<Question>,
Expand Down
86 changes: 86 additions & 0 deletions server/src/lambda.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
use axum::response::IntoResponse;
use http_body_util::BodyExt;
use lambda_http::Error;
use std::{future::Future, pin::Pin};
use tower::Layer;
use tower_http::trace::TraceLayer;
use tower_service::Service;
use tracing_subscriber::EnvFilter;

#[tokio::main]
async fn main() -> Result<(), Error> {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.without_time(/* cloudwatch does that */)
.init();

let app = wewerewondering_api::new().await;
// To run with AWS Lambda runtime, wrap in our `LambdaLayer`
let app = tower::ServiceBuilder::new()
.layer(TraceLayer::new_for_http())
.layer(LambdaLayer)
.service(app);

lambda_http::run(app).await
}

#[derive(Clone, Copy)]
pub struct LambdaLayer;

impl<S> Layer<S> for LambdaLayer {
type Service = LambdaService<S>;

fn layer(&self, inner: S) -> Self::Service {
LambdaService { inner }
}
}

pub struct LambdaService<S> {
inner: S,
}

impl<S> Service<lambda_http::Request> for LambdaService<S>
where
S: Service<axum::http::Request<axum::body::Body>>,
S::Response: axum::response::IntoResponse + Send + 'static,
S::Error: std::error::Error + Send + Sync + 'static,
S::Future: Send + 'static,
{
type Response = lambda_http::Response<lambda_http::Body>;
type Error = lambda_http::Error;
type Future =
Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send + 'static>>;

fn poll_ready(
&mut self,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx).map_err(Into::into)
}

fn call(&mut self, req: lambda_http::Request) -> Self::Future {
let (parts, body) = req.into_parts();
let body = match body {
lambda_http::Body::Empty => axum::body::Body::default(),
lambda_http::Body::Text(t) => t.into(),
lambda_http::Body::Binary(v) => v.into(),
};

let request = axum::http::Request::from_parts(parts, body);

let fut = self.inner.call(request);
let fut = async move {
let resp = fut.await?;
let (parts, body) = resp.into_response().into_parts();
let bytes = body.collect().await?.to_bytes();
let bytes: &[u8] = &bytes;
let resp: hyper::Response<lambda_http::Body> = match std::str::from_utf8(bytes) {
Ok(s) => hyper::Response::from_parts(parts, s.into()),
Err(_) => hyper::Response::from_parts(parts, bytes.into()),
};
Ok(resp)
};

Box::pin(fut)
}
}
154 changes: 154 additions & 0 deletions server/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
use aws_sdk_dynamodb::types::AttributeValue;
use axum::routing::{get, post};
use axum::Router;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tower_http::limit::RequestBodyLimitLayer;
use ulid::Ulid;

mod ask;
mod event;
mod list;
mod new;
mod questions;
mod toggle;
mod utils;
mod vote;

pub use ask::Question;
pub use utils::to_dynamo_timestamp;
pub use vote::UpDown;

#[cfg(debug_assertions)]
const SEED: &str = include_str!("test.json");

const QUESTIONS_EXPIRE_AFTER_DAYS: u64 = 30;
const QUESTIONS_TTL: Duration = Duration::from_secs(QUESTIONS_EXPIRE_AFTER_DAYS * 24 * 60 * 60);

const EVENTS_EXPIRE_AFTER_DAYS: u64 = 60;
const EVENTS_TTL: Duration = Duration::from_secs(EVENTS_EXPIRE_AFTER_DAYS * 24 * 60 * 60);

#[derive(Clone, Debug, Default)]
pub struct Local {
pub events: HashMap<Ulid, String>,
pub questions: HashMap<Ulid, HashMap<&'static str, AttributeValue>>,
pub questions_by_eid: HashMap<Ulid, Vec<Ulid>>,
}

#[derive(Clone, Debug)]
pub enum Backend {
Dynamo(aws_sdk_dynamodb::Client),
Local(Arc<Mutex<Local>>),
}

impl Backend {
#[allow(unused)]
async fn local() -> Self {
Backend::Local(Arc::new(Mutex::new(Local::default())))
}

/// Instantiate a DynamoDB backend.
///
/// If `USE_DYNAMODB` is set to "local", the `AWS_ENDPOINT_URL` will be taken
/// from the environment with the "http://localhost:8000" fallback , the `AWS_DEFAULT_REGION`
/// will be pulled from the environment as well and will default to "us-east-1",
/// as for the credentials - the [test credentials](https://docs.rs/aws-config/latest/aws_config/struct.ConfigLoader.html#method.test_credentials)
/// will be used to sign requests.
///
/// This spares setting those environment variables (including `AWS_ACCESS_KEY_ID`
/// and `AWS_SECRET_ACCESS_KEY`) via the command line or configuration files,
/// and allows to run the application against a local dynamodb instance with just:
/// ```sh
/// USE_DYNAMODB=local cargo run
/// ```
/// While the entire test suite can be run with:
/// ```sh
/// USE_DYNAMODB=local cargo t -- --include-ignored
/// ```
///
/// This also allows us to use the local instance of DynamoDB which is running
/// in a container on the same network, in which case the database will be accessible
/// under `http://<dynamodb_container_name>:<port>`. This facilitates the setup of
/// local API Gateway with SAM, since the `sam local start-api` command will launch our
/// back-end app in a docker container.
///
/// If more customization is needed (say, you want to set some specific credentials
/// rather than rely on those test creds generated by the `aws_config` crate),
/// set `USE_DYNAMODB` to e.g. "custom", and set the environment variables to whatever
/// values you need or let them be picked up from your `~/.aws` files
/// (see [`aws_config::load_from_env`](https://docs.rs/aws-config/latest/aws_config/fn.load_from_env.html))
pub async fn dynamo() -> Self {
let config = if std::env::var("USE_DYNAMODB")
.ok()
.is_some_and(|v| v == "local")
{
aws_config::from_env()
.endpoint_url(
std::env::var("AWS_ENDPOINT_URL")
.ok()
.unwrap_or("http://localhost:8000".into()),
)
.region(aws_config::Region::new(
std::env::var("AWS_DEFAULT_REGION")
.ok()
.unwrap_or("us-east-1".into()),
))
.test_credentials()
.load()
.await
} else {
aws_config::load_from_env().await
};
Backend::Dynamo(aws_sdk_dynamodb::Client::new(&config))
}
}

pub async fn new() -> Router {
#[cfg(not(debug_assertions))]
let backend = Backend::dynamo().await;

#[cfg(debug_assertions)]
let backend = {
use rand::prelude::IndexedRandom;

let mut backend = if std::env::var_os("USE_DYNAMODB").is_some() {
Backend::dynamo().await
} else {
Backend::local().await
};

// to aid in development, seed the backend with a test event and related
// questions, and auto-generate user votes over time
let qids = crate::utils::seed(&mut backend).await;
let cheat = backend.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(1));
interval.tick().await;
loop {
interval.tick().await;
let qid = qids
.choose(&mut rand::rng())
.expect("there _are_ some questions for our test event");
let _ = cheat.vote(qid, vote::UpDown::Up).await;
}
});

backend
};

Router::new()
.route("/api/event", post(new::new))
.route("/api/event/{eid}", post(ask::ask))
.route("/api/event/{eid}", get(event::event))
.route("/api/event/{eid}/questions", get(list::list))
.route("/api/event/{eid}/questions/{secret}", get(list::list_all))
.route(
"/api/event/{eid}/questions/{secret}/{qid}/toggle/{property}",
post(toggle::toggle),
)
.route("/api/vote/{qid}/{updown}", post(vote::vote))
.route("/api/questions/{qids}", get(questions::questions))
.layer(RequestBodyLimitLayer::new(1024))
.with_state(backend)
}
9 changes: 5 additions & 4 deletions server/src/list.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use super::{Backend, Local};
use crate::utils;
use aws_sdk_dynamodb::{
error::SdkError,
operation::query::{QueryError, QueryOutput},
Expand Down Expand Up @@ -59,7 +60,7 @@ impl Backend {
} = &mut *local;

if !events.contains_key(eid) {
return Err(super::mint_service_error(
return Err(utils::mint_service_error(
QueryError::ResourceNotFoundException(
ResourceNotFoundException::builder().build(),
),
Expand Down Expand Up @@ -125,7 +126,7 @@ async fn list_inner(
) {
let has_secret = if let Some(secret) = secret {
debug!("list questions with admin access");
if let Err(e) = super::check_secret(&dynamo, &eid, &secret).await {
if let Err(e) = utils::check_secret(&dynamo, &eid, &secret).await {
// a bad secret will not turn good and
// events are unlikely to re-appear with the same Ulid
return (
Expand All @@ -138,7 +139,7 @@ async fn list_inner(
trace!("list questions with guest access");
// ensure that the event exists:
// this is _just_ so give 404s for old events so clients stop polling
if let Err(e) = super::get_secret(&dynamo, &eid).await {
if let Err(e) = utils::get_secret(&dynamo, &eid).await {
// events are unlikely to re-appear with the same Ulid
return (
AppendHeaders([(header::CACHE_CONTROL, "max-age=86400")]),
Expand Down Expand Up @@ -236,7 +237,7 @@ async fn list_inner(
let votes = q["votes"].as_u64().expect("votes is a number") as f64;
// max so that even if vote count somehow got to 0, count it as 1
let votes = votes.max(1.);
let exp = (-1. * dt as f64).exp_m1() + 1.;
let exp = (-1. * dt).exp_m1() + 1.;
Score(exp * votes / (1. - exp))
};
questions.sort_by_cached_key(|q| std::cmp::Reverse(score(q)));
Expand Down
Loading

0 comments on commit f08963a

Please sign in to comment.