Skip to content

Commit acf1710

Browse files
committed
refactor: matcher
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 <[email protected]>
1 parent 2f33caf commit acf1710

File tree

2 files changed

+333
-114
lines changed

2 files changed

+333
-114
lines changed

src/pact/v3/match/matcher.py

Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
1+
"""
2+
Matching functionality for Pact.
3+
4+
Matchers are used in Pact to allow for more flexible matching of data. While the
5+
consumer defines the expected request and response, there are circumstances
6+
where the provider may return dynamically generated data. In these cases, the
7+
consumer should use a matcher to define the expected data.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from abc import ABC, abstractmethod
13+
from itertools import chain
14+
from json import JSONEncoder
15+
from typing import (
16+
TYPE_CHECKING,
17+
Any,
18+
Generic,
19+
Literal,
20+
Union,
21+
)
22+
23+
from pact.v3.match.types import Matchable, _MatchableT
24+
25+
if TYPE_CHECKING:
26+
from collections.abc import Mapping
27+
28+
from pact.v3.generate import Generator
29+
30+
_MatcherTypeV3 = Literal[
31+
"equality",
32+
"regex",
33+
"type",
34+
"include",
35+
"integer",
36+
"decimal",
37+
"number",
38+
"timestamp",
39+
"time",
40+
"date",
41+
"null",
42+
"boolean",
43+
"contentType",
44+
"values",
45+
"arrayContains",
46+
]
47+
"""
48+
Matchers defined in the V3 specification.
49+
"""
50+
51+
_MatcherTypeV4 = Literal[
52+
"statusCode",
53+
"notEmpty",
54+
"semver",
55+
"eachKey",
56+
"eachValue",
57+
]
58+
"""
59+
Matchers defined in the V4 specification.
60+
"""
61+
62+
MatcherType = Union[_MatcherTypeV3, _MatcherTypeV4]
63+
"""
64+
All supported matchers.
65+
"""
66+
67+
68+
class Unset:
69+
"""
70+
Special type to represent an unset value.
71+
72+
Typically, the value `None` is used to represent an unset value. However, we
73+
need to differentiate between a null value and an unset value. For example,
74+
a matcher may have a value of `None`, which is different from a matcher
75+
having no value at all. This class is used to represent the latter.
76+
"""
77+
78+
79+
_Unset = Unset()
80+
81+
82+
class Matcher(ABC, Generic[_MatchableT]):
83+
"""
84+
Abstract matcher.
85+
86+
In Pact, a matcher is used to define how a value should be compared. This
87+
allows for more flexible matching of data, especially when the provider
88+
returns dynamically generated data.
89+
90+
This class is abstract and should not be used directly. Instead, use one of
91+
the concrete matcher classes. Alternatively, you can create your own matcher
92+
by subclassing this class.
93+
94+
The matcher provides methods to convert into an integration JSON object and
95+
a matching rule. These methods are used internally by the Pact library when
96+
generating the Pact file.
97+
"""
98+
99+
@abstractmethod
100+
def to_integration_json(self) -> dict[str, Any]:
101+
"""
102+
Convert the matcher to an integration JSON object.
103+
104+
This method is used internally to convert the matcher to a JSON object
105+
which can be embedded directly in a number of places in the Pact FFI.
106+
107+
For more information about this format, see the docs:
108+
109+
> https://docs.pact.io/implementation_guides/rust/pact_ffi/integrationjson
110+
111+
Returns:
112+
The matcher as an integration JSON object.
113+
"""
114+
115+
@abstractmethod
116+
def to_matching_rule(self) -> dict[str, Any]:
117+
"""
118+
Convert the matcher to a matching rule.
119+
120+
This method is used internally to convert the matcher to a matching rule
121+
which can be embedded directly in a Pact file.
122+
123+
For more information about this format, see the docs:
124+
125+
> https://github.com/pact-foundation/pact-specification/tree/version-4
126+
127+
and
128+
129+
> https://github.com/pact-foundation/pact-specification/tree/version-2?tab=readme-ov-file#matchers
130+
131+
Returns:
132+
The matcher as a matching rule.
133+
"""
134+
135+
136+
class GenericMatcher(Matcher[_MatchableT]):
137+
"""
138+
Generic matcher.
139+
140+
In Pact, a matcher is used to define how a value should be compared. This
141+
allows for more flexible matching of data, especially when the provider
142+
returns dynamically generated data.
143+
"""
144+
145+
def __init__( # noqa: PLR0913
146+
self,
147+
type: MatcherType, # noqa: A002
148+
/,
149+
value: _MatchableT | Unset = _Unset,
150+
generator: Generator | None = None,
151+
extra_fields: Mapping[str, Matchable] | None = None,
152+
integration_fields: Mapping[str, Matchable] | None = None,
153+
matching_rule_fields: Mapping[str, Matchable] | None = None,
154+
**kwargs: Matchable,
155+
) -> None:
156+
"""
157+
Initialize the matcher.
158+
159+
Args:
160+
type:
161+
The type of the matcher.
162+
163+
value:
164+
The value to match. If not provided, the Pact library will
165+
generate a value based on the matcher type (or use the generator
166+
if provided). To ensure reproducibility, it is _highly_
167+
recommended to provide a value when creating a matcher.
168+
169+
generator:
170+
The generator to use when generating the value. The generator
171+
will generally only be used if value is not provided.
172+
173+
extra_fields:
174+
Additional configuration elements to pass to the matcher. These
175+
fields will be used when converting the matcher to an
176+
integration JSON object or a matching rule.
177+
178+
integration_fields:
179+
Additional configuration elements to pass to the matcher when
180+
converting it to an integration JSON object.
181+
182+
matching_rule_fields:
183+
Additional configuration elements to pass to the matcher when
184+
converting it to a matching rule.
185+
186+
**kwargs:
187+
Alternative way to define extra fields. See the `extra_fields`
188+
argument for more information.
189+
"""
190+
self.type = type
191+
"""
192+
The type of the matcher.
193+
"""
194+
195+
self.value: _MatchableT | Unset = value
196+
"""
197+
Default value used by Pact when executing tests.
198+
"""
199+
200+
self.generator = generator
201+
"""
202+
Generator used to generate a value when the value is not provided.
203+
"""
204+
205+
self._integration_fields = integration_fields or {}
206+
self._matching_rule_fields = matching_rule_fields or {}
207+
self._extra_fields = dict(chain((extra_fields or {}).items(), kwargs.items()))
208+
209+
def has_value(self) -> bool:
210+
"""
211+
Check if the matcher has a value.
212+
"""
213+
return not isinstance(self.value, Unset)
214+
215+
def extra_fields(self) -> dict[str, Matchable]:
216+
"""
217+
Return any extra fields for the matcher.
218+
219+
These fields are added to the matcher when it is converted to an
220+
integration JSON object or a matching rule.
221+
"""
222+
return self._extra_fields
223+
224+
def extra_integration_fields(self) -> dict[str, Matchable]:
225+
"""
226+
Return any extra fields for the integration JSON object.
227+
228+
These fields are added to the matcher when it is converted to an
229+
integration JSON object.
230+
231+
If there is any overlap in the keys between this method and
232+
[`extra_fields`](#extra_fields), the values from this method will be
233+
used.
234+
"""
235+
return {**self.extra_fields(), **self._integration_fields}
236+
237+
def extra_matching_rule_fields(self) -> dict[str, Matchable]:
238+
"""
239+
Return any extra fields for the matching rule.
240+
241+
These fields are added to the matcher when it is converted to a matching
242+
rule.
243+
244+
If there is any overlap in the keys between this method and
245+
[`extra_fields`](#extra_fields), the values from this method will be
246+
used.
247+
"""
248+
return {**self.extra_fields(), **self._matching_rule_fields}
249+
250+
def to_integration_json(self) -> dict[str, Matchable]:
251+
"""
252+
Convert the matcher to an integration JSON object.
253+
254+
This method is used internally to convert the matcher to a JSON object
255+
which can be embedded directly in a number of places in the Pact FFI.
256+
257+
For more information about this format, see the docs:
258+
259+
> https://docs.pact.io/implementation_guides/rust/pact_ffi/integrationjson
260+
261+
Returns:
262+
dict[str, Any]:
263+
The matcher as an integration JSON object.
264+
"""
265+
return {
266+
"pact:matcher:type": self.type,
267+
**({"value": self.value} if not isinstance(self.value, Unset) else {}),
268+
**(
269+
self.generator.to_integration_json()
270+
if self.generator is not None
271+
else {}
272+
),
273+
**self.extra_integration_fields(),
274+
}
275+
276+
def to_matching_rule(self) -> dict[str, Any]:
277+
"""
278+
Convert the matcher to a matching rule.
279+
280+
This method is used internally to convert the matcher to a matching rule
281+
which can be embedded directly in a Pact file.
282+
283+
For more information about this format, see the docs:
284+
285+
> https://github.com/pact-foundation/pact-specification/tree/version-4
286+
287+
and
288+
289+
> https://github.com/pact-foundation/pact-specification/tree/version-2?tab=readme-ov-file#matchers
290+
291+
Returns:
292+
dict[str, Any]:
293+
The matcher as a matching rule.
294+
"""
295+
return {
296+
"match": self.type,
297+
**({"value": self.value} if not isinstance(self.value, Unset) else {}),
298+
**(self.generator.to_matching_rule() if self.generator is not None else {}),
299+
**self.extra_fields(),
300+
**self.extra_matching_rule_fields(),
301+
}
302+
303+
304+
class MatchingRuleJSONEncoder(JSONEncoder):
305+
"""
306+
JSON encoder class for matching rules.
307+
308+
This class is used to encode matching rules to JSON.
309+
"""
310+
311+
def default(self, o: Any) -> Any: # noqa: ANN401
312+
"""
313+
Encode the object to JSON.
314+
"""
315+
if isinstance(o, Matcher):
316+
return o.to_matching_rule()
317+
return super().default(o)
318+
319+
320+
class IntegrationJSONEncoder(JSONEncoder):
321+
"""
322+
JSON encoder class for integration JSON objects.
323+
324+
This class is used to encode integration JSON objects to JSON.
325+
"""
326+
327+
def default(self, o: Any) -> Any: # noqa: ANN401
328+
"""
329+
Encode the object to JSON.
330+
"""
331+
if isinstance(o, Matcher):
332+
return o.to_integration_json()
333+
return super().default(o)

0 commit comments

Comments
 (0)