pact_plugin_driver/
content.rs

1//! Support for matching and generating content based on content types
2use std::collections::{BTreeMap, HashMap};
3use std::str::from_utf8;
4
5use anyhow::anyhow;
6use bytes::Bytes;
7use maplit::hashmap;
8use pact_models::bodies::OptionalBody;
9use pact_models::content_types::ContentTypeHint;
10use pact_models::matchingrules::{Category, MatchingRule, MatchingRuleCategory, RuleList};
11use pact_models::path_exp::DocPath;
12use pact_models::plugins::PluginData;
13use pact_models::prelude::{ContentType, Generator, GeneratorCategory, Generators, RuleLogic};
14use serde_json::Value;
15use tracing::{debug, error};
16
17use crate::catalogue_manager::{CatalogueEntry, CatalogueEntryProviderType};
18use crate::plugin_manager::lookup_plugin;
19use crate::plugin_models::{PactPluginManifest, PactPluginRpc, PluginInteractionConfig};
20use crate::proto::{
21  Body,
22  CompareContentsRequest,
23  ConfigureInteractionRequest,
24  ConfigureInteractionResponse,
25  GenerateContentRequest,
26  PluginConfiguration as ProtoPluginConfiguration
27};
28use crate::proto::body;
29use crate::proto::interaction_response::MarkupType;
30use crate::utils::{proto_struct_to_hashmap, proto_struct_to_json, proto_struct_to_map, to_proto_struct};
31
32/// Matcher for contents based on content type
33#[derive(Clone, Debug)]
34pub struct ContentMatcher {
35  /// Catalogue entry for this content matcher
36  pub catalogue_entry: CatalogueEntry
37}
38
39impl ContentMatcher {
40  /// Plugin details for this content matcher
41  pub fn plugin(&self) -> Option<PactPluginManifest> {
42    self.catalogue_entry.plugin.clone()
43  }
44}
45
46/// Mismatch result
47#[derive(Clone, Debug)]
48pub struct ContentMismatch {
49  /// Expected value in string format
50  // TODO: change to bytes
51  pub expected: String,
52  /// Actual value in string format
53  // TODO: change to bytes
54  pub actual: String,
55  /// Mismatch description
56  pub mismatch: String,
57  /// Path to the mismatch
58  pub path: String,
59  /// Optional diff of the expected and actual values
60  pub diff: Option<String>,
61  /// The type of item that the mismatch is for
62  pub mismatch_type: Option<String>
63}
64
65/// Interaction contents setup by the plugin
66#[derive(Clone, Debug, PartialEq)]
67pub struct InteractionContents {
68  /// Description of what part this interaction belongs to (in the case of there being more than
69  /// one, for instance, request/response messages)
70  pub part_name: String,
71
72  /// Body/Contents of the interaction
73  pub body: OptionalBody,
74
75  /// Matching rules to apply
76  pub rules: Option<MatchingRuleCategory>,
77
78  /// Generators to apply
79  pub generators: Option<Generators>,
80
81  /// Message metadata
82  pub metadata: Option<BTreeMap<String, Value>>,
83
84  /// Matching rules to apply to message metadata
85  pub metadata_rules: Option<MatchingRuleCategory>,
86
87  /// Plugin configuration data to apply to the interaction
88  pub plugin_config: PluginConfiguration,
89
90  /// Markup for the interaction to display in any UI
91  pub interaction_markup: String,
92
93  /// The type of the markup (CommonMark or HTML)
94  pub interaction_markup_type: String
95}
96
97impl Default for InteractionContents {
98  fn default() -> Self {
99    InteractionContents {
100      part_name: Default::default(),
101      body: Default::default(),
102      rules: None,
103      generators: None,
104      metadata: None,
105      metadata_rules: None,
106      plugin_config: Default::default(),
107      interaction_markup: Default::default(),
108      interaction_markup_type: Default::default()
109    }
110  }
111}
112
113/// Plugin data to persist into the Pact file
114#[derive(Clone, Debug, PartialEq)]
115pub struct PluginConfiguration {
116  /// Data to perist on the interaction
117  pub interaction_configuration: HashMap<String, Value>,
118  /// Data to persist in the Pact metadata
119  pub pact_configuration: HashMap<String, Value>
120}
121
122impl PluginConfiguration {
123  /// Plugin data is empty when the interaction and pact data is empty
124  pub fn is_empty(&self) -> bool {
125    self.pact_configuration.is_empty() && self.interaction_configuration.is_empty()
126  }
127}
128
129impl Default for PluginConfiguration {
130  fn default() -> Self {
131    PluginConfiguration {
132      interaction_configuration: Default::default(),
133      pact_configuration: Default::default()
134    }
135  }
136}
137
138impl From<ProtoPluginConfiguration> for PluginConfiguration {
139  fn from(config: ProtoPluginConfiguration) -> Self {
140    PluginConfiguration {
141      interaction_configuration: config.interaction_configuration
142        .as_ref()
143        .map(|c| proto_struct_to_hashmap(c))
144        .unwrap_or_default(),
145      pact_configuration: config.pact_configuration
146        .as_ref()
147        .map(|c| proto_struct_to_hashmap(c))
148        .unwrap_or_default()
149    }
150  }
151}
152
153impl ContentMatcher {
154  /// If this is a core framework matcher
155  pub fn is_core(&self) -> bool {
156    self.catalogue_entry.provider_type == CatalogueEntryProviderType::CORE
157  }
158
159  /// Catalogue entry key for this matcher
160  pub fn catalogue_entry_key(&self) -> String {
161    if self.is_core() {
162      format!("core/content-matcher/{}", self.catalogue_entry.key)
163    } else {
164      format!("plugin/{}/content-matcher/{}", self.plugin_name(), self.catalogue_entry.key)
165    }
166  }
167
168  /// Plugin name that provides this matcher
169  pub fn plugin_name(&self) -> String {
170    self.catalogue_entry.plugin.as_ref()
171      .map(|p| p.name.clone())
172      .unwrap_or("core".to_string())
173  }
174
175  /// Plugin version that provides this matcher
176  pub fn plugin_version(&self) -> String {
177    self.catalogue_entry.plugin.as_ref()
178      .map(|p| p.version.clone())
179      .unwrap_or_default()
180  }
181
182  /// Get the plugin to configure the interaction contents for the interaction part based on the
183  /// provided definition
184  #[deprecated(note = "Use the version that is spelled correctly")]
185  pub async fn configure_interation(
186    &self,
187    content_type: &ContentType,
188    definition: HashMap<String, Value>
189  ) -> anyhow::Result<(Vec<InteractionContents>, Option<PluginConfiguration>)> {
190    self.configure_interaction(content_type, definition).await
191  }
192
193  /// Get the plugin to configure the interaction contents for the interaction part based on the
194  /// provided definition
195  pub async fn configure_interaction(
196    &self,
197    content_type: &ContentType,
198    definition: HashMap<String, Value>
199  ) -> anyhow::Result<(Vec<InteractionContents>, Option<PluginConfiguration>)> {
200    debug!("Sending ConfigureContents request to plugin {:?}", self.catalogue_entry);
201    let request = ConfigureInteractionRequest {
202      content_type: content_type.to_string(),
203      contents_config: Some(to_proto_struct(&definition)),
204    };
205
206    let plugin_manifest = self.catalogue_entry.plugin.as_ref()
207      .expect("Plugin type is required");
208    match lookup_plugin(&plugin_manifest.as_dependency()) {
209      Some(plugin) => match plugin.configure_interaction(request).await {
210        Ok(response) => {
211          debug!("Got response: {:?}", response);
212          if response.error.is_empty() {
213            let results = Self::build_interaction_contents(&response)?;
214            Ok((results, response.plugin_configuration.map(|config| PluginConfiguration::from(config))))
215          } else {
216            Err(anyhow!("Request to configure interaction failed: {}", response.error))
217          }
218        }
219        Err(err) => {
220          error!("Call to plugin failed - {}", err);
221          Err(anyhow!("Call to plugin failed - {}", err))
222        }
223      },
224      None => {
225        error!("Plugin for {:?} was not found in the plugin register", self.catalogue_entry);
226        Err(anyhow!("Plugin for {:?} was not found in the plugin register", self.catalogue_entry))
227      }
228    }
229  }
230
231  pub(crate) fn build_interaction_contents(
232    response: &ConfigureInteractionResponse
233  ) -> anyhow::Result<Vec<InteractionContents>> {
234    let mut results = vec![];
235
236    for response in &response.interaction {
237      let body = match &response.contents {
238        Some(body) => {
239          let contents = body.content.as_ref().cloned().unwrap_or_default();
240          if contents.is_empty() {
241            OptionalBody::Empty
242          } else {
243            let returned_content_type = ContentType::parse(body.content_type.as_str()).ok();
244            OptionalBody::Present(Bytes::from(contents), returned_content_type,
245                                  Some(match body.content_type_hint() {
246                                    body::ContentTypeHint::Text => ContentTypeHint::TEXT,
247                                    body::ContentTypeHint::Binary => ContentTypeHint::BINARY,
248                                    body::ContentTypeHint::Default => ContentTypeHint::DEFAULT,
249                                  }))
250          }
251        },
252        None => OptionalBody::Missing
253      };
254
255      let rules = Self::setup_matching_rules(&response.rules)?;
256
257      let generators = if !response.generators.is_empty() || !response.metadata_generators.is_empty() {
258        let mut categories = hashmap! {};
259
260        if !response.generators.is_empty() {
261          let mut generators = hashmap! {};
262          for (k, g) in &response.generators {
263            generators.insert(DocPath::new(k)?,
264                              Generator::create(g.r#type.as_str(),
265                                                &g.values.as_ref().map(|attr| proto_struct_to_json(attr)).unwrap_or_default())?);
266          }
267          categories.insert(GeneratorCategory::BODY, generators);
268        }
269
270        if !response.metadata_generators.is_empty() {
271          let mut generators = hashmap! {};
272          for (k, g) in &response.metadata_generators {
273            generators.insert(DocPath::new(k)?,
274                              Generator::create(g.r#type.as_str(),
275                                                &g.values.as_ref().map(|attr| proto_struct_to_json(attr)).unwrap_or_default())?);
276          }
277          categories.insert(GeneratorCategory::METADATA, generators);
278        }
279
280        Some(Generators { categories })
281      } else {
282        None
283      };
284
285      let metadata = response.message_metadata.as_ref().map(|md| proto_struct_to_map(md));
286      let metadata_rules = Self::setup_matching_rules(&response.metadata_rules)?;
287
288      let plugin_config = if let Some(plugin_configuration) = &response.plugin_configuration {
289        PluginConfiguration {
290          interaction_configuration: plugin_configuration.interaction_configuration.as_ref()
291            .map(|val| proto_struct_to_hashmap(val)).unwrap_or_default(),
292          pact_configuration: plugin_configuration.pact_configuration.as_ref()
293            .map(|val| proto_struct_to_hashmap(val)).unwrap_or_default()
294        }
295      } else {
296        PluginConfiguration::default()
297      };
298
299      debug!("body={}", body);
300      debug!("rules={:?}", rules);
301      debug!("generators={:?}", generators);
302      debug!("metadata={:?}", metadata);
303      debug!("metadata_rules={:?}", metadata_rules);
304      debug!("pluginConfig={:?}", plugin_config);
305
306      results.push(InteractionContents {
307        part_name: response.part_name.clone(),
308        body,
309        rules,
310        generators,
311        metadata,
312        metadata_rules,
313        plugin_config,
314        interaction_markup: response.interaction_markup.clone(),
315        interaction_markup_type: match response.interaction_markup_type() {
316          MarkupType::Html => "HTML".to_string(),
317          _ => "COMMON_MARK".to_string(),
318        }
319      })
320    }
321
322    Ok(results)
323  }
324
325  fn setup_matching_rules(rules_map: &HashMap<String, crate::proto::MatchingRules>) -> anyhow::Result<Option<MatchingRuleCategory>> {
326    if !rules_map.is_empty() {
327      let mut rules = hashmap!{};
328      for (k, rule_list) in rules_map {
329        let mut vec = vec![];
330        for rule in &rule_list.rule {
331          let mr = MatchingRule::create(rule.r#type.as_str(), &rule.values.as_ref().map(|rule| {
332            proto_struct_to_json(rule)
333          }).unwrap_or_default())?;
334          vec.push(mr);
335        }
336        rules.insert(DocPath::new(k)?, RuleList {
337          rules: vec,
338          rule_logic: RuleLogic::And,
339          cascaded: false
340        });
341      }
342      Ok(Some(MatchingRuleCategory { name: Category::BODY, rules }))
343    } else {
344      Ok(None)
345    }
346  }
347
348  /// Get the plugin to match the contents against the expected contents returning all the mismatches.
349  /// Note that it is an error to call this with a non-plugin (core) content matcher.
350  ///
351  /// panics:
352  /// If called with a core content matcher
353  pub async fn match_contents(
354    &self,
355    expected: &OptionalBody,
356    actual: &OptionalBody,
357    context: &MatchingRuleCategory,
358    allow_unexpected_keys: bool,
359    plugin_config: Option<PluginInteractionConfig>
360  ) -> Result<(), HashMap<String, Vec<ContentMismatch>>> {
361    let request = CompareContentsRequest {
362      expected: Some(Body {
363        content_type: expected.content_type().unwrap_or_default().to_string(),
364        content: expected.value().map(|b| b.to_vec()),
365        content_type_hint: body::ContentTypeHint::Default as i32
366      }),
367      actual: Some(Body {
368        content_type: actual.content_type().unwrap_or_default().to_string(),
369        content: actual.value().map(|b| b.to_vec()),
370        content_type_hint: body::ContentTypeHint::Default as i32
371      }),
372      allow_unexpected_keys,
373      rules: context.rules.iter().map(|(k, r)| {
374        (k.to_string(), crate::proto::MatchingRules {
375          rule: r.rules.iter().map(|rule|{
376            crate::proto::MatchingRule {
377              r#type: rule.name(),
378              values: Some(to_proto_struct(&rule.values().iter().map(|(k, v)| (k.to_string(), v.clone())).collect())),
379            }
380          }).collect()
381        })
382      }).collect(),
383      plugin_configuration: plugin_config.map(|config| ProtoPluginConfiguration {
384        interaction_configuration: Some(to_proto_struct(&config.interaction_configuration)),
385        pact_configuration: Some(to_proto_struct(&config.pact_configuration))
386      })
387    };
388
389    let plugin_manifest = self.catalogue_entry.plugin.as_ref()
390      .expect("Plugin type is required");
391    match lookup_plugin(&plugin_manifest.as_dependency()) {
392      Some(plugin) => match plugin.compare_contents(request).await {
393        Ok(response) => if let Some(mismatch) = response.type_mismatch {
394          Err(hashmap!{
395            String::default() => vec![
396              ContentMismatch {
397                expected: mismatch.expected.clone(),
398                actual: mismatch.actual.clone(),
399                mismatch: format!("Expected content type '{}' but got '{}'", mismatch.expected, mismatch.actual),
400                path: "".to_string(),
401                diff: None,
402                mismatch_type: None
403              }
404            ]
405          })
406        } else if !response.error.is_empty() {
407          Err(hashmap! {
408            String::default() => vec![
409              ContentMismatch {
410                expected: Default::default(),
411                actual: Default::default(),
412                mismatch: response.error.clone(),
413                path: "".to_string(),
414                diff: None,
415                mismatch_type: None
416              }
417            ]
418          })
419        } else if !response.results.is_empty() {
420          Err(response.results.iter().map(|(k, v)| {
421            (k.clone(), v.mismatches.iter().map(|mismatch| {
422              ContentMismatch {
423                expected: mismatch.expected.as_ref()
424                  .map(|e| from_utf8(&e).unwrap_or_default().to_string())
425                  .unwrap_or_default(),
426                actual: mismatch.actual.as_ref()
427                  .map(|a| from_utf8(&a).unwrap_or_default().to_string())
428                  .unwrap_or_default(),
429                mismatch: mismatch.mismatch.clone(),
430                path: mismatch.path.clone(),
431                diff: if mismatch.diff.is_empty() {
432                  None
433                } else {
434                  Some(mismatch.diff.clone())
435                },
436                mismatch_type: Some(mismatch.mismatch_type.clone())
437              }
438            }).collect())
439          }).collect())
440        } else {
441          Ok(())
442        }
443        Err(err) => {
444          error!("Call to plugin failed - {}", err);
445          Err(hashmap! {
446            String::default() => vec![
447              ContentMismatch {
448                expected: "".to_string(),
449                actual: "".to_string(),
450                mismatch: format!("Call to plugin failed = {}", err),
451                path: "".to_string(),
452                diff: None,
453                mismatch_type: None
454              }
455            ]
456          })
457        }
458      },
459      None => {
460        error!("Plugin for {:?} was not found in the plugin register", self.catalogue_entry);
461        Err(hashmap! {
462          String::default() => vec![
463            ContentMismatch {
464              expected: "".to_string(),
465              actual: "".to_string(),
466              mismatch: format!("Plugin for {:?} was not found in the plugin register", self.catalogue_entry),
467              path: "".to_string(),
468              diff: None,
469              mismatch_type: None
470            }
471          ]
472        })
473      }
474    }
475  }
476}
477
478/// Generator for contents based on content type
479#[derive(Clone, Debug)]
480pub struct ContentGenerator {
481  /// Catalogue entry for this content matcher
482  pub catalogue_entry: CatalogueEntry
483}
484
485impl ContentGenerator {
486  /// If this is a core framework generator
487  pub fn is_core(&self) -> bool {
488    self.catalogue_entry.provider_type == CatalogueEntryProviderType::CORE
489  }
490
491  /// Catalogue entry key for this generator
492  pub fn catalogue_entry_key(&self) -> String {
493    if self.is_core() {
494      format!("core/content-generator/{}", self.catalogue_entry.key)
495    } else {
496      format!("plugin/{}/content-generator/{}", self.plugin_name(), self.catalogue_entry.key)
497    }
498  }
499
500  /// Plugin name that provides this matcher
501  pub fn plugin_name(&self) -> String {
502    self.catalogue_entry.plugin.as_ref()
503      .map(|p| p.name.clone())
504      .unwrap_or("core".to_string())
505  }
506
507  /// Generate the content for the given content type and body
508  pub async fn generate_content(
509    &self,
510    content_type: &ContentType,
511    generators: &HashMap<String, Generator>,
512    body: &OptionalBody,
513    plugin_data: &Vec<PluginData>,
514    interaction_data: &HashMap<String, HashMap<String, Value>>,
515    context: &HashMap<&str, Value>
516  ) -> anyhow::Result<OptionalBody> {
517    let pact_plugin_manifest = self.catalogue_entry.plugin.clone().unwrap_or_default();
518    let plugin_data = plugin_data.iter().find_map(|pd| {
519      if pact_plugin_manifest.name == pd.name {
520        Some(pd.configuration.clone())
521      } else {
522        None
523      }
524    });
525    let interaction_data = interaction_data.get(&pact_plugin_manifest.name);
526
527    let request = GenerateContentRequest {
528      contents: Some(crate::proto::Body {
529        content_type: content_type.to_string(),
530        content: Some(body.value().unwrap_or_default().to_vec()),
531        content_type_hint: body::ContentTypeHint::Default as i32
532      }),
533      generators: generators.iter().map(|(k, v)| {
534        (k.clone(), crate::proto::Generator {
535          r#type: v.name(),
536          values: Some(to_proto_struct(&v.values().iter()
537            .map(|(k, v)| (k.to_string(), v.clone())).collect())),
538        })
539      }).collect(),
540      plugin_configuration: Some(ProtoPluginConfiguration {
541        pact_configuration: plugin_data.as_ref().map(to_proto_struct),
542        interaction_configuration: interaction_data.map(to_proto_struct),
543        .. ProtoPluginConfiguration::default()
544      }),
545      test_context: Some(to_proto_struct(&context.iter().map(|(k, v)| (k.to_string(), v.clone())).collect())),
546      .. GenerateContentRequest::default()
547    };
548
549    let plugin_manifest = self.catalogue_entry.plugin.as_ref()
550      .expect("Plugin type is required");
551    match lookup_plugin(&plugin_manifest.as_dependency()) {
552      Some(plugin) => {
553        debug!("Sending generateContent request to plugin {:?}", plugin_manifest);
554        match plugin.generate_content(request).await?.contents {
555          Some(contents) => {
556            Ok(OptionalBody::Present(
557              Bytes::from(contents.content.unwrap_or_default()),
558              ContentType::parse(contents.content_type.as_str()).ok(),
559              None
560            ))
561          }
562          None => Ok(OptionalBody::Empty)
563        }
564      },
565      None => {
566        error!("Plugin for {:?} was not found in the plugin register", self.catalogue_entry);
567        Err(anyhow!("Plugin for {:?} was not found in the plugin register", self.catalogue_entry))
568      }
569    }
570  }
571}
572
573#[cfg(test)]
574mod tests {
575  use bytes::Bytes;
576  use maplit::btreemap;
577  use pact_models::bodies::OptionalBody;
578  use pact_models::content_types::{ContentType, ContentTypeHint};
579  use pretty_assertions::assert_eq;
580  use prost_types::value::Kind::StringValue;
581  use serde_json::Value;
582
583  use crate::proto::{Body, body, ConfigureInteractionResponse, InteractionResponse};
584
585  use super::{ContentMatcher, InteractionContents};
586
587  // Issue https://github.com/YOU54F/pact-ruby-ffi/issues/6
588  #[test_log::test]
589  fn build_interaction_contents_deals_with_empty_contents() {
590    let response = ConfigureInteractionResponse {
591      interaction: vec![
592        InteractionResponse {
593          contents: Some(Body {
594            content_type: "application/protobuf; message=.area_calculator.ShapeMessage".to_string(),
595            content: Some(b"\x12\n\r\0\0@@\x15\0\0\x80@".to_vec()),
596            content_type_hint: body::ContentTypeHint::Binary.into()
597          }),
598          message_metadata: Some(prost_types::Struct {
599            fields: btreemap!{
600              "contentType".to_string() => prost_types::Value {
601                kind: Some(StringValue("application/protobuf;message=.area_calculator.ShapeMessage".to_string()))
602              }
603            }
604          }),
605          part_name: "request".to_string(),
606          .. InteractionResponse::default()
607        },
608        InteractionResponse {
609          contents: Some(Body {
610            content_type: "application/protobuf; message=.area_calculator.AreaResponse".to_string(),
611            content: Some(vec![]),
612            content_type_hint: body::ContentTypeHint::Binary.into()
613          }),
614          message_metadata: Some(prost_types::Struct {
615            fields: btreemap!{
616              "grpc-message".to_string() => prost_types::Value {
617                kind: Some(StringValue("Not implemented".to_string()))
618              },
619              "grpc-status".to_string() => prost_types::Value {
620                kind: Some(StringValue("UNIMPLEMENTED".to_string()))
621              },
622              "contentType".to_string() => prost_types::Value {
623                kind: Some(StringValue("application/protobuf;message=.area_calculator.AreaResponse".to_string()))
624              }
625            }
626          }),
627          part_name: "response".to_string(),
628          .. InteractionResponse::default()
629        }
630      ],
631      .. ConfigureInteractionResponse::default()
632    };
633    let result = ContentMatcher::build_interaction_contents(&response).unwrap();
634
635    assert_eq!(result, vec![
636      InteractionContents {
637        part_name: "request".to_string(),
638        interaction_markup_type: "COMMON_MARK".to_string(),
639        body: OptionalBody::Present(Bytes::from(b"\x12\n\r\0\0@@\x15\0\0\x80@".to_vec()),
640          Some(ContentType::parse("application/protobuf;message=.area_calculator.ShapeMessage").unwrap()),
641          Some(ContentTypeHint::BINARY)),
642        metadata: Some(btreemap!{
643          "contentType".to_string() => Value::String("application/protobuf;message=.area_calculator.ShapeMessage".to_string())
644        }),
645        .. InteractionContents::default()
646      },
647
648      InteractionContents {
649        part_name: "response".to_string(),
650        interaction_markup_type: "COMMON_MARK".to_string(),
651        body: OptionalBody::Empty,
652        metadata: Some(btreemap!{
653          "grpc-status".to_string() => Value::String("UNIMPLEMENTED".to_string()),
654          "grpc-message".to_string() => Value::String("Not implemented".to_string()),
655          "contentType".to_string() => Value::String("application/protobuf;message=.area_calculator.AreaResponse".to_string())
656        }),
657        .. InteractionContents::default()
658      }
659    ]);
660  }
661}