diff --git a/Cargo.toml b/Cargo.toml index 3669484..d672d6f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,9 +14,14 @@ authors = [ ] [features] +json = ["dep:serde_json"] +matching = [] +regex = ["dep:regex"] [dependencies] +regex = { version = "1", optional = true } serde = { version = "1.0.127", features = ["derive"] } +serde_json = { version = "1", optional = true } void = "1.0.2" chrono = "0.4.19" url = { version = "2.2.2", features = ["serde"] } diff --git a/README.md b/README.md index 20e9c9b..71dc517 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,44 @@ To deserialize `.yaml` Cassette files use $ cargo add vcr-cassette ``` +## Features + +* `json` -- enables parsing and comparison of JSON request and response bodies. + Saves having to escape every double quote character in your JSON-format bodies when you're manually + writing them. Looks like this: + + ```json + { + "body": { + "json": { + "arbitrary": ["json", "is", "now", "supported"], + "success_factor": 100, + } + } + } + ``` + +* `matching` -- provides a mechanism for specifying "matchers" for request bodies, rather than a request body + 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). + They do more-or-less what they say on the tin. Use them like this: + + ```json + { + "body": { + "matches": [ + { "substring": "something" }, + { "substring": "funny" }, + { "regex": "\\d+" } + ] + } + } + ``` + + 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). + +* `regex` -- Enables the `regex` match type. + 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. + ## Safety This crate uses ``#![deny(unsafe_code)]`` to ensure everything is implemented in 100% Safe Rust. diff --git a/src/lib.rs b/src/lib.rs index 96fbde6..c61a155 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -54,8 +54,12 @@ use std::marker::PhantomData; use std::{collections::HashMap, str::FromStr}; use chrono::{offset::FixedOffset, DateTime}; -use serde::de::{self, MapAccess, Visitor}; -use serde::{Deserialize, Deserializer, Serialize}; +#[cfg(feature = "regex")] +use regex::Regex; +#[cfg(feature = "regex")] +use serde::de::Unexpected; +use serde::de::{self, Error, MapAccess, Visitor}; +use serde::{ser::SerializeMap, Deserialize, Deserializer, Serialize, Serializer}; use url::Url; use void::Void; @@ -114,7 +118,6 @@ pub struct HttpInteraction { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Response { /// An HTTP Body. - #[serde(deserialize_with = "string_or_struct")] pub body: Body, /// The version of the HTTP Response. pub http_version: Option, @@ -125,12 +128,246 @@ pub struct Response { } /// A recorded HTTP Body. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Body { - /// The encoding of the HTTP body. - pub encoding: Option, - /// The HTTP body encoded as a string. - pub string: String, +#[derive(Debug, Clone)] +#[non_exhaustive] +pub enum Body { + /// A bare string, eg `"body": "ohai!"` + /// + /// Only matches if the request's body matches the specified string *exactly*. + String(String), + /// A string and the request's encoding. Both must be exactly equal in order for the request + /// to match this interaction. + EncodedString { + /// The manner in which the string was encoded, such as `base64` + encoding: Option, + /// The encoded string + string: String, + }, + /// A series of [`BodyMatcher`] instances. All specified matchers must pass in order for the + /// request to be deemed to match this interaction. + #[cfg(feature = "matching")] + Matchers(Vec), + + /// A JSON body. Mostly useful to make it easier to define a JSON response body without having + /// to escape a thousand quotes. Does *not* modify the `Content-Type` response header; you + /// still have to do that yourself. + #[cfg(feature = "json")] + Json(serde_json::Value), +} + +impl std::fmt::Display for Body { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { + match self { + Self::String(s) => f.write_str(s), + Self::EncodedString { encoding, string } => if let Some(encoding) = encoding { + f.write_fmt(format_args!("({encoding}){string}")) + } else { + f.write_str(string) + }, + #[cfg(feature = "matching")] + Self::Matchers(m) => f.debug_list().entries(m.iter()).finish(), + #[cfg(feature = "json")] + Self::Json(j) => f.write_str(&serde_json::to_string(j).expect("invalid JSON body")), + } + } +} + +impl<'de> Deserialize<'de> for Body { + fn deserialize>(deserializer: D) -> Result { + struct BodyVisitor(PhantomData Body>); + + impl<'de> Visitor<'de> for BodyVisitor { + type Value = Body; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("string or map") + } + + fn visit_str(self, value: &str) -> Result { + Ok(FromStr::from_str(value).unwrap()) + } + + fn visit_map>(self, mut map: M) -> Result { + match map.next_key::()?.as_deref() { + Some("encoding") => { + let encoding = map.next_value()?; + match map.next_key::()?.as_deref() { + Some("string") => Ok(Body::EncodedString { + encoding, + string: map.next_value()?, + }), + Some(k) => Err(M::Error::unknown_field(k, &["string"])), + None => Err(M::Error::missing_field("string")), + } + } + Some("string") => { + let string = map.next_value()?; + match map.next_key::()?.as_deref() { + Some("encoding") => Ok(Body::EncodedString { + string, + encoding: map.next_value()?, + }), + Some(k) => Err(M::Error::unknown_field(k, &["encoding"])), + None => Err(M::Error::missing_field("encoding")), + } + } + #[cfg(feature = "matching")] + Some("matches") => Ok(Body::Matchers(map.next_value()?)), + #[cfg(feature = "json")] + Some("json") => Ok(Body::Json(map.next_value()?)), + Some(k) => Err(M::Error::unknown_field( + k, + &[ + "encoding", + "string", + #[cfg(feature = "matching")] + "matches", + #[cfg(feature = "json")] + "json", + ], + )), + None => { + // OK this is starting to get silly + #[cfg(all(feature = "matching", feature = "json"))] + let fields = "matches, json, encoding, or string"; + #[cfg(all(feature = "matching", not(feature = "json")))] + let fields = "matches, encoding, or string"; + #[cfg(all(not(feature = "matching"), feature = "json"))] + let fields = "json, encoding, or string"; + // Yes, DeMorgan says there's a better way to do this, but it's visually + // more similar to the previous versions, so it's more readable, IMO + #[cfg(all(not(feature = "matching"), not(feature = "json")))] + let fields = "encoding or string"; + + Err(M::Error::missing_field(fields)) + } + } + } + } + + deserializer.deserialize_any(BodyVisitor(PhantomData)) + } +} + +impl Serialize for Body { + fn serialize(&self, ser: S) -> Result { + match self { + Self::String(s) => ser.serialize_str(s), + Self::EncodedString { encoding, string } => { + let mut map = ser.serialize_map(Some(2))?; + map.serialize_entry("string", string)?; + map.serialize_entry("encoding", encoding)?; + map.end() + } + #[cfg(feature = "matching")] + Self::Matchers(m) => { + let mut map = ser.serialize_map(Some(1))?; + map.serialize_entry("matches", m)?; + map.end() + } + #[cfg(feature = "json")] + Self::Json(j) => { + let mut map = ser.serialize_map(Some(1))?; + map.serialize_entry("json", j)?; + map.end() + } + } + } +} + +impl PartialEq for Body { + fn eq(&self, other: &Body) -> bool { + match self { + Self::String(s) => match other { + Self::String(o) => s == o, + Self::EncodedString { encoding, string } => encoding.is_none() && s == string, + #[cfg(feature = "matching")] + Self::Matchers(_) => other.eq(self), + #[cfg(feature = "json")] + Self::Json(j) => serde_json::to_string(j).expect("invalid JSON body") == *s, + }, + Self::EncodedString { encoding, string } => match other { + Self::String(s) => encoding.is_none() && s == string, + Self::EncodedString { + encoding: oe, + string: os, + } => encoding == oe && string == os, + #[cfg(feature = "matching")] + Self::Matchers(_) => false, + #[cfg(feature = "json")] + Self::Json(_) => false, + }, + #[cfg(feature = "matching")] + Self::Matchers(matchers) => match other { + Self::String(s) => matchers.iter().all(|m| m.matches(s)), + Self::EncodedString { .. } => false, + #[cfg(feature = "matching")] + Self::Matchers(_) => false, + #[cfg(feature = "json")] + Self::Json(j) => { + let s = serde_json::to_string(j).expect("invalid JSON body"); + matchers.iter().all(|m| m.matches(&s)) + } + }, + #[cfg(feature = "json")] + Self::Json(_) => other.eq(self), + } + } +} + +/// A mechanism for determining if a request body matches a specified substring or regular +/// expression. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[non_exhaustive] +pub enum BodyMatcher { + /// The body must contain exactly the string specified. + #[serde(rename = "substring")] + Substring(String), + /// The body must match the specified regular expression. + #[cfg(feature = "regex")] + #[serde( + rename = "regex", + deserialize_with = "parse_regex", + serialize_with = "serialize_regex" + )] + Regex(Regex), +} + +#[cfg(feature = "regex")] +fn parse_regex<'de, D: Deserializer<'de>>(d: D) -> Result { + struct RegexVisitor(PhantomData Regex>); + + impl<'de> Visitor<'de> for RegexVisitor { + type Value = Regex; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("valid regular expression as a string") + } + + fn visit_str(self, s: &str) -> Result { + Regex::new(s).map_err(|_| { + E::invalid_value(Unexpected::Other("invalid regular expression"), &self) + }) + } + } + + d.deserialize_str(RegexVisitor(PhantomData)) +} + +#[cfg(feature = "regex")] +fn serialize_regex(r: &Regex, ser: S) -> Result { + ser.serialize_str(r.as_str()) +} + +#[cfg(feature = "matching")] +impl BodyMatcher { + fn matches(&self, s: &str) -> bool { + match self { + Self::Substring(m) => s.contains(m), + #[cfg(feature = "regex")] + Self::Regex(r) => r.is_match(s), + } + } } impl FromStr for Body { @@ -139,10 +376,7 @@ impl FromStr for Body { type Err = Void; fn from_str(s: &str) -> Result { - Ok(Body { - encoding: None, - string: s.to_string(), - }) + Ok(Body::String(s.to_string())) } } @@ -161,7 +395,6 @@ pub struct Request { /// The Request URI. pub uri: Url, /// The Request body. - #[serde(deserialize_with = "string_or_struct")] pub body: Body, /// The Request method. pub method: Method, @@ -240,39 +473,3 @@ pub enum Version { #[serde(rename = "3")] Http3_0, } - -// Copied from: https://serde.rs/string-or-struct.html -fn string_or_struct<'de, T, D>(deserializer: D) -> Result -where - T: Deserialize<'de> + FromStr, - D: Deserializer<'de>, -{ - struct StringOrStruct(PhantomData T>); - - impl<'de, T> Visitor<'de> for StringOrStruct - where - T: Deserialize<'de> + FromStr, - { - type Value = T; - - fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - formatter.write_str("string or map") - } - - fn visit_str(self, value: &str) -> Result - where - E: de::Error, - { - Ok(FromStr::from_str(value).unwrap()) - } - - fn visit_map(self, map: M) -> Result - where - M: MapAccess<'de>, - { - Deserialize::deserialize(de::value::MapAccessDeserializer::new(map)) - } - } - - deserializer.deserialize_any(StringOrStruct(PhantomData)) -} diff --git a/tests/test.rs b/tests/test.rs index 50323fa..09194f8 100644 --- a/tests/test.rs +++ b/tests/test.rs @@ -25,7 +25,8 @@ fn smoke_yaml() { for name in names { let name = format!("tests/fixtures/{}", name); println!("testing: {}", name); - let example = fs::read_to_string(name).unwrap(); - let _out: Cassette = serde_yaml::from_str(&example).unwrap(); + let example = fs::read_to_string(&name).unwrap(); + let _out: Cassette = + serde_yaml::from_str(&example).expect(&format!("failed to parse {name}")); } }