diff --git a/Cargo.toml b/Cargo.toml index 185f3fc3..2a9883ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ path = "src/lib.rs" [workspace] members = [ ".", + "awc", "actix-files", "actix-session", "actix-web-actors", @@ -37,7 +38,10 @@ members = [ features = ["ssl", "tls", "rust-tls", "brotli", "flate2-c", "cookies"] [features] -default = ["brotli", "flate2-c", "cookies"] +default = ["brotli", "flate2-c", "cookies", "client"] + +# http client +client = ["awc"] # brotli encoding, requires c compiler brotli = ["brotli2"] @@ -70,6 +74,7 @@ actix-web-codegen = { path="actix-web-codegen" } actix-http = { git = "https://github.com/actix/actix-http.git", features=["fail"] } actix-server = "0.4.1" actix-server-config = "0.1.0" +awc = { path = "awc", optional = true } bytes = "0.4" derive_more = "0.14" diff --git a/awc/Cargo.toml b/awc/Cargo.toml new file mode 100644 index 00000000..f316d062 --- /dev/null +++ b/awc/Cargo.toml @@ -0,0 +1,49 @@ +[package] +name = "awc" +version = "0.1.0" +authors = ["Nikolay Kim "] +description = "Actix web client." +readme = "README.md" +keywords = ["http", "web", "framework", "async", "futures"] +homepage = "https://actix.rs" +repository = "https://github.com/actix/actix-web.git" +documentation = "https://docs.rs/awc/" +license = "MIT/Apache-2.0" +exclude = [".gitignore", ".travis.yml", ".cargo/config", "appveyor.yml"] +workspace = ".." +edition = "2018" + +[lib] +name = "awc" +path = "src/lib.rs" + +[features] +default = ["cookies"] + +# openssl +ssl = ["openssl", "actix-http/ssl"] + +# cookies integration +cookies = ["cookie", "actix-http/cookies"] + +[dependencies] +actix-service = "0.3.4" +actix-http = { git = "https://github.com/actix/actix-http.git" } +actix-codec = "0.1.1" +bytes = "0.4" +futures = "0.1" +log =" 0.4" +percent-encoding = "1.0" +serde = "1.0" +serde_json = "1.0" +serde_urlencoded = "0.5.3" + +cookie = { version="0.11", features=["percent-encode"], optional = true } +openssl = { version="0.10", optional = true } + +[dev-dependencies] +env_logger = "0.6" +mime = "0.3" +actix-rt = "0.2.1" +actix-http = { git = "https://github.com/actix/actix-http.git", features=["ssl"] } +actix-http-test = { git = "https://github.com/actix/actix-http.git", features=["ssl"] } diff --git a/awc/src/builder.rs b/awc/src/builder.rs new file mode 100644 index 00000000..3104e524 --- /dev/null +++ b/awc/src/builder.rs @@ -0,0 +1,85 @@ +use std::cell::RefCell; +use std::fmt; +use std::rc::Rc; + +use actix_http::client::Connector; +use actix_http::http::{header::IntoHeaderValue, HeaderMap, HeaderName, HttpTryFrom}; + +use crate::connect::{Connect, ConnectorWrapper}; +use crate::Client; + +/// An HTTP Client builder +/// +/// This type can be used to construct an instance of `Client` through a +/// builder-like pattern. +pub struct ClientBuilder { + connector: Rc>, + default_headers: bool, + allow_redirects: bool, + max_redirects: usize, + headers: HeaderMap, +} + +impl ClientBuilder { + pub fn new() -> Self { + ClientBuilder { + default_headers: true, + allow_redirects: true, + max_redirects: 10, + headers: HeaderMap::new(), + connector: Rc::new(RefCell::new(ConnectorWrapper( + Connector::new().service(), + ))), + } + } + + /// Do not follow redirects. + /// + /// Redirects are allowed by default. + pub fn disable_redirects(mut self) -> Self { + self.allow_redirects = false; + self + } + + /// Set max number of redirects. + /// + /// Max redirects is set to 10 by default. + pub fn max_redirects(mut self, num: usize) -> Self { + self.max_redirects = num; + self + } + + /// Do not add default request headers. + /// By default `Accept-Encoding` and `User-Agent` headers are set. + pub fn skip_default_headers(mut self) -> Self { + self.default_headers = false; + self + } + + /// Add default header. This header adds to every request. + pub fn header(mut self, key: K, value: V) -> Self + where + HeaderName: HttpTryFrom, + >::Error: fmt::Debug, + V: IntoHeaderValue, + V::Error: fmt::Debug, + { + match HeaderName::try_from(key) { + Ok(key) => match value.try_into() { + Ok(value) => { + self.headers.append(key, value); + } + Err(e) => log::error!("Header value error: {:?}", e), + }, + Err(e) => log::error!("Header name error: {:?}", e), + } + self + } + + /// Finish build process and create `Client`. + pub fn finish(self) -> Client { + Client { + connector: self.connector, + } + } +} diff --git a/awc/src/connect.rs b/awc/src/connect.rs new file mode 100644 index 00000000..c52bc34c --- /dev/null +++ b/awc/src/connect.rs @@ -0,0 +1,38 @@ +use actix_http::body::Body; +use actix_http::client::{ClientResponse, ConnectError, Connection, SendRequestError}; +use actix_http::{http, RequestHead}; +use actix_service::Service; +use futures::Future; + +pub(crate) struct ConnectorWrapper(pub T); + +pub(crate) trait Connect { + fn send_request( + &mut self, + head: RequestHead, + body: Body, + ) -> Box>; +} + +impl Connect for ConnectorWrapper +where + T: Service, + T::Response: Connection, + ::Future: 'static, + T::Future: 'static, +{ + fn send_request( + &mut self, + head: RequestHead, + body: Body, + ) -> Box> { + Box::new( + self.0 + // connect to the host + .call(head.uri.clone()) + .from_err() + // send request + .and_then(move |connection| connection.send_request(head, body)), + ) + } +} diff --git a/awc/src/lib.rs b/awc/src/lib.rs new file mode 100644 index 00000000..b1a309c4 --- /dev/null +++ b/awc/src/lib.rs @@ -0,0 +1,120 @@ +use std::cell::RefCell; +use std::rc::Rc; + +pub use actix_http::client::{ + ClientResponse, ConnectError, InvalidUrl, SendRequestError, +}; +pub use actix_http::http; + +use actix_http::client::Connector; +use actix_http::http::{HttpTryFrom, Method, Uri}; + +mod builder; +mod connect; +mod request; + +pub use self::builder::ClientBuilder; +pub use self::request::ClientRequest; + +use self::connect::{Connect, ConnectorWrapper}; + +/// An HTTP Client Request +/// +/// ```rust +/// # use futures::future::{Future, lazy}; +/// use actix_rt::System; +/// use awc::Client; +/// +/// fn main() { +/// System::new("test").block_on(lazy(|| { +/// let mut client = Client::default(); +/// +/// client.get("http://www.rust-lang.org") // <- Create request builder +/// .header("User-Agent", "Actix-web") +/// .send() // <- Send http request +/// .map_err(|_| ()) +/// .and_then(|response| { // <- server http response +/// println!("Response: {:?}", response); +/// Ok(()) +/// }) +/// })); +/// } +/// ``` +#[derive(Clone)] +pub struct Client { + pub(crate) connector: Rc>, +} + +impl Default for Client { + fn default() -> Self { + Client { + connector: Rc::new(RefCell::new(ConnectorWrapper( + Connector::new().service(), + ))), + } + } +} + +impl Client { + /// Build client instance. + pub fn build() -> ClientBuilder { + ClientBuilder::new() + } + + /// Construct HTTP request. + pub fn request(&self, method: Method, url: U) -> ClientRequest + where + Uri: HttpTryFrom, + { + ClientRequest::new(method, url, self.connector.clone()) + } + + pub fn get(&self, url: U) -> ClientRequest + where + Uri: HttpTryFrom, + { + ClientRequest::new(Method::GET, url, self.connector.clone()) + } + + pub fn head(&self, url: U) -> ClientRequest + where + Uri: HttpTryFrom, + { + ClientRequest::new(Method::HEAD, url, self.connector.clone()) + } + + pub fn put(&self, url: U) -> ClientRequest + where + Uri: HttpTryFrom, + { + ClientRequest::new(Method::PUT, url, self.connector.clone()) + } + + pub fn post(&self, url: U) -> ClientRequest + where + Uri: HttpTryFrom, + { + ClientRequest::new(Method::POST, url, self.connector.clone()) + } + + pub fn patch(&self, url: U) -> ClientRequest + where + Uri: HttpTryFrom, + { + ClientRequest::new(Method::PATCH, url, self.connector.clone()) + } + + pub fn delete(&self, url: U) -> ClientRequest + where + Uri: HttpTryFrom, + { + ClientRequest::new(Method::DELETE, url, self.connector.clone()) + } + + pub fn options(&self, url: U) -> ClientRequest + where + Uri: HttpTryFrom, + { + ClientRequest::new(Method::OPTIONS, url, self.connector.clone()) + } +} diff --git a/awc/src/request.rs b/awc/src/request.rs new file mode 100644 index 00000000..90dfebcc --- /dev/null +++ b/awc/src/request.rs @@ -0,0 +1,471 @@ +use std::cell::RefCell; +use std::fmt; +use std::io::Write; +use std::rc::Rc; + +use bytes::{BufMut, Bytes, BytesMut}; +#[cfg(feature = "cookies")] +use cookie::{Cookie, CookieJar}; +use futures::future::{err, Either}; +use futures::{Future, Stream}; +use serde::Serialize; +use serde_json; + +use actix_http::body::{Body, BodyStream}; +use actix_http::client::{ClientResponse, InvalidUrl, SendRequestError}; +use actix_http::http::header::{self, Header, IntoHeaderValue}; +use actix_http::http::{ + uri, ConnectionType, Error as HttpError, HeaderName, HeaderValue, HttpTryFrom, + Method, Uri, Version, +}; +use actix_http::{Error, Head, RequestHead}; + +use crate::Connect; + +/// An HTTP Client request builder +/// +/// This type can be used to construct an instance of `ClientRequest` through a +/// builder-like pattern. +/// +/// ```rust +/// use futures::future::{Future, lazy}; +/// use actix_rt::System; +/// use actix_http::client; +/// +/// fn main() { +/// System::new("test").block_on(lazy(|| { +/// let mut connector = client::Connector::new().service(); +/// +/// client::ClientRequest::get("http://www.rust-lang.org") // <- Create request builder +/// .header("User-Agent", "Actix-web") +/// .finish().unwrap() +/// .send(&mut connector) // <- Send http request +/// .map_err(|_| ()) +/// .and_then(|response| { // <- server http response +/// println!("Response: {:?}", response); +/// Ok(()) +/// }) +/// })); +/// } +/// ``` +pub struct ClientRequest { + head: RequestHead, + err: Option, + #[cfg(feature = "cookies")] + cookies: Option, + default_headers: bool, + connector: Rc>, +} + +impl ClientRequest { + /// Create new client request builder. + pub(crate) fn new( + method: Method, + uri: U, + connector: Rc>, + ) -> Self + where + Uri: HttpTryFrom, + { + let mut err = None; + let mut head = RequestHead::default(); + head.method = method; + + match Uri::try_from(uri) { + Ok(uri) => head.uri = uri, + Err(e) => err = Some(e.into()), + } + + ClientRequest { + head, + err, + connector, + #[cfg(feature = "cookies")] + cookies: None, + default_headers: true, + } + } + + /// Set HTTP method of this request. + #[inline] + pub fn method(mut self, method: Method) -> Self { + self.head.method = method; + self + } + + #[doc(hidden)] + /// Set HTTP version of this request. + /// + /// By default requests's HTTP version depends on network stream + #[inline] + pub fn version(mut self, version: Version) -> Self { + self.head.version = version; + self + } + + /// Set a header. + /// + /// ```rust + /// fn main() { + /// # actix_rt::System::new("test").block_on(futures::future::lazy(|| { + /// let req = awc::Client::new() + /// .get("http://www.rust-lang.org") + /// .set(awc::http::header::Date::now()) + /// .set(awc::http::header::ContentType(mime::TEXT_HTML)); + /// # Ok::<_, ()>(()) + /// # })); + /// } + /// ``` + pub fn set(mut self, hdr: H) -> Self { + match hdr.try_into() { + Ok(value) => { + self.head.headers.insert(H::name(), value); + } + Err(e) => self.err = Some(e.into()), + } + self + } + + /// Append a header. + /// + /// Header gets appended to existing header. + /// To override header use `set_header()` method. + /// + /// ```rust + /// # extern crate actix_http; + /// # + /// use actix_http::{client, http}; + /// + /// fn main() { + /// let req = client::ClientRequest::build() + /// .header("X-TEST", "value") + /// .header(http::header::CONTENT_TYPE, "application/json") + /// .finish() + /// .unwrap(); + /// } + /// ``` + pub fn header(mut self, key: K, value: V) -> Self + where + HeaderName: HttpTryFrom, + V: IntoHeaderValue, + { + match HeaderName::try_from(key) { + Ok(key) => match value.try_into() { + Ok(value) => { + self.head.headers.append(key, value); + } + Err(e) => self.err = Some(e.into()), + }, + Err(e) => self.err = Some(e.into()), + } + self + } + + /// Insert a header, replaces existing header. + pub fn set_header(mut self, key: K, value: V) -> Self + where + HeaderName: HttpTryFrom, + V: IntoHeaderValue, + { + match HeaderName::try_from(key) { + Ok(key) => match value.try_into() { + Ok(value) => { + self.head.headers.insert(key, value); + } + Err(e) => self.err = Some(e.into()), + }, + Err(e) => self.err = Some(e.into()), + } + self + } + + /// Insert a header only if it is not yet set. + pub fn set_header_if_none(mut self, key: K, value: V) -> Self + where + HeaderName: HttpTryFrom, + V: IntoHeaderValue, + { + match HeaderName::try_from(key) { + Ok(key) => { + if !self.head.headers.contains_key(&key) { + match value.try_into() { + Ok(value) => { + self.head.headers.insert(key, value); + } + Err(e) => self.err = Some(e.into()), + } + } + } + Err(e) => self.err = Some(e.into()), + } + self + } + + /// Close connection + #[inline] + pub fn close_connection(mut self) -> Self { + self.head.set_connection_type(ConnectionType::Close); + self + } + + /// Set request's content type + #[inline] + pub fn content_type(mut self, value: V) -> Self + where + HeaderValue: HttpTryFrom, + { + match HeaderValue::try_from(value) { + Ok(value) => { + let _ = self.head.headers.insert(header::CONTENT_TYPE, value); + } + Err(e) => self.err = Some(e.into()), + } + self + } + + /// Set content length + #[inline] + pub fn content_length(self, len: u64) -> Self { + let mut wrt = BytesMut::new().writer(); + let _ = write!(wrt, "{}", len); + self.header(header::CONTENT_LENGTH, wrt.get_mut().take().freeze()) + } + + #[cfg(feature = "cookies")] + /// Set a cookie + /// + /// ```rust + /// # use actix_rt::System; + /// # use futures::future::{lazy, Future}; + /// fn main() { + /// System::new("test").block_on(lazy(|| { + /// awc::Client::new().get("https://www.rust-lang.org") + /// .cookie( + /// awc::http::Cookie::build("name", "value") + /// .domain("www.rust-lang.org") + /// .path("/") + /// .secure(true) + /// .http_only(true) + /// .finish(), + /// ) + /// .send() + /// .map_err(|_| ()) + /// .and_then(|response| { + /// println!("Response: {:?}", response); + /// Ok(()) + /// }) + /// })); + /// } + /// ``` + pub fn cookie<'c>(mut self, cookie: Cookie<'c>) -> Self { + if self.cookies.is_none() { + let mut jar = CookieJar::new(); + jar.add(cookie.into_owned()); + self.cookies = Some(jar) + } else { + self.cookies.as_mut().unwrap().add(cookie.into_owned()); + } + self + } + + /// Do not add default request headers. + /// By default `Accept-Encoding` and `User-Agent` headers are set. + pub fn no_default_headers(mut self) -> Self { + self.default_headers = false; + self + } + + /// This method calls provided closure with builder reference if + /// value is `true`. + pub fn if_true(mut self, value: bool, f: F) -> Self + where + F: FnOnce(&mut ClientRequest), + { + if value { + f(&mut self); + } + self + } + + /// This method calls provided closure with builder reference if + /// value is `Some`. + pub fn if_some(mut self, value: Option, f: F) -> Self + where + F: FnOnce(T, &mut ClientRequest), + { + if let Some(val) = value { + f(val, &mut self); + } + self + } + + /// Complete request construction and send body. + pub fn send_body( + mut self, + body: B, + ) -> impl Future + where + B: Into, + { + if let Some(e) = self.err.take() { + return Either::A(err(e.into())); + } + + let mut slf = if self.default_headers { + // enable br only for https + let https = self + .head + .uri + .scheme_part() + .map(|s| s == &uri::Scheme::HTTPS) + .unwrap_or(true); + + let mut slf = if https { + self.set_header_if_none(header::ACCEPT_ENCODING, "br, gzip, deflate") + } else { + self.set_header_if_none(header::ACCEPT_ENCODING, "gzip, deflate") + }; + + // set request host header + if let Some(host) = slf.head.uri.host() { + if !slf.head.headers.contains_key(header::HOST) { + let mut wrt = BytesMut::with_capacity(host.len() + 5).writer(); + + let _ = match slf.head.uri.port_u16() { + None | Some(80) | Some(443) => write!(wrt, "{}", host), + Some(port) => write!(wrt, "{}:{}", host, port), + }; + + match wrt.get_mut().take().freeze().try_into() { + Ok(value) => { + slf.head.headers.insert(header::HOST, value); + } + Err(e) => slf.err = Some(e.into()), + } + } + } + + // user agent + slf.set_header_if_none( + header::USER_AGENT, + concat!("actix-http/", env!("CARGO_PKG_VERSION")), + ) + } else { + self + }; + + #[allow(unused_mut)] + let mut head = slf.head; + + #[cfg(feature = "cookies")] + { + use percent_encoding::{percent_encode, USERINFO_ENCODE_SET}; + use std::fmt::Write; + + // set cookies + if let Some(ref mut jar) = slf.cookies { + let mut cookie = String::new(); + for c in jar.delta() { + let name = percent_encode(c.name().as_bytes(), USERINFO_ENCODE_SET); + let value = + percent_encode(c.value().as_bytes(), USERINFO_ENCODE_SET); + let _ = write!(&mut cookie, "; {}={}", name, value); + } + head.headers.insert( + header::COOKIE, + HeaderValue::from_str(&cookie.as_str()[2..]).unwrap(), + ); + } + } + + let uri = head.uri.clone(); + + // validate uri + if uri.host().is_none() { + Either::A(err(InvalidUrl::MissingHost.into())) + } else if uri.scheme_part().is_none() { + Either::A(err(InvalidUrl::MissingScheme.into())) + } else if let Some(scheme) = uri.scheme_part() { + match scheme.as_str() { + "http" | "ws" | "https" | "wss" => { + Either::B(slf.connector.borrow_mut().send_request(head, body.into())) + } + _ => Either::A(err(InvalidUrl::UnknownScheme.into())), + } + } else { + Either::A(err(InvalidUrl::UnknownScheme.into())) + } + } + + /// Set a JSON body and generate `ClientRequest` + pub fn send_json( + self, + value: T, + ) -> impl Future { + let body = match serde_json::to_string(&value) { + Ok(body) => body, + Err(e) => return Either::A(err(Error::from(e).into())), + }; + // set content-type + let slf = if !self.head.headers.contains_key(header::CONTENT_TYPE) { + self.header(header::CONTENT_TYPE, "application/json") + } else { + self + }; + + Either::B(slf.send_body(Body::Bytes(Bytes::from(body)))) + } + + /// Set a urlencoded body and generate `ClientRequest` + /// + /// `ClientRequestBuilder` can not be used after this call. + pub fn send_form( + self, + value: T, + ) -> impl Future { + let body = match serde_urlencoded::to_string(&value) { + Ok(body) => body, + Err(e) => return Either::A(err(Error::from(e).into())), + }; + + let slf = if !self.head.headers.contains_key(header::CONTENT_TYPE) { + self.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + } else { + self + }; + + Either::B(slf.send_body(Body::Bytes(Bytes::from(body)))) + } + + /// Set an streaming body and generate `ClientRequest`. + pub fn send_stream( + self, + stream: S, + ) -> impl Future + where + S: Stream + 'static, + E: Into + 'static, + { + self.send_body(Body::from_message(BodyStream::new(stream))) + } + + /// Set an empty body and generate `ClientRequest`. + pub fn send(self) -> impl Future { + self.send_body(Body::Empty) + } +} + +impl fmt::Debug for ClientRequest { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + writeln!( + f, + "\nClientRequest {:?} {}:{}", + self.head.version, self.head.method, self.head.uri + )?; + writeln!(f, " headers:")?; + for (key, val) in self.head.headers.iter() { + writeln!(f, " {:?}: {:?}", key, val)?; + } + Ok(()) + } +} diff --git a/src/lib.rs b/src/lib.rs index 8ae7156c..1bf29213 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -63,6 +63,7 @@ //! //! ## Package feature //! +//! * `client` - enables http client //! * `tls` - enables ssl support via `native-tls` crate //! * `ssl` - enables ssl support via `openssl` crate, supports `http/2` //! * `rust-tls` - enables ssl support via `rustls` crate, supports `http/2` @@ -105,6 +106,9 @@ extern crate actix_web_codegen; #[doc(hidden)] pub use actix_web_codegen::*; +#[cfg(feature = "client")] +pub use awc as client; + // re-export for convenience pub use actix_http::Response as HttpResponse; pub use actix_http::{http, Error, HttpMessage, ResponseError, Result};