wavefunk-ui 0.1.5

Askama and htmx UI component base for Wave Funk Rust applications.
Documentation
use crate::assets;
use std::borrow::Cow;

pub fn asset_router<S>() -> ::axum::Router<S>
where
    S: Clone + Send + Sync + 'static,
{
    ::axum::Router::<S>::new().route("/{*path}", ::axum::routing::get(asset))
}

async fn asset(
    ::axum::extract::Path(path): ::axum::extract::Path<String>,
    request_headers: ::axum::http::HeaderMap,
) -> ::axum::response::Response {
    use ::axum::http::{HeaderMap, HeaderValue, StatusCode, header};
    use ::axum::response::IntoResponse;

    match assets::get(&path) {
        Some(asset) => {
            let etag = assets::etag(&asset.path).expect("embedded asset should have an entity tag");
            let mut headers = HeaderMap::new();
            headers.insert(
                header::CONTENT_TYPE,
                HeaderValue::from_static(asset.content_type),
            );
            headers.insert(
                header::CACHE_CONTROL,
                HeaderValue::from_static(assets::CACHE_CONTROL),
            );
            headers.insert(
                header::ETAG,
                HeaderValue::from_str(&etag).expect("asset entity tags should be valid headers"),
            );

            if if_none_match_matches(request_headers.get(header::IF_NONE_MATCH), &etag) {
                return (StatusCode::NOT_MODIFIED, headers).into_response();
            }

            (StatusCode::OK, headers, body_from_asset_bytes(asset.bytes)).into_response()
        }
        None => StatusCode::NOT_FOUND.into_response(),
    }
}

fn body_from_asset_bytes(bytes: Cow<'static, [u8]>) -> ::axum::body::Body {
    match bytes {
        Cow::Borrowed(bytes) => ::axum::body::Body::from(bytes),
        Cow::Owned(bytes) => ::axum::body::Body::from(bytes),
    }
}

fn if_none_match_matches(header: Option<&::axum::http::HeaderValue>, etag: &str) -> bool {
    header
        .and_then(|value| value.to_str().ok())
        .is_some_and(|value| {
            value.split(',').any(|candidate| {
                let candidate = candidate.trim();
                candidate == "*" || candidate == etag || candidate.strip_prefix("W/") == Some(etag)
            })
        })
}

#[cfg(test)]
mod tests {
    use super::*;
    use ::axum::body;
    use ::axum::http::{StatusCode, header};

    #[tokio::test]
    async fn asset_response_sets_runtime_headers_and_body() {
        let response = asset(
            ::axum::extract::Path(crate::assets::STYLESHEET_PATH.to_owned()),
            ::axum::http::HeaderMap::new(),
        )
        .await;

        assert_eq!(response.status(), StatusCode::OK);
        assert_eq!(
            response.headers().get(header::CONTENT_TYPE).unwrap(),
            "text/css; charset=utf-8"
        );
        assert_eq!(
            response.headers().get(header::CACHE_CONTROL).unwrap(),
            crate::assets::CACHE_CONTROL
        );
        let etag = response
            .headers()
            .get(header::ETAG)
            .expect("asset response should expose an entity tag")
            .to_str()
            .expect("etag should be valid ascii");
        assert!(etag.starts_with('"') && etag.ends_with('"'));

        let bytes = body::to_bytes(response.into_body(), usize::MAX)
            .await
            .unwrap();
        assert!(!bytes.is_empty());
    }

    #[tokio::test]
    async fn missing_asset_returns_not_found_without_asset_headers() {
        let response = asset(
            ::axum::extract::Path("missing.css".to_owned()),
            ::axum::http::HeaderMap::new(),
        )
        .await;

        assert_eq!(response.status(), StatusCode::NOT_FOUND);
        assert!(response.headers().get(header::CONTENT_TYPE).is_none());
        assert!(response.headers().get(header::ETAG).is_none());
    }

    #[tokio::test]
    async fn matching_entity_tag_returns_not_modified() {
        let etag = crate::assets::etag(crate::assets::STYLESHEET_PATH).unwrap();
        let mut headers = ::axum::http::HeaderMap::new();
        headers.insert(header::IF_NONE_MATCH, etag.parse().unwrap());

        let response = asset(
            ::axum::extract::Path(crate::assets::STYLESHEET_PATH.to_owned()),
            headers,
        )
        .await;

        assert_eq!(response.status(), StatusCode::NOT_MODIFIED);
        assert_eq!(response.headers().get(header::ETAG).unwrap(), etag.as_str());
        let bytes = body::to_bytes(response.into_body(), usize::MAX)
            .await
            .unwrap();
        assert!(bytes.is_empty());
    }

    #[test]
    fn asset_handler_does_not_force_embed_bytes_into_owned_vecs() {
        let source = include_str!("axum.rs");
        let forbidden = concat!("into", "_owned()");

        assert!(
            !source.contains(forbidden),
            "asset responses should move owned debug bytes or borrow embedded bytes without an extra Vec copy"
        );
    }
}