diff --git a/README.md b/README.md index 5038d43..9416e35 100644 --- a/README.md +++ b/README.md @@ -580,6 +580,18 @@ There is a Pact test that verifies the plugin aqainst the Pact file published to Running this test requires a Pactflow API token and the plugin to be built and installed. See the installation instructions above. The test is run using `cargo test --test pact_verify`. +### Developer notes +#### Base64-encoded strings in unit-tests +Several unit-tests have base64 encoded proto descriptors as input. They're hard to read and update. +To decode and check out the contents: +- save the encoded string into a file, e.g. `/tmp/some.b64` and then `base64 -d /tmp/some.b64 | protoc --decode_raw` +- this will be somewhat readable, even if not a proper proto3 syntax, but it will be possible to reconstruct it. +- github copilot can decode the raw base64 string easily too + +To encode a .proto file into a base64-encoded FileDescriptorSet, +e.g. if you modified `tests/simple.proto` and need to update its encoded form in tests: +- `protoc --descriptor_set_out=/dev/stdout tests/simple.proto | base64` + ## Development Roadmap Pact plugin development board: https://github.com/pact-foundation/pact-plugins/projects/1 diff --git a/integrated_tests/response_metadata/pacts/grpc-consumer-rust-response_metadata.json b/integrated_tests/response_metadata/pacts/grpc-consumer-rust-response_metadata.json index e9569a4..fde4696 100644 --- a/integrated_tests/response_metadata/pacts/grpc-consumer-rust-response_metadata.json +++ b/integrated_tests/response_metadata/pacts/grpc-consumer-rust-response_metadata.json @@ -13,13 +13,13 @@ "pluginConfiguration": { "protobuf": { "descriptorKey": "628d9de1211ee7ee1d167e3e12b170bf", - "service": "Test/GetTest" + "service": ".pactissue.Test/GetTest" } }, "request": { "contents": { "content": "CgA=", - "contentType": "application/protobuf;message=MessageIn", + "contentType": "application/protobuf;message=.pactissue.MessageIn", "contentTypeHint": "BINARY", "encoded": "base64" }, @@ -46,7 +46,7 @@ } }, "metadata": { - "contentType": "application/protobuf;message=MessageIn", + "contentType": "application/protobuf;message=.pactissue.MessageIn", "key": "value" } }, @@ -54,7 +54,7 @@ { "contents": { "content": "EAE=", - "contentType": "application/protobuf;message=MessageOut", + "contentType": "application/protobuf;message=.pactissue.MessageOut", "contentTypeHint": "BINARY", "encoded": "base64" }, @@ -89,7 +89,7 @@ } }, "metadata": { - "contentType": "application/protobuf;message=MessageOut", + "contentType": "application/protobuf;message=.pactissue.MessageOut", "grpc-message": "not found", "grpc-status": "NOT_FOUND" } @@ -101,13 +101,23 @@ ], "metadata": { "pactRust": { - "consumer": "1.1.2", - "models": "1.1.18" + "consumer": "1.2.3", + "models": "1.2.3" }, "pactSpecification": { "version": "4.0" }, "plugins": [ + { + "configuration": { + "628d9de1211ee7ee1d167e3e12b170bf": { + "protoDescriptors": "CqUBChdyZXNwb25zZV9tZXRhZGF0YS5wcm90bxIJcGFjdGlzc3VlIhkKCU1lc3NhZ2VJbhIMCgFzGAEgASgJUgFzIhoKCk1lc3NhZ2VPdXQSDAoBYhgCIAEoCFIBYjJACgRUZXN0EjgKB0dldFRlc3QSFC5wYWN0aXNzdWUuTWVzc2FnZUluGhUucGFjdGlzc3VlLk1lc3NhZ2VPdXQiAGIGcHJvdG8z", + "protoFile": "syntax = \"proto3\";\n\npackage pactissue;\n\nmessage MessageIn {\n string s = 1;\n}\n\nmessage MessageOut {\n bool b = 2;\n}\n\nservice Test {\n rpc GetTest(MessageIn) returns (MessageOut) {}\n}\n" + } + }, + "name": "protobuf", + "version": "0.4.0" + }, { "configuration": { "628d9de1211ee7ee1d167e3e12b170bf": { diff --git a/src/matching.rs b/src/matching.rs index 4f4dbba..c972692 100644 --- a/src/matching.rs +++ b/src/matching.rs @@ -21,24 +21,38 @@ use prost_types::field_descriptor_proto::Type; use tracing::{debug, instrument, trace, warn}; use crate::message_decoder::{decode_message, ProtobufField, ProtobufFieldData}; -use crate::utils::{display_bytes, enum_name, field_data_to_json, find_message_field_by_name, find_message_type_by_name, find_service_descriptor, is_map_field, is_repeated_field, last_name}; +use crate::utils::{display_bytes, enum_name, field_data_to_json, find_message_descriptor_for_type, find_message_field_by_name, find_method_descriptor_for_service, find_service_descriptor_for_type, is_map_field, is_repeated_field, last_name, split_service_and_method}; /// Match a single Protobuf message +/// +/// # Arguments +/// - `message_name` - Name of the message to match. Can be a fully-qualified name +/// (if created with a recent version of the plugin), +/// or not (if created with an older version of the plugin). find_message_descriptor_for_type can handle both. +/// - `descriptors` - All file descriptors available for this interaction to lookup message in. +/// - `expected_message_bytes` - The expected message as bytes. +/// - `actual_message_bytes` - The actual message as bytes. +/// - `matching_rules` - Matching rules to use when comparing the messages. +/// - `allow_unexpected_keys` - If true, allow unexpected keys in the actual message. +/// +/// # Returns +/// A BodyMatchResult indicating if the messages match or not. pub fn match_message( message_name: &str, descriptors: &FileDescriptorSet, - expected_request: &mut Bytes, - actual_request: &mut Bytes, + expected_message_bytes: &mut Bytes, + actual_message_bytes: &mut Bytes, matching_rules: &MatchingRuleCategory, allow_unexpected_keys: bool ) -> anyhow::Result { - debug!("Looking for message '{}'", message_name); - let (message_descriptor, _) = find_message_type_by_name(message_name, descriptors)?; + // message_name can be a fully-qualified name (if created with a recent version of the plugin), + // or not (if created with an older version of the plugin). find_message_descriptor_for_type can handle both. + let (message_descriptor, _) = find_message_descriptor_for_type(message_name, &descriptors)?; - let expected_message = decode_message(expected_request, &message_descriptor, descriptors)?; + let expected_message = decode_message(expected_message_bytes, &message_descriptor, descriptors)?; debug!("expected message = {:?}", expected_message); - let actual_message = decode_message(actual_request, &message_descriptor, descriptors)?; + let actual_message = decode_message(actual_message_bytes, &message_descriptor, descriptors)?; debug!("actual message = {:?}", actual_message); let plugin_config = hashmap!{}; @@ -50,13 +64,23 @@ pub fn match_message( let context = CoreMatchingContext::new(diff_config, matching_rules, &plugin_config); compare(&message_descriptor, &expected_message, &actual_message, &context, - expected_request, descriptors) + expected_message_bytes, descriptors) } -/// Match a Protobuf service call, which has an input and output message +/// Match a Protobuf service call, which has an input and output message. +/// Not used when verifying a gRPC interaction, only when doing `compare_contents` call. +/// Contains logic to determine which message to compare based on the content type, which contains +/// a `message` attribute that specifies the message type to compare, +/// e.g. `application/protobuf;message=.routeguide.Feature`. +/// +/// If the message is there, we check if it's the same as the request or the response type in the service descriptor. +/// If it's the same as the request type, we compare the request message. +/// If it's the same as the response type, we compare the response message. +/// If it's not there but `request_part` is set to `request`, we compare request message. +/// - request part is a part of the method name, e.g. `GetFeature:request`. +/// In any other case we compare the response message. pub fn match_service( - service_name: &str, - method_name: &str, + service: &str, descriptors: &FileDescriptorSet, expected_request: &mut Bytes, actual_request: &mut Bytes, @@ -64,36 +88,43 @@ pub fn match_service( allow_unexpected_keys: bool, content_type: &ContentType ) -> anyhow::Result { - debug!("Looking for service '{}'", service_name); - let (_, service_descriptor) = find_service_descriptor(descriptors, service_name)?; - trace!("Found service descriptor with name {:?}", service_descriptor.name); + trace!(service, ?descriptors, allow_unexpected_keys, ?rules, ?content_type, ">> match_service"); + + let (service_name, method_name) = split_service_and_method(service)?; + // service_name can be a fully-qualified name (if created with a recent version of the plugin), + // or not (if created with an older version of the plugin). find_service_descriptor_for_type can handle both. + let (_, service_descriptor) = find_service_descriptor_for_type(service_name, descriptors)?; let (method_name, service_part) = if method_name.contains(':') { method_name.split_once(':').unwrap_or((method_name, "")) } else { (method_name, "") }; - let method_descriptor = service_descriptor.method.iter().find(|method_desc| { - method_desc.name.clone().unwrap_or_default() == method_name - }).ok_or_else(|| anyhow!("Did not find the method {} in the Protobuf file descriptor for service '{}'", method_name, service_name))?; - trace!("Found method descriptor with name {:?}", method_descriptor.name); - + let method_descriptor = find_method_descriptor_for_service(method_name, &service_descriptor)?; + let expected_message_type = content_type.attributes.get("message"); + + // TODO: what if both the request and response have the same type but different matching rules? let message_type = if let Some(message_type) = expected_message_type { - let input_type = method_descriptor.input_type.clone().unwrap_or_default(); - if last_name(input_type.as_str()) == message_type.as_str() { + // It's not necessary to look at the package from the content type here, as we're going to be using + // the package from the input or output type anyway, and those do contain package in their name + let message_type = last_name(message_type.as_str()); + let input_type = method_descriptor.input_type(); + if last_name(input_type) == message_type { input_type } else { - method_descriptor.output_type.clone().unwrap_or_default() + method_descriptor.output_type() } } else if service_part == "request" { - method_descriptor.input_type.clone().unwrap_or_default() + method_descriptor.input_type() } else { - method_descriptor.output_type.clone().unwrap_or_default() + method_descriptor.output_type() }; trace!("Message type = {}", message_type); - match_message(last_name(message_type.as_str()), descriptors, + // message_type is the value of method_descriptor.input/output_type field, which is usually a fully-qualified name + // that includes both the package and the type. match_message expects this kind of input. + match_message(message_type, descriptors, expected_request, actual_request, rules, allow_unexpected_keys) } @@ -848,7 +879,8 @@ mod tests { let bytes1 = Bytes::copy_from_slice(bytes.as_slice()); let fds = FileDescriptorSet::decode(bytes1).unwrap(); - let (message_descriptor, _) = find_message_type_by_name("InitPluginResponse", &fds).unwrap(); + let (message_descriptor, _) = find_message_descriptor_for_type( + ".io.pact.plugin.InitPluginResponse", &fds).unwrap(); let path = DocPath::new("$").unwrap(); let context = CoreMatchingContext::new(DiffConfig::AllowUnexpectedKeys, &matchingrules_list! { @@ -1029,7 +1061,8 @@ mod tests { EjIKCUdldFZhbHVlcxIQLlZhbHVlc01lc3NhZ2VJbhoRLlZhbHVlc01lc3NhZ2VPdXQiAGIGcHJvdG8z").unwrap(); let fds = FileDescriptorSet::decode(descriptors.as_slice()).unwrap(); - let (message_descriptor, _) = find_message_type_by_name("ValuesMessageIn", &fds).unwrap(); + // no package in this descriptor + let (message_descriptor, _) = find_message_descriptor_for_type(".ValuesMessageIn", &fds).unwrap(); let path = DocPath::new("$").unwrap(); let context = CoreMatchingContext::new(DiffConfig::AllowUnexpectedKeys, &matchingrules_list! { @@ -1086,7 +1119,8 @@ mod tests { 2FnZU91dCIAYgZwcm90bzM=").unwrap(); let fds = FileDescriptorSet::decode(descriptors.as_slice()).unwrap(); - let (message_descriptor, _) = find_message_type_by_name("Resource", &fds).unwrap(); + // use fully-qualified type name with no package. + let (message_descriptor, _) = find_message_descriptor_for_type(".Resource", &fds).unwrap(); let each_value = MatchingRule::EachValue(MatchingRuleDefinition::new("foo".to_string(), ValueType::Unknown, MatchingRule::Type, None)); let each_value_groups = MatchingRule::EachValue(MatchingRuleDefinition::new( @@ -1157,7 +1191,8 @@ mod tests { 46, 77, 101, 115, 115, 97, 103, 101, 79, 117, 116, 34, 0, 98, 6, 112, 114, 111, 116, 111, 51]; let fds = FileDescriptorSet::decode(descriptors).unwrap(); - let (message_descriptor, _) = find_message_type_by_name("MessageIn", &fds).unwrap(); + let (message_descriptor, _) = find_message_descriptor_for_type( + ".pactissue.MessageIn", &fds).unwrap(); let enum_descriptor= find_enum_by_name(&fds, "pactissue.TestDefault").unwrap(); let matching_rules = matchingrules! { @@ -1238,7 +1273,8 @@ mod tests { 46, 77, 101, 115, 115, 97, 103, 101, 79, 117, 116, 34, 0, 98, 6, 112, 114, 111, 116, 111, 51]; let fds = FileDescriptorSet::decode(descriptors).unwrap(); - let (message_descriptor, _) = find_message_type_by_name("MessageIn", &fds).unwrap(); + let (message_descriptor, _) = find_message_descriptor_for_type( + ".pactissue.MessageIn", &fds).unwrap(); let enum_descriptor= find_enum_by_name(&fds, "pactissue.TestDefault").unwrap(); let matching_rules = matchingrules! { diff --git a/src/message_decoder/mod.rs b/src/message_decoder/mod.rs index 7186326..c70e494 100644 --- a/src/message_decoder/mod.rs +++ b/src/message_decoder/mod.rs @@ -13,7 +13,7 @@ use prost_types::field_descriptor_proto::Type; use tracing::{debug, error, trace, warn}; use crate::utils::{ - as_hex, find_enum_by_name, find_enum_by_name_in_message, find_message_descriptor, is_repeated_field, should_be_packed_type, split_name + as_hex, find_enum_by_name, find_enum_by_name_in_message, find_message_descriptor_for_type, is_repeated_field, last_name, should_be_packed_type }; mod generators; @@ -381,6 +381,8 @@ pub fn decode_message( descriptors: &FileDescriptorSet ) -> anyhow::Result> where B: Buf { + trace!("Decoding message using descriptor {:?}", descriptor); + trace!("all descriptors available for decoding the message: {:?}", descriptors); trace!("Incoming buffer has {} bytes", buffer.remaining()); let mut fields = vec![]; @@ -439,12 +441,20 @@ pub fn decode_message( match t { Type::String => vec![ (ProtobufFieldData::String(from_utf8(&data_buffer)?.to_string()), wire_type) ], Type::Message => { - let (type_name, type_package) = split_name(field_descriptor.type_name.as_deref().unwrap_or_default()); - let message_proto = descriptor.nested_type.iter() - .find(|message_descriptor| message_descriptor.name.as_deref() == Some(type_name)) - .cloned() - .or_else(|| find_message_descriptor(type_name, type_package, descriptors.file.clone()).ok()) - .ok_or_else(|| anyhow!("Did not find the embedded message {:?} for the field {} in the Protobuf descriptor", field_descriptor.type_name, field_num))?; + let full_type_name = field_descriptor.type_name.as_deref().unwrap_or_default(); + // TODO: replace with proper support for nested fields + // this code checks fully qualified name first, if it can find it, this means the type name was a + // valid fully-qualified reference; + // if it's not found, it's a nested type, so we look for it in the nested types of the current message + // This misses the case when the type name refers to a fully-qualified nested type in another message + // or package. This also doesn't deal with relative paths, but I don't think descriptors actually + // contain those. + let message_proto = find_message_descriptor_for_type(full_type_name, descriptors).map(|(d,_)|d) + .or_else(|_| { + descriptor.nested_type.iter().find( + |message_descriptor| message_descriptor.name.as_deref() == Some(last_name(full_type_name)) + ).cloned().ok_or_else(|| anyhow!("Did not find the message {:?} for the field {} in the Protobuf descriptor", field_descriptor.type_name, field_num)) + })?; vec![ (ProtobufFieldData::Message(data_buffer.to_vec(), message_proto), wire_type) ] } Type::Bytes => vec![ (ProtobufFieldData::Bytes(data_buffer.to_vec()), wire_type) ], diff --git a/src/mock_server.rs b/src/mock_server.rs index b6d4b9f..0cb19b1 100644 --- a/src/mock_server.rs +++ b/src/mock_server.rs @@ -41,7 +41,7 @@ use tracing::{debug, error, Instrument, instrument, trace, trace_span}; use crate::dynamic_message::PactCodec; use crate::metadata::MetadataMatchResult; use crate::mock_service::MockService; -use crate::utils::{find_message_descriptor, last_name, split_name}; +use crate::utils::{build_grpc_route, find_message_descriptor_for_type, lookup_service_descriptors_for_interaction, parse_grpc_route, to_fully_qualified_name}; lazy_static! { pub static ref MOCK_SERVER_STATE: Mutex, HashMap)>)>> = Mutex::new(hashmap!{}); @@ -75,6 +75,9 @@ impl GrpcMockServer } /// Start the mock server, consuming this instance and returning the connection details + /// For each interaction, loads the corresponding service and file descriptors from the Pact file + /// into a map keyed by a gRPC route in a standard form of `/package.Service/Method`. + /// When serving, it allows to easily find the correct descriptors based on the route being called. #[instrument(skip(self))] pub async fn start_server(mut self, host_interface: &str, port: u32, tls: bool) -> anyhow::Result { // Get all the descriptors from the Pact file and parse them @@ -95,35 +98,21 @@ impl GrpcMockServer // Build a map of routes using the interactions in the Pact file self.routes = self.pact.interactions.iter() - .filter_map(|i| i.as_v4_sync_message()) - .filter_map(|i| i.plugin_config.get("protobuf").map(|p| (p.clone(), i.clone()))) - .filter_map(|(c, i)| { - if let Some(key) = c.get("descriptorKey") { - if let Some(descriptors) = self.descriptors.get(json_to_string(key).as_str()) { - if let Some(service) = c.get("service") { - if let Some((service_name, method_name)) = json_to_string(service).split_once('/') { - return descriptors.file.iter().find_map(|fd| fd.service.iter().find(|s| s.name.clone().unwrap_or_default() == service_name).map( |s| s.method.iter(). - find(|m| m.name.clone().unwrap_or_default() == method_name). - map(|m| (format!("{service_name}/{method_name}"), (descriptors.clone(), fd.clone(), m.clone(), i.clone()))) - ) - ).unwrap(); - } else { - // protobuf service was not properly formed / - None - } - } else { - // protobuf plugin configuration section did not have a service defined - None - } - } else { - // protobuf plugin configuration section did not have a matching key to the descriptors - None + .filter_map(|i| i.as_v4_sync_message()) + .filter_map(|i| match lookup_service_descriptors_for_interaction(&i, &self.pact) { + Ok((file_set, service, method, file)) => Some((file_set, service, method, file, i.clone())), + Err(_) => None + }).filter_map(|(file_set, service, method, file, i)| { + match to_fully_qualified_name(service.name(), file.package()) { + Ok(service_full_name) => { + match build_grpc_route(service_full_name.as_str(), method.name()) { + Ok(route) => Some((route, (file_set.clone(), file.clone(), method.clone(), i.clone()))), + Err(_) => None } - } else { - // Interaction did not have a protobuf plugin configuration section - None - } - }).collect(); + }, + Err(_) => None + } + }).collect(); // Bind to a OS provided port and create a TCP listener let interface = if host_interface.is_empty() { @@ -220,6 +209,10 @@ impl Service> for GrpcMockServer { Poll::Ready(Ok(())) } + /// Process gRPC call. + /// Looks up descriptors and interaction config in `self.routes` based on the request path, + /// then uses them to respond to construct a `mock_service.MockService` instance and call it. + /// The actual work is done in `mock_service.MockService::handle_message()`. #[instrument(skip(self), level = "trace")] fn call(&mut self, req: Request) -> Self::Future { let routes = self.routes.clone(); @@ -247,27 +240,20 @@ impl Service> for GrpcMockServer { if method == Method::POST { let request_path = req.uri().path(); debug!(?request_path, "gRPC request received"); - if let Some((service, method)) = request_path[1..].split_once('/') { - let service_name = last_name(service); - let lookup = format!("{service_name}/{method}"); - if let Some((file, _file_descriptor, method_descriptor, message)) = routes.get(lookup.as_str()) { + if let Some((service_full_name, method)) = parse_grpc_route(request_path) { + if let Some((file, _file_descriptor, method_descriptor, message)) = routes.get(request_path) { trace!(message = message.description.as_str(), "Found route for service call"); + + let service_and_method = format!("{service_full_name}/{method}"); // just for logging let input_name = method_descriptor.input_type.as_ref().expect(format!( - "Input message name is empty for service {}/{}", service_name, method).as_str()); - let (input_message_name, input_package_name) = split_name(input_name); - let input_message = find_message_descriptor( - input_message_name, input_package_name, file.file.clone()); - + "Input message name is empty for service {}", service_and_method.as_str()).as_str()); let output_name = method_descriptor.output_type.as_ref().expect(format!( - "Output message name is empty for service {}/{}", service_name, method).as_str()); - let (output_message_name, output_package_name) = split_name(output_name); - let output_message = find_message_descriptor( - output_message_name, output_package_name, file.file.clone()); + "Output message name is empty for service {}", service_and_method.as_str()).as_str()); - if let Ok(input_message) = input_message { - if let Ok(output_message) = output_message { + if let Ok((input_message, _)) = find_message_descriptor_for_type(input_name, &file) { + if let Ok((output_message, _)) = find_message_descriptor_for_type(output_name, &file) { let codec = PactCodec::new(file, &input_message, &output_message, message); - let mock_service = MockService::new(file, service_name, + let mock_service = MockService::new(file, service_full_name.as_str(), method_descriptor, &input_message, &output_message, message, server_key.as_str(), pact ); @@ -276,11 +262,11 @@ impl Service> for GrpcMockServer { trace!(?response, ">> sending response"); Ok(response) } else { - error!("Did not find the descriptor for the output message {}", output_message_name); + error!("Did not find the descriptor for the output message {}", output_name); Ok(failed_precondition()) } } else { - error!("Did not find the descriptor for the input message {}", input_message_name); + error!("Did not find the descriptor for the input message {}", input_name); Ok(failed_precondition()) } } else { diff --git a/src/mock_service.rs b/src/mock_service.rs index 07d662d..8c4101a 100644 --- a/src/mock_service.rs +++ b/src/mock_service.rs @@ -25,6 +25,7 @@ use crate::matching::compare; use crate::message_decoder::decode_message; use crate::metadata::{compare_metadata, grpc_status}; use crate::mock_server::MOCK_SERVER_STATE; +use crate::utils::build_grpc_route; #[derive(Debug, Clone)] pub(crate) struct MockService { @@ -39,6 +40,21 @@ pub(crate) struct MockService { } impl MockService { + /// Handle the gRPC call. Compare the incoming message to the expected request message, respond with + /// the expected response mesage. + /// + /// Stores comparison results in `MOCK_SERVER_STATE` for later retrieval. + /// + /// # Arguments + /// + /// * `request` - The incoming request message + /// * `message_descriptor` - The descriptor of the expected request message + /// * `response_descriptor` - The descriptor of the expected response message + /// * `request_metadata` - The incoming request metadata + /// + /// # Returns + /// + /// Returns a `Result` with the response message or an error pub(crate) async fn handle_message( &self, request: DynamicMessage, @@ -77,7 +93,11 @@ impl MockService { { // record the result in the static store let mut guard = MOCK_SERVER_STATE.lock().unwrap(); - let key = format!("{}/{}", self.service_name, self.method_descriptor.name.clone().unwrap_or_else(|| "unknown method".into())); + let method_name = self.method_descriptor.name.clone().unwrap_or_else(|| "unknown method".into()); + let key = match build_grpc_route(self.service_name.as_str(), method_name.as_str()) { + Ok(k) => k, + Err(err) => Err(Status::internal(err.to_string()))? + }; if let Some((_, results)) = guard.get_mut(self.server_key.as_str()) { let route_results = results.entry(key).or_insert((0, vec![])); trace!(store_length = route_results.1.len(), "Adding result to mock server '{}' static store", self.server_key); diff --git a/src/protobuf.rs b/src/protobuf.rs index 84568cc..42dba47 100644 --- a/src/protobuf.rs +++ b/src/protobuf.rs @@ -40,10 +40,39 @@ use crate::message_builder::{MessageBuilder, MessageFieldValue, MessageFieldValu use crate::metadata::{MessageMetadata, process_metadata}; use crate::protoc::Protoc; use crate::utils::{ - find_enum_value_by_name, find_enum_value_by_name_in_message, find_message_descriptor_from_hash_map, find_nested_type, is_map_field, is_repeated_field, prost_string, split_name + to_fully_qualified_name, find_enum_value_by_name, find_enum_value_by_name_in_message, find_message_descriptor_for_type_in_map, find_nested_type, is_map_field, is_repeated_field, last_name, prost_string, split_service_and_method }; -/// Process the provided protobuf file and configure the interaction +/// Converts user-provided configuration and .proto files into a pact interaction. +/// +/// # Arguments +/// +/// - `proto_file` - Path to the protobuf file +/// - `protoc` - Encapsulates protoc functionality; can parse protos into descriptors +/// - `config` - Test configuration as provided by the test author, e.g. +/// ```json +/// { +/// "pact:proto": "/path/to/protos/route/route_guide.proto", +/// "pact:proto-service": "RouteGuide/GetFeature", +/// "pact:content-type": "application/protobuf", +/// "pact:protobuf-config": { +/// "additionalIncludes": ["/path/to/protos/"] +/// }, +/// "request": { +/// "latitude": "matching(number, 180)", +/// "longitude": "matching(number, 200)" +/// }, +/// "response": { +/// "name": "notEmpty('Big Tree')", +/// } +/// } +/// ``` +/// +/// # Returns +/// +/// A tuple of values to construct the pact file: +/// - Vector of interactions - single for a message interaction, or a request/response pair for a grpc interaction +/// - Plugin configuration, which can be used to store the protobuf file and descriptors pub(crate) async fn process_proto( proto_file: String, protoc: &Protoc, @@ -72,7 +101,6 @@ pub(crate) async fn process_proto( trace!(" {:?}", message_type.name); } } - let descriptor_encoded = BASE64.encode(&descriptor_bytes); let descriptor_hash = format!("{:x}", md5::compute(&descriptor_bytes)); let mut interactions = vec![]; @@ -114,33 +142,40 @@ pub(crate) async fn process_proto( Ok((interactions, plugin_config)) } -/// Configure the interaction for a Protobuf service method, which has an input and output message +/// Configure the interaction for a gRPC service method, which has an input and output message. +/// Main work is done in `construct_protobuf_interaction_for_service`; +/// this function does two things: +/// - locates the correct service descriptor in the provided file descriptor +/// - adds interaction_configuration to the output of `construct_protobuf_interaction_for_service`, which contains: +/// - service: the fully qualified service name; allows to locate this service when verifying this interaction +/// - descriptorKey: a hash of the protobuf file descriptor, which allows to locate the file descriptor +/// in the plugin configuration when verifying this interaction fn configure_protobuf_service( - service_name: &str, + service_with_method: &str, config: &BTreeMap, descriptor: &FileDescriptorProto, all_descriptors: &HashMap, descriptor_hash: &str ) -> anyhow::Result<(Option, Vec)> { - trace!(">> configure_protobuf_service({service_name}, {config:?}, {descriptor_hash})"); + trace!(">> configure_protobuf_service({service_with_method}, {config:?}, {descriptor_hash})"); - debug!("Looking for service and method with name '{}'", service_name); - let (service, proc_name) = service_name.split_once('/') - .ok_or_else(|| anyhow!("Service name '{}' is not valid, it should be of the form /", service_name))?; + debug!("Looking for service and method with name '{}'", service_with_method); + let (service, method_name) = split_service_and_method(service_with_method)?; + // Lookup service inside the descriptor, but don't search all file descriptors to avoid similarly named services let service_descriptor = descriptor.service - .iter().find(|p| p.name.clone().unwrap_or_default() == service) - .ok_or_else(|| anyhow!("Did not find a descriptor for service '{}'", service_name))?; + .iter().find(|p| p.name() == service) + .ok_or_else(|| anyhow!("Did not find a descriptor for service '{}'", service_with_method))?; trace!("service_descriptor = {:?}", service_descriptor); - construct_protobuf_interaction_for_service(service_descriptor, config, service, - proc_name, all_descriptors, descriptor) + + let service_with_method = service_with_method.split_once(':').map(|(s, _)| s).unwrap_or(service_with_method); + let service_full_name = to_fully_qualified_name(service_with_method, descriptor.package())?; + construct_protobuf_interaction_for_service(service_descriptor, config, method_name, all_descriptors) .map(|(request, response)| { let plugin_configuration = Some(PluginConfiguration { interaction_configuration: Some(to_proto_struct(&hashmap! { - "service".to_string() => Value::String( - service_name.split_once(':').map(|(s, _)| s).unwrap_or(service_name).to_string() - ), + "service".to_string() => Value::String(service_full_name), "descriptorKey".to_string() => Value::String(descriptor_hash.to_string()) - })), + })), pact_configuration: None }); trace!("request = {request:?}"); @@ -152,15 +187,18 @@ fn configure_protobuf_service( }) } -/// Constructs an interaction for the given Protobuf service descriptor +/// Constructs an interaction for the given gRPC service descriptor +/// Interaction consists of request intraction and possibly multiple response interactions, +/// each is constructed by calling `construct_protobuf_interaction_for_message`. +/// Request and response types are looked up in all of the provided file descriptors using their +/// fully qualified names. fn construct_protobuf_interaction_for_service( - descriptor: &ServiceDescriptorProto, + service_descriptor: &ServiceDescriptorProto, config: &BTreeMap, - service_name: &str, method_name: &str, - all_descriptors: &HashMap, - file_descriptor: &FileDescriptorProto + all_descriptors: &HashMap ) -> anyhow::Result<(Option, Vec)> { + let service_name = service_descriptor.name.as_deref().expect("Service descriptor name cannot be empty"); trace!(">> construct_protobuf_interaction_for_service({config:?}, {service_name}, {method_name})"); let (method_name, service_part) = if method_name.contains(':') { @@ -169,33 +207,29 @@ fn construct_protobuf_interaction_for_service( (method_name, "") }; trace!(method_name, service_part, "looking up method descriptor"); - let method_descriptor = descriptor.method.iter() + let method_descriptor = service_descriptor.method.iter() .find(|m| m.name.clone().unwrap_or_default() == method_name) .ok_or_else(|| anyhow!("Did not find a method descriptor for method '{}' in service '{}'", method_name, service_name))?; let input_name = method_descriptor.input_type.as_ref() .ok_or_else(|| anyhow!("Input message name is empty for service {}/{}", service_name, method_name))?; let output_name = method_descriptor.output_type.as_ref() - .ok_or_else(|| anyhow!("Input message name is empty for service {}/{}", service_name, method_name))?; - let (input_message_name, input_package) = split_name(input_name.as_str()); - let (output_message_name, output_package) = split_name(output_name.as_str()); - - trace!(%input_name, ?input_package, input_message_name, "Input message"); - trace!(%output_name, ?output_package, output_message_name, "Output message"); - - let request_descriptor = find_message_descriptor_from_hash_map(input_message_name, input_package, all_descriptors)?; - let response_descriptor = find_message_descriptor_from_hash_map(output_message_name, output_package, all_descriptors)?; - - trace!("request_descriptor = {:?}", request_descriptor); - trace!("response_descriptor = {:?}", response_descriptor); - + .ok_or_else(|| anyhow!("Output message name is empty for service {}/{}", service_name, method_name))?; + + let (request_descriptor, request_file_descriptor) = + find_message_descriptor_for_type_in_map(input_name, all_descriptors)?; + let (response_descriptor, response_file_descriptor) = + find_message_descriptor_for_type_in_map(output_name, all_descriptors)?; + + trace!(%input_name, ?request_descriptor, ?request_file_descriptor, "Input message descriptor"); + trace!(%output_name, ?response_descriptor, ?response_file_descriptor, "Output message descriptor"); + let request_part_config = request_part(config, service_part)?; trace!(config = ?request_part_config, service_part, "Processing request part config"); let request_metadata = process_metadata(config.get("requestMetadata"))?; + let interaction = construct_protobuf_interaction_for_message(&request_descriptor, - &request_part_config, input_message_name, "", file_descriptor, all_descriptors, - request_metadata.as_ref() - )?; + &request_part_config, "", &request_file_descriptor, all_descriptors, request_metadata.as_ref())?; let request_part = Some(InteractionResponse { part_name: "request".into(), .. interaction @@ -207,9 +241,7 @@ fn construct_protobuf_interaction_for_service( for (config, md_config) in response_part_config { let response_metadata = process_metadata(md_config)?; let interaction = construct_protobuf_interaction_for_message( - &response_descriptor, &config, output_message_name, "", - file_descriptor, all_descriptors, response_metadata.as_ref() - )?; + &response_descriptor, &config, "", &response_file_descriptor, all_descriptors, response_metadata.as_ref())?; response_part.push(InteractionResponse { part_name: "response".into(), .. interaction }); } @@ -285,16 +317,17 @@ fn configure_protobuf_message( all_descriptors: &HashMap ) -> anyhow::Result { trace!(">> configure_protobuf_message({}, {:?})", message_name, descriptor_hash); - debug!("Looking for message of type '{}'", message_name); + debug!("Looking for message '{}' in '{}'", message_name, descriptor.name()); let message_descriptor = descriptor.message_type - .iter().find(|p| p.name.clone().unwrap_or_default() == message_name) - .ok_or_else(|| anyhow!("Did not find a descriptor for message '{}'", message_name))?; - construct_protobuf_interaction_for_message(message_descriptor, config, message_name, "", descriptor, all_descriptors, None) + .iter().find(|p| p.name() == message_name) + .ok_or_else(|| anyhow!("Did not find a descriptor for message '{}' in '{}'", message_name, descriptor.name()))?; + let message_full_name = to_fully_qualified_name(message_name, descriptor.package())?; + construct_protobuf_interaction_for_message(message_descriptor, config, "", descriptor, all_descriptors, None) .map(|interaction| { InteractionResponse { plugin_configuration: Some(PluginConfiguration { interaction_configuration: Some(to_proto_struct(&hashmap!{ - "message".to_string() => Value::String(message_name.to_string()), + "message".to_string() => Value::String(message_full_name), "descriptorKey".to_string() => Value::String(descriptor_hash.to_string()) })), pact_configuration: None @@ -304,21 +337,37 @@ fn configure_protobuf_message( }) } -/// Constructs an interaction for the given Protobuf message descriptor +/// Constructs an interaction for the given Protobuf message descriptor. +/// Used in both message pacts and gRPC service pacts. +/// +/// # Arguments +/// +/// - `message_descriptor` - Descriptor of the message to construct the interaction for +/// - `config` - Test configuration as provided by the test author. For request and response messages in gRPC +/// interaction, this will only contain the value of `request` or `response` fields in the original configuration. +/// For message pacts, this is a full configuration object (like in `process_proto` example) +/// - `message_part` - always empty string for now +/// - `file_descriptor` - Descriptor of the file containing the message +/// - `all_descriptors` - All file descriptors provided to the plugin +/// - `metadata` - Optional metadata for the message; for request and response messages in gRPC interaction +/// it's the values of `requestMetadata` and `responseMetadata` fields; not currently supported for message pacts. +/// +/// # Returns +/// - InteractionResponse - the constructed interaction #[instrument(ret, skip(message_descriptor, file_descriptor, all_descriptors))] fn construct_protobuf_interaction_for_message( message_descriptor: &DescriptorProto, config: &BTreeMap, - message_name: &str, message_part: &str, file_descriptor: &FileDescriptorProto, all_descriptors: &HashMap, metadata: Option<&MessageMetadata> ) -> anyhow::Result { - trace!(">> construct_protobuf_interaction_for_message({}, {}, {:?}, {:?}, {:?})", message_name, + trace!(">> construct_protobuf_interaction_for_message({}, {:?}, {:?}, {:?})", message_part, file_descriptor.name, config.keys(), metadata); trace!("message_descriptor = {:?}", message_descriptor); - + + let message_name = message_descriptor.name.as_ref().expect("Message descriptor name cannot be empty"); let mut message_builder = MessageBuilder::new(message_descriptor, message_name, file_descriptor); let mut matching_rules = MatchingRuleCategory::empty("body"); let mut generators = hashmap!{}; @@ -346,7 +395,12 @@ fn construct_protobuf_interaction_for_message( let rules = extract_rules(&matching_rules); let generators = extract_generators(&generators); - let content_type = format!("application/protobuf;message={}", message_name); + // Add a package to the message name. This value is read in: + // - server::generate_contents_impl() + // - matching::match_service() - as part of compare_contents flow + // it is not passed on to the provider under test + let message_with_package = to_fully_qualified_name(message_name, file_descriptor.package())?; + let content_type = format!("application/protobuf;message={}", message_with_package); let mut metadata_fields = btreemap! { "contentType".to_string() => prost_string(&content_type) }; @@ -640,11 +694,11 @@ fn build_single_embedded_field_value( Ok(None) } else if let Value::Object(config) = value { debug!("Configuring the message from config {:?}", config); - let (message_name, package_name) = split_name(type_name.as_str()); let embedded_type = find_nested_type(&message_builder.descriptor, field_descriptor) - .or_else(|| find_message_descriptor_from_hash_map(message_name, package_name, all_descriptors).ok()) + .or_else(|| find_message_descriptor_for_type_in_map(type_name.as_str(), all_descriptors).ok().map(|(m, _)| m)) .ok_or_else(|| anyhow!("Did not find message '{}' in the current message or in the file descriptors", type_name))?; - let mut embedded_builder = MessageBuilder::new(&embedded_type, message_name, &message_builder.file_descriptor); + let mut embedded_builder = MessageBuilder::new( + &embedded_type, last_name(type_name.as_str()), &message_builder.file_descriptor); let field_value = if let Some(definition) = config.get("pact:match") { let mut field_value = None; @@ -1269,7 +1323,7 @@ pub(crate) mod tests { response_part, value_for_type }; - use crate::utils::find_message_type_by_name; + use crate::utils::find_message_descriptor_for_type; #[test] fn value_for_type_test() { @@ -1324,9 +1378,14 @@ pub(crate) mod tests { #[test] fn construct_protobuf_interaction_for_message_test() { + // construct_protobuf_interaction_for_message doesn't actually verify + // that the message descriptor is part of a file descriptor + // so it doesn't have to be here as well + // It will still assume that the message came from this file descriptor and will use the package field from + // the file descriptor as the message package too. let file_descriptor = FileDescriptorProto { - name: None, - package: None, + name: Some("test_file.proto".to_string()), + package: Some("test_package".to_string()), dependency: vec![], public_dependency: vec![], weak_dependency: vec![], @@ -1410,10 +1469,10 @@ pub(crate) mod tests { }; let result = construct_protobuf_interaction_for_message(&message_descriptor, &config, - "test_message", "", &file_descriptor, &hashmap!{}, None).unwrap(); + "", &file_descriptor, &hashmap!{}, None).unwrap(); let body = result.contents.as_ref().unwrap(); - expect!(body.content_type.as_str()).to(be_equal_to("application/protobuf;message=test_message")); + expect!(body.content_type.as_str()).to(be_equal_to("application/protobuf;message=.test_package.test_message")); expect!(body.content_type_hint).to(be_equal_to(2)); expect!(body.content.as_ref()).to(be_some().value(&vec![ 10, // field 1 length encoded (1 << 3 + 2 == 10) @@ -1469,12 +1528,11 @@ pub(crate) mod tests { let config = btreemap! { "value".to_string() => prost_types::Value { kind: Some(prost_types::value::Kind::StringValue("eachValue(matching(type, '00000000000000000000000000000000'))".to_string())) } }; - let (message_descriptor, _) = find_message_type_by_name("ValuesMessageIn", &fds).unwrap(); + let (message_descriptor, _) = find_message_descriptor_for_type(".ValuesMessageIn", &fds).unwrap(); let result = construct_protobuf_interaction_for_message( &message_descriptor, &config, - "ValuesMessageIn", "", fs, &all_descriptors, @@ -1482,7 +1540,7 @@ pub(crate) mod tests { ).unwrap(); let body = result.contents.as_ref().unwrap(); - expect!(body.content_type.as_str()).to(be_equal_to("application/protobuf;message=ValuesMessageIn")); + expect!(body.content_type.as_str()).to(be_equal_to("application/protobuf;message=.ValuesMessageIn")); expect!(body.content.as_ref()).to(be_some().value(&vec![ 10, // field 1 length encoded (1 << 3 + 2 == 10) 32, // 32 bytes @@ -1503,7 +1561,7 @@ pub(crate) mod tests { fn construct_message_field_with_message_with_each_value_matcher() { let fds = FileDescriptorSet::decode(DESCRIPTORS_FOR_EACH_VALUE_TEST.as_slice()).unwrap(); let fs = fds.file.first().unwrap(); - let (message_descriptor, _) = find_message_type_by_name("ValuesMessageIn", &fds).unwrap(); + let (message_descriptor, _) = find_message_descriptor_for_type(".ValuesMessageIn", &fds).unwrap(); let mut message_builder = MessageBuilder::new(&message_descriptor, "ValuesMessageIn", fs); let path = DocPath::new("$.value").unwrap(); let mut matching_rules = MatchingRuleCategory::empty("body"); @@ -1543,7 +1601,7 @@ pub(crate) mod tests { fn build_field_value_with_message_with_each_value_matcher() { let fds = FileDescriptorSet::decode(DESCRIPTORS_FOR_EACH_VALUE_TEST.as_slice()).unwrap(); let fs = fds.file.first().unwrap(); - let (message_descriptor, _) = find_message_type_by_name("ValuesMessageIn", &fds).unwrap(); + let (message_descriptor, _) = find_message_descriptor_for_type(".ValuesMessageIn", &fds).unwrap(); let field_descriptor = message_descriptor.field.first().unwrap(); let mut message_builder = MessageBuilder::new(&message_descriptor, "ValuesMessageIn", fs); let path = DocPath::new("$.value").unwrap(); @@ -1649,8 +1707,8 @@ pub(crate) mod tests { method: vec![ MethodDescriptorProto { name: Some("call".to_string()), - input_type: Some("test_package.StringValue".to_string()), - output_type: Some("test_package.test_message".to_string()), + input_type: Some(".test_package.StringValue".to_string()), + output_type: Some(".test_package.test_message".to_string()), options: None, client_streaming: None, server_streaming: None @@ -1663,8 +1721,8 @@ pub(crate) mod tests { "request".to_string() => prost_types::Value { kind: Some(prost_types::value::Kind::BoolValue(true)) } }; - let result = construct_protobuf_interaction_for_service(&service_descriptor, &config, - "test_service", "call", &hashmap!{ "file".to_string() => &file_descriptor }, &file_descriptor); + let result = construct_protobuf_interaction_for_service( + &service_descriptor, &config, "call", &hashmap!{ "file".to_string() => &file_descriptor }); expect!(result.as_ref()).to(be_err()); expect!(result.unwrap_err().to_string()).to( be_equal_to("Request contents is of an un-processable type: BoolValue(true), it should be either a Struct or a StringValue") @@ -1744,8 +1802,8 @@ pub(crate) mod tests { method: vec![ MethodDescriptorProto { name: Some("call".to_string()), - input_type: Some("test_package.StringValue".to_string()), - output_type: Some("test_package.test_message".to_string()), + input_type: Some(".test_package.StringValue".to_string()), + output_type: Some(".test_package.test_message".to_string()), options: None, client_streaming: None, server_streaming: None @@ -1758,8 +1816,8 @@ pub(crate) mod tests { "request".to_string() => prost_types::Value { kind: Some(prost_types::value::Kind::StringValue("true".to_string())) } }; - let result = construct_protobuf_interaction_for_service(&service_descriptor, &config, - "test_service", "call", &hashmap!{ "file".to_string() => &file_descriptor }, &file_descriptor); + let result = construct_protobuf_interaction_for_service( + &service_descriptor, &config, "call", &hashmap!{ "file".to_string() => &file_descriptor }); expect!(result).to(be_ok()); } @@ -2709,7 +2767,11 @@ pub(crate) mod tests { #[test] fn construct_protobuf_interaction_with_provider_state_generator() { - let file_descriptor = FileDescriptorProto::default(); + let file_descriptor = FileDescriptorProto { + name: Some("test_file".to_string()), + package: Some("test_package".to_string()), + .. FileDescriptorProto::default() + }; let message_descriptor = DescriptorProto { name: Some("test_message".to_string()), field: vec![ @@ -2730,10 +2792,10 @@ pub(crate) mod tests { }; let result = construct_protobuf_interaction_for_message(&message_descriptor, &config, - "test_message", "", &file_descriptor, &hashmap!{}, None).unwrap(); + "", &file_descriptor, &hashmap!{}, None).unwrap(); let body = result.contents.as_ref().unwrap(); - expect!(body.content_type.as_str()).to(be_equal_to("application/protobuf;message=test_message")); + expect!(body.content_type.as_str()).to(be_equal_to("application/protobuf;message=.test_package.test_message")); expect!(body.content_type_hint).to(be_equal_to(2)); expect!(body.content.as_ref()).to(be_some().value(&vec![ 10, // field 1 length encoded (1 << 3 + 2 == 10) diff --git a/src/server.rs b/src/server.rs index 04f9dc0..0d3924e 100644 --- a/src/server.rs +++ b/src/server.rs @@ -51,12 +51,7 @@ use crate::mock_server::{GrpcMockServer, MOCK_SERVER_STATE}; use crate::protobuf::process_proto; use crate::protoc::setup_protoc; use crate::utils::{ - find_message_type_by_name, - get_descriptors_for_interaction, - last_name, - lookup_interaction_by_id, - lookup_service_descriptors_for_interaction, - parse_pact_from_request_json + build_grpc_route, find_message_descriptor_for_type, get_descriptors_for_interaction, lookup_interaction_by_id, lookup_service_descriptors_for_interaction, parse_pact_from_request_json, to_fully_qualified_name }; use crate::verification::verify_interaction; @@ -193,6 +188,34 @@ impl ProtobufPactPlugin { (ok, results) } + /// Compare expected and actual contents and return results of the comparison. + /// + /// # Arguments: + /// + /// * `request` - The request to compare the contents. Contains the following fields: + /// * `expected`, `actual` - Both are of type `Body` and contain the following fields: + /// * `content` - Actual request bytes + /// * `content_type` - We populate it, e.g. `"application/protobuf;message=.routeguide.Feature"`. + /// Older versions of the plugin would not use fully-qualified name, so we support both. + /// * `content_type_hint` - always Default + /// * `rules` - Matching rules for this interaction, as defined in the pact file + /// * `allow_unexpected_keys` - true (possibly configurable) + /// * `plugin_configuration` - contains both pact-level plugin config and interaction-specific config: + /// * `interaction_configuration` - interaction-level plugin config. In pact json file + /// it is under `pluginConfiguration` inside each interaction. Contains: + /// * `descriptorKey` - key to look up a file descriptor set in the pact-level plugin config + /// (it can contain multiple sets of file descriptors) + /// * `message` or `service` field (but not both) - specifying either the message for this interaction or + /// a gRPC service, a fully-qualified name if pact was generated by the current plugin version, or + /// without the ".package" part if it's an older version. + /// * `pact_configuration` - pact-level plugin config, map keyed by `descriptorKey`s containing: + /// * `protoDescriptors` field which is a serialized `FileDescriptorSet` containing all available + /// protobuf file descriptors. + /// * `protoFile`: raw text of a .proto file specified in `"pact:proto"` in the original test config + /// + /// # Returns + /// + /// Comparison results, containing either a list of mismatches or a success message. fn compare_contents_impl(&self, request: &CompareContentsRequest) -> anyhow::Result { // Check for the plugin specific configuration for the interaction let plugin_configuration = request.plugin_configuration.clone().unwrap_or_default(); @@ -243,10 +266,6 @@ impl ProtobufPactPlugin { ) } else if let Some(service_name) = service { debug!("Received compareContents request for service {}", service_name); - let (service, method) = match service_name.split_once('/') { - Some(result) => result, - None => return Err(anyhow!("Service name '{}' is not valid, it should be of the form /", service_name)) - }; let content_type = request.expected.as_ref().map(|body| body.content_type.clone()) .unwrap_or_default(); let expected_content_type = match ContentType::parse(content_type.as_str()) { @@ -254,8 +273,7 @@ impl ProtobufPactPlugin { Err(err) => return Err(anyhow!("Expected content type is not set or not valid - {}", err)) }; match_service( - service, - method, + service_name.as_str(), &descriptors, &mut expected_body, &mut actual_body, @@ -308,6 +326,10 @@ impl ProtobufPactPlugin { fn lookup_message_and_service( interaction_config: BTreeMap ) -> anyhow::Result<(Option, Option)> { + // Both message and service will be a fully-qualified name starting with the "." if the pact + // was generated by the current version of the plugin; or without the "." if it's an older version. + // Example message: `.routeguide.Feature` + // Service name will also include method, e.g. `.routeguide.RouteGuide/GetFeature` let message = interaction_config.get("message").and_then(proto_value_to_string); let service = interaction_config.get("service").and_then(proto_value_to_string); if message.is_none() && service.is_none() { @@ -328,6 +350,25 @@ impl ProtobufPactPlugin { } } + /// Generate contents for the interaction. + /// + /// Calls `generate_protobuf_contents` to do the actual generation. + /// + /// # Arguments: + /// + /// * `request` - The request to generate the contents. Contains the following fields: + /// * `contents`: the request body with `content_type`, `content` and `content_type_hint` fields, + /// * `generators`: map of generators for each field + /// * `plugin_configuration`: similar to `compare_contents` request: + /// * `interaction_configuration` with message/service, descriptorKey and package + /// * `pact_configuration` with hash of all descriptors + /// * `test_context`: test context + /// * `test_mode`: consumer or provider (or unknown) + /// * `content_for`: can be request or response + /// + /// # Returns + /// + /// Generated contents for the interaction. #[instrument(ret, skip(self))] fn generate_contents_impl(&self, request: &GenerateContentRequest) -> anyhow::Result { // Check for the plugin specific configuration for the interaction @@ -345,8 +386,7 @@ impl ProtobufPactPlugin { let content_type = ContentType::parse(contents.content_type.as_str())?; match content_type.attributes.get("message") { Some(message_type) => { - debug!("Generating contents for message {}", message_type); - let (message_descriptor, _file_descriptor) = find_message_type_by_name(message_type, &descriptors)?; + let (message_descriptor, _) = find_message_descriptor_for_type(message_type, &descriptors)?; let mut body = contents.content.clone().map(Bytes::from).unwrap_or_default(); if body.is_empty() { Ok(GenerateContentResponse::default()) @@ -403,6 +443,20 @@ impl ProtobufPactPlugin { } } +/// Generate contents for the interaction +/// +/// # Arguments: +/// * `fields` - all fields in the message to generate contents for +/// * `content_type` - content type of the message, comes from generation request +/// * `generators` - map of generators, comes from generation request +/// * `all_descriptors` - all descriptors for the interaction +/// (comes from plugin_configuration in the generation request) +/// +/// # Returns +/// Generated data for the interaction in form of `Body` struct which contains: +/// * `content_type` - content type of the generated message +/// * `content` - generated message bytes +/// * `content_type_hint` - always `ContentTypeHint::Binary` #[instrument(level = "trace")] fn generate_protobuf_contents( fields: &Vec, @@ -411,7 +465,7 @@ fn generate_protobuf_contents( all_descriptors: &FileDescriptorSet, mode: TestMode ) -> anyhow::Result { - let mut message = DynamicMessage::new(fields, all_descriptors); + let mut message: DynamicMessage = DynamicMessage::new(fields, all_descriptors, ); let variant_matcher = NoopVariantMatcher {}; let vm_boxed = variant_matcher.boxed(); let context = hashmap!{}; @@ -498,6 +552,7 @@ impl PactPlugin for ProtobufPactPlugin { } // Request to compare the contents and return the results of the comparison. + // see compare_contents_impl() for details. async fn compare_contents( &self, request: Request, @@ -580,6 +635,7 @@ impl PactPlugin for ProtobufPactPlugin { } // Request to generate the contents of the interaction. + // see generate_contents_impl() for details. async fn generate_content( &self, request: Request, @@ -702,6 +758,13 @@ impl PactPlugin for ProtobufPactPlugin { } } + /// Called as first step of provider verification flow. + /// - loads the pact from request json + /// - finds the interaction by key + /// - looks up gRPC service, method and file descriptors for interaction + /// - builds request body and metadata based on the loaded descriptors + /// - adds `request-path` field to metadata in form of `/package.Service/Method`, same as what protoc compiler does. + /// - returns the updated interaction with a built-out request async fn prepare_interaction_for_verification( &self, request: Request, @@ -729,7 +792,7 @@ impl PactPlugin for ProtobufPactPlugin { } }; - let (file_desc, service_desc, method_desc, package) = match lookup_service_descriptors_for_interaction(&interaction, &pact) { + let (all_file_desc, service_desc, method_desc, file_desc) = match lookup_service_descriptors_for_interaction(&interaction, &pact) { Ok(values) => values, Err(err) => { return Ok(Self::verification_preparation_error_response(err.to_string())) @@ -737,17 +800,16 @@ impl PactPlugin for ProtobufPactPlugin { }; let mut raw_request_body = interaction.request.contents.value().unwrap_or_default(); - let input_message_name = method_desc.input_type.clone().unwrap_or_default(); - let input_message = match find_message_type_by_name(last_name(input_message_name.as_str()), &file_desc) { + let input_message = match find_message_descriptor_for_type(method_desc.input_type(), &all_file_desc) { Ok((message, _)) => message, Err(err) => { return Ok(Self::verification_preparation_error_response(err.to_string())) } }; - let decoded_body = match decode_message(&mut raw_request_body, &input_message, &file_desc) { + let decoded_body = match decode_message(&mut raw_request_body, &input_message, &all_file_desc) { Ok(message) => { - let mut message = DynamicMessage::new(&message, &file_desc); + let mut message = DynamicMessage::new(&message, &all_file_desc); let config = proto_struct_to_map(&request.config.clone().unwrap_or_default()); if let Err(err) = message.apply_generators( interaction.request.generators.categories.get(&GeneratorCategory::BODY), @@ -770,8 +832,9 @@ impl PactPlugin for ProtobufPactPlugin { value: Some(proto::metadata_value::Value::NonBinaryValue(to_proto_value(v))) })) .collect(); - - let path = format!("/{}.{}/{}", package, service_desc.name.unwrap_or_default(), method_desc.name.unwrap_or_default()); + + let service_full_name = to_fully_qualified_name(service_desc.name(), file_desc.package()).unwrap_or_default(); + let path = build_grpc_route(service_full_name.as_str(), method_desc.name()).unwrap_or_default(); request_metadata.insert("request-path".to_string(), proto::MetadataValue { value: Some(proto::metadata_value::Value::NonBinaryValue(prost_types::Value { kind: Some(Kind::StringValue(path)) @@ -815,6 +878,11 @@ impl PactPlugin for ProtobufPactPlugin { })) } + /// Called as a second part in provider verification flow, + /// after `prepare_interaction_for_verification` to verify the interaction + /// After `prepare_interaction_for_verification` has built the request body and metadata, + /// this function will use that data to actually make the gRPC call to the provider and verify response. + /// Most of the work is done in `verification::verify_interaction` function. async fn verify_interaction( &self, request: Request @@ -834,6 +902,7 @@ impl PactPlugin for ProtobufPactPlugin { let key = request.interaction_key.as_str(); let interaction_by_id = lookup_interaction_by_id(key, &pact); + // TODO: this lookup of interactions by id is duplicate with at least one other function let interaction = match interaction_by_id { Some(interaction) => match interaction.as_v4_sync_message() { Some(interaction) => interaction, diff --git a/src/utils.rs b/src/utils.rs index a32808d..34d2e93 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -38,41 +38,85 @@ pub fn fds_to_map(fds: &FileDescriptorSet) -> HashMap) -> Vec { + descriptors.values().map(|d| *d).cloned().collect() +} + /// Return the last name in a dot separated string pub fn last_name(entry_type_name: &str) -> &str { entry_type_name.split('.').last().unwrap_or(entry_type_name) } /// Split a dot-seperated string into the package and name part -pub fn split_name(name: &str) -> (&str, Option<&str>) { - name.rsplit_once('.') +pub fn parse_name(name: &str) -> (&str, Option<&str>) { + // if name starts with the '.' it's a fully-qualified name that can contain a package + if name.starts_with('.') { + name.rsplit_once('.') .map(|(package, name)| { - if package.is_empty() { - (name, None) + if let Some(trimmed) = package.strip_prefix(".") { + (name, Some(trimmed)) } else { - if let Some(trimmed) = package.strip_prefix(".") { - (name, Some(trimmed)) - } else { - (name, Some(package)) - } + (name, Some(package)) } }) .unwrap_or_else(|| (name, None)) + } else { + // otherwise it's a relative name, so if it contains dots, this means embedded types, not packages? + (name, None) + } } -/// Search for a message by type name in all the file descriptors -pub fn find_message_type_by_name( - message_name: &str, - descriptors: &FileDescriptorSet -) -> anyhow::Result<(DescriptorProto, FileDescriptorProto)> { - descriptors.file.iter() - .map(|descriptor| { - find_message_type_in_file_descriptor(message_name, descriptor).map(|ds| (ds, descriptor)).ok() +/// Converts a relative protobuf type name to a fully qualified one by appending a `..` prefix, +/// or if the package is empty, just a `.` prefix. +/// E.g. `MyType` with package `example` becomes `.example.MyType` +/// and `MyType` with empty package becomes `.MyType` +pub fn to_fully_qualified_name(name: &str, package: &str) -> anyhow::Result { + match name { + "" => Err(anyhow!("type name cannot be empty when constructing a fully qualified name")), + _ => Ok(match package { + "" => format!(".{}", name), + _ => format!(".{}.{}", package, name) }) - .find(|result| result.is_some()) - .flatten() - .map(|(m, f)| (m, f.clone())) - .ok_or_else(|| anyhow!("Did not find a message type '{}' in the descriptors", message_name)) + } +} + +/// Split a service/method definition into two seprate parts. +/// E.g. MyService/MyMethod becomes ("MyService", "MyMethod") +pub fn split_service_and_method(service_name: &str) -> anyhow::Result<(&str, &str)> { + match service_name.split_once('/') { + Some(result) => Ok(result), + None => Err(anyhow!("Service name '{}' is not valid, it should be of the form /", service_name)) + } +} + + +/// Converts from `.package.Service` (fully-qualified name) and `Method` to `/package.Service/Method` +pub fn build_grpc_route(service_full_name: &str, method_name: &str) -> anyhow::Result { + if service_full_name.is_empty() { + return Err(anyhow!("Service name cannot be empty")); + } + if method_name.is_empty() { + return Err(anyhow!("Method name cannot be empty")); + } + let service_no_dot = if service_full_name.starts_with('.') { + &service_full_name[1..] // remove the leading dot + } else { + service_full_name + }; + Ok(format!("/{service_no_dot}/{method_name}")) +} + +/// Parses `/package.Service/Method` into `.package.Service` (fully-qualified name) and `Method` +pub fn parse_grpc_route(route_key: &str) -> Option<(String, String)> { + if !route_key.starts_with("/") { + return None; // invalid grpc route + } + // remove all trailing slashes + let route_key = route_key.trim_end_matches('/'); + match route_key[1..].split_once('/') { // remove the leading slash + Some((service, method)) => Some((format!(".{service}"), method.to_string())), + None => None + } } /// Search for a message by type name in the file descriptor @@ -81,53 +125,171 @@ pub fn find_message_type_in_file_descriptor( descriptor: &FileDescriptorProto ) -> anyhow::Result { descriptor.message_type.iter() - .find(|message| message.name.clone().unwrap_or_default() == message_name) + .find(|message| message.name() == message_name) .cloned() .ok_or_else(|| anyhow!("Did not find a message type '{}' in the file descriptor '{}'", message_name, descriptor.name.as_deref().unwrap_or("unknown"))) } -/// Finds message descriptor in a map of file descriptors. -pub fn find_message_descriptor_from_hash_map( - message_name: &str, - package: Option<&str>, +// TODO: handle nested types properly +// current name resolution is dumb - just splits package and message name by a dot +// but if you have .package.Message.NestedMessage.NestedMessageDeeperLevel this whole structure breaks down +// because we'll be looking for packages with the name `.package.Message.NestedMessage` +// while here the package is `.package` and then we need to find message called `Message` and go over it's nested types. +// To be fair, I don't think this ever worked properly - it was looking across all file descriptors instead of narrowing them down by packages, +// but it still wasn't looking for nested types. + +/// Helper to select a method descriptor by name from a service descriptor. +pub fn find_method_descriptor_for_service( + method_name: &str, + service_descriptor: &ServiceDescriptorProto +) -> anyhow::Result { + let method_descriptor = service_descriptor.method.iter().find(|method_desc| { + method_desc.name() == method_name + }).cloned().ok_or_else(|| anyhow!("Did not find the method {} in the Protobuf descriptor for service '{}'", + method_name, service_descriptor.name()))?; + trace!("Found method descriptor {:?} for method {}", method_descriptor, method_name); + Ok(method_descriptor) +} + +/// Find a descriptor for a given type name, fully qualified or relative. +/// Type name format is the same as in `type_name` field in field descriptor +/// or the `input_type`/`output_type` fields in method descriptor. +/// +/// If type name contains a dot ('.') it is a fully qualified name, so it is split into package name and message name; +/// if the package is empty, will only lookup messages which have no package. +/// +/// If type name does not contain a dot, it is a relative type. We'll search all file descriptors then. +/// This isn't techically correct, since we're supposed to start from the current file, and then search +/// level by level, but it's good enough for now (and this is how the plugin used to work for all messages anyway) +pub fn find_message_descriptor_for_type_in_vec( + type_name: &str, + all_descriptors: &Vec +) -> anyhow::Result<(DescriptorProto, FileDescriptorProto)> { + let (message_name, package) = parse_name(type_name); + find_message_descriptor(message_name, package, &all_descriptors) +} + +/// Find a descriptor for a given type name, fully qualified or relative. +/// Type name format is the same as in `type_name` field in field descriptor +/// or the `input_type`/`output_type` fields in method descriptor. +/// +/// If type name contains a dot ('.') it is a fully qualified name, so it is split into package name and message name; +/// if the package is empty, will only lookup messages which have no package. +/// +/// If type name does not contain a dot, it is a relative type. We'll search all file descriptors then. +/// This isn't techically correct, since we're supposed to start from the current file, and then search +/// level by level, but it's good enough for now (and this is how the plugin used to work for all messages anyway) +pub fn find_message_descriptor_for_type_in_map( + type_name: &str, descriptors: &HashMap, -) -> anyhow::Result { - let values = descriptors.values().map(|d| *d).cloned().collect(); - find_message_descriptor(message_name, package, values) +) -> anyhow::Result<(DescriptorProto, FileDescriptorProto)> { + let values = fds_map_to_vec(descriptors); + find_message_descriptor_for_type_in_vec(type_name, &values) } -/// Finds message descriptor in a vector of file descriptors. If the package is provided, it will -/// search only the descriptors matching the package. If not, it will search all descriptors with no package specified. -/// (because package is an optional field in proto3) -pub fn find_message_descriptor( +/// Find a descriptor for a given type name, fully qualified or relative. +/// Type name format is the same as in `type_name` field in field descriptor +/// or the `input_type`/`output_type` fields in method descriptor. +/// +/// If type name contains a dot ('.') it is a fully qualified name, so it is split into package name and message name; +/// if the package is empty, will only lookup messages which have no package. +/// +/// If type name does not contain a dot, it is a relative type. We'll search all file descriptors then. +/// This isn't techically correct, since we're supposed to start from the current file, and then search +/// level by level, but it's good enough for now (and this is how the plugin used to work for all messages anyway) +pub fn find_message_descriptor_for_type( + type_name: &str, + descriptors: &FileDescriptorSet, +) -> anyhow::Result<(DescriptorProto, FileDescriptorProto)> { + find_message_descriptor_for_type_in_vec(type_name, &descriptors.file) +} + +/// Finds message descriptor in a vector of file descriptors. If the package is not none, it will +/// search only the descriptors matching the package +/// (empty string means descriptors without package, because package is an optional field in proto3). +/// If it is none, it will search all descriptors, to support cases where pact was generated by the older +/// plugin version which didn't record the message package in the interaction config. +pub(crate) fn find_message_descriptor( message_name: &str, package: Option<&str>, - all_descriptors: Vec, -) -> anyhow::Result { - let descriptors; - if let Some(package) = package { - debug!( - "Looking for message '{}' in package '{}'", - message_name, package - ); - descriptors = find_all_file_descriptors_for_package(package, all_descriptors)?; + all_descriptors: &Vec, +) -> anyhow::Result<(DescriptorProto, FileDescriptorProto)> { + if package.is_some() { + trace!("Looking for message descriptor for message '{}' in package '{:?}'", message_name, package); } else { - descriptors = find_all_file_descriptors_with_no_package(all_descriptors)?; + trace!("Looking for message descriptor for message '{}'", message_name); } + let descriptors = find_file_descriptors(package, &all_descriptors)?; descriptors.iter() - .find_map(|fd| find_message_type_in_file_descriptor(message_name, fd).ok()) + .find_map(|fd| find_message_type_in_file_descriptor(message_name, fd).ok().map(|msg| (msg, fd.clone()))) .ok_or_else(|| { anyhow!( "Did not find a message type '{}' in any of the file descriptors '{:?}'", message_name, - descriptors.iter().map(|d| d.name.clone().unwrap_or_default()).collect::>()) + descriptors.iter().map(|d| d.name()).collect::>()) }) } +/// Find a service descriptor for a given service type name, fully qualified or relative. +/// +/// If type name contains a dot ('.') it is a fully qualified name, so it is split into package name and service name; +/// if the package is empty, will only lookup services which have no package. +/// +/// If type name does not contain a dot, it is a relative type. We'll search all file descriptors then. +/// This isn't techically correct, since we're supposed to start from the current file, and then search +/// level by level, but it's good enough for now (and this is how the plugin used to work for all services anyway) +pub(crate) fn find_service_descriptor_for_type( + type_name: &str, + all_descriptors: &FileDescriptorSet +) -> anyhow::Result<(FileDescriptorProto, ServiceDescriptorProto)> { + let (message_name, package) = parse_name(type_name); + find_service_descriptor(message_name, package, all_descriptors) +} + +pub(crate) fn find_service_descriptor( + service_name: &str, + package: Option<&str>, + descriptors: &FileDescriptorSet +) -> anyhow::Result<(FileDescriptorProto, ServiceDescriptorProto)> { + if package.is_some() { + debug!("Looking for service '{}' with package '{:?}'", service_name, package); + } else { + debug!("Looking for service '{}'", service_name); + } + let file_descriptors = find_file_descriptors(package, &descriptors.file)?; + file_descriptors.iter().filter_map(|descriptor| { + descriptor.service.iter() + .find(|p| p.name() == service_name) + .map(|p| { + trace!("Found service descriptor with name {:?}", p.name); + (descriptor.clone(), p.clone()) + }) + }) + .next() + .ok_or_else(|| anyhow!("Did not find a descriptor for service '{}'", service_name)) +} + +pub fn find_file_descriptors( + package: Option<&str>, + all_descriptors: &Vec, +) -> anyhow::Result> { + match package { + Some(pkg) if pkg.is_empty() => { + debug!("Looking for file descriptors with no package"); + find_all_file_descriptors_with_no_package(all_descriptors) + } + Some(pkg) => { + debug!("Looking for file descriptors with package '{}'", pkg); + find_all_file_descriptors_for_package(pkg, all_descriptors) + } + None => Ok(all_descriptors.clone()) + } +} + fn find_all_file_descriptors_for_package( package: &str, - all_descriptors: Vec, + all_descriptors: &Vec, ) -> anyhow::Result> { let package = if package.starts_with('.') { &package[1..] @@ -145,7 +307,7 @@ fn find_all_file_descriptors_for_package( } }).cloned().collect(); if found.is_empty() { - Err(anyhow!("Did not find a file descriptor for a package '{}'", package)) + Err(anyhow!("Did not find any file descriptors for a package '{}'", package)) } else { debug!("Found {} file descriptors for package '{}'", found.len(), package); Ok(found) @@ -153,7 +315,7 @@ fn find_all_file_descriptors_for_package( } fn find_all_file_descriptors_with_no_package( - all_descriptors: Vec + all_descriptors: &Vec ) -> anyhow::Result> { let found: Vec<_> = all_descriptors.iter().filter(|d| d.package.is_none()).cloned().collect(); if found.is_empty() { @@ -184,8 +346,7 @@ pub fn is_map_field(message_descriptor: &DescriptorProto, field: &FieldDescripto pub fn find_nested_type(message_descriptor: &DescriptorProto, field: &FieldDescriptorProto) -> Option { trace!(">> find_nested_type({:?}, {:?}, {:?}, {:?})", message_descriptor.name, field.name, field.r#type(), field.type_name); if field.r#type() == Type::Message { - let type_name = field.type_name.clone().unwrap_or_default(); - let message_type = last_name(type_name.as_str()); + let message_type = last_name(field.type_name()); trace!("find_nested_type: Looking for nested type '{}'", message_type); message_descriptor.nested_type.iter().find(|nested| { trace!("find_nested_type: type = '{:?}'", nested.name); @@ -219,7 +380,7 @@ pub(crate) fn display_bytes(data: &[u8]) -> String { /// Look for the message field data with the given name pub fn find_message_field_by_name(descriptor: &DescriptorProto, field_data: Vec, field_name: &str) -> Option { let field_num = match descriptor.field.iter() - .find(|f| f.name.clone().unwrap_or_default() == field_name) + .find(|f| f.name() == field_name) .map(|f| f.number.unwrap_or(-1)) { Some(n) => n, None => return None @@ -306,9 +467,9 @@ pub fn find_enum_value_by_name( let enum_name_full = enum_name.split('.').filter(|v| !v.is_empty()).collect::>().join("."); let result = descriptors.values() .find_map(|fd| { - let package = fd.package.clone().unwrap_or_default(); - if enum_name_full.starts_with(&package) { - let enum_name_short = enum_name_full.replace(&package, ""); + let package = fd.package(); + if enum_name_full.starts_with(package) { + let enum_name_short = enum_name_full.replace(package, ""); let enum_name_parts = enum_name_short.split('.').filter(|v| !v.is_empty()).collect::>(); if let Some((_name, message_name)) = enum_name_parts.split_last() { if message_name.is_empty() { @@ -342,12 +503,17 @@ pub fn find_enum_by_name( enum_name: &str ) -> Option { trace!(">> find_enum_by_name({})", enum_name); + // TODO: unify this name split logic with the one in split_name let enum_name_full = enum_name.split('.').filter(|v| !v.is_empty()).collect::>().join("."); let result = descriptors.file.iter() .find_map(|fd| { - let package = fd.package.clone().unwrap_or_default(); - if enum_name_full.starts_with(&package) { - let enum_name_short = enum_name_full.replace(&package, ""); + // TODO: combine this with the rest of the package search logic; + // this one actually supports nested enum types, + // but starts_with check is not always correct (need to split by dots) + // and I don't think it recurses inside message-in-message + let package = fd.package(); + if enum_name_full.starts_with(package) { + let enum_name_short = enum_name_full.replace(package, ""); let enum_name_parts = enum_name_short.split('.').filter(|v| !v.is_empty()).collect::>(); if let Some((_name, message_name)) = enum_name_parts.split_last() { if message_name.is_empty() { @@ -466,11 +632,38 @@ pub fn lookup_interaction_config(interaction: &dyn V4Interaction) -> Option anyhow::Result>{ + let plugin_config = pact.plugin_data.iter() + .find(|data| data.name == "protobuf") + .map(|data| &data.configuration) + .ok_or_else(|| anyhow!("Did not find any Protobuf configuration in the Pact file"))? + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + Ok(plugin_config) +} + +/// Returns the service descriptors for the given interaction. +/// Will load all descriptors from the pact file using `descriptorKey` from interaction config, +/// and then find the correct file, service and method descriptors using `service` value from +/// the interaction config. +/// +/// # Arguments +/// - `interaction` - A specific interaction from the pact +/// - `pact` - Pact (contains this interaction too) +/// +/// # Returns +/// A tuple of: +/// - FileDescriptorSet - all available file descriptors +/// - ServiceDescriptorProto - the service descriptor for this gRPC service +/// - MethodDescriptorProto - the method descriptor for this gRPC service +/// - FileDescriptorProto - the file descriptor containing this gRPC service pub(crate) fn lookup_service_descriptors_for_interaction( interaction: &dyn V4Interaction, pact: &V4Pact -) -> anyhow::Result<(FileDescriptorSet, ServiceDescriptorProto, MethodDescriptorProto, String)> { +) -> anyhow::Result<(FileDescriptorSet, ServiceDescriptorProto, MethodDescriptorProto, FileDescriptorProto)> { + // TODO: a similar flow happens in server::compare_contents, can it be refactored to a common function? + // compare_contents works with both service and message, while this one only works with the service. let interaction_config = lookup_interaction_config(interaction) .ok_or_else(|| anyhow!("Interaction does not have any Protobuf configuration"))?; let descriptor_key = interaction_config.get("descriptorKey") @@ -479,25 +672,27 @@ pub(crate) fn lookup_service_descriptors_for_interaction( let service = interaction_config.get("service") .map(json_to_string) .ok_or_else(|| anyhow!("Interaction gRPC service was missing in Pact file"))?; - let (service_name, method_name) = service.split_once('/') - .ok_or_else(|| anyhow!("Service name '{}' is not valid, it should be of the form /", service))?; - - let plugin_config = pact.plugin_data.iter() - .find(|data| data.name == "protobuf") - .map(|data| &data.configuration) - .ok_or_else(|| anyhow!("Did not find any Protobuf configuration in the Pact file"))? - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect(); - let descriptors = get_descriptors_for_interaction(descriptor_key.as_str(), - &plugin_config)?; - let (file_descriptor, service_descriptor) = find_service_descriptor(&descriptors, service_name)?; - let method_descriptor = service_descriptor.method.iter().find(|method_desc| { - method_desc.name.clone().unwrap_or_default() == method_name - }).ok_or_else(|| anyhow!("Did not find the method {} in the Protobuf file descriptor for service '{}'", method_name, service))?; + + let (service_with_package, method_name) = split_service_and_method(service.as_str())?; + trace!("gRPC service for interaction: {}", service_with_package); + + let plugin_config = lookup_plugin_config(pact)?; + let descriptors = get_descriptors_for_interaction(descriptor_key.as_str(), &plugin_config)?; + trace!("file descriptors for interaction {:?}", descriptors); + + let (file_descriptor, service_descriptor) = find_service_descriptor_for_type(service_with_package, &descriptors)?; + let method_descriptor = find_method_descriptor_for_service( method_name, &service_descriptor)?; + Ok((descriptors.clone(), service_descriptor.clone(), method_descriptor.clone(), file_descriptor.clone())) +} - let package = file_descriptor.package.clone(); - Ok((descriptors.clone(), service_descriptor.clone(), method_descriptor.clone(), package.unwrap_or_default())) +fn get_descriptor_config<'a>( + message_key: &str, + plugin_config: &'a BTreeMap +) -> anyhow::Result<&'a serde_json::Map> { + plugin_config.get(message_key) + .ok_or_else(|| anyhow!("Plugin configuration item with key '{}' is required. Received config {:?}", message_key, plugin_config.keys()))? + .as_object() + .ok_or_else(|| anyhow!("Plugin configuration item with key '{}' has an invalid format", message_key)) } /// Get the encoded Protobuf descriptors from the Pact level configuration for the message key @@ -505,10 +700,7 @@ pub fn get_descriptors_for_interaction( message_key: &str, plugin_config: &BTreeMap ) -> anyhow::Result { - let descriptor_config = plugin_config.get(message_key) - .ok_or_else(|| anyhow!("Plugin configuration item with key '{}' is required. Received config {:?}", message_key, plugin_config.keys()))? - .as_object() - .ok_or_else(|| anyhow!("Plugin configuration item with key '{}' has an invalid format", message_key))?; + let descriptor_config = get_descriptor_config(message_key, plugin_config)?; let descriptor_bytes_encoded = descriptor_config.get("protoDescriptors") .map(json_to_string) .unwrap_or_default(); @@ -537,19 +729,6 @@ pub fn get_descriptors_for_interaction( .map_err(|err| anyhow!(err)) } -pub(crate) fn find_service_descriptor<'a>( - descriptors: &'a FileDescriptorSet, - service_name: &str -) -> anyhow::Result<(&'a FileDescriptorProto, &'a ServiceDescriptorProto)> { - descriptors.file.iter().filter_map(|descriptor| { - descriptor.service.iter() - .find(|p| p.name.clone().unwrap_or_default() == service_name) - .map(|p| (descriptor, p)) - }) - .next() - .ok_or_else(|| anyhow!("Did not find a descriptor for service '{}'", service_name)) -} - /// If a field type should be packed. These are repeated fields of primitive numeric types /// (types which use the varint, 32-bit, or 64-bit wire types) pub fn should_be_packed_type(field_type: Type) -> bool { @@ -590,11 +769,12 @@ pub(crate) fn prost_string>(s: S) -> Value { #[cfg(test)] pub(crate) mod tests { + use std::collections::HashSet; use std::vec; use bytes::Bytes; use expectest::prelude::*; - use maplit::hashmap; + use maplit::{hashmap, hashset}; use prost::Message; use prost_types::{ DescriptorProto, @@ -608,16 +788,11 @@ use bytes::Bytes; use prost_types::field_descriptor_proto::{Label, Type}; use crate::utils::{ - as_hex, - find_message_descriptor, - find_enum_value_by_name, - find_message_type_by_name, - find_nested_type, - is_map_field, - last_name, - split_name + to_fully_qualified_name, as_hex, find_enum_value_by_name, find_nested_type, is_map_field, last_name, parse_name }; +use super::{build_grpc_route, find_file_descriptors, find_message_descriptor_for_type, find_method_descriptor_for_service, find_service_descriptor_for_type, parse_grpc_route, split_service_and_method}; + #[test] fn last_name_test() { expect!(last_name("")).to(be_equal_to("")); @@ -630,14 +805,61 @@ use bytes::Bytes; } #[test] - fn split_name_test() { - expect!(split_name("")).to(be_equal_to(("", None))); - expect!(split_name("test")).to(be_equal_to(("test", None))); - expect!(split_name(".")).to(be_equal_to(("", None))); - expect!(split_name("test.")).to(be_equal_to(("", Some("test")))); - expect!(split_name(".test")).to(be_equal_to(("test", None))); - expect!(split_name("1.2")).to(be_equal_to(("2", Some("1")))); - expect!(split_name("1.2.3.4")).to(be_equal_to(("4", Some("1.2.3")))); + fn parse_name_test() { + // fully-qulified names start with a dot + expect!(parse_name(".package.Type")).to(be_equal_to(("Type", Some("package")))); + expect!(parse_name(".Type")).to(be_equal_to(("Type", Some("")))); + expect!(parse_name(".")).to(be_equal_to(("", Some("")))); // TODO: should this be an error case? + + // relative names must have package set to None always + expect!(parse_name("")).to(be_equal_to(("", None))); // TODO: should this be an error case? + expect!(parse_name("test")).to(be_equal_to(("test", None))); + expect!(parse_name("test.")).to(be_equal_to(("test.", None))); + expect!(parse_name("1.2.3.4")).to(be_equal_to(("1.2.3.4", None))); + } + + #[test] + fn split_service_and_method_test() { + expect!(split_service_and_method("")).to(be_err()); + expect!(split_service_and_method("test")).to(be_err()); + expect!(split_service_and_method("/").unwrap()).to(be_equal_to(("", ""))); + expect!(split_service_and_method("/method").unwrap()).to(be_equal_to(("", "method"))); + expect!(split_service_and_method("service/").unwrap()).to(be_equal_to(("service", ""))); + expect!(split_service_and_method("service/method").unwrap()).to(be_equal_to(("service", "method"))); + // TODO: we don't support this case either way - maybe we should error out if there's more than one slash? + expect!(split_service_and_method("service/subservice/method").unwrap()).to(be_equal_to(("service", "subservice/method"))); + } + + #[test] + fn to_fully_qualified_name_test() { + expect!(to_fully_qualified_name("service", "package").unwrap()).to(be_equal_to(".package.service")); + expect!(to_fully_qualified_name("service", "package.with.dots").unwrap()).to(be_equal_to(".package.with.dots.service")); + expect!(to_fully_qualified_name("service", "").unwrap()).to(be_equal_to(".service")); + expect!(to_fully_qualified_name("", "package")).to(be_err()); + } + + #[test] + fn test_build_grpc_route() { + // Valid inputs + expect!(build_grpc_route(".com.example.Service", "Method").unwrap()).to(be_equal_to("/com.example.Service/Method")); + expect!(build_grpc_route("com.example.Service", "Method").unwrap()).to(be_equal_to("/com.example.Service/Method")); + + // Errors + expect!(build_grpc_route("", "Method")).to(be_err()); + expect!(build_grpc_route("com.example.Service", "")).to(be_err()); + expect!(build_grpc_route("", "")).to(be_err()); + } + + #[test] + fn test_parse_grpc_route() { + // Valid inputs + expect!(parse_grpc_route("/com.example.Service/Method")).to(be_some().value((".com.example.Service".to_string(), "Method".to_string()))); + expect!(parse_grpc_route("/com.example.Service/Method/")).to(be_some().value((".com.example.Service".to_string(), "Method".to_string()))); + + // Errors + expect!(parse_grpc_route("com.example.Service/Method")).to(be_none()); + expect!(parse_grpc_route("/com.example.Service")).to(be_none()); + expect!(parse_grpc_route("/com.example.Service/")).to(be_none()); } pub(crate) const DESCRIPTOR_WITH_EXT_MESSAGE: [u8; 626] = [ @@ -674,19 +896,31 @@ use bytes::Bytes; ]; #[test] - fn find_message_type_by_name_test() { + fn find_message_descriptor_for_type_ext_test() { + /* + Contents of the descriptor: + File descriptor: Some("Value.proto") package Some("area_calculator.Value") + Message: Some("AdBreakContext") + Enum: Some("AdBreakAdType") + File descriptor: Some("area_calculator.proto") package Some("area_calculator") + Message: Some("AdBreakRequest") + Message: Some("AreaResponse") + Service: Some("Calculator") + Method: Some("calculateOne") + */ let bytes: &[u8] = &DESCRIPTOR_WITH_EXT_MESSAGE; let buffer = Bytes::from(bytes); let fds = FileDescriptorSet::decode(buffer).unwrap(); - expect!(find_message_type_by_name("", &fds)).to(be_err()); - expect!(find_message_type_by_name("Does not exist", &fds)).to(be_err()); + expect!(find_message_descriptor_for_type("", &fds)).to(be_err()); + expect!(find_message_descriptor_for_type("Does not exist", &fds)).to(be_err()); - let (result, _) = find_message_type_by_name("AdBreakRequest", &fds).unwrap(); + let (result, _) = find_message_descriptor_for_type("AdBreakRequest", &fds).unwrap(); expect!(result.name).to(be_some().value("AdBreakRequest")); - let (result, _) = find_message_type_by_name("AdBreakContext", &fds).unwrap(); + let (result, file_descriptor) = find_message_descriptor_for_type(".area_calculator.Value.AdBreakContext", &fds).unwrap(); expect!(result.name).to(be_some().value("AdBreakContext")); + expect!(file_descriptor.package).to(be_some().value("area_calculator.Value")); } #[test] @@ -930,104 +1164,205 @@ use bytes::Bytes; } #[test] - fn find_message_descriptor_test() { + fn find_message_descriptor_for_type_test() { + let request_msg = DescriptorProto { + name: Some("Request".to_string()), + .. DescriptorProto::default() + }; + let another_request_msg = DescriptorProto { + name: Some("AnotherRequest".to_string()), + .. DescriptorProto::default() + }; + let request_file: FileDescriptorProto = FileDescriptorProto { + name: Some("request.proto".to_string()), + package: Some("service".to_string()), + message_type: vec![ + request_msg.clone(), + another_request_msg.clone() + ], + .. FileDescriptorProto::default() + }; + let request_file2: FileDescriptorProto = FileDescriptorProto { + name: Some("request.proto".to_string()), + package: Some("service2".to_string()), + message_type: vec![ + request_msg.clone() + ], + .. FileDescriptorProto::default() + }; + let all_descriptors = FileDescriptorSet{file: vec!{request_file.clone(), request_file2.clone()}}; + // fully qualified name + let (md, fd) = find_message_descriptor_for_type(".service.Request", &all_descriptors).unwrap(); + expect!(&md).to(be_equal_to(&request_msg)); + expect!(&fd).to(be_equal_to(&request_file)); + + // relative name + let (md, fd) = find_message_descriptor_for_type("AnotherRequest", &all_descriptors).unwrap(); + expect!(&md).to(be_equal_to(&another_request_msg)); + expect!(&fd).to(be_equal_to(&request_file)); + + // package not found error + let result_err = find_message_descriptor_for_type(".missing.MissingType", &all_descriptors); + expect!(result_err.as_ref()).to(be_err()); + expect!(&result_err.unwrap_err().to_string()).to(be_equal_to( + "Did not find any file descriptors for a package 'missing'")); + // message not found error + let result_err = find_message_descriptor_for_type(".service.MissingType", &all_descriptors); + expect!(result_err.as_ref()).to(be_err()); + let error_msg = result_err.unwrap_err().to_string(); + expect!(error_msg.starts_with( + "Did not find a message type 'MissingType' in any of the file descriptors")).to(be_true()); + } + + #[test] + fn find_service_descriptor_for_type_test() { + let service_desc = ServiceDescriptorProto { + name: Some("Service".to_string()), + .. ServiceDescriptorProto::default() + }; let service = FileDescriptorProto { name: Some("service.proto".to_string()), package: Some("service".to_string()), service: vec![ + service_desc.clone(), ServiceDescriptorProto { - name: Some("Service".to_string()), - method: vec![ - MethodDescriptorProto { - name: Some("Method".to_string()), - input_type: Some(".service.Request".to_string()), - output_type: Some(".service.Response".to_string()), - .. MethodDescriptorProto::default() - } - ], + name: Some("AnotherService".to_string()), .. ServiceDescriptorProto::default() } ], .. FileDescriptorProto::default() }; + let relative_name_service = ServiceDescriptorProto { + name: Some("RelativeNameService".to_string()), + .. ServiceDescriptorProto::default() + }; + let service2 = FileDescriptorProto { + name: Some("service.proto".to_string()), + package: Some("service".to_string()), + service: vec![ + ServiceDescriptorProto { + name: Some("Service".to_string()), + .. ServiceDescriptorProto::default() + }, + relative_name_service.clone() + ], + .. FileDescriptorProto::default() + }; + let all_descriptors = FileDescriptorSet { file: vec!{service.clone(), service2.clone()} }; + + let (fd, sd) = find_service_descriptor_for_type(".service.Service", &all_descriptors).unwrap(); + expect!(fd).to(be_equal_to(service)); + expect!(sd).to(be_equal_to(service_desc)); + + let (fd, sd) = find_service_descriptor_for_type("RelativeNameService", &all_descriptors).unwrap(); + expect!(fd).to(be_equal_to(service2)); + expect!(sd).to(be_equal_to(relative_name_service)); + + // missing package case + let result_err = find_service_descriptor_for_type(".missing.MissingService", &all_descriptors); + expect!(result_err.as_ref()).to(be_err()); + expect!(&result_err.unwrap_err().to_string()).to(be_equal_to( + "Did not find any file descriptors for a package 'missing'")); + // missing service case + let result_err = find_service_descriptor_for_type(".service.MissingService", &all_descriptors); + expect!(result_err.as_ref()).to(be_err()); + expect!(&result_err.unwrap_err().to_string()).to(be_equal_to( + "Did not find a descriptor for service 'MissingService'")); + } + + #[test] + fn find_file_descriptors_test() { let request: FileDescriptorProto = FileDescriptorProto { name: Some("request.proto".to_string()), package: Some("service".to_string()), - message_type: vec![ - DescriptorProto { - name: Some("Request".to_string()), - field: vec![ - FieldDescriptorProto { - name: Some("field".to_string()), - number: Some(1), - type_name: Some("string".to_string()), - .. FieldDescriptorProto::default() - }, - ], - .. DescriptorProto::default() - } - ], .. FileDescriptorProto::default() }; let response = FileDescriptorProto { name: Some("response.proto".to_string()), package: Some("service".to_string()), - message_type: vec![ - DescriptorProto { - name: Some("Response".to_string()), - .. DescriptorProto::default() - } - ], .. FileDescriptorProto::default() }; let request_no_package = FileDescriptorProto { name: Some("request_no_package.proto".to_string()), - message_type: vec![ - DescriptorProto { - name: Some("Request".to_string()), - field: vec![ - FieldDescriptorProto { - name: Some("bool_field".to_string()), - number: Some(1), - type_name: Some("bool".to_string()), - .. FieldDescriptorProto::default() - }, - ], - .. DescriptorProto::default() - } - ], .. FileDescriptorProto::default() }; - let all_descriptors = vec!{service, request, response, request_no_package}; + let response_no_package = FileDescriptorProto { + name: Some("response_no_package.proto".to_string()), + .. FileDescriptorProto::default() + }; + let all_descriptors_with_package_names = hashset!{ + "request.proto".to_string(), + "response.proto".to_string() + }; + let all_descriptors_with_no_pacakge_names = hashset!{ + "request_no_package.proto".to_string(), + "response_no_package.proto".to_string() + }; + let all_descritptor_names = hashset!{ + "request.proto".to_string(), + "response.proto".to_string(), + "request_no_package.proto".to_string(), + "response_no_package.proto".to_string() + }; + let all_descriptors = vec!{request, response, request_no_package, response_no_package}; // explicitly provide package name - let result_explicit_pkg = find_message_descriptor("Request", Some("service"), all_descriptors.clone()); - expect!(result_explicit_pkg.as_ref().unwrap().field[0].name.as_ref()).to(be_some().value(&"field")); + _check_find_file_descriptors(Some("service"), &all_descriptors_with_package_names, &all_descriptors); + // same but with a dot - let result_explicit_pkg_dot = find_message_descriptor("Request", Some(".service"), all_descriptors.clone()); - expect!(result_explicit_pkg_dot.as_ref().unwrap().field[0].name.as_ref()).to(be_some().value(&"field")); + _check_find_file_descriptors(Some(".service"), &all_descriptors_with_package_names, &all_descriptors); + + // empty package means return descriptors without packages only + _check_find_file_descriptors(Some(""), &all_descriptors_with_no_pacakge_names, &all_descriptors); + + // none package means return all descriptors + _check_find_file_descriptors(None, &all_descritptor_names, &all_descriptors); + + // Errors + // did not find any file descriptor with specified package + let result = find_file_descriptors(Some("missing"), &all_descriptors); + expect!(result.as_ref()).to(be_err()); + expect!(&result.unwrap_err().to_string()).to(be_equal_to("Did not find any file descriptors for a package 'missing'")); + // did not find any file descriptors with no package + let result = find_file_descriptors(Some(""), &vec!{}); + expect!(&result.unwrap_err().to_string()).to(be_equal_to("Did not find any file descriptors with no package specified")); + } - // no package provided means search descriptors without packages only - let result_no_pkg = find_message_descriptor("Request", None, all_descriptors.clone()); - expect!(result_no_pkg.as_ref().unwrap().field[0].name.as_ref()).to(be_some().value(&"bool_field")); + fn _check_find_file_descriptors( + package: Option<&str>, + expected: &HashSet, + all_descriptors: &Vec + ) { + let actual = find_file_descriptors(package, all_descriptors).unwrap().iter() + .map(|d: &FileDescriptorProto| d.name.clone().unwrap_or_default()).collect::>(); + expect!(&actual).to(be_equal_to(expected)); + } - // message not found error - let result_err = find_message_descriptor("Missing", Some("service"), all_descriptors.clone()); + #[test] + fn find_method_descriptor_for_service_test() { + let method_desc1 = MethodDescriptorProto{ + name: Some("method1".to_string()), + ..MethodDescriptorProto::default() + }; + let method_desc2 = MethodDescriptorProto{ + name: Some("method2".to_string()), + ..MethodDescriptorProto::default() + }; + let service_desc = ServiceDescriptorProto { + name: Some("Service".to_string()), + method: vec!{ + method_desc1.clone(), + method_desc2.clone() + }, + .. ServiceDescriptorProto::default() + }; + let actual = find_method_descriptor_for_service("method1", &service_desc).unwrap(); + expect!(actual).to(be_equal_to(method_desc1)); + // error case + let result_err = find_method_descriptor_for_service("missing", &service_desc); expect!(result_err.as_ref()).to(be_err()); - expect!(result_err.unwrap_err().to_string() - .starts_with("Did not find a message type 'Missing' in any of the file descriptors")) - .to(be_true()); - - // file descriptor not found for package - let result_err_no_pkg = find_message_descriptor("Request", Some("missing"), all_descriptors.clone()); - expect!(result_err_no_pkg.as_ref()).to(be_err()); - // "Did not find a file descriptor for package 'missing'" - expect!(result_err_no_pkg.unwrap_err().to_string()) - .to(be_equal_to("Did not find a file descriptor for a package 'missing'")); - - // no descriptors found without a package - let result_err_no_pkg = find_message_descriptor("Request", None, vec!{}); - expect!(result_err_no_pkg.as_ref()).to(be_err()); - expect!(result_err_no_pkg.unwrap_err().to_string()) - .to(be_equal_to("Did not find any file descriptors with no package specified")); + expect!(result_err.unwrap_err().to_string()) + .to(be_equal_to("Did not find the method missing in the Protobuf descriptor for service 'Service'")); } + } + diff --git a/src/verification.rs b/src/verification.rs index 5c8bc21..ca8c655 100644 --- a/src/verification.rs +++ b/src/verification.rs @@ -9,7 +9,6 @@ use anyhow::anyhow; use bytes::BytesMut; use maplit::hashmap; use pact_matching::{BodyMatchResult, CoreMatchingContext, DiffConfig, Mismatch}; -use pact_models::content_types::ContentType; use pact_models::json_utils::{json_to_num, json_to_string}; use pact_models::prelude::OptionalBody; use pact_models::prelude::v4::V4Pact; @@ -18,7 +17,7 @@ use pact_models::v4::sync_message::SynchronousMessage; use pact_plugin_driver::proto; use pact_plugin_driver::utils::proto_value_to_string; use pact_verifier::verification_result::VerificationMismatchResult; -use prost_types::{DescriptorProto, FileDescriptorSet, MethodDescriptorProto, ServiceDescriptorProto}; +use prost_types::{DescriptorProto, FileDescriptorSet, MethodDescriptorProto}; use serde_json::Value; use tonic::{Request, Response, Status}; use tonic::metadata::{Ascii, Binary, MetadataKey, MetadataMap, MetadataValue}; @@ -26,10 +25,10 @@ use tower::ServiceExt; use tracing::{debug, error, instrument, trace, warn}; use crate::dynamic_message::{DynamicMessage, PactCodec}; -use crate::matching::match_service; +use crate::matching::match_message; use crate::message_decoder::decode_message; use crate::metadata::{compare_metadata, grpc_status, MetadataMatchResult}; -use crate::utils::{find_message_type_by_name, last_name, lookup_service_descriptors_for_interaction}; +use crate::utils::{find_message_descriptor_for_type, lookup_service_descriptors_for_interaction}; #[derive(Debug)] struct GrpcError { @@ -44,7 +43,21 @@ impl Display for GrpcError { impl std::error::Error for GrpcError {} -/// Verify a gRPC interaction +/// Verify a gRPC interaction. +/// +/// This function will make a call to the gRPC server with the request body and metadata +/// and call `verify_response` to compare the response with the expected response in the interaction. +/// +/// # Arguments +/// * `pact` - Pact to verify against, full pact config containing all interactions and plugin configuration +/// * `interaction` - Interaction to verify +/// * `request_body` - Request body to send to the gRPC server; created in `server::prepare_interaction_for_verification` +/// * `metadata` - Metadata to send to the gRPC server; created in `server::prepare_interaction_for_verification` +/// * `config` - host and port to connect to the gRPC server +/// +/// # Returns +/// A tuple with a vector of verification results and a vector of strings +/// with the human-readable output of the verification pub async fn verify_interaction( pact: &V4Pact, interaction: &SynchronousMessage, @@ -53,19 +66,24 @@ pub async fn verify_interaction( config: &HashMap ) -> anyhow::Result<(Vec, Vec)> { debug!("Verifying interaction {}", interaction); - trace!("interaction={:?}", interaction); - trace!("metadata={:?}", metadata); - trace!("config={:?}", config); + trace!(?interaction, ?metadata, ?config, ?request_body, ?pact); - let (file_desc, service_desc, method_desc, _) = lookup_service_descriptors_for_interaction(interaction, pact)?; + let (all_file_descriptors, service_desc, method_desc, _) = + lookup_service_descriptors_for_interaction(interaction, pact)?; + let input_message_name = method_desc.input_type.clone().unwrap_or_default(); - let input_message = find_message_type_by_name(last_name(input_message_name.as_str()), &file_desc)?.0; + let (input_message_desc, _) = find_message_descriptor_for_type( + input_message_name.as_str(), &all_file_descriptors)?; + let output_message_name = method_desc.output_type.clone().unwrap_or_default(); - let output_message = find_message_type_by_name(last_name(output_message_name.as_str()), &file_desc)?.0; + // uses type name from method_descriptor, which always contains the doc; 3-way logic is safe here + let (output_message_desc, _) = find_message_descriptor_for_type( + output_message_name.as_str(), &all_file_descriptors)?; let bold = Style::new().bold(); - match build_grpc_request(request_body, metadata, &file_desc, &input_message) { - Ok(request) => match make_grpc_request(request, config, metadata, &file_desc, &input_message, &output_message, interaction).await { + match build_grpc_request(request_body, metadata, &all_file_descriptors, &input_message_desc) { + Ok(request) => match make_grpc_request( + request, config, metadata, &all_file_descriptors, &input_message_desc, &output_message_desc, interaction).await { Ok(response) => { debug!("Received response from gRPC server - {:?}", response); let response_metadata = response.metadata(); @@ -73,7 +91,7 @@ pub async fn verify_interaction( trace!("gRPC metadata: {:?}", response_metadata); trace!("gRPC body: {:?}", body); let (result, verification_output) = verify_response(body, response_metadata, interaction, - &file_desc, &service_desc, &method_desc)?; + &all_file_descriptors, &method_desc)?; let status_result = if !result.is_empty() { Red.paint("FAILED") @@ -177,13 +195,13 @@ fn verify_error_response( (results, output) } +/// Verify response from the gRPC server against expected response in the interaction fn verify_response( response_body: &DynamicMessage, response_metadata: &MetadataMap, interaction: &SynchronousMessage, - file_desc: &FileDescriptorSet, - service_desc: &ServiceDescriptorProto, - method_desc: &MethodDescriptorProto + all_file_descriptors: &FileDescriptorSet, + method_descriptor: &MethodDescriptorProto ) -> anyhow::Result<(Vec, Vec)> { let response = interaction.response.first().cloned() .unwrap_or_default(); @@ -196,22 +214,16 @@ fn verify_response( let mut output = vec![]; if let Some(mut expected_body) = expected_body { - let ct = ContentType { - main_type: "application".into(), - sub_type: "grpc".into(), - .. ContentType::default() - }; let mut actual_body = BytesMut::new(); response_body.write_to(&mut actual_body)?; - match match_service( - service_desc.name.clone().unwrap_or_default().as_str(), - method_desc.name.clone().unwrap_or_default().as_str(), - file_desc, + + match match_message( + method_descriptor.output_type(), + all_file_descriptors, &mut expected_body, &mut actual_body.freeze(), &response.matching_rules.rules_for_category("body").unwrap_or_default(), - true, - &ct + true ) { Ok(result) => { debug!("Match service result: {:?}", result); @@ -322,6 +334,7 @@ fn build_grpc_request( file_desc: &FileDescriptorSet, input_desc: &DescriptorProto ) -> anyhow::Result> { + trace!(?body, ?metadata, ?file_desc, ?input_desc, ">> build_grpc_request"); let mut bytes = body.value().unwrap_or_default(); let message_fields = decode_message(&mut bytes, input_desc, file_desc)?; let mut request = Request::new(DynamicMessage::new(&message_fields, file_desc)); diff --git a/tests/basic_values_test.rs b/tests/basic_values_test.rs index f983e6a..5af8221 100644 --- a/tests/basic_values_test.rs +++ b/tests/basic_values_test.rs @@ -8,7 +8,7 @@ use serde_json::json; use pact_protobuf_plugin::message_decoder::{decode_message, ProtobufField}; use pact_protobuf_plugin::message_decoder::ProtobufFieldData::{Boolean, Double, Integer32, String, UInteger32}; -use pact_protobuf_plugin::utils::{find_message_type_by_name, get_descriptors_for_interaction, lookup_interaction_config}; +use pact_protobuf_plugin::utils::{find_message_descriptor_for_type, get_descriptors_for_interaction, lookup_interaction_config}; #[test_log::test(tokio::test(flavor = "multi_thread"))] async fn basic_values_test() { @@ -53,7 +53,8 @@ async fn basic_values_test() { let interaction_config = lookup_interaction_config(&interaction).unwrap(); let descriptor_key = interaction_config.get("descriptorKey").map(json_to_string).unwrap(); let fds = get_descriptors_for_interaction(descriptor_key.as_str(), &plugin_config).unwrap(); - let (message_descriptor, _) = find_message_type_by_name("MessageIn", &fds).unwrap(); + let (message_descriptor, _) = find_message_descriptor_for_type( + ".com.pact.protobuf.example.MessageIn", &fds).unwrap(); let mut buffer = request.contents.value().unwrap(); let fields = decode_message(&mut buffer, &message_descriptor, &fds).unwrap(); diff --git a/tests/enum_tests.rs b/tests/enum_tests.rs index a8d7550..1092c37 100644 --- a/tests/enum_tests.rs +++ b/tests/enum_tests.rs @@ -10,7 +10,7 @@ use prost::encoding::WireType; use serde_json::json; use pact_protobuf_plugin::matching::compare_message; use pact_protobuf_plugin::message_decoder::{ProtobufField, ProtobufFieldData}; -use pact_protobuf_plugin::utils::{find_message_type_by_name, get_descriptors_for_interaction, lookup_interaction_config}; +use pact_protobuf_plugin::utils::{find_message_descriptor_for_type, get_descriptors_for_interaction, lookup_interaction_config}; #[test_log::test(tokio::test(flavor = "multi_thread"))] async fn repeated_enum_test() { @@ -54,7 +54,8 @@ async fn repeated_enum_test() { let path = DocPath::root(); let context = CoreMatchingContext::new(DiffConfig::NoUnexpectedKeys, &message.contents.matching_rules.rules_for_category("body").unwrap(), &hashmap!{}); - let (message_descriptor, fs) = find_message_type_by_name("MessageIn", &fds).unwrap(); + let (message_descriptor, fs) = find_message_descriptor_for_type( + ".example.enum.package.MessageIn", &fds).unwrap(); let enum_descriptor = fs.enum_type.first().unwrap(); let expected = vec![ ProtobufField { diff --git a/tests/mock_server_tests.rs b/tests/mock_server_tests.rs index 95d195a..82ad854 100644 --- a/tests/mock_server_tests.rs +++ b/tests/mock_server_tests.rs @@ -14,7 +14,7 @@ use tonic::Request; use tower::ServiceExt; use pact_protobuf_plugin::dynamic_message::{DynamicMessage, PactCodec}; use pact_protobuf_plugin::message_decoder::{ProtobufField, ProtobufFieldData}; -use pact_protobuf_plugin::utils::find_message_type_by_name; +use pact_protobuf_plugin::utils::{find_message_descriptor_for_type}; async fn mock_server_block() { let mut pact_builder = PactBuilderAsync::new_v4("null-and-void", "protobuf-plugin"); @@ -59,7 +59,7 @@ fn mock_server_with_no_requests() { let error = result.unwrap_err(); let error_message = panic_message::panic_message(&error); expect!(error_message).to(be_equal_to( - "plugin mock server failed verification:\n 1) Test/GetTest: Did not receive any requests for path 'Test/GetTest'\n")); + "plugin mock server failed verification:\n 1) /com.pact.protobuf.example.Test/GetTest: Did not receive any requests for path '/com.pact.protobuf.example.Test/GetTest'\n")); } #[test_log::test(tokio::test(flavor = "multi_thread"))] @@ -91,11 +91,18 @@ async fn each_value_matcher() { .await; let url = mock_server.url(); + // encoded descriptor of a simple.proto. + // To update: + // protoc --descriptor_set_out=/dev/stdout tests/simple.proto | base64 let descriptors = base64::engine::general_purpose::STANDARD.decode( - "CogCCgxzaW1wbGUucHJvdG8iGwoJTWVzc2FnZUluEg4KAmluGAEgASgIUgJpbiIeCgpNZXNzYWdlT3V0EhAKA291\ - dBgBIAEoCFIDb3V0IicKD1ZhbHVlc01lc3NhZ2VJbhIUCgV2YWx1ZRgBIAMoCVIFdmFsdWUiKAoQVmFsdWVzTWVzc2FnZU\ - 91dBIUCgV2YWx1ZRgBIAMoCVIFdmFsdWUyYAoEVGVzdBIkCgdHZXRUZXN0EgouTWVzc2FnZUluGgsuTWVzc2FnZU91dCIA\ - EjIKCUdldFZhbHVlcxIQLlZhbHVlc01lc3NhZ2VJbhoRLlZhbHVlc01lc3NhZ2VPdXQiAGIGcHJvdG8z").unwrap(); + "CpIDChJ0ZXN0cy9zaW1wbGUucHJvdG8SGWNvbS5wYWN0LnByb3RvYnVmLmV4YW1wbGUiGwoJTWVz\ + c2FnZUluEg4KAmluGAEgASgIUgJpbiIeCgpNZXNzYWdlT3V0EhAKA291dBgBIAEoCFIDb3V0IicK\ + D1ZhbHVlc01lc3NhZ2VJbhIUCgV2YWx1ZRgBIAMoCVIFdmFsdWUiKAoQVmFsdWVzTWVzc2FnZU91\ + dBIUCgV2YWx1ZRgBIAMoCVIFdmFsdWUyyAEKBFRlc3QSWAoHR2V0VGVzdBIkLmNvbS5wYWN0LnBy\ + b3RvYnVmLmV4YW1wbGUuTWVzc2FnZUluGiUuY29tLnBhY3QucHJvdG9idWYuZXhhbXBsZS5NZXNz\ + YWdlT3V0IgASZgoJR2V0VmFsdWVzEiouY29tLnBhY3QucHJvdG9idWYuZXhhbXBsZS5WYWx1ZXNN\ + ZXNzYWdlSW4aKy5jb20ucGFjdC5wcm90b2J1Zi5leGFtcGxlLlZhbHVlc01lc3NhZ2VPdXQiAGIG\ + cHJvdG8z").unwrap(); let fds = FileDescriptorSet::decode(descriptors.as_slice()).unwrap(); let field = ProtobufField { field_num: 1, @@ -122,15 +129,17 @@ async fn each_value_matcher() { .unwrap(); conn.ready().await.unwrap(); - let (input_message, _) = find_message_type_by_name("ValuesMessageIn", &fds).unwrap(); - let (output_message, _) = find_message_type_by_name("ValuesMessageOut", &fds).unwrap(); + let (input_message, _) = find_message_descriptor_for_type(".com.pact.protobuf.example.ValuesMessageIn", &fds).unwrap(); + // searching by name without package next, to confirm we're backwards compatible + // (it's verified by unit tests too, but wouldn't hurt to check here as well) + let (output_message, _) = find_message_descriptor_for_type("ValuesMessageOut", &fds).unwrap(); let interaction = pact_builder.build() .interactions().first().unwrap() .as_v4_sync_message().unwrap(); let codec = PactCodec::new(&fds, &input_message, &output_message, &interaction); let mut grpc = tonic::client::Grpc::new(conn); - let path = http::uri::PathAndQuery::try_from("/Test/GetValues").unwrap(); + let path = http::uri::PathAndQuery::try_from("/com.pact.protobuf.example.Test/GetValues").unwrap(); grpc.unary(Request::new(message), path, codec).await.unwrap(); }