// This Source Code Form is subject to the terms of the Mozilla Public License, // v. 2.0. If a copy of the MPL was not distributed with this file, You can // obtain one at https://mozilla.org/MPL/2.0/. //! Surf-vcr allows you to record communications with an HTTP server, then //! inject those pre-recorded responses into Surf HTTP sessions, providing you //! with reproducible tests. //! //! You'll typically be testing the functionality of a function that takes a //! Surf client as a parameter: //! //! ```ignore //! async fn retrieve_widget_list(client: &surf::Client) -> Result> //! { //! let req = client.get("http://example.com/see-widgets").unwrap(); //! // ... //! # Ok(vec![]) //! } //! ``` //! //! To ensure your tests and your application use the same configuration (other //! middlewares, the base URL, etc) create your client in a function; your tests //! can then call that function and wrap the client with the VcrMiddleware: //! //! ```ignore //! // Assuming we also have a middleware to manage our sessions, and another to //! // automatically retry 5xx responses a number of times: //! //! pub fn new_http_client(session: SessionMiddleware) -> surf::Client { //! let mut client = surf::Client::new() //! .with(session) //! .with(RetryMiddleware::<3>); //! //! client.set_base_url(Url::parse(SERVER_URL) //! .expect("Unable to parse SERVER_URL as a URL")); //! //! client //! } //! //! #[cfg(test)] //! pub async fn create_test_client( //! mode: VcrMode, //! cassette: &'static str, //! session: Option, //! ) -> surf::Client { //! let session = session.or(SessionMiddleware::default()); //! //! new_http_client(session) //! .with(VcrMiddleware::new(mode, cassette).await.unwrap()) //! } //! ``` //! //! Now run the server and record the test: //! //! ```ignore //! #[async_std::test] //! async fn user_cannot_see_widgets_if_not_logged_on() { //! let client = create_test_client( //! VcrMode::Record, //! "tests/sessions/session-tests.yml", //! None //! ).await.unwrap(); //! //! let widgets = retrieve_widget_list(&client).await; //! assert!(widgets.is_err()); //! } //! ``` //! //! Change the mode to Replay, and you can run the test without connecting to //! the server. If the server's output changes in the future, you could either //! manually adjust the YAML file or delete it and re-record the test (if that's //! common, it may be convenient to have a global MODE variable, and record or //! replay everything together). //! //! ```ignore //! #[async_std::test] //! async fn user_cannot_see_widgets_if_not_logged_on() { //! let client = create_test_client( //! VcrMode::Replay, //! "tests/sessions/session-tests.yml", //! None //! ).await.unwrap(); //! //! let widgets = retrieve_widget_list(&client).await; //! assert!(widgets.is_err()); //! } //! ``` //! //! You can also hide sensitive information before saving the data to your //! cassettes: //! //! ```ignore //! VcrMiddleware::new(VcrMode::Record, path).await? //! .with_modify_request(|req| { //! req.headers //! .entry("session-key".into()) //! .and_modify(|val| *val = vec!["...(erased)...".into()]); //! }) //! .with_modify_response(|res| { //! res.headers //! .entry("Set-Cookie".into()) //! .and_modify(|val| *val = vec!["...(erased)...".into()]); //! }); //! ``` use std::{ collections::HashMap, path::PathBuf, fmt, io, }; use async_std::{ prelude::*, sync::RwLock, fs, }; use serde::{Serialize, Deserialize}; use surf::{ http::{self, Method, Version}, middleware::{Middleware, Next}, Client, Request, Response, StatusCode, Url, }; use once_cell::sync::OnceCell; // For now we store requests and responses for ReplayMode as a pair of vecs; // we'll iterate the requests until we find the one we want, and return the // corresponding response. TODO: A multimap with the request URL or // (method, URL) as the key makes more sense for large recordings. type Session = (Vec, Vec); // We need to guard our file writes; we're going to lock the data though so that // we can still search for the desired file. The lock is over the session, but // we're guarding the file path; we must obtain the lock when reading or writing // to the file, even if we're ignoring the session. static CASSETTES: OnceCell>>>> = OnceCell::new(); type RequestModifier = dyn Fn(&mut VcrRequest) + Send + Sync + 'static; type ResponseModifier = dyn Fn(&mut VcrResponse) + Send + Sync + 'static; /// Record and playback HTTP sessions. /// /// This middleware must be registered to the client after any other middleware /// that modifies the HTTP request, or those modifications will not be recorded /// and replayed. /// /// ``` /// # async fn runtest() -> surf::Result { /// use surf_vcr::{VcrMiddleware, VcrMode}; /// /// let vcr = VcrMiddleware::new( /// VcrMode::Replay, /// "test-sessions/session-recording.yml" /// ).await?; /// # let some_other_middleware = VcrMiddleware::new( /// # VcrMode::Replay, /// # "test-sessions/session-recording.yml" /// # ).await?; /// /// let mut client = surf::Client::new() /// .with(some_other_middleware) /// .with(vcr); /// /// // And then make your requests: /// let req = surf::get("https://example.com") /// .header("X-my-header", "stuff"); /// /// let resp = client.send(req).await?; /// # Ok(resp) } /// ``` /// pub struct VcrMiddleware { mode: VcrMode, file: PathBuf, modify_request: Option>, modify_response: Option>, } #[surf::utils::async_trait] impl Middleware for VcrMiddleware { async fn handle(&self, mut req: Request, client: Client, next: Next<'_>) -> surf::Result { let mut request = VcrRequest::from_request(&mut req).await?; if let Some(ref modifier) = self.modify_request { modifier(&mut request); } match self.mode { VcrMode::Record => { let mut res = next.run(req, client).await?; let mut response = VcrResponse::try_from_response(&mut res) .await?; if let Some(ref modifier) = self.modify_response { modifier(&mut response); } let doc = serde_yaml::to_string( &( SerdeWrapper::Request(request), SerdeWrapper::Response(response) ) )?; let recorders = CASSETTES.get().unwrap().read().await; let lock = recorders[&self.file].write().await; let mut file = fs::OpenOptions::new() .create(true) .append(true) .open(&self.file).await?; // Each record is a new YAML document. file.write_all(doc.as_bytes()).await?; drop(lock); Ok(res) }, VcrMode::Replay => { let cassettes = CASSETTES.get().unwrap().read().await; let sessions = &cassettes[&self.file].read().await; let (requests, responses) = sessions.as_ref() .expect(&format!("Missing session: {:?}", self.file)); match requests.iter().position(|x| x == &request) { Some(pos) => Ok(Response::from(&responses[pos])), None => Err(surf::Error::new( StatusCode::NotFound, VcrError::Lookup(Request::from(request)) )), } } } } } impl VcrMiddleware { pub async fn new

(mode: VcrMode, recording: P) -> Result where P: Into, { let recording = recording.into(); if mode == VcrMode::Replay { // Ignore error; we only initialize once. let _ = CASSETTES.set(RwLock::new(HashMap::new())); let mut cassettes = CASSETTES.get().unwrap().write().await; let recording_exists = cassettes.contains_key(&recording) && cassettes[&recording].read().await.is_some(); if ! recording_exists { let mut requests = vec![]; let mut responses = vec![]; let replays = fs::read_to_string(&recording).await?; for replay in replays.split("\n---\n") { let (request, response) = serde_yaml::from_str(replay)?; let req = match request { SerdeWrapper::Request(r) => r, _ => panic!("Invalid request"), }; let resp = match response { SerdeWrapper::Response(r) => r, _ => panic!("Invalid response"), }; requests.push(req); responses.push(resp); } cassettes.insert( recording.clone(), RwLock::new(Some((requests, responses))) ); } } else { // VcrMode::Record // Ignore error; we only initialize once. let _ = CASSETTES.set(RwLock::new(HashMap::new())); let mut recorders = CASSETTES.get().unwrap().write().await; recorders.insert(recording.clone(), RwLock::new(None)); } Ok(Self { mode, file: recording, modify_request: None, modify_response: None }) } /// Register a modifier function to alter requests before saving to disk. pub fn with_modify_request(mut self, modifier: F) -> Self where F: Fn(&mut VcrRequest) + Send + Sync + 'static { self.modify_request.replace(Box::new(modifier)); self } /// Register a modifier function to alter responses before saving to disk. pub fn with_modify_response(mut self, modifier: F) -> Self where F: Fn(&mut VcrResponse) + Send + Sync + 'static { self.modify_response.replace(Box::new(modifier)); self } } // If the body is a valid string, it's much nicer to serialize to it; otherwise // we serialize to bytes. #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(untagged)] pub enum Body { Bytes(Vec), Str(String), } impl From<&[u8]> for Body { fn from(bytes: &[u8]) -> Self { match std::str::from_utf8(&bytes) { Ok(s) => Body::Str(s.to_owned()), Err(_) => Body::Bytes(bytes.to_vec()), } } } /// Determines whether the middleware should record the HTTP session or inject /// pre-recorded responses into the session. #[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] pub enum VcrMode { Record, Replay, } /// Request to be recorded in cassettes. /// /// You are unlikely to need to work with this directly except via /// [VcrMiddleware::with_modify_request]. #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct VcrRequest { pub method: Method, pub url: Url, pub headers: HashMap>, pub body: Body, } impl VcrRequest { async fn from_request(req: &mut Request) -> surf::Result { let headers = { let mut headers = HashMap::new(); for header in req.header_names() { let values = req.header(header).iter() // We use as_str() before to_string() to prevent the // unnecessary addition of escape characters, which double // up if we round-trip the request and response // de/serializations. .map(|v| v.as_str().to_string()) .collect::>(); headers.insert(header.to_string(), values); } headers }; let orig_body = req.take_body().into_bytes().await?; let body = Body::from(orig_body.as_slice()); // We have to replace the body in our source after the copy. req.set_body(orig_body.as_slice()); Ok(Self { method: req.method(), url: req.url().to_owned(), headers, body, }) } } impl From for Request { fn from(req: VcrRequest) -> Request { let mut request = http::Request::new(req.method, req.url); for name in req.headers.keys() { let values = &req.headers[name]; for value in values.iter() { request.append_header(name.as_str(), value); } } match &req.body { Body::Bytes(b) => request.set_body(b.as_slice()), Body::Str(s) => request.set_body(s.as_str()), } Request::from(request) } } /// Response to be recorded in cassettes. /// /// You are unlikely to need to work with this directly except via /// [VcrMiddleware::with_modify_response]. #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct VcrResponse { pub status: StatusCode, pub version: Option, pub headers: HashMap>, // We may want to use the surf::Body type; for large bodies we could stream // from the file instead of storing it in memory. pub body: Body, } impl VcrResponse { async fn try_from_response(resp: &mut Response) -> surf::Result { let headers = { let mut headers = HashMap::new(); for hdr in resp.header_names() { let values = resp.header(hdr).iter() // We use as_str() before to_string() to prevent the // unnecessary addition of escape characters, which double // up if we round-trip the request and response // de/serializations. .map(|v| v.as_str().to_string()) .collect::>(); headers.insert(hdr.to_string(), values); } headers }; let orig_body = resp.body_bytes().await?; let body = Body::from(orig_body.as_slice()); // We have to replace the body in our source after the copy. resp.set_body(orig_body.as_slice()); Ok(Self { status: resp.status(), version: resp.version(), headers, body, }) } } impl From<&VcrResponse> for Response { fn from(resp: &VcrResponse) -> Response { let mut response = http::Response::new(resp.status); response.set_version(resp.version); for name in resp.headers.keys() { let values = &resp.headers[name]; for value in values.iter() { response.append_header(name.as_str(), value); } } match &resp.body { Body::Bytes(b) => response.set_body(b.as_slice()), Body::Str(s) => response.set_body(s.as_str()), } Response::from(response) } } // serde only supports externally-tagged enums, but I want to tag the structs. // See https://github.com/serde-rs/serde/issues/2007 #[derive(Debug, Deserialize, Serialize)] enum SerdeWrapper { Request(VcrRequest), Response(VcrResponse), } #[derive(Debug)] pub enum VcrError { File(io::Error), Parse(serde_yaml::Error), Lookup(surf::Request), } impl std::error::Error for VcrError {} impl fmt::Display for VcrError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::File(e) => e.fmt(f), Self::Parse(e) => e.fmt(f), Self::Lookup(req) => write!(f, "Request not found at {}: {:#?}", req.url(), req), } } } impl From for VcrError { fn from(e: io::Error) -> Self { Self::File(e) } } impl From for VcrError { fn from(e: serde_yaml::Error) -> Self { Self::Parse(e) } } #[cfg(test)] mod tests { use super::*; #[async_std::test] async fn read_recording_from_disk() -> Result<(), VcrError> { let vcr = VcrMiddleware::new( VcrMode::Replay, "test-sessions/simple.yml" ).await?; let mut req_headers = HashMap::new(); req_headers.insert( "X-some-header".to_owned(), vec!["hello".to_owned()] ); let req = VcrRequest { method: Method::Get, url: Url::parse("https://example.com").unwrap(), headers: req_headers, body: Body::Str("My Request".to_owned()), }; let mut res_headers = HashMap::new(); res_headers.insert( "X-some-header".to_owned(), vec!["goodbye".to_owned()] ); let res = VcrResponse { status: StatusCode::Ok, version: None, headers: res_headers, body: Body::Str("A Response".to_owned()), }; let cassettes = CASSETTES.get().unwrap().read().await; let sessions = &cassettes[&vcr.file].read().await; let (requests, responses) = sessions.as_ref().unwrap(); assert_eq!(req, requests[0]); assert_eq!(res, responses[0]); Ok(()) } #[async_std::test] async fn replay_recorded_communications() -> Result<(), VcrError> { let vcr = VcrMiddleware::new( VcrMode::Replay, "test-sessions/simple.yml" ).await? .with_modify_request(|res| { *res.headers.get_mut("secret-header").unwrap() = vec![String::from("(secret)")]; }); let client = surf::Client::new().with(vcr); let req = surf::get("https://example.com") .header("X-some-header", "another hello") .header("secret-header", "sensitive data") .build(); let mut res = client.send(req).await.unwrap(); let mut res_headers = HashMap::new(); res_headers.insert( "x-some-header".to_owned(), vec!["another goodbye".to_owned()] ); res_headers.insert( "content-type".to_owned(), vec!["text/plain;charset=utf-8".to_owned()] ); res_headers.insert( "date".to_owned(), vec!["Fri, 28 May 2021 00:44:58 GMT".to_owned()] ); let expected = VcrResponse { status: StatusCode::Ok, version: None, headers: res_headers, body: Body::Str("A Response".to_owned()), }; assert_eq!( VcrResponse::try_from_response(&mut res).await.unwrap(), expected ); Ok(()) } #[async_std::test] async fn record_communication_in_write_mode() -> Result<(), VcrError> { // To avoid the need for a running server, we're actually using two // instances of VcrMiddleware - the one under test, and another to // replay a session to be recorded. let path = "test-sessions/record-test.yml"; // Ignore a non-existent file; assume deletion succeeds. let _ = async_std::fs::remove_file("test-sessions/record-test.yml") .await; fn hide_session_key(req: &mut VcrRequest) { req.headers.entry("session-key".into()) .and_modify(|val| *val = vec!["(some key)".into()]); } fn hide_cookie(res: &mut VcrResponse) { res.headers.entry("Set-Cookie".into()) .and_modify(|val| *val = vec!["(erased)".into()]); } let outer = VcrMiddleware::new( VcrMode::Replay, "test-sessions/simple.yml", ).await?; let vcr = VcrMiddleware::new(VcrMode::Record, path).await? .with_modify_request(hide_session_key) .with_modify_response(hide_cookie); let client = surf::Client::new() .with(vcr) .with(outer); let req = surf::get("https://example.com") .header("X-some-header", "another hello") .header("Content-Type", "application/octet-stream") .header("session-key", "00112233445566778899AABBCCDDEEFF") .build(); let mut expected_res = client.send(req).await.unwrap(); // Now we'll create a client to replay what we just did. let client = surf::Client::new() .with(VcrMiddleware::new(VcrMode::Replay, path).await?); let req = surf::get("https://example.com") .header("X-some-header", "another hello") .header("Content-Type", "application/octet-stream") .header("session-key", "(some key)") .build(); let mut res = client.send(req).await.unwrap(); let res = VcrResponse::try_from_response(&mut res).await.unwrap(); assert_eq!( res, VcrResponse::try_from_response(&mut expected_res).await.unwrap() ); let cookies = &res.headers["set-cookie"]; assert!(! cookies.contains(&"cookie2=val2; Expires=date2".into())); Ok(()) } }