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 `v2-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(&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(&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
508fn to_proto_matching_rule(rule: &MatchingRule) -> ProtoMatchingRule {
509  ProtoMatchingRule {
510    r#type: rule.name(),
511    values: Some(to_proto_struct(&rule.value_map()))
512  }
513}
514
515fn to_proto_generator(generator: &Generator) -> ProtoGenerator {
516  ProtoGenerator {
517    r#type: generator.name(),
518    values: Some(to_proto_struct(&generator.value_map()))
519  }
520}
521
522fn to_proto_plugin_config(config: PluginInteractionConfig) -> ProtoPluginConfiguration {
523  ProtoPluginConfiguration {
524    interaction_configuration: Some(to_proto_struct(&config.interaction_configuration)),
525    pact_configuration: Some(to_proto_struct(&config.pact_configuration))
526  }
527}
528
529#[cfg(test)]
530mod tests {
531  use async_trait::async_trait;
532  use expectest::prelude::*;
533  use maplit::hashmap;
534  use pact_models::matchingrules::MatchingRule;
535
536  use crate::catalogue_manager::{CatalogueEntryProviderType, register_core_entries};
537  use crate::core_capabilities::{
538    CoreFieldGenerator,
539    CoreFieldMatcher,
540    deregister_core_field_generator,
541    deregister_core_field_matcher,
542    register_core_field_generator,
543    register_core_field_matcher
544  };
545  use crate::proto_v2::ContentMismatch as ProtoContentMismatch;
546
547  use super::*;
548
549  #[test]
550  fn field_values_round_trip_through_the_proto_form() {
551    for value in [
552      FieldValue::Json(Value::String("4111111111111111".to_string())),
553      FieldValue::Json(serde_json::json!(100)),
554      FieldValue::Json(serde_json::json!(-100.5)),
555      FieldValue::Json(Value::Bool(true)),
556      FieldValue::Json(Value::Null),
557      FieldValue::Json(serde_json::json!({ "brand": "visa" })),
558      // A value that is not representable as JSON survives as bytes rather than being stringified
559      FieldValue::Binary(Bytes::from(vec![0u8, 159, 146, 150]))
560    ] {
561      expect!(FieldValue::from_proto(&value.to_proto())).to(be_equal_to(value));
562    }
563  }
564
565  #[test]
566  fn a_whole_number_stays_whole_and_a_decimal_stays_decimal() {
567    // The distinction the `integer`, `decimal` and `type` rules are built on, and the reason
568    // FieldValue does not put every value through a google.protobuf.Value
569    let integer = FieldValue::from_proto(&FieldValue::Json(serde_json::json!(100)).to_proto());
570    let decimal = FieldValue::from_proto(&FieldValue::Json(serde_json::json!(100.5)).to_proto());
571    let whole_decimal = FieldValue::from_proto(&FieldValue::Json(serde_json::json!(100.0)).to_proto());
572
573    expect!(integer.clone()).to(be_equal_to(FieldValue::Json(serde_json::json!(100))));
574    expect!(decimal).to(be_equal_to(FieldValue::Json(serde_json::json!(100.5))));
575    match integer {
576      FieldValue::Json(Value::Number(number)) => expect!(number.is_i64()).to(be_true()),
577      other => panic!("expected a JSON number, got {:?}", other)
578    };
579    // A decimal that happens to be whole stays a decimal - it is not quietly promoted to an
580    // integer, which would make `decimal` reject a value it should accept
581    expect!(whole_decimal.clone()).to(be_equal_to(FieldValue::Json(serde_json::json!(100.0))));
582    match whole_decimal {
583      FieldValue::Json(Value::Number(number)) => expect!(number.is_f64()).to(be_true()),
584      other => panic!("expected a JSON number, got {:?}", other)
585    };
586  }
587
588  #[test]
589  fn each_scalar_type_crosses_the_boundary_under_its_own_arm() {
590    let cases = [
591      (FieldValue::Json(Value::Null), "null"),
592      (FieldValue::Json(Value::Bool(true)), "boolean"),
593      (FieldValue::Json(serde_json::json!("4111111111111111")), "string"),
594      (FieldValue::Json(serde_json::json!(100)), "integer"),
595      (FieldValue::Json(serde_json::json!(100.5)), "decimal"),
596      (FieldValue::Binary(Bytes::from(vec![0u8, 159, 146, 150])), "binary"),
597      (FieldValue::Json(serde_json::json!({ "brand": "visa" })), "structured")
598    ];
599    for (value, expected_arm) in cases {
600      let arm = match value.to_proto().value {
601        Some(field_value::Value::NullValue(_)) => "null",
602        Some(field_value::Value::BooleanValue(_)) => "boolean",
603        Some(field_value::Value::StringValue(_)) => "string",
604        Some(field_value::Value::IntegerValue(_)) => "integer",
605        Some(field_value::Value::DecimalValue(_)) => "decimal",
606        Some(field_value::Value::BinaryValue(_)) => "binary",
607        Some(field_value::Value::StructuredValue(_)) => "structured",
608        None => "unset"
609      };
610      expect!(arm).to(be_equal_to(expected_arm));
611    }
612  }
613
614  #[test]
615  fn an_unset_proto_value_reads_as_json_null() {
616    expect!(FieldValue::from_proto(&ProtoFieldValue { value: None }))
617      .to(be_equal_to(FieldValue::Json(Value::Null)));
618  }
619
620  #[test]
621  fn a_plugin_rules_configuration_crosses_the_boundary() {
622    // A plugin rule's configuration keys are only known at runtime, which is why this reads
623    // value_map rather than values - the latter can only carry keys known at compile time, and
624    // returns nothing for a plugin rule
625    let rule = MatchingRule::Plugin {
626      name: "creditcard".to_string(),
627      values: serde_json::json!({ "brand": "visa" })
628    };
629
630    let proto = to_proto_matching_rule(&rule);
631    expect!(proto.r#type.as_str()).to(be_equal_to("creditcard"));
632    expect!(proto.values.unwrap().fields.get("brand").cloned()).to(
633      be_some().value(crate::utils::to_proto_value(&Value::String("visa".to_string()))));
634  }
635
636  #[test]
637  fn a_plugin_generators_configuration_crosses_the_boundary() {
638    let generator = Generator::Plugin {
639      name: "creditcard".to_string(),
640      values: serde_json::json!({ "brand": "visa" })
641    };
642
643    let proto = to_proto_generator(&generator);
644    expect!(proto.r#type.as_str()).to(be_equal_to("creditcard"));
645    expect!(proto.values.unwrap().fields.get("brand").cloned()).to(
646      be_some().value(crate::utils::to_proto_value(&Value::String("visa".to_string()))));
647  }
648
649  /// Records the request it was given, and answers with the mismatches it was built with
650  #[derive(Debug)]
651  struct TestCoreMatcher {
652    mismatches: Vec<ProtoContentMismatch>,
653    error: String
654  }
655
656  #[async_trait]
657  impl CoreFieldMatcher for TestCoreMatcher {
658    async fn match_field(&self, request: MatchFieldRequest) -> anyhow::Result<MatchFieldResponse> {
659      // Prove the request carried what the caller passed in
660      assert_eq!(request.path, "$.card.number");
661      assert_eq!(request.mismatch_type, "body");
662      assert_eq!(request.rule.as_ref().unwrap().r#type, "regex");
663      Ok(MatchFieldResponse {
664        error: self.error.clone(),
665        mismatches: self.mismatches.clone()
666      })
667    }
668  }
669
670  #[derive(Debug)]
671  struct TestCoreGenerator;
672
673  #[async_trait]
674  impl CoreFieldGenerator for TestCoreGenerator {
675    async fn generate_field(&self, request: GenerateFieldRequest) -> anyhow::Result<GenerateFieldResponse> {
676      assert_eq!(request.path, "$.card.number");
677      assert_eq!(request.test_mode, TestMode::Consumer.to_proto() as i32);
678      Ok(GenerateFieldResponse {
679        error: String::default(),
680        value: Some(FieldValue::Json(Value::String("4012888888881881".to_string())).to_proto())
681      })
682    }
683  }
684
685  fn register_core_matcher_entry(key: &str, entry_type: CatalogueEntryType) {
686    register_core_entries(&vec![CatalogueEntry {
687      entry_type,
688      provider_type: CatalogueEntryProviderType::CORE,
689      plugin: None,
690      key: key.to_string(),
691      values: hashmap!{}
692    }]);
693  }
694
695  /// The driver forwards whatever rule the host hands it - `rule.name()` and `rule.value_map()` -
696  /// so any rule exercises the plumbing. A plugin's own rule arrives here as
697  /// `MatchingRule::Plugin`, by exactly this path.
698  fn a_rule() -> MatchingRule {
699    MatchingRule::Regex("\\d{16}".to_string())
700  }
701
702  fn field_context() -> FieldContext {
703    FieldContext::new(&DocPath::new("$.card.number").unwrap(), "body")
704  }
705
706  #[test_log::test(tokio::test)]
707  async fn match_field_dispatches_to_a_registered_core_handler() {
708    let key = "match_field_dispatches_to_a_registered_core_handler";
709    register_core_matcher_entry(key, CatalogueEntryType::MATCHER);
710    register_core_field_matcher(key, Arc::new(TestCoreMatcher {
711      mismatches: vec![],
712      error: String::default()
713    }));
714
715    let matcher = find_field_matcher(key).unwrap();
716    let result = matcher.match_field(
717      &a_rule(),
718      &FieldValue::Json(Value::String("4111111111111111".to_string())),
719      &FieldValue::Json(Value::String("4012888888881881".to_string())),
720      &field_context()
721    ).await;
722
723    deregister_core_field_matcher(key);
724
725    expect!(matcher.is_core()).to(be_true());
726    expect!(result).to(be_ok());
727  }
728
729  #[test_log::test(tokio::test)]
730  async fn match_field_reports_mismatches_against_the_requested_path() {
731    let key = "match_field_reports_mismatches_against_the_requested_path";
732    register_core_matcher_entry(key, CatalogueEntryType::MATCHER);
733    register_core_field_matcher(key, Arc::new(TestCoreMatcher {
734      mismatches: vec![ProtoContentMismatch {
735        // Deliberately no path/mismatchType: the driver fills them in from the request
736        mismatch: "fails the Luhn check".to_string(),
737        expected: Some("4111111111111111".as_bytes().to_vec()),
738        actual: Some("4111111111111112".as_bytes().to_vec()),
739        .. ProtoContentMismatch::default()
740      }],
741      error: String::default()
742    }));
743
744    let matcher = find_field_matcher(key).unwrap();
745    let result = matcher.match_field(
746      &a_rule(),
747      &FieldValue::Json(Value::String("4111111111111111".to_string())),
748      &FieldValue::Json(Value::String("4111111111111112".to_string())),
749      &field_context()
750    ).await;
751
752    deregister_core_field_matcher(key);
753
754    let mismatches = result.expect_err("expected a mismatch");
755    expect!(mismatches.len()).to(be_equal_to(1));
756    expect!(mismatches[0].mismatch.clone()).to(be_equal_to("fails the Luhn check".to_string()));
757    expect!(mismatches[0].path.clone()).to(be_equal_to("$.card.number".to_string()));
758    expect!(mismatches[0].mismatch_type.clone()).to(be_some().value("body".to_string()));
759    expect!(mismatches[0].expected.clone()).to(be_equal_to("4111111111111111".to_string()));
760  }
761
762  #[test_log::test(tokio::test)]
763  async fn match_field_turns_a_handler_error_into_a_mismatch() {
764    let key = "match_field_turns_a_handler_error_into_a_mismatch";
765    register_core_matcher_entry(key, CatalogueEntryType::MATCHER);
766    register_core_field_matcher(key, Arc::new(TestCoreMatcher {
767      mismatches: vec![],
768      error: "'amx' is not a brand this plugin knows about".to_string()
769    }));
770
771    let matcher = find_field_matcher(key).unwrap();
772    let result = matcher.match_field(
773      &a_rule(),
774      &FieldValue::Json(Value::String("4111111111111111".to_string())),
775      &FieldValue::Json(Value::String("4111111111111111".to_string())),
776      &field_context()
777    ).await;
778
779    deregister_core_field_matcher(key);
780
781    let mismatches = result.expect_err("expected the error to surface");
782    expect!(mismatches[0].mismatch.clone())
783      .to(be_equal_to("'amx' is not a brand this plugin knows about".to_string()));
784  }
785
786  #[test_log::test(tokio::test)]
787  async fn match_field_fails_clearly_when_no_core_handler_is_registered() {
788    let key = "match_field_fails_clearly_when_no_core_handler_is_registered";
789    register_core_matcher_entry(key, CatalogueEntryType::MATCHER);
790
791    let matcher = find_field_matcher(key).unwrap();
792    let result = matcher.match_field(
793      &a_rule(),
794      &FieldValue::Json(Value::Null),
795      &FieldValue::Json(Value::Null),
796      &field_context()
797    ).await;
798
799    let mismatches = result.expect_err("expected an error for a registered entry with no handler");
800    expect!(mismatches[0].mismatch.contains("No core field matcher registered")).to(be_true());
801  }
802
803  #[test_log::test(tokio::test)]
804  async fn generate_field_dispatches_to_a_registered_core_handler() {
805    let key = "generate_field_dispatches_to_a_registered_core_handler";
806    register_core_matcher_entry(key, CatalogueEntryType::GENERATOR);
807    register_core_field_generator(key, Arc::new(TestCoreGenerator));
808
809    let generator = find_field_generator(key).unwrap();
810    let result = generator.generate_field(
811      &Generator::RandomString(16),
812      &FieldValue::Json(Value::String("4111111111111111".to_string())),
813      TestMode::Consumer,
814      &field_context()
815    ).await;
816
817    deregister_core_field_generator(key);
818
819    expect!(generator.is_core()).to(be_true());
820    expect!(result.unwrap()).to(be_equal_to(
821      FieldValue::Json(Value::String("4012888888881881".to_string()))
822    ));
823  }
824
825  #[test]
826  fn finding_a_rule_that_is_not_registered_says_so() {
827    let err = find_field_matcher("finding_a_rule_that_is_not_registered_says_so")
828      .expect_err("expected an error for an unregistered rule");
829    expect!(err.to_string().contains("No catalogue entry found")).to(be_true());
830  }
831
832  #[test]
833  fn finding_a_rule_that_is_a_generator_says_so() {
834    let key = "finding_a_rule_that_is_a_generator_says_so";
835    register_core_matcher_entry(key, CatalogueEntryType::GENERATOR);
836
837    let err = find_field_matcher(key).expect_err("expected an error for the wrong entry type");
838    expect!(err.to_string().contains("is a GENERATOR, not a MATCHER")).to(be_true());
839  }
840
841  #[test_log::test]
842  fn the_blocking_bridge_runs_a_call_from_a_synchronous_context() {
843    let key = "the_blocking_bridge_runs_a_call_from_a_synchronous_context";
844    register_core_matcher_entry(key, CatalogueEntryType::MATCHER);
845    register_core_field_matcher(key, Arc::new(TestCoreMatcher {
846      mismatches: vec![],
847      error: String::default()
848    }));
849
850    let matcher = find_field_matcher(key).unwrap();
851    let result = matcher.match_field_blocking(
852      &a_rule(),
853      &FieldValue::Json(Value::String("4111111111111111".to_string())),
854      &FieldValue::Json(Value::String("4012888888881881".to_string())),
855      &field_context()
856    );
857
858    deregister_core_field_matcher(key);
859
860    expect!(result).to(be_ok());
861  }
862
863  #[test_log::test(tokio::test(flavor = "multi_thread"))]
864  async fn the_blocking_bridge_works_from_inside_a_runtime() {
865    // The case Handle::block_on panics on: the calling thread is already driving async tasks.
866    // Run it on a blocking thread, which is how a host's synchronous matching path reaches us.
867    let key = "the_blocking_bridge_works_from_inside_a_runtime";
868    register_core_matcher_entry(key, CatalogueEntryType::MATCHER);
869    register_core_field_matcher(key, Arc::new(TestCoreMatcher {
870      mismatches: vec![],
871      error: String::default()
872    }));
873
874    let result = tokio::task::spawn_blocking(move || {
875      let matcher = find_field_matcher(key).unwrap();
876      matcher.match_field_blocking(
877        &a_rule(),
878        &FieldValue::Json(Value::String("4111111111111111".to_string())),
879        &FieldValue::Json(Value::String("4012888888881881".to_string())),
880        &field_context()
881      )
882    }).await.unwrap();
883
884    deregister_core_field_matcher(key);
885
886    expect!(result).to(be_ok());
887  }
888}