From c7f6667ad489632023db93d43d273e76e3479ebe Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Wed, 17 Jul 2024 12:43:44 +1000 Subject: [PATCH] refactor(tests): move InteractionDefinition in own module The `__init__` was getting a bit too cluttered, so I have refactored the entire `InteractionDefinition` class into its own module. I have also refacted the nested classes to stand alone now. A minor refactor was also made to avoid assigning custom attributes to the `Flask` app, and some changes were made to the provider initialisation to avoid passing a redundant `self.app` in a method call. Signed-off-by: JP-Ellis --- .../compatibility_suite/test_v1_consumer.py | 10 +- .../compatibility_suite/test_v1_provider.py | 6 +- .../compatibility_suite/test_v2_consumer.py | 10 +- .../compatibility_suite/test_v2_provider.py | 6 +- .../test_v3_message_producer.py | 13 +- .../compatibility_suite/test_v3_provider.py | 6 +- .../compatibility_suite/test_v4_provider.py | 6 +- tests/v3/compatibility_suite/util/__init__.py | 735 +---------------- tests/v3/compatibility_suite/util/consumer.py | 14 +- .../util/interaction_definition.py | 759 ++++++++++++++++++ tests/v3/compatibility_suite/util/provider.py | 42 +- 11 files changed, 822 insertions(+), 785 deletions(-) create mode 100644 tests/v3/compatibility_suite/util/interaction_definition.py diff --git a/tests/v3/compatibility_suite/test_v1_consumer.py b/tests/v3/compatibility_suite/test_v1_consumer.py index 75c7b07d2..8f222cbf5 100644 --- a/tests/v3/compatibility_suite/test_v1_consumer.py +++ b/tests/v3/compatibility_suite/test_v1_consumer.py @@ -6,10 +6,7 @@ from pytest_bdd import given, parsers, scenario -from tests.v3.compatibility_suite.util import ( - InteractionDefinition, - parse_markdown_table, -) +from tests.v3.compatibility_suite.util import parse_markdown_table from tests.v3.compatibility_suite.util.consumer import ( a_response_is_returned, request_n_is_made_to_the_mock_server, @@ -34,6 +31,9 @@ the_pact_test_is_done, the_payload_will_contain_the_json_document, ) +from tests.v3.compatibility_suite.util.interaction_definition import ( + InteractionDefinition, +) logger = logging.getLogger(__name__) @@ -320,7 +320,7 @@ def the_following_http_interactions_have_been_defined( # Parse the table into a more useful format interactions: dict[int, InteractionDefinition] = {} for row in content: - interactions[int(row["No"])] = InteractionDefinition(**row) + interactions[int(row["No"])] = InteractionDefinition(**row) # type: ignore[arg-type] return interactions diff --git a/tests/v3/compatibility_suite/test_v1_provider.py b/tests/v3/compatibility_suite/test_v1_provider.py index 1078f714a..60654a629 100644 --- a/tests/v3/compatibility_suite/test_v1_provider.py +++ b/tests/v3/compatibility_suite/test_v1_provider.py @@ -10,9 +10,9 @@ import pytest from pytest_bdd import given, parsers, scenario -from tests.v3.compatibility_suite.util import ( +from tests.v3.compatibility_suite.util import parse_markdown_table +from tests.v3.compatibility_suite.util.interaction_definition import ( InteractionDefinition, - parse_markdown_table, ) from tests.v3.compatibility_suite.util.provider import ( a_failed_verification_result_will_be_published_back, @@ -407,7 +407,7 @@ def the_following_http_interactions_have_been_defined( # Parse the table into a more useful format interactions: dict[int, InteractionDefinition] = {} for row in content: - interactions[int(row["No"])] = InteractionDefinition(**row) + interactions[int(row["No"])] = InteractionDefinition(**row) # type: ignore[arg-type] return interactions diff --git a/tests/v3/compatibility_suite/test_v2_consumer.py b/tests/v3/compatibility_suite/test_v2_consumer.py index cd1447cfe..b2b6e3101 100644 --- a/tests/v3/compatibility_suite/test_v2_consumer.py +++ b/tests/v3/compatibility_suite/test_v2_consumer.py @@ -6,10 +6,7 @@ from pytest_bdd import given, parsers, scenario -from tests.v3.compatibility_suite.util import ( - InteractionDefinition, - parse_markdown_table, -) +from tests.v3.compatibility_suite.util import parse_markdown_table from tests.v3.compatibility_suite.util.consumer import ( a_response_is_returned, request_n_is_made_to_the_mock_server, @@ -35,6 +32,9 @@ the_pact_test_is_done, the_payload_will_contain_the_json_document, ) +from tests.v3.compatibility_suite.util.interaction_definition import ( + InteractionDefinition, +) logger = logging.getLogger(__name__) @@ -196,7 +196,7 @@ def the_following_http_interactions_have_been_defined( # Parse the table into a more useful format interactions: dict[int, InteractionDefinition] = {} for row in table: - interactions[int(row["No"])] = InteractionDefinition(**row) + interactions[int(row["No"])] = InteractionDefinition(**row) # type: ignore[arg-type] return interactions diff --git a/tests/v3/compatibility_suite/test_v2_provider.py b/tests/v3/compatibility_suite/test_v2_provider.py index 94313bae2..f891b60d1 100644 --- a/tests/v3/compatibility_suite/test_v2_provider.py +++ b/tests/v3/compatibility_suite/test_v2_provider.py @@ -10,9 +10,9 @@ import pytest from pytest_bdd import given, parsers, scenario -from tests.v3.compatibility_suite.util import ( +from tests.v3.compatibility_suite.util import parse_markdown_table +from tests.v3.compatibility_suite.util.interaction_definition import ( InteractionDefinition, - parse_markdown_table, ) from tests.v3.compatibility_suite.util.provider import ( a_pact_file_for_interaction_is_to_be_verified, @@ -125,7 +125,7 @@ def the_following_http_interactions_have_been_defined( # Parse the table into a more useful format interactions: dict[int, InteractionDefinition] = {} for row in content: - interactions[int(row["No"])] = InteractionDefinition(**row) + interactions[int(row["No"])] = InteractionDefinition(**row) # type: ignore[arg-type] return interactions diff --git a/tests/v3/compatibility_suite/test_v3_message_producer.py b/tests/v3/compatibility_suite/test_v3_message_producer.py index 31d87d0e8..ed6fc99a3 100644 --- a/tests/v3/compatibility_suite/test_v3_message_producer.py +++ b/tests/v3/compatibility_suite/test_v3_message_producer.py @@ -18,10 +18,13 @@ from pact.v3.pact import Pact from tests.v3.compatibility_suite.util import ( - InteractionDefinition, parse_horizontal_markdown_table, parse_markdown_table, ) +from tests.v3.compatibility_suite.util.interaction_definition import ( + InteractionDefinition, + InteractionState, +) from tests.v3.compatibility_suite.util.provider import ( a_provider_is_started_that_can_generate_the_message, a_provider_state_callback_is_configured, @@ -229,7 +232,7 @@ def a_pact_file_for_is_to_be_verified_with_the_following( interaction_definition = InteractionDefinition( type="Async", description=name, - **table, + **table, # type: ignore[arg-type] ) interaction_definition.add_to_pact(pact, name) (temp_dir / "pacts").mkdir(exist_ok=True, parents=True) @@ -284,16 +287,14 @@ def a_pact_file_for_is_to_be_verified_with_provider_state( description=name, body=fixture, ) - interaction_definition.states = [InteractionDefinition.State(provider_state)] + interaction_definition.states = [InteractionState(provider_state)] interaction_definition.add_to_pact(pact, name) (temp_dir / "pacts").mkdir(exist_ok=True, parents=True) pact.write_file(temp_dir / "pacts") 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") - json.dump( - [s.as_dict() for s in [InteractionDefinition.State(provider_state)]], f - ) + json.dump([s.as_dict() for s in [InteractionState(provider_state)]], f) @given( diff --git a/tests/v3/compatibility_suite/test_v3_provider.py b/tests/v3/compatibility_suite/test_v3_provider.py index eaa452a45..b3f6442ea 100644 --- a/tests/v3/compatibility_suite/test_v3_provider.py +++ b/tests/v3/compatibility_suite/test_v3_provider.py @@ -10,9 +10,9 @@ import pytest from pytest_bdd import given, parsers, scenario -from tests.v3.compatibility_suite.util import ( +from tests.v3.compatibility_suite.util import parse_markdown_table +from tests.v3.compatibility_suite.util.interaction_definition import ( InteractionDefinition, - parse_markdown_table, ) from tests.v3.compatibility_suite.util.provider import ( a_pact_file_for_interaction_is_to_be_verified_with_a_provider_states_defined, @@ -100,7 +100,7 @@ def the_following_http_interactions_have_been_defined( # Parse the table into a more useful format interactions: dict[int, InteractionDefinition] = {} for row in content: - interactions[int(row["No"])] = InteractionDefinition(**row) + interactions[int(row["No"])] = InteractionDefinition(**row) # type: ignore[arg-type] return interactions diff --git a/tests/v3/compatibility_suite/test_v4_provider.py b/tests/v3/compatibility_suite/test_v4_provider.py index bffc68f74..5f3ba4482 100644 --- a/tests/v3/compatibility_suite/test_v4_provider.py +++ b/tests/v3/compatibility_suite/test_v4_provider.py @@ -10,9 +10,9 @@ import pytest from pytest_bdd import given, parsers, scenario -from tests.v3.compatibility_suite.util import ( +from tests.v3.compatibility_suite.util import parse_markdown_table +from tests.v3.compatibility_suite.util.interaction_definition import ( InteractionDefinition, - parse_markdown_table, ) from tests.v3.compatibility_suite.util.provider import ( a_pact_file_for_interaction_is_to_be_verified, @@ -104,7 +104,7 @@ def the_following_http_interactions_have_been_defined( # Parse the table into a more useful format interactions: dict[int, InteractionDefinition] = {} for row in content: - interactions[int(row["No"])] = InteractionDefinition(**row) + interactions[int(row["No"])] = InteractionDefinition(**row) # type: ignore[arg-type] return interactions diff --git a/tests/v3/compatibility_suite/util/__init__.py b/tests/v3/compatibility_suite/util/__init__.py index 867ac5895..c2ba356e3 100644 --- a/tests/v3/compatibility_suite/util/__init__.py +++ b/tests/v3/compatibility_suite/util/__init__.py @@ -22,28 +22,17 @@ def _(): from __future__ import annotations import base64 -import contextlib import hashlib -import json import logging -import sys import typing from collections.abc import Collection, Mapping from datetime import date, datetime, time from pathlib import Path -from typing import Any, Generic, Literal, TypeVar -from xml.etree import ElementTree +from typing import Any, Generic, TypeVar -import flask -from flask import request from multidict import MultiDict -from typing_extensions import Self -from yarl import URL - -from pact.v3.interaction import HttpInteraction, Interaction if typing.TYPE_CHECKING: - from pact.v3.interaction import Interaction from pact.v3.pact import Pact logger = logging.getLogger(__name__) @@ -297,725 +286,3 @@ def parse_matching_rules(matching_rules: str) -> str: with (FIXTURES_ROOT / matching_rules).open("r") as file: return file.read() - - -class InteractionDefinition: - """ - Interaction definition. - - This is a dictionary that represents a single interaction. It is used to - parse the HTTP interactions table into a more useful format. - """ - - class Body: - """ - Interaction body. - - The interaction body can be one of: - - - A file - - An arbitrary string - - A JSON document - - An XML document - """ - - def __init__(self, data: str) -> None: - """ - Instantiate the interaction body. - """ - self.string: str | None = None - self.bytes: bytes | None = None - self.mime_type: str | None = None - - if data.startswith("file: ") and data.endswith("-body.xml"): - self.parse_fixture(FIXTURES_ROOT / data[6:]) - return - - if data.startswith("file: "): - self.parse_file(FIXTURES_ROOT / data[6:]) - return - - if data.startswith("JSON: "): - self.string = data[6:] - self.bytes = self.string.encode("utf-8") - self.mime_type = "application/json" - return - - if data.startswith("XML: "): - self.string = data[5:] - self.bytes = self.string.encode("utf-8") - self.mime_type = "application/xml" - return - - self.bytes = data.encode("utf-8") - self.string = data - - def __repr__(self) -> str: - """ - Debugging representation. - """ - return "".format( - ", ".join( - str(k) + "=" + truncate(repr(v)) for k, v in vars(self).items() - ), - ) - - def parse_fixture(self, fixture: Path) -> None: - """ - Parse a fixture file. - - This is used to parse the fixture files that contain additional - metadata about the body (such as the content type). - """ - etree = ElementTree.parse(fixture) # noqa: S314 - root = etree.getroot() - if not root or root.tag != "body": - msg = "Invalid XML fixture document" - raise ValueError(msg) - - contents = root.find("contents") - content_type = root.find("contentType") - if contents is None: - msg = "Invalid XML fixture document: no contents" - raise ValueError(msg) - if content_type is None: - msg = "Invalid XML fixture document: no contentType" - raise ValueError(msg) - self.string = typing.cast(str, contents.text) - - if eol := contents.attrib.get("eol", None): - if eol == "CRLF": - self.string = self.string.replace("\r\n", "\n") - self.string = self.string.replace("\n", "\r\n") - elif eol == "LF": - self.string = self.string.replace("\r\n", "\n") - - self.bytes = self.string.encode("utf-8") - self.mime_type = content_type.text - - def parse_file(self, file: Path) -> None: - """ - Load the contents of a file. - - The mime type is inferred from the file extension, and the contents - are loaded as a byte array, and optionally as a string. - """ - self.bytes = file.read_bytes() - with contextlib.suppress(UnicodeDecodeError): - self.string = file.read_text() - - if file.suffix == ".xml": - self.mime_type = "application/xml" - elif file.suffix == ".json": - self.mime_type = "application/json" - elif file.suffix == ".jpg": - self.mime_type = "image/jpeg" - elif file.suffix == ".pdf": - self.mime_type = "application/pdf" - else: - msg = "Unknown file type" - raise ValueError(msg) - - def add_to_interaction( - self, - interaction: Interaction, - ) -> None: - """ - Add a body to the interaction. - - This is a helper method that adds the body to the interaction. This - relies on Pact's intelligent understanding of whether it is dealing with - a request or response (which is determined through the use of - `will_respond_with`). - - Args: - body: - The body to add to the interaction. - - interaction: - The interaction to add the body to. - - """ - if self.string: - logger.info( - "with_body(%r, %r)", - truncate(self.string), - self.mime_type, - ) - interaction.with_body( - self.string, - self.mime_type, - ) - elif self.bytes: - logger.info( - "with_binary_body(%r, %r)", - truncate(self.bytes), - self.mime_type, - ) - interaction.with_binary_body( - self.bytes, - self.mime_type, - ) - else: - msg = "Unexpected body definition" - raise RuntimeError(msg) - - if self.mime_type and isinstance(interaction, HttpInteraction): - logger.info('set_header("Content-Type", %r)', self.mime_type) - interaction.set_header("Content-Type", self.mime_type) - - class State: - """ - Provider state. - """ - - def __init__( - self, - name: str, - parameters: str | dict[str, Any] | None = None, - ) -> None: - """ - Instantiate the provider state. - """ - self.name = name - self.parameters: dict[str, Any] - if isinstance(parameters, str): - self.parameters = json.loads(parameters) - else: - self.parameters = parameters or {} - - def __repr__(self) -> str: - """ - Debugging representation. - """ - return "".format( - ", ".join( - str(k) + "=" + truncate(repr(v)) for k, v in vars(self).items() - ), - ) - - def as_dict(self) -> dict[str, str | dict[str, Any]]: - """ - Convert the provider state to a dictionary. - """ - return {"name": self.name, "parameters": self.parameters} - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> Self: - """ - Convert a dictionary to a provider state. - """ - return cls(**data) - - def __init__(self, metadata: dict[str, Any] | None = None, **kwargs: str) -> None: - """ - Initialise the interaction definition. - - As the interaction definitions are parsed from a Markdown table, - values are expected to be strings and must be converted to the correct - type. - - The _only_ exception to this is the `metadata` key, which expects - a dictionary. - """ - # A common pattern used in the tests is to have a table with the 'base' - # definitions, and have tests modify these definitions as need be. As a - # result, the `__init__` method is designed to set all the values to - # defaults, and the `update` method is used to update the values. - if type_ := kwargs.pop("type", "HTTP"): - if type_ not in ("HTTP", "Sync", "Async"): - msg = f"Invalid value for 'type': {type_}" - raise ValueError(msg) - self.type: Literal["HTTP", "Sync", "Async"] = type_ - - # General properties shared by all interaction types - self.id: int | None = None - self.description: str | None = None - self.states: list[InteractionDefinition.State] = [] - self.metadata: dict[str, Any] | None = None - self.pending: bool = False - self.is_pending: bool = False - self.test_name: str | None = None - self.text_comments: list[str] = [] - self.comments: dict[str, str] = {} - - # Request properties - self.method: str | None = None - self.path: str | None = None - self.response: int | None = None - self.query: str | None = None - self.headers: MultiDict[str] = MultiDict() - self.body: InteractionDefinition.Body | None = None - self.matching_rules: str | None = None - - # Response properties - self.response_headers: MultiDict[str] = MultiDict() - self.response_body: InteractionDefinition.Body | None = None - self.response_matching_rules: str | None = None - - self.update(metadata=metadata, **kwargs) - - def update(self, metadata: dict[str, Any] | None = None, **kwargs: str) -> None: - """ - Update the interaction definition. - - This is a convenience method that allows the interaction definition to - be updated with new values. - """ - kwargs = self._update_shared(metadata, **kwargs) - kwargs = self._update_request(**kwargs) - kwargs = self._update_response(**kwargs) - - if len(kwargs) > 0: - msg = f"Unexpected arguments: {kwargs.keys()}" - raise TypeError(msg) - - def _update_shared( - self, - metadata: dict[str, Any] | None = None, - **kwargs: str, - ) -> dict[str, str]: - """ - Update the shared properties of the interaction. - - Note that the following properties are not supported and must be - modified directly: - - - `states` - - `text_comments` - - `comments` - - Args: - metadata: - Metadata for the interaction. - - kwargs: - Remaining keyword arguments, which are: - - - `No`: Interaction ID. Used purely for debugging purposes. - - `description`: Description of the interaction (used by - asynchronous messages) - - `pending`: Whether the interaction is pending. - - `test_name`: Test name for the interaction. - - Returns: - The remaining keyword arguments. - """ - if interaction_id := kwargs.pop("No", None): - self.id = int(interaction_id) - - if description := kwargs.pop("description", None): - self.description = description - - if "states" in kwargs: - msg = "Unsupported. Modify the 'states' property directly." - raise ValueError(msg) - - if metadata: - self.metadata = metadata - - if "pending" in kwargs: - self.pending = kwargs.pop("pending") == "true" - - if test_name := kwargs.pop("test_name", None): - self.test_name = test_name - - if "text_comments" in kwargs: - msg = "Unsupported. Modify the 'text_comments' property directly." - raise ValueError(msg) - - if "comments" in kwargs: - msg = "Unsupported. Modify the 'comments' property directly." - raise ValueError(msg) - - return kwargs - - def _update_request(self, **kwargs: str) -> dict[str, str]: - """ - Update the request properties of the interaction. - - Args: - kwargs: - Remaining keyword arguments, which are: - - - `method`: Request method. - - `path`: Request path. - - `query`: Query parameters. - - `headers`: Request headers. - - `raw_headers`: Request headers. - - `body`: Request body. - - `content_type`: Request content type. - - `matching_rules`: Request matching rules. - - """ - if method := kwargs.pop("method", None): - self.method = method - - if path := kwargs.pop("path", None): - self.path = path - - if query := kwargs.pop("query", None): - self.query = query - - if headers := kwargs.pop("headers", None): - self.headers = parse_headers(headers) - - if headers := ( - kwargs.pop("raw headers", None) or kwargs.pop("raw_headers", None) - ): - self.headers = parse_headers(headers) - - if body := kwargs.pop("body", None): - # When updating the body, we _only_ update the body content, not - # the content type. - orig_content_type = self.body.mime_type if self.body else None - self.body = InteractionDefinition.Body(body) - self.body.mime_type = self.body.mime_type or orig_content_type - - if content_type := ( - kwargs.pop("content_type", None) or kwargs.pop("content type", None) - ): - if self.body is None: - self.body = InteractionDefinition.Body("") - self.body.mime_type = content_type - - if matching_rules := ( - kwargs.pop("matching_rules", None) or kwargs.pop("matching rules", None) - ): - self.matching_rules = parse_matching_rules(matching_rules) - - return kwargs - - def _update_response(self, **kwargs: str) -> dict[str, str]: - """ - Update the response properties of the interaction. - - Args: - kwargs: - Remaining keyword arguments, which are: - - - `response`: Response status code. - - `response_headers`: Response headers. - - `response_content`: Response content type. - - `response_body`: Response body. - - `response_matching_rules`: Response matching rules. - - Returns: - The remaining keyword arguments. - """ - if response := kwargs.pop("response", None) or kwargs.pop("status", None): - self.response = int(response) - - if response_headers := ( - kwargs.pop("response_headers", None) or kwargs.pop("response headers", None) - ): - self.response_headers = parse_headers(response_headers) - - if response_content := ( - kwargs.pop("response_content", None) or kwargs.pop("response content", None) - ): - if self.response_body is None: - self.response_body = InteractionDefinition.Body("") - self.response_body.mime_type = response_content - - if response_body := ( - kwargs.pop("response_body", None) or kwargs.pop("response body", None) - ): - orig_content_type = ( - self.response_body.mime_type if self.response_body else None - ) - self.response_body = InteractionDefinition.Body(response_body) - self.response_body.mime_type = ( - self.response_body.mime_type or orig_content_type - ) - - if matching_rules := ( - kwargs.pop("response_matching_rules", None) - or kwargs.pop("response matching rules", None) - ): - self.response_matching_rules = parse_matching_rules(matching_rules) - - return kwargs - - def __repr__(self) -> str: - """ - Debugging representation. - """ - return "".format( - ", ".join(f"{k}={v!r}" for k, v in vars(self).items()), - ) - - def add_to_pact( # noqa: C901, PLR0912, PLR0915 - self, - pact: Pact, - name: str, - ) -> None: - """ - Add the interaction to the pact. - - This is a convenience method that allows the interaction definition to - be added to the pact, defining the "upon receiving ... with ... will - respond with ...". - - Args: - pact: - The pact being defined. - - name: - Name for this interaction. Must be unique for the pact. - """ - interaction = pact.upon_receiving(name, self.type) - if isinstance(interaction, HttpInteraction): - assert self.method, "Method must be defined" - assert self.path, "Path must be defined" - - logger.info("with_request(%r, %r)", self.method, self.path) - interaction.with_request(self.method, self.path) - - for state in self.states or []: - if state.parameters: - logger.info("given(%r, parameters=%r)", state.name, state.parameters) - interaction.given(state.name, parameters=state.parameters) - else: - logger.info("given(%r)", state.name) - interaction.given(state.name) - - if self.pending: - logger.info("set_pending(True)") - interaction.set_pending(pending=True) - - if self.text_comments: - for comment in self.text_comments: - logger.info("add_text_comment(%r)", comment) - interaction.add_text_comment(comment) - - for key, value in self.comments.items(): - logger.info("set_comment(%r, %r)", key, value) - interaction.set_comment(key, value) - - if self.test_name: - logger.info("test_name(%r)", self.test_name) - interaction.test_name(self.test_name) - - if self.query: - assert isinstance( - interaction, HttpInteraction - ), "Query parameters require an HTTP interaction" - query = URL.build(query_string=self.query).query - logger.info("with_query_parameters(%r)", query.items()) - interaction.with_query_parameters(query.items()) - - if self.headers: - assert isinstance( - interaction, HttpInteraction - ), "Headers require an HTTP interaction" - logger.info("with_headers(%r)", self.headers.items()) - interaction.with_headers(self.headers.items()) - - if self.body: - self.body.add_to_interaction(interaction) - - if self.matching_rules: - logger.info("with_matching_rules(%r)", self.matching_rules) - interaction.with_matching_rules(self.matching_rules) - - if self.response: - assert isinstance( - interaction, HttpInteraction - ), "Response requires an HTTP interaction" - logger.info("will_respond_with(%r)", self.response) - interaction.will_respond_with(self.response) - - if self.response_headers: - assert isinstance( - interaction, HttpInteraction - ), "Response headers require an HTTP interaction" - logger.info("with_headers(%r)", self.response_headers) - interaction.with_headers(self.response_headers.items()) - - if self.response_body: - assert isinstance( - interaction, HttpInteraction - ), "Response body requires an HTTP interaction" - self.response_body.add_to_interaction(interaction) - - if self.response_matching_rules: - logger.info("with_matching_rules(%r)", self.response_matching_rules) - interaction.with_matching_rules(self.response_matching_rules) - - if self.metadata: - for key, value in self.metadata.items(): - if isinstance(value, str): - interaction.with_metadata({key: value}) - else: - interaction.with_metadata({key: json.dumps(value)}) - - def add_to_flask(self, app: flask.Flask) -> None: - """ - Add an interaction to a Flask app. - - Args: - app: - The Flask app to add the interaction to. - """ - logger.debug("Adding %s interaction to Flask app", self.type) - if self.type == "HTTP": - self._add_http_to_flask(app) - elif self.type == "Sync": - self._add_sync_to_flask(app) - elif self.type == "Async": - self._add_async_to_flask(app) - else: - msg = f"Unknown interaction type: {self.type}" - raise ValueError(msg) - - def _add_http_to_flask(self, app: flask.Flask) -> None: - """ - Add a HTTP interaction to a Flask app. - - Ths function works by defining a new function to handle the request and - produce the response. This function is then added to the Flask app as a - route. - - Args: - app: - The Flask app to add the interaction to. - """ - 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", - 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}" - - app.add_url_rule( - self.path, - view_func=route_fn, - methods=[self.method], - ) - - def _add_sync_to_flask(self, app: flask.Flask) -> None: - """ - Add a synchronous message interaction to a Flask app. - - Args: - app: - The Flask app to add the interaction to. - """ - raise NotImplementedError - - def _add_async_to_flask(self, app: flask.Flask) -> None: - """ - Add a synchronous message interaction to a Flask app. - - Args: - app: - The Flask app to add the interaction to. - """ - assert self.description, "Description must be set for async messages" - if hasattr(app, "pact_messages"): - app.pact_messages[self.description] = self - else: - app.pact_messages = {self.description: self} - - # 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 app.url_map.iter_rules(): - if rule.rule == "/_pact/message": - sys.stderr.write("Async message route already defined\n") - return - - sys.stderr.write("Adding async message route\n") - - @app.post("/_pact/message") - def post_message() -> flask.Response: - sys.stderr.write("Received POST request for message\n") - assert hasattr(app, "pact_messages"), "No messages defined" - assert isinstance(app.pact_messages, dict), "Messages must be a dictionary" - - body: dict[str, Any] = json.loads(request.data) - description: str = body["description"] - - if description not in app.pact_messages: - return flask.Response( - response=json.dumps({ - "error": f"Message {description} not found", - }), - status=404, - headers={"Content-Type": "application/json"}, - content_type="application/json", - ) - - interaction: InteractionDefinition = app.pact_messages[description] - return interaction.create_async_message_response() - - def create_async_message_response(self) -> flask.Response: - """ - Convert the interaction to a Flask response. - - When an async message needs to be produced, Pact expects the response - from the special `/_pact/message` endppoint to generate the expected - message. - - Whilst this is a Response from Flask's perspective, the attributes - returned - """ - assert self.type == "Async", "Only async messages are supported" - - 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, - ) diff --git a/tests/v3/compatibility_suite/util/consumer.py b/tests/v3/compatibility_suite/util/consumer.py index 1126ed5c3..76887b636 100644 --- a/tests/v3/compatibility_suite/util/consumer.py +++ b/tests/v3/compatibility_suite/util/consumer.py @@ -29,7 +29,9 @@ from pact.v3.interaction._async_message_interaction import AsyncMessageInteraction from pact.v3.pact import PactServer - from tests.v3.compatibility_suite.util import InteractionDefinition + from tests.v3.compatibility_suite.util.interaction_definition import ( + InteractionDefinition, + ) logger = logging.getLogger(__name__) @@ -121,7 +123,7 @@ def _( pact = Pact("consumer", "provider") pact.with_specification(version) definition = interaction_definitions[iid] - definition.update(**content[0]) + definition.update(**content[0]) # type: ignore[arg-type] logger.info("Adding modified interaction %s", iid) definition.add_to_pact(pact, f"interaction {iid}") @@ -154,6 +156,9 @@ def _( ): definition.headers.add("Content-Type", definition.body.mime_type) + assert definition.method is not None, "Method not defined" + assert definition.path is not None, "Path not defined" + return requests.request( definition.method, str(srv.url.with_path(definition.path)), @@ -195,7 +200,7 @@ def _( """ definition = interaction_definitions[request_id] assert len(content) == 1, "Expected exactly one row in the table" - definition.update(**content[0]) + definition.update(**content[0]) # type: ignore[arg-type] if ( definition.body @@ -204,6 +209,9 @@ def _( ): definition.headers.add("Content-Type", definition.body.mime_type) + assert definition.method is not None, "Method not defined" + assert definition.path is not None, "Path not defined" + return requests.request( definition.method, str(srv.url.with_path(definition.path)), diff --git a/tests/v3/compatibility_suite/util/interaction_definition.py b/tests/v3/compatibility_suite/util/interaction_definition.py new file mode 100644 index 000000000..19fd5ce05 --- /dev/null +++ b/tests/v3/compatibility_suite/util/interaction_definition.py @@ -0,0 +1,759 @@ +""" +Interaction definition. + +This module defines the `InteractionDefinition` class, which is used to help +parse the interaction definitions from the compatibility suite, and interact +with the `Pact` and `Interaction` classes. +""" + +from __future__ import annotations + +import base64 +import contextlib +import json +import logging +import sys +import typing +from typing import Any, Literal +from xml.etree import ElementTree + +import flask +from flask import request +from multidict import MultiDict +from typing_extensions import Self +from yarl import URL + +from pact.v3.interaction import HttpInteraction, Interaction +from tests.v3.compatibility_suite.util import ( + FIXTURES_ROOT, + parse_headers, + parse_matching_rules, + truncate, +) + +if typing.TYPE_CHECKING: + 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 + +logger = logging.getLogger(__name__) + + +class InteractionBody: + """ + Interaction body. + + The interaction body can be one of: + + - A file + - An arbitrary string + - A JSON document + - An XML document + """ + + def __init__(self, data: str) -> None: + """ + Instantiate the interaction body. + """ + self.string: str | None = None + self.bytes: bytes | None = None + self.mime_type: str | None = None + + if data.startswith("file: ") and data.endswith("-body.xml"): + self.parse_fixture(FIXTURES_ROOT / data[6:]) + return + + if data.startswith("file: "): + self.parse_file(FIXTURES_ROOT / data[6:]) + return + + if data.startswith("JSON: "): + self.string = data[6:] + self.bytes = self.string.encode("utf-8") + self.mime_type = "application/json" + return + + if data.startswith("XML: "): + self.string = data[5:] + self.bytes = self.string.encode("utf-8") + self.mime_type = "application/xml" + return + + self.bytes = data.encode("utf-8") + self.string = data + + def __repr__(self) -> str: + """ + Debugging representation. + """ + return "".format( + ", ".join(str(k) + "=" + truncate(repr(v)) for k, v in vars(self).items()), + ) + + def parse_fixture(self, fixture: Path) -> None: + """ + Parse a fixture file. + + This is used to parse the fixture files that contain additional + metadata about the body (such as the content type). + """ + etree = ElementTree.parse(fixture) # noqa: S314 + root = etree.getroot() + if not root or root.tag != "body": + msg = "Invalid XML fixture document" + raise ValueError(msg) + + contents = root.find("contents") + content_type = root.find("contentType") + if contents is None: + msg = "Invalid XML fixture document: no contents" + raise ValueError(msg) + if content_type is None: + msg = "Invalid XML fixture document: no contentType" + raise ValueError(msg) + self.string = typing.cast(str, contents.text) + + if eol := contents.attrib.get("eol", None): + if eol == "CRLF": + self.string = self.string.replace("\r\n", "\n") + self.string = self.string.replace("\n", "\r\n") + elif eol == "LF": + self.string = self.string.replace("\r\n", "\n") + + self.bytes = self.string.encode("utf-8") + self.mime_type = content_type.text + + def parse_file(self, file: Path) -> None: + """ + Load the contents of a file. + + The mime type is inferred from the file extension, and the contents + are loaded as a byte array, and optionally as a string. + """ + self.bytes = file.read_bytes() + with contextlib.suppress(UnicodeDecodeError): + self.string = file.read_text() + + if file.suffix == ".xml": + self.mime_type = "application/xml" + elif file.suffix == ".json": + self.mime_type = "application/json" + elif file.suffix == ".jpg": + self.mime_type = "image/jpeg" + elif file.suffix == ".pdf": + self.mime_type = "application/pdf" + else: + msg = "Unknown file type" + raise ValueError(msg) + + def add_to_interaction( + self, + interaction: Interaction, + ) -> None: + """ + Add a body to the interaction. + + This is a helper method that adds the body to the interaction. This + relies on Pact's intelligent understanding of whether it is dealing with + a request or response (which is determined through the use of + `will_respond_with`). + + Args: + body: + The body to add to the interaction. + + interaction: + The interaction to add the body to. + + """ + if self.string: + logger.info( + "with_body(%r, %r)", + truncate(self.string), + self.mime_type, + ) + interaction.with_body( + self.string, + self.mime_type, + ) + elif self.bytes: + logger.info( + "with_binary_body(%r, %r)", + truncate(self.bytes), + self.mime_type, + ) + interaction.with_binary_body( + self.bytes, + self.mime_type, + ) + else: + msg = "Unexpected body definition" + raise RuntimeError(msg) + + if self.mime_type and isinstance(interaction, HttpInteraction): + logger.info('set_header("Content-Type", %r)', self.mime_type) + interaction.set_header("Content-Type", self.mime_type) + + +class InteractionState: + """ + Provider state. + """ + + def __init__( + self, + name: str, + parameters: str | dict[str, Any] | None = None, + ) -> None: + """ + Instantiate the provider state. + """ + self.name = name + self.parameters: dict[str, Any] + if isinstance(parameters, str): + self.parameters = json.loads(parameters) + else: + self.parameters = parameters or {} + + def __repr__(self) -> str: + """ + Debugging representation. + """ + return "".format( + ", ".join(str(k) + "=" + truncate(repr(v)) for k, v in vars(self).items()), + ) + + def as_dict(self) -> dict[str, str | dict[str, Any]]: + """ + Convert the provider state to a dictionary. + """ + return {"name": self.name, "parameters": self.parameters} + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> Self: + """ + Convert a dictionary to a provider state. + """ + return cls(**data) + + +class InteractionDefinition: + """ + Interaction definition. + + This is a dictionary that represents a single interaction. It is used to + parse the HTTP interactions table into a more useful format. + """ + + def __init__( + self, + *, + metadata: dict[str, Any] | None = None, + **kwargs: str, + ) -> None: + """ + Initialise the interaction definition. + + As the interaction definitions are parsed from a Markdown table, + values are expected to be strings and must be converted to the correct + type. + + The _only_ exception to this is the `metadata` key, which expects + a dictionary. + """ + # A common pattern used in the tests is to have a table with the 'base' + # definitions, and have tests modify these definitions as need be. As a + # result, the `__init__` method is designed to set all the values to + # defaults, and the `update` method is used to update the values. + if type_ := kwargs.pop("type", "HTTP"): + if type_ not in ("HTTP", "Sync", "Async"): + msg = f"Invalid value for 'type': {type_}" + raise ValueError(msg) + self.type: Literal["HTTP", "Sync", "Async"] = type_ # type: ignore[assignment] + + # General properties shared by all interaction types + self.id: int | None = None + self.description: str | None = None + self.states: list[InteractionState] = [] + self.metadata: dict[str, Any] | None = None + self.pending: bool = False + self.is_pending: bool = False + self.test_name: str | None = None + self.text_comments: list[str] = [] + self.comments: dict[str, str] = {} + + # Request properties + self.method: str | None = None + self.path: str | None = None + self.response: int | None = None + self.query: str | None = None + self.headers: MultiDict[str] = MultiDict() + self.body: InteractionBody | None = None + self.matching_rules: str | None = None + + # Response properties + self.response_headers: MultiDict[str] = MultiDict() + self.response_body: InteractionBody | None = None + self.response_matching_rules: str | None = None + + self.update(metadata=metadata, **kwargs) + + def update(self, *, metadata: dict[str, Any] | None = None, **kwargs: str) -> None: + """ + Update the interaction definition. + + This is a convenience method that allows the interaction definition to + be updated with new values. + """ + kwargs = self._update_shared(metadata, **kwargs) + kwargs = self._update_request(**kwargs) + kwargs = self._update_response(**kwargs) + + if len(kwargs) > 0: + msg = f"Unexpected arguments: {kwargs.keys()}" + raise TypeError(msg) + + def _update_shared( + self, + metadata: dict[str, Any] | None = None, + **kwargs: str, + ) -> dict[str, str]: + """ + Update the shared properties of the interaction. + + Note that the following properties are not supported and must be + modified directly: + + - `states` + - `text_comments` + - `comments` + + Args: + metadata: + Metadata for the interaction. + + kwargs: + Remaining keyword arguments, which are: + + - `No`: Interaction ID. Used purely for debugging purposes. + - `description`: Description of the interaction (used by + asynchronous messages) + - `pending`: Whether the interaction is pending. + - `test_name`: Test name for the interaction. + + Returns: + The remaining keyword arguments. + """ + if interaction_id := kwargs.pop("No", None): + self.id = int(interaction_id) + + if description := kwargs.pop("description", None): + self.description = description + + if "states" in kwargs: + msg = "Unsupported. Modify the 'states' property directly." + raise ValueError(msg) + + if metadata: + self.metadata = metadata + + if "pending" in kwargs: + self.pending = kwargs.pop("pending") == "true" + + if test_name := kwargs.pop("test_name", None): + self.test_name = test_name + + if "text_comments" in kwargs: + msg = "Unsupported. Modify the 'text_comments' property directly." + raise ValueError(msg) + + if "comments" in kwargs: + msg = "Unsupported. Modify the 'comments' property directly." + raise ValueError(msg) + + return kwargs + + def _update_request(self, **kwargs: str) -> dict[str, str]: + """ + Update the request properties of the interaction. + + Args: + kwargs: + Remaining keyword arguments, which are: + + - `method`: Request method. + - `path`: Request path. + - `query`: Query parameters. + - `headers`: Request headers. + - `raw_headers`: Request headers. + - `body`: Request body. + - `content_type`: Request content type. + - `matching_rules`: Request matching rules. + + """ + if method := kwargs.pop("method", None): + self.method = method + + if path := kwargs.pop("path", None): + self.path = path + + if query := kwargs.pop("query", None): + self.query = query + + if headers := kwargs.pop("headers", None): + self.headers = parse_headers(headers) + + if headers := ( + kwargs.pop("raw headers", None) or kwargs.pop("raw_headers", None) + ): + self.headers = parse_headers(headers) + + if body := kwargs.pop("body", None): + # When updating the body, we _only_ update the body content, not + # the content type. + orig_content_type = self.body.mime_type if self.body else None + self.body = InteractionBody(body) + self.body.mime_type = self.body.mime_type or orig_content_type + + if content_type := ( + kwargs.pop("content_type", None) or kwargs.pop("content type", None) + ): + if self.body is None: + self.body = InteractionBody("") + self.body.mime_type = content_type + + if matching_rules := ( + kwargs.pop("matching_rules", None) or kwargs.pop("matching rules", None) + ): + self.matching_rules = parse_matching_rules(matching_rules) + + return kwargs + + def _update_response(self, **kwargs: str) -> dict[str, str]: + """ + Update the response properties of the interaction. + + Args: + kwargs: + Remaining keyword arguments, which are: + + - `response`: Response status code. + - `response_headers`: Response headers. + - `response_content`: Response content type. + - `response_body`: Response body. + - `response_matching_rules`: Response matching rules. + + Returns: + The remaining keyword arguments. + """ + if response := kwargs.pop("response", None) or kwargs.pop("status", None): + self.response = int(response) + + if response_headers := ( + kwargs.pop("response_headers", None) or kwargs.pop("response headers", None) + ): + self.response_headers = parse_headers(response_headers) + + if response_content := ( + kwargs.pop("response_content", None) or kwargs.pop("response content", None) + ): + if self.response_body is None: + self.response_body = InteractionBody("") + self.response_body.mime_type = response_content + + if response_body := ( + kwargs.pop("response_body", None) or kwargs.pop("response body", None) + ): + orig_content_type = ( + self.response_body.mime_type if self.response_body else None + ) + self.response_body = InteractionBody(response_body) + self.response_body.mime_type = ( + self.response_body.mime_type or orig_content_type + ) + + if matching_rules := ( + kwargs.pop("response_matching_rules", None) + or kwargs.pop("response matching rules", None) + ): + self.response_matching_rules = parse_matching_rules(matching_rules) + + return kwargs + + def __repr__(self) -> str: + """ + Debugging representation. + """ + return "".format( + ", ".join(f"{k}={v!r}" for k, v in vars(self).items()), + ) + + def add_to_pact( # noqa: C901, PLR0912, PLR0915 + self, + pact: Pact, + name: str, + ) -> None: + """ + Add the interaction to the pact. + + This is a convenience method that allows the interaction definition to + be added to the pact, defining the "upon receiving ... with ... will + respond with ...". + + Args: + pact: + The pact being defined. + + name: + Name for this interaction. Must be unique for the pact. + """ + interaction = pact.upon_receiving(name, self.type) + if isinstance(interaction, HttpInteraction): + assert self.method, "Method must be defined" + assert self.path, "Path must be defined" + + logger.info("with_request(%r, %r)", self.method, self.path) + interaction.with_request(self.method, self.path) + + for state in self.states or []: + if state.parameters: + logger.info("given(%r, parameters=%r)", state.name, state.parameters) + interaction.given(state.name, parameters=state.parameters) + else: + logger.info("given(%r)", state.name) + interaction.given(state.name) + + if self.pending: + logger.info("set_pending(True)") + interaction.set_pending(pending=True) + + if self.text_comments: + for comment in self.text_comments: + logger.info("add_text_comment(%r)", comment) + interaction.add_text_comment(comment) + + for key, value in self.comments.items(): + logger.info("set_comment(%r, %r)", key, value) + interaction.set_comment(key, value) + + if self.test_name: + logger.info("test_name(%r)", self.test_name) + interaction.test_name(self.test_name) + + if self.query: + assert isinstance( + interaction, HttpInteraction + ), "Query parameters require an HTTP interaction" + query = URL.build(query_string=self.query).query + logger.info("with_query_parameters(%r)", query.items()) + interaction.with_query_parameters(query.items()) + + if self.headers: + assert isinstance( + interaction, HttpInteraction + ), "Headers require an HTTP interaction" + logger.info("with_headers(%r)", self.headers.items()) + interaction.with_headers(self.headers.items()) + + if self.body: + self.body.add_to_interaction(interaction) + + if self.matching_rules: + logger.info("with_matching_rules(%r)", self.matching_rules) + interaction.with_matching_rules(self.matching_rules) + + if self.response: + assert isinstance( + interaction, HttpInteraction + ), "Response requires an HTTP interaction" + logger.info("will_respond_with(%r)", self.response) + interaction.will_respond_with(self.response) + + if self.response_headers: + assert isinstance( + interaction, HttpInteraction + ), "Response headers require an HTTP interaction" + logger.info("with_headers(%r)", self.response_headers) + interaction.with_headers(self.response_headers.items()) + + if self.response_body: + assert isinstance( + interaction, HttpInteraction + ), "Response body requires an HTTP interaction" + self.response_body.add_to_interaction(interaction) + + if self.response_matching_rules: + logger.info("with_matching_rules(%r)", self.response_matching_rules) + interaction.with_matching_rules(self.response_matching_rules) + + if self.metadata: + for key, value in self.metadata.items(): + if isinstance(value, str): + interaction.with_metadata({key: value}) + else: + interaction.with_metadata({key: json.dumps(value)}) + + def add_to_provider(self, provider: Provider) -> None: + """ + Add an interaction to a Flask app. + + Args: + provider: + The test provider to add the interaction to. + """ + logger.debug("Adding %s interaction to Flask app", self.type) + 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) + else: + msg = f"Unknown interaction type: {self.type}" + raise ValueError(msg) + + def _add_http_to_provider(self, provider: Provider) -> None: + """ + Add a HTTP interaction to a Flask app. + + Ths function works by defining a new function to handle the request and + produce the response. This function is then added to the Flask app as a + route. + + Args: + provider: + The test provider to add the interaction to. + """ + 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", + 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 + + def _add_async_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. + """ + assert self.description, "Description must be set for async messages" + provider.messages[self.description] = self + + # 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") + 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", + ) + + interaction: InteractionDefinition = provider.messages[description] + return interaction.create_async_message_response() + + def create_async_message_response(self) -> flask.Response: + """ + Convert the interaction to a Flask response. + + When an async message needs to be produced, Pact expects the response + from the special `/_pact/message` endppoint to generate the expected + message. + + Whilst this is a Response from Flask's perspective, the attributes + returned + """ + assert self.type == "Async", "Only async messages are supported" + + 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, + ) diff --git a/tests/v3/compatibility_suite/util/provider.py b/tests/v3/compatibility_suite/util/provider.py index 4288230b8..53ff8e40a 100644 --- a/tests/v3/compatibility_suite/util/provider.py +++ b/tests/v3/compatibility_suite/util/provider.py @@ -49,12 +49,15 @@ import pact.constants # type: ignore[import-untyped] from pact.v3.pact import Pact from tests.v3.compatibility_suite.util import ( - InteractionDefinition, parse_headers, parse_markdown_table, serialize, truncate, ) +from tests.v3.compatibility_suite.util.interaction_definition import ( + InteractionDefinition, + InteractionState, +) if TYPE_CHECKING: from collections.abc import Generator @@ -179,24 +182,24 @@ def __init__(self, provider_dir: Path | str, log_level: int) -> None: raise ValueError(msg) self.app: flask.Flask = flask.Flask("provider") - self._add_ping(self.app) - self._add_callback(self.app) - self._add_after_request(self.app) - self._add_interactions(self.app) + self._add_ping() + self._add_callback() + self._add_after_request() + self._add_interactions() - def _add_ping(self, app: flask.Flask) -> None: + def _add_ping(self) -> None: """ Add a ping endpoint to the provider. This is used to check that the provider is running. """ - @app.get("/_test/ping") + @self.app.get("/_test/ping") def ping() -> str: """Simple ping endpoint for testing.""" return "pong" - def _add_callback(self, app: flask.Flask) -> None: + def _add_callback(self) -> None: """ Add a callback endpoint to the provider. @@ -213,7 +216,7 @@ def _add_callback(self, app: flask.Flask) -> None: contents of the file. """ - @app.route("/_pact/callback", methods=["GET", "POST"]) + @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 @@ -222,7 +225,7 @@ def callback() -> tuple[str, int] | str: if provider_states_path.exists(): logger.debug("Provider states file found") with provider_states_path.open() as f: - states = [InteractionDefinition.State(**s) for s in json.load(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: @@ -257,7 +260,7 @@ def callback() -> tuple[str, int] | str: return "" - def _add_after_request(self, app: flask.Flask) -> None: + def _add_after_request(self) -> None: """ Add a handler to log requests and responses. @@ -265,7 +268,7 @@ def _add_after_request(self, app: flask.Flask) -> None: application (both to the logger as well as to files). """ - @app.after_request + @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")) @@ -292,7 +295,7 @@ def log_request(response: flask.Response) -> flask.Response: ) return response - @app.after_request + @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)) @@ -320,7 +323,7 @@ def log_response(response: flask.Response) -> flask.Response: ) return response - def _add_interactions(self, app: flask.Flask) -> None: + def _add_interactions(self) -> None: """ Add the interactions to the provider. """ @@ -328,7 +331,7 @@ def _add_interactions(self, app: flask.Flask) -> None: interactions: list[InteractionDefinition] = pickle.load(f) # noqa: S301 for interaction in interactions: - interaction.add_to_flask(app) + interaction.add_to_provider(self) def run(self) -> None: """ @@ -537,7 +540,7 @@ def latest_verification_results(self) -> requests.Response | None: sys.stderr.write(f"Usage: {sys.argv[0]} \n") sys.exit(1) - Provider(sys.argv[1], sys.argv[2]).run() + Provider(sys.argv[1], int(sys.argv[2])).run() ################################################################################ @@ -608,7 +611,7 @@ def _( defns: list[InteractionDefinition] = [] for interaction in interactions: defn = copy.deepcopy(interaction_definitions[interaction]) - defn.update(**changes[0]) + defn.update(**changes[0]) # type: ignore[arg-type] defns.append(defn) logger.debug( "Updated interaction %d: %s", @@ -950,7 +953,7 @@ def _( ) defn = interaction_definitions[interaction] - defn.states = [InteractionDefinition.State(state)] + defn.states = [InteractionState(state)] pact = Pact("consumer", "provider") pact.with_specification(version) @@ -996,8 +999,7 @@ def _( defn = interaction_definitions[interaction] defn.states = [ - InteractionDefinition.State(s["State Name"], s.get("Parameters", None)) - for s in states + InteractionState(s["State Name"], s.get("Parameters", None)) for s in states ] pact = Pact("consumer", "provider")