diff --git a/Cargo.toml b/Cargo.toml index 8ce615433..d4da4af11 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -99,11 +99,11 @@ time = { version = "0.2.7", default-features = false, features = ["std"] } url = "2.1" open-ssl = { package = "openssl", version = "0.10", optional = true } rust-tls = { package = "rustls", version = "0.18.0", optional = true } -tinyvec = { version = "0.3", features = ["alloc"] } +tinyvec = { version = "1", features = ["alloc"] } [dev-dependencies] actix = "0.10.0" -actix-http = { version = "2.0.0-beta.4", features = ["actors"] } +actix-http = { version = "2.0.0", features = ["actors"] } rand = "0.7" env_logger = "0.7" serde_derive = "1.0" diff --git a/actix-files/Cargo.toml b/actix-files/Cargo.toml index 8841f7fb1..634296c56 100644 --- a/actix-files/Cargo.toml +++ b/actix-files/Cargo.toml @@ -18,7 +18,6 @@ path = "src/lib.rs" [dependencies] actix-web = { version = "3.0.0", default-features = false } -actix-http = "2.0.0" actix-service = "1.0.6" bitflags = "1" bytes = "0.5.3" diff --git a/actix-files/src/chunked.rs b/actix-files/src/chunked.rs new file mode 100644 index 000000000..580b06787 --- /dev/null +++ b/actix-files/src/chunked.rs @@ -0,0 +1,94 @@ +use std::{ + cmp, fmt, + fs::File, + future::Future, + io::{self, Read, Seek}, + pin::Pin, + task::{Context, Poll}, +}; + +use actix_web::{ + error::{BlockingError, Error}, + web, +}; +use bytes::Bytes; +use futures_core::{ready, Stream}; +use futures_util::future::{FutureExt, LocalBoxFuture}; + +use crate::handle_error; + +type ChunkedBoxFuture = + LocalBoxFuture<'static, Result<(File, Bytes), BlockingError>>; + +#[doc(hidden)] +/// A helper created from a `std::fs::File` which reads the file +/// chunk-by-chunk on a `ThreadPool`. +pub struct ChunkedReadFile { + pub(crate) size: u64, + pub(crate) offset: u64, + pub(crate) file: Option, + pub(crate) fut: Option, + pub(crate) counter: u64, +} + +impl fmt::Debug for ChunkedReadFile { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("ChunkedReadFile") + } +} + +impl Stream for ChunkedReadFile { + type Item = Result; + + fn poll_next( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll> { + if let Some(ref mut fut) = self.fut { + return match ready!(Pin::new(fut).poll(cx)) { + Ok((file, bytes)) => { + self.fut.take(); + self.file = Some(file); + + self.offset += bytes.len() as u64; + self.counter += bytes.len() as u64; + + Poll::Ready(Some(Ok(bytes))) + } + Err(e) => Poll::Ready(Some(Err(handle_error(e)))), + }; + } + + let size = self.size; + let offset = self.offset; + let counter = self.counter; + + if size == counter { + Poll::Ready(None) + } else { + let mut file = self.file.take().expect("Use after completion"); + + self.fut = Some( + web::block(move || { + let max_bytes = + cmp::min(size.saturating_sub(counter), 65_536) as usize; + + let mut buf = Vec::with_capacity(max_bytes); + file.seek(io::SeekFrom::Start(offset))?; + + let n_bytes = + file.by_ref().take(max_bytes as u64).read_to_end(&mut buf)?; + + if n_bytes == 0 { + return Err(io::ErrorKind::UnexpectedEof.into()); + } + + Ok((file, Bytes::from(buf))) + }) + .boxed_local(), + ); + + self.poll_next(cx) + } + } +} diff --git a/actix-files/src/directory.rs b/actix-files/src/directory.rs new file mode 100644 index 000000000..3717985d3 --- /dev/null +++ b/actix-files/src/directory.rs @@ -0,0 +1,114 @@ +use std::{fmt::Write, fs::DirEntry, io, path::Path, path::PathBuf}; + +use actix_web::{dev::ServiceResponse, HttpRequest, HttpResponse}; +use percent_encoding::{utf8_percent_encode, CONTROLS}; +use v_htmlescape::escape as escape_html_entity; + +/// A directory; responds with the generated directory listing. +#[derive(Debug)] +pub struct Directory { + /// Base directory. + pub base: PathBuf, + + /// Path of subdirectory to generate listing for. + pub path: PathBuf, +} + +impl Directory { + /// Create a new directory + pub fn new(base: PathBuf, path: PathBuf) -> Directory { + Directory { base, path } + } + + /// Is this entry visible from this directory? + pub fn is_visible(&self, entry: &io::Result) -> bool { + if let Ok(ref entry) = *entry { + if let Some(name) = entry.file_name().to_str() { + if name.starts_with('.') { + return false; + } + } + if let Ok(ref md) = entry.metadata() { + let ft = md.file_type(); + return ft.is_dir() || ft.is_file() || ft.is_symlink(); + } + } + false + } +} + +pub(crate) type DirectoryRenderer = + dyn Fn(&Directory, &HttpRequest) -> Result; + +// show file url as relative to static path +macro_rules! encode_file_url { + ($path:ident) => { + utf8_percent_encode(&$path, CONTROLS) + }; +} + +// " -- " & -- & ' -- ' < -- < > -- > / -- / +macro_rules! encode_file_name { + ($entry:ident) => { + escape_html_entity(&$entry.file_name().to_string_lossy()) + }; +} + +pub(crate) fn directory_listing( + dir: &Directory, + req: &HttpRequest, +) -> Result { + let index_of = format!("Index of {}", req.path()); + let mut body = String::new(); + let base = Path::new(req.path()); + + for entry in dir.path.read_dir()? { + if dir.is_visible(&entry) { + let entry = entry.unwrap(); + let p = match entry.path().strip_prefix(&dir.path) { + Ok(p) if cfg!(windows) => { + base.join(p).to_string_lossy().replace("\\", "/") + } + Ok(p) => base.join(p).to_string_lossy().into_owned(), + Err(_) => continue, + }; + + // if file is a directory, add '/' to the end of the name + if let Ok(metadata) = entry.metadata() { + if metadata.is_dir() { + let _ = write!( + body, + "
  • {}/
  • ", + encode_file_url!(p), + encode_file_name!(entry), + ); + } else { + let _ = write!( + body, + "
  • {}
  • ", + encode_file_url!(p), + encode_file_name!(entry), + ); + } + } else { + continue; + } + } + } + + let html = format!( + "\ + {}\ +

    {}

    \ +
      \ + {}\ +
    \n", + index_of, index_of, body + ); + Ok(ServiceResponse::new( + req.clone(), + HttpResponse::Ok() + .content_type("text/html; charset=utf-8") + .body(html), + )) +} diff --git a/actix-files/src/files.rs b/actix-files/src/files.rs new file mode 100644 index 000000000..2b55e1aa9 --- /dev/null +++ b/actix-files/src/files.rs @@ -0,0 +1,250 @@ +use std::{cell::RefCell, fmt, io, path::PathBuf, rc::Rc}; + +use actix_service::{boxed, IntoServiceFactory, ServiceFactory}; +use actix_web::{ + dev::{ + AppService, HttpServiceFactory, ResourceDef, ServiceRequest, ServiceResponse, + }, + error::Error, + guard::Guard, + http::header::DispositionType, + HttpRequest, +}; +use futures_util::future::{ok, FutureExt, LocalBoxFuture}; + +use crate::{ + directory_listing, named, Directory, DirectoryRenderer, FilesService, + HttpNewService, MimeOverride, +}; + +/// Static files handling service. +/// +/// `Files` service must be registered with `App::service()` method. +/// +/// ```rust +/// use actix_web::App; +/// use actix_files::Files; +/// +/// let app = App::new() +/// .service(Files::new("/static", ".")); +/// ``` +pub struct Files { + path: String, + directory: PathBuf, + index: Option, + show_index: bool, + redirect_to_slash: bool, + default: Rc>>>, + renderer: Rc, + mime_override: Option>, + file_flags: named::Flags, + guards: Option>, +} + +impl fmt::Debug for Files { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("Files") + } +} + +impl Clone for Files { + fn clone(&self) -> Self { + Self { + directory: self.directory.clone(), + index: self.index.clone(), + show_index: self.show_index, + redirect_to_slash: self.redirect_to_slash, + default: self.default.clone(), + renderer: self.renderer.clone(), + file_flags: self.file_flags, + path: self.path.clone(), + mime_override: self.mime_override.clone(), + guards: self.guards.clone(), + } + } +} + +impl Files { + /// Create new `Files` instance for specified base directory. + /// + /// `File` uses `ThreadPool` for blocking filesystem operations. + /// By default pool with 5x threads of available cpus is used. + /// Pool size can be changed by setting ACTIX_THREADPOOL environment variable. + pub fn new>(path: &str, dir: T) -> Files { + let orig_dir = dir.into(); + let dir = match orig_dir.canonicalize() { + Ok(canon_dir) => canon_dir, + Err(_) => { + log::error!("Specified path is not a directory: {:?}", orig_dir); + PathBuf::new() + } + }; + + Files { + path: path.to_string(), + directory: dir, + index: None, + show_index: false, + redirect_to_slash: false, + default: Rc::new(RefCell::new(None)), + renderer: Rc::new(directory_listing), + mime_override: None, + file_flags: named::Flags::default(), + guards: None, + } + } + + /// Show files listing for directories. + /// + /// By default show files listing is disabled. + pub fn show_files_listing(mut self) -> Self { + self.show_index = true; + self + } + + /// Redirects to a slash-ended path when browsing a directory. + /// + /// By default never redirect. + pub fn redirect_to_slash_directory(mut self) -> Self { + self.redirect_to_slash = true; + self + } + + /// Set custom directory renderer + pub fn files_listing_renderer(mut self, f: F) -> Self + where + for<'r, 's> F: Fn(&'r Directory, &'s HttpRequest) -> Result + + 'static, + { + self.renderer = Rc::new(f); + self + } + + /// Specifies mime override callback + pub fn mime_override(mut self, f: F) -> Self + where + F: Fn(&mime::Name<'_>) -> DispositionType + 'static, + { + self.mime_override = Some(Rc::new(f)); + self + } + + /// Set index file + /// + /// Shows specific index file for directory "/" instead of + /// showing files listing. + pub fn index_file>(mut self, index: T) -> Self { + self.index = Some(index.into()); + self + } + + #[inline] + /// Specifies whether to use ETag or not. + /// + /// Default is true. + pub fn use_etag(mut self, value: bool) -> Self { + self.file_flags.set(named::Flags::ETAG, value); + self + } + + #[inline] + /// Specifies whether to use Last-Modified or not. + /// + /// Default is true. + pub fn use_last_modified(mut self, value: bool) -> Self { + self.file_flags.set(named::Flags::LAST_MD, value); + self + } + + /// Specifies custom guards to use for directory listings and files. + /// + /// Default behaviour allows GET and HEAD. + #[inline] + pub fn use_guards(mut self, guards: G) -> Self { + self.guards = Some(Rc::new(guards)); + self + } + + /// Disable `Content-Disposition` header. + /// + /// By default Content-Disposition` header is enabled. + #[inline] + pub fn disable_content_disposition(mut self) -> Self { + self.file_flags.remove(named::Flags::CONTENT_DISPOSITION); + self + } + + /// Sets default handler which is used when no matched file could be found. + pub fn default_handler(mut self, f: F) -> Self + where + F: IntoServiceFactory, + U: ServiceFactory< + Config = (), + Request = ServiceRequest, + Response = ServiceResponse, + Error = Error, + > + 'static, + { + // create and configure default resource + self.default = Rc::new(RefCell::new(Some(Rc::new(boxed::factory( + f.into_factory().map_init_err(|_| ()), + ))))); + + self + } +} + +impl HttpServiceFactory for Files { + fn register(self, config: &mut AppService) { + if self.default.borrow().is_none() { + *self.default.borrow_mut() = Some(config.default_service()); + } + + let rdef = if config.is_root() { + ResourceDef::root_prefix(&self.path) + } else { + ResourceDef::prefix(&self.path) + }; + + config.register_service(rdef, None, self, None) + } +} + +impl ServiceFactory for Files { + type Request = ServiceRequest; + type Response = ServiceResponse; + type Error = Error; + type Config = (); + type Service = FilesService; + type InitError = (); + type Future = LocalBoxFuture<'static, Result>; + + fn new_service(&self, _: ()) -> Self::Future { + let mut srv = FilesService { + directory: self.directory.clone(), + index: self.index.clone(), + show_index: self.show_index, + redirect_to_slash: self.redirect_to_slash, + default: None, + renderer: self.renderer.clone(), + mime_override: self.mime_override.clone(), + file_flags: self.file_flags, + guards: self.guards.clone(), + }; + + if let Some(ref default) = *self.default.borrow() { + default + .new_service(()) + .map(move |result| match result { + Ok(default) => { + srv.default = Some(default); + Ok(srv) + } + Err(_) => Err(()), + }) + .boxed_local() + } else { + ok(srv).boxed_local() + } + } +} diff --git a/actix-files/src/lib.rs b/actix-files/src/lib.rs index 91c054947..1fc7cb3f3 100644 --- a/actix-files/src/lib.rs +++ b/actix-files/src/lib.rs @@ -1,44 +1,52 @@ -//! Static files support +//! Static files support for Actix Web. +//! +//! Provides a non-blocking service for serving static files from disk. +//! +//! # Example +//! ```rust +//! use actix_web::App; +//! use actix_files::Files; +//! +//! let app = App::new() +//! .service(Files::new("/static", ".")); +//! ``` +//! +//! # Implementation Quirks +//! - If a filename contains non-ascii characters, that file will be served with the `charset=utf-8` +//! extension on the Content-Type header. #![deny(rust_2018_idioms)] -#![allow(clippy::borrow_interior_mutable_const)] +#![warn(missing_docs, missing_debug_implementations)] -use std::cell::RefCell; -use std::fmt::Write; -use std::fs::{DirEntry, File}; -use std::future::Future; -use std::io::{Read, Seek}; -use std::path::{Path, PathBuf}; -use std::pin::Pin; -use std::rc::Rc; -use std::task::{Context, Poll}; -use std::{cmp, io}; +use std::io; -use actix_service::boxed::{self, BoxService, BoxServiceFactory}; -use actix_service::{IntoServiceFactory, Service, ServiceFactory}; -use actix_web::dev::{ - AppService, HttpServiceFactory, Payload, ResourceDef, ServiceRequest, - ServiceResponse, +use actix_service::boxed::{BoxService, BoxServiceFactory}; +use actix_web::{ + dev::{ServiceRequest, ServiceResponse}, + error::{BlockingError, Error, ErrorInternalServerError}, + http::header::DispositionType, }; -use actix_web::error::{BlockingError, Error, ErrorInternalServerError}; -use actix_web::guard::Guard; -use actix_web::http::header::{self, DispositionType}; -use actix_web::http::Method; -use actix_web::{web, FromRequest, HttpRequest, HttpResponse}; -use bytes::Bytes; -use futures_core::Stream; -use futures_util::future::{ok, ready, Either, FutureExt, LocalBoxFuture, Ready}; use mime_guess::from_ext; -use percent_encoding::{utf8_percent_encode, CONTROLS}; -use v_htmlescape::escape as escape_html_entity; +mod chunked; +mod directory; mod error; +mod files; mod named; +mod path_buf; mod range; +mod service; -use self::error::{FilesError, UriSegmentError}; +pub use crate::chunked::ChunkedReadFile; +pub use crate::directory::Directory; +pub use crate::files::Files; pub use crate::named::NamedFile; pub use crate::range::HttpRange; +pub use crate::service::FilesService; + +use self::directory::{directory_listing, DirectoryRenderer}; +use self::error::FilesError; +use self::path_buf::PathBufWrap; type HttpService = BoxService; type HttpNewService = BoxServiceFactory<(), ServiceRequest, ServiceResponse, Error, ()>; @@ -51,612 +59,37 @@ pub fn file_extension_to_mime(ext: &str) -> mime::Mime { from_ext(ext).first_or_octet_stream() } -fn handle_error(err: BlockingError) -> Error { +pub(crate) fn handle_error(err: BlockingError) -> Error { match err { BlockingError::Error(err) => err.into(), BlockingError::Canceled => ErrorInternalServerError("Unexpected error"), } } -#[doc(hidden)] -/// A helper created from a `std::fs::File` which reads the file -/// chunk-by-chunk on a `ThreadPool`. -pub struct ChunkedReadFile { - size: u64, - offset: u64, - file: Option, - #[allow(clippy::type_complexity)] - fut: - Option>>>, - counter: u64, -} - -impl Stream for ChunkedReadFile { - type Item = Result; - - fn poll_next( - mut self: Pin<&mut Self>, - cx: &mut Context<'_>, - ) -> Poll> { - if let Some(ref mut fut) = self.fut { - return match Pin::new(fut).poll(cx) { - Poll::Ready(Ok((file, bytes))) => { - self.fut.take(); - self.file = Some(file); - self.offset += bytes.len() as u64; - self.counter += bytes.len() as u64; - Poll::Ready(Some(Ok(bytes))) - } - Poll::Ready(Err(e)) => Poll::Ready(Some(Err(handle_error(e)))), - Poll::Pending => Poll::Pending, - }; - } - - let size = self.size; - let offset = self.offset; - let counter = self.counter; - - if size == counter { - Poll::Ready(None) - } else { - let mut file = self.file.take().expect("Use after completion"); - self.fut = Some( - web::block(move || { - let max_bytes: usize; - max_bytes = cmp::min(size.saturating_sub(counter), 65_536) as usize; - let mut buf = Vec::with_capacity(max_bytes); - file.seek(io::SeekFrom::Start(offset))?; - let nbytes = - file.by_ref().take(max_bytes as u64).read_to_end(&mut buf)?; - if nbytes == 0 { - return Err(io::ErrorKind::UnexpectedEof.into()); - } - Ok((file, Bytes::from(buf))) - }) - .boxed_local(), - ); - self.poll_next(cx) - } - } -} - -type DirectoryRenderer = - dyn Fn(&Directory, &HttpRequest) -> Result; - -/// A directory; responds with the generated directory listing. -#[derive(Debug)] -pub struct Directory { - /// Base directory - pub base: PathBuf, - /// Path of subdirectory to generate listing for - pub path: PathBuf, -} - -impl Directory { - /// Create a new directory - pub fn new(base: PathBuf, path: PathBuf) -> Directory { - Directory { base, path } - } - - /// Is this entry visible from this directory? - pub fn is_visible(&self, entry: &io::Result) -> bool { - if let Ok(ref entry) = *entry { - if let Some(name) = entry.file_name().to_str() { - if name.starts_with('.') { - return false; - } - } - if let Ok(ref md) = entry.metadata() { - let ft = md.file_type(); - return ft.is_dir() || ft.is_file() || ft.is_symlink(); - } - } - false - } -} - -// show file url as relative to static path -macro_rules! encode_file_url { - ($path:ident) => { - utf8_percent_encode(&$path, CONTROLS) - }; -} - -// " -- " & -- & ' -- ' < -- < > -- > / -- / -macro_rules! encode_file_name { - ($entry:ident) => { - escape_html_entity(&$entry.file_name().to_string_lossy()) - }; -} - -fn directory_listing( - dir: &Directory, - req: &HttpRequest, -) -> Result { - let index_of = format!("Index of {}", req.path()); - let mut body = String::new(); - let base = Path::new(req.path()); - - for entry in dir.path.read_dir()? { - if dir.is_visible(&entry) { - let entry = entry.unwrap(); - let p = match entry.path().strip_prefix(&dir.path) { - Ok(p) if cfg!(windows) => { - base.join(p).to_string_lossy().replace("\\", "/") - } - Ok(p) => base.join(p).to_string_lossy().into_owned(), - Err(_) => continue, - }; - - // if file is a directory, add '/' to the end of the name - if let Ok(metadata) = entry.metadata() { - if metadata.is_dir() { - let _ = write!( - body, - "
  • {}/
  • ", - encode_file_url!(p), - encode_file_name!(entry), - ); - } else { - let _ = write!( - body, - "
  • {}
  • ", - encode_file_url!(p), - encode_file_name!(entry), - ); - } - } else { - continue; - } - } - } - - let html = format!( - "\ - {}\ -

    {}

    \ -
      \ - {}\ -
    \n", - index_of, index_of, body - ); - Ok(ServiceResponse::new( - req.clone(), - HttpResponse::Ok() - .content_type("text/html; charset=utf-8") - .body(html), - )) -} type MimeOverride = dyn Fn(&mime::Name<'_>) -> DispositionType; -/// Static files handling -/// -/// `Files` service must be registered with `App::service()` method. -/// -/// ```rust -/// use actix_web::App; -/// use actix_files::Files; -/// -/// let app = App::new() -/// .service(Files::new("/static", ".")); -/// ``` -pub struct Files { - path: String, - directory: PathBuf, - index: Option, - show_index: bool, - redirect_to_slash: bool, - default: Rc>>>, - renderer: Rc, - mime_override: Option>, - file_flags: named::Flags, - // FIXME: Should re-visit later. - #[allow(clippy::redundant_allocation)] - guards: Option>>, -} - -impl Clone for Files { - fn clone(&self) -> Self { - Self { - directory: self.directory.clone(), - index: self.index.clone(), - show_index: self.show_index, - redirect_to_slash: self.redirect_to_slash, - default: self.default.clone(), - renderer: self.renderer.clone(), - file_flags: self.file_flags, - path: self.path.clone(), - mime_override: self.mime_override.clone(), - guards: self.guards.clone(), - } - } -} - -impl Files { - /// Create new `Files` instance for specified base directory. - /// - /// `File` uses `ThreadPool` for blocking filesystem operations. - /// By default pool with 5x threads of available cpus is used. - /// Pool size can be changed by setting ACTIX_THREADPOOL environment variable. - pub fn new>(path: &str, dir: T) -> Files { - let orig_dir = dir.into(); - let dir = match orig_dir.canonicalize() { - Ok(canon_dir) => canon_dir, - Err(_) => { - log::error!("Specified path is not a directory: {:?}", orig_dir); - PathBuf::new() - } - }; - - Files { - path: path.to_string(), - directory: dir, - index: None, - show_index: false, - redirect_to_slash: false, - default: Rc::new(RefCell::new(None)), - renderer: Rc::new(directory_listing), - mime_override: None, - file_flags: named::Flags::default(), - guards: None, - } - } - - /// Show files listing for directories. - /// - /// By default show files listing is disabled. - pub fn show_files_listing(mut self) -> Self { - self.show_index = true; - self - } - - /// Redirects to a slash-ended path when browsing a directory. - /// - /// By default never redirect. - pub fn redirect_to_slash_directory(mut self) -> Self { - self.redirect_to_slash = true; - self - } - - /// Set custom directory renderer - pub fn files_listing_renderer(mut self, f: F) -> Self - where - for<'r, 's> F: Fn(&'r Directory, &'s HttpRequest) -> Result - + 'static, - { - self.renderer = Rc::new(f); - self - } - - /// Specifies mime override callback - pub fn mime_override(mut self, f: F) -> Self - where - F: Fn(&mime::Name<'_>) -> DispositionType + 'static, - { - self.mime_override = Some(Rc::new(f)); - self - } - - /// Set index file - /// - /// Shows specific index file for directory "/" instead of - /// showing files listing. - pub fn index_file>(mut self, index: T) -> Self { - self.index = Some(index.into()); - self - } - - #[inline] - /// Specifies whether to use ETag or not. - /// - /// Default is true. - pub fn use_etag(mut self, value: bool) -> Self { - self.file_flags.set(named::Flags::ETAG, value); - self - } - - #[inline] - /// Specifies whether to use Last-Modified or not. - /// - /// Default is true. - pub fn use_last_modified(mut self, value: bool) -> Self { - self.file_flags.set(named::Flags::LAST_MD, value); - self - } - - /// Specifies custom guards to use for directory listings and files. - /// - /// Default behaviour allows GET and HEAD. - #[inline] - pub fn use_guards(mut self, guards: G) -> Self { - self.guards = Some(Rc::new(Box::new(guards))); - self - } - - /// Disable `Content-Disposition` header. - /// - /// By default Content-Disposition` header is enabled. - #[inline] - pub fn disable_content_disposition(mut self) -> Self { - self.file_flags.remove(named::Flags::CONTENT_DISPOSITION); - self - } - - /// Sets default handler which is used when no matched file could be found. - pub fn default_handler(mut self, f: F) -> Self - where - F: IntoServiceFactory, - U: ServiceFactory< - Config = (), - Request = ServiceRequest, - Response = ServiceResponse, - Error = Error, - > + 'static, - { - // create and configure default resource - self.default = Rc::new(RefCell::new(Some(Rc::new(boxed::factory( - f.into_factory().map_init_err(|_| ()), - ))))); - - self - } -} - -impl HttpServiceFactory for Files { - fn register(self, config: &mut AppService) { - if self.default.borrow().is_none() { - *self.default.borrow_mut() = Some(config.default_service()); - } - let rdef = if config.is_root() { - ResourceDef::root_prefix(&self.path) - } else { - ResourceDef::prefix(&self.path) - }; - config.register_service(rdef, None, self, None) - } -} - -impl ServiceFactory for Files { - type Request = ServiceRequest; - type Response = ServiceResponse; - type Error = Error; - type Config = (); - type Service = FilesService; - type InitError = (); - type Future = LocalBoxFuture<'static, Result>; - - fn new_service(&self, _: ()) -> Self::Future { - let mut srv = FilesService { - directory: self.directory.clone(), - index: self.index.clone(), - show_index: self.show_index, - redirect_to_slash: self.redirect_to_slash, - default: None, - renderer: self.renderer.clone(), - mime_override: self.mime_override.clone(), - file_flags: self.file_flags, - guards: self.guards.clone(), - }; - - if let Some(ref default) = *self.default.borrow() { - default - .new_service(()) - .map(move |result| match result { - Ok(default) => { - srv.default = Some(default); - Ok(srv) - } - Err(_) => Err(()), - }) - .boxed_local() - } else { - ok(srv).boxed_local() - } - } -} - -pub struct FilesService { - directory: PathBuf, - index: Option, - show_index: bool, - redirect_to_slash: bool, - default: Option, - renderer: Rc, - mime_override: Option>, - file_flags: named::Flags, - // FIXME: Should re-visit later. - #[allow(clippy::redundant_allocation)] - guards: Option>>, -} - -impl FilesService { - #[allow(clippy::type_complexity)] - fn handle_err( - &mut self, - e: io::Error, - req: ServiceRequest, - ) -> Either< - Ready>, - LocalBoxFuture<'static, Result>, - > { - log::debug!("Files: Failed to handle {}: {}", req.path(), e); - if let Some(ref mut default) = self.default { - Either::Right(default.call(req)) - } else { - Either::Left(ok(req.error_response(e))) - } - } -} - -impl Service for FilesService { - type Request = ServiceRequest; - type Response = ServiceResponse; - type Error = Error; - #[allow(clippy::type_complexity)] - type Future = Either< - Ready>, - LocalBoxFuture<'static, Result>, - >; - - fn poll_ready(&mut self, _: &mut Context<'_>) -> Poll> { - Poll::Ready(Ok(())) - } - - fn call(&mut self, req: ServiceRequest) -> Self::Future { - let is_method_valid = if let Some(guard) = &self.guards { - // execute user defined guards - (**guard).check(req.head()) - } else { - // default behavior - matches!(*req.method(), Method::HEAD | Method::GET) - }; - - if !is_method_valid { - return Either::Left(ok(req.into_response( - actix_web::HttpResponse::MethodNotAllowed() - .header(header::CONTENT_TYPE, "text/plain") - .body("Request did not meet this resource's requirements."), - ))); - } - - let real_path = match PathBufWrp::get_pathbuf(req.match_info().path()) { - Ok(item) => item, - Err(e) => return Either::Left(ok(req.error_response(e))), - }; - - // full file path - let path = match self.directory.join(&real_path.0).canonicalize() { - Ok(path) => path, - Err(e) => return self.handle_err(e, req), - }; - - if path.is_dir() { - if let Some(ref redir_index) = self.index { - if self.redirect_to_slash && !req.path().ends_with('/') { - let redirect_to = format!("{}/", req.path()); - return Either::Left(ok(req.into_response( - HttpResponse::Found() - .header(header::LOCATION, redirect_to) - .body("") - .into_body(), - ))); - } - - let path = path.join(redir_index); - - match NamedFile::open(path) { - Ok(mut named_file) => { - if let Some(ref mime_override) = self.mime_override { - let new_disposition = - mime_override(&named_file.content_type.type_()); - named_file.content_disposition.disposition = new_disposition; - } - - named_file.flags = self.file_flags; - let (req, _) = req.into_parts(); - Either::Left(ok(match named_file.into_response(&req) { - Ok(item) => ServiceResponse::new(req, item), - Err(e) => ServiceResponse::from_err(e, req), - })) - } - Err(e) => self.handle_err(e, req), - } - } else if self.show_index { - let dir = Directory::new(self.directory.clone(), path); - let (req, _) = req.into_parts(); - let x = (self.renderer)(&dir, &req); - match x { - Ok(resp) => Either::Left(ok(resp)), - Err(e) => Either::Left(ok(ServiceResponse::from_err(e, req))), - } - } else { - Either::Left(ok(ServiceResponse::from_err( - FilesError::IsDirectory, - req.into_parts().0, - ))) - } - } else { - match NamedFile::open(path) { - Ok(mut named_file) => { - if let Some(ref mime_override) = self.mime_override { - let new_disposition = - mime_override(&named_file.content_type.type_()); - named_file.content_disposition.disposition = new_disposition; - } - - named_file.flags = self.file_flags; - let (req, _) = req.into_parts(); - match named_file.into_response(&req) { - Ok(item) => { - Either::Left(ok(ServiceResponse::new(req.clone(), item))) - } - Err(e) => Either::Left(ok(ServiceResponse::from_err(e, req))), - } - } - Err(e) => self.handle_err(e, req), - } - } - } -} - -#[derive(Debug)] -struct PathBufWrp(PathBuf); - -impl PathBufWrp { - fn get_pathbuf(path: &str) -> Result { - let mut buf = PathBuf::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(PathBufWrp(buf)) - } -} - -impl FromRequest for PathBufWrp { - type Error = UriSegmentError; - type Future = Ready>; - type Config = (); - - fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future { - ready(PathBufWrp::get_pathbuf(req.match_info().path())) - } -} - #[cfg(test)] mod tests { - use std::fs; - use std::iter::FromIterator; - use std::ops::Add; - use std::time::{Duration, SystemTime}; + use std::{ + fs::{self, File}, + ops::Add, + time::{Duration, SystemTime}, + }; + + use actix_service::ServiceFactory; + use actix_web::{ + guard, + http::{ + header::{self, ContentDisposition, DispositionParam, DispositionType}, + Method, StatusCode, + }, + middleware::Compress, + test::{self, TestRequest}, + web, App, HttpResponse, Responder, + }; + use futures_util::future::ok; use super::*; - use actix_web::guard; - use actix_web::http::header::{ - self, ContentDisposition, DispositionParam, DispositionType, - }; - use actix_web::http::{Method, StatusCode}; - use actix_web::middleware::Compress; - use actix_web::test::{self, TestRequest}; - use actix_web::{App, Responder}; #[actix_rt::test] async fn test_file_extension_to_mime() { @@ -1013,7 +446,7 @@ mod tests { // Check file contents let bytes = response.body().await.unwrap(); - let data = Bytes::from(fs::read("tests/test.binary").unwrap()); + let data = web::Bytes::from(fs::read("tests/test.binary").unwrap()); assert_eq!(bytes, data); } @@ -1046,7 +479,7 @@ mod tests { assert_eq!(response.status(), StatusCode::OK); let bytes = test::read_body(response).await; - let data = Bytes::from(fs::read("tests/test space.binary").unwrap()); + let data = web::Bytes::from(fs::read("tests/test space.binary").unwrap()); assert_eq!(bytes, data); } @@ -1224,7 +657,7 @@ mod tests { let resp = test::call_service(&mut st, req).await; assert_eq!(resp.status(), StatusCode::OK); let bytes = test::read_body(resp).await; - assert_eq!(bytes, Bytes::from_static(b"default content")); + assert_eq!(bytes, web::Bytes::from_static(b"default content")); } // #[actix_rt::test] @@ -1340,36 +773,4 @@ mod tests { // let response = srv.execute(request.send()).unwrap(); // assert_eq!(response.status(), StatusCode::OK); // } - - #[actix_rt::test] - async fn test_path_buf() { - assert_eq!( - PathBufWrp::get_pathbuf("/test/.tt").map(|t| t.0), - Err(UriSegmentError::BadStart('.')) - ); - assert_eq!( - PathBufWrp::get_pathbuf("/test/*tt").map(|t| t.0), - Err(UriSegmentError::BadStart('*')) - ); - assert_eq!( - PathBufWrp::get_pathbuf("/test/tt:").map(|t| t.0), - Err(UriSegmentError::BadEnd(':')) - ); - assert_eq!( - PathBufWrp::get_pathbuf("/test/tt<").map(|t| t.0), - Err(UriSegmentError::BadEnd('<')) - ); - assert_eq!( - PathBufWrp::get_pathbuf("/test/tt>").map(|t| t.0), - Err(UriSegmentError::BadEnd('>')) - ); - assert_eq!( - PathBufWrp::get_pathbuf("/seg1/seg2/").unwrap().0, - PathBuf::from_iter(vec!["seg1", "seg2"]) - ); - assert_eq!( - PathBufWrp::get_pathbuf("/seg1/../seg2/").unwrap().0, - PathBuf::from_iter(vec!["seg2"]) - ); - } } diff --git a/actix-files/src/named.rs b/actix-files/src/named.rs index 12da722d2..3caa4a809 100644 --- a/actix-files/src/named.rs +++ b/actix-files/src/named.rs @@ -7,17 +7,20 @@ use std::time::{SystemTime, UNIX_EPOCH}; #[cfg(unix)] use std::os::unix::fs::MetadataExt; -use bitflags::bitflags; -use mime_guess::from_path; - -use actix_http::body::SizedStream; -use actix_web::dev::BodyEncoding; -use actix_web::http::header::{ - self, Charset, ContentDisposition, DispositionParam, DispositionType, ExtendedValue, +use actix_web::{ + dev::{BodyEncoding, SizedStream}, + http::{ + header::{ + self, Charset, ContentDisposition, DispositionParam, DispositionType, + ExtendedValue, + }, + ContentEncoding, StatusCode, + }, + Error, HttpMessage, HttpRequest, HttpResponse, Responder, }; -use actix_web::http::{ContentEncoding, StatusCode}; -use actix_web::{Error, HttpMessage, HttpRequest, HttpResponse, Responder}; +use bitflags::bitflags; use futures_util::future::{ready, Ready}; +use mime_guess::from_path; use crate::range::HttpRange; use crate::ChunkedReadFile; @@ -93,8 +96,10 @@ impl NamedFile { mime::IMAGE | mime::TEXT | mime::VIDEO => DispositionType::Inline, _ => DispositionType::Attachment, }; + let mut parameters = vec![DispositionParam::Filename(String::from(filename.as_ref()))]; + if !filename.is_ascii() { parameters.push(DispositionParam::FilenameExt(ExtendedValue { charset: Charset::Ext(String::from("UTF-8")), @@ -102,16 +107,19 @@ impl NamedFile { value: filename.into_owned().into_bytes(), })) } + let cd = ContentDisposition { disposition, parameters, }; + (ct, cd) }; let md = file.metadata()?; let modified = md.modified().ok(); let encoding = None; + Ok(NamedFile { path, file, @@ -242,6 +250,7 @@ impl NamedFile { let dur = mtime .duration_since(UNIX_EPOCH) .expect("modification time must be after epoch"); + header::EntityTag::strong(format!( "{:x}:{:x}:{:x}:{:x}", ino, @@ -256,9 +265,11 @@ impl NamedFile { self.modified.map(|mtime| mtime.into()) } + /// Creates an `HttpResponse` with file as a streaming body. pub fn into_response(self, req: &HttpRequest) -> Result { if self.status_code != StatusCode::OK { let mut resp = HttpResponse::build(self.status_code); + resp.set(header::ContentType(self.content_type.clone())) .if_true(self.flags.contains(Flags::CONTENT_DISPOSITION), |res| { res.header( @@ -266,9 +277,11 @@ impl NamedFile { self.content_disposition.to_string(), ); }); + if let Some(current_encoding) = self.encoding { resp.encoding(current_encoding); } + let reader = ChunkedReadFile { size: self.md.len(), offset: 0, @@ -276,6 +289,7 @@ impl NamedFile { fut: None, counter: 0, }; + return Ok(resp.streaming(reader)); } @@ -284,6 +298,7 @@ impl NamedFile { } else { None }; + let last_modified = if self.flags.contains(Flags::LAST_MD) { self.last_modified() } else { @@ -298,6 +313,7 @@ impl NamedFile { { let t1: SystemTime = m.clone().into(); let t2: SystemTime = since.clone().into(); + match (t1.duration_since(UNIX_EPOCH), t2.duration_since(UNIX_EPOCH)) { (Ok(t1), Ok(t2)) => t1 > t2, _ => false, @@ -309,13 +325,14 @@ impl NamedFile { // check last modified let not_modified = if !none_match(etag.as_ref(), req) { true - } else if req.headers().contains_key(&header::IF_NONE_MATCH) { + } else if req.headers().contains_key(header::IF_NONE_MATCH) { false } else if let (Some(ref m), Some(header::IfModifiedSince(ref since))) = (last_modified, req.get_header()) { let t1: SystemTime = m.clone().into(); let t2: SystemTime = since.clone().into(); + match (t1.duration_since(UNIX_EPOCH), t2.duration_since(UNIX_EPOCH)) { (Ok(t1), Ok(t2)) => t1 <= t2, _ => false, @@ -332,6 +349,7 @@ impl NamedFile { self.content_disposition.to_string(), ); }); + // default compressing if let Some(current_encoding) = self.encoding { resp.encoding(current_encoding); @@ -350,11 +368,12 @@ impl NamedFile { let mut offset = 0; // check for range header - if let Some(ranges) = req.headers().get(&header::RANGE) { - if let Ok(rangesheader) = ranges.to_str() { - if let Ok(rangesvec) = HttpRange::parse(rangesheader, length) { - length = rangesvec[0].length; - offset = rangesvec[0].start; + if let Some(ranges) = req.headers().get(header::RANGE) { + if let Ok(ranges_header) = ranges.to_str() { + if let Ok(ranges) = HttpRange::parse(ranges_header, length) { + length = ranges[0].length; + offset = ranges[0].start; + resp.encoding(ContentEncoding::Identity); resp.header( header::CONTENT_RANGE, @@ -414,6 +433,7 @@ impl DerefMut for NamedFile { fn any_match(etag: Option<&header::EntityTag>, req: &HttpRequest) -> bool { match req.get_header::() { None | Some(header::IfMatch::Any) => true, + Some(header::IfMatch::Items(ref items)) => { if let Some(some_etag) = etag { for item in items { @@ -422,6 +442,7 @@ fn any_match(etag: Option<&header::EntityTag>, req: &HttpRequest) -> bool { } } } + false } } @@ -431,6 +452,7 @@ fn any_match(etag: Option<&header::EntityTag>, req: &HttpRequest) -> bool { fn none_match(etag: Option<&header::EntityTag>, req: &HttpRequest) -> bool { match req.get_header::() { Some(header::IfNoneMatch::Any) => false, + Some(header::IfNoneMatch::Items(ref items)) => { if let Some(some_etag) = etag { for item in items { @@ -439,8 +461,10 @@ fn none_match(etag: Option<&header::EntityTag>, req: &HttpRequest) -> bool { } } } + true } + None => true, } } diff --git a/actix-files/src/path_buf.rs b/actix-files/src/path_buf.rs new file mode 100644 index 000000000..2f3ae84d4 --- /dev/null +++ b/actix-files/src/path_buf.rs @@ -0,0 +1,99 @@ +use std::{ + path::{Path, PathBuf}, + str::FromStr, +}; + +use actix_web::{dev::Payload, FromRequest, HttpRequest}; +use futures_util::future::{ready, Ready}; + +use crate::error::UriSegmentError; + +#[derive(Debug)] +pub(crate) struct PathBufWrap(PathBuf); + +impl FromStr for PathBufWrap { + type Err = UriSegmentError; + + fn from_str(path: &str) -> Result { + let mut buf = PathBuf::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(PathBufWrap(buf)) + } +} + +impl AsRef for PathBufWrap { + fn as_ref(&self) -> &Path { + self.0.as_ref() + } +} + +impl FromRequest for PathBufWrap { + type Error = UriSegmentError; + type Future = Ready>; + type Config = (); + + fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future { + ready(req.match_info().path().parse()) + } +} + +#[cfg(test)] +mod tests { + use std::iter::FromIterator; + + use super::*; + + #[test] + fn test_path_buf() { + assert_eq!( + PathBufWrap::from_str("/test/.tt").map(|t| t.0), + Err(UriSegmentError::BadStart('.')) + ); + assert_eq!( + PathBufWrap::from_str("/test/*tt").map(|t| t.0), + Err(UriSegmentError::BadStart('*')) + ); + assert_eq!( + PathBufWrap::from_str("/test/tt:").map(|t| t.0), + Err(UriSegmentError::BadEnd(':')) + ); + assert_eq!( + PathBufWrap::from_str("/test/tt<").map(|t| t.0), + Err(UriSegmentError::BadEnd('<')) + ); + assert_eq!( + PathBufWrap::from_str("/test/tt>").map(|t| t.0), + Err(UriSegmentError::BadEnd('>')) + ); + assert_eq!( + PathBufWrap::from_str("/seg1/seg2/").unwrap().0, + PathBuf::from_iter(vec!["seg1", "seg2"]) + ); + assert_eq!( + PathBufWrap::from_str("/seg1/../seg2/").unwrap().0, + PathBuf::from_iter(vec!["seg2"]) + ); + } +} diff --git a/actix-files/src/range.rs b/actix-files/src/range.rs index 47673b0b0..e891ca7ec 100644 --- a/actix-files/src/range.rs +++ b/actix-files/src/range.rs @@ -1,11 +1,14 @@ /// HTTP Range header representation. #[derive(Debug, Clone, Copy)] pub struct HttpRange { + /// Start of range. pub start: u64, + + /// Length of range. pub length: u64, } -static PREFIX: &str = "bytes="; +const PREFIX: &str = "bytes="; const PREFIX_LEN: usize = 6; impl HttpRange { diff --git a/actix-files/src/service.rs b/actix-files/src/service.rs new file mode 100644 index 000000000..cbf4c2d3b --- /dev/null +++ b/actix-files/src/service.rs @@ -0,0 +1,167 @@ +use std::{ + fmt, io, + path::PathBuf, + rc::Rc, + task::{Context, Poll}, +}; + +use actix_service::Service; +use actix_web::{ + dev::{ServiceRequest, ServiceResponse}, + error::Error, + guard::Guard, + http::{header, Method}, + HttpResponse, +}; +use futures_util::future::{ok, Either, LocalBoxFuture, Ready}; + +use crate::{ + named, Directory, DirectoryRenderer, FilesError, HttpService, MimeOverride, + NamedFile, PathBufWrap, +}; + +/// Assembled file serving service. +pub struct FilesService { + pub(crate) directory: PathBuf, + pub(crate) index: Option, + pub(crate) show_index: bool, + pub(crate) redirect_to_slash: bool, + pub(crate) default: Option, + pub(crate) renderer: Rc, + pub(crate) mime_override: Option>, + pub(crate) file_flags: named::Flags, + pub(crate) guards: Option>, +} + +type FilesServiceFuture = Either< + Ready>, + LocalBoxFuture<'static, Result>, +>; + +impl FilesService { + fn handle_err(&mut self, e: io::Error, req: ServiceRequest) -> FilesServiceFuture { + log::debug!("Failed to handle {}: {}", req.path(), e); + + if let Some(ref mut default) = self.default { + Either::Right(default.call(req)) + } else { + Either::Left(ok(req.error_response(e))) + } + } +} + +impl fmt::Debug for FilesService { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("FilesService") + } +} + +impl Service for FilesService { + type Request = ServiceRequest; + type Response = ServiceResponse; + type Error = Error; + type Future = FilesServiceFuture; + + fn poll_ready(&mut self, _: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, req: ServiceRequest) -> Self::Future { + let is_method_valid = if let Some(guard) = &self.guards { + // execute user defined guards + (**guard).check(req.head()) + } else { + // default behavior + matches!(*req.method(), Method::HEAD | Method::GET) + }; + + if !is_method_valid { + return Either::Left(ok(req.into_response( + actix_web::HttpResponse::MethodNotAllowed() + .header(header::CONTENT_TYPE, "text/plain") + .body("Request did not meet this resource's requirements."), + ))); + } + + let real_path: PathBufWrap = match req.match_info().path().parse() { + Ok(item) => item, + Err(e) => return Either::Left(ok(req.error_response(e))), + }; + + // full file path + let path = match self.directory.join(&real_path).canonicalize() { + Ok(path) => path, + Err(e) => return self.handle_err(e, req), + }; + + if path.is_dir() { + if let Some(ref redir_index) = self.index { + if self.redirect_to_slash && !req.path().ends_with('/') { + let redirect_to = format!("{}/", req.path()); + + return Either::Left(ok(req.into_response( + HttpResponse::Found() + .header(header::LOCATION, redirect_to) + .body("") + .into_body(), + ))); + } + + let path = path.join(redir_index); + + match NamedFile::open(path) { + Ok(mut named_file) => { + if let Some(ref mime_override) = self.mime_override { + let new_disposition = + mime_override(&named_file.content_type.type_()); + named_file.content_disposition.disposition = new_disposition; + } + named_file.flags = self.file_flags; + + let (req, _) = req.into_parts(); + Either::Left(ok(match named_file.into_response(&req) { + Ok(item) => ServiceResponse::new(req, item), + Err(e) => ServiceResponse::from_err(e, req), + })) + } + Err(e) => self.handle_err(e, req), + } + } else if self.show_index { + let dir = Directory::new(self.directory.clone(), path); + + let (req, _) = req.into_parts(); + let x = (self.renderer)(&dir, &req); + + match x { + Ok(resp) => Either::Left(ok(resp)), + Err(e) => Either::Left(ok(ServiceResponse::from_err(e, req))), + } + } else { + Either::Left(ok(ServiceResponse::from_err( + FilesError::IsDirectory, + req.into_parts().0, + ))) + } + } else { + match NamedFile::open(path) { + Ok(mut named_file) => { + if let Some(ref mime_override) = self.mime_override { + let new_disposition = + mime_override(&named_file.content_type.type_()); + named_file.content_disposition.disposition = new_disposition; + } + named_file.flags = self.file_flags; + + let (req, _) = req.into_parts(); + match named_file.into_response(&req) { + Ok(item) => { + Either::Left(ok(ServiceResponse::new(req.clone(), item))) + } + Err(e) => Either::Left(ok(ServiceResponse::from_err(e, req))), + } + } + Err(e) => self.handle_err(e, req), + } + } + } +} diff --git a/actix-http/README.md b/actix-http/README.md index d4c96f2a7..96fc54d2e 100644 --- a/actix-http/README.md +++ b/actix-http/README.md @@ -13,12 +13,11 @@ Actix http ## Example ```rust -// see examples/framed_hello.rs for complete list of used crates. use std::{env, io}; use actix_http::{HttpService, Response}; use actix_server::Server; -use futures::future; +use futures_util::future; use http::header::HeaderValue; use log::info; diff --git a/actix-web-codegen/CHANGES.md b/actix-web-codegen/CHANGES.md index 5c0ce828a..793864d43 100644 --- a/actix-web-codegen/CHANGES.md +++ b/actix-web-codegen/CHANGES.md @@ -2,8 +2,10 @@ ## Unreleased - 2020-xx-xx * Added compile success and failure testing. [#1677] +* Add `route` macro for supporting multiple HTTP methods guards. [#1677]: https://github.com/actix/actix-web/pull/1677 +[#1674]: https://github.com/actix/actix-web/pull/1674 ## 0.3.0 - 2020-09-11 diff --git a/actix-web-codegen/Cargo.toml b/actix-web-codegen/Cargo.toml index 1bf78f997..da37b8de6 100644 --- a/actix-web-codegen/Cargo.toml +++ b/actix-web-codegen/Cargo.toml @@ -23,3 +23,4 @@ actix-rt = "1.0.0" actix-web = "3.0.0" futures-util = { version = "0.3.5", default-features = false } trybuild = "1" +rustversion = "1" diff --git a/actix-web-codegen/src/lib.rs b/actix-web-codegen/src/lib.rs index 445fe924d..62a1cc5fa 100644 --- a/actix-web-codegen/src/lib.rs +++ b/actix-web-codegen/src/lib.rs @@ -1,144 +1,103 @@ #![recursion_limit = "512"] -//! Helper and convenience macros for Actix-web. -//! -//! ## Runtime Setup -//! -//! - [main](attr.main.html) -//! -//! ## Resource Macros: -//! -//! - [get](attr.get.html) -//! - [post](attr.post.html) -//! - [put](attr.put.html) -//! - [delete](attr.delete.html) -//! - [head](attr.head.html) -//! - [connect](attr.connect.html) -//! - [options](attr.options.html) -//! - [trace](attr.trace.html) -//! - [patch](attr.patch.html) -//! -//! ### Attributes: -//! -//! - `"path"` - Raw literal string with path for which to register handle. Mandatory. -//! - `guard="function_name"` - Registers function as guard using `actix_web::guard::fn_guard` -//! - `wrap="Middleware"` - Registers a resource middleware. -//! -//! ### Notes -//! -//! Function name can be specified as any expression that is going to be accessible to the generate -//! code (e.g `my_guard` or `my_module::my_guard`) -//! -//! ### Example: -//! -//! ```rust -//! use actix_web::HttpResponse; -//! use actix_web_codegen::get; -//! -//! #[get("/test")] -//! async fn async_test() -> Result { -//! Ok(HttpResponse::Ok().finish()) -//! } -//! ``` - extern crate proc_macro; -mod route; - use proc_macro::TokenStream; -/// Creates route handler with `GET` method guard. +mod route; + +/// Creates resource handler, allowing multiple HTTP method guards. /// -/// Syntax: `#[get("path" [, attributes])]` +/// ## Syntax +/// ```text +/// #[route("path", method="HTTP_METHOD"[, attributes])] +/// ``` /// -/// ## Attributes: +/// ### Attributes +/// - `"path"` - Raw literal string with path for which to register handler. +/// - `method="HTTP_METHOD"` - Registers HTTP method to provide guard for. Upper-case string, "GET", "POST" for example. +/// - `guard="function_name"` - Registers function as guard using `actix_web::guard::fn_guard` +/// - `wrap="Middleware"` - Registers a resource middleware. /// -/// - `"path"` - Raw literal string with path for which to register handler. Mandatory. -/// - `guard = "function_name"` - Registers function as guard using `actix_web::guard::fn_guard` -/// - `wrap = "Middleware"` - Registers a resource middleware. +/// ### Notes +/// Function name can be specified as any expression that is going to be accessible to the generate +/// code, e.g `my_guard` or `my_module::my_guard`. +/// +/// ## Example +/// +/// ```rust +/// use actix_web::HttpResponse; +/// use actix_web_codegen::route; +/// +/// #[route("/", method="GET", method="HEAD")] +/// async fn example() -> HttpResponse { +/// HttpResponse::Ok().finish() +/// } +/// ``` #[proc_macro_attribute] -pub fn get(args: TokenStream, input: TokenStream) -> TokenStream { - route::generate(args, input, route::GuardType::Get) +pub fn route(args: TokenStream, input: TokenStream) -> TokenStream { + route::with_method(None, args, input) } -/// Creates route handler with `POST` method guard. -/// -/// Syntax: `#[post("path" [, attributes])]` -/// -/// Attributes are the same as in [get](attr.get.html) -#[proc_macro_attribute] -pub fn post(args: TokenStream, input: TokenStream) -> TokenStream { - route::generate(args, input, route::GuardType::Post) +macro_rules! doc_comment { + ($x:expr; $($tt:tt)*) => { + #[doc = $x] + $($tt)* + }; } -/// Creates route handler with `PUT` method guard. -/// -/// Syntax: `#[put("path" [, attributes])]` -/// -/// Attributes are the same as in [get](attr.get.html) -#[proc_macro_attribute] -pub fn put(args: TokenStream, input: TokenStream) -> TokenStream { - route::generate(args, input, route::GuardType::Put) +macro_rules! method_macro { + ( + $($variant:ident, $method:ident,)+ + ) => { + $(doc_comment! { +concat!(" +Creates route handler with `actix_web::guard::", stringify!($variant), "`. + +## Syntax +```text +#[", stringify!($method), r#"("path"[, attributes])] +``` + +### Attributes +- `"path"` - Raw literal string with path for which to register handler. +- `guard="function_name"` - Registers function as guard using `actix_web::guard::fn_guard`. +- `wrap="Middleware"` - Registers a resource middleware. + +### Notes +Function name can be specified as any expression that is going to be accessible to the generate +code, e.g `my_guard` or `my_module::my_guard`. + +## Example + +```rust +use actix_web::HttpResponse; +use actix_web_codegen::"#, stringify!($method), "; + +#[", stringify!($method), r#"("/")] +async fn example() -> HttpResponse { + HttpResponse::Ok().finish() +} +``` +"#); + #[proc_macro_attribute] + pub fn $method(args: TokenStream, input: TokenStream) -> TokenStream { + route::with_method(Some(route::MethodType::$variant), args, input) + } + })+ + }; } -/// Creates route handler with `DELETE` method guard. -/// -/// Syntax: `#[delete("path" [, attributes])]` -/// -/// Attributes are the same as in [get](attr.get.html). -#[proc_macro_attribute] -pub fn delete(args: TokenStream, input: TokenStream) -> TokenStream { - route::generate(args, input, route::GuardType::Delete) -} - -/// Creates route handler with `HEAD` method guard. -/// -/// Syntax: `#[head("path" [, attributes])]` -/// -/// Attributes are the same as in [get](attr.get.html). -#[proc_macro_attribute] -pub fn head(args: TokenStream, input: TokenStream) -> TokenStream { - route::generate(args, input, route::GuardType::Head) -} - -/// Creates route handler with `CONNECT` method guard. -/// -/// Syntax: `#[connect("path" [, attributes])]` -/// -/// Attributes are the same as in [get](attr.get.html). -#[proc_macro_attribute] -pub fn connect(args: TokenStream, input: TokenStream) -> TokenStream { - route::generate(args, input, route::GuardType::Connect) -} - -/// Creates route handler with `OPTIONS` method guard. -/// -/// Syntax: `#[options("path" [, attributes])]` -/// -/// Attributes are the same as in [get](attr.get.html). -#[proc_macro_attribute] -pub fn options(args: TokenStream, input: TokenStream) -> TokenStream { - route::generate(args, input, route::GuardType::Options) -} - -/// Creates route handler with `TRACE` method guard. -/// -/// Syntax: `#[trace("path" [, attributes])]` -/// -/// Attributes are the same as in [get](attr.get.html). -#[proc_macro_attribute] -pub fn trace(args: TokenStream, input: TokenStream) -> TokenStream { - route::generate(args, input, route::GuardType::Trace) -} - -/// Creates route handler with `PATCH` method guard. -/// -/// Syntax: `#[patch("path" [, attributes])]` -/// -/// Attributes are the same as in [get](attr.get.html). -#[proc_macro_attribute] -pub fn patch(args: TokenStream, input: TokenStream) -> TokenStream { - route::generate(args, input, route::GuardType::Patch) +method_macro! { + Get, get, + Post, post, + Put, put, + Delete, delete, + Head, head, + Connect, connect, + Options, options, + Trace, trace, + Patch, patch, } /// Marks async main function as the actix system entry-point. diff --git a/actix-web-codegen/src/route.rs b/actix-web-codegen/src/route.rs index 676e75e07..ddbd42454 100644 --- a/actix-web-codegen/src/route.rs +++ b/actix-web-codegen/src/route.rs @@ -1,5 +1,8 @@ extern crate proc_macro; +use std::collections::HashSet; +use std::convert::TryFrom; + use proc_macro::TokenStream; use proc_macro2::{Span, TokenStream as TokenStream2}; use quote::{format_ident, quote, ToTokens, TokenStreamExt}; @@ -17,53 +20,81 @@ impl ToTokens for ResourceType { } } -#[derive(PartialEq)] -pub enum GuardType { - Get, - Post, - Put, - Delete, - Head, - Connect, - Options, - Trace, - Patch, -} - -impl GuardType { - fn as_str(&self) -> &'static str { - match self { - GuardType::Get => "Get", - GuardType::Post => "Post", - GuardType::Put => "Put", - GuardType::Delete => "Delete", - GuardType::Head => "Head", - GuardType::Connect => "Connect", - GuardType::Options => "Options", - GuardType::Trace => "Trace", - GuardType::Patch => "Patch", +macro_rules! method_type { + ( + $($variant:ident, $upper:ident,)+ + ) => { + #[derive(Debug, PartialEq, Eq, Hash)] + pub enum MethodType { + $( + $variant, + )+ } - } + + impl MethodType { + fn as_str(&self) -> &'static str { + match self { + $(Self::$variant => stringify!($variant),)+ + } + } + + fn parse(method: &str) -> Result { + match method { + $(stringify!($upper) => Ok(Self::$variant),)+ + _ => Err(format!("Unexpected HTTP method: `{}`", method)), + } + } + } + }; } -impl ToTokens for GuardType { +method_type! { + Get, GET, + Post, POST, + Put, PUT, + Delete, DELETE, + Head, HEAD, + Connect, CONNECT, + Options, OPTIONS, + Trace, TRACE, + Patch, PATCH, +} + +impl ToTokens for MethodType { fn to_tokens(&self, stream: &mut TokenStream2) { let ident = Ident::new(self.as_str(), Span::call_site()); stream.append(ident); } } +impl TryFrom<&syn::LitStr> for MethodType { + type Error = syn::Error; + + fn try_from(value: &syn::LitStr) -> Result { + Self::parse(value.value().as_str()) + .map_err(|message| syn::Error::new_spanned(value, message)) + } +} + struct Args { path: syn::LitStr, guards: Vec, wrappers: Vec, + methods: HashSet, } impl Args { - fn new(args: AttributeArgs) -> syn::Result { + fn new(args: AttributeArgs, method: Option) -> syn::Result { let mut path = None; let mut guards = Vec::new(); let mut wrappers = Vec::new(); + let mut methods = HashSet::new(); + + let is_route_macro = method.is_none(); + if let Some(method) = method { + methods.insert(method); + } + for arg in args { match arg { NestedMeta::Lit(syn::Lit::Str(lit)) => match path { @@ -96,10 +127,33 @@ impl Args { "Attribute wrap expects type", )); } + } else if nv.path.is_ident("method") { + if !is_route_macro { + return Err(syn::Error::new_spanned( + &nv, + "HTTP method forbidden here. To handle multiple methods, use `route` instead", + )); + } else if let syn::Lit::Str(ref lit) = nv.lit { + let method = MethodType::try_from(lit)?; + if !methods.insert(method) { + return Err(syn::Error::new_spanned( + &nv.lit, + &format!( + "HTTP method defined more than once: `{}`", + lit.value() + ), + )); + } + } else { + return Err(syn::Error::new_spanned( + nv.lit, + "Attribute method expects literal string!", + )); + } } else { return Err(syn::Error::new_spanned( nv.path, - "Unknown attribute key is specified. Allowed: guard and wrap", + "Unknown attribute key is specified. Allowed: guard, method and wrap", )); } } @@ -112,6 +166,7 @@ impl Args { path: path.unwrap(), guards, wrappers, + methods, }) } } @@ -121,7 +176,6 @@ pub struct Route { args: Args, ast: syn::ItemFn, resource_type: ResourceType, - guard: GuardType, } fn guess_resource_type(typ: &syn::Type) -> ResourceType { @@ -150,21 +204,30 @@ impl Route { pub fn new( args: AttributeArgs, input: TokenStream, - guard: GuardType, + method: Option, ) -> syn::Result { if args.is_empty() { return Err(syn::Error::new( Span::call_site(), format!( - r#"invalid server definition, expected #[{}("")]"#, - guard.as_str().to_ascii_lowercase() + r#"invalid service definition, expected #[{}("")]"#, + method + .map(|it| it.as_str()) + .unwrap_or("route") + .to_ascii_lowercase() ), )); } let ast: syn::ItemFn = syn::parse(input)?; let name = ast.sig.ident.clone(); - let args = Args::new(args)?; + let args = Args::new(args, method)?; + if args.methods.is_empty() { + return Err(syn::Error::new( + Span::call_site(), + "The #[route(..)] macro requires at least one `method` attribute", + )); + } let resource_type = if ast.sig.asyncness.is_some() { ResourceType::Async @@ -185,7 +248,6 @@ impl Route { args, ast, resource_type, - guard, }) } } @@ -194,17 +256,36 @@ impl ToTokens for Route { fn to_tokens(&self, output: &mut TokenStream2) { let Self { name, - guard, ast, args: Args { path, guards, wrappers, + methods, }, resource_type, } = self; let resource_name = name.to_string(); + let method_guards = { + let mut others = methods.iter(); + // unwrapping since length is checked to be at least one + let first = others.next().unwrap(); + + if methods.len() > 1 { + quote! { + .guard( + actix_web::guard::Any(actix_web::guard::#first()) + #(.or(actix_web::guard::#others()))* + ) + } + } else { + quote! { + .guard(actix_web::guard::#first()) + } + } + }; + let stream = quote! { #[allow(non_camel_case_types, missing_docs)] pub struct #name; @@ -214,7 +295,7 @@ impl ToTokens for Route { #ast let __resource = actix_web::Resource::new(#path) .name(#resource_name) - .guard(actix_web::guard::#guard()) + #method_guards #(.guard(actix_web::guard::fn_guard(#guards)))* #(.wrap(#wrappers))* .#resource_type(#name); @@ -228,13 +309,13 @@ impl ToTokens for Route { } } -pub(crate) fn generate( +pub(crate) fn with_method( + method: Option, args: TokenStream, input: TokenStream, - guard: GuardType, ) -> TokenStream { let args = parse_macro_input!(args as syn::AttributeArgs); - match Route::new(args, input, guard) { + match Route::new(args, input, method) { Ok(route) => route.into_token_stream().into(), Err(err) => err.to_compile_error().into(), } diff --git a/actix-web-codegen/tests/test_macro.rs b/actix-web-codegen/tests/test_macro.rs index 13e9120f6..dd2bccd7f 100644 --- a/actix-web-codegen/tests/test_macro.rs +++ b/actix-web-codegen/tests/test_macro.rs @@ -5,7 +5,9 @@ use std::task::{Context, Poll}; use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform}; use actix_web::http::header::{HeaderName, HeaderValue}; use actix_web::{http, test, web::Path, App, Error, HttpResponse, Responder}; -use actix_web_codegen::{connect, delete, get, head, options, patch, post, put, trace}; +use actix_web_codegen::{ + connect, delete, get, head, options, patch, post, put, route, trace, +}; use futures_util::future; // Make sure that we can name function as 'config' @@ -79,6 +81,11 @@ async fn get_param_test(_: Path) -> impl Responder { HttpResponse::Ok() } +#[route("/multi", method = "GET", method = "POST", method = "HEAD")] +async fn route_test() -> impl Responder { + HttpResponse::Ok() +} + pub struct ChangeStatusCode; impl Transform for ChangeStatusCode @@ -172,6 +179,7 @@ async fn test_body() { .service(trace_test) .service(patch_test) .service(test_handler) + .service(route_test) }); let request = srv.request(http::Method::GET, srv.url("/test")); let response = request.send().await.unwrap(); @@ -210,6 +218,22 @@ async fn test_body() { let request = srv.request(http::Method::GET, srv.url("/test")); let response = request.send().await.unwrap(); assert!(response.status().is_success()); + + let request = srv.request(http::Method::GET, srv.url("/multi")); + let response = request.send().await.unwrap(); + assert!(response.status().is_success()); + + let request = srv.request(http::Method::POST, srv.url("/multi")); + let response = request.send().await.unwrap(); + assert!(response.status().is_success()); + + let request = srv.request(http::Method::HEAD, srv.url("/multi")); + let response = request.send().await.unwrap(); + assert!(response.status().is_success()); + + let request = srv.request(http::Method::PATCH, srv.url("/multi")); + let response = request.send().await.unwrap(); + assert!(!response.status().is_success()); } #[actix_rt::test] diff --git a/actix-web-codegen/tests/trybuild.rs b/actix-web-codegen/tests/trybuild.rs index b675947d3..1bc2bd25e 100644 --- a/actix-web-codegen/tests/trybuild.rs +++ b/actix-web-codegen/tests/trybuild.rs @@ -4,4 +4,24 @@ fn compile_macros() { t.pass("tests/trybuild/simple.rs"); t.compile_fail("tests/trybuild/simple-fail.rs"); + + t.pass("tests/trybuild/route-ok.rs"); + t.compile_fail("tests/trybuild/route-duplicate-method-fail.rs"); + t.compile_fail("tests/trybuild/route-unexpected-method-fail.rs"); + + test_route_missing_method(&t) } + +#[rustversion::stable(1.42)] +fn test_route_missing_method(t: &trybuild::TestCases) { + t.compile_fail("tests/trybuild/route-missing-method-fail-msrv.rs"); +} + +#[rustversion::not(stable(1.42))] +#[rustversion::not(nightly)] +fn test_route_missing_method(t: &trybuild::TestCases) { + t.compile_fail("tests/trybuild/route-missing-method-fail.rs"); +} + +#[rustversion::nightly] +fn test_route_missing_method(_t: &trybuild::TestCases) {} diff --git a/actix-web-codegen/tests/trybuild/route-duplicate-method-fail.rs b/actix-web-codegen/tests/trybuild/route-duplicate-method-fail.rs new file mode 100644 index 000000000..9ce980251 --- /dev/null +++ b/actix-web-codegen/tests/trybuild/route-duplicate-method-fail.rs @@ -0,0 +1,15 @@ +use actix_web::*; + +#[route("/", method="GET", method="GET")] +async fn index() -> impl Responder { + HttpResponse::Ok() +} + +#[actix_web::main] +async fn main() { + let srv = test::start(|| App::new().service(index)); + + let request = srv.get("/"); + let response = request.send().await.unwrap(); + assert!(response.status().is_success()); +} diff --git a/actix-web-codegen/tests/trybuild/route-duplicate-method-fail.stderr b/actix-web-codegen/tests/trybuild/route-duplicate-method-fail.stderr new file mode 100644 index 000000000..613054de5 --- /dev/null +++ b/actix-web-codegen/tests/trybuild/route-duplicate-method-fail.stderr @@ -0,0 +1,11 @@ +error: HTTP method defined more than once: `GET` + --> $DIR/route-duplicate-method-fail.rs:3:35 + | +3 | #[route("/", method="GET", method="GET")] + | ^^^^^ + +error[E0425]: cannot find value `index` in this scope + --> $DIR/route-duplicate-method-fail.rs:10:49 + | +10 | let srv = test::start(|| App::new().service(index)); + | ^^^^^ not found in this scope diff --git a/actix-web-codegen/tests/trybuild/route-missing-method-fail-msrv.rs b/actix-web-codegen/tests/trybuild/route-missing-method-fail-msrv.rs new file mode 100644 index 000000000..5c30b57ce --- /dev/null +++ b/actix-web-codegen/tests/trybuild/route-missing-method-fail-msrv.rs @@ -0,0 +1,15 @@ +use actix_web::*; + +#[route("/")] +async fn index() -> impl Responder { + HttpResponse::Ok() +} + +#[actix_web::main] +async fn main() { + let srv = test::start(|| App::new().service(index)); + + let request = srv.get("/"); + let response = request.send().await.unwrap(); + assert!(response.status().is_success()); +} diff --git a/actix-web-codegen/tests/trybuild/route-missing-method-fail-msrv.stderr b/actix-web-codegen/tests/trybuild/route-missing-method-fail-msrv.stderr new file mode 100644 index 000000000..f59f6c27e --- /dev/null +++ b/actix-web-codegen/tests/trybuild/route-missing-method-fail-msrv.stderr @@ -0,0 +1,11 @@ +error: The #[route(..)] macro requires at least one `method` attribute + --> $DIR/route-missing-method-fail-msrv.rs:3:1 + | +3 | #[route("/")] + | ^^^^^^^^^^^^^ + +error[E0425]: cannot find value `index` in this scope + --> $DIR/route-missing-method-fail-msrv.rs:10:49 + | +10 | let srv = test::start(|| App::new().service(index)); + | ^^^^^ not found in this scope diff --git a/actix-web-codegen/tests/trybuild/route-missing-method-fail.rs b/actix-web-codegen/tests/trybuild/route-missing-method-fail.rs new file mode 100644 index 000000000..5c30b57ce --- /dev/null +++ b/actix-web-codegen/tests/trybuild/route-missing-method-fail.rs @@ -0,0 +1,15 @@ +use actix_web::*; + +#[route("/")] +async fn index() -> impl Responder { + HttpResponse::Ok() +} + +#[actix_web::main] +async fn main() { + let srv = test::start(|| App::new().service(index)); + + let request = srv.get("/"); + let response = request.send().await.unwrap(); + assert!(response.status().is_success()); +} diff --git a/actix-web-codegen/tests/trybuild/route-missing-method-fail.stderr b/actix-web-codegen/tests/trybuild/route-missing-method-fail.stderr new file mode 100644 index 000000000..6d35ea600 --- /dev/null +++ b/actix-web-codegen/tests/trybuild/route-missing-method-fail.stderr @@ -0,0 +1,13 @@ +error: The #[route(..)] macro requires at least one `method` attribute + --> $DIR/route-missing-method-fail.rs:3:1 + | +3 | #[route("/")] + | ^^^^^^^^^^^^^ + | + = note: this error originates in an attribute macro (in Nightly builds, run with -Z macro-backtrace for more info) + +error[E0425]: cannot find value `index` in this scope + --> $DIR/route-missing-method-fail.rs:10:49 + | +10 | let srv = test::start(|| App::new().service(index)); + | ^^^^^ not found in this scope diff --git a/actix-web-codegen/tests/trybuild/route-ok.rs b/actix-web-codegen/tests/trybuild/route-ok.rs new file mode 100644 index 000000000..bfac56e12 --- /dev/null +++ b/actix-web-codegen/tests/trybuild/route-ok.rs @@ -0,0 +1,15 @@ +use actix_web::*; + +#[route("/", method="GET", method="HEAD")] +async fn index() -> impl Responder { + HttpResponse::Ok() +} + +#[actix_web::main] +async fn main() { + let srv = test::start(|| App::new().service(index)); + + let request = srv.get("/"); + let response = request.send().await.unwrap(); + assert!(response.status().is_success()); +} diff --git a/actix-web-codegen/tests/trybuild/route-unexpected-method-fail.rs b/actix-web-codegen/tests/trybuild/route-unexpected-method-fail.rs new file mode 100644 index 000000000..f4d8d9445 --- /dev/null +++ b/actix-web-codegen/tests/trybuild/route-unexpected-method-fail.rs @@ -0,0 +1,15 @@ +use actix_web::*; + +#[route("/", method="UNEXPECTED")] +async fn index() -> impl Responder { + HttpResponse::Ok() +} + +#[actix_web::main] +async fn main() { + let srv = test::start(|| App::new().service(index)); + + let request = srv.get("/"); + let response = request.send().await.unwrap(); + assert!(response.status().is_success()); +} diff --git a/actix-web-codegen/tests/trybuild/route-unexpected-method-fail.stderr b/actix-web-codegen/tests/trybuild/route-unexpected-method-fail.stderr new file mode 100644 index 000000000..fe17fdf12 --- /dev/null +++ b/actix-web-codegen/tests/trybuild/route-unexpected-method-fail.stderr @@ -0,0 +1,11 @@ +error: Unexpected HTTP method: `UNEXPECTED` + --> $DIR/route-unexpected-method-fail.rs:3:21 + | +3 | #[route("/", method="UNEXPECTED")] + | ^^^^^^^^^^^^ + +error[E0425]: cannot find value `index` in this scope + --> $DIR/route-unexpected-method-fail.rs:10:49 + | +10 | let srv = test::start(|| App::new().service(index)); + | ^^^^^ not found in this scope diff --git a/actix-web-codegen/tests/trybuild/simple-fail.rs b/actix-web-codegen/tests/trybuild/simple-fail.rs index 140497687..368cff046 100644 --- a/actix-web-codegen/tests/trybuild/simple-fail.rs +++ b/actix-web-codegen/tests/trybuild/simple-fail.rs @@ -22,4 +22,9 @@ async fn four() -> impl Responder { HttpResponse::Ok() } +#[delete("/five", method="GET")] +async fn five() -> impl Responder { + HttpResponse::Ok() +} + fn main() {} diff --git a/actix-web-codegen/tests/trybuild/simple-fail.stderr b/actix-web-codegen/tests/trybuild/simple-fail.stderr index 12c32c00d..cffc81ff8 100644 --- a/actix-web-codegen/tests/trybuild/simple-fail.stderr +++ b/actix-web-codegen/tests/trybuild/simple-fail.stderr @@ -21,3 +21,9 @@ error: Multiple paths specified! Should be only one! | 20 | #[delete("/four", "/five")] | ^^^^^^^ + +error: HTTP method forbidden here. To handle multiple methods, use `route` instead + --> $DIR/simple-fail.rs:25:19 + | +25 | #[delete("/five", method="GET")] + | ^^^^^^^^^^^^