Skip to main content

pact_plugin_driver/
catalogue_manager.rs

1//! Manages the catalogue of features provided by plugins
2
3use std::collections::HashMap;
4use std::fmt::{self, Display, Formatter};
5use std::sync::Mutex;
6
7use itertools::Itertools;
8use lazy_static::lazy_static;
9use maplit::hashset;
10use pact_models::content_types::ContentType;
11use regex::Regex;
12use serde::{Deserialize, Serialize};
13use tracing::{debug, error, instrument, trace, warn};
14
15use crate::content::{ContentGenerator, ContentMatcher};
16use crate::plugin_models::PactPluginManifest;
17use crate::proto::catalogue_entry::EntryType;
18use crate::proto::CatalogueEntry as ProtoCatalogueEntry;
19use crate::proto_v2::catalogue_entry::EntryType as EntryTypeV2;
20
21lazy_static! {
22  static ref CATALOGUE_REGISTER: Mutex<HashMap<String, CatalogueEntry>> = Mutex::new(HashMap::new());
23}
24
25/// Type of catalogue entry
26#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
27#[allow(non_camel_case_types)]
28pub enum CatalogueEntryType {
29  /// Content matcher (based on content type)
30  CONTENT_MATCHER,
31  /// Content generator (based on content type)
32  CONTENT_GENERATOR,
33  /// Network transport
34  TRANSPORT,
35  /// Matching rule for a content field/value
36  MATCHER,
37  /// Type of interaction
38  INTERACTION,
39  /// Generator for a content field/value. Only representable on the V2 plugin interface - the V1
40  /// `EntryType` enum has no equivalent. See proposal 006 (Field-level matchers and generators).
41  GENERATOR
42}
43
44impl CatalogueEntryType {
45  /// Return the protobuf type for this entry type.
46  ///
47  /// This maps to the *V1* enum, which cannot represent every entry type: `GENERATOR` has no V1
48  /// equivalent and is reported as `Matcher`. Use [`CatalogueEntryType::to_proto_value`] instead,
49  /// which returns the wire value and is lossless for both interface versions.
50  #[deprecated(
51    since = "1.2.0",
52    note = "Use to_proto_value, which can represent entry types that only exist on the V2 interface"
53  )]
54  pub fn to_proto_type(&self) -> EntryType {
55    match self {
56      CatalogueEntryType::CONTENT_MATCHER => EntryType::ContentMatcher,
57      CatalogueEntryType::CONTENT_GENERATOR => EntryType::ContentGenerator,
58      CatalogueEntryType::TRANSPORT => EntryType::Transport,
59      CatalogueEntryType::MATCHER => EntryType::Matcher,
60      CatalogueEntryType::INTERACTION => EntryType::Interaction,
61      CatalogueEntryType::GENERATOR => EntryType::Matcher
62    }
63  }
64
65  /// The V2 protobuf enum for this entry type. V2 is the canonical source of the wire values: it
66  /// mirrors V1 for the entry types both versions share, and adds the ones V1 never had.
67  fn to_proto_enum(self) -> EntryTypeV2 {
68    match self {
69      CatalogueEntryType::CONTENT_MATCHER => EntryTypeV2::ContentMatcher,
70      CatalogueEntryType::CONTENT_GENERATOR => EntryTypeV2::ContentGenerator,
71      CatalogueEntryType::TRANSPORT => EntryTypeV2::Transport,
72      CatalogueEntryType::MATCHER => EntryTypeV2::Matcher,
73      CatalogueEntryType::INTERACTION => EntryTypeV2::Interaction,
74      CatalogueEntryType::GENERATOR => EntryTypeV2::Generator
75    }
76  }
77
78  fn from_proto_enum(entry_type: EntryTypeV2) -> CatalogueEntryType {
79    match entry_type {
80      EntryTypeV2::ContentMatcher => CatalogueEntryType::CONTENT_MATCHER,
81      EntryTypeV2::ContentGenerator => CatalogueEntryType::CONTENT_GENERATOR,
82      EntryTypeV2::Transport => CatalogueEntryType::TRANSPORT,
83      EntryTypeV2::Matcher => CatalogueEntryType::MATCHER,
84      EntryTypeV2::Interaction => CatalogueEntryType::INTERACTION,
85      EntryTypeV2::Generator => CatalogueEntryType::GENERATOR
86    }
87  }
88
89  /// The protobuf enum value for this entry type, for setting the `type` field of a
90  /// `CatalogueEntry` message. Lossless for every entry type, including those a V1 plugin will
91  /// not recognise - a V1 plugin decodes an unknown value as an unrecognised enum and ignores the
92  /// entry, which is the correct outcome, whereas mapping it onto some other V1 type would have
93  /// the plugin act on an entry that is not what it thinks it is.
94  pub fn to_proto_value(self) -> i32 {
95    self.to_proto_enum() as i32
96  }
97
98  /// The entry type for a protobuf enum value, or `None` if the value is not one this driver
99  /// understands (an entry type added by a later interface version). Deliberately not defaulting
100  /// to `CONTENT_MATCHER` the way prost's generated accessor does: silently mis-typing an entry
101  /// is worse than ignoring one.
102  pub fn from_proto_value(value: i32) -> Option<CatalogueEntryType> {
103    EntryTypeV2::try_from(value).ok().map(CatalogueEntryType::from_proto_enum)
104  }
105
106  /// The protobuf enum value name for this entry type, e.g. `"CONTENT_MATCHER"`. This is the form
107  /// a Lua plugin uses in the catalogue entries returned from its `init` function.
108  pub fn as_proto_name(&self) -> &'static str {
109    self.to_proto_enum().as_str_name()
110  }
111
112  /// The entry type for a protobuf enum value name, e.g. `"CONTENT_MATCHER"`, or `None` if the
113  /// name is not one this driver understands.
114  pub fn from_proto_name(name: &str) -> Option<CatalogueEntryType> {
115    EntryTypeV2::from_str_name(name).map(CatalogueEntryType::from_proto_enum)
116  }
117}
118
119impl Display for CatalogueEntryType {
120  fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
121    match self {
122      CatalogueEntryType::CONTENT_MATCHER => write!(f, "content-matcher"),
123      CatalogueEntryType::CONTENT_GENERATOR => write!(f, "content-generator"),
124      CatalogueEntryType::TRANSPORT => write!(f, "transport"),
125      CatalogueEntryType::MATCHER => write!(f, "matcher"),
126      CatalogueEntryType::INTERACTION => write!(f, "interaction"),
127      CatalogueEntryType::GENERATOR => write!(f, "generator"),
128    }
129  }
130}
131
132impl From<&str> for CatalogueEntryType {
133  fn from(s: &str) -> Self {
134    match s {
135      "content-matcher" => CatalogueEntryType::CONTENT_MATCHER,
136      "content-generator" => CatalogueEntryType::CONTENT_GENERATOR,
137      "interaction" => CatalogueEntryType::INTERACTION,
138      "matcher" => CatalogueEntryType::MATCHER,
139      "transport" => CatalogueEntryType::TRANSPORT,
140      "generator" => CatalogueEntryType::GENERATOR,
141      _ => {
142        let message = format!("'{}' is not a valid CatalogueEntryType value", s);
143        error!("{}", message);
144        panic!("{}", message)
145      }
146    }
147  }
148}
149
150impl From<String> for CatalogueEntryType {
151  fn from(s: String) -> Self {
152    Self::from(s.as_str())
153  }
154}
155
156impl From<EntryType> for CatalogueEntryType {
157  fn from(t: EntryType) -> Self {
158    match t {
159      EntryType::ContentMatcher => CatalogueEntryType::CONTENT_MATCHER,
160      EntryType::ContentGenerator => CatalogueEntryType::CONTENT_GENERATOR,
161      EntryType::Transport => CatalogueEntryType::TRANSPORT,
162      EntryType::Matcher => CatalogueEntryType::MATCHER,
163      EntryType::Interaction => CatalogueEntryType::INTERACTION
164    }
165  }
166}
167
168/// Provider of the catalogue entry
169#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
170#[allow(non_camel_case_types)]
171pub enum CatalogueEntryProviderType {
172  /// Core Pact framework
173  CORE,
174  /// Plugin
175  PLUGIN
176}
177
178/// Catalogue entry
179#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
180#[serde(rename_all = "camelCase")]
181pub struct CatalogueEntry {
182  /// Type of entry
183  pub entry_type: CatalogueEntryType,
184  /// Provider of the entry
185  pub provider_type: CatalogueEntryProviderType,
186  /// Plugin manifest
187  pub plugin: Option<PactPluginManifest>,
188  /// Entry key
189  pub key: String,
190  /// assocaited Entry values
191  pub values: HashMap<String, String>
192}
193
194/// Register the entries in the global catalogue
195pub fn register_plugin_entries(plugin: &PactPluginManifest, catalogue_list: &Vec<ProtoCatalogueEntry>) {
196  trace!("register_plugin_entries({:?}, {:?})", plugin, catalogue_list);
197
198  let mut guard = CATALOGUE_REGISTER.lock().unwrap();
199
200  for entry in catalogue_list {
201    // Deliberately reading the raw field rather than prost's `entry.r#type()` accessor: the
202    // accessor is generated against the V1 enum and maps anything it doesn't recognise to the
203    // default (`CONTENT_MATCHER`), which would silently register a V2-only entry type - a
204    // `GENERATOR`, say - as a content matcher.
205    let entry_type = match CatalogueEntryType::from_proto_value(entry.r#type) {
206      Some(entry_type) => entry_type,
207      None => {
208        warn!(
209          "Ignoring catalogue entry '{}' from plugin '{}': {} is not a catalogue entry type this driver understands",
210          entry.key, plugin.name, entry.r#type
211        );
212        continue;
213      }
214    };
215    let key = format!("plugin/{}/{}/{}", plugin.name, entry_type, entry.key);
216    guard.insert(key.clone(), CatalogueEntry {
217      entry_type,
218      provider_type: CatalogueEntryProviderType::PLUGIN,
219      plugin: Some(plugin.clone()),
220      key: entry.key.clone(),
221      values: entry.values.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
222    });
223  }
224
225  debug!("Updated catalogue entries:\n{}", guard.keys().sorted().join("\n"))
226}
227
228/// Register the core Pact framework entries in the global catalogue
229pub fn register_core_entries(entries: &Vec<CatalogueEntry>) {
230  trace!("register_core_entries({:?})", entries);
231
232  let mut inner = CATALOGUE_REGISTER.lock().unwrap();
233
234  let mut updated_keys = hashset!();
235  for entry in entries {
236    let key = format!("core/{}/{}", entry.entry_type, entry.key);
237    if !inner.contains_key(&key) {
238      inner.insert(key.clone(), entry.clone());
239      updated_keys.insert(key.clone());
240    }
241  }
242
243  if !updated_keys.is_empty() {
244    debug!("Updated catalogue entries:\n{}", updated_keys.iter().sorted().join("\n"));
245  }
246}
247
248/// Lookup an entry in the catalogue by the key, matched the same way [`resolve_capability`] does:
249/// by name first - the whole catalogue key, or a trailing run of its `/`-separated components -
250/// then against the core catalogue's versioned naming convention.
251///
252/// Unlike [`resolve_capability`], this takes the first match when more than one entry matches, and
253/// `HashMap` iteration order is randomised per process. Prefer [`resolve_capability`] wherever the
254/// expected entry type is known and a deterministic answer matters.
255pub fn lookup_entry(key: &str) -> Option<CatalogueEntry> {
256  let inner = CATALOGUE_REGISTER.lock().unwrap();
257  inner.iter()
258    .find(|(k, _)| names_catalogue_key(k, key))
259    .or_else(|| inner.iter().find(|(_, entry)| names_versioned_core_key(entry, key)))
260    .map(|(_, v)| v.clone())
261}
262
263/// Where a resolved catalogue entry's capability should be dispatched. Shared by every transport
264/// that lets a plugin call back into a capability by catalogue entry key - the gRPC `PluginHost`
265/// service, and the Lua/WASM host functions - so there is exactly one place that decides "who
266/// provides this entry". See proposal 007 ("One resolver, [multiple] call directions").
267#[derive(Debug, Clone)]
268pub enum ResolvedCapability {
269  /// A host-registered core handler, keyed by the unprefixed catalogue entry key.
270  Core(String),
271  /// A running plugin, identified by its manifest.
272  Plugin(Box<PactPluginManifest>)
273}
274
275/// Does `entry_key` name this catalogue key? Either the whole key (`core/content-matcher/xml`),
276/// or a trailing run of its `/`-separated components (`content-matcher/xml`, or just `xml`).
277///
278/// Components are compared whole, never as substrings: `"type"` does not name
279/// `core/matcher/v2-type`. That distinction is the point - the core catalogue prefixes the Pact
280/// specification version a matcher was introduced in to its name, so a substring match would let
281/// an unqualified name collide with a versioned core key it has nothing to do with (a plugin's
282/// own `matcher/date` against `core/matcher/v3-date`, say). The versioned form is handled
283/// deliberately by [`names_versioned_core_key`] instead, as a fallback rather than a coincidence.
284fn names_catalogue_key(catalogue_key: &str, entry_key: &str) -> bool {
285  let key_parts = catalogue_key.split('/').collect::<Vec<_>>();
286  let query_parts = entry_key.split('/').collect::<Vec<_>>();
287  query_parts.len() <= key_parts.len()
288    && key_parts[key_parts.len() - query_parts.len()..] == query_parts[..]
289}
290
291/// Does `entry_key` name this entry under the core catalogue's versioned naming convention - the
292/// Pact specification version the rule was introduced in, prefixed to its name (`v2-type`,
293/// `v3-date`, `v4-not-empty`)? This is what lets a caller ask for `type` and get `v2-type`
294/// without having to know which specification version introduced it.
295///
296/// Only the whole name after the version prefix counts, so `type` names `v2-type` but not
297/// `v3-content-type` or `v2-min-type` - those are the `content-type` and `min-type` rules.
298///
299/// The convention only applies to matching rules and generators. Content matchers, content
300/// generators and transports are registered under plain names (`xml`, `json`, `grpc`), so a
301/// leading `v<n>-` there is part of the name rather than a version, and stripping it would be
302/// wrong.
303fn names_versioned_core_key(entry: &CatalogueEntry, entry_key: &str) -> bool {
304  if entry.entry_type != CatalogueEntryType::MATCHER && entry.entry_type != CatalogueEntryType::GENERATOR {
305    return false;
306  }
307  match entry.key.split_once('-') {
308    Some((version, name)) => name == entry_key
309      && version.len() > 1
310      && version.starts_with('v')
311      && version[1..].chars().all(|c| c.is_ascii_digit()),
312    None => false
313  }
314}
315
316/// Resolve a callback's catalogue entry key to a dispatch target, the same way
317/// [`crate::content::ContentMatcher::is_core`]/[`crate::content::ContentGenerator::is_core`] do
318/// for the driver's own outbound calls.
319///
320/// `entry_key` is matched against the catalogue in two passes:
321///
322/// 1. by name - the whole catalogue key (`core/content-matcher/xml`) or a trailing run of its
323///    components (`content-matcher/xml`, `xml`), compared component by component;
324/// 2. failing that, against the core catalogue's versioned naming convention, so `type` resolves
325///    to `v2-type` and `date` to `v3-date`.
326///
327/// Naming an entry directly always wins over the versioned fallback: if a plugin registers its
328/// own `matcher/date`, `date` resolves to that plugin, and a caller that specifically wants the
329/// core rule asks for `v3-date`.
330///
331/// Unlike [`lookup_entry`], this does not just take the first `HashMap` hit when more than one
332/// entry matches. A short, unqualified `entry_key` can still match more than one entry of the
333/// *same* `expected_type` (a plugin registering its own `content-matcher/xml` alongside a
334/// host-registered core `content-matcher/xml`), and `HashMap` iteration order is randomised per
335/// process - silently picking one would make the dispatch target non-deterministic across
336/// restarts. `expected_type` still guards against the *wrong* capability shape (a
337/// content-generator registered under the same name as an unrelated content-matcher), mirroring
338/// the explicit `entry_type` check [`find_content_matcher`]/[`find_content_generator`] already do.
339pub fn resolve_capability(entry_key: &str, expected_type: CatalogueEntryType) -> anyhow::Result<ResolvedCapability> {
340  let entry = resolve_capability_entry(entry_key, expected_type)?;
341  match entry.provider_type {
342    CatalogueEntryProviderType::CORE => Ok(ResolvedCapability::Core(entry.key.clone())),
343    CatalogueEntryProviderType::PLUGIN => entry.plugin.clone()
344      .map(|manifest| ResolvedCapability::Plugin(Box::new(manifest)))
345      .ok_or_else(|| anyhow::anyhow!("Catalogue entry '{}' has no plugin manifest", entry_key))
346  }
347}
348
349/// Resolve a catalogue entry key to the entry itself, using the same two-pass matching
350/// [`resolve_capability`] documents. Callers that need the entry rather than a dispatch target -
351/// [`crate::field::find_field_matcher`] and [`crate::field::find_field_generator`], which wrap it
352/// in a matcher/generator - use this directly.
353pub fn resolve_capability_entry(entry_key: &str, expected_type: CatalogueEntryType) -> anyhow::Result<CatalogueEntry> {
354  let all_entries: Vec<(String, CatalogueEntry)> = {
355    let inner = CATALOGUE_REGISTER.lock().unwrap();
356    inner.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
357  };
358
359  let named: Vec<(String, CatalogueEntry)> = all_entries.iter()
360    .filter(|(k, _)| names_catalogue_key(k, entry_key))
361    .cloned()
362    .collect();
363  let candidates = if named.iter().any(|(_, entry)| entry.entry_type == expected_type) {
364    named
365  } else {
366    let versioned: Vec<(String, CatalogueEntry)> = all_entries.iter()
367      .filter(|(_, entry)| names_versioned_core_key(entry, entry_key))
368      .cloned()
369      .collect();
370    // Keep any wrong-typed direct matches for the diagnostic below if the fallback finds nothing
371    if versioned.is_empty() { named } else { versioned }
372  };
373
374  let mut of_expected_type = candidates.iter().filter(|(_, entry)| entry.entry_type == expected_type);
375  let entry = match (of_expected_type.next(), of_expected_type.next()) {
376    (None, _) => return match candidates.first() {
377      Some((_, entry)) => Err(anyhow::anyhow!(
378        "Catalogue entry '{}' is a {:?}, not a {:?}", entry_key, entry.entry_type, expected_type
379      )),
380      None => Err(anyhow::anyhow!("No catalogue entry found for key '{}'", entry_key))
381    },
382    (Some(only), None) => &only.1,
383    (Some(first), Some(second)) => {
384      let mut keys: Vec<&str> = std::iter::once(first).chain(std::iter::once(second))
385        .chain(of_expected_type)
386        .map(|(k, _)| k.as_str())
387        .collect();
388      keys.sort_unstable();
389      return Err(anyhow::anyhow!(
390        "Ambiguous catalogue entry key '{}': matches multiple entries ({}) - register it under a more specific key",
391        entry_key, keys.join(", ")
392      ));
393    }
394  };
395
396  Ok(entry.clone())
397}
398
399/// Remove all entries for a plugin given the plugin name
400pub fn remove_plugin_entries(name: &str) {
401  trace!("remove_plugin_entries({})", name);
402
403  let prefix = format!("plugin/{}/", name);
404  let keys: Vec<String> = {
405    let guard = CATALOGUE_REGISTER.lock().unwrap();
406    guard.keys()
407      .filter(|key| key.starts_with(&prefix))
408      .cloned()
409      .collect()
410  };
411
412  let mut guard = CATALOGUE_REGISTER.lock().unwrap();
413  for key in keys {
414    guard.remove(&key);
415  }
416
417  debug!("Removed all catalogue entries for plugin {}", name);
418}
419
420/// Find a content matcher in the global catalogue for the provided content type
421#[instrument(level = "trace", skip(content_type))]
422pub fn find_content_matcher<CT: Into<String>>(content_type: CT) -> Option<ContentMatcher> {
423  let content_type_str = content_type.into();
424  debug!("Looking for a content matcher for {}", content_type_str);
425  let content_type = match ContentType::parse(content_type_str.as_str()) {
426    Ok(ct) => ct,
427    Err(err) => {
428      error!("'{}' is not a valid content type", err);
429      return None;
430    }
431  };
432  let guard = CATALOGUE_REGISTER.lock().unwrap();
433  trace!("Catalogue has {} entries", guard.len());
434  guard.values().find(|entry| {
435    trace!("Catalogue entry {:?}", entry);
436    if entry.entry_type == CatalogueEntryType::CONTENT_MATCHER {
437      trace!("Catalogue entry is a content matcher for {:?}", entry.values.get("content-types"));
438      if let Some(content_types) = entry.values.get("content-types") {
439        content_types.split(";").any(|ct| matches_pattern(ct.trim(), &content_type))
440      } else {
441        false
442      }
443    } else {
444      false
445    }
446  }).map(|entry| ContentMatcher { catalogue_entry: entry.clone() })
447}
448
449/// Checks if a registered content-type pattern matches a content type. The pattern is
450/// matched as a regex against the base type (i.e. with any parameters like `charset`
451/// stripped), anchored at both ends (the whole base type must match, not just a substring) -
452/// this must stay consistent with the equivalent check in the JVM driver's
453/// `CatalogueManager.matches`, so that a plugin's catalogue registration behaves the same way
454/// regardless of which driver loaded it. Regex metacharacters in a content type (most
455/// commonly `+`, as in a `+json`/`+xml` structured syntax suffix) need to be escaped by the
456/// plugin author for a literal match.
457fn matches_pattern(pattern: &str, content_type: &ContentType) -> bool {
458  // Deliberately not `content_type.base_type()`: that replaces the subtype with the
459  // structured syntax suffix (e.g. "application/jwt+json" -> "application/json"), which is
460  // useful for deciding how to *parse* a body but wrong here - it would make two unrelated
461  // "+json" content types register as the same catalogue entry. Just strip attributes
462  // (e.g. `charset`), keeping the type/subtype+suffix as the plugin actually registered it.
463  let base_type = match &content_type.suffix {
464    Some(suffix) => format!("{}/{}+{}", content_type.main_type, content_type.sub_type, suffix),
465    None => format!("{}/{}", content_type.main_type, content_type.sub_type)
466  };
467  match Regex::new(&format!("^(?:{})$", pattern)) {
468    Ok(regex) => regex.is_match(base_type.as_str()),
469    Err(err) => {
470      error!("Failed to parse '{}' as a regex - {}", pattern, err);
471      false
472    }
473  }
474}
475
476/// Find a content generator in the global catalogue for the provided content type
477pub fn find_content_generator(content_type: &ContentType) -> Option<ContentGenerator> {
478  debug!("Looking for a content generator for {}", content_type);
479  let guard = CATALOGUE_REGISTER.lock().unwrap();
480  guard.values().find(|entry| {
481    if entry.entry_type == CatalogueEntryType::CONTENT_GENERATOR {
482      if let Some(content_types) = entry.values.get("content-types") {
483        content_types.split(";").any(|ct| matches_pattern(ct.trim(), content_type))
484      } else {
485        false
486      }
487    } else {
488      false
489    }
490  }).map(|entry| ContentGenerator { catalogue_entry: entry.clone() })
491}
492
493/// Returns a copy of all catalogue entries
494pub fn all_entries() -> Vec<CatalogueEntry> {
495  let guard = CATALOGUE_REGISTER.lock().unwrap();
496  guard.values().cloned().collect()
497}
498
499/// Returns catalogue entries provided by the core host framework (excludes plugin entries)
500pub fn core_entries() -> Vec<CatalogueEntry> {
501  let guard = CATALOGUE_REGISTER.lock().unwrap();
502  guard.values()
503    .filter(|entry| entry.provider_type == CatalogueEntryProviderType::CORE)
504    .cloned()
505    .collect()
506}
507
508#[cfg(test)]
509mod tests {
510  use expectest::prelude::*;
511  use maplit::hashmap;
512
513  use crate::proto::catalogue_entry;
514
515  use super::*;
516
517  #[test]
518  fn sets_plugin_catalogue_entries_correctly() {
519    // Given
520    let manifest = PactPluginManifest {
521      name: "sets_plugin_catalogue_entries_correctly".to_string(),
522      .. PactPluginManifest::default()
523    };
524    let entries = vec![
525      ProtoCatalogueEntry {
526        r#type: catalogue_entry::EntryType::ContentMatcher as i32,
527        key: "protobuf".to_string(),
528        values: hashmap!{ "content-types".to_string() => "application/protobuf;application/grpc".to_string() }
529      },
530      ProtoCatalogueEntry {
531        r#type: catalogue_entry::EntryType::ContentGenerator as i32,
532        key: "protobuf".to_string(),
533        values: hashmap!{ "content-types".to_string() => "application/protobuf;application/grpc".to_string() }
534      },
535      ProtoCatalogueEntry {
536        r#type: catalogue_entry::EntryType::Transport as i32,
537        key: "grpc".to_string(),
538        values: hashmap!{}
539      }
540    ];
541
542    // When
543    register_plugin_entries(&manifest, &entries);
544
545    // Then
546    let matcher_entry = lookup_entry("content-matcher/protobuf");
547    let generator_entry = lookup_entry("content-generator/protobuf");
548    let transport_entry = lookup_entry("transport/grpc");
549
550    remove_plugin_entries("sets_plugin_catalogue_entries_correctly");
551
552    expect!(matcher_entry).to(be_some().value(CatalogueEntry {
553      entry_type: CatalogueEntryType::CONTENT_MATCHER,
554      provider_type: CatalogueEntryProviderType::PLUGIN,
555      plugin: Some(manifest.clone()),
556      key: "protobuf".to_string(),
557      values: hashmap!{ "content-types".to_string() => "application/protobuf;application/grpc".to_string() }
558    }));
559    expect!(generator_entry).to(be_some().value(CatalogueEntry {
560      entry_type: CatalogueEntryType::CONTENT_GENERATOR,
561      provider_type: CatalogueEntryProviderType::PLUGIN,
562      plugin: Some(manifest.clone()),
563      key: "protobuf".to_string(),
564      values: hashmap!{ "content-types".to_string() => "application/protobuf;application/grpc".to_string() }
565    }));
566    expect!(transport_entry).to(be_some().value(CatalogueEntry {
567      entry_type: CatalogueEntryType::TRANSPORT,
568      provider_type: CatalogueEntryProviderType::PLUGIN,
569      plugin: Some(manifest.clone()),
570      key: "grpc".to_string(),
571      values: hashmap!{}
572    }));
573  }
574
575  #[test]
576  fn entry_type_proto_values_and_names_round_trip() {
577    for entry_type in [
578      CatalogueEntryType::CONTENT_MATCHER,
579      CatalogueEntryType::CONTENT_GENERATOR,
580      CatalogueEntryType::TRANSPORT,
581      CatalogueEntryType::MATCHER,
582      CatalogueEntryType::INTERACTION,
583      CatalogueEntryType::GENERATOR
584    ] {
585      expect!(CatalogueEntryType::from_proto_value(entry_type.to_proto_value()))
586        .to(be_some().value(entry_type));
587      expect!(CatalogueEntryType::from_proto_name(entry_type.as_proto_name()))
588        .to(be_some().value(entry_type));
589      // The Display form round-trips too - it is what catalogue keys are built from
590      expect!(CatalogueEntryType::from(entry_type.to_string().as_str())).to(be_equal_to(entry_type));
591    }
592
593    expect!(CatalogueEntryType::from_proto_value(99)).to(be_none());
594    expect!(CatalogueEntryType::from_proto_name("NOT_AN_ENTRY_TYPE")).to(be_none());
595  }
596
597  #[test]
598  fn entry_types_shared_with_v1_keep_their_v1_wire_values() {
599    // The wire values come from the V2 enum, but the driver publishes one catalogue to every
600    // running plugin, V1 ones included - so the values V1 knows about must not shift.
601    expect!(CatalogueEntryType::CONTENT_MATCHER.to_proto_value())
602      .to(be_equal_to(EntryType::ContentMatcher as i32));
603    expect!(CatalogueEntryType::CONTENT_GENERATOR.to_proto_value())
604      .to(be_equal_to(EntryType::ContentGenerator as i32));
605    expect!(CatalogueEntryType::TRANSPORT.to_proto_value())
606      .to(be_equal_to(EntryType::Transport as i32));
607    expect!(CatalogueEntryType::MATCHER.to_proto_value())
608      .to(be_equal_to(EntryType::Matcher as i32));
609    expect!(CatalogueEntryType::INTERACTION.to_proto_value())
610      .to(be_equal_to(EntryType::Interaction as i32));
611  }
612
613  #[test]
614  fn registers_a_generator_entry_under_its_own_entry_type() {
615    let name = "registers_a_generator_entry_under_its_own_entry_type";
616    let manifest = PactPluginManifest { name: name.to_string(), .. PactPluginManifest::default() };
617    // GENERATOR only exists on the V2 enum. This is exactly the case where prost's generated
618    // `entry.r#type()` accessor - built against V1 - would report CONTENT_MATCHER instead.
619    let entries = vec![
620      ProtoCatalogueEntry {
621        r#type: CatalogueEntryType::GENERATOR.to_proto_value(),
622        key: name.to_string(),
623        values: hashmap!{}
624      }
625    ];
626
627    register_plugin_entries(&manifest, &entries);
628
629    let entry = lookup_entry(&format!("generator/{}", name));
630    let as_a_content_matcher = lookup_entry(&format!("content-matcher/{}", name));
631    remove_plugin_entries(name);
632
633    expect!(entry.map(|entry| entry.entry_type)).to(be_some().value(CatalogueEntryType::GENERATOR));
634    expect!(as_a_content_matcher).to(be_none());
635  }
636
637  #[test]
638  fn ignores_a_catalogue_entry_whose_type_this_driver_does_not_understand() {
639    let name = "ignores_a_catalogue_entry_whose_type_this_driver_does_not_understand";
640    let manifest = PactPluginManifest { name: name.to_string(), .. PactPluginManifest::default() };
641    let entries = vec![
642      ProtoCatalogueEntry { r#type: 99, key: name.to_string(), values: hashmap!{} }
643    ];
644
645    register_plugin_entries(&manifest, &entries);
646
647    let registered = all_entries().into_iter().find(|entry| entry.key == name);
648    remove_plugin_entries(name);
649
650    expect!(registered).to(be_none());
651  }
652
653  #[test]
654  fn find_content_matcher_requires_the_whole_base_type_to_match() {
655    let manifest = PactPluginManifest {
656      name: "find_content_matcher_requires_the_whole_base_type_to_match".to_string(),
657      .. PactPluginManifest::default()
658    };
659    let entries = vec![
660      ProtoCatalogueEntry {
661        r#type: catalogue_entry::EntryType::ContentMatcher as i32,
662        key: "jwt".to_string(),
663        // "+" must be escaped, otherwise it's a regex quantifier, not a literal character
664        values: hashmap!{ "content-types".to_string() => "application/jwt;application/jwt\\+json".to_string() }
665      }
666    ];
667    register_plugin_entries(&manifest, &entries);
668
669    let exact_match = find_content_matcher("application/jwt+json");
670    let with_params = find_content_matcher("application/jwt+json;charset=utf-8");
671    let longer_type = find_content_matcher("application/jwt+jsonextra");
672    let unrelated_type = find_content_matcher("application/json");
673
674    remove_plugin_entries("find_content_matcher_requires_the_whole_base_type_to_match");
675
676    expect!(exact_match).to(be_some());
677    expect!(with_params).to(be_some());
678    expect!(longer_type).to(be_none());
679    expect!(unrelated_type).to(be_none());
680  }
681
682  #[test]
683  fn resolve_capability_resolves_an_unambiguous_core_entry() {
684    let key = "resolve_capability_resolves_an_unambiguous_core_entry";
685    register_core_entries(&vec![CatalogueEntry {
686      entry_type: CatalogueEntryType::CONTENT_MATCHER,
687      provider_type: CatalogueEntryProviderType::CORE,
688      plugin: None,
689      key: key.to_string(),
690      values: hashmap!{}
691    }]);
692
693    let resolved = resolve_capability(key, CatalogueEntryType::CONTENT_MATCHER).unwrap();
694
695    let core_key = match resolved {
696      ResolvedCapability::Core(core_key) => core_key,
697      ResolvedCapability::Plugin(_) => panic!("expected a Core resolution, got Plugin")
698    };
699    expect!(core_key).to(be_equal_to(key.to_string()));
700  }
701
702  /// The core matcher entries as the Pact frameworks actually register them - the Pact
703  /// specification version the rule was introduced in, prefixed to the name. Kept in sync with
704  /// `MATCHER_CATALOGUE_ENTRIES` in pact_matching and `MatcherExecutor.kt` in Pact-JVM.
705  fn register_core_matcher_entries() {
706    let entries = ["v2-regex", "v2-type", "v3-number-type", "v3-integer-type", "v3-decimal-type",
707      "v3-date", "v3-time", "v3-datetime", "v2-min-type", "v2-max-type", "v2-minmax-type",
708      "v3-includes", "v3-null", "v4-equals-ignore-order", "v4-min-equals-ignore-order",
709      "v4-max-equals-ignore-order", "v4-minmax-equals-ignore-order", "v3-content-type",
710      "v4-array-contains", "v1-equality", "v4-not-empty", "v4-semver"]
711      .iter()
712      .map(|key| CatalogueEntry {
713        entry_type: CatalogueEntryType::MATCHER,
714        provider_type: CatalogueEntryProviderType::CORE,
715        plugin: None,
716        key: key.to_string(),
717        values: hashmap!{}
718      })
719      .collect();
720    register_core_entries(&entries);
721  }
722
723  fn resolved_core_key(entry_key: &str) -> String {
724    match resolve_capability(entry_key, CatalogueEntryType::MATCHER) {
725      Ok(ResolvedCapability::Core(key)) => key,
726      other => panic!("expected '{}' to resolve to a core entry, got {:?}", entry_key, other)
727    }
728  }
729
730  #[test]
731  fn resolve_capability_falls_back_to_the_versioned_core_key() {
732    register_core_matcher_entries();
733
734    // The name a Pact file (and a plugin calling back) uses, without needing to know which
735    // specification version introduced the rule
736    expect!(resolved_core_key("type")).to(be_equal_to("v2-type".to_string()));
737    expect!(resolved_core_key("regex")).to(be_equal_to("v2-regex".to_string()));
738    expect!(resolved_core_key("date")).to(be_equal_to("v3-date".to_string()));
739    expect!(resolved_core_key("equality")).to(be_equal_to("v1-equality".to_string()));
740    expect!(resolved_core_key("semver")).to(be_equal_to("v4-semver".to_string()));
741    expect!(resolved_core_key("not-empty")).to(be_equal_to("v4-not-empty".to_string()));
742
743    // Only the whole name after the version prefix counts, so these are distinct rules and not
744    // ambiguous with `type`/`equals-ignore-order`
745    expect!(resolved_core_key("content-type")).to(be_equal_to("v3-content-type".to_string()));
746    expect!(resolved_core_key("min-type")).to(be_equal_to("v2-min-type".to_string()));
747    expect!(resolved_core_key("equals-ignore-order"))
748      .to(be_equal_to("v4-equals-ignore-order".to_string()));
749
750    // The versioned key itself still resolves, by name
751    expect!(resolved_core_key("v2-type")).to(be_equal_to("v2-type".to_string()));
752    expect!(resolved_core_key("core/matcher/v3-date")).to(be_equal_to("v3-date".to_string()));
753    expect!(resolved_core_key("matcher/v3-date")).to(be_equal_to("v3-date".to_string()));
754  }
755
756  #[test]
757  fn resolve_capability_does_not_match_a_key_component_as_a_substring() {
758    // "type" must not name `core/matcher/v2-type` by suffix - if it did, it would match all eight
759    // core keys ending in "type" and be ambiguous. It resolves through the versioned fallback to
760    // exactly one entry instead.
761    expect!(names_catalogue_key("core/matcher/v2-type", "type")).to(be_false());
762    expect!(names_catalogue_key("core/matcher/v2-type", "v2-type")).to(be_true());
763    expect!(names_catalogue_key("core/matcher/v2-type", "matcher/v2-type")).to(be_true());
764    expect!(names_catalogue_key("core/matcher/v2-type", "core/matcher/v2-type")).to(be_true());
765    expect!(names_catalogue_key("core/matcher/v2-type", "r/v2-type")).to(be_false());
766    expect!(names_catalogue_key("core/content-matcher/xml", "xml")).to(be_true());
767    expect!(names_catalogue_key("core/content-matcher/xml", "ml")).to(be_false());
768  }
769
770  #[test]
771  fn the_versioned_fallback_only_applies_to_matcher_and_generator_entries() {
772    // Content matchers, content generators and transports are registered under plain names, so a
773    // leading "v<n>-" there is part of the name, not a version to be stripped.
774    let name = "the_versioned_fallback_only_applies_to_matcher_and_generator_entries";
775    register_core_entries(&vec![
776      CatalogueEntry {
777        entry_type: CatalogueEntryType::CONTENT_MATCHER,
778        provider_type: CatalogueEntryProviderType::CORE,
779        plugin: None,
780        key: format!("v2-{}", name),
781        values: hashmap!{}
782      },
783      CatalogueEntry {
784        entry_type: CatalogueEntryType::MATCHER,
785        provider_type: CatalogueEntryProviderType::CORE,
786        plugin: None,
787        key: format!("v2-matcher-{}", name),
788        values: hashmap!{}
789      }
790    ]);
791
792    // The content matcher is only reachable by its actual name
793    expect!(resolve_capability(name, CatalogueEntryType::CONTENT_MATCHER).is_err()).to(be_true());
794    expect!(lookup_entry(name).map(|entry| entry.key)).to(be_none());
795    expect!(resolve_capability(&format!("v2-{}", name), CatalogueEntryType::CONTENT_MATCHER).is_ok())
796      .to(be_true());
797
798    // ... while the matcher entry still gets the fallback
799    expect!(resolved_core_key(&format!("matcher-{}", name)))
800      .to(be_equal_to(format!("v2-matcher-{}", name)));
801  }
802
803  #[test]
804  fn lookup_entry_matches_by_name_not_by_substring() {
805    let name = "lookup_entry_matches_by_name_not_by_substring";
806    let manifest = PactPluginManifest { name: name.to_string(), .. PactPluginManifest::default() };
807    register_plugin_entries(&manifest, &vec![
808      ProtoCatalogueEntry {
809        r#type: CatalogueEntryType::CONTENT_MATCHER.to_proto_value(),
810        key: name.to_string(),
811        values: hashmap!{}
812      },
813      ProtoCatalogueEntry {
814        r#type: CatalogueEntryType::MATCHER.to_proto_value(),
815        key: format!("v3-{}", name),
816        values: hashmap!{}
817      }
818    ]);
819
820    let by_name = lookup_entry(name).map(|entry| entry.entry_type);
821    let by_components = lookup_entry(&format!("content-matcher/{}", name)).map(|entry| entry.entry_type);
822    let fully_qualified = lookup_entry(&format!("plugin/{}/content-matcher/{}", name, name))
823      .map(|entry| entry.entry_type);
824    // A trailing substring of a component names nothing
825    let by_substring = lookup_entry(&name[3..]);
826    // But the versioned convention still resolves for a matcher entry
827    let versioned = lookup_entry(&format!("{}-{}", "matcher-fallback", name));
828
829    remove_plugin_entries(name);
830
831    expect!(by_name).to(be_some().value(CatalogueEntryType::CONTENT_MATCHER));
832    expect!(by_components).to(be_some().value(CatalogueEntryType::CONTENT_MATCHER));
833    expect!(fully_qualified).to(be_some().value(CatalogueEntryType::CONTENT_MATCHER));
834    expect!(by_substring).to(be_none());
835    expect!(versioned).to(be_none());
836  }
837
838  #[test]
839  fn lookup_entry_falls_back_to_the_versioned_core_key() {
840    register_core_matcher_entries();
841
842    expect!(lookup_entry("type").map(|entry| entry.key)).to(be_some().value("v2-type".to_string()));
843    expect!(lookup_entry("v3-date").map(|entry| entry.key)).to(be_some().value("v3-date".to_string()));
844    expect!(lookup_entry("matcher/v3-date").map(|entry| entry.key)).to(be_some().value("v3-date".to_string()));
845  }
846
847  #[test]
848  fn resolve_capability_prefers_an_entry_named_directly_over_the_versioned_fallback() {
849    let name = "resolve_capability_prefers_an_entry_named_directly_over_the_versioned_fallback";
850    register_core_entries(&vec![CatalogueEntry {
851      entry_type: CatalogueEntryType::MATCHER,
852      provider_type: CatalogueEntryProviderType::CORE,
853      plugin: None,
854      key: format!("v3-{}", name),
855      values: hashmap!{}
856    }]);
857    // Before the plugin registers anything, the bare name finds the core rule via the fallback
858    let core_first = resolved_core_key(name);
859
860    let manifest = PactPluginManifest { name: name.to_string(), .. PactPluginManifest::default() };
861    register_plugin_entries(&manifest, &vec![ProtoCatalogueEntry {
862      r#type: CatalogueEntryType::MATCHER.to_proto_value(),
863      key: name.to_string(),
864      values: hashmap!{}
865    }]);
866
867    let resolved = resolve_capability(name, CatalogueEntryType::MATCHER);
868    // A caller that specifically wants the core rule can still name its versioned key
869    let still_core = resolved_core_key(&format!("v3-{}", name));
870    remove_plugin_entries(name);
871
872    expect!(core_first).to(be_equal_to(format!("v3-{}", name)));
873    match resolved.expect("expected the plugin's own entry to resolve") {
874      ResolvedCapability::Plugin(resolved_manifest) => expect!(resolved_manifest.name).to(be_equal_to(name.to_string())),
875      ResolvedCapability::Core(key) => panic!("expected the plugin entry to win, got core '{}'", key)
876    };
877    expect!(still_core).to(be_equal_to(format!("v3-{}", name)));
878  }
879
880  #[test]
881  fn resolve_capability_returns_a_clear_error_for_an_unregistered_key() {
882    let result = resolve_capability(
883      "resolve_capability_returns_a_clear_error_for_an_unregistered_key",
884      CatalogueEntryType::CONTENT_MATCHER
885    );
886
887    let err = result.expect_err("expected an error for an unregistered key");
888    expect!(err.to_string().contains("No catalogue entry found")).to(be_true());
889  }
890
891  #[test]
892  fn resolve_capability_returns_a_clear_error_for_the_wrong_capability_shape() {
893    let key = "resolve_capability_returns_a_clear_error_for_the_wrong_capability_shape";
894    register_core_entries(&vec![CatalogueEntry {
895      entry_type: CatalogueEntryType::CONTENT_GENERATOR,
896      provider_type: CatalogueEntryProviderType::CORE,
897      plugin: None,
898      key: key.to_string(),
899      values: hashmap!{}
900    }]);
901
902    let result = resolve_capability(key, CatalogueEntryType::CONTENT_MATCHER);
903
904    let err = result.expect_err("expected an error when the entry is a generator, not a matcher");
905    expect!(err.to_string().contains("is a CONTENT_GENERATOR, not a CONTENT_MATCHER")).to(be_true());
906  }
907
908  #[test]
909  fn resolve_capability_rejects_an_ambiguous_key_shared_by_a_core_and_a_plugin_entry() {
910    let key = "resolve_capability_rejects_an_ambiguous_key_shared_by_a_core_and_a_plugin_entry";
911    let manifest = PactPluginManifest {
912      name: "resolve_capability_rejects_an_ambiguous_key_shared_by_a_core_and_a_plugin_entry".to_string(),
913      .. PactPluginManifest::default()
914    };
915    register_core_entries(&vec![CatalogueEntry {
916      entry_type: CatalogueEntryType::CONTENT_MATCHER,
917      provider_type: CatalogueEntryProviderType::CORE,
918      plugin: None,
919      key: key.to_string(),
920      values: hashmap!{}
921    }]);
922    register_plugin_entries(&manifest, &vec![ProtoCatalogueEntry {
923      r#type: catalogue_entry::EntryType::ContentMatcher as i32,
924      key: key.to_string(),
925      values: hashmap!{}
926    }]);
927
928    let result = resolve_capability(key, CatalogueEntryType::CONTENT_MATCHER);
929
930    remove_plugin_entries(&manifest.name);
931
932    let err = result.expect_err("expected an error for a key matching more than one entry");
933    expect!(err.to_string().contains("Ambiguous catalogue entry key")).to(be_true());
934  }
935}