#![warn(missing_docs)]
#![doc(
html_logo_url = "https://raw.githubusercontent.com/lipanski/mockito/master/docs/logo-black.png"
)]
#[macro_use]
extern crate lazy_static;
#[macro_use]
extern crate log;
mod diff;
mod request;
mod response;
mod server;
type Request = request::Request;
type Response = response::Response;
use rand::distributions::Alphanumeric;
use rand::{thread_rng, Rng};
use regex::Regex;
use std::cell::RefCell;
use std::collections::HashMap;
use std::convert::{From, Into};
use std::fmt;
use std::fs::File;
use std::io;
use std::io::Read;
use std::ops::Drop;
use std::path::Path;
use std::string::ToString;
use std::sync::Arc;
use std::sync::{LockResult, Mutex, MutexGuard};
lazy_static! {
static ref TEST_MUTEX: Mutex<()> = Mutex::new(());
}
thread_local!(
static LOCAL_TEST_MUTEX: RefCell<LockResult<MutexGuard<'static, ()>>> =
RefCell::new(TEST_MUTEX.lock());
);
#[deprecated(note = "Call server_address() instead")]
pub const SERVER_ADDRESS: &str = SERVER_ADDRESS_INTERNAL;
const SERVER_ADDRESS_INTERNAL: &str = "127.0.0.1:1234";
#[deprecated(note = "Call server_url() instead")]
pub const SERVER_URL: &str = "http://127.0.0.1:1234";
pub use crate::server::address as server_address;
pub use crate::server::url as server_url;
use assert_json_diff::{assert_json_matches_no_panic, CompareMode};
pub fn mock<P: Into<Matcher>>(method: &str, path: P) -> Mock {
Mock::new(method, path)
}
pub fn reset() {
server::try_start();
let mut state = server::STATE.lock().unwrap();
state.mocks.clear();
}
#[allow(missing_docs)]
pub fn start() {
server::try_start();
}
#[derive(Clone, PartialEq, Debug)]
#[allow(deprecated)] pub enum Matcher {
Exact(String),
Binary(BinaryBody),
Regex(String),
Json(serde_json::Value),
JsonString(String),
PartialJson(serde_json::Value),
PartialJsonString(String),
UrlEncoded(String, String),
AnyOf(Vec<Matcher>),
AllOf(Vec<Matcher>),
Any,
Missing,
}
impl<'a> From<&'a str> for Matcher {
fn from(value: &str) -> Self {
Matcher::Exact(value.to_string())
}
}
#[allow(clippy::fallible_impl_from)]
impl From<&Path> for Matcher {
fn from(value: &Path) -> Self {
Matcher::Binary(BinaryBody::from_path(value).unwrap())
}
}
impl From<&mut File> for Matcher {
fn from(value: &mut File) -> Self {
Matcher::Binary(BinaryBody::from_file(value))
}
}
impl From<Vec<u8>> for Matcher {
fn from(value: Vec<u8>) -> Self {
Matcher::Binary(BinaryBody::from_bytes(value))
}
}
impl fmt::Display for Matcher {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let join_matches = |matches: &[Self]| {
matches
.iter()
.map(Self::to_string)
.fold(String::new(), |acc, matcher| {
if acc.is_empty() {
matcher
} else {
format!("{}, {}", acc, matcher)
}
})
};
let result = match self {
Matcher::Exact(ref value) => value.to_string(),
Matcher::Binary(ref file) => format!("{} (binary)", file),
Matcher::Regex(ref value) => format!("{} (regex)", value),
Matcher::Json(ref json_obj) => format!("{} (json)", json_obj),
Matcher::JsonString(ref value) => format!("{} (json)", value),
Matcher::PartialJson(ref json_obj) => format!("{} (partial json)", json_obj),
Matcher::PartialJsonString(ref value) => format!("{} (partial json)", value),
Matcher::UrlEncoded(ref field, ref value) => {
format!("{}={} (urlencoded)", field, value)
}
Matcher::Any => "(any)".to_string(),
Matcher::AnyOf(x) => format!("({}) (any of)", join_matches(x)),
Matcher::AllOf(x) => format!("({}) (all of)", join_matches(x)),
Matcher::Missing => "(missing)".to_string(),
};
write!(f, "{}", result)
}
}
impl Matcher {
fn matches_values(&self, header_values: &[&str]) -> bool {
match self {
Matcher::Missing => header_values.is_empty(),
Matcher::AnyOf(ref matchers) if header_values.is_empty() => {
matchers.iter().any(|m| m.matches_values(header_values))
}
Matcher::AllOf(ref matchers) if header_values.is_empty() => {
matchers.iter().all(|m| m.matches_values(header_values))
}
_ => {
!header_values.is_empty() && header_values.iter().all(|val| self.matches_value(val))
}
}
}
fn matches_binary_value(&self, binary: &[u8]) -> bool {
match self {
Matcher::Binary(ref file) => binary == &*file.content,
_ => false,
}
}
#[allow(deprecated)]
fn matches_value(&self, other: &str) -> bool {
let compare_json_config = assert_json_diff::Config::new(CompareMode::Inclusive);
match self {
Matcher::Exact(ref value) => value == other,
Matcher::Binary(_) => false,
Matcher::Regex(ref regex) => Regex::new(regex).unwrap().is_match(other),
Matcher::Json(ref json_obj) => {
let other: serde_json::Value = serde_json::from_str(other).unwrap();
*json_obj == other
}
Matcher::JsonString(ref value) => {
let value: serde_json::Value = serde_json::from_str(value).unwrap();
let other: serde_json::Value = serde_json::from_str(other).unwrap();
value == other
}
Matcher::PartialJson(ref json_obj) => {
let actual: serde_json::Value = serde_json::from_str(other).unwrap();
let expected = json_obj.clone();
assert_json_matches_no_panic(&actual, &expected, compare_json_config).is_ok()
}
Matcher::PartialJsonString(ref value) => {
let expected: serde_json::Value = serde_json::from_str(value).unwrap();
let actual: serde_json::Value = serde_json::from_str(other).unwrap();
assert_json_matches_no_panic(&actual, &expected, compare_json_config).is_ok()
}
Matcher::UrlEncoded(ref expected_field, ref expected_value) => {
serde_urlencoded::from_str::<HashMap<String, String>>(other)
.map(|params: HashMap<_, _>| {
params.into_iter().any(|(ref field, ref value)| {
field == expected_field && value == expected_value
})
})
.unwrap_or(false)
}
Matcher::Any => true,
Matcher::AnyOf(ref matchers) => matchers.iter().any(|m| m.matches_value(other)),
Matcher::AllOf(ref matchers) => matchers.iter().all(|m| m.matches_value(other)),
Matcher::Missing => other.is_empty(),
}
}
}
#[derive(Clone, PartialEq, Debug)]
enum PathAndQueryMatcher {
Unified(Matcher),
Split(Box<Matcher>, Box<Matcher>),
}
impl PathAndQueryMatcher {
fn matches_value(&self, other: &str) -> bool {
match self {
PathAndQueryMatcher::Unified(matcher) => matcher.matches_value(other),
PathAndQueryMatcher::Split(ref path_matcher, ref query_matcher) => {
let mut parts = other.splitn(2, '?');
let path = parts.next().unwrap();
let query = parts.next().unwrap_or("");
path_matcher.matches_value(path) && query_matcher.matches_value(query)
}
}
}
}
#[derive(Debug, Clone)]
pub struct BinaryBody {
path: Option<String>,
content: Vec<u8>,
}
impl BinaryBody {
pub fn from_path(path: &Path) -> Result<Self, io::Error> {
Ok(Self {
path: path.to_str().map(ToString::to_string),
content: std::fs::read(path)?,
})
}
pub fn from_file(file: &mut File) -> Self {
Self {
path: None,
content: get_content_from(file),
}
}
#[allow(clippy::missing_const_for_fn)]
pub fn from_bytes(content: Vec<u8>) -> Self {
Self {
path: None,
content,
}
}
}
fn get_content_from(file: &mut File) -> Vec<u8> {
let mut filecontent: Vec<u8> = Vec::new();
file.read_to_end(&mut filecontent).unwrap();
filecontent
}
impl PartialEq for BinaryBody {
fn eq(&self, other: &Self) -> bool {
match (self.path.as_ref(), other.path.as_ref()) {
(Some(p), Some(o)) => p == o,
_ => self.content == other.content,
}
}
}
impl fmt::Display for BinaryBody {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if let Some(filepath) = self.path.as_ref() {
write!(f, "filepath: {}", filepath)
} else {
let len: usize = std::cmp::min(self.content.len(), 8);
let first_bytes: Vec<u8> = self.content.iter().copied().take(len).collect();
write!(f, "filecontent: {:?}", first_bytes)
}
}
}
#[derive(Clone, PartialEq, Debug)]
pub struct Mock {
id: String,
method: String,
path: PathAndQueryMatcher,
headers: Vec<(String, Matcher)>,
body: Matcher,
response: Response,
hits: usize,
expected_hits_at_least: Option<usize>,
expected_hits_at_most: Option<usize>,
is_remote: bool,
created: bool,
}
impl Mock {
fn new<P: Into<Matcher>>(method: &str, path: P) -> Self {
Self {
id: thread_rng()
.sample_iter(&Alphanumeric)
.map(char::from)
.take(24)
.collect(),
method: method.to_owned().to_uppercase(),
path: PathAndQueryMatcher::Unified(path.into()),
headers: Vec::new(),
body: Matcher::Any,
response: Response::default(),
hits: 0,
expected_hits_at_least: None,
expected_hits_at_most: None,
is_remote: false,
created: false,
}
}
pub fn match_query<M: Into<Matcher>>(mut self, query: M) -> Self {
let new_path = match &self.path {
PathAndQueryMatcher::Unified(matcher) => {
PathAndQueryMatcher::Split(Box::new(matcher.clone()), Box::new(query.into()))
}
PathAndQueryMatcher::Split(path, _) => {
PathAndQueryMatcher::Split(path.clone(), Box::new(query.into()))
}
};
self.path = new_path;
self
}
pub fn match_header<M: Into<Matcher>>(mut self, field: &str, value: M) -> Self {
self.headers
.push((field.to_owned().to_lowercase(), value.into()));
self
}
pub fn match_body<M: Into<Matcher>>(mut self, body: M) -> Self {
self.body = body.into();
self
}
pub fn with_status(mut self, status: usize) -> Self {
self.response.status = status.into();
self
}
pub fn with_header(mut self, field: &str, value: &str) -> Self {
self.response
.headers
.push((field.to_owned(), value.to_owned()));
self
}
pub fn with_body<StrOrBytes: AsRef<[u8]>>(mut self, body: StrOrBytes) -> Self {
self.response.body = response::Body::Bytes(body.as_ref().to_owned());
self
}
pub fn with_body_from_fn(
mut self,
cb: impl Fn(&mut dyn io::Write) -> io::Result<()> + Send + Sync + 'static,
) -> Self {
self.response.body = response::Body::Fn(Arc::new(cb));
self
}
pub fn with_body_from_file(mut self, path: impl AsRef<Path>) -> Self {
self.response.body = response::Body::Bytes(std::fs::read(path).unwrap());
self
}
#[allow(clippy::missing_const_for_fn)]
pub fn expect(mut self, hits: usize) -> Self {
self.expected_hits_at_least = Some(hits);
self.expected_hits_at_most = Some(hits);
self
}
pub fn expect_at_least(mut self, hits: usize) -> Self {
self.expected_hits_at_least = Some(hits);
if self.expected_hits_at_most.is_some()
&& self.expected_hits_at_most < self.expected_hits_at_least
{
self.expected_hits_at_most = None;
}
self
}
pub fn expect_at_most(mut self, hits: usize) -> Self {
self.expected_hits_at_most = Some(hits);
if self.expected_hits_at_least.is_some()
&& self.expected_hits_at_least > self.expected_hits_at_most
{
self.expected_hits_at_least = None;
}
self
}
pub fn assert(&self) {
let mut opt_message = None;
{
let state = server::STATE.lock().unwrap();
if let Some(remote_mock) = state.mocks.iter().find(|mock| mock.id == self.id) {
let mut message = match (self.expected_hits_at_least, self.expected_hits_at_most) {
(Some(min), Some(max)) if min == max => format!(
"\n> Expected {} request(s) to:\n{}\n...but received {}\n\n",
min, self, remote_mock.hits
),
(Some(min), Some(max)) => format!(
"\n> Expected between {} and {} request(s) to:\n{}\n...but received {}\n\n",
min, max, self, remote_mock.hits
),
(Some(min), None) => format!(
"\n> Expected at least {} request(s) to:\n{}\n...but received {}\n\n",
min, self, remote_mock.hits
),
(None, Some(max)) => format!(
"\n> Expected at most {} request(s) to:\n{}\n...but received {}\n\n",
max, self, remote_mock.hits
),
(None, None) => format!(
"\n> Expected 1 request(s) to:\n{}\n...but received {}\n\n",
self, remote_mock.hits
),
};
if let Some(last_request) = state.unmatched_requests.last() {
message.push_str(&format!(
"> The last unmatched request was:\n{}\n",
last_request
));
let difference = diff::compare(&self.to_string(), &last_request.to_string());
message.push_str(&format!("> Difference:\n{}\n", difference));
}
opt_message = Some(message);
}
}
if let Some(message) = opt_message {
assert!(self.matched(), "{}", message)
} else {
panic!("Could not retrieve enough information about the remote mock.")
}
}
pub fn matched(&self) -> bool {
let state = server::STATE.lock().unwrap();
state
.mocks
.iter()
.find(|mock| mock.id == self.id)
.map_or(false, |remote_mock| {
let hits = remote_mock.hits;
match (self.expected_hits_at_least, self.expected_hits_at_most) {
(Some(min), Some(max)) => hits >= min && hits <= max,
(Some(min), None) => hits >= min,
(None, Some(max)) => hits <= max,
(None, None) => hits == 1,
}
})
}
#[must_use]
pub fn create(mut self) -> Self {
server::try_start();
LOCAL_TEST_MUTEX.with(|_| {});
let mut state = server::STATE.lock().unwrap();
self.created = true;
let mut remote_mock = self.clone();
remote_mock.is_remote = true;
state.mocks.push(remote_mock);
self
}
#[allow(clippy::missing_const_for_fn)]
fn is_local(&self) -> bool {
!self.is_remote
}
}
impl Drop for Mock {
fn drop(&mut self) {
if self.is_local() {
let mut state = server::STATE.lock().unwrap();
if let Some(pos) = state.mocks.iter().position(|mock| mock.id == self.id) {
state.mocks.remove(pos);
}
debug!("Mock::drop() called for {}", self);
if !self.created {
warn!("Missing .create() call on mock {}", self);
}
}
}
}
impl fmt::Display for PathAndQueryMatcher {
#[allow(deprecated)]
#[allow(clippy::write_with_newline)]
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PathAndQueryMatcher::Unified(matcher) => write!(f, "{}\r\n", &matcher),
PathAndQueryMatcher::Split(path, query) => write!(f, "{}?{}\r\n", &path, &query),
}
}
}
impl fmt::Display for Mock {
#[allow(deprecated)]
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut formatted = String::new();
formatted.push_str("\r\n");
formatted.push_str(&self.method);
formatted.push(' ');
formatted.push_str(&self.path.to_string());
for &(ref key, ref value) in &self.headers {
formatted.push_str(key);
formatted.push_str(": ");
formatted.push_str(&value.to_string());
formatted.push_str("\r\n");
}
match self.body {
Matcher::Exact(ref value)
| Matcher::JsonString(ref value)
| Matcher::PartialJsonString(ref value)
| Matcher::Regex(ref value) => {
formatted.push_str(value);
formatted.push_str("\r\n");
}
Matcher::Binary(_) => {
formatted.push_str("(binary)\r\n");
}
Matcher::Json(ref json_obj) | Matcher::PartialJson(ref json_obj) => {
formatted.push_str(&json_obj.to_string());
formatted.push_str("\r\n")
}
Matcher::UrlEncoded(ref field, ref value) => {
formatted.push_str(field);
formatted.push('=');
formatted.push_str(value);
}
Matcher::Missing => formatted.push_str("(missing)\r\n"),
Matcher::AnyOf(..) => formatted.push_str("(any of)\r\n"),
Matcher::AllOf(..) => formatted.push_str("(all of)\r\n"),
Matcher::Any => {}
}
f.write_str(&formatted)
}
}