Skip to content

Commit 9cc780b

Browse files
committed
Add quality-of-life improvements for bodies
On the request side, you can now match the request body against one or more substrings and regular expressions, which makes life easier for those of us who need to deal with request bodies that have non-deterministic elements in them (like nonces). On the response side. you can now write your JSON response bodies *as JSON*, which is significantly easier than having to escape eleventy-billion quotes, and match up braces by eye. To avoid any bloat or unpleasant interactions with existing code, all the new features are gated behind, well, features.
1 parent 9028dbe commit 9cc780b

File tree

4 files changed

+291
-52
lines changed

4 files changed

+291
-52
lines changed

Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,14 @@ authors = [
1414
]
1515

1616
[features]
17+
json = ["dep:serde_json"]
18+
matching = []
19+
regex = ["dep:regex"]
1720

1821
[dependencies]
22+
regex = { version = "1", optional = true }
1923
serde = { version = "1.0.127", features = ["derive"] }
24+
serde_json = { version = "1", optional = true }
2025
void = "1.0.2"
2126
chrono = "0.4.19"
2227
url = { version = "2.2.2", features = ["serde"] }

README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,44 @@ To deserialize `.yaml` Cassette files use
8989
$ cargo add vcr-cassette
9090
```
9191

92+
## Features
93+
94+
* `json` -- enables parsing and comparison of JSON request and response bodies.
95+
Saves having to escape every double quote character in your JSON-format bodies when you're manually
96+
writing them. Looks like this:
97+
98+
```json
99+
{
100+
"body": {
101+
"json": {
102+
"arbitrary": ["json", "is", "now", "supported"],
103+
"success_factor": 100,
104+
}
105+
}
106+
}
107+
```
108+
109+
* `matching` -- provides a mechanism for specifying "matchers" for request bodies, rather than a request body
110+
having to be byte-for-byte compatible with what's specified in the cassette. There are currently two match types available, `substring` and `regex` (if the `regex` feature is also enabled).
111+
They do more-or-less what they say on the tin. Use them like this:
112+
113+
```json
114+
{
115+
"body": {
116+
"matches": [
117+
{ "substring": "something" },
118+
{ "substring": "funny" },
119+
{ "regex": "\\d+" }
120+
]
121+
}
122+
}
123+
```
124+
125+
The above stanza, appropriately placed in a *request* specification, will match any request whose body contains the strings `"something"`, and `"funny"`, and *also* contains a number (of any length).
126+
127+
* `regex` -- Enables the `regex` match type.
128+
This is a separate feature, because the `regex` crate can be a bit heavyweight for resource-constrained environments, and so it's optional, in case you don't need it.
129+
92130
## Safety
93131
This crate uses ``#![deny(unsafe_code)]`` to ensure everything is implemented in
94132
100% Safe Rust.

src/lib.rs

Lines changed: 245 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,12 @@ use std::marker::PhantomData;
5454
use std::{collections::HashMap, str::FromStr};
5555

5656
use chrono::{offset::FixedOffset, DateTime};
57-
use serde::de::{self, MapAccess, Visitor};
58-
use serde::{Deserialize, Deserializer, Serialize};
57+
#[cfg(feature = "regex")]
58+
use regex::Regex;
59+
#[cfg(feature = "regex")]
60+
use serde::de::Unexpected;
61+
use serde::de::{self, Error, MapAccess, Visitor};
62+
use serde::{ser::SerializeMap, Deserialize, Deserializer, Serialize, Serializer};
5963
use url::Url;
6064
use void::Void;
6165

@@ -114,7 +118,6 @@ pub struct HttpInteraction {
114118
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
115119
pub struct Response {
116120
/// An HTTP Body.
117-
#[serde(deserialize_with = "string_or_struct")]
118121
pub body: Body,
119122
/// The version of the HTTP Response.
120123
pub http_version: Option<Version>,
@@ -125,12 +128,244 @@ pub struct Response {
125128
}
126129

127130
/// A recorded HTTP Body.
128-
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
129-
pub struct Body {
130-
/// The encoding of the HTTP body.
131-
pub encoding: Option<String>,
132-
/// The HTTP body encoded as a string.
133-
pub string: String,
131+
#[derive(Debug, Clone)]
132+
#[non_exhaustive]
133+
pub enum Body {
134+
/// A bare string, eg `"body": "ohai!"`
135+
///
136+
/// Only matches if the request's body matches the specified string *exactly*.
137+
String(String),
138+
/// A string and the request's encoding. Both must be exactly equal in order for the request
139+
/// to match this interaction.
140+
EncodedString {
141+
/// The manner in which the string was encoded, such as `base64`
142+
encoding: String,
143+
/// The encoded string
144+
string: String,
145+
},
146+
/// A series of [`BodyMatcher`] instances. All specified matchers must pass in order for the
147+
/// request to be deemed to match this interaction.
148+
#[cfg(feature = "matching")]
149+
Matchers(Vec<BodyMatcher>),
150+
151+
/// A JSON body. Mostly useful to make it easier to define a JSON response body without having
152+
/// to escape a thousand quotes. Does *not* modify the `Content-Type` response header; you
153+
/// still have to do that yourself.
154+
#[cfg(feature = "json")]
155+
Json(serde_json::Value),
156+
}
157+
158+
impl std::fmt::Display for Body {
159+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
160+
match self {
161+
Self::String(s) => f.write_str(s),
162+
Self::EncodedString { encoding, string } => {
163+
f.write_fmt(format_args!("({encoding}){string}"))
164+
}
165+
#[cfg(feature = "matching")]
166+
Self::Matchers(m) => f.debug_list().entries(m.iter()).finish(),
167+
#[cfg(feature = "json")]
168+
Self::Json(j) => f.write_str(&serde_json::to_string(j).expect("invalid JSON body")),
169+
}
170+
}
171+
}
172+
173+
impl<'de> Deserialize<'de> for Body {
174+
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
175+
struct BodyVisitor(PhantomData<fn() -> Body>);
176+
177+
impl<'de> Visitor<'de> for BodyVisitor {
178+
type Value = Body;
179+
180+
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
181+
formatter.write_str("string or map")
182+
}
183+
184+
fn visit_str<E: de::Error>(self, value: &str) -> Result<Body, E> {
185+
Ok(FromStr::from_str(value).unwrap())
186+
}
187+
188+
fn visit_map<M: MapAccess<'de>>(self, mut map: M) -> Result<Body, M::Error> {
189+
match map.next_key::<String>()?.as_deref() {
190+
Some("encoding") => {
191+
let encoding = map.next_value()?;
192+
match map.next_key::<String>()?.as_deref() {
193+
Some("string") => Ok(Body::EncodedString {
194+
encoding,
195+
string: map.next_value()?,
196+
}),
197+
Some(k) => Err(M::Error::unknown_field(k, &["string"])),
198+
None => Err(M::Error::missing_field("string")),
199+
}
200+
}
201+
Some("string") => {
202+
let string = map.next_value()?;
203+
match map.next_key::<String>()?.as_deref() {
204+
Some("encoding") => Ok(Body::EncodedString {
205+
string,
206+
encoding: map.next_value()?,
207+
}),
208+
Some(k) => Err(M::Error::unknown_field(k, &["encoding"])),
209+
None => Err(M::Error::missing_field("encoding")),
210+
}
211+
}
212+
#[cfg(feature = "matching")]
213+
Some("matches") => Ok(Body::Matchers(map.next_value()?)),
214+
#[cfg(feature = "json")]
215+
Some("json") => Ok(Body::Json(map.next_value()?)),
216+
Some(k) => Err(M::Error::unknown_field(
217+
k,
218+
&[
219+
"encoding",
220+
"string",
221+
#[cfg(feature = "matching")]
222+
"matches",
223+
#[cfg(feature = "json")]
224+
"json",
225+
],
226+
)),
227+
None => {
228+
// OK this is starting to get silly
229+
#[cfg(all(feature = "matching", feature = "json"))]
230+
let fields = "matches, json, encoding, or string";
231+
#[cfg(all(feature = "matching", not(feature = "json")))]
232+
let fields = "matches, encoding, or string";
233+
#[cfg(all(not(feature = "matching"), feature = "json"))]
234+
let fields = "json, encoding, or string";
235+
// Yes, DeMorgan says there's a better way to do this, but it's visually
236+
// more similar to the previous versions, so it's more readable, IMO
237+
#[cfg(all(not(feature = "matching"), not(feature = "json")))]
238+
let fields = "encoding or string";
239+
240+
Err(M::Error::missing_field(fields))
241+
}
242+
}
243+
}
244+
}
245+
246+
deserializer.deserialize_any(BodyVisitor(PhantomData))
247+
}
248+
}
249+
250+
impl Serialize for Body {
251+
fn serialize<S: Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
252+
match self {
253+
Self::String(s) => ser.serialize_str(s),
254+
Self::EncodedString { encoding, string } => {
255+
let mut map = ser.serialize_map(Some(2))?;
256+
map.serialize_entry("string", string)?;
257+
map.serialize_entry("encoding", encoding)?;
258+
map.end()
259+
}
260+
#[cfg(feature = "matching")]
261+
Self::Matchers(m) => {
262+
let mut map = ser.serialize_map(Some(1))?;
263+
map.serialize_entry("matches", m)?;
264+
map.end()
265+
}
266+
#[cfg(feature = "json")]
267+
Self::Json(j) => {
268+
let mut map = ser.serialize_map(Some(1))?;
269+
map.serialize_entry("json", j)?;
270+
map.end()
271+
}
272+
}
273+
}
274+
}
275+
276+
impl PartialEq for Body {
277+
fn eq(&self, other: &Body) -> bool {
278+
match self {
279+
Self::String(s) => match other {
280+
Self::String(o) => s == o,
281+
Self::EncodedString { .. } => false,
282+
#[cfg(feature = "matching")]
283+
Self::Matchers(_) => other.eq(self),
284+
#[cfg(feature = "json")]
285+
Self::Json(j) => serde_json::to_string(j).expect("invalid JSON body") == *s,
286+
},
287+
Self::EncodedString { encoding, string } => match other {
288+
Self::String(_) => false,
289+
Self::EncodedString {
290+
encoding: oe,
291+
string: os,
292+
} => encoding == oe && string == os,
293+
#[cfg(feature = "matching")]
294+
Self::Matchers(_) => false,
295+
#[cfg(feature = "json")]
296+
Self::Json(_) => false,
297+
},
298+
#[cfg(feature = "matching")]
299+
Self::Matchers(matchers) => match other {
300+
Self::String(s) => matchers.iter().all(|m| m.matches(s)),
301+
Self::EncodedString { .. } => false,
302+
#[cfg(feature = "matching")]
303+
Self::Matchers(_) => false,
304+
#[cfg(feature = "json")]
305+
Self::Json(j) => {
306+
let s = serde_json::to_string(j).expect("invalid JSON body");
307+
matchers.iter().all(|m| m.matches(&s))
308+
}
309+
},
310+
#[cfg(feature = "json")]
311+
Self::Json(_) => other.eq(self),
312+
}
313+
}
314+
}
315+
316+
/// A mechanism for determining if a request body matches a specified substring or regular
317+
/// expression.
318+
#[derive(Debug, Clone, Serialize, Deserialize)]
319+
#[non_exhaustive]
320+
pub enum BodyMatcher {
321+
/// The body must contain exactly the string specified.
322+
#[serde(rename = "substring")]
323+
Substring(String),
324+
/// The body must match the specified regular expression.
325+
#[cfg(feature = "regex")]
326+
#[serde(
327+
rename = "regex",
328+
deserialize_with = "parse_regex",
329+
serialize_with = "serialize_regex"
330+
)]
331+
Regex(Regex),
332+
}
333+
334+
#[cfg(feature = "regex")]
335+
fn parse_regex<'de, D: Deserializer<'de>>(d: D) -> Result<Regex, D::Error> {
336+
struct RegexVisitor(PhantomData<fn() -> Regex>);
337+
338+
impl<'de> Visitor<'de> for RegexVisitor {
339+
type Value = Regex;
340+
341+
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
342+
formatter.write_str("valid regular expression as a string")
343+
}
344+
345+
fn visit_str<E: de::Error>(self, s: &str) -> Result<Regex, E> {
346+
Regex::new(s).map_err(|_| {
347+
E::invalid_value(Unexpected::Other("invalid regular expression"), &self)
348+
})
349+
}
350+
}
351+
352+
d.deserialize_str(RegexVisitor(PhantomData))
353+
}
354+
355+
#[cfg(feature = "regex")]
356+
fn serialize_regex<S: Serializer>(r: &Regex, ser: S) -> Result<S::Ok, S::Error> {
357+
ser.serialize_str(r.as_str())
358+
}
359+
360+
#[cfg(feature = "matching")]
361+
impl BodyMatcher {
362+
fn matches(&self, s: &str) -> bool {
363+
match self {
364+
Self::Substring(m) => s.contains(m),
365+
#[cfg(feature = "regex")]
366+
Self::Regex(r) => r.is_match(s),
367+
}
368+
}
134369
}
135370

136371
impl FromStr for Body {
@@ -139,10 +374,7 @@ impl FromStr for Body {
139374
type Err = Void;
140375

141376
fn from_str(s: &str) -> Result<Self, Self::Err> {
142-
Ok(Body {
143-
encoding: None,
144-
string: s.to_string(),
145-
})
377+
Ok(Body::String(s.to_string()))
146378
}
147379
}
148380

@@ -161,7 +393,6 @@ pub struct Request {
161393
/// The Request URI.
162394
pub uri: Url,
163395
/// The Request body.
164-
#[serde(deserialize_with = "string_or_struct")]
165396
pub body: Body,
166397
/// The Request method.
167398
pub method: Method,
@@ -240,39 +471,3 @@ pub enum Version {
240471
#[serde(rename = "3")]
241472
Http3_0,
242473
}
243-
244-
// Copied from: https://serde.rs/string-or-struct.html
245-
fn string_or_struct<'de, T, D>(deserializer: D) -> Result<T, D::Error>
246-
where
247-
T: Deserialize<'de> + FromStr<Err = Void>,
248-
D: Deserializer<'de>,
249-
{
250-
struct StringOrStruct<T>(PhantomData<fn() -> T>);
251-
252-
impl<'de, T> Visitor<'de> for StringOrStruct<T>
253-
where
254-
T: Deserialize<'de> + FromStr<Err = Void>,
255-
{
256-
type Value = T;
257-
258-
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
259-
formatter.write_str("string or map")
260-
}
261-
262-
fn visit_str<E>(self, value: &str) -> Result<T, E>
263-
where
264-
E: de::Error,
265-
{
266-
Ok(FromStr::from_str(value).unwrap())
267-
}
268-
269-
fn visit_map<M>(self, map: M) -> Result<T, M::Error>
270-
where
271-
M: MapAccess<'de>,
272-
{
273-
Deserialize::deserialize(de::value::MapAccessDeserializer::new(map))
274-
}
275-
}
276-
277-
deserializer.deserialize_any(StringOrStruct(PhantomData))
278-
}

0 commit comments

Comments
 (0)