use axum::{
body::{to_bytes, Body},
extract::{Request, State},
http::{header, Method, StatusCode},
middleware::Next,
response::Response,
};
use sqlx::PgPool;
use crate::company::{CompanyVerifier, CompanyContext};
const MAX_BODY: usize = 64 * 1024;
#[derive(Clone)]
pub struct IdempotencyState {
pub verifier: CompanyVerifier,
pub pool: PgPool,
}
impl IdempotencyState {
pub fn new(verifier: CompanyVerifier, pool: PgPool) -> Self {
Self { verifier, pool }
}
}
pub async fn migrate(pool: &PgPool) -> Result<(), sqlx::Error> {
sqlx::query(
r#"CREATE TABLE IF NOT EXISTS public.idempotency_requests (
company_id uuid NOT NULL,
key text NOT NULL,
status_code int NOT NULL,
body text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (company_id, key)
)"#,
)
.execute(pool)
.await?;
Ok(())
}
pub async fn idempotency_middleware(
State(st): State<IdempotencyState>,
req: Request,
next: Next,
) -> Response {
if !matches!(*req.method(), Method::POST | Method::PUT | Method::PATCH | Method::DELETE) {
return next.run(req).await;
}
let key = match req.headers().get("idempotency-key").and_then(|v| v.to_str().ok()) {
Some(k) => k.to_string(),
None => return next.run(req).await,
};
let company_id = match company_from_request(&st.verifier, &req) {
Some(c) => c.company_id,
None => return next.run(req).await,
};
if let Some((code, body)) = lookup(&st.pool, company_id, &key).await {
return Response::builder()
.status(StatusCode::from_u16(code).unwrap_or(StatusCode::OK))
.header(header::CONTENT_TYPE, "application/json")
.header("idempotent-replay", "true")
.body(Body::from(body))
.unwrap();
}
let resp = next.run(req).await;
if !resp.status().is_success() {
return resp; }
let code = resp.status().as_u16();
let (parts, body) = resp.into_parts();
let bytes = to_bytes(body, MAX_BODY).await.unwrap_or_default();
if bytes.len() <= MAX_BODY {
let _ = store(&st.pool, company_id, &key, code, &bytes).await;
}
Response::from_parts(parts, Body::from(bytes))
}
fn company_from_request(verifier: &CompanyVerifier, req: &Request) -> Option<CompanyContext> {
let token = req
.headers()
.get(header::AUTHORIZATION)
.and_then(|h| h.to_str().ok())
.and_then(|raw| raw.strip_prefix("Bearer ").or_else(|| raw.strip_prefix("bearer ")))?;
verifier.verify(token)
}
async fn lookup(pool: &PgPool, company_id: uuid::Uuid, key: &str) -> Option<(u16, String)> {
sqlx::query_as::<_, (i32, String)>(
"SELECT status_code, body FROM public.idempotency_requests WHERE company_id=$1 AND key=$2",
)
.bind(company_id)
.bind(key)
.fetch_optional(pool)
.await
.ok()
.flatten()
.map(|(code, body)| (code as u16, body))
}
async fn store(
pool: &PgPool,
company_id: uuid::Uuid,
key: &str,
code: u16,
body: &[u8],
) -> Result<(), sqlx::Error> {
let body_text = std::str::from_utf8(body).unwrap_or("").to_string();
sqlx::query(
r#"INSERT INTO public.idempotency_requests (company_id, key, status_code, body)
VALUES ($1, $2, $3, $4)
ON CONFLICT (company_id, key) DO NOTHING"#,
)
.bind(company_id)
.bind(key)
.bind(code as i32)
.bind(body_text)
.execute(pool)
.await?;
Ok(())
}