Skip to content

Implement a Matcher interface #746

Closed
@JP-Ellis

Description

@JP-Ellis

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'
    }),
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:v3Relating to the pact.v3 moduledifficulty:hardA task requiring a lot of work and an in-depth understanding of the codebasesmartbear-supportedThis issue is supported by SmartBeartype:featureNew feature

    Type

    No type

    Projects

    Status

    ✅ Completed

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions