Skip to content

Commit

Permalink
refactor: matcher
Browse files Browse the repository at this point in the history
Some minor renaming. Instead of `ConcreteMatcher`, prefer
`GenericMatcher` as it leaves room for other mathcers to be
created (e.g. `BooleanMatcher`, `BaseModelmatcher`, etc.).

Secondly, made the matcher compatible with both the Integration JSON
format and Matching Rules format (and created two separate encoders to
go with these).

Signed-off-by: JP-Ellis <josh@jpellis.me>
  • Loading branch information
JP-Ellis committed Sep 26, 2024
1 parent 2f33caf commit acf1710
Show file tree
Hide file tree
Showing 2 changed files with 333 additions and 114 deletions.
333 changes: 333 additions & 0 deletions src/pact/v3/match/matcher.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,333 @@
"""
Matching functionality for Pact.
Matchers are used in Pact to allow for more flexible matching of data. While the
consumer defines the expected request and response, there are circumstances
where the provider may return dynamically generated data. In these cases, the
consumer should use a matcher to define the expected data.
"""

from __future__ import annotations

from abc import ABC, abstractmethod
from itertools import chain
from json import JSONEncoder
from typing import (
TYPE_CHECKING,
Any,
Generic,
Literal,
Union,
)

from pact.v3.match.types import Matchable, _MatchableT

if TYPE_CHECKING:
from collections.abc import Mapping

from pact.v3.generate import Generator

_MatcherTypeV3 = Literal[
"equality",
"regex",
"type",
"include",
"integer",
"decimal",
"number",
"timestamp",
"time",
"date",
"null",
"boolean",
"contentType",
"values",
"arrayContains",
]
"""
Matchers defined in the V3 specification.
"""

_MatcherTypeV4 = Literal[
"statusCode",
"notEmpty",
"semver",
"eachKey",
"eachValue",
]
"""
Matchers defined in the V4 specification.
"""

MatcherType = Union[_MatcherTypeV3, _MatcherTypeV4]
"""
All supported matchers.
"""


class Unset:
"""
Special type to represent an unset value.
Typically, the value `None` is used to represent an unset value. However, we
need to differentiate between a null value and an unset value. For example,
a matcher may have a value of `None`, which is different from a matcher
having no value at all. This class is used to represent the latter.
"""


_Unset = Unset()


class Matcher(ABC, Generic[_MatchableT]):
"""
Abstract matcher.
In Pact, a matcher is used to define how a value should be compared. This
allows for more flexible matching of data, especially when the provider
returns dynamically generated data.
This class is abstract and should not be used directly. Instead, use one of
the concrete matcher classes. Alternatively, you can create your own matcher
by subclassing this class.
The matcher provides methods to convert into an integration JSON object and
a matching rule. These methods are used internally by the Pact library when
generating the Pact file.
"""

@abstractmethod
def to_integration_json(self) -> dict[str, Any]:
"""
Convert the matcher to an integration JSON object.
This method is used internally to convert the matcher to a JSON object
which can be embedded directly in a number of places in the Pact FFI.
For more information about this format, see the docs:
> https://docs.pact.io/implementation_guides/rust/pact_ffi/integrationjson
Returns:
The matcher as an integration JSON object.
"""

@abstractmethod
def to_matching_rule(self) -> dict[str, Any]:
"""
Convert the matcher to a matching rule.
This method is used internally to convert the matcher to a matching rule
which can be embedded directly in a Pact file.
For more information about this format, see the docs:
> https://github.com/pact-foundation/pact-specification/tree/version-4
and
> https://github.com/pact-foundation/pact-specification/tree/version-2?tab=readme-ov-file#matchers
Returns:
The matcher as a matching rule.
"""


class GenericMatcher(Matcher[_MatchableT]):
"""
Generic matcher.
In Pact, a matcher is used to define how a value should be compared. This
allows for more flexible matching of data, especially when the provider
returns dynamically generated data.
"""

def __init__( # noqa: PLR0913
self,
type: MatcherType, # noqa: A002
/,
value: _MatchableT | Unset = _Unset,
generator: Generator | None = None,
extra_fields: Mapping[str, Matchable] | None = None,
integration_fields: Mapping[str, Matchable] | None = None,
matching_rule_fields: Mapping[str, Matchable] | None = None,
**kwargs: Matchable,
) -> None:
"""
Initialize the matcher.
Args:
type:
The type of the matcher.
value:
The value to match. If not provided, the Pact library will
generate a value based on the matcher type (or use the generator
if provided). To ensure reproducibility, it is _highly_
recommended to provide a value when creating a matcher.
generator:
The generator to use when generating the value. The generator
will generally only be used if value is not provided.
extra_fields:
Additional configuration elements to pass to the matcher. These
fields will be used when converting the matcher to an
integration JSON object or a matching rule.
integration_fields:
Additional configuration elements to pass to the matcher when
converting it to an integration JSON object.
matching_rule_fields:
Additional configuration elements to pass to the matcher when
converting it to a matching rule.
**kwargs:
Alternative way to define extra fields. See the `extra_fields`
argument for more information.
"""
self.type = type
"""
The type of the matcher.
"""

self.value: _MatchableT | Unset = value
"""
Default value used by Pact when executing tests.
"""

self.generator = generator
"""
Generator used to generate a value when the value is not provided.
"""

self._integration_fields = integration_fields or {}
self._matching_rule_fields = matching_rule_fields or {}
self._extra_fields = dict(chain((extra_fields or {}).items(), kwargs.items()))

def has_value(self) -> bool:
"""
Check if the matcher has a value.
"""
return not isinstance(self.value, Unset)

def extra_fields(self) -> dict[str, Matchable]:
"""
Return any extra fields for the matcher.
These fields are added to the matcher when it is converted to an
integration JSON object or a matching rule.
"""
return self._extra_fields

def extra_integration_fields(self) -> dict[str, Matchable]:
"""
Return any extra fields for the integration JSON object.
These fields are added to the matcher when it is converted to an
integration JSON object.
If there is any overlap in the keys between this method and
[`extra_fields`](#extra_fields), the values from this method will be
used.
"""
return {**self.extra_fields(), **self._integration_fields}

def extra_matching_rule_fields(self) -> dict[str, Matchable]:
"""
Return any extra fields for the matching rule.
These fields are added to the matcher when it is converted to a matching
rule.
If there is any overlap in the keys between this method and
[`extra_fields`](#extra_fields), the values from this method will be
used.
"""
return {**self.extra_fields(), **self._matching_rule_fields}

def to_integration_json(self) -> dict[str, Matchable]:
"""
Convert the matcher to an integration JSON object.
This method is used internally to convert the matcher to a JSON object
which can be embedded directly in a number of places in the Pact FFI.
For more information about this format, see the docs:
> https://docs.pact.io/implementation_guides/rust/pact_ffi/integrationjson
Returns:
dict[str, Any]:
The matcher as an integration JSON object.
"""
return {
"pact:matcher:type": self.type,
**({"value": self.value} if not isinstance(self.value, Unset) else {}),
**(
self.generator.to_integration_json()
if self.generator is not None
else {}
),
**self.extra_integration_fields(),
}

def to_matching_rule(self) -> dict[str, Any]:
"""
Convert the matcher to a matching rule.
This method is used internally to convert the matcher to a matching rule
which can be embedded directly in a Pact file.
For more information about this format, see the docs:
> https://github.com/pact-foundation/pact-specification/tree/version-4
and
> https://github.com/pact-foundation/pact-specification/tree/version-2?tab=readme-ov-file#matchers
Returns:
dict[str, Any]:
The matcher as a matching rule.
"""
return {
"match": self.type,
**({"value": self.value} if not isinstance(self.value, Unset) else {}),
**(self.generator.to_matching_rule() if self.generator is not None else {}),
**self.extra_fields(),
**self.extra_matching_rule_fields(),
}


class MatchingRuleJSONEncoder(JSONEncoder):
"""
JSON encoder class for matching rules.
This class is used to encode matching rules to JSON.
"""

def default(self, o: Any) -> Any: # noqa: ANN401
"""
Encode the object to JSON.
"""
if isinstance(o, Matcher):
return o.to_matching_rule()
return super().default(o)


class IntegrationJSONEncoder(JSONEncoder):
"""
JSON encoder class for integration JSON objects.
This class is used to encode integration JSON objects to JSON.
"""

def default(self, o: Any) -> Any: # noqa: ANN401
"""
Encode the object to JSON.
"""
if isinstance(o, Matcher):
return o.to_integration_json()
return super().default(o)
Loading

0 comments on commit acf1710

Please sign in to comment.