├── pactman ├── mock │ ├── __init__.py │ ├── provider.py │ ├── publish.py │ ├── response.py │ ├── mock_urlopen.py │ ├── request.py │ ├── pact_request_handler.py │ ├── mock_server.py │ ├── consumer.py │ ├── pact.py │ └── matchers.py ├── test │ ├── __init__.py │ ├── mock_matchers │ │ ├── __init__.py │ │ ├── test_term.py │ │ ├── test_like.py │ │ ├── test_eachlike.py │ │ ├── test_headers.py │ │ ├── test_equality.py │ │ ├── test_include.py │ │ ├── test_non_json.py │ │ └── test_mock_matchers.py │ ├── pact_serialisation │ │ ├── __init__.py │ │ ├── test_path.py │ │ ├── test_header.py │ │ ├── test_body.py │ │ ├── test_full_payload.py │ │ ├── test_given.py │ │ └── test_request_query.py │ ├── testcases-version-2 │ │ ├── request │ │ │ ├── does-not-match-missing-header.json │ │ │ ├── params_match_type.json │ │ │ ├── params_match_regex.json │ │ │ └── params_match_regex_fail.json │ │ └── response │ │ │ ├── non-json body with unicode.json │ │ │ ├── empty array in body matches.json │ │ │ ├── empty spec array does not match non-empty data array.json │ │ │ └── non-empty spec array does not match empty data array.json │ ├── testcases-version-3 │ │ ├── response │ │ │ ├── header value is different.json │ │ │ ├── expected array is empty.json │ │ │ ├── expected array is not empty.json │ │ │ ├── matches content type with charset.json │ │ │ ├── body null when object expected.json │ │ │ ├── null when array expected.json │ │ │ ├── array contents match.json │ │ │ ├── body element missing.json │ │ │ ├── body null when object expected with matcher.json │ │ │ ├── type mismatch.json │ │ │ ├── this array is no object.json │ │ │ ├── null when array expected with matcher.json │ │ │ ├── type mismatch nested object.json │ │ │ └── multiple matchers or combination.json │ │ ├── request │ │ │ ├── body null when object expected.json │ │ │ ├── unexpected-key.json │ │ │ ├── header fails with regex.json │ │ │ ├── query matches with regex wildcard.json │ │ │ ├── query matches with regex wildcard fail.json │ │ │ ├── query matches with like.json │ │ │ ├── match fails with equality.json │ │ │ ├── match succeeds with equality.json │ │ │ ├── query detects mismatch with like.json │ │ │ ├── match fails with include.json │ │ │ └── match succeeds with include.json │ │ └── dummypact.json │ ├── exercise_sample.py │ ├── test_mock_server.py │ ├── test_parse_header.py │ ├── test_result.py │ ├── test_mock_consumer.py │ ├── test_pytest_plugin.py │ ├── test_mock_urlopen.py │ ├── test_matching_rules.py │ ├── test_pact_generation.py │ └── test_mock_pact.py ├── verifier │ ├── __init__.py │ ├── paths.py │ ├── parse_header.py │ ├── result.py │ ├── command_line.py │ ├── broker_pact.py │ ├── pytest_plugin.py │ └── matching_rule.py ├── __version__.py ├── __main__.py └── __init__.py ├── pyproject.toml ├── MANIFEST.in ├── TODO.txt ├── .travis.yml ├── setup.cfg ├── tox.ini ├── .gitmodules ├── .gitignore ├── setup.py ├── CONTRIBUTING.md └── LICENSE /pactman/mock/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pactman/test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pactman/verifier/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pactman/test/mock_matchers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pactman/test/pact_serialisation/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pactman/__version__.py: -------------------------------------------------------------------------------- 1 | __version__ = "2.31.0" 2 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 100 3 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include *.txt 3 | include *.md 4 | -------------------------------------------------------------------------------- /pactman/__main__.py: -------------------------------------------------------------------------------- 1 | from .verifier.command_line import main 2 | 3 | main() 4 | -------------------------------------------------------------------------------- /TODO.txt: -------------------------------------------------------------------------------- 1 | Further pytest support 2 | ---------------------- 3 | 4 | Maybe handle config.option.tbstyle == "line" and others. 5 | 6 | Implement the "pending" pact feature. 7 | -------------------------------------------------------------------------------- /pactman/verifier/paths.py: -------------------------------------------------------------------------------- 1 | def format_path(path): 2 | s = path[0] 3 | for elem in path[1:]: 4 | if isinstance(elem, int): 5 | s += f"[{elem}]" 6 | else: 7 | s += "." + elem 8 | return s 9 | -------------------------------------------------------------------------------- /pactman/test/testcases-version-2/request/does-not-match-missing-header.json: -------------------------------------------------------------------------------- 1 | { 2 | "match": false, 3 | "comment": "Does not match if missing header", 4 | "expected" : { 5 | "headers": { 6 | "X-Custom-Header": "value1" 7 | } 8 | }, 9 | "actual": { 10 | "headers": {} 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | dist: xenial 3 | sudo: required 4 | matrix: 5 | include: 6 | - python: 3.7 7 | env: TOXENV=py37 8 | - python: 3.8 9 | env: TOXENV=py38 10 | - python: 3.9 11 | env: TOXENV=py39 12 | install: 13 | - python3 -m pip install tox 14 | script: 15 | - tox 16 | -------------------------------------------------------------------------------- /pactman/test/testcases-version-3/response/header value is different.json: -------------------------------------------------------------------------------- 1 | { 2 | "match": false, 3 | "comment": "Headers values are different", 4 | "expected" : { 5 | "headers": { 6 | "Content-Type": "html-yeah" 7 | } 8 | }, 9 | "actual": { 10 | "headers": { 11 | "Content-Type": "json-yeah" 12 | } 13 | } 14 | } -------------------------------------------------------------------------------- /pactman/test/mock_matchers/test_term.py: -------------------------------------------------------------------------------- 1 | from pactman import Term 2 | 3 | 4 | def test_regex(): 5 | assert Term("[a-zA-Z]", "abcXYZ").ruby_protocol() == { 6 | "json_class": "Pact::Term", 7 | "data": { 8 | "matcher": {"json_class": "Regexp", "s": "[a-zA-Z]", "o": 0}, 9 | "generate": "abcXYZ", 10 | }, 11 | } 12 | -------------------------------------------------------------------------------- /pactman/test/testcases-version-3/response/expected array is empty.json: -------------------------------------------------------------------------------- 1 | { 2 | "match": false, 3 | "comment": "want an array but got an empty one", 4 | "expected": { 5 | "headers": {"Content-Type": "application/json"}, 6 | "body": ["a", "b", "c"] 7 | }, 8 | "actual": { 9 | "headers": { 10 | "Content-Type": "application/json" 11 | }, 12 | "body": [] 13 | } 14 | } -------------------------------------------------------------------------------- /pactman/test/pact_serialisation/test_path.py: -------------------------------------------------------------------------------- 1 | from pactman import Term 2 | from pactman.mock.request import Request 3 | 4 | 5 | def test_matcher_in_path_gets_converted(): 6 | target = Request("GET", Term(r"\/.+", "/test-path")) 7 | assert target.json("2.0.0") == { 8 | "method": "GET", 9 | "path": "/test-path", 10 | "matchingRules": {"$.path": {"regex": r"\/.+"}}, 11 | } 12 | -------------------------------------------------------------------------------- /pactman/test/testcases-version-3/response/expected array is not empty.json: -------------------------------------------------------------------------------- 1 | { 2 | "match": false, 3 | "comment": "want an empty array but received a full one", 4 | "expected": { 5 | "headers": {"Content-Type": "application/json"}, 6 | "body": [] 7 | }, 8 | "actual": { 9 | "headers": { 10 | "Content-Type": "application/json" 11 | }, 12 | "body": ["a", "b", "c"] 13 | } 14 | } -------------------------------------------------------------------------------- /pactman/test/testcases-version-3/response/matches content type with charset.json: -------------------------------------------------------------------------------- 1 | { 2 | "match": false, 3 | "comment": "content-type is different *and* charset presence differs", 4 | "expected" : { 5 | "headers": { 6 | "Content-Type": "application/json" 7 | } 8 | }, 9 | "actual": { 10 | "headers": { 11 | "Content-Type": "text/plain; charset=UTF-8" 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /pactman/test/testcases-version-2/response/non-json body with unicode.json: -------------------------------------------------------------------------------- 1 | { 2 | "match": true, 3 | "comment": "unicode plain text body matches", 4 | "expected": { 5 | "headers": {"Content-Type": "text/plain; charset=utf-8"}, 6 | "body": "h\\u00e9llo world" 7 | }, 8 | "actual": { 9 | "headers": {"Content-Type": "text/plain; charset=utf-8"}, 10 | "body": "h\\u00e9llo world" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /pactman/test/testcases-version-3/request/body null when object expected.json: -------------------------------------------------------------------------------- 1 | { 2 | "match": false, 3 | "comment": "Mismatched type", 4 | "expected": { 5 | "metaData": { 6 | "contentType": "application/json" 7 | }, 8 | "body": { 9 | "a": "b" 10 | } 11 | }, 12 | "actual": { 13 | "metaData": { 14 | "contentType": "application/json" 15 | }, 16 | "body": null 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /pactman/test/testcases-version-3/response/body null when object expected.json: -------------------------------------------------------------------------------- 1 | { 2 | "match": false, 3 | "comment": "Mismatched type", 4 | "expected": { 5 | "metaData": { 6 | "contentType": "application/json" 7 | }, 8 | "body": { 9 | "a": "b" 10 | } 11 | }, 12 | "actual": { 13 | "metaData": { 14 | "contentType": "application/json" 15 | }, 16 | "body": null 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /pactman/mock/provider.py: -------------------------------------------------------------------------------- 1 | """Classes and methods to describe contract Providers.""" 2 | 3 | 4 | class Provider(object): 5 | """A Pact provider.""" 6 | 7 | def __init__(self, name): 8 | """ 9 | Create a new Provider. 10 | 11 | :param name: The name of this provider. This will be shown in the Pact 12 | when it is published. 13 | :type name: str 14 | """ 15 | self.name = name 16 | -------------------------------------------------------------------------------- /pactman/test/testcases-version-3/response/null when array expected.json: -------------------------------------------------------------------------------- 1 | { 2 | "match": false, 3 | "comment": "Mismatched type", 4 | "expected": { 5 | "metaData": { 6 | "contentType": "application/json" 7 | }, 8 | "body": { 9 | "a": [1, 2, 3] 10 | } 11 | }, 12 | "actual": { 13 | "metaData": { 14 | "contentType": "application/json" 15 | }, 16 | "body": { 17 | "a": null 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /pactman/test/testcases-version-3/request/unexpected-key.json: -------------------------------------------------------------------------------- 1 | { 2 | "match": false, 3 | "comment": "Unexpected data in request", 4 | "expected": { 5 | "metaData": { 6 | "contentType": "application/json" 7 | }, 8 | "body": { 9 | "a": "b" 10 | } 11 | }, 12 | "actual": { 13 | "metaData": { 14 | "contentType": "application/json" 15 | }, 16 | "body": { 17 | "a": "b", 18 | "d": "b" 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /pactman/__init__.py: -------------------------------------------------------------------------------- 1 | """Python methods for interactive with a Pact Mock Service.""" 2 | from .mock.consumer import Consumer 3 | from .mock.matchers import EachLike, Equals, Includes, Like, SomethingLike, Term 4 | from .mock.pact import Pact 5 | from .mock.provider import Provider 6 | 7 | __all__ = ( 8 | "Consumer", 9 | "EachLike", 10 | "Equals", 11 | "Includes", 12 | "Like", 13 | "Pact", 14 | "Provider", 15 | "SomethingLike", 16 | "Term", 17 | ) 18 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [coverage:report] 2 | omit = pactman/verifier/pytest_plugin.py, pactman/verifier/command_line.py, pactman/test/*, pactman/__version__.py 3 | exclude_lines = 4 | if __name__ == .__main__.: 5 | pragma: no cover 6 | 7 | [flake8] 8 | max-complexity=10 9 | max-line-length=120 10 | 11 | [nosetests] 12 | with-coverage=true 13 | cover-package=pact 14 | cover-branches=true 15 | with-xunit=true 16 | xunit-file=nosetests.xml 17 | 18 | [pydocstyle] 19 | match-dir=[^(test|\.)].* 20 | -------------------------------------------------------------------------------- /pactman/test/testcases-version-3/dummypact.json: -------------------------------------------------------------------------------- 1 | { 2 | "provider": {"name": "SpamProvider"}, 3 | "consumer": {"name": "SpamConsumer"}, 4 | "interactions": [{ 5 | "description": "dummy", 6 | "request": {"method": "GET", "path": "/users-service/user/alex"}, 7 | "response": {"headers": {}, "status": 200} 8 | }], 9 | "metadata": {"pactSpecification": {"version": "2.0.0"} 10 | } 11 | } -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py37,py38,py39 3 | 4 | [testenv] 5 | deps = 6 | configparser 7 | codecov 8 | coverage 9 | mccabe 10 | pytest 11 | pytest-flake8 12 | pytest-mock 13 | pydocstyle 14 | wheel 15 | commands = 16 | coverage run --source=pactman -m pytest --flake8 {posargs} 17 | coverage html --directory=htmlcov 18 | coverage report --fail-under=88 19 | 20 | [testenv:sample] 21 | commands = python -m pactman.test.exercise_sample {posargs} 22 | -------------------------------------------------------------------------------- /pactman/test/pact_serialisation/test_header.py: -------------------------------------------------------------------------------- 1 | from pactman import Term 2 | from pactman.mock.request import Request 3 | 4 | 5 | def test_matcher_in_path_gets_converted(): 6 | target = Request("GET", "/", headers={"Spam": Term(r"\w+", "spam")}) 7 | assert target.json("3.0.0") == { 8 | "method": "GET", 9 | "path": "/", 10 | "headers": {"Spam": "spam"}, 11 | "matchingRules": {"header": {"Spam": {"matchers": [{"match": "regex", "regex": "\\w+"}]}}}, 12 | } 13 | -------------------------------------------------------------------------------- /pactman/test/testcases-version-2/request/params_match_type.json: -------------------------------------------------------------------------------- 1 | { 2 | "match": true, 3 | "comment": "Queries are the same", 4 | "expected" : { 5 | "method": "GET", 6 | "path": "/path", 7 | "query": "alligator=Mary&hippo=John", 8 | "headers": {}, 9 | "matchingRules": { 10 | "$.query": { 11 | "match": "type" 12 | } 13 | } 14 | }, 15 | "actual": { 16 | "method": "GET", 17 | "path": "/path", 18 | "query": "alligator=Alex&hippo=Sam", 19 | "headers": {} 20 | } 21 | } -------------------------------------------------------------------------------- /pactman/test/testcases-version-3/response/array contents match.json: -------------------------------------------------------------------------------- 1 | { 2 | "match": true, 3 | "comment": "match third array element by type", 4 | "expected": { 5 | "headers": {"Content-Type": "application/json"}, 6 | "body": ["a", "b", "c"], 7 | "matchingRules": { 8 | "body": { 9 | "$[2]": {"matchers": [{"match": "type"}]} 10 | } 11 | } 12 | 13 | }, 14 | "actual": { 15 | "headers": { 16 | "Content-Type": "application/json" 17 | }, 18 | "body": ["a", "b", "c"] 19 | } 20 | } -------------------------------------------------------------------------------- /pactman/test/testcases-version-3/response/body element missing.json: -------------------------------------------------------------------------------- 1 | { 2 | "match": false, 3 | "comment": "Mismatched type", 4 | "expected": { 5 | "metaData": { 6 | "contentType": "application/json" 7 | }, 8 | "body": { 9 | "a": [ 10 | {"b": 2}, 11 | {"b": "b"} 12 | ] 13 | } 14 | }, 15 | "actual": { 16 | "metaData": { 17 | "contentType": "application/json" 18 | }, 19 | "body": { 20 | "a": [ 21 | {"b": 2}, 22 | {"d": "b"} 23 | ] 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "pactman/test/pact-spec-version-1.1"] 2 | path = pactman/test/pact-spec-version-1.1 3 | url = https://github.com/pact-foundation/pact-specification.git 4 | branch = version-1.1 5 | [submodule "pactman/test/pact-spec-version-2"] 6 | path = pactman/test/pact-spec-version-2 7 | url = https://github.com/pact-foundation/pact-specification.git 8 | branch = version-2 9 | [submodule "pactman/test/pact-spec-version-3"] 10 | path = pactman/test/pact-spec-version-3 11 | url = https://github.com/pact-foundation/pact-specification.git 12 | branch = version-3 13 | -------------------------------------------------------------------------------- /pactman/test/testcases-version-3/response/body null when object expected with matcher.json: -------------------------------------------------------------------------------- 1 | { 2 | "match": false, 3 | "comment": "Mismatched type", 4 | "expected": { 5 | "metaData": { 6 | "contentType": "application/json" 7 | }, 8 | "body": { 9 | "a": "b" 10 | }, 11 | "matchingRules": { 12 | "body": { 13 | "$": { 14 | "match": "type" 15 | } 16 | } 17 | } 18 | }, 19 | "actual": { 20 | "metaData": { 21 | "contentType": "application/json" 22 | }, 23 | "body": null 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /pactman/test/testcases-version-2/request/params_match_regex.json: -------------------------------------------------------------------------------- 1 | { 2 | "match": true, 3 | "comment": "Queries pass matching by regex", 4 | "expected" : { 5 | "method": "GET", 6 | "path": "/path", 7 | "query": "alligator=1234&hippo=John", 8 | "headers": {}, 9 | "matchingRules": { 10 | "$.query.alligator": { 11 | "match": "regex", 12 | "regex": "\\d+" 13 | } 14 | } 15 | }, 16 | "actual": { 17 | "method": "GET", 18 | "path": "/path", 19 | "query": "alligator=123&hippo=John", 20 | "headers": {} 21 | } 22 | } -------------------------------------------------------------------------------- /pactman/test/testcases-version-3/response/type mismatch.json: -------------------------------------------------------------------------------- 1 | { 2 | "match": false, 3 | "comment": "Mismatched type", 4 | "expected": { 5 | "metaData": { 6 | "contentType": "application/json" 7 | }, 8 | "body": { 9 | "name": 2 10 | }, 11 | "matchingRules": { 12 | "body": { 13 | "$.name": { 14 | "match": "type" 15 | } 16 | } 17 | } 18 | }, 19 | "actual": { 20 | "metaData": { 21 | "contentType": "application/json" 22 | }, 23 | "body": { 24 | "name": "spam" 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /pactman/test/testcases-version-2/request/params_match_regex_fail.json: -------------------------------------------------------------------------------- 1 | { 2 | "match": false, 3 | "comment": "Queries fail matching by regex", 4 | "expected" : { 5 | "method": "GET", 6 | "path": "/path", 7 | "query": "alligator=1234&hippo=John", 8 | "headers": {}, 9 | "matchingRules": { 10 | "$.query.alligator": { 11 | "match": "regex", 12 | "regex": "\\d+" 13 | } 14 | } 15 | }, 16 | "actual": { 17 | "method": "GET", 18 | "path": "/path", 19 | "query": "alligator=Alex&hippo=John", 20 | "headers": {} 21 | } 22 | } -------------------------------------------------------------------------------- /pactman/test/testcases-version-3/response/this array is no object.json: -------------------------------------------------------------------------------- 1 | { 2 | "match": false, 3 | "comment": "Expected an object to apply rule, found an array", 4 | "expected": { 5 | "metaData": { 6 | "contentType": "application/json" 7 | }, 8 | "body": { 9 | "name": 2 10 | }, 11 | "matchingRules": { 12 | "body": { 13 | "$.name": { 14 | "match": "type" 15 | } 16 | } 17 | } 18 | }, 19 | "actual": { 20 | "metaData": { 21 | "contentType": "application/json" 22 | }, 23 | "body": [1] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /pactman/test/testcases-version-2/response/empty array in body matches.json: -------------------------------------------------------------------------------- 1 | { 2 | "match": true, 3 | "comment": "empty arrays match", 4 | "expected": { 5 | "headers": {"Content-Type": "application/json"}, 6 | "body": { 7 | "id": 1014753708, 8 | "addresses": [] 9 | }, 10 | "matchingRules": { 11 | "$.body": { 12 | "match": "type" 13 | } 14 | } 15 | }, 16 | "actual": { 17 | "headers": { 18 | "Content-Type": "application/json" 19 | }, 20 | "body": { 21 | "id": 1014753708, 22 | "addresses": [] 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /pactman/test/testcases-version-3/response/null when array expected with matcher.json: -------------------------------------------------------------------------------- 1 | { 2 | "match": false, 3 | "comment": "Mismatched type", 4 | "expected": { 5 | "metaData": { 6 | "contentType": "application/json" 7 | }, 8 | "body": { 9 | "a": [1, 2, 3] 10 | }, 11 | "matchingRules": { 12 | "body": { 13 | "$": { 14 | "match": "type" 15 | } 16 | } 17 | } 18 | }, 19 | "actual": { 20 | "metaData": { 21 | "contentType": "application/json" 22 | }, 23 | "body": { 24 | "a": null 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /pactman/test/testcases-version-3/request/header fails with regex.json: -------------------------------------------------------------------------------- 1 | { 2 | "match": false, 3 | "comment": "Headers match with regex", 4 | "expected" : { 5 | "headers": { 6 | "Accept": "alligators", 7 | "Content-Type": "hippos" 8 | }, 9 | "matchingRules": { 10 | "header": { 11 | "Accept": { 12 | "matchers": [ 13 | { 14 | "match": "regex", 15 | "regex": "\\d+" 16 | } 17 | ] 18 | } 19 | } 20 | } 21 | }, 22 | "actual": { 23 | "headers": { 24 | "Content-Type": "hippos", 25 | "Accept": "godzilla" 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /pactman/test/testcases-version-2/response/empty spec array does not match non-empty data array.json: -------------------------------------------------------------------------------- 1 | { 2 | "match": false, 3 | "comment": "empty spec array does not match non-empty data array", 4 | "expected": { 5 | "headers": {"Content-Type": "application/json"}, 6 | "body": { 7 | "id": 1014753708, 8 | "addresses": [] 9 | }, 10 | "matchingRules": { 11 | "$.body": { 12 | "match": "type" 13 | } 14 | } 15 | }, 16 | "actual": { 17 | "headers": { 18 | "Content-Type": "application/json" 19 | }, 20 | "body": { 21 | "id": 1014753708, 22 | "addresses": ["foo@bar.com"] 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /pactman/test/testcases-version-2/response/non-empty spec array does not match empty data array.json: -------------------------------------------------------------------------------- 1 | { 2 | "match": false, 3 | "comment": "non-empty spec array does not match empty data array", 4 | "expected": { 5 | "headers": {"Content-Type": "application/json"}, 6 | "body": { 7 | "id": 1014753708, 8 | "addresses": ["foo@bar.com"] 9 | }, 10 | "matchingRules": { 11 | "$.body": { 12 | "match": "type" 13 | } 14 | } 15 | }, 16 | "actual": { 17 | "headers": { 18 | "Content-Type": "application/json" 19 | }, 20 | "body": { 21 | "id": 1014753708, 22 | "addresses": [] 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /pactman/test/testcases-version-3/response/type mismatch nested object.json: -------------------------------------------------------------------------------- 1 | { 2 | "match": false, 3 | "comment": "Mismatched type in nested object", 4 | "expected": { 5 | "metaData": { 6 | "contentType": "application/json" 7 | }, 8 | "body": { 9 | "a": [ 10 | {"name": 2}, 11 | {"name": 1} 12 | ] 13 | }, 14 | "matchingRules": { 15 | "body": { 16 | "$.a[*].name": { 17 | "match": "type" 18 | } 19 | } 20 | } 21 | }, 22 | "actual": { 23 | "metaData": { 24 | "contentType": "application/json" 25 | }, 26 | "body": { 27 | "a": [ 28 | { 29 | "name": 10 30 | }, 31 | { 32 | "name": "spam" 33 | } 34 | ] 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /pactman/test/testcases-version-3/request/query matches with regex wildcard.json: -------------------------------------------------------------------------------- 1 | { 2 | "match": true, 3 | "comment": "Queries match with regex wildcard", 4 | "expected" : { 5 | "method": "GET", 6 | "path": "/path", 7 | "query": { 8 | "alligator": ["444"], 9 | "hippo": ["555"] 10 | }, 11 | "headers": {}, 12 | "matchingRules": { 13 | "query": { 14 | "*": { 15 | "matchers": [ 16 | { 17 | "match": "regex", 18 | "regex": "\\d+" 19 | } 20 | ] 21 | } 22 | } 23 | } 24 | }, 25 | "actual": { 26 | "method": "GET", 27 | "path": "/path", 28 | "query": { 29 | "alligator": ["123"], 30 | "hippo": ["456"] 31 | }, 32 | "headers": {} 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /pactman/test/testcases-version-3/request/query matches with regex wildcard fail.json: -------------------------------------------------------------------------------- 1 | { 2 | "match": false, 3 | "comment": "Queries match with regex wildcard", 4 | "expected" : { 5 | "method": "GET", 6 | "path": "/path", 7 | "query": { 8 | "alligator": ["444"], 9 | "hippo": ["555"] 10 | }, 11 | "headers": {}, 12 | "matchingRules": { 13 | "query": { 14 | "*": { 15 | "matchers": [ 16 | { 17 | "match": "regex", 18 | "regex": "\\d+" 19 | } 20 | ] 21 | } 22 | } 23 | } 24 | }, 25 | "actual": { 26 | "method": "GET", 27 | "path": "/path", 28 | "query": { 29 | "alligator": ["123"], 30 | "hippo": ["fruitcake"] 31 | }, 32 | "headers": {} 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /pactman/test/exercise_sample.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import sys 4 | from unittest.mock import Mock 5 | 6 | import semver 7 | from pactman.test.test_verifier import FakeResponse 8 | from pactman.verifier.result import LoggedResult 9 | from pactman.verifier.verify import ResponseVerifier 10 | 11 | logging.basicConfig(level=logging.DEBUG) 12 | 13 | result = LoggedResult() 14 | version = sys.argv[1] 15 | verifier = ResponseVerifier 16 | response = FakeResponse 17 | 18 | with open(sys.argv[2]) as file: 19 | case = json.load(file) 20 | 21 | pact = Mock( 22 | provider="SpamProvider", 23 | consumer="SpamConsumer", 24 | version=version, 25 | semver=semver.VersionInfo.parse(version), 26 | ) 27 | verifier(pact, case["expected"], result).verify(response(case["actual"])) 28 | -------------------------------------------------------------------------------- /pactman/mock/publish.py: -------------------------------------------------------------------------------- 1 | from pactman.verifier.broker_pact import PactBrokerConfig 2 | 3 | # NOTE: this code is a WIP, it's not used yet 4 | 5 | 6 | class Publisher: 7 | def __init__(self, broker: PactBrokerConfig): 8 | self.broker = broker 9 | self.nav = self.broker.get_broker_navigator() 10 | 11 | def publish_pact(self, pact, version, tags): 12 | # there's no direct link from the broker root to a pacticipant, so we go via pb:latest-version 13 | tagger = self.nav["latest-version"](pacticipant=pact["consumer"])["pacticipant"][ 14 | "version-tag" 15 | ] 16 | for tag in tags: 17 | tagger(tag=tag, version=version).upsert({}) 18 | 19 | self.nav["publish-pact"]( 20 | consumer=pact["consumer"], provider=pact["provider"], version=version 21 | ) 22 | -------------------------------------------------------------------------------- /pactman/test/test_mock_server.py: -------------------------------------------------------------------------------- 1 | from queue import Queue 2 | from unittest.mock import Mock 3 | 4 | import pytest 5 | from pactman.mock import mock_server 6 | 7 | 8 | def queue(*a): 9 | q = Queue() 10 | for value in a: 11 | q.put(value) 12 | return q 13 | 14 | 15 | @pytest.mark.parametrize( 16 | "results,exception", 17 | [ 18 | (queue(dict(status="error", reason="spam")), mock_server.MockServer.Error), 19 | (queue(dict(status="failed", reason="spam")), AssertionError), 20 | ], 21 | ) 22 | def test_correct_result_assertion(mocker, results, exception): 23 | mocker.patch("pactman.mock.mock_server.Process", autospec=True) 24 | s = mock_server.Server(Mock()) 25 | s.results = results 26 | with pytest.raises(exception) as e: 27 | s.verify() 28 | assert "spam" in str(e.value) 29 | -------------------------------------------------------------------------------- /pactman/test/testcases-version-3/response/multiple matchers or combination.json: -------------------------------------------------------------------------------- 1 | { 2 | "match": true, 3 | "comment": "Messages match a fallback matcher regex", 4 | "expected": { 5 | "metaData": { 6 | "contentType": "application/json" 7 | }, 8 | "body": { 9 | "name": "alligator" 10 | }, 11 | "matchingRules": { 12 | "body": { 13 | "$.name": { 14 | "matchers": [ 15 | { 16 | "match": "regex", 17 | "regex": "ham" 18 | }, 19 | { 20 | "match": "regex", 21 | "regex": "spam" 22 | } 23 | ], 24 | "combine": "OR" 25 | } 26 | } 27 | } 28 | }, 29 | "actual": { 30 | "metaData": { 31 | "contentType": "application/json" 32 | }, 33 | "body": { 34 | "name": "spam" 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /pactman/test/testcases-version-3/request/query matches with like.json: -------------------------------------------------------------------------------- 1 | { 2 | "match": true, 3 | "comment": "Queries match with like", 4 | "expected" : { 5 | "method": "GET", 6 | "path": "/path", 7 | "query": { 8 | "alligator": ["Mary"], 9 | "hippo": ["John"] 10 | }, 11 | "headers": {}, 12 | "matchingRules": { 13 | "query": { 14 | "hippo": { 15 | "matchers": [ 16 | { 17 | "match": "type" 18 | } 19 | ] 20 | }, 21 | "alligator": { 22 | "matchers": [ 23 | { 24 | "match": "type" 25 | } 26 | ] 27 | } 28 | } 29 | } 30 | }, 31 | "actual": { 32 | "method": "GET", 33 | "path": "/path", 34 | "query": { 35 | "alligator": ["Alex"], 36 | "hippo": ["Fred"] 37 | }, 38 | "headers": {} 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /pactman/test/testcases-version-3/request/match fails with equality.json: -------------------------------------------------------------------------------- 1 | { 2 | "match": false, 3 | "comment": "Request match with equality and fail", 4 | "expected" : { 5 | "method": "POST", 6 | "path": "/", 7 | "query": {}, 8 | "headers": {"Content-Type": "application/json"}, 9 | "matchingRules": { 10 | "body": { 11 | "$": { 12 | "matchers": [ 13 | { 14 | "match": "type" 15 | } 16 | ] 17 | }, 18 | "$.alligator.feet": { 19 | "matchers": [ 20 | { 21 | "match": "equality" 22 | } 23 | ] 24 | } 25 | } 26 | }, 27 | "body": { 28 | "alligator":{ 29 | "name": "Jay", 30 | "feet": 4 31 | } 32 | } 33 | }, 34 | "actual": { 35 | "method": "POST", 36 | "path": "/", 37 | "query": {}, 38 | "headers": {"Content-Type": "application/json"}, 39 | "body": { 40 | "alligator":{ 41 | "feet": 5, 42 | "name": "Alex" 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /pactman/test/testcases-version-3/request/match succeeds with equality.json: -------------------------------------------------------------------------------- 1 | { 2 | "match": true, 3 | "comment": "Request match with equality and pass", 4 | "expected" : { 5 | "method": "POST", 6 | "path": "/", 7 | "query": {}, 8 | "headers": {"Content-Type": "application/json"}, 9 | "matchingRules": { 10 | "body": { 11 | "$": { 12 | "matchers": [ 13 | { 14 | "match": "type" 15 | } 16 | ] 17 | }, 18 | "$.alligator.feet": { 19 | "matchers": [ 20 | { 21 | "match": "equality" 22 | } 23 | ] 24 | } 25 | } 26 | }, 27 | "body": { 28 | "alligator":{ 29 | "name": "Jay", 30 | "feet": 4 31 | } 32 | } 33 | }, 34 | "actual": { 35 | "method": "POST", 36 | "path": "/", 37 | "query": {}, 38 | "headers": {"Content-Type": "application/json"}, 39 | "body": { 40 | "alligator":{ 41 | "feet": 4, 42 | "name": "Alex" 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /pactman/test/testcases-version-3/request/query detects mismatch with like.json: -------------------------------------------------------------------------------- 1 | { 2 | "match": false, 3 | "comment": "Queries with a single like and and explicit match will fail", 4 | "expected" : { 5 | "method": "GET", 6 | "path": "/path", 7 | "query": { 8 | "alligator": ["Mary"], 9 | "hippo": ["John"] 10 | }, 11 | "headers": {"Accept": "alligators"}, 12 | "matchingRules": { 13 | "query": { 14 | "hippo": { 15 | "matchers": [ 16 | { 17 | "match": "type" 18 | } 19 | ] 20 | } 21 | }, 22 | "header": { 23 | "Accept": { 24 | "matchers": [ 25 | { 26 | "match": "regex", 27 | "regex": "\\w+" 28 | } 29 | ] 30 | } 31 | } 32 | } 33 | }, 34 | "actual": { 35 | "method": "GET", 36 | "path": "/path", 37 | "query": { 38 | "alligator": ["Alex"], 39 | "hippo": ["Fred"] 40 | }, 41 | "headers": { 42 | "Accept": "godzilla" 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /pactman/test/testcases-version-3/request/match fails with include.json: -------------------------------------------------------------------------------- 1 | { 2 | "match": false, 3 | "comment": "Request match with include and fail", 4 | "expected" : { 5 | "method": "POST", 6 | "path": "/", 7 | "query": {}, 8 | "headers": {"Content-Type": "application/json"}, 9 | "matchingRules": { 10 | "body": { 11 | "$": { 12 | "matchers": [ 13 | { 14 | "match": "type" 15 | } 16 | ] 17 | }, 18 | "$.alligator.name": { 19 | "matchers": [ 20 | { 21 | "match": "include", 22 | "value": "alex" 23 | } 24 | ] 25 | } 26 | } 27 | }, 28 | "body": { 29 | "alligator":{ 30 | "name": "Jay", 31 | "feet": 4 32 | } 33 | } 34 | }, 35 | "actual": { 36 | "method": "POST", 37 | "path": "/", 38 | "query": {}, 39 | "headers": {"Content-Type": "application/json"}, 40 | "body": { 41 | "alligator":{ 42 | "feet": 5, 43 | "name": "spam" 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /pactman/test/testcases-version-3/request/match succeeds with include.json: -------------------------------------------------------------------------------- 1 | { 2 | "match": true, 3 | "comment": "Request match with include and pass", 4 | "expected" : { 5 | "method": "POST", 6 | "path": "/", 7 | "query": {}, 8 | "headers": {"Content-Type": "application/json"}, 9 | "matchingRules": { 10 | "body": { 11 | "$": { 12 | "matchers": [ 13 | { 14 | "match": "type" 15 | } 16 | ] 17 | }, 18 | "$.alligator.name": { 19 | "matchers": [ 20 | { 21 | "match": "include", 22 | "value": "alex" 23 | } 24 | ] 25 | } 26 | } 27 | }, 28 | "body": { 29 | "alligator":{ 30 | "name": "Jay", 31 | "feet": 4 32 | } 33 | } 34 | }, 35 | "actual": { 36 | "method": "POST", 37 | "path": "/", 38 | "query": {}, 39 | "headers": {"Content-Type": "application/json"}, 40 | "body": { 41 | "alligator":{ 42 | "feet": 5, 43 | "name": "alexis" 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /pactman/test/test_parse_header.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pactman.verifier.parse_header import Part, parse_header 3 | 4 | 5 | def test_comma_whitespace_ignored(): 6 | assert list(parse_header("spam, ham")) == list(parse_header("spam,ham")) 7 | 8 | 9 | @pytest.mark.parametrize( 10 | ["header", "result"], 11 | [ 12 | ( 13 | ("audio/*; q=0.2, audio/basic"), 14 | [Part(["audio/*"], []), Part(["audio/basic"], [("q", "0.2")])], 15 | ), 16 | ( 17 | ( 18 | """text/plain; q=0.5, text/html, 19 | text/x-dvi; q="0.8", text/x-c""" 20 | ), 21 | [ 22 | Part(["text/plain"], []), 23 | Part(["text/html", "text/x-dvi"], [("q", "0.5")]), 24 | Part(["text/x-c"], [("q", "0.8")]), 25 | ], 26 | ), 27 | ( 28 | ('"xyz\\"zy",W/"r2d2,xxxx","spam"'), 29 | [Part(['"xyz\\"zy"', 'W/"r2d2,xxxx"', '"spam"'], [])], 30 | ), 31 | ], 32 | ) 33 | def test_accept_variants(header, result): 34 | assert list(parse_header(header)) == result 35 | -------------------------------------------------------------------------------- /pactman/test/mock_matchers/test_like.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pactman import Like, SomethingLike 3 | from pactman.mock.matchers import Matcher, Term 4 | 5 | 6 | def test_is_something_like(): 7 | assert SomethingLike is Like 8 | 9 | 10 | def test_valid_types(): 11 | types = [None, list(), dict(), 1, 1.0, "string", "unicode", Matcher()] 12 | 13 | for t in types: 14 | SomethingLike(t) 15 | 16 | 17 | def test_invalid_types(): 18 | with pytest.raises(AssertionError) as e: 19 | SomethingLike(set()) 20 | 21 | assert "matcher must be one of " in str(e.value) 22 | 23 | 24 | def test_basic_type(): 25 | assert SomethingLike(123).ruby_protocol() == { 26 | "json_class": "Pact::SomethingLike", 27 | "contents": 123, 28 | } 29 | 30 | 31 | def test_complex_type(): 32 | assert SomethingLike({"name": Term(".+", "admin")}).ruby_protocol() == { 33 | "json_class": "Pact::SomethingLike", 34 | "contents": { 35 | "name": { 36 | "json_class": "Pact::Term", 37 | "data": { 38 | "matcher": {"json_class": "Regexp", "s": ".+", "o": 0}, 39 | "generate": "admin", 40 | }, 41 | } 42 | }, 43 | } 44 | -------------------------------------------------------------------------------- /pactman/test/mock_matchers/test_eachlike.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pactman import EachLike, Like, Term 3 | 4 | 5 | def test_default_options(): 6 | assert EachLike(1).ruby_protocol() == {"json_class": "Pact::ArrayLike", "contents": 1, "min": 1} 7 | 8 | 9 | def test_minimum(): 10 | assert EachLike(1, minimum=2).ruby_protocol() == { 11 | "json_class": "Pact::ArrayLike", 12 | "contents": 1, 13 | "min": 2, 14 | } 15 | 16 | 17 | def test_minimum_assertion_error(): 18 | with pytest.raises(AssertionError) as e: 19 | EachLike(1, minimum=0) 20 | assert str(e.value) == "Minimum must be greater than or equal to 1" 21 | 22 | 23 | def test_nested_matchers(): 24 | matcher = EachLike({"username": Term("[a-z]+", "user"), "id": Like(123)}) 25 | assert matcher.ruby_protocol() == { 26 | "json_class": "Pact::ArrayLike", 27 | "contents": { 28 | "username": { 29 | "json_class": "Pact::Term", 30 | "data": { 31 | "matcher": {"json_class": "Regexp", "s": "[a-z]+", "o": 0}, 32 | "generate": "user", 33 | }, 34 | }, 35 | "id": {"json_class": "Pact::SomethingLike", "contents": 123}, 36 | }, 37 | "min": 1, 38 | } 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | .pytest_cache 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Editor stuff 62 | .idea/ 63 | .vscode/ 64 | .history/ 65 | 66 | # Scrapy stuff: 67 | .scrapy 68 | 69 | # Sphinx documentation 70 | docs/_build/ 71 | 72 | # PyBuilder 73 | target/ 74 | 75 | # IPython Notebook 76 | .ipynb_checkpoints 77 | 78 | # pyenv 79 | .python-version 80 | 81 | # celery beat schedule file 82 | celerybeat-schedule 83 | 84 | # dotenv 85 | .env 86 | 87 | # virtualenv 88 | venv/ 89 | ENV/ 90 | .venv/ 91 | 92 | # Spyder project settings 93 | .spyderproject 94 | 95 | # Rope project settings 96 | .ropeproject 97 | 98 | /*.json 99 | -------------------------------------------------------------------------------- /pactman/test/mock_matchers/test_headers.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import requests 3 | from pactman import Pact, Term 4 | 5 | 6 | def test_regex_passes(): 7 | pact = ( 8 | Pact("Consumer", "Provider", file_write_mode="never") 9 | .given("spam") 10 | .with_request("GET", "/", headers={"Spam": Term(r"\w+", "spam")}) 11 | .will_respond_with(200) 12 | ) 13 | with pact: 14 | requests.get(pact.uri, headers={"Spam": "ham"}) 15 | 16 | 17 | def test_regex_fails(): 18 | pact = ( 19 | Pact("Consumer", "Provider", file_write_mode="never") 20 | .given("spam") 21 | .with_request("GET", "/", headers={"Spam": Term(r"\w+", "spam")}) 22 | .will_respond_with(200) 23 | ) 24 | with pact: 25 | with pytest.raises(AssertionError): 26 | requests.get(pact.uri, headers={"Spam": "!@#$"}) 27 | 28 | 29 | def test_regex_passes_v3(): 30 | pact = ( 31 | Pact("Consumer", "Provider", file_write_mode="never", version="3.0.0") 32 | .given("spam") 33 | .with_request("GET", "/", headers={"Spam": Term(r"\w+", "spam")}) 34 | .will_respond_with(200) 35 | ) 36 | with pact: 37 | requests.get(pact.uri, headers={"Spam": "ham"}) 38 | 39 | 40 | def test_regex_fails_v3(): 41 | pact = ( 42 | Pact("Consumer", "Provider", file_write_mode="never", version="3.0.0") 43 | .given("spam") 44 | .with_request("GET", "/", headers={"Spam": Term(r"\w+", "spam")}) 45 | .will_respond_with(200) 46 | ) 47 | with pact: 48 | with pytest.raises(AssertionError): 49 | requests.get(pact.uri, headers={"Spam": "!@#$"}) 50 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from setuptools import find_packages, setup 4 | 5 | here = os.path.abspath(os.path.dirname(__file__)) 6 | 7 | about = {} 8 | with open(os.path.join(here, "pactman", "__version__.py")) as f: 9 | exec(f.read(), about) 10 | 11 | 12 | def read(filename): 13 | with open(os.path.join(here, filename), "rb") as f: 14 | return f.read().decode("utf-8") 15 | 16 | 17 | setup( 18 | name="pactman", 19 | version=about["__version__"], 20 | description=( 21 | "Tools for creating and verifying consumer driven contracts using the Pact framework." 22 | ), 23 | long_description=read("README.md"), 24 | long_description_content_type="text/markdown", 25 | author="ReeceTech", 26 | author_email="richard.jones@reece.com.au", 27 | url="https://github.com/reecetech/pactman", 28 | entry_points={ 29 | "pytest11": ["pactman-verifier=pactman.verifier.pytest_plugin"], 30 | "console_scripts": ["pactman-verifier=pactman.verifier.command_line:main"], 31 | }, 32 | install_requires=["pytest", "requests", "semver", "colorama", "restnavigator"], 33 | packages=find_packages(), 34 | license="MIT, Copyright (c) 2018 ReeceTech", 35 | classifiers=[ 36 | "Intended Audience :: Developers", 37 | "License :: OSI Approved :: MIT License", 38 | "Programming Language :: Python :: 3", 39 | "Programming Language :: Python :: 3.7", 40 | "Programming Language :: Python :: 3.8", 41 | "Programming Language :: Python :: 3.9", 42 | "Topic :: Software Development :: Testing", 43 | "Topic :: Software Development :: Testing :: Mocking", 44 | "Topic :: Software Development :: Testing :: Acceptance", 45 | ], 46 | ) 47 | -------------------------------------------------------------------------------- /pactman/test/pact_serialisation/test_body.py: -------------------------------------------------------------------------------- 1 | from pactman import Consumer, EachLike, Provider 2 | from pactman.mock.request import Request 3 | from pactman.mock.response import Response 4 | 5 | 6 | def test_eachlike(): 7 | pact = ( 8 | Consumer("consumer") 9 | .has_pact_with(Provider("provider"), version="2.0.0") 10 | .given("the condition exists") 11 | .upon_receiving("a request") 12 | .with_request("POST", "/path", body=EachLike(1)) 13 | .will_respond_with(200, body={"results": EachLike(1)}) 14 | ) 15 | 16 | result = pact.construct_pact(pact._interactions[0]) 17 | assert result == { 18 | "consumer": {"name": "consumer"}, 19 | "provider": {"name": "provider"}, 20 | "interactions": [ 21 | { 22 | "description": "a request", 23 | "providerState": "the condition exists", 24 | "request": dict( 25 | method="POST", 26 | path="/path", 27 | body=[1], 28 | matchingRules={"$.body": {"match": "type", "min": 1}}, 29 | ), 30 | "response": dict( 31 | status=200, 32 | body={"results": [1]}, 33 | matchingRules={"$.body.results": {"match": "type", "min": 1}}, 34 | ), 35 | } 36 | ], 37 | "metadata": dict(pactSpecification=dict(version="2.0.0")), 38 | } 39 | 40 | 41 | def test_falsey_request_body(): 42 | target = Request("GET", "/path", body=[]) 43 | assert target.json("2.0.0") == {"method": "GET", "path": "/path", "body": []} 44 | 45 | 46 | def test_falsey_response_body(): 47 | target = Response(200, body=[]) 48 | assert target.json("2.0.0") == {"status": 200, "body": []} 49 | -------------------------------------------------------------------------------- /pactman/test/mock_matchers/test_equality.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import requests 3 | from pactman import Consumer, Equals, Like, Provider 4 | 5 | 6 | @pytest.mark.parametrize("object", [None, list(), dict(), 1, 1.0, "string"]) 7 | def test_valid_types(object): 8 | Equals(object) 9 | 10 | 11 | @pytest.mark.parametrize("object", [set(), b"bytes"]) 12 | def test_invalid_types(object): 13 | with pytest.raises(AssertionError) as e: 14 | Equals(object) 15 | 16 | assert "matcher must be one of " in str(e.value) 17 | 18 | 19 | def test_basic_type(): 20 | assert Equals(123).generate_matching_rule_v3() == {"matchers": [{"match": "equality"}]} 21 | 22 | 23 | def test_v2_not_allowed(): 24 | with pytest.raises(Equals.NotAllowed): 25 | Consumer("C").has_pact_with(Provider("P"), version="2.0.0").given("g").upon_receiving( 26 | "r" 27 | ).with_request("post", "/foo", body=Equals("bee")).will_respond_with(200) 28 | 29 | 30 | def test_mock_usage_pass_validation(): 31 | pact = ( 32 | Consumer("C") 33 | .has_pact_with(Provider("P"), version="3.0.0") 34 | .given("g") 35 | .upon_receiving("r") 36 | .with_request("post", "/foo", body=Like({"a": "spam", "b": Equals("bee")})) 37 | .will_respond_with(200) 38 | ) 39 | 40 | with pact: 41 | requests.post(pact.uri + "/foo", json={"a": "ham", "b": "bee"}) 42 | 43 | 44 | def test_mock_usage_fail_validation(): 45 | pact = ( 46 | Consumer("C") 47 | .has_pact_with(Provider("P"), version="3.0.0") 48 | .given("g") 49 | .upon_receiving("r") 50 | .with_request("post", "/foo", body=Like({"a": "spam", "b": Equals("bee")})) 51 | .will_respond_with(200) 52 | ) 53 | 54 | with pytest.raises(AssertionError), pact: 55 | requests.post(pact.uri + "/foo", json={"a": "ham", "b": "wasp"}) 56 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Raising issues 2 | 3 | _Before raising an issue, please ensure that you are using the latest version of pactman._ 4 | 5 | Please provide the following information with your issue to enable us to respond as quickly as possible. 6 | 7 | * The relevant versions of the packages you are using. 8 | * The steps to recreate your issue. 9 | * The full stacktrace if there is an exception. 10 | * An executable code example where possible. 11 | 12 | # Contributing 13 | 14 | To setup a development environment: 15 | 16 | 1. Clone the repository `https://github.com/reecetech/pactman` and invoke `git submodule update --init`. 17 | 2. Install Python 3.6 from source or using a tool like [pyenv]. 18 | 3. It's recommended (but not required) to use [pre-commit] to automatically handle code styling. 19 | After installing `pre-commit` run `pre-commit install` in your local pactman clone. 20 | 21 | Then for a change you're making: 22 | 23 | 1. Create your feature branch (`git checkout -b my-new-feature`). 24 | 2. Commit your changes (`git commit -am 'Add some feature'`). 25 | 3. Push to the branch (`git push origin my-new-feature`). 26 | 4. Run the test suite (`tox`). 27 | 4. Create new Pull Request. 28 | 29 | If you are intending to implement a fairly large feature we'd appreciate if you open 30 | an issue with GitHub detailing your use case and intended solution to discuss how it 31 | might impact other work that is in flight. 32 | 33 | We also appreciate it if you take the time to update and write tests for any changes 34 | you submit. 35 | 36 | # Releasing 37 | 38 | To package the application, run: 39 | `python setup.py sdist` 40 | 41 | This creates a `dist/pactman-N.N.N.tar.gz` file, where the Ns are the current version. 42 | From there you can use pip to install it: 43 | `pip install ./dist/pactman-N.N.N.tar.gz` 44 | 45 | 46 | [pyenv]: https://github.com/pyenv/pyenv 47 | [pre-commit]: https://pre-commit.com/ 48 | -------------------------------------------------------------------------------- /pactman/test/mock_matchers/test_include.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import requests 3 | from pactman import Consumer, Includes, Like, Provider 4 | 5 | 6 | def test_valid_types(): 7 | Includes("string", "sample data") 8 | 9 | 10 | @pytest.mark.parametrize("object", [None, list(), dict(), set(), 1, 1.0, b"bytes"]) 11 | def test_invalid_types(object): 12 | with pytest.raises(AssertionError) as e: 13 | Includes(object, "sample data") 14 | 15 | assert "matcher must be a string" in str(e.value) 16 | 17 | 18 | def test_basic_type(): 19 | assert Includes("spam", "sample data").generate_matching_rule_v3() == { 20 | "matchers": [{"match": "include", "value": "spam"}] 21 | } 22 | 23 | 24 | def test_v2_not_allowed(): 25 | with pytest.raises(Includes.NotAllowed): 26 | Consumer("C").has_pact_with(Provider("P"), version="2.0.0").given("g").upon_receiving( 27 | "r" 28 | ).with_request("post", "/foo", body=Includes("bee", "been")).will_respond_with(200) 29 | 30 | 31 | def test_mock_usage_pass_validation(): 32 | pact = ( 33 | Consumer("C") 34 | .has_pact_with(Provider("P"), version="3.0.0") 35 | .given("g") 36 | .upon_receiving("r") 37 | .with_request("post", "/foo", body=Like({"a": "spam", "b": Includes("bee", "been")})) 38 | .will_respond_with(200) 39 | ) 40 | 41 | with pact: 42 | requests.post(pact.uri + "/foo", json={"a": "ham", "b": "has bee in it"}) 43 | 44 | 45 | def test_mock_usage_fail_validation(): 46 | pact = ( 47 | Consumer("C") 48 | .has_pact_with(Provider("P"), version="3.0.0") 49 | .given("g") 50 | .upon_receiving("r") 51 | .with_request("post", "/foo", body=Like({"a": "spam", "b": Includes("bee", "been")})) 52 | .will_respond_with(200) 53 | ) 54 | 55 | with pytest.raises(AssertionError), pact: 56 | requests.post(pact.uri + "/foo", json={"a": "ham", "b": "wasp"}) 57 | -------------------------------------------------------------------------------- /pactman/test/test_result.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | from pactman.verifier import result 4 | 5 | 6 | def test_logged_start_result(monkeypatch): 7 | monkeypatch.setattr(result, "log", Mock()) 8 | r = result.LoggedResult() 9 | r.start(Mock()) 10 | result.log.info.assert_called_once() 11 | 12 | 13 | def test_logged_fail_result(monkeypatch): 14 | monkeypatch.setattr(result, "log", Mock()) 15 | r = result.LoggedResult() 16 | r.fail("message") 17 | result.log.warning.assert_called_once_with(" message") 18 | 19 | 20 | def test_logged_warning(monkeypatch): 21 | monkeypatch.setattr(result, "log", Mock()) 22 | r = result.LoggedResult() 23 | r.warn("message") 24 | result.log.warning.assert_called_once_with(" message") 25 | 26 | 27 | def test_logged_fail_result_path(monkeypatch): 28 | monkeypatch.setattr(result, "log", Mock()) 29 | r = result.LoggedResult() 30 | r.fail("message", ["a", 0]) 31 | result.log.warning.assert_called_once_with(" message") 32 | 33 | 34 | def test_CaptureResult_for_passing_verification(capsys): 35 | r = result.CaptureResult() 36 | r.start(Mock()) 37 | r.end() 38 | captured = capsys.readouterr() 39 | assert "PASSED" in captured.out 40 | 41 | 42 | def test_CaptureResult_for_failing_verification(capsys): 43 | r = result.CaptureResult() 44 | r.start(Mock()) 45 | r.fail("message1") 46 | r.end() 47 | captured = capsys.readouterr() 48 | assert "FAILED" in captured.out 49 | assert "message1" in captured.out 50 | 51 | 52 | def test_CaptureResult_for_fail_with_path(capsys): 53 | r = result.CaptureResult() 54 | r.start(Mock()) 55 | r.fail("message1", path=["x", "y"]) 56 | r.end() 57 | captured = capsys.readouterr() 58 | assert "message1 at x.y" in captured.out 59 | 60 | 61 | def test_CaptureResult_for_passing_verification_with_warning(capsys): 62 | r = result.CaptureResult() 63 | r.start(Mock()) 64 | r.warn("message1") 65 | r.end() 66 | captured = capsys.readouterr() 67 | assert "PASSED" in captured.out 68 | assert "message1" in captured.out 69 | -------------------------------------------------------------------------------- /pactman/test/mock_matchers/test_non_json.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from pactman import Pact 4 | 5 | 6 | def test_params_url_coded(): 7 | pact = ( 8 | Pact("Consumer", "Provider", file_write_mode="never") 9 | .given("everything is ideal") 10 | .upon_receiving("a request") 11 | .with_request( 12 | method="POST", 13 | path="/", 14 | headers={"Content-Type": "application/x-www-form-urlencoded"}, 15 | query={ 16 | "client_id": ["test1"], 17 | "secret": ["test1-secret-key"], 18 | "scope": ["openid", "profile", "phone", "offline_access"], 19 | }, 20 | ) 21 | .will_respond_with(200, body="some data") 22 | ) 23 | with pact: 24 | requests.post( 25 | pact.uri, 26 | params=dict( 27 | client_id="test1", 28 | secret="test1-secret-key", 29 | scope="openid profile phone offline_access".split(), 30 | ), 31 | headers={"Content-Type": "application/x-www-form-urlencoded"}, 32 | ) 33 | 34 | 35 | def test_body_url_coded(): 36 | pact = ( 37 | Pact("Consumer", "Provider", file_write_mode="never") 38 | .given("everything is ideal") 39 | .upon_receiving("a request") 40 | .with_request( 41 | method="POST", 42 | path="/", 43 | headers={"Content-Type": "application/x-www-form-urlencoded"}, 44 | body={ 45 | "client_id": ["test1"], 46 | "secret": ["test1-secret-key"], 47 | "scope": ["openid", "profile", "phone", "offline_access"], 48 | }, 49 | ) 50 | .will_respond_with(200, body="some data") 51 | ) 52 | with pact: 53 | requests.post( 54 | pact.uri, 55 | data=dict( 56 | client_id="test1", 57 | secret="test1-secret-key", 58 | scope="openid profile phone offline_access".split(), 59 | ), 60 | headers={"Content-Type": "application/x-www-form-urlencoded"}, 61 | ) 62 | -------------------------------------------------------------------------------- /pactman/mock/response.py: -------------------------------------------------------------------------------- 1 | from .matchers import get_generated_values, get_matching_rules_v2, get_matching_rules_v3 2 | 3 | 4 | class Response: 5 | """Represents an HTTP response and supports Matchers on its properties.""" 6 | 7 | def __init__(self, status, headers=None, body=None): 8 | """ 9 | Create a new Response. 10 | 11 | :param status: The expected HTTP status of the response. 12 | :type status: int 13 | :param headers: The expected headers of the response. 14 | :type headers: dict 15 | :param body: The expected body of the response. 16 | :type body: str, dict, or list 17 | """ 18 | self.status = status 19 | self.body = body 20 | self.headers = headers 21 | 22 | def json(self, spec_version): 23 | """Convert the Response to a JSON Pact.""" 24 | response = {"status": self.status} 25 | if self.body is not None: 26 | response["body"] = get_generated_values(self.body) 27 | 28 | if self.headers: 29 | response["headers"] = get_generated_values(self.headers) 30 | 31 | if spec_version == "2.0.0": 32 | matchingRules = self.generate_v2_matchingRules() 33 | elif spec_version == "3.0.0": 34 | matchingRules = self.generate_v3_matchingRules() 35 | else: 36 | raise ValueError(f"Invalid Pact specification version={spec_version}") 37 | 38 | if matchingRules: 39 | response["matchingRules"] = matchingRules 40 | 41 | return response 42 | 43 | def generate_v2_matchingRules(self): 44 | # TODO check there's generation *and* verification tests for all these 45 | matchingRules = get_matching_rules_v2(self.headers, "$.headers") 46 | matchingRules.update(get_matching_rules_v2(self.body, "$.body")) 47 | return matchingRules 48 | 49 | def generate_v3_matchingRules(self): 50 | # TODO check there's generation *and* verification tests for all these 51 | matchingRules = get_matching_rules_v3(self.headers, "headers") 52 | body_rules = get_matching_rules_v3(self.body, "$") 53 | if body_rules: 54 | matchingRules["body"] = body_rules 55 | return matchingRules 56 | -------------------------------------------------------------------------------- /pactman/verifier/parse_header.py: -------------------------------------------------------------------------------- 1 | from functools import total_ordering 2 | 3 | 4 | @total_ordering 5 | class Part: 6 | def __init__(self, value, params): 7 | self.value = value 8 | self.params = params 9 | 10 | def has_param(self, name): 11 | for k, v in self.params: 12 | if k == name: 13 | return True 14 | return False 15 | 16 | def __repr__(self): 17 | return f'' 18 | 19 | def __eq__(self, other): 20 | return (self.value, self.params) == (other.value, other.params) 21 | 22 | def __lt__(self, other): 23 | return (self.value, self.params) < (other.value, other.params) 24 | 25 | 26 | def _parseparam(s, marker): 27 | while s[:1] == marker: 28 | s = s[1:] 29 | end = s.find(marker) 30 | while end > 0 and (s.count('"', 0, end) - s.count('\\"', 0, end)) % 2: 31 | end = s.find(marker, end + 1) 32 | if end < 0: 33 | end = len(s) 34 | f = s[:end] 35 | yield f.strip() 36 | s = s[end:] 37 | 38 | 39 | def parse_header(line): 40 | """Parse RFC-822 style headers to pull out parameters. 41 | 42 | For example: 43 | >>> parse_header("audio/*; q=0.2, audio/basic") 44 | [Part(["audio/*"], []), Part(["audio/basic"], [("q", "0.2")])] 45 | """ 46 | parts = _parseparam(";" + line, ";") 47 | for part in parts: 48 | params = [] 49 | key = [] 50 | for option in _parseparam("," + part, ","): 51 | i = option.find("=") 52 | if i >= 0: 53 | name = option[:i].strip().lower() 54 | value = option[i + 1 :].strip() # noqa: E203 55 | if len(value) >= 2 and value[0] == value[-1] == '"': 56 | value = value[1:-1] 57 | value = value.replace("\\\\", "\\").replace('\\"', '"') 58 | params.append((name, value)) 59 | else: 60 | key.append(option) 61 | yield Part(key, params) 62 | 63 | 64 | def get_header_param(header, name): 65 | for header_part in parse_header(header): 66 | for param, value in header_part.params: 67 | if param == name: 68 | return value 69 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 ReeceTech 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | 24 | The pactman.mock subpackage is derived in part from the pact-python library from 25 | the Pact Foundation: 26 | 27 | MIT License 28 | 29 | Copyright (c) 2017 Pact Foundation 30 | 31 | Permission is hereby granted, free of charge, to any person obtaining a copy 32 | of this software and associated documentation files (the "Software"), to deal 33 | in the Software without restriction, including without limitation the rights 34 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 35 | copies of the Software, and to permit persons to whom the Software is 36 | furnished to do so, subject to the following conditions: 37 | 38 | The above copyright notice and this permission notice shall be included in all 39 | copies or substantial portions of the Software. 40 | 41 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 42 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 43 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 44 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 45 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 46 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 47 | SOFTWARE. 48 | -------------------------------------------------------------------------------- /pactman/test/test_mock_consumer.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from unittest.mock import Mock 3 | 4 | from pactman.mock.consumer import Consumer 5 | from pactman.mock.pact import Pact 6 | from pactman.mock.provider import Provider 7 | 8 | 9 | class ConsumerTestCase(TestCase): 10 | def setUp(self): 11 | self.mock_service = Mock(Pact) 12 | self.provider = Mock(Provider) 13 | self.consumer = Consumer("TestConsumer", service_cls=self.mock_service) 14 | 15 | def test_init(self): 16 | result = Consumer("TestConsumer") 17 | self.assertIsInstance(result, Consumer) 18 | self.assertEqual(result.name, "TestConsumer") 19 | self.assertIs(result.service_cls, Pact) 20 | 21 | def test_has_pact_with(self): 22 | result = self.consumer.has_pact_with(self.provider) 23 | self.assertIs(result, self.mock_service.return_value) 24 | self.mock_service.assert_called_once_with( 25 | consumer=self.consumer, 26 | provider=self.provider, 27 | host_name="localhost", 28 | port=None, 29 | file_write_mode="overwrite", 30 | log_dir=None, 31 | ssl=False, 32 | sslcert=None, 33 | sslkey=None, 34 | pact_dir=None, 35 | version="2.0.0", 36 | use_mocking_server=False, 37 | ) 38 | 39 | def test_has_pact_with_customer_all_options(self): 40 | result = self.consumer.has_pact_with( 41 | self.provider, 42 | host_name="example.com", 43 | port=1111, 44 | log_dir="/logs", 45 | ssl=True, 46 | sslcert="/ssl.cert", 47 | sslkey="ssl.pem", 48 | pact_dir="/pacts", 49 | version="3.0.0", 50 | use_mocking_server=False, 51 | ) 52 | 53 | self.assertIs(result, self.mock_service.return_value) 54 | self.mock_service.assert_called_once_with( 55 | consumer=self.consumer, 56 | provider=self.provider, 57 | host_name="example.com", 58 | port=1111, 59 | file_write_mode="overwrite", 60 | log_dir="/logs", 61 | ssl=True, 62 | sslcert="/ssl.cert", 63 | sslkey="ssl.pem", 64 | pact_dir="/pacts", 65 | version="3.0.0", 66 | use_mocking_server=False, 67 | ) 68 | 69 | def test_has_pact_with_not_a_provider(self): 70 | with self.assertRaises(ValueError): 71 | self.consumer.has_pact_with(None) 72 | -------------------------------------------------------------------------------- /pactman/test/test_pytest_plugin.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | from pactman.verifier.pytest_plugin import pytest_generate_tests 6 | 7 | 8 | class TestConfig: 9 | def __init__(self, options=None): 10 | if options is None: 11 | options = {} 12 | self.options = options 13 | 14 | def getoption(self, option: str, default=None) -> str: 15 | return self.options.get(option, default) 16 | 17 | 18 | class TestMetaFunc: 19 | fixturenames = ['pact_verifier'] 20 | 21 | def __init__(self, config): 22 | self.config = config 23 | 24 | def parametrize(self, argnames, argvalues, indirect=False, ids=None, scope=None): 25 | pass 26 | 27 | 28 | def test_requires_either_pact_broker_url_or_pact_files(cleanup_environment_variables): 29 | # given: 30 | if 'PACT_BROKER_URL' in os.environ: 31 | del os.environ['PACT_BROKER_URL'] 32 | # and: 33 | config = TestConfig() 34 | metafunction = TestMetaFunc(config) 35 | 36 | # expect: 37 | with pytest.raises(ValueError) as e: 38 | pytest_generate_tests(metafunction) 39 | 40 | # then: 41 | assert str(e.value) == 'need a --pact-broker-url or --pact-files option' 42 | 43 | 44 | def test_pact_broker_url_option_requires_pact_provider_name(): 45 | # given: 46 | config = TestConfig({'pact_broker_url': 'foo'}) 47 | metafunction = TestMetaFunc(config) 48 | 49 | # expect: 50 | with pytest.raises(ValueError) as e: 51 | pytest_generate_tests(metafunction) 52 | 53 | # then: 54 | assert str(e.value) == '--pact-broker-url requires the --pact-provider-name option' 55 | 56 | 57 | def test_pact_broker_url_environment_variable_requires_pact_provider_name(cleanup_environment_variables): 58 | # given: 59 | os.environ['PACT_BROKER_URL'] = 'foo' 60 | # and: 61 | config = TestConfig() 62 | metafunction = TestMetaFunc(config) 63 | 64 | # expect: 65 | with pytest.raises(ValueError) as e: 66 | pytest_generate_tests(metafunction) 67 | 68 | # then: 69 | assert str(e.value) == '--pact-broker-url requires the --pact-provider-name option' 70 | 71 | 72 | def test_pact_broker_url_can_be_loaded_from_options(): 73 | # given: 74 | config = TestConfig({'pact_broker_url': 'foo', 'pact_provider_name': 'bar'}) 75 | metafunction = TestMetaFunc(config) 76 | 77 | # when: 78 | pytest_generate_tests(metafunction) 79 | 80 | # then: 81 | # no exception thrown 82 | 83 | 84 | def test_pact_broker_url_can_be_loaded_from_environment_variables(cleanup_environment_variables): 85 | # given: 86 | config = TestConfig({'pact_provider_name': 'bar'}) 87 | metafunction = TestMetaFunc(config) 88 | # and: 89 | os.environ['PACT_BROKER_URL'] = 'foo' 90 | 91 | # when: 92 | pytest_generate_tests(metafunction) 93 | 94 | # then: 95 | # no exception thrown 96 | 97 | 98 | @pytest.fixture 99 | def cleanup_environment_variables(): 100 | backup = {key: os.environ.get(key, None) for key in ['PACT_BROKER_URL']} 101 | yield 102 | for (k, v) in backup.items(): 103 | if v: 104 | os.environ[k] = v 105 | elif k in os.environ: 106 | del os.environ[k] 107 | -------------------------------------------------------------------------------- /pactman/test/pact_serialisation/test_full_payload.py: -------------------------------------------------------------------------------- 1 | from pactman import Consumer, Provider 2 | 3 | 4 | def test_full_payload_v2(): 5 | pact = Consumer("consumer").has_pact_with(Provider("provider"), version="2.0.0") 6 | ( 7 | pact.given("UserA exists and is not an administrator") 8 | .upon_receiving("a request for UserA") 9 | .with_request( 10 | "get", "/users/UserA", headers={"Accept": "application/json"}, query="term=test" 11 | ) 12 | .will_respond_with( 13 | 200, body={"username": "UserA"}, headers={"Content-Type": "application/json"} 14 | ) 15 | ) 16 | result = pact.construct_pact(pact._interactions[0]) 17 | assert result == { 18 | "consumer": {"name": "consumer"}, 19 | "provider": {"name": "provider"}, 20 | "interactions": [ 21 | { 22 | "description": "a request for UserA", 23 | "providerState": "UserA exists and is not an administrator", 24 | "request": dict( 25 | method="get", 26 | path="/users/UserA", 27 | headers={"Accept": "application/json"}, 28 | query="term=test", 29 | ), 30 | "response": dict( 31 | status=200, 32 | body={"username": "UserA"}, 33 | headers={"Content-Type": "application/json"}, 34 | ), 35 | } 36 | ], 37 | "metadata": dict(pactSpecification=dict(version="2.0.0")), 38 | } 39 | 40 | 41 | def test_full_payload_v3(): 42 | pact = Consumer("consumer").has_pact_with(Provider("provider"), version="3.0.0") 43 | ( 44 | pact.given( 45 | [{"name": "User exists and is not an administrator", "params": {"username": "UserA"}}] 46 | ) 47 | .upon_receiving("a request for UserA") 48 | .with_request( 49 | "get", "/users/UserA", headers={"Accept": "application/json"}, query=dict(term=["test"]) 50 | ) 51 | .will_respond_with( 52 | 200, body={"username": "UserA"}, headers={"Content-Type": "application/json"} 53 | ) 54 | ) 55 | result = pact.construct_pact(pact._interactions[0]) 56 | assert result == { 57 | "consumer": {"name": "consumer"}, 58 | "provider": {"name": "provider"}, 59 | "interactions": [ 60 | { 61 | "description": "a request for UserA", 62 | "providerStates": [ 63 | { 64 | "name": "User exists and is not an administrator", 65 | "params": {"username": "UserA"}, 66 | } 67 | ], 68 | "request": dict( 69 | method="get", 70 | path="/users/UserA", 71 | headers={"Accept": "application/json"}, 72 | query=dict(term=["test"]), 73 | ), 74 | "response": dict( 75 | status=200, 76 | body={"username": "UserA"}, 77 | headers={"Content-Type": "application/json"}, 78 | ), 79 | } 80 | ], 81 | "metadata": dict(pactSpecification=dict(version="3.0.0")), 82 | } 83 | -------------------------------------------------------------------------------- /pactman/test/test_mock_urlopen.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf8 -*- 2 | from unittest.mock import Mock, call, patch 3 | 4 | import urllib3 5 | import urllib3.poolmanager 6 | from urllib3.response import HTTPResponse 7 | 8 | from pactman.mock.mock_urlopen import MockURLOpenHandler, patcher 9 | 10 | 11 | def test_patched_urlopen_calls_service_with_request_parameters(): 12 | pact = Mock(port=1234) 13 | mock_service = Mock(pact=pact, return_value=HTTPResponse()) 14 | try: 15 | patcher.add_service(mock_service) 16 | http = urllib3.PoolManager() 17 | response = http.request("GET", "http://api.test:1234/path") 18 | finally: 19 | patcher.remove_service(mock_service) 20 | assert mock_service.call_args == call("GET", "/path", body=None, headers={}) 21 | assert response is mock_service.return_value 22 | 23 | 24 | @patch.object(urllib3.connectionpool.HTTPConnectionPool, "urlopen") 25 | def test_patched_urlopen_handles_many_positional_arguments(HTTPConnectionPool_urlopen): 26 | # urllib3 passes in up to 7 positional arguments to urlopen so we need to ensure 27 | # our mocked urlopen method handles these 28 | mock_service = Mock(config=Mock(port=1234), return_value=HTTPResponse()) 29 | try: 30 | patcher.add_service(mock_service) 31 | pool = urllib3.poolmanager.pool_classes_by_scheme["http"]("api.test", port=5678) 32 | pool.urlopen("POST", "/path", "body1", {}, None, True, False) 33 | finally: 34 | patcher.remove_service(mock_service) 35 | expected_call = call("POST", "/path", "body1", {}, None, True, False) 36 | assert HTTPConnectionPool_urlopen.call_args == expected_call 37 | 38 | 39 | def test_urlopen_responder_handles_json_body(): 40 | h = MockURLOpenHandler(Mock()) 41 | 42 | interaction = dict(response=dict(body={"message": "hello world"}, status=200)) 43 | r = h.respond_for_interaction(interaction) 44 | 45 | assert r.data == b'{"message": "hello world"}' 46 | assert r.headers["Content-Type"] == "application/json; charset=UTF-8" 47 | 48 | 49 | def test_urlopen_responder_handles_json_string_body(): 50 | h = MockURLOpenHandler(Mock()) 51 | 52 | interaction = dict(response=dict(body="hello world", status=200)) 53 | r = h.respond_for_interaction(interaction) 54 | 55 | assert r.data == b'"hello world"' 56 | assert r.headers["Content-Type"] == "application/json; charset=UTF-8" 57 | 58 | 59 | def test_urlopen_responder_handles_json_encoding(): 60 | h = MockURLOpenHandler(Mock()) 61 | 62 | interaction = dict( 63 | response=dict( 64 | headers={"content-type": "application/json; charset=utf-8"}, 65 | body="héllo world", 66 | status=200, 67 | ) 68 | ) 69 | r = h.respond_for_interaction(interaction) 70 | 71 | assert r.data == b'"h\\u00e9llo world"' 72 | assert r.headers["Content-Type"] == "application/json; charset=utf-8" 73 | 74 | 75 | def test_urlopen_responder_handles_non_json_body(): 76 | h = MockURLOpenHandler(Mock()) 77 | 78 | interaction = dict( 79 | response=dict( 80 | headers={"content-type": "text/plain; charset=utf-8"}, 81 | body="héllo world".encode("utf-8"), 82 | status=200, 83 | ) 84 | ) 85 | r = h.respond_for_interaction(interaction) 86 | 87 | assert r.data == b"h\xc3\xa9llo world" 88 | assert r.headers["Content-Type"] == "text/plain; charset=utf-8" 89 | -------------------------------------------------------------------------------- /pactman/mock/mock_urlopen.py: -------------------------------------------------------------------------------- 1 | import io 2 | import logging 3 | 4 | import urllib3.connectionpool 5 | import urllib3.poolmanager 6 | from urllib3.response import HTTPResponse 7 | 8 | from .pact_request_handler import PactRequestHandler 9 | 10 | _providers = {} 11 | 12 | 13 | log = logging.getLogger(__name__) 14 | 15 | 16 | class MockConnectionPool(urllib3.connectionpool.HTTPConnectionPool): 17 | mocks = {} 18 | 19 | @classmethod 20 | def add_mock(cls, mock): 21 | cls.mocks[mock.pact.port] = mock 22 | 23 | @classmethod 24 | def remove_mock(cls, mock): 25 | del cls.mocks[mock.pact.port] 26 | 27 | def urlopen(self, method, url, body=None, headers=None, *args, **kwargs): 28 | if self.port not in self.mocks: 29 | return super().urlopen(method, url, body, headers, *args, **kwargs) 30 | return self.mocks[self.port](method, url, body=body, headers=headers) 31 | 32 | 33 | class MonkeyPatcher: 34 | def __init__(self): 35 | self.patched = False 36 | 37 | def add_service(self, handler): 38 | MockConnectionPool.add_mock(handler) 39 | if not self.patched: 40 | self.patch() 41 | 42 | def remove_service(self, handler): 43 | MockConnectionPool.remove_mock(handler) 44 | if not MockConnectionPool.mocks: 45 | self.clear() 46 | 47 | def patch(self): 48 | urllib3.poolmanager.pool_classes_by_scheme["http"] = MockConnectionPool 49 | self.patched = True 50 | 51 | def clear(self): 52 | urllib3.poolmanager.pool_classes_by_scheme[ 53 | "http" 54 | ] = urllib3.connectionpool.HTTPConnectionPool 55 | self.patched = False 56 | 57 | 58 | patcher = MonkeyPatcher() 59 | 60 | 61 | class MockURLOpenHandler(PactRequestHandler): 62 | def __init__(self, config): 63 | self.interactions = [] 64 | super().__init__(config) 65 | patcher.add_service(self) 66 | 67 | def terminate(self): 68 | patcher.remove_service(self) 69 | 70 | def setup(self, interactions): 71 | self.interactions = interactions 72 | 73 | def verify(self): 74 | pass 75 | 76 | def __call__(self, method, url, redirect=True, headers=None, body=None, **kw): 77 | self.path = url 78 | self.headers = headers or {} 79 | self.body = body 80 | return self.validate_request(method) 81 | 82 | def get_interaction(self, path): 83 | try: 84 | interaction = self.interactions.pop() 85 | except IndexError: 86 | raise AssertionError( 87 | f"Request at {path} received but no interaction registered" 88 | ) from None 89 | return interaction 90 | 91 | def handle_failure(self, reason): 92 | raise AssertionError(reason) 93 | 94 | def handle_success(self, interaction): 95 | pass 96 | 97 | def respond_for_interaction(self, interaction): 98 | headers = {} 99 | if "headers" in interaction["response"]: 100 | headers.update(interaction["response"]["headers"]) 101 | if "body" in interaction["response"]: 102 | body = self.handle_response_encoding(interaction["response"], headers) 103 | else: 104 | body = b"" 105 | return HTTPResponse( 106 | body=io.BytesIO(body), 107 | status=interaction["response"]["status"], 108 | preload_content=False, 109 | headers=headers, 110 | ) 111 | -------------------------------------------------------------------------------- /pactman/test/mock_matchers/test_mock_matchers.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from pactman.mock.matchers import ( 4 | generate_ruby_protocol, 5 | get_generated_values, 6 | EachLike, 7 | Like, 8 | Term, 9 | get_matching_rules_v2, 10 | get_matching_rules_v3, 11 | Equals, 12 | ) 13 | 14 | 15 | def test_generated_value_unknown_type(): 16 | with pytest.raises(ValueError): 17 | get_generated_values(set()) 18 | 19 | 20 | @pytest.mark.parametrize( 21 | "input, output", 22 | [ 23 | (None, None), 24 | (False, False), 25 | ("testing", "testing"), 26 | (123, 123), 27 | (3.14, 3.14), 28 | ( 29 | {"id": 123, "info": {"active": False, "user": "admin"}}, 30 | {"id": 123, "info": {"active": False, "user": "admin"}}, 31 | ), 32 | ([1, 123, "sample"], [1, 123, "sample"]), 33 | (EachLike({"a": 1}), [{"a": 1}]), 34 | (EachLike({"a": 1}, minimum=5), [{"a": 1}] * 5), 35 | (Like(123), 123), 36 | (Term("[a-f0-9]+", "abc123"), "abc123"), 37 | (Equals(["a", Like("b")]), ["a", "b"]), 38 | ( 39 | [EachLike({"username": Term("[a-zA-Z]+", "firstlast"), "id": Like(123)})], 40 | [[{"username": "firstlast", "id": 123}]], 41 | ), 42 | ], 43 | ) 44 | def test_generation(input, output): 45 | assert get_generated_values(input) == output 46 | 47 | 48 | def test_matching_rules_v2_invald_type(): 49 | with pytest.raises(ValueError): 50 | assert get_matching_rules_v2(set(), "*") 51 | 52 | 53 | def test_matching_rules_v3_invald_type(): 54 | with pytest.raises(ValueError): 55 | assert get_matching_rules_v3(set(), "*") 56 | 57 | 58 | @pytest.mark.parametrize( 59 | "input, output", 60 | [ 61 | (None, None), 62 | ("testing", "testing"), 63 | (123, 123), 64 | (3.14, 3.14), 65 | ( 66 | {"id": 123, "info": {"active": False, "user": "admin"}}, 67 | {"id": 123, "info": {"active": False, "user": "admin"}}, 68 | ), 69 | ([1, 123, "sample"], [1, 123, "sample"]), 70 | (EachLike({"a": 1}), {"json_class": "Pact::ArrayLike", "contents": {"a": 1}, "min": 1}), 71 | (Like(123), {"json_class": "Pact::SomethingLike", "contents": 123}), 72 | ( 73 | Term("[a-f0-9]+", "abc123"), 74 | { 75 | "json_class": "Pact::Term", 76 | "data": { 77 | "matcher": {"json_class": "Regexp", "s": "[a-f0-9]+", "o": 0}, 78 | "generate": "abc123", 79 | }, 80 | }, 81 | ), 82 | ( 83 | [EachLike({"username": Term("[a-zA-Z]+", "firstlast"), "id": Like(123)})], 84 | [ 85 | { 86 | "contents": { 87 | "id": {"contents": 123, "json_class": "Pact::SomethingLike"}, 88 | "username": { 89 | "data": { 90 | "generate": "firstlast", 91 | "matcher": {"json_class": "Regexp", "o": 0, "s": "[a-zA-Z]+"}, 92 | }, 93 | "json_class": "Pact::Term", 94 | }, 95 | }, 96 | "json_class": "Pact::ArrayLike", 97 | "min": 1, 98 | } 99 | ], 100 | ), 101 | ], 102 | ) 103 | def test_from_term(input, output): 104 | assert generate_ruby_protocol(input) == output 105 | 106 | 107 | def test_ruby_protocol_unknown_type(): 108 | with pytest.raises(ValueError): 109 | generate_ruby_protocol(set()) 110 | -------------------------------------------------------------------------------- /pactman/verifier/result.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from colorama import Fore, Style 4 | 5 | from .paths import format_path 6 | 7 | log = logging.getLogger(__name__) 8 | 9 | 10 | class Result: 11 | PASS = True 12 | FAIL = False 13 | success = PASS 14 | 15 | def start(self, message): 16 | self.success = self.PASS 17 | 18 | def end(self): 19 | pass 20 | 21 | def warn(self, message): 22 | raise NotImplementedError() 23 | 24 | def fail(self, message, path=None): 25 | raise NotImplementedError() # pragma: no cover 26 | 27 | 28 | class LoggedResult(Result): 29 | def start(self, interaction): 30 | super().start(interaction) 31 | log.info(f"Verifying {interaction}") 32 | 33 | def warn(self, message): 34 | log.warning(" " + message) 35 | 36 | def fail(self, message, path=None): 37 | self.success = self.FAIL 38 | log.warning(" " + message) 39 | return not message 40 | 41 | 42 | class PytestResult(Result): # pragma: no cover 43 | def __init__(self, *, level=logging.WARNING): 44 | self.records = [] 45 | self.level = level 46 | self.current_consumer = None 47 | 48 | def start(self, interaction): 49 | super().start(interaction) 50 | log = logging.getLogger("pactman") 51 | log.handlers = [self] 52 | log.setLevel(logging.DEBUG) 53 | log.propagate = False 54 | self.records[:] = [] 55 | 56 | def handle(self, record): 57 | self.records.append(record) 58 | 59 | def fail(self, message, path=None): 60 | from _pytest.outcomes import Failed 61 | 62 | __tracebackhide__ = True 63 | self.success = self.FAIL 64 | if path: 65 | message += " at " + format_path(path) 66 | log.error(message) 67 | raise Failed(message) from None 68 | 69 | def warn(self, message): 70 | log.warning(message) 71 | 72 | def results_for_terminal(self): 73 | for record in self.records: 74 | if record.levelno > logging.WARN: 75 | yield record.msg, dict(red=True) 76 | elif record.levelno > logging.INFO: 77 | yield record.msg, dict(yellow=True) 78 | else: 79 | yield record.msg, {} 80 | 81 | 82 | class CaptureResult(Result): 83 | def __init__(self, *, level=logging.WARNING): 84 | self.messages = [] 85 | self.level = level 86 | self.current_consumer = None 87 | 88 | def start(self, interaction): 89 | super().start(interaction) 90 | log = logging.getLogger("pactman") 91 | log.handlers = [self] 92 | log.setLevel(logging.DEBUG) 93 | self.messages[:] = [] 94 | if self.current_consumer != interaction.pact.consumer: 95 | print(f"{Style.BRIGHT}Consumer: {interaction.pact.consumer}") 96 | self.current_consumer = interaction.pact.consumer 97 | print(f'Request: "{interaction.description}" ... ', end="") 98 | 99 | def end(self): 100 | if self.success: 101 | print(Fore.GREEN + "PASSED") 102 | else: 103 | print(Fore.RED + "FAILED") 104 | if self.messages: 105 | print((Fore.RESET + "\n").join(self.messages)) 106 | 107 | def warn(self, message): 108 | log.warning(message) 109 | 110 | def fail(self, message, path=None): 111 | self.success = self.FAIL 112 | if path: 113 | message += " at " + format_path(path) 114 | log.error(message) 115 | return not message 116 | 117 | def handle(self, record): 118 | color = "" 119 | if record.levelno >= logging.ERROR: 120 | color = Fore.RED 121 | elif record.levelno >= logging.WARNING: 122 | color = Fore.YELLOW 123 | self.messages.append(" " + color + record.msg) 124 | -------------------------------------------------------------------------------- /pactman/test/pact_serialisation/test_given.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pactman import Consumer, Provider 3 | 4 | 5 | @pytest.mark.parametrize("version", ["2.0.0", "3.0.0"]) 6 | def test_null(version): 7 | pact = Consumer("consumer").has_pact_with(Provider("provider"), version=version) 8 | pact.given(None).upon_receiving("a request").with_request("GET", "/path").will_respond_with( 9 | 200, body="ok" 10 | ) 11 | 12 | result = pact.construct_pact(pact._interactions[0]) 13 | assert result == { 14 | "consumer": {"name": "consumer"}, 15 | "provider": {"name": "provider"}, 16 | "interactions": [ 17 | { 18 | "description": "a request", 19 | "request": dict(method="GET", path="/path"), 20 | "response": dict(status=200, body="ok"), 21 | } 22 | ], 23 | "metadata": dict(pactSpecification=dict(version=version)), 24 | } 25 | 26 | 27 | def test_v2(): 28 | pact = Consumer("consumer").has_pact_with(Provider("provider"), version="2.0.0") 29 | pact.given("the condition exists").upon_receiving("a request").with_request( 30 | "GET", "/path" 31 | ).will_respond_with(200, body="ok") 32 | 33 | result = pact.construct_pact(pact._interactions[0]) 34 | assert result == { 35 | "consumer": {"name": "consumer"}, 36 | "provider": {"name": "provider"}, 37 | "interactions": [ 38 | { 39 | "description": "a request", 40 | "providerState": "the condition exists", 41 | "request": dict(method="GET", path="/path"), 42 | "response": dict(status=200, body="ok"), 43 | } 44 | ], 45 | "metadata": dict(pactSpecification=dict(version="2.0.0")), 46 | } 47 | 48 | 49 | def test_v3(): 50 | pact = Consumer("consumer").has_pact_with(Provider("provider"), version="3.0.0") 51 | pact.given( 52 | [ 53 | dict(name="the condition exists", params={}), 54 | dict(name="the user exists", params=dict(username="alex")), 55 | ] 56 | ).upon_receiving("a request").with_request("GET", "/path").will_respond_with(200, body="ok") 57 | 58 | result = pact.construct_pact(pact._interactions[0]) 59 | assert result == { 60 | "consumer": {"name": "consumer"}, 61 | "provider": {"name": "provider"}, 62 | "interactions": [ 63 | { 64 | "description": "a request", 65 | "providerStates": [ 66 | {"name": "the condition exists", "params": {}}, 67 | {"name": "the user exists", "params": {"username": "alex"}}, 68 | ], 69 | "request": dict(method="GET", path="/path"), 70 | "response": dict(status=200, body="ok"), 71 | } 72 | ], 73 | "metadata": dict(pactSpecification=dict(version="3.0.0")), 74 | } 75 | 76 | 77 | def test_v3_and_given(): 78 | pact = ( 79 | Consumer("consumer") 80 | .has_pact_with(Provider("provider"), version="3.0.0") 81 | .given("the condition exists") 82 | .and_given("the user exists", username="alex") 83 | .upon_receiving("a request") 84 | .with_request("GET", "/path") 85 | .will_respond_with(200, body="ok") 86 | ) 87 | 88 | result = pact.construct_pact(pact._interactions[0]) 89 | assert result == { 90 | "consumer": {"name": "consumer"}, 91 | "provider": {"name": "provider"}, 92 | "interactions": [ 93 | { 94 | "description": "a request", 95 | "providerStates": [ 96 | {"name": "the condition exists", "params": {}}, 97 | {"name": "the user exists", "params": {"username": "alex"}}, 98 | ], 99 | "request": dict(method="GET", path="/path"), 100 | "response": dict(status=200, body="ok"), 101 | } 102 | ], 103 | "metadata": dict(pactSpecification=dict(version="3.0.0")), 104 | } 105 | -------------------------------------------------------------------------------- /pactman/mock/request.py: -------------------------------------------------------------------------------- 1 | from .matchers import get_generated_values, get_matching_rules_v2, get_matching_rules_v3 2 | 3 | 4 | class Request: 5 | """Represents an HTTP request and supports Matchers on its properties.""" 6 | 7 | def __init__(self, method, path, body=None, headers=None, query=""): 8 | """ 9 | Create a new instance of Request. 10 | 11 | :param method: The HTTP method that is expected. 12 | :type method: str 13 | :param path: The URI path that is expected on this request. 14 | :type path: str, Matcher 15 | :param body: The contents of the body of the expected request. 16 | :type body: str, dict, list 17 | :param headers: The headers of the expected request. 18 | :type headers: dict 19 | :param query: The URI query of the expected request. 20 | :type query: str or dict 21 | """ 22 | self.method = method 23 | self.path = path 24 | self.body = body 25 | self.headers = headers 26 | self.query = query 27 | 28 | def json(self, spec_version): 29 | """Convert the Request to a JSON Pact.""" 30 | request = {"method": self.method, "path": get_generated_values(self.path)} 31 | 32 | if self.headers: 33 | request["headers"] = get_generated_values(self.headers) 34 | 35 | if self.body is not None: 36 | request["body"] = get_generated_values(self.body) 37 | 38 | if self.query: 39 | request["query"] = get_generated_values(self.query) 40 | 41 | if spec_version == "2.0.0": 42 | matchingRules = self.generate_v2_matchingRules() 43 | elif spec_version == "3.0.0": 44 | matchingRules = self.generate_v3_matchingRules() 45 | else: 46 | raise ValueError(f"Invalid Pact specification version={spec_version}") 47 | 48 | if matchingRules: 49 | request["matchingRules"] = matchingRules 50 | return request 51 | 52 | def generate_v2_matchingRules(self): 53 | # TODO check there's generation *and* verification tests for all these 54 | matchingRules = get_matching_rules_v2(self.path, "$.path") 55 | matchingRules.update(get_matching_rules_v2(self.headers, "$.headers")) 56 | matchingRules.update(get_matching_rules_v2(self.body, "$.body")) 57 | matchingRules.update(get_matching_rules_v2(self.query, "$.query")) 58 | return matchingRules 59 | 60 | def generate_v3_matchingRules(self): 61 | # TODO check there's generation *and* verification tests for all these 62 | matchingRules = get_matching_rules_v3(self.path, "path") 63 | matchingRules.update(split_header_paths(get_matching_rules_v3(self.headers, "headers"))) 64 | 65 | # body and query rules look different 66 | body_rules = get_matching_rules_v3(self.body, "$") 67 | if body_rules: 68 | matchingRules["body"] = body_rules 69 | query_rules = get_matching_rules_v3(self.query, "query") 70 | if query_rules: 71 | expand_query_rules(query_rules) 72 | matchingRules["query"] = query_rules 73 | return matchingRules 74 | 75 | 76 | def expand_query_rules(rules): 77 | # Query rules in the pact JSON are declared without the array notation (even though they always 78 | # match arrays). 79 | # The matchers will be coded to JSON paths by get_matching_rules_v3, and we need to extract 80 | # them out to a dictionary where the original rule path will look like 'query.param' 81 | # and we need to extract "param". 82 | # If there's no param (it's just "query") then make it a wildcard 83 | for rule_path in list(rules): 84 | matchers = rules.pop(rule_path) 85 | rule_param = rule_path[6:] 86 | # trim off any array wildcard, it's implied here 87 | if rule_param.endswith("[*]"): 88 | rule_param = rule_param[:-3] 89 | if not rule_param: 90 | rule_param = "*" 91 | rules[rule_param] = matchers 92 | 93 | 94 | def split_header_paths(rules): 95 | # Header rules in v3 pacts are stored differently to other types - in a single object called "header" 96 | # with a sub key per header. 97 | if not rules: 98 | return {} 99 | result = dict(header={}) 100 | for k in rules: 101 | header = k.split(".")[1] 102 | result["header"][header] = rules[k] 103 | return result 104 | -------------------------------------------------------------------------------- /pactman/mock/pact_request_handler.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import os.path 4 | import urllib.parse 5 | 6 | from ..verifier.parse_header import get_header_param 7 | from ..verifier.result import Result 8 | from ..verifier.verify import RequestVerifier 9 | 10 | 11 | class Request: 12 | def __init__(self, method, path, query, headers, body): 13 | self.method = method 14 | self.path = path 15 | self.query = query 16 | self.headers = headers 17 | self.body = body 18 | 19 | def json(self): 20 | return self.body 21 | 22 | 23 | class RecordResult(Result): 24 | def start(self, interaction): 25 | super().start(interaction) 26 | 27 | def fail(self, message, path=None): 28 | self.success = self.FAIL 29 | self.reason = message 30 | return not message 31 | 32 | 33 | class PactVersionConflict(AssertionError): 34 | pass 35 | 36 | 37 | class PactInteractionMismatch(AssertionError): 38 | pass 39 | 40 | 41 | class PactRequestHandler: 42 | def __init__(self, pact): 43 | self.pact = pact 44 | 45 | def validate_request(self, method): 46 | url_parts = urllib.parse.urlparse(self.path) 47 | 48 | interaction = self.get_interaction(url_parts.path) 49 | body = self.get_body() 50 | 51 | request = Request(method, url_parts.path, url_parts.query, self.headers, body) 52 | result = RecordResult() 53 | RequestVerifier(self.pact, interaction["request"], result).verify(request) 54 | if not result.success: 55 | return self.handle_failure(result.reason) 56 | self.handle_success(interaction) 57 | if self.pact.file_write_mode != "never": 58 | self.write_pact(interaction) 59 | return self.respond_for_interaction(interaction) 60 | 61 | def get_body(self): 62 | if not self.body: 63 | return "" 64 | content_type = [self.headers[h] for h in self.headers if h.lower() == "content-type"] 65 | if content_type: 66 | content_type = content_type[0] 67 | else: 68 | # default content type for pacts 69 | content_type = "application/json" 70 | 71 | if content_type == "application/json": 72 | return json.loads(self.body) 73 | elif content_type == "application/x-www-form-urlencoded": 74 | return urllib.parse.parse_qs(self.body) 75 | raise ValueError(f"Unhandled body content type {content_type}") 76 | 77 | def get_interaction(self, path): 78 | raise NotImplementedError() 79 | 80 | def handle_success(self, interaction): 81 | raise NotImplementedError() 82 | 83 | def handle_failure(self, reason): 84 | raise NotImplementedError() 85 | 86 | def respond_for_interaction(self, reason): 87 | raise NotImplementedError() 88 | 89 | def handle_response_encoding(self, response, headers): 90 | # default to content-type to json 91 | # rfc4627 states JSON is Unicode and defaults to UTF-8 92 | content_type = [headers[h] for h in headers if h.lower() == "content-type"] 93 | if content_type: 94 | content_type = content_type[0] 95 | if "application/json" not in content_type: 96 | return response["body"] 97 | charset = get_header_param(content_type, "charset") 98 | if not charset: 99 | charset = "UTF-8" 100 | else: 101 | headers["Content-Type"] = "application/json; charset=UTF-8" 102 | charset = "UTF-8" 103 | return json.dumps(response["body"]).encode(charset) 104 | 105 | def write_pact(self, interaction): 106 | if self.pact.semver.major >= 3: 107 | provider_state_key = "providerStates" 108 | else: 109 | provider_state_key = "providerState" 110 | 111 | if os.path.exists(self.pact.pact_json_filename): 112 | with open(self.pact.pact_json_filename) as f: 113 | pact = json.load(f) 114 | existing_version = pact["metadata"]["pactSpecification"]["version"] 115 | if existing_version != self.pact.version: 116 | raise PactVersionConflict( 117 | f'Existing pact ("{pact["interactions"][0]["description"]}") specifies ' 118 | f'version {existing_version} but new pact ("interaction["description"]") ' 119 | f"specifies version {self.pact.version}" 120 | ) 121 | for existing in pact["interactions"]: 122 | if existing["description"] == interaction["description"] and existing.get( 123 | provider_state_key 124 | ) == interaction.get(provider_state_key): 125 | # already got one of these... 126 | if existing != interaction: 127 | raise PactInteractionMismatch( 128 | f'Existing "{existing["description"]}" pact given {existing.get(provider_state_key)!r} ' 129 | "exists with different request/response" 130 | ) 131 | return 132 | pact["interactions"].append(interaction) 133 | else: 134 | pact = self.pact.construct_pact(interaction) 135 | 136 | with open(self.pact.pact_json_filename, "w") as f: 137 | json.dump(pact, f, indent=2) 138 | -------------------------------------------------------------------------------- /pactman/mock/mock_server.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import queue 3 | import traceback 4 | from http.server import BaseHTTPRequestHandler, HTTPServer 5 | from multiprocessing import Process, Queue 6 | 7 | from .pact_request_handler import PactRequestHandler 8 | 9 | _providers = {} 10 | 11 | 12 | log = logging.getLogger(__name__) 13 | 14 | 15 | def getMockServer(pact): 16 | if pact.provider.name not in _providers: 17 | _providers[pact.provider.name] = Server(pact) 18 | return _providers[pact.provider.name] 19 | 20 | 21 | class Server: 22 | def __init__(self, pact): 23 | self.pact = pact 24 | self.interactions = Queue() 25 | self.results = Queue() 26 | self.process = Process(target=run_server, args=(pact, self.interactions, self.results)) 27 | self.process.start() 28 | 29 | def setup(self, interactions): 30 | for interaction in interactions: 31 | self.interactions.put_nowait(interaction) 32 | 33 | def verify(self): 34 | while not self.results.empty(): 35 | result = self.results.get() 36 | if result["status"] == "error": 37 | raise MockServer.Error(result["reason"]) 38 | if result["status"] == "failed": 39 | raise AssertionError(result["reason"]) 40 | 41 | def terminate(self): 42 | self.process.terminate() 43 | 44 | 45 | def run_server(pact, interactions, results): 46 | httpd = MockServer(pact, interactions, results) 47 | httpd.serve_forever() 48 | 49 | 50 | class MockServer(HTTPServer): 51 | def __init__(self, pact, interactions, results): 52 | self.pact = pact 53 | self.incoming_interactions = interactions 54 | self.outgoing_results = results 55 | server_address = ("", pact.port) 56 | super().__init__(server_address, MockHTTPRequestHandler) 57 | self.interactions = [] 58 | self.log = logging.getLogger(__name__ + "." + pact.provider.name) 59 | self.log.addHandler(logging.FileHandler(f"{pact.log_dir}/{pact.provider.name}.log")) 60 | self.log.setLevel(logging.DEBUG) 61 | self.log.propagate = False 62 | 63 | class Error(Exception): 64 | pass 65 | 66 | 67 | class MockHTTPRequestHandler(BaseHTTPRequestHandler, PactRequestHandler): 68 | def __init__(self, request, client_address, server): 69 | self.response_status_code = None 70 | self.response_headers = {} 71 | self.response_body = None 72 | PactRequestHandler.__init__(self, server.pact) 73 | BaseHTTPRequestHandler.__init__(self, request, client_address, server) 74 | 75 | def error_result(self, message, content="", status="error", status_code=500): 76 | self.server.outgoing_results.put({"status": status, "reason": message}) 77 | self.response_status_code = status_code 78 | self.response_headers = {"Content-Type": "text/plain; charset=utf-8"} 79 | self.response_body = (content or message).encode("utf8") 80 | 81 | def run_request(self, method): 82 | try: 83 | self.body = None 84 | for header in self.headers: 85 | if header.lower() == "content-length": 86 | self.body = self.rfile.read(int(self.headers[header])) 87 | self.validate_request(method) 88 | except AssertionError as e: 89 | self.error_result(str(e)) 90 | except Exception as e: 91 | self.error_result(f"Internal Error: {e}", traceback.format_exc()) 92 | self.send_response(self.response_status_code) 93 | for header in self.response_headers: 94 | self.send_header(header, self.response_headers[header]) 95 | self.end_headers() 96 | if self.response_body: 97 | self.wfile.write(self.response_body) 98 | 99 | def get_interaction(self, path): 100 | try: 101 | interaction = self.server.incoming_interactions.get(False) 102 | except queue.Empty: 103 | raise AssertionError( 104 | f"Request at {path} received but no interaction registered" 105 | ) from None 106 | return interaction 107 | 108 | def handle_success(self, interaction): 109 | self.server.outgoing_results.put({"status": "success"}) 110 | 111 | def handle_failure(self, reason): 112 | self.error_result(reason, status="failed", status_code=418) 113 | 114 | def respond_for_interaction(self, interaction): 115 | self.response_status_code = interaction["response"]["status"] 116 | if "headers" in interaction["response"]: 117 | self.response_headers.update(interaction["response"]["headers"]) 118 | if "body" in interaction["response"]: 119 | self.response_body = self.handle_response_encoding( 120 | interaction["response"], self.response_headers 121 | ) 122 | 123 | def do_DELETE(self): 124 | self.run_request("DELETE") 125 | 126 | def do_GET(self): 127 | self.run_request("GET") 128 | 129 | def do_HEAD(self): 130 | self.run_request("HEAD") 131 | 132 | def do_POST(self): 133 | self.run_request("POST") 134 | 135 | def do_PUT(self): 136 | self.run_request("PUT") 137 | 138 | def do_PATCH(self): 139 | self.run_request("PATCH") 140 | 141 | def log_message(self, format, *args): 142 | self.server.log.info("MockServer %s\n" % format % args) 143 | -------------------------------------------------------------------------------- /pactman/mock/consumer.py: -------------------------------------------------------------------------------- 1 | """Classes and methods to describe contract Consumers.""" 2 | import os 3 | 4 | from .pact import Pact 5 | from .provider import Provider 6 | 7 | USE_MOCKING_SERVER = os.environ.get("PACT_USE_MOCKING_SERVER", "no") == "yes" 8 | 9 | 10 | class Consumer(object): 11 | """ 12 | A Pact consumer. 13 | 14 | Use this class to describe the service making requests to the provider and 15 | then use `has_pact_with` to create a contract with a specific service: 16 | 17 | >>> from pactman import Consumer, Provider 18 | >>> consumer = Consumer('my-web-front-end') 19 | >>> consumer.has_pact_with(Provider('my-backend-serivce')) 20 | """ 21 | 22 | def __init__(self, name, service_cls=Pact): 23 | """ 24 | Constructor for the Consumer class. 25 | 26 | :param name: The name of this Consumer. This will be shown in the Pact 27 | when it is published. 28 | :type name: str 29 | :param service_cls: Pact, or a sub-class of it, to use when creating 30 | the contracts. This is useful when you have a custom URL or port 31 | for your mock service and want to use the same value on all of 32 | your contracts. 33 | :type service_cls: pact.Pact 34 | """ 35 | self.name = name 36 | self.service_cls = service_cls 37 | 38 | def has_pact_with( 39 | self, 40 | provider, 41 | host_name="localhost", 42 | port=None, 43 | log_dir=None, 44 | ssl=False, 45 | sslcert=None, 46 | sslkey=None, 47 | pact_dir=None, 48 | version="2.0.0", 49 | file_write_mode="overwrite", 50 | use_mocking_server=USE_MOCKING_SERVER, 51 | ): 52 | """ 53 | Create a contract between the `provider` and this consumer. 54 | 55 | If you are running the Pact mock service in a non-default location, 56 | you can provide the host name and port here: 57 | 58 | >>> from pactman import Consumer, Provider 59 | >>> consumer = Consumer('my-web-front-end') 60 | >>> consumer.has_pact_with( 61 | ... Provider('my-backend-serivce'), 62 | ... host_name='192.168.1.1', 63 | ... port=8000) 64 | 65 | :param provider: The provider service for this contract. 66 | :type provider: pact.Provider 67 | :param host_name: An optional host name to use when contacting the 68 | Pact mock service. This will need to be the same host name used by 69 | your code under test to contact the mock service. It defaults to: 70 | `localhost`. 71 | :type host_name: str 72 | :param port: The TCP port to use when contacting the Pact mock service. 73 | This will need to tbe the same port used by your code under test 74 | to contact the mock service. It defaults to a port >= 8050. 75 | :type port: int 76 | :param log_dir: The directory where logs should be written. Defaults to 77 | the current directory. 78 | :type log_dir: str 79 | :param ssl: Flag to control the use of a self-signed SSL cert to run 80 | the server over HTTPS , defaults to False. 81 | :type ssl: bool 82 | :param sslcert: Path to a custom self-signed SSL cert file, 'ssl' 83 | option must be set to True to use this option. Defaults to None. 84 | :type sslcert: str 85 | :param sslkey: Path to a custom key and self-signed SSL cert key file, 86 | 'ssl' option must be set to True to use this option. 87 | Defaults to None. 88 | :type sslkey: str 89 | :param pact_dir: Directory where the resulting pact files will be 90 | written. Defaults to the current directory. 91 | :type pact_dir: str 92 | :param version: The Pact Specification version to use, defaults to 93 | '2.0.0'. 94 | :type version: str 95 | :param file_write_mode: `overwrite` or `merge`. Use `merge` when 96 | running multiple mock service instances in parallel for the same 97 | consumer/provider pair. Ensure the pact file is deleted before 98 | running tests when using this option so that interactions deleted 99 | from the code are not maintained in the file. Defaults to 100 | `overwrite`. 101 | :type file_write_mode: str 102 | :param use_mocking_server: If True the mocking will be done using a 103 | HTTP server rather than patching urllib3 connections. Can be set 104 | by the environment variable PACT_USE_MOCKING_SERVER which has 105 | values "no" (default) or "yes". 106 | :type use_mocking_server: bool 107 | :return: A Pact object which you can use to define the specific 108 | interactions your code will have with the provider. 109 | :rtype: pact.Pact 110 | """ 111 | if not isinstance(provider, (Provider,)): 112 | raise ValueError("provider must be an instance of the Provider class.") 113 | 114 | return self.service_cls( 115 | consumer=self, 116 | provider=provider, 117 | host_name=host_name, 118 | port=port, 119 | log_dir=log_dir, 120 | ssl=ssl, 121 | sslcert=sslcert, 122 | sslkey=sslkey, 123 | pact_dir=pact_dir, 124 | version=version, 125 | file_write_mode=file_write_mode, 126 | use_mocking_server=use_mocking_server, 127 | ) 128 | -------------------------------------------------------------------------------- /pactman/verifier/command_line.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import logging 3 | from functools import partial 4 | 5 | from colorama import init 6 | 7 | from ..__version__ import __version__ 8 | from .broker_pact import BrokerPact, BrokerPacts, PactBrokerConfig 9 | from .result import CaptureResult 10 | 11 | # TODO: add these options, which exist in the ruby command line? 12 | """ 13 | @click.option( 14 | 'timeout', '-t', '--timeout', 15 | default=30, 16 | help='The duration in seconds we should wait to confirm verification' 17 | ' process was successful. Defaults to 30.', 18 | type=int) 19 | """ 20 | 21 | parser = argparse.ArgumentParser(description="Verify pact contracts") 22 | 23 | parser.add_argument( 24 | "provider_name", metavar="PROVIDER_NAME", help="the name of the provider being verified" 25 | ) 26 | 27 | parser.add_argument("provider_url", metavar="PROVIDER_URL", help="the URL of the provider service") 28 | 29 | parser.add_argument( 30 | "provider_setup_url", metavar="PROVIDER_SETUP_URL", help="the URL to provider state setup" 31 | ) 32 | 33 | parser.add_argument( 34 | "-b", 35 | "--broker-url", 36 | default=None, 37 | help="the URL of the pact broker which may include basic auth; " 38 | "may also be provided in PACT_BROKER_URL env var", 39 | ) 40 | 41 | parser.add_argument( 42 | "--broker-token", 43 | default=None, 44 | help="pact broker bearer token; may also be provided in PACT_BROKER_TOKEN env var", 45 | ) 46 | 47 | parser.add_argument("-l", "--local-pact-file", default=None, help="path to a local pact file") 48 | 49 | parser.add_argument("-c", "--consumer", default=None, help="the name of the consumer to test") 50 | 51 | parser.add_argument( 52 | "--consumer-version-tag", 53 | metavar="TAG", 54 | action="append", 55 | help="limit broker pacts tested to those matching the tag. May be specified " 56 | "multiple times in which case pacts matching any of these tags will be " 57 | "verified.", 58 | ) 59 | 60 | parser.add_argument( 61 | "--custom-provider-header", 62 | metavar="PROVIDER_EXTRA_HEADER", 63 | action="append", 64 | help="Header to add to provider state set up and pact verification requests. " 65 | 'eg "Authorization: Basic cGFjdDpwYWN0". May be specified multiple times.', 66 | ) 67 | 68 | parser.add_argument( 69 | "-r", 70 | "--publish-verification-results", 71 | default=False, 72 | action="store_true", 73 | help="send verification results to the pact broker", 74 | ) 75 | 76 | parser.add_argument( 77 | "-a", 78 | "--provider-app-version", 79 | default=None, 80 | help="provider application version, required for results publication (same as -p)", 81 | ) 82 | 83 | parser.add_argument( 84 | "-p", 85 | "--provider-version", 86 | default=None, 87 | help="provider application version, required for results publication (same as -a)", 88 | ) 89 | 90 | parser.add_argument( 91 | "-v", 92 | "--verbose", 93 | default=False, 94 | action="store_true", 95 | help="output more information about the verification", 96 | ) 97 | 98 | parser.add_argument( 99 | "-q", 100 | "--quiet", 101 | default=False, 102 | action="store_true", 103 | help="output less information about the verification", 104 | ) 105 | 106 | parser.add_argument( 107 | "-V", "--version", default=False, action="version", version=f"%(prog)s {__version__}" 108 | ) 109 | 110 | 111 | def main(): 112 | init(autoreset=True) 113 | args = parser.parse_args() 114 | provider_version = args.provider_version or args.provider_app_version 115 | custom_headers = get_custom_headers(args) 116 | if args.publish_verification_results and not provider_version: 117 | print("Provider version is required to publish results to the broker") 118 | return False 119 | pacts = get_pacts(args) 120 | success = True 121 | for pact in pacts: 122 | if args.consumer and pact.consumer != args.consumer: 123 | continue 124 | for interaction in pact.interactions: 125 | interaction.verify( 126 | args.provider_url, args.provider_setup_url, extra_provider_headers=custom_headers 127 | ) 128 | success = interaction.result.success and success 129 | if args.publish_verification_results: 130 | pact.publish_result(provider_version) 131 | else: 132 | print() 133 | return int(not success) 134 | 135 | 136 | def get_pacts(args): 137 | result_log_level = get_log_level(args) 138 | result_factory = partial(CaptureResult, level=result_log_level) 139 | if args.local_pact_file: 140 | pacts = [BrokerPact.load_file(args.local_pact_file, result_factory)] 141 | else: 142 | broker_config = PactBrokerConfig( 143 | args.broker_url, args.broker_token, args.consumer_version_tag 144 | ) 145 | pacts = BrokerPacts(args.provider_name, broker_config, result_factory).consumers() 146 | return pacts 147 | 148 | 149 | def get_log_level(args): 150 | if args.quiet: 151 | result_log_level = logging.WARNING 152 | elif args.verbose: 153 | result_log_level = logging.DEBUG 154 | else: 155 | result_log_level = logging.INFO 156 | return result_log_level 157 | 158 | 159 | def get_custom_headers(args): 160 | custom_headers = {} 161 | if args.custom_provider_header: 162 | for header in args.custom_provider_header: 163 | k, v = header.split(":") 164 | custom_headers[k] = v.strip() 165 | return custom_headers 166 | 167 | 168 | if __name__ == "__main__": 169 | import sys 170 | 171 | sys.exit(main()) 172 | -------------------------------------------------------------------------------- /pactman/verifier/broker_pact.py: -------------------------------------------------------------------------------- 1 | """Implement verification of pacts as per specification version 2: 2 | 3 | https://github.com/pact-foundation/pact-specification/tree/version-2 4 | """ 5 | import json 6 | import os 7 | import urllib.parse 8 | 9 | import semver 10 | from restnavigator import Navigator 11 | 12 | from .result import LoggedResult 13 | from .verify import Interaction 14 | 15 | 16 | def pact_id(param): 17 | return repr(param) 18 | 19 | 20 | class PactBrokerConfig: 21 | def __init__(self, url=None, token=None, tags=None): 22 | url = url or os.environ.get("PACT_BROKER_URL") 23 | if not url: 24 | raise ValueError("pact broker URL must be specified") 25 | 26 | # pull the hostname and optionally any basic auth from the broker URL 27 | # (backwards compat to once upon a time when the broker config URL was specified with a path) 28 | url_parts = urllib.parse.urlparse(url) 29 | host = netloc = url_parts.netloc 30 | self.auth = None 31 | if "@" in netloc: 32 | url_auth, host = netloc.split("@") 33 | self.auth = tuple(url_auth.split(":")) 34 | self.url = f"{url_parts.scheme}://{host}/" 35 | 36 | if not self.auth: 37 | auth = os.environ.get("PACT_BROKER_AUTH") 38 | if auth: 39 | self.auth = tuple(auth.split(":")) 40 | 41 | token = token or os.environ.get("PACT_BROKER_TOKEN") 42 | self.headers = None 43 | if token: 44 | self.headers = {"Authorization": f"Bearer {token}"} 45 | 46 | self.tags = tags 47 | 48 | def get_broker_navigator(self): 49 | return Navigator.hal(self.url, default_curie="pb", auth=self.auth, headers=self.headers) 50 | 51 | def get_pacts_for_provider(self, provider): 52 | if self.tags: 53 | yield from self.get_tagged_pacts(provider) 54 | else: 55 | yield from self.get_all_pacts(provider) 56 | 57 | def get_all_pacts(self, provider): 58 | nav = self.get_broker_navigator() 59 | try: 60 | broker_provider = nav["latest-provider-pacts"](provider=provider) 61 | except Exception as e: 62 | raise ValueError(f"error fetching pacts from {self.url} for {provider}: {e}") 63 | broker_provider.fetch() 64 | for broker_pact in broker_provider["pacts"]: 65 | yield broker_pact, broker_pact.fetch() 66 | 67 | def get_tagged_pacts(self, provider): 68 | nav = self.get_broker_navigator() 69 | # fetch a set for each tag, just make sure we don't verify the same pact more than once 70 | seen = set() 71 | for tag in self.tags: 72 | try: 73 | broker_provider = nav["latest-provider-pacts-with-tag"](provider=provider, tag=tag) 74 | except Exception as e: 75 | raise ValueError(f"error fetching pacts from {self.url} for {provider}: {e}") 76 | broker_provider.fetch() 77 | for broker_pact in broker_provider["pacts"]: 78 | content = broker_pact.fetch() 79 | # don't re-run the same pact content 80 | h = str(content) 81 | if h in seen: 82 | continue 83 | seen.add(h) 84 | yield broker_pact, content 85 | 86 | 87 | class BrokerPacts: 88 | def __init__(self, provider_name, pact_broker=None, result_factory=LoggedResult): 89 | self.provider_name = provider_name 90 | self.pact_broker = pact_broker or PactBrokerConfig() 91 | self.result_factory = result_factory 92 | 93 | def consumers(self): 94 | for broker_pact, pact_contents in self.pact_broker.get_pacts_for_provider( 95 | self.provider_name 96 | ): 97 | yield BrokerPact(pact_contents, self.result_factory, broker_pact) 98 | 99 | def all_interactions(self): 100 | for pact in self.consumers(): 101 | yield from pact.interactions 102 | 103 | def __iter__(self): 104 | return self.all_interactions() 105 | 106 | 107 | class BrokerPact: 108 | def __init__(self, pact, result_factory, broker_pact=None): 109 | self.pact = pact 110 | self.provider = pact["provider"]["name"] 111 | self.consumer = pact["consumer"]["name"] 112 | self.metadata = pact["metadata"] 113 | if "pactSpecification" in self.metadata: 114 | # the Ruby implementation generates non-compliant metadata, handle that :-( 115 | self.version = self.metadata["pactSpecification"]["version"] 116 | else: 117 | self.version = self.metadata["pact-specification"]["version"] 118 | self.semver = semver.VersionInfo.parse(self.version, optional_minor_and_patch=True) 119 | self.interactions = [ 120 | Interaction(self, interaction, result_factory) for interaction in pact["interactions"] 121 | ] 122 | self.broker_pact = broker_pact 123 | 124 | def __repr__(self): 125 | return f"" 126 | 127 | def __str__(self): 128 | return f"Pact between consumer {self.consumer} and provider {self.provider}" 129 | 130 | @property 131 | def success(self): 132 | return all(interaction.result.success for interaction in self.interactions) 133 | 134 | def publish_result(self, version): 135 | if self.broker_pact is None: 136 | return 137 | self.broker_pact["publish-verification-results"].create( 138 | dict(success=self.success, providerApplicationVersion=version) 139 | ) 140 | 141 | @classmethod 142 | def load_file(cls, filename, result_factory=LoggedResult): 143 | with open(filename) as file: 144 | return cls(json.load(file), result_factory) 145 | -------------------------------------------------------------------------------- /pactman/test/pact_serialisation/test_request_query.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pactman import Consumer, Like, Provider, Term 3 | from pactman.mock.request import Request 4 | 5 | 6 | def test_v2(): 7 | pact = Consumer("consumer").has_pact_with(Provider("provider"), version="2.0.0") 8 | 9 | pact.given("the condition exists").upon_receiving("a request").with_request( 10 | "GET", "/path", query="fields=first,second" 11 | ).will_respond_with(200, body="ok") 12 | 13 | result = pact.construct_pact(pact._interactions[0]) 14 | assert result == { 15 | "consumer": {"name": "consumer"}, 16 | "provider": {"name": "provider"}, 17 | "interactions": [ 18 | { 19 | "description": "a request", 20 | "providerState": "the condition exists", 21 | "request": dict(method="GET", path="/path", query="fields=first,second"), 22 | "response": dict(status=200, body="ok"), 23 | } 24 | ], 25 | "metadata": dict(pactSpecification=dict(version="2.0.0")), 26 | } 27 | 28 | 29 | @pytest.mark.parametrize("query_field", ["first,second", ["first,second"]]) 30 | def test_v3(query_field): 31 | pact = Consumer("consumer").has_pact_with(Provider("provider"), version="3.0.0") 32 | pact.given([{"name": "the condition exists", "params": {}}]).upon_receiving( 33 | "a request" 34 | ).with_request("GET", "/path", query=dict(fields=query_field)).will_respond_with(200, body="ok") 35 | 36 | result = pact.construct_pact(pact._interactions[0]) 37 | assert result == { 38 | "consumer": {"name": "consumer"}, 39 | "provider": {"name": "provider"}, 40 | "interactions": [ 41 | { 42 | "description": "a request", 43 | "providerStates": [{"name": "the condition exists", "params": {}}], 44 | "request": dict(method="GET", path="/path", query=dict(fields=["first,second"])), 45 | "response": dict(status=200, body="ok"), 46 | } 47 | ], 48 | "metadata": dict(pactSpecification=dict(version="3.0.0")), 49 | } 50 | 51 | 52 | def test_like_v2(): 53 | pact = Consumer("consumer").has_pact_with(Provider("provider"), version="2.0.0") 54 | 55 | pact.given("the condition exists").upon_receiving("a request").with_request( 56 | "GET", "/path", query=Like("fields=first,second") 57 | ).will_respond_with(200, body="ok") 58 | 59 | result = pact.construct_pact(pact._interactions[0]) 60 | assert result == { 61 | "consumer": {"name": "consumer"}, 62 | "provider": {"name": "provider"}, 63 | "interactions": [ 64 | { 65 | "description": "a request", 66 | "providerState": "the condition exists", 67 | "request": dict( 68 | method="GET", 69 | path="/path", 70 | query="fields=first,second", 71 | matchingRules={"$.query": {"match": "type"}}, 72 | ), 73 | "response": dict(status=200, body="ok"), 74 | } 75 | ], 76 | "metadata": dict(pactSpecification=dict(version="2.0.0")), 77 | } 78 | 79 | 80 | def test_like_v3(): 81 | pact = ( 82 | Consumer("consumer") 83 | .has_pact_with(Provider("provider"), version="3.0.0") 84 | .given("the condition exists") 85 | .upon_receiving("a request") 86 | .with_request("GET", "/path", query=dict(fields=Like(["first,second"]))) 87 | .will_respond_with(200, body="ok") 88 | ) 89 | 90 | result = pact.construct_pact(pact._interactions[0]) 91 | assert result == { 92 | "consumer": {"name": "consumer"}, 93 | "provider": {"name": "provider"}, 94 | "interactions": [ 95 | { 96 | "description": "a request", 97 | "providerStates": [{"name": "the condition exists", "params": {}}], 98 | "request": dict( 99 | method="GET", 100 | path="/path", 101 | query=dict(fields=["first,second"]), 102 | matchingRules={"query": {"fields": {"matchers": [{"match": "type"}]}}}, 103 | ), 104 | "response": dict(status=200, body="ok"), 105 | } 106 | ], 107 | "metadata": dict(pactSpecification=dict(version="3.0.0")), 108 | } 109 | 110 | 111 | def test_broader_like_v3(): 112 | pact = ( 113 | Consumer("consumer") 114 | .has_pact_with(Provider("provider"), version="3.0.0") 115 | .given("the condition exists") 116 | .upon_receiving("a request") 117 | .with_request("GET", "/path", query=Like(dict(fields=["first,second"]))) 118 | .will_respond_with(200, body="ok") 119 | ) 120 | 121 | result = pact.construct_pact(pact._interactions[0]) 122 | assert result == { 123 | "consumer": {"name": "consumer"}, 124 | "provider": {"name": "provider"}, 125 | "interactions": [ 126 | { 127 | "description": "a request", 128 | "providerStates": [{"name": "the condition exists", "params": {}}], 129 | "request": dict( 130 | method="GET", 131 | path="/path", 132 | query=dict(fields=["first,second"]), 133 | matchingRules={"query": {"*": {"matchers": [{"match": "type"}]}}}, 134 | ), 135 | "response": dict(status=200, body="ok"), 136 | } 137 | ], 138 | "metadata": dict(pactSpecification=dict(version="3.0.0")), 139 | } 140 | 141 | 142 | def test_matcher_in_query(): 143 | target = Request("GET", "/test-path", query={"q": [Like("spam")], "l": [Term(r"\d+", "10")]}) 144 | assert target.json("3.0.0") == { 145 | "method": "GET", 146 | "path": "/test-path", 147 | "query": {"q": ["spam"], "l": ["10"]}, 148 | "matchingRules": { 149 | "query": { 150 | "q": {"matchers": [{"match": "type"}]}, 151 | "l": {"matchers": [{"match": "regex", "regex": r"\d+"}]}, 152 | } 153 | }, 154 | } 155 | -------------------------------------------------------------------------------- /pactman/test/test_matching_rules.py: -------------------------------------------------------------------------------- 1 | import collections 2 | from unittest.mock import Mock 3 | 4 | import pytest 5 | from pactman.verifier.matching_rule import ( 6 | InvalidMatcher, 7 | MatchDecimal, 8 | MatchEquality, 9 | Matcher, 10 | MatchInclude, 11 | MatchInteger, 12 | MatchNull, 13 | MatchNumber, 14 | MatchRegex, 15 | MatchType, 16 | RuleFailed, 17 | fold_type, 18 | log, 19 | rule_matchers_v2, 20 | rule_matchers_v3, 21 | split_path, 22 | weight_path, 23 | ) 24 | 25 | 26 | def test_stringify(): 27 | r = MatchType("$", {"match": "type"}) 28 | assert str(r) == "Rule match by {'match': 'type'} at $" 29 | assert repr(r) == "" 30 | 31 | 32 | def test_invalid_match_type(monkeypatch): 33 | monkeypatch.setattr(log, "warning", Mock()) 34 | assert isinstance(Matcher.get_matcher("$", {"match": "spam"}), InvalidMatcher) 35 | log.warning.assert_called_once() 36 | 37 | 38 | @pytest.mark.parametrize( 39 | "path, weight, spam_weight", 40 | [ 41 | ("$.body", 4, 4), 42 | ("$.body.item1.level[0].id", 0, 0), 43 | ("$.body.item1.level[1].id", 64, 0), 44 | ("$.body.item1.level[*].id", 32, 0), 45 | ("$.body.*.level[*].id", 16, 0), 46 | ("$.body.*.*[*].id", 8, 8), 47 | ], 48 | ) 49 | def test_weightings(path, weight, spam_weight): 50 | rule = Matcher(path, {"match": "type"}) 51 | assert rule.weight(["$", "body", "item1", "level", 1, "id"]).weight == weight 52 | assert rule.weight(["$", "body", "item2", "spam", 1, "id"]).weight == spam_weight 53 | 54 | 55 | @pytest.mark.parametrize( 56 | "path, result", 57 | [ 58 | ("", []), 59 | ("$", ["$"]), 60 | ("$.body", ["$", "body"]), 61 | ("$.body.item1", ["$", "body", "item1"]), 62 | ("$.body.item2", ["$", "body", "item2"]), 63 | ("$.header.item1", ["$", "header", "item1"]), 64 | ("$.body.item1.level", ["$", "body", "item1", "level"]), 65 | ("$.body.item1.level[1]", ["$", "body", "item1", "level", 1]), 66 | ("$.body.item1.level[1].id", ["$", "body", "item1", "level", 1, "id"]), 67 | ("$.body.item1.level[1].name", ["$", "body", "item1", "level", 1, "name"]), 68 | ("$.body.item1.level[2]", ["$", "body", "item1", "level", 2]), 69 | ("$.body.item1.level[2].id", ["$", "body", "item1", "level", 2, "id"]), 70 | ("$.body.item1.level[*].id", ["$", "body", "item1", "level", "*", "id"]), 71 | ("$.body.*.level[*].id", ["$", "body", "*", "level", "*", "id"]), 72 | ], 73 | ) 74 | def test_split_path(path, result): 75 | assert list(split_path(path)) == result 76 | 77 | 78 | @pytest.mark.parametrize( 79 | "spec, test, result", 80 | [ 81 | (["a"], ["b"], 0), 82 | (["*"], ["a"], 1), 83 | (["a", "*"], ["a", "b"], 2), 84 | (["a", "b"], ["a", "b"], 4), 85 | (["a", "b", "c"], ["a", "b"], 0), 86 | (["a", "b"], ["a", "b", "c"], 4), 87 | ], 88 | ) 89 | def test_weight_path(spec, test, result): 90 | assert weight_path(spec, test) == result 91 | 92 | 93 | @pytest.mark.parametrize("data, spec", [(1, 1), (1, 1.0), (1.0, 1.0), (1.0, 1.0)]) 94 | def test_numbers(data, spec): 95 | MatchType("$", dict(match="type")).apply(data, spec, ["a"]) 96 | 97 | 98 | def test_regex(): 99 | MatchRegex("$", dict(match="regex", regex=r"\w+")).apply("spam", None, ["a"]) 100 | 101 | 102 | def test_regex_fail(): 103 | with pytest.raises(RuleFailed): 104 | MatchRegex("$", dict(match="regex", regex=r"\W+")).apply("spam", None, ["a"]) 105 | 106 | 107 | def test_integer(): 108 | MatchInteger("$", dict(match="integer")).apply(1, None, ["a"]) 109 | 110 | 111 | def test_integer_fail(): 112 | with pytest.raises(RuleFailed): 113 | MatchInteger("$", dict(match="integer")).apply(1.0, None, ["a"]) 114 | 115 | 116 | def test_decimal(): 117 | MatchDecimal("$", dict(match="decimal")).apply(1.0, None, ["a"]) 118 | 119 | 120 | def test_decimal_fail(): 121 | with pytest.raises(RuleFailed): 122 | MatchDecimal("$", dict(match="decimal")).apply(1, None, ["a"]) 123 | 124 | 125 | @pytest.mark.parametrize("value", [1, 1.0]) 126 | def test_number(value): 127 | MatchNumber("$", dict(match="number")).apply(value, None, ["a"]) 128 | 129 | 130 | def test_number_fail(): 131 | with pytest.raises(RuleFailed): 132 | MatchNumber("$", dict(match="number")).apply("spam", None, ["a"]) 133 | 134 | 135 | def test_equality(): 136 | MatchEquality("$", dict(match="equality")).apply("spam", "spam", ["a"]) 137 | 138 | 139 | def test_equality_fail(): 140 | with pytest.raises(RuleFailed): 141 | MatchEquality("$", dict(match="equality")).apply("ham", "spam", ["a"]) 142 | 143 | 144 | def test_include(): 145 | MatchInclude("$", dict(match="include", value="spam")).apply("spammer", None, ["a"]) 146 | 147 | 148 | def test_include_fail(): 149 | with pytest.raises(RuleFailed): 150 | MatchInclude("$", dict(match="include", value="spam")).apply("ham", None, ["a"]) 151 | 152 | 153 | def test_null(): 154 | MatchNull("$", dict(match="null")).apply(None, None, ["a"]) 155 | 156 | 157 | def test_null_fail(): 158 | with pytest.raises(RuleFailed): 159 | MatchNull("$", dict(match="null")).apply("ham", None, ["spam"]) 160 | 161 | 162 | def test_min(): 163 | MatchType("$", dict(match="type", min=1)).apply(["spam"], ["a"], []) 164 | 165 | 166 | def test_min_not_met(): 167 | with pytest.raises(RuleFailed): 168 | MatchType("$", dict(match="type", min=2)).apply(["spam"], ["a", "b"], ["spam"]) 169 | 170 | 171 | def test_min_ignored(): 172 | MatchType("$", dict(match="type", min=1)).apply(0, 0, []) 173 | 174 | 175 | def test_max(): 176 | MatchType("$", dict(match="type", max=1)).apply(["spam"], ["a"], []) 177 | 178 | 179 | def test_max_not_met(): 180 | with pytest.raises(RuleFailed): 181 | MatchType("$", dict(match="type", max=2)).apply([1, 2, 3], [1, 2], ["spam"]) 182 | 183 | 184 | def test_max_ignored(): 185 | MatchType("$", dict(match="type", max=1)).apply(0, 0, []) 186 | 187 | 188 | @pytest.mark.parametrize( 189 | "source, result", [({}, dict), ([], list), (collections.OrderedDict(), dict)] 190 | ) 191 | def test_fold_type(source, result): 192 | assert fold_type(source) == result 193 | 194 | 195 | def test_build_matching_rules_handles_rule_with_unknown_type_v2(monkeypatch): 196 | monkeypatch.setattr(log, "warning", Mock()) 197 | rules = rule_matchers_v2({"$.body": {"match": "SPAM"}, "$.body[*].*": {"match": "type"}}) 198 | assert 2 == len(rules["body"]) 199 | log.warning.assert_called_once() 200 | 201 | 202 | def test_build_matching_rules_handles_rule_with_unknown_type_v3(monkeypatch): 203 | monkeypatch.setattr(log, "warning", Mock()) 204 | rules = rule_matchers_v3( 205 | { 206 | "body": { 207 | "$": {"matchers": [{"match": "SPAM"}]}, 208 | "$[*].*": {"matchers": [{"match": "type"}]}, 209 | } 210 | } 211 | ) 212 | assert 2 == len(rules["body"]) 213 | log.warning.assert_called_once() 214 | -------------------------------------------------------------------------------- /pactman/test/test_pact_generation.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import tempfile 4 | from unittest.mock import Mock, mock_open, patch 5 | 6 | import pactman.mock.pact 7 | import pytest 8 | import requests 9 | import semver 10 | from pactman.mock.consumer import Consumer 11 | from pactman.mock.pact import Pact 12 | from pactman.mock.pact_request_handler import (PactInteractionMismatch, 13 | PactRequestHandler, 14 | PactVersionConflict) 15 | from pactman.mock.provider import Provider 16 | 17 | 18 | @pytest.fixture 19 | def mock_pact(monkeypatch): 20 | def f(file_write_mode=None, version="2.0.0"): 21 | monkeypatch.setattr(Pact, "allocate_port", Mock()) 22 | monkeypatch.setattr(os, "remove", Mock()) 23 | monkeypatch.setattr(os.path, "exists", Mock(return_value=True)) 24 | log_dir = "/tmp/a" 25 | pact_dir = "/tmp/pact" 26 | return Pact( 27 | Consumer("CONSUMER"), 28 | Provider("PROVIDER"), 29 | log_dir=log_dir, 30 | pact_dir=pact_dir, 31 | version=version, 32 | file_write_mode=file_write_mode, 33 | ) 34 | 35 | return f 36 | 37 | 38 | @pytest.mark.parametrize("file_write_mode", [None, "overwrite"]) 39 | def test_pact_init(monkeypatch, file_write_mode, mock_pact): 40 | monkeypatch.setattr(pactman.mock.pact, "ensure_pact_dir", Mock(return_value=True)) 41 | mock_pact = mock_pact(file_write_mode) 42 | filename = mock_pact.pact_json_filename 43 | 44 | assert mock_pact.consumer.name == "CONSUMER" 45 | assert mock_pact.provider.name == "PROVIDER" 46 | assert mock_pact.log_dir == "/tmp/a" 47 | assert mock_pact.version == "2.0.0" 48 | assert mock_pact.semver == semver.VersionInfo.parse("2.0.0") 49 | mock_pact.allocate_port.assert_called_once_with() 50 | assert mock_pact.BASE_PORT_NUMBER >= 8150 51 | pactman.mock.pact.ensure_pact_dir.assert_called_once_with("/tmp/pact") 52 | if file_write_mode == "overwrite": 53 | os.path.exists.assert_called_once_with(filename) 54 | os.remove.assert_called_once_with(filename) 55 | else: 56 | os.path.exists.assert_not_called() 57 | os.remove.assert_not_called() 58 | 59 | 60 | def test_config_pact_filename(mock_pact): 61 | mock_pact = mock_pact() 62 | assert mock_pact.pact_json_filename == os.path.join( 63 | mock_pact.pact_dir, "CONSUMER-PROVIDER-pact.json" 64 | ) 65 | 66 | 67 | def test_ensure_pact_dir_when_exists(monkeypatch): 68 | monkeypatch.setattr(os.path, "exists", Mock(side_effect=[True])) 69 | monkeypatch.setattr(os, "mkdir", Mock()) 70 | pactman.mock.pact.ensure_pact_dir("/tmp/pacts") 71 | os.mkdir.assert_not_called() 72 | 73 | 74 | def test_ensure_pact_dir_when_parent_exists(monkeypatch): 75 | monkeypatch.setattr(os.path, "exists", Mock(side_effect=[False, True])) 76 | monkeypatch.setattr(os, "mkdir", Mock()) 77 | pactman.mock.pact.ensure_pact_dir("/tmp/pacts") 78 | os.mkdir.assert_called_once_with("/tmp/pacts") 79 | 80 | 81 | def generate_pact(version): 82 | return { 83 | "consumer": {"name": "CONSUMER"}, 84 | "provider": {"name": "PROVIDER"}, 85 | "interactions": [dict(description="spam")], 86 | "metadata": {"pactSpecification": {"version": version}}, 87 | } 88 | 89 | 90 | @pytest.mark.parametrize("version", ["2.0.0", "3.0.0"]) 91 | @patch("builtins.open", new_callable=mock_open, read_data="data") 92 | def test_pact_request_handler_write_pact(mock_open, monkeypatch, mock_pact, version): 93 | monkeypatch.setattr(pactman.mock.pact, "ensure_pact_dir", Mock(return_value=True)) 94 | mock_pact = mock_pact(version=version) 95 | mock_pact.semver = semver.VersionInfo.parse(version) 96 | my_pact = PactRequestHandler(mock_pact) 97 | os.path.exists.return_value = False 98 | with patch("json.dump", Mock()) as json_mock: 99 | my_pact.write_pact(dict(description="spam")) 100 | mock_open.assert_called_once_with(mock_pact.pact_json_filename, "w") 101 | json_mock.assert_called_once_with(generate_pact(version), mock_open(), indent=2) 102 | 103 | 104 | @patch("builtins.open", new_callable=mock_open, read_data="data") 105 | def test_versions_are_consistent(mock_open, monkeypatch, mock_pact): 106 | monkeypatch.setattr(pactman.mock.pact, "ensure_pact_dir", Mock(return_value=True)) 107 | monkeypatch.setattr(json, "dump", Mock()) 108 | monkeypatch.setattr(json, "load", lambda f: generate_pact("2.0.0")) 109 | 110 | # write the v2 pact 111 | pact = mock_pact() 112 | pact.semver = semver.VersionInfo.parse(pact.version) 113 | hdlr = PactRequestHandler(pact) 114 | hdlr.write_pact(dict(description="spam")) 115 | 116 | # try to add the v3 pact 117 | pact = mock_pact(version="3.0.0") 118 | pact.semver = semver.VersionInfo.parse(pact.version) 119 | hdlr = PactRequestHandler(pact) 120 | with pytest.raises(PactVersionConflict): 121 | hdlr.write_pact(dict(description="spam")) 122 | 123 | 124 | def test_pacts_written(): 125 | with tempfile.TemporaryDirectory() as d: 126 | pact = Consumer("C").has_pact_with(Provider("P"), pact_dir=d) 127 | with pact.given("g").upon_receiving("r").with_request("get", "/foo").will_respond_with(200): 128 | requests.get(pact.uri + "/foo") 129 | 130 | # force a failure 131 | with pytest.raises(AssertionError): 132 | with pact.given("g").upon_receiving("r2").with_request("get", "/bar").will_respond_with( 133 | 200 134 | ): 135 | requests.get(pact.uri + "/foo") 136 | 137 | # make sure mocking still works 138 | with pact.given("g").upon_receiving("r2").with_request("get", "/bar").will_respond_with( 139 | 200 140 | ): 141 | requests.get(pact.uri + "/bar") 142 | 143 | # ensure two pacts written 144 | with open(pact.pact_json_filename) as f: 145 | content = json.load(f) 146 | assert len(content["interactions"]) == 2 147 | 148 | 149 | def test_detect_mismatch_request_manual_mode(): 150 | with tempfile.TemporaryDirectory() as d: 151 | pact = ( 152 | Consumer("C") 153 | .has_pact_with(Provider("P"), pact_dir=d, file_write_mode="merge") 154 | .given("g") 155 | .upon_receiving("r") 156 | .with_request("get", "/foo") 157 | .will_respond_with(200) 158 | ) 159 | with pact: 160 | requests.get(pact.uri + "/foo") 161 | 162 | # force a failure by specifying the same given/providerState but different request 163 | pact = ( 164 | Consumer("C") 165 | .has_pact_with(Provider("P"), pact_dir=d, file_write_mode="merge") 166 | .given("g") 167 | .upon_receiving("r") 168 | .with_request("get", "/bar") 169 | .will_respond_with(200) 170 | ) 171 | with pytest.raises(PactInteractionMismatch): 172 | with pact: 173 | requests.get(pact.uri + "/bar") 174 | 175 | 176 | def test_detect_mismatch_request_retained_relationship(): 177 | with tempfile.TemporaryDirectory() as d: 178 | pact = Consumer("C").has_pact_with(Provider("P"), pact_dir=d) 179 | with pact.given("g").upon_receiving("r").with_request("get", "/foo").will_respond_with(200): 180 | requests.get(pact.uri + "/foo") 181 | 182 | # force a failure by specifying the same given/providerState but different request 183 | with pytest.raises(PactInteractionMismatch): 184 | with pact.given("g").upon_receiving("r").with_request("get", "/bar").will_respond_with( 185 | 200 186 | ): 187 | requests.get(pact.uri + "/bar") 188 | -------------------------------------------------------------------------------- /pactman/verifier/pytest_plugin.py: -------------------------------------------------------------------------------- 1 | import glob 2 | import logging 3 | import os 4 | import warnings 5 | 6 | import pytest 7 | from _pytest.outcomes import Failed 8 | from _pytest.reports import TestReport 9 | 10 | from .broker_pact import BrokerPact, BrokerPacts, PactBrokerConfig 11 | from .result import PytestResult, log 12 | 13 | 14 | def pytest_addoption(parser): 15 | group = parser.getgroup("pact specific options (pactman)") 16 | group.addoption( 17 | "--pact-files", default=None, help="pact JSON files to verify (wildcards allowed)" 18 | ) 19 | group.addoption("--pact-broker-url", default="", help="pact broker URL") 20 | group.addoption("--pact-broker-token", default="", help="pact broker bearer token") 21 | group.addoption( 22 | "--pact-provider-name", default=None, help="pact name of provider being verified" 23 | ) 24 | group.addoption( 25 | "--pact-consumer-name", 26 | default=None, 27 | help="consumer name to limit pact verification to - " 28 | "DEPRECATED, use --pact-verify-consumer instead", 29 | ) 30 | group.addoption( 31 | "--pact-verify-consumer", default=None, help="consumer name to limit pact verification to" 32 | ) 33 | group.addoption( 34 | "--pact-verify-consumer-tag", 35 | metavar="TAG", 36 | action="append", 37 | help="limit broker pacts verified to those matching the tag. May be " 38 | "specified multiple times in which case pacts matching any of these " 39 | "tags will be verified.", 40 | ) 41 | group.addoption( 42 | "--pact-publish-results", 43 | action="store_true", 44 | default=False, 45 | help="report pact verification results to pact broker", 46 | ) 47 | group.addoption( 48 | "--pact-provider-version", 49 | default=None, 50 | help="provider version to use when reporting pact results to pact broker", 51 | ) 52 | group.addoption( 53 | "--pact-allow-fail", 54 | default=False, 55 | action="store_true", 56 | help="do not fail the pytest run if any pacts fail verification", 57 | ) 58 | 59 | 60 | # Future options to be implemented. Listing them here so naming consistency can be a thing. 61 | # group.addoption("--pact-publish-pacts", action="store_true", default=False, 62 | # help="publish pacts to pact broker") 63 | # group.addoption("--pact-consumer-version", default=None, 64 | # help="consumer version to use when publishing pacts to the broker") 65 | # group.addoption("--pact-consumer-version-source", default=None, 66 | # help="generate consumer version from source 'git-tag' or 'git-hash'") 67 | # group.addoption("--pact-consumer-version-tag", metavar='TAG', action="append", 68 | # help="tag(s) that should be applied to the consumer version when pacts " 69 | # "are uploaded to the broker; multiple tags may be supplied") 70 | 71 | 72 | def get_broker_url(config): 73 | return config.getoption("pact_broker_url") or os.environ.get("PACT_BROKER_URL") 74 | 75 | 76 | def get_provider_name(config): 77 | return config.getoption("pact_provider_name") or os.environ.get("PACT_PROVIDER_NAME") 78 | 79 | 80 | # add the pact broker URL to the pytest output if running verbose 81 | def pytest_report_header(config): 82 | if config.getoption("verbose") > 0: 83 | location = get_broker_url(config) or config.getoption("pact_files") 84 | return [f"Loading pacts from {location}"] 85 | 86 | 87 | def pytest_configure(config): 88 | logging.getLogger("pactman").handlers = [] 89 | logging.basicConfig(format="%(message)s") 90 | verbosity = config.getoption("verbose") 91 | if verbosity > 0: 92 | log.setLevel(logging.DEBUG) 93 | 94 | 95 | class PytestPactVerifier: 96 | def __init__(self, publish_results, provider_version, interaction, consumer): 97 | self.publish_results = publish_results 98 | self.provider_version = provider_version 99 | self.interaction = interaction 100 | self.consumer = consumer 101 | 102 | def verify(self, provider_url, provider_setup, extra_provider_headers={}): 103 | try: 104 | self.interaction.verify_with_callable_setup(provider_url, provider_setup, extra_provider_headers) 105 | except (Failed, AssertionError) as e: 106 | raise Failed(str(e)) from None 107 | 108 | def finish(self): 109 | if self.consumer and self.publish_results and self.provider_version: 110 | self.consumer.publish_result(self.provider_version) 111 | 112 | 113 | def flatten_pacts(pacts): 114 | for consumer in pacts: 115 | last = consumer.interactions[-1] 116 | for interaction in consumer.interactions: 117 | if interaction is last: 118 | yield (interaction, consumer) 119 | else: 120 | yield (interaction, None) 121 | 122 | 123 | def load_pact_files(file_location): 124 | for filename in glob.glob(file_location, recursive=True): 125 | yield BrokerPact.load_file(filename, result_factory=PytestResult) 126 | 127 | 128 | def test_id(identifier): 129 | interaction, _ = identifier 130 | return str(interaction) 131 | 132 | 133 | def pytest_generate_tests(metafunc): 134 | if "pact_verifier" in metafunc.fixturenames: 135 | broker_url = get_broker_url(metafunc.config) 136 | if not broker_url: 137 | pact_files_location = metafunc.config.getoption("pact_files") 138 | if not pact_files_location: 139 | raise ValueError("need a --pact-broker-url or --pact-files option") 140 | pact_files = load_pact_files(pact_files_location) 141 | metafunc.parametrize( 142 | "pact_verifier", flatten_pacts(pact_files), ids=test_id, indirect=True 143 | ) 144 | else: 145 | provider_name = get_provider_name(metafunc.config) 146 | if not provider_name: 147 | raise ValueError("--pact-broker-url requires the --pact-provider-name option") 148 | broker = PactBrokerConfig( 149 | broker_url, 150 | metafunc.config.getoption("pact_broker_token"), 151 | metafunc.config.getoption("pact_verify_consumer_tag", []), 152 | ) 153 | broker_pacts = BrokerPacts( 154 | provider_name, pact_broker=broker, result_factory=PytestResult 155 | ) 156 | pacts = broker_pacts.consumers() 157 | filter_consumer_name = metafunc.config.getoption("pact_verify_consumer") 158 | if not filter_consumer_name: 159 | filter_consumer_name = metafunc.config.getoption("pact_consumer_name") 160 | if filter_consumer_name: 161 | warnings.warn( 162 | "The --pact-consumer-name command-line option is deprecated " 163 | "and will be removed in the 3.0.0 release.", 164 | DeprecationWarning, 165 | ) 166 | if filter_consumer_name: 167 | pacts = [pact for pact in pacts if pact.consumer == filter_consumer_name] 168 | metafunc.parametrize("pact_verifier", flatten_pacts(pacts), ids=test_id, indirect=True) 169 | 170 | 171 | class PactTestReport(TestReport): 172 | """Custom TestReport that allows us to attach an interaction to the result, and 173 | then display the interaction's verification result ouput as well as the traceback 174 | of the failure. 175 | """ 176 | 177 | @classmethod 178 | def from_item_and_call(cls, item, call, interaction): 179 | report = super().from_item_and_call(item, call) 180 | report.pact_interaction = interaction 181 | # the toterminal() call can't reasonably get at this config, so we store it here 182 | report.verbosity = item.config.option.verbose 183 | return report 184 | 185 | def toterminal(self, out): 186 | out.line("Pact failure details:", bold=True) 187 | for text, kw in self.pact_interaction.result.results_for_terminal(): 188 | out.line(text, **kw) 189 | if self.verbosity > 0: 190 | out.line("Traceback:", bold=True) 191 | return super().toterminal(out) 192 | else: 193 | out.line("Traceback not shown, use pytest -v to show it") 194 | 195 | 196 | def pytest_runtest_makereport(item, call): 197 | if call.when != "call" or "pact_verifier" not in getattr(item, "fixturenames", []): 198 | return 199 | # use our custom TestReport subclass if we're reporting on a pact verification call 200 | interaction = item.funcargs["pact_verifier"].interaction 201 | report = PactTestReport.from_item_and_call(item, call, interaction) 202 | if report.failed and item.config.getoption("pact_allow_fail"): 203 | # convert the fail into an "expected" fail, which allows the run to pass 204 | report.wasxfail = True 205 | report.outcome = "passed" 206 | return report 207 | 208 | 209 | def pytest_report_teststatus(report, config): 210 | if not hasattr(report, "pact_interaction"): 211 | return 212 | if hasattr(report, "wasxfail"): 213 | # wasxfail usually displays an "X" but since it's not *expected* to fail an "f" is a little clearer 214 | return "ignore fail", "f", "IGNORE_FAIL" 215 | 216 | 217 | @pytest.fixture() 218 | def pact_verifier(pytestconfig, request): 219 | interaction, consumer = request.param 220 | p = PytestPactVerifier( 221 | pytestconfig.getoption("pact_publish_results"), 222 | pytestconfig.getoption("pact_provider_version"), 223 | interaction, 224 | consumer, 225 | ) 226 | yield p 227 | p.finish() 228 | -------------------------------------------------------------------------------- /pactman/test/test_mock_pact.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | from unittest import TestCase 4 | from unittest.mock import call, patch 5 | 6 | import requests 7 | from pactman.mock.consumer import Consumer 8 | from pactman.mock.pact import Pact 9 | from pactman.mock.provider import Provider 10 | 11 | 12 | class PactTestCase(TestCase): 13 | def setUp(self): 14 | self.consumer = Consumer("TestConsumer") 15 | self.provider = Provider("TestProvider") 16 | 17 | def test_init_defaults(self): 18 | target = Pact(self.consumer, self.provider) 19 | self.assertIs(target.consumer, self.consumer) 20 | self.assertEqual(target.host_name, "localhost") 21 | self.assertEqual(target.log_dir, os.getcwd()) 22 | self.assertEqual(target.pact_dir, os.getcwd()) 23 | self.assertEqual(target.port, Pact.BASE_PORT_NUMBER) 24 | self.assertIs(target.provider, self.provider) 25 | self.assertIs(target.ssl, False) 26 | self.assertIsNone(target.sslcert) 27 | self.assertIsNone(target.sslkey) 28 | self.assertEqual(target.uri, f"http://localhost:{Pact.BASE_PORT_NUMBER}") 29 | self.assertEqual(target.version, "2.0.0") 30 | self.assertEqual(len(target._interactions), 0) 31 | 32 | def test_init_custom_mock_service(self): 33 | with tempfile.TemporaryDirectory() as d: 34 | target = Pact( 35 | self.consumer, 36 | self.provider, 37 | host_name="192.168.1.1", 38 | port=8000, 39 | log_dir="/logs", 40 | ssl=True, 41 | sslcert="/ssl.cert", 42 | sslkey="/ssl.pem", 43 | pact_dir=d, 44 | version="3.0.0", 45 | file_write_mode="merge", 46 | use_mocking_server=False, 47 | ) 48 | 49 | self.assertIs(target.consumer, self.consumer) 50 | self.assertEqual(target.host_name, "192.168.1.1") 51 | self.assertEqual(target.log_dir, "/logs") 52 | self.assertEqual(target.pact_dir, d) 53 | self.assertEqual(target.port, 8000) 54 | self.assertIs(target.provider, self.provider) 55 | self.assertIs(target.ssl, True) 56 | self.assertEqual(target.sslcert, "/ssl.cert") 57 | self.assertEqual(target.sslkey, "/ssl.pem") 58 | self.assertEqual(target.uri, "https://192.168.1.1:8000") 59 | self.assertEqual(target.version, "3.0.0") 60 | self.assertEqual(target.file_write_mode, "merge") 61 | self.assertEqual(len(target._interactions), 0) 62 | self.assertIs(target.use_mocking_server, False) 63 | 64 | def test_definition_sparse(self): 65 | target = Pact(self.consumer, self.provider) 66 | ( 67 | target.given("I am creating a new pact using the Pact class") 68 | .upon_receiving("a specific request to the server") 69 | .with_request("GET", "/path") 70 | .will_respond_with(200, body="success") 71 | ) 72 | 73 | self.assertEqual(len(target._interactions), 1) 74 | 75 | self.assertEqual( 76 | target._interactions[0]["providerState"], 77 | "I am creating a new pact using the Pact class", 78 | ) 79 | 80 | self.assertEqual(target._interactions[0]["description"], "a specific request to the server") 81 | 82 | self.assertEqual(target._interactions[0]["request"], {"path": "/path", "method": "GET"}) 83 | self.assertEqual(target._interactions[0]["response"], {"status": 200, "body": "success"}) 84 | 85 | def test_definition_all_options(self): 86 | target = Pact(self.consumer, self.provider, version="2.0.0") 87 | ( 88 | target.given("I am creating a new pact using the Pact class") 89 | .upon_receiving("a specific request to the server") 90 | .with_request( 91 | "GET", 92 | "/path", 93 | body={"key": "value"}, 94 | headers={"Accept": "application/json"}, 95 | query="search=test", 96 | ) 97 | .will_respond_with(200, body="success", headers={"Content-Type": "application/json"}) 98 | ) 99 | 100 | self.assertEqual( 101 | target._interactions[0]["providerState"], 102 | "I am creating a new pact using the Pact class", 103 | ) 104 | 105 | self.assertEqual(target._interactions[0]["description"], "a specific request to the server") 106 | 107 | self.assertEqual( 108 | target._interactions[0]["request"], 109 | { 110 | "path": "/path", 111 | "method": "GET", 112 | "body": {"key": "value"}, 113 | "headers": {"Accept": "application/json"}, 114 | "query": "search=test", 115 | }, 116 | ) 117 | self.assertEqual( 118 | target._interactions[0]["response"], 119 | {"status": 200, "body": "success", "headers": {"Content-Type": "application/json"}}, 120 | ) 121 | 122 | def test_definition_all_options_v3(self): 123 | target = Pact(self.consumer, self.provider, version="3.0.0") 124 | ( 125 | target.given([{"name": "I am creating a new pact using the Pact class", "params": {}}]) 126 | .upon_receiving("a specific request to the server") 127 | .with_request( 128 | "GET", 129 | "/path", 130 | body={"key": "value"}, 131 | headers={"Accept": "application/json"}, 132 | query={"search": ["test"]}, 133 | ) 134 | .will_respond_with(200, body="success", headers={"Content-Type": "application/json"}) 135 | ) 136 | 137 | self.assertEqual( 138 | target._interactions[0]["providerStates"], 139 | [{"name": "I am creating a new pact using the Pact class", "params": {}}], 140 | ) 141 | 142 | self.assertEqual(target._interactions[0]["description"], "a specific request to the server") 143 | 144 | self.assertEqual( 145 | target._interactions[0]["request"], 146 | { 147 | "path": "/path", 148 | "method": "GET", 149 | "body": {"key": "value"}, 150 | "headers": {"Accept": "application/json"}, 151 | "query": {"search": ["test"]}, 152 | }, 153 | ) 154 | self.assertEqual( 155 | target._interactions[0]["response"], 156 | {"status": 200, "body": "success", "headers": {"Content-Type": "application/json"}}, 157 | ) 158 | 159 | def test_definition_v3_requires_new_providerStates(self): 160 | target = Pact(self.consumer, self.provider, version="3.0.0") 161 | target.given("I am creating a new pact using the Pact class") 162 | self.assertEqual( 163 | target._interactions[0]["providerStates"], 164 | [{"name": "I am creating a new pact using the Pact class", "params": {}}], 165 | ) 166 | 167 | def test_definition_multiple_interactions(self): 168 | target = Pact(self.consumer, self.provider) 169 | ( 170 | target.given("I am creating a new pact using the Pact class") 171 | .upon_receiving("a specific request to the server") 172 | .with_request("GET", "/foo") 173 | .will_respond_with(200, body="success") 174 | .given("I am creating another new pact using the Pact class") 175 | .upon_receiving("a different request to the server") 176 | .with_request("GET", "/bar") 177 | .will_respond_with(200, body="success") 178 | ) 179 | 180 | self.assertEqual(len(target._interactions), 2) 181 | 182 | self.assertEqual( 183 | target._interactions[1]["providerState"], 184 | "I am creating a new pact using the Pact class", 185 | ) 186 | self.assertEqual( 187 | target._interactions[0]["providerState"], 188 | "I am creating another new pact using the Pact class", 189 | ) 190 | 191 | self.assertEqual(target._interactions[1]["description"], "a specific request to the server") 192 | self.assertEqual( 193 | target._interactions[0]["description"], "a different request to the server" 194 | ) 195 | 196 | self.assertEqual(target._interactions[1]["request"], {"path": "/foo", "method": "GET"}) 197 | self.assertEqual(target._interactions[0]["request"], {"path": "/bar", "method": "GET"}) 198 | 199 | self.assertEqual(target._interactions[1]["response"], {"status": 200, "body": "success"}) 200 | self.assertEqual(target._interactions[0]["response"], {"status": 200, "body": "success"}) 201 | 202 | 203 | class PactSetupTestCase(PactTestCase): 204 | def setUp(self): 205 | super(PactSetupTestCase, self).setUp() 206 | self.addCleanup(patch.stopall) 207 | self.target = Pact(self.consumer, self.provider) 208 | ( 209 | self.target.given("I am creating a new pact using the Pact class") 210 | .upon_receiving("a specific request to the server") 211 | .with_request("GET", "/path") 212 | .will_respond_with(200, body="success") 213 | ) 214 | 215 | self.delete_call = call( 216 | "delete", "http://localhost:1234/interactions", headers={"X-Pact-Mock-Service": "true"} 217 | ) 218 | 219 | self.put_interactions_call = call( 220 | "put", 221 | "http://localhost:1234/interactions", 222 | data=None, 223 | headers={"X-Pact-Mock-Service": "true"}, 224 | json={ 225 | "interactions": [ 226 | { 227 | "response": {"status": 200, "body": "success"}, 228 | "request": {"path": "/path", "method": "GET"}, 229 | "description": "a specific request to the server", 230 | "providerState": "I am creating a new pact using the Pact class", 231 | } 232 | ] 233 | }, 234 | ) 235 | 236 | 237 | class PactContextManagerTestCase(PactTestCase): 238 | def setUp(self): 239 | super(PactContextManagerTestCase, self).setUp() 240 | self.addCleanup(patch.stopall) 241 | self.mock_setup = patch.object(Pact, "setup", autospec=True).start() 242 | 243 | self.mock_verify = patch.object(Pact, "verify", autospec=True).start() 244 | 245 | def test_successful(self): 246 | pact = Pact(self.consumer, self.provider) 247 | with pact: 248 | pass 249 | 250 | self.mock_setup.assert_called_once_with(pact) 251 | self.mock_verify.assert_called_once_with(pact) 252 | 253 | def test_context_raises_error(self): 254 | pact = Pact(self.consumer, self.provider) 255 | with self.assertRaises(RuntimeError): 256 | with pact: 257 | raise RuntimeError 258 | 259 | self.mock_setup.assert_called_once_with(pact) 260 | self.assertFalse(self.mock_verify.called) 261 | 262 | 263 | def test_multiple_pacts_dont_break_during_teardown(): 264 | # ensure teardown is only done on when all pacts __exit__ 265 | pact = Pact(Consumer("Consumer"), Provider("Provider")) 266 | p1 = ( 267 | pact.given("given") 268 | .upon_receiving("when") 269 | .with_request("GET", "/path") 270 | .will_respond_with(201) 271 | ) 272 | p2 = ( 273 | pact.given("given2") 274 | .upon_receiving("when2") 275 | .with_request("GET", "/path2") 276 | .will_respond_with(201) 277 | ) 278 | with p1, p2: 279 | requests.get(p1.uri + "/path") 280 | -------------------------------------------------------------------------------- /pactman/mock/pact.py: -------------------------------------------------------------------------------- 1 | """API for creating a contract and configuring the mock service.""" 2 | from __future__ import unicode_literals 3 | 4 | import os 5 | 6 | import semver 7 | 8 | from ..mock.request import Request 9 | from ..mock.response import Response 10 | from .mock_server import getMockServer 11 | from .mock_urlopen import MockURLOpenHandler 12 | 13 | USE_MOCKING_SERVER = os.environ.get("PACT_USE_MOCKING_SERVER", "no") == "yes" 14 | 15 | 16 | def ensure_pact_dir(pact_dir): 17 | if not os.path.exists(pact_dir): 18 | parent_dir = os.path.dirname(pact_dir) 19 | if not os.path.exists(parent_dir): 20 | raise ValueError(f"Pact destination directory {pact_dir} does not exist") 21 | os.mkdir(pact_dir) 22 | 23 | 24 | class Pact(object): 25 | """ 26 | Represents a contract between a consumer and provider. 27 | 28 | Provides Python context handlers to configure the Pact mock service to 29 | perform tests on a Python consumer. For example: 30 | 31 | >>> from pactman import Consumer, Provider 32 | >>> pact = Consumer('consumer').has_pact_with(Provider('provider')) 33 | >>> (pact.given('the echo service is available') 34 | ... .upon_receiving('a request is made to the echo service') 35 | ... .with_request('get', '/echo', query={'text': 'Hello!'}) 36 | ... .will_respond_with(200, body='Hello!')) 37 | >>> with pact: 38 | ... requests.get(pact.uri + '/echo?text=Hello!') 39 | 40 | The GET request is made to the mock service, which will verify that it 41 | was a GET to /echo with a query string with a key named `text` and its 42 | value is `Hello!`. If the request does not match an error is raised, if it 43 | does match the defined interaction, it will respond with the text `Hello!`. 44 | """ 45 | 46 | HEADERS = {"X-Pact-Mock-Service": "true"} 47 | 48 | def __init__( 49 | self, 50 | consumer, 51 | provider, 52 | host_name="localhost", 53 | port=None, 54 | log_dir=None, 55 | ssl=False, 56 | sslcert=None, 57 | sslkey=None, 58 | pact_dir=None, 59 | version="2.0.0", 60 | file_write_mode="overwrite", 61 | use_mocking_server=USE_MOCKING_SERVER, 62 | ): 63 | """ 64 | Constructor for Pact. 65 | 66 | :param consumer: The consumer for this contract. 67 | :type consumer: pact.Consumer 68 | :param provider: The provider for this contract. 69 | :type provider: pact.Provider 70 | :param host_name: The host name where the mock service is running. 71 | :type host_name: str 72 | :param port: The port number where the mock service is running. Defaults 73 | to a port >= 8050. 74 | :type port: int 75 | :param log_dir: The directory where logs should be written. Defaults to 76 | the current directory. 77 | :type log_dir: str 78 | :param ssl: Flag to control the use of a self-signed SSL cert to run 79 | the server over HTTPS , defaults to False. 80 | :type ssl: bool 81 | :param sslcert: Path to a custom self-signed SSL cert file, 'ssl' 82 | option must be set to True to use this option. Defaults to None. 83 | :type sslcert: str 84 | :param sslkey: Path to a custom key and self-signed SSL cert key file, 85 | 'ssl' option must be set to True to use this option. 86 | Defaults to None. 87 | :type sslkey: str 88 | :param pact_dir: Directory where the resulting pact files will be 89 | written. Defaults to the current directory. 90 | :type pact_dir: str 91 | :param version: The Pact Specification version to use, defaults to 92 | '2.0.0'. 93 | :type version: str 94 | :param file_write_mode: `overwrite` or `merge`. Use `merge` when 95 | running multiple mock service instances in parallel for the same 96 | consumer/provider pair. Ensure the pact file is deleted before 97 | running tests when using this option so that interactions deleted 98 | from the code are not maintained in the file. Defaults to 99 | `overwrite`. 100 | :type file_write_mode: str 101 | :param use_mocking_server: If True the mocking will be done using a 102 | HTTP server rather than patching urllib3 connections. 103 | :type use_mocking_server: bool 104 | """ 105 | self.scheme = "https" if ssl else "http" 106 | self.consumer = consumer 107 | self.file_write_mode = file_write_mode 108 | self.host_name = host_name 109 | self.log_dir = log_dir or os.getcwd() 110 | self.pact_dir = pact_dir or os.getcwd() 111 | self.port = port or self.allocate_port() 112 | self.provider = provider 113 | # TODO implement SSL 114 | self.ssl = ssl 115 | self.sslcert = sslcert 116 | self.sslkey = sslkey 117 | self.version = version 118 | self.semver = semver.VersionInfo.parse(self.version) 119 | self.use_mocking_server = use_mocking_server 120 | self._interactions = [] 121 | self._mock_handler = None 122 | self._pact_dir_checked = False 123 | self._enter_count = 0 124 | 125 | @property 126 | def uri(self): 127 | return f"{self.scheme}://{self.host_name}:{self.port}" 128 | 129 | BASE_PORT_NUMBER = 8150 130 | 131 | @classmethod 132 | def allocate_port(cls): 133 | cls.BASE_PORT_NUMBER += 5 134 | return cls.BASE_PORT_NUMBER 135 | 136 | def check_existing_file(self): 137 | # ensure destination directory exists 138 | if self.file_write_mode == "never": 139 | return 140 | if self._pact_dir_checked: 141 | return 142 | self._pact_dir_checked = True 143 | ensure_pact_dir(self.pact_dir) 144 | if self.file_write_mode == "overwrite": 145 | if os.path.exists(self.pact_json_filename): 146 | os.remove(self.pact_json_filename) 147 | 148 | @property 149 | def pact_json_filename(self): 150 | self.check_existing_file() 151 | return os.path.join(self.pact_dir, f"{self.consumer.name}-{self.provider.name}-pact.json") 152 | 153 | def given(self, provider_state, **params): 154 | """ 155 | Define the provider state for this pact. 156 | 157 | When the provider verifies this contract, they will use this field to 158 | setup pre-defined data that will satisfy the response expectations. 159 | 160 | In pact v2 the provider state is a short sentence that is unique to describe 161 | the provider state for this contract. For example: 162 | 163 | "an alligator with the given name Mary exists and the spam nozzle is operating" 164 | 165 | In pact v3 the provider state is a list of state specifications with a name and 166 | associated params to define specific values for the state. This may be provided 167 | in two ways. Either call with a single list, for example: 168 | 169 | [ 170 | { 171 | "name": "an alligator with the given name exists", 172 | "params": {"name" : "Mary"} 173 | }, { 174 | "name": "the spam nozzle is operating", 175 | "params" : {} 176 | } 177 | ] 178 | 179 | or for convenience call `.given()` with a string as in v2, which implies a single 180 | provider state, with params taken from keyword arguments like so: 181 | 182 | .given("an alligator with the given name exists", name="Mary") 183 | 184 | If additional provider states are required for a v3 pact you may either use the list 185 | form above, or make an additional call to `.and_given()`. 186 | 187 | If you don't have control over the provider, and they cannot implement a provider 188 | state, you may use an explicit `None` for the provider state value. This is 189 | discouraged as it introduces fragile external dependencies in your tests. 190 | 191 | :param provider_state: The state as described above. 192 | :type provider_state: string or list as above 193 | :rtype: Pact 194 | """ 195 | if provider_state is None: 196 | self._interactions.insert(0, {}) 197 | return self 198 | 199 | if self.semver.major < 3: 200 | provider_state_key = "providerState" 201 | if not isinstance(provider_state, str): 202 | raise ValueError("pact v2 provider states must be strings") 203 | else: 204 | provider_state_key = "providerStates" 205 | if isinstance(provider_state, str): 206 | provider_state = [{"name": provider_state, "params": params}] 207 | elif not isinstance(provider_state, list): 208 | raise ValueError( 209 | 'pact v3+ provider states must be lists of {name: "", params: {}} specs' 210 | ) 211 | self._interactions.insert(0, {provider_state_key: provider_state}) 212 | return self 213 | 214 | def and_given(self, provider_state, **params): 215 | """ 216 | Define an additional provider state for this pact. 217 | 218 | Supply the provider state name and any params taken in keyword arguments like so: 219 | 220 | .given("an alligator with the given name exists", name="Mary") 221 | 222 | :param provider_state: The state as described above. 223 | :type provider_state: string or list as above 224 | :rtype: Pact 225 | """ 226 | if self.semver.major < 3: 227 | raise ValueError("pact v2 only allows a single provider state") 228 | elif not self._interactions: 229 | raise ValueError("only invoke and_given() after given()") 230 | self._interactions[-1]["providerStates"].append({"name": provider_state, "params": params}) 231 | return self 232 | 233 | def setup(self): 234 | self._mock_handler.setup(self._interactions) 235 | 236 | def start_mocking(self): 237 | if self.use_mocking_server: 238 | self._mock_handler = getMockServer(self) 239 | else: 240 | # ain't no port, we're monkey-patching (but the URLs we generate still need to look correct) 241 | self._mock_handler = MockURLOpenHandler(self) 242 | 243 | def stop_mocking(self): 244 | self._mock_handler.terminate() 245 | self._mock_handler = None 246 | 247 | # legacy pact-python API support 248 | start_service = start_mocking 249 | stop_service = stop_mocking 250 | 251 | def upon_receiving(self, scenario): 252 | """ 253 | Define the name of this contract. 254 | 255 | :param scenario: A unique name for this contract. 256 | :type scenario: basestring 257 | :rtype: Pact 258 | """ 259 | self._interactions[0]["description"] = scenario 260 | return self 261 | 262 | def verify(self): 263 | """ 264 | Have the mock service verify all interactions occurred. 265 | 266 | Calls the mock service to verify that all interactions occurred as 267 | expected, and has it write out the contracts to disk. 268 | 269 | :raises AssertionError: When not all interactions are found. 270 | """ 271 | try: 272 | self._mock_handler.verify() 273 | finally: 274 | # clear the interactions once we've attempted to verify, allowing re-use of the mock 275 | self._interactions[:] = [] 276 | 277 | def with_request(self, method, path, body=None, headers=None, query=None): 278 | """ 279 | Define the request the request that the client is expected to perform. 280 | 281 | :param method: The HTTP method. 282 | :type method: str 283 | :param path: The path portion of the URI the client will access. 284 | :type path: str, Matcher 285 | :param body: The request body, can be a string or an object that will 286 | serialize to JSON, like list or dict, defaults to None. 287 | :type body: list, dict or None 288 | :param headers: The headers the client is expected to include on with 289 | this request. Defaults to None. 290 | :type headers: dict or None 291 | :param query: The query options the client is expected to send. Can be 292 | a dict of keys and values, or a URL encoded string. 293 | Defaults to None. 294 | :type query: dict, str, or None 295 | :rtype: Pact 296 | """ 297 | # ensure all query values are lists of values 298 | if isinstance(query, dict): 299 | for k, v in query.items(): 300 | if isinstance(v, str): 301 | query[k] = [v] 302 | self._interactions[0]["request"] = Request( 303 | method, path, body=body, headers=headers, query=query 304 | ).json(self.version) 305 | return self 306 | 307 | def will_respond_with(self, status, headers=None, body=None): 308 | """ 309 | Define the response the server is expected to create. 310 | 311 | :param status: The HTTP status code. 312 | :type status: int 313 | :param headers: All required headers. Defaults to None. 314 | :type headers: dict or None 315 | :param body: The response body, or a collection of Matcher objects to 316 | allow for pattern matching. Defaults to None. 317 | :type body: Matcher, dict, list, basestring, or None 318 | :rtype: Pact 319 | """ 320 | self._interactions[0]["response"] = Response(status, headers=headers, body=body).json( 321 | self.version 322 | ) 323 | return self 324 | 325 | _auto_mocked = False 326 | 327 | def __enter__(self): 328 | """ 329 | Handler for entering a Python context. 330 | 331 | Sets up the mock service to expect the client requests. 332 | """ 333 | if not self.use_mocking_server and not self._mock_handler: 334 | self._auto_mocked = True 335 | self.start_mocking() 336 | 337 | self.setup() 338 | self._enter_count += 1 339 | 340 | def __exit__(self, exc_type, exc_val, exc_tb): 341 | """ 342 | Handler for exiting a Python context. 343 | 344 | Calls the mock service to verify that all interactions occurred as 345 | expected, and has it write out the contracts to disk. 346 | """ 347 | self._enter_count -= 1 348 | 349 | if (exc_type, exc_val, exc_tb) != (None, None, None): 350 | # let the exception go through to the keeper 351 | return 352 | 353 | # don't invoke teardown until all interactions for this pact are exited 354 | if self._enter_count: 355 | return 356 | 357 | self.verify() 358 | 359 | if not self.use_mocking_server and self._auto_mocked: 360 | self.stop_mocking() 361 | 362 | def construct_pact(self, interaction): 363 | """Construct a pact JSON data structure for the interaction. 364 | """ 365 | return dict( 366 | consumer={"name": self.consumer.name}, 367 | provider={"name": self.provider.name}, 368 | interactions=[interaction], 369 | metadata=dict(pactSpecification=dict(version=self.version)), 370 | ) 371 | -------------------------------------------------------------------------------- /pactman/verifier/matching_rule.py: -------------------------------------------------------------------------------- 1 | """Implement matching rules as defined in the pact specification version 2: 2 | 3 | https://github.com/pact-foundation/pact-specification/tree/version-2 4 | """ 5 | import logging 6 | import re 7 | from collections import OrderedDict, defaultdict 8 | 9 | from .paths import format_path 10 | 11 | log = logging.getLogger(__name__) 12 | 13 | 14 | class RuleFailed(Exception): 15 | def __init__(self, path, message): 16 | if isinstance(path, list): 17 | path = format_path(path) 18 | message = path + " " + message 19 | super().__init__(message) 20 | 21 | 22 | def split_path(path): 23 | """Split a JSON path from a pact matchingRule. 24 | 25 | Pact does not support the full JSON path expressions, only ones that match the following rules: 26 | 27 | * All paths start with a dollar ($), representing the root. 28 | * All path elements are either separated by periods (`.`) or use the JSON path bracket notation (square brackets 29 | and single quotes around the values: e.g. `['x.y']`), except array indices which use square brackets (`[]`). 30 | For elements where the value contains white space or non-alphanumeric characters, the JSON path bracket notation 31 | (`['']`) should be used. 32 | * The second element of the path is the http type that the matcher is applied to (e.g., $.body or $.header). 33 | * Path elements represent keys. 34 | * A star (*) can be used to match all keys of a map or all items of an array (one level only). 35 | 36 | The empty path (see "path" matching rules) should result in an empty split path. 37 | 38 | Returns an iterator that has each path element as an item with array indexes converted to integers. 39 | """ 40 | if not path: 41 | return 42 | for elem in re.split(r"[\.\[]", path): 43 | if elem == "*]": 44 | yield "*" 45 | elif elem[0] in "'\"": 46 | yield elem[1:-2] 47 | elif elem[-1] == "]": 48 | yield int(elem[:-1]) 49 | else: 50 | yield elem 51 | 52 | 53 | def weight_path(spec_path, element_path): 54 | """Determine the weighting number for a matchingRule path spec applied to an actual element path from a 55 | response body. 56 | 57 | Both paths are passed in as lists which represent the paths split per split_path() 58 | 59 | In spec version 2 paths should always start with ['$', 'body'] and contain object element names or array indexes. 60 | 61 | In spec version 3 the path "context" element (like 'body') is moved outside of the path, and the path contains 62 | just the elements inside the path. For bodies this always starts at the root '$', so the shortest body path is 63 | now ['$']. 64 | 65 | Weighting is calculated as: 66 | 67 | * The root node ($) is assigned the value 2. 68 | * Any path element that does not match is assigned the value 0. 69 | * Any property name that matches a path element is assigned the value 2. 70 | * Any array index that matches a path element is assigned the value 2. 71 | * Any star (*) that matches a property or array index is assigned the value 1. 72 | * Everything else is assigned the value 0. 73 | 74 | Return the numeric score. 75 | """ 76 | # if the spec path is more specific than the element path it'll never match 77 | if len(spec_path) > len(element_path): 78 | return 0 79 | score = 1 80 | for spec, element in zip(spec_path, element_path): 81 | if spec == element: 82 | score *= 2 83 | elif spec == "*": 84 | score *= 1 85 | else: 86 | return 0 87 | return score 88 | 89 | 90 | def fold_type(obj): 91 | if type(obj) == OrderedDict: 92 | return dict 93 | return type(obj) 94 | 95 | 96 | class WeightedRule: 97 | def __init__(self, rule, weight): 98 | self.rule = rule 99 | self.weight = weight 100 | 101 | def __lt__(self, other): 102 | return self.weight < other.weight 103 | 104 | def __str__(self): 105 | return f'rule="{self.rule}", weight={self.weight}' 106 | 107 | 108 | class Matcher: 109 | """Hold a JSONpath spec and a matchingRule rule and know how to test it. 110 | 111 | The valid rules are: 112 | 113 | `{"match": "regex", "regex": "\\d+"}` 114 | This executes a regular expression match against the string representation of a values. 115 | 116 | `{"match": "type"}` 117 | This executes a type based match against the values, that is, they are equal if they are the same type. 118 | 119 | `{"match": "type", "min": 2}` 120 | This executes a type based match against the values, that is, they are equal if they are the same type. 121 | In addition, if the values represent a collection, the length of the actual value is compared against the minimum. 122 | 123 | `{"match": "type", "max": 10}` 124 | This executes a type based match against the values, that is, they are equal if they are the same type. 125 | In addition, if the values represent a collection, the length of the actual value is compared against the maximum. 126 | 127 | Note that this code 128 | """ 129 | 130 | def __init__(self, path, rule): 131 | log.debug(f"MatchingRule.__init__ {path!r} {rule!r}") 132 | self.path = path 133 | self.rule = rule 134 | 135 | def __repr__(self): 136 | return f"<{self.__class__.__name__} path={self.path!r} rule={self.rule}>" 137 | 138 | def __str__(self): 139 | return f"Rule match by {self.rule} at {self.path}" 140 | 141 | def apply(self, data, spec, path): 142 | raise NotImplementedError() 143 | 144 | def weight(self, element_path): 145 | """Given a matching rule path and an element path a blob, determine the weighting for the match. 146 | 147 | Return the weight, or 0 if there is no match. 148 | """ 149 | return WeightedRule(self, weight_path(list(split_path(self.path)), element_path)) 150 | 151 | def check_min(self, data, path): 152 | if "min" not in self.rule: 153 | return 154 | if type(data) not in (dict, list, str): 155 | return 156 | if len(data) < self.rule["min"]: 157 | raise RuleFailed( 158 | path, f'size {len(data)!r} is smaller than minimum size {self.rule["min"]}' 159 | ) 160 | 161 | def check_max(self, data, path): 162 | if "max" not in self.rule: 163 | return 164 | if type(data) not in (dict, list, str): 165 | return 166 | if len(data) > self.rule["max"]: 167 | raise RuleFailed( 168 | path, f'size {len(data)!r} is larger than maximum size {self.rule["max"]}' 169 | ) 170 | 171 | REGISTRY = {} 172 | 173 | def __init_subclass__(cls, **kwargs): 174 | if cls not in Matcher.REGISTRY: 175 | Matcher.REGISTRY[cls.type] = cls 176 | super().__init_subclass__(**kwargs) 177 | 178 | @classmethod 179 | def get_matcher(cls, path, rule): 180 | if "matchers" in rule: 181 | # v3 matchingRules always have a matchers array, even if there's a single rule 182 | return MultipleMatchers(path, **rule) 183 | if "regex" in rule: 184 | # there's a weirdness in the spec here: it promotes use of regex without a match type :( 185 | type_name = "regex" 186 | else: 187 | type_name = rule.get("match", "type") 188 | if type_name not in cls.REGISTRY: 189 | log.warning(f'invalid match type "{type_name}" in rule at path {path}') 190 | type_name = "invalid" 191 | return cls.REGISTRY[type_name](path, rule) 192 | 193 | 194 | class InvalidMatcher(Matcher): 195 | type = "invalid" 196 | 197 | def apply(self, data, spec, path): 198 | pass 199 | 200 | 201 | class MatchType(Matcher): 202 | type = "type" 203 | 204 | def apply(self, data, spec, path): 205 | log.debug(f"match type {data!r} {spec!r} {path!r}") 206 | if type(spec) in (int, float): 207 | if type(data) not in (int, float): 208 | raise RuleFailed( 209 | path, f"not correct type ({nice_type(data)} is not {nice_type(spec)})" 210 | ) 211 | elif fold_type(spec) != fold_type(data): 212 | raise RuleFailed(path, f"not correct type ({nice_type(data)} is not {nice_type(spec)})") 213 | self.check_min(data, path) 214 | self.check_max(data, path) 215 | 216 | 217 | class MatchRegex(Matcher): 218 | type = "regex" 219 | 220 | def apply(self, data, spec, path): 221 | # we have to cast data to str because Java treats all JSON values as strings and thus is happy to 222 | # specify a regex matcher for an integer (!!) 223 | log.debug(f'match regex {data!r} {spec!r} {path!r}: {self.rule["regex"]}') 224 | if re.fullmatch(self.rule["regex"], str(data)) is None: 225 | raise RuleFailed(path, f'value {data!r} does not match regex {self.rule["regex"]}') 226 | 227 | 228 | class MatchInteger(Matcher): 229 | type = "integer" 230 | 231 | def apply(self, data, spec, path): 232 | log.debug(f"match integer {data!r} {spec!r} {path!r}") 233 | if type(data) != int: 234 | raise RuleFailed(path, f"not correct type ({nice_type(data)} is not integer)") 235 | self.check_min(data, path) 236 | self.check_max(data, path) 237 | 238 | 239 | class MatchDecimal(Matcher): 240 | type = "decimal" 241 | 242 | def apply(self, data, spec, path): 243 | log.debug(f"match decimal {data!r} {spec!r} {path!r}") 244 | if type(data) != float: 245 | raise RuleFailed(path, f"not correct type ({nice_type(data)} is not decimal)") 246 | self.check_min(data, path) 247 | self.check_max(data, path) 248 | 249 | 250 | class MatchNumber(Matcher): 251 | type = "number" 252 | 253 | def apply(self, data, spec, path): 254 | log.debug(f"match number {data!r} {spec!r} {path!r}") 255 | if type(data) not in (int, float): 256 | raise RuleFailed(path, f"not correct type ({nice_type(data)} is not number)") 257 | self.check_min(data, path) 258 | self.check_max(data, path) 259 | 260 | 261 | class MatchEquality(Matcher): 262 | type = "equality" 263 | 264 | def apply(self, data, spec, path): 265 | log.debug(f"match equality {data!r} {spec!r} {path!r}") 266 | if data != spec: 267 | raise RuleFailed(path, f"value {data!r} does not equal expected {spec!r}") 268 | 269 | 270 | class MatchInclude(Matcher): 271 | type = "include" 272 | 273 | def apply(self, data, spec, path): 274 | log.debug(f"match include {data!r} {spec!r} {path!r}") 275 | if self.rule["value"] not in data: 276 | raise RuleFailed( 277 | path, f'value {data!r} does not contain expected value {self.rule["value"]!r}' 278 | ) 279 | 280 | 281 | class MatchNull(Matcher): 282 | type = "null" 283 | 284 | def apply(self, data, spec, path): 285 | log.debug(f"match null {data!r} {spec!r} {path!r}") 286 | if data is not None: 287 | raise RuleFailed(path, f"value {data!r} is not null") 288 | 289 | 290 | class MultipleMatchers(Matcher): 291 | type = "" 292 | 293 | def __init__(self, path, matchers=None, combine="AND"): 294 | super().__init__(path, matchers) 295 | self.matchers = [Matcher.get_matcher(path, rule) for rule in matchers] 296 | self.combine = combine 297 | 298 | def apply(self, data, spec, path): 299 | log.debug(f"MultipleMatchers.__call__ {data!r} {spec!r} {path!r}") 300 | for matcher in self.matchers: 301 | log.debug(f"... matching {matcher}") 302 | try: 303 | matcher.apply(data, spec, path) 304 | except RuleFailed: 305 | if self.combine == "AND": 306 | raise 307 | else: 308 | if self.combine == "OR": 309 | return 310 | 311 | 312 | def rule_matchers_v2(rules): 313 | """Get spec v2 rule matchers for the rules sets in a pact's ruleMatchers (passed in as "rules"). 314 | 315 | v2 rules are specified in a single dictionary with the jsonpath $.
[.additional.jsonpath]: 316 | 317 | "matchingRules": { 318 | "$.query.customer_number": { 319 | "regex": "\\d+" 320 | }, 321 | "$.body[0][*].email": { 322 | "match": "type" 323 | }, 324 | "$.path": { 325 | "regex": "/user/\\w+/" 326 | } 327 | } 328 | 329 | Returns a dict with lists of Matcher subclass instances (e.g. MatchType) for each of path, query, header and body. 330 | """ 331 | matchers = defaultdict(list) 332 | for path, spec in rules.items(): 333 | split = list(split_path(path)) 334 | section = split[1] 335 | if section == "query": 336 | # query rules need to be fudged so they match the elements of the *array* if the path 337 | # doesn't already reference the array - so $.query.customer_number will become 338 | # $.query.customer_number[*] but $.query.customer_number[1] will be untouched 339 | if split[-1][0] not in "*0123456789": 340 | path += "[*]" 341 | matchers[section].append(Matcher.get_matcher(path, spec)) 342 | return matchers 343 | 344 | 345 | def rule_matchers_v3(rules): 346 | """Get spec v3 rule matchers for the rules sets in a pact's ruleMatchers (passed in as "rules"). 347 | 348 | v3 rules are specified in sections with a sub-dict for each of path, query, header and body: 349 | 350 | "matchingRules": { 351 | "path": { 352 | "matchers": [ 353 | { "match": "regex", "regex": "\\w+" } 354 | ] 355 | }, 356 | "query": { 357 | "Q1": { 358 | "matchers": [ 359 | { "match": "regex", "regex": "\\w+" } 360 | ] 361 | } 362 | }, 363 | "header": { 364 | "Accept": { 365 | "matchers": [ 366 | { "match" : "regex", "regex" : "\\w+" } 367 | ] 368 | } 369 | }, 370 | "body": { 371 | "$.animals": { 372 | "matchers": [{"min": 1, "match": "type"}] 373 | } 374 | } 375 | } 376 | 377 | Returns a dict with lists of Matcher subclass instances (e.g. MatchType) for each of path, query, header and body. 378 | """ 379 | matchers = {} 380 | if "path" in rules: 381 | # "path" rules are a bit different - there's no jsonpath as there's only a single value to compare, so we 382 | # hard-code the path to '' which always matches when looking for weighted path matches 383 | matchers["path"] = [MultipleMatchers("", **rules["path"])] 384 | if "query" in rules: 385 | # "query" rules are a bit different too - matchingRules are a flat single-level dictionary of keys which map to 386 | # array elements, but the data they match is keys mapping to an array, so alter the path such that the rule 387 | # maps to that array: "Q1" becomes "Q1[*]" 388 | matchers["query"] = [ 389 | Matcher.get_matcher(path + "[*]", rule) for path, rule in rules["query"].items() 390 | ] 391 | for section in ["header", "body"]: 392 | if section in rules: 393 | matchers[section] = [ 394 | Matcher.get_matcher(path, rule) for path, rule in rules[section].items() 395 | ] 396 | return matchers 397 | 398 | 399 | def nice_type(obj): 400 | """Turn our Python type name into a JSON type name. 401 | """ 402 | t = fold_type(obj) 403 | return { 404 | str: "string", 405 | int: "number", 406 | float: "number", 407 | type(None): "null", 408 | list: "array", 409 | dict: "object", 410 | }.get(t, str(t)) 411 | -------------------------------------------------------------------------------- /pactman/mock/matchers.py: -------------------------------------------------------------------------------- 1 | """Classes for defining request and response data that is variable.""" 2 | 3 | 4 | class Matcher(object): 5 | """Base class for defining complex contract expectations.""" 6 | 7 | def ruby_protocol(self): # pragma: no cover 8 | """ 9 | Serialise this Matcher for the Ruby mocking server. 10 | 11 | :rtype: any 12 | """ 13 | raise NotImplementedError 14 | 15 | def generate_matching_rule_v3(self): # pragma: no cover 16 | raise NotImplementedError 17 | 18 | 19 | class EachLike(Matcher): 20 | """ 21 | Expect the data to be a list of similar objects. 22 | 23 | Example: 24 | 25 | >>> from pactman import Consumer, Provider 26 | >>> pact = Consumer('consumer').has_pact_with(Provider('provider')) 27 | >>> (pact.given('there are three comments') 28 | ... .upon_receiving('a request for the most recent 2 comments') 29 | ... .with_request('get', '/comment', query={'limit': 2}) 30 | ... .will_respond_with(200, body={ 31 | ... 'comments': EachLike( 32 | ... {'name': Like('bob'), 'text': Like('Hello!')}, 33 | ... minimum=2) 34 | ... })) 35 | 36 | Would expect the response to be a JSON object, with a comments list. In 37 | that list should be at least 2 items, and each item should be a `dict` 38 | with the keys `name` and `text`, 39 | """ 40 | 41 | def __init__(self, matcher, minimum=1): 42 | """ 43 | Create a new EachLike. 44 | 45 | :param matcher: The expected value that each item in a list should 46 | look like, this can be other matchers. 47 | :type matcher: None, list, dict, int, float, str, unicode, Matcher 48 | :param minimum: The minimum number of items expected. 49 | Must be greater than or equal to 1. 50 | :type minimum: int 51 | """ 52 | self.matcher = matcher 53 | if minimum < 1: 54 | raise AssertionError("Minimum must be greater than or equal to 1") 55 | self.minimum = minimum 56 | 57 | def ruby_protocol(self): 58 | """ 59 | Serialise this EachLike for the Ruby mocking server. 60 | 61 | :return: A dict containing the information about the contents of the 62 | list and the provided minimum number of items for that list. 63 | :rtype: dict 64 | """ 65 | return { 66 | "json_class": "Pact::ArrayLike", 67 | "contents": generate_ruby_protocol(self.matcher), 68 | "min": self.minimum, 69 | } 70 | 71 | def generate_matching_rule_v3(self): 72 | return {"matchers": [{"match": "type", "min": self.minimum}]} 73 | 74 | 75 | class Like(Matcher): 76 | """ 77 | Expect the type of the value to be the same as matcher. 78 | 79 | Example: 80 | 81 | >>> from pactman import Consumer, Provider 82 | >>> pact = Consumer('consumer').has_pact_with(Provider('provider')) 83 | >>> (pact 84 | ... .given('there is a random number generator') 85 | ... .upon_receiving('a request for a random number') 86 | ... .with_request('get', '/generate-number') 87 | ... .will_respond_with(200, body={ 88 | ... 'number': Like(1111222233334444) 89 | ... })) 90 | 91 | Would expect the response body to be a JSON object, containing the key 92 | `number`, which would contain an integer. When the consumer runs this 93 | contract, the value `1111222233334444` will be returned by the mock 94 | service, instead of a randomly generated value. 95 | """ 96 | 97 | def __init__(self, matcher): 98 | """ 99 | Create a new Like. 100 | 101 | :param matcher: The object that should be expected. The mock 102 | will return this value. When verified against the provider, the 103 | type of this value will be asserted, while the value will be 104 | ignored. 105 | :type matcher: None, list, dict, int, float, str, Matcher 106 | """ 107 | valid_types = (type(None), list, dict, int, float, str, Matcher) 108 | 109 | assert isinstance( 110 | matcher, valid_types 111 | ), f"matcher must be one of '{valid_types}', got '{type(matcher)}'" 112 | 113 | self.matcher = matcher 114 | 115 | def ruby_protocol(self): 116 | """ 117 | Serialise this Like for the Ruby mocking server. 118 | 119 | :return: A dict containing the information about what the contents of 120 | the request/response should be. 121 | :rtype: dict 122 | """ 123 | return { 124 | "json_class": "Pact::SomethingLike", 125 | "contents": generate_ruby_protocol(self.matcher), 126 | } 127 | 128 | def generate_matching_rule_v3(self): 129 | return {"matchers": [{"match": "type"}]} 130 | 131 | 132 | # Remove SomethingLike in major version 1.0.0 133 | SomethingLike = Like 134 | 135 | 136 | class Term(Matcher): 137 | """ 138 | Expect the response to match a specified regular expression. 139 | 140 | Example: 141 | 142 | >>> from pactman import Consumer, Provider 143 | >>> pact = Consumer('consumer').has_pact_with(Provider('provider')) 144 | >>> (pact.given('the current user is logged in as `tester`') 145 | ... .upon_receiving('a request for the user profile') 146 | ... .with_request('get', '/profile') 147 | ... .will_respond_with(200, body={ 148 | ... 'name': 'tester', 149 | ... 'theme': Term('light|dark|legacy', 'dark') 150 | ... })) 151 | 152 | Would expect the response body to be a JSON object, containing the key 153 | `name`, which will contain the value `tester`, and `theme` which must be 154 | one of the values: light, dark, or legacy. When the consumer runs this 155 | contract, the value `dark` will be returned by the mock. 156 | """ 157 | 158 | def __init__(self, matcher, generate): 159 | """ 160 | Create a new Term. 161 | 162 | :param matcher: A regular expression to find. 163 | :type matcher: basestring 164 | :param generate: A value to be returned by the mock when 165 | generating the response to the consumer. 166 | :type generate: basestring 167 | """ 168 | self.matcher = matcher 169 | self.generate = generate 170 | 171 | def ruby_protocol(self): 172 | """ 173 | Serialise this Term for the Ruby mocking server. 174 | 175 | :return: A dict containing the information about what the contents of 176 | the request/response should be, and what should match for the requests. 177 | :rtype: dict 178 | """ 179 | return { 180 | "json_class": "Pact::Term", 181 | "data": { 182 | "generate": self.generate, 183 | "matcher": {"json_class": "Regexp", "o": 0, "s": self.matcher}, 184 | }, 185 | } 186 | 187 | def generate_matching_rule_v3(self): 188 | return {"matchers": [{"match": "regex", "regex": self.matcher}]} 189 | 190 | 191 | class Equals(Matcher): 192 | """ 193 | Expect the value to be the same as matcher. 194 | 195 | Example: 196 | 197 | >>> from pactman import Consumer, Provider 198 | >>> pact = Consumer('consumer').has_pact_with(Provider('provider')) 199 | >>> (pact 200 | ... .given('there is a random number generator') 201 | ... .upon_receiving('a request for a random number') 202 | ... .with_request('get', '/generate-number') 203 | ... .will_respond_with(200, body={ 204 | ... 'number': Equals(1111222233334444) 205 | ... })) 206 | 207 | Would expect the response body to be a JSON object, containing the key 208 | `number`, which would contain the value `1111222233334444`. 209 | When the consumer runs this contract, the value `1111222233334444` 210 | will be returned by the mock, instead of a randomly generated value. 211 | """ 212 | 213 | class NotAllowed(TypeError): 214 | pass 215 | 216 | def __init__(self, matcher): 217 | """ 218 | Create a new Equals. 219 | 220 | :param matcher: The object that should be expected. The mock 221 | will return this value. When verified against the provider, the 222 | value will be asserted. 223 | :type matcher: None, list, dict, int, float, str 224 | """ 225 | valid_types = (type(None), list, dict, int, float, str) 226 | assert isinstance( 227 | matcher, valid_types 228 | ), f"matcher must be one of '{valid_types}', got '{type(matcher)}'" 229 | 230 | self.matcher = matcher 231 | 232 | def generate_matching_rule_v3(self): 233 | return {"matchers": [{"match": "equality"}]} 234 | 235 | 236 | class Includes(Matcher): 237 | """ 238 | Expect the string value to contain the matcher. 239 | 240 | Example: 241 | 242 | >>> from pactman import Consumer, Provider 243 | >>> pact = Consumer('consumer').has_pact_with(Provider('provider')) 244 | >>> (pact 245 | ... .given('there is a random number generator') 246 | ... .upon_receiving('a request for a random number') 247 | ... .with_request('get', '/generate-number') 248 | ... .will_respond_with(200, body={ 249 | ... 'content': Includes('spam', 'Some example spamming content') 250 | ... })) 251 | 252 | Would expect the response body to be a JSON object, containing the key 253 | `content`, which be a string containing `'spam'`. 254 | When the consumer runs this contract, the value `'Some example spamming content'` 255 | will be returned by the mock. 256 | """ 257 | 258 | class NotAllowed(TypeError): 259 | pass 260 | 261 | def __init__(self, matcher, generate): 262 | """ 263 | Create a new Includes. 264 | 265 | :param matcher: The substring that should be expected. When verified against the 266 | provider, the value will be asserted. 267 | :type matcher: string 268 | :param generate: The mock will return this value. 269 | :type generate: string 270 | """ 271 | assert isinstance(matcher, str), f"matcher must be a string, got '{type(matcher)}'" 272 | 273 | self.matcher = matcher 274 | self.generate = generate 275 | 276 | def generate_matching_rule_v3(self): 277 | return {"matchers": [{"match": "include", "value": self.matcher}]} 278 | 279 | 280 | def generate_ruby_protocol(term): 281 | """ 282 | Parse the provided term into the JSON for the Ruby mock server. 283 | 284 | :param term: The term to be parsed. 285 | :type term: None, list, dict, int, float, str, unicode, Matcher 286 | :return: The JSON representation for this term. 287 | :rtype: dict, list, str 288 | """ 289 | if term is None: 290 | return term 291 | elif isinstance(term, (str, int, float)): 292 | return term 293 | elif isinstance(term, dict): 294 | return {k: generate_ruby_protocol(v) for k, v in term.items()} 295 | elif isinstance(term, list): 296 | return [generate_ruby_protocol(t) for i, t in enumerate(term)] 297 | elif issubclass(term.__class__, (Matcher,)): 298 | return term.ruby_protocol() 299 | else: 300 | raise ValueError("Unknown type: %s" % type(term)) 301 | 302 | 303 | # For backwards compatiblity with test code that uses pact-python to declare pacts, 304 | # allow the various classes from that package to also be used to define rules 305 | try: 306 | import pact as pact_python 307 | 308 | LIKE_CLASSES = (Like, pact_python.Like) 309 | EACHLIKE_CLASSES = (EachLike, pact_python.EachLike) 310 | TERM_CLASSES = (Term, pact_python.Term) 311 | except ImportError: 312 | pact_python = None 313 | LIKE_CLASSES = (Like,) 314 | EACHLIKE_CLASSES = (EachLike,) 315 | TERM_CLASSES = (Term,) 316 | 317 | 318 | # this function is long and complex (C901) because it has to handle the pact-python 319 | # types :-( 320 | def get_generated_values(input): # noqa: C901 321 | """ 322 | Resolve (nested) Matchers to their generated values for assertion. 323 | 324 | :param input: The input to be resolved to its generated values. 325 | :type input: None, list, dict, int, float, bool, str, unicode, Matcher 326 | :return: The input resolved to its generated value(s) 327 | :rtype: None, list, dict, int, float, bool, str, unicode, Matcher 328 | """ 329 | if input is None: 330 | return input 331 | if isinstance(input, (str, int, float, bool)): 332 | return input 333 | if isinstance(input, dict): 334 | return {k: get_generated_values(v) for k, v in input.items()} 335 | if isinstance(input, list): 336 | return [get_generated_values(t) for i, t in enumerate(input)] 337 | elif isinstance(input, LIKE_CLASSES): 338 | return get_generated_values(input.matcher) 339 | elif isinstance(input, EACHLIKE_CLASSES): 340 | return [get_generated_values(input.matcher)] * input.minimum 341 | elif isinstance(input, Term): 342 | return input.generate 343 | elif pact_python is not None and isinstance(input, pact_python.Term): 344 | return input._generate 345 | elif isinstance(input, Equals): 346 | return get_generated_values(input.matcher) 347 | elif isinstance(input, Includes): 348 | return input.generate 349 | else: 350 | raise ValueError("Unknown type: %s" % type(input)) 351 | 352 | 353 | # this function is long and complex (C901) because it has to handle the pact-python 354 | # types :-( 355 | def get_matching_rules_v2(input, path): # noqa: C901 356 | """Turn a matcher into the matchingRules structure for pact JSON. 357 | 358 | This is done recursively, adding new paths as new matching rules 359 | are encountered. 360 | """ 361 | if input is None or isinstance(input, (str, int, float, bool)): 362 | return {} 363 | if isinstance(input, dict): 364 | rules = {} 365 | for k, v in input.items(): 366 | sub_path = path + "." + k 367 | rules.update(get_matching_rules_v2(v, sub_path)) 368 | return rules 369 | if isinstance(input, list): 370 | rules = {} 371 | for i, v in enumerate(input): 372 | sub_path = path + "[*]" 373 | rules.update(get_matching_rules_v2(v, sub_path)) 374 | return rules 375 | if isinstance(input, LIKE_CLASSES): 376 | rules = {path: {"match": "type"}} 377 | rules.update(get_matching_rules_v2(input.matcher, path)) 378 | return rules 379 | if isinstance(input, EACHLIKE_CLASSES): 380 | rules = {path: {"match": "type", "min": input.minimum}} 381 | rules.update(get_matching_rules_v2(input.matcher, path)) 382 | return rules 383 | if isinstance(input, TERM_CLASSES): 384 | return {path: {"regex": input.matcher}} 385 | if isinstance(input, Equals): 386 | raise Equals.NotAllowed("Equals() cannot be used in pact version 2") 387 | if isinstance(input, Includes): 388 | raise Includes.NotAllowed("Includes() cannot be used in pact version 2") 389 | 390 | raise ValueError("Unknown type: %s" % type(input)) 391 | 392 | 393 | class MatchingRuleV3(dict): 394 | def generate(self, input, path): 395 | if self.handle_basic_types(input, path): 396 | return 397 | if self.handle_pactman_types(input, path): 398 | return 399 | if self.handle_pact_python_types(input, path): 400 | return 401 | raise ValueError("Unknown type: %s" % type(input)) 402 | 403 | def handle_basic_types(self, input, path): 404 | if input is None or isinstance(input, (str, int, float, bool)): 405 | return True 406 | if isinstance(input, dict): 407 | for k, v in input.items(): 408 | self.generate(v, path + "." + k) 409 | return True 410 | if isinstance(input, list): 411 | for v in input: 412 | self.generate(v, path + "[*]") 413 | return True 414 | return False 415 | 416 | def handle_pactman_types(self, input, path): 417 | if not hasattr(input, "generate_matching_rule_v3"): 418 | return False 419 | self[path] = input.generate_matching_rule_v3() 420 | if isinstance(input.matcher, (list, dict)): 421 | self.handle_basic_types(input.matcher, path) 422 | return True 423 | 424 | def handle_pact_python_types(self, input, path): 425 | if pact_python is None: 426 | return False 427 | 428 | if isinstance(input, pact_python.Like): 429 | self[path] = {"matchers": [{"match": "type"}]} 430 | self.generate(input.matcher, path) 431 | elif isinstance(input, pact_python.EachLike): 432 | self[path] = {"matchers": [{"match": "type", "min": input.minimum}]} 433 | self.generate(input.matcher, path) 434 | elif isinstance(input, pact_python.Term): 435 | self[path] = {"matchers": [{"match": "regex", "regex": input.matcher}]} 436 | else: 437 | return False 438 | 439 | return True 440 | 441 | 442 | def get_matching_rules_v3(input, path): 443 | """Turn a matcher into the matchingRules structure for pact JSON. 444 | 445 | This is done recursively, adding new paths as new matching rules 446 | are encountered. 447 | """ 448 | rules = MatchingRuleV3() 449 | rules.generate(input, path) 450 | return rules 451 | --------------------------------------------------------------------------------