pact-foundation / pact-python

Python version of Pact. Enables consumer driven contract testing, providing a mock service and DSL for the consumer project, and interaction playback and verification for the service provider project.
http://pact.io
MIT License
568 stars 137 forks source link

Implement a `Matcher` interface #746

Open JP-Ellis opened 1 month ago

JP-Ellis commented 1 month ago

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:

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:

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:

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 commented 1 month ago

@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 commented 1 month ago

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 commented 1 month ago

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 commented 1 month ago

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.