From 9bd36b2a08811a6cd7f8c1660a78e0fe6d7b9287 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Tue, 10 Oct 2023 12:56:21 +1100 Subject: [PATCH] feat(v3): implement pact class This (rather large) commit implements core functionality for the `Pact` class, and the `Interaction` class to handle specific interactions within a Pact. This does _not_ implement every method that Pacts and/or interactions might have (e.g., it is currently not possible to specify a Pact version). The intent of this commit is to be a minimal working example which can be improved upon more incrementally. Signed-off-by: JP-Ellis --- pact/v3/__init__.py | 7 + pact/v3/ffi.py | 802 +++++++++++++++++++++++++++--------------- pact/v3/pact.py | 788 +++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 26 +- tests/v3/conftest.py | 18 + tests/v3/test_pact.py | 431 +++++++++++++++++++++++ 6 files changed, 1785 insertions(+), 287 deletions(-) create mode 100644 pact/v3/pact.py create mode 100644 tests/v3/conftest.py create mode 100644 tests/v3/test_pact.py diff --git a/pact/v3/__init__.py b/pact/v3/__init__.py index 443bf9164..cb0059d95 100644 --- a/pact/v3/__init__.py +++ b/pact/v3/__init__.py @@ -19,3 +19,10 @@ library is moved to the `pact.v2` scope. The `pact.v2` module will be considered deprecated, and will be removed in a future release. """ + +from .pact import Interaction, Pact + +__all__ = [ + "Pact", + "Interaction", +] diff --git a/pact/v3/ffi.py b/pact/v3/ffi.py index 37605a1e0..5dc0aebea 100644 --- a/pact/v3/ffi.py +++ b/pact/v3/ffi.py @@ -67,16 +67,28 @@ appropriate Python exception class, and should be documented in the function's docstring. """ +# The following lints are disabled during initial development and should be +# removed later. # ruff: noqa: ARG001 (unused-function-argument) # ruff: noqa: A002 (builtin-argument-shadowing) # ruff: noqa: D101 (undocumented-public-class) +# The following lints are disabled for this file. +# ruff: noqa: SLF001 +# private-member-access, as we need access to other handles' internal +# references, without exposing them to the user. + +from __future__ import annotations + import warnings from enum import Enum -from typing import List +from typing import TYPE_CHECKING, List from ._ffi import ffi, lib # type: ignore[import] +if TYPE_CHECKING: + from pathlib import Path + # The follow types are classes defined in the Rust code. Ultimately, a Python # alternative should be implemented, but for now, the follow lines only serve # to inform the type checker of the existence of these types. @@ -111,7 +123,34 @@ class HttpResponse: class InteractionHandle: - ... + """ + Handle to a HTTP Interaction. + + [Rust + `InteractionHandle`](https://docs.rs/pact_ffi/0.4.9/pact_ffi/mock_server/handles/struct.InteractionHandle.html) + """ + + def __init__(self, ref: int) -> None: + """ + Initialise a new Interaction Handle. + + Args: + ref: + Reference to the Interaction Handle. + """ + self._ref: int = ref + + def __str__(self) -> str: + """ + String representation of the Interaction Handle. + """ + return f"InteractionHandle({self._ref})" + + def __repr__(self) -> str: + """ + String representation of the Interaction Handle. + """ + return f"InteractionHandle({self._ref})" class MatchingRule: @@ -195,7 +234,89 @@ class Pact: class PactHandle: - ... + """ + Handle to a Pact. + + [Rust + `PactHandle`](https://docs.rs/pact_ffi/0.4.9/pact_ffi/mock_server/handles/struct.PactHandle.html) + """ + + def __init__(self, ref: int) -> None: + """ + Initialise a new Pact Handle. + + Args: + ref: + Rust library reference to the Pact Handle. + """ + self._ref: int = ref + + def __del__(self) -> None: + """ + Destructor for the Pact Handle. + """ + free_pact_handle(self) + + def __str__(self) -> str: + """ + String representation of the Pact Handle. + """ + return f"PactHandle({self._ref})" + + def __repr__(self) -> str: + """ + String representation of the Pact Handle. + """ + return f"PactHandle({self._ref})" + + +class PactServerHandle: + """ + Handle to a Pact Server. + + This does not have an exact correspondance in the Rust library. It is used + to manage the lifecycle of the mock server. + + # Implementation Notes + + The Rust library uses the port number as a unique identifier, in much the + same was as it uses a wrapped integer for the Pact handle. + """ + + def __init__(self, ref: int) -> None: + """ + Initialise a new Pact Server Handle. + + Args: + ref: + Rust library reference to the Pact Server. + """ + self._ref: int = ref + + def __del__(self) -> None: + """ + Destructor for the Pact Server Handle. + """ + cleanup_mock_server(self) + + def __str__(self) -> str: + """ + String representation of the Pact Server Handle. + """ + return f"PactServerHandle({self._ref})" + + def __repr__(self) -> str: + """ + String representation of the Pact Server Handle. + """ + return f"PactServerHandle({self._ref})" + + @property + def port(self) -> int: + """ + Port on which the Pact Server is running. + """ + return self._ref class PactInteraction: @@ -432,9 +553,12 @@ def __repr__(self) -> str: def version() -> str: """ - Wraps a Pact model struct. + Return the version of the pact_ffi library. [Rust `pactffi_version`](https://docs.rs/pact_ffi/0.4.9/pact_ffi/?search=pactffi_version) + + Returns: + The version of the pact_ffi library as a string, in the form of `x.y.z`. """ return ffi.string(lib.pactffi_version()).decode("utf-8") @@ -665,13 +789,28 @@ def log_to_stdout(level_filter: LevelFilter) -> int: raise NotImplementedError -def log_to_stderr(level_filter: LevelFilter) -> int: +def log_to_stderr(level_filter: LevelFilter | str = LevelFilter.ERROR) -> None: """ Convenience function to direct all logging to stderr. - [Rust `pactffi_log_to_stderr`](https://docs.rs/pact_ffi/0.4.9/pact_ffi/?search=pactffi_log_to_stderr) + [Rust + `pactffi_log_to_stderr`](https://docs.rs/pact_ffi/0.4.9/pact_ffi/?search=pactffi_log_to_stderr) + + Args: + level_filter: + The level of logs to filter to. If a string is given, it must match + one of the [`LevelFilter`][pact.v3.ffi.LevelFilter] values (case + insensitive). + + Raises: + RuntimeError: If there was an error setting the logger. """ - raise NotImplementedError + if isinstance(level_filter, str): + level_filter = LevelFilter[level_filter.upper()] + ret = lib.pactffi_log_to_stderr(level_filter.value) + if ret != 0: + msg = "There was an unknown error setting the logger." + raise RuntimeError(msg) def log_to_file(file_name: str, level_filter: LevelFilter) -> int: @@ -4075,54 +4214,67 @@ def create_mock_server_for_transport( addr: str, port: int, transport: str, - transport_config: str, -) -> int: + transport_config: str | None, +) -> PactServerHandle: """ Create a mock server for the provided Pact handle and transport. - If the transport is not provided (it is a NULL pointer or an empty string), - will default to an HTTP transport. The address is the interface bind to, and - will default to the loopback adapter if not specified. Specifying a value of - zero for the port will result in the operating system allocating the port. - [Rust `pactffi_create_mock_server_for_transport`](https://docs.rs/pact_ffi/0.4.9/pact_ffi/?search=pactffi_create_mock_server_for_transport) - Parameters: - - * `pact` - Handle to a Pact model created with created with - `pactffi_new_pact`. - * `addr` - Address to bind to (i.e. `127.0.0.1` or `[::1]`). Must be a valid - UTF-8 NULL-terminated string, or NULL or empty, in which case the loopback - adapter is used. - * `port` - Port number to bind to. A value of zero will result in the - operating system allocating an available port. - * `transport` - The transport to use (i.e. http, https, grpc). Must be a - valid UTF-8 NULL-terminated string, or NULL or empty, in which case http - will be used. - * `transport_config` - (OPTIONAL) Configuration for the transport as a valid - JSON string. Set to NULL or empty if not required. - - The port of the mock server is returned. - - # Safety NULL pointers or empty strings can be passed in for the address, - transport and transport_config, in which case a default value will be used. - Passing in an invalid pointer will result in undefined behaviour. - - # Errors - - Errors are returned as negative values. - - | Error | Description | - |-------|-------------| - | -1 | An invalid handle was received. Handles should be created with `pactffi_new_pact` | - | -2 | transport_config is not valid JSON | - | -3 | The mock server could not be started | - | -4 | The method panicked | - | -5 | The address is not valid | - - """ # noqa: E501 - raise NotImplementedError + Args: + pact: + Handle to the Pact model. + + addr: + The address to bind to. + + port: + The port number to bind to. A value of zero will result in the + operating system allocating an available port. + + transport: + The transport to use (i.e. http, https, grpc). The underlying Pact + library will interpret this, typically in a case-sensitive way. + + transport_config: + Configuration to be passed to the transport. This must be a valid + JSON string, or `None` if not required. + + Returns: + A handle to the mock server. + + Raises: + RuntimeError: If the mock server could not be created. The error message + will contain details of the error. + """ + ret: int = lib.pactffi_create_mock_server_for_transport( + pact._ref, + addr.encode("utf-8"), + port, + transport.encode("utf-8"), + ( + transport_config.encode("utf-8") + if transport_config is not None + else ffi.NULL + ), + ) + if ret > 0: + return PactServerHandle(ret) + + if ret == -1: + msg = f"An invalid Pact handle was received: {pact}." + elif ret == -2: # noqa: PLR2004 + msg = "Invalid transport_config JSON." + elif ret == -3: # noqa: PLR2004 + msg = f"Pact mock server could not be started for {pact}." + elif ret == -4: # noqa: PLR2004 + msg = f"Panick during Pact mock server creation for {pact}." + elif ret == -5: # noqa: PLR2004 + msg = f"Address is invalid: {addr}." + else: + msg = f"An unknown error occurred during Pact mock server creation for {pact}." + raise RuntimeError(msg) def mock_server_matched(mock_server_port: int) -> bool: @@ -4163,49 +4315,83 @@ def mock_server_mismatches(mock_server_port: int) -> str: raise NotImplementedError -def cleanup_mock_server(mock_server_port: int) -> bool: +def cleanup_mock_server(mock_server_handle: PactServerHandle) -> None: """ External interface to cleanup a mock server. This function will try terminate the mock server with the given port number - and cleanup any memory allocated for it. Returns true, unless a mock server - with the given port number does not exist, or the function panics. + and cleanup any memory allocated for it. [Rust `pactffi_cleanup_mock_server`](https://docs.rs/pact_ffi/0.4.9/pact_ffi/?search=pactffi_cleanup_mock_server) + + Args: + mock_server_handle: + Handle to the mock server to cleanup. + + Raises: + RuntimeError: If the mock server could not be cleaned up. """ - raise NotImplementedError + success: bool = lib.pactffi_cleanup_mock_server(mock_server_handle._ref) + if not success: + msg = f"Could not cleanup mock server with port {mock_server_handle._ref}" + raise RuntimeError(msg) -def write_pact_file(mock_server_port: int, directory: str, *, overwrite: bool) -> int: +def write_pact_file( + mock_server_handle: PactServerHandle, + directory: str | Path, + *, + overwrite: bool, +) -> None: """ External interface to trigger a mock server to write out its pact file. This function should be called if all the consumer tests have passed. The - directory to write the file to is passed as the second parameter. If a NULL - pointer is passed, the current working directory is used. + directory to write the file to is passed as the second parameter. [Rust `pactffi_write_pact_file`](https://docs.rs/pact_ffi/0.4.9/pact_ffi/?search=pactffi_write_pact_file) - If overwrite is true, the file will be overwritten with the contents of the - current pact. Otherwise, it will be merged with any existing pact file. - - Returns 0 if the pact file was successfully written. Returns a positive code - if the file can not be written, or there is no mock server running on that - port or the function panics. + Args: + mock_server_handle: + Handle to the mock server to write the pact file for. - # Errors + directory: + Directory to write the pact file to. - Errors are returned as positive values. + overwrite: + Whether to overwrite any existing pact files. If this is false, the + pact file will be merged with any existing pact file. - | Error | Description | - |-------|-------------| - | 1 | A general panic was caught | - | 2 | The pact file was not able to be written | - | 3 | A mock server with the provided port was not found | + Raises: + RuntimeError: If there was an error writing the pact file. """ - raise NotImplementedError + ret: int = lib.pactffi_write_pact_file( + mock_server_handle._ref, + directory, + overwrite, + ) + if ret == 0: + return + if ret == 1: + msg = ( + f"The function panicked while writing the Pact for {mock_server_handle} in" + f" {directory}." + ) + elif ret == 2: # noqa: PLR2004 + msg = ( + f"The Pact file for {mock_server_handle} could not be written in" + f" {directory}." + ) + elif ret == 3: # noqa: PLR2004 + msg = f"The Pact for the {mock_server_handle} was not found." + else: + msg = ( + "An unknown error occurred while writing the Pact for" + f" {mock_server_handle} in {directory}." + ) + raise RuntimeError(msg) def mock_server_logs(mock_server_port: int) -> str: @@ -4308,13 +4494,22 @@ def new_pact(consumer_name: str, provider_name: str) -> PactHandle: [Rust `pactffi_new_pact`](https://docs.rs/pact_ffi/0.4.9/pact_ffi/?search=pactffi_new_pact) - * `consumer_name` - The name of the consumer for the pact. - * `provider_name` - The name of the provider for the pact. + Args: + consumer_name: + The name of the consumer for the pact. - Returns a new `PactHandle`. The handle will need to be freed with the - `pactffi_free_pact_handle` method to release its resources. + provider_name: + The name of the provider for the pact. + + Returns: + Handle to the new Pact model. """ - raise NotImplementedError + return PactHandle( + lib.pactffi_new_pact( + consumer_name.encode("utf-8"), + provider_name.encode("utf-8"), + ), + ) def new_interaction(pact: PactHandle, description: str) -> InteractionHandle: @@ -4324,12 +4519,23 @@ def new_interaction(pact: PactHandle, description: str) -> InteractionHandle: [Rust `pactffi_new_interaction`](https://docs.rs/pact_ffi/0.4.9/pact_ffi/?search=pactffi_new_interaction) - * `description` - The interaction description. It needs to be unique for - each interaction. + Args: + pact: + Handle to the Pact model. - Returns a new `InteractionHandle`. + description: + The interaction description. It needs to be unique for each + interaction. + + Returns: + Handle to the new Interaction. """ - raise NotImplementedError + return InteractionHandle( + lib.pactffi_new_interaction( + pact._ref, + description.encode("utf-8"), + ), + ) def new_message_interaction(pact: PactHandle, description: str) -> InteractionHandle: @@ -4381,19 +4587,27 @@ def upon_receiving(interaction: InteractionHandle, description: str) -> bool: raise NotImplementedError -def given(interaction: InteractionHandle, description: str) -> bool: +def given(interaction: InteractionHandle, description: str) -> None: """ Adds a provider state to the Interaction. - Returns false if the interaction or Pact can't be modified (i.e. the mock - server for it has already started) - [Rust `pactffi_given`](https://docs.rs/pact_ffi/0.4.9/pact_ffi/?search=pactffi_given) - * `description` - The provider state description. It needs to be unique. + Args: + interaction: + Handle to the Interaction. + + description: + The provider state description. It needs to be unique. + + Raises: + RuntimeError: If the provider state could not be specified. """ - raise NotImplementedError + success: bool = lib.pactffi_given(interaction._ref, description.encode("utf-8")) + if not success: + msg = "The provider state could not be specified." + raise RuntimeError(msg) def interaction_test_name(interaction: InteractionHandle, test_name: str) -> int: @@ -4486,62 +4700,47 @@ def given_with_params( raise NotImplementedError -def with_request(interaction: InteractionHandle, method: str, path: str) -> bool: +def with_request(interaction: InteractionHandle, method: str, path: str) -> None: r""" Configures the request for the Interaction. - Returns false if the interaction or Pact can't be modified (i.e. the mock - server for it has already started) - [Rust `pactffi_with_request`](https://docs.rs/pact_ffi/0.4.9/pact_ffi/?search=pactffi_with_request) - * `method` - The request method. Defaults to GET. - * `path` - The request path. Defaults to `/`. - - To include matching rules for the path (only regex really makes sense to - use), include the matching rule JSON format with the value as a single JSON - document. I.e. - - ```c - const char* value = "{\"value\":\"/path/to/100\", \"pact:matcher:type\":\"regex\", \"regex\":\"\\/path\\/to\\/\\\\d+\"}"; - pactffi_with_request(handle, "GET", value); - ``` - See - [IntegrationJson.md](https://github.com/pact-foundation/pact-reference/blob/master/rust/pact_ffi/IntegrationJson.md) - """ # noqa: E501 - raise NotImplementedError - + Args: + interaction: + Handle to the Interaction. -def with_query_parameter( - interaction: InteractionHandle, - name: str, - index: int, - value: str, -) -> bool: - """ - Configures a query parameter for the Interaction. + method: + The request HTTP method. - Returns false if the interaction or Pact can't be modified (i.e. the mock - server for it has already started) + path: + The request path. - [Rust - `pactffi_with_query_parameter`](https://docs.rs/pact_ffi/0.4.9/pact_ffi/?search=pactffi_with_query_parameter) + This may be a simple string in which case it will be used as-is, or + it may be a [JSON matching + rule](https://github.com/pact-foundation/pact-reference/blob/libpact_ffi-v0.4.9/rust/pact_ffi/IntegrationJson.md) + which allows regex patterns. For examples: - * `name` - the query parameter name. - * `value` - the query parameter value. - * `index` - the index of the value (starts at 0). You can use this to create - a query parameter with multiple values + ```json + { + "value": "/path/to/100", + "pact:matcher:type": "regex", + "regex": "/path/to/\\d+" + } + ``` - **DEPRECATED:** Use `pactffi_with_query_parameter_v2`, which deals with - multiple values correctly + Raises: + RuntimeError: If the request could not be specified. """ - warnings.warn( - "This function is deprecated, use with_query_parameter_v2 instead", - DeprecationWarning, - stacklevel=2, + success: bool = lib.pactffi_with_request( + interaction._ref, + method.encode("utf-8"), + path.encode("utf-8"), ) - raise NotImplementedError + if not success: + msg = f"The request '{method} {path}' could not be specified for {interaction}." + raise RuntimeError(msg) def with_query_parameter_v2( @@ -4549,53 +4748,80 @@ def with_query_parameter_v2( name: str, index: int, value: str, -) -> bool: +) -> None: r""" Configures a query parameter for the Interaction. - Returns false if the interaction or Pact can't be modified (i.e. the mock - server for it has already started) - [Rust `pactffi_with_query_parameter_v2`](https://docs.rs/pact_ffi/0.4.9/pact_ffi/?search=pactffi_with_query_parameter_v2) - * `name` - the query parameter name. - * `value` - the query parameter value. Either a simple string or a JSON - document. - * `index` - the index of the value (starts at 0). You can use this to create - a query parameter with multiple values - To setup a query parameter with multiple values, you can either call this - function multiple times with a different index value, i.e. to create - `id=2&id=3` + function multiple times with a different index value: - ```c - pactffi_with_query_parameter_v2(handle, "id", 0, "2"); - pactffi_with_query_parameter_v2(handle, "id", 1, "3"); + ```python + with_query_parameter_v2(handle, "version", 0, "2") + with_query_parameter_v2(handle, "version", 0, "3") ``` Or you can call it once with a JSON value that contains multiple values: - ```c - const char* value = "{\"value\": [\"2\",\"3\"]}"; - pactffi_with_query_parameter_v2(handle, "id", 0, value); + ```python + with_query_parameter_v2( + handle, + "version", + 0, + json.dumps({ "value": ["2", "3"] }) + ) ``` - To include matching rules for the query parameter, include the matching rule - JSON format with the value as a single JSON document. I.e. - - ```c - const char* value = "{\"value\":\"2\", \"pact:matcher:type\":\"regex\", \"regex\":\"\\\\d+\"}"; - pactffi_with_query_parameter_v2(handle, "id", 0, value); + The JSON value can also contain a matcher, which will be used to match the + query parameter value. For example, a semver matcher might look like this: + + ```python + with_query_parameter_v2( + handle, + "version", + 0, + json.dumps({ + "value": "1.2.3", + "pact:matcher:type": "regex", + "regex": r"\d+\.\d+\.\d+", + }), + ) ``` - See [IntegrationJson.md](https://github.com/pact-foundation/pact-reference/blob/master/rust/pact_ffi/IntegrationJson.md) - # Safety + See [IntegrationJson.md](https://github.com/pact-foundation/pact-reference/blob/libpact_ffi-v0.4.9/rust/pact_ffi/IntegrationJson.md) - The name and value parameters must be valid pointers to NULL terminated strings. - ``` - """ # noqa: E501 - raise NotImplementedError + Args: + interaction: + Handle to the Interaction. + + name: + The query parameter name. + + index: + The index of the value (starts at 0). You can use this to create a + query parameter with multiple values. + + value: + The query parameter value. + + This may be a simple string in which case it will be used as-is, or + it may be a [JSON matching + rule](https://github.com/pact-foundation/pact-reference/blob/libpact_ffi-v0.4.9/rust/pact_ffi/IntegrationJson.md). + + Raises: + RuntimeError: If there was an error setting the query parameter. + """ + success: bool = lib.pactffi_with_query_parameter_v2( + interaction._ref, + name.encode("utf-8"), + index, + value.encode("utf-8"), + ) + if not success: + msg = f"Failed to add query parameter {name} to request {interaction}." + raise RuntimeError(msg) def with_specification(pact: PactHandle, version: PactSpecification) -> bool: @@ -4638,94 +4864,91 @@ def with_pact_metadata( raise NotImplementedError -def with_header( - interaction: InteractionHandle, - part: InteractionPart, - name: str, - index: int, - value: str, -) -> bool: - """ - Configures a header for the Interaction. - - Returns false if the interaction or Pact can't be modified (i.e. the mock - server for it has already started) - - [Rust - `pactffi_with_header`](https://docs.rs/pact_ffi/0.4.9/pact_ffi/?search=pactffi_with_header) - - * `part` - The part of the interaction to add the header to (Request or - Response). - * `name` - the header name. - * `value` - the header value. - * `index` - the index of the value (starts at 0). You can use this to create - a header with multiple values - - **DEPRECATED:** Use `pactffi_with_header_v2`, which deals with multiple - values correctly - """ - warnings.warn( - "This function is deprecated, use with_header_v2 instead", - DeprecationWarning, - stacklevel=2, - ) - raise NotImplementedError - - def with_header_v2( interaction: InteractionHandle, part: InteractionPart, name: str, index: int, value: str, -) -> bool: +) -> None: r""" Configures a header for the Interaction. - Returns false if the interaction or Pact can't be modified (i.e. the mock - server for it has already started) - [Rust `pactffi_with_header_v2`](https://docs.rs/pact_ffi/0.4.9/pact_ffi/?search=pactffi_with_header_v2) - * `part` - The part of the interaction to add the header to (Request or - Response). - * `name` - the header name. - * `value` - the header value. - * `index` - the index of the value (starts at 0). You can use this to create - a header with multiple values - - To setup a header with multiple values, you can either call this function - multiple times with a different index value, i.e. to create `x-id=2, 3` + To setup a header with multiple values, you can either call this + function multiple times with a different index value: - ```c - pactffi_with_header_v2(handle, InteractionPart::Request, "x-id", 0, "2"); - pactffi_with_header_v2(handle, InteractionPart::Request, "x-id", 1, "3"); + ```python + with_header_v2(handle, part, "Accept-Version", 0, "2") + with_header_v2(handle, part, "Accept-Version", 0, "3") ``` Or you can call it once with a JSON value that contains multiple values: - ```c - const char* value = "{\"value\": [\"2\",\"3\"]}"; - pactffi_with_header_v2(handle, InteractionPart::Request, "x-id", 0, value); + ```python + with_header_v2( + handle, + part, + "Accept-Version", + 0, + json.dumps({ "value": ["2", "3"] }) + ) ``` - To include matching rules for the header, include the matching rule JSON - format with the value as a single JSON document. I.e. - - ```c - const char* value = "{\"value\":\"2\", \"pact:matcher:type\":\"regex\", \"regex\":\"\\\\d+\"}"; - pactffi_with_header_v2(handle, InteractionPart::Request, "id", 0, value); + The JSON value can also contain a matcher, which will be used to match the + query parameter value. For example, a semver matcher might look like this: + + ```python + with_query_parameter_v2( + handle, + "Accept-Version", + 0, + json.dumps({ + "value": "1.2.3", + "pact:matcher:type": "regex", + "regex": r"\d+\.\d+\.\d+", + }), + ) ``` - See [IntegrationJson.md](https://github.com/pact-foundation/pact-reference/blob/master/rust/pact_ffi/IntegrationJson.md) + See [IntegrationJson.md](https://github.com/pact-foundation/pact-reference/blob/libpact_ffi-v0.4.9/rust/pact_ffi/IntegrationJson.md) - NOTE: If you pass in a form with multiple values, the index will be ignored. + Args: + interaction: + Handle to the Interaction. - # Safety + part: + The part of the interaction to add the header to (Request or + Response). - The name and value parameters must be valid pointers to NULL terminated strings. - """ # noqa: E501 - raise NotImplementedError + name: + The header name. This is case insensitive. + + index: + The index of the value (starts at 0). You can use this to create a + header with multiple values. + + value: + The header value. + + This may be a simple string in which case it will be used as-is, or + it may be a [JSON matching + rule](https://github.com/pact-foundation/pact-reference/blob/libpact_ffi-v0.4.9/rust/pact_ffi/IntegrationJson.md). + + Raises: + RuntimeError: If there was an error setting the header. + """ + success: bool = lib.pactffi_with_header_v2( + interaction._ref, + part.value, + name.encode("utf-8"), + index, + value.encode("utf-8"), + ) + if not success: + msg = f"The header {name!r} could not be specified for {interaction}." + raise RuntimeError(msg) def set_header( @@ -4733,90 +4956,115 @@ def set_header( part: InteractionPart, name: str, value: str, -) -> bool: +) -> None: """ Sets a header for the Interaction. - Returns false if the interaction or Pact can't be modified (i.e. the mock - server for it has already started). Note that this function will overwrite - any previously set header values. Also, this function will not process the - value in any way, so matching rules and generators can not be configured - with it. + Note that this function will overwrite any previously set header values. + Also, this function will not process the value in any way, so matching rules + and generators can not be configured with it. [Rust `pactffi_set_header`](https://docs.rs/pact_ffi/0.4.9/pact_ffi/?search=pactffi_set_header) If matching rules are required to be set, use `pactffi_with_header_v2`. - * `part` - The part of the interaction to add the header to (Request or - Response). - * `name` - the header name. - * `value` - the header value. + Args: + interaction: + Handle to the Interaction. - # Safety The name and value parameters must be valid pointers to NULL - terminated strings. + part: + The part of the interaction to add the header to (Request or + Response). + + name: + The header name. This is case insensitive. + + value: + The header value. This is handled as-is, with no processing. + + Raises: + RuntimeError: If the header could not be set. """ - raise NotImplementedError + success: bool = lib.pactffi_set_header( + interaction._ref, + part.value, + name.encode("utf-8"), + value.encode("utf-8"), + ) + if not success: + msg = f"The header {name!r} could not be set for {interaction}." + raise RuntimeError(msg) -def response_status(interaction: InteractionHandle, status: int) -> bool: +def response_status(interaction: InteractionHandle, status: int) -> None: """ Configures the response for the Interaction. - Returns false if the interaction or Pact can't be modified (i.e. the mock - server for it has already started) - [Rust `pactffi_response_status`](https://docs.rs/pact_ffi/0.4.9/pact_ffi/?search=pactffi_response_status) - * `status` - the response status. Defaults to 200. + Args: + interaction: + Handle to the Interaction. + + status: + The response status. Defaults to 200. + + Raises: + RuntimeError: If the response status could not be set. """ - raise NotImplementedError + success: bool = lib.pactffi_response_status(interaction._ref, status) + if not success: + msg = f"The response status {status} could not be set for {interaction}." + raise RuntimeError(msg) def with_body( interaction: InteractionHandle, part: InteractionPart, content_type: str, - body: str, -) -> bool: + body: str | None, +) -> None: """ Adds the body for the interaction. - Returns false if the interaction or Pact can't be modified (i.e. the mock - server for it has already started) - [Rust `pactffi_with_body`](https://docs.rs/pact_ffi/0.4.9/pact_ffi/?search=pactffi_with_body) - * `part` - The part of the interaction to add the body to (Request or - Response). - * `content_type` - The content type of the body. Defaults to `text/plain`. - Will be ignored if a content type header is already set. - * `body` - The body contents. For JSON payloads, matching rules can be - embedded in the body. See - [IntegrationJson.md](https://github.com/pact-foundation/pact-reference/blob/master/rust/pact_ffi/IntegrationJson.md) - For HTTP and async message interactions, this will overwrite the body. With asynchronous messages, the part parameter will be ignored. With synchronous messages, the request contents will be overwritten, while a new response will be appended to the message. - # Safety + Args: + interaction: + Handle to the Interaction. - The interaction contents and content type must either be NULL pointers, or - point to valid UTF-8 encoded NULL-terminated strings. Otherwise, behaviour - is undefined. + part: + The part of the interaction to add the body to (Request or + Response). - # Error Handling + content_type: + The content type of the body. Will be ignored if a content type + header is already set. + + body: + The body contents. For JSON payloads, matching rules can be embedded + in the body. See [IntegrationJson.md](https://github.com/pact-foundation/pact-reference/blob/libpact_ffi-v0.4.9/rust/pact_ffi/IntegrationJson.md). - If the contents is a NULL pointer, it will set the body contents as null. If - the content type is a null pointer, or can't be parsed, it will set the - content type as TEXT. Returns false if the interaction or Pact can't be - modified (i.e. the mock server for it has already started) or an error has - occurred. + Raises: + RuntimeError: If the body could not be specified. """ - raise NotImplementedError + success: bool = lib.pactffi_with_body( + interaction._ref, + part.value, + content_type.encode("utf-8"), + body.encode("utf-8") if body is not None else None, + ) + if not success: + msg = f"Unable to set body for {interaction}." + raise RuntimeError(msg) def with_binary_file( @@ -5271,22 +5519,24 @@ def new_async_message(pact: PactHandle, description: str) -> MessageHandle: raise NotImplementedError -def free_pact_handle(pact: PactHandle) -> int: +def free_pact_handle(pact: PactHandle) -> None: """ Delete a Pact handle and free the resources used by it. [Rust `pactffi_free_pact_handle`](https://docs.rs/pact_ffi/0.4.9/pact_ffi/?search=pactffi_free_pact_handle) - # Error Handling - - On failure, this function will return a positive integer value. - - * `1` - The handle is not valid or does not refer to a valid Pact. Could be - that it was previously deleted. - - """ - raise NotImplementedError + Raises: + RuntimeError: If the handle could not be freed. + """ + ret: int = lib.pactffi_free_pact_handle(pact._ref) + if ret == 0: + return + if ret == 1: + msg = f"{pact} is not valid or does not refer to a valid Pact." + else: + msg = f"There was an unknown error freeing {pact}." + raise RuntimeError(msg) def free_message_pact_handle(pact: MessagePactHandle) -> int: diff --git a/pact/v3/pact.py b/pact/v3/pact.py new file mode 100644 index 000000000..92e6d7cfa --- /dev/null +++ b/pact/v3/pact.py @@ -0,0 +1,788 @@ +""" +Pact between a consumer and a provider. + +This module defines the classes that are used to define a Pact between a +consumer and a provider. It defines the interactions between the two parties, +and provides the functionality to verify that the interactions are satisfied. + +For the roles of consumer and provider, see the documentation for the +`pact.v3.service` module. +""" + +from __future__ import annotations + +from collections import defaultdict +from pathlib import Path +from typing import TYPE_CHECKING, Iterable, Literal, Set + +from yarl import URL + +import pact.v3.ffi + +if TYPE_CHECKING: + from types import TracebackType + + try: + from typing import Self + except ImportError: + from typing_extensions import Self + + +class Interaction: + """ + Interaction between a consumer and a provider. + + This class defines an interaction between a consumer and a provider. It + defines a specific request that the consumer makes to the provider, and the + response that the provider should return. + + A set of interactions between a consumer and a provider is called a Pact. + """ + + def __init__(self, pact_handle: pact.v3.ffi.PactHandle, description: str) -> None: + """ + Initialise a new Interaction. + + This function should not be called directly. Instead, an Interaction + should be created using the + [`upon_receiving(...)`][pact.v3.Pact.upon_receiving] method of a + [`Pact`][pact.v3.Pact] instance. + """ + self._handle = pact.v3.ffi.new_interaction(pact_handle, description) + self._is_request = True + self._request_indices: dict[ + tuple[pact.v3.ffi.InteractionPart, str], + int, + ] = defaultdict(int) + self._parameter_indices: dict[str, int] = defaultdict(int) + + def __str__(self) -> str: + """ + Informal string representation of the Interaction. + """ + raise NotImplementedError + + def __repr__(self) -> str: + """ + Information-rich string representation of the Interaction. + """ + raise NotImplementedError + + def given(self, state: str) -> Interaction: + """ + Set the provider state. + + This is the state that the provider should be in when the Interaction is + executed. + + Args: + state: + Provider state for the Interaction. + """ + pact.v3.ffi.given(self._handle, state) + return self + + def with_request(self, method: str, path: str) -> Interaction: + """ + Set the request. + + This is the request that the consumer will make to the provider. + + Args: + method: + HTTP method for the request. + path: + Path for the request. + """ + pact.v3.ffi.with_request(self._handle, method, path) + return self + + def _interaction_part( + self, + part: Literal["Request", "Response", None], + ) -> pact.v3.ffi.InteractionPart: + """ + Convert the input into an InteractionPart. + """ + part = part or ("Request" if self._is_request else "Response") + if part == "Request": + return pact.v3.ffi.InteractionPart.REQUEST + if part == "Response": + return pact.v3.ffi.InteractionPart.RESPONSE + msg = f"Invalid part: {part}" + raise ValueError(msg) + + def with_header( + self, + name: str, + value: str, + part: Literal["Request", "Response"] | None = None, + ) -> Interaction: + r""" + Add a header to the request. + + # Repeated Headers + + If the same header has multiple values ([see RFC9110 + §5.2](https://www.rfc-editor.org/rfc/rfc9110.html#section-5.2)), then + the same header must be specified multiple times with _order being + preserved_. For example + + ```python + ( + pact.upon_receiving("a request") + .with_header("X-Foo", "bar") + .with_header("X-Foo", "baz") + ) + ``` + + will expect a request with the following headers: + + ```http + X-Foo: bar + X-Foo: baz + # Or, equivalently: + X-Foo: bar, baz + ``` + + Note that repeated headers are _case insensitive_ in accordance with + [RFC 9110 + §5.1](https://www.rfc-editor.org/rfc/rfc9110.html#section-5.1). + + # JSON Matching + + Pact's matching rules are defined in the [upstream + documentation](https://github.com/pact-foundation/pact-reference/blob/libpact_ffi-v0.4.9/rust/pact_ffi/IntegrationJson.md) + and support a wide range of matching rules. These can be specified + using a JSON object as a strong using `json.dumps(...)`. For example, + the above rule whereby the `X-Foo` header has multiple values can be + specified as: + + ```python + ( + pact.upon_receiving("a request") + .with_header( + "X-Foo", + json.dumps({ + "value": ["bar", "baz"], + }), + ) + ) + ``` + + It is also possible to have a more complicated Regex pattern for the + header. For example, a pattern for an `Accept-Version` header might be + specified as: + + ```python + ( + pact.upon_receiving("a request") + .with_header( + "Accept-Version", + json.dumps({ + "value": "1.2.3", + "pact:matcher:type": "regex", + "regex": r"\d+\.\d+\.\d+", + }), + ) + ) + ``` + + If the value of the header is expected to be a JSON object and clashes + with the above syntax, then it is recommended to make use of the + [`set_header(...)`][pact.v3.Interaction.set_header] method instead. + + Args: + name: + Name of the header. + + value: + Value of the header. + + part: + Whether the header should be added to the request or the + response. If `None`, then the function intelligently determines + whether the header should be added to the request or the + response, based on whether the + [`will_respond_with(...)`][pact.v3.Interaction.will_respond_with] + method has been called. + """ + interaction_part = self._interaction_part(part) + name_lower = name.lower() + index = self._request_indices[(interaction_part, name_lower)] + self._request_indices[(interaction_part, name_lower)] += 1 + pact.v3.ffi.with_header_v2( + self._handle, + interaction_part, + name, + index, + value, + ) + return self + + def with_headers( + self, + headers: dict[str, str] | Iterable[tuple[str, str]], + part: Literal["Request", "Response"] | None = None, + ) -> Interaction: + """ + Add multiple headers to the request. + + Note that due to the requirement of Python dictionaries to + have unique keys, it is _not_ possible to specify a header multiple + times to create a multi-valued header. Instead, you may: + + - Use an alternative data structure. Any iterable of key-value pairs + is accepted, including a list of tuples, a list of lists, or a + dictionary view. + + - Make multiple calls to + [`with_header(...)`][pact.v3.Interaction.with_header] or + [`with_headers(...)`][pact.v3.Interaction.with_headers]. + + - Specify the multiple values in a JSON object of the form: + + ```python + ( + pact.upon_receiving("a request") + .with_headers({ + "X-Foo": json.dumps({ + "value": ["bar", "baz"], + }), + ) + ) + ``` + + See [`with_header(...)`][pact.v3.Interaction.with_header] for more + information. + + Args: + headers: + Headers to add to the request. + + part: + Whether the header should be added to the request or the + response. If `None`, then the function intelligently determines + whether the header should be added to the request or the + response, based on whether the + [`will_respond_with(...)`][pact.v3.Interaction.will_respond_with] + method has been called. + """ + if isinstance(headers, dict): + headers = headers.items() + for name, value in headers: + self.with_header(name, value, part) + return self + + def set_header( + self, + name: str, + value: str, + part: Literal["Request", "Response"] | None = None, + ) -> Interaction: + r""" + Add a header to the request. + + Unlike [`with_header(...)`][pact.v3.Interaction.with_header], this + function does no additional processing of the header value. This is + useful for headers that contain a JSON object. + + Args: + name: + Name of the header. + + value: + Value of the header. + + part: + Whether the header should be added to the request or the + response. If `None`, then the function intelligently determines + whether the header should be added to the request or the + response, based on whether the + [`will_respond_with(...)`][pact.v3.Interaction.will_respond_with] + method has been called. + """ + pact.v3.ffi.set_header( + self._handle, + self._interaction_part(part), + name, + value, + ) + return self + + def set_headers( + self, + headers: dict[str, str] | Iterable[tuple[str, str]], + part: Literal["Request", "Response"] | None = None, + ) -> Interaction: + """ + Add multiple headers to the request. + + This function intelligently determines whether the header should be + added to the request or the response, based on whether the + [`will_respond_with(...)`][pact.v3.Interaction.will_respond_with] method + has been called. + + See [`set_header(...)`][pact.v3.Interaction.set_header] for more + information. + + Args: + headers: + Headers to add to the request. + + part: + Whether the headers should be added to the request or the + response. If `None`, then the function intelligently determines + whether the header should be added to the request or the + response, based on whether the + [`will_respond_with(...)`][pact.v3.Interaction.will_respond_with] + method has been called. + """ + if isinstance(headers, dict): + headers = headers.items() + for name, value in headers: + self.set_header(name, value, part) + return self + + def with_query_parameter(self, name: str, value: str) -> Interaction: + r""" + Add a query to the request. + + This is the query parameter(s) that the consumer will send to the + provider. + + If the same parameter can support multiple values, then the same + parameter can be specified multiple times: + + ```python + ( + pact.upon_receiving("a request") + .with_query_parameter("name", "John") + .with_query_parameter("name", "Mary") + ) + ``` + + The above can equivalently be specified as: + + ```python + ( + pact.upon_receiving("a request") + .with_query_parameter( + "name", + json.dumps({ + "value": ["John", "Mary"], + }), + ) + ) + ``` + + It is also possible to have a more complicated Regex pattern for the + paramater. For example, a pattern for an `version` parameter might be + specified as: + + ```python + ( + pact.upon_receiving("a request") + .with_query_parameter( + "version", + json.dumps({ + "value": "1.2.3", + "pact:matcher:type": "regex", + "regex": r"\d+\.\d+\.\d+", + }), + ) + ) + ``` + + For more information on the format of the JSON object, see the [upstream + documentation](https://github.com/pact-foundation/pact-reference/blob/libpact_ffi-v0.4.9/rust/pact_ffi/IntegrationJson.md). + + Args: + name: + Name of the query parameter. + + value: + Value of the query parameter. + """ + index = self._parameter_indices[name] + self._parameter_indices[name] += 1 + pact.v3.ffi.with_query_parameter_v2( + self._handle, + name, + index, + value, + ) + return self + + def with_query_parameters( + self, + parameters: dict[str, str] | Iterable[tuple[str, str]], + ) -> Interaction: + """ + Add multiple query parameters to the request. + + See [`with_query_parameter(...)`][pact.v3.Interaction.with_query_parameter] + for more information. + + Args: + parameters: + Query parameters to add to the request. + """ + if isinstance(parameters, dict): + parameters = parameters.items() + for name, value in parameters: + self.with_query_parameter(name, value) + return self + + def with_body( + self, + body: str | None = None, + content_type: str = "text/plain", + part: Literal["Request", "Response"] | None = None, + ) -> Interaction: + """ + Set the body of the request. + + Args: + body: + Body of the request. If this is `None`, then the body is + empty. + + content_type: + Content type of the body. This is ignored if the `Content-Type` + header has already been set. + + part: + Whether the body should be added to the request or the response. + If `None`, then the function intelligently determines whether + the body should be added to the request or the response, based + on whether the + [`will_respond_with(...)`][pact.v3.Interaction.will_respond_with] + method has been called. + """ + pact.v3.ffi.with_body( + self._handle, + self._interaction_part(part), + content_type, + body, + ) + return self + + def will_respond_with(self, status: int) -> Interaction: + """ + Set the response status. + + Ideally, this function is called once all of the request information has + been set. This allows functions such as + [`with_header(...)`][pact.v3.Interaction.with_header] to intelligently + determine whether this is a request or response header. + + Alternatively, the `part` argument can be used to explicitly specify + whether the header should be added to the request or the response. + + Args: + status: + Status for the response. + """ + pact.v3.ffi.response_status(self._handle, status) + self._is_request = False + return self + + +class Pact: + """ + A Pact between a consumer and a provider. + + This class defines a Pact between a consumer and a provider. It is the + central class in Pact's framework, and is responsible for defining the + interactions between the two parties. + + One Pact instance should be created for each provider that a consumer + interacts with. This instance can then be used to define the interactions + between the two parties. + """ + + def __init__( + self, + consumer: str, + provider: str, + ) -> None: + """ + Initialise a new Pact. + + Args: + consumer: + Name of the consumer. + + provider: + Name of the provider. + """ + if not consumer: + msg = "Consumer name cannot be empty." + raise ValueError(msg) + if not provider: + msg = "Provider name cannot be empty." + raise ValueError(msg) + + self._consumer = consumer + self._provider = provider + self._interactions: Set[Interaction] = set() + self._handle: pact.v3.ffi.PactHandle = pact.v3.ffi.new_pact( + consumer, + provider, + ) + + def __str__(self) -> str: + """ + Informal string representation of the Pact. + """ + return f"{self.consumer} -> {self.provider}" + + def __repr__(self) -> str: + """ + Information-rich string representation of the Pact. + """ + return f"Pact({self})" + + @property + def consumer(self) -> str: + """ + Consumer name. + """ + return self._consumer + + @property + def provider(self) -> str: + """ + Provider name. + """ + return self._provider + + def upon_receiving(self, description: str) -> Interaction: + """ + Create a new Interaction. + + This is an alias for [`interaction(...)`][pact.v3.Pact.interaction]. + + Args: + description: + Description of the interaction. This must be unique + within the Pact. + """ + return Interaction(self._handle, description) + + def serve( + self, + addr: str = "localhost", + port: int = 0, + transport: str = "http", + transport_config: str | None = None, + ) -> PactServer: + """ + Return a mock server for the Pact. + + This function configures a mock server for the Pact. The mock server + is then started when the Pact is entered into a `with` block: + + ```python + pact = Pact("consumer", "provider") + with pact.serve() as srv: + ... + ``` + + Args: + addr: + Address to bind the mock server to. Defaults to `localhost`. + + port: + Port to bind the mock server to. Defaults to `0`, which will + select a random port. + + transport: + Transport to use for the mock server. Defaults to `HTTP`. + + transport_config: + Configuration for the transport. This is specific to the + transport being used and should be a JSON string. + """ + return PactServer( + self._handle, + addr, + port, + transport, + transport_config, + ) + + +class PactServer: + """ + Pact Server. + + This class handles the lifecycle of the Pact mock server. It is responsible + for starting the mock server when the Pact is entered into a `with` block, + and stopping the mock server when the block is exited. + """ + + def __init__( # noqa: PLR0913 + self, + pact_handle: pact.v3.ffi.PactHandle, + host: str = "localhost", + port: int = 0, + transport: str = "HTTP", + transport_config: str | None = None, + ) -> None: + """ + Initialise a new Pact Server. + + This function should not be called directly. Instead, a Pact Server + should be created using the + [`serve(...)`][pact.v3.Pact.serve] method of a + [`Pact`][pact.v3.Pact] instance: + + ```python + pact = Pact("consumer", "provider") + with pact.serve(...) as srv: + ... + ``` + + Args: + pact_handle: + Handle for the Pact. + + host: + Hostname of IP for the mock server. + + port: + Port to bind the mock server to. The value of `0` will select a + random available port. + + transport: + Transport to use for the mock server. + + transport_config: + Configuration for the transport. This is specific to the + transport being used and should be a JSON string. + """ + self._host = host + self._port = port + self._transport = transport + self._transport_config = transport_config + self._pact_handle = pact_handle + self._handle: None | pact.v3.ffi.PactServerHandle = None + + @property + def port(self) -> int: + """ + Port on which the server is running. + + If the server is not running, then this will be `0`. + """ + # Unlike the other properties, this value might be different to what was + # passed in to the constructor as the server can be started on a random + # port. + return self._handle.port if self._handle else 0 + + @property + def host(self) -> str: + """ + Address to which the server is bound. + """ + return self._host + + @property + def transport(self) -> str: + """ + Transport method. + """ + return self._transport + + @property + def url(self) -> URL: + """ + Base URL for the server. + """ + return URL(str(self)) + + def __str__(self) -> str: + """ + URL for the server. + """ + return f"{self.transport}://{self.host}:{self.port}" + + def __repr__(self) -> str: + """ + Information-rich string representation of the Pact Server. + """ + return f"PactServer({self})" + + def __enter__(self) -> Self: + """ + Launch the server. + + Once the server is running, it is generally no possible to make + modifications to the underlying Pact. + """ + self._handle = pact.v3.ffi.create_mock_server_for_transport( + self._pact_handle, + self._host, + self._port, + self._transport, + self._transport_config, + ) + + return self + + def __exit__( + self, + _exc_type: type[BaseException] | None, + _exc_value: BaseException | None, + _traceback: TracebackType | None, + ) -> None: + """ + Stop the server. + """ + if self._handle: + self._handle = None + + def __truediv__(self, other: str) -> URL: + """ + URL for the server. + """ + if isinstance(other, str): + return self.url / other + return NotImplemented + + def write_file( + self, + directory: str | Path | None = None, + *, + overwrite: bool = False, + ) -> None: + """ + Write out the pact to a file. + + Args: + directory: + The directory to write the pact to. If the directory does not + exist, it will be created. The filename will be + automatically generated from the underlying Pact. + + overwrite: + Whether or not to overwrite the file if it already exists. + """ + if not self._handle: + msg = "The server is not running." + raise RuntimeError(msg) + + directory = Path(directory) if directory else Path.cwd() + if not directory.exists(): + directory.mkdir(parents=True) + elif not directory.is_dir(): + msg = f"{directory} is not a directory" + raise ValueError(msg) + + pact.v3.ffi.write_pact_file( + self._handle, + str(directory), + overwrite=overwrite, + ) diff --git a/pyproject.toml b/pyproject.toml index 290ae4e9b..0d7bc0b17 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ dependencies = [ "psutil ~= 5.9", "requests ~= 2.31", "six ~= 1.16", + "typing-extensions ~= 4.8 ; python_version < '3.10'", "uvicorn ~= 0.13", ] @@ -53,14 +54,16 @@ types = [ "types-requests ~= 2.31", ] test = [ - "coverage[toml] ~= 7.3", - "flask[async] ~= 2.3", - "httpx ~= 0.24", - "mock ~= 5.1", - "pytest ~= 7.4", - "pytest-cov ~= 4.1", - "testcontainers ~= 3.7", - "yarl ~= 1.9", + "aiohttp[speedups] ~= 3.8", + "coverage[toml] ~= 7.3", + "flask[async] ~= 2.3", + "httpx ~= 0.24", + "mock ~= 5.1", + "pytest ~= 7.4", + "pytest-asyncio ~= 0.21", + "pytest-cov ~= 4.1", + "testcontainers ~= 3.7", + "yarl ~= 1.9", ] dev = [ "pact-python[types]", @@ -132,9 +135,10 @@ addopts = ["--import-mode=importlib"] [tool.coverage.report] exclude_lines = [ - "if __name__ == .__main__.:", - "if TYPE_CHECKING:", - "pragma: no cover", + "if __name__ == .__main__.:", # Ignore non-runnable code + "if TYPE_CHECKING:", # Ignore typing + "raise NotImplementedError", # Ignore defensive assertions + "@(abc\\.)?abstractmethod", # Ignore abstract methods ] ################################################################################ diff --git a/tests/v3/conftest.py b/tests/v3/conftest.py new file mode 100644 index 000000000..e76e3be75 --- /dev/null +++ b/tests/v3/conftest.py @@ -0,0 +1,18 @@ +""" +PyTest configuration file for the v3 API tests. + +This file is loaded automatically by PyTest when running the tests in this +directory. +""" + +import pytest + + +@pytest.fixture(scope="session", autouse=True) +def _setup_pact_logging() -> None: + """ + Set up logging for the pact package. + """ + from pact.v3 import ffi + + ffi.log_to_stderr(ffi.LevelFilter.DEBUG) diff --git a/tests/v3/test_pact.py b/tests/v3/test_pact.py new file mode 100644 index 000000000..92049b6a6 --- /dev/null +++ b/tests/v3/test_pact.py @@ -0,0 +1,431 @@ +""" +Pact unit tests. +""" + +from __future__ import annotations + +import json + +import aiohttp +import pytest +from pact.v3 import Pact + + +@pytest.fixture() +def pact() -> Pact: + """ + Fixture for a Pact instance. + """ + return Pact("consumer", "provider") + + +def test_init(pact: Pact) -> None: + assert pact.consumer == "consumer" + assert pact.provider == "provider" + + +def test_empty_consumer() -> None: + with pytest.raises(ValueError, match="Consumer name cannot be empty"): + Pact("", "provider") + + +def test_empty_provider() -> None: + with pytest.raises(ValueError, match="Provider name cannot be empty"): + Pact("consumer", "") + + +def test_serve(pact: Pact) -> None: + with pact.serve() as srv: + assert srv.port > 0 + assert srv.host == "localhost" + assert str(srv).startswith("http://localhost") + assert srv.url.scheme == "http" + assert srv.url.host == "localhost" + assert srv.url.path == "/" + assert srv / "foo" == srv.url / "foo" + assert str(srv / "foo") == f"http://localhost:{srv.port}/foo" + + +@pytest.mark.parametrize( + "method", + [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "HEAD", + "OPTIONS", + "TRACE", + "CONNECT", + ], +) +@pytest.mark.asyncio() +async def test_basic_request_method(pact: Pact, method: str) -> None: + ( + pact.upon_receiving(f"a basic {method} request") + .with_request(method, "/") + .will_respond_with(200) + ) + with pact.serve() as srv: + async with aiohttp.ClientSession(srv.url) as session: + async with session.request(method, "/") as resp: + assert resp.status == 200 + + +@pytest.mark.parametrize( + "status", + list(range(200, 600, 13)), +) +@pytest.mark.asyncio() +async def test_basic_response_status(pact: Pact, status: int) -> None: + ( + pact.upon_receiving(f"a basic request producing status {status}") + .with_request("GET", "/") + .will_respond_with(status) + ) + with pact.serve() as srv: + async with aiohttp.ClientSession(srv.url) as session: + async with session.request("GET", "/") as resp: + assert resp.status == status + + +@pytest.mark.parametrize( + "headers", + [ + [("X-Test", "true")], + [("X-Foo", "true"), ("X-Bar", "true")], + [("X-Test", "1"), ("X-Test", "2")], + ], +) +@pytest.mark.asyncio() +async def test_with_header_request( + pact: Pact, + headers: list[tuple[str, str]], +) -> None: + ( + pact.upon_receiving("a basic request with a header") + .with_request("GET", "/") + .with_headers(headers) + .will_respond_with(200) + ) + with pact.serve() as srv: + async with aiohttp.ClientSession(srv.url) as session: + async with session.request( + "GET", + "/", + headers=headers, + ) as resp: + assert resp.status == 200 + + +@pytest.mark.parametrize( + "headers", + [ + [("X-Test", "true")], + [("X-Foo", "true"), ("X-Bar", "true")], + [("X-Test", "1"), ("X-Test", "2")], + ], +) +@pytest.mark.asyncio() +async def test_with_header_response( + pact: Pact, + headers: list[tuple[str, str]], +) -> None: + ( + pact.upon_receiving("a basic request with a header") + .with_request("GET", "/") + .will_respond_with(200) + .with_headers(headers) + ) + with pact.serve() as srv: + async with aiohttp.ClientSession(srv.url) as session: + async with session.request( + "GET", + "/", + ) as resp: + assert resp.status == 200 + response_headers = [(h.lower(), v) for h, v in resp.headers.items()] + for header, value in headers: + assert (header.lower(), value) in response_headers + + +@pytest.mark.asyncio() +async def test_with_header_dict(pact: Pact) -> None: + ( + pact.upon_receiving("a basic request with a headers from a dict") + .with_request("GET", "/") + .with_headers({"X-Test": "true", "X-Foo": "bar"}) + .will_respond_with(200) + ) + with pact.serve() as srv: + async with aiohttp.ClientSession(srv.url) as session: + async with session.request( + "GET", + "/", + headers={"X-Test": "true", "X-Foo": "bar"}, + ) as resp: + assert resp.status == 200 + + +@pytest.mark.parametrize( + "headers", + [ + [("X-Test", "true")], + [("X-Foo", "true"), ("X-Bar", "true")], + ], +) +@pytest.mark.asyncio() +async def test_set_header_request( + pact: Pact, + headers: list[tuple[str, str]], +) -> None: + ( + pact.upon_receiving("a basic request with a header") + .with_request("GET", "/") + .set_headers(headers) + .will_respond_with(200) + ) + with pact.serve() as srv: + async with aiohttp.ClientSession(srv.url) as session: + async with session.request( + "GET", + "/", + headers=headers, + ) as resp: + assert resp.status == 200 + + +@pytest.mark.asyncio() +async def test_set_header_request_repeat( + pact: Pact, +) -> None: + headers = [("X-Test", "1"), ("X-Test", "2")] + ( + pact.upon_receiving("a basic request with a header") + .with_request("GET", "/") + # As set_headers makes no additional processing, the last header will be + # the one that is used. + .set_headers(headers) + .will_respond_with(200) + ) + with pact.serve() as srv: + async with aiohttp.ClientSession(srv.url) as session: + async with session.request( + "GET", + "/", + headers=headers, + ) as resp: + assert resp.status == 500 + + +@pytest.mark.parametrize( + "headers", + [ + [("X-Test", "true")], + [("X-Foo", "true"), ("X-Bar", "true")], + ], +) +@pytest.mark.asyncio() +async def test_set_header_response( + pact: Pact, + headers: list[tuple[str, str]], +) -> None: + ( + pact.upon_receiving("a basic request with a header") + .with_request("GET", "/") + .will_respond_with(200) + .set_headers(headers) + ) + with pact.serve() as srv: + async with aiohttp.ClientSession(srv.url) as session: + async with session.request( + "GET", + "/", + ) as resp: + assert resp.status == 200 + response_headers = [(h.lower(), v) for h, v in resp.headers.items()] + for header, value in headers: + assert (header.lower(), value) in response_headers + + +@pytest.mark.asyncio() +async def test_set_header_response_repeat( + pact: Pact, +) -> None: + headers = [("X-Test", "1"), ("X-Test", "2")] + ( + pact.upon_receiving("a basic request with a header") + .with_request("GET", "/") + .will_respond_with(200) + # As set_headers makes no additional processing, the last header will be + # the one that is used. + .set_headers(headers) + ) + with pact.serve() as srv: + async with aiohttp.ClientSession(srv.url) as session: + async with session.request( + "GET", + "/", + headers=headers, + ) as resp: + assert resp.status == 200 + response_headers = [(h.lower(), v) for h, v in resp.headers.items()] + assert ("x-test", "2") in response_headers + assert ("x-test", "1") not in response_headers + + +@pytest.mark.asyncio() +async def test_set_header_dict(pact: Pact) -> None: + ( + pact.upon_receiving("a basic request with a headers from a dict") + .with_request("GET", "/") + .set_headers({"X-Test": "true", "X-Foo": "bar"}) + .will_respond_with(200) + ) + with pact.serve() as srv: + async with aiohttp.ClientSession(srv.url) as session: + async with session.request( + "GET", + "/", + headers={"X-Test": "true", "X-Foo": "bar"}, + ) as resp: + assert resp.status == 200 + + +@pytest.mark.parametrize( + "query", + [ + [("test", "true")], + [("foo", "true"), ("bar", "true")], + [("test", "1"), ("test", "2")], + ], +) +@pytest.mark.asyncio() +async def test_with_query_parameter_request( + pact: Pact, + query: list[tuple[str, str]], +) -> None: + ( + pact.upon_receiving("a basic request with a query parameter") + .with_request("GET", "/") + .with_query_parameters(query) + .will_respond_with(200) + ) + with pact.serve() as srv: + async with aiohttp.ClientSession(srv.url) as session: + url = srv.url.with_query(query) + async with session.request( + "GET", + url.path_qs, + ) as resp: + assert resp.status == 200 + + +@pytest.mark.asyncio() +async def test_with_query_parameter_dict(pact: Pact) -> None: + ( + pact.upon_receiving("a basic request with a query parameter from a dict") + .with_request("GET", "/") + .with_query_parameters({"test": "true", "foo": "bar"}) + .will_respond_with(200) + ) + with pact.serve() as srv: + async with aiohttp.ClientSession(srv.url) as session: + url = srv.url.with_query({"test": "true", "foo": "bar"}) + async with session.request( + "GET", + url.path_qs, + ) as resp: + assert resp.status == 200 + + +@pytest.mark.parametrize( + "method", + ["GET", "POST", "PUT"], +) +@pytest.mark.asyncio() +async def test_with_body_request(pact: Pact, method: str) -> None: + ( + pact.upon_receiving(f"a basic {method} request with a body") + .with_request(method, "/") + .with_body(json.dumps({"test": True}), "application/json") + .will_respond_with(200) + ) + with pact.serve() as srv: + async with aiohttp.ClientSession(srv.url) as session: + async with session.request( + method, + "/", + json={"test": True}, + ) as resp: + assert resp.status == 200 + + +@pytest.mark.parametrize( + "method", + ["GET", "POST", "PUT"], +) +@pytest.mark.asyncio() +async def test_with_body_response(pact: Pact, method: str) -> None: + ( + pact.upon_receiving( + f"a basic {method} request expecting a response with a body", + ) + .with_request(method, "/") + .will_respond_with(200) + .with_body(json.dumps({"test": True}), "application/json") + ) + with pact.serve() as srv: + async with aiohttp.ClientSession(srv.url) as session: + async with session.request( + method, + "/", + json={"test": True}, + ) as resp: + assert resp.status == 200 + assert await resp.json() == {"test": True} + + +@pytest.mark.asyncio() +async def test_with_body_explicit(pact: Pact) -> None: + ( + pact.upon_receiving("") + .with_request("GET", "/") + .will_respond_with(200) + .with_body(json.dumps({"request": True}), "application/json", "Request") + .with_body(json.dumps({"response": True}), "application/json", "Response") + ) + with pact.serve() as srv: + async with aiohttp.ClientSession(srv.url) as session: + async with session.request( + "GET", + "/", + json={"request": True}, + ) as resp: + assert resp.status == 200 + assert await resp.json() == {"response": True} + + +def test_with_body_invalid(pact: Pact) -> None: + with pytest.raises(ValueError, match="Invalid part: Invalid"): + ( + pact.upon_receiving("") + .with_request("GET", "/") + .will_respond_with(200) + .with_body(json.dumps({"request": True}), "application/json", "Invalid") + ) + + +@pytest.mark.asyncio() +async def test_given(pact: Pact) -> None: + ( + pact.upon_receiving("a basic request given state 1") + .given("state 1") + .with_request("GET", "/") + .will_respond_with(200) + ) + with pact.serve() as srv: + async with aiohttp.ClientSession(srv.url) as session: + async with session.request("GET", "/") as resp: + assert resp.status == 200