vite-static-actix-web 0.5.0

Actix Web integration support for `vite-static`
Documentation
#![warn(clippy::pedantic)]

use std::{ops::Deref, rc::Rc};
use vite_static_shared::{Manifest, ManifestChunk};

use actix_web::{
    Error, HttpMessage as _, HttpRequest, HttpResponse,
    dev::{
        AppService, HttpServiceFactory, ResourceDef, Service, ServiceFactory, ServiceRequest,
        ServiceResponse, always_ready,
    },
    http::{
        Method, StatusCode,
        header::{
            CONTENT_TYPE, CacheControl, CacheDirective, ETag, EntityTag, IfMatch, IfNoneMatch,
        },
    },
};
use futures_util::future::{FutureExt, LocalBoxFuture, Ready, ok};

/// Actix service for serving Vite Static.
///
/// ```rust
/// // <snip>
///
/// HttpServer::new(|| {
///     App::new()
///         // <snip>
///         .service(ActixFiles::new("/", MyViteStatic.boxed()))
/// })
/// .bind(("127.0.0.1", 8080))?
/// .run()
/// .await
///
/// // <snip>
/// ```
#[derive(Clone)]
pub struct ActixFiles {
    path: String,
    manifest: Rc<Box<dyn Manifest<'static>>>,
    cache_control: CacheControl,
}

impl ActixFiles {
    /// Creates new [`ActixFiles`] service.
    ///
    /// Takes path (where serve static) and [`Manifest`] itself.
    ///
    /// ```rust
    /// ActixFiles::new("/", MyViteStatic.boxed())
    /// ActixFiles::new("/static", MyViteStaticWithChangedBase.boxed())
    /// // ^-- in vite config `{ base: "/static" }`
    /// ```
    #[must_use]
    pub fn new(path: &str, manifest: Box<dyn Manifest<'static>>) -> Self {
        Self {
            path: path.to_string(),
            manifest: Rc::new(manifest),
            cache_control: CacheControl(vec![CacheDirective::MaxAge(604_800)]),
        }
    }

    /// Sets [`CacheControl`] for served static.
    ///
    /// By default, `CacheControl` is set to "Max Age of 7 days".
    ///
    /// ```rust
    /// ActixFiles::new("/", MyViteStatic.boxed())
    ///     .cache_control(CacheControl(vec![CacheDirective::MaxAge(604_800)])) // 7 days
    /// ```
    #[must_use]
    pub fn cache_control(mut self, value: CacheControl) -> Self {
        self.cache_control = value;
        self
    }
}

impl HttpServiceFactory for ActixFiles {
    fn register(self, config: &mut AppService) {
        let path = self.path.trim_start_matches('/');
        let rdef = if config.is_root() {
            ResourceDef::root_prefix(path)
        } else {
            ResourceDef::prefix(path)
        };
        // TODO: guards
        config.register_service(rdef, None, self, None);
    }
}

impl ServiceFactory<ServiceRequest> for ActixFiles {
    type Response = ServiceResponse;
    type Error = Error;
    type Config = ();
    type Service = ActixFilesService;
    type InitError = ();
    type Future = LocalBoxFuture<'static, Result<Self::Service, Self::InitError>>;

    fn new_service(&self, _cfg: Self::Config) -> Self::Future {
        ok(ActixFilesService {
            inner: self.clone(),
        })
        .boxed_local()
    }
}

/// Internal implementation of [`ActixFiles`] service.
///
/// Use [`ActixFiles`] instead!
pub struct ActixFilesService {
    inner: ActixFiles,
}

impl Deref for ActixFilesService {
    type Target = ActixFiles;

    fn deref(&self) -> &Self::Target {
        &self.inner
    }
}

impl ActixFilesService {
    fn respond_to(&self, req: &HttpRequest, chunk: &ManifestChunk<'_>) -> HttpResponse {
        let mut response = HttpResponse::Ok();
        response.insert_header((CONTENT_TYPE, chunk.mime_type.as_ref()));

        let etag = EntityTag::new_strong(chunk.hash.to_string());
        response.insert_header(ETag(etag.clone()));

        let precondition_failed = match req.get_header::<IfMatch>() {
            None | Some(IfMatch::Any) => false,
            Some(IfMatch::Items(items)) => items.iter().all(|item| item.strong_ne(&etag)),
        };
        if precondition_failed {
            return response.status(StatusCode::PRECONDITION_FAILED).finish();
        }

        let not_modified = match req.get_header::<IfNoneMatch>() {
            None => false,
            Some(IfNoneMatch::Any) => true,
            Some(IfNoneMatch::Items(items)) => items.iter().all(|item| item.weak_ne(&etag)),
        };
        if not_modified {
            return response.status(StatusCode::NOT_MODIFIED).finish();
        }

        response.insert_header(self.cache_control.clone());
        response.body(chunk.contents.to_vec())
    }
}

impl Service<ServiceRequest> for ActixFilesService {
    type Response = ServiceResponse;
    type Error = Error;
    type Future = Ready<Result<Self::Response, Self::Error>>;

    always_ready!();

    fn call(&self, req: ServiceRequest) -> Self::Future {
        match *req.method() {
            Method::GET => (),
            _ => {
                return ok(ServiceResponse::new(
                    req.into_parts().0,
                    HttpResponse::MethodNotAllowed().body("Only GET requests are allowed"),
                ));
            }
        }

        let (req, _) = req.into_parts();
        let requested_path = req.match_info().unprocessed().trim_start_matches('/');

        let response = match self.manifest.chunk(requested_path) {
            Some(chunk) => self.respond_to(&req, &chunk),
            None => HttpResponse::NotFound().body("Not found"),
        };

        ok(ServiceResponse::new(req, response))
    }
}