From 5345e13069d9008a4b17f27ad2eb4ed367f5cbcf Mon Sep 17 00:00:00 2001 From: Matt Palmer Date: Fri, 7 Jun 2024 19:09:21 +1000 Subject: [PATCH] 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. --- Cargo.toml | 5 + README.md | 38 +++++++ src/lib.rs | 297 +++++++++++++++++++++++++++++++++++++++++--------- tests/test.rs | 5 +- 4 files changed, 293 insertions(+), 52 deletions(-) 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}")); } }