Skip to content

Conversation

@stan-is-hate
Copy link
Contributor

@stan-is-hate stan-is-hate commented Aug 7, 2024

This PR changes how message and service types are looked up in file descriptors.
Previously in most of the places the lookup was simply by the message or service name. This was leading to issues where a wrong proto message was selected for decoding the request or response if multiple messages had the same name, causing test failures.
Now the package is taken into account everywhere and lookup by name function is gone.

This PR introduces new find_... functions that replace find_by_name functions.
New functions take a message or a service name as an input still, but attempt to parse it if the name is fully qualified.
The rules are:

  • If the name starts with a dot, it's a fully-qualified name. In this case we rsplit by "." to separate package from the type name and only do the lookup in the file descriptors that have package field matching the package part.
  • If the name does not start with the dot, we don't do any splits and just lookup the name directly in all file descriptors.

This does not support embedded types yet (these lookup functions haven't supported them before either). Embedded types are partially supported in decode_message, and embedded enums are supported to some extent too, but not searching for message types, input/output messages or service descriptors.

I will send a separate PR to support embedded fields at some point in the future (unless someone beats me to it - it's relatively straightforward, all lookups are encapsulated in a couple of functions in the utils file)

With new behavior, we're using fully qualified name in several places in the pact file now:

  • under interaction config service or message field - previously it was just the message or a service name, now it includes the package. This is used to share data between the consumer and provider pacts.
  • in the contentType message attribute, this is also sharing data between different plugin workflows (message consumer to message provider or protobuf (non-grpc) service consumer to provider)

Because of pact file change, the plugin is backwards compatible (with a caveat for message provider), but not forwards compatible:

  • for gRPC service tests:
    • new plugin can verify pacts generated by older plugins - those will have service field without package and will fallback to the old behavior, searching by name and ignoring package - e.g. RouteGuide/GetFeature
    • old plugin won't be able to verify pacts generated by new plugin - new pacts will have fully-qualified service which old plugin won't be able to parse - e.g. .routeguide.RouteGuide/GetFeature
  • for protobuf message tests:
  • new plugin can verify pacts created by older plugins - but would have to adjust contentType depending on whether the pact is generated by the older plugin or the newer one, like here - if verifying older pact, must be message=Feature; if verifying newer pact, must be message=.routeguide.Feature.
  • for protobuf service without grpc server:
    • I'm not quite sure how to verify that tbh

As a side note, it would be good to add some kind of a backwards compatibility test script, which would install an existing version of the plugin, generate some pacts, build and install a new version of the plugin and verify those pacts with the new plugin version. I can look into this too, but I won't have bandwidth in the near future.

@stan-is-hate stan-is-hate force-pushed the fully-qualified-lookups branch from 342db47 to e001efa Compare August 7, 2024 08:35
Copy link
Contributor

@ermul ermul left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is great! I haven't made it through the whole diff, but will finish after I have some lunch.

let (method_name, service_part) = if method_name.contains(':') {
method_name.split_once(':').unwrap_or((method_name, ""))
} else {
(method_name, "")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:
This isn't part of your diff, but I believe the if/else is redundant here:

let (method_name, service_part) = method_name.split_once(':').unwrap_or((method_name, ""))

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, looks like it, I can update it later

})
.unwrap_or_else(|| (name, None))
} else {
// otherwise it's a relative name, so if it contains dots, this means embedded types, not packages?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment on lines +69 to +70
/// Converts a relative protobuf type name to a fully qualified one by appending a `.<package>.` prefix,
/// or if the package is empty, just a `.` prefix.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: It reads a little funny as appending a prefix, maybe:

/// Converts a relative protobuf type name to a fully qualified one by prepending `.<package>.`,
/// or if the package is empty, just `.`.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good idea, I'll update late tonight. Thanks!


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?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I'm understanding correctly, the request and response having the same type would imply that message_type receives the same value w/o considering the matching rules.

It's not clear to me in what context this function is invoked. This function takes the expected and actual payloads as input, but is also trying to figure out whether to match against the request or response message of the service method. This leads me to believe that the expected_request may be coming from a contract that is being verified against the provider. Unfortunately this also doesn't seem correct, since the contract would include whether it's the request or response. Could you help me understand what I'm missing?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this part is tricky. The only place this function is invoked (after my changes) is in the compare_contents flow.
This is different from gRPC testing flow that we're used to.

It's invoked when you do a message provider verification, or apparently you can also verify a grpc interaction too without using gRPC testing flow (which I haven't used myself and have a pretty vague understanding of ) - https://github.com/pactflow/pact-protobuf-plugin?tab=readme-ov-file#testing-a-grpc-service-method-interaction-without-a-grpc-server

Here plugin receives CompareContents request.
This request would contain:

  • expected and actual message and matching rules
  • contentType string with message type as an attribute
  • pact configs with message or service field, but never both
  • If it has message this means we're verifying a message pact, if it's a service, we're comparing either the request being sent to the server or the response received from the server.
  • so now we know we're comparing the server interaction
  • we know the service descriptor and we know the message type we've received too, but we don't know if we're verifying a request message or a response message, it's not specified explicitly
  • hence the logic to compare the type, like if the message is smth like GetFeatureRequest, and it matches the request type in the server, we know we're comparing the request. But if the service method is defined as rpc UpdatePoint (Point) returns (Point) {}, we wouldn't know which one it is we're checking.
  • a proper fix would be to include extra data in the contentType attributes, something like application/protobuf;message=.routeguide.Point;flow=[Request|Response], or find a different way to pass data from the consumer

At least that's my understanding so far, but I haven't been able to test this flow properly - but I haven't really changed it much either, apart from using package names there too.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, this would not be used by many people. Most would be using a gRPC server for their service calls. However, I know of one company that uses pure Protobufs and not gRPC. But I think it is ok to leave this for now until we have confirmation that it is needed by someone.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the detailed explanation!

@rholshausen
Copy link
Contributor

Lots of respect! This is a mammoth effort.


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?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, this would not be used by many people. Most would be using a gRPC server for their service calls. However, I know of one company that uses pure Protobufs and not gRPC. But I think it is ok to leave this for now until we have confirmation that it is needed by someone.

@rholshausen
Copy link
Contributor

I'm happy to merge this. Let me know when you are ready (I saw some comments about some small changes).

Copy link
Contributor

@ermul ermul left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM Thanks for taking this on! My comments are mainly just questions and commentary.


Have you happened to look into whether it's possible to leverage the machinery from libprotobuf or some rust implementation to help w/ resolving references? - Maybe the full AST has some useful methods? 🤷

Comment on lines +135 to +138
// 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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm also a little concerned about how we will handle resolving relative references when there are nested packages & messages. This looks non-trivial from the outside after skimming through reference resolution in the protobuf language specification.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not, that's why it's something to look into at a later time. I think the next step would be nested types with fully qualified names, and I don't think we should tackle relative names any time soon - at least currently all the descriptors I've seen had fully-qualified type names filled in, I wonder if this can be adjusted by protoc flags? Because the docs on descriptor types mention a possibility of relative names.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if there's a way to confirm that descriptors we get from protoc will always have fully-qualified type names filled in. If they do, this makes our life so much easier, this means all the heavy-lifting of resolving relative references and all we have to do is to figure out if the dot-separated token refers to a package or a type, which is annoying but not too hard to do

/// 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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be "If the type name is prefixed with a dot it is a fully qualified name"?

As I understand it, we're not handling all the possibilities for partially qualified, and unqualified references yet. - Which I think this is fine, because I expect that this already covers the vast majority of wild protos. Please correct me if I'm wrong.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's correct, it's about starting with a dot. The comment is inaccurate, let me send another PR to address this PR comments :)

@rholshausen rholshausen merged commit ed4034c into pactflow:main Aug 8, 2024
@stan-is-hate
Copy link
Contributor Author

Have you happened to look into whether it's possible to leverage the machinery from libprotobuf or some rust implementation to help w/ resolving references? - Maybe the full AST has some useful methods?

Nope, and it's a good idea to look into this, thanks! Will def start there if/when I get to resolving nested types.

@stan-is-hate
Copy link
Contributor Author

Here's a PR to address a couple of comments pointed out by @ermul #72

@YOU54F
Copy link
Member

YOU54F commented Aug 9, 2024

As a side note, it would be good to add some kind of a backwards compatibility test script, which would install an existing version of the plugin, generate some pacts, build and install a new version of the plugin and verify those pacts with the new plugin version

This is a really good shout, we should table this as a new issue and anyone is then free to pick it up

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants