Skip to main content

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::core_capabilities;
19use crate::plugin_manager::lookup_plugin;
20use crate::plugin_models::{PactPluginManifest, PluginInteractionConfig};
21use crate::proto::{
22  Body,
23  CompareContentsRequest,
24  ConfigureInteractionRequest,
25  ConfigureInteractionResponse,
26  GenerateContentRequest,
27  PluginConfiguration as ProtoPluginConfiguration
28};
29use crate::proto::body;
30use crate::proto::interaction_response::MarkupType;
31use crate::utils::{proto_struct_to_hashmap, proto_struct_to_json, proto_struct_to_map, to_proto_struct};
32
33/// Matcher for contents based on content type
34#[derive(Clone, Debug)]
35pub struct ContentMatcher {
36  /// Catalogue entry for this content matcher
37  pub catalogue_entry: CatalogueEntry
38}
39
40impl ContentMatcher {
41  /// Plugin details for this content matcher
42  pub fn plugin(&self) -> Option<PactPluginManifest> {
43    self.catalogue_entry.plugin.clone()
44  }
45}
46
47/// Mismatch result
48#[derive(Clone, Debug)]
49pub struct ContentMismatch {
50  /// Expected value in string format
51  // TODO: change to bytes
52  pub expected: String,
53  /// Actual value in string format
54  // TODO: change to bytes
55  pub actual: String,
56  /// Mismatch description
57  pub mismatch: String,
58  /// Path to the mismatch
59  pub path: String,
60  /// Optional diff of the expected and actual values
61  pub diff: Option<String>,
62  /// The type of item that the mismatch is for
63  pub mismatch_type: Option<String>
64}
65
66/// Interaction contents setup by the plugin
67#[derive(Clone, Debug, PartialEq)]
68pub struct InteractionContents {
69  /// Description of what part this interaction belongs to (in the case of there being more than
70  /// one, for instance, request/response messages)
71  pub part_name: String,
72
73  /// Body/Contents of the interaction
74  pub body: OptionalBody,
75
76  /// Matching rules to apply
77  pub rules: Option<MatchingRuleCategory>,
78
79  /// Generators to apply
80  pub generators: Option<Generators>,
81
82  /// Message metadata
83  pub metadata: Option<BTreeMap<String, Value>>,
84
85  /// Matching rules to apply to message metadata
86  pub metadata_rules: Option<MatchingRuleCategory>,
87
88  /// Plugin configuration data to apply to the interaction
89  pub plugin_config: PluginConfiguration,
90
91  /// Markup for the interaction to display in any UI
92  pub interaction_markup: String,
93
94  /// The type of the markup (CommonMark or HTML)
95  pub interaction_markup_type: String
96}
97
98impl Default for InteractionContents {
99  fn default() -> Self {
100    InteractionContents {
101      part_name: Default::default(),
102      body: Default::default(),
103      rules: None,
104      generators: None,
105      metadata: None,
106      metadata_rules: None,
107      plugin_config: Default::default(),
108      interaction_markup: Default::default(),
109      interaction_markup_type: Default::default()
110    }
111  }
112}
113
114/// Plugin data to persist into the Pact file
115#[derive(Clone, Debug, PartialEq)]
116pub struct PluginConfiguration {
117  /// Data to perist on the interaction
118  pub interaction_configuration: HashMap<String, Value>,
119  /// Data to persist in the Pact metadata
120  pub pact_configuration: HashMap<String, Value>
121}
122
123impl PluginConfiguration {
124  /// Plugin data is empty when the interaction and pact data is empty
125  pub fn is_empty(&self) -> bool {
126    self.pact_configuration.is_empty() && self.interaction_configuration.is_empty()
127  }
128}
129
130impl Default for PluginConfiguration {
131  fn default() -> Self {
132    PluginConfiguration {
133      interaction_configuration: Default::default(),
134      pact_configuration: Default::default()
135    }
136  }
137}
138
139impl From<ProtoPluginConfiguration> for PluginConfiguration {
140  fn from(config: ProtoPluginConfiguration) -> Self {
141    PluginConfiguration {
142      interaction_configuration: config.interaction_configuration
143        .as_ref()
144        .map(|c| proto_struct_to_hashmap(c))
145        .unwrap_or_default(),
146      pact_configuration: config.pact_configuration
147        .as_ref()
148        .map(|c| proto_struct_to_hashmap(c))
149        .unwrap_or_default()
150    }
151  }
152}
153
154impl ContentMatcher {
155  /// If this is a core framework matcher
156  pub fn is_core(&self) -> bool {
157    self.catalogue_entry.provider_type == CatalogueEntryProviderType::CORE
158  }
159
160  /// Catalogue entry key for this matcher
161  pub fn catalogue_entry_key(&self) -> String {
162    if self.is_core() {
163      format!("core/content-matcher/{}", self.catalogue_entry.key)
164    } else {
165      format!("plugin/{}/content-matcher/{}", self.plugin_name(), self.catalogue_entry.key)
166    }
167  }
168
169  /// Plugin name that provides this matcher
170  pub fn plugin_name(&self) -> String {
171    self.catalogue_entry.plugin.as_ref()
172      .map(|p| p.name.clone())
173      .unwrap_or("core".to_string())
174  }
175
176  /// Plugin version that provides this matcher
177  pub fn plugin_version(&self) -> String {
178    self.catalogue_entry.plugin.as_ref()
179      .map(|p| p.version.clone())
180      .unwrap_or_default()
181  }
182
183  /// Get the plugin to configure the interaction contents for the interaction part based on the
184  /// provided definition
185  #[deprecated(note = "Use the version that is spelled correctly")]
186  pub async fn configure_interation(
187    &self,
188    content_type: &ContentType,
189    definition: HashMap<String, Value>
190  ) -> anyhow::Result<(Vec<InteractionContents>, Option<PluginConfiguration>)> {
191    self.configure_interaction(content_type, definition).await
192  }
193
194  /// Get the plugin to configure the interaction contents for the interaction part based on the
195  /// provided definition
196  pub async fn configure_interaction(
197    &self,
198    content_type: &ContentType,
199    definition: HashMap<String, Value>
200  ) -> anyhow::Result<(Vec<InteractionContents>, Option<PluginConfiguration>)> {
201    debug!("Sending ConfigureContents request to plugin {:?}", self.catalogue_entry);
202    let request = ConfigureInteractionRequest {
203      content_type: content_type.to_string(),
204      contents_config: Some(to_proto_struct(&definition)),
205    };
206
207    let plugin_manifest = self.catalogue_entry.plugin.as_ref()
208      .expect("Plugin type is required");
209    match lookup_plugin(&plugin_manifest.as_dependency()) {
210      Some(plugin) => match plugin.configure_interaction(request).await {
211        Ok(response) => {
212          debug!("Got response: {:?}", response);
213          if response.error.is_empty() {
214            let results = Self::build_interaction_contents(&response)?;
215            Ok((results, response.plugin_configuration.map(|config| PluginConfiguration::from(config))))
216          } else {
217            Err(anyhow!("Request to configure interaction failed: {}", response.error))
218          }
219        }
220        Err(err) => {
221          error!("Call to plugin failed - {}", err);
222          Err(anyhow!("Call to plugin failed - {}", err))
223        }
224      },
225      None => {
226        error!("Plugin for {:?} was not found in the plugin register", self.catalogue_entry);
227        Err(anyhow!("Plugin for {:?} was not found in the plugin register", self.catalogue_entry))
228      }
229    }
230  }
231
232  pub(crate) fn build_interaction_contents(
233    response: &ConfigureInteractionResponse
234  ) -> anyhow::Result<Vec<InteractionContents>> {
235    let mut results = vec![];
236
237    for response in &response.interaction {
238      let body = match &response.contents {
239        Some(body) => {
240          let contents = body.content.as_ref().cloned().unwrap_or_default();
241          if contents.is_empty() {
242            OptionalBody::Empty
243          } else {
244            let returned_content_type = ContentType::parse(body.content_type.as_str()).ok();
245            OptionalBody::Present(Bytes::from(contents), returned_content_type,
246                                  Some(match body.content_type_hint() {
247                                    body::ContentTypeHint::Text => ContentTypeHint::TEXT,
248                                    body::ContentTypeHint::Binary => ContentTypeHint::BINARY,
249                                    body::ContentTypeHint::Default => ContentTypeHint::DEFAULT,
250                                  }))
251          }
252        },
253        None => OptionalBody::Missing
254      };
255
256      let rules = Self::setup_matching_rules(&response.rules)?;
257
258      let generators = if !response.generators.is_empty() || !response.metadata_generators.is_empty() {
259        let mut categories = hashmap! {};
260
261        if !response.generators.is_empty() {
262          let mut generators = hashmap! {};
263          for (k, g) in &response.generators {
264            generators.insert(DocPath::new(k)?,
265                              Generator::create(g.r#type.as_str(),
266                                                &g.values.as_ref().map(|attr| proto_struct_to_json(attr)).unwrap_or_default())?);
267          }
268          categories.insert(GeneratorCategory::BODY, generators);
269        }
270
271        if !response.metadata_generators.is_empty() {
272          let mut generators = hashmap! {};
273          for (k, g) in &response.metadata_generators {
274            generators.insert(DocPath::new(k)?,
275                              Generator::create(g.r#type.as_str(),
276                                                &g.values.as_ref().map(|attr| proto_struct_to_json(attr)).unwrap_or_default())?);
277          }
278          categories.insert(GeneratorCategory::METADATA, generators);
279        }
280
281        Some(Generators { categories })
282      } else {
283        None
284      };
285
286      let metadata = response.message_metadata.as_ref().map(|md| proto_struct_to_map(md));
287      let metadata_rules = Self::setup_matching_rules(&response.metadata_rules)?;
288
289      let plugin_config = if let Some(plugin_configuration) = &response.plugin_configuration {
290        PluginConfiguration {
291          interaction_configuration: plugin_configuration.interaction_configuration.as_ref()
292            .map(|val| proto_struct_to_hashmap(val)).unwrap_or_default(),
293          pact_configuration: plugin_configuration.pact_configuration.as_ref()
294            .map(|val| proto_struct_to_hashmap(val)).unwrap_or_default()
295        }
296      } else {
297        PluginConfiguration::default()
298      };
299
300      debug!("body={}", body);
301      debug!("rules={:?}", rules);
302      debug!("generators={:?}", generators);
303      debug!("metadata={:?}", metadata);
304      debug!("metadata_rules={:?}", metadata_rules);
305      debug!("pluginConfig={:?}", plugin_config);
306
307      results.push(InteractionContents {
308        part_name: response.part_name.clone(),
309        body,
310        rules,
311        generators,
312        metadata,
313        metadata_rules,
314        plugin_config,
315        interaction_markup: response.interaction_markup.clone(),
316        interaction_markup_type: match response.interaction_markup_type() {
317          MarkupType::Html => "HTML".to_string(),
318          _ => "COMMON_MARK".to_string(),
319        }
320      })
321    }
322
323    Ok(results)
324  }
325
326  fn setup_matching_rules(rules_map: &HashMap<String, crate::proto::MatchingRules>) -> anyhow::Result<Option<MatchingRuleCategory>> {
327    if !rules_map.is_empty() {
328      let mut rules = hashmap!{};
329      for (k, rule_list) in rules_map {
330        let mut vec = vec![];
331        for rule in &rule_list.rule {
332          let mr = MatchingRule::create(rule.r#type.as_str(), &rule.values.as_ref().map(|rule| {
333            proto_struct_to_json(rule)
334          }).unwrap_or_default())?;
335          vec.push(mr);
336        }
337        rules.insert(DocPath::new(k)?, RuleList {
338          rules: vec,
339          rule_logic: RuleLogic::And,
340          cascaded: false
341        });
342      }
343      Ok(Some(MatchingRuleCategory { name: Category::BODY, rules }))
344    } else {
345      Ok(None)
346    }
347  }
348
349  /// Get the plugin to match the contents against the expected contents returning all the mismatches.
350  /// Note that it is an error to call this with a non-plugin (core) content matcher.
351  ///
352  /// panics:
353  /// If called with a core content matcher
354  pub async fn match_contents(
355    &self,
356    expected: &OptionalBody,
357    actual: &OptionalBody,
358    context: &MatchingRuleCategory,
359    allow_unexpected_keys: bool,
360    plugin_config: Option<PluginInteractionConfig>
361  ) -> Result<(), HashMap<String, Vec<ContentMismatch>>> {
362    let request = CompareContentsRequest {
363      expected: Some(Body {
364        content_type: expected.content_type().unwrap_or_default().to_string(),
365        content: expected.value().map(|b| b.to_vec()),
366        content_type_hint: body::ContentTypeHint::Default as i32
367      }),
368      actual: Some(Body {
369        content_type: actual.content_type().unwrap_or_default().to_string(),
370        content: actual.value().map(|b| b.to_vec()),
371        content_type_hint: body::ContentTypeHint::Default as i32
372      }),
373      allow_unexpected_keys,
374      rules: context.rules.iter().map(|(k, r)| {
375        (k.to_string(), crate::proto::MatchingRules {
376          rule: r.rules.iter().map(|rule|{
377            crate::proto::MatchingRule {
378              r#type: rule.name(),
379              values: Some(to_proto_struct(&rule.values().iter().map(|(k, v)| (k.to_string(), v.clone())).collect())),
380            }
381          }).collect()
382        })
383      }).collect(),
384      plugin_configuration: plugin_config.map(|config| ProtoPluginConfiguration {
385        interaction_configuration: Some(to_proto_struct(&config.interaction_configuration)),
386        pact_configuration: Some(to_proto_struct(&config.pact_configuration))
387      })
388    };
389
390    if self.is_core() {
391      return match core_capabilities::lookup_core_content_matcher(&self.catalogue_entry.key) {
392        Some(handler) => Self::process_compare_contents_response(handler.compare_contents(request).await),
393        None => {
394          error!("No core content matcher registered for {:?}", self.catalogue_entry);
395          Err(hashmap! {
396            String::default() => vec![
397              ContentMismatch {
398                expected: "".to_string(),
399                actual: "".to_string(),
400                mismatch: format!("No core content matcher registered for {:?}", self.catalogue_entry),
401                path: "".to_string(),
402                diff: None,
403                mismatch_type: None
404              }
405            ]
406          })
407        }
408      };
409    }
410
411    let plugin_manifest = self.catalogue_entry.plugin.as_ref()
412      .expect("Plugin type is required");
413    match lookup_plugin(&plugin_manifest.as_dependency()) {
414      Some(plugin) => {
415        let chain_id = crate::call_chain::new_call_chain_id();
416        let deadline_ms = crate::call_chain::default_deadline_ms();
417        Self::process_compare_contents_response(
418          plugin.compare_contents_with_chain(request, &chain_id, deadline_ms).await
419        )
420      }
421      None => {
422        error!("Plugin for {:?} was not found in the plugin register", self.catalogue_entry);
423        Err(hashmap! {
424          String::default() => vec![
425            ContentMismatch {
426              expected: "".to_string(),
427              actual: "".to_string(),
428              mismatch: format!("Plugin for {:?} was not found in the plugin register", self.catalogue_entry),
429              path: "".to_string(),
430              diff: None,
431              mismatch_type: None
432            }
433          ]
434        })
435      }
436    }
437  }
438
439  /// Convert the result of a CompareContents call (from either a core handler or a plugin) into
440  /// the mismatch map result shape shared by both callers of `match_contents`.
441  fn process_compare_contents_response(
442    response: anyhow::Result<crate::proto::CompareContentsResponse>
443  ) -> Result<(), HashMap<String, Vec<ContentMismatch>>> {
444    match response {
445      Ok(response) => if let Some(mismatch) = response.type_mismatch {
446        Err(hashmap!{
447          String::default() => vec![
448            ContentMismatch {
449              expected: mismatch.expected.clone(),
450              actual: mismatch.actual.clone(),
451              mismatch: format!("Expected content type '{}' but got '{}'", mismatch.expected, mismatch.actual),
452              path: "".to_string(),
453              diff: None,
454              mismatch_type: None
455            }
456          ]
457        })
458      } else if !response.error.is_empty() {
459        Err(hashmap! {
460          String::default() => vec![
461            ContentMismatch {
462              expected: Default::default(),
463              actual: Default::default(),
464              mismatch: response.error.clone(),
465              path: "".to_string(),
466              diff: None,
467              mismatch_type: None
468            }
469          ]
470        })
471      } else if !response.results.is_empty() {
472        Err(response.results.iter().map(|(k, v)| {
473          (k.clone(), v.mismatches.iter().map(|mismatch| {
474            ContentMismatch {
475              expected: mismatch.expected.as_ref()
476                .map(|e| from_utf8(e).unwrap_or_default().to_string())
477                .unwrap_or_default(),
478              actual: mismatch.actual.as_ref()
479                .map(|a| from_utf8(a).unwrap_or_default().to_string())
480                .unwrap_or_default(),
481              mismatch: mismatch.mismatch.clone(),
482              path: mismatch.path.clone(),
483              diff: if mismatch.diff.is_empty() {
484                None
485              } else {
486                Some(mismatch.diff.clone())
487              },
488              mismatch_type: Some(mismatch.mismatch_type.clone())
489            }
490          }).collect())
491        }).collect())
492      } else {
493        Ok(())
494      }
495      Err(err) => {
496        error!("Call to compare contents handler failed - {}", err);
497        Err(hashmap! {
498          String::default() => vec![
499            ContentMismatch {
500              expected: "".to_string(),
501              actual: "".to_string(),
502              mismatch: format!("Call to compare contents handler failed = {}", err),
503              path: "".to_string(),
504              diff: None,
505              mismatch_type: None
506            }
507          ]
508        })
509      }
510    }
511  }
512}
513
514/// Generator for contents based on content type
515#[derive(Clone, Debug)]
516pub struct ContentGenerator {
517  /// Catalogue entry for this content matcher
518  pub catalogue_entry: CatalogueEntry
519}
520
521impl ContentGenerator {
522  /// If this is a core framework generator
523  pub fn is_core(&self) -> bool {
524    self.catalogue_entry.provider_type == CatalogueEntryProviderType::CORE
525  }
526
527  /// Catalogue entry key for this generator
528  pub fn catalogue_entry_key(&self) -> String {
529    if self.is_core() {
530      format!("core/content-generator/{}", self.catalogue_entry.key)
531    } else {
532      format!("plugin/{}/content-generator/{}", self.plugin_name(), self.catalogue_entry.key)
533    }
534  }
535
536  /// Plugin name that provides this matcher
537  pub fn plugin_name(&self) -> String {
538    self.catalogue_entry.plugin.as_ref()
539      .map(|p| p.name.clone())
540      .unwrap_or("core".to_string())
541  }
542
543  /// Generate the content for the given content type and body
544  pub async fn generate_content(
545    &self,
546    content_type: &ContentType,
547    generators: &HashMap<String, Generator>,
548    body: &OptionalBody,
549    plugin_data: &Vec<PluginData>,
550    interaction_data: &HashMap<String, HashMap<String, Value>>,
551    context: &HashMap<&str, Value>
552  ) -> anyhow::Result<OptionalBody> {
553    let pact_plugin_manifest = self.catalogue_entry.plugin.clone().unwrap_or_default();
554    let plugin_data = plugin_data.iter().find_map(|pd| {
555      if pact_plugin_manifest.name == pd.name {
556        Some(pd.configuration.clone())
557      } else {
558        None
559      }
560    });
561    let interaction_data = interaction_data.get(&pact_plugin_manifest.name);
562
563    let request = GenerateContentRequest {
564      contents: Some(crate::proto::Body {
565        content_type: content_type.to_string(),
566        content: Some(body.value().unwrap_or_default().to_vec()),
567        content_type_hint: body::ContentTypeHint::Default as i32
568      }),
569      generators: generators.iter().map(|(k, v)| {
570        (k.clone(), crate::proto::Generator {
571          r#type: v.name(),
572          values: Some(to_proto_struct(&v.values().iter()
573            .map(|(k, v)| (k.to_string(), v.clone())).collect())),
574        })
575      }).collect(),
576      plugin_configuration: Some(ProtoPluginConfiguration {
577        pact_configuration: plugin_data.as_ref().map(to_proto_struct),
578        interaction_configuration: interaction_data.map(to_proto_struct),
579        .. ProtoPluginConfiguration::default()
580      }),
581      test_context: Some(to_proto_struct(&context.iter().map(|(k, v)| (k.to_string(), v.clone())).collect())),
582      .. GenerateContentRequest::default()
583    };
584
585    if self.is_core() {
586      let handler = core_capabilities::lookup_core_content_generator(&self.catalogue_entry.key)
587        .ok_or_else(|| anyhow!("No core content generator registered for {:?}", self.catalogue_entry))?;
588      debug!("Calling core content generator for {:?}", self.catalogue_entry);
589      return Self::response_to_body(handler.generate_content(request).await?.contents);
590    }
591
592    let plugin_manifest = self.catalogue_entry.plugin.as_ref()
593      .expect("Plugin type is required");
594    match lookup_plugin(&plugin_manifest.as_dependency()) {
595      Some(plugin) => {
596        debug!("Sending generateContent request to plugin {:?}", plugin_manifest);
597        let chain_id = crate::call_chain::new_call_chain_id();
598        let deadline_ms = crate::call_chain::default_deadline_ms();
599        Self::response_to_body(plugin.generate_content_with_chain(request, &chain_id, deadline_ms).await?.contents)
600      },
601      None => {
602        error!("Plugin for {:?} was not found in the plugin register", self.catalogue_entry);
603        Err(anyhow!("Plugin for {:?} was not found in the plugin register", self.catalogue_entry))
604      }
605    }
606  }
607
608  /// Convert a returned `Body` (from either a core handler or a plugin) into an `OptionalBody`,
609  /// the shape shared by both callers of `generate_content`.
610  fn response_to_body(contents: Option<crate::proto::Body>) -> anyhow::Result<OptionalBody> {
611    match contents {
612      Some(contents) => Ok(OptionalBody::Present(
613        Bytes::from(contents.content.unwrap_or_default()),
614        ContentType::parse(contents.content_type.as_str()).ok(),
615        None
616      )),
617      None => Ok(OptionalBody::Empty)
618    }
619  }
620}
621
622#[cfg(test)]
623mod tests {
624  use std::collections::HashMap;
625  use std::sync::Arc;
626
627  use async_trait::async_trait;
628  use bytes::Bytes;
629  use maplit::btreemap;
630  use pact_models::bodies::OptionalBody;
631  use pact_models::content_types::{ContentType, ContentTypeHint};
632  use pact_models::matchingrules::{Category, MatchingRuleCategory};
633  use pretty_assertions::assert_eq;
634  use prost_types::value::Kind::StringValue;
635  use serde_json::Value;
636
637  use crate::catalogue_manager::{CatalogueEntry, CatalogueEntryProviderType, CatalogueEntryType};
638  use crate::core_capabilities::{self, CoreContentGenerator, CoreContentMatcher};
639  use crate::proto::{
640    Body, body, CompareContentsRequest, CompareContentsResponse, ConfigureInteractionResponse,
641    GenerateContentRequest, GenerateContentResponse, InteractionResponse
642  };
643
644  use super::{ContentGenerator, ContentMatcher, InteractionContents};
645
646  // Issue https://github.com/YOU54F/pact-ruby-ffi/issues/6
647  #[test_log::test]
648  fn build_interaction_contents_deals_with_empty_contents() {
649    let response = ConfigureInteractionResponse {
650      interaction: vec![
651        InteractionResponse {
652          contents: Some(Body {
653            content_type: "application/protobuf; message=.area_calculator.ShapeMessage".to_string(),
654            content: Some(b"\x12\n\r\0\0@@\x15\0\0\x80@".to_vec()),
655            content_type_hint: body::ContentTypeHint::Binary.into()
656          }),
657          message_metadata: Some(prost_types::Struct {
658            fields: btreemap!{
659              "contentType".to_string() => prost_types::Value {
660                kind: Some(StringValue("application/protobuf;message=.area_calculator.ShapeMessage".to_string()))
661              }
662            }
663          }),
664          part_name: "request".to_string(),
665          .. InteractionResponse::default()
666        },
667        InteractionResponse {
668          contents: Some(Body {
669            content_type: "application/protobuf; message=.area_calculator.AreaResponse".to_string(),
670            content: Some(vec![]),
671            content_type_hint: body::ContentTypeHint::Binary.into()
672          }),
673          message_metadata: Some(prost_types::Struct {
674            fields: btreemap!{
675              "grpc-message".to_string() => prost_types::Value {
676                kind: Some(StringValue("Not implemented".to_string()))
677              },
678              "grpc-status".to_string() => prost_types::Value {
679                kind: Some(StringValue("UNIMPLEMENTED".to_string()))
680              },
681              "contentType".to_string() => prost_types::Value {
682                kind: Some(StringValue("application/protobuf;message=.area_calculator.AreaResponse".to_string()))
683              }
684            }
685          }),
686          part_name: "response".to_string(),
687          .. InteractionResponse::default()
688        }
689      ],
690      .. ConfigureInteractionResponse::default()
691    };
692    let result = ContentMatcher::build_interaction_contents(&response).unwrap();
693
694    assert_eq!(result, vec![
695      InteractionContents {
696        part_name: "request".to_string(),
697        interaction_markup_type: "COMMON_MARK".to_string(),
698        body: OptionalBody::Present(Bytes::from(b"\x12\n\r\0\0@@\x15\0\0\x80@".to_vec()),
699          Some(ContentType::parse("application/protobuf;message=.area_calculator.ShapeMessage").unwrap()),
700          Some(ContentTypeHint::BINARY)),
701        metadata: Some(btreemap!{
702          "contentType".to_string() => Value::String("application/protobuf;message=.area_calculator.ShapeMessage".to_string())
703        }),
704        .. InteractionContents::default()
705      },
706
707      InteractionContents {
708        part_name: "response".to_string(),
709        interaction_markup_type: "COMMON_MARK".to_string(),
710        body: OptionalBody::Empty,
711        metadata: Some(btreemap!{
712          "grpc-status".to_string() => Value::String("UNIMPLEMENTED".to_string()),
713          "grpc-message".to_string() => Value::String("Not implemented".to_string()),
714          "contentType".to_string() => Value::String("application/protobuf;message=.area_calculator.AreaResponse".to_string())
715        }),
716        .. InteractionContents::default()
717      }
718    ]);
719  }
720
721  struct SuccessfulCoreMatcher;
722
723  #[async_trait]
724  impl CoreContentMatcher for SuccessfulCoreMatcher {
725    async fn compare_contents(&self, _request: CompareContentsRequest) -> anyhow::Result<CompareContentsResponse> {
726      Ok(CompareContentsResponse::default())
727    }
728  }
729
730  fn core_content_matcher(key: &str) -> ContentMatcher {
731    ContentMatcher {
732      catalogue_entry: CatalogueEntry {
733        entry_type: CatalogueEntryType::CONTENT_MATCHER,
734        provider_type: CatalogueEntryProviderType::CORE,
735        plugin: None,
736        key: key.to_string(),
737        values: Default::default()
738      }
739    }
740  }
741
742  #[test_log::test(tokio::test)]
743  async fn match_contents_calls_the_registered_core_handler_for_a_core_catalogue_entry() {
744    let key = "match_contents_calls_the_registered_core_handler_for_a_core_catalogue_entry";
745    core_capabilities::register_core_content_matcher(key, Arc::new(SuccessfulCoreMatcher));
746
747    let result = core_content_matcher(key).match_contents(
748      &OptionalBody::Present(Bytes::from("expected"), None, None),
749      &OptionalBody::Present(Bytes::from("actual"), None, None),
750      &MatchingRuleCategory { name: Category::BODY, rules: Default::default() },
751      true,
752      None
753    ).await;
754
755    core_capabilities::deregister_core_content_matcher(key);
756
757    assert!(result.is_ok());
758  }
759
760  #[test_log::test(tokio::test)]
761  async fn match_contents_returns_a_clear_error_when_no_core_handler_is_registered() {
762    let key = "match_contents_returns_a_clear_error_when_no_core_handler_is_registered";
763
764    let result = core_content_matcher(key).match_contents(
765      &OptionalBody::Present(Bytes::from("expected"), None, None),
766      &OptionalBody::Present(Bytes::from("actual"), None, None),
767      &MatchingRuleCategory { name: Category::BODY, rules: Default::default() },
768      true,
769      None
770    ).await;
771
772    let mismatches = result.expect_err("expected an error when no core handler is registered");
773    let messages: Vec<String> = mismatches.values().flatten().map(|m| m.mismatch.clone()).collect();
774    assert!(
775      messages.iter().any(|m| m.contains("No core content matcher registered")),
776      "expected a 'No core content matcher registered' mismatch, got: {:?}", messages
777    );
778  }
779
780  struct FixedCoreGenerator;
781
782  #[async_trait]
783  impl CoreContentGenerator for FixedCoreGenerator {
784    async fn generate_content(&self, _request: GenerateContentRequest) -> anyhow::Result<GenerateContentResponse> {
785      Ok(GenerateContentResponse {
786        contents: Some(Body {
787          content_type: "text/plain".to_string(),
788          content: Some(b"generated".to_vec()),
789          content_type_hint: body::ContentTypeHint::Default as i32
790        })
791      })
792    }
793  }
794
795  fn core_content_generator(key: &str) -> ContentGenerator {
796    ContentGenerator {
797      catalogue_entry: CatalogueEntry {
798        entry_type: CatalogueEntryType::CONTENT_GENERATOR,
799        provider_type: CatalogueEntryProviderType::CORE,
800        plugin: None,
801        key: key.to_string(),
802        values: Default::default()
803      }
804    }
805  }
806
807  #[test_log::test(tokio::test)]
808  async fn generate_content_calls_the_registered_core_handler_for_a_core_catalogue_entry() {
809    let key = "generate_content_calls_the_registered_core_handler_for_a_core_catalogue_entry";
810    core_capabilities::register_core_content_generator(key, Arc::new(FixedCoreGenerator));
811
812    let result = core_content_generator(key).generate_content(
813      &ContentType::parse("text/plain").unwrap(),
814      &HashMap::new(),
815      &OptionalBody::Empty,
816      &vec![],
817      &HashMap::new(),
818      &HashMap::new()
819    ).await;
820
821    core_capabilities::deregister_core_content_generator(key);
822
823    let body = result.expect("expected the core generator's content to be returned");
824    assert_eq!(body, OptionalBody::Present(Bytes::from("generated"), ContentType::parse("text/plain").ok(), None));
825  }
826
827  #[test_log::test(tokio::test)]
828  async fn generate_content_returns_a_clear_error_when_no_core_handler_is_registered() {
829    let key = "generate_content_returns_a_clear_error_when_no_core_handler_is_registered";
830
831    let result = core_content_generator(key).generate_content(
832      &ContentType::parse("text/plain").unwrap(),
833      &HashMap::new(),
834      &OptionalBody::Empty,
835      &vec![],
836      &HashMap::new(),
837      &HashMap::new()
838    ).await;
839
840    let err = result.expect_err("expected an error when no core handler is registered");
841    assert!(
842      err.to_string().contains("No core content generator registered"),
843      "expected a 'No core content generator registered' error, got: {}", err
844    );
845  }
846}