1use 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#[derive(Clone, Debug)]
34pub struct ContentMatcher {
35 pub catalogue_entry: CatalogueEntry
37}
38
39impl ContentMatcher {
40 pub fn plugin(&self) -> Option<PactPluginManifest> {
42 self.catalogue_entry.plugin.clone()
43 }
44}
45
46#[derive(Clone, Debug)]
48pub struct ContentMismatch {
49 pub expected: String,
52 pub actual: String,
55 pub mismatch: String,
57 pub path: String,
59 pub diff: Option<String>,
61 pub mismatch_type: Option<String>
63}
64
65#[derive(Clone, Debug, PartialEq)]
67pub struct InteractionContents {
68 pub part_name: String,
71
72 pub body: OptionalBody,
74
75 pub rules: Option<MatchingRuleCategory>,
77
78 pub generators: Option<Generators>,
80
81 pub metadata: Option<BTreeMap<String, Value>>,
83
84 pub metadata_rules: Option<MatchingRuleCategory>,
86
87 pub plugin_config: PluginConfiguration,
89
90 pub interaction_markup: String,
92
93 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#[derive(Clone, Debug, PartialEq)]
115pub struct PluginConfiguration {
116 pub interaction_configuration: HashMap<String, Value>,
118 pub pact_configuration: HashMap<String, Value>
120}
121
122impl PluginConfiguration {
123 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 pub fn is_core(&self) -> bool {
156 self.catalogue_entry.provider_type == CatalogueEntryProviderType::CORE
157 }
158
159 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 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 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 #[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 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 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#[derive(Clone, Debug)]
480pub struct ContentGenerator {
481 pub catalogue_entry: CatalogueEntry
483}
484
485impl ContentGenerator {
486 pub fn is_core(&self) -> bool {
488 self.catalogue_entry.provider_type == CatalogueEntryProviderType::CORE
489 }
490
491 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 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 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 #[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}