use std::convert::Infallible;
use std::task::{Context, Poll};
use axum::http::header::HeaderName;
use axum::http::{Request, Response};
use futures::future::BoxFuture;
use hwhkit_core::TenantId;
use tower::{Layer, Service};
pub const DEFAULT_TENANT_HEADER: &str = "x-tenant-id";
pub const MAX_TENANT_ID_LEN: usize = 128;
#[derive(Clone)]
pub struct TenantExtractorLayer {
header_name: HeaderName,
max_len: usize,
}
impl TenantExtractorLayer {
pub fn new(header: &str) -> Self {
let header_name = HeaderName::from_bytes(header.as_bytes())
.unwrap_or_else(|_| HeaderName::from_static(DEFAULT_TENANT_HEADER));
Self {
header_name,
max_len: MAX_TENANT_ID_LEN,
}
}
pub fn with_max_len(mut self, max_len: usize) -> Self {
self.max_len = max_len;
self
}
}
impl Default for TenantExtractorLayer {
fn default() -> Self {
Self::new(DEFAULT_TENANT_HEADER)
}
}
impl<S> Layer<S> for TenantExtractorLayer {
type Service = TenantExtractor<S>;
fn layer(&self, inner: S) -> Self::Service {
TenantExtractor {
inner,
header_name: self.header_name.clone(),
max_len: self.max_len,
}
}
}
#[derive(Clone)]
pub struct TenantExtractor<S> {
inner: S,
header_name: HeaderName,
max_len: usize,
}
impl<S, ReqBody, ResBody> Service<Request<ReqBody>> for TenantExtractor<S>
where
S: Service<Request<ReqBody>, Response = Response<ResBody>, Error = Infallible>
+ Clone
+ Send
+ 'static,
S::Future: Send + 'static,
ReqBody: Send + 'static,
ResBody: Send + 'static,
{
type Response = Response<ResBody>;
type Error = Infallible;
type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, mut req: Request<ReqBody>) -> Self::Future {
let validated = req
.headers()
.get(&self.header_name)
.and_then(|raw| match raw.to_str() {
Ok(s) => Some(s.trim().to_string()),
Err(_) => {
tracing::debug!("tenant id header rejected (non-utf-8 bytes)");
None
}
})
.filter(|trimmed| {
if trimmed.is_empty() {
return false;
}
if trimmed.len() > self.max_len {
tracing::debug!(
len = trimmed.len(),
max_len = self.max_len,
"tenant id header rejected (too long)"
);
return false;
}
if !trimmed
.chars()
.all(|c| !c.is_control() && !c.is_whitespace())
{
tracing::debug!("tenant id header rejected (contains control or whitespace)");
return false;
}
true
});
if let Some(value) = validated {
req.extensions_mut().insert(TenantId::new(value));
}
let clone = self.inner.clone();
let mut inner = std::mem::replace(&mut self.inner, clone);
Box::pin(async move { inner.call(req).await })
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::{Request, Response, StatusCode};
use std::convert::Infallible;
use tower::{Service, ServiceExt};
#[derive(Clone)]
struct EchoTenantPresence;
impl Service<Request<Body>> for EchoTenantPresence {
type Response = Response<Body>;
type Error = Infallible;
type Future = futures::future::BoxFuture<'static, Result<Self::Response, Self::Error>>;
fn poll_ready(
&mut self,
_cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
std::task::Poll::Ready(Ok(()))
}
fn call(&mut self, req: Request<Body>) -> Self::Future {
let has_tenant = req.extensions().get::<TenantId>().is_some();
Box::pin(async move {
Ok(Response::builder()
.status(if has_tenant {
StatusCode::OK
} else {
StatusCode::NO_CONTENT
})
.body(Body::empty())
.unwrap())
})
}
}
fn build() -> TenantExtractor<EchoTenantPresence> {
let layer = TenantExtractorLayer::default().with_max_len(8);
tower::Layer::layer(&layer, EchoTenantPresence)
}
#[tokio::test]
async fn accepts_normal_tenant() {
let mut svc = build();
let req = Request::builder()
.header(DEFAULT_TENANT_HEADER, "acme")
.body(Body::empty())
.unwrap();
let r = ServiceExt::<Request<Body>>::ready(&mut svc)
.await
.unwrap()
.call(req)
.await
.unwrap();
assert_eq!(r.status(), StatusCode::OK);
}
#[tokio::test]
async fn rejects_overlong() {
let mut svc = build();
let req = Request::builder()
.header(DEFAULT_TENANT_HEADER, "012345678") .body(Body::empty())
.unwrap();
let r = ServiceExt::<Request<Body>>::ready(&mut svc)
.await
.unwrap()
.call(req)
.await
.unwrap();
assert_eq!(r.status(), StatusCode::NO_CONTENT);
}
#[tokio::test]
async fn rejects_control_chars() {
let mut svc = build();
let req = Request::builder()
.header(DEFAULT_TENANT_HEADER, "ac\tme")
.body(Body::empty())
.unwrap();
let r = ServiceExt::<Request<Body>>::ready(&mut svc)
.await
.unwrap()
.call(req)
.await
.unwrap();
assert_eq!(r.status(), StatusCode::NO_CONTENT);
}
}