Skip to content

Commit 38a5a0b

Browse files
authored
Expose server mocks and enable mutability (#5)
Signed-off-by: declark1 <[email protected]>
1 parent 9c6e350 commit 38a5a0b

File tree

10 files changed

+160
-88
lines changed

10 files changed

+160
-88
lines changed

Cargo.lock

+5-4
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

mocktail-test/Cargo.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "mocktail-test"
3-
version = "0.1.2-alpha"
3+
version = "0.1.3-alpha"
44
edition = "2021"
55
authors = ["Dan Clark", "Gaurav Kumbhat"]
66

mocktail-test/src/http_hello.rs

+30-1
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ mod tests {
3232
server.start().await?;
3333

3434
let client = reqwest::Client::new();
35+
3536
let response = client
3637
.post(server.url("/hello"))
3738
.json(&HelloRequest { name: "Dan".into() })
@@ -41,7 +42,6 @@ mod tests {
4142
let body = response.json::<HelloResponse>().await?;
4243
dbg!(&body);
4344

44-
let client = reqwest::Client::new();
4545
let response = client
4646
.post(server.url("/hello"))
4747
.json(&HelloRequest {
@@ -51,6 +51,35 @@ mod tests {
5151
.await?;
5252
assert!(response.status() == StatusCode::NOT_FOUND);
5353

54+
// Clear the mocks on the server
55+
server.mocks().clear();
56+
57+
assert!(server.mocks().is_empty());
58+
59+
// Add a new mock to the server
60+
server.mocks().insert(
61+
MockPath::post("/hello"),
62+
Mock::new(
63+
MockRequest::json(HelloRequest {
64+
name: "There".into(),
65+
}),
66+
MockResponse::json(HelloResponse {
67+
message: "Hello There!".into(),
68+
}),
69+
),
70+
);
71+
72+
let response = client
73+
.post(server.url("/hello"))
74+
.json(&HelloRequest {
75+
name: "There".into(),
76+
})
77+
.send()
78+
.await?;
79+
assert!(response.status().is_success());
80+
let body = response.json::<HelloResponse>().await?;
81+
dbg!(&body);
82+
5483
Ok(())
5584
}
5685
}

mocktail/Cargo.toml

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "mocktail"
3-
version = "0.1.2-alpha"
3+
version = "0.1.3-alpha"
44
edition = "2021"
55
authors = ["Dan Clark", "Gaurav Kumbhat"]
66
description = "HTTP & gRPC server mocking for Rust"
@@ -17,6 +17,7 @@ categories = ["development-tools", "development-tools::testing"]
1717
doctest = false
1818

1919
[dependencies]
20+
async-trait = "0.1.87"
2021
bytes = "1.9.0"
2122
futures = "0.3.31"
2223
h2 = { version = "0.4.8", features = ["stream"] }

mocktail/src/lib.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ pub mod prelude {
77

88
pub use crate::{
99
mock::{Mock, MockBody, MockPath, MockRequest, MockResponse, MockSet},
10-
server::{GrpcMockServer, HttpMockServer},
10+
server::{GrpcMockServer, HttpMockServer, MockServer},
1111
utils::prost::MessageExt as _,
1212
Error,
1313
};

mocktail/src/mock/set.rs

+26-18
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,27 @@
11
use std::collections::{hash_map, HashMap};
22

33
use super::Mock;
4-
use crate::utils::HeaderMapExt;
54

65
/// A set of mocks for a service.
76
#[derive(Default, Debug, Clone)]
87
pub struct MockSet(HashMap<MockPath, Vec<Mock>>);
98

109
impl MockSet {
11-
/// Creates a empty [`MockSet`].
10+
/// Creates an empty mockset.
1211
pub fn new() -> Self {
1312
Self::default()
1413
}
1514

15+
/// Returns the number of entries in the mockset.
16+
pub fn len(&self) -> usize {
17+
self.0.len()
18+
}
19+
20+
/// Returns true if the mockset contains no entries.
21+
pub fn is_empty(&self) -> bool {
22+
self.0.is_empty()
23+
}
24+
1625
/// Inserts a [`Mock`].
1726
pub fn insert(&mut self, path: MockPath, mock: Mock) {
1827
match self.0.entry(path) {
@@ -25,6 +34,21 @@ impl MockSet {
2534
}
2635
}
2736

37+
/// Gets an entry for in-place manipulation.
38+
pub fn entry(&mut self, path: MockPath) -> hash_map::Entry<'_, MockPath, Vec<Mock>> {
39+
self.0.entry(path)
40+
}
41+
42+
/// Removes an entry from the mockset.
43+
pub fn remove(&mut self, path: &MockPath) -> Option<Vec<Mock>> {
44+
self.0.remove(path)
45+
}
46+
47+
/// Clears the mockset.
48+
pub fn clear(&mut self) {
49+
self.0.clear()
50+
}
51+
2852
/// Matches a [`Mock`] by path and predicate.
2953
pub fn find<P>(&self, path: &MockPath, predicate: P) -> Option<&Mock>
3054
where
@@ -39,22 +63,6 @@ impl MockSet {
3963
pub fn match_by_body(&self, path: &MockPath, body: &[u8]) -> Option<&Mock> {
4064
self.find(path, |mock| mock.request.body() == body)
4165
}
42-
43-
/// Matches a [`Mock`] by path, body, and headers.
44-
pub fn match_by_body_and_headers(
45-
&self,
46-
path: &MockPath,
47-
body: &[u8],
48-
headers: &http::HeaderMap,
49-
) -> Option<&Mock> {
50-
// `headers` must be a superset of `mock.request.headers`,
51-
if let Some(mock) = self.match_by_body(path, body) {
52-
if headers.is_superset(&mock.request.headers) {
53-
return Some(mock);
54-
}
55-
}
56-
None
57-
}
5866
}
5967

6068
impl FromIterator<(MockPath, Vec<Mock>)> for MockSet {

mocktail/src/server.rs

+29-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
use crate::mock::MockSet;
1+
use std::{
2+
net::SocketAddr,
3+
sync::{RwLock, RwLockWriteGuard},
4+
};
5+
6+
use async_trait::async_trait;
7+
use url::Url;
8+
9+
use crate::{mock::MockSet, Error};
210

311
mod grpc;
412
pub use grpc::GrpcMockServer;
@@ -9,11 +17,29 @@ pub use http::HttpMockServer;
917
/// Server state.
1018
#[derive(Debug)]
1119
pub struct ServerState {
12-
pub mocks: MockSet,
20+
pub mocks: RwLock<MockSet>,
1321
}
1422

1523
impl ServerState {
1624
pub fn new(mocks: MockSet) -> Self {
17-
Self { mocks }
25+
Self {
26+
mocks: RwLock::new(mocks),
27+
}
1828
}
1929
}
30+
31+
#[async_trait]
32+
pub trait MockServer {
33+
async fn start(&self) -> Result<(), Error>;
34+
35+
/// Returns the server's service name.
36+
fn name(&self) -> &str;
37+
38+
/// Returns the server's address.
39+
fn addr(&self) -> SocketAddr;
40+
41+
/// Returns the url for a path.
42+
fn url(&self, path: &str) -> Url;
43+
44+
fn mocks(&self) -> RwLockWriteGuard<'_, MockSet>;
45+
}

mocktail/src/server/grpc.rs

+32-16
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
1-
use std::{convert::Infallible, net::SocketAddr, sync::Arc, time::Duration};
1+
use std::{
2+
convert::Infallible,
3+
net::SocketAddr,
4+
sync::{Arc, RwLockWriteGuard},
5+
time::Duration,
6+
};
27

8+
use async_trait::async_trait;
39
use bytes::{Bytes, BytesMut};
410
use futures::{future::BoxFuture, StreamExt};
511
use http::{HeaderMap, HeaderValue, Request, Response};
@@ -12,7 +18,7 @@ use tokio_stream::wrappers::ReceiverStream;
1218
use tracing::{debug, error, info};
1319
use url::Url;
1420

15-
use super::ServerState;
21+
use super::{MockServer, ServerState};
1622
use crate::{
1723
mock::{MockPath, MockSet, TonicBoxBody},
1824
utils::{find_available_port, has_content_type, tonic::CodeExt},
@@ -23,6 +29,7 @@ use crate::{
2329
pub struct GrpcMockServer {
2430
name: &'static str,
2531
addr: SocketAddr,
32+
base_url: Url,
2633
state: Arc<ServerState>,
2734
}
2835

@@ -31,14 +38,20 @@ impl GrpcMockServer {
3138
pub fn new(name: &'static str, mocks: MockSet) -> Result<Self, Error> {
3239
let port = find_available_port().unwrap();
3340
let addr: SocketAddr = format!("0.0.0.0:{}", port).parse().unwrap();
41+
let base_url = Url::parse(&format!("http://{}", &addr)).unwrap();
42+
let state = Arc::new(ServerState::new(mocks));
3443
Ok(Self {
3544
name,
3645
addr,
37-
state: Arc::new(ServerState::new(mocks)),
46+
base_url,
47+
state,
3848
})
3949
}
50+
}
4051

41-
pub async fn start(&self) -> Result<(), Error> {
52+
#[async_trait]
53+
impl MockServer for GrpcMockServer {
54+
async fn start(&self) -> Result<(), Error> {
4255
let service = GrpcMockSvc {
4356
state: self.state.clone(),
4457
};
@@ -63,28 +76,26 @@ impl GrpcMockServer {
6376
}
6477
});
6578

66-
// Cushion for server to become ready, there is probably a better approach :)
67-
tokio::time::sleep(Duration::from_secs(1)).await;
79+
// Give the server time to become ready
80+
tokio::time::sleep(Duration::from_millis(10)).await;
6881

6982
Ok(())
7083
}
7184

72-
/// Returns the server's service name.
73-
pub fn name(&self) -> &str {
85+
fn name(&self) -> &str {
7486
self.name
7587
}
7688

77-
/// Returns the server's address.
78-
pub fn addr(&self) -> SocketAddr {
89+
fn addr(&self) -> SocketAddr {
7990
self.addr
8091
}
8192

82-
pub fn base_url(&self) -> Url {
83-
Url::parse(&format!("http://{}", self.addr())).unwrap()
93+
fn url(&self, path: &str) -> Url {
94+
self.base_url.join(path).unwrap()
8495
}
8596

86-
pub fn url(&self, path: &str) -> Url {
87-
self.base_url().join(path).unwrap()
97+
fn mocks(&self) -> RwLockWriteGuard<'_, MockSet> {
98+
self.state.mocks.write().unwrap()
8899
}
89100
}
90101

@@ -140,7 +151,13 @@ impl Service<Request<Incoming>> for GrpcMockSvc {
140151
buf.extend(chunk);
141152
// Attempt to match buffered data to mock
142153
let body = buf.clone().freeze();
143-
if let Some(mock) = state.mocks.match_by_body(&path, &body) {
154+
let mock = state
155+
.mocks
156+
.read()
157+
.unwrap()
158+
.match_by_body(&path, &body)
159+
.cloned();
160+
if let Some(mock) = mock {
144161
matched = true;
145162
// A matching mock has been found, send response
146163
debug!("mock found, sending response");
@@ -166,7 +183,6 @@ impl Service<Request<Incoming>> for GrpcMockSvc {
166183
debug!("request stream closed");
167184
if !matched {
168185
debug!("no mocks found, sending error");
169-
dbg!(&state.mocks);
170186
let mut trailers = HeaderMap::new();
171187
trailers.insert("grpc-status", (tonic::Code::NotFound as i32).into());
172188
trailers.insert("grpc-message", HeaderValue::from_static("mock not found"));

0 commit comments

Comments
 (0)