diff --git a/.cirrus.yml b/.cirrus.yml index 0ad7b2988..37de20efa 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -2,50 +2,52 @@ BUILD_TEST_TASK_TEMPLATE: &BUILD_TEST_TASK_TEMPLATE arch_check_script: - uname -am test_script: + - script/install_plugins.sh - python --version - python -m pip install --upgrade pip - python -m pip install -r requirements_dev.txt - - python -m flake8 + - python -m flake8 --show-source --exclude examples/area_calculator/area_calculator_pb2.py,examples/area_calculator/area_calculator_pb2_grpc.py,.git,__pycache__,build,dist,.tox - python -m pydocstyle pact - python -m tox -e test # - make examples -linux_arm64_task: +linux_arm64_task: + only_if: $CIRRUS_CHANGE_TITLE !=~ 'ci\(gha\).*' env: + # PACT_FFI_PATH: .tox/test/lib/python$VERSION/site-packages/pact/bin matrix: - # - IMAGE: python:3.6-slim # This works locally, with cirrus run, but fails in CI - - IMAGE: python:3.7-slim - - IMAGE: python:3.8-slim - - IMAGE: python:3.9-slim - - IMAGE: python:3.10-slim + # - VERSION: 3.6 + - VERSION: 3.7 + - VERSION: 3.8 + - VERSION: 3.9 + - VERSION: 3.10 + - VERSION: 3.11 arm_container: - image: $IMAGE + image: python:$VERSION-slim install_script: - - apt update --yes && apt install --yes gcc make + - apt update --yes && apt install --yes gcc make curl << : *BUILD_TEST_TASK_TEMPLATE -macosx_arm64_task: +macos_arm64_task: + only_if: $CIRRUS_CHANGE_TITLE !=~ 'ci\(gha\).*' macos_instance: image: ghcr.io/cirruslabs/macos-ventura-base:latest env: PATH: ${HOME}/.pyenv/shims:${PATH} + # PACT_FFI_PATH: .tox/test/lib/python$VERSION/site-packages/pact/bin matrix: - - PYTHON: 3.6 - - PYTHON: 3.7 - - PYTHON: 3.8 - - PYTHON: 3.9 - - PYTHON: 3.10 + # - VERSION: 3.6 + - VERSION: 3.7 + - VERSION: 3.8 + - VERSION: 3.9 + - VERSION: 3.10 + - VERSION: 3.11 install_script: - # Per the pyenv homebrew recommendations. - # https://github.com/pyenv/pyenv/wiki#suggested-build-environment - # - xcode-select --install # Unnecessary on Cirrus - brew update - # - brew install openssl readline sqlite3 xz zlib - brew install pyenv - - pyenv install ${PYTHON} - - pyenv global ${PYTHON} + - pyenv install ${VERSION} + - pyenv global ${VERSION} - pyenv rehash - ## To install rosetta - # - softwareupdate --install-rosetta --agree-to-license + - chmod +x script/install_plugins.sh << : *BUILD_TEST_TASK_TEMPLATE diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 26e8651ed..11daf5981 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -21,11 +21,11 @@ jobs: - '3.11' os: [ ubuntu-latest, windows-latest, macos-latest ] - # These versions are no longer supported by Python team, and may - # eventually be dropped from GitHub Actions. - include: - - python-version: '3.6' - os: ubuntu-20.04 + # # These versions are no longer supported by Python team, and may + # # eventually be dropped from GitHub Actions. + # include: + # - python-version: '3.6' + # os: ubuntu-20.04 steps: - name: Check out code @@ -43,11 +43,17 @@ jobs: - name: Lint with flake8, pydocstyle run: | - flake8 + flake8 --show-source --exclude examples/area_calculator/area_calculator_pb2.py,examples/area_calculator/area_calculator_pb2_grpc.py,.git,__pycache__,build,dist,.tox pydocstyle pact + - name: Install Pact plugins for tests + run: script/install_plugins.sh + shell: bash + - name: Test with pytest run: tox -e test + # env: + # PACT_FFI_PATH: .tox/test/lib/python${{ matrix.python-version }}/site-packages/pact/bin - name: Test examples if: runner.os == 'Linux' diff --git a/.gitignore b/.gitignore index ba0d76014..e13970594 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,7 @@ __pycache__/ *$py.class # C extensions -*.so +# *.so # Distribution / packaging .Python @@ -105,3 +105,5 @@ ENV/ .noseids +# MacOS stuff +.DS_Store \ No newline at end of file diff --git a/MANIFEST b/MANIFEST index c8f174abe..843181427 100644 --- a/MANIFEST +++ b/MANIFEST @@ -18,14 +18,21 @@ pact/message_consumer.py pact/message_pact.py pact/message_provider.py pact/pact.py +pact/pact_exception.py pact/provider.py pact/verifier.py pact/verify_wrapper.py +pact/bin/libpact_ffi-linux-aarch64.so.gz +pact/bin/libpact_ffi-linux-x86_64.so.gz +pact/bin/libpact_ffi-osx-aarch64-apple-darwin.dylib.gz +pact/bin/libpact_ffi-osx-x86_64.dylib.gz pact/bin/pact-2.0.3-linux-arm64.tar.gz pact/bin/pact-2.0.3-linux-x86_64.tar.gz pact/bin/pact-2.0.3-osx-arm64.tar.gz pact/bin/pact-2.0.3-osx-x86_64.tar.gz pact/bin/pact-2.0.3-windows-x86.zip pact/bin/pact-2.0.3-windows-x86_64.zip +pact/bin/pact.h +pact/bin/pact_ffi-windows-x86_64.dll.gz pact/cli/__init__.py pact/cli/verify.py diff --git a/Makefile b/Makefile index 10f39ede5..03c733970 100644 --- a/Makefile +++ b/Makefile @@ -110,7 +110,7 @@ package: .PHONY: test test: deps - flake8 + flake8 --exclude examples/area_calculator/area_calculator_pb2.py,examples/area_calculator/area_calculator_pb2_grpc.py pydocstyle pact coverage erase tox diff --git a/README.md b/README.md index 230d6fd9d..b95dff97b 100644 --- a/README.md +++ b/README.md @@ -17,17 +17,19 @@ Note: As of Version 1.0 deprecates support for python 2.7 to allow us to incorpo # How to use pact-python ## Installation + ``` pip install pact-python ``` ## Getting started + A guide follows but if you go to the [examples](https://github.com/pact-foundation/pact-python/tree/master/examples). This has a consumer, provider and pact-broker set of tests for both FastAPI and Flask. ## Writing a Pact -Creating a complete contract is a two step process: +Creating a complete contract is a two-step process: 1. Create a test on the consumer side that declares the expectations it has of the provider 2. Create a provider state that allows the contract to pass when replayed against the provider @@ -82,7 +84,6 @@ class GetUserInfoContract(unittest.TestCase): result = user('UserA') self.assertEqual(result, expected) - ``` This does a few important things: @@ -164,11 +165,13 @@ The mock service offers you several important features when building your contra - Finally, it will record your contracts as a JSON file that you can store in your repository or publish to a Pact broker. ## Expecting Variable Content + The above test works great if that user information is always static, but what happens if the user has a last updated field that is set to the current time every time the object is modified? To handle variable data and make your tests more robust, there are 3 helpful matchers: ### Term(matcher, generate) + Asserts the value should match the given regular expression. You could use this to expect a timestamp with a particular format in the request or response where you know you need a particular format, but are unconcerned about the exact date: @@ -194,6 +197,7 @@ provider, the regex will be used to search the response from the real provider s and the test will be considered successful if the regex finds a match in the response. ### Like(matcher) + Asserts the element's type matches the matcher. For example: ```python @@ -202,6 +206,7 @@ Like(123) # Matches if the value is an integer Like('hello world') # Matches if the value is a string Like(3.14) # Matches if the value is a float ``` + The argument supplied to `Like` will be what the mock service responds with. When a dictionary is used as an argument for Like, all the child objects (and their child objects etc.) will be matched according to their types, unless you use a more specific matcher like a Term. @@ -453,7 +458,8 @@ The provider application version. Required for publishing verification results. Publish verification results to the broker. ### Python API -You can use the Verifier class. This allows you to write native python code and the test framework of your choice. + +You can use the Verifier class. This has all the same parameters as the cli tool but allows you to write native python code and the test framework of your choice. ```python verifier = Verifier(provider='UserService', @@ -507,6 +513,7 @@ You can see more details in the examples - [FastAPI Provider Verifier Test](https://github.com/pact-foundation/pact-python/tree/master/examples/fastapi_provider/tests/provider/test_provider.py) ### Provider States + In many cases, your contracts will need very specific data to exist on the provider to pass successfully. If you are fetching a user profile, that user needs to exist, if querying a list of records, one or more records needs to exist. To support @@ -525,18 +532,20 @@ on the provider application or a separate one. Some strategies for managing stat For more information about provider states, refer to the [Pact documentation] on [Provider States]. # Development + Please read [CONTRIBUTING.md](https://github.com/pact-foundation/pact-python/blob/master/CONTRIBUTING.md) To setup a development environment: -1. If you want to run tests for all Python versions, install 2.7, 3.3, 3.4, 3.5, and 3.6 from source or using a tool like [pyenv] -2. Its recommended to create a Python [virtualenv] for the project +1. If you want to run tests for all Python versions, install 3.6, 3.7, 3.8, and 3.9 from source or using a tool like [pyenv] +2. It's recommended to create a Python [virtualenv] for the project. +3. We are now using FFI bindings. For mac you might want to read these [setup FFI](https://cffi.readthedocs.io/en/latest/installation.html) To setup the environment, run tests, and package the application, run: `make release` -If you are just interested in packaging pact-python so you can install it using pip: +If you are just interested in packaging pact-python you can install it using pip: `make package` diff --git a/examples/area_calculator/area_calculator_client.py b/examples/area_calculator/area_calculator_client.py new file mode 100644 index 000000000..f8f8cd545 --- /dev/null +++ b/examples/area_calculator/area_calculator_client.py @@ -0,0 +1,30 @@ +"""The Python implementation of the GRPC Area Calculator client.""" + +from __future__ import print_function + +import logging + +import grpc +import area_calculator_pb2 +import area_calculator_pb2_grpc + + +def run(): + get_rectangle_area('localhost:37757') + +def get_rectangle_area(address): + print("Getting rectangle area.") + with grpc.insecure_channel(address) as channel: + stub = area_calculator_pb2_grpc.CalculatorStub(channel) + rect = { + "length": 3, + "width": 4 + } + response = stub.calculateOne(area_calculator_pb2.ShapeMessage(rectangle=rect)) + print(f"AreaCalculator client received: {response.value[0]}") + return response.value[0] + + +if __name__ == '__main__': + logging.basicConfig() + run() diff --git a/examples/area_calculator/area_calculator_pb2.py b/examples/area_calculator/area_calculator_pb2.py new file mode 100644 index 000000000..9d65740af --- /dev/null +++ b/examples/area_calculator/area_calculator_pb2.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: area_calculator.proto +"""Generated protocol buffer code.""" +from google.protobuf.internal import builder as _builder +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x15\x61rea_calculator.proto\x12\x0f\x61rea_calculator\"\x86\x02\n\x0cShapeMessage\x12)\n\x06square\x18\x01 \x01(\x0b\x32\x17.area_calculator.SquareH\x00\x12/\n\trectangle\x18\x02 \x01(\x0b\x32\x1a.area_calculator.RectangleH\x00\x12)\n\x06\x63ircle\x18\x03 \x01(\x0b\x32\x17.area_calculator.CircleH\x00\x12-\n\x08triangle\x18\x04 \x01(\x0b\x32\x19.area_calculator.TriangleH\x00\x12\x37\n\rparallelogram\x18\x05 \x01(\x0b\x32\x1e.area_calculator.ParallelogramH\x00\x42\x07\n\x05shape\"\x1d\n\x06Square\x12\x13\n\x0b\x65\x64ge_length\x18\x01 \x01(\x02\"*\n\tRectangle\x12\x0e\n\x06length\x18\x01 \x01(\x02\x12\r\n\x05width\x18\x02 \x01(\x02\"\x18\n\x06\x43ircle\x12\x0e\n\x06radius\x18\x01 \x01(\x02\":\n\x08Triangle\x12\x0e\n\x06\x65\x64ge_a\x18\x01 \x01(\x02\x12\x0e\n\x06\x65\x64ge_b\x18\x02 \x01(\x02\x12\x0e\n\x06\x65\x64ge_c\x18\x03 \x01(\x02\"4\n\rParallelogram\x12\x13\n\x0b\x62\x61se_length\x18\x01 \x01(\x02\x12\x0e\n\x06height\x18\x02 \x01(\x02\"<\n\x0b\x41reaRequest\x12-\n\x06shapes\x18\x01 \x03(\x0b\x32\x1d.area_calculator.ShapeMessage\"\x1d\n\x0c\x41reaResponse\x12\r\n\x05value\x18\x01 \x03(\x02\x32\xad\x01\n\nCalculator\x12N\n\x0c\x63\x61lculateOne\x12\x1d.area_calculator.ShapeMessage\x1a\x1d.area_calculator.AreaResponse\"\x00\x12O\n\x0e\x63\x61lculateMulti\x12\x1c.area_calculator.AreaRequest\x1a\x1d.area_calculator.AreaResponse\"\x00\x42\x1cZ\x17io.pact/area_calculator\xd0\x02\x01\x62\x06proto3') + +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'area_calculator_pb2', globals()) +if _descriptor._USE_C_DESCRIPTORS == False: + + DESCRIPTOR._options = None + DESCRIPTOR._serialized_options = b'Z\027io.pact/area_calculator\320\002\001' + _SHAPEMESSAGE._serialized_start=43 + _SHAPEMESSAGE._serialized_end=305 + _SQUARE._serialized_start=307 + _SQUARE._serialized_end=336 + _RECTANGLE._serialized_start=338 + _RECTANGLE._serialized_end=380 + _CIRCLE._serialized_start=382 + _CIRCLE._serialized_end=406 + _TRIANGLE._serialized_start=408 + _TRIANGLE._serialized_end=466 + _PARALLELOGRAM._serialized_start=468 + _PARALLELOGRAM._serialized_end=520 + _AREAREQUEST._serialized_start=522 + _AREAREQUEST._serialized_end=582 + _AREARESPONSE._serialized_start=584 + _AREARESPONSE._serialized_end=613 + _CALCULATOR._serialized_start=616 + _CALCULATOR._serialized_end=789 +# @@protoc_insertion_point(module_scope) diff --git a/examples/area_calculator/area_calculator_pb2.pyi b/examples/area_calculator/area_calculator_pb2.pyi new file mode 100644 index 000000000..7dae3bc60 --- /dev/null +++ b/examples/area_calculator/area_calculator_pb2.pyi @@ -0,0 +1,70 @@ +from google.protobuf.internal import containers as _containers +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from typing import ClassVar as _ClassVar, Iterable as _Iterable, Mapping as _Mapping, Optional as _Optional, Union as _Union + +DESCRIPTOR: _descriptor.FileDescriptor + +class AreaRequest(_message.Message): + __slots__ = ["shapes"] + SHAPES_FIELD_NUMBER: _ClassVar[int] + shapes: _containers.RepeatedCompositeFieldContainer[ShapeMessage] + def __init__(self, shapes: _Optional[_Iterable[_Union[ShapeMessage, _Mapping]]] = ...) -> None: ... + +class AreaResponse(_message.Message): + __slots__ = ["value"] + VALUE_FIELD_NUMBER: _ClassVar[int] + value: _containers.RepeatedScalarFieldContainer[float] + def __init__(self, value: _Optional[_Iterable[float]] = ...) -> None: ... + +class Circle(_message.Message): + __slots__ = ["radius"] + RADIUS_FIELD_NUMBER: _ClassVar[int] + radius: float + def __init__(self, radius: _Optional[float] = ...) -> None: ... + +class Parallelogram(_message.Message): + __slots__ = ["base_length", "height"] + BASE_LENGTH_FIELD_NUMBER: _ClassVar[int] + HEIGHT_FIELD_NUMBER: _ClassVar[int] + base_length: float + height: float + def __init__(self, base_length: _Optional[float] = ..., height: _Optional[float] = ...) -> None: ... + +class Rectangle(_message.Message): + __slots__ = ["length", "width"] + LENGTH_FIELD_NUMBER: _ClassVar[int] + WIDTH_FIELD_NUMBER: _ClassVar[int] + length: float + width: float + def __init__(self, length: _Optional[float] = ..., width: _Optional[float] = ...) -> None: ... + +class ShapeMessage(_message.Message): + __slots__ = ["circle", "parallelogram", "rectangle", "square", "triangle"] + CIRCLE_FIELD_NUMBER: _ClassVar[int] + PARALLELOGRAM_FIELD_NUMBER: _ClassVar[int] + RECTANGLE_FIELD_NUMBER: _ClassVar[int] + SQUARE_FIELD_NUMBER: _ClassVar[int] + TRIANGLE_FIELD_NUMBER: _ClassVar[int] + circle: Circle + parallelogram: Parallelogram + rectangle: Rectangle + square: Square + triangle: Triangle + def __init__(self, square: _Optional[_Union[Square, _Mapping]] = ..., rectangle: _Optional[_Union[Rectangle, _Mapping]] = ..., circle: _Optional[_Union[Circle, _Mapping]] = ..., triangle: _Optional[_Union[Triangle, _Mapping]] = ..., parallelogram: _Optional[_Union[Parallelogram, _Mapping]] = ...) -> None: ... + +class Square(_message.Message): + __slots__ = ["edge_length"] + EDGE_LENGTH_FIELD_NUMBER: _ClassVar[int] + edge_length: float + def __init__(self, edge_length: _Optional[float] = ...) -> None: ... + +class Triangle(_message.Message): + __slots__ = ["edge_a", "edge_b", "edge_c"] + EDGE_A_FIELD_NUMBER: _ClassVar[int] + EDGE_B_FIELD_NUMBER: _ClassVar[int] + EDGE_C_FIELD_NUMBER: _ClassVar[int] + edge_a: float + edge_b: float + edge_c: float + def __init__(self, edge_a: _Optional[float] = ..., edge_b: _Optional[float] = ..., edge_c: _Optional[float] = ...) -> None: ... diff --git a/examples/area_calculator/area_calculator_pb2_grpc.py b/examples/area_calculator/area_calculator_pb2_grpc.py new file mode 100644 index 000000000..a77c753ed --- /dev/null +++ b/examples/area_calculator/area_calculator_pb2_grpc.py @@ -0,0 +1,99 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc + +import area_calculator_pb2 as area__calculator__pb2 + + +class CalculatorStub(object): + """Missing associated documentation comment in .proto file.""" + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.calculateOne = channel.unary_unary( + '/area_calculator.Calculator/calculateOne', + request_serializer=area__calculator__pb2.ShapeMessage.SerializeToString, + response_deserializer=area__calculator__pb2.AreaResponse.FromString, + ) + self.calculateMulti = channel.unary_unary( + '/area_calculator.Calculator/calculateMulti', + request_serializer=area__calculator__pb2.AreaRequest.SerializeToString, + response_deserializer=area__calculator__pb2.AreaResponse.FromString, + ) + + +class CalculatorServicer(object): + """Missing associated documentation comment in .proto file.""" + + def calculateOne(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def calculateMulti(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_CalculatorServicer_to_server(servicer, server): + rpc_method_handlers = { + 'calculateOne': grpc.unary_unary_rpc_method_handler( + servicer.calculateOne, + request_deserializer=area__calculator__pb2.ShapeMessage.FromString, + response_serializer=area__calculator__pb2.AreaResponse.SerializeToString, + ), + 'calculateMulti': grpc.unary_unary_rpc_method_handler( + servicer.calculateMulti, + request_deserializer=area__calculator__pb2.AreaRequest.FromString, + response_serializer=area__calculator__pb2.AreaResponse.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'area_calculator.Calculator', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) + + + # This class is part of an EXPERIMENTAL API. +class Calculator(object): + """Missing associated documentation comment in .proto file.""" + + @staticmethod + def calculateOne(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary(request, target, '/area_calculator.Calculator/calculateOne', + area__calculator__pb2.ShapeMessage.SerializeToString, + area__calculator__pb2.AreaResponse.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + + @staticmethod + def calculateMulti(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary(request, target, '/area_calculator.Calculator/calculateMulti', + area__calculator__pb2.AreaRequest.SerializeToString, + area__calculator__pb2.AreaResponse.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) diff --git a/examples/area_calculator/area_calculator_server.py b/examples/area_calculator/area_calculator_server.py new file mode 100644 index 000000000..a1459d606 --- /dev/null +++ b/examples/area_calculator/area_calculator_server.py @@ -0,0 +1,30 @@ +"""The Python implementation of the GRPC AreaCalculator server.""" + +from concurrent import futures +import logging + +import grpc +import area_calculator_pb2 +import area_calculator_pb2_grpc + +class AreaCalculator(area_calculator_pb2_grpc.CalculatorServicer): + + def calculateOne(self, request, context): + print(request.rectangle) + area = request.rectangle.length * request.rectangle.width + return area_calculator_pb2.AreaResponse(value=[area]) + + +def serve(): + port = '37757' + server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) + area_calculator_pb2_grpc.add_CalculatorServicer_to_server(AreaCalculator(), server) + server.add_insecure_port('[::]:' + port) + server.start() + print("Server started, listening on " + port) + server.wait_for_termination() + + +if __name__ == '__main__': + logging.basicConfig() + serve() diff --git a/examples/area_calculator/requirements.txt b/examples/area_calculator/requirements.txt new file mode 100644 index 000000000..0f08a713d --- /dev/null +++ b/examples/area_calculator/requirements.txt @@ -0,0 +1,2 @@ +grpcio==1.50.0 +grpcio-tools==1.50.0 \ No newline at end of file diff --git a/examples/fastapi_provider/tests/provider/test_provider.py b/examples/fastapi_provider/tests/provider/test_provider.py index bc903b481..0c21c1c2f 100644 --- a/examples/fastapi_provider/tests/provider/test_provider.py +++ b/examples/fastapi_provider/tests/provider/test_provider.py @@ -3,7 +3,8 @@ import pytest -from pact import Verifier +# from pact import Verifier +from pact.ffi.ffi_verifier import FFIVerify log = logging.getLogger(__name__) logging.basicConfig(level=logging.INFO) @@ -40,13 +41,15 @@ def broker_opts(): def test_user_service_provider_against_broker(server, broker_opts): - verifier = Verifier(provider="UserService", provider_base_url=PROVIDER_URL) + verifier = FFIVerify() # Request all Pact(s) from the Pact Broker to verify this Provider against. # In the Pact Broker logs, this corresponds to the following entry: # PactBroker::Api::Resources::ProviderPactsForVerification -- Fetching pacts for verification by UserService -- {:provider_name=>"UserService", :params=>{}} - success, logs = verifier.verify_with_broker( + success, logs = verifier.verify( **broker_opts, + provider="UserService", + provider_base_url=PROVIDER_URL, verbose=True, provider_states_setup_url=f"{PROVIDER_URL}/_pact/provider_states", enable_pending=False, @@ -69,7 +72,7 @@ def test_user_service_provider_against_broker(server, broker_opts): def test_user_service_provider_against_pact(server): - verifier = Verifier(provider="UserService", provider_base_url=PROVIDER_URL) + verifier = FFIVerify() # Rather than requesting the Pact interactions from the Pact Broker, this # will perform the verification based on the Pact file locally. @@ -78,8 +81,10 @@ def test_user_service_provider_against_pact(server): # if it has been successful in the past (since this is what the Pact Broker # is for), if the verification of an interaction fails then the success # result will be != 0, and so the test will FAIL. - output, _ = verifier.verify_pacts( + output, _ = verifier.verify( "../pacts/userserviceclient-userservice.json", + provider="UserService", + provider_base_url=PROVIDER_URL, verbose=False, provider_states_setup_url="{}/_pact/provider_states".format(PROVIDER_URL), ) diff --git a/examples/ffi/README.md b/examples/ffi/README.md new file mode 100644 index 000000000..cc2835238 --- /dev/null +++ b/examples/ffi/README.md @@ -0,0 +1,17 @@ +# FFI Examples + +This contains the following files which are for reference/information purposes +only i.e. they are not functionally used by end users. They may be used where +documented by developer helper scripts. The files are included here to make it +easier to identify when any changes have occurred in a new version of the Pact +FFI library. + +### pact_ffi_verifier_args.json + +The various arguments available to the Pact Verifier, both options and flags. +This format is used to construct the CLI arguments for `pact-verifier`, which +calls the method that produces this data each time during runtime. + +### pact_ffi_verifier_help.txt + +The output from ``pact-verifier --help`` \ No newline at end of file diff --git a/examples/ffi/pact_ffi_verifier_args.json b/examples/ffi/pact_ffi_verifier_args.json new file mode 100644 index 000000000..dde1fcc80 --- /dev/null +++ b/examples/ffi/pact_ffi_verifier_args.json @@ -0,0 +1,187 @@ +{ + "options": [ + { + "long": "loglevel", + "short": "l", + "help": "Log level (defaults to warn)", + "possible_values": [ + "error", + "warn", + "info", + "debug", + "trace", + "none" + ], + "multiple": false + }, + { + "long": "file", + "short": "f", + "help": "Pact file to verify (can be repeated)", + "multiple": true + }, + { + "long": "dir", + "short": "d", + "help": "Directory of pact files to verify (can be repeated)", + "multiple": true + }, + { + "long": "url", + "short": "u", + "help": "URL of pact file to verify (can be repeated)", + "multiple": true + }, + { + "long": "broker-url", + "short": "b", + "help": "URL of the pact broker to fetch pacts from to verify (requires the provider name parameter)", + "multiple": false, + "env": "PACT_BROKER_BASE_URL" + }, + { + "long": "hostname", + "short": "h", + "help": "Provider hostname (defaults to localhost)", + "multiple": false + }, + { + "long": "port", + "short": "p", + "help": "Provider port (defaults to protocol default 80/443)", + "multiple": false + }, + { + "long": "scheme", + "help": "Provider URI scheme (defaults to http)", + "possible_values": [ + "http", + "https" + ], + "default_value": "http", + "multiple": false + }, + { + "long": "provider-name", + "short": "n", + "help": "Provider name (defaults to provider)", + "multiple": false + }, + { + "long": "state-change-url", + "short": "s", + "help": "URL to post state change requests to", + "multiple": false + }, + { + "long": "filter-description", + "help": "Only validate interactions whose descriptions match this filter", + "multiple": false, + "env": "PACT_DESCRIPTION" + }, + { + "long": "filter-state", + "help": "Only validate interactions whose provider states match this filter", + "multiple": false, + "env": "PACT_PROVIDER_STATE" + }, + { + "long": "filter-no-state", + "help": "Only validate interactions that have no defined provider state", + "multiple": false, + "env": "PACT_PROVIDER_NO_STATE" + }, + { + "long": "filter-consumer", + "short": "c", + "help": "Consumer name to filter the pacts to be verified (can be repeated)", + "multiple": true + }, + { + "long": "user", + "help": "Username to use when fetching pacts from URLS", + "multiple": false, + "env": "PACT_BROKER_USERNAME" + }, + { + "long": "password", + "help": "Password to use when fetching pacts from URLS", + "multiple": false, + "env": "PACT_BROKER_PASSWORD" + }, + { + "long": "token", + "short": "t", + "help": "Bearer token to use when fetching pacts from URLS", + "multiple": false, + "env": "PACT_BROKER_TOKEN" + }, + { + "long": "provider-version", + "help": "Provider version that is being verified. This is required when publishing results.", + "multiple": false + }, + { + "long": "build-url", + "help": "URL of the build to associate with the published verification results.", + "multiple": false + }, + { + "long": "provider-tags", + "help": "Provider tags to use when publishing results. Accepts comma-separated values.", + "multiple": false + }, + { + "long": "base-path", + "help": "Base path to add to all requests", + "multiple": false + }, + { + "long": "consumer-version-tags", + "help": "Consumer tags to use when fetching pacts from the Broker. Accepts comma-separated values.", + "multiple": false + }, + { + "long": "consumer-version-selectors", + "help": "Consumer version selectors to use when fetching pacts from the Broker. Accepts a JSON string as per https://docs.pact.io/pact_broker/advanced_topics/consumer_version_selectors/", + "multiple": false + }, + { + "long": "include-wip-pacts-since", + "help": "Allow pacts that don't match given consumer selectors (or tags) to be verified, without causing the overall task to fail. For more information, see https://pact.io/wip", + "multiple": false + }, + { + "long": "request-timeout", + "help": "Sets the HTTP request timeout in milliseconds for requests to the target API and for state change requests.", + "multiple": false + } + ], + "flags": [ + { + "long": "state-change-as-query", + "help": "State change request data will be sent as query parameters instead of in the request body", + "multiple": false + }, + { + "long": "state-change-teardown", + "help": "State change teardown requests are to be made after each interaction", + "multiple": false + }, + { + "long": "publish", + "help": "Enables publishing of verification results back to the Pact Broker. Requires the broker-url and provider-version parameters.", + "multiple": false + }, + { + "long": "disable-ssl-verification", + "help": "Disables validation of SSL certificates", + "multiple": false + }, + { + "long": "enable-pending", + "help": "Enables Pending Pacts", + "multiple": false + } + ] +} \ No newline at end of file diff --git a/examples/ffi/pact_ffi_verifier_help.txt b/examples/ffi/pact_ffi_verifier_help.txt new file mode 100644 index 000000000..2c354c663 --- /dev/null +++ b/examples/ffi/pact_ffi_verifier_help.txt @@ -0,0 +1,37 @@ +Usage: pact-verifier [OPTIONS] + +Options: + --debug-click Display arguments passed to the FFI library + --enable-pending Enables Pending Pacts + --disable-ssl-verification Disables validation of SSL certificates + --publish Enables publishing of verification results back to the Pact Broker. Requires the broker-url and provider-version parameters. + --state-change-teardown State change teardown requests are to be made after each interaction + --state-change-as-query State change request data will be sent as query parameters instead of in the request body + --request-timeout TEXT Sets the HTTP request timeout in milliseconds for requests to the target API and for state change requests. + --include-wip-pacts-since TEXT Allow pacts that don't match given consumer selectors (or tags) to be verified, without causing the overall task to fail. For more information, see https://pact.io/wip + --consumer-version-selectors TEXT + Consumer version selectors to use when fetching pacts from the Broker. Accepts a JSON string as per https://docs.pact.io/pact_broker/advanced_topics/consumer_version_selectors/ + --consumer-version-tags TEXT Consumer tags to use when fetching pacts from the Broker. Accepts comma-separated values. + --base-path TEXT Base path to add to all requests + --provider-tags TEXT Provider tags to use when publishing results. Accepts comma-separated values. + --build-url TEXT URL of the build to associate with the published verification results. + --provider-version TEXT Provider version that is being verified. This is required when publishing results. + -t, --token TEXT Bearer token to use when fetching pacts from URLS. Alternatively: $PACT_BROKER_TOKEN + --password TEXT Password to use when fetching pacts from URLS. Alternatively: $PACT_BROKER_PASSWORD + --user TEXT Username to use when fetching pacts from URLS. Alternatively: $PACT_BROKER_USERNAME + -c, --filter-consumer TEXT Consumer name to filter the pacts to be verified (can be repeated) + --filter-no-state TEXT Only validate interactions that have no defined provider state. Alternatively: $PACT_PROVIDER_NO_STATE + --filter-state TEXT Only validate interactions whose provider states match this filter. Alternatively: $PACT_PROVIDER_STATE + --filter-description TEXT Only validate interactions whose descriptions match this filter. Alternatively: $PACT_DESCRIPTION + -s, --state-change-url TEXT URL to post state change requests to + -n, --provider-name TEXT Provider name (defaults to provider) + --scheme [http|https] Provider URI scheme (defaults to http) + -p, --port TEXT Provider port (defaults to protocol default 80/443) + -h, --hostname TEXT Provider hostname (defaults to localhost) + -b, --broker-url TEXT URL of the pact broker to fetch pacts from to verify (requires the provider name parameter). Alternatively: $PACT_BROKER_BASE_URL + -u, --url TEXT URL of pact file to verify (can be repeated) + -d, --dir TEXT Directory of pact files to verify (can be repeated) + -f, --file TEXT Pact file to verify (can be repeated) + -l, --loglevel [error|warn|info|debug|trace|none] + Log level (defaults to warn) + --help Show this message and exit. \ No newline at end of file diff --git a/examples/flask_provider/tests/provider/test_provider.py b/examples/flask_provider/tests/provider/test_provider.py index f87257785..0b09a285b 100644 --- a/examples/flask_provider/tests/provider/test_provider.py +++ b/examples/flask_provider/tests/provider/test_provider.py @@ -4,7 +4,8 @@ import pytest -from pact import Verifier +# from pact import Verifier +from pact.ffi.ffi_verifier import FFIVerify log = logging.getLogger(__name__) logging.basicConfig(level=logging.INFO) @@ -36,13 +37,16 @@ def broker_opts(): def test_user_service_provider_against_broker(broker_opts): - verifier = Verifier(provider="UserService", provider_base_url=PROVIDER_URL) + verifier = FFIVerify() + # verifier = Verifier(provider="UserService", provider_base_url=PROVIDER_URL) # Request all Pact(s) from the Pact Broker to verify this Provider against. # In the Pact Broker logs, this corresponds to the following entry: # PactBroker::Api::Resources::ProviderPactsForVerification -- Fetching pacts for verification by UserService -- {:provider_name=>"UserService", :params=>{}} - success, logs = verifier.verify_with_broker( + success, logs = verifier.verify( **broker_opts, + provider="UserService", + provider_base_url=PROVIDER_URL, verbose=True, provider_states_setup_url=f"{PROVIDER_URL}/_pact/provider_states", enable_pending=False, @@ -65,7 +69,8 @@ def test_user_service_provider_against_broker(broker_opts): def test_user_service_provider_against_pact(): - verifier = Verifier(provider="UserService", provider_base_url=PROVIDER_URL) + verifier = FFIVerify() + # verifier = Verifier(provider="UserService", provider_base_url=PROVIDER_URL) # Rather than requesting the Pact interactions from the Pact Broker, this # will perform the verification based on the Pact file locally. @@ -74,10 +79,34 @@ def test_user_service_provider_against_pact(): # if it has been successful in the past (since this is what the Pact Broker # is for), if the verification of an interaction fails then the success # result will be != 0, and so the test will FAIL. - output, _ = verifier.verify_pacts( + output, _ = verifier.verify( "../pacts/userserviceclient-userservice.json", + provider="UserService", + provider_base_url=PROVIDER_URL, verbose=False, provider_states_setup_url="{}/_pact/provider_states".format(PROVIDER_URL), ) assert output == 0 + +def test_user_service_provider_against_pact_url(broker_opts): + verifier = FFIVerify() + # verifier = Verifier(provider="UserService", provider_base_url=PROVIDER_URL) + + # Rather than requesting the Pact interactions from the Pact Broker, this + # will perform the verification based on the Pact file locally. + # + # Because there is no way of knowing the previous state of an interaction, + # if it has been successful in the past (since this is what the Pact Broker + # is for), if the verification of an interaction fails then the success + # result will be != 0, and so the test will FAIL. + output, _ = verifier.verify( + "http://localhost/pacts/provider/UserService/consumer/UserServiceClient/latest", + **broker_opts, + provider="UserService", + provider_base_url=PROVIDER_URL, + log_level='DEBUG', + provider_states_setup_url="{}/_pact/provider_states".format(PROVIDER_URL), + ) + + assert output == 0 diff --git a/examples/message/tests/provider/test_message_provider.py b/examples/message/tests/provider/test_message_provider.py index ae20ab3a1..ad78689c2 100644 --- a/examples/message/tests/provider/test_message_provider.py +++ b/examples/message/tests/provider/test_message_provider.py @@ -48,10 +48,11 @@ def test_verify_success(): ) with provider: - provider.verify() + success, logs = provider.verify() + assert success == 0 -def test_verify_failure_when_a_provider_missing(): +def test_verify_failure_when_a_handler_missing(): provider = MessageProvider( message_providers={ 'A document created successfully': document_created_handler, @@ -62,9 +63,9 @@ def test_verify_failure_when_a_provider_missing(): ) - with pytest.raises(AssertionError): - with provider: - provider.verify() + with provider: + success, logs = provider.verify() + assert success == 1 def test_verify_from_broker(default_opts): @@ -75,9 +76,8 @@ def test_verify_from_broker(default_opts): }, provider='ContentProvider', consumer='DetectContentLambda', - pact_dir='pacts' - ) with provider: - provider.verify_with_broker(**default_opts) + success, logs = provider.verify_with_broker(**default_opts) + assert success == 0 diff --git a/examples/pacts/grpc-consumer-python-area-calculator-provider.json b/examples/pacts/grpc-consumer-python-area-calculator-provider.json new file mode 100644 index 000000000..ca8af0d17 --- /dev/null +++ b/examples/pacts/grpc-consumer-python-area-calculator-provider.json @@ -0,0 +1,107 @@ +{ + "consumer": { + "name": "grpc-consumer-python" + }, + "interactions": [ + { + "description": "A gRPC calculateMulti request", + "interactionMarkup": { + "markup": "```protobuf\nmessage AreaResponse {\n repeated float value = 1;\n}\n```\n", + "markupType": "COMMON_MARK" + }, + "pending": false, + "pluginConfiguration": { + "protobuf": { + "descriptorKey": "a85dff8f82655a9681aad113575dcfbb", + "service": "Calculator/calculateOne" + } + }, + "request": { + "contents": { + "content": "EgoNAABAQBUAAIBA", + "contentType": "application/protobuf;message=ShapeMessage", + "contentTypeHint": "BINARY", + "encoded": "base64" + }, + "matchingRules": { + "body": { + "$.rectangle.length": { + "combine": "AND", + "matchers": [ + { + "match": "number" + } + ] + }, + "$.rectangle.width": { + "combine": "AND", + "matchers": [ + { + "match": "number" + } + ] + } + } + }, + "metadata": { + "contentType": "application/protobuf;message=ShapeMessage" + } + }, + "response": [ + { + "contents": { + "content": "CgQAAEBB", + "contentType": "application/protobuf;message=AreaResponse", + "contentTypeHint": "BINARY", + "encoded": "base64" + }, + "matchingRules": { + "body": { + "$.value[0].*": { + "combine": "AND", + "matchers": [ + { + "match": "number" + } + ] + } + } + }, + "metadata": { + "contentType": "application/protobuf;message=AreaResponse" + } + } + ], + "transport": "grpc", + "type": "Synchronous/Messages" + } + ], + "metadata": { + "pact-python": { + "ffi": "0.4.5" + }, + "pactRust": { + "ffi": "0.4.5", + "mockserver": "1.1.1", + "models": "1.1.2" + }, + "pactSpecification": { + "version": "4.0" + }, + "plugins": [ + { + "configuration": { + "a85dff8f82655a9681aad113575dcfbb": { + "protoDescriptors": "CsoHChVhcmVhX2NhbGN1bGF0b3IucHJvdG8SD2FyZWFfY2FsY3VsYXRvciK6AgoMU2hhcGVNZXNzYWdlEjEKBnNxdWFyZRgBIAEoCzIXLmFyZWFfY2FsY3VsYXRvci5TcXVhcmVIAFIGc3F1YXJlEjoKCXJlY3RhbmdsZRgCIAEoCzIaLmFyZWFfY2FsY3VsYXRvci5SZWN0YW5nbGVIAFIJcmVjdGFuZ2xlEjEKBmNpcmNsZRgDIAEoCzIXLmFyZWFfY2FsY3VsYXRvci5DaXJjbGVIAFIGY2lyY2xlEjcKCHRyaWFuZ2xlGAQgASgLMhkuYXJlYV9jYWxjdWxhdG9yLlRyaWFuZ2xlSABSCHRyaWFuZ2xlEkYKDXBhcmFsbGVsb2dyYW0YBSABKAsyHi5hcmVhX2NhbGN1bGF0b3IuUGFyYWxsZWxvZ3JhbUgAUg1wYXJhbGxlbG9ncmFtQgcKBXNoYXBlIikKBlNxdWFyZRIfCgtlZGdlX2xlbmd0aBgBIAEoAlIKZWRnZUxlbmd0aCI5CglSZWN0YW5nbGUSFgoGbGVuZ3RoGAEgASgCUgZsZW5ndGgSFAoFd2lkdGgYAiABKAJSBXdpZHRoIiAKBkNpcmNsZRIWCgZyYWRpdXMYASABKAJSBnJhZGl1cyJPCghUcmlhbmdsZRIVCgZlZGdlX2EYASABKAJSBWVkZ2VBEhUKBmVkZ2VfYhgCIAEoAlIFZWRnZUISFQoGZWRnZV9jGAMgASgCUgVlZGdlQyJICg1QYXJhbGxlbG9ncmFtEh8KC2Jhc2VfbGVuZ3RoGAEgASgCUgpiYXNlTGVuZ3RoEhYKBmhlaWdodBgCIAEoAlIGaGVpZ2h0IkQKC0FyZWFSZXF1ZXN0EjUKBnNoYXBlcxgBIAMoCzIdLmFyZWFfY2FsY3VsYXRvci5TaGFwZU1lc3NhZ2VSBnNoYXBlcyIkCgxBcmVhUmVzcG9uc2USFAoFdmFsdWUYASADKAJSBXZhbHVlMq0BCgpDYWxjdWxhdG9yEk4KDGNhbGN1bGF0ZU9uZRIdLmFyZWFfY2FsY3VsYXRvci5TaGFwZU1lc3NhZ2UaHS5hcmVhX2NhbGN1bGF0b3IuQXJlYVJlc3BvbnNlIgASTwoOY2FsY3VsYXRlTXVsdGkSHC5hcmVhX2NhbGN1bGF0b3IuQXJlYVJlcXVlc3QaHS5hcmVhX2NhbGN1bGF0b3IuQXJlYVJlc3BvbnNlIgBCHFoXaW8ucGFjdC9hcmVhX2NhbGN1bGF0b3LQAgFiBnByb3RvMw==", + "protoFile": "syntax = \"proto3\";\n\npackage area_calculator;\n\noption php_generic_services = true;\noption go_package = \"io.pact/area_calculator\";\n\nservice Calculator {\n rpc calculateOne (ShapeMessage) returns (AreaResponse) {}\n rpc calculateMulti (AreaRequest) returns (AreaResponse) {}\n}\n\nmessage ShapeMessage {\n oneof shape {\n Square square = 1;\n Rectangle rectangle = 2;\n Circle circle = 3;\n Triangle triangle = 4;\n Parallelogram parallelogram = 5;\n }\n}\n\nmessage Square {\n float edge_length = 1;\n}\n\nmessage Rectangle {\n float length = 1;\n float width = 2;\n}\n\nmessage Circle {\n float radius = 1;\n}\n\nmessage Triangle {\n float edge_a = 1;\n float edge_b = 2;\n float edge_c = 3;\n}\n\nmessage Parallelogram {\n float base_length = 1;\n float height = 2;\n}\n\nmessage AreaRequest {\n repeated ShapeMessage shapes = 1;\n}\n\nmessage AreaResponse {\n repeated float value = 1;\n}" + } + }, + "name": "protobuf", + "version": "0.3.4" + } + ] + }, + "provider": { + "name": "area-calculator-provider" + } +} \ No newline at end of file diff --git a/examples/pacts/http-consumer-1-http-provider.json b/examples/pacts/http-consumer-1-http-provider.json new file mode 100644 index 000000000..75096a1df --- /dev/null +++ b/examples/pacts/http-consumer-1-http-provider.json @@ -0,0 +1,154 @@ +{ + "consumer": { + "name": "http-consumer-1" + }, + "interactions": [ + { + "description": "A POST request to create book", + "providerStates": [ + { + "name": "No book fixtures required" + } + ], + "request": { + "body": { + "author": "Margaret Atwood", + "description": "Brilliantly conceived and executed, this powerful evocation of twenty-first century America gives full rein to Margaret Atwood's devastating irony, wit and astute perception.", + "isbn": "0099740915", + "publicationDate": "1985-07-31T00:00:00+00:00", + "title": "The Handmaid's Tale" + }, + "headers": { + "Content-Type": "application/json" + }, + "matchingRules": { + "body": { + "$.author": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.description": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.isbn": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.publicationDate": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "^\\d{4}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d([+-][0-2]\\d:[0-5]\\d|Z)$" + } + ] + }, + "$.title": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "header": {} + }, + "method": "POST", + "path": "/api/books" + }, + "response": { + "body": { + "@context": "/api/contexts/Book", + "@id": "/api/books/0114b2a8-3347-49d8-ad99-0e792c5a30e6", + "@type": "Book", + "author": "Melisa Kassulke", + "description": "Quaerat odit quia nisi accusantium natus voluptatem. Explicabo corporis eligendi ut ut sapiente ut qui quidem. Optio amet velit aut delectus. Sed alias asperiores perspiciatis deserunt omnis. Mollitia unde id in.", + "publicationDate": "1999-02-13T00:00:00+07:00", + "reviews": [], + "title": "Voluptas et tempora repellat corporis excepturi." + }, + "headers": { + "Content-Type": "application/ld+json; charset=utf-8" + }, + "matchingRules": { + "body": { + "$.author": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.description": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.publicationDate": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "^\\d{4}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d([+-][0-2]\\d:[0-5]\\d|Z)$" + } + ] + }, + "$.title": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$['@id']": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "^\\/api\\/books\\/[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$" + } + ] + } + }, + "header": {} + }, + "status": 200 + } + } + ], + "metadata": { + "pact-python": { + "ffi": "0.4.5" + }, + "pactRust": { + "ffi": "0.4.5", + "mockserver": "1.1.1", + "models": "1.1.2" + }, + "pactSpecification": { + "version": "3.0.0" + } + }, + "provider": { + "name": "http-provider" + } +} \ No newline at end of file diff --git a/examples/pacts/http-consumer-2-http-provider.json b/examples/pacts/http-consumer-2-http-provider.json new file mode 100644 index 000000000..1c545b278 --- /dev/null +++ b/examples/pacts/http-consumer-2-http-provider.json @@ -0,0 +1,42 @@ +{ + "consumer": { + "name": "http-consumer-2" + }, + "interactions": [ + { + "description": "A PUT request to generate book cover", + "providerStates": [ + { + "name": "A book with id fb5a885f-f7e8-4a50-950f-c1a64a94d500 is required" + } + ], + "request": { + "body": [], + "headers": { + "Content-Type": "application/json" + }, + "method": "PUT", + "path": "/api/books/fb5a885f-f7e8-4a50-950f-c1a64a94d500/generate-cover" + }, + "response": { + "status": 204 + } + } + ], + "metadata": { + "pact-python": { + "ffi": "0.4.5" + }, + "pactRust": { + "ffi": "0.4.5", + "mockserver": "1.1.1", + "models": "1.1.2" + }, + "pactSpecification": { + "version": "3.0.0" + } + }, + "provider": { + "name": "http-provider" + } +} \ No newline at end of file diff --git a/examples/pacts/message-consumer-2-message-provider.json b/examples/pacts/message-consumer-2-message-provider.json new file mode 100644 index 000000000..e1228d269 --- /dev/null +++ b/examples/pacts/message-consumer-2-message-provider.json @@ -0,0 +1,46 @@ +{ + "consumer": { + "name": "message-consumer-2" + }, + "messages": [ + { + "contents": { + "uuid": "fb5a885f-f7e8-4a50-950f-c1a64a94d500" + }, + "description": "Book (id fb5a885f-f7e8-4a50-950f-c1a64a94d500) created message", + "matchingRules": { + "body": { + "$.uuid": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$" + } + ] + } + } + }, + "metadata": { + "contentType": "application/json" + }, + "providerStates": [ + { + "name": "A book with id fb5a885f-f7e8-4a50-950f-c1a64a94d500 is required" + } + ] + } + ], + "metadata": { + "pactRust": { + "ffi": "0.4.5", + "models": "1.1.2" + }, + "pactSpecification": { + "version": "3.0.0" + } + }, + "provider": { + "name": "message-provider" + } +} \ No newline at end of file diff --git a/examples/pacts/pact-consumer-one-pact-provider-one.json b/examples/pacts/pact-consumer-one-pact-provider-one.json new file mode 100644 index 000000000..79c031487 --- /dev/null +++ b/examples/pacts/pact-consumer-one-pact-provider-one.json @@ -0,0 +1,32 @@ +{ + "consumer": { + "name": "pact-consumer-one" + }, + "provider": { + "name": "pact-provider-one" + }, + "interactions": [ + { + "description": "Data is requested from provider-one", + "providerState": "Some data exists to be returned by provider-one endpoint", + "request": { + "method": "get", + "path": "/test-provider-one" + }, + "response": { + "status": 200, + "headers": { + "Content-type": "application/json" + }, + "body": { + "answer": 42 + } + } + } + ], + "metadata": { + "pactSpecification": { + "version": "2.0.0" + } + } +} \ No newline at end of file diff --git a/examples/pacts/v2-http.json b/examples/pacts/v2-http.json new file mode 100644 index 000000000..3cc870c0a --- /dev/null +++ b/examples/pacts/v2-http.json @@ -0,0 +1,76 @@ +{ + "consumer": { + "name": "Example App" + }, + "provider": { + "name": "Example API" + }, + "interactions": [ + { + "description": "a request for an alligator", + "providerState": "there is an alligator named Mary", + "request": { + "method": "get", + "path": "/alligators/Mary", + "headers": { + "Accept": "application/json" + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json;charset=utf-8" + }, + "body": { + "name": "Mary" + }, + "matchingRules": { + "$.body.name": { + "match": "type" + } + } + } + }, + { + "description": "a request for an alligator", + "providerState": "there is not an alligator named Mary", + "request": { + "method": "get", + "path": "/alligators/Mary", + "headers": { + "Accept": "application/json" + } + }, + "response": { + "status": 404, + "headers": {} + } + }, + { + "_id": "e57e7ac251a8bd078fcb81cad1e577cbafebcef5", + "description": "a request for an alligator", + "providerState": "an error occurs retrieving an alligator", + "request": { + "method": "get", + "path": "/alligators/Mary", + "headers": { + "Accept": "application/json" + } + }, + "response": { + "status": 500, + "headers": { + "Content-Type": "application/json;charset=utf-8" + }, + "body": { + "error": "Argh!!!" + } + } + } + ], + "metadata": { + "pactSpecification": { + "version": "2.0.0" + } + } +} \ No newline at end of file diff --git a/examples/pacts/v3-http.json b/examples/pacts/v3-http.json new file mode 100644 index 000000000..982d91f19 --- /dev/null +++ b/examples/pacts/v3-http.json @@ -0,0 +1,154 @@ +{ + "consumer": { + "name": "http-consumer-1" + }, + "interactions": [ + { + "description": "A POST request to create book", + "providerStates": [ + { + "name": "No book fixtures required" + } + ], + "request": { + "body": { + "author": "Margaret Atwood", + "description": "Brilliantly conceived and executed, this powerful evocation of twenty-first century America gives full rein to Margaret Atwood's devastating irony, wit and astute perception.", + "isbn": "0099740915", + "publicationDate": "1985-07-31T00:00:00+00:00", + "title": "The Handmaid's Tale" + }, + "headers": { + "Content-Type": "application/json" + }, + "matchingRules": { + "body": { + "$.author": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.description": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.isbn": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.publicationDate": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "^\\d{4}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d([+-][0-2]\\d:[0-5]\\d|Z)$" + } + ] + }, + "$.title": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "header": {} + }, + "method": "POST", + "path": "/api/books" + }, + "response": { + "body": { + "@context": "/api/contexts/Book", + "@id": "/api/books/0114b2a8-3347-49d8-ad99-0e792c5a30e6", + "@type": "Book", + "author": "Melisa Kassulke", + "description": "Quaerat odit quia nisi accusantium natus voluptatem. Explicabo corporis eligendi ut ut sapiente ut qui quidem. Optio amet velit aut delectus. Sed alias asperiores perspiciatis deserunt omnis. Mollitia unde id in.", + "publicationDate": "1999-02-13T00:00:00+07:00", + "reviews": [], + "title": "Voluptas et tempora repellat corporis excepturi." + }, + "headers": { + "Content-Type": "application/ld+json; charset=utf-8" + }, + "matchingRules": { + "body": { + "$.author": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.description": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.publicationDate": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "^\\d{4}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d([+-][0-2]\\d:[0-5]\\d|Z)$" + } + ] + }, + "$.title": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$['@id']": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "^\\/api\\/books\\/[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$" + } + ] + } + }, + "header": {} + }, + "status": 200 + } + } + ], + "metadata": { + "pact-python": { + "ffi": "0.3.15" + }, + "pactRust": { + "ffi": "0.3.15", + "mockserver": "0.9.5", + "models": "1.0.0" + }, + "pactSpecification": { + "version": "3.0.0" + } + }, + "provider": { + "name": "http-provider" + } + } \ No newline at end of file diff --git a/examples/pacts/v3-message.json b/examples/pacts/v3-message.json new file mode 100644 index 000000000..ab1a3ced7 --- /dev/null +++ b/examples/pacts/v3-message.json @@ -0,0 +1,46 @@ +{ + "consumer": { + "name": "message-consumer-2" + }, + "messages": [ + { + "contents": { + "uuid": "fb5a885f-f7e8-4a50-950f-c1a64a94d500" + }, + "description": "Book (id fb5a885f-f7e8-4a50-950f-c1a64a94d500) created message", + "matchingRules": { + "body": { + "$.uuid": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$" + } + ] + } + } + }, + "metadata": { + "contentType": "application/json" + }, + "providerStates": [ + { + "name": "A book with id fb5a885f-f7e8-4a50-950f-c1a64a94d500 is required" + } + ] + } + ], + "metadata": { + "pactRust": { + "ffi": "0.3.15", + "models": "1.0.0" + }, + "pactSpecification": { + "version": "3.0.0" + } + }, + "provider": { + "name": "message-provider" + } + } \ No newline at end of file diff --git a/examples/pacts/v4-grpc.json b/examples/pacts/v4-grpc.json new file mode 100644 index 000000000..b3a8fff95 --- /dev/null +++ b/examples/pacts/v4-grpc.json @@ -0,0 +1,102 @@ +{ + "consumer": { + "name": "grpc-consumer-python" + }, + "interactions": [ + { + "description": "A gRPC calculateMulti request", + "interactionMarkup": { + "markup": "```protobuf\nmessage AreaResponse {\n repeated float value = 1;\n}\n```\n", + "markupType": "COMMON_MARK" + }, + "key": "103e10b578b91d6b", + "pending": false, + "pluginConfiguration": { + "protobuf": { + "descriptorKey": "a85dff8f82655a9681aad113575dcfbb", + "service": "Calculator/calculateOne" + } + }, + "request": { + "contents": { + "content": "EgoNAABAQBUAAIBA", + "contentType": "application/protobuf;message=ShapeMessage", + "contentTypeHint": "BINARY", + "encoded": "base64" + }, + "matchingRules": { + "body": { + "$.rectangle.length": { + "combine": "AND", + "matchers": [ + { + "match": "number" + } + ] + }, + "$.rectangle.width": { + "combine": "AND", + "matchers": [ + { + "match": "number" + } + ] + } + } + } + }, + "response": [ + { + "contents": { + "content": "CgQAAEBB", + "contentType": "application/protobuf;message=AreaResponse", + "contentTypeHint": "BINARY", + "encoded": "base64" + }, + "matchingRules": { + "body": { + "$.value[0].*": { + "combine": "AND", + "matchers": [ + { + "match": "number" + } + ] + } + } + } + } + ], + "transport": "grpc", + "type": "Synchronous/Messages" + } + ], + "metadata": { + "pact-python": { + "ffi": "0.3.15" + }, + "pactRust": { + "ffi": "0.3.15", + "mockserver": "0.9.5", + "models": "1.0.0" + }, + "pactSpecification": { + "version": "4.0" + }, + "plugins": [ + { + "configuration": { + "a85dff8f82655a9681aad113575dcfbb": { + "protoDescriptors": "CsoHChVhcmVhX2NhbGN1bGF0b3IucHJvdG8SD2FyZWFfY2FsY3VsYXRvciK6AgoMU2hhcGVNZXNzYWdlEjEKBnNxdWFyZRgBIAEoCzIXLmFyZWFfY2FsY3VsYXRvci5TcXVhcmVIAFIGc3F1YXJlEjoKCXJlY3RhbmdsZRgCIAEoCzIaLmFyZWFfY2FsY3VsYXRvci5SZWN0YW5nbGVIAFIJcmVjdGFuZ2xlEjEKBmNpcmNsZRgDIAEoCzIXLmFyZWFfY2FsY3VsYXRvci5DaXJjbGVIAFIGY2lyY2xlEjcKCHRyaWFuZ2xlGAQgASgLMhkuYXJlYV9jYWxjdWxhdG9yLlRyaWFuZ2xlSABSCHRyaWFuZ2xlEkYKDXBhcmFsbGVsb2dyYW0YBSABKAsyHi5hcmVhX2NhbGN1bGF0b3IuUGFyYWxsZWxvZ3JhbUgAUg1wYXJhbGxlbG9ncmFtQgcKBXNoYXBlIikKBlNxdWFyZRIfCgtlZGdlX2xlbmd0aBgBIAEoAlIKZWRnZUxlbmd0aCI5CglSZWN0YW5nbGUSFgoGbGVuZ3RoGAEgASgCUgZsZW5ndGgSFAoFd2lkdGgYAiABKAJSBXdpZHRoIiAKBkNpcmNsZRIWCgZyYWRpdXMYASABKAJSBnJhZGl1cyJPCghUcmlhbmdsZRIVCgZlZGdlX2EYASABKAJSBWVkZ2VBEhUKBmVkZ2VfYhgCIAEoAlIFZWRnZUISFQoGZWRnZV9jGAMgASgCUgVlZGdlQyJICg1QYXJhbGxlbG9ncmFtEh8KC2Jhc2VfbGVuZ3RoGAEgASgCUgpiYXNlTGVuZ3RoEhYKBmhlaWdodBgCIAEoAlIGaGVpZ2h0IkQKC0FyZWFSZXF1ZXN0EjUKBnNoYXBlcxgBIAMoCzIdLmFyZWFfY2FsY3VsYXRvci5TaGFwZU1lc3NhZ2VSBnNoYXBlcyIkCgxBcmVhUmVzcG9uc2USFAoFdmFsdWUYASADKAJSBXZhbHVlMq0BCgpDYWxjdWxhdG9yEk4KDGNhbGN1bGF0ZU9uZRIdLmFyZWFfY2FsY3VsYXRvci5TaGFwZU1lc3NhZ2UaHS5hcmVhX2NhbGN1bGF0b3IuQXJlYVJlc3BvbnNlIgASTwoOY2FsY3VsYXRlTXVsdGkSHC5hcmVhX2NhbGN1bGF0b3IuQXJlYVJlcXVlc3QaHS5hcmVhX2NhbGN1bGF0b3IuQXJlYVJlc3BvbnNlIgBCHFoXaW8ucGFjdC9hcmVhX2NhbGN1bGF0b3LQAgFiBnByb3RvMw==", + "protoFile": "syntax = \"proto3\";\n\npackage area_calculator;\n\noption php_generic_services = true;\noption go_package = \"io.pact/area_calculator\";\n\nservice Calculator {\n rpc calculateOne (ShapeMessage) returns (AreaResponse) {}\n rpc calculateMulti (AreaRequest) returns (AreaResponse) {}\n}\n\nmessage ShapeMessage {\n oneof shape {\n Square square = 1;\n Rectangle rectangle = 2;\n Circle circle = 3;\n Triangle triangle = 4;\n Parallelogram parallelogram = 5;\n }\n}\n\nmessage Square {\n float edge_length = 1;\n}\n\nmessage Rectangle {\n float length = 1;\n float width = 2;\n}\n\nmessage Circle {\n float radius = 1;\n}\n\nmessage Triangle {\n float edge_a = 1;\n float edge_b = 2;\n float edge_c = 3;\n}\n\nmessage Parallelogram {\n float base_length = 1;\n float height = 2;\n}\n\nmessage AreaRequest {\n repeated ShapeMessage shapes = 1;\n}\n\nmessage AreaResponse {\n repeated float value = 1;\n}" + } + }, + "name": "protobuf", + "version": "0.3.2" + } + ] + }, + "provider": { + "name": "area-calculator-provider" + } + } \ No newline at end of file diff --git a/examples/proto/area_calculator.proto b/examples/proto/area_calculator.proto new file mode 100644 index 000000000..a622379e9 --- /dev/null +++ b/examples/proto/area_calculator.proto @@ -0,0 +1,53 @@ +syntax = "proto3"; + +package area_calculator; + +option php_generic_services = true; +option go_package = "io.pact/area_calculator"; + +service Calculator { + rpc calculateOne (ShapeMessage) returns (AreaResponse) {} + rpc calculateMulti (AreaRequest) returns (AreaResponse) {} +} + +message ShapeMessage { + oneof shape { + Square square = 1; + Rectangle rectangle = 2; + Circle circle = 3; + Triangle triangle = 4; + Parallelogram parallelogram = 5; + } +} + +message Square { + float edge_length = 1; +} + +message Rectangle { + float length = 1; + float width = 2; +} + +message Circle { + float radius = 1; +} + +message Triangle { + float edge_a = 1; + float edge_b = 2; + float edge_c = 3; +} + +message Parallelogram { + float base_length = 1; + float height = 2; +} + +message AreaRequest { + repeated ShapeMessage shapes = 1; +} + +message AreaResponse { + repeated float value = 1; +} \ No newline at end of file diff --git a/pact/cli/verify.py b/pact/cli/verify.py index b388a5300..ad8dca66f 100644 --- a/pact/cli/verify.py +++ b/pact/cli/verify.py @@ -1,5 +1,6 @@ """Methods to verify previously created pacts.""" import sys +from setup import PACT_STANDALONE_VERSION from pact.verify_wrapper import path_exists, expand_directories, VerifyWrapper @@ -8,6 +9,7 @@ @click.command() @click.argument('pacts', nargs=-1) +@click.version_option("pact-ruby-standalone-v{}".format(PACT_STANDALONE_VERSION)) @click.option( 'base_url', '--provider-base-url', help='Base URL of the provider to verify against.', @@ -193,12 +195,12 @@ def main(pacts, base_url, pact_url, pact_urls, states_url, states_setup_url, options = dict(filter(lambda item: item[1] != '', options.items())) options = dict(filter(lambda item: is_empty_list(item), options.items())) - success, logs = VerifyWrapper().call_verify(*all_pact_urls, - provider=provider, - provider_base_url=base_url, - enable_pending=enable_pending, - include_wip_pacts_since=include_wip_pacts_since, - **options) + success, logs = VerifyWrapper().verify(*all_pact_urls, + provider=provider, + provider_base_url=base_url, + enable_pending=enable_pending, + include_wip_pacts_since=include_wip_pacts_since, + **options) sys.exit(success) diff --git a/pact/constants.py b/pact/constants.py index 9c6f7b117..c0677101f 100644 --- a/pact/constants.py +++ b/pact/constants.py @@ -46,3 +46,6 @@ def provider_verifier_exe(): VERIFIER_PATH = normpath(join( dirname(__file__), 'bin', 'pact', 'bin', provider_verifier_exe())) + +FFI_LIB_PATH = normpath(join( + dirname(__file__), 'bin')) diff --git a/pact/ffi/cli/verify.py b/pact/ffi/cli/verify.py new file mode 100644 index 000000000..9ec1df5cc --- /dev/null +++ b/pact/ffi/cli/verify.py @@ -0,0 +1,126 @@ +"""Methods to verify previously created pacts.""" +import re +import sys +from typing import Callable + +import click +from click.core import ParameterSource +from pact.ffi.verifier import Verifier, Arguments + + +def cli_options(): + """ + Dynamically construct the Click CLI options available to interface with the current version of the FFI library. + + This attempts to ensure there cannot be a mismatch between the two, and + means there doesn't need to be a duplication of logic. + """ + def inner_func(function: Callable) -> Callable: + verifier = Verifier() + args: Arguments = verifier.cli_args() + + # Handle the options requiring values + for opt in args.options: + type_choice = click.Choice(opt.possible_values) if opt.possible_values else None + + # Let the user know if an ENV can be used here instead + _help = "{}. Alternatively: {} if opt.env else ''".format(opt.help, click.style(opt.env, bold=True)) + + if opt.short: + function = click.option( + "-{}".format(opt.short), + "--{}".format(opt.long), + help=_help, + type=type_choice, + default=opt.default_value, + multiple=opt.multiple, + envvar=opt.env, + )(function) + else: + function = click.option( + "--{}".format(opt.long), + help=_help, + type=type_choice, + default=opt.default_value, + multiple=opt.multiple, + envvar=opt.env, + )(function) + + # Handle the boolean flags + for flag in args.flags: + # Let the user know if an ENV can be used here instead + # Note: future proofing, there do not seem to be any as of Pact FFI Library 0.0.2 + _help = "{}. Alternatively: {} if flag.env else ''".format(flag.help, click.style(flag.env, bold=True)) + + function = click.option("--{}".format(flag.long), help=_help, envvar=flag.env, is_flag=True)(function) + + function = click.option( + f'{"--debug-click"}', + help="Display arguments passed to the Pact Rust FFI library, for debugging pact-verifier wrapper", + is_flag=True, + )(function) + + return function + + return inner_func + + +@click.command(name="pact-verifier-ffi", context_settings=dict(max_content_width=120)) +@click.version_option("libpact_ffi-v{}".format(Verifier().version())) +@cli_options() +def main(**kwargs): + """ + Verify one or more contracts against a provider service. + + Minimal example: pact-verifier --hostname localhost --port 8080 -d ./pacts + """ + # Since we may only have default args, which are SOME args and we don't know + # which are required, make sure we have at least one CLI argument + ctx = click.get_current_context() + if not [key for key, value in kwargs.items() if ctx.get_parameter_source(key) != ParameterSource.DEFAULT]: + click.echo(ctx.get_help()) + sys.exit(0) + + verifier = Verifier() + + cli_args = verifier.args_dict_to_str(kwargs) + + if kwargs.get("debug_click"): + click.echo("kwargs received:") + click.echo(kwargs) + click.echo("") + + # To try and avoid confusion and help with debugging, notify the user when ENVs are being used + arguments_from_envs = [ + key for key, value in kwargs.items() if ctx.get_parameter_source(key) == ParameterSource.ENVIRONMENT + ] + if arguments_from_envs: + click.echo("The following arguments are using values provided by ENVs: {}".format(arguments_from_envs)) + click.echo("") + + click.echo("CLI args to send via FFI:") + click.echo(cli_args) + click.echo("") + + result = verifier.verify(cli_args) + + if kwargs.get("debug_click"): + click.echo("Result from FFI call to verify:") + click.echo("{}=".format(result.return_code)) + click.echo("{}=".format(result.logs)) + + # If the FFI method returned some log output + if result.logs: + for log in result.logs: + m = re.search('.*error verifying Pact: "error: (.*)", kind: .*', log) + if m: + for line in m.group(1).split("\\n"): + click.echo(line) + else: + click.echo(log) + + sys.exit(result.return_code) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pact/ffi/ffi_verifier.py b/pact/ffi/ffi_verifier.py new file mode 100644 index 000000000..0defd0f6e --- /dev/null +++ b/pact/ffi/ffi_verifier.py @@ -0,0 +1,235 @@ +"""Wrapper to pact reference dynamic libraries using FFI.""" +import json +import os +from pact.ffi.verifier import VerifyResult +from pact.pact_exception import PactException +from cffi import FFI +from pact.ffi.register_ffi import RegisterFfi +from pact.verify_wrapper import expand_directories, is_url + + +class FFIVerify(object): + """A Pact Verifier Wrapper.""" + + def version(self): + """Publish version info.""" + ffi = FFI() + lib = RegisterFfi().get_ffi_lib(ffi) + result = lib.pactffi_version() + return ffi.string(result).decode("utf-8") + + def verify( # noqa: max-complexity: 15 + self, + *pacts, + provider_base_url, + provider, + enable_pending=False, + include_wip_pacts_since=None, + **kwargs + ): + """Call verify method.""" + self._validate_input(pacts, **kwargs) + + # verbose = kwargs.get("verbose", False) + provider_app_version = kwargs.get("provider_app_version") + provider_version_branch = kwargs.get("provider_version_branch") + publish_verification_results = kwargs.get("publish_verification_results", False) + broker_username = kwargs.get("broker_username", None) + broker_password = kwargs.get("broker_password", None) + broker_token = kwargs.get("broker_token", None) + broker_url = kwargs.get("broker_url", None) + provider_states_setup_url = kwargs.get("provider_states_setup_url") + state_change_as_query = kwargs.get("state_change_as_query", True) + state_change_teardown = kwargs.get("state_change_teardown", False) + log_dir = kwargs.get("log_dir") + # TODO - log_level is applied globally + # doesnt seem to be a way to set a new log level + # during a test run + log_level = kwargs.get("log_level", "INFO") + provider_tags = kwargs.get("provider_tags", []) + consumer_version_selectors = kwargs.get("consumer_selectors", []) + consumer_version_tags = kwargs.get("consumer_tags", []) + request_timeout = kwargs.get("request_timeout", 10) + # Additional parameters + filter_description = kwargs.get("filter_description", None) + filter_state = kwargs.get("filter_state", None) + filter_no_state = kwargs.get("filter_no_state", False) + build_url = kwargs.get("build_url", None) + disable_ssl_verification = kwargs.get("disable_ssl_verification", False) + # Set it to greater than zero to enable an error when no pacts + # * are found to verify, and set it to zero to disable this. + no_pacts_is_error = kwargs.get("no_pacts_is_error", False) + # plugin_name = kwargs.get("plugin_name", None) + # plugin_version = kwargs.get("plugin_version", None) + provider_transport = kwargs.get("provider_transport", None) + if provider_base_url is not None: + provider_scheme = provider_base_url.split(":")[0] + provider_hostname = provider_base_url.split(":")[1].replace("//", "") + if provider_hostname is None: + provider_hostname = "localhost" + try: + provider_path = provider_hostname.split("/")[1] + except IndexError: + provider_path = "/" + try: + provider_port = int(provider_base_url.split(":")[2]) + except IndexError: + provider_port = 8000 + + def safe_encode(s): + return b"NULL" if s is None or "" else s.encode("ascii") + + def null_encode(): + return b"NULL" + + ffi = FFI() + lib = RegisterFfi().get_ffi_lib(ffi) + # lib.pactffi_log_to_stdout(5) + # lib.pactffi_log_to_buffer(1) + + LOG_LEVEL_MAPPING = { + "NONE": 0, + "ERROR": 1, + "WARN": 2, + "INFO": 3, + "DEBUG": 4, + "TRACE": 5, + } + if log_dir is not None: + lib.pactffi_log_to_file(safe_encode(os.path.join(log_dir, "pact.log")), 5) + else: + lib.pactffi_log_to_buffer(LOG_LEVEL_MAPPING[log_level]) + # lib.pactffi_log_to_stderr(LOG_LEVEL_MAPPING[log_level]) + + verifier = lib.pactffi_verifier_new_for_application(b"pact-python", b"1.0.0") + + lib.pactffi_verifier_set_provider_info( + verifier, + safe_encode(provider), + safe_encode(provider_scheme), + safe_encode(provider_hostname), + provider_port, + safe_encode(provider_path), + ) + if provider_scheme not in ("http", "https") and provider_transport is not None: + lib.pactffi_verifier_add_provider_transport( + verifier, + safe_encode(provider_transport), + provider_port, + safe_encode(provider_path), + safe_encode(provider_scheme), + ) + + lib.pactffi_verifier_set_verification_options(verifier, False, 5000) + + if provider_states_setup_url is not None: + lib.pactffi_verifier_set_provider_state(verifier, safe_encode(provider_states_setup_url), state_change_teardown, state_change_as_query) + + local_file = False + all_pact_urls = False + if pacts: + all_pact_urls = expand_directories(list(pacts)) + for pact_url in all_pact_urls: + if not is_url(pact_url): + local_file = True + + if all_pact_urls and local_file: + for pact in all_pact_urls: + lib.pactffi_verifier_add_file_source(verifier, pact.encode("ascii")) + elif all_pact_urls and local_file is False: + for pact in all_pact_urls: + lib.pactffi_verifier_url_source( + verifier, + pact.encode("ascii"), + safe_encode(broker_username), + safe_encode(broker_password), + safe_encode(broker_token), + ) + elif not all_pact_urls and ( + consumer_version_selectors is [] + and consumer_version_tags is [] + and enable_pending is False + and include_wip_pacts_since is None + ): + lib.pactffi_verifier_broker_source( + verifier, + safe_encode(broker_url), + safe_encode(broker_username), + safe_encode(broker_password), + safe_encode(broker_token), + ) + elif not all_pact_urls: + lib.pactffi_verifier_broker_source_with_selectors( + verifier, + safe_encode(broker_url), + safe_encode(broker_username), + safe_encode(broker_password), + safe_encode(broker_token), + 1 if enable_pending else 0, + safe_encode(include_wip_pacts_since) + if include_wip_pacts_since + else safe_encode(""), + ffi.new("char[]", json.dumps(provider_tags).encode("ascii")) + if provider_tags + else ffi.cast("void *", 0), # TODO + provider_tags.len() if provider_tags else 0, + safe_encode(provider_version_branch) + if provider_version_branch + else null_encode(), + ffi.new( + "char[]", json.dumps(consumer_version_selectors).encode("ascii") + ) + if consumer_version_selectors + else ffi.cast("void *", 0), # TODO + consumer_version_selectors.len() if consumer_version_selectors else 0, + ffi.new("char[]", json.dumps(consumer_version_tags).encode("ascii")) + if consumer_version_tags + else ffi.cast("void *", 0), # TODO + consumer_version_tags.len() if consumer_version_tags else 0, + ) + + if not all_pact_urls and publish_verification_results is True: + lib.pactffi_verifier_set_publish_options( + verifier, + safe_encode(provider_app_version), + safe_encode(build_url), + ffi.new("char[]", json.dumps(provider_tags).encode("ascii")) + if provider_tags + else ffi.cast("void *", 0), # TODO + provider_tags.len() if provider_tags else 0, + safe_encode(provider_version_branch) + if provider_version_branch + else null_encode(), + ) + + if no_pacts_is_error is True: + lib.pactffi_verifier_set_no_pacts_is_error(verifier, 1) + + if filter_state is not None or filter_description is not None: + lib.pactffi_verifier_set_filter_info( + verifier, + safe_encode(filter_description), + safe_encode(filter_state), + 1 if filter_no_state else 0, + ) + + lib.pactffi_verifier_set_verification_options( + verifier, 1 if disable_ssl_verification else 0, request_timeout + ) + result = lib.pactffi_verifier_execute(verifier) + get_logs = lib.pactffi_verifier_logs(verifier) + lib.pactffi_verifier_shutdown(verifier) + + logs = ffi.string(get_logs).decode("utf-8").rstrip().split("\n") + lib.pactffi_string_delete(get_logs) + # print(logs) + return VerifyResult(result, logs) + + def _validate_input(self, pacts, **kwargs): + if len(pacts) == 0 and not self._broker_present(**kwargs): + raise PactException("Pact urls or Pact broker required") + + def _broker_present(self, **kwargs): + if kwargs.get("broker_url") is None: + return False + return True diff --git a/pact/ffi/log.py b/pact/ffi/log.py new file mode 100644 index 000000000..8cce7676f --- /dev/null +++ b/pact/ffi/log.py @@ -0,0 +1,34 @@ +"""For handling the logging setup and output from the FFI library. + +As per: https://docs.rs/pact_ffi/0.0.2/pact_ffi/log/index.html +""" +from enum import unique, Enum + + +@unique +class LogToBufferStatus(Enum): + """Return codes from a request to setup a logger. + + As per: https://docs.rs/pact_ffi/0.0.2/pact_ffi/log/fn.pactffi_logger_attach_sink.html#error-handling + """ + + SUCCESS = 0 # Operation succeeded + CANT_SET_LOGGER = -1 # Can't set the logger + NO_LOGGER = -2 # No logger has been initialized + SPECIFIER_NOT_UTF8 = -3 # The sink specifier was not UTF-8 encoded + UNKNOWN_SINK_TYPE = -4 # The sink type specified is not a known type + MISSING_FILE_PATH = -5 # No file path was specified in the sink specification + CANT_OPEN_SINK_TO_FILE = -6 # Opening a sink to the given file failed + CANT_CONSTRUCT_SINK = -7 # Can't construct sink + + +@unique +class LogLevel(Enum): + """Log levels which can be used by the Verifier.""" + + OFF = 0 + ERROR = 1 + WARN = 2 + INFO = 3 + DEBUG = 4 + TRACE = 5 diff --git a/pact/ffi/pact_ffi.py b/pact/ffi/pact_ffi.py new file mode 100644 index 000000000..5caf6f00e --- /dev/null +++ b/pact/ffi/pact_ffi.py @@ -0,0 +1,97 @@ +"""Wrapper to pact reference dynamic libraries using FFI.""" +import tempfile +from typing import List + +from cffi import FFI +import threading + +from pact.ffi.log import LogToBufferStatus, LogLevel +from pact.ffi.register_ffi import RegisterFfi + + +class PactFFI(object): + """This interfaces with the Rust crate `pact_ffi`. + + Interface with the pact_ffi API via a C Foreign Function Interface. In the case of python, the library + is then accessed using `CFFI`. + + This class will implement the shared library loading, along with a wrapper + for the functions provided by the base crate. For each of the Rust modules + exposed, a corresponding python class will extend this base class, and + provide the wrapper for the functions the module provides. + + .. _pact_ffi: + https://docs.rs/pact_ffi/0.0.1/pact_ffi/index.html + .. _CFFI: + https://cffi.readthedocs.io/en/latest/ + """ + + ffi: FFI = None + lib = None + + _instance = None + _lock = threading.Lock() + + # Required if outputting logs to a file, can be remove if using a buffer + output_dir: tempfile.TemporaryDirectory = None + output_file: str = None + + def __new__(cls): + """Initiate the ffi.""" + # Make sure we only initialise once, or the log setup will fail + if not cls._instance: + with cls._lock: + if not cls._instance: + cls._instance = super(PactFFI, cls).__new__(cls) + cls.ffi = FFI() + + # # Define all the functions from the various modules, since we + # # can only load the library once + # cls.lib = RegisterFfi().get_ffi_lib(cls.ffi) + cls.lib = cls._load_ffi_library(cls.ffi) + + # We can setup logs like this, if preferred to buffer: + # The output will be stored in a file in this directory, which + # will be cleaned up automatically at the end + PactFFI.output_dir = tempfile.TemporaryDirectory() + # Setup logging to a file in the output_dir + # PactFFI.output_file = os.path.join(PactFFI.output_dir.name, "output") + # output_c = cls.ffi.new("char[]", bytes(cls.output_file, "utf-8")) + # result = cls.lib.pactffi_log_to_file(output_c, LogLevel.INFO.value) + # assert LogToBufferStatus(result) == LogToBufferStatus.SUCCESS + + # Having problems with the buffer output, when running via CLI + # Reverting to log file output instead + result = cls.lib.pactffi_log_to_buffer(LogLevel.INFO.value) + assert LogToBufferStatus(result) == LogToBufferStatus.SUCCESS + return cls._instance + + def version(self) -> str: + """Get the current library version. + + :return: pact_ffi library version, for example "0.0.1" + """ + result = self.lib.pactffi_version() + return self.ffi.string(result).decode("utf-8") + + @staticmethod + def _load_ffi_library(ffi): + return RegisterFfi().get_ffi_lib(ffi) + + def get_logs(self) -> List[str]: + """Retrieve the contents of the FFI log buffer. + + :return: List of log entries, each a line of log output + """ + # Having problems with the buffer output, when running via CLI + # Reverting to log file output instead + result = self.lib.pactffi_fetch_log_buffer(b'NULL') + logs = self.ffi.string(result).decode("utf-8").rstrip().split("\n") + print(f"{logs=}") + self.lib.pactffi_string_delete(result) + return logs + + # # If using log to file, retrieve like this: + # lines = open(PactFFI.output_file).readlines() + # open(PactFFI.output_file, "w").close() + # return [line.lstrip("\x00") for line in lines] diff --git a/pact/ffi/register_ffi.py b/pact/ffi/register_ffi.py new file mode 100644 index 000000000..23bc63cf9 --- /dev/null +++ b/pact/ffi/register_ffi.py @@ -0,0 +1,68 @@ +"""Registers our FFI library.""" +import platform +import os +import sys +from pact.constants import FFI_LIB_PATH + +class RegisterFfi(object): + """Registers our FFI library.""" + + IS_64 = sys.maxsize > 2 ** 32 + + DIRECTIVES = [ + "#ifndef pact_ffi_h", + "#define pact_ffi_h", + "#include ", + "#include ", + "#include ", + "#include ", + "#endif /* pact_ffi_h */" + ] + + FFI_HEADER_PATH = os.path.join(FFI_LIB_PATH, "pact.h") + + def process_pact_header_file(self, file): + """Process the pact header file.""" + with open(file, "r") as fp: + lines = fp.readlines() + + pactfile = [] + + for line in lines: + if line.strip("\n") not in self.DIRECTIVES: + pactfile.append(line) + + return ''.join(pactfile) + + def load_ffi_library(self, ffi): + """Load the right library.""" + target_platform = platform.platform().lower() + # print(target_platform) + # print(platform.machine()) + + if ("darwin" in target_platform or "macos" in target_platform) and ("aarch64" in platform.machine() or "arm64" in platform.machine()): + libname = os.path.join(FFI_LIB_PATH, "libpact_ffi-osx-aarch64-apple-darwin.dylib") + elif "darwin" in target_platform or "macos" in target_platform: + libname = os.path.join(FFI_LIB_PATH, "libpact_ffi-osx-x86_64.dylib") + elif "linux" in target_platform and self.IS_64 and ("aarch64" in platform.machine() or "arm64" in platform.machine()): + libname = os.path.join(FFI_LIB_PATH, "libpact_ffi-linux-aarch64.so") + elif "linux" in target_platform and self.IS_64: + libname = os.path.join(FFI_LIB_PATH, "libpact_ffi-linux-x86_64.so") + elif 'windows' in target_platform: + libname = os.path.join(FFI_LIB_PATH, "pact_ffi-windows-x86_64.dll") + else: + msg = ('Unfortunately, {} is not a supported platform. Only Linux,' + ' Windows, and OSX are currently supported.').format(target_platform) + raise Exception(msg) + + return ffi.dlopen(libname) + + def load_ffi_headers(self, ffi): + """Load the FFI headers.""" + return ffi.cdef(self.process_pact_header_file(self.FFI_HEADER_PATH)) + + def get_ffi_lib(self, ffi): + """Load the FFI library.""" + self.load_ffi_headers(ffi) + lib = self.load_ffi_library(ffi) + return lib diff --git a/pact/ffi/verifier.py b/pact/ffi/verifier.py new file mode 100644 index 000000000..c603d3923 --- /dev/null +++ b/pact/ffi/verifier.py @@ -0,0 +1,137 @@ +"""Wrapper to pact reference dynamic libraries using FFI.""" +from enum import Enum, unique +from typing import Dict, NamedTuple, List + +from pact.ffi.pact_ffi import PactFFI +import json + + +@unique +class VerifyStatus(Enum): + """Return codes from a verify request. + + As per: https://docs.rs/pact_ffi/0.0.2/pact_ffi/verifier/fn.pactffi_verify.html + """ + + SUCCESS = 0 # Operation succeeded + VERIFIER_FAILED = 1 # The verification process failed, see output for errors + NULL_POINTER = 2 # A null pointer was received + PANIC = 3 # The method panicked + INVALID_ARGS = 4 # Invalid arguments were provided to the verification process + + +class VerifyResult(NamedTuple): + """Wrap up the return code, and log output.""" + + return_code: VerifyStatus + logs: List[str] + + +class Argument: + """Hold the attributes of a single argument which can be used by the Verifier.""" + + long: str # For example: "token" + short: str = None # For example "t" + help: str # Help description, for example: "Bearer token to use when fetching pacts from URLS" + default_value: str = None # The value which will be passed if none are provided, such as "http" for schema + possible_values: List[str] = None # If only specific values can be used, such as ["http", "https"] for schema + multiple: bool # If the argument can be provided multiple times, for example with file + env: str = None # ENV which will be used in the absence of a provided argument, for example PACT_BROKER_TOKEN + + def __init__( + self, + long: str, + help: str, + multiple: bool, + short: str = None, + default_value: str = None, + possible_values: List[str] = None, + env: str = None, + ): + """Create a new Argument object.""" + self.long = long + self.short = short + self.help = help + self.default_value = default_value + self.possible_values = possible_values + self.multiple = multiple + self.env = env + + +class Arguments: + """Hold the various options and flags which can be used by the Verifier.""" + + options: List[Argument] = [] + flags: List[Argument] = [] + + def __init__(self, options: List[Argument], flags: List[Argument]): + """Create a new Arguments object.""" + self.options = [Argument(**option) for option in options] + self.flags = [Argument(**flags) for flags in flags] + + +class Verifier(PactFFI): + """A Pact Verifier Wrapper. + + This interfaces with the Rust FFI crate pact_ffi, specifically the + `verifier`_ module. + + .. _verifier: + https://docs.rs/pact_ffi/0.0.2/pact_ffi/verifier/index.html + """ + + def __new__(cls): + """Create a new instance of the Verifier.""" + return super(Verifier, cls).__new__(cls) + + def verify(self, args=None) -> VerifyResult: + """Call verify method.""" + # The FFI library specifically defines "usage" of no args, so we will + # replicate that here. In reality we will always want args. + if args: + c_args = self.ffi.new("char[]", bytes(args, "utf-8")) + else: + c_args = self.ffi.NULL + + result = self.lib.pactffi_verify(c_args) + logs = self.get_logs() + return VerifyResult(result, logs) + + def _cli_args_raw(self) -> Dict: + """Call and return the output from the pactffi_verifier_cli_args method. + + :return: The arguments, in raw dict form + """ + result = self.lib.pactffi_verifier_cli_args() + arguments = json.loads(self.ffi.string(result).decode("utf-8")) + # print(arguments) + self.lib.pactffi_free_string(result) + return arguments + + def cli_args(self) -> Arguments: + """Retrieve the Arguments available to the Pact Verifier. + + :return: The arguments, in a Arguments structure + """ + arguments = Arguments(**self._cli_args_raw()) + return arguments + + @staticmethod + def args_dict_to_str(cli_args_dict: Dict) -> str: + """Convert a dict of arguments to the delimited str required to call the FFI function.""" + cli_args = "" + for key, value in cli_args_dict.items(): + # Special case, don't pass through the debug flag for Click + if key == "debug_click": + continue + + key_arg = key.replace("_", "-") # Snake case for python, kebab case for CLI + if value and isinstance(value, bool): + cli_args = f"{cli_args}\n--{key_arg}" + elif value and isinstance(value, str): + cli_args = f"{cli_args}\n--{key_arg}={value}" + elif value and isinstance(value, tuple) or isinstance(value, list): + for multiple_opt in value: + cli_args = f"{cli_args}\n--{key_arg}={multiple_opt}" + cli_args = cli_args.strip() + return cli_args diff --git a/pact/ffi/verifier_args.py b/pact/ffi/verifier_args.py new file mode 100644 index 000000000..d2f6949b2 --- /dev/null +++ b/pact/ffi/verifier_args.py @@ -0,0 +1,100 @@ +"""This module contains the dataclass that represents the arguments available to the pact verifier.""" +import typing +from dataclasses import dataclass, field + + +@dataclass +class VerifierArgs: + """Auto-generated class, containing the arguments available to the pact verifier.""" + + # Log level (defaults to warn) + loglevel: typing.Optional[str] = None + + # URL of the pact broker to fetch pacts from to verify (requires the provider name parameter) + broker_url: typing.Optional[str] = None + + # Provider hostname (defaults to localhost) + hostname: typing.Optional[str] = None + + # Provider port (defaults to protocol default 80/443) + port: typing.Optional[str] = None + + # Provider URI scheme (defaults to http) + scheme: typing.Optional[str] = None + + # Provider name (defaults to provider) + provider_name: typing.Optional[str] = None + + # URL to post state change requests to + state_change_url: typing.Optional[str] = None + + # Only validate interactions whose descriptions match this filter + filter_description: typing.Optional[str] = None + + # Only validate interactions whose provider states match this filter + filter_state: typing.Optional[str] = None + + # Only validate interactions that have no defined provider state + filter_no_state: typing.Optional[str] = None + + # Username to use when fetching pacts from URLS + user: typing.Optional[str] = None + + # Password to use when fetching pacts from URLS + password: typing.Optional[str] = None + + # Bearer token to use when fetching pacts from URLS + token: typing.Optional[str] = None + + # Provider version that is being verified. This is required when publishing results. + provider_version: typing.Optional[str] = None + + # URL of the build to associate with the published verification results. + build_url: typing.Optional[str] = None + + # Provider tags to use when publishing results. Accepts comma-separated values. + provider_tags: typing.Optional[str] = None + + # Base path to add to all requests + base_path: typing.Optional[str] = None + + # Consumer tags to use when fetching pacts from the Broker. Accepts comma-separated values. + consumer_version_tags: typing.Optional[str] = None + + # Consumer version selectors to use when fetching pacts from the Broker. Accepts a JSON string as + # per https://docs.pact.io/pact_broker/advanced_topics/consumer_version_selectors/ + consumer_version_selectors: typing.Optional[str] = None + + # Allow pacts that don't match given consumer selectors (or tags) to be verified, without causing + # the overall task to fail. For more information, see https://pact.io/wip + include_wip_pacts_since: typing.Optional[str] = None + + # Sets the HTTP request timeout in milliseconds for requests to the target API and for state change requests. + request_timeout: typing.Optional[str] = None + + # Pact file to verify (can be repeated) + file: typing.Optional[typing.List[str]] = field(default_factory=list) + + # Directory of pact files to verify (can be repeated) + dir: typing.Optional[typing.List[str]] = field(default_factory=list) + + # URL of pact file to verify (can be repeated) + url: typing.Optional[typing.List[str]] = field(default_factory=list) + + # Consumer name to filter the pacts to be verified (can be repeated) + filter_consumer: typing.Optional[typing.List[str]] = field(default_factory=list) + + # State change request data will be sent as query parameters instead of in the request body + state_change_as_query: typing.Optional[bool] = None + + # State change teardown requests are to be made after each interaction + state_change_teardown: typing.Optional[bool] = None + + # Enables publishing of verification results back to the Pact Broker. Requires the broker-url and provider-version parameters. + publish: typing.Optional[bool] = None + + # Disables validation of SSL certificates + disable_ssl_verification: typing.Optional[bool] = None + + # Enables Pending Pacts + enable_pending: typing.Optional[bool] = None diff --git a/pact/ffi/verify_wrapper.py b/pact/ffi/verify_wrapper.py new file mode 100644 index 000000000..c293abb46 --- /dev/null +++ b/pact/ffi/verify_wrapper.py @@ -0,0 +1,7 @@ +"""A Pact Verifier Wrapper.""" +class VerifyWrapper(object): + """A Pact Verifier Wrapper.""" + + def verify(self): + """Call verify method.""" + pass diff --git a/pact/http_proxy.py b/pact/http_proxy.py index 23c9ebda9..39ddfdd53 100644 --- a/pact/http_proxy.py +++ b/pact/http_proxy.py @@ -1,5 +1,5 @@ """Http Proxy to be used as provider url in verifier.""" -from fastapi import FastAPI, status, Request, HTTPException +from fastapi import FastAPI, Response, status, Request, HTTPException import uvicorn as uvicorn import logging log = logging.getLogger(__name__) @@ -29,11 +29,15 @@ def _match_states(payload): @app.post("/") -async def root(request: Request): +async def root(request: Request, response: Response): """Match states with provided message handlers.""" payload = await request.json() message = _match_states(payload) - return {'contents': message} + # TODO:- Read message metadata from request, parse as json + # and base64 encode - the example below is {"Content-Type": "application/json"} + # https://github.com/pact-foundation/pact-reference/tree/master/rust/pact_verifier_cli#verifying-metadata + response.headers["Pact-Message-Metadata"] = "eyJDb250ZW50LVR5cGUiOiAiYXBwbGljYXRpb24vanNvbiJ9Cg==" + return message @app.get('/ping', status_code=status.HTTP_200_OK) diff --git a/pact/message_provider.py b/pact/message_provider.py index 4774fc84a..8a0b6b8a5 100644 --- a/pact/message_provider.py +++ b/pact/message_provider.py @@ -6,7 +6,10 @@ from requests.adapters import HTTPAdapter from requests.packages.urllib3 import Retry from multiprocessing import Process -from .verifier import Verifier + +from pact.ffi.ffi_verifier import FFIVerify +from pact.ffi.verifier import VerifyResult +# from .verifier import Verifier from .http_proxy import run_proxy import logging @@ -35,7 +38,8 @@ def __init__( pact_dir=os.getcwd(), version="3.0.0", proxy_host='localhost', - proxy_port='1234' + proxy_port='1234', + **kwargs ): """Create a Message Provider instance.""" self.message_providers = message_providers @@ -107,13 +111,16 @@ def _stop_proxy(self): if isinstance(self._process, Process): self._wait_for_server_stop() - def verify(self): + def verify(self, **kwargs): """Verify pact files with executable verifier.""" pact_files = f'{self.pact_dir}/{self._pact_file()}' - verifier = Verifier(provider=self.provider, - provider_base_url=self._proxy_url()) - return_code, _ = verifier.verify_pacts(pact_files, verbose=False) - assert (return_code == 0), f'Expected returned_code = 0, actual = {return_code}' + # verifier = Verifier(provider=self.provider, + # provider_base_url=self._proxy_url()) + verifier = FFIVerify() + return_code, logs = verifier.verify(pact_files, provider=self.provider, + provider_base_url=self._proxy_url(), verbose=False, **kwargs) + # assert (return_code == 0), f'Expected returned_code = 0, actual = {return_code}' + return VerifyResult(return_code, logs) def verify_with_broker(self, enable_pending=False, include_wip_pacts_since=None, **kwargs): """Use Broker to verify. @@ -122,17 +129,21 @@ def verify_with_broker(self, enable_pending=False, include_wip_pacts_since=None, broker_username ([String]): broker username broker_password ([String]): broker password broker_url ([String]): url of broker - enable_pending ([Boolean]) - include_wip_pacts_since ([String]) - publish_version ([String]) + enable_pending ([Boolean]): enable pending pacts + include_wip_pacts_since ([String]): include wip pacts since + publish_version ([String]): publish version + pacts ([String]): pacts to verify """ - verifier = Verifier(provider=self.provider, - provider_base_url=self._proxy_url()) + # verifier = Verifier(provider=self.provider, + # provider_base_url=self._proxy_url()) + verifier = FFIVerify() - return_code, _ = verifier.verify_with_broker(enable_pending, include_wip_pacts_since, **kwargs) + return_code, logs = verifier.verify(provider=self.provider, provider_base_url=self._proxy_url(), + enable_pending=enable_pending, include_wip_pacts_since=include_wip_pacts_since, **kwargs) - assert (return_code == 0), f'Expected returned_code = 0, actual = {return_code}' + # assert (return_code == 0), f'Expected returned_code = 0, actual = {return_code}' + return VerifyResult(return_code, logs) def __enter__(self): """ diff --git a/pact/pact.py b/pact/pact.py index 28126bfc6..f6ed25952 100644 --- a/pact/pact.py +++ b/pact/pact.py @@ -276,7 +276,7 @@ def verify(self): """ self._interactions = [] resp = requests.get( - self.uri + "/interactions/verification", headers=self.HEADERS, verify=False + self.uri + "/interactions/verification", headers=self.HEADERS, verify=False, allow_redirects=True ) assert resp.status_code == 200, resp.text resp = requests.post(self.uri + "/pact", headers=self.HEADERS, verify=False) diff --git a/pact/pact_exception.py b/pact/pact_exception.py new file mode 100644 index 000000000..7d7b9e5b1 --- /dev/null +++ b/pact/pact_exception.py @@ -0,0 +1,21 @@ +"""Custom Pact Exception.""" + +class PactException(Exception): + """PactException when input isn't valid. + + Args: + Exception ([type]): [description] + + Raises: + KeyError: [description] + Exception: [description] + + Returns: + [type]: [description] + + """ + + def __init__(self, *args, **kwargs): + """Create wrapper.""" + super().__init__(*args, **kwargs) + self.message = args[0] diff --git a/pact/verifier.py b/pact/verifier.py index 4388e1095..5e78eaa55 100644 --- a/pact/verifier.py +++ b/pact/verifier.py @@ -27,6 +27,10 @@ def __str__(self): """ return 'Verifier for {} with url {}'.format(self.provider, self.provider_base_url) + def version(self): + """Return version info.""" + return VerifyWrapper().version() + def validate_publish(self, **kwargs): """Validate publish has a version.""" if (kwargs.get('publish') is not None) and (kwargs.get('publish_version') is None): @@ -50,16 +54,16 @@ def verify_pacts(self, *pacts, enable_pending=False, include_wip_pacts_since=Non pacts = expand_directories(pacts) options = self.extract_params(**kwargs) - success, logs = VerifyWrapper().call_verify(*pacts, - provider=self.provider, - provider_base_url=self.provider_base_url, - enable_pending=enable_pending, - include_wip_pacts_since=include_wip_pacts_since, - **options) + success, logs = VerifyWrapper().verify(*pacts, + provider=self.provider, + provider_base_url=self.provider_base_url, + enable_pending=enable_pending, + include_wip_pacts_since=include_wip_pacts_since, + **options) return success, logs - def verify_with_broker(self, enable_pending=False, include_wip_pacts_since=None, **kwargs): + def verify_with_broker(self, pacts=None, enable_pending=False, include_wip_pacts_since=None, **kwargs): """Use Broker to verify. Args: @@ -69,6 +73,7 @@ def verify_with_broker(self, enable_pending=False, include_wip_pacts_since=None, enable_pending ([Boolean]) include_wip_pacts_since ([String]) publish_version ([String]) + pacts ([String]) """ broker_username = kwargs.get('broker_username', None) @@ -84,11 +89,12 @@ def verify_with_broker(self, enable_pending=False, include_wip_pacts_since=None, } options.update(self.extract_params(**kwargs)) - success, logs = VerifyWrapper().call_verify(provider=self.provider, - provider_base_url=self.provider_base_url, - enable_pending=enable_pending, - include_wip_pacts_since=include_wip_pacts_since, - **options) + success, logs = VerifyWrapper().verify(pacts=pacts, + provider=self.provider, + provider_base_url=self.provider_base_url, + enable_pending=enable_pending, + include_wip_pacts_since=include_wip_pacts_since, + **options) return success, logs def extract_params(self, **kwargs): diff --git a/pact/verify_wrapper.py b/pact/verify_wrapper.py index 0789b6aef..5098aa9c8 100644 --- a/pact/verify_wrapper.py +++ b/pact/verify_wrapper.py @@ -1,6 +1,7 @@ """Wrapper to verify previously created pacts.""" from pact.constants import VERIFIER_PATH +from pact.pact_exception import PactException import sys import os import platform @@ -8,8 +9,25 @@ import subprocess from os.path import isdir, join, isfile from os import listdir +from urllib.parse import urlparse +def is_url(path): + """Determine if a string is a valid url. + + Can be provided a URL or local path. URLs always result in a True. + + :param path: The path to check. + :type path: str + :return: True if url otherwise False. + :rtype: bool + """ + try: + result = urlparse(path) + return all([result.scheme, result.netloc]) + except ValueError: + return False + def capture_logs(process, verbose): """Capture logs from ruby process.""" result = '' @@ -102,26 +120,6 @@ def rerun_command(): env['PACT_INTERACTION_RERUN_COMMAND'] = command return env -class PactException(Exception): - """PactException when input isn't valid. - - Args: - Exception ([type]): [description] - - Raises: - KeyError: [description] - Exception: [description] - - Returns: - [type]: [description] - - """ - - def __init__(self, *args, **kwargs): - """Create wrapper.""" - super().__init__(*args, **kwargs) - self.message = args[0] - class VerifyWrapper(object): """A Pact Verifier Wrapper.""" @@ -134,17 +132,18 @@ def _validate_input(self, pacts, **kwargs): if len(pacts) == 0 and not self._broker_present(**kwargs): raise PactException('Pact urls or Pact broker required') - def call_verify( + def verify( # noqa: max-complexity: 15 self, *pacts, provider_base_url, provider, enable_pending=False, include_wip_pacts_since=None, **kwargs ): """Call verify method.""" verbose = kwargs.get('verbose', False) - - self._validate_input(pacts, **kwargs) + # if pacts: + # self._validate_input(pacts, **kwargs) provider_app_version = kwargs.get('provider_app_version') provider_version_branch = kwargs.get('provider_version_branch') + publish_verification_results = kwargs.get('publish_verification_results', False) options = { '--provider-base-url': provider_base_url, '--provider': provider, @@ -158,42 +157,53 @@ def call_verify( } command = [VERIFIER_PATH] - all_pact_urls = expand_directories(list(pacts)) + local_file = False + all_pact_urls = False + if pacts: + all_pact_urls = expand_directories(list(pacts)) + for pact_url in all_pact_urls: + if not is_url(pact_url): + local_file = True + + command.extend(all_pact_urls) - command.extend(all_pact_urls) command.extend(['{}={}'.format(k, v) for k, v in options.items() if v]) + if not all_pact_urls and not kwargs.get('broker_url', None): + raise PactException('Pact urls or Pact broker required') + + if publish_verification_results is True and local_file: + raise PactException('Cannot publish verification results for local files') + if (provider_app_version): - command.extend(["--provider-app-version", - provider_app_version]) + command.extend(["--provider-app-version", provider_app_version]) - if (kwargs.get('publish_verification_results', False) is True): + if publish_verification_results is True and not local_file: command.extend(['--publish-verification-results']) - if (kwargs.get('verbose', False) is True): + if verbose: command.extend(['--verbose']) - if enable_pending: - command.append('--enable-pending') - - else: - command.append('--no-enable-pending') - - if include_wip_pacts_since: - command.extend(['--include-wip-pacts-since={}'.format(include_wip_pacts_since)]) - if provider_version_branch: command.extend(["--provider-version-branch={}".format(provider_version_branch)]) headers = kwargs.get('custom_provider_headers', []) for header in headers: command.extend(['{}={}'.format('--custom-provider-header', header)]) - for tag in kwargs.get('consumer_tags', []): - command.extend(["--consumer-version-tag={}".format(tag)]) - for tag in kwargs.get('consumer_selectors', []): - command.extend(["--consumer-version-selector={}".format(tag)]) - for tag in kwargs.get('provider_tags', []): - command.extend(["--provider-version-tag={}".format(tag)]) + + if not all_pact_urls: + if enable_pending: + command.append('--enable-pending') + else: + command.append('--no-enable-pending') + if include_wip_pacts_since: + command.extend(['--include-wip-pacts-since={}'.format(include_wip_pacts_since)]) + for tag in kwargs.get('provider_tags', []): + command.extend(["--provider-version-tag={}".format(tag)]) + for selector in kwargs.get('consumer_selectors', []): + command.extend(["--consumer-version-selector={}".format(selector)]) + for tag in kwargs.get('consumer_tags', []): + command.extend(["--consumer-version-tag={}".format(tag)]) env = rerun_command() result = subprocess.Popen(command, bufsize=1, env=env, stdout=subprocess.PIPE, @@ -205,12 +215,6 @@ def call_verify( return result.returncode, logs - def publish_results(self, provider_app_version, command): - """Publish results to broker.""" - if not provider_app_version: - # todo implement - raise Exception('todo') - - command.extend(["--provider-app-version", - provider_app_version, - "--publish-verification-results"]) + def version(self): + """Publish version info.""" + return '0.0.0' diff --git a/requirements_dev.txt b/requirements_dev.txt index e179ddbee..d7f1c29e1 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -24,3 +24,7 @@ markupsafe==2.0.1; python_version < '3.7' markupsafe==2.1.2; python_version >= '3.7' httpx==0.22.0; python_version < '3.7' httpx==0.23.3; python_version >= '3.7' +cffi>=1.14.6 +pytest-httpserver==1.0.1 +grpcio==1.56.2 +grpcio-tools==1.56.2 \ No newline at end of file diff --git a/script/generate_verifier_args.py b/script/generate_verifier_args.py new file mode 100755 index 000000000..191422938 --- /dev/null +++ b/script/generate_verifier_args.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python +""" +Generate Verifier args class + +This script generates the python source for a class to be used for the Pact +verifier. It works by parsing the JSON produced by the Rust Pact FFI library +function "pactffi_verifier_cli_args". + +Each argument available to the Rust Verifier is added as a line, along with the +description provided from Rust. This covers the single option argument, bool +arguments, and lists of options. +""" +import json +from pathlib import Path +from typing import List +from typing import Optional + +from pact.ffi.verifier import Verifier + + +def generate_verifier_args_json(pact_ffi_args_json: str) -> None: + """Call the Rust Pact FFI library to identify the args, and write to a file""" + arguments = Verifier()._cli_args_raw() + + with open(pact_ffi_args_json, "w") as f: + f.writelines(json.dumps(arguments, indent=2)) + + +def generate_verifier_args_source(pact_ffi_args_json: str) -> None: + """Take the generated JSON file, and construct a quick and dirty class""" + cli_json_path = Path.cwd().joinpath(pact_ffi_args_json) + with open(cli_json_path) as json_file: + data = json.load(json_file) + + list_options = [ + (attribute["long"].replace("-", "_"), Optional[List[str]], attribute["help"], "field(default_factory=list)") + for attribute in data.get("options") + if attribute["multiple"] + ] + str_options = [ + (attribute["long"].replace("-", "_"), Optional[str], attribute["help"], None) + for attribute in data.get("options") + if not attribute["multiple"] + ] + bool_options = [ + (attribute["long"].replace("-", "_"), Optional[bool], attribute["help"], None) + for attribute in data.get("flags") + ] + + nl = "\n" + lines = ( + f"import typing{nl}" + f"from dataclasses import dataclass, field{nl}" + f"{nl}" + f"{nl}" + f"@dataclass{nl}" + f"class VerifierArgs:{nl}" + f' """Auto-generated class, containing the arguments available to the pact verifier."""{nl}' + f"{nl}" + f'{nl.join([f" # {option[2]}{nl} {option[0]}: {option[1]} = {option[3]}{nl}" for option in str_options + list_options + bool_options])}' + "" + ) + print(lines) + + with open("pact/ffi/verifier_args.py", "w") as f: + f.writelines(lines) + + +if __name__ == "__main__": + pact_ffi_args_json = "examples/ffi/pact_ffi_verifier_args.json" + + generate_verifier_args_json(pact_ffi_args_json) + generate_verifier_args_source(pact_ffi_args_json) diff --git a/script/install_plugins.sh b/script/install_plugins.sh new file mode 100755 index 000000000..34b93d733 --- /dev/null +++ b/script/install_plugins.sh @@ -0,0 +1,59 @@ +#!/bin/bash -e +# +# Usage: +# $ curl -fsSL https://raw.githubusercontent.com/pact-foundation/pact-plugins/master/install-cli.sh | bash +# or +# $ wget -q https://raw.githubusercontent.com/pact-foundation/pact-plugins/master/install-cli.sh -O- | bash +# +set -e # Needed for Windows bash, which doesn't read the shebang + +function detect_osarch() { + case $(uname -sm) in + 'Linux x86_64') + os='linux' + arch='x86_64' + ;; + 'Linux aarch64') + os='linux' + arch='aarch64' + ;; + 'Darwin x86' | 'Darwin x86_64') + os='osx' + arch='x86_64' + ;; + 'Darwin arm64') + os='osx' + arch='aarch64' + ;; + CYGWIN*|MINGW32*|MSYS*|MINGW*) + os="windows" + arch='x86_64' + ext='.exe' + ;; + *) + echo "Sorry, you'll need to install the plugin CLI manually." + exit 1 + ;; + esac +} + + +PLUGIN_CLI_VERSION="0.1.0" +PROTOBUF_PLUGIN_VERSION="0.3.4" +detect_osarch + +if [ ! -f ~/.pact/bin/pact-plugin-cli ]; then + echo "--- 🐿 Installing plugins CLI version '${VERSION}' (from tag ${TAG})" + mkdir -p ~/.pact/bin + DOWNLOAD_LOCATION=https://github.com/pact-foundation/pact-plugins/releases/download/pact-plugin-cli-v${PLUGIN_CLI_VERSION}/pact-plugin-cli-${os}-${arch}${ext}.gz + echo " Downloading from: ${DOWNLOAD_LOCATION}" + curl -L -o ~/.pact/bin/pact-plugin-cli-${os}-${arch}.gz "${DOWNLOAD_LOCATION}" + echo " Downloaded $(file ~/.pact/bin/pact-plugin-cli-${os}-${arch}.gz)" + gunzip -N -f ~/.pact/bin/pact-plugin-cli-${os}-${arch}.gz + chmod +x ~/.pact/bin/pact-plugin-cli +fi + +if [ ! -d ~/.pact/plugins/protobuf-${PROTOBUF_PLUGIN_VERSION} ]; then + echo "--- 🐿 Installing protobuf plugin" + ~/.pact/bin/pact-plugin-cli install https://github.com/pactflow/pact-protobuf-plugin/releases/tag/v-${PROTOBUF_PLUGIN_VERSION} +fi \ No newline at end of file diff --git a/script/release_prep.sh b/script/release_prep.sh index b6bf29ca2..91cddbd1b 100755 --- a/script/release_prep.sh +++ b/script/release_prep.sh @@ -2,7 +2,7 @@ VERSION=$1 -if [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]*$ ]]; then +if [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+[A-z01]*$ ]]; then echo "Updating version $VERSION." else echo "Invalid version number $VERSION" diff --git a/setup.py b/setup.py index 2269af5f5..abec9776d 100644 --- a/setup.py +++ b/setup.py @@ -1,35 +1,53 @@ """pact-python PyPI Package.""" +import gzip import os import platform import shutil import sys import tarfile - +from distutils.command.sdist import sdist as sdist_orig +from typing import NamedTuple from zipfile import ZipFile - from setuptools import setup from setuptools.command.develop import develop from setuptools.command.install import install -from distutils.command.sdist import sdist as sdist_orig - IS_64 = sys.maxsize > 2 ** 32 PACT_STANDALONE_VERSION = '2.0.3' -PACT_STANDALONE_SUFFIXES = ['osx-x86_64.tar.gz', - 'osx-arm64.tar.gz', - 'linux-x86_64.tar.gz', - 'linux-arm64.tar.gz', - 'windows-x86_64.zip', - 'windows-x86.zip', - ] +PACT_STANDALONE_SUFFIXES = [ + 'osx-x86_64.tar.gz', + 'osx-arm64.tar.gz', + 'linux-x86_64.tar.gz', + 'linux-arm64.tar.gz', + 'windows-x86_64.zip', + 'windows-x86.zip', +] +PACT_FFI_VERSION = "0.4.5" +PACT_FFI_FILENAMES = [ + "libpact_ffi-linux-aarch64.so.gz", + "libpact_ffi-linux-x86_64.so.gz", + "libpact_ffi-osx-aarch64-apple-darwin.dylib.gz", + "libpact_ffi-osx-x86_64.dylib.gz", + "pact_ffi-windows-x86_64.dll.gz", +] +PACT_RUBY_FILENAME = "pact-{version}-{suffix}" here = os.path.abspath(os.path.dirname(__file__)) + +class Binary(NamedTuple): + filename: str # For example: "pact-1.2.3-linux-x86_64.tar.gz" + version: str # For example: "1.2.3" + suffix: str # For example: "linux-x86_64.tar.gz" + single_file: bool # True for Pact Rust FFI where we have one library file + + about = {} with open(os.path.join(here, "pact", "__version__.py")) as f: exec(f.read(), about) + class sdist(sdist_orig): """Subclass sdist to download all standalone ruby applications into ./pact/bin.""" @@ -41,15 +59,20 @@ def run(self): shutil.rmtree(package_bin_path, ignore_errors=True) os.mkdir(package_bin_path) + # Ruby binary for suffix in PACT_STANDALONE_SUFFIXES: - filename = ('pact-{version}-{suffix}').format(version=PACT_STANDALONE_VERSION, suffix=suffix) - download_ruby_app_binary(package_bin_path, filename, suffix) + filename = PACT_RUBY_FILENAME.format(version=PACT_STANDALONE_VERSION, suffix=suffix) + download_binary(package_bin_path, filename, get_ruby_uri(suffix=suffix)) + + # Rust FFI library + for filename in PACT_FFI_FILENAMES: + download_binary(package_bin_path, filename, get_rust_uri(filename=filename)) + download_binary(package_bin_path, 'pact.h', get_rust_uri(filename='pact.h')) super().run() class PactPythonDevelopCommand(develop): - """ - Custom develop mode installer for pact-python. + """Custom develop mode installer for pact-python. When the package is installed using `python setup.py develop` or `pip install -e` it will download and unpack the appropriate Pact @@ -59,26 +82,29 @@ class PactPythonDevelopCommand(develop): def run(self): """Install ruby command.""" develop.run(self) - package_bin_path = os.path.join(os.path.dirname(__file__), 'pact', 'bin') + package_bin_path = os.path.join(os.path.dirname(__file__), "pact", "bin") if not os.path.exists(package_bin_path): os.mkdir(package_bin_path) - install_ruby_app(package_bin_path, download_bin_path=None) + # Ruby + install_binary(package_bin_path, download_bin_path=None, binary=ruby_app_binary()) + + # Rust + install_binary(package_bin_path, download_bin_path=None, binary=rust_lib_binary()) class PactPythonInstallCommand(install): - """ - Custom installer for pact-python. + """Custom installer for pact-python. Installs the Python package and unpacks the platform appropriate version of the Ruby mock service and provider verifier. User Options: - --bin-path An absolute folder path containing predownloaded pact binaries + --bin-path An absolute folder path containing pre-downloaded pact binaries that should be used instead of fetching from the internet. """ - user_options = install.user_options + [('bin-path=', None, None)] + user_options = install.user_options + [("bin-path=", None, None)] def initialize_options(self): """Load our preconfigured options.""" @@ -92,44 +118,86 @@ def finalize_options(self): def run(self): """Install python binary.""" install.run(self) - package_bin_path = os.path.join(self.install_lib, 'pact', 'bin') + package_bin_path = os.path.join(self.install_lib, "pact", "bin") if not os.path.exists(package_bin_path): os.mkdir(package_bin_path) - install_ruby_app(package_bin_path, self.bin_path) + # Ruby + install_binary(package_bin_path, self.bin_path, binary=ruby_app_binary()) -def install_ruby_app(package_bin_path: str, download_bin_path=None): - """ - Installs the ruby standalone application for this OS. + # Rust + install_binary(package_bin_path, self.bin_path, binary=rust_lib_binary()) + + +def get_ruby_uri(suffix) -> str: + """Determine the full URI to download the Ruby binary from.""" + uri = ( + "https://github.com/pact-foundation/pact-ruby-standalone/releases" + "/download/v{version}/pact-{version}-{suffix}" + ) + return uri.format(version=PACT_STANDALONE_VERSION, suffix=suffix) + + +def get_rust_uri(filename) -> str: + """Determine the full URI to download the Rust binary from.""" + uri = ( + "https://github.com/pact-foundation/pact-reference/releases" + "/download/libpact_ffi-v{version}/{filename}" + ) + return uri.format(version=PACT_FFI_VERSION, filename=filename) + + +def install_binary(package_bin_path, download_bin_path, binary: Binary): + """Installs the ruby standalone application for this OS. :param package_bin_path: The path where we want our pact binaries unarchived. :param download_bin_path: An optional path containing pre-downloaded pact binaries. + :param binary: Details of the zipped binary files required """ - binary = ruby_app_binary() - - # The compressed Pact .tar.gz, zip etc file is expected to be in download_bin_path (if provided). - # Otherwise we will look in package_bin_path. - source_dir = download_bin_path if download_bin_path else package_bin_path - pact_unextracted_path = os.path.join(source_dir, binary['filename']) - - if os.path.isfile(pact_unextracted_path): - # Already downloaded, so just need to extract - extract_ruby_app_binary(source_dir, package_bin_path, binary['filename']) + print("-> install_binary({package_bin_path}, {download_bin_path}, {binary})".format( + package_bin_path=package_bin_path, + download_bin_path=download_bin_path, + binary=binary + )) + + if download_bin_path is not None: + # If a download_bin_path has been provided, but does not contain what we + # expect, do not continue + path = os.path.join(download_bin_path, binary.filename) + if not os.path.isfile(path): + raise RuntimeError("Could not find {} binary.".format(path)) + else: + if binary.single_file: + extract_gz(download_bin_path, package_bin_path, binary.filename) + else: + extract_ruby_app_binary(download_bin_path, package_bin_path, binary.filename) else: - if download_bin_path: - # An alternative source was provided, but did not contain the .tar.gz - raise RuntimeError('Could not find {} binary.'.format(pact_unextracted_path)) + # Otherwise, download to the destination package_bin_path, skipping to + # just extract if we have it already + path = os.path.join(package_bin_path, binary.filename) + if not os.path.isfile(path): + # Ruby binary + if binary.suffix in PACT_STANDALONE_SUFFIXES: + download_binary(package_bin_path, binary.filename, uri=get_ruby_uri(binary.suffix)) + + # Rust FFI library + if binary.filename in PACT_FFI_FILENAMES: + print(binary.filename) + download_binary(package_bin_path, binary.filename, get_rust_uri(filename=binary.filename)) + download_binary(package_bin_path, 'pact.h', get_rust_uri(filename='pact.h')) + + if binary.single_file: + extract_gz(package_bin_path, package_bin_path, binary.filename) else: - # Clean start, download an extract - download_ruby_app_binary(package_bin_path, binary['filename'], binary['suffix']) - extract_ruby_app_binary(package_bin_path, package_bin_path, binary['filename']) + extract_ruby_app_binary(package_bin_path, package_bin_path, binary.filename) + print("<- install_binary") -def ruby_app_binary(): - """ - Determine the ruby app binary required for this OS. - :return A dictionary of type {'filename': string, 'version': string, 'suffix': string } +def ruby_app_binary() -> Binary: + """Determines the ruby app binary required for this OS. + + :return Details of the binary file required """ target_platform = platform.platform().lower() @@ -147,91 +215,118 @@ def ruby_app_binary(): elif 'windows' in target_platform: suffix = 'windows-x86.zip' else: - msg = ('Unfortunately, {} is not a supported platform. Only Linux,' - ' Windows, and OSX are currently supported.').format( - platform.platform()) + msg = ( + "Unfortunately, {} is not a supported platform. Only Linux," " Windows, and OSX are currently supported." + ).format(platform.platform()) raise Exception(msg) - binary = binary.format(version=PACT_STANDALONE_VERSION, suffix=suffix) - return {'filename': binary, 'version': PACT_STANDALONE_VERSION, 'suffix': suffix} + binary = PACT_RUBY_FILENAME.format(version=PACT_STANDALONE_VERSION, suffix=suffix) + return Binary(filename=binary, version=PACT_STANDALONE_VERSION, suffix=suffix, single_file=False) + -def download_ruby_app_binary(path_to_download_to, filename, suffix): +def rust_lib_binary() -> Binary: + """Determines the Rust FFI library binary required for this OS. + + :return Details of the binary file required """ - Download `binary` into `path_to_download_to`. + target_platform = platform.platform().lower() + # print(target_platform) + # print(platform.machine()) + + if ("darwin" in target_platform or "macos" in target_platform) and ("aarch64" in platform.machine() or "arm64" in platform.machine()): + binary = "libpact_ffi-osx-aarch64-apple-darwin.dylib.gz" + elif "darwin" in target_platform or "macos" in target_platform: + binary = "libpact_ffi-osx-x86_64.dylib.gz" + elif "linux" in target_platform and IS_64 and ("aarch64" in platform.machine() or "arm64" in platform.machine()): + binary = "libpact_ffi-linux-aarch64.so.gz" + elif "linux" in target_platform and IS_64: + binary = "libpact_ffi-linux-x86_64.so.gz" + elif "windows" in target_platform: + binary = "pact_ffi-windows-x86_64.dll.gz" + else: + msg = ( + "Unfortunately, {} is not a supported platform. Only Linux x86_64," + " Windows, and OSX are currently supported." + ).format(target_platform) + raise Exception(msg) + + return Binary(filename=binary, version=PACT_FFI_VERSION, suffix=None, single_file=True) + +def download_binary(path_to_download_to, filename, uri): + """Downloads `filename` into `path_to_download_to`. :param path_to_download_to: The path where binaries should be downloaded. :param filename: The filename that should be installed. - :param suffix: The suffix of the standalone app to install. + :param uri: The URI to download the file from. """ - uri = ('https://github.com/pact-foundation/pact-ruby-standalone/releases' - '/download/v{version}/pact-{version}-{suffix}') + print("-> download_binary({path_to_download_to}, {filename}, {uri})".format(path_to_download_to=path_to_download_to, filename=filename, uri=uri)) - if sys.version_info.major == 2: - from urllib import urlopen - else: - from urllib.request import urlopen + from urllib3 import Retry, PoolManager path = os.path.join(path_to_download_to, filename) - resp = urlopen(uri.format(version=PACT_STANDALONE_VERSION, suffix=suffix)) - with open(path, 'wb') as f: - if resp.code == 200: - f.write(resp.read()) + retries = Retry(connect=5, read=2, redirect=5, backoff_factor=0.1) + http = PoolManager(retries=retries) + resp = http.request('GET', uri) + with open(path, "wb") as f: + if resp.status == 200: + f.write(resp.data) else: - raise RuntimeError( - 'Received HTTP {} when downloading {}'.format( - resp.code, resp.url)) + raise RuntimeError("Received HTTP {} when downloading {}".format(resp.code, resp.url)) -def extract_ruby_app_binary(source, destination, binary): - """ - Extract the ruby app binary from `source` into `destination`. + print("<- download_binary") + + +def extract_ruby_app_binary(source: str, destination: str, binary: str): + """Extracts the ruby app binary from `source` into `destination`. :param source: The location of the binary to unarchive. :param destination: The location to unarchive to. :param binary: The binary that needs to be unarchived. """ + print("-> extract_ruby_app_binary({source}, {destination}, {binary})".format(source=source, destination=destination, binary=binary)) + path = os.path.join(source, binary) - if 'windows' in platform.platform().lower(): + if "windows" in platform.platform().lower(): with ZipFile(path) as f: f.extractall(destination) else: with tarfile.open(path) as f: - def is_within_directory(directory, target): - - abs_directory = os.path.abspath(directory) - abs_target = os.path.abspath(target) - - prefix = os.path.commonprefix([abs_directory, abs_target]) + f.extractall(destination) + os.remove(path) + print("<- extract_ruby_app_binary") - return prefix == abs_directory - def safe_extract(tar, path=".", members=None, *, numeric_owner=False): +def extract_gz(source: str, destination: str, binary: str): + print("-> extract_gz({source}, {destination}, {binary})".format(source=source, destination=destination, binary=binary)) - for member in tar.getmembers(): - member_path = os.path.join(path, member.name) - if not is_within_directory(path, member_path): - raise Exception("Attempted Path Traversal in Tar File") + path = os.path.join(source, binary) + dest = os.path.splitext(os.path.join(destination, binary))[0] - tar.extractall(path, members, numeric_owner=numeric_owner) + with gzip.open(path, "rb") as f_in, open(dest, "wb") as f_out: + shutil.copyfileobj(f_in, f_out) + os.remove(path) - safe_extract(f, destination) + print("<- extract_gz") def read(filename): """Read file contents.""" path = os.path.realpath(os.path.join(os.path.dirname(__file__), filename)) - with open(path, 'rb') as f: - return f.read().decode('utf-8') + with open(path, "rb") as f: + return f.read().decode("utf-8") dependencies = [ + 'cffi==1.15.1', 'psutil>=5.9.4', 'six>=1.16.0', 'fastapi>=0.67.0', 'urllib3>=1.26.12', ] -if sys.version_info < (3, 7): +if sys.version_info.major < 4 and sys.version_info.minor < 7: dependencies += [ + 'cffi==1.15.1', 'click<=8.0.4', 'httpx==0.22.0', 'requests==2.27.1', @@ -239,6 +334,7 @@ def read(filename): ] else: dependencies += [ + 'cffi==1.15.1', 'click>=8.1.3', 'httpx==0.23.3', 'requests>=2.28.0', @@ -265,30 +361,27 @@ def read(filename): 'License :: OSI Approved :: MIT License', ] -if __name__ == '__main__': +if __name__ == "__main__": setup( - cmdclass={ - 'develop': PactPythonDevelopCommand, - 'install': PactPythonInstallCommand, - 'sdist': sdist}, - name='pact-python', - version=about['__version__'], - description=( - 'Tools for creating and verifying consumer driven ' - 'contracts using the Pact framework.'), - long_description=read('README.md'), - long_description_content_type='text/markdown', - author='Matthew Balvanz', - author_email='matthew.balvanz@workiva.com', - url='https://github.com/pact-foundation/pact-python', - entry_points=''' + cmdclass={"develop": PactPythonDevelopCommand, "install": PactPythonInstallCommand, "sdist": sdist}, + name="pact-python", + version=about["__version__"], + description=("Tools for creating and verifying consumer driven " "contracts using the Pact framework."), + long_description=read("README.md"), + long_description_content_type="text/markdown", + author="Matthew Balvanz", + author_email="matthew.balvanz@workiva.com", + url="https://github.com/pact-foundation/pact-python", + entry_points=""" [console_scripts] pact-verifier=pact.cli.verify:main - ''', + pact-verifier-ffi=pact.ffi.cli.verify:main + """, classifiers=CLASSIFIERS, python_requires='>=3.6,<4', install_requires=dependencies, - packages=['pact', 'pact.cli'], - package_data={'pact': ['bin/*']}, - package_dir={'pact': 'pact'}, - license='MIT License') + packages=["pact", "pact.cli"], + package_data={"pact": ["bin/*"]}, + package_dir={"pact": "pact"}, + license="MIT License", + ) diff --git a/tests/cli/test_verify.py b/tests/cli/test_verify.py index 3c242a941..bca49fb68 100644 --- a/tests/cli/test_verify.py +++ b/tests/cli/test_verify.py @@ -69,7 +69,7 @@ def test_provider_base_url_is_required(self): self.assertEqual(result.exit_code, 2) self.assertIn('--provider-base-url', result.output) - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") + @patch("pact.verify_wrapper.VerifyWrapper.verify") def test_pact_urls_or_broker_are_required(self, mock_wrapper): result = self.runner.invoke( verify.main, ['--provider-base-url=http://localhost']) @@ -78,7 +78,7 @@ def test_pact_urls_or_broker_are_required(self, mock_wrapper): self.assertIn('at least one', result.output) mock_wrapper.assert_not_called() - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") + @patch("pact.verify_wrapper.VerifyWrapper.verify") def test_broker_url_but_no_provider_required(self, mock_wrapper): result = self.runner.invoke( verify.main, ['--provider-base-url=http://localhost', @@ -87,17 +87,17 @@ def test_broker_url_but_no_provider_required(self, mock_wrapper): mock_wrapper.assert_not_called() self.assertEqual(result.exit_code, 1) - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") + @patch("pact.verify_wrapper.VerifyWrapper.verify") @patch("pact.verify_wrapper.isfile", return_value=True) def test_wrapper_error_code_returned(self, mock_isfile, mock_wrapper): mock_wrapper.return_value = 8, None # rnd number to indicate retval returned result = self.runner.invoke(verify.main, self.all_url_opts) - self.assertFalse(mock_wrapper.call_verify.called) + self.assertFalse(mock_wrapper.verify.called) self.assertEqual(result.exit_code, 8) - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") + @patch("pact.verify_wrapper.VerifyWrapper.verify") @patch("pact.verify_wrapper.isfile", return_value=True) def test_successful_verification(self, mock_isfile, mock_wrapper): mock_wrapper.return_value = 0, None # rnd number to indicate retval returned @@ -115,7 +115,7 @@ def test_successful_verification(self, mock_isfile, mock_wrapper): publish_verification_results=False, include_wip_pacts_since=None) - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") + @patch("pact.verify_wrapper.VerifyWrapper.verify") @patch("pact.verify_wrapper.isfile", return_value=True) def test_broker_url_and_provider_required(self, mock_isfile, mock_wrapper): mock_wrapper.return_value = 0, None @@ -125,7 +125,7 @@ def test_broker_url_and_provider_required(self, mock_isfile, mock_wrapper): mock_wrapper.assert_called() self.assertEqual(result.exit_code, 0) - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") + @patch("pact.verify_wrapper.VerifyWrapper.verify") @patch("pact.verify_wrapper.isfile", return_value=True) def test_pact_url_param_supported(self, mock_isfile, mock_wrapper): mock_wrapper.return_value = 0, None @@ -146,7 +146,7 @@ def test_pact_url_param_supported(self, mock_isfile, mock_wrapper): include_wip_pacts_since=None) self.assertEqual(result.exit_code, 0) - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") + @patch("pact.verify_wrapper.VerifyWrapper.verify") @patch("pact.verify_wrapper.isfile", return_value=True) def test_pact_urls_param_supported(self, mock_isfile, mock_wrapper): mock_wrapper.return_value = 0, None @@ -169,7 +169,7 @@ def test_pact_urls_param_supported(self, mock_isfile, mock_wrapper): include_wip_pacts_since=None) self.assertEqual(result.exit_code, 0) - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") + @patch("pact.verify_wrapper.VerifyWrapper.verify") @patch("pact.verify_wrapper.isfile", return_value=False) def test_local_pact_urls_must_exist(self, mock_isfile, mock_wrapper): mock_wrapper.return_value = 0, None @@ -177,9 +177,9 @@ def test_local_pact_urls_must_exist(self, mock_isfile, mock_wrapper): result = self.runner.invoke(verify.main, self.all_url_opts) self.assertEqual(result.exit_code, 1) self.assertIn('./pacts/consumer-provider.json', result.output) - mock_wrapper.call_verify.assert_not_called + mock_wrapper.verify.assert_not_called - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") + @patch("pact.verify_wrapper.VerifyWrapper.verify") @patch("pact.verify_wrapper.isfile", return_value=True) def test_failed_verification(self, mock_isfile, mock_wrapper): mock_wrapper.return_value = 3, None @@ -198,7 +198,7 @@ def test_failed_verification(self, mock_isfile, mock_wrapper): @patch.dict(os.environ, {'PACT_BROKER_PASSWORD': 'pwd', 'PACT_BROKER_USERNAME': 'broker_user', 'PACT_BROKER_BASE_URL': 'http://broker/'}) - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") + @patch("pact.verify_wrapper.VerifyWrapper.verify") @patch("pact.verify_wrapper.isfile", return_value=True) def test_broker_creds_from_env_var(self, mock_isfile, mock_wrapper): mock_wrapper.return_value = 0, None @@ -219,7 +219,7 @@ def test_broker_creds_from_env_var(self, mock_isfile, mock_wrapper): publish_verification_results=False, include_wip_pacts_since=None) - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") + @patch("pact.verify_wrapper.VerifyWrapper.verify") @patch("pact.verify_wrapper.isfile", return_value=True) def test_all_url_options(self, mock_isfile, mock_wrapper): mock_wrapper.return_value = 0, None @@ -270,7 +270,7 @@ def test_all_url_options(self, mock_isfile, mock_wrapper): publish_verification_results=False, include_wip_pacts_since=None) - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") + @patch("pact.verify_wrapper.VerifyWrapper.verify") def test_all_broker_options(self, mock_wrapper): mock_wrapper.return_value = 0, None result = self.runner.invoke(verify.main, [ diff --git a/tests/ffi/cli/test_verify.py b/tests/ffi/cli/test_verify.py new file mode 100644 index 000000000..b1d2635f6 --- /dev/null +++ b/tests/ffi/cli/test_verify.py @@ -0,0 +1,81 @@ +# from pact.ffi.cli.verify import main +# from pact.ffi.verifier import Verifier, VerifyStatus + + +# def test_cli_args(): +# """Make sure we have at least some arguments and they all have the required +# long version and help.""" +# args = Verifier().cli_args() + +# assert len(args.options) > 0 +# assert len(args.flags) > 0 +# assert all([arg.help is not None for arg in args.options]) +# assert all([arg.long is not None for arg in args.options]) +# assert all([arg.help is not None for arg in args.flags]) +# assert all([arg.long is not None for arg in args.flags]) + + +# def test_cli_args_cautious(cli_options, cli_flags): +# """ +# If desired, we can keep track of the list of arguments supported by the FFI +# CLI, and then at least be alerted if there is a change (this test will fail). + +# We don't really *need* to test against this, but it might be nice to know to +# avoid any surprises. +# """ +# args = Verifier().cli_args() +# assert len(args.options) == len(cli_options) +# assert all([arg.long in cli_options for arg in args.options]) + +# assert len(args.flags) == len(cli_flags) +# assert all([arg.long in cli_flags for arg in args.flags]) + + +# def test_cli_help(runner): +# """Click should return the usage information.""" +# result = runner.invoke(main, ["--help"]) +# assert result.exit_code == 0 +# assert result.output.startswith("Usage: pact-verifier-ffi [OPTIONS]") + + +# def test_cli_no_args(runner): +# """If no args are provided, but Click passes the default, we still want help.""" +# result = runner.invoke(main, []) +# assert result.exit_code == 0 +# assert result.output.startswith("Usage: pact-verifier-ffi [OPTIONS]") + + +# def test_cli_verify_success(runner, httpserver, pact_consumer_one_pact_provider_one_path): +# """ +# Use the FFI library to verify a simple pact, using a mock httpserver. +# In this case the response is as expected, so the verify succeeds. +# """ +# body = {"answer": 42} # 42 will be returned as an int, as expected +# endpoint = "/test-provider-one" +# httpserver.expect_request(endpoint).respond_with_json(body) + +# args = [ +# f"--port={httpserver.port}", +# f"--file={pact_consumer_one_pact_provider_one_path}", +# ] +# result = runner.invoke(main, args) + +# assert VerifyStatus(result.exit_code) == VerifyStatus.SUCCESS + + +# def test_cli_verify_failure(runner, httpserver, pact_consumer_one_pact_provider_one_path): +# """ +# Use the FFI library to verify a simple pact, using a mock httpserver. +# In this case the response is NOT as expected (str not int), so the verify fails. +# """ +# body = {"answer": "42"} # 42 will be returned as a str, which will fail +# endpoint = "/test-provider-one" +# httpserver.expect_request(endpoint).respond_with_json(body) + +# args = [ +# f"--port={httpserver.port}", +# f"--file={pact_consumer_one_pact_provider_one_path}", +# ] +# result = runner.invoke(main, args) + +# assert VerifyStatus(result.exit_code) == VerifyStatus.VERIFIER_FAILED diff --git a/tests/ffi/conftest.py b/tests/ffi/conftest.py new file mode 100644 index 000000000..6f95b4e84 --- /dev/null +++ b/tests/ffi/conftest.py @@ -0,0 +1,64 @@ +from pathlib import Path + +import pytest +from click.testing import CliRunner + + +@pytest.fixture +def runner(): + return CliRunner() + + +# CLI option arguments supported, correct as of Pact FFI 0.0.2. +@pytest.fixture +def cli_options(): + return [ + "loglevel", + "file", + "dir", + "url", + "broker-url", + "hostname", + "port", + "scheme", + "provider-name", + "state-change-url", + "filter-description", + "filter-state", + "filter-no-state", + "filter-consumer", + "user", + "password", + "token", + "provider-version", + "build-url", + "provider-tags", + "provider-branch", + "base-path", + "consumer-version-tags", + "consumer-version-selectors", + "include-wip-pacts-since", + "request-timeout", + "header" + ] + + +# CLI flag arguments supported, correct as of Pact FFI 0.0.2. +@pytest.fixture +def cli_flags(): + return ["state-change-as-query", "state-change-teardown", "publish", "disable-ssl-verification", "enable-pending"] + + +@pytest.fixture +def pacts_dir(): + """Find the correct pacts dir, depending on where the tests are run from""" + relative = "../examples/pacts/" if Path.cwd().name == "tests" else "examples/pacts" + return Path.cwd().joinpath(relative) + + +@pytest.fixture +def pact_consumer_one_pact_provider_one_path(pacts_dir): + """Provide the full path to a JSON pact for tests""" + pact = pacts_dir.joinpath("pact-consumer-one-pact-provider-one.json") + assert pact.is_file() + return str(pact) diff --git a/tests/ffi/test_ffi_grpc_consumer.py b/tests/ffi/test_ffi_grpc_consumer.py new file mode 100644 index 000000000..b0961b59d --- /dev/null +++ b/tests/ffi/test_ffi_grpc_consumer.py @@ -0,0 +1,48 @@ +import os +import json +from tests.ffi.utils import check_results, se +from pact.ffi.pact_ffi import PactFFI +from area_calculator_client import get_rectangle_area +import sys +sys.path.insert(0, './examples/area_calculator') + +pactlib = PactFFI() +PACT_FILE_DIR = './examples/pacts' + +def test_ffi_grpc_consumer(): + # Setup pact for testing + pact_handle = pactlib.lib.pactffi_new_pact(b'grpc-consumer-python', b'area-calculator-provider') + pactlib.lib.pactffi_with_pact_metadata(pact_handle, b'pact-python', b'ffi', se(pactlib.version())) + message_pact = pactlib.lib.pactffi_new_sync_message_interaction(pact_handle, b'A gRPC calculateMulti request') + pactlib.lib.pactffi_with_specification(pact_handle, 5) + + # our interaction contents + contents = { + "pact:proto": os.path.abspath('./examples/proto/area_calculator.proto'), + "pact:proto-service": 'Calculator/calculateOne', + "pact:content-type": 'application/protobuf', + "request": { + "rectangle": { + "length": 'matching(number, 3)', + "width": 'matching(number, 4)' + } + }, + "response": { + "value": ['matching(number, 12)'] + } + } + + # Start mock server + pactlib.lib.pactffi_using_plugin(pact_handle, b'protobuf', b'0.3.4') + pactlib.lib.pactffi_interaction_contents(message_pact, 0, b'application/grpc', pactlib.ffi.new("char[]", json.dumps(contents).encode('ascii'))) + mock_server_port = pactlib.lib.pactffi_create_mock_server_for_transport(pact_handle, b'0.0.0.0', 0, b'grpc', pactlib.ffi.cast("void *", 0)) + print(f"Mock server started: {mock_server_port}") + + # Make our client call + expected_response = 12.0 + response = get_rectangle_area(f"localhost:{mock_server_port}") + print(f"Client response: {response}") + print(f"Client response - matched expected: {response == expected_response}") + + # Check our result and write pact to file + check_results(pactlib, mock_server_port, pact_handle, PACT_FILE_DIR) diff --git a/tests/ffi/test_ffi_http_consumer.py b/tests/ffi/test_ffi_http_consumer.py new file mode 100644 index 000000000..990aafff2 --- /dev/null +++ b/tests/ffi/test_ffi_http_consumer.py @@ -0,0 +1,111 @@ +import json +import requests + +from pact.ffi.pact_ffi import PactFFI +from tests.ffi.test_ffi_grpc_consumer import check_results, se + +pactlib = PactFFI() +PACT_FILE_DIR = './examples/pacts' + +def test_ffi_http_consumer(): + request_interaction_body = { + "isbn": { + "pact:matcher:type": "type", + "value": "0099740915" + }, + "title": { + "pact:matcher:type": "type", + "value": "The Handmaid\'s Tale" + }, + "description": { + "pact:matcher:type": "type", + "value": "Brilliantly conceived and executed, this powerful evocation of twenty-first\ + century America gives full rein to Margaret Atwood\'s devastating irony, wit and astute perception." + }, + "author": { + "pact:matcher:type": "type", + "value": "Margaret Atwood" + }, + "publicationDate": { + "pact:matcher:type": "regex", + "regex": "^\\d{4}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d([+-][0-2]\\d:[0-5]\\d|Z)$", + "value": "1985-07-31T00:00:00+00:00" + } + } + + response_interaction_body = { + "@context": "/api/contexts/Book", + "@id": { + "pact:matcher:type": "regex", + "regex": "^\\/api\\/books\\/[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$", + "value": "/api/books/0114b2a8-3347-49d8-ad99-0e792c5a30e6" + }, + "@type": "Book", + "title": { + "pact:matcher:type": "type", + "value": "Voluptas et tempora repellat corporis excepturi." + }, + "description": { + "pact:matcher:type": "type", + "value": "Quaerat odit quia nisi accusantium natus voluptatem. Explicabo \ + corporis eligendi ut ut sapiente ut qui quidem. Optio amet velit aut delectus. \ + Sed alias asperiores perspiciatis deserunt omnis. Mollitia unde id in." + }, + "author": { + "pact:matcher:type": "type", + "value": "Melisa Kassulke" + }, + "publicationDate": { + "pact:matcher:type": "regex", + "regex": "^\\d{4}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d([+-][0-2]\\d:[0-5]\\d|Z)$", + "value": "1999-02-13T00:00:00+07:00" + }, + "reviews": [ + ] + } + # Setup pact for testing + pact_handle = pactlib.lib.pactffi_new_pact(b'http-consumer-1', b'http-provider') + pactlib.lib.pactffi_with_pact_metadata(pact_handle, b'pact-python', b'ffi', se(pactlib.version())) + interaction = pactlib.lib.pactffi_new_interaction(pact_handle, b'A POST request to create book') + + # setup interaction request + pactlib.lib.pactffi_upon_receiving(interaction, b'A POST request to create book') + pactlib.lib.pactffi_given(interaction, b'No book fixtures required') + pactlib.lib.pactffi_with_request(interaction, b'POST', b'/api/books') + pactlib.lib.pactffi_with_header_v2(interaction, 0, b'Content-Type', 0, b'application/json') + pactlib.lib.pactffi_with_body(interaction, 0, b'application/json', pactlib.ffi.new("char[]", json.dumps(request_interaction_body).encode('ascii'))) + # setup interaction response + pactlib.lib.pactffi_response_status(interaction, 200) + pactlib.lib.pactffi_with_header_v2(interaction, 1, b'Content-Type', 0, b'application/ld+json; charset=utf-8') + pactlib.lib.pactffi_with_body(interaction, 1, b'application/ld+json; charset=utf-8', + pactlib.ffi.new("char[]", json.dumps(response_interaction_body).encode('ascii'))) + + # Start mock server + mock_server_port = pactlib.lib.pactffi_create_mock_server_for_transport(pact_handle, b'0.0.0.0', 0, b'http', pactlib.ffi.cast("void *", 0)) + print(f"Mock server started: {mock_server_port}") + + # Make our client call + body = { + "isbn": '0099740915', + "title": "The Handmaid's Tale", + "description": 'Brilliantly conceived and executed, this powerful evocation of twenty-first century \ + America gives full rein to Margaret Atwood\'s devastating irony, wit and astute perception.', + "author": 'Margaret Atwood', + "publicationDate": '1985-07-31T00:00:00+00:00' + } + expected_response = '{"@context":"/api/contexts/Book","@id":"/api/books/0114b2a8-3347-49d8-ad99-0e792c5a30e6","@type":"Book","author":"Melisa Kassulke",\ + "description":"Quaerat odit quia nisi accusantium natus voluptatem. Explicabo corporis eligendi ut ut sapiente ut qui quidem. \ + Optio amet velit aut delectus. Sed alias asperiores perspiciatis deserunt omnis. Mollitia unde id in.",\ + "publicationDate":"1999-02-13T00:00:00+07:00","reviews":[],"title":"Voluptas et tempora repellat corporis excepturi."}' + try: + response = requests.post(f"http://127.0.0.1:{mock_server_port}/api/books", data=json.dumps(body), + headers={'Content-Type': 'application/json'}) + print(f"Client response - matched: {response.text}") + print(f"Client response - matched: {response.text == expected_response}") + response.raise_for_status() + except requests.HTTPError as http_err: + print(f'Client request - HTTP error occurred: {http_err}') # Python 3.6 + except Exception as err: + print(f'Client request - Other error occurred: {err}') # Python 3.6 + + check_results(pactlib, mock_server_port, pact_handle, PACT_FILE_DIR) diff --git a/tests/ffi/test_ffi_message_consumer.py b/tests/ffi/test_ffi_message_consumer.py new file mode 100644 index 000000000..fbd1c475f --- /dev/null +++ b/tests/ffi/test_ffi_message_consumer.py @@ -0,0 +1,58 @@ +import json +import requests + +from pact.ffi.pact_ffi import PactFFI +from tests.ffi.utils import check_results, se + +pactlib = PactFFI() +PACT_FILE_DIR = './examples/pacts' + +def test_ffi_message_consumer(): + # Setup pact for testing + pact_handle = pactlib.lib.pactffi_new_pact(b'http-consumer-2', b'http-provider') + pactlib.lib.pactffi_with_pact_metadata(pact_handle, b'pact-python', b'ffi', se(pactlib.version())) + interaction = pactlib.lib.pactffi_new_interaction(pact_handle, b'A PUT request to generate book cover') + message_pact = pactlib.lib.pactffi_new_pact(b'message-consumer-2', b'message-provider') + message = pactlib.lib.pactffi_new_message(message_pact, b'Book (id fb5a885f-f7e8-4a50-950f-c1a64a94d500) created message') + + # setup interaction request + pactlib.lib.pactffi_upon_receiving(interaction, b'A PUT request to generate book cover') + pactlib.lib.pactffi_given(interaction, b'A book with id fb5a885f-f7e8-4a50-950f-c1a64a94d500 is required') + pactlib.lib.pactffi_with_request(interaction, b'PUT', b'/api/books/fb5a885f-f7e8-4a50-950f-c1a64a94d500/generate-cover') + pactlib.lib.pactffi_with_header_v2(interaction, 0, b'Content-Type', 0, b'application/json') + pactlib.lib.pactffi_with_body(interaction, 0, b'application/json', b'[]') + # setup interaction response + pactlib.lib.pactffi_response_status(interaction, 204) + contents = { + "uuid": { + "pact:matcher:type": 'regex', + "regex": '^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$', + "value": 'fb5a885f-f7e8-4a50-950f-c1a64a94d500' + } + } + length = len(json.dumps(contents)) + size = length + 1 + pactlib.lib.pactffi_message_expects_to_receive(message, b'Book (id fb5a885f-f7e8-4a50-950f-c1a64a94d500) created message') + pactlib.lib.pactffi_message_given(message, b'A book with id fb5a885f-f7e8-4a50-950f-c1a64a94d500 is required') + pactlib.lib.pactffi_message_with_contents(message, b'application/json', pactlib.ffi.new("char[]", json.dumps(contents).encode('ascii')), size) + # Start mock server + mock_server_port = pactlib.lib.pactffi_create_mock_server_for_transport(pact_handle, b'0.0.0.0', 0, b'http', pactlib.ffi.cast("void *", 0)) + print(f"Mock server started: {mock_server_port}") + reified = pactlib.lib.pactffi_message_reify(message) + uuid = json.loads(pactlib.ffi.string(reified).decode('utf-8'))['contents']['uuid'] + + # Make our client call + body = [] + try: + response = requests.put(f"http://127.0.0.1:{mock_server_port}/api/books/{uuid}/generate-cover", data=json.dumps(body), + headers={'Content-Type': 'application/json'}) + print(f"Client response - matched: {response.text}") + print(f"Client response - matched: {response.status_code}") + print(f"Client response - matched: {response.status_code == '204'}") + response.raise_for_status() + except requests.HTTPError as http_err: + print(f'Client request - HTTP error occurred: {http_err}') # Python 3.6 + except Exception as err: + print(f'Client request - Other error occurred: {err}') # Python 3.6 + + check_results(pactlib, mock_server_port, pact_handle, PACT_FILE_DIR, message_pact) diff --git a/tests/ffi/test_ffi_verifier.py b/tests/ffi/test_ffi_verifier.py new file mode 100644 index 000000000..b96d4d2b5 --- /dev/null +++ b/tests/ffi/test_ffi_verifier.py @@ -0,0 +1,163 @@ +import platform +from os.path import join, dirname +import subprocess +import pytest +import mock +from pact.ffi.verifier import VerifyStatus + +from pact.pact_exception import PactException +from pact.ffi.ffi_verifier import FFIVerify +# import sys +# sys.path.insert(0, './examples/area_calculator') +# from area_calculator_server import serve + +def test_version(): + assert FFIVerify().version() == "0.4.5" + +@mock.patch("os.listdir") +def test_pact_urls_or_broker_required(mock_Popen): + verifier = FFIVerify() + + with pytest.raises(PactException) as e: + verifier.verify(provider="provider", provider_base_url="http://localhost") + + assert "Pact urls or Pact broker required" in str(e) + +def test_pact_file_does_not_exist(): + wrapper = FFIVerify() + result = wrapper.verify( + "consumer-provider.json", + provider="test_provider", + provider_base_url="http://localhost", + ) + assert VerifyStatus(result.return_code) == VerifyStatus.VERIFIER_FAILED + target_platform = platform.platform().lower() + + if 'windows' in target_platform: + assert ( + "Failed to load pact 'consumer-provider.json' - The system cannot find the file specified." + in "\n".join(result.logs) + ) + else: + assert ( + "Failed to load pact 'consumer-provider.json' - No such file or directory" + in "\n".join(result.logs) + ) + +def test_pact_url_does_not_exist(): + wrapper = FFIVerify() + result = wrapper.verify( + "http://broker.com/pacts/consumer-provider.json", + provider="test_provider", + provider_base_url="http://localhost", + ) + assert VerifyStatus(result.return_code) == VerifyStatus.VERIFIER_FAILED + assert ( + "Failed to load pact 'http://broker.com/pacts/consumer-provider.json' - Request failed with status - 404 Not Found" + in "\n".join(result.logs) + ) + +def test_broker_url_does_not_exist(): + wrapper = FFIVerify() + result = wrapper.verify( + broker_url="http://broker.com/", + provider="test_provider", + provider_base_url="http://localhost", + ) + assert VerifyStatus(result.return_code) == VerifyStatus.VERIFIER_FAILED + assert ( + "Failed to load pact - \x1b[31mCould not load pacts from the pact broker 'http://broker.com/'" + in "\n".join(result.logs) + ) + +def test_authed_broker_without_credentials(): + wrapper = FFIVerify() + result = wrapper.verify( + broker_url="https://test.pactflow.io", + provider="Example API", + provider_base_url="http://localhost", + ) + assert VerifyStatus(result.return_code) == VerifyStatus.VERIFIER_FAILED + assert ( + "Failed to load pact - \x1b[31mCould not load pacts from the pact broker 'https://test.pactflow.io'" + in "\n".join(result.logs) + ) + +def test_broker_http_v2_pact_with_filter_state(httpserver): + body = {"name": "Mary"} + endpoint = "/alligators/Mary" + httpserver.expect_request(endpoint).respond_with_json( + body, content_type="application/json;charset=utf-8" + ) + + wrapper = FFIVerify() + result = wrapper.verify( + broker_url="https://test.pactflow.io", + broker_username="dXfltyFMgNOFZAxr8io9wJ37iUpY42M", + broker_password="O5AIZWxelWbLvqMd8PkAVycBJh2Psyg1", + provider="Example API", + provider_base_url="http://127.0.0.1:{}".format(httpserver.port), + filter_state="there is an alligator named Mary", + ) + assert VerifyStatus(result.return_code) == VerifyStatus.SUCCESS + +def test_local_http_v2_pact_with_filter_state(httpserver): + body = {"name": "testing matchers - string"} + endpoint = "/alligators/Mary" + httpserver.expect_request(endpoint).respond_with_json( + body, content_type="application/json;charset=utf-8" + ) + + wrapper = FFIVerify() + result = wrapper.verify( + "./examples/pacts/v2-http.json", + provider="Example API", + provider_base_url="http://127.0.0.1:{}".format(httpserver.port), + request_timeout=10, + filter_state="there is an alligator named Mary", + ) + assert VerifyStatus(result.return_code) == VerifyStatus.SUCCESS + +def test_local_http_v3_pact(httpserver): + body = { + "@context": "/api/contexts/Book", + "@id": "/api/books/0114b2a8-3347-49d8-ad99-0e792c5a31e6", + "@type": "Book", + "author": "testing matchers - using regex for id and pub date", + "description": "testing matchers - string", + "publicationDate": "2023-02-13T00:00:00+07:00", + "reviews": [], + "title": "testing matchers - string" + } + endpoint = "/api/books" + httpserver.expect_request(endpoint).respond_with_json( + body, content_type="application/ld+json;charset=utf-8" + ) + + wrapper = FFIVerify() + result = wrapper.verify( + "./examples/pacts/v3-http.json", + provider="Example API", + provider_base_url="http://127.0.0.1:{}".format(httpserver.port), + request_timeout=10, + # filter_state="there is an alligator named Mary", + ) + assert VerifyStatus(result.return_code) == VerifyStatus.SUCCESS + +def test_grpc_local_pact(): + + grpc_server_process = subprocess.Popen(['python', 'area_calculator_server.py'], + cwd=join(dirname(__file__), '..', '..', 'examples', 'area_calculator')) + + wrapper = FFIVerify() + result = wrapper.verify( + "./examples/pacts/v4-grpc.json", + provider="area-calculator-provider", + provider_base_url="tcp://127.0.0.1:37757", + request_timeout=10, + log_level="INFO", + provider_transport="protobuf" + ) + assert VerifyStatus(result.return_code) == VerifyStatus.SUCCESS + # TODO - Plugin success or failure not returned in logs + grpc_server_process.terminate() diff --git a/tests/ffi/test_verifier.py b/tests/ffi/test_verifier.py new file mode 100644 index 000000000..36345919b --- /dev/null +++ b/tests/ffi/test_verifier.py @@ -0,0 +1,109 @@ + +# from pact.ffi.verifier import Verifier, VerifyStatus + +# def test_version(): +# result = Verifier().version() +# assert result == "0.4.5" + + +# # def test_verify_no_args(): +# # result = Verifier().verify(args=None) +# # assert VerifyStatus(result.return_code) == VerifyStatus.NULL_POINTER + + +# def test_verify_help(): +# result = Verifier().verify(args="--help") +# assert VerifyStatus(result.return_code) == VerifyStatus.INVALID_ARGS +# # assert "kind: HelpDisplayed" in "\n".join(result.logs) + + +# def test_verify_version(): +# result = Verifier().verify(args="--version") +# assert VerifyStatus(result.return_code) == VerifyStatus.INVALID_ARGS +# # assert "kind: VersionDisplayed" in "\n".join(result.logs) + + +# def test_verify_invalid_args(): +# """Verify we get an expected return code and log content to invalid args. + +# Example output, with TRACE (default) logs: +# [TRACE][mio::poll] registering event source with poller: token=Token(0), interests=READABLE | WRITABLE +# [ERROR][pact_ffi::verifier::verifier] error verifying Pact: "error: Found argument 'Your argument +# is invalid' which wasn't expected, or isn't valid in this context\n\nUSAGE:\n pact_verifier_cli +# [FLAGS] [OPTIONS] --broker-url ... --dir ... --file ... --provider-name +# --url ...\n\nFor more information try --help" Error { message: "error: Found argument 'Your argument is +# invalid' which wasn't expected, or isn't valid in this context\n\nUSAGE:\n pact_verifier_cli [FLAGS] [OPTIONS] +# --broker-url ... --dir ... --file ... --provider-name --url ...\n\n +# For more information try --help", kind: UnknownArgument, info: Some(["Your argument is invalid"]) } +# """ +# result = Verifier().verify(args="Your argument is invalid") +# assert VerifyStatus(result.return_code) == VerifyStatus.INVALID_ARGS +# # assert "kind: UnknownArgument" in "\n".join(result.logs) +# # assert len(result.logs) == 1 # 1 for only the ERROR log, otherwise will be 2 + + +# def test_verify_success(httpserver, pact_consumer_one_pact_provider_one_path): +# """ +# Use the FFI library to verify a simple pact, using a mock httpserver. +# In this case the response is as expected, so the verify succeeds. +# """ +# body = {"answer": 42} # 42 will be returned as an int, as expected +# endpoint = "/test-provider-one" +# httpserver.expect_request(endpoint).respond_with_json(body) + +# args_list = [ +# f"--port={httpserver.port}", +# f"--file={pact_consumer_one_pact_provider_one_path}", +# ] +# args = "\n".join(args_list) +# result = Verifier().verify(args=args) +# assert VerifyStatus(result.return_code) == VerifyStatus.SUCCESS + + +# def test_verify_failure(httpserver, pact_consumer_one_pact_provider_one_path): +# """ +# Use the FFI library to verify a simple pact, using a mock httpserver. +# In this case the response is NOT as expected (str not int), so the verify fails. +# """ +# body = {"answer": "42"} # 42 will be returned as a str, which will fail +# endpoint = "/test-provider-one" +# httpserver.expect_request(endpoint).respond_with_json(body) + +# args_list = [ +# f"--port={httpserver.port}", +# f"--file={pact_consumer_one_pact_provider_one_path}", +# ] +# args = "\n".join(args_list) +# result = Verifier().verify(args=args) +# assert VerifyStatus(result.return_code) == VerifyStatus.VERIFIER_FAILED + + +# """ +# Original verifier tests. Moving as they are implemented via FFI instead. + +# TODO: +# def test_verifier_with_provider_and_files(self, mock_path_exists, mock_wrapper): +# def test_verifier_with_provider_and_files_passes_consumer_selctors(self, mock_path_exists, mock_wrapper): +# def test_validate_on_publish_results(self): +# def test_publish_on_success(self, mock_path_exists, mock_wrapper): +# def test_raises_error_on_missing_pact_files(self, mock_path_exists): +# def test_expand_directories_called_for_pacts(self, mock_path_exists, mock_expand_dir, mock_wrapper): +# def test_passes_enable_pending_flag_value(self, mock_wrapper): +# def test_passes_include_wip_pacts_since_value(self, mock_path_exists, mock_wrapper): +# def test_verifier_with_broker(self, mock_wrapper): +# def test_verifier_and_pubish_with_broker(self, mock_wrapper): +# def test_verifier_with_broker_passes_consumer_selctors(self, mock_wrapper): +# def test_publish_on_success(self, mock_path_exists, mock_wrapper): +# def test_passes_enable_pending_flag_value(self, mock_wrapper): +# def test_passes_include_wip_pacts_since_value(self, mock_path_exists, mock_wrapper): + +# Done: +# def test_version(self): + +# Issues: +# Skipped test_verifier.py and cli/test_ffi_verifier.py, there is an issue with the loggers whereby +# test_ffi_verifier.py uses pactffi_log_to_buffer and pactffi_verifier_logs with a verifier handle +# the others, either write to a pactffi_log_to_file, or call pactffi_log_to_buffer but call pactffi_fetch_log_buffer +# This function can take a log specifier but its not clear how to set that. +# if the tests are run individually they are fine... +# """ diff --git a/tests/ffi/utils.py b/tests/ffi/utils.py new file mode 100644 index 000000000..d2cdf7a07 --- /dev/null +++ b/tests/ffi/utils.py @@ -0,0 +1,29 @@ +import json + + +def se(s): + return b"NULL" if s is None or "" else s.encode("ascii") + +def ne(): + return b"NULL" + +def check_results(pactlib, mock_server_port, pact_handle, PACT_FILE_DIR, message_pact=None): + result = pactlib.lib.pactffi_mock_server_matched(mock_server_port) + print(f"Pact - Got matching client requests: {result}") + if result is True: + print(f"Writing pact file to {PACT_FILE_DIR}") + res_write_pact = pactlib.lib.pactffi_write_pact_file(mock_server_port, PACT_FILE_DIR.encode('ascii'), False) + print(f"Pact file writing results: {res_write_pact}") + if message_pact is not None: + res_write_message_pact = pactlib.lib.pactffi_write_message_pact_file(message_pact, PACT_FILE_DIR.encode('ascii'), False) + print(f"Pact message file writing results: {res_write_message_pact}") + else: + print('pactffi_mock_server_matched did not match') + mismatches = pactlib.lib.pactffi_mock_server_mismatches(mock_server_port) + if mismatches: + result = json.loads(pactlib.ffi.string(mismatches)) + print(json.dumps(result, indent=4)) + + # Cleanup + pactlib.lib.pactffi_cleanup_mock_server(mock_server_port) + pactlib.lib.pactffi_cleanup_plugins(pact_handle) diff --git a/tests/test_http_proxy.py b/tests/test_http_proxy.py index 36bac25a8..f21c1f1a5 100644 --- a/tests/test_http_proxy.py +++ b/tests/test_http_proxy.py @@ -63,7 +63,7 @@ def test_home_should_return_expected_response(self): json=payload ) - self.assertEqual(res.json(), {'contents': message}) + self.assertEqual(res.json(), message) def test_home_raise_runtime_error_if_no_matched(self): data = { diff --git a/tests/test_message_provider.py b/tests/test_message_provider.py index 8b71c3bdd..fcfcb23e8 100644 --- a/tests/test_message_provider.py +++ b/tests/test_message_provider.py @@ -51,23 +51,29 @@ def test_init(self): self.assertEqual(self.provider.proxy_host, 'localhost') self.assertEqual(self.provider.proxy_port, '1234') - @patch('pact.Verifier.verify_pacts', return_value=(0, 'logs')) - def test_verify(self, mock_verify_pacts): + @patch('pact.ffi.ffi_verifier.FFIVerify.verify', return_value=(0, 'logs')) + def test_verify(self, mock_verify): self.provider.verify() - assert mock_verify_pacts.call_count == 1 - mock_verify_pacts.assert_called_with(f'{self.provider.pact_dir}/{self.provider._pact_file()}', verbose=False) + assert mock_verify.call_count == 1 + mock_verify.assert_called_with(f'{self.provider.pact_dir}/{self.provider._pact_file()}', + provider='DocumentService', provider_base_url='http://localhost:1234', + verbose=False) - @patch('pact.Verifier.verify_with_broker', return_value=(0, 'logs')) - def test_verify_with_broker(self, mock_verify_pacts): + @patch('pact.ffi.ffi_verifier.FFIVerify.verify', return_value=(0, 'logs')) + def test_verify_with_broker(self, mock_verify): self.provider.verify_with_broker(**self.options) - assert mock_verify_pacts.call_count == 1 - mock_verify_pacts.assert_called_with(False, None, broker_username="test", - broker_password="test", - broker_url="http://localhost", - publish_version='3', - publish_verification_results=False) + assert mock_verify.call_count == 1 + mock_verify.assert_called_with(enable_pending=False, + provider='DocumentService', + provider_base_url='http://localhost:1234', + include_wip_pacts_since=None, + broker_username="test", + broker_password="test", + broker_url="http://localhost", + publish_version='3', + publish_verification_results=False) class MessageProviderContextManagerTestCase(MessageProviderTestCase): diff --git a/tests/test_pact.py b/tests/test_pact.py index 76e1e7059..8a23e92e4 100644 --- a/tests/test_pact.py +++ b/tests/test_pact.py @@ -488,11 +488,13 @@ def setUp(self): .upon_receiving('a specific request to the server') .with_request('GET', '/path') .will_respond_with(200, body='success')) + self.get_verification_call = call( 'get', 'http://localhost:1234/interactions/verification', headers={'X-Pact-Mock-Service': 'true'}, verify=False, - params=None) + params=None, + allow_redirects=True) self.post_publish_pacts_call = call( 'post', 'http://localhost:1234/pact', @@ -534,7 +536,6 @@ def test_error_writing_pacts_to_file(self): self.mock_requests.assert_has_calls([ self.get_verification_call, self.post_publish_pacts_call]) - class PactContextManagerTestCase(PactTestCase): def setUp(self): super(PactContextManagerTestCase, self).setUp() diff --git a/tests/test_verifier.py b/tests/test_verifier.py index 7b0a46f05..54d37dca2 100644 --- a/tests/test_verifier.py +++ b/tests/test_verifier.py @@ -24,9 +24,12 @@ def setUp(self): provider_base_url="http://localhost:8888") self.mock_wrapper = patch.object( - VerifyWrapper, 'call_verify').start() + VerifyWrapper, 'verify').start() - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") + def test_version(self): + self.assertEqual(self.verifier.version(), "0.0.0") + + @patch("pact.verify_wrapper.VerifyWrapper.verify") @patch('pact.verifier.path_exists', return_value=True) def test_verifier_with_provider_and_files(self, mock_path_exists, mock_wrapper): mock_wrapper.return_value = (True, 'some logs') @@ -46,7 +49,7 @@ def test_verifier_with_provider_and_files(self, mock_path_exists, mock_wrapper): enable_pending=False, include_wip_pacts_since=None) - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") + @patch("pact.verify_wrapper.VerifyWrapper.verify") @patch('pact.verifier.path_exists', return_value=True) def test_verifier_with_provider_and_files_passes_consumer_selctors(self, mock_path_exists, mock_wrapper): mock_wrapper.return_value = (True, 'some logs') @@ -75,10 +78,11 @@ def test_verifier_with_provider_and_files_passes_consumer_selctors(self, mock_pa consumer_selectors=['{"tag": "main", "latest": true}', '{"tag": "test", "latest": false}']) - def test_validate_on_publish_results(self): + @patch('pact.verifier.path_exists', return_value=True) + def test_validate_on_publish_results_without_version(self, mock_path_exists): self.assertRaises(Exception, self.verifier.verify_pacts, 'path/to/pact1', publish=True) - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") + @patch("pact.verify_wrapper.VerifyWrapper.verify") @patch('pact.verifier.path_exists', return_value=True) def test_publish_on_success(self, mock_path_exists, mock_wrapper): mock_wrapper.return_value = (True, 'some logs') @@ -103,7 +107,7 @@ def test_raises_error_on_missing_pact_files(self, mock_path_exists): mock_path_exists.assert_called_with('path/to/pact2') - @patch("pact.verify_wrapper.VerifyWrapper.call_verify", return_value=(0, None)) + @patch("pact.verify_wrapper.VerifyWrapper.verify", return_value=(0, None)) @patch('pact.verifier.expand_directories', return_value=['./pacts/pact1', './pacts/pact2']) @patch('pact.verifier.path_exists', return_value=True) def test_expand_directories_called_for_pacts(self, mock_path_exists, mock_expand_dir, mock_wrapper): @@ -112,7 +116,7 @@ def test_expand_directories_called_for_pacts(self, mock_path_exists, mock_expand mock_expand_dir.assert_called_once() - @patch('pact.verify_wrapper.VerifyWrapper.call_verify', return_value=(0, None)) + @patch('pact.verify_wrapper.VerifyWrapper.verify', return_value=(0, None)) def test_passes_enable_pending_flag_value(self, mock_wrapper): for value in (True, False): with self.subTest(value=value): @@ -123,7 +127,7 @@ def test_passes_enable_pending_flag_value(self, mock_wrapper): mock_wrapper.call_args.kwargs, ) - @patch('pact.verify_wrapper.VerifyWrapper.call_verify', return_value=(0, None)) + @patch('pact.verify_wrapper.VerifyWrapper.verify', return_value=(0, None)) @patch('pact.verifier.path_exists', return_value=True) def test_passes_include_wip_pacts_since_value(self, mock_path_exists, mock_wrapper): self.verifier.verify_pacts('any.json', include_wip_pacts_since='2018-01-01') @@ -142,7 +146,7 @@ def setUp(self): provider_base_url="http://localhost:8888") self.mock_wrapper = patch.object( - VerifyWrapper, 'call_verify').start() + VerifyWrapper, 'verify').start() self.broker_username = 'broker_username' self.broker_password = 'broker_password' self.broker_url = 'http://broker' @@ -154,7 +158,7 @@ def setUp(self): 'broker_token': 'token' } - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") + @patch("pact.verify_wrapper.VerifyWrapper.verify") def test_verifier_with_broker(self, mock_wrapper): mock_wrapper.return_value = (True, 'some value') @@ -163,6 +167,7 @@ def test_verifier_with_broker(self, mock_wrapper): self.assertTrue(output) assertVerifyCalled(mock_wrapper, + pacts=None, provider='test_provider', provider_base_url='http://localhost:8888', broker_password=self.broker_password, @@ -174,7 +179,7 @@ def test_verifier_with_broker(self, mock_wrapper): enable_pending=False, include_wip_pacts_since=None) - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") + @patch("pact.verify_wrapper.VerifyWrapper.verify") def test_verifier_and_pubish_with_broker(self, mock_wrapper): mock_wrapper.return_value = (True, 'some value') @@ -184,6 +189,7 @@ def test_verifier_and_pubish_with_broker(self, mock_wrapper): self.assertTrue(output) assertVerifyCalled(mock_wrapper, + pacts=None, provider='test_provider', provider_base_url='http://localhost:8888', broker_password=self.broker_password, @@ -197,7 +203,7 @@ def test_verifier_and_pubish_with_broker(self, mock_wrapper): provider_app_version='1.0.0', ) - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") + @patch("pact.verify_wrapper.VerifyWrapper.verify") def test_verifier_with_broker_passes_consumer_selctors(self, mock_wrapper): mock_wrapper.return_value = (True, 'some value') @@ -213,6 +219,7 @@ def test_verifier_with_broker_passes_consumer_selctors(self, mock_wrapper): self.assertTrue(output) assertVerifyCalled(mock_wrapper, + pacts=None, provider='test_provider', provider_base_url='http://localhost:8888', broker_password=self.broker_password, @@ -226,7 +233,7 @@ def test_verifier_with_broker_passes_consumer_selctors(self, mock_wrapper): consumer_selectors=['{"tag": "main", "latest": true}', '{"tag": "test", "latest": false}']) - @patch("pact.verify_wrapper.VerifyWrapper.call_verify") + @patch("pact.verify_wrapper.VerifyWrapper.verify") @patch('pact.verifier.path_exists', return_value=True) def test_publish_on_success(self, mock_path_exists, mock_wrapper): mock_wrapper.return_value = (True, 'some logs') @@ -234,6 +241,7 @@ def test_publish_on_success(self, mock_path_exists, mock_wrapper): self.verifier.verify_with_broker(publish_version='1.0.0', **self.default_opts) assertVerifyCalled(mock_wrapper, + pacts=None, provider='test_provider', provider_base_url='http://localhost:8888', broker_password=self.broker_password, @@ -246,7 +254,7 @@ def test_publish_on_success(self, mock_path_exists, mock_wrapper): enable_pending=False, include_wip_pacts_since=None) - @patch('pact.verify_wrapper.VerifyWrapper.call_verify', return_value=(0, None)) + @patch('pact.verify_wrapper.VerifyWrapper.verify', return_value=(0, None)) def test_passes_enable_pending_flag_value(self, mock_wrapper): for value in (True, False): with self.subTest(value=value): @@ -257,7 +265,7 @@ def test_passes_enable_pending_flag_value(self, mock_wrapper): mock_wrapper.call_args.kwargs, ) - @patch('pact.verify_wrapper.VerifyWrapper.call_verify', return_value=(0, None)) + @patch('pact.verify_wrapper.VerifyWrapper.verify', return_value=(0, None)) @patch('pact.verifier.path_exists', return_value=True) def test_passes_include_wip_pacts_since_value(self, mock_path_exists, mock_wrapper): self.verifier.verify_with_broker(include_wip_pacts_since='2018-01-01') diff --git a/tests/test_verify_wrapper.py b/tests/test_verify_wrapper.py index 8ec63eaf7..3dda847ef 100644 --- a/tests/test_verify_wrapper.py +++ b/tests/test_verify_wrapper.py @@ -9,7 +9,6 @@ from subprocess import PIPE, Popen - class VerifyWrapperTestCase(TestCase): """ use traceback.print_exception(*result.exc_info) to debug """ @@ -38,9 +37,9 @@ def setUp(self): verify_wrapper, 'rerun_command', autospec=True).start() self.default_call = [ - './pacts/consumer-provider.json', - './pacts/consumer-provider2.json', - '--no-enable-pending', + # './pacts/consumer-provider.json', + # './pacts/consumer-provider2.json', + # '--no-enable-pending', '--provider=test_provider', '--provider-base-url=http://localhost'] @@ -63,6 +62,7 @@ def assertProcess(self, *expected): process_call = self.mock_Popen.mock_calls[0] actual = process_call[1][0] + print(actual) self.assertEqual(actual[0], VERIFIER_PATH) self.assertEqual(len(actual), len(expected) + 1) self.assertEqual(set(actual[1:]), set(expected)) @@ -72,19 +72,23 @@ def assertProcess(self, *expected): self.mock_rerun_command.return_value) self.assertTrue(self.mock_Popen.called) + def test_version(self): + wrapper = VerifyWrapper() + self.assertEqual(wrapper.version(), "0.0.0") + def test_pact_urls_or_broker_required(self): self.mock_Popen.return_value.returncode = 2 wrapper = VerifyWrapper() with self.assertRaises(PactException) as context: - wrapper.call_verify(provider='provider', provider_base_url='http://localhost') + wrapper.verify(provider='provider', provider_base_url='http://localhost') self.assertTrue('Pact urls or Pact broker required' in context.exception.message) def test_broker_without_authentication_can_be_used(self): self.mock_Popen.return_value.returncode = 0 wrapper = VerifyWrapper() - wrapper.call_verify( + wrapper.verify( provider='provider', provider_base_url='http://localhost', broker_url='http://broker.example.com' ) self.assertProcess(*[ @@ -94,14 +98,29 @@ def test_broker_without_authentication_can_be_used(self): '--provider=provider', ]) + def test_pact_files_provided(self): + self.mock_Popen.return_value.returncode = 0 + wrapper = VerifyWrapper() + + result, output = wrapper.verify('./pacts/consumer-provider.json', + './pacts/consumer-provider2.json', + provider='test_provider', + provider_base_url='http://localhost') + self.default_call.insert(0, './pacts/consumer-provider.json') + self.default_call.insert(1, './pacts/consumer-provider2.json') + self.assertProcess(*self.default_call) + self.assertEqual(result, 0) + def test_pact_urls_provided(self): self.mock_Popen.return_value.returncode = 0 wrapper = VerifyWrapper() - result, output = wrapper.call_verify('./pacts/consumer-provider.json', - './pacts/consumer-provider2.json', - provider='test_provider', - provider_base_url='http://localhost') + result, output = wrapper.verify('http://broker.com/pacts/consumer-provider.json', + 'http://broker.com/pacts/consumer-provider2.json', + provider='test_provider', + provider_base_url='http://localhost') + self.default_call.insert(0, 'http://broker.com/pacts/consumer-provider.json') + self.default_call.insert(1, 'http://broker.com/pacts/consumer-provider2.json') self.assertProcess(*self.default_call) self.assertEqual(result, 0) @@ -111,19 +130,19 @@ def test_all_url_options(self, mock_isfile): self.mock_Popen.return_value.returncode = 0 wrapper = VerifyWrapper() - result, output = wrapper.call_verify('./pacts/consumer-provider5.json', - './pacts/consumer-provider3.json', - provider_base_url='http://localhost', - provider_states_setup_url='http://localhost/provider-states/set', - provider='provider', - provider_app_version='1.2.3', - custom_provider_headers=['Authorization: Basic cGFj', 'CustomHeader: somevalue'], - log_dir='tmp/logs/pact.test.log', - log_level='INFO', - timeout=60, - verbose=True, - enable_pending=True, - include_wip_pacts_since='2018-01-01') + result, output = wrapper.verify('./pacts/consumer-provider5.json', + './pacts/consumer-provider3.json', + provider_base_url='http://localhost', + provider_states_setup_url='http://localhost/provider-states/set', + provider='provider', + provider_app_version='1.2.3', + custom_provider_headers=['Authorization: Basic cGFj', 'CustomHeader: somevalue'], + log_dir='tmp/logs/pact.test.log', + log_level='INFO', + timeout=60, + verbose=True, + enable_pending=True, + include_wip_pacts_since='2018-01-01') self.assertEqual(result, 0) self.mock_Popen.return_value.wait.assert_called_once_with() @@ -140,23 +159,23 @@ def test_all_url_options(self, mock_isfile): '--log-dir=tmp/logs/pact.test.log', '--log-level=INFO', '--verbose', - '--enable-pending', - '--include-wip-pacts-since=2018-01-01', + # '--enable-pending', + # '--include-wip-pacts-since=2018-01-01', ) def test_uses_broker_if_no_pacts_and_provider_required(self): self.mock_Popen.return_value.returncode = 0 wrapper = VerifyWrapper() - result, output = wrapper.call_verify(provider='test_provider', - provider_base_url='http://localhost', - broker_username='username', - broker_password='pwd', - broker_token='token', - broker_url='http://broker', - consumer_tags=['prod', 'dev'], - provider_tags=['dev', 'qa'], - provider_version_branch='provider-branch') + result, output = wrapper.verify(provider='test_provider', + provider_base_url='http://localhost', + broker_username='username', + broker_password='pwd', + broker_token='token', + broker_url='http://broker', + consumer_tags=['prod', 'dev'], + provider_tags=['dev', 'qa'], + provider_version_branch='provider-branch') self.assertProcess(*self.broker_call) self.assertEqual(result, 0) @@ -169,10 +188,10 @@ def test_rerun_command_called(self, mock_rerun_cmd, mock_expand_dirs, mock_sanit self.mock_Popen.return_value.returncode = 0 wrapper = VerifyWrapper() - result, output = wrapper.call_verify('path/to/pact1', - 'path/to/pact2', - provider_base_url='http://localhost', - provider='provider') + result, output = wrapper.verify('path/to/pact1', + 'path/to/pact2', + provider_base_url='http://localhost', + provider='provider') mock_rerun_cmd.assert_called_once() @@ -184,28 +203,44 @@ def test_sanitize_called(self, mock_rerun_cmd, mock_expand_dirs, mock_sanitize_l self.mock_Popen.return_value.returncode = 0 wrapper = VerifyWrapper() - result, output = wrapper.call_verify('path/to/pact1', - 'path/to/pact2', - provider_base_url='http://localhost', - provider='provider') + result, output = wrapper.verify('path/to/pact1', + 'path/to/pact2', + provider_base_url='http://localhost', + provider='provider') mock_sanitize_logs.assert_called_with(self.mock_Popen.return_value, False) @patch('pact.verify_wrapper.path_exists', return_value=True) @patch('pact.verify_wrapper.sanitize_logs') - def test_publishing_with_version(self, mock_sanitize_logs, mock_path_exists): + def test_publishing_with_local_file(self, mock_sanitize_logs, mock_path_exists): self.mock_Popen.return_value.returncode = 0 wrapper = VerifyWrapper() - result, output = wrapper.call_verify('./pacts/consumer-provider.json', - './pacts/consumer-provider2.json', - provider='test_provider', - provider_base_url='http://localhost', - provider_app_version='1.2.3', - publish_verification_results=True) + with self.assertRaises(PactException) as context: + wrapper.verify('./pacts/consumer-provider.json', + './pacts/consumer-provider2.json', + provider='test_provider', + provider_base_url='http://localhost', + provider_app_version='1.2.3', + publish_verification_results=True) - self.default_call.extend(['--provider-app-version', '1.2.3', '--publish-verification-results']) + self.assertTrue('Cannot publish verification results for local files' in context.exception.message) + @patch('pact.verify_wrapper.sanitize_logs') + def test_publishing_with_version(self, mock_sanitize_logs): + self.mock_Popen.return_value.returncode = 0 + wrapper = VerifyWrapper() + + result, output = wrapper.verify('http://broker.com/pacts/consumer-provider.json', + 'http://broker.com/pacts/consumer-provider2.json', + provider='test_provider', + provider_base_url='http://localhost', + provider_app_version='1.2.3', + publish_verification_results=True) + + self.default_call.insert(0, 'http://broker.com/pacts/consumer-provider.json') + self.default_call.insert(1, 'http://broker.com/pacts/consumer-provider2.json') + self.default_call.extend(['--provider-app-version', '1.2.3', '--publish-verification-results']) self.assertProcess(*self.default_call) self.assertEqual(result, 0) @@ -217,10 +252,10 @@ def test_expand_dirs_called(self, mock_rerun_cmd, mock_expand_dirs, mock_sanitiz self.mock_Popen.return_value.returncode = 0 wrapper = VerifyWrapper() - result, output = wrapper.call_verify('path/to/pact1', - 'path/to/pact2', - provider_base_url='http://localhost', - provider='provider') + result, output = wrapper.verify('path/to/pact1', + 'path/to/pact2', + provider_base_url='http://localhost', + provider='provider') mock_expand_dirs.assert_called_with(['path/to/pact1', 'path/to/pact2']) diff --git a/tox.ini b/tox.ini index 2cfa5fdeb..c3638b31e 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,13 @@ [tox] envlist=py{36,37,38,39,310,311}-{test,install} + [testenv] +setenv = + PACT_DO_NOT_TRACK = true +; passenv = * +usedevelop=True deps= test: -rrequirements_dev.txt commands= - test: pytest --cov pact tests - install: python -c "import pact" + test: pytest --cov pact tests -rx + install: python -c "import pact" \ No newline at end of file