Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement a Matcher interface #746

Open
Tracked by #396
JP-Ellis opened this issue Aug 2, 2024 · 5 comments
Open
Tracked by #396

Implement a Matcher interface #746

JP-Ellis opened this issue Aug 2, 2024 · 5 comments
Labels
area:v3 Relating to the pact.v3 module difficulty:hard A task requiring a lot of work and an in-depth understanding of the codebase smartbear-supported This issue is supported by SmartBear type:feature New feature

Comments

@JP-Ellis
Copy link
Contributor

JP-Ellis commented Aug 2, 2024

Summary

Provide a Pythonic interface for creating matching rules.

Motivation

The Pact specification supports matching rules for most aspects of an interaction. These matching rules ensure that the content fit some more customisable constraints, as opposed to a literal value.

Some example of matching rules including matching a particular regex pattern, or ensuring a number is constrained to a range, or simple asserting that an entry is a floating point.

At present, functions such as with_body or with_matching_rules only take strings or byte arrays which are passed as-is to the underlying FFI. As a result, while matchers are technically supported, the end user is responsible for writing out the matching rules in a JSON string. This is clearly undesirable.

Proposed Implementation

General Considerations

The proposed changes would apply to all functions that can support matching rules, whether it be the body, headers, metadata, etc. The specific might change slightly based on context, but the general idea should remain the same.

There should also be consistency between using with_body(...) with a matching rule, and with_matching_rules(...). That is, a user should be able to straightforward refactor from one to the other. Similarly, adapting a rule from with_body to with_header should be equally straightforward.

Literal Conversion

At present, with_body consumes a string or a byte array. A dictionary for example is not supported and the end-user must wrap is with a json.dumps to serialise the data into a JSON string. If someone wants to match a value literally, they should be able to pass the value directly to with_body and the serialisation should be handled automatically.

There should be support for:

  • All base types
    • Strings and bytes would need to be handled specifically so as to ensure that their literal value is matched (even if the underlying Pact library is able to parse it).
  • All collections to be mapped to the appropriate JSON object or array
  • Furthermore, the following should be considered:

Pydantic Support

Values which are subclasses of Pydantic's BaseModel should be serialised to JSON objects. Support for Pydantic should be opt-in so as to not introduce a new dependency.

Custom Serialisation

Lastly, we should consider whether to support arbitrary classes. For example, it might be useful to inspect a value for any of the following methods:

  • to_json / as_json / json
  • to_dict / as_dict / dict

It might also be worth standardising a __pact__ method which can be used to provide a custom serialisation.

Matching Rules Constructor

When a matching rule is required, we should expose a set of functions which can be used to create and compose rules.

I would suggest the following, but I am open to suggestions:

  • Expose a number of simple functions which can be used to create a matching rule on the fly:
    • regex, type, min, max, timestamp, time, date, etc.
    • I have an open question around the use of like and each_like from the Pact JS. The term 'like' might be a bit ambiguous I wonder if there is a better name.
  • Have these functions return a MatchingRule object.
    • It should be transparently supported within the with_body function and friends.
    • It should overload the & and | operators to allow for logical and and or. This would allow for the creation of more complex rules such as type(42) & min(0) & max(100).

To avoid polluting the namespace, it might be best to introduce a best practice of importing the module with an alias:

import pact.v3.matchers as match

example = {
  "name": match.type("Alice"),
  "age:" match.type(42) & match.min(0),
  match.regex(r"address_\d+"): match.type(dict) | match.type(str)
}

References

Below are some references as to how Pact JS handles matching rules, and how the Rust library handles them internally.

Rust Library

The main logic for parsing an arbitrary JSON value into a MatchingRule is in the pact_models library (rules_from_json and MatchingRule::from_json)

fn rules_from_json(attributes: &Map<String, Value>) -> anyhow::Result<Vec<Either<MatchingRule, MatchingReference>>> {
    match attributes.get("rules") {
        Some(rules) => match rules {
            Value::Array(rules) => {
                let rules = rules.iter()
                    .map(|rule| MatchingRule::from_json(rule));
                if let Some(err) = rules.clone().find(|rule| rule.is_err()) {
                    Err(anyhow!("Matching rule configuration is not correct - {}", err.unwrap_err()))
                } else {
                    Ok(rules.map(|rule| Either::Left(rule.unwrap())).collect())
                }
            }
        _ => Err(anyhow!("EachKey matcher config is not valid. Was expected an array but got {}", rules))
        }
        None => Ok(vec![])
    }
}

pub fn MatchingRule::from_json(value: &Value) -> anyhow::Result<MatchingRule> {
    match value {
        Value::Object(m) => match m.get("match").or_else(|| m.get("pact:matcher:type")) {
        Some(match_val) => {
            let val = json_to_string(match_val);
            MatchingRule::create(val.as_str(), value)
        }
        None => if let Some(val) = m.get("regex") {
            Ok(MatchingRule::Regex(json_to_string(val)))
        } else if let Some(val) = json_to_num(m.get("min").cloned()) {
            Ok(MatchingRule::MinType(val))
        } else if let Some(val) = json_to_num(m.get("max").cloned()) {
            Ok(MatchingRule::MaxType(val))
        } else if let Some(val) = m.get("timestamp") {
            Ok(MatchingRule::Timestamp(json_to_string(val)))
        } else if let Some(val) = m.get("time") {
            Ok(MatchingRule::Time(json_to_string(val)))
        } else if let Some(val) = m.get("date") {
            Ok(MatchingRule::Date(json_to_string(val)))
        } else {
            Err(anyhow!("Matching rule missing 'match' field and unable to guess its type"))
        }
        },
        _ => Err(anyhow!("Matching rule JSON is not an Object")),
    }
}

Pact JS

Pact JS has quite a nice API for creating matching rules. The like and eachLike functions are particularly useful.

const example = {
    name: 'Alice', // Literal match
    age: Matchers.integer(42), // Type match
    email: Matchers.email(), // Built-in regex
    address: like({ // Nested object
        street: '123 Main St',
        city: 'Springfield'
    }),
}
@JP-Ellis JP-Ellis mentioned this issue Aug 2, 2024
48 tasks
@JP-Ellis JP-Ellis changed the title Implement a interface Implement a Matcher interface Aug 2, 2024
@JP-Ellis JP-Ellis added type:feature New feature difficulty:hard A task requiring a lot of work and an in-depth understanding of the codebase area:v3 Relating to the pact.v3 module labels Aug 2, 2024
@JP-Ellis JP-Ellis moved this to 🚀 To-Do in Pact Python Aug 2, 2024
@JP-Ellis
Copy link
Contributor Author

JP-Ellis commented Aug 2, 2024

@valkolovos has expressed interest in implementing this. If possible, I would like to first get some eyes across the proposed implementation to hopefully "get it right" the first time around.

Tagging @YOU54F, @mefellows

@JP-Ellis
Copy link
Contributor Author

JP-Ellis commented Aug 7, 2024

Following you community meetup, the use of like is very commonplace across other parts of the ecosystem. And while the name is a bit ambiguous, anyone familiar with other SDKs would immediately know what it stands for.

As a result, I suggest we support like (and similarly named functions), but ensure that a warning is generated when it is used to nudge people towards a better named function.

@YOU54F
Copy link
Member

YOU54F commented Aug 7, 2024

Great write up @JP-Ellis

I would try to maintain as close to the public interface for matchers already existing in pact-python which are aliases to the new terms, as eluded due to Josh's comments.

I think there are still ambiguity over matchers/generators and spec compat.

v3 had more matchers and introduced generators
v4 has new matchers/generators

should rust or client libraries enforce matchers/generators only used against the appropriate spec? example ProviderStateis v4 only.

Do we need a v2/v3/v4 matchers interface, or can the client lib try and be smart about it, so the end users sets the spec, and then they get the appropriate matchers that can be used with that spec or errors if they try to step outside the bounds.

What were your thoughts on this as the probably doesn't suggest the matchers are split by spec version (unlike Pact JS)

I assume pact ref in rust it accepts a certain standard via the integration json format, regardless of spec and then with coerce into whatever shape is required for v2/v3/v4.

I'd want to check in rust, can we store a v4 matcher/generator in a v2 spec file. what happens on the consumer/provider matching sides, just to get an idea of the emergent behaviour and whether it is preferable or not.

All those concerns aren't worth blocking efforts on movement on this ticket.

@JP-Ellis
Copy link
Contributor Author

JP-Ellis commented Aug 7, 2024

I think that having a split in matcher interfaces is an unnecessary complication for the end user, especially when under the hood they mostly work the same. From what I understand, the main differences is not how matchers fundamentally work, but rather which sets of matchers are generally supported.

Furthermore, splitting matcher interfaces I think will result in end users being unnecessarily locked in to older Pact versions, which ultimately will only result in a more and more fractured ecosystem. If/when Pact V5 is released, I would want to ensure that the transition to the new Pact is essentially seamless, instead of the end user having to rewrite every single Pact.

As a result, I think Pact Python should be developed with a "v4-by-default" mindset, with explicit options to downgrade from V4 to older versions of Pact. Taking this further, I think we should expose all the options that V4 has to offer all the time, and should the end user explicitly decide to downgrade to an older version, then whichever functions are incompatible can error.

Lastly, as to the error logic, I think this should be handled by the FFI, though I understand that this may need to be tested.

@JP-Ellis JP-Ellis moved this from 🚀 To-Do to 👀 In Review in Pact Python Sep 25, 2024
@JP-Ellis JP-Ellis added the smartbear-supported This issue is supported by SmartBear label Sep 25, 2024
Copy link

🤖 Great news! We've labeled this issue as smartbear-supported and created a tracking ticket in PactFlow's Jira (PACT-2546). We'll keep work public and post updates here. Meanwhile, feel free to check out our docs. Thanks for your patience!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area:v3 Relating to the pact.v3 module difficulty:hard A task requiring a lot of work and an in-depth understanding of the codebase smartbear-supported This issue is supported by SmartBear type:feature New feature
Projects
Status: 👀 In Review
Development

No branches or pull requests

2 participants