diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index e3ad56ae0..a11172a7f 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -11,7 +11,6 @@ Repository. - [Code of Conduct](#code-of-conduct) - [Bad Actors](#bad-actors) -- [Developer Certificate of Origin](#developer-certificate-of-origin) ## Code of Conduct The project has a [Code of Conduct](./CODE_OF_CONDUCT.md) that *all* @@ -54,10 +53,3 @@ contributors the benefit of the doubt and having a sincere willingness to admit that you *might* be wrong is critical for any successful open collaboration. Don't be a bad actor. - -## Developer Certificate of Origin -All contributors must read and agree to the [Developer Certificate of -Origin (DCO)](../CERTIFICATE). - -The DCO allows us to accept contributions from people to the project, similarly -to how a license allows us to distribute our code. diff --git a/.travis.yml b/.travis.yml index 3e4d10906..5f0f9df5a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,13 +1,56 @@ language: rust -rust: - - nightly-2019-05-09 - -before_script: | - rustup component add rustfmt clippy -script: | - cargo fmt --all -- --check && - cargo clippy --all -- -D clippy::all && - cargo build --no-default-features --verbose && - cargo build --all --verbose && - cargo test --all --verbose -cache: cargo +rust: beta + +before_script: +- > + [[ "$(cargo-sweep --version)" == "cargo-sweep 0.4.1" ]] + || cargo install cargo-sweep +- cargo sweep --stamp + +before_cache: +- cargo sweep --file + +matrix: + include: + - name: cargo doc + env: [CACHE_NAME=docs] + script: + - RUSTDOCFLAGS=-Dwarnings cargo doc + --all --all-features + --exclude tide + --no-deps + + - name: cargo fmt + cache: false + before_script: [] + install: + - rustup component add rustfmt + script: + - cargo fmt --all -- --check + + - name: cargo clippy + env: [CACHE_NAME=clippy] + install: + - rustup component add clippy + script: + - cargo clippy + --all --all-targets + -- -Dwarnings + + - name: cargo build --no-default-features + env: [CACHE_NAME=no-default-features] + script: + - cargo build + --manifest-path tide-core/Cargo.toml + --no-default-features + - cargo build + --no-default-features + + - name: cargo test + script: + - cargo test --all --verbose + + - name: cargo test --all-features + script: + - cargo test --all-features + - cargo test --manifest-path tide-slog/Cargo.toml --all-features diff --git a/CERTIFICATE b/CERTIFICATE deleted file mode 100644 index 8201f9921..000000000 --- a/CERTIFICATE +++ /dev/null @@ -1,37 +0,0 @@ -Developer Certificate of Origin -Version 1.1 - -Copyright (C) 2004, 2006 The Linux Foundation and its contributors. -1 Letterman Drive -Suite D4700 -San Francisco, CA, 94129 - -Everyone is permitted to copy and distribute verbatim copies of this -license document, but changing it is not allowed. - - -Developer's Certificate of Origin 1.1 - -By making a contribution to this project, I certify that: - -(a) The contribution was created in whole or in part by me and I - have the right to submit it under the open source license - indicated in the file; or - -(b) The contribution is based upon previous work that, to the best - of my knowledge, is covered under an appropriate open source - license and I have the right under that license to submit that - work with modifications, whether created in whole or in part - by me, under the same open source license (unless I am - permitted to submit under a different license), as indicated - in the file; or - -(c) The contribution was provided directly to me by some other - person who certified (a), (b) or (c) and I have not modified - it. - -(d) I understand and agree that this project and the contribution - are public and that a record of the contribution (including all - personal information I submit with it, including my sign-off) is - maintained indefinitely and may be redistributed consistent with - this project or the open source license(s) involved. diff --git a/Cargo.toml b/Cargo.toml index d90daaea7..ac9d811c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,50 +18,59 @@ readme = "README.md" repository = "https://github.com/rustasync/tide" version = "0.2.0" -[dependencies] -cookie = { version = "0.12", features = ["percent-encode"] } -futures-preview = "0.3.0-alpha.16" -fnv = "1.0.6" -http = "0.1" -http-service = "0.2.0" -pin-utils = "0.1.0-alpha.4" -route-recognizer = "0.1.12" -serde = "1.0.91" -serde_derive = "1.0.91" -serde_json = "1.0.39" -slog = "2.4.1" -slog-async = "2.3.0" -slog-term = "2.4.0" -typemap = "0.3.3" -serde_urlencoded = "0.5.5" - -[dependencies.http-service-hyper] -optional = true -version = "0.2.0" - -[dependencies.multipart] -default-features = false -features = ["server"] -version = "0.16.1" - [features] -default = ["hyper"] +default = ["hyper", "cookies", "cors"] +cookies = ["tide-cookies"] +cors = ["tide-cors"] hyper = ["http-service-hyper"] +[dependencies] +futures-preview = "0.3.0-alpha.18" +http = "0.1" +http-service = { git = "https://github.com/rustasync/http-service", branch = "tokio-0.2" } +http-service-hyper = { git = "https://github.com/rustasync/http-service", branch = "tokio-0.2", optional = true } +# Routing +fnv = "1.0.6" +route-recognizer = "0.1.13" +# Tide components +tide-cookies = { path = "./tide-cookies", optional = true, default-features = false } +tide-cors = { path = "./tide-cors", optional = true, default-features = false } +tide-core = { path = "./tide-core", default-features = false } +tide-headers = { path = "./tide-headers", default-features = false } +tide-log = { path = "./tide-log", default-features = false } +tide-forms = { path = "./tide-forms", default-features = false } +tide-querystring = { path = "./tide-querystring", default-features = false } + [dev-dependencies] -basic-cookies = "0.1.3" bytes = "0.4.12" +cookie = { version = "0.12", features = ["percent-encode"] } +env_logger = "0.7.0" futures-fs = "0.0.5" -futures-util-preview = { version = "0.3.0-alpha.16", features = ["compat"] } -http-service-mock = "0.2.0" -juniper = "0.11.1" +futures-util-preview = { version = "0.3.0-alpha.18", features = ["compat"] } +http-service-mock = { git = "https://github.com/rustasync/http-service", branch = "tokio-0.2" } +juniper = "0.14.0" +log = "0.4.6" +log4rs = "0.8.3" mime = "0.3.13" -mime_guess = "2.0.0-alpha.6" -percent-encoding = "1.0.1" -serde = { version = "1.0.90", features = ["derive"] } -structopt = "0.2.15" +mime_guess = "2.0.1" +percent-encoding = "2.0.0" +serde = { version = "1.0.91", features = ["derive"] } +tera = "0.11" +runtime = "0.3.0-alpha.7" +# Tide components +tide-log = { path = "./tide-log", default-features = false } +tokio = "0.2.0-alpha.4" -[patch.crates-io] -http-service = { git = "https://github.com/rustasync/http-service", branch = "master" } -http-service-hyper = { git = "https://github.com/rustasync/http-service", branch = "master" } -http-service-mock = { git = "https://github.com/rustasync/http-service", branch = "master" } +[workspace] +members = [ + "tide-compression", + "tide-cookies", + "tide-core", + "tide-cors", + "tide-forms", + "tide-headers", + "tide-log", + "tide-panic", + "tide-querystring", + "tide-slog", +] diff --git a/README.md b/README.md index 548b64ba2..49535e870 100644 --- a/README.md +++ b/README.md @@ -59,26 +59,26 @@ Ecosystem WG, and **not ready for production use yet**. **Hello World** -```rust -#![feature(async_await)] - -fn main() -> Result<(), std::io::Error> { +```rust,no_run +#[tokio::main] +async fn main() -> Result<(), std::io::Error> { let mut app = tide::App::new(); - app.at("/").get(async move |_| "Hello, world!"); - Ok(app.serve("127.0.0.1:8000")?) + app.at("/").get(|_| async move { "Hello, world!" }); + app.serve("127.0.0.1:8000").await } ``` **More Examples** -- [Hello World](https://github.com/rustasync/tide/tree/master/examples/hello.rs) +- [Hello World](https://github.com/rustasync/tide/blob/master/examples/hello.rs) - [Messages](https://github.com/rustasync/tide/blob/master/examples/messages.rs) - [Body Types](https://github.com/rustasync/tide/blob/master/examples/body_types.rs) -- [Multipart Form](https://github.com/rustasync/tide/tree/master/examples/multipart-form/main.rs) -- [Catch All](https://github.com/rustasync/tide/tree/master/examples/catch_all.rs) -- [Cookies](https://github.com/rustasync/tide/tree/master/examples/cookies.rs) -- [Default Headers](https://github.com/rustasync/tide/tree/master/examples/default_headers.rs) -- [GraphQL](https://github.com/rustasync/tide/tree/master/examples/graphql.rs) +- [Multipart Form](https://github.com/rustasync/tide/blob/master/examples/multipart_form/mod.rs) +- [Catch All](https://github.com/rustasync/tide/blob/master/examples/catch_all.rs) +- [Cookies](https://github.com/rustasync/tide/blob/master/examples/cookies.rs) +- [Default Headers](https://github.com/rustasync/tide/blob/master/examples/default_headers.rs) +- [GraphQL](https://github.com/rustasync/tide/blob/master/examples/graphql.rs) +- [Staticfile](https://github.com/rustasync/tide/blob/master/examples/staticfile.rs) ## Resources diff --git a/examples/body_types.rs b/examples/body_types.rs index 01c9b393c..dca1a0831 100644 --- a/examples/body_types.rs +++ b/examples/body_types.rs @@ -1,9 +1,7 @@ -#![feature(async_await)] - use serde::{Deserialize, Serialize}; use tide::{ error::ResultExt, - forms::{self, ExtractForms}, + forms::{self, ContextExt}, response, App, Context, EndpointResult, }; @@ -13,35 +11,32 @@ struct Message { contents: String, } -#[allow(unused_mut)] // Workaround clippy bug async fn echo_string(mut cx: Context<()>) -> String { let msg = cx.body_string().await.unwrap(); println!("String: {}", msg); msg } -#[allow(unused_mut)] // Workaround clippy bug async fn echo_bytes(mut cx: Context<()>) -> Vec { let msg = cx.body_bytes().await.unwrap(); println!("Bytes: {:?}", msg); msg } -#[allow(unused_mut)] // Workaround clippy bug async fn echo_json(mut cx: Context<()>) -> EndpointResult { - let msg = cx.body_json().await.client_err()?; + let msg: Message = cx.body_json().await.client_err()?; println!("JSON: {:?}", msg); Ok(response::json(msg)) } -#[allow(unused_mut)] // Workaround clippy bug async fn echo_form(mut cx: Context<()>) -> EndpointResult { - let msg = cx.body_form().await?; + let msg: Message = cx.body_form().await?; println!("Form: {:?}", msg); Ok(forms::form(msg)) } -fn main() { +#[tokio::main] +async fn main() { let mut app = App::new(); app.at("/echo/string").post(echo_string); @@ -49,5 +44,5 @@ fn main() { app.at("/echo/json").post(echo_json); app.at("/echo/form").post(echo_form); - app.serve("127.0.0.1:8000").unwrap(); + app.serve("127.0.0.1:8000").await.unwrap(); } diff --git a/examples/catch_all.rs b/examples/catch_all.rs index 354ddbb03..a1906eb24 100644 --- a/examples/catch_all.rs +++ b/examples/catch_all.rs @@ -1,5 +1,3 @@ -#![feature(async_await)] - use tide::Context; async fn echo_path(cx: Context<()>) -> String { @@ -7,8 +5,9 @@ async fn echo_path(cx: Context<()>) -> String { format!("Your path is: {}", path) } -fn main() { +#[tokio::main] +async fn main() { let mut app = tide::App::new(); app.at("/echo_path/*path").get(echo_path); - app.serve("127.0.0.1:8000").unwrap(); + app.serve("127.0.0.1:8000").await.unwrap(); } diff --git a/examples/cookies.rs b/examples/cookies.rs index 094030f27..8c28f997b 100644 --- a/examples/cookies.rs +++ b/examples/cookies.rs @@ -1,30 +1,26 @@ -#![feature(async_await)] - use cookie::Cookie; use tide::{cookies::ContextExt, middleware::CookiesMiddleware, Context}; /// Tide will use the the `Cookies`'s `Extract` implementation to build this parameter. -/// async fn retrieve_cookie(mut cx: Context<()>) -> String { format!("hello cookies: {:?}", cx.get_cookie("hello").unwrap()) } -#[allow(unused_mut)] // Workaround clippy bug async fn set_cookie(mut cx: Context<()>) { cx.set_cookie(Cookie::new("hello", "world")).unwrap(); } -#[allow(unused_mut)] // Workaround clippy bug async fn remove_cookie(mut cx: Context<()>) { cx.remove_cookie(Cookie::named("hello")).unwrap(); } -fn main() { +#[tokio::main] +async fn main() { let mut app = tide::App::new(); app.middleware(CookiesMiddleware::new()); app.at("/").get(retrieve_cookie); app.at("/set").get(set_cookie); app.at("/remove").get(remove_cookie); - app.serve("127.0.0.1:8000").unwrap(); + app.serve("127.0.0.1:8000").await.unwrap(); } diff --git a/examples/cors.rs b/examples/cors.rs new file mode 100644 index 000000000..3a9edc7cf --- /dev/null +++ b/examples/cors.rs @@ -0,0 +1,25 @@ +use http::header::HeaderValue; +use tide::middleware::{CorsMiddleware, CorsOrigin}; + +#[tokio::main] +async fn main() { + let mut app = tide::App::new(); + + app.middleware( + CorsMiddleware::new() + .allow_origin(CorsOrigin::from("*")) + .allow_methods(HeaderValue::from_static("GET, POST, OPTIONS")), + ); + + app.at("/").get(|_| async move { "Hello, world!" }); + + app.serve("127.0.0.1:8000").await.unwrap(); +} + +// You can test this by running the following in your browser: +// +// ```console +// $ fetch("http://127.0.0.1:8000") +// ``` +// +// You will probably get a browser alert when running without cors middleware. diff --git a/examples/default_headers.rs b/examples/default_headers.rs index 47f13a091..abd4ef277 100644 --- a/examples/default_headers.rs +++ b/examples/default_headers.rs @@ -1,8 +1,7 @@ -#![feature(async_await)] - use tide::middleware::DefaultHeaders; -fn main() { +#[tokio::main] +async fn main() { let mut app = tide::App::new(); app.middleware( @@ -11,7 +10,7 @@ fn main() { .header("X-Server", "Tide"), ); - app.at("/").get(async move |_| "Hello, world!"); + app.at("/").get(|_| async move { "Hello, world!" }); - app.serve("127.0.0.1:8000").unwrap(); + app.serve("127.0.0.1:8000").await.unwrap(); } diff --git a/examples/graphql.rs b/examples/graphql.rs index aeb9abfdf..8ce57ad0c 100644 --- a/examples/graphql.rs +++ b/examples/graphql.rs @@ -2,27 +2,24 @@ // a look at [the Juniper book]. // // [the Juniper book]: https://graphql-rust.github.io/ - -#![feature(async_await)] - use http::status::StatusCode; use juniper::graphql_object; use std::sync::{atomic, Arc}; use tide::{error::ResultExt, response, App, Context, EndpointResult}; -// First, we define `Data` that holds accumulator state. This is accessible as App data in +// First, we define `State` that holds accumulator state. This is accessible as state in // Tide, and as executor context in Juniper. #[derive(Clone, Default)] -struct Data(Arc); +struct State(Arc); -impl juniper::Context for Data {} +impl juniper::Context for State {} // We define `Query` unit struct here. GraphQL queries will refer to this struct. The struct itself -// doesn't have any associated data (and there's no need to do so), but instead it exposes the +// doesn't have any associated state (and there's no need to do so), but instead it exposes the // accumulator state from the context. struct Query; -graphql_object!(Query: Data |&self| { +graphql_object!(Query: State |&self| { // GraphQL integers are signed and 32 bits long. field accumulator(&executor) -> i32 as "Current value of the accumulator" { executor.context().0.load(atomic::Ordering::Relaxed) as i32 @@ -33,7 +30,7 @@ graphql_object!(Query: Data |&self| { // `Query`, but it provides the way to "mutate" the accumulator state. struct Mutation; -graphql_object!(Mutation: Data |&self| { +graphql_object!(Mutation: State |&self| { field add(&executor, by: i32) -> i32 as "Add given value to the accumulator." { executor.context().0.fetch_add(by as isize, atomic::Ordering::Relaxed) as i32 + by } @@ -45,7 +42,7 @@ type Schema = juniper::RootNode<'static, Query, Mutation>; // Finally, we'll bridge between Tide and Juniper. `GraphQLRequest` from Juniper implements // `Deserialize`, so we use `Json` extractor to deserialize the request body. -async fn handle_graphql(mut cx: Context) -> EndpointResult { +async fn handle_graphql(mut cx: Context) -> EndpointResult { let query: juniper::http::GraphQLRequest = cx.body_json().await.client_err()?; let schema = Schema::new(Query, Mutation); let response = query.execute(&schema, cx.state()); @@ -59,8 +56,9 @@ async fn handle_graphql(mut cx: Context) -> EndpointResult { Ok(resp) } -fn main() { - let mut app = App::with_state(Data::default()); +#[tokio::main] +async fn main() { + let mut app = App::with_state(State::default()); app.at("/graphql").post(handle_graphql); - app.serve("127.0.0.1:8000").unwrap(); + app.serve("127.0.0.1:8000").await.unwrap(); } diff --git a/examples/hello.rs b/examples/hello.rs index 030d394c1..f66088c8e 100644 --- a/examples/hello.rs +++ b/examples/hello.rs @@ -1,7 +1,6 @@ -#![feature(async_await)] - -fn main() { +#[tokio::main] +async fn main() { let mut app = tide::App::new(); - app.at("/").get(async move |_| "Hello, world!"); - app.serve("127.0.0.1:8000").unwrap(); + app.at("/").get(|_| async move { "Hello, world!" }); + app.serve("127.0.0.1:8000").await.unwrap(); } diff --git a/examples/hello_envlog.rs b/examples/hello_envlog.rs new file mode 100644 index 000000000..d85b2d6b4 --- /dev/null +++ b/examples/hello_envlog.rs @@ -0,0 +1,8 @@ +#[tokio::main] +async fn main() { + env_logger::from_env(env_logger::Env::default().default_filter_or("info")).init(); + let mut app = tide::App::new(); + app.middleware(tide::middleware::RequestLogger::new()); + app.at("/").get(|_| async move { "Hello, world!" }); + app.serve("127.0.0.1:8000").await.unwrap(); +} diff --git a/examples/hello_logrs.rs b/examples/hello_logrs.rs new file mode 100644 index 000000000..612bd3086 --- /dev/null +++ b/examples/hello_logrs.rs @@ -0,0 +1,18 @@ +#[tokio::main] +async fn main() { + use log::LevelFilter; + use log4rs::append::console::ConsoleAppender; + use log4rs::config::{Appender, Config, Root}; + + let stdout = ConsoleAppender::builder().build(); + let config = Config::builder() + .appender(Appender::builder().build("stdout", Box::new(stdout))) + .build(Root::builder().appender("stdout").build(LevelFilter::Info)) + .unwrap(); + let _handle = log4rs::init_config(config).unwrap(); + + let mut app = tide::App::new(); + app.middleware(tide::middleware::RequestLogger::new()); + app.at("/").get(|_| async move { "Hello, world!" }); + app.serve("127.0.0.1:8000").await.unwrap(); +} diff --git a/examples/messages.rs b/examples/messages.rs index 7b8ef25dd..9587b08c5 100644 --- a/examples/messages.rs +++ b/examples/messages.rs @@ -1,5 +1,3 @@ -#![feature(async_await)] - use http::status::StatusCode; use serde::{Deserialize, Serialize}; use std::sync::Mutex; @@ -39,13 +37,11 @@ impl Database { } } -#[allow(unused_mut)] // Workaround clippy bug async fn new_message(mut cx: Context) -> EndpointResult { let msg = cx.body_json().await.client_err()?; Ok(cx.state().insert(msg).to_string()) } -#[allow(unused_mut)] // Workaround clippy bug async fn set_message(mut cx: Context) -> EndpointResult<()> { let msg = cx.body_json().await.client_err()?; let id = cx.param("id").client_err()?; @@ -53,7 +49,7 @@ async fn set_message(mut cx: Context) -> EndpointResult<()> { if cx.state().set(id, msg) { Ok(()) } else { - Err(StatusCode::NOT_FOUND)? + Err(StatusCode::NOT_FOUND.into()) } } @@ -62,13 +58,14 @@ async fn get_message(cx: Context) -> EndpointResult { if let Some(msg) = cx.state().get(id) { Ok(response::json(msg)) } else { - Err(StatusCode::NOT_FOUND)? + Err(StatusCode::NOT_FOUND.into()) } } -fn main() { +#[tokio::main] +async fn main() { let mut app = App::with_state(Database::default()); app.at("/message").post(new_message); app.at("/message/:id").get(get_message).post(set_message); - app.serve("127.0.0.1:8000").unwrap(); + app.serve("127.0.0.1:8000").await.unwrap(); } diff --git a/examples/multipart-form/main.rs b/examples/multipart_form/mod.rs similarity index 91% rename from examples/multipart-form/main.rs rename to examples/multipart_form/mod.rs index 14ca63639..e9628e5c2 100644 --- a/examples/multipart-form/main.rs +++ b/examples/multipart_form/mod.rs @@ -1,8 +1,6 @@ -#![feature(async_await)] - use serde::{Deserialize, Serialize}; use std::io::Read; -use tide::{forms::ExtractForms, response, App, Context, EndpointResult}; +use tide::{forms::ContextExt, response, App, Context, EndpointResult}; #[derive(Serialize, Deserialize, Clone)] struct Message { @@ -11,7 +9,6 @@ struct Message { file: Option, } -#[allow(unused_mut)] // Workaround clippy bug async fn upload_file(mut cx: Context<()>) -> EndpointResult { // https://stackoverflow.com/questions/43424982/how-to-parse-multipart-forms-using-abonander-multipart-with-rocket let mut message = Message { @@ -57,10 +54,11 @@ async fn upload_file(mut cx: Context<()>) -> EndpointResult { Ok(response::json(message)) } -fn main() { +#[tokio::main] +pub async fn main() { let mut app = App::new(); app.at("/upload_file").post(upload_file); - app.serve("127.0.0.1:8000").unwrap(); + app.serve("127.0.0.1:8000").await.unwrap(); } // Test with: diff --git a/examples/multipart-form/test.txt b/examples/multipart_form/test.txt similarity index 100% rename from examples/multipart-form/test.txt rename to examples/multipart_form/test.txt diff --git a/examples/staticfile.rs b/examples/staticfile.rs index 462a1f4bd..c3cee9848 100644 --- a/examples/staticfile.rs +++ b/examples/staticfile.rs @@ -1,14 +1,12 @@ -#![feature(async_await)] - use bytes::Bytes; -use futures_fs::FsPool; -use futures_util::compat::*; +use futures::prelude::*; use http::{ header::{self, HeaderMap}, StatusCode, }; use http_service::Body; use tide::{App, Context, EndpointResult, Response}; +use tokio::codec::Decoder; use std::path::{Component, Path, PathBuf}; use std::{fs, io}; @@ -19,7 +17,6 @@ const DEFAULT_5XX_BODY: &[u8] = b"I'm broken, apparently." as &[_]; /// Simple static file handler for Tide inspired from https://github.com/iron/staticfile. #[derive(Clone)] struct StaticFile { - fs_pool: FsPool, root: PathBuf, } @@ -31,10 +28,7 @@ impl StaticFile { // warn maybe? } - StaticFile { - root, - fs_pool: FsPool::default(), - } + StaticFile { root } } fn stream_bytes(&self, actual_path: &str, headers: &HeaderMap) -> Result { @@ -44,7 +38,7 @@ impl StaticFile { // Check if the path exists and handle if it's a directory containing `index.html` if meta.is_some() && meta.as_ref().map(|m| !m.is_file()).unwrap_or(false) { // Redirect if path is a dir and URL doesn't end with "/" - if !actual_path.ends_with("/") { + if !actual_path.ends_with('/') { return Ok(response .status(StatusCode::MOVED_PERMANENTLY) .header(header::LOCATION, String::from(actual_path) + "/") @@ -68,7 +62,7 @@ impl StaticFile { } }; - let mime = mime_guess::guess_mime_type(path); + let mime = mime_guess::from_path(path).first_or_octet_stream(); let mime_str = mime.as_ref(); let size = meta.len(); @@ -78,9 +72,12 @@ impl StaticFile { .header(header::CONTENT_TYPE, mime_str) .header(header::CONTENT_LENGTH, size); - let stream = self.fs_pool.read(PathBuf::from(path), Default::default()); + let stream = tokio::fs::File::open(PathBuf::from(path)) + .map_ok(|file| tokio::codec::BytesCodec::new().framed(file)) + .try_flatten_stream() + .map_ok(From::from); Ok(response - .body(Body::from_stream(stream.compat())) + .body(Body::from_stream(stream)) .expect("invalid request?")) } @@ -121,8 +118,9 @@ async fn handle_path(ctx: Context) -> EndpointResult { }) } -fn main() { +#[tokio::main] +async fn main() { let mut app = App::with_state(StaticFile::new("./")); app.at("/*").get(handle_path); - app.serve("127.0.0.1:8000").unwrap(); + app.serve("127.0.0.1:8000").await.unwrap(); } diff --git a/examples/templates/tera-hello-world.html b/examples/templates/tera-hello-world.html new file mode 100644 index 000000000..0dc05a7a3 --- /dev/null +++ b/examples/templates/tera-hello-world.html @@ -0,0 +1,15 @@ + + + {{ page_title }} + + + +
    + {% for point in points %} +
  • + {{ point }} +
  • + {% endfor %} +
+ + diff --git a/examples/templating_tera.rs b/examples/templating_tera.rs new file mode 100644 index 000000000..10e296dd1 --- /dev/null +++ b/examples/templating_tera.rs @@ -0,0 +1,53 @@ +use tera::{self, compile_templates}; +use tide::{self, App, Context, EndpointResult, Error}; + +// AppState to pass with context and will hold +// the interface to the tera rendering engine +struct AppState { + template: tera::Tera, +} + +// Render some data into the 'tera-hello-world.html template in examples/templates directory +async fn index(ctx: Context) -> EndpointResult { + // Create the context for the template + let mut context = tera::Context::new(); + context.insert("page_title", "Hello from Tera templating!"); + context.insert("points", &vec!["point1", "point2"]); + + // Render the variables into the template + let s = ctx + .state() + .template + .render("tera-hello-world.html", &context) + .map_err(|err| { + // Map the tera::Error into a Tide error + let resp = http::Response::builder() + .status(500) + .body(err.description().into()) + .unwrap(); + Error::from(resp) + })?; + + // Build normal response, putting the rendered string into bytes -> Body + let resp = http::Response::builder() + .header(http::header::CONTENT_TYPE, mime::TEXT_HTML.as_ref()) + .status(http::StatusCode::OK) + .body(s.as_bytes().into()) + .expect("Failed to build response"); + Ok(resp) +} + +#[tokio::main] +async fn main() -> Result<(), std::io::Error> { + let template_dir = format!("{}/examples/templates/*", env!("CARGO_MANIFEST_DIR")); + + let state = AppState { + template: compile_templates!(&template_dir), + }; + + let mut app = App::with_state(state); + app.at("/").get(index); + app.serve("127.0.0.1:8000").await?; + + Ok(()) +} diff --git a/rfcs/001-app-new.md b/rfcs/001-app-new.md index f8cff3830..c19c24b48 100644 --- a/rfcs/001-app-new.md +++ b/rfcs/001-app-new.md @@ -48,19 +48,15 @@ renamed to `Context::state`. __no state__ ```rust -#![feature(async_await)] - fn main() -> Result<(), failure::Error> { let mut app = tide::App::new(); - app.at("/").get(async move |_| "Hello, world!"); + app.at("/").get(|_| async move { "Hello, world!" }); app.serve("127.0.0.1:8000")?; } ``` __with state__ ```rust -#![feature(async_await)] - #[derive(Default)] struct State { /* db connection goes here */ @@ -68,7 +64,7 @@ struct State { fn main() -> Result<(), failure::Error> { let mut app = tide::App::with_state(State::default()); - app.at("/").get(async move |_| "Hello, world!"); + app.at("/").get(|_| async move { "Hello, world!" }); app.serve("127.0.0.1:8000")?; } ``` diff --git a/src/app.rs b/src/app.rs index 7e4273036..436c50bb4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4,8 +4,8 @@ use std::sync::Arc; use crate::{ middleware::{Middleware, Next}, - router::{Router, Selection}, - Context, Route, + router::{Route, Router}, + Context, }; /// The entry point for building a Tide application. @@ -30,23 +30,22 @@ use crate::{ /// on `127.0.0.1:8000` with: /// /// ```rust, no_run -/// #![feature(async_await)] -/// -/// let mut app = tide::App::new(); -/// app.at("/hello").get(async move |_| "Hello, world!"); -/// app.serve("127.0.0.1:8000"); +/// #[tokio::main] +/// async fn main() { +/// let mut app = tide::App::new(); +/// app.at("/hello").get(|_| async move { "Hello, world!" }); +/// app.serve("127.0.0.1:8000").await.unwrap(); +/// } /// ``` /// /// # Routing and parameters /// /// Tide's routing system is simple and similar to many other frameworks. It -/// uses `:foo` for "wildcard" URL segments, and `:foo*` to match the rest of a +/// uses `:foo` for "wildcard" URL segments, and `*foo` to match the rest of a /// URL (which may include multiple segments). Here's an example using wildcard /// segments as parameters to endpoints: /// /// ```rust, no_run -/// #![feature(async_await)] -/// /// use tide::error::ResultExt; /// /// async fn hello(cx: tide::Context<()>) -> tide::EndpointResult { @@ -59,15 +58,18 @@ use crate::{ /// Ok(format!("Goodbye, {}.", user)) /// } /// -/// let mut app = tide::App::new(); +/// #[tokio::main] +/// async fn main() { +/// let mut app = tide::App::new(); /// -/// app.at("/hello/:user").get(hello); -/// app.at("/goodbye/:user").get(goodbye); -/// app.at("/").get(async move |_| { -/// "Use /hello/{your name} or /goodbye/{your name}" -/// }); +/// app.at("/hello/:user").get(hello); +/// app.at("/goodbye/:user").get(goodbye); +/// app.at("/").get(|_| async move { +/// "Use /hello/{your name} or /goodbye/{your name}" +/// }); /// -/// app.serve("127.0.0.1:8000"); +/// app.serve("127.0.0.1:8000").await.unwrap(); +/// } /// ``` /// /// You can learn more about routing in the [`App::at`] documentation. @@ -75,8 +77,6 @@ use crate::{ /// # Application state /// /// ```rust, no_run -/// #![feature(async_await)] -/// /// use http::status::StatusCode; /// use serde::{Deserialize, Serialize}; /// use std::sync::Mutex; @@ -119,11 +119,12 @@ use crate::{ /// } /// } /// -/// fn main() { +/// #[tokio::main] +/// async fn main() { /// let mut app = App::with_state(Database::default()); /// app.at("/message").post(new_message); /// app.at("/message/:id").get(get_message); -/// app.serve("127.0.0.1:8000").unwrap(); +/// app.serve("127.0.0.1:8000").await.unwrap(); /// } /// ``` @@ -131,7 +132,7 @@ use crate::{ pub struct App { router: Router, middleware: Vec>>, - data: State, + state: State, } impl App<()> { @@ -153,7 +154,7 @@ impl App { App { router: Router::new(), middleware: Vec::new(), - data: state, + state, } } @@ -166,9 +167,8 @@ impl App { /// respective endpoint of the selected resource. Example: /// /// ```rust,no_run - /// # #![feature(async_await)] /// # let mut app = tide::App::new(); - /// app.at("/").get(async move |_| "Hello, world!"); + /// app.at("/").get(|_| async move { "Hello, world!" }); /// ``` /// /// A path is comprised of zero or many segments, i.e. non-empty strings @@ -181,12 +181,13 @@ impl App { /// parameter called `name`. It is not possible to define wildcard segments /// with different names for otherwise identical paths. /// - /// Wildcard definitions can be followed by an optional *wildcard - /// modifier*. Currently, there is only one modifier: `*`, which means that - /// the wildcard will match to the end of given path, no matter how many - /// segments are left, even nothing. It is an error to define two wildcard - /// segments with different wildcard modifiers, or to write other path - /// segment after a segment with wildcard modifier. + /// Alternatively a wildcard definitions can start with a `*`, for example + /// `*path`, which means that the wildcard will match to the end of given + /// path, no matter how many segments are left, even nothing. + /// + /// The name of the parameter can be omitted to define a path that matches + /// the required structure, but where the parameters are not required. + /// `:` will match a segment, and `*` will match an entire path. /// /// Here are some examples omitting the HTTP verb based endpoint selection: /// @@ -195,7 +196,9 @@ impl App { /// app.at("/"); /// app.at("/hello"); /// app.at("add_two/:num"); - /// app.at("static/:path*"); + /// app.at("files/:user/*"); + /// app.at("static/*path"); + /// app.at("static/:context/:"); /// ``` /// /// There is no fallback route matching, i.e. either a resource is a full @@ -215,6 +218,8 @@ impl App { /// /// Middleware can only be added at the "top level" of an application, /// and is processed in the order in which it is applied. + /// + /// [`Middleware`]: crate::middleware::Middleware pub fn middleware(&mut self, m: impl Middleware) -> &mut Self { self.middleware.push(Arc::new(m)); self @@ -227,24 +232,22 @@ impl App { pub fn into_http_service(self) -> Server { Server { router: Arc::new(self.router), - data: Arc::new(self.data), + state: Arc::new(self.state), middleware: Arc::new(self.middleware), } } - /// Start serving the app at the given address. - /// - /// Blocks the calling thread indefinitely. + /// Asynchronously serve the app at the given address. #[cfg(feature = "hyper")] - pub fn serve(self, addr: impl std::net::ToSocketAddrs) -> std::io::Result<()> { + pub async fn serve(self, addr: impl std::net::ToSocketAddrs) -> std::io::Result<()> { let addr = addr .to_socket_addrs()? .next() .ok_or(std::io::ErrorKind::InvalidInput)?; - println!("Server is listening on: http://{}", addr); - http_service_hyper::run(self.into_http_service(), addr); - Ok(()) + http_service_hyper::serve(self.into_http_service(), addr) + .await + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)) } } @@ -252,11 +255,13 @@ impl App { /// /// This type is useful only in conjunction with the [`HttpService`] trait, /// i.e. for hosting a Tide app within some custom HTTP server. +/// +/// [`HttpService`]: http_service::HttpService #[derive(Clone)] #[allow(missing_debug_implementations)] pub struct Server { router: Arc>, - data: Arc, + state: Arc, middleware: Arc>>>, } @@ -274,23 +279,18 @@ impl HttpService for Server { let method = req.method().to_owned(); let router = self.router.clone(); let middleware = self.middleware.clone(); - let data = self.data.clone(); + let state = self.state.clone(); - box_async! { + Box::pin(async move { let fut = { - let Selection { endpoint, params } = router.route(&path, method); - let cx = Context::new(data, req, params); - - let next = Next { - endpoint, - next_middleware: &middleware, - }; - + let (endpoint, params) = router.route(&path, method).into_components(); + let cx = Context::new(state, req, params); + let next = Next::new(endpoint, &middleware); next.run(cx) }; Ok(fut.await) - } + }) } } @@ -300,35 +300,31 @@ mod tests { use std::sync::Arc; use super::*; - use crate::{middleware::Next, router::Selection, Context, Response}; + use crate::{middleware::Next, Context, Response}; - fn simulate_request<'a, Data: Default + Clone + Send + Sync + 'static>( - app: &'a App, + fn simulate_request<'a, State: Default + Clone + Send + Sync + 'static>( + app: &'a App, path: &'a str, method: http::Method, ) -> BoxFuture<'a, Response> { - let Selection { endpoint, params } = app.router.route(path, method.clone()); + let (endpoint, params) = app.router.route(path, method.clone()).into_components(); - let data = Arc::new(Data::default()); + let state = Arc::new(State::default()); let req = http::Request::builder() .method(method) .body(http_service::Body::empty()) .unwrap(); - let cx = Context::new(data, req, params); - let next = Next { - endpoint, - next_middleware: &app.middleware, - }; - + let cx = Context::new(state, req, params); + let next = Next::new(endpoint, &app.middleware); next.run(cx) } #[test] fn simple_static() { let mut router = App::new(); - router.at("/").get(async move |_| "/"); - router.at("/foo").get(async move |_| "/foo"); - router.at("/foo/bar").get(async move |_| "/foo/bar"); + router.at("/").get(|_| async move { "/" }); + router.at("/foo").get(|_| async move { "/foo" }); + router.at("/foo/bar").get(|_| async move { "/foo/bar" }); for path in &["/", "/foo", "/foo/bar"] { let res = block_on(simulate_request(&router, path, http::Method::GET)); @@ -340,23 +336,23 @@ mod tests { #[test] fn nested_static() { let mut router = App::new(); - router.at("/a").get(async move |_| "/a"); + router.at("/a").get(|_| async move { "/a" }); router.at("/b").nest(|router| { - router.at("/").get(async move |_| "/b"); - router.at("/a").get(async move |_| "/b/a"); - router.at("/b").get(async move |_| "/b/b"); + router.at("/").get(|_| async move { "/b" }); + router.at("/a").get(|_| async move { "/b/a" }); + router.at("/b").get(|_| async move { "/b/b" }); router.at("/c").nest(|router| { - router.at("/a").get(async move |_| "/b/c/a"); - router.at("/b").get(async move |_| "/b/c/b"); + router.at("/a").get(|_| async move { "/b/c/a" }); + router.at("/b").get(|_| async move { "/b/c/b" }); }); - router.at("/d").get(async move |_| "/b/d"); + router.at("/d").get(|_| async move { "/b/d" }); }); router.at("/a/a").nest(|router| { - router.at("/a").get(async move |_| "/a/a/a"); - router.at("/b").get(async move |_| "/a/a/b"); + router.at("/a").get(|_| async move { "/a/a/a" }); + router.at("/b").get(|_| async move { "/a/a/b" }); }); router.at("/a/b").nest(|router| { - router.at("/").get(async move |_| "/a/b"); + router.at("/").get(|_| async move { "/a/b" }); }); for failing_path in &["/", "/a/a", "/a/b/a"] { @@ -382,9 +378,9 @@ mod tests { fn multiple_methods() { let mut router = App::new(); router.at("/a").nest(|router| { - router.at("/b").get(async move |_| "/a/b GET"); + router.at("/b").get(|_| async move { "/a/b GET" }); }); - router.at("/a/b").post(async move |_| "/a/b POST"); + router.at("/a/b").post(|_| async move { "/a/b POST" }); for (path, method) in &[("/a/b", http::Method::GET), ("/a/b", http::Method::POST)] { let res = block_on(simulate_request(&router, path, method.clone())); diff --git a/src/error.rs b/src/error.rs deleted file mode 100644 index 174c6987a..000000000 --- a/src/error.rs +++ /dev/null @@ -1,105 +0,0 @@ -use core::pin::Pin; -use futures::future::Future; -use http::{HttpTryFrom, Response, StatusCode}; -use http_service::Body; - -use crate::response::IntoResponse; - -pub(crate) type BoxTryFuture = Pin> + Send + 'static>>; - -/// A convenient `Result` instantiation appropriate for most endpoints. -pub type EndpointResult> = Result; - -#[derive(Debug)] -pub struct StringError(pub String); -impl std::error::Error for StringError {} - -impl std::fmt::Display for StringError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::result::Result<(), std::fmt::Error> { - self.0.fmt(f) - } -} - -macro_rules! err_fmt { - {$($t:tt)*} => { - crate::error::StringError(format!($($t)*)) - } -} - -/// A generic endpoint error, which can be converted into a response. -#[derive(Debug)] -pub struct Error { - resp: Response, -} - -impl IntoResponse for Error { - fn into_response(self) -> Response { - self.resp - } -} - -struct Cause(Box); - -impl From> for Error { - fn from(resp: Response) -> Error { - Error { resp } - } -} - -impl From for Error { - fn from(status: StatusCode) -> Error { - let resp = Response::builder() - .status(status) - .body(Body::empty()) - .unwrap(); - Error { resp } - } -} - -/// Extends the `Result` type with convenient methods for constructing Tide errors. -pub trait ResultExt: Sized { - /// Convert to an `EndpointResult`, treating the `Err` case as a client - /// error (response code 400). - fn client_err(self) -> EndpointResult { - self.with_err_status(400) - } - - /// Convert to an `EndpointResult`, treating the `Err` case as a server - /// error (response code 500). - fn server_err(self) -> EndpointResult { - self.with_err_status(500) - } - - /// Convert to an `EndpointResult`, wrapping the `Err` case with a custom - /// response status. - fn with_err_status(self, status: S) -> EndpointResult - where - StatusCode: HttpTryFrom; -} - -/// Extends the `Response` type with a method to extract error causes when applicable. -pub trait ResponseExt { - /// Extract the cause of the unsuccessful response, if any - fn err_cause(&self) -> Option<&(dyn std::error::Error + Send + Sync + 'static)>; -} - -impl ResponseExt for Response { - fn err_cause(&self) -> Option<&(dyn std::error::Error + Send + Sync + 'static)> { - self.extensions().get().map(|Cause(c)| &**c) - } -} - -impl ResultExt for std::result::Result { - fn with_err_status(self, status: S) -> EndpointResult - where - StatusCode: HttpTryFrom, - { - self.map_err(|e| Error { - resp: Response::builder() - .status(status) - .extension(Cause(Box::new(e))) - .body(Body::empty()) - .unwrap(), - }) - } -} diff --git a/src/lib.rs b/src/lib.rs index ac931431d..1a96ffb37 100755 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,54 +1,54 @@ -#![cfg_attr(feature = "nightly", deny(missing_docs))] -#![cfg_attr(feature = "nightly", feature(external_doc))] -#![cfg_attr(feature = "nightly", doc(include = "../README.md"))] -#![cfg_attr(test, deny(warnings))] -#![feature(async_await, existential_type)] -#![allow(unused_variables)] -#![deny( - nonstandard_style, - rust_2018_idioms, - future_incompatible, - missing_debug_implementations -)] -// TODO: Remove this after clippy bug due to async await is resolved. -// ISSUE: https://github.com/rust-lang/rust-clippy/issues/3988 -#![allow(clippy::needless_lifetimes)] - -//! //! Welcome to Tide. //! //! The [`App`](struct.App.html) docs are a good place to get started. -//! -//! -macro_rules! box_async { - {$($t:tt)*} => { - ::futures::future::FutureExt::boxed(async move { $($t)* }) - }; -} +#![warn( + nonstandard_style, + rust_2018_idioms, + future_incompatible, + missing_debug_implementations, + missing_docs +)] -#[macro_use] -pub mod error; +pub use http; mod app; -mod context; -pub mod cookies; -mod endpoint; -pub mod forms; -pub mod middleware; -pub mod querystring; -pub mod response; -mod route; mod router; +pub use app::{App, Server}; + +#[cfg(feature = "cookies")] #[doc(inline)] -pub use crate::{ - app::{App, Server}, - context::Context, - endpoint::Endpoint, - error::{EndpointResult, Error}, - response::Response, - route::Route, -}; +pub use tide_cookies as cookies; -pub use http; +#[cfg(feature = "cors")] +#[doc(inline)] +pub use tide_cors as cors; + +#[doc(inline)] +pub use tide_core::{response, Body, Context, Endpoint, EndpointResult, Error, Response}; + +pub mod error { + //! Error types re-exported from `tide-core` + pub use tide_core::error::{Error, ResponseExt, ResultDynErrExt, ResultExt, StringError}; +} + +pub use tide_forms as forms; +pub use tide_querystring as querystring; + +pub mod middleware { + //! Module to export tide_core middleware + + // Core + pub use tide_core::middleware::{Middleware, Next}; + + // Exports from tide repo. + pub use tide_headers::DefaultHeaders; + pub use tide_log::RequestLogger; + + #[cfg(feature = "cors")] + pub use tide_cors::{CorsMiddleware, CorsOrigin}; + + #[cfg(feature = "cookies")] + pub use tide_cookies::CookiesMiddleware; +} diff --git a/src/middleware/logger.rs b/src/middleware/logger.rs deleted file mode 100644 index 3becf7928..000000000 --- a/src/middleware/logger.rs +++ /dev/null @@ -1,50 +0,0 @@ -use slog::{info, o, Drain}; -use slog_async; -use slog_term; - -use futures::future::BoxFuture; - -use crate::{ - middleware::{Middleware, Next}, - Context, Response, -}; - -/// Root logger for Tide. Wraps over logger provided by slog.SimpleLogger -#[derive(Debug)] -pub struct RootLogger { - // drain: dyn slog::Drain, - inner_logger: slog::Logger, -} - -impl RootLogger { - pub fn new() -> RootLogger { - let decorator = slog_term::TermDecorator::new().build(); - let drain = slog_term::CompactFormat::new(decorator).build().fuse(); - let drain = slog_async::Async::new(drain).build().fuse(); - - let log = slog::Logger::root(drain, o!()); - RootLogger { inner_logger: log } - } -} - -impl Default for RootLogger { - fn default() -> Self { - Self::new() - } -} - -/// Stores information during request phase and logs information once the response -/// is generated. -impl Middleware for RootLogger { - fn handle<'a>(&'a self, cx: Context, next: Next<'a, Data>) -> BoxFuture<'a, Response> { - box_async! { - let path = cx.uri().path().to_owned(); - let method = cx.method().as_str().to_owned(); - - let res = next.run(cx).await; - let status = res.status(); - info!(self.inner_logger, "{} {} {}", method, path, status.as_str()); - res - } - } -} diff --git a/src/router.rs b/src/router/core.rs similarity index 52% rename from src/router.rs rename to src/router/core.rs index 34710110d..a4814ce3b 100644 --- a/src/router.rs +++ b/src/router/core.rs @@ -1,31 +1,32 @@ +//! Router core types + use fnv::FnvHashMap; -use futures::future::{BoxFuture, FutureExt}; +use futures::future::BoxFuture; +use futures::prelude::*; use http_service::Body; use route_recognizer::{Match, Params, Router as MethodRouter}; -use crate::{ - endpoint::{DynEndpoint, Endpoint}, - Context, Response, -}; +use tide_core::{internal::DynEndpoint, Context, Endpoint, Response}; /// The routing table used by `App` /// /// Internally, we have a separate state machine per http method; indexing /// by the method first allows the table itself to be more efficient. #[allow(missing_debug_implementations)] -pub(crate) struct Router { +#[derive(Default)] +pub struct Router { method_map: FnvHashMap>>>, } -/// The result of routing a URL -pub(crate) struct Selection<'a, State> { - pub(crate) endpoint: &'a DynEndpoint, - pub(crate) params: Params, +#[allow(missing_debug_implementations)] +pub struct Selection<'a, State> { + endpoint: &'a DynEndpoint, + params: Params, } impl Router { - pub(crate) fn new() -> Router { - Router { + pub(crate) fn new() -> Self { + Self { method_map: FnvHashMap::default(), } } @@ -37,32 +38,41 @@ impl Router { .add(path, Box::new(move |cx| ep.call(cx).boxed())) } - pub(crate) fn route(&self, path: &str, method: http::Method) -> Selection<'_, State> { + pub fn route(&self, path: &str, method: http::Method) -> Selection<'_, State> { if let Some(Match { handler, params }) = self .method_map .get(&method) .and_then(|r| r.recognize(path).ok()) { - Selection { - endpoint: &**handler, - params, - } + Selection::new(&**handler, params) } else if method == http::Method::HEAD { // If it is a HTTP HEAD request then check if there is a callback in the endpoints map // if not then fallback to the behavior of HTTP GET else proceed as usual self.route(path, http::Method::GET) } else { - Selection { - endpoint: ¬_found_endpoint, - params: Params::new(), - } + Selection::new(¬_found_endpoint, Params::new()) } } } -fn not_found_endpoint(_cx: Context) -> BoxFuture<'static, Response> { - box_async! { - http::Response::builder().status(http::StatusCode::NOT_FOUND).body(Body::empty()).unwrap() +impl<'a, State> Selection<'a, State> { + /// Create a new Selection + pub(crate) fn new(endpoint: &'a DynEndpoint, params: Params) -> Self { + Self { endpoint, params } + } + + /// Break Selection into it's components + pub fn into_components(self) -> (&'a DynEndpoint, Params) { + (self.endpoint, self.params) } } + +fn not_found_endpoint(_cx: Context) -> BoxFuture<'static, Response> { + Box::pin(async move { + http::Response::builder() + .status(http::StatusCode::NOT_FOUND) + .body(Body::empty()) + .unwrap() + }) +} diff --git a/src/router/mod.rs b/src/router/mod.rs new file mode 100644 index 000000000..380bc62cd --- /dev/null +++ b/src/router/mod.rs @@ -0,0 +1,5 @@ +mod core; +mod route; + +pub use self::core::{Router, Selection}; +pub use route::Route; diff --git a/src/route.rs b/src/router/route.rs similarity index 93% rename from src/route.rs rename to src/router/route.rs index 0406e793a..5862dc608 100644 --- a/src/route.rs +++ b/src/router/route.rs @@ -1,4 +1,6 @@ -use crate::{router::Router, Endpoint}; +use tide_core::Endpoint; + +use super::core::Router; /// A handle to a route. /// @@ -15,8 +17,8 @@ pub struct Route<'a, State> { } impl<'a, State: 'static> Route<'a, State> { - pub(crate) fn new(router: &'a mut Router, path: String) -> Route<'a, State> { - Route { router, path } + pub fn new(router: &'a mut Router, path: String) -> Self { + Self { router, path } } /// Extend the route with the given `path`. @@ -37,6 +39,7 @@ impl<'a, State: 'static> Route<'a, State> { } } + /// Add endpoint nested routes pub fn nest(&mut self, f: impl FnOnce(&mut Route<'a, State>)) -> &mut Self { f(self); self diff --git a/tests/head_response_empty_body.rs b/tests/head_response_empty_body.rs new file mode 100644 index 000000000..4db3ddd31 --- /dev/null +++ b/tests/head_response_empty_body.rs @@ -0,0 +1,21 @@ +use futures::executor::block_on; +use http_service::Body; +use http_service_mock::make_server; +use tide::Context; + +async fn ok(_cx: Context<()>) -> String { + String::from("this shouldn't exist in the body of a HEAD response") +} + +#[test] +fn head_response_empty() { + let mut app = tide::App::new(); + app.at("/").get(ok); + let mut server = make_server(app.into_http_service()).unwrap(); + + let req = http::Request::head("/").body(Body::empty()).unwrap(); + let res = server.simulate(req).unwrap(); + let body = block_on(res.into_body().into_vec()).unwrap(); + dbg!(&String::from_utf8(body)); + //assert!(body.is_empty()); +} diff --git a/tests/wildcard.rs b/tests/wildcard.rs index 6d21ccf46..be5119d4d 100644 --- a/tests/wildcard.rs +++ b/tests/wildcard.rs @@ -1,5 +1,3 @@ -#![feature(async_await)] - use futures::executor::block_on; use http_service::Body; use http_service_mock::make_server; @@ -10,6 +8,22 @@ async fn add_one(cx: Context<()>) -> Result { Ok((num + 1).to_string()) } +async fn add_two(cx: Context<()>) -> Result { + let one: i64 = cx.param("one").client_err()?; + let two: i64 = cx.param("two").client_err()?; + Ok((one + two).to_string()) +} + +async fn echo_path(cx: Context<()>) -> Result { + let path: String = cx.param("path").client_err()?; + Ok(path) +} + +async fn echo_empty(cx: Context<()>) -> Result { + let path: String = cx.param("").client_err()?; + Ok(path) +} + #[test] fn wildcard() { let mut app = tide::App::new(); @@ -56,3 +70,156 @@ fn not_found_error() { let res = server.simulate(req).unwrap(); assert_eq!(res.status(), 404); } + +#[test] +fn wildpath() { + let mut app = tide::App::new(); + app.at("/echo/*path").get(echo_path); + let mut server = make_server(app.into_http_service()).unwrap(); + + let req = http::Request::get("/echo/some_path") + .body(Body::empty()) + .unwrap(); + let res = server.simulate(req).unwrap(); + assert_eq!(res.status(), 200); + let body = block_on(res.into_body().into_vec()).unwrap(); + assert_eq!(&*body, &*b"some_path"); + + let req = http::Request::get("/echo/multi/segment/path") + .body(Body::empty()) + .unwrap(); + let res = server.simulate(req).unwrap(); + assert_eq!(res.status(), 200); + let body = block_on(res.into_body().into_vec()).unwrap(); + assert_eq!(&*body, &*b"multi/segment/path"); + + let req = http::Request::get("/echo/").body(Body::empty()).unwrap(); + let res = server.simulate(req).unwrap(); + assert_eq!(res.status(), 404); + let body = block_on(res.into_body().into_vec()).unwrap(); + assert_eq!(&*body, &*b""); +} + +#[test] +fn multi_wildcard() { + let mut app = tide::App::new(); + app.at("/add_two/:one/:two/").get(add_two); + let mut server = make_server(app.into_http_service()).unwrap(); + + let req = http::Request::get("/add_two/1/2/") + .body(Body::empty()) + .unwrap(); + let res = server.simulate(req).unwrap(); + assert_eq!(res.status(), 200); + let body = block_on(res.into_body().into_vec()).unwrap(); + assert_eq!(&*body, &*b"3"); + + let req = http::Request::get("/add_two/-1/2/") + .body(Body::empty()) + .unwrap(); + let res = server.simulate(req).unwrap(); + assert_eq!(res.status(), 200); + let body = block_on(res.into_body().into_vec()).unwrap(); + assert_eq!(&*body, &*b"1"); + let req = http::Request::get("/add_two/1") + .body(Body::empty()) + .unwrap(); + let res = server.simulate(req).unwrap(); + assert_eq!(res.status(), 404); +} + +#[test] +fn wild_last_segment() { + let mut app = tide::App::new(); + app.at("/echo/:path/*").get(echo_path); + let mut server = make_server(app.into_http_service()).unwrap(); + + let req = http::Request::get("/echo/one/two") + .body(Body::empty()) + .unwrap(); + let res = server.simulate(req).unwrap(); + assert_eq!(res.status(), 200); + let body = block_on(res.into_body().into_vec()).unwrap(); + assert_eq!(&*body, &*b"one"); + + let req = http::Request::get("/echo/one/two/three/four") + .body(Body::empty()) + .unwrap(); + let res = server.simulate(req).unwrap(); + assert_eq!(res.status(), 200); + let body = block_on(res.into_body().into_vec()).unwrap(); + assert_eq!(&*body, &*b"one"); +} + +#[test] +fn invalid_wildcard() { + let mut app = tide::App::new(); + app.at("/echo/*path/:one/").get(echo_path); + let mut server = make_server(app.into_http_service()).unwrap(); + + let req = http::Request::get("/echo/one/two") + .body(Body::empty()) + .unwrap(); + let res = server.simulate(req).unwrap(); + assert_eq!(res.status(), 404); +} + +#[test] +fn nameless_wildcard() { + let mut app = tide::App::new(); + app.at("/echo/:").get(|_| async move { "" }); + + let mut server = make_server(app.into_http_service()).unwrap(); + + let req = http::Request::get("/echo/one/two") + .body(Body::empty()) + .unwrap(); + let res = server.simulate(req).unwrap(); + assert_eq!(res.status(), 404); + + let req = http::Request::get("/echo/one").body(Body::empty()).unwrap(); + let res = server.simulate(req).unwrap(); + assert_eq!(res.status(), 200); +} + +#[test] +fn nameless_internal_wildcard() { + let mut app = tide::App::new(); + app.at("/echo/:/:path").get(echo_path); + let mut server = make_server(app.into_http_service()).unwrap(); + + let req = http::Request::get("/echo/one").body(Body::empty()).unwrap(); + let res = server.simulate(req).unwrap(); + assert_eq!(res.status(), 404); + + let req = http::Request::get("/echo/one/two") + .body(Body::empty()) + .unwrap(); + let res = server.simulate(req).unwrap(); + assert_eq!(res.status(), 200); + let body = block_on(res.into_body().into_vec()).unwrap(); + assert_eq!(&*body, &*b"two"); + + let req = http::Request::get("/echo/one/two") + .body(Body::empty()) + .unwrap(); + let res = server.simulate(req).unwrap(); + assert_eq!(res.status(), 200); + let body = block_on(res.into_body().into_vec()).unwrap(); + assert_eq!(&*body, &*b"two"); +} + +#[test] +fn nameless_internal_wildcard2() { + let mut app = tide::App::new(); + app.at("/echo/:/:path").get(echo_empty); + let mut server = make_server(app.into_http_service()).unwrap(); + + let req = http::Request::get("/echo/one/two") + .body(Body::empty()) + .unwrap(); + let res = server.simulate(req).unwrap(); + assert_eq!(res.status(), 200); + let body = block_on(res.into_body().into_vec()).unwrap(); + assert_eq!(&*body, &*b"one"); +} diff --git a/tide-compression/Cargo.toml b/tide-compression/Cargo.toml new file mode 100644 index 000000000..e1fd0d1b7 --- /dev/null +++ b/tide-compression/Cargo.toml @@ -0,0 +1,32 @@ +[package] +authors = [ + "Tide Developers", +] +description = "Compression-related middleware for Tide" +documentation = "https://docs.rs/tide-compression" +keywords = ["tide", "web", "async", "middleware", "compression"] +categories = ["network-programming", "compression", "asynchronous"] +edition = "2018" +license = "MIT OR Apache-2.0" +name = "tide-compression" +readme = "README.md" +repository = "https://github.com/rustasync/tide" +version = "0.1.0" + +[dependencies] +tide = { path = "../", default-features = false } +accept-encoding = "0.2.0-alpha.2" +bytes = "0.4.12" +futures-preview = "0.3.0-alpha.18" +http = "0.1" +http-service = { git = "https://github.com/rustasync/http-service", branch = "tokio-0.2" } + +[dependencies.async-compression] +default-features = false +features = ["stream", "gzip", "zlib", "brotli", "zstd"] +version = "0.1.0-alpha.3" + +[dev-dependencies] +tide = { path = "../", default-features = false } +tokio = "0.2.0-alpha.2" +http-service-mock = { git = "https://github.com/rustasync/http-service", branch = "tokio-0.2" } diff --git a/tide-compression/README.md b/tide-compression/README.md new file mode 100644 index 000000000..73c7370fd --- /dev/null +++ b/tide-compression/README.md @@ -0,0 +1,18 @@ +# tide-compression + +This crate provides compression-related middleware for Tide. + +## Examples + +Examples are in the `/examples` folder of this crate. + +__Simple Example__ + +You can test the simple example by running `cargo run --example simple` while in this crate's directory, and then running any variety of the following commands: + +```console +$ curl -v http://127.0.0.1:8000/ +$ curl -v -H 'Accept-Encoding: br' http://127.0.0.1:8000/ +$ echo 'hello there' | gzip | curl -v --compressed -H 'Content-Encoding: gzip' http://127.0.0.1:8000/echo --data-binary @- +$ echo 'general kenobi' | brotli | curl -v --compressed -H 'Content-Encoding: br' http://127.0.0.1:8000/echo --data-binary @- +``` diff --git a/tide-compression/examples/simple.rs b/tide-compression/examples/simple.rs new file mode 100644 index 000000000..f586cac7c --- /dev/null +++ b/tide-compression/examples/simple.rs @@ -0,0 +1,22 @@ +use tide::{App, Context}; +use tide_compression::{Compression, Decompression, Encoding}; + +// Returns a portion of the lorem ipsum text. +async fn lorem_ipsum(_cx: Context<()>) -> String { + String::from("Lorem ipsum dolor sit amet, consectetur adipiscing elit.") +} + +// Echoes the request body in bytes. +async fn echo_bytes(mut cx: Context<()>) -> Vec { + cx.body_bytes().await.unwrap() +} + +#[tokio::main] +async fn main() { + let mut app = App::new(); + app.at("/").get(lorem_ipsum); + app.at("/echo").post(echo_bytes); + app.middleware(Compression::with_default(Encoding::Brotli)); + app.middleware(Decompression::new()); + app.serve("127.0.0.1:8000").await.unwrap(); +} diff --git a/tide-compression/src/lib.rs b/tide-compression/src/lib.rs new file mode 100644 index 000000000..f1685b53b --- /dev/null +++ b/tide-compression/src/lib.rs @@ -0,0 +1,414 @@ +//! Crate that provides helpers and/or middlewares for Tide +//! related to compression. + +#![warn( + nonstandard_style, + rust_2018_idioms, + future_incompatible, + missing_debug_implementations, + missing_docs +)] + +pub use accept_encoding::Encoding; +use async_compression::stream; +use futures::future::BoxFuture; +use http::{header::CONTENT_ENCODING, status::StatusCode, HeaderMap}; +use http_service::{Body, Request}; +use tide::{ + middleware::{Middleware, Next}, + response::IntoResponse, + Context, Error, Response, +}; + +/// Encode settings for the compression middleware. +/// +/// This can be modified in the case that you want more control over the speed or quality of compression. +/// +/// For more information on how to configure each of these settings, see the async-compression crate. +#[derive(Debug)] +pub struct EncodeSettings { + /// Settings for gzip compression. + pub gzip: async_compression::flate2::Compression, + /// Settings for deflate compression. + pub deflate: async_compression::flate2::Compression, + /// Settings for brotli compression. Ranges from 0-11. (default: `11`) + pub brotli: u32, + /// Settings for zstd compression. Ranges from 1-21. (default: `3`) + pub zstd: i32, +} + +impl Default for EncodeSettings { + fn default() -> Self { + Self { + gzip: Default::default(), + deflate: Default::default(), + brotli: 11, + zstd: 3, + } + } +} + +/// Middleware for automatically handling outgoing response compression. +/// +/// This middleware currently supports HTTP compression using `gzip`, `deflate`, `br`, and `zstd`. +#[derive(Debug)] +pub struct Compression { + default_encoding: Encoding, + settings: EncodeSettings, +} + +impl Default for Compression { + fn default() -> Self { + Self::new() + } +} + +impl Compression { + /// Creates a new Compression middleware. The default encoding is [`Encoding::Identity`] (no encoding). + pub fn new() -> Self { + Self { + default_encoding: Encoding::Identity, + settings: Default::default(), + } + } + + /// Creates a new Compression middleware with a provided default encoding. + /// + /// This encoding will be selected if the client has not set the `Accept-Encoding` header or `*` is set as the most preferred encoding. + pub fn with_default(default_encoding: Encoding) -> Self { + Self { + default_encoding, + settings: Default::default(), + } + } + + /// Accesses a mutable handle to this middleware's [`EncodeSettings`]. + /// + /// This will allow you to configure this middleware's settings. + pub fn settings_mut(&mut self) -> &mut EncodeSettings { + &mut self.settings + } + + fn preferred_encoding(&self, headers: &HeaderMap) -> Result { + let encoding = match accept_encoding::parse(headers) { + Ok(encoding) => encoding, + Err(_) => return Err(Error::from(StatusCode::BAD_REQUEST)), + }; + Ok(encoding.unwrap_or(self.default_encoding)) + } + + /// Consumes the response and returns an encoded version of it. + fn encode(&self, mut res: Response, encoding: Encoding) -> Response { + if res.headers().get(CONTENT_ENCODING).is_some() || encoding == Encoding::Identity { + return res; // avoid double-encoding a given response + } + let body = std::mem::replace(res.body_mut(), Body::empty()); + match encoding { + Encoding::Gzip => { + let stream = stream::GzipEncoder::new(body, self.settings.gzip); + *res.body_mut() = Body::from_stream(stream); + } + Encoding::Deflate => { + let stream = stream::ZlibEncoder::new(body, self.settings.deflate); + *res.body_mut() = Body::from_stream(stream); + } + Encoding::Brotli => { + let stream = stream::BrotliEncoder::new(body, self.settings.brotli); + *res.body_mut() = Body::from_stream(stream); + } + Encoding::Zstd => { + let stream = stream::ZstdEncoder::new(body, self.settings.zstd); + *res.body_mut() = Body::from_stream(stream); + } + Encoding::Identity => unreachable!(), + }; + res.headers_mut() + .append(CONTENT_ENCODING, encoding.to_header_value()); + res + } +} + +impl Middleware for Compression { + fn handle<'a>(&'a self, cx: Context, next: Next<'a, State>) -> BoxFuture<'a, Response> { + Box::pin(async move { + let encoding = match self.preferred_encoding(cx.headers()) { + Ok(encoding) => encoding, + Err(e) => return e.into_response(), + }; + let res = next.run(cx).await; + self.encode(res, encoding) + }) + } +} + +/// Middleware for handling incoming request decompression. +/// +/// This middleware currently supports HTTP decompression under the `gzip`, `deflate`, `br`, and `zstd` algorithms. +#[derive(Debug, Default)] +pub struct Decompression {} + +impl Decompression { + /// Creates a new Decompression middleware. + pub fn new() -> Self { + Self {} + } + + fn parse_encoding(s: &str) -> Result { + match s { + "gzip" => Ok(Encoding::Gzip), + "deflate" => Ok(Encoding::Deflate), + "br" => Ok(Encoding::Brotli), + "zstd" => Ok(Encoding::Zstd), + "identity" => Ok(Encoding::Identity), + _ => Err(Error::from(StatusCode::UNSUPPORTED_MEDIA_TYPE)), + } + } + + fn decode(&self, req: &mut Request) -> Result<(), Error> { + let encodings = if let Some(hval) = req.headers().get(CONTENT_ENCODING) { + let hval = match hval.to_str() { + Ok(hval) => hval, + Err(_) => return Err(Error::from(StatusCode::BAD_REQUEST)), + }; + hval.split(',') + .map(str::trim) + .rev() // apply decodings in reverse order + .map(Decompression::parse_encoding) + .collect::, Error>>()? + } else { + return Ok(()); + }; + + for encoding in encodings { + match encoding { + Encoding::Gzip => { + let body = std::mem::replace(req.body_mut(), Body::empty()); + let stream = stream::GzipDecoder::new(body); + *req.body_mut() = Body::from_stream(stream); + } + Encoding::Deflate => { + let body = std::mem::replace(req.body_mut(), Body::empty()); + let stream = stream::ZlibDecoder::new(body); + *req.body_mut() = Body::from_stream(stream); + } + Encoding::Brotli => { + let body = std::mem::replace(req.body_mut(), Body::empty()); + let stream = stream::BrotliDecoder::new(body); + *req.body_mut() = Body::from_stream(stream); + } + Encoding::Zstd => { + let body = std::mem::replace(req.body_mut(), Body::empty()); + let stream = stream::ZstdDecoder::new(body); + *req.body_mut() = Body::from_stream(stream); + } + Encoding::Identity => (), + } + } + + // strip the content-encoding header + req.headers_mut().remove(CONTENT_ENCODING).unwrap(); + + Ok(()) + } +} + +impl Middleware for Decompression { + fn handle<'a>( + &'a self, + mut cx: Context, + next: Next<'a, State>, + ) -> BoxFuture<'a, Response> { + Box::pin(async move { + match self.decode(cx.request_mut()) { + Ok(_) => (), + Err(e) => return e.into_response(), + }; + next.run(cx).await + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use async_compression::flate2; + use bytes::Bytes; + use futures::{ + executor::{block_on, block_on_stream}, + stream::StreamExt, + }; + use http::header::ACCEPT_ENCODING; + use http_service::Body; + use http_service_mock::make_server; + + async fn lorem_ipsum(_cx: Context<()>) -> String { + String::from(r#" + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam rutrum et risus sed egestas. Maecenas dapibus enim a posuere + semper. Cras venenatis et turpis quis aliquam. Suspendisse eget risus in libero tristique consectetur. Ut ut risus cursus, scelerisque + enim ac, tempus tellus. Vestibulum ac porta felis. Aenean fringilla posuere felis, in blandit enim tristique ut. Sed elementum iaculis + enim eu commodo. + "#) + } + + fn lorem_ipsum_bytes() -> Vec { + String::from(r#" + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam rutrum et risus sed egestas. Maecenas dapibus enim a posuere + semper. Cras venenatis et turpis quis aliquam. Suspendisse eget risus in libero tristique consectetur. Ut ut risus cursus, scelerisque + enim ac, tempus tellus. Vestibulum ac porta felis. Aenean fringilla posuere felis, in blandit enim tristique ut. Sed elementum iaculis + enim eu commodo. + "#).into_bytes() + } + + // Echoes the request body in bytes. + async fn echo_bytes(mut cx: Context<()>) -> Vec { + cx.body_bytes().await.unwrap() + } + + // Generates the app. + fn app() -> tide::App<()> { + let mut app = tide::App::new(); + app.at("/").get(lorem_ipsum); + app.at("/echo").post(echo_bytes); + app.middleware(Compression::new()); + app.middleware(Decompression::new()); + app + } + + // Generates a response given a string that represents the Accept-Encoding header value. + fn get_encoded_response(hval: &str) -> Response { + let app = app(); + let mut server = make_server(app.into_http_service()).unwrap(); + let req = http::Request::get("/") + .header(ACCEPT_ENCODING, hval) + .body(Body::empty()) + .unwrap(); + server.simulate(req).unwrap() + } + + // Generates a decoded response given a request body and the header value representing its encoding. + fn get_decoded_response(body: Body, hval: &str) -> Response { + let app = app(); + let mut server = make_server(app.into_http_service()).unwrap(); + let req = http::Request::post("/echo") + .header(CONTENT_ENCODING, hval) + .body(body) + .unwrap(); + server.simulate(req).unwrap() + } + + #[test] + fn compressed_gzip_response() { + let res = get_encoded_response("gzip"); + assert_eq!(res.status(), 200); + let body = res.into_body(); + let stream = stream::GzipDecoder::new(body); + let decompressed_body: Vec = block_on_stream(stream) + .map(Result::unwrap) + .flatten() + .collect(); + let lorem_ipsum = lorem_ipsum_bytes(); + assert_eq!(decompressed_body, lorem_ipsum); + } + + #[test] + fn compressed_deflate_response() { + let res = get_encoded_response("deflate"); + assert_eq!(res.status(), 200); + let body = res.into_body(); + let stream = stream::ZlibDecoder::new(body); + let decompressed_body: Vec = block_on_stream(stream) + .map(Result::unwrap) + .flatten() + .collect(); + let lorem_ipsum = lorem_ipsum_bytes(); + assert_eq!(decompressed_body, lorem_ipsum); + } + + #[test] + fn compressed_brotli_response() { + let res = get_encoded_response("br"); + assert_eq!(res.status(), 200); + let body = res.into_body(); + let stream = stream::BrotliDecoder::new(body); + let decompressed_body: Vec = block_on_stream(stream) + .map(Result::unwrap) + .flatten() + .collect(); + let lorem_ipsum = lorem_ipsum_bytes(); + assert_eq!(decompressed_body, lorem_ipsum); + } + + #[test] + fn compressed_zstd_response() { + let res = get_encoded_response("zstd"); + assert_eq!(res.status(), 200); + let body = res.into_body(); + let stream = stream::ZstdDecoder::new(body); + let decompressed_body: Vec = block_on_stream(stream) + .map(Result::unwrap) + .flatten() + .collect(); + let lorem_ipsum = lorem_ipsum_bytes(); + assert_eq!(decompressed_body, lorem_ipsum); + } + + #[test] + fn decompressed_gzip_response() { + let lorem_ipsum = lorem_ipsum_bytes(); + let req_body = Body::from_stream(stream::GzipEncoder::new( + futures::stream::iter(vec![lorem_ipsum]) + .map(Bytes::from) + .map(Ok), + flate2::Compression::default(), + )); + let res = get_decoded_response(req_body, "gzip"); + let body = block_on(res.into_body().into_vec()).unwrap(); + let lorem_ipsum = lorem_ipsum_bytes(); + assert_eq!(body, lorem_ipsum); + } + + #[test] + fn decompressed_deflate_response() { + let lorem_ipsum = lorem_ipsum_bytes(); + let req_body = Body::from_stream(stream::ZlibEncoder::new( + futures::stream::iter(vec![lorem_ipsum]) + .map(Bytes::from) + .map(Ok), + flate2::Compression::default(), + )); + let res = get_decoded_response(req_body, "deflate"); + let body = block_on(res.into_body().into_vec()).unwrap(); + let lorem_ipsum = lorem_ipsum_bytes(); + assert_eq!(body, lorem_ipsum); + } + + #[test] + fn decompressed_brotli_response() { + let lorem_ipsum = lorem_ipsum_bytes(); + let req_body = Body::from_stream(stream::BrotliEncoder::new( + futures::stream::iter(vec![lorem_ipsum]) + .map(Bytes::from) + .map(Ok), + 11, + )); + let res = get_decoded_response(req_body, "br"); + let body = block_on(res.into_body().into_vec()).unwrap(); + let lorem_ipsum = lorem_ipsum_bytes(); + assert_eq!(body, lorem_ipsum); + } + + #[test] + fn decompressed_zstd_response() { + let lorem_ipsum = lorem_ipsum_bytes(); + let req_body = Body::from_stream(stream::ZstdEncoder::new( + futures::stream::iter(vec![lorem_ipsum]) + .map(Bytes::from) + .map(Ok), + 3, + )); + let res = get_decoded_response(req_body, "zstd"); + let body = block_on(res.into_body().into_vec()).unwrap(); + let lorem_ipsum = lorem_ipsum_bytes(); + assert_eq!(body, lorem_ipsum); + } +} diff --git a/tide-cookies/Cargo.toml b/tide-cookies/Cargo.toml new file mode 100644 index 000000000..5fbf57854 --- /dev/null +++ b/tide-cookies/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "tide-cookies" +version = "0.2.0" +edition = "2018" +authors = [ + "Tide Developers", +] +description = "Cookie middleware and extensions for Tide" +documentation = "https://docs.rs/tide-cookies" +license = "MIT OR Apache-2.0" +repository = "https://github.com/rustasync/tide" + +[dependencies] +cookie = { version = "0.12", features = ["percent-encode"] } +futures-preview = "0.3.0-alpha.18" +http = "0.1" +http-service = { git = "https://github.com/rustasync/http-service", branch = "tokio-0.2" } +tide-core = { path = "../tide-core", default-features = false } + +[dev-dependencies] +tide = { path = "../", default-features = false } +http-service-mock = { git = "https://github.com/rustasync/http-service", branch = "tokio-0.2" } diff --git a/src/cookies.rs b/tide-cookies/src/data.rs similarity index 98% rename from src/cookies.rs rename to tide-cookies/src/data.rs index 48fa1a2cb..7430b10e0 100644 --- a/src/cookies.rs +++ b/tide-cookies/src/data.rs @@ -1,9 +1,8 @@ use cookie::{Cookie, CookieJar, ParseError}; -use crate::error::StringError; -use crate::Context; use http::HeaderMap; use std::sync::{Arc, RwLock}; +use tide_core::{error::StringError, Context}; const MIDDLEWARE_MISSING_MSG: &str = "CookiesMiddleware must be used to populate request and response cookies"; diff --git a/tide-cookies/src/lib.rs b/tide-cookies/src/lib.rs new file mode 100644 index 000000000..0d4130e74 --- /dev/null +++ b/tide-cookies/src/lib.rs @@ -0,0 +1,16 @@ +//! Crate that provides helpers and/or middlewares for Tide +//! related to cookies. + +#![warn( + nonstandard_style, + rust_2018_idioms, + future_incompatible, + missing_debug_implementations, + missing_docs +)] + +mod data; +mod middleware; + +pub use self::data::ContextExt; +pub use self::middleware::CookiesMiddleware; diff --git a/src/middleware/cookies.rs b/tide-cookies/src/middleware.rs similarity index 81% rename from src/middleware/cookies.rs rename to tide-cookies/src/middleware.rs index 9c3dd6910..3ee13a21c 100644 --- a/src/middleware/cookies.rs +++ b/tide-cookies/src/middleware.rs @@ -1,37 +1,41 @@ -use crate::cookies::CookieData; +use crate::data::CookieData; use futures::future::BoxFuture; use http::header::HeaderValue; -use crate::{ +use tide_core::{ middleware::{Middleware, Next}, Context, Response, }; /// Middleware to work with cookies. /// -/// [`CookiesMiddleware`] along with [`ContextExt`](crate::cookies::ContextExt) provide smooth +/// [`CookiesMiddleware`] along with [`ContextExt`] provide smooth /// access to request cookies and setting/removing cookies from response. This leverages the /// [cookie](https://crates.io/crates/cookie) crate. /// This middleware parses cookies from request and caches them in the extension. Once the request /// is processed by endpoints and other middlewares, all the added and removed cookies are set on /// on the response. You will need to add this middle before any other middlewares that might need /// to access Cookies. +/// +/// [`CookiesMiddleware`]: crate::middleware::CookiesMiddleware +/// [`ContextExt`]: ../../tide/cookies/trait.ContextExt.html #[derive(Clone, Default, Debug)] pub struct CookiesMiddleware {} impl CookiesMiddleware { + /// CookieMiddleware constructor pub fn new() -> Self { Self {} } } -impl Middleware for CookiesMiddleware { +impl Middleware for CookiesMiddleware { fn handle<'a>( &'a self, - mut cx: Context, - next: Next<'a, Data>, + mut cx: Context, + next: Next<'a, State>, ) -> BoxFuture<'a, Response> { - box_async! { + Box::pin(async move { let cookie_data = cx .extensions_mut() .remove() @@ -39,7 +43,9 @@ impl Middleware for CookiesMiddleware { let cookie_jar = cookie_data.content.clone(); - cx.extensions_mut().insert(cookie_data); + // The `let _ = ...` is a workaround for issue: https://github.com/rustasync/tide/issues/278 + // Solution is according to suggestion in https://github.com/rust-lang/rust/issues/61579#issuecomment-500436524 + let _ = cx.extensions_mut().insert(cookie_data); let mut res = next.run(cx).await; let headers = res.headers_mut(); for cookie in cookie_jar.read().unwrap().delta() { @@ -56,46 +62,47 @@ impl Middleware for CookiesMiddleware { } } res - } + }) } } #[cfg(test)] mod tests { use super::*; - use crate::{cookies::ContextExt, Context}; + use crate::data::ContextExt; use cookie::Cookie; use futures::executor::block_on; use http_service::Body; use http_service_mock::make_server; + use tide_core::Context; static COOKIE_NAME: &str = "testCookie"; /// Tide will use the the `Cookies`'s `Extract` implementation to build this parameter. - #[allow(unused_mut)] // Workaround clippy bug async fn retrieve_cookie(mut cx: Context<()>) -> String { - format!("{}", cx.get_cookie(COOKIE_NAME).unwrap().unwrap().value()) + cx.get_cookie(COOKIE_NAME) + .unwrap() + .unwrap() + .value() + .to_string() } - #[allow(unused_mut)] // Workaround clippy bug async fn set_cookie(mut cx: Context<()>) { cx.set_cookie(Cookie::new(COOKIE_NAME, "NewCookieValue")) .unwrap(); } - #[allow(unused_mut)] // Workaround clippy bug async fn remove_cookie(mut cx: Context<()>) { cx.remove_cookie(Cookie::named(COOKIE_NAME)).unwrap(); } - #[allow(unused_mut)] // Workaround clippy bug async fn set_multiple_cookie(mut cx: Context<()>) { cx.set_cookie(Cookie::new("C1", "V1")).unwrap(); cx.set_cookie(Cookie::new("C2", "V2")).unwrap(); } - fn app() -> crate::App<()> { - let mut app = crate::App::new(); + fn app() -> tide::App<()> { + let mut app = tide::App::new(); app.middleware(CookiesMiddleware::new()); app.at("/get").get(retrieve_cookie); @@ -112,8 +119,7 @@ mod tests { .header(http::header::COOKIE, "testCookie=RequestCookieValue") .body(Body::empty()) .unwrap(); - let res = server.simulate(req).unwrap(); - res + server.simulate(req).unwrap() } #[test] @@ -127,7 +133,7 @@ mod tests { #[test] fn successfully_set_cookie() { let res = make_request("/set"); - assert_eq!(res.status(), 200); + assert_eq!(res.status(), 204); let test_cookie_header = res.headers().get(http::header::SET_COOKIE).unwrap(); assert_eq!( test_cookie_header.to_str().unwrap(), @@ -138,7 +144,7 @@ mod tests { #[test] fn successfully_remove_cookie() { let res = make_request("/remove"); - assert_eq!(res.status(), 200); + assert_eq!(res.status(), 204); let test_cookie_header = res.headers().get(http::header::SET_COOKIE).unwrap(); assert!(test_cookie_header .to_str() @@ -154,7 +160,7 @@ mod tests { #[test] fn successfully_set_multiple_cookies() { let res = make_request("/multi"); - assert_eq!(res.status(), 200); + assert_eq!(res.status(), 204); let cookie_header = res.headers().get_all(http::header::SET_COOKIE); let mut iter = cookie_header.iter(); @@ -172,5 +178,4 @@ mod tests { assert!(iter.next().is_none()); } - } diff --git a/tide-core/Cargo.toml b/tide-core/Cargo.toml new file mode 100644 index 000000000..a2e294004 --- /dev/null +++ b/tide-core/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "tide-core" +version = "0.2.0" +edition = "2018" +authors = [ + "Tide Developers", +] +description = "Core types and traits for Tide web framework" +documentation = "https://docs.rs/tide-core" +license = "MIT OR Apache-2.0" +repository = "https://github.com/rustasync/tide" + +[dependencies] +futures-preview = "0.3.0-alpha.18" +http = "0.1" +http-service = { git = "https://github.com/rustasync/http-service", branch = "tokio-0.2" } +serde = "1.0.91" +serde_json = "1.0.39" +route-recognizer = "0.1.13" + +[dev-dependencies] +tide = { path = "../", default-features = false } +serde_derive = "1.0.91" diff --git a/src/context.rs b/tide-core/src/context.rs similarity index 91% rename from src/context.rs rename to tide-core/src/context.rs index 4c96cc896..ced4c1df0 100644 --- a/src/context.rs +++ b/tide-core/src/context.rs @@ -3,7 +3,7 @@ use http_service::Body; use route_recognizer::Params; use std::{str::FromStr, sync::Arc}; -/// Data associated with a request-response lifecycle. +/// State associated with a request-response lifecycle. /// /// The `Context` gives endpoints access to basic information about the incoming /// request, route parameters, and various ways of accessing the request's body. @@ -18,12 +18,9 @@ pub struct Context { } impl Context { - pub(crate) fn new( - state: Arc, - request: http::Request, - route_params: Params, - ) -> Context { - Context { + /// Create a new Context + pub fn new(state: Arc, request: http::Request, route_params: Params) -> Self { + Self { state, request, route_params, @@ -55,7 +52,12 @@ impl Context { &self.request } - /// Access app-global data. + /// Access a mutable handle to the entire request. + pub fn request_mut(&mut self) -> &mut http_service::Request { + &mut self.request + } + + /// Access the state. pub fn state(&self) -> &State { &self.state } diff --git a/src/endpoint.rs b/tide-core/src/endpoint.rs similarity index 72% rename from src/endpoint.rs rename to tide-core/src/endpoint.rs index f4adbfcf8..7cc48b08a 100644 --- a/src/endpoint.rs +++ b/tide-core/src/endpoint.rs @@ -1,6 +1,6 @@ use futures::future::{BoxFuture, Future}; -use crate::{response::IntoResponse, Context, Response}; +use crate::{error::Error, response::IntoResponse, Context, Response}; /// A Tide endpoint. /// @@ -8,27 +8,25 @@ use crate::{response::IntoResponse, Context, Response}; /// directly by Tide users. /// /// In practice, endpoints are functions that take a `Context` as an argument and -/// return a type `T` that implements [`IntoResponse`]. +/// return a type `T` that implements [`IntoResponse`](crate::response::IntoResponse). /// /// # Examples /// /// Endpoints are implemented as asynchronous functions that make use of language features -/// currently only available in Rust Nightly. For this reason, we have to explicitly enable -/// those features with `#![feature(async_await)]`. To keep examples concise, -/// the attribute will be omitted in most of the documentation. +/// currently only available in Rust Nightly. /// /// A simple endpoint that is invoked on a `GET` request and returns a `String`: /// /// ```rust, no_run -/// # #![feature(async_await)] /// async fn hello(_cx: tide::Context<()>) -> String { /// String::from("hello") /// } /// -/// fn main() { +/// #[tokio::main] +/// async fn main() { /// let mut app = tide::App::new(); /// app.at("/hello").get(hello); -/// app.serve("127.0.0.1:8000").unwrap() +/// app.serve("127.0.0.1:8000").await.unwrap() /// } /// ``` /// @@ -40,10 +38,11 @@ use crate::{response::IntoResponse, Context, Response}; /// futures::future::ready(String::from("hello")) /// } /// -/// fn main() { +/// #[tokio::main] +/// async fn main() { /// let mut app = tide::App::new(); /// app.at("/hello").get(hello); -/// app.serve("127.0.0.1:8000").unwrap() +/// app.serve("127.0.0.1:8000").await.unwrap() /// } /// ``` /// @@ -56,9 +55,6 @@ pub trait Endpoint: Send + Sync + 'static { fn call(&self, cx: Context) -> Self::Fut; } -pub(crate) type DynEndpoint = - dyn (Fn(Context) -> BoxFuture<'static, Response>) + 'static + Send + Sync; - impl Endpoint for F where F: Fn(Context) -> Fut, @@ -68,8 +64,9 @@ where type Fut = BoxFuture<'static, Response>; fn call(&self, cx: Context) -> Self::Fut { let fut = (self)(cx); - box_async! { - fut.await.into_response() - } + Box::pin(async move { fut.await.into_response() }) } } + +/// A convenient `Result` instantiation appropriate for most endpoints. +pub type EndpointResult = Result; diff --git a/tide-core/src/error.rs b/tide-core/src/error.rs new file mode 100644 index 000000000..af460ad45 --- /dev/null +++ b/tide-core/src/error.rs @@ -0,0 +1,160 @@ +//! Error types + +pub use ext::{ResponseExt, ResultDynErrExt, ResultExt}; +pub use types::{Cause, Error, StringError}; + +mod types { + use http::StatusCode; + use http_service::{Body, Response}; + + use crate::response::IntoResponse; + + /// A generic endpoint error, which can be converted into a response. + #[derive(Debug)] + pub struct Error { + resp: Response, + } + + impl IntoResponse for Error { + fn into_response(self) -> Response { + self.resp + } + } + + impl From for Error { + fn from(resp: Response) -> Error { + Error { resp } + } + } + + impl From for Error { + fn from(status: StatusCode) -> Error { + let resp = http::Response::builder() + .status(status) + .body(Body::empty()) + .unwrap(); + Error { resp } + } + } + + /// `Cause` type that is used for error nesting + #[derive(Debug)] + pub struct Cause(Box); + + impl Cause { + /// Create a new cause from boxed std Error + pub fn new(error: Box) -> Self { + Self(error) + } + + /// Access inner std error + #[allow(clippy::borrowed_box)] + pub fn inner_ref(&self) -> &Box { + &self.0 + } + + /// Get the original std error out + pub fn into_inner(self) -> Box { + self.0 + } + } + + /// A simple error type that wraps a String + #[derive(Debug)] + pub struct StringError(pub String); + impl std::error::Error for StringError {} + + impl std::fmt::Display for StringError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::result::Result<(), std::fmt::Error> { + self.0.fmt(f) + } + } +} + +mod ext { + use http::{HttpTryFrom, Response, StatusCode}; + use http_service::Body; + + use super::types::{Cause, Error}; + use crate::endpoint::EndpointResult; + + /// Extends the `Response` type with a method to extract error causes when applicable. + pub trait ResponseExt { + /// Extract the cause of the unsuccessful response, if any + fn err_cause(&self) -> Option<&(dyn std::error::Error + Send + Sync + 'static)>; + } + + impl ResponseExt for Response { + fn err_cause(&self) -> Option<&(dyn std::error::Error + Send + Sync + 'static)> { + self.extensions().get().map(|c: &Cause| &**c.inner_ref()) + } + } + + /// Extends the `Result` type with convenient methods for constructing Tide errors. + pub trait ResultExt: Sized { + /// Convert to an `EndpointResult`, treating the `Err` case as a client + /// error (response code 400). + fn client_err(self) -> EndpointResult { + self.with_err_status(400) + } + + /// Convert to an `EndpointResult`, treating the `Err` case as a server + /// error (response code 500). + fn server_err(self) -> EndpointResult { + self.with_err_status(500) + } + + /// Convert to an `EndpointResult`, wrapping the `Err` case with a custom response status. + fn with_err_status(self, status: S) -> EndpointResult + where + StatusCode: HttpTryFrom; + } + + impl ResultExt for std::result::Result { + fn with_err_status(self, status: S) -> EndpointResult + where + StatusCode: HttpTryFrom, + { + let r = self.map_err(|e| Box::new(e) as Box); + r.with_err_status(status) + } + } + + /// Extends the `Result` type using `std::error::Error` trait object as the error type with + /// convenient methods for constructing Tide errors. + pub trait ResultDynErrExt: Sized { + /// Convert to an `EndpointResult`, treating the `Err` case as a client + /// error (response code 400). + fn client_err(self) -> EndpointResult { + self.with_err_status(400) + } + + /// Convert to an `EndpointResult`, treating the `Err` case as a server + /// error (response code 500). + fn server_err(self) -> EndpointResult { + self.with_err_status(500) + } + + /// Convert to an `EndpointResult`, wrapping the `Err` case with a custom response status. + fn with_err_status(self, status: S) -> EndpointResult + where + StatusCode: HttpTryFrom; + } + + impl ResultDynErrExt for std::result::Result> { + fn with_err_status(self, status: S) -> EndpointResult + where + StatusCode: HttpTryFrom, + { + self.map_err(|e| { + Error::from( + Response::builder() + .status(status) + .extension(Cause::new(e)) + .body(Body::empty()) + .unwrap(), + ) + }) + } + } +} diff --git a/tide-core/src/internal.rs b/tide-core/src/internal.rs new file mode 100644 index 000000000..2a58bd247 --- /dev/null +++ b/tide-core/src/internal.rs @@ -0,0 +1,15 @@ +//! For internal use. These APIs will never be stable and +//! are meant to be used internally by the tide repo. + +use core::pin::Pin; +use futures::future::{BoxFuture, Future}; + +use crate::{Context, Response}; + +/// Convenience alias for pinned box of Future> + Send + 'static +pub type BoxTryFuture = + Pin> + Send + 'static>>; + +/// Convenience alias that's used to take loose functions as an Endpoint +pub type DynEndpoint = + dyn (Fn(Context) -> BoxFuture<'static, Response>) + 'static + Send + Sync; diff --git a/tide-core/src/lib.rs b/tide-core/src/lib.rs new file mode 100644 index 000000000..31daf5d17 --- /dev/null +++ b/tide-core/src/lib.rs @@ -0,0 +1,29 @@ +//! Core types and traits from Tide + +#![warn( + nonstandard_style, + rust_2018_idioms, + future_incompatible, + missing_debug_implementations, + missing_docs +)] +// TODO: Remove this after clippy bug due to async await is resolved. +// ISSUE: https://github.com/rust-lang/rust-clippy/issues/3988 +#![allow(clippy::needless_lifetimes)] + +mod context; +mod endpoint; + +pub mod error; +pub mod middleware; +pub mod response; + +// Internal shared API for limited use across crates in our repo +pub mod internal; + +pub use crate::{ + context::Context, + endpoint::{Endpoint, EndpointResult}, + error::Error, + response::{Body, Response}, +}; diff --git a/src/middleware/mod.rs b/tide-core/src/middleware.rs similarity index 58% rename from src/middleware/mod.rs rename to tide-core/src/middleware.rs index 64c6c5692..851ba9da6 100644 --- a/src/middleware/mod.rs +++ b/tide-core/src/middleware.rs @@ -1,25 +1,23 @@ +//! Middleware types + +use crate::{internal::DynEndpoint, Context, Response}; use futures::future::BoxFuture; use std::sync::Arc; -use crate::{endpoint::DynEndpoint, Context, Response}; - -mod cookies; -mod default_headers; -mod logger; - -pub use self::{cookies::CookiesMiddleware, default_headers::DefaultHeaders, logger::RootLogger}; - /// Middleware that wraps around remaining middleware chain. pub trait Middleware: 'static + Send + Sync { /// Asynchronously handle the request, and return a response. fn handle<'a>(&'a self, cx: Context, next: Next<'a, State>) -> BoxFuture<'a, Response>; } -impl Middleware for F +impl Middleware for F where - F: Send + Sync + 'static + for<'a> Fn(Context, Next<'a, Data>) -> BoxFuture<'a, Response>, + F: Send + + Sync + + 'static + + for<'a> Fn(Context, Next<'a, State>) -> BoxFuture<'a, Response>, { - fn handle<'a>(&'a self, cx: Context, next: Next<'a, Data>) -> BoxFuture<'a, Response> { + fn handle<'a>(&'a self, cx: Context, next: Next<'a, State>) -> BoxFuture<'a, Response> { (self)(cx, next) } } @@ -27,11 +25,19 @@ where /// The remainder of a middleware chain, including the endpoint. #[allow(missing_debug_implementations)] pub struct Next<'a, State> { - pub(crate) endpoint: &'a DynEndpoint, - pub(crate) next_middleware: &'a [Arc>], + endpoint: &'a DynEndpoint, + next_middleware: &'a [Arc>], } impl<'a, State: 'static> Next<'a, State> { + /// Create a new instance + pub fn new(endpoint: &'a DynEndpoint, next: &'a [Arc>]) -> Self { + Self { + endpoint, + next_middleware: next, + } + } + /// Asynchronously execute the remaining middleware chain. pub fn run(mut self, cx: Context) -> BoxFuture<'a, Response> { if let Some((current, next)) = self.next_middleware.split_first() { diff --git a/src/response.rs b/tide-core/src/response.rs similarity index 96% rename from src/response.rs rename to tide-core/src/response.rs index 90751b25d..1853afdb3 100644 --- a/src/response.rs +++ b/tide-core/src/response.rs @@ -1,5 +1,8 @@ -use http_service::Body; +//! Response types +pub use http_service::Body; + +/// An Http response pub type Response = http_service::Response; /// Serialize `t` into a JSON-encoded response. @@ -35,7 +38,7 @@ pub trait IntoResponse: Send + Sized { impl IntoResponse for () { fn into_response(self) -> Response { http::Response::builder() - .status(http::status::StatusCode::OK) + .status(http::status::StatusCode::NO_CONTENT) .body(Body::empty()) .unwrap() } diff --git a/tide-cors/Cargo.toml b/tide-cors/Cargo.toml new file mode 100644 index 000000000..a3e9ed240 --- /dev/null +++ b/tide-cors/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "tide-cors" +version = "0.2.0" +authors = [ + "Tide Developers", +] +description = "Cors middleware and extensions for Tide" +documentation = "https://docs.rs/tide-cors" +readme = "README.md" +repository = "https://github.com/rustasync/tide" +license = "MIT OR Apache-2.0" +edition = "2018" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +futures-preview = "0.3.0-alpha.18" +http = "0.1" +http-service = { git = "https://github.com/rustasync/http-service", branch = "tokio-0.2" } +tide-core = { path = "../tide-core" } + +[dev-dependencies] +tide = { path = "../" } +http-service-mock = { git = "https://github.com/rustasync/http-service", branch = "tokio-0.2" } diff --git a/tide-cors/README.md b/tide-cors/README.md new file mode 100644 index 000000000..85a19e8d3 --- /dev/null +++ b/tide-cors/README.md @@ -0,0 +1,37 @@ +# tide-cors + +This crate provides cors-related middleware for Tide. + +## Examples + +Examples are in the `/examples` folder of this crate. + +```rust,no_run +use http::header::HeaderValue; +use tide::middleware::CorsMiddleware; + +#[tokio::main] +async fn main() { + let mut app = tide::App::new(); + + app.middleware( + CorsMiddleware::new() + .allow_origin(HeaderValue::from_static("*")) + .allow_methods(HeaderValue::from_static("GET, POST, OPTIONS")), + ); + + app.at("/").get(|_| async move { "Hello, world!" }); + + app.serve("127.0.0.1:8000").await.unwrap(); +} +``` + +__Simple Example__ + +You can test the simple example by running `cargo run --example cors` while in this crate's directory, and then running this script in the browser console: + +```console +$ fetch("http://127.0.0.1:8000") +``` + +You will probably get a browser alert when running without cors middleware. diff --git a/tide-cors/src/lib.rs b/tide-cors/src/lib.rs new file mode 100644 index 000000000..356908548 --- /dev/null +++ b/tide-cors/src/lib.rs @@ -0,0 +1,42 @@ +//! This crate provides cors-related middleware for Tide. +//! +//! ## Examples +//! +//! ```rust,no_run +//! use http::header::HeaderValue; +//! use tide::middleware::{CorsMiddleware, CorsOrigin}; +//! +//! #[tokio::main] +//! fn main() { +//! let mut app = tide::App::new(); +//! +//! app.middleware( +//! CorsMiddleware::new() +//! .allow_origin(CorsOrigin::from("*")) +//! .allow_methods(HeaderValue::from_static("GET, POST, OPTIONS")), +//! ); +//! +//! app.at("/").get(|_| async move { "Hello, world!" }); +//! +//! app.serve("127.0.0.1:8000").await.unwrap(); +//! } +//! ``` +//! You can test this by running the following in your browser: +//! +//! ```console +//! $ fetch("http://127.0.0.1:8000") +//! ``` +//! +//! You will probably get a browser alert when running without cors middleware. + +#![warn( + nonstandard_style, + rust_2018_idioms, + future_incompatible, + missing_debug_implementations, + missing_docs +)] + +mod middleware; + +pub use self::middleware::{CorsMiddleware, CorsOrigin}; diff --git a/tide-cors/src/middleware.rs b/tide-cors/src/middleware.rs new file mode 100644 index 000000000..2a5a8be0a --- /dev/null +++ b/tide-cors/src/middleware.rs @@ -0,0 +1,421 @@ +//! Cors middleware + +use futures::future::BoxFuture; + +use http::header::HeaderValue; +use http::{header, Method, StatusCode}; +use http_service::Body; +use tide_core::{ + middleware::{Middleware, Next}, + Context, Response, +}; + +/// Middleware for CORS +/// +/// # Example +/// +/// ```rust +///use http::header::HeaderValue; +///use tide::middleware::{CorsOrigin, CorsMiddleware}; +/// +///CorsMiddleware::new() +/// .allow_origin(CorsOrigin::from("*")) +/// .allow_methods(HeaderValue::from_static("GET, POST, OPTIONS")) +/// .allow_credentials(false); +/// ``` +#[derive(Clone, Debug, Hash)] +pub struct CorsMiddleware { + allow_credentials: Option, + allow_headers: HeaderValue, + allow_methods: HeaderValue, + allow_origin: CorsOrigin, + expose_headers: Option, + max_age: HeaderValue, +} + +/// allow_origin enum +#[derive(Clone, Debug, Hash, PartialEq)] +pub enum CorsOrigin { + /// Wildcard. Accept all origin requests + Any, + /// Set a single allow_origin target + Exact(String), + /// Set multiple allow_origin targets + List(Vec), +} + +impl From for CorsOrigin { + fn from(s: String) -> Self { + if s == "*" { + return CorsOrigin::Any; + } + CorsOrigin::Exact(s) + } +} + +impl From<&str> for CorsOrigin { + fn from(s: &str) -> Self { + CorsOrigin::from(s.to_string()) + } +} + +impl From> for CorsOrigin { + fn from(list: Vec) -> Self { + if list.len() == 1 { + return Self::from(list[0].clone()); + } + + CorsOrigin::List(list) + } +} + +impl From> for CorsOrigin { + fn from(list: Vec<&str>) -> Self { + CorsOrigin::from(list.iter().map(|s| s.to_string()).collect::>()) + } +} + +pub const DEFAULT_MAX_AGE: &str = "86400"; +pub const DEFAULT_METHODS: &str = "GET, POST, OPTIONS"; +pub const WILDCARD: &str = "*"; + +impl CorsMiddleware { + /// Creates a new Cors middleware. + pub fn new() -> Self { + Self { + allow_credentials: None, + allow_headers: HeaderValue::from_static(WILDCARD), + allow_methods: HeaderValue::from_static(DEFAULT_METHODS), + allow_origin: CorsOrigin::Any, + expose_headers: None, + max_age: HeaderValue::from_static(DEFAULT_MAX_AGE), + } + } + + /// Set allow_credentials and return new CorsMiddleware + pub fn allow_credentials(mut self, allow_credentials: bool) -> Self { + self.allow_credentials = match HeaderValue::from_str(&allow_credentials.to_string()) { + Ok(header) => Some(header), + Err(_) => None, + }; + self + } + + /// Set allow_headers and return new CorsMiddleware + pub fn allow_headers>(mut self, headers: T) -> Self { + self.allow_headers = headers.into(); + self + } + + /// Set max_age and return new CorsMiddleware + pub fn max_age>(mut self, max_age: T) -> Self { + self.max_age = max_age.into(); + self + } + + /// Set allow_methods and return new CorsMiddleware + pub fn allow_methods>(mut self, methods: T) -> Self { + self.allow_methods = methods.into(); + self + } + + /// Set allow_origin and return new CorsMiddleware + pub fn allow_origin>(mut self, origin: T) -> Self { + self.allow_origin = origin.into(); + self + } + + /// Set expose_headers and return new CorsMiddleware + pub fn expose_headers>(mut self, headers: T) -> Self { + self.expose_headers = Some(headers.into()); + self + } + + fn build_preflight_response(&self, origin: &HeaderValue) -> http::response::Response { + let mut response = http::Response::builder() + .status(StatusCode::OK) + .header::<_, HeaderValue>(header::ACCESS_CONTROL_ALLOW_ORIGIN, origin.clone()) + .header( + header::ACCESS_CONTROL_ALLOW_METHODS, + self.allow_methods.clone(), + ) + .header( + header::ACCESS_CONTROL_ALLOW_HEADERS, + self.allow_headers.clone(), + ) + .header(header::ACCESS_CONTROL_MAX_AGE, self.max_age.clone()) + .body(Body::empty()) + .unwrap(); + + if let Some(allow_credentials) = self.allow_credentials.clone() { + response + .headers_mut() + .append(header::ACCESS_CONTROL_ALLOW_CREDENTIALS, allow_credentials); + } + + if let Some(expose_headers) = self.expose_headers.clone() { + response + .headers_mut() + .append(header::ACCESS_CONTROL_EXPOSE_HEADERS, expose_headers); + } + + response + } + + /// Look at origin of request and determine allow_origin + fn response_origin>(&self, origin: T) -> Option { + let origin = origin.into(); + if !self.is_valid_origin(origin.clone()) { + return None; + } + + match self.allow_origin { + CorsOrigin::Any => Some(HeaderValue::from_static(WILDCARD)), + _ => Some(origin), + } + } + + /// Determine if origin is appropriate + fn is_valid_origin>(&self, origin: T) -> bool { + let origin = match origin.into().to_str() { + Ok(s) => s.to_string(), + Err(_) => return false, + }; + + match &self.allow_origin { + CorsOrigin::Any => true, + CorsOrigin::Exact(s) => s == &origin, + CorsOrigin::List(list) => list.contains(&origin), + } + } +} + +impl Middleware for CorsMiddleware { + fn handle<'a>(&'a self, cx: Context, next: Next<'a, State>) -> BoxFuture<'a, Response> { + Box::pin(async move { + let origin = cx + .request() + .headers() + .get(header::ORIGIN) + .cloned() + .unwrap_or_else(|| HeaderValue::from_static("")); + + if !self.is_valid_origin(&origin) { + return http::Response::builder() + .status(StatusCode::UNAUTHORIZED) + .body(Body::empty()) + .unwrap(); + } + + // Return results immediately upon preflight request + if cx.method() == Method::OPTIONS { + return self.build_preflight_response(&origin); + } + + let mut response = next.run(cx).await; + let headers = response.headers_mut(); + + headers.append( + header::ACCESS_CONTROL_ALLOW_ORIGIN, + self.response_origin(origin).unwrap(), + ); + + if let Some(allow_credentials) = self.allow_credentials.clone() { + headers.append(header::ACCESS_CONTROL_ALLOW_CREDENTIALS, allow_credentials); + } + response + }) + } +} + +impl Default for CorsMiddleware { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod test { + use super::*; + use http::header::HeaderValue; + use http_service::Body; + use http_service_mock::make_server; + + const ALLOW_ORIGIN: &str = "example.com"; + const ALLOW_METHODS: &str = "GET, POST, OPTIONS, DELETE"; + const EXPOSE_HEADER: &str = "X-My-Custom-Header"; + + const ENDPOINT: &str = "/cors"; + + fn app() -> tide::App<()> { + let mut app = tide::App::new(); + app.at(ENDPOINT).get(|_| async move { "Hello World" }); + + app + } + + fn request() -> http::Request { + http::Request::get(ENDPOINT) + .header(http::header::ORIGIN, ALLOW_ORIGIN) + .method(http::method::Method::GET) + .body(Body::empty()) + .unwrap() + } + + #[test] + fn preflight_request() { + let mut app = app(); + app.middleware( + CorsMiddleware::new() + .allow_origin(CorsOrigin::from(ALLOW_ORIGIN)) + .allow_methods(HeaderValue::from_static(ALLOW_METHODS)) + .expose_headers(HeaderValue::from_static(EXPOSE_HEADER)) + .allow_credentials(true), + ); + + let mut server = make_server(app.into_http_service()).unwrap(); + + let req = http::Request::get(ENDPOINT) + .header(http::header::ORIGIN, ALLOW_ORIGIN) + .method(http::method::Method::OPTIONS) + .body(Body::empty()) + .unwrap(); + + let res = server.simulate(req).unwrap(); + + assert_eq!(res.status(), 200); + + assert_eq!( + res.headers().get("access-control-allow-origin").unwrap(), + ALLOW_ORIGIN + ); + assert_eq!( + res.headers().get("access-control-allow-methods").unwrap(), + ALLOW_METHODS + ); + assert_eq!( + res.headers().get("access-control-allow-headers").unwrap(), + WILDCARD + ); + assert_eq!( + res.headers().get("access-control-max-age").unwrap(), + DEFAULT_MAX_AGE + ); + + assert_eq!( + res.headers() + .get("access-control-allow-credentials") + .unwrap(), + "true" + ); + } + #[test] + fn default_cors_middleware() { + let mut app = app(); + app.middleware(CorsMiddleware::new()); + + let mut server = make_server(app.into_http_service()).unwrap(); + let res = server.simulate(request()).unwrap(); + + assert_eq!(res.status(), 200); + + assert_eq!( + res.headers().get("access-control-allow-origin").unwrap(), + "*" + ); + } + + #[test] + fn custom_cors_middleware() { + let mut app = app(); + app.middleware( + CorsMiddleware::new() + .allow_origin(CorsOrigin::from(ALLOW_ORIGIN)) + .allow_credentials(false) + .allow_methods(HeaderValue::from_static(ALLOW_METHODS)) + .expose_headers(HeaderValue::from_static(EXPOSE_HEADER)), + ); + + let mut server = make_server(app.into_http_service()).unwrap(); + let res = server.simulate(request()).unwrap(); + + assert_eq!(res.status(), 200); + assert_eq!( + res.headers().get("access-control-allow-origin").unwrap(), + ALLOW_ORIGIN + ); + } + + #[test] + fn credentials_true() { + let mut app = app(); + app.middleware(CorsMiddleware::new().allow_credentials(true)); + + let mut server = make_server(app.into_http_service()).unwrap(); + let res = server.simulate(request()).unwrap(); + + assert_eq!(res.status(), 200); + assert_eq!( + res.headers() + .get("access-control-allow-credentials") + .unwrap(), + "true" + ); + } + #[test] + fn set_allow_origin_list() { + let mut app = app(); + let origins = vec![ALLOW_ORIGIN, "foo.com", "bar.com"]; + app.middleware(CorsMiddleware::new().allow_origin(origins.clone())); + let mut server = make_server(app.into_http_service()).unwrap(); + + for origin in origins { + let request = http::Request::get(ENDPOINT) + .header(http::header::ORIGIN, origin) + .method(http::method::Method::GET) + .body(Body::empty()) + .unwrap(); + + let res = server.simulate(request).unwrap(); + + assert_eq!(res.status(), 200); + assert_eq!( + res.headers().get("access-control-allow-origin").unwrap(), + origin + ); + } + } + + #[test] + fn not_set_origin_header() { + let mut app = app(); + app.middleware(CorsMiddleware::new()); + + let request = http::Request::get(ENDPOINT) + .method(http::method::Method::GET) + .body(Body::empty()) + .unwrap(); + + let mut server = make_server(app.into_http_service()).unwrap(); + let res = server.simulate(request).unwrap(); + + assert_eq!(res.status(), 200); + } + + #[test] + fn unauthorized_origin() { + let mut app = app(); + app.middleware(CorsMiddleware::new().allow_origin(ALLOW_ORIGIN)); + + let request = http::Request::get(ENDPOINT) + .header(http::header::ORIGIN, "unauthorize-origin.net") + .method(http::method::Method::GET) + .body(Body::empty()) + .unwrap(); + + let mut server = make_server(app.into_http_service()).unwrap(); + let res = server.simulate(request).unwrap(); + + assert_eq!(res.status(), 401); + } +} diff --git a/tide-forms/Cargo.toml b/tide-forms/Cargo.toml new file mode 100644 index 000000000..a96a64992 --- /dev/null +++ b/tide-forms/Cargo.toml @@ -0,0 +1,31 @@ +[package] +authors = [ + "Tide Developers" +] +description = "Form helpers and extensions for Tide" +documentation = "https://docs.rs/tide-forms" +keywords = ["tide", "web", "async", "helpers", "forms"] +categories = [ + "network-programming", + "web-programming::http-server", +] +edition = "2018" +license = "MIT OR Apache-2.0" +name = "tide-forms" +readme = "README.md" +repository = "https://github.com/rustasync/tide" +version = "0.1.0" + +[dependencies] +tide-core = { path = "../tide-core", default-features = false } +http-service = { git = "https://github.com/rustasync/http-service", branch = "tokio-0.2" } +futures-preview = "0.3.0-alpha.18" +http = "0.1" +log = "0.4.6" +multipart = { version = "0.16.1", features = ["server"], default-features = false } +serde = { version = "1.0.91", features = ["derive"] } +serde_urlencoded = "0.6.0" + +[dev-dependencies] +tide = { path = "../", default-features = false } + diff --git a/src/forms.rs b/tide-forms/src/lib.rs similarity index 67% rename from src/forms.rs rename to tide-forms/src/lib.rs index b7338dab5..01c476b99 100644 --- a/src/forms.rs +++ b/tide-forms/src/lib.rs @@ -1,14 +1,25 @@ +//! Crate that provides helpers and extensions for Tide +//! related to forms. + +#![warn( + nonstandard_style, + rust_2018_idioms, + future_incompatible, + missing_debug_implementations +)] + use http_service::Body; use multipart::server::Multipart; use std::io::Cursor; -use crate::{ - error::{BoxTryFuture, ResultExt}, +use tide_core::{ + error::{ResultExt, StringError}, + internal::BoxTryFuture, Context, Response, }; /// An extension trait for `Context`, providing form extraction. -pub trait ExtractForms { +pub trait ContextExt { /// Asynchronously extract the entire body as a single form. fn body_form(&mut self) -> BoxTryFuture; @@ -16,13 +27,15 @@ pub trait ExtractForms { fn body_multipart(&mut self) -> BoxTryFuture>>>; } -impl ExtractForms for Context { +impl ContextExt for Context { fn body_form(&mut self) -> BoxTryFuture { let body = self.take_body(); - box_async! { + Box::pin(async move { let body = body.into_vec().await.client_err()?; - Ok(serde_urlencoded::from_bytes(&body).map_err(|e| err_fmt!("could not decode form: {}", e)).client_err()?) - } + Ok(serde_urlencoded::from_bytes(&body) + .map_err(|e| StringError(format!("could not decode form: {}", e))) + .client_err()?) + }) } fn body_multipart(&mut self) -> BoxTryFuture>>> { @@ -35,11 +48,13 @@ impl ExtractForms for Context { let body = self.take_body(); - box_async! { + Box::pin(async move { let body = body.into_vec().await.client_err()?; - let boundary = boundary.ok_or_else(|| err_fmt!("no boundary found")).client_err()?; + let boundary = boundary + .ok_or_else(|| StringError("no boundary found".to_string())) + .client_err()?; Ok(Multipart::with_body(Cursor::new(body), boundary)) - } + }) } } diff --git a/tide-headers/Cargo.toml b/tide-headers/Cargo.toml new file mode 100644 index 000000000..b954fb6dd --- /dev/null +++ b/tide-headers/Cargo.toml @@ -0,0 +1,26 @@ +[package] +authors = [ + "Tide Developers" +] +description = "Header related middleware for Tide" +documentation = "https://docs.rs/tide-headers" +keywords = ["tide", "web", "async", "middleware", "headers"] +categories = [ + "network-programming", + "web-programming::http-server", +] +edition = "2018" +license = "MIT OR Apache-2.0" +name = "tide-headers" +readme = "README.md" +repository = "https://github.com/rustasync/tide" +version = "0.1.0" + +[dependencies] +tide-core = { path = "../tide-core", default-features = false } +futures-preview = "0.3.0-alpha.18" +http = "0.1" +log = "0.4.6" + +[dev-dependencies] +tide = { path = "../", default-features = false } diff --git a/src/middleware/default_headers.rs b/tide-headers/src/lib.rs similarity index 67% rename from src/middleware/default_headers.rs rename to tide-headers/src/lib.rs index d7b5bffe4..7e1d85c83 100644 --- a/src/middleware/default_headers.rs +++ b/tide-headers/src/lib.rs @@ -1,11 +1,22 @@ +//! Crate that provides helpers and/or middlewares for Tide +//! related to http headers. + +#![warn( + nonstandard_style, + rust_2018_idioms, + future_incompatible, + missing_debug_implementations +)] + use futures::future::BoxFuture; +use log::trace; use http::{ header::{HeaderValue, IntoHeaderName}, HeaderMap, HttpTryFrom, }; -use crate::{ +use tide_core::{ middleware::{Middleware, Next}, Context, Response, }; @@ -19,10 +30,9 @@ pub struct DefaultHeaders { impl DefaultHeaders { /// Construct a new instance with an empty list of headers. pub fn new() -> DefaultHeaders { - DefaultHeaders::default() + Self::default() } - #[inline] /// Add a header to the default header list. pub fn header(mut self, key: K, value: V) -> Self where @@ -34,21 +44,20 @@ impl DefaultHeaders { .expect("Cannot create default header"); self.headers.append(key, value); - self } } -impl Middleware for DefaultHeaders { - fn handle<'a>(&'a self, cx: Context, next: Next<'a, Data>) -> BoxFuture<'a, Response> { - box_async! { +impl Middleware for DefaultHeaders { + fn handle<'a>(&'a self, cx: Context, next: Next<'a, State>) -> BoxFuture<'a, Response> { + Box::pin(async move { let mut res = next.run(cx).await; - let headers = res.headers_mut(); for (key, value) in self.headers.iter() { + trace!("add default: {} {:?}", &key, &value); headers.entry(key).unwrap().or_insert_with(|| value.clone()); } res - } + }) } } diff --git a/tide-log/Cargo.toml b/tide-log/Cargo.toml new file mode 100644 index 000000000..60fcee847 --- /dev/null +++ b/tide-log/Cargo.toml @@ -0,0 +1,27 @@ +[package] +authors = [ + "Tide Developers" +] +description = "Logging middleware for Tide" +documentation = "https://docs.rs/tide-log" +keywords = ["tide", "web", "async", "middleware", "logging"] +categories = [ + "logging", + "network-programming", + "web-programming::http-server", +] +edition = "2018" +license = "MIT OR Apache-2.0" +name = "tide-log" +readme = "README.md" +repository = "https://github.com/rustasync/tide" +version = "0.1.0" + +[dependencies] +tide-core = { path = "../tide-core", default-features = false } +futures-preview = "0.3.0-alpha.18" +http = "0.1" +log = "0.4.6" + +[dev-dependencies] +tide = { path = "../", default-features = false } diff --git a/tide-log/src/lib.rs b/tide-log/src/lib.rs new file mode 100644 index 000000000..cdba3f396 --- /dev/null +++ b/tide-log/src/lib.rs @@ -0,0 +1,76 @@ +//! Crate that provides helpers and/or middlewares for Tide +//! related to logging. + +#![warn( + nonstandard_style, + rust_2018_idioms, + future_incompatible, + missing_debug_implementations +)] + +use futures::future::BoxFuture; + +use log::{info, trace}; + +use tide_core::{ + middleware::{Middleware, Next}, + Context, Response, +}; + +/// A simple requests logger +/// +/// # Examples +/// +/// ```rust +/// +/// let mut app = tide::App::new(); +/// app.middleware(tide_log::RequestLogger::new()); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct RequestLogger { + target: String, +} + +impl RequestLogger { + /// Create a new instance of logger with default target as + /// "requests" + pub fn new() -> Self { + Self { + target: "requests".to_owned(), + } + } + + /// Create a new instance of logger with supplied `target` for + /// logging. + pub fn with_target(target: String) -> Self { + Self { target } + } + + async fn log_basic<'a, State: Send + Sync + 'static>( + &'a self, + ctx: Context, + next: Next<'a, State>, + ) -> Response { + let path = ctx.uri().path().to_owned(); + let method = ctx.method().as_str().to_owned(); + trace!(target: &self.target, "IN => {} {}", method, path); + let start = std::time::Instant::now(); + let res = next.run(ctx).await; + let status = res.status(); + info!( + target: &self.target, + "{} {} {} {}ms", + method, + path, + status.as_str(), + start.elapsed().as_millis() + ); + res + } +} + +impl Middleware for RequestLogger { + fn handle<'a>(&'a self, ctx: Context, next: Next<'a, State>) -> BoxFuture<'a, Response> { + Box::pin(async move { self.log_basic(ctx, next).await }) + } +} diff --git a/tide-panic/Cargo.toml b/tide-panic/Cargo.toml new file mode 100644 index 000000000..cbb3543c1 --- /dev/null +++ b/tide-panic/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "tide-panic" +version = "0.1.0" +edition = "2018" +authors = ["Tide Developers"] +description = "Advanced panic support for Tide" +documentation = "https://docs.rs/tide-panic" +license = "MIT OR Apache-2.0" +repository = "https://github.com/rustasync/tide" + +[dependencies] +futures-preview = "0.3.0-alpha.18" +http = "0.1" +http-service = { git = "https://github.com/rustasync/http-service", branch = "tokio-0.2" } +tide-core = { path = "../tide-core" } diff --git a/tide-panic/LICENSE-APACHE b/tide-panic/LICENSE-APACHE new file mode 120000 index 000000000..965b606f3 --- /dev/null +++ b/tide-panic/LICENSE-APACHE @@ -0,0 +1 @@ +../LICENSE-APACHE \ No newline at end of file diff --git a/tide-panic/LICENSE-MIT b/tide-panic/LICENSE-MIT new file mode 120000 index 000000000..76219eb72 --- /dev/null +++ b/tide-panic/LICENSE-MIT @@ -0,0 +1 @@ +../LICENSE-MIT \ No newline at end of file diff --git a/tide-panic/src/catch_unwind.rs b/tide-panic/src/catch_unwind.rs new file mode 100644 index 000000000..eb5726a7f --- /dev/null +++ b/tide-panic/src/catch_unwind.rs @@ -0,0 +1,65 @@ +use futures::future::{BoxFuture, FutureExt, TryFutureExt}; +use http::status::StatusCode; +use std::{ + any::Any, + panic::{AssertUnwindSafe, RefUnwindSafe}, +}; +use tide_core::{ + middleware::{Middleware, Next}, + response::IntoResponse, + Context, Response, +}; + +/// A [`Middleware`] that will catch any panics from later middleware or handlers and return a +/// response to the client. +/// +/// It is **not** recommended to use this middleware for a general try/catch mechanism. The +/// [`Result`] type is more appropriate to use for middleware/handlers that can fail on a regular +/// basis. Additionally, this middleware is not guaranteed to catch all panics, see the "Notes" +/// section in the [`std::panic::catch_unwind`] docs. +pub struct CatchUnwind { + f: Box) -> Response + Send + Sync>, +} + +impl CatchUnwind { + /// Create a [`CatchUnwind`] which will respond with [`StatusCode::INTERNAL_SERVER_ERROR`] when + /// any panic is caught. + pub fn new() -> Self { + Self::with_response(|_| { + "Internal server error" + .with_status(StatusCode::INTERNAL_SERVER_ERROR) + .into_response() + }) + } + + /// Create a [`CatchUnwind`] with a custom function to generate the response, the function will + /// be passed the caught panic. + pub fn with_response( + response: impl Fn(Box) -> Response + Send + Sync + 'static, + ) -> Self { + Self { + f: Box::new(response), + } + } +} + +impl Middleware for CatchUnwind { + fn handle<'a>(&'a self, cx: Context, next: Next<'a, State>) -> BoxFuture<'a, Response> { + AssertUnwindSafe(next.run(cx)) + .catch_unwind() + .unwrap_or_else(move |err| (self.f)(err)) + .boxed() + } +} + +impl Default for CatchUnwind { + fn default() -> Self { + Self::new() + } +} + +impl std::fmt::Debug for CatchUnwind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CatchUnwind").finish() + } +} diff --git a/tide-panic/src/lib.rs b/tide-panic/src/lib.rs new file mode 100644 index 000000000..a528a66fb --- /dev/null +++ b/tide-panic/src/lib.rs @@ -0,0 +1,17 @@ +//! Advanced panic support for Tide applications. +//! +//! These middleware should not generally be necessary, they are provided for situations in which +//! Tide's default panic handling is not usable by your application. Before using these you should +//! have a good understanding of how the different components involved in [`std::panic`] works. + +#![warn( + nonstandard_style, + rust_2018_idioms, + future_incompatible, + missing_debug_implementations, + missing_docs +)] + +mod catch_unwind; + +pub use crate::catch_unwind::CatchUnwind; diff --git a/tide-querystring/Cargo.toml b/tide-querystring/Cargo.toml new file mode 100644 index 000000000..b46d12c8d --- /dev/null +++ b/tide-querystring/Cargo.toml @@ -0,0 +1,30 @@ +[package] +authors = [ + "Tide Developers" +] +description = "Query string helpers and extensions for Tide" +documentation = "https://docs.rs/tide-querystring" +keywords = ["tide", "web", "async", "helpers", "querystring"] +categories = [ + "network-programming", + "web-programming::http-server", +] +edition = "2018" +license = "MIT OR Apache-2.0" +name = "tide-querystring" +readme = "README.md" +repository = "https://github.com/rustasync/tide" +version = "0.1.0" + +[dependencies] +tide-core = { path = "../tide-core", default-features = false } +futures-preview = "0.3.0-alpha.18" +http = "0.1" +log = "0.4.6" +serde = { version = "1.0.91", features = ["derive"] } +serde_qs = "0.5.0" + +[dev-dependencies] +tide = { path = "../", default-features = false } +http-service = { git = "https://github.com/rustasync/http-service", branch = "tokio-0.2" } +http-service-mock = { git = "https://github.com/rustasync/http-service", branch = "tokio-0.2" } diff --git a/src/querystring.rs b/tide-querystring/src/lib.rs similarity index 66% rename from src/querystring.rs rename to tide-querystring/src/lib.rs index 883108a32..86e65e8ff 100644 --- a/src/querystring.rs +++ b/tide-querystring/src/lib.rs @@ -1,23 +1,45 @@ -use crate::{error::Error, Context}; +//! Crate that provides helpers and extensions for Tide +//! related to query strings. + +#![warn( + nonstandard_style, + rust_2018_idioms, + future_incompatible, + missing_debug_implementations +)] + use http::StatusCode; use serde::Deserialize; +use tide_core::{error::Error, Context}; /// An extension trait for `Context`, providing query string deserialization. +/// +/// # Example +/// +/// Turning the query parameters into a `HashMap`: +/// +/// ``` +/// # use std::collections::HashMap; +/// use tide::querystring::ContextExt; +/// +/// let mut app = tide::App::new(); +/// app.at("/").get(|cx: tide::Context<()>| async move { +/// let map: HashMap = cx.url_query().unwrap(); +/// format!("{:?}", map) +/// }); +/// ``` pub trait ContextExt<'de> { + /// Analyze url and extract query parameters fn url_query>(&'de self) -> Result; } -impl<'de, Data> ContextExt<'de> for Context { - #[inline] +impl<'de, State> ContextExt<'de> for Context { fn url_query>(&'de self) -> Result { let query = self.uri().query(); - if query.is_none() { return Err(Error::from(StatusCode::BAD_REQUEST)); } - - Ok(serde_urlencoded::from_str(query.unwrap()) - .map_err(|_| Error::from(StatusCode::BAD_REQUEST))?) + Ok(serde_qs::from_str(query.unwrap()).map_err(|_| Error::from(StatusCode::BAD_REQUEST))?) } } @@ -27,20 +49,20 @@ mod tests { use futures::executor::block_on; use http_service::Body; use http_service_mock::make_server; - use serde_derive::Deserialize; + use serde::Deserialize; #[derive(Deserialize)] struct Params { msg: String, } - async fn handler(cx: crate::Context<()>) -> Result { + async fn handler(cx: tide::Context<()>) -> Result { let p = cx.url_query::()?; Ok(p.msg) } - fn app() -> crate::App<()> { - let mut app = crate::App::new(); + fn app() -> tide::App<()> { + let mut app = tide::App::new(); app.at("/").get(handler); app } diff --git a/tide-slog/Cargo.toml b/tide-slog/Cargo.toml new file mode 100644 index 000000000..03d19f33c --- /dev/null +++ b/tide-slog/Cargo.toml @@ -0,0 +1,37 @@ +[package] +authors = ["Tide Developers"] +description = "Logging middleware for Tide based on slog" +documentation = "https://docs.rs/tide-slog" +keywords = ["tide", "web", "middleware", "logging", "slog"] +categories = [ + "logging", + "network-programming", + "web-programming::http-server", +] +edition = "2018" +license = "MIT OR Apache-2.0" +name = "tide-slog" +readme = "README.md" +repository = "https://github.com/rustasync/tide" +version = "0.1.0" + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docrs"] + +[features] +scope = ["slog-scope", "slog-scope-futures"] + +[dependencies] +tide-core = { path = "../tide-core", default-features = false } +futures-preview = "0.3.0-alpha.18" +http = "0.1" +slog = "2.4.1" +slog-scope = { version = "4.1.1", optional = true } +slog-scope-futures = { version = "0.1.1", optional = true } + +[dev-dependencies] +tide = { path = "../", default-features = false } +uuid = { version = "0.7.4", default-features = false, features = ["v4"] } +slog-stdlog = "4.0" +slog-scope = "4.1.1" diff --git a/tide-slog/src/lib.rs b/tide-slog/src/lib.rs new file mode 100644 index 000000000..916180e36 --- /dev/null +++ b/tide-slog/src/lib.rs @@ -0,0 +1,41 @@ +//! Crate that provides helpers and/or middlewares for Tide +//! related to structured logging with slog. + +#![cfg_attr(docrs, feature(doc_cfg))] +#![warn( + nonstandard_style, + rust_2018_idioms, + future_incompatible, + missing_debug_implementations +)] + +mod per_request_logger; +mod request_logger; +#[cfg(feature = "scope")] +mod set_slog_scope_logger; + +pub use per_request_logger::PerRequestLogger; +pub use request_logger::RequestLogger; +#[cfg(feature = "scope")] +pub use set_slog_scope_logger::SetSlogScopeLogger; + +use tide_core::Context; + +/// An extension to [`Context`] that provides access to a per-request [`slog::Logger`] +pub trait ContextExt { + /// Returns a [`slog::Logger`] scoped to this request. + /// + /// # Panics + /// + /// Will panic if no [`PerRequestLogger`] middleware has been used to setup the request scoped + /// logger. + fn logger(&self) -> &slog::Logger; +} + +impl ContextExt for Context { + fn logger(&self) -> &slog::Logger { + self.extensions() + .get::() + .expect("PerRequestLogger must be used to populate request logger") + } +} diff --git a/tide-slog/src/per_request_logger.rs b/tide-slog/src/per_request_logger.rs new file mode 100644 index 000000000..822983cac --- /dev/null +++ b/tide-slog/src/per_request_logger.rs @@ -0,0 +1,89 @@ +use futures::future::BoxFuture; +use tide_core::{ + middleware::{Middleware, Next}, + Context, Response, +}; + +/// Middleware that injects a per-request [`slog::Logger`] onto the request [`Context`]. +pub struct PerRequestLogger { + setup: Box) -> slog::Logger) + Send + Sync + 'static>, +} + +impl PerRequestLogger { + /// Initialize this middleware with a function to create a per-request logger. + /// + /// # Examples + /// + /// ## Adding a base16 encoded per-request UUID onto the logger context + /// + /// ``` + /// use slog::o; + /// + /// let mut app = tide::App::new(); + /// + /// let request_id = || uuid::Uuid::new_v4().to_simple().to_string(); + /// + /// let root_logger = slog::Logger::root(slog::Discard, o!()); + /// app.middleware(tide_slog::PerRequestLogger::with_setup(move |_cx| root_logger.new(o! { + /// "request" => request_id(), + /// }))); + /// ``` + /// + /// ## Taking an externally provided request id from headers for the logger context + /// + /// ``` + /// use slog::o; + /// + /// let mut app = tide::App::new(); + /// + /// let root_logger = slog::Logger::root(slog::Discard, o!()); + /// app.middleware(tide_slog::PerRequestLogger::with_setup(move |cx| root_logger.new(o! { + /// "request" => cx.headers().get("Request-Id").unwrap().to_str().unwrap().to_owned(), + /// }))); + /// ``` + pub fn with_setup( + setup: impl (Fn(&mut Context) -> slog::Logger) + Send + Sync + 'static, + ) -> Self { + Self { + setup: Box::new(setup), + } + } + + /// Initialize this middleware with a logger that will provided to each request. + /// + /// # Examples + /// + /// ``` + /// use slog::o; + /// + /// let mut app = tide::App::new(); + /// + /// let root_logger = slog::Logger::root(slog::Discard, o!()); + /// app.middleware(tide_slog::PerRequestLogger::with_logger(root_logger)); + /// ``` + pub fn with_logger(logger: slog::Logger) -> Self { + Self { + setup: Box::new(move |_cx| logger.clone()), + } + } +} + +impl Middleware for PerRequestLogger { + fn handle<'a>( + &'a self, + mut cx: Context, + next: Next<'a, State>, + ) -> BoxFuture<'a, Response> { + let logger = (self.setup)(&mut cx); + cx.extensions_mut().insert(logger); + next.run(cx) + } +} + +impl std::fmt::Debug for PerRequestLogger { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PerRequestLogger") + .field("setup", &"[closure]") + .finish() + } +} diff --git a/tide-slog/src/request_logger.rs b/tide-slog/src/request_logger.rs new file mode 100644 index 000000000..f10682dea --- /dev/null +++ b/tide-slog/src/request_logger.rs @@ -0,0 +1,74 @@ +use crate::ContextExt; +use futures::future::BoxFuture; +use slog::{info, trace}; +use tide_core::{ + middleware::{Middleware, Next}, + Context, Response, +}; + +/// Middleware that logs minimal request details to the current request's [`slog::Logger`]. +/// +/// Relies on having a [`PerRequestLogger`][crate::PerRequestLogger] middleware instance setup +/// beforehand to get the logger from. +/// +/// # Examples +/// +/// ``` +/// use slog::o; +/// +/// let mut app = tide::App::new(); +/// +/// let root_logger = slog::Logger::root(slog::Discard, o!()); +/// app.middleware(tide_slog::PerRequestLogger::with_logger(root_logger)); +/// app.middleware(tide_slog::RequestLogger::new()); +/// ``` +#[derive(Debug)] +pub struct RequestLogger { + // In case we want to make this configurable in the future + _reserved: (), +} + +impl RequestLogger { + /// Create a new [`RequestLogger`] instance. + pub fn new() -> Self { + Self { _reserved: () } + } +} + +impl Middleware for RequestLogger { + fn handle<'a>(&'a self, cx: Context, next: Next<'a, State>) -> BoxFuture<'a, Response> { + Box::pin(async move { + let logger = cx.logger().clone(); + let path = cx.uri().path().to_owned(); + let method = cx.method().as_str().to_owned(); + + trace!( + logger, + "IN => {method} {path}", + method = &method, + path = &path + ); + + let start = std::time::Instant::now(); + let res = next.run(cx).await; + let status = res.status(); + + info!( + logger, + "{method} {path} {status} {elapsed}ms", + method = &method, + path = &path, + status = status.as_str(), + elapsed = start.elapsed().as_millis(), + ); + + res + }) + } +} + +impl Default for RequestLogger { + fn default() -> Self { + Self::new() + } +} diff --git a/tide-slog/src/set_slog_scope_logger.rs b/tide-slog/src/set_slog_scope_logger.rs new file mode 100644 index 000000000..ec771126f --- /dev/null +++ b/tide-slog/src/set_slog_scope_logger.rs @@ -0,0 +1,51 @@ +use crate::ContextExt; +use futures::future::{BoxFuture, FutureExt as _}; +use slog_scope_futures::FutureExt as _; +use tide_core::{ + middleware::{Middleware, Next}, + Response, +}; + +#[cfg_attr(docrs, doc(cfg(feature = "scope")))] +/// Middleware that ensures the current request's [`slog::Logger`] will be accessible using +/// [`slog-scope::logger`] during all following processing of the request. +/// +/// Relies on having a [`PerRequestLogger`][crate::PerRequestLogger] middleware instance setup +/// beforehand to get the logger from. +/// +/// This can be used along with [`slog-stdlog`](https://docs.rs/slog-stdlog/) to +/// integrate per-request logging with middleware that use [`log`](https://docs.rs/log)`. +/// +/// # Examples +/// +/// ``` +/// use slog::o; +/// +/// let root_logger = slog::Logger::root(slog::Discard, o!()); +/// +/// let _guard = slog_scope::set_global_logger(root_logger.clone()); +/// slog_stdlog::init()?; +/// +/// let mut app = tide::App::new(); +/// +/// app.middleware(tide_slog::PerRequestLogger::with_logger(root_logger)); +/// app.middleware(tide_slog::SetSlogScopeLogger); +/// +/// // The default tide request logger uses `log`, but since we are using `slog-stdlog` and run +/// // `SetSlogScopeLogger` first it will be redirected into the per-request logger instance. +/// app.middleware(tide::middleware::RequestLogger::new()); +/// # Ok::<(), Box>(()) +/// ``` +#[derive(Debug)] +pub struct SetSlogScopeLogger; + +impl Middleware for SetSlogScopeLogger { + fn handle<'a>( + &'a self, + cx: tide_core::Context, + next: Next<'a, State>, + ) -> BoxFuture<'a, Response> { + let logger = cx.logger().clone(); + next.run(cx).with_logger(logger).boxed() + } +}