From f635a16509d525c93a082e6a13a047884435bdbd Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Fri, 29 Nov 2024 15:49:03 +1100 Subject: [PATCH 01/13] feat(v3): add message relay and callback servers Pact makes use of additional HTTP endpoints to be able to communicate. In particular: - A callback endpoint is required in order to setup/teardown the provider states; and, - HTTP is used as the transport mechanism for message interactions as the specific protocol is abstracted away. This commit adds the foundational HTTP servers for both use cases. Signed-off-by: JP-Ellis --- src/pact/v3/_server.py | 518 ++++++++++++++++++++++++++++++++++++++++ tests/v3/test_server.py | 191 +++++++++++++++ 2 files changed, 709 insertions(+) create mode 100644 src/pact/v3/_server.py create mode 100644 tests/v3/test_server.py diff --git a/src/pact/v3/_server.py b/src/pact/v3/_server.py new file mode 100644 index 000000000..befeccbe0 --- /dev/null +++ b/src/pact/v3/_server.py @@ -0,0 +1,518 @@ +""" +Internal Pact server. + +Pact typically communicates directly with the client/server under test over +HTTP. When testing message interactions, however, Pact abstracts away the +transport layer and instead verifies the message payload and metadata directly. + +Internally, this verification process still requires some form of transport +layer to communication with the underlying Pact Core library. This is where the +Pact server comes in. It is a lightweight HTTP server which translates +communications from the underlying Pact Core library with direct Python function +calls. + +In order to be able to both handle incoming requests, and verify the +interactions, the server is started in a separate thread within the same Python +process. This does have some risks, as the server is not isolated from the rest +of the Python process. This also relies on the requests being made sequentially +and not in parallel, as the server (and more specifically, the verification +process), is _not_ thread-safe. +""" + +from __future__ import annotations + +import base64 +import binascii +import json +import logging +import warnings +from collections.abc import Callable +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from threading import Thread +from typing import TYPE_CHECKING, Any, Generic, Self, TypeAlias, TypeVar +from urllib.parse import parse_qs, urlparse + +from pact import __version__ +from pact.v3._util import find_free_port + +if TYPE_CHECKING: + from types import TracebackType + +logger = logging.getLogger(__name__) + + +_C = TypeVar("_C", bound=Callable[..., Any]) + + +class HandlerHttpServer(ThreadingHTTPServer, Generic[_C]): + """ + A simple HTTP server with an custom handler function. + + Both the message relay and state handler need to be instantiated with a + user-provided function which is accessed during the handling of a request. + As Python's lightweight HTTP server makes the underlying server instance + accessible while processing a request, we can use this to pass the handler + function to the request handler. + """ + + def __init__( + self, + *args: Any, # noqa: ANN401 + handler: _C, + **kwargs: Any, # noqa: ANN401 + ) -> None: + """ + Initialize the HTTP server. + + Args: + handler: + The handler function to call when a request is received. + + *args: + Additional arguments to pass to the server. These are not used + by this class and are passed to the superclass. + + **kwargs: + Additional keyword arguments to pass to the server. These are + not used by this class and are passed to the superclass. + """ + self.handler = handler + super().__init__(*args, **kwargs) + + +################################################################################ +## Message Relay +################################################################################ + + +MessageHandlerCallable: TypeAlias = Callable[ + [bytes | None, dict[str, Any] | None], bytes | None +] + + +class MessageRelay: + """ + Internal message relay server. + + The Pact server is a lightweight HTTP server which translates communications + from the underlying Pact Core library with direct Python function calls. + + The server is responsible for starting and stopping the Pact server, as well + as handling the communication between the server and the underlying Pact + Core library. + """ + + def __init__( + self, + handler: MessageHandlerCallable, + host: str = "localhost", + port: int | None = None, + ) -> None: + """ + Initialize the Pact server. + + Args: + handler: + The handler function to call when a request is received. It must + accept two positional arguments: + + - The body of the request if present as a byte string, or + `None`. + - The metadata of the request if present as a dictionary, or + `None`. + + The handler function must return a byte string response, or + `None`. + + host: + The host to run the server on. + + port: + The port to run the server on. If not provided, a free port will + be found. + """ + self._host = host + self._port = port or find_free_port() + + self._handler = handler + + self._server: HandlerHttpServer[MessageHandlerCallable] | None = None + self._thread: Thread | None = None + + @property + def host(self) -> str: + """ + Server host. + """ + return self._host + + @property + def port(self) -> int: + """ + Server port. + """ + return self._port + + @property + def url(self) -> str: + """ + Server URL. + """ + return f"http://{self.host}:{self.port}" + + def __enter__(self) -> Self: + """ + Enter the Pact message server context. + + This method starts the Pact server in a separate thread to handle the + communication between the server and the underlying Pact Core library. + """ + self._server = HandlerHttpServer( + (self.host, self.port), + MessageRelayHandler, + handler=self._handler, + ) + self._thread = Thread( + target=self._server.serve_forever, + name="Pact Message Relay Server", + ) + self._thread.start() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + """ + Exit the Pact message server context. + """ + if not self._thread or not self._server: + warnings.warn( + "Exiting server context despite server not being started.", + stacklevel=2, + ) + return + + self._server.shutdown() + self._thread.join() + + +class MessageRelayHandler(SimpleHTTPRequestHandler): + """ + Request handler for the message relay server. + + The `do_GET` and `do_POST` methods allow the server to handle GET and POST + requests. A new instance of this class is created for each request and + attributes can be inspected to determine the request details and respond + accordingly. + + Specifically, the request details can be found in the following attributes: + + - `self.path` contains the HTTP path of the request. + - `self.headers` contains the HTTP headers of the request. + - `self.rfile` is an input stream containing the body of the request. + + The response can be sent using: + + - `self.send_response(code, message)` to set the response code and message. + - `self.send_header(header, value)` to set a response header. + - `self.end_headers()` to end the headers. + """ + + if TYPE_CHECKING: + server: HandlerHttpServer[MessageHandlerCallable] + + MESSAGE_PATH = "/_pact/message" + + def version_string(self) -> str: + """ + Return the server version string. + + This method is overridden to return a custom server version string. + """ + return f"Pact Python Message Relay/{__version__}" + + def _process(self) -> tuple[bytes | None, dict[str, str] | None]: + """ + Process the request. + + Read the body and headers from the request and perform some common logic + shared between GET and POST requests. + + Returns: + body: + The body of the request as a byte string, if present. + + metadata: + The metadata of the request, if present. + """ + if content_length := self.headers.get("Content-Length"): + body = self.rfile.read(int(content_length)) + else: + body = None + + if data := self.headers.get("Pact-Message-Metadata"): + try: + metadata = json.loads(base64.b64decode(data)) + except binascii.Error as err: + msg = "Unable to base64 decode Pact metadata header." + raise RuntimeError(msg) from err + except json.JSONDecodeError as err: + msg = "Unable to JSON decode Pact metadata header." + raise RuntimeError(msg) from err + else: + return body, metadata + + return body, None + + def do_POST(self) -> None: # noqa: N802 + """ + Handle a POST request. + + This method is called when a POST request is received by the server. + """ + logger.debug( + "Received POST request: %s", + self.path, + extra={"headers": self.headers}, + ) + self.close_connection = True + if self.path != self.MESSAGE_PATH: + self.send_response(404) + self.end_headers() + return + + body, metadata = self._process() + self.send_response(200, "OK") + self.end_headers() + + response = self.server.handler(body, metadata) + if response: + self.wfile.write(response) + + def do_GET(self) -> None: # noqa: N802 + """ + Handle a GET request. + + This method is called when a GET request is received by the server. + """ + logger.debug( + "Received GET request: %s", + self.path, + extra={"headers": self.headers}, + ) + self.close_connection = True + if self.path != self.MESSAGE_PATH: + self.send_response(404) + self.end_headers() + return + + body, metadata = self._process() + response = self.server.handler(body, metadata) + self.send_response(200, "OK") + self.end_headers() + + if response: + self.wfile.write(response) + + +################################################################################ +## State Handler +################################################################################ + + +StateHandlerCallable: TypeAlias = Callable[[str, str, dict[str, Any] | None], None] +""" +State handler function. + +It must accept three positional arguments: + +- The state name, for example "user exists" +- The state action, which is either "setup" or "teardown" +- The metadata of the request if present as a dictionary, or `None`. For + example, `{"user_id": 123}`. +""" + + +class StateCallback: + """ + Internal server for handlng state callbacks. + + The state handler is a lightweight HTTP server which listens for state + change requests from the underlying Pact Core library. It then calls a + user-provided function to handle the setup/teardown of the state. + """ + + def __init__( + self, + handler: StateHandlerCallable, + host: str = "localhost", + port: int | None = None, + ) -> None: + """ + Initialize the state handler. + + Args: + handler: + The handler function to call when a state callback is + received. + + The + + host: + The host to run the server on. + + port: + The port to run the server on. If not provided, a free port will + be found. + """ + self._host = host + self._port = port or find_free_port() + + self._handler = handler + + self._server: HandlerHttpServer[StateHandlerCallable] | None = None + self._thread: Thread | None = None + + @property + def host(self) -> str: + """ + Server host. + """ + return self._host + + @property + def port(self) -> int: + """ + Server port. + """ + return self._port + + @property + def url(self) -> str: + """ + Server URL. + """ + return f"http://{self.host}:{self.port}" + + def __enter__(self) -> Self: + """ + Enter the state handler context. + + This method starts the Pact server in a separate thread to handle the + communication between the server and the underlying Pact Core library. + """ + self._server = HandlerHttpServer( + (self.host, self.port), + StateCallbackHandler, + handler=self._handler, + ) + self._thread = Thread( + target=self._server.serve_forever, + name="Pact Message Relay Server", + ) + self._thread.start() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + """ + Exit the state handler context. + """ + if not self._thread or not self._server: + warnings.warn( + "Exiting server context despite server not being started.", + stacklevel=2, + ) + return + + self._server.shutdown() + self._thread.join() + + +class StateCallbackHandler(SimpleHTTPRequestHandler): + """ + Request handler for the state callback server. + + See the docs of [`MessageRelayHandler`](#messagerelayhandler) for more + information on how to handle requests. + """ + + if TYPE_CHECKING: + server: HandlerHttpServer[StateHandlerCallable] + + CALLBACK_PATH = "/_pact/state" + + def version_string(self) -> str: + """ + Return the server version string. + + This method is overridden to return a custom server version string. + """ + return f"Pact Python State Callback/{__version__}" + + def do_POST(self) -> None: # noqa: N802 + """ + Handle a POST request. + + This method is called when a POST request is received by the server. + """ + logger.debug( + "Received POST request: %s", + self.path, + extra={"headers": self.headers}, + ) + self.close_connection = True + url = urlparse(self.path) + if url.path != self.CALLBACK_PATH: + self.send_response(404) + self.end_headers() + return + + if query := url.query: + data: dict[str, Any] = parse_qs(query) + # Convert single-element lists to single values + for k, v in data.items(): + if isinstance(v, list) and len(v) == 1: + data[k] = v[0] + + else: + content_length = self.headers.get("Content-Length") + if not content_length: + self.send_response(400, "Bad Request") + self.end_headers() + return + data = json.loads(self.rfile.read(int(content_length))) + + state = data.pop("state") + action = data.pop("action") + + if not state or not action: + self.send_response(400, "Bad Request") + self.end_headers() + return + + self.server.handler(state, action, data) + self.send_response(200, "OK") + self.end_headers() + + def do_GET(self) -> None: # noqa: N802 + """ + Handle a GET request. + + This method is called when a GET request is received by the server. + """ + logger.debug( + "Received GET request: %s", + self.path, + extra={"headers": self.headers}, + ) + self.close_connection = True + self.send_response(404) + self.end_headers() diff --git a/tests/v3/test_server.py b/tests/v3/test_server.py new file mode 100644 index 000000000..de6f665db --- /dev/null +++ b/tests/v3/test_server.py @@ -0,0 +1,191 @@ +""" +Tests for `pact.v3._server` module. +""" + +import base64 +import json +from unittest.mock import MagicMock + +import aiohttp +import pytest + +from pact.v3._server import MessageRelay, StateCallback + + +def test_relay_default_init() -> None: + handler = MagicMock() + server = MessageRelay(handler) + + assert server.host == "localhost" + assert server.port > 1024 # Random non-privileged port + assert server.url == f"http://{server.host}:{server.port}" + + +@pytest.mark.asyncio +async def test_relay_invalid_path_http() -> None: + handler = MagicMock(return_value="Not OK") + server = MessageRelay(handler) + + with server: + async with aiohttp.ClientSession() as session: + async with session.get(server.url) as response: + assert response.status == 404 + handler.assert_not_called() + + +@pytest.mark.asyncio +async def test_relay_get_http() -> None: + handler = MagicMock(return_value=b"Pact Python is awesome!") + server = MessageRelay(handler) + + with server: + async with aiohttp.ClientSession() as session: + async with session.get(server.url + "/_pact/message") as response: + assert response.status == 200 + assert await response.text() == "Pact Python is awesome!" + + handler.assert_called_once() + assert handler.call_args.args == (None, None) + + +@pytest.mark.asyncio +async def test_relay_post_http() -> None: + handler = MagicMock(return_value=b"Pact Python is awesome!") + server = MessageRelay(handler) + + with server: + async with aiohttp.ClientSession() as session: + async with session.post( + server.url + "/_pact/message", + data='{"hello": "world"}', + ) as response: + assert response.status == 200 + assert await response.text() == "Pact Python is awesome!" + + handler.assert_called_once() + assert handler.call_args.args == (b'{"hello": "world"}', None) + + +@pytest.mark.asyncio +async def test_relay_get_with_metadata() -> None: + handler = MagicMock(return_value=b"Pact Python is awesome!") + server = MessageRelay(handler) + metadata = base64.b64encode(json.dumps({"key": "value"}).encode()).decode() + + with server: + async with aiohttp.ClientSession() as session: + async with session.get( + server.url + "/_pact/message", + headers={"Pact-Message-Metadata": metadata}, + ) as response: + assert response.status == 200 + assert await response.text() == "Pact Python is awesome!" + + handler.assert_called_once() + assert handler.call_args.args == (None, {"key": "value"}) + + +@pytest.mark.asyncio +async def test_relay_post_with_metadata() -> None: + handler = MagicMock(return_value=b"Pact Python is awesome!") + server = MessageRelay(handler) + metadata = base64.b64encode(json.dumps({"key": "value"}).encode()).decode() + + with server: + async with aiohttp.ClientSession() as session: + async with session.post( + server.url + "/_pact/message", + data='{"hello": "world"}', + headers={"Pact-Message-Metadata": metadata}, + ) as response: + assert response.status == 200 + assert await response.text() == "Pact Python is awesome!" + + handler.assert_called_once() + assert handler.call_args.args == (b'{"hello": "world"}', {"key": "value"}) + + +def test_callback_default_init() -> None: + handler = MagicMock() + server = StateCallback(handler) + + assert server.host == "localhost" + assert server.port > 1024 # Random non-privileged port + assert server.url == f"http://{server.host}:{server.port}" + + +@pytest.mark.asyncio +async def test_callback_invalid_http() -> None: + handler = MagicMock(return_value=None) + server = StateCallback(handler) + + with server: + async with aiohttp.ClientSession() as session: + async with session.get(server.url) as response: + assert response.status == 404 + handler.assert_not_called() + + +@pytest.mark.asyncio +async def test_callback_get_http() -> None: + handler = MagicMock(return_value=None) + server = StateCallback(handler) + + with server: + async with aiohttp.ClientSession() as session: + async with session.get(server.url + "/_pact/state") as response: + assert response.status == 404 + + handler.assert_not_called() + + +@pytest.mark.asyncio +async def test_callback_post_query() -> None: + handler = MagicMock(return_value=None) + server = StateCallback(handler) + + with server: + async with aiohttp.ClientSession() as session: + async with session.post( + server.url + "/_pact/state", + params={ + "state": "user exists", + "action": "setup", + "foo": "bar", + "1": 2, + }, + ) as response: + assert response.status == 200 + + handler.assert_called_once() + assert handler.call_args.args == ( + "user exists", + "setup", + {"foo": "bar", "1": "2"}, + ) + + +@pytest.mark.asyncio +async def test_callback_post_body() -> None: + handler = MagicMock(return_value=None) + server = StateCallback(handler) + + with server: + async with aiohttp.ClientSession() as session: + async with session.post( + server.url + "/_pact/state", + json={ + "state": "user exists", + "action": "setup", + "foo": "bar", + "1": 2, + }, + ) as response: + assert response.status == 200 + + handler.assert_called_once() + assert handler.call_args.args == ( + "user exists", + "setup", + {"foo": "bar", "1": 2}, + ) From 173f220676ff6ab90c1fd241f7b3dc609eacf06f Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Fri, 29 Nov 2024 15:50:54 +1100 Subject: [PATCH 02/13] feat(v3)!: integrate message relay server With the message relay server added, this commit modifies the verifier to allow for a handler function to be used to handle messages. Note that this does _not_ modify the tests yet (this will be done in another commit). BREAKING CHANGE: The provider name must be given as an argument of the `Verifier` constructor, instead of the first argument of the `set_info` method. BREAKING CHANGE: The `set_info` verifier method is removed, with `add_transport` needing to be used. Signed-off-by: JP-Ellis --- examples/tests/v3/test_01_fastapi_provider.py | 13 +- examples/tests/v3/test_03_message_provider.py | 4 +- examples/tests/v3/test_match.py | 4 +- src/pact/v3/verifier.py | 306 +++++++++++------- tests/v3/compatibility_suite/conftest.py | 2 +- .../test_v3_http_matching.py | 2 +- tests/v3/compatibility_suite/util/provider.py | 2 +- tests/v3/test_verifier.py | 26 +- 8 files changed, 212 insertions(+), 147 deletions(-) diff --git a/examples/tests/v3/test_01_fastapi_provider.py b/examples/tests/v3/test_01_fastapi_provider.py index 1e181a4bb..2f91fbabe 100644 --- a/examples/tests/v3/test_01_fastapi_provider.py +++ b/examples/tests/v3/test_01_fastapi_provider.py @@ -157,11 +157,14 @@ def test_provider() -> None: proc = Process(target=run_server, daemon=True) proc.start() time.sleep(2) - verifier = Verifier().set_info("v3_http_provider", url=PROVIDER_URL) - verifier.add_source("examples/pacts/v3_http_consumer-v3_http_provider.json") - verifier.set_state( - PROVIDER_URL / "_pact" / "callback", - teardown=True, + verifier = ( + Verifier("v3_http_provider") + .add_transport(url=PROVIDER_URL) + .add_source("examples/pacts/v3_http_consumer-v3_http_provider.json") + .set_state( + PROVIDER_URL / "_pact" / "callback", + teardown=True, + ) ) verifier.verify() diff --git a/examples/tests/v3/test_03_message_provider.py b/examples/tests/v3/test_03_message_provider.py index e3b5d6126..e9473c496 100644 --- a/examples/tests/v3/test_03_message_provider.py +++ b/examples/tests/v3/test_03_message_provider.py @@ -61,12 +61,12 @@ def test_producer() -> None: state_provider_function="state_provider_function", ) as provider_url: verifier = ( - Verifier() + Verifier("provider") + .add_transport(url=f"{provider_url}/produce_message") .set_state( provider_url / "set_provider_state", teardown=True, ) - .set_info("provider", url=f"{provider_url}/produce_message") .filter_consumers("v3_message_consumer") .add_source(PACT_DIR / "v3_message_consumer-v3_message_provider.json") ) diff --git a/examples/tests/v3/test_match.py b/examples/tests/v3/test_match.py index bb8dafeb9..02080b1f6 100644 --- a/examples/tests/v3/test_match.py +++ b/examples/tests/v3/test_match.py @@ -127,8 +127,8 @@ def test_matchers() -> None: pact.write_file(pact_dir, overwrite=True) with start_provider() as url: verifier = ( - Verifier() - .set_info("My Provider", url=url) + Verifier("My Provider") + .add_transport(url=url) .add_source(pact_dir / "consumer-provider.json") ) verifier.verify() diff --git a/src/pact/v3/verifier.py b/src/pact/v3/verifier.py index a51e3c910..824652faa 100644 --- a/src/pact/v3/verifier.py +++ b/src/pact/v3/verifier.py @@ -31,13 +31,19 @@ # In the case of local Pact files -verifier = Verifier().set_info("My Provider", url="http://localhost:8080") -verifier.add_source("pact/to/pacts/") +verifier = ( + Verifier("My Provider") + .add_transport("http", url="http://localhost:8080") + .add_source("pact/to/pacts/") +) verifier.verify() # In the case of a Pact Broker -verifier = Verifier().set_info("My Provider", url="http://localhost:8080") -verifier.broker_source("https://broker.example.com/") +verifier = ( + Verifier("My Provider") + .add_transport("http", url="http://localhost:8080") + .broker_source("https://broker.example.com/") +) verifier.verify() ``` @@ -68,19 +74,62 @@ from __future__ import annotations import json +from contextlib import nullcontext from datetime import date from pathlib import Path -from typing import TYPE_CHECKING, Any, Literal, overload +from typing import TYPE_CHECKING, Any, Callable, Literal, TypedDict, overload from typing_extensions import Self from yarl import URL import pact.v3.ffi +from pact.v3._server import MessageRelay if TYPE_CHECKING: from collections.abc import Iterable +class _ProviderTransport(TypedDict): + """ + Provider transport information. + + When the verifier is set up, it needs to communicate with the Provider. This + is typically done over a single transport method (e.g., HTTP); however, Pact + _does_ support multiple transport methods. + + This dictionary is used to store information for each transport method and + is a reflection of Rust's [`ProviderTransport` + struct](https://github.com/pact-foundation/pact-reference/blob/b55407ef2be897d286af9330506219d17d2a746c/rust/pact_verifier/src/lib.rs#L168). + """ + + transport: str + """ + The transport method for payloads. + + This is typically one of `http` or `message`. Any other value is used as a + custom plugin (e.g., `grpc`). + """ + port: int | None + """ + The port on which the provider is listening. + """ + path: str | None + """ + The path under which the provider is listening. + + This is prefixed to all paths in interactions. For example, if the path is + `/api`, and the interaction path is `/users`, the request will be made to + `/api/users`. + """ + scheme: str | None + """ + The scheme to use for the provider. + + This is typically only used for the `http` transport method, where this + value can either be `http` or `https`. + """ + + class Verifier: """ A Verifier between a consumer and a provider. @@ -91,16 +140,31 @@ class Verifier: match the expectations set by the consumer. """ - def __init__(self) -> None: + def __init__(self, name: str, host: str | None = None) -> None: """ Create a new Verifier. + + Args: + name: + The name of the provider to verify. This is used to identify + which interactions the provider is involved in, and then Pact + will replay these interactions against the provider. + + host: + The host on which the Pact verifier is running. This is used to + communicate with the provider. If not specified, the default + value is `localhost`. """ - self._handle: pact.v3.ffi.VerifierHandle = ( - pact.v3.ffi.verifier_new_for_application() - ) + self._name = name + self._host = host or "localhost" + self._handle = pact.v3.ffi.verifier_new_for_application() # In order to provide a fluent interface, we remember some options which - # are set using the same FFI method. + # are set using the same FFI method. In particular, we remember + # transport methods defined, and then before verification call the + # `set_info` and `add_transport` FFI methods as needed. + self._transports: list[_ProviderTransport] = [] + self._message_relay: MessageRelay | nullcontext[None] = nullcontext() self._disable_ssl_verification = False self._request_timeout = 5000 @@ -108,107 +172,19 @@ def __str__(self) -> str: """ Informal string representation of the Verifier. """ - return "Verifier" + return f"Verifier({self._name})" def __repr__(self) -> str: """ Information-rish string representation of the Verifier. """ - return f"" - - def set_info( # noqa: PLR0913 - self, - name: str, - *, - url: str | URL | None = None, - scheme: str | None = None, - host: str | None = None, - port: int | None = None, - path: str | None = None, - ) -> Self: - """ - Set the provider information. - - This sets up information about the provider as well as the way it - communicates with the consumer. Note that for historical reasons, a - HTTP(S) transport method is always added. - - For a provider which uses other protocols (such as message queues), the - [`add_transport`][pact.v3.verifier.Verifier.add_transport] must be used. - This method can be called multiple times to add multiple transport - methods. - - Args: - name: - A user-friendly name for the provider. - - url: - The URL on which requests are made to the provider by Pact. - - It is recommended to use this parameter to set the provider URL. - If the port is not explicitly set, the default port for the - scheme will be used. - - This parameter is mutually exclusive with the individual - parameters. - - scheme: - The provider scheme. This must be one of `http` or `https`. - - host: - The provider hostname or IP address. If the provider is running - on the same machine as the verifier, `localhost` can be used. - - port: - The provider port. If not specified, the default port for the - schema will be used. - - path: - The provider context path. If not specified, the root path will - be used. - - If a non-root path is used, the path given here will be - prepended to the path in the interaction. For example, if the - path is `/api`, and the interaction path is `/users`, the - request will be made to `/api/users`. - """ - if url is not None: - if any(param is not None for param in (scheme, host, port, path)): - msg = "Cannot specify both `url` and individual parameters" - raise ValueError(msg) - - url = URL(url) - scheme = url.scheme - host = url.host - port = url.explicit_port - path = url.path - - if port is None: - msg = "Unable to determine default port for scheme {scheme}" - raise ValueError(msg) - - pact.v3.ffi.verifier_set_provider_info( - self._handle, - name, - scheme, - host, - port, - path, - ) - return self - - url = URL.build( - scheme=scheme or "http", - host=host or "localhost", - port=port, - path=path or "", - ) - return self.set_info(name, url=url) + return f"" def add_transport( self, *, - protocol: str, + url: str | URL | None = None, + protocol: str | None = None, port: int | None = None, path: str | None = None, scheme: str | None = None, @@ -222,25 +198,29 @@ def add_transport( methods. As some transport methods may not use ports, paths or schemes, these - parameters are optional. + parameters are optional. Note that while optional, these _may_ still be + used during testing as Pact uses HTTP(S) to communicate with the + provider. For example, if you are implementing your own message + verification, it needs to be exposed over HTTP and the `port` and `path` + arguments are used for this testing communication. Args: + url: + A convenient way to set the provider transport. This option + is mutually exclusive with the other options. + protocol: The protocol to use. This will typically be one of: - - `http` for communications over HTTP(S). Note that when - setting up the provider information in - [`set_info`][pact.v3.verifier.Verifier.set_info], a HTTP - transport method is always added and it is unlikely that an - additional HTTP transport method will be needed unless the - provider is running on additional ports. + - `http` for communications over HTTP(S) - - `message` for non-plugin synchronous message-based - communications. + - `message` for non-plugin message-based communications Any other protocol will be treated as a custom protocol and will be handled by a plugin. + If `url` is _not_ specified, this parameter is required. + port: The provider port. @@ -269,18 +249,82 @@ def add_transport( This is typically only used for the `http` protocol, where this value can either be `http` (the default) or `https`. """ + if url and any(x is not None for x in (protocol, port, path, scheme)): + msg = "The `url` parameter is mutually exclusive with other parameters" + raise ValueError(msg) + + if url: + url = URL(url) + if url.host != self._host: + msg = f"Host mismatch: {url.host} != {self._host}" + raise ValueError(msg) + protocol = url.scheme + if protocol == "https": + protocol = "http" + port = url.port + path = url.path + scheme = url.scheme + return self.add_transport( + protocol=protocol, + port=port, + path=path, + scheme=scheme, + ) + + if not protocol: + msg = "A protocol must be specified" + raise ValueError(msg) + if port is None and scheme: if scheme.lower() == "http": port = 80 elif scheme.lower() == "https": port = 443 - pact.v3.ffi.verifier_add_provider_transport( - self._handle, - protocol, - port or 0, - path, - scheme, + self._transports.append( + _ProviderTransport( + transport=protocol, + port=port, + path=path, + scheme=scheme, + ) + ) + + return self + + def message_handler(self, handler: Callable[[Any, Any], None]) -> Self: + """ + Set the message handler. + + This method can be used to set a custom message handler for the + verifier. The message handler is called when the verifier needs to send + a message to the provider. + + As message interactions abstract the transport layer, the message + handler is responsible for receiving (and possibly responding) to the + messages. + + ## Implementation + + Internally, Pact Python uses a lightweight HTTP server as we need to use + _some_ transport method to communicate between the Pact Core library and + the Python provider. The lightweight HTTP server receives the payloads + and then passes them to the message handler. + + It is possible to use your own HTTP server to handle messages by using + the `add_transport` method. It is not possible to use both this method + and `add_transport` to handle messages. + + Args: + handler: + The message handler. This should be a callable that takes no + arguments. + """ + self._message_relay = MessageRelay(handler) + self.add_transport( + protocol="message", + port=self._message_relay.port, + path="/_pact/message", ) return self @@ -766,7 +810,33 @@ def verify(self) -> Self: Returns: Whether the interactions were verified successfully. """ - pact.v3.ffi.verifier_execute(self._handle) + if not self._transports: + msg = "No transports have been set" + raise RuntimeError(msg) + + first, *rest = self._transports + + pact.v3.ffi.verifier_set_provider_info( + self._handle, + self._name, + first["scheme"], + self._host, + first["port"], + first["path"], + ) + + for transport in rest: + pact.v3.ffi.verifier_add_provider_transport( + self._handle, + transport["transport"], + transport["port"] or 0, + transport["path"], + transport["scheme"], + ) + + with self._message_relay: + pact.v3.ffi.verifier_execute(self._handle) + return self @property diff --git a/tests/v3/compatibility_suite/conftest.py b/tests/v3/compatibility_suite/conftest.py index bb5fdbab2..57f980289 100644 --- a/tests/v3/compatibility_suite/conftest.py +++ b/tests/v3/compatibility_suite/conftest.py @@ -39,7 +39,7 @@ def _submodule_init() -> None: @pytest.fixture def verifier() -> Verifier: """Return a new Verifier.""" - return Verifier() + return Verifier("provider") @pytest.fixture(scope="session") diff --git a/tests/v3/compatibility_suite/test_v3_http_matching.py b/tests/v3/compatibility_suite/test_v3_http_matching.py index 92b0118be..50a204f3f 100644 --- a/tests/v3/compatibility_suite/test_v3_http_matching.py +++ b/tests/v3/compatibility_suite/test_v3_http_matching.py @@ -178,7 +178,7 @@ def the_comparison_should_not_be_ok( negated: bool, # noqa: FBT001 ) -> Verifier: """The comparison should NOT be OK.""" - verifier.set_info("provider", url=provider_url) + verifier.add_transport(url=provider_url) verifier.add_transport( protocol="http", port=provider_url.port, diff --git a/tests/v3/compatibility_suite/util/provider.py b/tests/v3/compatibility_suite/util/provider.py index 99f389f38..be2c0d2a1 100644 --- a/tests/v3/compatibility_suite/util/provider.py +++ b/tests/v3/compatibility_suite/util/provider.py @@ -1117,7 +1117,7 @@ def _( """ logger.debug("Running verification on %r", verifier) - verifier.set_info("provider", url=provider_url) + verifier.add_transport(url=provider_url) verifier.add_transport( protocol="message", port=provider_url.port, diff --git a/tests/v3/test_verifier.py b/tests/v3/test_verifier.py index 933e1bd2c..c31a45a37 100644 --- a/tests/v3/test_verifier.py +++ b/tests/v3/test_verifier.py @@ -18,30 +18,21 @@ @pytest.fixture def verifier() -> Verifier: - return Verifier() + return Verifier("Tester") def test_str_repr(verifier: Verifier) -> None: - assert str(verifier) == "Verifier" - assert re.match(r"", repr(verifier)) + assert str(verifier) == "Verifier(Tester)" + assert re.match( + r"", + repr(verifier), + ) def test_set_provider_info(verifier: Verifier) -> None: - name = "test_provider" url = "http://localhost:8888/api" - verifier.set_info(name, url=url) - - scheme = "http" - host = "localhost" - port = 8888 - path = "/api" - verifier.set_info( - name, - scheme=scheme, - host=host, - port=port, - path=path, - ) + verifier.add_transport(url=url) + verifier.verify() def test_add_provider_transport(verifier: Verifier) -> None: @@ -163,6 +154,7 @@ def test_broker_source_selector(verifier: Verifier) -> None: def test_verify(verifier: Verifier) -> None: + verifier.add_transport(url="http://localhost:8080") verifier.verify() From fc8a1745fe6d9b21b60c4e07616f78b77f3c7290 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Mon, 2 Dec 2024 11:02:10 +1100 Subject: [PATCH 03/13] feat(v3)!: add state handler server When testing a provider, it is very common that the provider state needs to be set up. This commit allows for functions to be provided instead of needing to configure a HTTP handler for the callback. BREAKING CHANGE: `set_state` has been renamed to `state_handler`. If using a URL still, the `body` keyword argument is now a _required_ parameter. Signed-off-by: JP-Ellis --- src/pact/v3/verifier.py | 363 +++++++++++++++++- tests/v3/compatibility_suite/util/provider.py | 3 +- tests/v3/test_verifier.py | 4 +- 3 files changed, 350 insertions(+), 20 deletions(-) diff --git a/src/pact/v3/verifier.py b/src/pact/v3/verifier.py index 824652faa..f20405fe6 100644 --- a/src/pact/v3/verifier.py +++ b/src/pact/v3/verifier.py @@ -73,22 +73,75 @@ from __future__ import annotations +import inspect import json +import typing from contextlib import nullcontext from datetime import date from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable, Literal, TypedDict, overload +from typing import TYPE_CHECKING, Any, Callable, Literal, TypeAlias, TypedDict, overload from typing_extensions import Self from yarl import URL import pact.v3.ffi -from pact.v3._server import MessageRelay +from pact.v3._server import MessageRelay, StateCallback if TYPE_CHECKING: from collections.abc import Iterable +StateHandlerFull: TypeAlias = Callable[[str, str, dict[str, Any] | None], None] +""" +Full state handler signature. + +This is the signature for a state handler that takes three arguments: + +1. The state name, as a string. +2. The action (either `setup` or `teardown`), as a string. +3. A dictionary of parameters, or `None` if no parameters are provided. +""" +StateHandlerNoAction: TypeAlias = Callable[[str, dict[str, Any] | None], None] +""" +State handler signature without the action. + +This is the signature for a state handler that takes two arguments: + +1. The state name, as a string. +2. A dictionary of parameters, or `None` if no parameters are provided. +""" +StateHandlerNoState: TypeAlias = Callable[[str, dict[str, Any] | None], None] +""" +State handler signature without the state. + +This is the signature for a state handler that takes two arguments: + +1. The action (either `setup` or `teardown`), as a string. +2. A dictionary of parameters, or `None` if no parameters are provided. + +This function must be provided as part of a dictionary mapping state names to +functions. +""" +StateHandlerNoActionNoState: TypeAlias = Callable[[dict[str, Any] | None], None] +""" +State handler signature without the state or action. + +This is the signature for a state handler that takes one argument: + +1. A dictionary of parameters, or `None` if no parameters are provided. + +This function must be provided as part of a dictionary mapping state names to +functions. +""" +StateHandlerUrl: TypeAlias = str | URL +""" +State handler URL signature. + +Instead of providing a function to handle state changes, it is possible to +provide a URL endpoint to which the request should be made. +""" + + class _ProviderTransport(TypedDict): """ Provider transport information. @@ -165,6 +218,7 @@ def __init__(self, name: str, host: str | None = None) -> None: # `set_info` and `add_transport` FFI methods as needed. self._transports: list[_ProviderTransport] = [] self._message_relay: MessageRelay | nullcontext[None] = nullcontext() + self._state_handler: StateCallback | nullcontext[None] = nullcontext() self._disable_ssl_verification = False self._request_timeout = 5000 @@ -363,26 +417,157 @@ def filter( ) return self - def set_state( + # Cases where the handler takes the state name. + @overload + def state_handler( self, - url: str | URL, + handler: StateHandlerFull, + *, + teardown: Literal[True], + body: None = None, + ) -> Self: ... + @overload + def state_handler( + self, + handler: StateHandlerNoAction, + *, + teardown: Literal[False] = False, + body: None = None, + ) -> Self: ... + # Cases where the handler takes a dictionary of functions + @overload + def state_handler( + self, + handler: dict[str, StateHandlerNoState], + *, + teardown: Literal[True], + body: None = None, + ) -> Self: ... + @overload + def state_handler( + self, + handler: dict[str, StateHandlerNoActionNoState], + *, + teardown: Literal[False] = False, + body: None = None, + ) -> Self: ... + # Cases where the handler takes a URL + @overload + def state_handler( + self, + handler: StateHandlerUrl, *, teardown: bool = False, - body: bool = False, + body: bool, + ) -> Self: ... + + def state_handler( + self, + handler: StateHandlerFull + | StateHandlerNoAction + | dict[str, StateHandlerNoState] + | dict[str, StateHandlerNoActionNoState] + | StateHandlerUrl, + *, + teardown: bool = False, + body: bool | None = None, ) -> Self: """ - Set the provider state URL. + Set the state handler. + + In many interactions, the consumer will assume that the provider is in a + certain state. For example, a consumer requesting information about a + user with ID `123` will have specified `given("user with ID 123 + exists")`. + + The state handler is responsible for changing the provider's internal + state to match the expected state before the interaction is replayed. + + This can be done in one of three ways: + + 1. By providing a single function that will be called for all state + changes. + 2. By providing a mapping of state names to functions. + 3. By providing the URL endpoint to which the request should be made. + + The first two options are most straightforward to use. + + When providing a function, the arguments should be: - The URL is used when the provider's internal state needs to be changed. - For example, a consumer might have an interaction that requires a - specific user to be present in the database. The provider state URL is - used to change the provider's internal state to include the required - user. + 1. The state name, as a string. + 2. The action (either `setup` or `teardown`), as a string. + 3. A dictionary of parameters, or `None` if no parameters are provided. + + Note that these arguments will change in the following ways: + + 1. If a dictionary mapping is used, the state name is _not_ provided to + the function. + 2. If `teardown` is `False` thereby indicating that the function is + only called for setup, the `action` argument is not provided. + + This means that in the case of a dictionary mapping of function with + `teardown=False`, the function should take only one argument: the + dictionary of parameters (which itself may be `None`, albeit still an + argument). Args: - url: - The URL to which a `GET` request will be made to change the - provider's internal state. + handler: + The handler for the state changes. This can be one of the + following: + + - A single function that will be called for all state changes. + - A dictionary mapping state names to functions. + - A URL endpoint to which the request should be made. + + See above for more information on the function signature. + + teardown: + Whether to teardown the provider state after an interaction is + validated. + + body: + Whether to include the state change request in the body (`True`) + or in the query string (`False`). This must be left as `None` if + providing one or more handler functions; and it must be set to + a boolean if providing a URL. + """ + if isinstance(handler, StateHandlerUrl): + if body is None: + msg = "The `body` parameter must be a boolean when providing a URL" + raise ValueError(msg) + return self._state_handler_url(handler, teardown=teardown, body=body) + + if isinstance(handler, dict): + if body is not None: + msg = "The `body` parameter must be `None` when providing a dictionary" + raise ValueError(msg) + return self._state_handler_dict(handler, teardown=teardown) + + if callable(handler): + if body is not None: + msg = "The `body` parameter must be `None` when providing a function" + raise ValueError(msg) + return self._set_function_state_handler(handler, teardown=teardown) + + msg = "Invalid handler type" + raise TypeError(msg) + + def _state_handler_url( + self, + handler: StateHandlerUrl, + *, + teardown: bool, + body: bool, + ) -> Self: + """ + Set the state handler to a URL. + + This method is used to set the state handler to a URL endpoint. This + endpoint will be called to change the provider's state. + + Args: + handler: + The URL endpoint to which the request should be made. teardown: Whether to teardown the provider state after an interaction is @@ -391,15 +576,161 @@ def set_state( body: Whether to include the state change request in the body (`True`) or in the query string (`False`). + + Returns: + The verifier instance. """ pact.v3.ffi.verifier_set_provider_state( self._handle, - url if isinstance(url, str) else str(url), + str(handler), teardown=teardown, body=body, ) return self + def _state_handler_dict( + self, + handler: dict[str, StateHandlerNoState] + | dict[str, StateHandlerNoActionNoState], + *, + teardown: bool, + ) -> Self: + """ + Set the state handler to a dictionary of functions. + + This method is used to set the state handler to a dictionary of functions. + Each function is called when the provider's state needs to be changed. + + Args: + handler: + The dictionary mapping state names to functions. If `teardown` + is `True`, the functions must take two arguments: the action and + the parameters. If `teardown` is `False`, the functions must take + one argument: the parameters. + + teardown: + Whether to teardown the provider state after an interaction is + validated. + + Returns: + The verifier instance. + """ + if any(not callable(f) for f in handler.values()): + msg = "All values in the dictionary must be callable" + raise TypeError(msg) + + if teardown: + if any( + len(inspect.signature(f).parameters) != 2 # noqa: PLR2004 + for f in handler.values() + ): + msg = "All functions must take two arguments: action and parameters" + raise TypeError(msg) + + handler_map = typing.cast(dict[str, StateHandlerNoState], handler) + + def _handler( + state: str, + action: str, + parameters: dict[str, Any] | None, + ) -> None: + handler_map[state](action, parameters) + + else: + if any(len(inspect.signature(f).parameters) != 1 for f in handler.values()): + msg = "All functions must take one argument: parameters" + raise TypeError(msg) + + handler_map_no_action = typing.cast( + dict[str, StateHandlerNoActionNoState], + handler, + ) + + def _handler( + state: str, + action: str, # noqa: ARG001 + parameters: dict[str, Any] | None, + ) -> None: + handler_map_no_action[state](parameters) + + self._state_handler = StateCallback(_handler) + pact.v3.ffi.verifier_set_provider_state( + self._handle, + self._state_handler.url, + teardown=teardown, + body=True, + ) + + return self + + def _set_function_state_handler( + self, + handler: StateHandlerFull | StateHandlerNoAction, + *, + teardown: bool, + ) -> Self: + """ + Set the state handler to a single function. + + This method is used to set the state handler to a single function. This + function will be called when the provider's state needs to be changed. + + Args: + handler: + The function to call when the provider's state needs to be + changed. If `teardown` is `True`, the function must take three + arguments: the state, the action, and the parameters. If + `teardown` is `False`, the function must take two arguments: the + state and the parameters. + + teardown: + Whether to teardown the provider state after an interaction is + validated. + + Returns: + The verifier instance. + """ + if teardown: + if len(inspect.signature(handler).parameters) != 3: # noqa: PLR2004 + msg = ( + "The function must take three arguments: " + "state, action, and parameters." + ) + raise TypeError(msg) + + handler_fn_full = typing.cast(StateHandlerFull, handler) + + def _handler( + state: str, + action: str, + parameters: dict[str, Any] | None, + ) -> None: + handler_fn_full(state, action, parameters) + + else: + if len(inspect.signature(handler).parameters) != 2: # noqa: PLR2004 + msg = "The function must take two arguments: state and parameters" + raise TypeError(msg) + + handler_fn_no_action = typing.cast(StateHandlerNoAction, handler) + + def _handler( + state: str, + action: str, # noqa: ARG001 + parameters: dict[str, Any] | None, + ) -> None: + handler_fn_no_action(state, parameters) + + self._state_handler = StateCallback(_handler) + pact.v3.ffi.verifier_set_provider_state( + self._handle, + self._state_handler.url, + teardown=teardown, + body=True, + ) + + return self + def disable_ssl_verification(self) -> Self: """ Disable SSL verification. @@ -834,7 +1165,7 @@ def verify(self) -> Self: transport["scheme"], ) - with self._message_relay: + with self._message_relay, self._state_handler: pact.v3.ffi.verifier_execute(self._handle) return self diff --git a/tests/v3/compatibility_suite/util/provider.py b/tests/v3/compatibility_suite/util/provider.py index be2c0d2a1..d5edc83c0 100644 --- a/tests/v3/compatibility_suite/util/provider.py +++ b/tests/v3/compatibility_suite/util/provider.py @@ -973,9 +973,10 @@ def _( with (temp_dir / "fail_callback").open("w") as f: f.write("true") - verifier.set_state( + verifier.state_handler( provider_url / "_pact" / "callback", teardown=True, + body=False, ) diff --git a/tests/v3/test_verifier.py b/tests/v3/test_verifier.py index c31a45a37..ad1a89363 100644 --- a/tests/v3/test_verifier.py +++ b/tests/v3/test_verifier.py @@ -71,9 +71,7 @@ def test_set_filter(verifier: Verifier) -> None: def test_set_state(verifier: Verifier) -> None: - verifier.set_state("test_state") - verifier.set_state("test_state", teardown=True) - verifier.set_state("test_state", body=True) + verifier.state_handler("test_state", body=True) def test_disable_ssl_verification(verifier: Verifier) -> None: From 59ded89884fae9103aa9ce08d55819202701cede Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Wed, 4 Dec 2024 17:38:34 +1100 Subject: [PATCH 04/13] feat(v3)!: further simplify message interface The initial implentation required the end user to provide a function; however, this has been expanded to also allow for mapping of message names to either functions, or static message definitions. Furthermore, the function signature has been tweaked. It now expects a dictionary, with the `Message` typed dictionary being provided for convenience. As part of this commit, the MessageRelay has been renamed to MessageProducer to more correctly reflect its purpose. BREAKING CHANGE: `message_handler` signature has been changed and expanded. Signed-off-by: JP-Ellis --- src/pact/v3/_server.py | 119 +++++++++++----------- src/pact/v3/types.py | 110 +++++++++++++++++++- src/pact/v3/types.pyi | 86 +++++++++++++++- src/pact/v3/verifier.py | 215 ++++++++++++++++++++++++++-------------- 4 files changed, 393 insertions(+), 137 deletions(-) diff --git a/src/pact/v3/_server.py b/src/pact/v3/_server.py index befeccbe0..bb6301af7 100644 --- a/src/pact/v3/_server.py +++ b/src/pact/v3/_server.py @@ -29,7 +29,7 @@ from collections.abc import Callable from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from threading import Thread -from typing import TYPE_CHECKING, Any, Generic, Self, TypeAlias, TypeVar +from typing import TYPE_CHECKING, Any, Generic, Self, TypeVar from urllib.parse import parse_qs, urlparse from pact import __version__ @@ -38,6 +38,8 @@ if TYPE_CHECKING: from types import TracebackType + from pact.v3.types import MessageProducerFull, StateHandlerFull + logger = logging.getLogger(__name__) @@ -85,14 +87,9 @@ def __init__( ################################################################################ -MessageHandlerCallable: TypeAlias = Callable[ - [bytes | None, dict[str, Any] | None], bytes | None -] - - -class MessageRelay: +class MessageProducer: """ - Internal message relay server. + Internal message producer server. The Pact server is a lightweight HTTP server which translates communications from the underlying Pact Core library with direct Python function calls. @@ -104,7 +101,7 @@ class MessageRelay: def __init__( self, - handler: MessageHandlerCallable, + handler: MessageProducerFull, host: str = "localhost", port: int | None = None, ) -> None: @@ -136,7 +133,7 @@ def __init__( self._handler = handler - self._server: HandlerHttpServer[MessageHandlerCallable] | None = None + self._server: HandlerHttpServer[MessageProducerFull] | None = None self._thread: Thread | None = None @property @@ -153,12 +150,19 @@ def port(self) -> int: """ return self._port + @property + def path(self) -> str: + """ + Server path. + """ + return MessageProducerHandler.MESSAGE_PATH + @property def url(self) -> str: """ Server URL. """ - return f"http://{self.host}:{self.port}" + return f"http://{self.host}:{self.port}{self.path}" def __enter__(self) -> Self: """ @@ -169,7 +173,7 @@ def __enter__(self) -> Self: """ self._server = HandlerHttpServer( (self.host, self.port), - MessageRelayHandler, + MessageProducerHandler, handler=self._handler, ) self._thread = Thread( @@ -199,7 +203,7 @@ def __exit__( self._thread.join() -class MessageRelayHandler(SimpleHTTPRequestHandler): +class MessageProducerHandler(SimpleHTTPRequestHandler): """ Request handler for the message relay server. @@ -222,7 +226,7 @@ class MessageRelayHandler(SimpleHTTPRequestHandler): """ if TYPE_CHECKING: - server: HandlerHttpServer[MessageHandlerCallable] + server: HandlerHttpServer[MessageProducerFull] MESSAGE_PATH = "/_pact/message" @@ -280,17 +284,39 @@ def do_POST(self) -> None: # noqa: N802 ) self.close_connection = True if self.path != self.MESSAGE_PATH: - self.send_response(404) - self.end_headers() + self.send_error(404, "Not Found") + return + + data: dict[str, Any] = json.loads( + self.rfile.read(int(self.headers.get("Content-Length", -1))) + ) + + description: str | None = data.pop("description", None) + if not description: + logger.error("No description provided in message.") + self.send_error(400, "Bad Request") return - body, metadata = self._process() self.send_response(200, "OK") - self.end_headers() - response = self.server.handler(body, metadata) - if response: - self.wfile.write(response) + message = self.server.handler(description, data) + + metadata = message.get("metadata") or {} + if content_type := message.get("content_type"): + self.send_header("Content-Type", content_type) + if "contentType" not in metadata: + metadata["contentType"] = content_type + + if metadata: + self.send_header( + "Pact-Message-Metadata", + base64.b64encode(json.dumps(metadata).encode()).decode(), + ) + + contents = message.get("contents", b"") + self.send_header("Content-Length", str(len(contents))) + self.end_headers() + self.wfile.write(contents) def do_GET(self) -> None: # noqa: N802 """ @@ -304,18 +330,7 @@ def do_GET(self) -> None: # noqa: N802 extra={"headers": self.headers}, ) self.close_connection = True - if self.path != self.MESSAGE_PATH: - self.send_response(404) - self.end_headers() - return - - body, metadata = self._process() - response = self.server.handler(body, metadata) - self.send_response(200, "OK") - self.end_headers() - - if response: - self.wfile.write(response) + self.send_error(404, "Not Found") ################################################################################ @@ -323,19 +338,6 @@ def do_GET(self) -> None: # noqa: N802 ################################################################################ -StateHandlerCallable: TypeAlias = Callable[[str, str, dict[str, Any] | None], None] -""" -State handler function. - -It must accept three positional arguments: - -- The state name, for example "user exists" -- The state action, which is either "setup" or "teardown" -- The metadata of the request if present as a dictionary, or `None`. For - example, `{"user_id": 123}`. -""" - - class StateCallback: """ Internal server for handlng state callbacks. @@ -347,7 +349,7 @@ class StateCallback: def __init__( self, - handler: StateHandlerCallable, + handler: StateHandlerFull, host: str = "localhost", port: int | None = None, ) -> None: @@ -373,7 +375,7 @@ def __init__( self._handler = handler - self._server: HandlerHttpServer[StateHandlerCallable] | None = None + self._server: HandlerHttpServer[StateHandlerFull] | None = None self._thread: Thread | None = None @property @@ -395,7 +397,7 @@ def url(self) -> str: """ Server URL. """ - return f"http://{self.host}:{self.port}" + return f"http://{self.host}:{self.port}{StateCallbackHandler.CALLBACK_PATH}" def __enter__(self) -> Self: """ @@ -445,7 +447,7 @@ class StateCallbackHandler(SimpleHTTPRequestHandler): """ if TYPE_CHECKING: - server: HandlerHttpServer[StateHandlerCallable] + server: HandlerHttpServer[StateHandlerFull] CALLBACK_PATH = "/_pact/state" @@ -471,8 +473,7 @@ def do_POST(self) -> None: # noqa: N802 self.close_connection = True url = urlparse(self.path) if url.path != self.CALLBACK_PATH: - self.send_response(404) - self.end_headers() + self.send_error(404, "Not Found") return if query := url.query: @@ -485,20 +486,19 @@ def do_POST(self) -> None: # noqa: N802 else: content_length = self.headers.get("Content-Length") if not content_length: - self.send_response(400, "Bad Request") - self.end_headers() + self.send_error(400, "Bad Request") return data = json.loads(self.rfile.read(int(content_length))) state = data.pop("state") action = data.pop("action") + params = data.pop("params") - if not state or not action: - self.send_response(400, "Bad Request") - self.end_headers() + if state is None or action is None: + self.send_error(400, "Bad Request") return - self.server.handler(state, action, data) + self.server.handler(state, action, params) self.send_response(200, "OK") self.end_headers() @@ -514,5 +514,4 @@ def do_GET(self) -> None: # noqa: N802 extra={"headers": self.headers}, ) self.close_connection = True - self.send_response(404) - self.end_headers() + self.send_error(404, "Not Found") diff --git a/src/pact/v3/types.py b/src/pact/v3/types.py index d4bd0e6a0..5850e434c 100644 --- a/src/pact/v3/types.py +++ b/src/pact/v3/types.py @@ -6,9 +6,13 @@ information to static type checkers like `mypy`. """ -from typing import Any +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, TypedDict from typing_extensions import TypeAlias +from yarl import URL Matchable: TypeAlias = Any """ @@ -26,6 +30,110 @@ """ +class Message(TypedDict): + """ + Message definition. + + This is a dictionary that is used to represent the message. This class can + be used as an initializer to create a new message, or the return of a + dictionary can be used directly. + """ + + contents: bytes + """ + Message contents. + + These are the actual contents of the message, as a `bytes` object. + """ + metadata: dict[str, Any] | None + """ + Any additional metadata associated with the message. + """ + content_type: str | None + """ + Content type of the message. + + This should be specified as a MIME type, such as `application/json`. + """ + + +MessageProducerFull: TypeAlias = Callable[[str, dict[str, Any] | None], Message] +""" +Full message producer signature. + +This is the signature for a message producer that takes two arguments: + +1. The message name, as a string. +2. A dictionary of parameters, or `None` if no parameters are provided. + +The function must return a `bytes` object. +""" + +MessageProducerNoName: TypeAlias = Callable[[dict[str, Any] | None], Message] +""" +Message producer signature without the name. + +This is the signature for a message producer that takes one argument: + +1. A dictionary of parameters, or `None` if no parameters are provided. + +The function must return a `bytes` object. + +This function must be provided as part of a dictionary mapping message names to +functions. +""" + +StateHandlerFull: TypeAlias = Callable[[str, str, dict[str, Any] | None], None] +""" +Full state handler signature. + +This is the signature for a state handler that takes three arguments: + +1. The state name, as a string. +2. The action (either `setup` or `teardown`), as a string. +3. A dictionary of parameters, or `None` if no parameters are provided. +""" +StateHandlerNoAction: TypeAlias = Callable[[str, dict[str, Any] | None], None] +""" +State handler signature without the action. + +This is the signature for a state handler that takes two arguments: + +1. The state name, as a string. +2. A dictionary of parameters, or `None` if no parameters are provided. +""" +StateHandlerNoState: TypeAlias = Callable[[str, dict[str, Any] | None], None] +""" +State handler signature without the state. + +This is the signature for a state handler that takes two arguments: + +1. The action (either `setup` or `teardown`), as a string. +2. A dictionary of parameters, or `None` if no parameters are provided. + +This function must be provided as part of a dictionary mapping state names to +functions. +""" +StateHandlerNoActionNoState: TypeAlias = Callable[[dict[str, Any] | None], None] +""" +State handler signature without the state or action. + +This is the signature for a state handler that takes one argument: + +1. A dictionary of parameters, or `None` if no parameters are provided. + +This function must be provided as part of a dictionary mapping state names to +functions. +""" +StateHandlerUrl: TypeAlias = str | URL +""" +State handler URL signature. + +Instead of providing a function to handle state changes, it is possible to +provide a URL endpoint to which the request should be made. +""" + + class Unset: """ Special type to represent an unset value. diff --git a/src/pact/v3/types.pyi b/src/pact/v3/types.pyi index e29d6bd41..9c9f66264 100644 --- a/src/pact/v3/types.pyi +++ b/src/pact/v3/types.pyi @@ -4,15 +4,16 @@ # As a result, it is safe to perform expensive imports, even if they are not # used or available at runtime. -from collections.abc import Collection, Mapping, Sequence +from collections.abc import Callable, Collection, Mapping, Sequence from collections.abc import Set as AbstractSet from datetime import date, datetime, time from decimal import Decimal from fractions import Fraction -from typing import Literal +from typing import Any, Literal, TypedDict from pydantic import BaseModel from typing_extensions import TypeAlias +from yarl import URL _BaseMatchable: TypeAlias = ( int | float | complex | bool | str | bytes | bytearray | memoryview | None @@ -116,6 +117,87 @@ GeneratorType: TypeAlias = _GeneratorTypeV3 | _GeneratorTypeV4 All supported generator types. """ +class Message(TypedDict): + contents: bytes + metadata: dict[str, Any] | None + content_type: str | None + +MessageProducerFull: TypeAlias = Callable[[str, dict[str, Any] | None], Message] +""" +Full message producer signature. + +This is the signature for a message producer that takes two arguments: + +1. The message name, as a string. +2. A dictionary of parameters, or `None` if no parameters are provided. + +The function must return a `bytes` object. +""" + +MessageProducerNoName: TypeAlias = Callable[[dict[str, Any] | None], Message] +""" +Message producer signature without the name. + +This is the signature for a message producer that takes one argument: + +1. A dictionary of parameters, or `None` if no parameters are provided. + +The function must return a `bytes` object. + +This function must be provided as part of a dictionary mapping message names to +functions. +""" + +StateHandlerFull: TypeAlias = Callable[[str, str, dict[str, Any] | None], None] +""" +Full state handler signature. + +This is the signature for a state handler that takes three arguments: + +1. The state name, as a string. +2. The action (either `setup` or `teardown`), as a string. +3. A dictionary of parameters, or `None` if no parameters are provided. +""" +StateHandlerNoAction: TypeAlias = Callable[[str, dict[str, Any] | None], None] +""" +State handler signature without the action. + +This is the signature for a state handler that takes two arguments: + +1. The state name, as a string. +2. A dictionary of parameters, or `None` if no parameters are provided. +""" +StateHandlerNoState: TypeAlias = Callable[[str, dict[str, Any] | None], None] +""" +State handler signature without the state. + +This is the signature for a state handler that takes two arguments: + +1. The action (either `setup` or `teardown`), as a string. +2. A dictionary of parameters, or `None` if no parameters are provided. + +This function must be provided as part of a dictionary mapping state names to +functions. +""" +StateHandlerNoActionNoState: TypeAlias = Callable[[dict[str, Any] | None], None] +""" +State handler signature without the state or action. + +This is the signature for a state handler that takes one argument: + +1. A dictionary of parameters, or `None` if no parameters are provided. + +This function must be provided as part of a dictionary mapping state names to +functions. +""" +StateHandlerUrl: TypeAlias = str | URL +""" +State handler URL signature. + +Instead of providing a function to handle state changes, it is possible to +provide a URL endpoint to which the request should be made. +""" + class Unset: ... UNSET = Unset() diff --git a/src/pact/v3/verifier.py b/src/pact/v3/verifier.py index f20405fe6..f464bde5a 100644 --- a/src/pact/v3/verifier.py +++ b/src/pact/v3/verifier.py @@ -75,71 +75,33 @@ import inspect import json +import logging import typing from contextlib import nullcontext from datetime import date from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable, Literal, TypeAlias, TypedDict, overload +from typing import TYPE_CHECKING, Any, Literal, TypedDict, overload from typing_extensions import Self from yarl import URL import pact.v3.ffi -from pact.v3._server import MessageRelay, StateCallback +from pact.v3._server import MessageProducer, StateCallback +from pact.v3.types import ( + Message, + MessageProducerFull, + MessageProducerNoName, + StateHandlerFull, + StateHandlerNoAction, + StateHandlerNoActionNoState, + StateHandlerNoState, + StateHandlerUrl, +) if TYPE_CHECKING: from collections.abc import Iterable - -StateHandlerFull: TypeAlias = Callable[[str, str, dict[str, Any] | None], None] -""" -Full state handler signature. - -This is the signature for a state handler that takes three arguments: - -1. The state name, as a string. -2. The action (either `setup` or `teardown`), as a string. -3. A dictionary of parameters, or `None` if no parameters are provided. -""" -StateHandlerNoAction: TypeAlias = Callable[[str, dict[str, Any] | None], None] -""" -State handler signature without the action. - -This is the signature for a state handler that takes two arguments: - -1. The state name, as a string. -2. A dictionary of parameters, or `None` if no parameters are provided. -""" -StateHandlerNoState: TypeAlias = Callable[[str, dict[str, Any] | None], None] -""" -State handler signature without the state. - -This is the signature for a state handler that takes two arguments: - -1. The action (either `setup` or `teardown`), as a string. -2. A dictionary of parameters, or `None` if no parameters are provided. - -This function must be provided as part of a dictionary mapping state names to -functions. -""" -StateHandlerNoActionNoState: TypeAlias = Callable[[dict[str, Any] | None], None] -""" -State handler signature without the state or action. - -This is the signature for a state handler that takes one argument: - -1. A dictionary of parameters, or `None` if no parameters are provided. - -This function must be provided as part of a dictionary mapping state names to -functions. -""" -StateHandlerUrl: TypeAlias = str | URL -""" -State handler URL signature. - -Instead of providing a function to handle state changes, it is possible to -provide a URL endpoint to which the request should be made. -""" +logger = logging.getLogger(__name__) class _ProviderTransport(TypedDict): @@ -217,7 +179,7 @@ def __init__(self, name: str, host: str | None = None) -> None: # transport methods defined, and then before verification call the # `set_info` and `add_transport` FFI methods as needed. self._transports: list[_ProviderTransport] = [] - self._message_relay: MessageRelay | nullcontext[None] = nullcontext() + self._message_producer: MessageProducer | nullcontext[None] = nullcontext() self._state_handler: StateCallback | nullcontext[None] = nullcontext() self._disable_ssl_verification = False self._request_timeout = 5000 @@ -335,6 +297,15 @@ def add_transport( elif scheme.lower() == "https": port = 443 + logger.debug( + "Adding transport to verifier", + extra={ + "protocol": protocol, + "port": port, + "path": path, + "scheme": scheme, + }, + ) self._transports.append( _ProviderTransport( transport=protocol, @@ -346,40 +317,103 @@ def add_transport( return self - def message_handler(self, handler: Callable[[Any, Any], None]) -> Self: + @overload + def message_handler(self, handler: MessageProducerFull) -> Self: ... + @overload + def message_handler( + self, + handler: dict[str, MessageProducerNoName | Message], + ) -> Self: ... + + def message_handler( + self, + handler: MessageProducerFull | dict[str, MessageProducerNoName | Message], + ) -> Self: """ Set the message handler. - This method can be used to set a custom message handler for the - verifier. The message handler is called when the verifier needs to send - a message to the provider. + This method sets a custom message handler for the verifier. The handler + can be called to produce a specific message to send to the provider. + + This can be provided in one of two ways: + + 1. A fully fledged function that will be called for all messages. The + function must take two arguments: the name of the message (as a + string), and optional parameters (as a dictionary). This then + returns the message as bytes. - As message interactions abstract the transport layer, the message - handler is responsible for receiving (and possibly responding) to the - messages. + This is the most powerful option as it allows for full control over + the message generation. + + 2. A dictionary mapping message names to producer functions, or bytes. + In this case, the producer function must take optional parameters + (as a dictionary) and return the message as bytes. + + If the message to be produced is static, the bytes can be provided + directly. ## Implementation - Internally, Pact Python uses a lightweight HTTP server as we need to use - _some_ transport method to communicate between the Pact Core library and - the Python provider. The lightweight HTTP server receives the payloads - and then passes them to the message handler. + There are a large number of ways to send messages, and the specifics of + the transport methods are not specifically relevant to Pact. As such, + Pact abstracts the transport layer away and uses a lightweight HTTP + server to handle messages. - It is possible to use your own HTTP server to handle messages by using - the `add_transport` method. It is not possible to use both this method - and `add_transport` to handle messages. + Pact Python is capable of setting up this server and handling the + messages internally using user-provided handlers. It is possible to use + your own HTTP server to handle messages by using the `add_transport` + method. It is not possible to use both this method and `add_transport` + to handle messages. Args: handler: The message handler. This should be a callable that takes no - arguments. + arguments: the """ - self._message_relay = MessageRelay(handler) - self.add_transport( - protocol="message", - port=self._message_relay.port, - path="/_pact/message", + logger.debug( + "Setting message handler for verifier", + extra={ + "path": "/_pact/message", + }, ) + + if callable(handler): + if len(inspect.signature(handler).parameters) != 2: # noqa: PLR2004 + msg = "The function must take two arguments: name and parameters" + raise TypeError(msg) + + self._message_producer = MessageProducer(handler) + self.add_transport( + protocol="message", + port=self._message_producer.port, + path=self._message_producer.path, + ) + + if isinstance(handler, dict): + # Check that all values are either callable with one argument, or + # bytes. + for value in handler.values(): + if callable(value) and len(inspect.signature(value).parameters) != 1: + msg = "All functions must take one argument: parameters" + raise TypeError(msg) + if not callable(value) and not isinstance(value, dict): + msg = "All values must be callable or dictionaries" + raise TypeError(msg) + + def _handler(name: str, parameters: dict[str, Any] | None) -> Message: + logger.info("Internal handler called") + val = handler[name] + if callable(val): + return val(parameters) + return val + + self._message_producer = MessageProducer(_handler) + self.add_transport( + protocol="message", + port=self._message_producer.port, + path=self._message_producer.path, + ) + return self def filter( @@ -409,6 +443,14 @@ def filter( no_state: Whether to include interactions with no state. """ + logger.debug( + "Setting filter for verifier", + extra={ + "description": description, + "state": state, + "no_state": no_state, + }, + ) pact.v3.ffi.verifier_set_filter_info( self._handle, description, @@ -580,6 +622,14 @@ def _state_handler_url( Returns: The verifier instance. """ + logger.debug( + "Setting URL state handler for verifier", + extra={ + "handler": handler, + "teardown": teardown, + "body": body, + }, + ) pact.v3.ffi.verifier_set_provider_state( self._handle, str(handler), @@ -619,6 +669,14 @@ def _state_handler_dict( msg = "All values in the dictionary must be callable" raise TypeError(msg) + logger.debug( + "Setting dictionary state handler for verifier", + extra={ + "handler": handler, + "teardown": teardown, + }, + ) + if teardown: if any( len(inspect.signature(f).parameters) != 2 # noqa: PLR2004 @@ -690,6 +748,14 @@ def _set_function_state_handler( Returns: The verifier instance. """ + logger.debug( + "Setting function state handler for verifier", + extra={ + "handler": handler, + "teardown": teardown, + }, + ) + if teardown: if len(inspect.signature(handler).parameters) != 3: # noqa: PLR2004 msg = ( @@ -1165,8 +1231,9 @@ def verify(self) -> Self: transport["scheme"], ) - with self._message_relay, self._state_handler: + with self._message_producer, self._state_handler: pact.v3.ffi.verifier_execute(self._handle) + logger.debug("Verifier executed") return self From d5c1b80fb0a560b01faf04ab1eabb8355af53fc7 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Thu, 5 Dec 2024 10:42:09 +1100 Subject: [PATCH 05/13] chore: update tests to use new message/state fns Signed-off-by: JP-Ellis --- .../compatibility_suite/test_v1_consumer.py | 2 +- .../compatibility_suite/test_v1_provider.py | 7 +- .../test_v3_http_matching.py | 32 +- .../test_v3_message_producer.py | 24 +- tests/v3/compatibility_suite/util/consumer.py | 2 + .../util/interaction_definition.py | 273 +++--- tests/v3/compatibility_suite/util/provider.py | 796 ++++++++---------- tests/v3/test_server.py | 14 +- 8 files changed, 529 insertions(+), 621 deletions(-) diff --git a/tests/v3/compatibility_suite/test_v1_consumer.py b/tests/v3/compatibility_suite/test_v1_consumer.py index 3f60c310b..578c0d4ed 100644 --- a/tests/v3/compatibility_suite/test_v1_consumer.py +++ b/tests/v3/compatibility_suite/test_v1_consumer.py @@ -312,7 +312,7 @@ def the_following_http_interactions_have_been_defined( The first row is ignored, as it is assumed to be the column headers. The order of the columns is similarly ignored. """ - logger.debug("Parsing interaction definitions") + logger.info("Parsing interaction definitions") # Check that the table is well-formed definitions = parse_horizontal_table(datatable) diff --git a/tests/v3/compatibility_suite/test_v1_provider.py b/tests/v3/compatibility_suite/test_v1_provider.py index c8ec326fa..e13ff25ae 100644 --- a/tests/v3/compatibility_suite/test_v1_provider.py +++ b/tests/v3/compatibility_suite/test_v1_provider.py @@ -27,7 +27,6 @@ a_verification_result_will_not_be_published_back, a_warning_will_be_displayed_that_there_was_no_callback_configured, publishing_of_verification_results_is_enabled, - reset_broker_var, the_provider_state_callback_will_be_called_after_the_verification_is_run, the_provider_state_callback_will_be_called_before_the_verification_is_run, the_provider_state_callback_will_not_receive_a_setup_call, @@ -93,7 +92,6 @@ def test_incorrect_request_is_made_to_provider() -> None: ) def test_verifying_a_simple_http_request_via_a_pact_broker() -> None: """Verifying a simple HTTP request via a Pact broker.""" - reset_broker_var.set(True) @pytest.mark.skipif( @@ -107,7 +105,6 @@ def test_verifying_a_simple_http_request_via_a_pact_broker() -> None: ) def test_verifying_a_simple_http_request_via_a_pact_broker_with_publishing() -> None: """Verifying a simple HTTP request via a Pact broker with publishing.""" - reset_broker_var.set(True) @pytest.mark.skipif( @@ -121,7 +118,6 @@ def test_verifying_a_simple_http_request_via_a_pact_broker_with_publishing() -> ) def test_verifying_multiple_pact_files_via_a_pact_broker() -> None: """Verifying multiple Pact files via a Pact broker.""" - reset_broker_var.set(True) @pytest.mark.skipif( @@ -135,7 +131,6 @@ def test_verifying_multiple_pact_files_via_a_pact_broker() -> None: ) def test_incorrect_request_is_made_to_provider_via_a_pact_broker() -> None: """Incorrect request is made to provider via a Pact broker.""" - reset_broker_var.set(True) @pytest.mark.skipif( @@ -397,7 +392,7 @@ def the_following_http_interactions_have_been_defined( The first row is ignored, as it is assumed to be the column headers. The order of the columns is similarly ignored. """ - logger.debug("Parsing interaction definitions") + logger.info("Parsing interaction definitions") # Check that the table is well-formed definitions = parse_horizontal_table(datatable) diff --git a/tests/v3/compatibility_suite/test_v3_http_matching.py b/tests/v3/compatibility_suite/test_v3_http_matching.py index 50a204f3f..750256e02 100644 --- a/tests/v3/compatibility_suite/test_v3_http_matching.py +++ b/tests/v3/compatibility_suite/test_v3_http_matching.py @@ -1,6 +1,5 @@ """Matching HTTP parts (request or response) feature tests.""" -import pickle import re import sys from collections.abc import Generator @@ -14,14 +13,13 @@ then, when, ) -from yarl import URL from pact.v3 import Pact from pact.v3.verifier import Verifier from tests.v3.compatibility_suite.util.interaction_definition import ( InteractionDefinition, ) -from tests.v3.compatibility_suite.util.provider import start_provider +from tests.v3.compatibility_suite.util.provider import Provider ################################################################################ ## Scenarios @@ -122,20 +120,20 @@ def test_comparing_content_type_headers_which_are_equal() -> None: @given( parsers.re( r'a request is received with an? "(?P[^"]+)" header of "(?P[^"]+)"' - ) + ), + target_fixture="interaction_definition", ) -def a_request_is_received_with_header(name: str, value: str, temp_dir: Path) -> None: +def a_request_is_received_with_header(name: str, value: str) -> InteractionDefinition: """A request is received with a "content-type" header of "application/json".""" interaction_definition = InteractionDefinition(method="GET", path="/", type="HTTP") interaction_definition.response_headers.update({name: value}) - with (temp_dir / "interactions.pkl").open("wb") as pkl_file: - pickle.dump([interaction_definition], pkl_file) + return interaction_definition @given( parsers.re( r'an expected request with an? "(?P[^"]+)" header of "(?P[^"]+)"' - ), + ) ) def an_expected_request_with_header(name: str, value: str, temp_dir: Path) -> None: """An expected request with a "content-type" header of "application/json".""" @@ -153,12 +151,16 @@ def an_expected_request_with_header(name: str, value: str, temp_dir: Path) -> No ################################################################################ -@when("the request is compared to the expected one", target_fixture="provider_url") +@when("the request is compared to the expected one", target_fixture="provider") def the_request_is_compared_to_the_expected_one( - temp_dir: Path, -) -> Generator[URL, None, None]: + interaction_definition: InteractionDefinition, +) -> Generator[Provider, None, None]: """The request is compared to the expected one.""" - yield from start_provider(temp_dir) + provider = Provider() + provider.add_interaction(interaction_definition) + + with provider: + yield provider ################################################################################ @@ -172,16 +174,16 @@ def the_request_is_compared_to_the_expected_one( target_fixture="verifier_result", ) def the_comparison_should_not_be_ok( - provider_url: URL, + provider: Provider, verifier: Verifier, temp_dir: Path, negated: bool, # noqa: FBT001 ) -> Verifier: """The comparison should NOT be OK.""" - verifier.add_transport(url=provider_url) + verifier.add_transport(url=provider.url) verifier.add_transport( protocol="http", - port=provider_url.port, + port=provider.url.port, path="/", ) verifier.add_source(temp_dir / "pacts") diff --git a/tests/v3/compatibility_suite/test_v3_message_producer.py b/tests/v3/compatibility_suite/test_v3_message_producer.py index 936a9dce7..2cb5cfb74 100644 --- a/tests/v3/compatibility_suite/test_v3_message_producer.py +++ b/tests/v3/compatibility_suite/test_v3_message_producer.py @@ -4,7 +4,6 @@ import json import logging -import pickle import re import sys from pathlib import Path @@ -30,7 +29,6 @@ a_pact_file_for_message_is_to_be_verified, a_provider_is_started_that_can_generate_the_message, a_provider_state_callback_is_configured, - start_provider, the_provider_state_callback_will_be_called_after_the_verification_is_run, the_provider_state_callback_will_be_called_before_the_verification_is_run, the_provider_state_callback_will_receive_a_setup_call, @@ -40,10 +38,6 @@ ) if TYPE_CHECKING: - from collections.abc import Generator - - from yarl import URL - from pact.v3.verifier import Verifier TEST_PACT_FILE_DIRECTORY = Path(Path(__file__).parent / "pacts") @@ -388,22 +382,18 @@ def a_pact_file_for_is_to_be_verified_with_the_following_metadata( r'message with "(?P[^"]+)" and the following metadata:', re.DOTALL, ), - target_fixture="provider_url", + target_fixture="provider", ) def a_provider_is_started_that_can_generate_the_message_with_the_following_metadata( - temp_dir: Path, + verifier: Verifier, name: str, fixture: str, datatable: list[list[str]], -) -> Generator[URL, None, None]: +) -> None: """A provider is started that can generate the message with the following metadata.""" # noqa: E501 - interaction_definitions: list[InteractionDefinition] = [] metadata = parse_horizontal_table(datatable) - if (temp_dir / "interactions.pkl").exists(): - with (temp_dir / "interactions.pkl").open("rb") as pkl_file: - interaction_definitions = pickle.load(pkl_file) # noqa: S301 - interaction_definition = InteractionDefinition( + interaction = InteractionDefinition( type="Async", description=name, body=fixture, @@ -414,12 +404,8 @@ def a_provider_is_started_that_can_generate_the_message_with_the_following_metad for row in metadata }, ) - interaction_definitions.append(interaction_definition) - - with (temp_dir / "interactions.pkl").open("wb") as pkl_file: - pickle.dump(interaction_definitions, pkl_file) - yield from start_provider(temp_dir) + verifier.message_handler(interaction.message_producer) ################################################################################ diff --git a/tests/v3/compatibility_suite/util/consumer.py b/tests/v3/compatibility_suite/util/consumer.py index dfe73c0d8..2646d740d 100644 --- a/tests/v3/compatibility_suite/util/consumer.py +++ b/tests/v3/compatibility_suite/util/consumer.py @@ -126,6 +126,7 @@ def _() -> PactInteractionTuple[AsyncMessageInteraction]: """ A message integration is being defined for a consumer test. """ + logger.info("Creating a message interaction") pact = Pact("consumer", "provider") pact.with_specification(version) return PactInteractionTuple( @@ -158,6 +159,7 @@ def _( interaction_definitions: dict[int, InteractionDefinition], ) -> Generator[PactServer, Any, None]: """The mock server is started with interactions.""" + logger.info("Starting Pact mock server") pact = Pact("consumer", "provider") pact.with_specification(version) for iid in ids: diff --git a/tests/v3/compatibility_suite/util/interaction_definition.py b/tests/v3/compatibility_suite/util/interaction_definition.py index b94dd1ff9..d490c9c6e 100644 --- a/tests/v3/compatibility_suite/util/interaction_definition.py +++ b/tests/v3/compatibility_suite/util/interaction_definition.py @@ -8,17 +8,14 @@ from __future__ import annotations -import base64 import contextlib import json import logging -import sys import typing +import warnings from typing import Any, Literal from xml.etree import ElementTree as ET -import flask -from flask import request from multidict import MultiDict from typing_extensions import Self from yarl import URL @@ -32,11 +29,12 @@ ) if typing.TYPE_CHECKING: + from http.server import SimpleHTTPRequestHandler from pathlib import Path from pact.v3.interaction import Interaction from pact.v3.pact import Pact - from tests.v3.compatibility_suite.util.provider import Provider + from pact.v3.types import Message logger = logging.getLogger(__name__) @@ -595,26 +593,55 @@ def add_to_pact( # noqa: C901, PLR0912, PLR0915 else: interaction.with_metadata({key: json.dumps(value)}) - def add_to_provider(self, provider: Provider) -> None: + def matches_request(self, request: SimpleHTTPRequestHandler) -> bool: """ - Add an interaction to a Flask app. + Check if a request matches the interaction. Args: - provider: - The test provider to add the interaction to. + request: + The request to check. + + Returns: + Whether the request matches the interaction. + """ + if self.type == "HTTP": + logger.debug( + "Checking whether request '%s %s' matches '%s %s'", + request.command, + request.path, + self.method, + self.path, + ) + return ( + request.command == self.method + and request.path.split("?")[0] == self.path + ) + return False + + def handle_request(self, request: SimpleHTTPRequestHandler) -> None: + """ + Handle a HTTP request. + + Internally, we use Python's built-in [`http.server`][http.server] module + to handle the request. For each request, Pythhon instantiates a new + [`SimpleHTTPRequestHandler`][http.server.SimpleHTTPRequestHandler] + object with the request details and provider the interface to respond. + + Args: + request: + The request to handle. """ - logger.debug("Adding %s interaction to Flask app", self.type) + logger.debug("Handling request: %s %s", request.command, request.path) if self.type == "HTTP": - self._add_http_to_provider(provider) - elif self.type == "Sync": - self._add_sync_to_provider(provider) - elif self.type == "Async": - self._add_async_to_provider(provider) + self._handle_request_http(request) + elif self.type in ("Sync", "Async"): + msg = "Sync and Async interactions are handled by message relay." + raise ValueError(msg) else: msg = f"Unknown interaction type: {self.type}" raise ValueError(msg) - def _add_http_to_provider(self, provider: Provider) -> None: + def _handle_request_http(self, request: SimpleHTTPRequestHandler) -> None: # noqa: C901, PLR0912 """ Add a HTTP interaction to a Flask app. @@ -623,137 +650,125 @@ def _add_http_to_provider(self, provider: Provider) -> None: route. Args: - provider: - The test provider to add the interaction to. + request: + The request to handle. """ assert isinstance(self.method, str), "Method must be a string" assert isinstance(self.path, str), "Path must be a string" logger.info( - "Adding HTTP '%s %s' interaction to Flask app", + "Handling HTTP '%s %s' interaction", self.method, self.path, ) - logger.debug("-> Query: %s", self.query) - logger.debug("-> Headers: %s", self.headers) - logger.debug("-> Body: %s", self.body) - logger.debug("-> Response Status: %s", self.response) - logger.debug("-> Response Headers: %s", self.response_headers) - logger.debug("-> Response Body: %s", self.response_body) - - def route_fn() -> flask.Response: - if self.query: - query = URL.build(query_string=self.query).query - # Perform a two-way check to ensure that the query parameters - # are present in the request, and that the request contains no - # unexpected query parameters. - for k, v in query.items(): - assert request.args[k] == v - for k, v in request.args.items(): - assert query[k] == v - - if self.headers: - # Perform a one-way check to ensure that the expected headers - # are present in the request, but don't check for any unexpected - # headers. - for k, v in self.headers.items(): - assert k in request.headers - assert request.headers[k] == v - - if self.body: - assert request.data == self.body.bytes - - return flask.Response( - response=self.response_body.bytes or self.response_body.string or None - if self.response_body - else None, - status=self.response, - headers=dict(**self.response_headers), - content_type=self.response_body.mime_type - if self.response_body - else None, - direct_passthrough=True, - ) - - # The route function needs to have a unique name - clean_name = self.path.replace("/", "_").replace("__", "_") - route_fn.__name__ = f"{self.method.lower()}_{clean_name}" - - provider.app.add_url_rule( - self.path, - view_func=route_fn, - methods=[self.method], - ) - - def _add_sync_to_provider(self, provider: Provider) -> None: - """ - Add a synchronous message interaction to a Flask app. - Args: - provider: - The test provider to add the interaction to. - """ - raise NotImplementedError + # Check the request method + if request.command != self.method: + logger.error("Method mismatch: %s != %s", request.command, self.method) + request.send_error(405, "Method Not Allowed") + return - def _add_async_to_provider(self, provider: Provider) -> None: - """ - Add a synchronous message interaction to a Flask app. + # Check the request path + if request.path.split("?")[0] != self.path: + logger.error("Path mismatch: %s != %s", request.path, self.path) + request.send_error(404, "Not Found") + return - Args: - provider: - The test provider to add the interaction to. - """ - assert self.description, "Description must be set for async messages" - provider.messages[self.description] = self + # Check the query parameters + # + # We expect an exact match of the query parameters (unlike the headers) + if self.query: + logger.info("Checking request query parameters") + expected_query = URL.build(query_string=self.query).query + request_query = URL.build( + query_string=request.path.split("?")[1] if "?" in request.path else "" + ).query + if (expected_keys := set(expected_query.keys())) != ( + request_keys := set(request_query.keys()) + ): + logger.error( + "Query parameter mismatch: %s != %s", + request_keys, + expected_keys, + ) + request.send_error(400, "Bad Request") + return - # All messages are handled by the same route. So we just need to check - # whether the route has been defined, and if not, define it. - for rule in provider.app.url_map.iter_rules(): - if rule.rule == "/_pact/message": - sys.stderr.write("Async message route already defined\n") + for k in expected_query: + if (request_vals := request_query.getall(k)) != ( + expected_vals := expected_query.getall(k) + ): + logger.error( + "Query parameter mismatch: %s != %s", + request_vals, + expected_vals, + ) + request.send_error(400, "Bad Request") + return + + # Check the headers + # + # We only check for the headers we expect from the interaction + # definition. It is very likely that the request will contain additional + # headers (e.g. `Host`, `User-Agent`, etc.) that we do not care about. + if self.headers: + logger.info("Checking request headers") + for k, v in self.headers.items(): + if (rv := request.headers.get(k)) != v: + logger.error("Header mismatch: %s != %s", rv, v) + request.send_error(400, "Bad Request") + return + + # Check the body + if self.body: + content_length = int(request.headers.get("Content-Length", 0)) + request_body = request.rfile.read(content_length) + if request_body != self.body.bytes: + request.send_error(400, "Bad Request") return - sys.stderr.write("Adding async message route\n") - - @provider.app.post("/_pact/message") - def post_message() -> flask.Response: - body: dict[str, Any] = json.loads(request.data) - description: str = body["description"] - - if description not in provider.messages: - return flask.Response( - response=json.dumps({ - "error": f"Message {description} not found", - }), - status=404, - headers={"Content-Type": "application/json"}, - content_type="application/json", - ) + # Send the response + if not self.response: + warnings.warn( + "No response defined, defaulting to 200", + RuntimeWarning, + stacklevel=2, + ) - interaction: InteractionDefinition = provider.messages[description] - return interaction.create_async_message_response() + request.send_response(self.response or 200) + for k, v in self.response_headers.items(): + request.send_header(k, v) + if self.response_body and self.response_body.mime_type: + request.send_header("Content-Type", self.response_body.mime_type) + request.end_headers() + if self.response_body and self.response_body.bytes: + request.wfile.write(self.response_body.bytes) - def create_async_message_response(self) -> flask.Response: + def message_producer( + self, + name: str, + metadata: dict[str, Any] | None, + ) -> Message: """ - Convert the interaction to a Flask response. + Handle a message interaction. - When an async message needs to be produced, Pact expects the response - from the special `/_pact/message` endppoint to generate the expected - message. + Args: + name: + The name of the message to produce. - Whilst this is a Response from Flask's perspective, the attributes - returned + metadata: + Metadata for the message. """ - assert self.type == "Async", "Only async messages are supported" + logger.info("Handling message interaction") + logger.info(" -> Body: %r", name) + logger.info(" -> Metadata: %r", metadata) + assert self.type in ("Sync", "Async"), "Message interactions only" - if self.metadata: - self.headers["Pact-Message-Metadata"] = base64.b64encode( - json.dumps(self.metadata).encode("utf-8") - ).decode("utf-8") - - return flask.Response( - response=self.body.bytes or self.body.string or None if self.body else None, - headers=((k, v) for k, v in self.headers.items()), - content_type=self.body.mime_type if self.body else None, - direct_passthrough=True, - ) + assert name == self.description, "Description mismatch" + + contents = self.body.bytes if self.body else None + return { + "contents": contents or b"", + "content_type": self.body.mime_type if self.body else None, + "metadata": self.metadata, + } diff --git a/tests/v3/compatibility_suite/util/provider.py b/tests/v3/compatibility_suite/util/provider.py index d5edc83c0..b733e4466 100644 --- a/tests/v3/compatibility_suite/util/provider.py +++ b/tests/v3/compatibility_suite/util/provider.py @@ -14,45 +14,35 @@ from __future__ import annotations -import sys -from pathlib import Path - -import pytest - -from pact.v3._util import find_free_port - -sys.path.append(str(Path(__file__).parent.parent.parent.parent.parent)) - - import copy +import inspect import json import logging import os -import pickle import re import shutil -import signal import subprocess -import time import warnings -from contextvars import ContextVar -from datetime import datetime, timezone +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from io import BytesIO from threading import Thread -from typing import TYPE_CHECKING, Any, NoReturn +from typing import TYPE_CHECKING, Any, ClassVar, Self, TypedDict +from unittest.mock import MagicMock -import flask +import pytest import requests -from flask import request +from multidict import CIMultiDict from pytest_bdd import given, parsers, then, when from yarl import URL import pact.constants # type: ignore[import-untyped] +from pact import __version__ +from pact.v3._server import MessageProducer +from pact.v3._util import find_free_port from pact.v3.pact import Pact from tests.v3.compatibility_suite.util import ( parse_headers, parse_horizontal_table, - serialize, - truncate, ) from tests.v3.compatibility_suite.util.interaction_definition import ( InteractionDefinition, @@ -61,29 +51,15 @@ if TYPE_CHECKING: from collections.abc import Generator + from pathlib import Path + from types import TracebackType + from pact.v3.types import Message from pact.v3.verifier import Verifier logger = logging.getLogger(__name__) -version_var = ContextVar("version_var", default="0") -""" -Shared context variable to store the version of the consumer application. - -This is used to generate a new version for the consumer application to use when -publishing the interactions to the Pact Broker. -""" -reset_broker_var = ContextVar("reset_broker", default=True) -""" -This context variable is used to determine whether the Pact broker should be -cleaned up. It is used to ensure that the broker is only cleaned up once, even -if a step is run multiple times. - -All scenarios which make use of the Pact broker should set this to `True` at the -start of the scenario. -""" - VERIFIER_ERROR_MAP: dict[str, str] = { "Response status did not match": "StatusMismatch", "Headers had differences": "HeaderMismatch", @@ -92,7 +68,7 @@ } -def next_version() -> str: +def _next_version() -> Generator[str, None, None]: """ Get the next version for the consumer. @@ -102,221 +78,245 @@ def next_version() -> str: Returns: The next version. """ - version = version_var.get() - version_var.set(str(int(version) + 1)) - return version + version = 0 + while True: + yield str(version) + version += 1 -def _setup_logging(log_level: int) -> None: - """ - Set up logging for the provider. +version_iter = _next_version() - Pytest is responsible for setting up the logging for the main Python - process, but the provider runs in a subprocess and does not automatically - inherit the logging configuration. - This function sets up the logging within the provider subprocess, provided - that it wasn't already set up (in case any logging configuration is - inherited). +class Provider: """ - if logging.getLogger().handlers: - return + HTTP provider for the compatibility suite tests. - logging.basicConfig( - level=log_level, - format="%(asctime)s.%(msec)03d [%(levelname)s] %(name)s: %(message)s", - datefmt="%H:%M:%S", - ) - logger.debug("Debug logging enabled") + As we are testing specific scenarios, this provider server is designed to + be easily customized to return specific responses for specific requests. + """ + interactions: ClassVar[list[InteractionDefinition]] = [] -class Provider: + def __init__( + self, + host: str = "localhost", + port: int | None = None, + ) -> None: + """ + Initialize the provider. + + Args: + host: + The host for the provider. + + port: + The port for the provider. If not provided, then a free port + will be found. + """ + self._host = host + self._port = port or find_free_port() + + self._interactions: list[InteractionDefinition] = [] + self.requests: list[ProviderRequestDict] | None = None + self._server: ProviderServer | None = None + self._thread: Thread | None = None + + @property + def host(self) -> str: + """ + Server host. + """ + return self._host + + @property + def port(self) -> int: + """ + Server port. + """ + return self._port + + @property + def url(self) -> URL: + """ + Server URL. + """ + return URL(f"http://{self.host}:{self.port}") + + def add_interaction(self, interaction: InteractionDefinition) -> None: + """ + Add an interaction to the provider. + + Args: + interaction: + The interaction to add. + """ + self._interactions.append(interaction) + + def __enter__(self) -> Self: + """ + Start the provider. + """ + logger.info( + "Starting provider on %s with %s interaction(s)", + self.url, + len(self._interactions), + ) + self._server = ProviderServer( + (self.host, self.port), + ProviderRequestHandler, + interactions=self._interactions, + ) + self._thread = Thread( + target=self._server.serve_forever, + name="Compatibility Suite Provider Server", + ) + self._thread.start() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + """ + Exit the Provider context. + """ + if not self._thread or not self._server: + warnings.warn( + "Exiting server context despite server not being started.", + stacklevel=2, + ) + return + + self.requests = self._server.requests + self._server.shutdown() + self._thread.join() + + +class ProviderServer(ThreadingHTTPServer): """ - HTTP Provider. + Simple HTTP server for the provider. """ - def __init__(self, provider_dir: Path | str, log_level: int) -> None: + def __init__( + self, + *args: Any, # noqa: ANN401 + interactions: list[InteractionDefinition], + **kwargs: Any, # noqa: ANN401 + ) -> None: """ - Instantiate a new provider. + Initialize the server. Args: - provider_dir: - The directory containing various files used to configure the - provider. At a minimum, this directory must contain a file - called `interactions.pkl`. This file must contain a list of - [`InteractionDefinition`] objects. + interactions: + The interactions to use for the server. + + *args: + Positional arguments to pass to the base `ThreadingHTTPServer` + class. - log_level: - The log level for the provider. + **kwargs: + Keyword arguments to pass to the base `ThreadingHTTPServer` + class. """ - _setup_logging(log_level) + self.interactions = interactions + self.requests: list[ProviderRequestDict] = [] + super().__init__(*args, **kwargs) - self.messages: dict[str, InteractionDefinition] = {} - self.provider_dir = Path(provider_dir) - if not self.provider_dir.is_dir(): - msg = f"Directory {self.provider_dir} does not exist" - raise ValueError(msg) - self.app: flask.Flask = flask.Flask("provider") - self._add_ping() - self._add_callback() - self._add_after_request() - self._add_interactions() - - def _add_ping(self) -> None: - """ - Add a ping endpoint to the provider. - - This is used to check that the provider is running. - """ - - @self.app.get("/_test/ping") - def ping() -> str: - """Simple ping endpoint for testing.""" - return "pong" - - def _add_callback(self) -> None: - """ - Add a callback endpoint to the provider. - - This is used to receive any callbacks from Pact to configure any - internal state (e.g., "given a user exists"). As far as the testing - is concerned, this is just a simple endpoint that records the request - and returns an empty response. - - If the provider directory contains a file called `fail_callback`, then - the callback will return a 404 response. - - If the provider directory contains a file called `provider_state`, then - the callback will check that the `state` query parameter matches the - contents of the file. - """ - - @self.app.route("/_pact/callback", methods=["GET", "POST"]) - def callback() -> tuple[str, int] | str: - if (self.provider_dir / "fail_callback").exists(): - return "Provider state not found", 404 - - provider_states_path = self.provider_dir / "provider_states" - if provider_states_path.exists(): - logger.debug("Provider states file found") - with provider_states_path.open() as f: - states = [InteractionState(**s) for s in json.load(f)] - logger.debug("Provider states: %s", states) - for state in states: - if request.args["state"] == state.name: - logger.debug("State found: %s", state) - for k, v in state.parameters.items(): - assert k in request.args - assert str(request.args[k]) == str(v) - logger.debug("State parameters match") - break - else: - msg = "State not found" - raise ValueError(msg) - - timestamp = datetime.now(tz=timezone.utc).strftime("%H:%M:%S.%f") - json_file = self.provider_dir / f"callback.{timestamp}.json" - with json_file.open("w") as f: - json.dump( - { - "method": request.method, - "path": request.path, - "query_string": request.query_string.decode("utf-8"), - "query_params": serialize(request.args), - "headers_list": serialize(request.headers), - "headers_dict": serialize(dict(request.headers)), - "body": request.data.decode("utf-8", errors="backslashreplace"), - "form": serialize(request.form), - }, - f, - ) - - return "" - - def _add_after_request(self) -> None: - """ - Add a handler to log requests and responses. - - This is used to log the requests and responses to the provider - application (both to the logger as well as to files). - """ - - @self.app.after_request - def log_request(response: flask.Response) -> flask.Response: - logger.debug("Received request: %s %s", request.method, request.path) - logger.debug("-> Query string: %s", request.query_string.decode("utf-8")) - logger.debug("-> Headers: %s", serialize(request.headers)) - logger.debug("-> Body: %s", truncate(request.get_data().decode("utf-8"))) - logger.debug("-> Form: %s", serialize(request.form)) - - timestamp = datetime.now(tz=timezone.utc).strftime("%H:%M:%S.%f") - with (self.provider_dir / f"request.{timestamp}.json").open("w") as f: - json.dump( - { - "method": request.method, - "path": request.path, - "query_string": request.query_string.decode("utf-8"), - "query_params": serialize(request.args), - "headers_list": serialize(request.headers), - "headers_dict": serialize(dict(request.headers)), - "body": request.data.decode("utf-8", errors="backslashreplace"), - "form": serialize(request.form), - }, - f, - ) - return response - - @self.app.after_request - def log_response(response: flask.Response) -> flask.Response: - logger.debug("Returning response: %d", response.status_code) - logger.debug("-> Headers: %s", serialize(response.headers)) - logger.debug( - "-> Body: %s", - truncate( - response.get_data().decode("utf-8", errors="backslashreplace") - ), - ) +class ProviderRequestDict(TypedDict): + """ + Request dictionary for the provider server. + """ + + method: str | None + path: str | None + query: str | None + headers: CIMultiDict[str] | None + body: bytes | None - timestamp = datetime.now(tz=timezone.utc).strftime("%H:%M:%S.%f") - with (self.provider_dir / f"response.{timestamp}.json").open("w") as f: - json.dump( - { - "status_code": response.status_code, - "headers_list": serialize(response.headers), - "headers_dict": serialize(dict(response.headers)), - "body": response.get_data().decode( - "utf-8", errors="backslashreplace" - ), - }, - f, - ) - return response - def _add_interactions(self) -> None: +class ProviderRequestHandler(SimpleHTTPRequestHandler): + """ + Request handler for the provider server. + + This class is responsible for handling the requests made to the provider + server. It uses the standard library's + [`SimpleHTTPRequestHandler`][http.server.SimpleHTTPRequestHandler]. + """ + + if TYPE_CHECKING: + server: ProviderServer + + def version_string(self) -> str: """ - Add the interactions to the provider. + Get the server version string. + + Returns: + The server version string. """ - with (self.provider_dir / "interactions.pkl").open("rb") as f: - interactions: list[InteractionDefinition] = pickle.load(f) # noqa: S301 + return f"Compatibility Suite Provider/{__version__}" - for interaction in interactions: - interaction.add_to_provider(self) + def _record_request(self) -> None: + """ + Record the request. + + Parses the request and records it in the server's request list. - def run(self) -> None: + The `rfile` attribute, being a file-like object, can only be read once. + This method reads the request body and then replaces the `rfile` + attribute with a new `BytesIO` object containing the request body. """ - Start the provider. + size = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(size) + request: ProviderRequestDict = { + "method": self.command, + "path": self.path, + "query": self.path.split("?", 1)[1] if "?" in self.path else None, + "headers": CIMultiDict(self.headers.items()), + "body": body, + } + self.server.requests.append(request) + self.rfile = BytesIO(body) + + def do_POST(self) -> None: # noqa: N802 """ - url = URL(f"http://localhost:{find_free_port()}") - sys.stderr.write(f"Starting provider on {url}\n") - for endpoint in self.app.url_map.iter_rules(): - sys.stderr.write(f" * {endpoint}\n") + Handle a POST request. + """ + logger.info("Handling %s %s", self.command, self.path) + self._record_request() + + for interaction in self.server.interactions: + if interaction.matches_request(self): + interaction.handle_request(self) + return - self.app.run( - host=url.host, - port=url.port, - debug=True, + logger.warning( + "No matching interaction found for %s %s", + self.command, + self.path, ) + self.send_error(404, "Not Found") + + def do_GET(self) -> None: # noqa: N802 + """ + Handle a GET request. + """ + logger.info("Handling %s %s", self.command, self.path) + self._record_request() + + for interaction in self.server.interactions: + if interaction.matches_request(self): + interaction.handle_request(self) + return + + logger.warning( + "No matching interaction found for %s %s", + self.command, + self.path, + ) + self.send_error(404, "Not Found") class PactBroker: @@ -408,7 +408,7 @@ def publish(self, directory: Path | str, version: str | None = None) -> None: if self.password: cmd.extend(["--broker-password", self.password]) - cmd.extend(["--consumer-app-version", version or next_version()]) + cmd.extend(["--consumer-app-version", version or next(version_iter)]) subprocess.run( # noqa: S603 cmd, @@ -503,16 +503,6 @@ def latest_verification_results(self) -> requests.Response | None: return response -if __name__ == "__main__": - import sys - - if len(sys.argv) != 3: - sys.stderr.write(f"Usage: {sys.argv[0]} \n") - sys.exit(1) - - Provider(sys.argv[1], int(sys.argv[2])).run() - - ################################################################################ ## Given ################################################################################ @@ -527,26 +517,25 @@ def a_provider_is_started_that_returns_the_responses_from_interactions( r'from interactions? "?(?P[0-9, ]+)"?', ), converters={"interactions": lambda x: [int(i) for i in x.split(",") if i]}, - target_fixture="provider_url", + target_fixture="provider", stacklevel=stacklevel + 1, ) def _( interaction_definitions: dict[int, InteractionDefinition], interactions: list[int], - temp_dir: Path, - ) -> Generator[URL, None, None]: + ) -> Generator[Provider, None, None]: """ Start a provider that returns the responses from the given interactions. """ - logger.debug("Starting provider for interactions %s", interactions) + logger.info("Starting provider for interactions %s", interactions) + provider = Provider() for i in interactions: - logger.debug("Interaction %d: %s", i, interaction_definitions[i]) - - with (temp_dir / "interactions.pkl").open("wb") as pkl_file: - pickle.dump([interaction_definitions[i] for i in interactions], pkl_file) + logger.info("Interaction %d: %s", i, interaction_definitions[i]) + provider.add_interaction(interaction_definitions[i]) - yield from start_provider(temp_dir) + with provider: + yield provider def a_provider_is_started_that_returns_the_responses_from_interactions_with_changes( @@ -555,44 +544,42 @@ def a_provider_is_started_that_returns_the_responses_from_interactions_with_chan @given( parsers.re( r"a provider is started that returns the responses?" - r' from interactions? "?(?P[0-9, ]+)"?' + r' from interactions? "?(?P[0-9, ]+)"?' r" with the following changes:", re.DOTALL, ), - converters={ - "interactions": lambda x: [int(i) for i in x.split(",") if i], - }, - target_fixture="provider_url", + converters={"ids": lambda x: [int(i) for i in x.split(",") if i]}, + target_fixture="provider", stacklevel=stacklevel + 1, ) def _( interaction_definitions: dict[int, InteractionDefinition], - interactions: list[int], + ids: list[int], datatable: list[list[str]], - temp_dir: Path, - ) -> Generator[URL, None, None]: + ) -> Generator[Provider, None, None]: """ Start a provider that returns the responses from the given interactions. """ - logger.debug("Starting provider for modified interactions %s", interactions) + logger.info("Starting provider for modified interactions %s", ids) changes = parse_horizontal_table(datatable) assert len(changes) == 1, "Only one set of changes is supported" - defns: list[InteractionDefinition] = [] - for interaction in interactions: - defn = copy.deepcopy(interaction_definitions[interaction]) - defn.update(**changes[0]) # type: ignore[arg-type] - defns.append(defn) - logger.debug( + interactions: list[InteractionDefinition] = [] + for id_ in ids: + interaction = copy.deepcopy(interaction_definitions[id_]) + interaction.update(**changes[0]) # type: ignore[arg-type] + interactions.append(interaction) + logger.info( "Updated interaction %d: %s", + id_, interaction, - defn, ) - with (temp_dir / "interactions.pkl").open("wb") as pkl_file: - pickle.dump(defns, pkl_file) - - yield from start_provider(temp_dir) + provider = Provider() + for interaction in interactions: + provider.add_interaction(interaction) + with provider: + yield provider def a_provider_is_started_that_can_generate_the_message( @@ -604,20 +591,15 @@ def a_provider_is_started_that_can_generate_the_message( r' that can generate the "(?P[^"]+)" message' r' with "(?P.+)"$' ), - target_fixture="provider_url", + target_fixture="provider", stacklevel=stacklevel + 1, ) def _( - temp_dir: Path, + verifier: Verifier, name: str, body: str, - ) -> Generator[URL, None, None]: - interactions: list[InteractionDefinition] = [] - interactions_pkl = temp_dir / "interactions.pkl" - if interactions_pkl.exists(): - with interactions_pkl.open("rb") as f: - interactions = pickle.load(f) # noqa: S301 - + ) -> None: + logger.info("Starting provider for message %s", name) interaction = InteractionDefinition( type="Async", description=name, @@ -626,78 +608,23 @@ def _( # If there's no content type, then it is a `text/plain` message if interaction.body and not interaction.body.mime_type: interaction.body.mime_type = "text/plain" - interactions.append(interaction) - - with (temp_dir / "interactions.pkl").open("wb") as pkl_file: - pickle.dump(interactions, pkl_file) - - yield from start_provider(temp_dir) - - -def start_provider(provider_dir: str | Path) -> Generator[URL, None, None]: # noqa: C901 - """Start the provider app with the given interactions.""" - process = subprocess.Popen( # noqa: S603 - [ - sys.executable, - Path(__file__), - str(provider_dir), - str(logger.getEffectiveLevel()), - ], - cwd=Path.cwd(), - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - encoding="utf-8", - ) - - pattern = re.compile(r" \* Running on (?P[^ ]+)") - 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.rstrip()) - if process.stderr: - while line := process.stderr.readline(): - logger.debug("Provider stderr: %s", line.rstrip()) + # The following is a hack to allow for multiple message interactions to + # be defined. Typically, the end user would know all messages to be + # produced; however, we don't have this luxury in this context. + if isinstance(verifier._message_producer, MessageProducer): # noqa: SLF001 + original_handler = verifier._message_producer._handler # noqa: SLF001 - thread = Thread(target=redirect, daemon=True) - thread.start() + def handler(*args: Any, **kwargs: Any) -> Message: # noqa: ANN401 + try: + return original_handler(*args, **kwargs) + except AssertionError: + return interaction.message_producer(*args, **kwargs) - yield url + verifier.message_handler(handler) - process.send_signal(signal.SIGINT) + else: + verifier.message_handler(interaction.message_producer) def a_pact_file_for_interaction_is_to_be_verified( @@ -722,7 +649,7 @@ def _( """ Verify the Pact file for the given interaction. """ - logger.debug( + logger.info( "Adding interaction %d to be verified: %s", interaction, interaction_definitions[interaction], @@ -742,7 +669,7 @@ def _( encoding="utf-8", ) as f: for line in f: - logger.debug("Pact file: %s", line.rstrip()) + logger.info("Pact file: %s", line.rstrip()) verifier.add_source(temp_dir / "pacts") @@ -772,7 +699,7 @@ def _( body=fixture, ) defn.pending = pending - logger.debug("Adding message interaction: %s", defn) + logger.info("Adding message interaction: %s", defn) pact = Pact("consumer", "provider") pact.with_specification(version) @@ -781,7 +708,7 @@ def _( pact.write_file(temp_dir / "pacts") with (temp_dir / "pacts" / "consumer-provider.json").open() as f: - logger.debug("Pact file contents: %s", f.read()) + logger.info("Pact file contents: %s", f.read()) verifier.add_source(temp_dir / "pacts") @@ -809,7 +736,7 @@ def _( """ Verify the Pact file for the given interaction. """ - logger.debug( + logger.info( "Adding interaction %d to be verified: %s", interaction, interaction_definitions[interaction], @@ -836,7 +763,7 @@ def _( encoding="utf-8", ) as f: for line in f: - logger.debug("Pact file: %s", line.rstrip()) + logger.info("Pact file: %s", line.rstrip()) verifier.add_source(temp_dir / "pacts") @@ -860,6 +787,7 @@ def _( fixture: str, datatable: list[list[str]], ) -> None: + logger.info("Adding message interaction %s with comments", name) defn = InteractionDefinition( type="Async", description=name, @@ -882,7 +810,7 @@ def _( pact.write_file(temp_dir / "pacts") with (temp_dir / "pacts" / "consumer-provider.json").open() as f: - logger.debug("Pact file contents: %s", f.read()) + logger.info("Pact file contents: %s", f.read()) verifier.add_source(temp_dir / "pacts") @@ -910,7 +838,7 @@ def _( """ Verify the Pact file for the given interaction from a Pact broker. """ - logger.debug( + logger.info( "Adding interaction %d to be verified from a Pact broker", interaction ) @@ -925,10 +853,6 @@ def _( pact.write_file(pacts_dir) pact_broker = PactBroker(broker_url) - if reset_broker_var.get(): - logger.debug("Resetting Pact broker") - pact_broker.reset() - reset_broker_var.set(False) pact_broker.publish(pacts_dir) verifier.broker_source(pact_broker.url) yield pact_broker @@ -940,7 +864,7 @@ def _(verifier: Verifier) -> None: """ Enable publishing of verification results. """ - logger.debug("Publishing verification results") + logger.info("Publishing verification results") verifier.set_publish_options( "0.0.0", @@ -955,29 +879,36 @@ def a_provider_state_callback_is_configured( r"a provider state callback is configured" r"(?P(, but will return a failure)?)", ), + target_fixture="provider_callback", converters={"failure": lambda x: x != ""}, stacklevel=stacklevel + 1, ) def _( verifier: Verifier, - provider_url: URL, - temp_dir: Path, failure: bool, # noqa: FBT001 - ) -> None: + ) -> MagicMock: """ Configure a provider state callback. """ - logger.debug("Configuring provider state callback") + logger.info("Configuring provider state callback") + + def _callback( + _name: str, + _action: str, + _params: dict[str, str] | None, + ) -> None: + pass + provider_callback = MagicMock(return_value=None, spec=_callback) + provider_callback.__signature__ = inspect.signature(_callback) if failure: - with (temp_dir / "fail_callback").open("w") as f: - f.write("true") + provider_callback.side_effect = RuntimeError("Provider state change failed") verifier.state_handler( - provider_url / "_pact" / "callback", + provider_callback, teardown=True, - body=False, ) + return provider_callback def a_pact_file_for_interaction_is_to_be_verified_with_a_provider_state_defined( @@ -1002,7 +933,7 @@ def _( """ Verify the Pact file for the given interaction with a provider state defined. """ - logger.debug( + logger.info( "Adding interaction %d to be verified with provider state %s", interaction, state, @@ -1020,7 +951,7 @@ def _( verifier.add_source(temp_dir / "pacts") with (temp_dir / "provider_states").open("w") as f: - logger.debug("Writing provider state to %s", temp_dir / "provider_states") + logger.info("Writing provider state to %s", temp_dir / "provider_states") json.dump([s.as_dict() for s in defn.states], f) @@ -1048,7 +979,7 @@ def _( Verify the Pact file for the given interaction with provider states defined. """ states = parse_horizontal_table(datatable) - logger.debug( + logger.info( "Adding interaction %d to be verified with provider states %s", interaction, states, @@ -1068,7 +999,7 @@ def _( verifier.add_source(temp_dir / "pacts") with (temp_dir / "provider_states").open("w") as f: - logger.debug("Writing provider state to %s", temp_dir / "provider_states") + logger.info("Writing provider state to %s", temp_dir / "provider_states") json.dump([s.as_dict() for s in defn.states], f) @@ -1086,7 +1017,7 @@ def _( """ Configure a request filter to make the given changes. """ - logger.debug("Configuring request filter") + logger.info("Configuring request filter") changes = parse_horizontal_table(datatable) if "headers" in changes[0]: @@ -1111,19 +1042,16 @@ def the_verification_is_run( ) def _( verifier: Verifier, - provider_url: URL, + provider: Provider | None, ) -> tuple[Verifier, Exception | None]: """ Run the verification. """ - logger.debug("Running verification on %r", verifier) + logger.info("Running verification on %r", verifier) + + if provider: + verifier.add_transport(url=provider.url) - verifier.add_transport(url=provider_url) - verifier.add_transport( - protocol="message", - port=provider_url.port, - path="/_pact/message", - ) try: verifier.verify() except Exception as e: # noqa: BLE001 @@ -1151,8 +1079,8 @@ def _( """ Check that the verification was successful. """ - logger.debug("Checking verification result") - logger.debug("Verifier result: %s", verifier_result) + logger.info("Checking verification result") + logger.info("Verifier result: %s", verifier_result) if negated: assert verifier_result[1] is not None @@ -1171,10 +1099,10 @@ def _(verifier_result: tuple[Verifier, Exception | None], error: str) -> None: """ Check that the verification results contain the given error. """ - logger.debug("Checking that verification results contain error %s", error) + logger.info("Checking that verification results contain error %s", error) verifier = verifier_result[0] - logger.debug("Verification results: %s", json.dumps(verifier.results, indent=2)) + logger.info("Verification results: %s", json.dumps(verifier.results, indent=2)) mismatch_type = VERIFIER_ERROR_MAP.get(error) if not mismatch_type: @@ -1212,7 +1140,7 @@ def _(pact_broker: PactBroker) -> None: """ Check that the verification result was published back to the Pact broker. """ - logger.debug("Checking that verification result was not published back") + logger.info("Checking that verification result was not published back") response = pact_broker.latest_verification_results() if response: @@ -1239,7 +1167,7 @@ def _( """ Check that the verification result was published back to the Pact broker. """ - logger.debug( + logger.info( "Checking that verification result was published back for interaction %d", interaction, ) @@ -1279,7 +1207,7 @@ def _( """ Check that the verification result was published back to the Pact broker. """ - logger.debug( + logger.info( "Checking that failed verification result" " was published back for interaction %d", interaction, @@ -1312,7 +1240,7 @@ def _() -> None: """ Check that the provider state callback was called before the verification. """ - logger.debug("Checking provider state callback was called before verification") + logger.info("Checking provider state callback was called before verification") def the_provider_state_callback_will_receive_a_setup_call( @@ -1327,7 +1255,7 @@ def the_provider_state_callback_will_receive_a_setup_call( stacklevel=stacklevel + 1, ) def _( - temp_dir: Path, + provider_callback: MagicMock, action: str, state: str, ) -> None: @@ -1335,20 +1263,14 @@ def _( Check that the provider state callback received a setup call. """ logger.info("Checking provider state callback received a %s call", action) - logger.info("Callback files: %s", list(temp_dir.glob("callback.*.json"))) - for file in temp_dir.glob("callback.*.json"): - with file.open("r") as f: - data: dict[str, Any] = json.load(f) - logger.debug("Checking callback data: %s", data) - if ( - "action" in data["query_params"] - and data["query_params"]["action"] == action - and data["query_params"]["state"] == state - ): - break - else: - msg = f"No {action} call found" - raise AssertionError(msg) + logger.debug("Calls: %s", provider_callback.call_args_list) + provider_callback.assert_called() + for calls in provider_callback.call_args_list: + if calls.args[0] == state and calls.args[1] == action: + return + + msg = f"No {action} call found" + raise AssertionError(msg) def the_provider_state_callback_will_receive_a_setup_call_with_parameters( @@ -1365,7 +1287,7 @@ def the_provider_state_callback_will_receive_a_setup_call_with_parameters( stacklevel=stacklevel + 1, ) def _( - temp_dir: Path, + provider_callback: MagicMock, action: str, state: str, datatable: list[list[str]], @@ -1374,30 +1296,19 @@ def _( Check that the provider state callback received a setup call. """ logger.info("Checking provider state callback received a %s call", action) - logger.info("Callback files: %s", list(temp_dir.glob("callback.*.json"))) parameters = parse_horizontal_table(datatable) - params: dict[str, str] = parameters[0] - # If we have a string that looks quoted, unquote it + params: dict[str, Any] = parameters[0] + # Values are JSON values, so parse them for key, value in params.items(): - if value.startswith('"') and value.endswith('"'): - params[key] = value[1:-1] + params[key] = json.loads(value) - for file in temp_dir.glob("callback.*.json"): - with file.open("r") as f: - data: dict[str, Any] = json.load(f) - logger.debug("Checking callback data: %s", data) - if ( - "action" in data["query_params"] - and data["query_params"]["action"] == action - and data["query_params"]["state"] == state - ): - for key, value in params.items(): - assert key in data["query_params"], f"Parameter {key} not found" - assert data["query_params"][key] == value - break - else: - msg = f"No {action} call found" - raise AssertionError(msg) + provider_callback.assert_called() + for calls in provider_callback.call_args_list: + if calls.args[0] == state and calls.args[1] == action: + assert calls.args[2] == params + return + msg = f"No {action} call found" + raise AssertionError(msg) def the_provider_state_callback_will_not_receive_a_setup_call( @@ -1420,7 +1331,7 @@ def _( for file in temp_dir.glob("callback.*.json"): with file.open("r") as f: data: dict[str, Any] = json.load(f) - logger.debug("Checking callback data: %s", data) + logger.info("Checking callback data: %s", data) if ( "action" in data["query_params"] and data["query_params"]["action"] == action @@ -1459,7 +1370,7 @@ def _( """ Check that a warning was displayed that there was no callback configured. """ - logger.debug("Checking for warning about missing provider state callback") + logger.info("Checking for warning about missing provider state callback") assert state @@ -1474,27 +1385,24 @@ def the_request_to_the_provider_will_contain_the_header( stacklevel=stacklevel + 1, ) def _( - verifier_result: tuple[Verifier, Exception | None], + provider: Provider, header: dict[str, str], - temp_dir: Path, + # verifier_result: tuple[Verifier, Exception | None], + # temp_dir: Path, ) -> None: """ Check that the request to the provider contained the given header. """ - verifier = verifier_result[0] - logger.debug("verifier output: %s", verifier.output(strip_ansi=True)) - logger.debug("verifier results: %s", json.dumps(verifier.results, indent=2)) - for request_path in temp_dir.glob("request.*.json"): - with request_path.open("r") as f: - data: dict[str, Any] = json.load(f) - if data["path"].startswith("/_test"): - continue - logger.debug("Checking request data: %s", data) - assert all([k, v] in data["headers_list"] for k, v in header.items()) - break - else: - msg = "No request found" - raise AssertionError(msg) + logger.info("Checking for header %r in provider requests", header) + provider.__exit__(None, None, None) + assert provider.requests + assert len(provider.requests) == 1 + request = provider.requests[0] + assert request["headers"] + + for key, value in header.items(): + assert key in request["headers"] + assert request["headers"][key] == value def there_will_be_a_pending_error( @@ -1511,7 +1419,7 @@ def _( """ There will be a pending error. """ - logger.debug("Checking for pending error") + logger.info("Checking for pending error") verifier, err = verifier_result if error == "Body had differences": @@ -1551,8 +1459,8 @@ def _( Check that the given comment was printed to the console. """ verifier, err = verifier_result - logger.debug("Checking for comment %r in verifier output", comment) - logger.debug("Verifier output: %s", verifier.output(strip_ansi=True)) + logger.info("Checking for comment %r in verifier output", comment) + logger.info("Verifier output: %s", verifier.output(strip_ansi=True)) assert err is None assert comment in verifier.output(strip_ansi=True) @@ -1574,7 +1482,7 @@ def _( Check that the given test name was displayed as the original test name. """ verifier, err = verifier_result - logger.debug("Checking for test name %r in verifier output", test_name) - logger.debug("Verifier output: %s", verifier.output(strip_ansi=True)) + logger.info("Checking for test name %r in verifier output", test_name) + logger.info("Verifier output: %s", verifier.output(strip_ansi=True)) assert err is None assert test_name in verifier.output(strip_ansi=True) diff --git a/tests/v3/test_server.py b/tests/v3/test_server.py index de6f665db..7de91e006 100644 --- a/tests/v3/test_server.py +++ b/tests/v3/test_server.py @@ -9,12 +9,12 @@ import aiohttp import pytest -from pact.v3._server import MessageRelay, StateCallback +from pact.v3._server import MessageProducer, StateCallback def test_relay_default_init() -> None: handler = MagicMock() - server = MessageRelay(handler) + server = MessageProducer(handler) assert server.host == "localhost" assert server.port > 1024 # Random non-privileged port @@ -24,7 +24,7 @@ def test_relay_default_init() -> None: @pytest.mark.asyncio async def test_relay_invalid_path_http() -> None: handler = MagicMock(return_value="Not OK") - server = MessageRelay(handler) + server = MessageProducer(handler) with server: async with aiohttp.ClientSession() as session: @@ -36,7 +36,7 @@ async def test_relay_invalid_path_http() -> None: @pytest.mark.asyncio async def test_relay_get_http() -> None: handler = MagicMock(return_value=b"Pact Python is awesome!") - server = MessageRelay(handler) + server = MessageProducer(handler) with server: async with aiohttp.ClientSession() as session: @@ -51,7 +51,7 @@ async def test_relay_get_http() -> None: @pytest.mark.asyncio async def test_relay_post_http() -> None: handler = MagicMock(return_value=b"Pact Python is awesome!") - server = MessageRelay(handler) + server = MessageProducer(handler) with server: async with aiohttp.ClientSession() as session: @@ -69,7 +69,7 @@ async def test_relay_post_http() -> None: @pytest.mark.asyncio async def test_relay_get_with_metadata() -> None: handler = MagicMock(return_value=b"Pact Python is awesome!") - server = MessageRelay(handler) + server = MessageProducer(handler) metadata = base64.b64encode(json.dumps({"key": "value"}).encode()).decode() with server: @@ -88,7 +88,7 @@ async def test_relay_get_with_metadata() -> None: @pytest.mark.asyncio async def test_relay_post_with_metadata() -> None: handler = MagicMock(return_value=b"Pact Python is awesome!") - server = MessageRelay(handler) + server = MessageProducer(handler) metadata = base64.b64encode(json.dumps({"key": "value"}).encode()).decode() with server: From 207919d836239f40bddfb2fd5066830f01a435c8 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Thu, 5 Dec 2024 13:35:31 +1100 Subject: [PATCH 06/13] chore: adapt examples to use function handlers With functions being able to be provided directly to the Verifier, this greatly simplifies the examples as we no longer need custom HTTP servers to wrap a function. Signed-off-by: JP-Ellis --- examples/src/consumer.py | 6 +- examples/src/fastapi.py | 6 +- examples/src/flask.py | 14 +- examples/src/message.py | 16 +- examples/src/message_producer.py | 4 + examples/tests/v3/provider_server.py | 242 ------------------ examples/tests/v3/test_01_fastapi_provider.py | 191 ++++++++------ examples/tests/v3/test_03_message_provider.py | 65 ++--- 8 files changed, 171 insertions(+), 373 deletions(-) delete mode 100644 examples/tests/v3/provider_server.py diff --git a/examples/src/consumer.py b/examples/src/consumer.py index 9986db9cd..13e097ce2 100644 --- a/examples/src/consumer.py +++ b/examples/src/consumer.py @@ -19,9 +19,9 @@ from the provider is the user's ID, name, and creation date. This is despite the provider having additional fields in the response. -Note that the code in this module is agnostic of Pact. The `pact-python` -dependency only appears in the tests. This is because the consumer is not -concerned with Pact, only the tests are. +Note that the code in this module is agnostic of Pact (i.e., this would be your +production code). The `pact-python` dependency only appears in the tests. This +is because the consumer is not concerned with Pact, only the tests are. """ from __future__ import annotations diff --git a/examples/src/fastapi.py b/examples/src/fastapi.py index c9e919c08..2a3b9e06b 100644 --- a/examples/src/fastapi.py +++ b/examples/src/fastapi.py @@ -20,9 +20,9 @@ testing will provide feedback on whether the consumer is compatible with the provider's changes. -Note that the code in this module is agnostic of Pact. The `pact-python` -dependency only appears in the tests. This is because the consumer is not -concerned with Pact, only the tests are. +Note that the code in this module is agnostic of Pact (i.e., this would be your +production code). The `pact-python` dependency only appears in the tests. This +is because the consumer is not concerned with Pact, only the tests are. """ from __future__ import annotations diff --git a/examples/src/flask.py b/examples/src/flask.py index 4d3b09a4c..6be632e5a 100644 --- a/examples/src/flask.py +++ b/examples/src/flask.py @@ -12,9 +12,17 @@ (the consumer) and returns a response. In this example, we have a simple endpoint which returns a user's information from a (fake) database. -Note that the code in this module is agnostic of Pact. The `pact-python` -dependency only appears in the tests. This is because the consumer is not -concerned with Pact, only the tests are. +This also showcases how Pact tests differ from merely testing adherence to an +OpenAPI specification. The Pact tests are more concerned with the practical use +of the API, rather than the formally defined specification. The User class +defined here has additional fields which are not used by the consumer. Should +the provider later decide to add or remove fields, Pact's consumer-driven +testing will provide feedback on whether the consumer is compatible with the +provider's changes. + +Note that the code in this module is agnostic of Pact (i.e., this would be your +production code). The `pact-python` dependency only appears in the tests. This +is because the consumer is not concerned with Pact, only the tests are. """ from __future__ import annotations diff --git a/examples/src/message.py b/examples/src/message.py index 13f14c49f..aa4f33333 100644 --- a/examples/src/message.py +++ b/examples/src/message.py @@ -2,9 +2,19 @@ Handler for non-HTTP interactions. This module implements a very basic handler to handle JSON payloads which might -be sent from Kafka, or some queueing system. Unlike a HTTP interaction, the -handler is solely responsible for processing the message, and does not -necessarily need to send a response. +be sent through a messaging system. Unlike a HTTP interaction, the handler is +solely responsible for processing the message, and does not necessarily need to +send a response. This specific example handles file system events. + +Due to the broad range of possible technologies underpinning message systems +(e.g., Kafka, RabbitMQ, SQS, SNS, etc.), Pact's implementation is agnostic to +the transport mechanism. Instead, Pact Python v3 allows to provide a simple +function (or mapping of functions) to produce messages. Under the hood, Pact +uses HTTP to communicate + +Note that the code in this module is agnostic of Pact (i.e., this would be your +production code). The `pact-python` dependency only appears in the tests. This +is because the consumer is not concerned with Pact, only the tests are. """ from __future__ import annotations diff --git a/examples/src/message_producer.py b/examples/src/message_producer.py index efbe7e762..fe8efcb26 100644 --- a/examples/src/message_producer.py +++ b/examples/src/message_producer.py @@ -3,6 +3,10 @@ This modules implements a very basic message producer which could send to an eventing system, such as Kafka, or a message queue. + +Note that the code in this module is agnostic of Pact (i.e., this would be your +production code). The `pact-python` dependency only appears in the tests. This +is because the consumer is not concerned with Pact, only the tests are. """ from __future__ import annotations diff --git a/examples/tests/v3/provider_server.py b/examples/tests/v3/provider_server.py deleted file mode 100644 index 20f1637ca..000000000 --- a/examples/tests/v3/provider_server.py +++ /dev/null @@ -1,242 +0,0 @@ -""" -HTTP Server to route message requests to message producer function. -""" - -from __future__ import annotations - -import logging -import re -import signal -import subprocess -import sys -import time -from contextlib import contextmanager -from importlib import import_module -from pathlib import Path -from threading import Thread -from typing import TYPE_CHECKING, NoReturn - -import requests - -from pact.v3._util import find_free_port - -sys.path.append(str(Path(__file__).parent.parent.parent.parent)) - -from yarl import URL - -import flask - -if TYPE_CHECKING: - from collections.abc import Generator - -logger = logging.getLogger(__name__) - - -class Provider: - """ - Provider class to route message requests to message producer function. - - Sets up three endpoints: - - /_test/ping: A simple ping endpoint for testing. - - /produce_message: Route message requests to the handler function. - - /set_provider_state: Set the provider state. - - The specific `produce_message` and `set_provider_state` URLs can be configured - with the `produce_message_url` and `set_provider_state_url` arguments. - """ - - def __init__( # noqa: PLR0913 - self, - handler_module: str, - handler_function: str, - produce_message_url: str, - state_provider_module: str, - state_provider_function: str, - set_provider_state_url: str, - ) -> None: - """ - Initialize the provider. - - Args: - handler_module: - The name of the module containing the handler function. - handler_function: - The name of the handler function. - produce_message_url: - The URL to route message requests to the handler function. - state_provider_module: - The name of the module containing the state provider setup function. - state_provider_function: - The name of the state provider setup function. - set_provider_state_url: - The URL to set the provider state. - """ - self.app = flask.Flask("Provider") - self.handler_function = getattr(import_module(handler_module), handler_function) - self.produce_message_url = produce_message_url - self.set_provider_state_url = set_provider_state_url - if state_provider_module: - self.state_provider_function = getattr( - import_module(state_provider_module), state_provider_function - ) - - @self.app.get("/_test/ping") - def ping() -> str: - """Simple ping endpoint for testing.""" - return "pong" - - @self.app.route(self.produce_message_url, methods=["POST"]) - def produce_message() -> flask.Response | tuple[str, int]: - """ - Route a message request to the handler function. - - Returns: - The response from the handler function. - """ - try: - body, content_type = self.handler_function() - return flask.Response( - response=body, - status=200, - content_type=content_type, - direct_passthrough=True, - ) - except Exception as e: # noqa: BLE001 - return str(e), 500 - - @self.app.route(self.set_provider_state_url, methods=["POST"]) - def set_provider_state() -> tuple[str, int]: - """ - Calls the state provider function with the state provided in the request. - - Returns: - A response indicating that the state has been set. - """ - if self.state_provider_function: - self.state_provider_function(flask.request.args["state"]) - return "Provider state set", 200 - - def run(self) -> None: - """ - Start the provider. - """ - url = URL(f"http://localhost:{find_free_port()}") - sys.stderr.write(f"Starting provider on {url}\n") - - self.app.run( - host=url.host, - port=url.port, - debug=True, - ) - - -@contextmanager -def start_provider(**kwargs: str) -> Generator[URL, None, None]: # noqa: C901 - """ - Start the provider app. - - Expects kwargs to to contain the following: - handler_module: Required. The name of the module containing - the handler function. - handler_function: Required. The name of the handler function. - produce_message_url: Optional. The URL to route message requests to - the handler function. - state_provider_module: Optional. The name of the module containing - the state provider setup function. - state_provider_function: Optional. The name of the state provider - setup function. - set_provider_state_url: Optional. The URL to set the provider state. - """ - process = subprocess.Popen( # noqa: S603 - [ - sys.executable, - Path(__file__), - kwargs.pop("handler_module"), - kwargs.pop("handler_function"), - kwargs.pop("produce_message_url", "/produce_message"), - kwargs.pop("state_provider_module", ""), - kwargs.pop("state_provider_function", ""), - kwargs.pop("set_provider_state_url", "/set_provider_state"), - ], - cwd=Path.cwd(), - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - encoding="utf-8", - ) - - pattern = re.compile(r" \* Running on (?P[^ ]+)") - 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__": - import sys - - if len(sys.argv) < 5: - sys.stderr.write( - f"Usage: {sys.argv[0]} " - f" " - ) - sys.exit(1) - - handler_module = sys.argv[1] - handler_function = sys.argv[2] - produce_message_url = sys.argv[3] - state_provider_module = sys.argv[4] - state_provider_function = sys.argv[5] - set_provider_state_url = sys.argv[6] - Provider( - handler_module, - handler_function, - produce_message_url, - state_provider_module, - state_provider_function, - set_provider_state_url, - ).run() diff --git a/examples/tests/v3/test_01_fastapi_provider.py b/examples/tests/v3/test_01_fastapi_provider.py index 2f91fbabe..2ef49a213 100644 --- a/examples/tests/v3/test_01_fastapi_provider.py +++ b/examples/tests/v3/test_01_fastapi_provider.py @@ -18,99 +18,79 @@ side effects, the provider's database calls are mocked out using functionalities from `unittest.mock`. -In order to set the provider into the correct state, this test module defines an -additional endpoint on the provider, in this case `/_pact/callback`. Calls to -this endpoint mock the relevant database calls to set the provider into the -correct state. +Note that Pact requires tat the provider be running on an accessible URL. This +means that FastAPI's [`TestClient`][fastapi.testclient.TestClient] cannot be used +to test the provider. Instead, the provider is run in a separate thread using +Python's [`Thread`][threading.Thread] class. """ from __future__ import annotations +import contextlib import time from datetime import datetime, timezone -from multiprocessing import Process -from typing import TYPE_CHECKING, Callable, Literal +from threading import Thread +from typing import TYPE_CHECKING, Any from unittest.mock import MagicMock +import pytest import uvicorn from yarl import URL -from examples.src.fastapi import User, app +from examples.src.fastapi import User from pact.v3 import Verifier -PROVIDER_URL = URL("http://localhost:8000") - - -@app.post("/_pact/callback") -async def mock_pact_provider_states( - action: Literal["setup", "teardown"], - state: str, -) -> dict[Literal["result"], str]: - """ - Handler for the provider state callback. - - For Pact to be able to correctly tests compliance with the contract, the - internal state of the provider needs to be set up correctly. For example, if - the consumer expects a user to exist in the database, the provider needs to - have a user with the given ID in the database. - - Naïvely, this can be achieved by setting up the database with the correct - data for the test, but this can be slow and error-prone, and requires - standing up additional infrastructure. The alternative showcased here is to - mock the relevant calls to the database so as to avoid any side effects. The - `unittest.mock` library is used to achieve this as part of the `setup` - action. - - The added benefit of using this approach is that the mock can subsequently - be inspected to ensure that the correct calls were made to the database. For - example, asserting that the correct user ID was retrieved from the database. - These checks are performed as part of the `teardown` action. This action can - also be used to reset the mock, or in the case were a real database is used, - to clean up any side effects. - - Args: - action: - One of `setup` or `teardown`. Determines whether the provider state - should be set up or torn down. - - state: - The name of the state to set up or tear down. - - Returns: - A dictionary containing the result of the action. - """ - mapping: dict[str, dict[str, Callable[[], None]]] = {} - mapping["setup"] = { - "user doesn't exists": mock_user_doesnt_exist, - "user exists": mock_user_exists, - "the specified user doesn't exist": mock_post_request_to_create_user, - "user is present in DB": mock_delete_request_to_delete_user, - } - mapping["teardown"] = { - "user doesn't exists": verify_user_doesnt_exist_mock, - "user exists": verify_user_exists_mock, - "the specified user doesn't exist": verify_mock_post_request_to_create_user, - "user is present in DB": verify_mock_delete_request_to_delete_user, - } +if TYPE_CHECKING: + from collections.abc import Generator - mapping[action][state]() - return {"result": f"{action} {state} completed"} +PROVIDER_URL = URL("http://localhost:8000") -def run_server() -> None: +class Server(uvicorn.Server): """ - Run the FastAPI server. + Custom server class to run the FastAPI server in a separate thread. - This function is required to run the FastAPI server in a separate process. A - lambda cannot be used as the target of a `multiprocessing.Process` as it - cannot be pickled. + Thanks to [this StackOverflow + answer](https://stackoverflow.com/a/64521239/1573761) for this solution. """ - host = PROVIDER_URL.host if PROVIDER_URL.host else "localhost" - port = PROVIDER_URL.port if PROVIDER_URL.port else 8000 - uvicorn.run(app, host=host, port=port) - -def test_provider() -> None: + def install_signal_handlers(self) -> None: + """ + Prevent the server from installing signal handlers. + + This is required to run the FastAPI server in a separate process. The + default behaviour of `uvicorn.Server` is to install signal handlers which + would interfere with the signal handlers of the main process. + """ + + @contextlib.contextmanager + def run_in_thread(self) -> Generator[str, None, None]: + """ + Run the FastAPI server in a separate thread. + + This method runs the FastAPI server in a separate thread and yields the + URL of the server. The server is started in a separate thread to allow the + tests to run in the main thread. + """ + thread = Thread(target=self.run) + thread.start() + try: + while not self.started: + time.sleep(0.01) + yield f"http://{self.config.host}:{self.config.port}" + finally: + self.should_exit = True + thread.join() + + +@pytest.fixture(scope="session") +def server() -> Generator[str, None, None]: + server = Server(uvicorn.Config("examples.src.fastapi:app", host="localhost")) + with server.run_in_thread() as url: + yield url + + +def test_provider(server: str) -> None: """ Test the FastAPI provider with Pact. @@ -154,21 +134,68 @@ def test_provider() -> None: the tests will fail and the output will show which interactions failed and why. """ - proc = Process(target=run_server, daemon=True) - proc.start() - time.sleep(2) verifier = ( Verifier("v3_http_provider") - .add_transport(url=PROVIDER_URL) + .add_transport(url=server) .add_source("examples/pacts/v3_http_consumer-v3_http_provider.json") - .set_state( - PROVIDER_URL / "_pact" / "callback", - teardown=True, - ) + .state_handler(provider_state_handler, teardown=True) ) verifier.verify() - proc.terminate() + +def provider_state_handler( + state: str, + action: str, + _parameters: dict[str, Any] | None, +) -> None: + """ + Handler for the provider state callback. + + For Pact to be able to correctly tests compliance with the contract, the + internal state of the provider needs to be set up correctly. For example, if + the consumer expects a user to exist in the database, the provider needs to + have a user with the given ID in the database. + + Naïvely, this can be achieved by setting up the database with the correct + data for the test, but this can be slow and error-prone, and requires + standing up additional infrastructure. The alternative showcased here is to + mock the relevant calls to the database so as to avoid any side effects. The + `unittest.mock` library is used to achieve this as part of the `setup` + action. + + The added benefit of using this approach is that the mock can subsequently + be inspected to ensure that the correct calls were made to the database. For + example, asserting that the correct user ID was retrieved from the database. + These checks are performed as part of the `teardown` action. This action can + also be used to reset the mock, or in the case were a real database is used, + to clean up any side effects. + + Args: + action: + One of `setup` or `teardown`. Determines whether the provider state + should be set up or torn down. + + state: + The name of the state to set up or tear down. + + Returns: + A dictionary containing the result of the action. + """ + if action == "setup": + { + "user doesn't exists": mock_user_doesnt_exist, + "user exists": mock_user_exists, + "the specified user doesn't exist": mock_post_request_to_create_user, + "user is present in DB": mock_delete_request_to_delete_user, + }[state]() + + if action == "teardown": + { + "user doesn't exists": verify_user_doesnt_exist_mock, + "user exists": verify_user_exists_mock, + "the specified user doesn't exist": verify_mock_post_request_to_create_user, + "user is present in DB": verify_mock_delete_request_to_delete_user, + }[state]() def mock_user_doesnt_exist() -> None: diff --git a/examples/tests/v3/test_03_message_provider.py b/examples/tests/v3/test_03_message_provider.py index e9473c496..a6462947c 100644 --- a/examples/tests/v3/test_03_message_provider.py +++ b/examples/tests/v3/test_03_message_provider.py @@ -7,16 +7,18 @@ from __future__ import annotations +import json from pathlib import Path +from typing import Any from unittest.mock import MagicMock from examples.src.message_producer import FileSystemMessageProducer -from examples.tests.v3.provider_server import start_provider from pact.v3 import Verifier +from pact.v3.types import Message PACT_DIR = (Path(__file__).parent.parent.parent / "pacts").resolve() -responses: dict[str, dict[str, str]] = { +RESPONSES: dict[str, dict[str, str]] = { "a request to write test.txt": { "function_name": "send_write_event", }, @@ -25,49 +27,38 @@ }, } -CURRENT_STATE: str | None = None +def message_producer(message: str, metadata: dict[str, Any] | None) -> Message: # noqa: ARG001 + """ + Function to produce a message for the provider. -def message_producer_function() -> tuple[str, str]: - producer = FileSystemMessageProducer() - producer.queue = MagicMock() - - assert CURRENT_STATE is not None, "State is not set" - function_name = responses.get(CURRENT_STATE, {}).get("function_name") - assert function_name is not None, "Function name could not be found" - producer_function = getattr(producer, function_name) - - if producer_function.__name__ == "send_write_event": - producer_function("provider_file_name.txt", "Hello, world!") - elif producer_function.__name__ == "send_read_event": - producer_function("provider_file_name.txt") + This specific implementation is rather simple as it returns static content. + In fact, a straight mapping of the message names to the expected responses + could be given to the message handler directly. However, this function is + provided to demonstrate the capability of the message handler to be very + generic. - return producer.queue.send.call_args[0][0], "application/json" + Args: + message: + The message name. + metadata: + Any metadata associated with the message which can be used to + determine the response. + """ + producer = FileSystemMessageProducer() + producer.queue = MagicMock() -def state_provider_function(state_name: str) -> None: - global CURRENT_STATE # noqa: PLW0603 - CURRENT_STATE = state_name + return Message( + contents=json.dumps(RESPONSES[message]).encode("utf-8"), + content_type="application/json", + metadata=None, + ) def test_producer() -> None: """ Test the message producer. """ - with start_provider( - handler_module=__name__, - handler_function="message_producer_function", - state_provider_module=__name__, - state_provider_function="state_provider_function", - ) as provider_url: - verifier = ( - Verifier("provider") - .add_transport(url=f"{provider_url}/produce_message") - .set_state( - provider_url / "set_provider_state", - teardown=True, - ) - .filter_consumers("v3_message_consumer") - .add_source(PACT_DIR / "v3_message_consumer-v3_message_provider.json") - ) - verifier.verify() + verifier = Verifier("provider").message_handler(message_producer) + verifier.verify() From 7a1f14f86875ee731e8059bc37c2c1533e138cc3 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Thu, 5 Dec 2024 13:55:46 +1100 Subject: [PATCH 07/13] chore: move matchers test out of examples Signed-off-by: JP-Ellis --- examples/tests/v3/basic_flask_server.py | 148 --------------------- {examples/tests => tests}/v3/test_match.py | 142 +++++++++++++++++++- 2 files changed, 140 insertions(+), 150 deletions(-) delete mode 100644 examples/tests/v3/basic_flask_server.py rename {examples/tests => tests}/v3/test_match.py (54%) diff --git a/examples/tests/v3/basic_flask_server.py b/examples/tests/v3/basic_flask_server.py deleted file mode 100644 index 1dfbd406f..000000000 --- a/examples/tests/v3/basic_flask_server.py +++ /dev/null @@ -1,148 +0,0 @@ -""" -Simple flask server for matcher example. -""" - -import logging -import re -import signal -import subprocess -import sys -import time -from collections.abc import Generator -from contextlib import contextmanager -from datetime import datetime -from pathlib import Path -from random import randint, uniform -from threading import Thread -from typing import NoReturn - -import requests -from yarl import URL - -from flask import Flask, Response, make_response - -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[^ ]+)") - 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/") - def hello_world(test_id: int) -> Response: - random_regex_matches = "1-8 digits: 12345678, 1-8 random letters abcdefgh" - response = make_response({ - "response": { - "id": test_id, - "regexMatches": "must end with 'hello world'", - "randomRegexMatches": random_regex_matches, - "integerMatches": test_id, - "decimalMatches": round(uniform(0, 9), 3), # noqa: S311 - "booleanMatches": True, - "randomIntegerMatches": randint(1, 100), # noqa: S311 - "randomDecimalMatches": round(uniform(0, 9), 1), # noqa: S311 - "randomStringMatches": "hi there", - "includeMatches": "hello world", - "includeWithGeneratorMatches": "say 'hello world' for me", - "minMaxArrayMatches": [ - round(uniform(0, 9), 1) # noqa: S311 - for _ in range(randint(3, 5)) # noqa: S311 - ], - "arrayContainingMatches": [randint(1, 100), randint(1, 100)], # noqa: S311 - "numbers": { - "intMatches": 42, - "floatMatches": 3.1415, - "intGeneratorMatches": randint(1, 100), # noqa: S311, - "decimalGeneratorMatches": round(uniform(10, 99), 2), # noqa: S311 - }, - "dateMatches": "1999-12-31", - "randomDateMatches": "1999-12-31", - "timeMatches": "12:34:56", - "timestampMatches": datetime.now().isoformat(), # noqa: DTZ005 - "nullMatches": None, - "eachKeyMatches": { - "id_1": { - "name": "John Doe", - }, - "id_2": { - "name": "Jane Doe", - }, - }, - } - }) - response.headers["SpecialHeader"] = "Special: Hi" - return response - - @app.get("/_test/ping") - def ping() -> str: - """Simple ping endpoint for testing.""" - return "pong" - - app.run() diff --git a/examples/tests/v3/test_match.py b/tests/v3/test_match.py similarity index 54% rename from examples/tests/v3/test_match.py rename to tests/v3/test_match.py index 02080b1f6..2c003f50c 100644 --- a/examples/tests/v3/test_match.py +++ b/tests/v3/test_match.py @@ -2,14 +2,152 @@ Example test to show usage of matchers (and generators by extension). """ +import logging import re +import signal +import subprocess +import sys +import time +from collections.abc import Generator +from contextlib import contextmanager +from datetime import datetime from pathlib import Path +from random import randint, uniform +from threading import Thread +from typing import NoReturn import requests +from flask import Flask, Response, make_response +from yarl import URL -from examples.tests.v3.basic_flask_server import start_provider from pact.v3 import Pact, Verifier, generate, match +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[^ ]+)") + 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/") + def hello_world(test_id: int) -> Response: + random_regex_matches = "1-8 digits: 12345678, 1-8 random letters abcdefgh" + response = make_response({ + "response": { + "id": test_id, + "regexMatches": "must end with 'hello world'", + "randomRegexMatches": random_regex_matches, + "integerMatches": test_id, + "decimalMatches": round(uniform(0, 9), 3), # noqa: S311 + "booleanMatches": True, + "randomIntegerMatches": randint(1, 100), # noqa: S311 + "randomDecimalMatches": round(uniform(0, 9), 1), # noqa: S311 + "randomStringMatches": "hi there", + "includeMatches": "hello world", + "includeWithGeneratorMatches": "say 'hello world' for me", + "minMaxArrayMatches": [ + round(uniform(0, 9), 1) # noqa: S311 + for _ in range(randint(3, 5)) # noqa: S311 + ], + "arrayContainingMatches": [randint(1, 100), randint(1, 100)], # noqa: S311 + "numbers": { + "intMatches": 42, + "floatMatches": 3.1415, + "intGeneratorMatches": randint(1, 100), # noqa: S311, + "decimalGeneratorMatches": round(uniform(10, 99), 2), # noqa: S311 + }, + "dateMatches": "1999-12-31", + "randomDateMatches": "1999-12-31", + "timeMatches": "12:34:56", + "timestampMatches": datetime.now().isoformat(), # noqa: DTZ005 + "nullMatches": None, + "eachKeyMatches": { + "id_1": { + "name": "John Doe", + }, + "id_2": { + "name": "Jane Doe", + }, + }, + } + }) + response.headers["SpecialHeader"] = "Special: Hi" + return response + + @app.get("/_test/ping") + def ping() -> str: + """Simple ping endpoint for testing.""" + return "pong" + + app.run() + def test_matchers() -> None: pact_dir = Path(Path(__file__).parent.parent.parent / "pacts") @@ -127,7 +265,7 @@ def test_matchers() -> None: pact.write_file(pact_dir, overwrite=True) with start_provider() as url: verifier = ( - Verifier("My Provider") + Verifier("My Provider", host="127.0.0.1") .add_transport(url=url) .add_source(pact_dir / "consumer-provider.json") ) From 9203aa032c91099a5cdb12796860e26784ac8c5a Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Thu, 5 Dec 2024 13:56:18 +1100 Subject: [PATCH 08/13] chore: adjust tests based on new implementation Signed-off-by: JP-Ellis --- tests/v3/test_server.py | 117 ++++++++++------------------------------ 1 file changed, 29 insertions(+), 88 deletions(-) diff --git a/tests/v3/test_server.py b/tests/v3/test_server.py index 7de91e006..eb3fded09 100644 --- a/tests/v3/test_server.py +++ b/tests/v3/test_server.py @@ -2,7 +2,6 @@ Tests for `pact.v3._server` module. """ -import base64 import json from unittest.mock import MagicMock @@ -12,17 +11,17 @@ from pact.v3._server import MessageProducer, StateCallback -def test_relay_default_init() -> None: +def test_message_default_init() -> None: handler = MagicMock() server = MessageProducer(handler) assert server.host == "localhost" assert server.port > 1024 # Random non-privileged port - assert server.url == f"http://{server.host}:{server.port}" + assert server.url == f"http://{server.host}:{server.port}/_pact/message" @pytest.mark.asyncio -async def test_relay_invalid_path_http() -> None: +async def test_message_invalid_path_http() -> None: handler = MagicMock(return_value="Not OK") server = MessageProducer(handler) @@ -34,75 +33,42 @@ async def test_relay_invalid_path_http() -> None: @pytest.mark.asyncio -async def test_relay_get_http() -> None: +async def test_message_get_http() -> None: handler = MagicMock(return_value=b"Pact Python is awesome!") server = MessageProducer(handler) with server: async with aiohttp.ClientSession() as session: - async with session.get(server.url + "/_pact/message") as response: - assert response.status == 200 - assert await response.text() == "Pact Python is awesome!" - - handler.assert_called_once() - assert handler.call_args.args == (None, None) - - -@pytest.mark.asyncio -async def test_relay_post_http() -> None: - handler = MagicMock(return_value=b"Pact Python is awesome!") - server = MessageProducer(handler) - - with server: - async with aiohttp.ClientSession() as session: - async with session.post( - server.url + "/_pact/message", - data='{"hello": "world"}', - ) as response: - assert response.status == 200 - assert await response.text() == "Pact Python is awesome!" - - handler.assert_called_once() - assert handler.call_args.args == (b'{"hello": "world"}', None) - - -@pytest.mark.asyncio -async def test_relay_get_with_metadata() -> None: - handler = MagicMock(return_value=b"Pact Python is awesome!") - server = MessageProducer(handler) - metadata = base64.b64encode(json.dumps({"key": "value"}).encode()).decode() - - with server: - async with aiohttp.ClientSession() as session: - async with session.get( - server.url + "/_pact/message", - headers={"Pact-Message-Metadata": metadata}, - ) as response: - assert response.status == 200 - assert await response.text() == "Pact Python is awesome!" + async with session.get(server.url) as response: + assert response.status == 404 - handler.assert_called_once() - assert handler.call_args.args == (None, {"key": "value"}) + handler.assert_not_called() @pytest.mark.asyncio -async def test_relay_post_with_metadata() -> None: - handler = MagicMock(return_value=b"Pact Python is awesome!") +async def test_message_post_http() -> None: + handler = MagicMock( + return_value={ + "contents": json.dumps({"hello": "world"}).encode(), + "metadata": None, + "content_type": "application/json", + } + ) server = MessageProducer(handler) - metadata = base64.b64encode(json.dumps({"key": "value"}).encode()).decode() with server: async with aiohttp.ClientSession() as session: async with session.post( - server.url + "/_pact/message", - data='{"hello": "world"}', - headers={"Pact-Message-Metadata": metadata}, + server.url, + data=json.dumps({ + "description": "A simple message", + }), ) as response: assert response.status == 200 - assert await response.text() == "Pact Python is awesome!" + assert await response.text() == '{"hello": "world"}' handler.assert_called_once() - assert handler.call_args.args == (b'{"hello": "world"}', {"key": "value"}) + assert handler.call_args.args == ("A simple message", {}) def test_callback_default_init() -> None: @@ -111,7 +77,7 @@ def test_callback_default_init() -> None: assert server.host == "localhost" assert server.port > 1024 # Random non-privileged port - assert server.url == f"http://{server.host}:{server.port}" + assert server.url == f"http://{server.host}:{server.port}/_pact/state" @pytest.mark.asyncio @@ -133,52 +99,27 @@ async def test_callback_get_http() -> None: with server: async with aiohttp.ClientSession() as session: - async with session.get(server.url + "/_pact/state") as response: + async with session.get(server.url) as response: assert response.status == 404 handler.assert_not_called() @pytest.mark.asyncio -async def test_callback_post_query() -> None: - handler = MagicMock(return_value=None) - server = StateCallback(handler) - - with server: - async with aiohttp.ClientSession() as session: - async with session.post( - server.url + "/_pact/state", - params={ - "state": "user exists", - "action": "setup", - "foo": "bar", - "1": 2, - }, - ) as response: - assert response.status == 200 - - handler.assert_called_once() - assert handler.call_args.args == ( - "user exists", - "setup", - {"foo": "bar", "1": "2"}, - ) - - -@pytest.mark.asyncio -async def test_callback_post_body() -> None: +async def test_callback_post() -> None: handler = MagicMock(return_value=None) server = StateCallback(handler) with server: async with aiohttp.ClientSession() as session: async with session.post( - server.url + "/_pact/state", + server.url, json={ "state": "user exists", "action": "setup", - "foo": "bar", - "1": 2, + "params": { + "id": 123, + }, }, ) as response: assert response.status == 200 @@ -187,5 +128,5 @@ async def test_callback_post_body() -> None: assert handler.call_args.args == ( "user exists", "setup", - {"foo": "bar", "1": 2}, + {"id": 123}, ) From 183841a2eb0cf80474157b090915a55a25fdb71d Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Thu, 5 Dec 2024 16:10:22 +1100 Subject: [PATCH 09/13] chore: remove dead code Signed-off-by: JP-Ellis --- src/pact/v3/_server.py | 54 +++++------------------------------------- 1 file changed, 6 insertions(+), 48 deletions(-) diff --git a/src/pact/v3/_server.py b/src/pact/v3/_server.py index bb6301af7..6c81d4e0d 100644 --- a/src/pact/v3/_server.py +++ b/src/pact/v3/_server.py @@ -22,7 +22,6 @@ from __future__ import annotations import base64 -import binascii import json import logging import warnings @@ -30,7 +29,7 @@ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from threading import Thread from typing import TYPE_CHECKING, Any, Generic, Self, TypeVar -from urllib.parse import parse_qs, urlparse +from urllib.parse import urlparse from pact import __version__ from pact.v3._util import find_free_port @@ -238,39 +237,6 @@ def version_string(self) -> str: """ return f"Pact Python Message Relay/{__version__}" - def _process(self) -> tuple[bytes | None, dict[str, str] | None]: - """ - Process the request. - - Read the body and headers from the request and perform some common logic - shared between GET and POST requests. - - Returns: - body: - The body of the request as a byte string, if present. - - metadata: - The metadata of the request, if present. - """ - if content_length := self.headers.get("Content-Length"): - body = self.rfile.read(int(content_length)) - else: - body = None - - if data := self.headers.get("Pact-Message-Metadata"): - try: - metadata = json.loads(base64.b64decode(data)) - except binascii.Error as err: - msg = "Unable to base64 decode Pact metadata header." - raise RuntimeError(msg) from err - except json.JSONDecodeError as err: - msg = "Unable to JSON decode Pact metadata header." - raise RuntimeError(msg) from err - else: - return body, metadata - - return body, None - def do_POST(self) -> None: # noqa: N802 """ Handle a POST request. @@ -476,19 +442,11 @@ def do_POST(self) -> None: # noqa: N802 self.send_error(404, "Not Found") return - if query := url.query: - data: dict[str, Any] = parse_qs(query) - # Convert single-element lists to single values - for k, v in data.items(): - if isinstance(v, list) and len(v) == 1: - data[k] = v[0] - - else: - content_length = self.headers.get("Content-Length") - if not content_length: - self.send_error(400, "Bad Request") - return - data = json.loads(self.rfile.read(int(content_length))) + content_length = self.headers.get("Content-Length") + if not content_length: + self.send_error(400, "Bad Request") + return + data = json.loads(self.rfile.read(int(content_length))) state = data.pop("state") action = data.pop("action") From 3c4359bf4a46a62cf0d29dcd70b4634127321764 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Mon, 23 Dec 2024 15:00:00 +1100 Subject: [PATCH 10/13] docs: fix minor typos Signed-off-by: JP-Ellis --- examples/tests/test_01_provider_fastapi.py | 2 +- examples/tests/test_01_provider_flask.py | 2 +- examples/tests/v3/test_01_fastapi_provider.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/tests/test_01_provider_fastapi.py b/examples/tests/test_01_provider_fastapi.py index f0328b4f2..3fab5d9c3 100644 --- a/examples/tests/test_01_provider_fastapi.py +++ b/examples/tests/test_01_provider_fastapi.py @@ -58,7 +58,7 @@ async def mock_pact_provider_states( """ Define the provider state. - For Pact to be able to correctly tests compliance with the contract, the + For Pact to be able to correctly test compliance with the contract, the internal state of the provider needs to be set up correctly. Naively, this would be achieved by setting up the database with the correct data for the test, but this can be slow and error-prone. Instead this is best achieved by diff --git a/examples/tests/test_01_provider_flask.py b/examples/tests/test_01_provider_flask.py index bc6d2e037..347d57e7f 100644 --- a/examples/tests/test_01_provider_flask.py +++ b/examples/tests/test_01_provider_flask.py @@ -48,7 +48,7 @@ async def mock_pact_provider_states() -> dict[str, str | None]: """ Define the provider state. - For Pact to be able to correctly tests compliance with the contract, the + For Pact to be able to correctly test compliance with the contract, the internal state of the provider needs to be set up correctly. Naively, this would be achieved by setting up the database with the correct data for the test, but this can be slow and error-prone. Instead this is best achieved by diff --git a/examples/tests/v3/test_01_fastapi_provider.py b/examples/tests/v3/test_01_fastapi_provider.py index 2ef49a213..6bdecf580 100644 --- a/examples/tests/v3/test_01_fastapi_provider.py +++ b/examples/tests/v3/test_01_fastapi_provider.py @@ -18,7 +18,7 @@ side effects, the provider's database calls are mocked out using functionalities from `unittest.mock`. -Note that Pact requires tat the provider be running on an accessible URL. This +Note that Pact requires that the provider be running on an accessible URL. This means that FastAPI's [`TestClient`][fastapi.testclient.TestClient] cannot be used to test the provider. Instead, the provider is run in a separate thread using Python's [`Thread`][threading.Thread] class. @@ -151,7 +151,7 @@ def provider_state_handler( """ Handler for the provider state callback. - For Pact to be able to correctly tests compliance with the contract, the + For Pact to be able to correctly test compliance with the contract, the internal state of the provider needs to be set up correctly. For example, if the consumer expects a user to exist in the database, the provider needs to have a user with the given ID in the database. From 5a0d3d88aedd1dc0b466d7e82ac4f28816000239 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Mon, 30 Dec 2024 14:30:55 +1100 Subject: [PATCH 11/13] chore: fix compatibility with 3.9, 3.10 The `typing.Self` annotation was only introduced in Python 3.11, and therefore we have to rely on the typing extensions for versions 3.9 and 3.10. There are also issues with TypeAliases and the use of the `|` operator. Signed-off-by: JP-Ellis --- src/pact/v3/_server.py | 4 +++- src/pact/v3/types.py | 16 ++++++++-------- src/pact/v3/verifier.py | 4 +++- tests/v3/compatibility_suite/util/provider.py | 3 ++- 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/pact/v3/_server.py b/src/pact/v3/_server.py index 6c81d4e0d..299531109 100644 --- a/src/pact/v3/_server.py +++ b/src/pact/v3/_server.py @@ -28,9 +28,11 @@ from collections.abc import Callable from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from threading import Thread -from typing import TYPE_CHECKING, Any, Generic, Self, TypeVar +from typing import TYPE_CHECKING, Any, Generic, TypeVar from urllib.parse import urlparse +from typing_extensions import Self + from pact import __version__ from pact.v3._util import find_free_port diff --git a/src/pact/v3/types.py b/src/pact/v3/types.py index 5850e434c..7a40062d0 100644 --- a/src/pact/v3/types.py +++ b/src/pact/v3/types.py @@ -9,7 +9,7 @@ from __future__ import annotations from collections.abc import Callable -from typing import Any, TypedDict +from typing import Any, Optional, TypedDict, Union from typing_extensions import TypeAlias from yarl import URL @@ -57,7 +57,7 @@ class Message(TypedDict): """ -MessageProducerFull: TypeAlias = Callable[[str, dict[str, Any] | None], Message] +MessageProducerFull: TypeAlias = Callable[[str, Optional[dict[str, Any]]], Message] """ Full message producer signature. @@ -69,7 +69,7 @@ class Message(TypedDict): The function must return a `bytes` object. """ -MessageProducerNoName: TypeAlias = Callable[[dict[str, Any] | None], Message] +MessageProducerNoName: TypeAlias = Callable[[Optional[dict[str, Any]]], Message] """ Message producer signature without the name. @@ -83,7 +83,7 @@ class Message(TypedDict): functions. """ -StateHandlerFull: TypeAlias = Callable[[str, str, dict[str, Any] | None], None] +StateHandlerFull: TypeAlias = Callable[[str, str, Optional[dict[str, Any]]], None] """ Full state handler signature. @@ -93,7 +93,7 @@ class Message(TypedDict): 2. The action (either `setup` or `teardown`), as a string. 3. A dictionary of parameters, or `None` if no parameters are provided. """ -StateHandlerNoAction: TypeAlias = Callable[[str, dict[str, Any] | None], None] +StateHandlerNoAction: TypeAlias = Callable[[str, Optional[dict[str, Any]]], None] """ State handler signature without the action. @@ -102,7 +102,7 @@ class Message(TypedDict): 1. The state name, as a string. 2. A dictionary of parameters, or `None` if no parameters are provided. """ -StateHandlerNoState: TypeAlias = Callable[[str, dict[str, Any] | None], None] +StateHandlerNoState: TypeAlias = Callable[[str, Optional[dict[str, Any]]], None] """ State handler signature without the state. @@ -114,7 +114,7 @@ class Message(TypedDict): This function must be provided as part of a dictionary mapping state names to functions. """ -StateHandlerNoActionNoState: TypeAlias = Callable[[dict[str, Any] | None], None] +StateHandlerNoActionNoState: TypeAlias = Callable[[Optional[dict[str, Any]]], None] """ State handler signature without the state or action. @@ -125,7 +125,7 @@ class Message(TypedDict): This function must be provided as part of a dictionary mapping state names to functions. """ -StateHandlerUrl: TypeAlias = str | URL +StateHandlerUrl: TypeAlias = Union[str, URL] """ State handler URL signature. diff --git a/src/pact/v3/verifier.py b/src/pact/v3/verifier.py index f464bde5a..0821418ea 100644 --- a/src/pact/v3/verifier.py +++ b/src/pact/v3/verifier.py @@ -573,7 +573,9 @@ def state_handler( providing one or more handler functions; and it must be set to a boolean if providing a URL. """ - if isinstance(handler, StateHandlerUrl): + # A tuple is required instead of `StateHandlerUrl` for support for + # Python 3.9. This should be changed to `StateHandlerUrl` in the future. + if isinstance(handler, (str, URL)): if body is None: msg = "The `body` parameter must be a boolean when providing a URL" raise ValueError(msg) diff --git a/tests/v3/compatibility_suite/util/provider.py b/tests/v3/compatibility_suite/util/provider.py index b733e4466..f678c92c5 100644 --- a/tests/v3/compatibility_suite/util/provider.py +++ b/tests/v3/compatibility_suite/util/provider.py @@ -26,13 +26,14 @@ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from io import BytesIO from threading import Thread -from typing import TYPE_CHECKING, Any, ClassVar, Self, TypedDict +from typing import TYPE_CHECKING, Any, ClassVar, TypedDict from unittest.mock import MagicMock import pytest import requests from multidict import CIMultiDict from pytest_bdd import given, parsers, then, when +from typing_extensions import Self from yarl import URL import pact.constants # type: ignore[import-untyped] From 76e8953df72cb75f769e6581ff919b51539fb8ab Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Mon, 30 Dec 2024 15:02:31 +1100 Subject: [PATCH 12/13] chore: add pytest-rerunfailures Unfortunately, CI is flaky and this is difficult to replicate locally. Signed-off-by: JP-Ellis --- pyproject.toml | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 16dd46529..079ff7955 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,17 +76,18 @@ devel-docs = [ "mkdocstrings[python] ~= 0.23", ] devel-test = [ - "aiohttp[speedups] ~=3.0", - "coverage[toml] ~=7.0", - "flask[async] ~=3.0", - "httpx ~=0.0", - "mock ~=5.0", - "pytest-asyncio ~=0.0", - "pytest-bdd ~=8.0", - "pytest-cov ~=6.0", - "pytest-xdist ~=3.0", - "pytest ~=8.0", - "testcontainers ~=4.0", + "aiohttp[speedups] ~=3.0", + "coverage[toml] ~=7.0", + "flask[async] ~=3.0", + "httpx ~=0.0", + "mock ~=5.0", + "pytest-asyncio ~=0.0", + "pytest-bdd ~=8.0", + "pytest-cov ~=6.0", + "pytest-rerunfailures ~=15.0", + "pytest-xdist ~=3.0", + "pytest ~=8.0", + "testcontainers ~=4.0", ] devel = ["pact-python[devel-types,devel-docs,devel-test]", "ruff==0.8.4"] @@ -191,6 +192,9 @@ addopts = [ # Xdist options "--numprocesses=logical", "--dist=worksteal", + # Rerun options + "--reruns=3", + "--rerun-except=assert", ] filterwarnings = [ "ignore::DeprecationWarning:examples", From 038fcc329eb782c8ac1cb0f0e08074f99c1f5dbf Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Mon, 30 Dec 2024 15:10:40 +1100 Subject: [PATCH 13/13] chore: fix windows compatibility The `SIGINT` signal is _not_ supported on Windows, so it is replaced with the cross-platform `process.terminate` method. Signed-off-by: JP-Ellis --- tests/v3/test_match.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/v3/test_match.py b/tests/v3/test_match.py index 2c003f50c..9ed6581f7 100644 --- a/tests/v3/test_match.py +++ b/tests/v3/test_match.py @@ -4,7 +4,6 @@ import logging import re -import signal import subprocess import sys import time @@ -90,7 +89,7 @@ def redirect() -> NoReturn: try: yield url finally: - process.send_signal(signal.SIGINT) + process.terminate() if __name__ == "__main__":