Compare commits

..

2 commits

Author SHA1 Message Date
eb18690a5c (tests): Adapta la suite al nuevo framework web
- Sustituye `service::test::*` por `web::test::*` (migración de actix-web a
  axum).
- Extrae `setup()` en los módulos que sólo renderizan componentes,
  evitando levantar un router completo en cada test.
- Elimina los `env::set_var("PAGETOP_RUN_MODE", "test")` manuales, ya
  cubiertos por la *feature* `testing`.
2026-06-01 02:04:02 +02:00
87e4eac27c 🔥 (statics): Elimina código residual de actix-web
`ResourceFiles` y `UriSegmentError` quedaron sin uso al migrar de
actix-web a axum/tower.
2026-06-01 01:01:24 +02:00
8 changed files with 46 additions and 453 deletions

View file

@ -47,10 +47,7 @@ pub mod resource;
pub use resource::Resource as StaticResource;
mod resource_dir;
pub use resource_dir::{resource_dir, ResourceDir};
mod resource_files;
pub use resource_files::{ResourceFiles, UriSegmentError};
pub use resource_dir::{ResourceDir, resource_dir};
/// Support for module based generations. Use it for large data sets (more than 128 Mb).
pub mod sets;

View file

@ -1,396 +0,0 @@
use super::resource::Resource;
use actix_web::{
dev::{
always_ready, AppService, HttpServiceFactory, ResourceDef, Service, ServiceFactory,
ServiceRequest, ServiceResponse,
},
error::Error,
guard::{Guard, GuardContext},
http::{
header::{self, ContentType},
Method, StatusCode,
},
HttpMessage, HttpRequest, HttpResponse, ResponseError,
};
use derive_more::{Deref, Display, Error};
use futures_util::future::{ok, FutureExt, LocalBoxFuture, Ready};
use std::{collections::HashMap, ops::Deref, rc::Rc};
/// Static resource files handling
///
/// `ResourceFiles` service must be registered with `App::service` method.
///
/// ```rust
/// use std::collections::HashMap;
///
/// use actix_web::App;
///
/// fn main() {
/// // serve root directory with default options:
/// // - resolve index.html
/// let files: HashMap<&'static str, pagetop_statics::StaticResource> = HashMap::new();
/// let app = App::new()
/// .service(pagetop_statics::ResourceFiles::new("/", files));
/// // or subpath with additional option to not resolve index.html
/// let files: HashMap<&'static str, pagetop_statics::StaticResource> = HashMap::new();
/// let app = App::new()
/// .service(pagetop_statics::ResourceFiles::new("/imgs", files)
/// .do_not_resolve_defaults());
/// }
/// ```
#[allow(clippy::needless_doctest_main)]
pub struct ResourceFiles {
not_resolve_defaults: bool,
use_guard: bool,
not_found_resolves_to: Option<String>,
inner: Rc<ResourceFilesInner>,
}
pub struct ResourceFilesInner {
path: String,
files: HashMap<&'static str, Resource>,
}
const INDEX_HTML: &str = "index.html";
impl ResourceFiles {
pub fn new(path: &str, files: HashMap<&'static str, Resource>) -> Self {
let inner = ResourceFilesInner {
path: path.into(),
files,
};
Self {
inner: Rc::new(inner),
not_resolve_defaults: false,
not_found_resolves_to: None,
use_guard: false,
}
}
/// By default trying to resolve '.../' to '.../index.html' if it exists.
/// Turn off this resolution by calling this function.
pub fn do_not_resolve_defaults(mut self) -> Self {
self.not_resolve_defaults = true;
self
}
/// Resolves not found references to this path.
///
/// This can be useful for angular-like applications.
pub fn resolve_not_found_to<S: ToString>(mut self, path: S) -> Self {
self.not_found_resolves_to = Some(path.to_string());
self
}
/// Resolves not found references to root path.
///
/// This can be useful for angular-like applications.
pub fn resolve_not_found_to_root(self) -> Self {
self.resolve_not_found_to(INDEX_HTML)
}
/// If this is called, we will use an [actix_web::guard::Guard] to check if this request should be handled.
/// If set to true, we skip using the handler for files that haven't been found, instead of sending 404s.
/// Would be ignored, if `resolve_not_found_to` or `resolve_not_found_to_root` is used.
///
/// Can be useful if you want to share files on a (sub)path that's also used by a different route handler.
pub fn skip_handler_when_not_found(mut self) -> Self {
self.use_guard = true;
self
}
fn select_guard(&self) -> Box<dyn Guard> {
if self.not_resolve_defaults {
Box::new(NotResolveDefaultsGuard::from(self))
} else {
Box::new(ResolveDefaultsGuard::from(self))
}
}
}
impl Deref for ResourceFiles {
type Target = ResourceFilesInner;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
struct NotResolveDefaultsGuard {
inner: Rc<ResourceFilesInner>,
}
impl Guard for NotResolveDefaultsGuard {
fn check(&self, ctx: &GuardContext<'_>) -> bool {
self.inner
.files
.contains_key(ctx.head().uri.path().trim_start_matches('/'))
}
}
impl From<&ResourceFiles> for NotResolveDefaultsGuard {
fn from(files: &ResourceFiles) -> Self {
Self {
inner: files.inner.clone(),
}
}
}
struct ResolveDefaultsGuard {
inner: Rc<ResourceFilesInner>,
}
impl Guard for ResolveDefaultsGuard {
fn check(&self, ctx: &GuardContext<'_>) -> bool {
let path = ctx.head().uri.path().trim_start_matches('/');
self.inner.files.contains_key(path)
|| ((path.is_empty() || path.ends_with('/'))
&& self
.inner
.files
.contains_key((path.to_string() + INDEX_HTML).as_str()))
}
}
impl From<&ResourceFiles> for ResolveDefaultsGuard {
fn from(files: &ResourceFiles) -> Self {
Self {
inner: files.inner.clone(),
}
}
}
impl HttpServiceFactory for ResourceFiles {
fn register(self, config: &mut AppService) {
let prefix = self.path.trim_start_matches('/');
let rdef = if config.is_root() {
ResourceDef::root_prefix(prefix)
} else {
ResourceDef::prefix(prefix)
};
let guards = if self.use_guard && self.not_found_resolves_to.is_none() {
Some(vec![self.select_guard()])
} else {
None
};
config.register_service(rdef, guards, self, None);
}
}
impl ServiceFactory<ServiceRequest> for ResourceFiles {
type Config = ();
type Response = ServiceResponse;
type Error = Error;
type Service = ResourceFilesService;
type InitError = ();
type Future = LocalBoxFuture<'static, Result<Self::Service, Self::InitError>>;
fn new_service(&self, _: ()) -> Self::Future {
ok(ResourceFilesService {
resolve_defaults: !self.not_resolve_defaults,
not_found_resolves_to: self.not_found_resolves_to.clone(),
inner: self.inner.clone(),
})
.boxed_local()
}
}
#[derive(Deref)]
pub struct ResourceFilesService {
resolve_defaults: bool,
not_found_resolves_to: Option<String>,
#[deref]
inner: Rc<ResourceFilesInner>,
}
impl Service<ServiceRequest> for ResourceFilesService {
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::HEAD | Method::GET => (),
_ => {
return ok(ServiceResponse::new(
req.into_parts().0,
HttpResponse::MethodNotAllowed()
.insert_header(ContentType::plaintext())
.insert_header((header::ALLOW, "GET, HEAD"))
.body("This resource only supports GET and HEAD."),
));
}
}
let req_path = req.match_info().unprocessed();
let mut item = self.files.get(req_path);
if item.is_none()
&& self.resolve_defaults
&& (req_path.is_empty() || req_path.ends_with('/'))
{
let index_req_path = req_path.to_string() + INDEX_HTML;
item = self.files.get(index_req_path.trim_start_matches('/'));
}
let (req, response) = if item.is_some() {
let (req, _) = req.into_parts();
let response = respond_to(&req, item);
(req, response)
} else {
let real_path = match get_pathbuf(req_path) {
Ok(item) => item,
Err(e) => return ok(req.error_response(e)),
};
let (req, _) = req.into_parts();
let mut item = self.files.get(real_path.as_str());
if item.is_none() && self.not_found_resolves_to.is_some() {
let not_found_path = self.not_found_resolves_to.as_ref().unwrap();
item = self.files.get(not_found_path.as_str());
}
let response = respond_to(&req, item);
(req, response)
};
ok(ServiceResponse::new(req, response))
}
}
fn respond_to(req: &HttpRequest, item: Option<&Resource>) -> HttpResponse {
if let Some(file) = item {
let etag = Some(header::EntityTag::new_strong(format!(
"{:x}:{:x}",
file.data.len(),
file.modified
)));
let precondition_failed = !any_match(etag.as_ref(), req);
let not_modified = !none_match(etag.as_ref(), req);
let mut resp = HttpResponse::build(StatusCode::OK);
resp.insert_header((header::CONTENT_TYPE, file.mime_type));
if let Some(etag) = etag {
resp.insert_header(header::ETag(etag));
}
if precondition_failed {
return resp.status(StatusCode::PRECONDITION_FAILED).finish();
} else if not_modified {
return resp.status(StatusCode::NOT_MODIFIED).finish();
}
resp.body(file.data)
} else {
HttpResponse::NotFound().body("Not found")
}
}
/// Returns true if `req` has no `If-Match` header or one which matches `etag`.
fn any_match(etag: Option<&header::EntityTag>, req: &HttpRequest) -> bool {
match req.get_header::<header::IfMatch>() {
None | Some(header::IfMatch::Any) => true,
Some(header::IfMatch::Items(ref items)) => {
if let Some(some_etag) = etag {
for item in items {
if item.strong_eq(some_etag) {
return true;
}
}
}
false
}
}
}
/// Returns true if `req` doesn't have an `If-None-Match` header matching `req`.
fn none_match(etag: Option<&header::EntityTag>, req: &HttpRequest) -> bool {
match req.get_header::<header::IfNoneMatch>() {
Some(header::IfNoneMatch::Any) => false,
Some(header::IfNoneMatch::Items(ref items)) => {
if let Some(some_etag) = etag {
for item in items {
if item.weak_eq(some_etag) {
return false;
}
}
}
true
}
None => true,
}
}
/// Error type representing invalid characters in a URI path segment.
///
/// This enum is used to report specific formatting errors in individual segments of a URI path,
/// such as starting, ending, or containing disallowed characters. Each variant wraps the offending
/// character that caused the error.
#[derive(Debug, PartialEq, Display, Error)]
pub enum UriSegmentError {
/// The segment started with the wrapped invalid character.
#[display(fmt = "The segment started with the wrapped invalid character")]
BadStart(#[error(not(source))] char),
/// The segment contained the wrapped invalid character.
#[display(fmt = "The segment contained the wrapped invalid character")]
BadChar(#[error(not(source))] char),
/// The segment ended with the wrapped invalid character.
#[display(fmt = "The segment ended with the wrapped invalid character")]
BadEnd(#[error(not(source))] char),
}
#[cfg(test)]
mod tests_error_impl {
use super::*;
fn assert_send_and_sync<T: Send + Sync + 'static>() {}
#[test]
fn test_error_impl() {
// ensure backwards compatibility when migrating away from failure
assert_send_and_sync::<UriSegmentError>();
}
}
/// Return `BadRequest` for `UriSegmentError`
impl ResponseError for UriSegmentError {
fn error_response(&self) -> HttpResponse {
HttpResponse::new(StatusCode::BAD_REQUEST)
}
}
fn get_pathbuf(path: &str) -> Result<String, UriSegmentError> {
let mut buf = Vec::new();
for segment in path.split('/') {
if segment == ".." {
buf.pop();
} else if segment.starts_with('.') {
return Err(UriSegmentError::BadStart('.'));
} else if segment.starts_with('*') {
return Err(UriSegmentError::BadStart('*'));
} else if segment.ends_with(':') {
return Err(UriSegmentError::BadEnd(':'));
} else if segment.ends_with('>') {
return Err(UriSegmentError::BadEnd('>'));
} else if segment.ends_with('<') {
return Err(UriSegmentError::BadEnd('<'));
} else if segment.is_empty() {
continue;
} else if cfg!(windows) && segment.contains('\\') {
return Err(UriSegmentError::BadChar('\\'));
} else {
buf.push(segment)
}
}
Ok(buf.join("/"))
}

View file

@ -46,18 +46,20 @@ async fn component_html_default_renders_empty_markup() {
}
#[pagetop::test]
async fn component_html_can_access_http_method() {
let req = service::test::TestRequest::with_uri("/").to_http_request();
async fn component_html_can_access_request_path() {
let req = web::test::TestRequest::get()
.uri("/hello/world")
.to_http_request();
let mut cx = Context::new(Some(req));
let mut component = Html::with(|cx| {
let method = cx
let path = cx
.request()
.map(|r| r.method().to_string())
.map(|r| r.path().to_string())
.unwrap_or_default();
html! { span { (method) } }
html! { span { (path) } }
});
let markup = component.render(&mut cx);
assert_eq!(markup.0, "<span>GET</span>");
assert_eq!(markup.0, "<span>/hello/world</span>");
}

View file

@ -1,8 +1,16 @@
use pagetop::prelude::*;
/// Inicializa PageTop (locale, extensiones…) una sola vez para toda la suite.
///
/// Los tests de este módulo renderizan componentes directamente con `Context::default()`, por lo
/// que sólo necesitan el subsistema de localización y las extensiones registradas, no un router.
fn setup() {
let _ = Application::new();
}
#[pagetop::test]
async fn poweredby_default_shows_only_pagetop_recognition() {
let _app = service::test::init_service(Application::new().test()).await;
setup();
let mut p = PoweredBy::default();
let html = p.render(&mut Context::default());
@ -16,7 +24,7 @@ async fn poweredby_default_shows_only_pagetop_recognition() {
#[pagetop::test]
async fn poweredby_new_includes_current_year_and_app_name() {
let _app = service::test::init_service(Application::new().test()).await;
setup();
let mut p = PoweredBy::new();
let html = p.render(&mut Context::default());
@ -40,7 +48,7 @@ async fn poweredby_new_includes_current_year_and_app_name() {
#[pagetop::test]
async fn poweredby_with_copyright_overrides_text() {
let _app = service::test::init_service(Application::new().test()).await;
setup();
let custom = "2001 © FooBar Inc.";
let mut p = PoweredBy::default().with_copyright(Some(custom));
@ -52,7 +60,7 @@ async fn poweredby_with_copyright_overrides_text() {
#[pagetop::test]
async fn poweredby_with_copyright_none_hides_text() {
let _app = service::test::init_service(Application::new().test()).await;
setup();
let mut p = PoweredBy::new().with_copyright(None::<String>);
let html = p.render(&mut Context::default());
@ -64,7 +72,7 @@ async fn poweredby_with_copyright_none_hides_text() {
#[pagetop::test]
async fn poweredby_link_points_to_crates_io() {
let _app = service::test::init_service(Application::new().test()).await;
setup();
let mut p = PoweredBy::default();
let html = p.render(&mut Context::default());
@ -77,7 +85,7 @@ async fn poweredby_link_points_to_crates_io() {
#[pagetop::test]
async fn poweredby_getter_reflects_internal_state() {
let _app = service::test::init_service(Application::new().test()).await;
setup();
// Por defecto no hay copyright.
let p0 = PoweredBy::default();

View file

@ -2,8 +2,6 @@ use pagetop::prelude::*;
use serde::Deserialize;
use std::env;
include_config!(SETTINGS: Settings => [
"test.string_value" => "Test String",
"test.int_value" => -321,
@ -22,10 +20,12 @@ pub struct Test {
pub float_value: f32,
}
// La *feature* `testing` (activo con `cargo ts` / `cargo tw`) fija el modo "test" en tiempo de
// compilación dentro de `config::CONFIG_VALUES`, de forma que `global::SETTINGS` y cualquier
// `include_config!` local cargan automáticamente la configuración del modo "test".
#[pagetop::test]
async fn check_global_config() {
env::set_var("PAGETOP_RUN_MODE", "test");
assert_eq!(global::SETTINGS.app.run_mode, "test");
assert_eq!(global::SETTINGS.app.name, "Testing");
assert_eq!(global::SETTINGS.server.bind_port, 9000);
@ -33,8 +33,6 @@ async fn check_global_config() {
#[pagetop::test]
async fn check_local_config() {
env::set_var("PAGETOP_RUN_MODE", "test");
assert_eq!(SETTINGS.test.string_value, "Modified value");
assert_eq!(SETTINGS.test.int_value, -321);
assert_eq!(SETTINGS.test.float_value, 8.7654);

View file

@ -2,7 +2,7 @@ use pagetop::prelude::*;
#[pagetop::test]
async fn literal_text() {
let _app = service::test::init_service(Application::new().test()).await;
let _app = web::test::init_router(Application::new().test());
let l10n = L10n::n("© 2025 PageTop");
assert_eq!(l10n.get(), Some("© 2025 PageTop".to_string()));
@ -10,7 +10,7 @@ async fn literal_text() {
#[pagetop::test]
async fn translation_without_args() {
let _app = service::test::init_service(Application::new().test()).await;
let _app = web::test::init_router(Application::new().test());
let l10n = L10n::l("test_hello_world");
let translation = l10n.lookup(&Locale::resolve("es-ES"));
@ -19,7 +19,7 @@ async fn translation_without_args() {
#[pagetop::test]
async fn translation_with_args() {
let _app = service::test::init_service(Application::new().test()).await;
let _app = web::test::init_router(Application::new().test());
let l10n = L10n::l("test_hello_user").with_arg("userName", "Manuel");
let translation = l10n.lookup(&Locale::resolve("es-ES"));
@ -28,7 +28,7 @@ async fn translation_with_args() {
#[pagetop::test]
async fn translation_with_plural_and_select() {
let _app = service::test::init_service(Application::new().test()).await;
let _app = web::test::init_router(Application::new().test());
let l10n = L10n::l("test_shared_photos").with_args(vec![
("userName", "Roberto"),
@ -41,7 +41,7 @@ async fn translation_with_plural_and_select() {
#[pagetop::test]
async fn check_fallback_language() {
let _app = service::test::init_service(Application::new().test()).await;
let _app = web::test::init_router(Application::new().test());
let l10n = L10n::l("test_hello_world");
let translation = l10n.lookup(&Locale::resolve("xx-YY")); // Retrocede a "en-US".
@ -50,7 +50,7 @@ async fn check_fallback_language() {
#[pagetop::test]
async fn check_unknown_key() {
let _app = service::test::init_service(Application::new().test()).await;
let _app = web::test::init_router(Application::new().test());
let l10n = L10n::l("non-existent-key");
let translation = l10n.lookup(&Locale::resolve("en-US"));

View file

@ -2,11 +2,11 @@ use pagetop::prelude::*;
#[pagetop::test]
async fn homepage_returns_404() {
let app = service::test::init_service(Application::new().test()).await;
let app = web::test::init_router(Application::new().test());
let req = service::test::TestRequest::get().uri("/").to_request();
let resp = service::test::call_service(&app, req).await;
let req = web::test::TestRequest::get().uri("/").to_request();
let resp = web::test::send_request(&app, req).await;
// Comprueba el acceso a la ruta de inicio.
assert_eq!(resp.status(), service::http::StatusCode::OK);
assert_eq!(resp.status(), web::http::StatusCode::OK);
}

View file

@ -1,6 +1,6 @@
use pagetop::prelude::*;
use std::{borrow::Cow, env, fs, io};
use std::{borrow::Cow, fs, io};
use tempfile::TempDir;
// **< Testing normalize_ascii() >******************************************************************
@ -265,7 +265,7 @@ mod unix {
#[pagetop::test]
async fn ok_absolute_dir() -> io::Result<()> {
let _app = service::test::init_service(Application::new().test()).await;
let _app = web::test::init_router(Application::new().test());
// /tmp/<rand>/sub
let td = TempDir::new()?;
@ -279,21 +279,13 @@ mod unix {
#[pagetop::test]
async fn ok_relative_dir_with_manifest() -> io::Result<()> {
let _app = service::test::init_service(Application::new().test()).await;
let _app = web::test::init_router(Application::new().test());
let td = TempDir::new()?;
let sub = td.path().join("sub");
fs::create_dir(&sub)?;
// Fija CARGO_MANIFEST_DIR para que "sub" se resuelva contra td.path()
let prev_manifest_dir = env::var_os("CARGO_MANIFEST_DIR");
env::set_var("CARGO_MANIFEST_DIR", td.path());
let res = util::resolve_absolute_dir("sub");
// Restaura entorno.
match prev_manifest_dir {
Some(v) => env::set_var("CARGO_MANIFEST_DIR", v),
None => env::remove_var("CARGO_MANIFEST_DIR"),
}
let res = util::resolve_absolute_dir_with_base("sub", Some(td.path().to_path_buf()));
assert_eq!(res?, std::fs::canonicalize(&sub)?);
Ok(())
@ -301,7 +293,7 @@ mod unix {
#[pagetop::test]
async fn error_not_a_directory() -> io::Result<()> {
let _app = service::test::init_service(Application::new().test()).await;
let _app = web::test::init_router(Application::new().test());
let td = TempDir::new()?;
let file = td.path().join("foo.txt");
@ -319,7 +311,7 @@ mod windows {
#[pagetop::test]
async fn ok_absolute_dir() -> io::Result<()> {
let _app = service::test::init_service(Application::new().test()).await;
let _app = web::test::init_router(Application::new().test());
// C:\Users\...\Temp\...
let td = TempDir::new()?;
@ -333,21 +325,13 @@ mod windows {
#[pagetop::test]
async fn ok_relative_dir_with_manifest() -> io::Result<()> {
let _app = service::test::init_service(Application::new().test()).await;
let _app = web::test::init_router(Application::new().test());
let td = TempDir::new()?;
let sub = td.path().join("sub");
fs::create_dir(&sub)?;
// Fija CARGO_MANIFEST_DIR para que "sub" se resuelva contra td.path()
let prev_manifest_dir = env::var_os("CARGO_MANIFEST_DIR");
env::set_var("CARGO_MANIFEST_DIR", td.path());
let res = util::resolve_absolute_dir("sub");
// Restaura entorno.
match prev_manifest_dir {
Some(v) => env::set_var("CARGO_MANIFEST_DIR", v),
None => env::remove_var("CARGO_MANIFEST_DIR"),
}
let res = resolve_absolute_dir_with_base("sub", Some(td.path().to_path_buf()));
assert_eq!(res?, std::fs::canonicalize(&sub)?);
Ok(())
@ -355,7 +339,7 @@ mod windows {
#[pagetop::test]
async fn error_not_a_directory() -> io::Result<()> {
let _app = service::test::init_service(Application::new().test()).await;
let _app = web::test::init_router(Application::new().test());
let td = TempDir::new()?;
let file = td.path().join("foo.txt");