Skip to content

Commit

Permalink
adding matcher POC
Browse files Browse the repository at this point in the history
  • Loading branch information
valkolovos committed Aug 13, 2024
1 parent baaeba5 commit e5939c3
Show file tree
Hide file tree
Showing 6 changed files with 269 additions and 6 deletions.
109 changes: 109 additions & 0 deletions examples/tests/v3/basic_flask_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import logging
import re
import signal
import subprocess
import sys
import time
from contextlib import contextmanager
from pathlib import Path
from threading import Thread
from typing import Generator, NoReturn

import requests
from flask import Flask, Response, make_response
from yarl import URL

logger = logging.getLogger(__name__)

@contextmanager
def start_provider() -> Generator[URL, None, None]: # noqa: C901
"""
Start the provider app.
"""
process = subprocess.Popen( # noqa: S603
[
sys.executable,
Path(__file__),
],
cwd=Path.cwd(),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
encoding="utf-8",
)

pattern = re.compile(r" \* Running on (?P<url>[^ ]+)")
while True:
if process.poll() is not None:
logger.error("Provider process exited with code %d", process.returncode)
logger.error(
"Provider stdout: %s", process.stdout.read() if process.stdout else ""
)
logger.error(
"Provider stderr: %s", process.stderr.read() if process.stderr else ""
)
msg = f"Provider process exited with code {process.returncode}"
raise RuntimeError(msg)
if (
process.stderr
and (line := process.stderr.readline())
and (match := pattern.match(line))
):
break
time.sleep(0.1)

url = URL(match.group("url"))
logger.debug("Provider started on %s", url)
for _ in range(50):
try:
response = requests.get(str(url / "_test" / "ping"), timeout=1)
assert response.text == "pong"
break
except (requests.RequestException, AssertionError):
time.sleep(0.1)
continue
else:
msg = "Failed to ping provider"
raise RuntimeError(msg)

def redirect() -> NoReturn:
while True:
if process.stdout:
while line := process.stdout.readline():
logger.debug("Provider stdout: %s", line.strip())
if process.stderr:
while line := process.stderr.readline():
logger.debug("Provider stderr: %s", line.strip())

thread = Thread(target=redirect, daemon=True)
thread.start()

try:
yield url
finally:
process.send_signal(signal.SIGINT)

if __name__ == "__main__":
app = Flask(__name__)

@app.route("/path/to/<test_id>")
def hello_world(test_id: int) -> Response:
response = make_response(
{
"response": {
"id": test_id,
"regex": "must end with 'hello world'",
"integer": 42,
"include": "hello world",
"minMaxArray": [1.0, 1.1, 1.2],
}
}
)
response.headers["SpecialHeader"] = "Special: Hi"
return response

@app.get("/_test/ping")
def ping() -> str:
"""Simple ping endpoint for testing."""
return "pong"

app.run()
48 changes: 48 additions & 0 deletions examples/tests/v3/test_matchers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
from pathlib import Path

from examples.tests.v3.basic_flask_server import start_provider
from pact.v3 import Pact, Verifier, matchers


def test_matchers() -> None:
pact_dir = Path(Path(__file__).parent.parent.parent / "pacts")
pact = Pact("consumer", "provider").with_specification("V4")
(
pact.upon_receiving("a request")
.given("a state")
.with_request(
"GET",
matchers.regex("/path/to/100", r"/path/to/\d+", generator="Regex")
)
.will_respond_with(200)
.with_body(
{
"response": matchers.like(
{
"regex": matchers.regex(
"must end with 'hello world'", r".*hello world'$"
),
"integer": matchers.integer(42),
"include": matchers.include("hello world", "world"),
"minMaxArray": matchers.each_like(
matchers.decimal(1.0),
min_count=3,
max_count=5,
),
},
min_count=1,
)
}
)
.with_header(
"SpecialHeader", matchers.regex("Special: Foo", r"Special: \w+")
)
)
pact.write_file(pact_dir, overwrite=True)
with start_provider() as url:
verifier = (
Verifier()
.set_info("My Provider", url=url)
.add_source(pact_dir / "consumer-provider.json")
)
verifier.verify()
10 changes: 8 additions & 2 deletions src/pact/v3/interaction/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from typing import TYPE_CHECKING, Any, Literal, overload

import pact.v3.ffi
from pact.v3.matchers import Matcher, MatcherEncoder

if TYPE_CHECKING:
from pathlib import Path
Expand Down Expand Up @@ -245,7 +246,7 @@ def given(

def with_body(
self,
body: str | None = None,
body: str | dict | Matcher | None = None,
content_type: str | None = None,
part: Literal["Request", "Response"] | None = None,
) -> Self:
Expand All @@ -266,11 +267,16 @@ def with_body(
If `None`, then the function intelligently determines whether
the body should be added to the request or the response.
"""
if not isinstance(body, str):
body_str = json.dumps(body, cls=MatcherEncoder)
else:
body_str = body

pact.v3.ffi.with_body(
self._handle,
self._parse_interaction_part(part),
content_type,
body,
body_str,
)
return self

Expand Down
18 changes: 14 additions & 4 deletions src/pact/v3/interaction/_http_interaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@

from __future__ import annotations

import json
from collections import defaultdict
from typing import TYPE_CHECKING, Iterable, Literal

import pact.v3.ffi
from pact.v3.interaction._base import Interaction
from pact.v3.matchers import Matcher, MatcherEncoder

if TYPE_CHECKING:
try:
Expand Down Expand Up @@ -94,7 +96,7 @@ def _interaction_part(self) -> pact.v3.ffi.InteractionPart:
"""
return self.__interaction_part

def with_request(self, method: str, path: str) -> Self:
def with_request(self, method: str, path: str | Matcher) -> Self:
"""
Set the request.
Expand All @@ -106,13 +108,17 @@ def with_request(self, method: str, path: str) -> Self:
path:
Path for the request.
"""
pact.v3.ffi.with_request(self._handle, method, path)
if isinstance(path, Matcher):
path_str = json.dumps(path, cls=MatcherEncoder)
else:
path_str = path
pact.v3.ffi.with_request(self._handle, method, path_str)
return self

def with_header(
self,
name: str,
value: str,
value: str | dict | Matcher,
part: Literal["Request", "Response"] | None = None,
) -> Self:
r"""
Expand Down Expand Up @@ -208,12 +214,16 @@ def with_header(
name_lower = name.lower()
index = self._request_indices[(interaction_part, name_lower)]
self._request_indices[(interaction_part, name_lower)] += 1
if not isinstance(value, str):
value_str: str = json.dumps(value, cls=MatcherEncoder)
else:
value_str = value
pact.v3.ffi.with_header_v2(
self._handle,
interaction_part,
name,
index,
value,
value_str,
)
return self

Expand Down
22 changes: 22 additions & 0 deletions src/pact/v3/matchers/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from .matchers import ( # noqa: TID252
Matcher,
MatcherEncoder,
decimal,
each_like,
include,
integer,
like,
regex,
)

__all__ = [
"decimal",
"each_like",
"integer",
"include",
"like",
"type",
"regex",
"Matcher",
"MatcherEncoder",
]
68 changes: 68 additions & 0 deletions src/pact/v3/matchers/matchers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
from __future__ import annotations

from json import JSONEncoder
from typing import Any, Dict, Optional, Union


class Matcher:
def __init__(
self,
matcher_type: str,
value: Optional[Any] = None, # noqa: ANN401
generator: Optional[str] = None,
**kwargs: Optional[str | int | float | bool],
) -> None:
self.type = matcher_type
self.value = value
self.generator = generator
self.extra_attrs = {k: v for k, v in kwargs.items() if v is not None}

def to_json(self) -> Union[str, Dict[str, Any]]:
json_data: Dict[str, Any] = {
"pact:matcher:type": self.type,
}
if self.value is not None:
json_data["value"] = self.value
if self.generator is not None:
json_data["pact:generator:type"] = self.generator
[json_data.update({k: v}) for k, v in self.extra_attrs.items() if v is not None]
return json_data


class MatcherEncoder(JSONEncoder):
def default(self, obj: Any) -> Union[str, Dict[str, Any]]: # noqa: ANN401
if isinstance(obj, Matcher):
return obj.to_json()
return super().default(obj)


def decimal(value: float) -> Matcher:
return Matcher("decimal", value)


def each_like(
value: Any, # noqa: ANN401
min_count: Optional[int] = None,
max_count: Optional[int] = None,
) -> Matcher:
return Matcher("type", [value], min=min_count, max=max_count)


def include(value: str, include: str) -> Matcher:
return Matcher("include", value, include=include)


def integer(value: int) -> Matcher:
return Matcher("integer", value)


def like(
value: Any, # noqa: ANN401
min_count: Optional[int] = None,
max_count: Optional[int] = None,
) -> Matcher:
return Matcher("type", value, min=min_count, max=max_count)


def regex(value: str, regex: str, generator: Optional[str] = None) -> Matcher:
return Matcher("regex", value, regex=regex, generator=generator)

0 comments on commit e5939c3

Please sign in to comment.