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};
14
15use crate::content::{ContentGenerator, ContentMatcher};
16use crate::plugin_models::PactPluginManifest;
17use crate::proto::catalogue_entry::EntryType;
18use crate::proto::CatalogueEntry as ProtoCatalogueEntry;
19
20lazy_static! {
21  static ref CATALOGUE_REGISTER: Mutex<HashMap<String, CatalogueEntry>> = Mutex::new(HashMap::new());
22}
23
24/// Type of catalogue entry
25#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
26#[allow(non_camel_case_types)]
27pub enum CatalogueEntryType {
28  /// Content matcher (based on content type)
29  CONTENT_MATCHER,
30  /// Content generator (based on content type)
31  CONTENT_GENERATOR,
32  /// Network transport
33  TRANSPORT,
34  /// Matching rule
35  MATCHER,
36  /// Generator
37  INTERACTION
38}
39
40impl CatalogueEntryType {
41  /// Return the protobuf type for this entry type
42  pub fn to_proto_type(&self) -> EntryType {
43    match self {
44      CatalogueEntryType::CONTENT_MATCHER => EntryType::ContentMatcher,
45      CatalogueEntryType::CONTENT_GENERATOR => EntryType::ContentGenerator,
46      CatalogueEntryType::TRANSPORT => EntryType::Transport,
47      CatalogueEntryType::MATCHER => EntryType::Matcher,
48      CatalogueEntryType::INTERACTION => EntryType::Interaction
49    }
50  }
51}
52
53impl Display for CatalogueEntryType {
54  fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
55    match self {
56      CatalogueEntryType::CONTENT_MATCHER => write!(f, "content-matcher"),
57      CatalogueEntryType::CONTENT_GENERATOR => write!(f, "content-generator"),
58      CatalogueEntryType::TRANSPORT => write!(f, "transport"),
59      CatalogueEntryType::MATCHER => write!(f, "matcher"),
60      CatalogueEntryType::INTERACTION => write!(f, "interaction"),
61    }
62  }
63}
64
65impl From<&str> for CatalogueEntryType {
66  fn from(s: &str) -> Self {
67    match s {
68      "content-matcher" => CatalogueEntryType::CONTENT_MATCHER,
69      "content-generator" => CatalogueEntryType::CONTENT_GENERATOR,
70      "interaction" => CatalogueEntryType::INTERACTION,
71      "matcher" => CatalogueEntryType::MATCHER,
72      "transport" => CatalogueEntryType::TRANSPORT,
73      _ => {
74        let message = format!("'{}' is not a valid CatalogueEntryType value", s);
75        error!("{}", message);
76        panic!("{}", message)
77      }
78    }
79  }
80}
81
82impl From<String> for CatalogueEntryType {
83  fn from(s: String) -> Self {
84    Self::from(s.as_str())
85  }
86}
87
88impl From<EntryType> for CatalogueEntryType {
89  fn from(t: EntryType) -> Self {
90    match t {
91      EntryType::ContentMatcher => CatalogueEntryType::CONTENT_MATCHER,
92      EntryType::ContentGenerator => CatalogueEntryType::CONTENT_GENERATOR,
93      EntryType::Transport => CatalogueEntryType::TRANSPORT,
94      EntryType::Matcher => CatalogueEntryType::MATCHER,
95      EntryType::Interaction => CatalogueEntryType::INTERACTION
96    }
97  }
98}
99
100/// Provider of the catalogue entry
101#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
102#[allow(non_camel_case_types)]
103pub enum CatalogueEntryProviderType {
104  /// Core Pact framework
105  CORE,
106  /// Plugin
107  PLUGIN
108}
109
110/// Catalogue entry
111#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
112#[serde(rename_all = "camelCase")]
113pub struct CatalogueEntry {
114  /// Type of entry
115  pub entry_type: CatalogueEntryType,
116  /// Provider of the entry
117  pub provider_type: CatalogueEntryProviderType,
118  /// Plugin manifest
119  pub plugin: Option<PactPluginManifest>,
120  /// Entry key
121  pub key: String,
122  /// assocaited Entry values
123  pub values: HashMap<String, String>
124}
125
126/// Register the entries in the global catalogue
127pub fn register_plugin_entries(plugin: &PactPluginManifest, catalogue_list: &Vec<ProtoCatalogueEntry>) {
128  trace!("register_plugin_entries({:?}, {:?})", plugin, catalogue_list);
129
130  let mut guard = CATALOGUE_REGISTER.lock().unwrap();
131
132  for entry in catalogue_list {
133    let entry_type = CatalogueEntryType::from(entry.r#type());
134    let key = format!("plugin/{}/{}/{}", plugin.name, entry_type, entry.key);
135    guard.insert(key.clone(), CatalogueEntry {
136      entry_type,
137      provider_type: CatalogueEntryProviderType::PLUGIN,
138      plugin: Some(plugin.clone()),
139      key: entry.key.clone(),
140      values: entry.values.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
141    });
142  }
143
144  debug!("Updated catalogue entries:\n{}", guard.keys().sorted().join("\n"))
145}
146
147/// Register the core Pact framework entries in the global catalogue
148pub fn register_core_entries(entries: &Vec<CatalogueEntry>) {
149  trace!("register_core_entries({:?})", entries);
150
151  let mut inner = CATALOGUE_REGISTER.lock().unwrap();
152
153  let mut updated_keys = hashset!();
154  for entry in entries {
155    let key = format!("core/{}/{}", entry.entry_type, entry.key);
156    if !inner.contains_key(&key) {
157      inner.insert(key.clone(), entry.clone());
158      updated_keys.insert(key.clone());
159    }
160  }
161
162  if !updated_keys.is_empty() {
163    debug!("Updated catalogue entries:\n{}", updated_keys.iter().sorted().join("\n"));
164  }
165}
166
167/// Lookup an entry in the catalogue by the key. Will find the first entry that ends with the
168/// given key.
169pub fn lookup_entry(key: &str) -> Option<CatalogueEntry> {
170  let inner = CATALOGUE_REGISTER.lock().unwrap();
171  inner.iter()
172    .find(|(k, _)| k.ends_with(key))
173    .map(|(_, v)| v.clone())
174}
175
176/// Where a resolved catalogue entry's capability should be dispatched. Shared by every transport
177/// that lets a plugin call back into a capability by catalogue entry key - the gRPC `PluginHost`
178/// service, and the Lua/WASM host functions - so there is exactly one place that decides "who
179/// provides this entry". See proposal 007 ("One resolver, [multiple] call directions").
180#[derive(Debug, Clone)]
181pub enum ResolvedCapability {
182  /// A host-registered core handler, keyed by the unprefixed catalogue entry key.
183  Core(String),
184  /// A running plugin, identified by its manifest.
185  Plugin(Box<PactPluginManifest>)
186}
187
188/// Resolve a callback's catalogue entry key to a dispatch target, the same way
189/// [`crate::content::ContentMatcher::is_core`]/[`crate::content::ContentGenerator::is_core`] do
190/// for the driver's own outbound calls.
191///
192/// `entry_key` is matched by suffix against the full catalogue key (e.g. a plugin passing
193/// `"xml"` matches `"core/content-matcher/xml"`), the same lookup [`lookup_entry`] does - except
194/// unlike [`lookup_entry`], this does not just take the first `HashMap` hit if more than one
195/// entry matches the suffix. A short, unqualified `entry_key` could otherwise coincidentally
196/// match more than one entry of the *same* `expected_type` (e.g. a plugin registering its own
197/// `content-matcher/xml` alongside a host-registered core `content-matcher/xml`), and `HashMap`
198/// iteration order is randomised per process - silently picking one would make the dispatch
199/// target non-deterministic across restarts. `expected_type` still guards against the *wrong*
200/// capability shape (a content-generator registered under the same name as an unrelated
201/// content-matcher), mirroring the explicit `entry_type` check [`find_content_matcher`]/
202/// [`find_content_generator`] already do.
203pub fn resolve_capability(entry_key: &str, expected_type: CatalogueEntryType) -> anyhow::Result<ResolvedCapability> {
204  let candidates: Vec<(String, CatalogueEntry)> = {
205    let inner = CATALOGUE_REGISTER.lock().unwrap();
206    inner.iter()
207      .filter(|(k, _)| k.ends_with(entry_key))
208      .map(|(k, v)| (k.clone(), v.clone()))
209      .collect()
210  };
211
212  let mut of_expected_type = candidates.iter().filter(|(_, entry)| entry.entry_type == expected_type);
213  let entry = match (of_expected_type.next(), of_expected_type.next()) {
214    (None, _) => return match candidates.first() {
215      Some((_, entry)) => Err(anyhow::anyhow!(
216        "Catalogue entry '{}' is a {:?}, not a {:?}", entry_key, entry.entry_type, expected_type
217      )),
218      None => Err(anyhow::anyhow!("No catalogue entry found for key '{}'", entry_key))
219    },
220    (Some(only), None) => &only.1,
221    (Some(first), Some(second)) => {
222      let mut keys: Vec<&str> = std::iter::once(first).chain(std::iter::once(second))
223        .chain(of_expected_type)
224        .map(|(k, _)| k.as_str())
225        .collect();
226      keys.sort_unstable();
227      return Err(anyhow::anyhow!(
228        "Ambiguous catalogue entry key '{}': matches multiple entries ({}) - register it under a more specific key",
229        entry_key, keys.join(", ")
230      ));
231    }
232  };
233
234  match entry.provider_type {
235    CatalogueEntryProviderType::CORE => Ok(ResolvedCapability::Core(entry.key.clone())),
236    CatalogueEntryProviderType::PLUGIN => entry.plugin.clone()
237      .map(|manifest| ResolvedCapability::Plugin(Box::new(manifest)))
238      .ok_or_else(|| anyhow::anyhow!("Catalogue entry '{}' has no plugin manifest", entry_key))
239  }
240}
241
242/// Remove all entries for a plugin given the plugin name
243pub fn remove_plugin_entries(name: &str) {
244  trace!("remove_plugin_entries({})", name);
245
246  let prefix = format!("plugin/{}/", name);
247  let keys: Vec<String> = {
248    let guard = CATALOGUE_REGISTER.lock().unwrap();
249    guard.keys()
250      .filter(|key| key.starts_with(&prefix))
251      .cloned()
252      .collect()
253  };
254
255  let mut guard = CATALOGUE_REGISTER.lock().unwrap();
256  for key in keys {
257    guard.remove(&key);
258  }
259
260  debug!("Removed all catalogue entries for plugin {}", name);
261}
262
263/// Find a content matcher in the global catalogue for the provided content type
264#[instrument(level = "trace", skip(content_type))]
265pub fn find_content_matcher<CT: Into<String>>(content_type: CT) -> Option<ContentMatcher> {
266  let content_type_str = content_type.into();
267  debug!("Looking for a content matcher for {}", content_type_str);
268  let content_type = match ContentType::parse(content_type_str.as_str()) {
269    Ok(ct) => ct,
270    Err(err) => {
271      error!("'{}' is not a valid content type", err);
272      return None;
273    }
274  };
275  let guard = CATALOGUE_REGISTER.lock().unwrap();
276  trace!("Catalogue has {} entries", guard.len());
277  guard.values().find(|entry| {
278    trace!("Catalogue entry {:?}", entry);
279    if entry.entry_type == CatalogueEntryType::CONTENT_MATCHER {
280      trace!("Catalogue entry is a content matcher for {:?}", entry.values.get("content-types"));
281      if let Some(content_types) = entry.values.get("content-types") {
282        content_types.split(";").any(|ct| matches_pattern(ct.trim(), &content_type))
283      } else {
284        false
285      }
286    } else {
287      false
288    }
289  }).map(|entry| ContentMatcher { catalogue_entry: entry.clone() })
290}
291
292/// Checks if a registered content-type pattern matches a content type. The pattern is
293/// matched as a regex against the base type (i.e. with any parameters like `charset`
294/// stripped), anchored at both ends (the whole base type must match, not just a substring) -
295/// this must stay consistent with the equivalent check in the JVM driver's
296/// `CatalogueManager.matches`, so that a plugin's catalogue registration behaves the same way
297/// regardless of which driver loaded it. Regex metacharacters in a content type (most
298/// commonly `+`, as in a `+json`/`+xml` structured syntax suffix) need to be escaped by the
299/// plugin author for a literal match.
300fn matches_pattern(pattern: &str, content_type: &ContentType) -> bool {
301  // Deliberately not `content_type.base_type()`: that replaces the subtype with the
302  // structured syntax suffix (e.g. "application/jwt+json" -> "application/json"), which is
303  // useful for deciding how to *parse* a body but wrong here - it would make two unrelated
304  // "+json" content types register as the same catalogue entry. Just strip attributes
305  // (e.g. `charset`), keeping the type/subtype+suffix as the plugin actually registered it.
306  let base_type = match &content_type.suffix {
307    Some(suffix) => format!("{}/{}+{}", content_type.main_type, content_type.sub_type, suffix),
308    None => format!("{}/{}", content_type.main_type, content_type.sub_type)
309  };
310  match Regex::new(&format!("^(?:{})$", pattern)) {
311    Ok(regex) => regex.is_match(base_type.as_str()),
312    Err(err) => {
313      error!("Failed to parse '{}' as a regex - {}", pattern, err);
314      false
315    }
316  }
317}
318
319/// Find a content generator in the global catalogue for the provided content type
320pub fn find_content_generator(content_type: &ContentType) -> Option<ContentGenerator> {
321  debug!("Looking for a content generator for {}", content_type);
322  let guard = CATALOGUE_REGISTER.lock().unwrap();
323  guard.values().find(|entry| {
324    if entry.entry_type == CatalogueEntryType::CONTENT_GENERATOR {
325      if let Some(content_types) = entry.values.get("content-types") {
326        content_types.split(";").any(|ct| matches_pattern(ct.trim(), content_type))
327      } else {
328        false
329      }
330    } else {
331      false
332    }
333  }).map(|entry| ContentGenerator { catalogue_entry: entry.clone() })
334}
335
336/// Returns a copy of all catalogue entries
337pub fn all_entries() -> Vec<CatalogueEntry> {
338  let guard = CATALOGUE_REGISTER.lock().unwrap();
339  guard.values().cloned().collect()
340}
341
342/// Returns catalogue entries provided by the core host framework (excludes plugin entries)
343pub fn core_entries() -> Vec<CatalogueEntry> {
344  let guard = CATALOGUE_REGISTER.lock().unwrap();
345  guard.values()
346    .filter(|entry| entry.provider_type == CatalogueEntryProviderType::CORE)
347    .cloned()
348    .collect()
349}
350
351#[cfg(test)]
352mod tests {
353  use expectest::prelude::*;
354  use maplit::hashmap;
355
356  use crate::proto::catalogue_entry;
357
358  use super::*;
359
360  #[test]
361  fn sets_plugin_catalogue_entries_correctly() {
362    // Given
363    let manifest = PactPluginManifest {
364      name: "sets_plugin_catalogue_entries_correctly".to_string(),
365      .. PactPluginManifest::default()
366    };
367    let entries = vec![
368      ProtoCatalogueEntry {
369        r#type: catalogue_entry::EntryType::ContentMatcher as i32,
370        key: "protobuf".to_string(),
371        values: hashmap!{ "content-types".to_string() => "application/protobuf;application/grpc".to_string() }
372      },
373      ProtoCatalogueEntry {
374        r#type: catalogue_entry::EntryType::ContentGenerator as i32,
375        key: "protobuf".to_string(),
376        values: hashmap!{ "content-types".to_string() => "application/protobuf;application/grpc".to_string() }
377      },
378      ProtoCatalogueEntry {
379        r#type: catalogue_entry::EntryType::Transport as i32,
380        key: "grpc".to_string(),
381        values: hashmap!{}
382      }
383    ];
384
385    // When
386    register_plugin_entries(&manifest, &entries);
387
388    // Then
389    let matcher_entry = lookup_entry("content-matcher/protobuf");
390    let generator_entry = lookup_entry("content-generator/protobuf");
391    let transport_entry = lookup_entry("transport/grpc");
392
393    remove_plugin_entries("sets_plugin_catalogue_entries_correctly");
394
395    expect!(matcher_entry).to(be_some().value(CatalogueEntry {
396      entry_type: CatalogueEntryType::CONTENT_MATCHER,
397      provider_type: CatalogueEntryProviderType::PLUGIN,
398      plugin: Some(manifest.clone()),
399      key: "protobuf".to_string(),
400      values: hashmap!{ "content-types".to_string() => "application/protobuf;application/grpc".to_string() }
401    }));
402    expect!(generator_entry).to(be_some().value(CatalogueEntry {
403      entry_type: CatalogueEntryType::CONTENT_GENERATOR,
404      provider_type: CatalogueEntryProviderType::PLUGIN,
405      plugin: Some(manifest.clone()),
406      key: "protobuf".to_string(),
407      values: hashmap!{ "content-types".to_string() => "application/protobuf;application/grpc".to_string() }
408    }));
409    expect!(transport_entry).to(be_some().value(CatalogueEntry {
410      entry_type: CatalogueEntryType::TRANSPORT,
411      provider_type: CatalogueEntryProviderType::PLUGIN,
412      plugin: Some(manifest.clone()),
413      key: "grpc".to_string(),
414      values: hashmap!{}
415    }));
416  }
417
418  #[test]
419  fn find_content_matcher_requires_the_whole_base_type_to_match() {
420    let manifest = PactPluginManifest {
421      name: "find_content_matcher_requires_the_whole_base_type_to_match".to_string(),
422      .. PactPluginManifest::default()
423    };
424    let entries = vec![
425      ProtoCatalogueEntry {
426        r#type: catalogue_entry::EntryType::ContentMatcher as i32,
427        key: "jwt".to_string(),
428        // "+" must be escaped, otherwise it's a regex quantifier, not a literal character
429        values: hashmap!{ "content-types".to_string() => "application/jwt;application/jwt\\+json".to_string() }
430      }
431    ];
432    register_plugin_entries(&manifest, &entries);
433
434    let exact_match = find_content_matcher("application/jwt+json");
435    let with_params = find_content_matcher("application/jwt+json;charset=utf-8");
436    let longer_type = find_content_matcher("application/jwt+jsonextra");
437    let unrelated_type = find_content_matcher("application/json");
438
439    remove_plugin_entries("find_content_matcher_requires_the_whole_base_type_to_match");
440
441    expect!(exact_match).to(be_some());
442    expect!(with_params).to(be_some());
443    expect!(longer_type).to(be_none());
444    expect!(unrelated_type).to(be_none());
445  }
446
447  #[test]
448  fn resolve_capability_resolves_an_unambiguous_core_entry() {
449    let key = "resolve_capability_resolves_an_unambiguous_core_entry";
450    register_core_entries(&vec![CatalogueEntry {
451      entry_type: CatalogueEntryType::CONTENT_MATCHER,
452      provider_type: CatalogueEntryProviderType::CORE,
453      plugin: None,
454      key: key.to_string(),
455      values: hashmap!{}
456    }]);
457
458    let resolved = resolve_capability(key, CatalogueEntryType::CONTENT_MATCHER).unwrap();
459
460    let core_key = match resolved {
461      ResolvedCapability::Core(core_key) => core_key,
462      ResolvedCapability::Plugin(_) => panic!("expected a Core resolution, got Plugin")
463    };
464    expect!(core_key).to(be_equal_to(key.to_string()));
465  }
466
467  #[test]
468  fn resolve_capability_returns_a_clear_error_for_an_unregistered_key() {
469    let result = resolve_capability(
470      "resolve_capability_returns_a_clear_error_for_an_unregistered_key",
471      CatalogueEntryType::CONTENT_MATCHER
472    );
473
474    let err = result.expect_err("expected an error for an unregistered key");
475    expect!(err.to_string().contains("No catalogue entry found")).to(be_true());
476  }
477
478  #[test]
479  fn resolve_capability_returns_a_clear_error_for_the_wrong_capability_shape() {
480    let key = "resolve_capability_returns_a_clear_error_for_the_wrong_capability_shape";
481    register_core_entries(&vec![CatalogueEntry {
482      entry_type: CatalogueEntryType::CONTENT_GENERATOR,
483      provider_type: CatalogueEntryProviderType::CORE,
484      plugin: None,
485      key: key.to_string(),
486      values: hashmap!{}
487    }]);
488
489    let result = resolve_capability(key, CatalogueEntryType::CONTENT_MATCHER);
490
491    let err = result.expect_err("expected an error when the entry is a generator, not a matcher");
492    expect!(err.to_string().contains("is a CONTENT_GENERATOR, not a CONTENT_MATCHER")).to(be_true());
493  }
494
495  #[test]
496  fn resolve_capability_rejects_an_ambiguous_key_shared_by_a_core_and_a_plugin_entry() {
497    let key = "resolve_capability_rejects_an_ambiguous_key_shared_by_a_core_and_a_plugin_entry";
498    let manifest = PactPluginManifest {
499      name: "resolve_capability_rejects_an_ambiguous_key_shared_by_a_core_and_a_plugin_entry".to_string(),
500      .. PactPluginManifest::default()
501    };
502    register_core_entries(&vec![CatalogueEntry {
503      entry_type: CatalogueEntryType::CONTENT_MATCHER,
504      provider_type: CatalogueEntryProviderType::CORE,
505      plugin: None,
506      key: key.to_string(),
507      values: hashmap!{}
508    }]);
509    register_plugin_entries(&manifest, &vec![ProtoCatalogueEntry {
510      r#type: catalogue_entry::EntryType::ContentMatcher as i32,
511      key: key.to_string(),
512      values: hashmap!{}
513    }]);
514
515    let result = resolve_capability(key, CatalogueEntryType::CONTENT_MATCHER);
516
517    remove_plugin_entries(&manifest.name);
518
519    let err = result.expect_err("expected an error for a key matching more than one entry");
520    expect!(err.to_string().contains("Ambiguous catalogue entry key")).to(be_true());
521  }
522}