diff options
Diffstat (limited to 'net/crimp/src')
-rw-r--r-- | net/crimp/src/lib.rs | 537 | ||||
-rw-r--r-- | net/crimp/src/tests.rs | 185 |
2 files changed, 722 insertions, 0 deletions
diff --git a/net/crimp/src/lib.rs b/net/crimp/src/lib.rs new file mode 100644 index 000000000000..4dd4d6c31bd7 --- /dev/null +++ b/net/crimp/src/lib.rs @@ -0,0 +1,537 @@ +// crimp - Higher-level Rust cURL API +// +// Copyright (C) 2019 Vincent Ambo +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +//! # crimp +//! +//! This library provides a simplified API over the [cURL Rust +//! bindings][] that resemble that of higher-level libraries such as +//! [reqwest][]. All calls are synchronous. +//! +//! `crimp` is intended to be used in situations where HTTP client +//! functionality is desired without adding a significant number of +//! dependencies or sacrificing too much usability. +//! +//! Using `crimp` to make HTTP requests is done using a simple +//! builder-pattern style API. For example, to make a `GET`-request +//! and print the result to `stdout`: +//! +//! ```rust +//! use crimp::Request; +//! +//! let response = Request::get("http://httpbin.org/get") +//! .user_agent("crimp test suite") +//! .unwrap() +//! .send() +//! .unwrap() +//! .as_string() +//! .unwrap(); +//! +//! println!("Status: {}\nBody: {}", response.status, response.body); +//! # assert_eq!(response.status, 200); +//! ``` +//! +//! If a feature from the underlying cURL library is missing, the +//! `Request::raw` method can be used as an escape hatch to deal with +//! the handle directly. Should you find yourself doing this, please +//! [file an issue][]. +//! +//! `crimp` does not currently expose functionality for re-using a +//! cURL Easy handle, meaning that keep-alive of HTTP connections and +//! the like is not supported. +//! +//! ## Cargo features +//! +//! All optional features are enabled by default. +//! +//! * `json`: Adds `Request::json` and `Response::as_json` methods which can be used for convenient +//! serialisation of request/response bodies using `serde_json`. This feature adds a dependency on +//! the `serde` and `serde_json` crates. +//! +//! ## Initialisation +//! +//! It is recommended to call the underlying `curl::init` method +//! (re-exported as `crimp::init`) when launching your application to +//! initialise the cURL library. This is not required, but will +//! otherwise occur the first time a request is made. +//! +//! [cURL Rust bindings]: https://docs.rs/curl +//! [reqwest]: https://docs.rs/reqwest +//! [file an issue]: https://github.com/tazjin/crimp/issues + +extern crate curl; + +#[cfg(feature = "json")] +extern crate serde; +#[cfg(feature = "json")] +extern crate serde_json; + +pub use curl::init; + +use curl::easy::{Auth, Easy, Form, List, ReadError, Transfer, WriteError}; +use std::collections::HashMap; +use std::io::Write; +use std::path::Path; +use std::string::{FromUtf8Error, ToString}; +use std::time::Duration; + +#[cfg(feature = "json")] +use serde::de::DeserializeOwned; +#[cfg(feature = "json")] +use serde::Serialize; + +#[cfg(test)] +mod tests; + +/// HTTP method to use for the request. +enum Method { + Get, + Post, + Put, + Patch, + Delete, +} + +/// Certificate types for client-certificate key pairs. +pub enum CertType { + P12, + PEM, + DER, +} + +/// Builder structure for an HTTP request. +/// +/// This is the primary API-type in `crimp`. After creating a new +/// request its parameters are modified using the various builder +/// methods until it is consumed by `send()`. +pub struct Request<'a> { + url: &'a str, + method: Method, + handle: Easy, + headers: List, + body: Body<'a>, +} + +enum Body<'a> { + NoBody, + Form(Form), + + Bytes { + content_type: &'a str, + data: &'a [u8], + }, + + #[cfg(feature = "json")] + Json(Vec<u8>), +} + +/// HTTP responses structure containing response data and headers. +/// +/// By default the `send()` function of the `Request` structure will +/// return a `Response<Vec<u8>>`. Convenience helpers exist for +/// decoding a string via `Response::as_string` or to a +/// `serde`-compatible type with `Response::as_json` (if the +/// `json`-feature is enabled). +#[derive(Debug, PartialEq)] +pub struct Response<T> { + /// HTTP status code of the response. + pub status: u32, + + /// HTTP headers returned from the remote. + pub headers: HashMap<String, String>, + + /// Body data from the HTTP response. + pub body: T, +} + +impl<'a> Request<'a> { + /// Initiate an HTTP request with the given method and URL. + fn new(method: Method, url: &'a str) -> Self { + Request { + url, + method, + handle: Easy::new(), + headers: List::new(), + body: Body::NoBody, + } + } + + /// Initiate a GET request with the given URL. + pub fn get(url: &'a str) -> Self { + Request::new(Method::Get, url) + } + + /// Initiate a POST request with the given URL. + pub fn post(url: &'a str) -> Self { + Request::new(Method::Post, url) + } + + /// Initiate a PUT request with the given URL. + pub fn put(url: &'a str) -> Self { + Request::new(Method::Put, url) + } + + /// Initiate a PATCH request with the given URL. + pub fn patch(url: &'a str) -> Self { + Request::new(Method::Patch, url) + } + + /// Initiate a DELETE request with the given URL. + pub fn delete(url: &'a str) -> Self { + Request::new(Method::Delete, url) + } + + /// Add an HTTP header to a request. + pub fn header(mut self, k: &str, v: &str) -> Result<Self, curl::Error> { + self.headers.append(&format!("{}: {}", k, v))?; + Ok(self) + } + + /// Set the `User-Agent` for this request. By default this will be + /// set to cURL's standard user agent. + pub fn user_agent(mut self, agent: &str) -> Result<Self, curl::Error> { + self.handle.useragent(agent)?; + Ok(self) + } + + /// Set the `Authorization` header to a `Bearer` value with the + /// supplied token. + pub fn bearer_auth(mut self, token: &str) -> Result<Self, curl::Error> { + self.headers + .append(&format!("Authorization: Bearer {}", token))?; + Ok(self) + } + + /// Set the `Authorization` header to a basic authentication value + /// from the supplied username and password. + pub fn basic_auth(mut self, username: &str, password: &str) -> Result<Self, curl::Error> { + let mut auth = Auth::new(); + auth.basic(true); + self.handle.username(username)?; + self.handle.password(password)?; + self.handle.http_auth(&auth)?; + Ok(self) + } + + /// Configure a TLS client certificate on the request. + /// + /// Depending on whether the certificate file contains the private + /// key or not, calling `tls_client_key` may be required in + /// addition. + /// + /// Consult the documentation for the `ssl_cert` and `ssl_key` + /// functions in `curl::easy::Easy2` for details on supported + /// formats and defaults. + pub fn tls_client_cert<P: AsRef<Path>>( + mut self, + cert_type: CertType, + cert: P, + ) -> Result<Self, curl::Error> { + self.handle.ssl_cert(cert)?; + self.handle.ssl_cert_type(match cert_type { + CertType::P12 => "P12", + CertType::PEM => "PEM", + CertType::DER => "DER", + })?; + + Ok(self) + } + + /// Configure a TLS client certificate key on the request. + /// + /// Note that this does **not** need to be called again for + /// PKCS12-encoded key pairs which are set via `tls_client_cert`. + /// + /// Currently only PEM-encoded key files are supported. + pub fn tls_client_key<P: AsRef<Path>>(mut self, key: P) -> Result<Self, curl::Error> { + self.handle.ssl_key(key)?; + Ok(self) + } + + /// Configure an encryption password for a TLS client certificate + /// key on the request. + /// + /// This is required in case of an encrypted private key that + /// should be used. + pub fn tls_key_password(mut self, password: &str) -> Result<Self, curl::Error> { + self.handle.key_password(password)?; + Ok(self) + } + + /// Configure a timeout for the request after which the request + /// will be aborted. + pub fn timeout(mut self, timeout: Duration) -> Result<Self, curl::Error> { + self.handle.timeout(timeout)?; + Ok(self) + } + + /// Set custom configuration on the cURL `Easy` handle. + /// + /// This function can be considered an "escape-hatch" from the + /// high-level API which lets users access the internal + /// `curl::easy::Easy` handle and configure options on it + /// directly. + /// + /// ``` + /// # use crimp::Request; + /// let response = Request::get("https://httpbin.org/get") + /// .with_handle(|mut handle| handle.referer("Example-Referer")) + /// .unwrap() + /// .send() + /// .unwrap(); + /// # + /// # assert!(response.is_success()); + /// ``` + pub fn with_handle<F>(mut self, function: F) -> Result<Self, curl::Error> + where + F: FnOnce(&mut Easy) -> Result<(), curl::Error>, + { + function(&mut self.handle)?; + Ok(self) + } + + /// Add a byte-array body to a request using the specified + /// `Content-Type`. + pub fn body(mut self, content_type: &'a str, data: &'a [u8]) -> Self { + self.body = Body::Bytes { data, content_type }; + self + } + + /// Add a form-encoded body to a request using the `curl::Form` + /// type. + /// + /// ```rust + /// # extern crate curl; + /// # extern crate serde_json; + /// # use crimp::*; + /// # use serde_json::{Value, json}; + /// use curl::easy::Form; + /// + /// let mut form = Form::new(); + /// form.part("some-name") + /// .contents("some-data".as_bytes()) + /// .add() + /// .unwrap(); + /// + /// let response = Request::post("https://httpbin.org/post") + /// .user_agent("crimp test suite") + /// .unwrap() + /// .form(form) + /// .send() + /// .unwrap(); + /// # + /// # assert_eq!(200, response.status, "form POST should succeed"); + /// # assert_eq!( + /// # response.as_json::<Value>().unwrap().body.get("form").unwrap(), + /// # &json!({"some-name": "some-data"}), + /// # "posted form data should match", + /// # ); + /// ``` + /// + /// See the documentation of `curl::easy::Form` for details on how + /// to construct a form body. + pub fn form(mut self, form: Form) -> Self { + self.body = Body::Form(form); + self + } + + /// Add a JSON-encoded body from a serializable type. + #[cfg(feature = "json")] + pub fn json<T: Serialize>(mut self, body: &T) -> Result<Self, serde_json::Error> { + let json = serde_json::to_vec(body)?; + self.body = Body::Json(json); + Ok(self) + } + + /// Send the HTTP request and return a response structure + /// containing the raw body. + pub fn send(mut self) -> Result<Response<Vec<u8>>, curl::Error> { + // Configure request basics: + self.handle.url(self.url)?; + + match self.method { + Method::Get => self.handle.get(true)?, + Method::Post => self.handle.post(true)?, + Method::Put => self.handle.put(true)?, + Method::Patch => self.handle.custom_request("PATCH")?, + Method::Delete => self.handle.custom_request("DELETE")?, + } + + // Create structures in which to store the response data: + let mut headers = HashMap::new(); + let mut body = vec![]; + + // Submit a form value to cURL if it is set and proceed + // pretending that there is no body, as the handling of this + // type of body happens under-the-hood. + if let Body::Form(form) = self.body { + self.handle.httppost(form)?; + self.body = Body::NoBody; + } + + // Optionally set content type if a body payload is configured + // and configure the expected body size (or form payload). + match self.body { + Body::Bytes { content_type, data } => { + self.handle.post_field_size(data.len() as u64)?; + self.headers + .append(&format!("Content-Type: {}", content_type))?; + } + + #[cfg(feature = "json")] + Body::Json(ref data) => { + self.handle.post_field_size(data.len() as u64)?; + self.headers.append("Content-Type: application/json")?; + } + + // Do not set content-type header at all if there is no + // body, or if the form handler was invoked above. + _ => (), + }; + + // Configure headers on the request: + self.handle.http_headers(self.headers)?; + + { + // Take a scoped transfer from the Easy handle. This makes it + // possible to write data into the above local buffers without + // fighting the borrow-checker: + let mut transfer = self.handle.transfer(); + + // Write the payload if it exists: + match self.body { + Body::Bytes { data, .. } => chunked_read_function(&mut transfer, data)?, + + #[cfg(feature = "json")] + Body::Json(ref json) => chunked_read_function(&mut transfer, json)?, + + // Do nothing if there is no body or if the body is a + // form. + _ => (), + }; + + // Read one header per invocation. Request processing is + // terminated if any header is malformed: + transfer.header_function(|header| { + // Headers are expected to be valid UTF-8 data. If they + // are not, the conversion is lossy. + // + // Technically it is legal for HTTP requests to use + // different encodings, but we don't interface with such + // services for hygienic reasons. + let header = String::from_utf8_lossy(header); + let split = header.splitn(2, ':').collect::<Vec<_>>(); + + // "Malformed" headers are skipped. In most cases this + // will only be the HTTP version statement. + if split.len() != 2 { + return true; + } + + headers.insert(split[0].trim().to_string(), split[1].trim().to_string()); + true + })?; + + // Read the body to the allocated buffer. + transfer.write_function(|data| { + let len = data.len(); + body.write_all(data) + .map(|_| len) + .map_err(|_| WriteError::Pause) + })?; + + transfer.perform()?; + } + + Ok(Response { + status: self.handle.response_code()?, + headers, + body, + }) + } +} + +/// Provide a data chunk potentially larger than cURL's initial write +/// buffer to the data reading callback by tracking the offset off +/// already written data. +/// +/// As we manually set the expected upload size, cURL will call the +/// read callback repeatedly until it has all the data it needs. +fn chunked_read_function<'easy, 'data>( + transfer: &mut Transfer<'easy, 'data>, + data: &'data [u8], +) -> Result<(), curl::Error> { + let mut data = data; + + transfer.read_function(move |mut into| { + let written = into.write(data).map_err(|_| ReadError::Abort)?; + + data = &data[written..]; + + Ok(written) + }) +} + +impl<T> Response<T> { + /// Check whether the status code of this HTTP response is a + /// success (i.e. in the 200-299 range). + pub fn is_success(&self) -> bool { + self.status >= 200 && self.status < 300 + } + + /// Check whether a request succeeded using `Request::is_success` + /// and let users provide a closure that creates a custom error + /// from the request if it did not. + /// + /// This function exists for convenience to avoid having to write + /// repetitive `if !response.is_success() { ... }` blocks. + pub fn error_for_status<F, E>(self, closure: F) -> Result<Self, E> + where + F: FnOnce(Self) -> E, + { + if !self.is_success() { + return Err(closure(self)); + } + + Ok(self) + } +} + +impl Response<Vec<u8>> { + /// Attempt to parse the HTTP response body as a UTF-8 encoded + /// string. + pub fn as_string(self) -> Result<Response<String>, FromUtf8Error> { + let body = String::from_utf8(self.body)?; + + Ok(Response { + body, + status: self.status, + headers: self.headers, + }) + } + + /// Attempt to deserialize the HTTP response body from JSON. + #[cfg(feature = "json")] + pub fn as_json<T: DeserializeOwned>(self) -> Result<Response<T>, serde_json::Error> { + let deserialized = serde_json::from_slice(&self.body)?; + + Ok(Response { + body: deserialized, + status: self.status, + headers: self.headers, + }) + } +} diff --git a/net/crimp/src/tests.rs b/net/crimp/src/tests.rs new file mode 100644 index 000000000000..e8e9223ce804 --- /dev/null +++ b/net/crimp/src/tests.rs @@ -0,0 +1,185 @@ +// All tests expect an httpbin instance to be available at +// `http://localhost:4662`. +// +// This is easily spun up using Docker by running: +// +// docker run --rm -p 4662:80 kennethreitz/httpbin + +use super::*; +use serde_json::{json, Value}; + +// These tests check whether the correct HTTP method is used in the +// requests. + +#[test] +fn test_http_get() { + let resp = Request::get("http://127.0.0.1:4662/get") + .send() + .expect("failed to send request"); + + assert!(resp.is_success(), "request should have succeeded"); +} + +#[test] +fn test_http_delete() { + let resp = Request::delete("http://127.0.0.1:4662/delete") + .send() + .expect("failed to send request"); + + assert_eq!(200, resp.status, "response status should be 200 OK"); +} + +#[test] +fn test_http_put() { + let resp = Request::put("http://127.0.0.1:4662/put") + .send() + .expect("failed to send request"); + + assert_eq!(200, resp.status, "response status should be 200 OK"); +} + +#[test] +fn test_http_patch() { + let resp = Request::patch("http://127.0.0.1:4662/patch") + .send() + .expect("failed to send request"); + + assert_eq!(200, resp.status, "response status should be 200 OK"); +} + +// These tests perform various requests with different body payloads +// and verify that those were received correctly by the remote side. + +#[test] +fn test_http_post() { + let body = "test body"; + let response = Request::post("http://127.0.0.1:4662/post") + .user_agent("crimp test suite") + .expect("failed to set user-agent") + .timeout(Duration::from_secs(5)) + .expect("failed to set request timeout") + .body("text/plain", &body.as_bytes()) + .send() + .expect("failed to send request") + .as_json::<Value>() + .expect("failed to deserialize response"); + + let data = response.body; + + assert_eq!(200, response.status, "response status should be 200 OK"); + + assert_eq!( + data.get("data").unwrap(), + &json!("test body"), + "test body should have been POSTed" + ); + + assert_eq!( + data.get("headers").unwrap().get("Content-Type").unwrap(), + &json!("text/plain"), + "Content-Type should be `text/plain`", + ); +} + +#[cfg(feature = "json")] +#[test] +fn test_http_post_json() { + let body = json!({ + "purpose": "testing!" + }); + + let response = Request::post("http://127.0.0.1:4662/post") + .user_agent("crimp test suite") + .expect("failed to set user-agent") + .timeout(Duration::from_secs(5)) + .expect("failed to set request timeout") + .json(&body) + .expect("request serialization failed") + .send() + .expect("failed to send request") + .as_json::<Value>() + .expect("failed to deserialize response"); + + let data = response.body; + + assert_eq!(200, response.status, "response status should be 200 OK"); + + assert_eq!( + data.get("json").unwrap(), + &body, + "test body should have been POSTed" + ); + + assert_eq!( + data.get("headers").unwrap().get("Content-Type").unwrap(), + &json!("application/json"), + "Content-Type should be `application/json`", + ); +} + +// Tests for different authentication methods that are supported +// out-of-the-box: + +#[test] +fn test_bearer_auth() { + let response = Request::get("http://127.0.0.1:4662/bearer") + .bearer_auth("some-token") + .expect("failed to set auth header") + .send() + .expect("failed to send request"); + + assert!(response.is_success(), "authorized request should succeed"); +} + +#[test] +fn test_basic_auth() { + let request = Request::get("http://127.0.0.1:4662/basic-auth/alan_watts/oneness"); + + let response = request + .basic_auth("alan_watts", "oneness") + .expect("failed to set auth header") + .send() + .expect("failed to send request"); + + assert!(response.is_success(), "authorized request should succeed"); +} + +#[test] +fn test_large_body() { + // By default cURL buffers seem to be 2^16 bytes in size. The test + // size is therefore 2^16+1. + const BODY_SIZE: usize = 65537; + + let resp = Request::post("http://127.0.0.1:4662/post") + .body("application/octet-stream", &[0; BODY_SIZE]) + .send() + .expect("sending request") + .as_json::<Value>() + .expect("JSON deserialisation"); + + // httpbin returns the uploaded data as a string in the `data` + // field. + let data = resp.body.get("data").unwrap().as_str().unwrap(); + + assert_eq!( + BODY_SIZE, + data.len(), + "uploaded data length should be correct" + ); +} + +// Tests for various other features. + +#[test] +fn test_error_for_status() { + let response = Request::get("http://127.0.0.1:4662/patch") + .send() + .expect("failed to send request") + .error_for_status(|resp| format!("Response error code: {}", resp.status)); + + assert_eq!( + Err("Response error code: 405".into()), + response, + "returned error should be converted into Result::Err" + ); +} |