Skip to main content

pact_plugin_driver/
field.rs

1//! Support for matching and generating individual field/element values.
2//!
3//! This is the field-level counterpart of [`crate::content`]: where a content matcher owns a whole
4//! content type, a field matcher applies one matching rule to one value inside somebody else's
5//! content - a field in a JSON body, a header, a message metadata value. See proposal 006
6//! (Field-level matchers and generators) for the design.
7//!
8//! The proto types used here are the V2 interface ones. Field-level operations were introduced in
9//! V2 and have no V1 equivalent, so a V1 plugin cannot provide them.
10
11use std::collections::HashMap;
12use std::sync::{Arc, Mutex};
13
14use anyhow::anyhow;
15use bytes::Bytes;
16use lazy_static::lazy_static;
17use pact_models::matchingrules::MatchingRule;
18use pact_models::path_exp::DocPath;
19use pact_models::prelude::Generator;
20use serde_json::Value;
21use tokio::runtime::Runtime;
22use tracing::{debug, error};
23
24use crate::catalogue_manager::{CatalogueEntry, CatalogueEntryProviderType, CatalogueEntryType, resolve_capability_entry};
25use crate::content::ContentMismatch;
26use crate::core_capabilities;
27use crate::plugin_manager::lookup_plugin;
28use crate::plugin_models::{PactPluginManifest, PluginInteractionConfig};
29use crate::proto_v2::{
30  FieldValue as ProtoFieldValue,
31  GenerateFieldRequest,
32  GenerateFieldResponse,
33  MatchFieldRequest,
34  MatchFieldResponse,
35  MatchingRule as ProtoMatchingRule,
36  Generator as ProtoGenerator,
37  PluginConfiguration as ProtoPluginConfiguration,
38  field_value
39};
40use crate::utils::{proto_value_to_json, to_proto_struct, to_proto_value};
41
42/// A single value being matched or generated at the field/element level - the driver-side
43/// counterpart of the proto `FieldValue`.
44///
45/// Binary-safe by construction: a value that is not representable as text is carried as bytes
46/// rather than being stringified into one. Scalar types survive the trip to a plugin intact,
47/// including the difference between a whole number and a decimal, which the `integer`, `decimal`
48/// and `type` matching rules all depend on - see [`FieldValue::to_proto`].
49#[derive(Clone, Debug, PartialEq)]
50pub enum FieldValue {
51  /// A JSON-like value
52  Json(Value),
53  /// Raw bytes, for a value that is not representable as JSON
54  Binary(Bytes)
55}
56
57impl FieldValue {
58  /// Convert to the protobuf form sent to a plugin or core handler.
59  ///
60  /// Each scalar type gets its own arm, so a matching rule on the other side sees the type the
61  /// value actually has - in particular a whole number stays whole, which `integer`, `decimal` and
62  /// `type` all depend on. Only maps and lists go across as a `google.protobuf.Value`.
63  pub fn to_proto(&self) -> ProtoFieldValue {
64    ProtoFieldValue {
65      value: Some(match self {
66        FieldValue::Binary(bytes) => field_value::Value::BinaryValue(bytes.to_vec()),
67        FieldValue::Json(Value::Null) => field_value::Value::NullValue(0),
68        FieldValue::Json(Value::Bool(value)) => field_value::Value::BooleanValue(*value),
69        FieldValue::Json(Value::String(value)) => field_value::Value::StringValue(value.clone()),
70        FieldValue::Json(Value::Number(number)) => match number.as_i64() {
71          Some(value) => field_value::Value::IntegerValue(value),
72          // A u64 above i64::MAX has nowhere exact to go; a double at least keeps the magnitude
73          None => field_value::Value::DecimalValue(number.as_f64().unwrap_or_default())
74        },
75        FieldValue::Json(value) => field_value::Value::StructuredValue(to_proto_value(value))
76      })
77    }
78  }
79
80  /// Convert from the protobuf form returned by a plugin or core handler. An unset value is
81  /// treated as null, matching how an absent `oneof` reads everywhere else in the interface.
82  pub fn from_proto(value: &ProtoFieldValue) -> FieldValue {
83    match &value.value {
84      Some(field_value::Value::NullValue(_)) | None => FieldValue::Json(Value::Null),
85      Some(field_value::Value::BooleanValue(value)) => FieldValue::Json(Value::Bool(*value)),
86      Some(field_value::Value::StringValue(value)) => FieldValue::Json(Value::String(value.clone())),
87      Some(field_value::Value::IntegerValue(value)) => FieldValue::Json(Value::Number((*value).into())),
88      Some(field_value::Value::DecimalValue(value)) => FieldValue::Json(
89        serde_json::Number::from_f64(*value)
90          .map(Value::Number)
91          // NaN and the infinities have no JSON representation
92          .unwrap_or(Value::Null)
93      ),
94      Some(field_value::Value::BinaryValue(bytes)) => FieldValue::Binary(Bytes::from(bytes.clone())),
95      Some(field_value::Value::StructuredValue(value)) => FieldValue::Json(proto_value_to_json(value))
96    }
97  }
98}
99
100impl From<Value> for FieldValue {
101  fn from(value: Value) -> Self {
102    FieldValue::Json(value)
103  }
104}
105
106impl From<Bytes> for FieldValue {
107  fn from(bytes: Bytes) -> Self {
108    FieldValue::Binary(bytes)
109  }
110}
111
112/// Where a value sits and what is known about it, shared by matching and generation.
113#[derive(Clone, Debug)]
114pub struct FieldContext {
115  /// Path to the value, as a Pact matching rule expression (`$.card.number`)
116  pub path: DocPath,
117  /// Part of the interaction the value came from: `body`, `header`, `metadata`, `query`, `path`,
118  /// `status`. Only affects how a mismatch is reported; generation ignores it.
119  pub category: String,
120  /// Plugin configuration persisted into the Pact file for this interaction
121  pub plugin_config: Option<PluginInteractionConfig>,
122  /// Context data provided by the test framework
123  pub test_context: HashMap<String, Value>
124}
125
126impl Default for FieldContext {
127  fn default() -> Self {
128    FieldContext {
129      path: DocPath::root(),
130      category: "body".to_string(),
131      plugin_config: None,
132      test_context: HashMap::default()
133    }
134  }
135}
136
137impl FieldContext {
138  /// A context for a value at the given path in the given part of the interaction
139  pub fn new(path: &DocPath, category: &str) -> FieldContext {
140    FieldContext {
141      path: path.clone(),
142      category: category.to_string(),
143      .. FieldContext::default()
144    }
145  }
146
147  /// Set the plugin configuration
148  pub fn with_plugin_config(self, plugin_config: Option<PluginInteractionConfig>) -> FieldContext {
149    FieldContext { plugin_config, .. self }
150  }
151
152  /// Set the test framework context data
153  pub fn with_test_context(self, test_context: HashMap<String, Value>) -> FieldContext {
154    FieldContext { test_context, .. self }
155  }
156}
157
158/// Matching rule for a single field/element value, provided by a plugin or by a handler the host
159/// framework registered (see [`crate::core_capabilities::CoreFieldMatcher`]).
160#[derive(Clone, Debug)]
161pub struct FieldMatcher {
162  /// Catalogue entry for this matching rule
163  pub catalogue_entry: CatalogueEntry
164}
165
166/// Generator for a single field/element value. See [`FieldMatcher`].
167#[derive(Clone, Debug)]
168pub struct FieldGenerator {
169  /// Catalogue entry for this generator
170  pub catalogue_entry: CatalogueEntry
171}
172
173/// Find the field-level matching rule with the given name. The name is resolved against the
174/// catalogue the same way any other capability key is - see
175/// [`crate::catalogue_manager::resolve_capability`] - so `creditcard` finds a plugin's own rule and
176/// `type` finds the core `type` rule. Returns a descriptive error if the name matches nothing,
177/// matches more than one rule, or names something that is not a matching rule.
178pub fn find_field_matcher(name: &str) -> anyhow::Result<FieldMatcher> {
179  resolve_capability_entry(name, CatalogueEntryType::MATCHER)
180    .map(|catalogue_entry| FieldMatcher { catalogue_entry })
181}
182
183/// Find the field-level generator with the given name. See [`find_field_matcher`].
184pub fn find_field_generator(name: &str) -> anyhow::Result<FieldGenerator> {
185  resolve_capability_entry(name, CatalogueEntryType::GENERATOR)
186    .map(|catalogue_entry| FieldGenerator { catalogue_entry })
187}
188
189impl FieldMatcher {
190  /// If this is a matching rule provided by the core framework rather than a plugin
191  pub fn is_core(&self) -> bool {
192    self.catalogue_entry.provider_type == CatalogueEntryProviderType::CORE
193  }
194
195  /// Catalogue entry key for this matching rule
196  pub fn catalogue_entry_key(&self) -> String {
197    if self.is_core() {
198      format!("core/matcher/{}", self.catalogue_entry.key)
199    } else {
200      format!("plugin/{}/matcher/{}", self.plugin_name(), self.catalogue_entry.key)
201    }
202  }
203
204  /// Plugin that provides this matching rule, if any
205  pub fn plugin(&self) -> Option<PactPluginManifest> {
206    self.catalogue_entry.plugin.clone()
207  }
208
209  /// Name of the plugin that provides this matching rule
210  pub fn plugin_name(&self) -> String {
211    self.catalogue_entry.plugin.as_ref()
212      .map(|plugin| plugin.name.clone())
213      .unwrap_or("core".to_string())
214  }
215
216  /// Apply this matching rule to a single value.
217  ///
218  /// The context carries where the value lives and which part of the interaction it came from;
219  /// both are echoed back on any mismatch that does not place itself. An empty result means the
220  /// value matched.
221  pub async fn match_field(
222    &self,
223    rule: &MatchingRule,
224    expected: &FieldValue,
225    actual: &FieldValue,
226    context: &FieldContext
227  ) -> Result<(), Vec<ContentMismatch>> {
228    let request = MatchFieldRequest {
229      key: self.catalogue_entry.key.clone(),
230      rule: Some(to_proto_matching_rule(rule)),
231      path: context.path.to_string(),
232      mismatch_type: context.category.clone(),
233      expected: Some(expected.to_proto()),
234      actual: Some(actual.to_proto()),
235      plugin_configuration: context.plugin_config.clone().map(to_proto_plugin_config),
236      test_context: Some(to_proto_struct(&with_test_run_id(&context.test_context)))
237    };
238
239    let response = if self.is_core() {
240      match core_capabilities::lookup_core_field_matcher(&self.catalogue_entry.key) {
241        Some(handler) => handler.match_field(request).await,
242        None => Err(anyhow!("No core field matcher registered for '{}'", self.catalogue_entry.key))
243      }
244    } else {
245      self.call_plugin(request).await
246    };
247
248    process_match_field_response(response, context)
249  }
250
251  async fn call_plugin(&self, request: MatchFieldRequest) -> anyhow::Result<MatchFieldResponse> {
252    let manifest = self.catalogue_entry.plugin.as_ref()
253      .ok_or_else(|| anyhow!("Catalogue entry '{}' has no plugin manifest", self.catalogue_entry_key()))?;
254    let plugin = lookup_plugin(&manifest.as_dependency())
255      .ok_or_else(|| anyhow!("Plugin '{}' for matching rule '{}' is not currently running",
256        manifest.name, self.catalogue_entry.key))?;
257    debug!("Sending MatchField request to plugin {:?}", manifest.name);
258    let chain_id = crate::call_chain::new_call_chain_id();
259    let deadline_ms = crate::call_chain::default_deadline_ms();
260    plugin.match_field_with_chain(request, &chain_id, deadline_ms).await
261  }
262
263  /// Apply this matching rule to a single value from a synchronous call path.
264  ///
265  /// Every Rust host applies matching rules synchronously (`match_values` and the matching engine's
266  /// `execute_*_plan` functions), while a plugin call is async, so this bridge exists so each host
267  /// does not have to build its own - see [`block_on_field_call`] for why the obvious ways of doing
268  /// it do not work.
269  pub fn match_field_blocking(
270    &self,
271    rule: &MatchingRule,
272    expected: &FieldValue,
273    actual: &FieldValue,
274    context: &FieldContext
275  ) -> Result<(), Vec<ContentMismatch>> {
276    let matcher = self.clone();
277    let rule = rule.clone();
278    let expected = expected.clone();
279    let actual = actual.clone();
280    let call_context = context.clone();
281
282    block_on_field_call(async move {
283      matcher.match_field(&rule, &expected, &actual, &call_context).await
284    })
285    .unwrap_or_else(|err| Err(vec![mismatch_for(err.to_string(), context)]))
286  }
287}
288
289impl FieldGenerator {
290  /// If this is a generator provided by the core framework rather than a plugin
291  pub fn is_core(&self) -> bool {
292    self.catalogue_entry.provider_type == CatalogueEntryProviderType::CORE
293  }
294
295  /// Catalogue entry key for this generator
296  pub fn catalogue_entry_key(&self) -> String {
297    if self.is_core() {
298      format!("core/generator/{}", self.catalogue_entry.key)
299    } else {
300      format!("plugin/{}/generator/{}", self.plugin_name(), self.catalogue_entry.key)
301    }
302  }
303
304  /// Plugin that provides this generator, if any
305  pub fn plugin(&self) -> Option<PactPluginManifest> {
306    self.catalogue_entry.plugin.clone()
307  }
308
309  /// Name of the plugin that provides this generator
310  pub fn plugin_name(&self) -> String {
311    self.catalogue_entry.plugin.as_ref()
312      .map(|plugin| plugin.name.clone())
313      .unwrap_or("core".to_string())
314  }
315
316  /// Generate a single value, replacing the example value from the Pact interaction.
317  pub async fn generate_field(
318    &self,
319    generator: &Generator,
320    example: &FieldValue,
321    mode: TestMode,
322    context: &FieldContext
323  ) -> anyhow::Result<FieldValue> {
324    let request = GenerateFieldRequest {
325      key: self.catalogue_entry.key.clone(),
326      generator: Some(to_proto_generator(generator)),
327      path: context.path.to_string(),
328      example_value: Some(example.to_proto()),
329      plugin_configuration: context.plugin_config.clone().map(to_proto_plugin_config),
330      test_context: Some(to_proto_struct(&with_test_run_id(&context.test_context))),
331      test_mode: mode.to_proto() as i32
332    };
333
334    let response = if self.is_core() {
335      let handler = core_capabilities::lookup_core_field_generator(&self.catalogue_entry.key)
336        .ok_or_else(|| anyhow!("No core field generator registered for '{}'", self.catalogue_entry.key))?;
337      handler.generate_field(request).await?
338    } else {
339      self.call_plugin(request).await?
340    };
341
342    if !response.error.is_empty() {
343      return Err(anyhow!("Generator '{}' failed: {}", self.catalogue_entry.key, response.error));
344    }
345    match &response.value {
346      Some(value) => Ok(FieldValue::from_proto(value)),
347      None => Err(anyhow!("Generator '{}' returned no value", self.catalogue_entry.key))
348    }
349  }
350
351  async fn call_plugin(&self, request: GenerateFieldRequest) -> anyhow::Result<GenerateFieldResponse> {
352    let manifest = self.catalogue_entry.plugin.as_ref()
353      .ok_or_else(|| anyhow!("Catalogue entry '{}' has no plugin manifest", self.catalogue_entry_key()))?;
354    let plugin = lookup_plugin(&manifest.as_dependency())
355      .ok_or_else(|| anyhow!("Plugin '{}' for generator '{}' is not currently running",
356        manifest.name, self.catalogue_entry.key))?;
357    debug!("Sending GenerateField request to plugin {:?}", manifest.name);
358    let chain_id = crate::call_chain::new_call_chain_id();
359    let deadline_ms = crate::call_chain::default_deadline_ms();
360    plugin.generate_field_with_chain(request, &chain_id, deadline_ms).await
361  }
362
363  /// Generate a single value from a synchronous call path. See
364  /// [`FieldMatcher::match_field_blocking`].
365  pub fn generate_field_blocking(
366    &self,
367    generator: &Generator,
368    example: &FieldValue,
369    mode: TestMode,
370    context: &FieldContext
371  ) -> anyhow::Result<FieldValue> {
372    let field_generator = self.clone();
373    let generator = generator.clone();
374    let example = example.clone();
375    let context = context.clone();
376
377    block_on_field_call(async move {
378      field_generator.generate_field(&generator, &example, mode, &context).await
379    })?
380  }
381}
382
383/// Which side of the test a generator is running on, mirroring `GenerateContentRequest.TestMode`
384/// in the plugin interface.
385#[derive(Clone, Copy, Debug, PartialEq, Eq)]
386pub enum TestMode {
387  /// Running on the consumer side
388  Consumer,
389  /// Running on the provider side
390  Provider,
391  /// Not known
392  Unknown
393}
394
395impl TestMode {
396  fn to_proto(self) -> crate::proto_v2::generate_content_request::TestMode {
397    use crate::proto_v2::generate_content_request::TestMode as ProtoTestMode;
398    match self {
399      TestMode::Consumer => ProtoTestMode::Consumer,
400      TestMode::Provider => ProtoTestMode::Provider,
401      TestMode::Unknown => ProtoTestMode::Unknown
402    }
403  }
404}
405
406lazy_static! {
407  /// Runtime the driver owns for field-level plugin calls made from a synchronous call path.
408  /// Built on first use and never dropped - dropping a Tokio runtime from inside an async context
409  /// panics, and by definition this one is reached from call paths that may be exactly that.
410  static ref FIELD_RUNTIME: Mutex<Option<Arc<Runtime>>> = Mutex::new(None);
411}
412
413fn field_runtime() -> anyhow::Result<Arc<Runtime>> {
414  let mut guard = FIELD_RUNTIME.lock()
415    .map_err(|err| anyhow!("FIELD_RUNTIME mutex poisoned - {}", err))?;
416  match guard.as_ref() {
417    Some(runtime) => Ok(runtime.clone()),
418    None => {
419      let runtime = Arc::new(tokio::runtime::Builder::new_multi_thread()
420        .worker_threads(1)
421        .enable_all()
422        .thread_name("pact-plugin-field")
423        .build()?);
424      *guard = Some(runtime.clone());
425      Ok(runtime)
426    }
427  }
428}
429
430/// Run a field-level plugin call to completion from a synchronous call path.
431///
432/// The obvious approaches do not work. `Handle::current().block_on(..)` panics ("Cannot start a
433/// runtime from within a runtime") because matching is reached from an async call path, so the
434/// calling thread is already driving tasks. `task::block_in_place` re-enters legitimately but
435/// panics on a `current_thread` runtime, and some Pact entry points use one.
436///
437/// So the future runs on a runtime the driver owns, and the calling thread waits on a channel. It
438/// does block a host thread for the duration, which is inherent to bridging sync and async, but the
439/// plugin call itself never depends on the host's runtime making progress: the driver opens a fresh
440/// gRPC channel per call (see `GrpcPactPlugin::connect_channel`), so the connection driving that
441/// call belongs to this runtime too.
442fn block_on_field_call<F, T>(future: F) -> anyhow::Result<T>
443where
444  F: std::future::Future<Output = T> + Send + 'static,
445  T: Send + 'static
446{
447  let runtime = field_runtime()?;
448  let deadline_ms = crate::call_chain::default_deadline_ms();
449  let (sender, receiver) = std::sync::mpsc::channel();
450  runtime.spawn(async move {
451    // A send error just means the caller already gave up waiting
452    let _ = sender.send(future.await);
453  });
454  receiver.recv_timeout(crate::call_chain::remaining(deadline_ms))
455    .map_err(|err| {
456      error!("Timed out waiting for a field-level plugin call to complete - {}", err);
457      anyhow!("Timed out waiting for the plugin call to complete - {}", err)
458    })
459}
460
461fn process_match_field_response(
462  response: anyhow::Result<MatchFieldResponse>,
463  context: &FieldContext
464) -> Result<(), Vec<ContentMismatch>> {
465  let path = context.path.to_string();
466  match response {
467    Ok(response) => if !response.error.is_empty() {
468      Err(vec![mismatch_for(response.error, context)])
469    } else if response.mismatches.is_empty() {
470      Ok(())
471    } else {
472      Err(response.mismatches.iter().map(|mismatch| ContentMismatch {
473        expected: mismatch.expected.as_ref()
474          .map(|bytes| String::from_utf8_lossy(bytes).to_string())
475          .unwrap_or_default(),
476        actual: mismatch.actual.as_ref()
477          .map(|bytes| String::from_utf8_lossy(bytes).to_string())
478          .unwrap_or_default(),
479        mismatch: mismatch.mismatch.clone(),
480        // A mismatch that does not place itself is reported against the value being matched
481        path: if mismatch.path.is_empty() { path.clone() } else { mismatch.path.clone() },
482        diff: if mismatch.diff.is_empty() { None } else { Some(mismatch.diff.clone()) },
483        mismatch_type: if mismatch.mismatch_type.is_empty() {
484          Some(context.category.clone())
485        } else {
486          Some(mismatch.mismatch_type.clone())
487        }
488      }).collect())
489    },
490    Err(err) => {
491      error!("Field-level match call failed - {}", err);
492      Err(vec![mismatch_for(err.to_string(), context)])
493    }
494  }
495}
496
497fn mismatch_for(message: String, context: &FieldContext) -> ContentMismatch {
498  ContentMismatch {
499    expected: Default::default(),
500    actual: Default::default(),
501    mismatch: message,
502    path: context.path.to_string(),
503    diff: None,
504    mismatch_type: Some(context.category.clone())
505  }
506}
507
508/// The test context a field-level request carries, with the current test run ID added if the host
509/// did not supply one.
510///
511/// The host has no test context to hand at the point a matching rule is applied - it is deep
512/// inside matching, several layers below anything that knows about the test - so without this the
513/// `testContext` on a field request would always be empty and a plugin could not correlate what it
514/// logs with the test that caused it. The gRPC content-level path does the same thing for the same
515/// reason (see `grpc_plugin::PluginClient::compare_contents`); doing it here rather than there
516/// covers every transport a field rule can be dispatched through, including an in-process core
517/// handler and Lua. See proposals 006 and 008.
518fn with_test_run_id(test_context: &HashMap<String, Value>) -> HashMap<String, Value> {
519  let mut context = test_context.clone();
520  if let Some(id) = crate::test_context::current_test_run_id() {
521    context.entry("testRunId".to_string()).or_insert_with(|| Value::String(id));
522  }
523  context
524}
525
526fn to_proto_matching_rule(rule: &MatchingRule) -> ProtoMatchingRule {
527  ProtoMatchingRule {
528    r#type: rule.name(),
529    values: Some(to_proto_struct(&rule.value_map()))
530  }
531}
532
533fn to_proto_generator(generator: &Generator) -> ProtoGenerator {
534  ProtoGenerator {
535    r#type: generator.name(),
536    values: Some(to_proto_struct(&generator.value_map()))
537  }
538}
539
540fn to_proto_plugin_config(config: PluginInteractionConfig) -> ProtoPluginConfiguration {
541  ProtoPluginConfiguration {
542    interaction_configuration: Some(to_proto_struct(&config.interaction_configuration)),
543    pact_configuration: Some(to_proto_struct(&config.pact_configuration))
544  }
545}
546
547#[cfg(test)]
548mod tests {
549  use async_trait::async_trait;
550  use expectest::prelude::*;
551  use maplit::hashmap;
552  use pact_models::matchingrules::MatchingRule;
553
554  use crate::catalogue_manager::{CatalogueEntryProviderType, register_core_entries};
555  use crate::core_capabilities::{
556    CoreFieldGenerator,
557    CoreFieldMatcher,
558    deregister_core_field_generator,
559    deregister_core_field_matcher,
560    register_core_field_generator,
561    register_core_field_matcher
562  };
563  use crate::proto_v2::ContentMismatch as ProtoContentMismatch;
564
565  use super::*;
566
567  #[test]
568  fn field_values_round_trip_through_the_proto_form() {
569    for value in [
570      FieldValue::Json(Value::String("4111111111111111".to_string())),
571      FieldValue::Json(serde_json::json!(100)),
572      FieldValue::Json(serde_json::json!(-100.5)),
573      FieldValue::Json(Value::Bool(true)),
574      FieldValue::Json(Value::Null),
575      FieldValue::Json(serde_json::json!({ "brand": "visa" })),
576      // A value that is not representable as JSON survives as bytes rather than being stringified
577      FieldValue::Binary(Bytes::from(vec![0u8, 159, 146, 150]))
578    ] {
579      expect!(FieldValue::from_proto(&value.to_proto())).to(be_equal_to(value));
580    }
581  }
582
583  #[test]
584  fn a_whole_number_stays_whole_and_a_decimal_stays_decimal() {
585    // The distinction the `integer`, `decimal` and `type` rules are built on, and the reason
586    // FieldValue does not put every value through a google.protobuf.Value
587    let integer = FieldValue::from_proto(&FieldValue::Json(serde_json::json!(100)).to_proto());
588    let decimal = FieldValue::from_proto(&FieldValue::Json(serde_json::json!(100.5)).to_proto());
589    let whole_decimal = FieldValue::from_proto(&FieldValue::Json(serde_json::json!(100.0)).to_proto());
590
591    expect!(integer.clone()).to(be_equal_to(FieldValue::Json(serde_json::json!(100))));
592    expect!(decimal).to(be_equal_to(FieldValue::Json(serde_json::json!(100.5))));
593    match integer {
594      FieldValue::Json(Value::Number(number)) => expect!(number.is_i64()).to(be_true()),
595      other => panic!("expected a JSON number, got {:?}", other)
596    };
597    // A decimal that happens to be whole stays a decimal - it is not quietly promoted to an
598    // integer, which would make `decimal` reject a value it should accept
599    expect!(whole_decimal.clone()).to(be_equal_to(FieldValue::Json(serde_json::json!(100.0))));
600    match whole_decimal {
601      FieldValue::Json(Value::Number(number)) => expect!(number.is_f64()).to(be_true()),
602      other => panic!("expected a JSON number, got {:?}", other)
603    };
604  }
605
606  #[test]
607  fn each_scalar_type_crosses_the_boundary_under_its_own_arm() {
608    let cases = [
609      (FieldValue::Json(Value::Null), "null"),
610      (FieldValue::Json(Value::Bool(true)), "boolean"),
611      (FieldValue::Json(serde_json::json!("4111111111111111")), "string"),
612      (FieldValue::Json(serde_json::json!(100)), "integer"),
613      (FieldValue::Json(serde_json::json!(100.5)), "decimal"),
614      (FieldValue::Binary(Bytes::from(vec![0u8, 159, 146, 150])), "binary"),
615      (FieldValue::Json(serde_json::json!({ "brand": "visa" })), "structured")
616    ];
617    for (value, expected_arm) in cases {
618      let arm = match value.to_proto().value {
619        Some(field_value::Value::NullValue(_)) => "null",
620        Some(field_value::Value::BooleanValue(_)) => "boolean",
621        Some(field_value::Value::StringValue(_)) => "string",
622        Some(field_value::Value::IntegerValue(_)) => "integer",
623        Some(field_value::Value::DecimalValue(_)) => "decimal",
624        Some(field_value::Value::BinaryValue(_)) => "binary",
625        Some(field_value::Value::StructuredValue(_)) => "structured",
626        None => "unset"
627      };
628      expect!(arm).to(be_equal_to(expected_arm));
629    }
630  }
631
632  #[test]
633  fn an_unset_proto_value_reads_as_json_null() {
634    expect!(FieldValue::from_proto(&ProtoFieldValue { value: None }))
635      .to(be_equal_to(FieldValue::Json(Value::Null)));
636  }
637
638  #[test]
639  fn a_plugin_rules_configuration_crosses_the_boundary() {
640    // A plugin rule's configuration keys are only known at runtime, which is why this reads
641    // value_map rather than values - the latter can only carry keys known at compile time, and
642    // returns nothing for a plugin rule
643    let rule = MatchingRule::Plugin {
644      name: "creditcard".to_string(),
645      values: serde_json::json!({ "brand": "visa" })
646    };
647
648    let proto = to_proto_matching_rule(&rule);
649    expect!(proto.r#type.as_str()).to(be_equal_to("creditcard"));
650    expect!(proto.values.unwrap().fields.get("brand").cloned()).to(
651      be_some().value(crate::utils::to_proto_value(&Value::String("visa".to_string()))));
652  }
653
654  #[test]
655  fn a_plugin_generators_configuration_crosses_the_boundary() {
656    let generator = Generator::Plugin {
657      name: "creditcard".to_string(),
658      values: serde_json::json!({ "brand": "visa" })
659    };
660
661    let proto = to_proto_generator(&generator);
662    expect!(proto.r#type.as_str()).to(be_equal_to("creditcard"));
663    expect!(proto.values.unwrap().fields.get("brand").cloned()).to(
664      be_some().value(crate::utils::to_proto_value(&Value::String("visa".to_string()))));
665  }
666
667  /// A plugin correlates what it logs with the test that caused it via `testRunId` in the request's
668  /// test context (proposal 008). The host has nothing to put there at the point a rule is applied,
669  /// so the driver fills it in - for every transport, not just gRPC.
670  #[tokio::test]
671  async fn a_field_request_carries_the_current_test_run_id() {
672    #[derive(Debug)]
673    struct CapturingMatcher {
674      test_run_ids: Arc<Mutex<Vec<Option<String>>>>
675    }
676
677    #[async_trait]
678    impl CoreFieldMatcher for CapturingMatcher {
679      async fn match_field(&self, request: MatchFieldRequest) -> anyhow::Result<MatchFieldResponse> {
680        let id = request.test_context.as_ref()
681          .and_then(|context| context.fields.get("testRunId"))
682          .and_then(|value| match &value.kind {
683            Some(prost_types::value::Kind::StringValue(value)) => Some(value.clone()),
684            _ => None
685          });
686        self.test_run_ids.lock().unwrap().push(id);
687        Ok(MatchFieldResponse::default())
688      }
689    }
690
691    let key = "a_field_request_carries_the_current_test_run_id";
692    let test_run_ids = Arc::new(Mutex::new(vec![]));
693    register_core_field_matcher(key, Arc::new(CapturingMatcher { test_run_ids: test_run_ids.clone() }));
694    register_core_entries(&vec![CatalogueEntry {
695      entry_type: CatalogueEntryType::MATCHER,
696      provider_type: CatalogueEntryProviderType::CORE,
697      plugin: None,
698      key: key.to_string(),
699      values: hashmap!{}
700    }]);
701    let matcher = find_field_matcher(key).unwrap();
702    let context = FieldContext::new(&DocPath::new_unwrap("$.one"), "body");
703
704    crate::test_context::set_test_run_id(Some("test-run-1".to_string()));
705    let _ = matcher.match_field(&MatchingRule::Type, &FieldValue::Json(Value::Null),
706      &FieldValue::Json(Value::Null), &context).await;
707    crate::test_context::set_test_run_id(None);
708    let _ = matcher.match_field(&MatchingRule::Type, &FieldValue::Json(Value::Null),
709      &FieldValue::Json(Value::Null), &context).await;
710
711    deregister_core_field_matcher(key);
712
713    let ids = test_run_ids.lock().unwrap().clone();
714    expect!(ids).to(be_equal_to(vec![Some("test-run-1".to_string()), None]));
715  }
716
717  /// Records the request it was given, and answers with the mismatches it was built with
718  #[derive(Debug)]
719  struct TestCoreMatcher {
720    mismatches: Vec<ProtoContentMismatch>,
721    error: String
722  }
723
724  #[async_trait]
725  impl CoreFieldMatcher for TestCoreMatcher {
726    async fn match_field(&self, request: MatchFieldRequest) -> anyhow::Result<MatchFieldResponse> {
727      // Prove the request carried what the caller passed in
728      assert_eq!(request.path, "$.card.number");
729      assert_eq!(request.mismatch_type, "body");
730      assert_eq!(request.rule.as_ref().unwrap().r#type, "regex");
731      Ok(MatchFieldResponse {
732        error: self.error.clone(),
733        mismatches: self.mismatches.clone()
734      })
735    }
736  }
737
738  #[derive(Debug)]
739  struct TestCoreGenerator;
740
741  #[async_trait]
742  impl CoreFieldGenerator for TestCoreGenerator {
743    async fn generate_field(&self, request: GenerateFieldRequest) -> anyhow::Result<GenerateFieldResponse> {
744      assert_eq!(request.path, "$.card.number");
745      assert_eq!(request.test_mode, TestMode::Consumer.to_proto() as i32);
746      Ok(GenerateFieldResponse {
747        error: String::default(),
748        value: Some(FieldValue::Json(Value::String("4012888888881881".to_string())).to_proto())
749      })
750    }
751  }
752
753  fn register_core_matcher_entry(key: &str, entry_type: CatalogueEntryType) {
754    register_core_entries(&vec![CatalogueEntry {
755      entry_type,
756      provider_type: CatalogueEntryProviderType::CORE,
757      plugin: None,
758      key: key.to_string(),
759      values: hashmap!{}
760    }]);
761  }
762
763  /// The driver forwards whatever rule the host hands it - `rule.name()` and `rule.value_map()` -
764  /// so any rule exercises the plumbing. A plugin's own rule arrives here as
765  /// `MatchingRule::Plugin`, by exactly this path.
766  fn a_rule() -> MatchingRule {
767    MatchingRule::Regex("\\d{16}".to_string())
768  }
769
770  fn field_context() -> FieldContext {
771    FieldContext::new(&DocPath::new("$.card.number").unwrap(), "body")
772  }
773
774  #[test_log::test(tokio::test)]
775  async fn match_field_dispatches_to_a_registered_core_handler() {
776    let key = "match_field_dispatches_to_a_registered_core_handler";
777    register_core_matcher_entry(key, CatalogueEntryType::MATCHER);
778    register_core_field_matcher(key, Arc::new(TestCoreMatcher {
779      mismatches: vec![],
780      error: String::default()
781    }));
782
783    let matcher = find_field_matcher(key).unwrap();
784    let result = matcher.match_field(
785      &a_rule(),
786      &FieldValue::Json(Value::String("4111111111111111".to_string())),
787      &FieldValue::Json(Value::String("4012888888881881".to_string())),
788      &field_context()
789    ).await;
790
791    deregister_core_field_matcher(key);
792
793    expect!(matcher.is_core()).to(be_true());
794    expect!(result).to(be_ok());
795  }
796
797  #[test_log::test(tokio::test)]
798  async fn match_field_reports_mismatches_against_the_requested_path() {
799    let key = "match_field_reports_mismatches_against_the_requested_path";
800    register_core_matcher_entry(key, CatalogueEntryType::MATCHER);
801    register_core_field_matcher(key, Arc::new(TestCoreMatcher {
802      mismatches: vec![ProtoContentMismatch {
803        // Deliberately no path/mismatchType: the driver fills them in from the request
804        mismatch: "fails the Luhn check".to_string(),
805        expected: Some("4111111111111111".as_bytes().to_vec()),
806        actual: Some("4111111111111112".as_bytes().to_vec()),
807        .. ProtoContentMismatch::default()
808      }],
809      error: String::default()
810    }));
811
812    let matcher = find_field_matcher(key).unwrap();
813    let result = matcher.match_field(
814      &a_rule(),
815      &FieldValue::Json(Value::String("4111111111111111".to_string())),
816      &FieldValue::Json(Value::String("4111111111111112".to_string())),
817      &field_context()
818    ).await;
819
820    deregister_core_field_matcher(key);
821
822    let mismatches = result.expect_err("expected a mismatch");
823    expect!(mismatches.len()).to(be_equal_to(1));
824    expect!(mismatches[0].mismatch.clone()).to(be_equal_to("fails the Luhn check".to_string()));
825    expect!(mismatches[0].path.clone()).to(be_equal_to("$.card.number".to_string()));
826    expect!(mismatches[0].mismatch_type.clone()).to(be_some().value("body".to_string()));
827    expect!(mismatches[0].expected.clone()).to(be_equal_to("4111111111111111".to_string()));
828  }
829
830  #[test_log::test(tokio::test)]
831  async fn match_field_turns_a_handler_error_into_a_mismatch() {
832    let key = "match_field_turns_a_handler_error_into_a_mismatch";
833    register_core_matcher_entry(key, CatalogueEntryType::MATCHER);
834    register_core_field_matcher(key, Arc::new(TestCoreMatcher {
835      mismatches: vec![],
836      error: "'amx' is not a brand this plugin knows about".to_string()
837    }));
838
839    let matcher = find_field_matcher(key).unwrap();
840    let result = matcher.match_field(
841      &a_rule(),
842      &FieldValue::Json(Value::String("4111111111111111".to_string())),
843      &FieldValue::Json(Value::String("4111111111111111".to_string())),
844      &field_context()
845    ).await;
846
847    deregister_core_field_matcher(key);
848
849    let mismatches = result.expect_err("expected the error to surface");
850    expect!(mismatches[0].mismatch.clone())
851      .to(be_equal_to("'amx' is not a brand this plugin knows about".to_string()));
852  }
853
854  #[test_log::test(tokio::test)]
855  async fn match_field_fails_clearly_when_no_core_handler_is_registered() {
856    let key = "match_field_fails_clearly_when_no_core_handler_is_registered";
857    register_core_matcher_entry(key, CatalogueEntryType::MATCHER);
858
859    let matcher = find_field_matcher(key).unwrap();
860    let result = matcher.match_field(
861      &a_rule(),
862      &FieldValue::Json(Value::Null),
863      &FieldValue::Json(Value::Null),
864      &field_context()
865    ).await;
866
867    let mismatches = result.expect_err("expected an error for a registered entry with no handler");
868    expect!(mismatches[0].mismatch.contains("No core field matcher registered")).to(be_true());
869  }
870
871  #[test_log::test(tokio::test)]
872  async fn generate_field_dispatches_to_a_registered_core_handler() {
873    let key = "generate_field_dispatches_to_a_registered_core_handler";
874    register_core_matcher_entry(key, CatalogueEntryType::GENERATOR);
875    register_core_field_generator(key, Arc::new(TestCoreGenerator));
876
877    let generator = find_field_generator(key).unwrap();
878    let result = generator.generate_field(
879      &Generator::RandomString(16),
880      &FieldValue::Json(Value::String("4111111111111111".to_string())),
881      TestMode::Consumer,
882      &field_context()
883    ).await;
884
885    deregister_core_field_generator(key);
886
887    expect!(generator.is_core()).to(be_true());
888    expect!(result.unwrap()).to(be_equal_to(
889      FieldValue::Json(Value::String("4012888888881881".to_string()))
890    ));
891  }
892
893  #[test]
894  fn finding_a_rule_that_is_not_registered_says_so() {
895    let err = find_field_matcher("finding_a_rule_that_is_not_registered_says_so")
896      .expect_err("expected an error for an unregistered rule");
897    expect!(err.to_string().contains("No catalogue entry found")).to(be_true());
898  }
899
900  #[test]
901  fn finding_a_rule_that_is_a_generator_says_so() {
902    let key = "finding_a_rule_that_is_a_generator_says_so";
903    register_core_matcher_entry(key, CatalogueEntryType::GENERATOR);
904
905    let err = find_field_matcher(key).expect_err("expected an error for the wrong entry type");
906    expect!(err.to_string().contains("is a GENERATOR, not a MATCHER")).to(be_true());
907  }
908
909  #[test_log::test]
910  fn the_blocking_bridge_runs_a_call_from_a_synchronous_context() {
911    let key = "the_blocking_bridge_runs_a_call_from_a_synchronous_context";
912    register_core_matcher_entry(key, CatalogueEntryType::MATCHER);
913    register_core_field_matcher(key, Arc::new(TestCoreMatcher {
914      mismatches: vec![],
915      error: String::default()
916    }));
917
918    let matcher = find_field_matcher(key).unwrap();
919    let result = matcher.match_field_blocking(
920      &a_rule(),
921      &FieldValue::Json(Value::String("4111111111111111".to_string())),
922      &FieldValue::Json(Value::String("4012888888881881".to_string())),
923      &field_context()
924    );
925
926    deregister_core_field_matcher(key);
927
928    expect!(result).to(be_ok());
929  }
930
931  #[test_log::test(tokio::test(flavor = "multi_thread"))]
932  async fn the_blocking_bridge_works_from_inside_a_runtime() {
933    // The case Handle::block_on panics on: the calling thread is already driving async tasks.
934    // Run it on a blocking thread, which is how a host's synchronous matching path reaches us.
935    let key = "the_blocking_bridge_works_from_inside_a_runtime";
936    register_core_matcher_entry(key, CatalogueEntryType::MATCHER);
937    register_core_field_matcher(key, Arc::new(TestCoreMatcher {
938      mismatches: vec![],
939      error: String::default()
940    }));
941
942    let result = tokio::task::spawn_blocking(move || {
943      let matcher = find_field_matcher(key).unwrap();
944      matcher.match_field_blocking(
945        &a_rule(),
946        &FieldValue::Json(Value::String("4111111111111111".to_string())),
947        &FieldValue::Json(Value::String("4012888888881881".to_string())),
948        &field_context()
949      )
950    }).await.unwrap();
951
952    deregister_core_field_matcher(key);
953
954    expect!(result).to(be_ok());
955  }
956}