use std::future::{Ready, ready};
use std::marker::PhantomData;
use std::rc::Rc;
use std::sync::Arc;
use std::task::{Context, Poll};
use actix_web::body::{EitherBody, MessageBody};
use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
use actix_web::http::StatusCode;
use actix_web::{Error as ActixError, HttpRequest, HttpResponse};
use futures::future::LocalBoxFuture;
use noema::core::{Container, Resolver};
use tracing::Instrument;
use uuid::Uuid;
pub const IDEMPOTENCY_HEADER: &str = "Idempotency-Key";
#[derive(Debug, Clone)]
pub struct RequestError {
status: StatusCode,
message: String,
}
impl RequestError {
pub fn new(status: StatusCode, message: impl Into<String>) -> Self {
Self {
status,
message: message.into(),
}
}
pub fn unauthorized(message: impl Into<String>) -> Self {
Self::new(StatusCode::UNAUTHORIZED, message)
}
pub fn forbidden(message: impl Into<String>) -> Self {
Self::new(StatusCode::FORBIDDEN, message)
}
pub fn bad_request(message: impl Into<String>) -> Self {
Self::new(StatusCode::BAD_REQUEST, message)
}
pub fn status(&self) -> StatusCode {
self.status
}
pub fn message(&self) -> &str {
&self.message
}
pub fn into_response(self) -> HttpResponse {
HttpResponse::build(self.status).body(self.message)
}
}
impl std::fmt::Display for RequestError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.status, self.message)
}
}
impl std::error::Error for RequestError {}
const REQUEST_NOT_BOUND: &str =
"RequestContext not bound; wrap with request_context::<T>() or inject a test double";
#[async_trait::async_trait(?Send)]
pub trait RequestExtra: Clone + Send + Sync + 'static {
async fn from_request(req: &HttpRequest) -> Result<Self, RequestError>;
}
#[async_trait::async_trait(?Send)]
impl RequestExtra for () {
async fn from_request(_req: &HttpRequest) -> Result<Self, RequestError> {
Ok(())
}
}
pub trait RequestContext<T: RequestExtra>: Send + Sync {
fn id(&self) -> Uuid;
fn idempotency_key(&self) -> Option<String>;
fn extra(&self) -> T;
}
#[derive(Clone)]
pub struct RequestScope<T: RequestExtra> {
id: Uuid,
idempotency_key: Option<String>,
extra: T,
}
impl<T: RequestExtra> RequestScope<T> {
pub fn new(id: Uuid, idempotency_key: Option<String>, extra: T) -> Self {
Self {
id,
idempotency_key,
extra,
}
}
pub fn get() -> Option<Self> {
CURRENT
.try_with(|slot| {
let ctx = unsafe { &*(slot.ptr as *const RequestScope<T>) };
ctx.clone()
})
.ok()
}
pub fn id(&self) -> Uuid {
self.id
}
pub fn idempotency_key(&self) -> Option<&str> {
self.idempotency_key.as_deref()
}
pub fn extra(&self) -> &T {
&self.extra
}
}
impl<T: RequestExtra> RequestContext<T> for RequestScope<T> {
fn id(&self) -> Uuid {
self.id
}
fn idempotency_key(&self) -> Option<String> {
self.idempotency_key.clone()
}
fn extra(&self) -> T {
self.extra.clone()
}
}
struct AmbientRequestContext<T>(PhantomData<fn() -> T>);
impl<T: RequestExtra> RequestContext<T> for AmbientRequestContext<T> {
fn id(&self) -> Uuid {
RequestScope::<T>::get().expect(REQUEST_NOT_BOUND).id()
}
fn idempotency_key(&self) -> Option<String> {
RequestScope::<T>::get()
.expect(REQUEST_NOT_BOUND)
.idempotency_key
}
fn extra(&self) -> T {
RequestScope::<T>::get().expect(REQUEST_NOT_BOUND).extra
}
}
impl<T: RequestExtra> Resolver<dyn RequestContext<T> + Send + Sync> for Container {
fn resolve() -> Arc<dyn RequestContext<T> + Send + Sync> {
Arc::new(AmbientRequestContext(PhantomData))
}
}
#[derive(Clone, Copy)]
struct Slot {
ptr: usize,
}
tokio::task_local! {
static CURRENT: Slot;
}
async fn with_request<T, F, R>(ctx: &RequestScope<T>, fut: F) -> R
where
T: RequestExtra,
F: std::future::Future<Output = R>,
{
let slot = Slot {
ptr: ctx as *const RequestScope<T> as usize,
};
CURRENT.scope(slot, fut).await
}
pub fn request_context<T: RequestExtra>() -> RequestContextTransform<T> {
RequestContextTransform(PhantomData)
}
pub struct RequestContextTransform<T>(PhantomData<T>);
impl<S, B, T> Transform<S, ServiceRequest> for RequestContextTransform<T>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
S::Future: 'static,
B: MessageBody + 'static,
T: RequestExtra,
{
type Response = ServiceResponse<EitherBody<B>>;
type Error = ActixError;
type InitError = ();
type Transform = RequestContextMiddleware<S, T>;
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(RequestContextMiddleware {
service: Rc::new(service),
_t: PhantomData,
}))
}
}
pub struct RequestContextMiddleware<S, T> {
service: Rc<S>,
_t: PhantomData<T>,
}
impl<S, B, T> Service<ServiceRequest> for RequestContextMiddleware<S, T>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
S::Future: 'static,
B: MessageBody + 'static,
T: RequestExtra,
{
type Response = ServiceResponse<EitherBody<B>>;
type Error = ActixError;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
fn poll_ready(&self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.service.poll_ready(cx)
}
fn call(&self, req: ServiceRequest) -> Self::Future {
let service = Rc::clone(&self.service);
Box::pin(async move {
let extra = match T::from_request(req.request()).await {
Ok(extra) => extra,
Err(err) => {
let res = crate::cors::apply_cors(req.request(), err.into_response());
return Ok(req.into_response(res).map_into_right_body());
}
};
let idempotency_key = req
.headers()
.get(IDEMPOTENCY_HEADER)
.and_then(|v| v.to_str().ok())
.filter(|s| !s.is_empty())
.map(str::to_string);
let ctx = RequestScope {
id: Uuid::now_v7(),
idempotency_key,
extra,
};
let request_id = ctx.id();
with_request(
&ctx,
async move { service.call(req).await }.instrument(tracing::info_span!(
"http.request",
request_id = %request_id
)),
)
.await
.map(|res| res.map_into_left_body())
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use actix_web::{App, HttpResponse, http::StatusCode, test, web};
use std::sync::Arc;
async fn echo_id() -> HttpResponse {
let ctx = RequestScope::<()>::get().expect("bound");
HttpResponse::Ok().body(ctx.id().to_string())
}
async fn echo_key() -> HttpResponse {
let ctx = RequestScope::<()>::get().expect("bound");
HttpResponse::Ok().body(ctx.idempotency_key().unwrap_or("").to_string())
}
#[actix_web::test]
async fn get_is_none_without_wrap() {
async fn inner() -> HttpResponse {
assert!(RequestScope::<()>::get().is_none());
HttpResponse::Ok().finish()
}
let srv = test::init_service(App::new().route("/", web::get().to(inner))).await;
let resp = test::call_service(&srv, test::TestRequest::get().uri("/").to_request()).await;
assert_eq!(resp.status(), StatusCode::OK);
}
#[actix_web::test]
async fn wrap_binds_id_and_idempotency_key() {
let srv = test::init_service(
App::new()
.wrap(request_context::<()>())
.route("/id", web::get().to(echo_id))
.route("/key", web::get().to(echo_key)),
)
.await;
let id_resp =
test::call_service(&srv, test::TestRequest::get().uri("/id").to_request()).await;
assert_eq!(id_resp.status(), StatusCode::OK);
let key_req = test::TestRequest::get()
.uri("/key")
.insert_header((IDEMPOTENCY_HEADER, "abc-1"))
.to_request();
let key_resp = test::call_service(&srv, key_req).await;
assert_eq!(key_resp.status(), StatusCode::OK);
let body = test::read_body(key_resp).await;
assert_eq!(body, "abc-1");
}
#[derive(Clone)]
struct Flag(u8);
#[async_trait::async_trait(?Send)]
impl RequestExtra for Flag {
async fn from_request(_req: &HttpRequest) -> Result<Self, RequestError> {
Ok(Flag(7))
}
}
#[actix_web::test]
async fn extra_is_typed() {
async fn inner() -> HttpResponse {
let ctx = RequestScope::<Flag>::get().expect("flag");
assert_eq!(ctx.extra().0, 7);
HttpResponse::Ok().finish()
}
let srv = test::init_service(
App::new()
.wrap(request_context::<Flag>())
.route("/", web::get().to(inner)),
)
.await;
let resp = test::call_service(&srv, test::TestRequest::get().uri("/").to_request()).await;
assert_eq!(resp.status(), StatusCode::OK);
}
#[derive(Clone)]
struct Denied;
#[async_trait::async_trait(?Send)]
impl RequestExtra for Denied {
async fn from_request(_req: &HttpRequest) -> Result<Self, RequestError> {
Err(RequestError::unauthorized("nope"))
}
}
#[actix_web::test]
async fn from_request_err_skips_handler() {
async fn inner() -> HttpResponse {
panic!("handler must not run");
}
let srv = test::init_service(
App::new()
.wrap(request_context::<Denied>())
.route("/", web::get().to(inner)),
)
.await;
let resp = test::call_service(&srv, test::TestRequest::get().uri("/").to_request()).await;
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[actix_web::test]
async fn from_request_err_gets_acao_when_cors_is_outer() {
async fn inner() -> HttpResponse {
panic!("handler must not run");
}
let cfg = crate::config::CorsConfig {
origins: vec!["*".into()],
..crate::config::CorsConfig::default()
};
let srv = test::init_service(
App::new()
.wrap(request_context::<Denied>())
.wrap(crate::cors::cors_from(&cfg))
.route("/", web::get().to(inner)),
)
.await;
let req = test::TestRequest::get()
.uri("/")
.insert_header((actix_web::http::header::ORIGIN, "http://localhost:8080"))
.to_request();
let resp = test::call_service(&srv, req).await;
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
assert_eq!(
resp.headers()
.get(actix_web::http::header::ACCESS_CONTROL_ALLOW_ORIGIN)
.unwrap(),
"http://localhost:8080"
);
assert_eq!(test::read_body(resp).await, "nope");
}
struct UsesCtx {
ctx: Arc<dyn RequestContext<Flag> + Send + Sync>,
}
impl UsesCtx {
fn flag(&self) -> u8 {
self.ctx.extra().0
}
}
#[actix_web::test]
async fn handler_accepts_injected_request_context() {
let h = UsesCtx {
ctx: Arc::new(RequestScope::new(
Uuid::now_v7(),
Some("abc-1".into()),
Flag(7),
)),
};
assert_eq!(h.flag(), 7);
assert_eq!(h.ctx.idempotency_key().as_deref(), Some("abc-1"));
}
#[actix_web::test]
async fn resolve_delegates_to_bound_scope() {
async fn inner() -> HttpResponse {
let ctx = noema::resolve::<dyn RequestContext<()> + Send + Sync>();
HttpResponse::Ok().body(ctx.idempotency_key().unwrap_or_default())
}
let srv = test::init_service(
App::new()
.wrap(request_context::<()>())
.route("/", web::get().to(inner)),
)
.await;
let req = test::TestRequest::get()
.uri("/")
.insert_header((IDEMPOTENCY_HEADER, "abc-1"))
.to_request();
let resp = test::call_service(&srv, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(test::read_body(resp).await, "abc-1");
}
}