Make ServieFile specialized version of ServeDir (#187)

* Make ServieFile specialized version of ServeDir

* Add missing test for index.html redirection

* Fix tiny doc error

* Update changelog to include breaking change in service types
This commit is contained in:
Oskar Nehlin
2021-11-24 14:08:12 +01:00
committed by GitHub
parent 43bb27709d
commit 1069c2ee78
5 changed files with 182 additions and 214 deletions

1
test-files/index.html Normal file
View File

@@ -0,0 +1 @@
<b>HTML!</b>

View File

@@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
# Unreleased
- `ServeDir` and `ServeFile`: Changed service types, both now return `ServeFileSystemResponseBody` and `ServeFileSystemResponseFuture` ([#187])
- `AddAuthorization`, `InFlightRequests`, `SetRequestHeader`, `SetResponseHeader`, `AddExtension`, `MapRequestBody` and `MapResponseBody`
now requires underlying service to use `http::Request<B>` and `http::Response<U>` as request and responses ([#182])
- `ServeDir` and `ServeFile`: Ability to serve precompressed files ([#156])
@@ -42,6 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
[#170]: https://github.com/tower-rs/tower-http/pull/170
[#172]: https://github.com/tower-rs/tower-http/pull/172
[#182]: https://github.com/tower-rs/tower-http/pull/182
[#187]: https://github.com/tower-rs/tower-http/pull/187
# 0.1.2 (November 13, 2021)

View File

@@ -5,7 +5,7 @@ use http::{HeaderMap, Response, StatusCode};
use http_body::{combinators::BoxBody, Body, Empty};
use pin_project_lite::pin_project;
use std::fs::Metadata;
use std::{ffi::OsStr, future::Future, path::PathBuf};
use std::{ffi::OsStr, path::PathBuf};
use std::{
io,
pin::Pin,
@@ -27,11 +27,12 @@ use crate::content_encoding::{Encoding, SupportedEncodings};
pub use self::{
serve_dir::{
ResponseBody as ServeDirResponseBody, ResponseFuture as ServeDirResponseFuture, ServeDir,
},
serve_file::{
ResponseBody as ServeFileResponseBody, ResponseFuture as ServeFileResponseFuture, ServeFile,
// The response body and future are used for both ServeDir and ServeFile
ResponseBody as ServeFileSystemResponseBody,
ResponseFuture as ServeFileSystemResponseFuture,
ServeDir,
},
serve_file::ServeFile,
};
#[derive(Clone, Copy, Debug)]
@@ -65,9 +66,6 @@ impl SupportedEncodings for PrecompressedVariants {
}
}
type FileFuture =
Pin<Box<dyn Future<Output = io::Result<(File, Option<Encoding>)>> + Send + Sync + 'static>>;
// Returns the preferred_encoding encoding and modifies the path extension
// to the corresponding file extension for the encoding.
fn preferred_encoding(

View File

@@ -58,14 +58,14 @@ use tower_service::Service;
/// [`and_then`](tower::ServiceBuilder::and_then) to change the response:
///
/// ```
/// use tower_http::services::fs::{ServeDir, ServeDirResponseBody};
/// use tower_http::services::fs::{ServeDir, ServeFileSystemResponseBody};
/// use tower::ServiceBuilder;
/// use http::{StatusCode, Response};
/// use http_body::{Body as _, Full};
/// use std::io;
///
/// let service = ServiceBuilder::new()
/// .and_then(|response: Response<ServeDirResponseBody>| async move {
/// .and_then(|response: Response<ServeFileSystemResponseBody>| async move {
/// let response = if response.status() == StatusCode::NOT_FOUND {
/// let body = Full::from("Not Found")
/// .map_err(|err| match err {})
@@ -92,9 +92,53 @@ use tower_service::Service;
#[derive(Clone, Debug)]
pub struct ServeDir {
base: PathBuf,
append_index_html_on_directories: bool,
buf_chunk_size: usize,
precompressed_variants: Option<PrecompressedVariants>,
// This is used to specialise implementation for
// single files
variant: ServeVariant,
}
// Allow the ServeDir service to be used in the ServeFile service
// with almost no overhead
#[derive(Clone, Debug)]
enum ServeVariant {
Directory {
append_index_html_on_directories: bool,
},
SingleFile {
mime: HeaderValue,
},
}
impl ServeVariant {
fn full_path(&self, base_path: &Path, requested_path: &str) -> Option<PathBuf> {
match self {
ServeVariant::Directory {
append_index_html_on_directories: _,
} => {
let full_path = build_and_validate_path(base_path, requested_path)?;
Some(full_path)
}
ServeVariant::SingleFile { mime: _ } => Some(base_path.to_path_buf()),
}
}
}
fn build_and_validate_path(base_path: &Path, requested_path: &str) -> Option<PathBuf> {
// build and validate the path
let path = requested_path.trim_start_matches('/');
let path_decoded = percent_decode(path.as_ref()).decode_utf8().ok()?;
let mut full_path = base_path.to_path_buf();
for seg in path_decoded.split('/') {
if seg.starts_with("..") || seg.contains('\\') {
return None;
}
full_path.push(seg);
}
Some(full_path)
}
impl ServeDir {
@@ -105,9 +149,20 @@ impl ServeDir {
Self {
base,
append_index_html_on_directories: true,
buf_chunk_size: DEFAULT_CAPACITY,
precompressed_variants: None,
variant: ServeVariant::Directory {
append_index_html_on_directories: true,
},
}
}
pub(crate) fn new_single_file<P: AsRef<Path>>(path: P, mime: HeaderValue) -> Self {
Self {
base: path.as_ref().to_owned(),
buf_chunk_size: DEFAULT_CAPACITY,
precompressed_variants: None,
variant: ServeVariant::SingleFile { mime },
}
}
@@ -117,8 +172,15 @@ impl ServeDir {
///
/// Defaults to `true`.
pub fn append_index_html_on_directories(mut self, append: bool) -> Self {
self.append_index_html_on_directories = append;
self
match &mut self.variant {
ServeVariant::Directory {
append_index_html_on_directories,
} => {
*append_index_html_on_directories = append;
self
}
ServeVariant::SingleFile { mime: _ } => self,
}
}
/// Set a specific read buffer chunk size.
@@ -181,6 +243,29 @@ impl ServeDir {
}
}
async fn maybe_redirect_or_append_path(
full_path: &mut PathBuf,
uri: Uri,
append_index_html_on_directories: bool,
) -> Option<Output> {
if !uri.path().ends_with('/') {
if is_dir(full_path).await {
let location = HeaderValue::from_str(&append_slash_on_path(uri).to_string()).unwrap();
return Some(Output::Redirect(location));
} else {
return None;
}
} else if is_dir(full_path).await {
if append_index_html_on_directories {
full_path.push("index.html");
return None;
} else {
return Some(Output::NotFound);
}
}
None
}
impl<ReqBody> Service<Request<ReqBody>> for ServeDir {
type Response = Response<ResponseBody>;
type Error = io::Error;
@@ -192,29 +277,15 @@ impl<ReqBody> Service<Request<ReqBody>> for ServeDir {
}
fn call(&mut self, req: Request<ReqBody>) -> Self::Future {
// build and validate the path
let path = req.uri().path();
let path = path.trim_start_matches('/');
let path_decoded = if let Ok(decoded_utf8) = percent_decode(path.as_ref()).decode_utf8() {
decoded_utf8
} else {
return ResponseFuture {
inner: Inner::Invalid,
};
};
let mut full_path = self.base.clone();
for seg in path_decoded.split('/') {
if seg.starts_with("..") || seg.contains('\\') {
let mut full_path = match self.variant.full_path(&self.base, req.uri().path()) {
Some(full_path) => full_path,
None => {
return ResponseFuture {
inner: Inner::Invalid,
};
}
}
full_path.push(seg);
}
};
let append_index_html_on_directories = self.append_index_html_on_directories;
let buf_chunk_size = self.buf_chunk_size;
let uri = req.uri().clone();
@@ -226,29 +297,35 @@ impl<ReqBody> Service<Request<ReqBody>> for ServeDir {
);
let request_method = req.method().clone();
let variant = self.variant.clone();
let open_file_future = Box::pin(async move {
if !uri.path().ends_with('/') {
if is_dir(&full_path).await {
let location =
HeaderValue::from_str(&append_slash_on_path(uri).to_string()).unwrap();
return Ok(Output::Redirect(location));
let mime = match variant {
ServeVariant::Directory {
append_index_html_on_directories,
} => {
// Might already at this point know a redirect or not found result should be
// returned which corresponds to a Some(output). Otherwise the path might be
// modified and proceed to the open file/metadata future.
if let Some(output) = maybe_redirect_or_append_path(
&mut full_path,
uri,
append_index_html_on_directories,
)
.await
{
return Ok(output);
}
let guess = mime_guess::from_path(&full_path);
guess
.first_raw()
.map(|mime| HeaderValue::from_static(mime))
.unwrap_or_else(|| {
HeaderValue::from_str(mime::APPLICATION_OCTET_STREAM.as_ref()).unwrap()
})
}
} else if is_dir(&full_path).await {
if append_index_html_on_directories {
full_path.push("index.html");
} else {
return Ok(Output::NotFound);
}
}
let guess = mime_guess::from_path(&full_path);
let mime = guess
.first_raw()
.map(|mime| HeaderValue::from_static(mime))
.unwrap_or_else(|| {
HeaderValue::from_str(mime::APPLICATION_OCTET_STREAM.as_ref()).unwrap()
});
ServeVariant::SingleFile { mime } => mime,
};
match request_method {
Method::HEAD => {
@@ -399,7 +476,7 @@ fn empty_body() -> ResponseBody {
}
opaque_body! {
/// Response body for [`ServeDir`].
/// Response body for [`ServeDir`] and [`ServeFile`].
pub type ResponseBody = BoxBody<Bytes, io::Error>;
}
@@ -435,6 +512,20 @@ mod tests {
assert_eq!(body, contents);
}
#[tokio::test]
async fn basic_with_index() {
let svc = ServeDir::new("../test-files");
let req = Request::new(Body::empty());
let res = svc.oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(res.headers()[header::CONTENT_TYPE], "text/html");
let body = body_into_text(res.into_body()).await;
assert_eq!(body, "<b>HTML!</b>\n");
}
#[tokio::test]
async fn head_request() {
let svc = ServeDir::new("../test-files");

View File

@@ -1,42 +1,24 @@
//! Service that serves a file.
use super::{
file_metadata_with_fallback, open_file_with_fallback, AsyncReadBody, FileFuture,
PrecompressedVariants,
};
use crate::{
content_encoding::{encodings, Encoding},
services::fs::DEFAULT_CAPACITY,
};
use bytes::Bytes;
use http::{header, method::Method, HeaderValue, Request, Response};
use http_body::{combinators::BoxBody, Body};
use super::ServeDir;
use http::{HeaderValue, Request};
use mime::Mime;
use std::{
fs::Metadata,
future::Future,
io,
path::{Path, PathBuf},
pin::Pin,
path::Path,
task::{Context, Poll},
};
use tower_service::Service;
/// Service that serves a file.
#[derive(Clone, Debug)]
pub struct ServeFile {
path: PathBuf,
mime: HeaderValue,
buf_chunk_size: usize,
precompressed_variants: Option<PrecompressedVariants>,
}
pub struct ServeFile(ServeDir);
// Note that this is just a special case of ServeDir
impl ServeFile {
/// Create a new [`ServeFile`].
///
/// The `Content-Type` will be guessed from the file extension.
pub fn new<P: AsRef<Path>>(path: P) -> Self {
let guess = mime_guess::from_path(&path);
let guess = mime_guess::from_path(path.as_ref());
let mime = guess
.first_raw()
.map(|mime| HeaderValue::from_static(mime))
@@ -44,14 +26,7 @@ impl ServeFile {
HeaderValue::from_str(mime::APPLICATION_OCTET_STREAM.as_ref()).unwrap()
});
let path = path.as_ref().to_owned();
Self {
path,
mime,
buf_chunk_size: DEFAULT_CAPACITY,
precompressed_variants: None,
}
Self(ServeDir::new_single_file(path, mime))
}
/// Create a new [`ServeFile`] with a specific mime type.
@@ -63,14 +38,7 @@ impl ServeFile {
/// [header value]: https://docs.rs/http/latest/http/header/struct.HeaderValue.html
pub fn new_with_mime<P: AsRef<Path>>(path: P, mime: &Mime) -> Self {
let mime = HeaderValue::from_str(mime.as_ref()).expect("mime isn't a valid header value");
let path = path.as_ref().to_owned();
Self {
path,
mime,
buf_chunk_size: DEFAULT_CAPACITY,
precompressed_variants: None,
}
Self(ServeDir::new_single_file(path, mime))
}
/// Informs the service that it should also look for a precompressed gzip
@@ -83,11 +51,8 @@ impl ServeFile {
/// Both the precompressed version and the uncompressed version are expected
/// to be present in the same directory. Different precompressed
/// variants can be combined.
pub fn precompressed_gzip(mut self) -> Self {
self.precompressed_variants
.get_or_insert(Default::default())
.gzip = true;
self
pub fn precompressed_gzip(self) -> Self {
Self(self.0.precompressed_gzip())
}
/// Informs the service that it should also look for a precompressed brotli
@@ -100,11 +65,8 @@ impl ServeFile {
/// Both the precompressed version and the uncompressed version are expected
/// to be present in the same directory. Different precompressed
/// variants can be combined.
pub fn precompressed_br(mut self) -> Self {
self.precompressed_variants
.get_or_insert(Default::default())
.br = true;
self
pub fn precompressed_br(self) -> Self {
Self(self.0.precompressed_br())
}
/// Informs the service that it should also look for a precompressed deflate
@@ -117,31 +79,22 @@ impl ServeFile {
/// Both the precompressed version and the uncompressed version are expected
/// to be present in the same directory. Different precompressed
/// variants can be combined.
pub fn precompressed_deflate(mut self) -> Self {
self.precompressed_variants
.get_or_insert(Default::default())
.deflate = true;
self
pub fn precompressed_deflate(self) -> Self {
Self(self.0.precompressed_deflate())
}
/// Set a specific read buffer chunk size.
///
/// The default capacity is 64kb.
pub fn with_buf_chunk_size(mut self, chunk_size: usize) -> Self {
self.buf_chunk_size = chunk_size;
self
pub fn with_buf_chunk_size(self, chunk_size: usize) -> Self {
Self(self.0.with_buf_chunk_size(chunk_size))
}
}
enum FileReadFuture {
Open(FileFuture),
Metadata(Pin<Box<dyn Future<Output = io::Result<(Metadata, Option<Encoding>)>>>>),
}
impl<ReqBody> Service<Request<ReqBody>> for ServeFile {
type Error = io::Error;
type Response = Response<ResponseBody>;
type Future = ResponseFuture;
type Error = <ServeDir as Service<Request<ReqBody>>>::Error;
type Response = <ServeDir as Service<Request<ReqBody>>>::Response;
type Future = <ServeDir as Service<Request<ReqBody>>>::Future;
#[inline]
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
@@ -149,112 +102,21 @@ impl<ReqBody> Service<Request<ReqBody>> for ServeFile {
}
fn call(&mut self, req: Request<ReqBody>) -> Self::Future {
let path = self.path.clone();
// The negotiated encodings based on the Accept-Encoding header and
// precompressed variants
let negotiated_encodings = encodings(
req.headers(),
self.precompressed_variants.unwrap_or_default(),
);
let file_future = match *req.method() {
Method::HEAD => FileReadFuture::Metadata(Box::pin(file_metadata_with_fallback(
path,
negotiated_encodings,
))),
_ => FileReadFuture::Open(Box::pin(open_file_with_fallback(
path,
negotiated_encodings,
))),
};
ResponseFuture {
file_future,
mime: Some(self.mime.clone()),
buf_chunk_size: self.buf_chunk_size,
}
self.0.call(req)
}
}
/// Response future of [`ServeFile`].
pub struct ResponseFuture {
file_future: FileReadFuture,
mime: Option<HeaderValue>,
buf_chunk_size: usize,
}
impl Future for ResponseFuture {
type Output = io::Result<Response<ResponseBody>>;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
match &mut self.file_future {
FileReadFuture::Open(open_file_future) => {
let (file, maybe_encoding) = match open_file_future.as_mut().poll(cx) {
Poll::Ready(Ok((file, maybe_encoding))) => (file, maybe_encoding),
Poll::Ready(Err(err)) => {
return Poll::Ready(
super::response_from_io_error(err)
.map(|res| res.map(ResponseBody::new)),
)
}
Poll::Pending => return Poll::Pending,
};
let chunk_size = self.buf_chunk_size;
let body = AsyncReadBody::with_capacity(file, chunk_size).boxed();
let body = ResponseBody::new(body);
let mut res = Response::new(body);
res.headers_mut()
.insert(header::CONTENT_TYPE, self.mime.take().unwrap());
if let Some(encoding) = maybe_encoding {
res.headers_mut()
.insert(header::CONTENT_ENCODING, encoding.into_header_value());
}
Poll::Ready(Ok(res))
}
FileReadFuture::Metadata(metadata_future) => {
let (metadata, maybe_encoding) = match metadata_future.as_mut().poll(cx) {
Poll::Ready(Ok((metadata, maybe_encoding))) => (metadata, maybe_encoding),
Poll::Ready(Err(err)) => {
return Poll::Ready(
super::response_from_io_error(err)
.map(|res| res.map(ResponseBody::new)),
)
}
Poll::Pending => return Poll::Pending,
};
let mut res = Response::new(ResponseBody::new(BoxBody::default()));
res.headers_mut()
.insert(header::CONTENT_LENGTH, metadata.len().into());
res.headers_mut()
.insert(header::CONTENT_TYPE, self.mime.take().unwrap());
if let Some(encoding) = maybe_encoding {
res.headers_mut()
.insert(header::CONTENT_ENCODING, encoding.into_header_value());
}
Poll::Ready(Ok(res))
}
}
}
}
opaque_body! {
/// Response body for [`ServeFile`].
pub type ResponseBody = BoxBody<Bytes, io::Error>;
}
#[cfg(test)]
mod tests {
use std::io::Read;
use std::str::FromStr;
#[allow(unused_imports)]
use super::*;
use brotli::BrotliDecompress;
use flate2::bufread::DeflateDecoder;
use flate2::bufread::GzDecoder;
use http::header;
use http::Method;
use http::{Request, StatusCode};
use http_body::Body as _;
@@ -275,6 +137,20 @@ mod tests {
assert!(body.starts_with("# Tower HTTP"));
}
#[tokio::test]
async fn basic_with_mime() {
let svc = ServeFile::new_with_mime("../README.md", &Mime::from_str("image/jpg").unwrap());
let res = svc.oneshot(Request::new(Body::empty())).await.unwrap();
assert_eq!(res.headers()["content-type"], "image/jpg");
let body = res.into_body().data().await.unwrap().unwrap();
let body = String::from_utf8(body.to_vec()).unwrap();
assert!(body.starts_with("# Tower HTTP"));
}
#[tokio::test]
async fn head_request() {
let svc = ServeFile::new("../test-files/precompressed.txt");