From 75edcbb4eb15cca859969a00ec2d76ba5982e655 Mon Sep 17 00:00:00 2001 From: Amit Singh Date: Tue, 27 Aug 2024 13:33:04 +0530 Subject: [PATCH] feat(examples): add post and delete Extend the existing examples to showcase both HTTP POST and DELETE requests, and how they are handled in Pact. Specifically showcasing how the verifying can ensure that any side-effects have taken place. Co-authored-by: Amit Singh Signed-off-by: JP-Ellis --- .pre-commit-config.yaml | 2 +- examples/.ruff.toml | 1 + examples/src/consumer.py | 43 +++++++- examples/src/fastapi.py | 56 ++++++++++- examples/src/flask.py | 32 +++++- examples/tests/test_00_consumer.py | 82 ++++++++++++++- examples/tests/test_01_provider_fastapi.py | 62 +++++++++++- examples/tests/test_01_provider_flask.py | 57 ++++++++++- examples/tests/v3/test_00_consumer.py | 64 ++++++++++++ examples/tests/v3/test_01_fastapi_provider.py | 99 +++++++++++++++++++ 10 files changed, 483 insertions(+), 15 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b961ce17f4..def7e72229 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -81,5 +81,5 @@ repos: entry: hatch run mypy language: system types: [python] - exclude: ^(src/pact|tests)/(?!v3/).*\.py$ + exclude: ^(src/pact|tests|examples/tests)/(?!v3/).*\.py$ stages: [pre-push] diff --git a/examples/.ruff.toml b/examples/.ruff.toml index 7e5b439eb5..5222985a35 100644 --- a/examples/.ruff.toml +++ b/examples/.ruff.toml @@ -5,6 +5,7 @@ ignore = [ "S101", # Forbid assert statements "D103", # Require docstring in public function "D104", # Require docstring in public package + "PLR2004" # Forbid Magic Numbers ] [lint.per-file-ignores] diff --git a/examples/src/consumer.py b/examples/src/consumer.py index fa260d214d..b2815d2972 100644 --- a/examples/src/consumer.py +++ b/examples/src/consumer.py @@ -21,7 +21,7 @@ from dataclasses import dataclass from datetime import datetime -from typing import Any, Dict +from typing import Any, Dict, Tuple import requests @@ -102,3 +102,44 @@ def get_user(self, user_id: int) -> User: name=data["name"], created_on=datetime.fromisoformat(data["created_on"]), ) + + def create_user( + self, user: Dict[str, Any], header: Dict[str, str] + ) -> Tuple[int, User]: + """ + Create a new user on the server. + + Args: + user: The user data to create. + header: The headers to send with the request. + + Returns: + The user data including the ID assigned by the server; Error if user exists. + """ + uri = f"{self.base_uri}/users/" + response = requests.post(uri, headers=header, json=user, timeout=5) + response.raise_for_status() + data: Dict[str, Any] = response.json() + return ( + response.status_code, + User( + id=data["id"], + name=data["name"], + created_on=datetime.fromisoformat(data["created_on"]), + ), + ) + + def delete_user(self, user_id: int) -> int: + """ + Delete a user by ID from the server. + + Args: + user_id: The ID of the user to delete. + + Returns: + The response status code. + """ + uri = f"{self.base_uri}/users/{user_id}" + response = requests.delete(uri, timeout=5) + response.raise_for_status() + return response.status_code diff --git a/examples/src/fastapi.py b/examples/src/fastapi.py index b25d3c1a8e..e522b08f85 100644 --- a/examples/src/fastapi.py +++ b/examples/src/fastapi.py @@ -19,12 +19,30 @@ from __future__ import annotations +import logging from typing import Any, Dict -from fastapi import FastAPI +from pydantic import BaseModel + +from fastapi import FastAPI, HTTPException from fastapi.responses import JSONResponse app = FastAPI() +logger = logging.getLogger(__name__) + + +class User(BaseModel): + """ + User data class. + + This class is used to represent a user in the application. It is used to + validate the incoming data and to dump the data to a dictionary. + """ + + id: int | None = None + name: str + email: str + """ As this is a simple example, we'll use a simple dict to represent a database. @@ -52,3 +70,39 @@ async def get_user_by_id(uid: int) -> JSONResponse: if not user: return JSONResponse(status_code=404, content={"error": "User not found"}) return JSONResponse(status_code=200, content=user) + + +@app.post("/users/") +async def create_new_user(user: User) -> JSONResponse: + """ + Create a new user . + + Args: + user: The user data to create + + Returns: + The status code 200 and user data if successfully created, HTTP 404 if not + """ + if user.id is not None: + raise HTTPException(status_code=400, detail="ID should not be provided.") + new_uid = len(FAKE_DB) + FAKE_DB[new_uid] = user.model_dump() + + return JSONResponse(status_code=200, content=FAKE_DB[new_uid]) + + +@app.delete("/users/{user_id}", status_code=204) +async def delete_user(user_id: int): # noqa: ANN201 + """ + Delete an existing user . + + Args: + user_id: The ID of the user to delete + + Returns: + The status code 204, HTTP 404 if not + """ + if user_id not in FAKE_DB: + raise HTTPException(status_code=404, detail="User not found") + + del FAKE_DB[user_id] diff --git a/examples/src/flask.py b/examples/src/flask.py index 31d614042a..7fa32cdeae 100644 --- a/examples/src/flask.py +++ b/examples/src/flask.py @@ -19,12 +19,14 @@ from __future__ import annotations -from typing import Any, Dict, Union +import logging +from typing import Any, Dict, Tuple, Union -from flask import Flask +from flask import Flask, Response, abort, jsonify, request -app = Flask(__name__) +logger = logging.getLogger(__name__) +app = Flask(__name__) """ As this is a simple example, we'll use a simple dict to represent a database. This would be replaced with a real database in a real application. @@ -47,7 +49,29 @@ def get_user_by_id(uid: int) -> Union[Dict[str, Any], tuple[Dict[str, Any], int] Returns: The user data if found, HTTP 404 if not """ - user = FAKE_DB.get(uid) + user = FAKE_DB.get(int(uid)) if not user: return {"error": "User not found"}, 404 return user + + +@app.route("/users/", methods=["POST"]) +def create_user() -> Tuple[Response, int]: + if request.json is None: + abort(400, description="Invalid JSON data") + + data: Dict[str, Any] = request.json + new_uid: int = len(FAKE_DB) + if new_uid in FAKE_DB: + abort(400, description="User already exists") + + FAKE_DB[new_uid] = {"id": new_uid, "name": data["name"], "email": data["email"]} + return jsonify(FAKE_DB[new_uid]), 200 + + +@app.route("/users/", methods=["DELETE"]) +def delete_user(user_id: int) -> Tuple[str, int]: + if user_id not in FAKE_DB: + abort(404, description="User not found") + del FAKE_DB[user_id] + return "", 204 # No Content status code diff --git a/examples/tests/test_00_consumer.py b/examples/tests/test_00_consumer.py index 9076785081..4c5a86dfdc 100644 --- a/examples/tests/test_00_consumer.py +++ b/examples/tests/test_00_consumer.py @@ -16,6 +16,7 @@ from __future__ import annotations import logging +from datetime import UTC, datetime, timedelta from http import HTTPStatus from typing import TYPE_CHECKING, Any, Dict, Generator @@ -24,18 +25,28 @@ from yarl import URL from examples.src.consumer import User, UserConsumer -from pact import Consumer, Format, Like, Provider +from pact import Consumer, Format, Like, Provider # type: ignore[attr-defined] if TYPE_CHECKING: from pathlib import Path - from pact.pact import Pact + from pact.pact import Pact # type: ignore[import-untyped] -log = logging.getLogger(__name__) +logger = logging.getLogger(__name__) MOCK_URL = URL("http://localhost:8080") +@pytest.fixture(scope="session", autouse=True) +def _setup_pact_logging() -> None: + """ + Set up logging for the pact package. + """ + from pact.v3 import ffi + + ffi.log_to_stderr("INFO") + + @pytest.fixture def user_consumer() -> UserConsumer: """ @@ -78,7 +89,7 @@ def pact(broker: URL, pact_dir: Path) -> Generator[Pact, Any, None]: pact = consumer.has_pact_with( Provider("UserProvider"), pact_dir=pact_dir, - publish_to_broker=True, + publish_to_broker=False, # Mock service configuration host_name=MOCK_URL.host, port=MOCK_URL.port, @@ -142,3 +153,66 @@ def test_get_unknown_user(pact: Pact, user_consumer: UserConsumer) -> None: assert excinfo.value.response is not None assert excinfo.value.response.status_code == HTTPStatus.NOT_FOUND pact.verify() + + +def test_post_request_to_create_user(pact: Pact, user_consumer: UserConsumer) -> None: + """ + Test the POST request for creating a new user. + + This test defines the expected interaction for a POST request to create + a new user. It sets up the expected request and response from the provider, + including the request body and headers, and verifies that the response + status code is 200 and the response body matches the expected user data. + """ + expected: Dict[str, Any] = { + "id": 124, + "name": "Jane Doe", + "email": "jane@example.com", + "created_on": Format().iso_8601_datetime(), + } + header = {"Content-Type": "application/json"} + payload: dict[str, str] = { + "name": "Jane Doe", + "email": "jane@example.com", + "created_on": (datetime.now(tz=UTC) - timedelta(days=318)).isoformat(), + } + expected_response_code: int = 200 + + ( + pact.given("create user 124") + .upon_receiving("A request to create a new user") + .with_request(method="POST", path="/users/", headers=header, body=payload) + .will_respond_with(status=200, headers=header, body=Like(expected)) + ) + + with pact: + response = user_consumer.create_user(user=payload, header=header) + assert response[0] == expected_response_code + assert response[1].id == 124 + assert response[1].name == "Jane Doe" + + pact.verify() + + +def test_delete_request_to_delete_user(pact: Pact, user_consumer: UserConsumer) -> None: + """ + Test the DELETE request for deleting a user. + + This test defines the expected interaction for a DELETE request to delete + a user. It sets up the expected request and response from the provider, + including the request body and headers, and verifies that the response + status code is 200 and the response body matches the expected user data. + """ + expected_response_code: int = 204 + ( + pact.given("delete the user 124") + .upon_receiving("a request for deleting user") + .with_request(method="DELETE", path="/users/124") + .will_respond_with(204) + ) + + with pact: + response_status_code = user_consumer.delete_user(124) + assert response_status_code == expected_response_code + + pact.verify() diff --git a/examples/tests/test_01_provider_fastapi.py b/examples/tests/test_01_provider_fastapi.py index a95b5b5f8d..c9fd23cebf 100644 --- a/examples/tests/test_01_provider_fastapi.py +++ b/examples/tests/test_01_provider_fastapi.py @@ -25,6 +25,7 @@ from __future__ import annotations import time +from datetime import UTC, datetime, timedelta from multiprocessing import Process from typing import Any, Dict, Generator, Union from unittest.mock import MagicMock @@ -35,7 +36,7 @@ from yarl import URL from examples.src.fastapi import app -from pact import Verifier +from pact import Verifier # type: ignore[import-untyped] PROVIDER_URL = URL("http://localhost:8080") @@ -68,6 +69,8 @@ async def mock_pact_provider_states( mapping = { "user 123 doesn't exist": mock_user_123_doesnt_exist, "user 123 exists": mock_user_123_exists, + "create user 124": mock_post_request_to_create_user, + "delete the user 124": mock_delete_request_to_delete_user, } return {"result": mapping[state.state]()} @@ -127,13 +130,68 @@ def mock_user_123_exists() -> None: examples.src.fastapi.FAKE_DB.get.return_value = { "id": 123, "name": "Verna Hampton", - "created_on": "2016-12-15T20:16:01", + "created_on": datetime.now(tz=UTC).isoformat(), "ip_address": "10.1.2.3", "hobbies": ["hiking", "swimming"], "admin": False, } +def mock_post_request_to_create_user() -> None: + """ + Mock the database for the post request to create a user. + """ + import examples.src.fastapi + + examples.src.fastapi.FAKE_DB = MagicMock() + examples.src.fastapi.FAKE_DB.__len__.return_value = 124 + examples.src.fastapi.FAKE_DB.__setitem__.return_value = None + examples.src.fastapi.FAKE_DB.__getitem__.return_value = { + "id": 124, + "created_on": (datetime.now(tz=UTC) - timedelta(days=152)).isoformat(), + "email": "jane@example.com", + "name": "Jane Doe", + "ip_address": "10.1.2.3", + "hobbies": ["hiking", "swimming"], + "admin": False, + } + + +def mock_delete_request_to_delete_user() -> None: + """ + Mock the database for the delete request to delete a user. + """ + import examples.src.fastapi + + db_values = { + 123: { + "id": 123, + "name": "Verna Hampton", + "email": "verna@example.com", + "created_on": (datetime.now(tz=UTC) - timedelta(days=152)).isoformat(), + "ip_address": "10.1.2.3", + "hobbies": ["hiking", "swimming"], + "admin": False, + }, + 124: { + "id": 124, + "name": "Jane Doe", + "email": "jane@example.com", + "created_on": (datetime.now(tz=UTC) - timedelta(days=152)).isoformat(), + "ip_address": "10.1.2.5", + "hobbies": ["running", "dancing"], + "admin": False, + }, + } + + examples.src.fastapi.FAKE_DB = MagicMock() + examples.src.fastapi.FAKE_DB.__delitem__.side_effect = ( + lambda key: db_values.__delitem__(key) + ) + examples.src.fastapi.FAKE_DB.__getitem__.side_effect = lambda key: db_values[key] + examples.src.fastapi.FAKE_DB.__contains__.side_effect = lambda key: key in db_values + + def test_against_broker(broker: URL, verifier: Verifier) -> None: """ Test the provider against the broker. diff --git a/examples/tests/test_01_provider_flask.py b/examples/tests/test_01_provider_flask.py index 58cb024585..2ebd0c80a9 100644 --- a/examples/tests/test_01_provider_flask.py +++ b/examples/tests/test_01_provider_flask.py @@ -25,6 +25,7 @@ from __future__ import annotations import time +from datetime import UTC, datetime, timedelta from multiprocessing import Process from typing import Any, Dict, Generator, Union from unittest.mock import MagicMock @@ -34,7 +35,7 @@ from examples.src.flask import app from flask import request -from pact import Verifier +from pact import Verifier # type: ignore[import-untyped] PROVIDER_URL = URL("http://localhost:8080") @@ -58,6 +59,8 @@ async def mock_pact_provider_states() -> Dict[str, Union[str, None]]: mapping = { "user 123 doesn't exist": mock_user_123_doesnt_exist, "user 123 exists": mock_user_123_exists, + "create user 124": mock_post_request_to_create_user, + "delete the user 124": mock_delete_request_to_delete_user, } return {"result": mapping[request.json["state"]]()} # type: ignore[index] @@ -115,13 +118,63 @@ def mock_user_123_exists() -> None: examples.src.flask.FAKE_DB.get.return_value = { "id": 123, "name": "Verna Hampton", - "created_on": "2016-12-15T20:16:01", + "created_on": datetime.now(tz=UTC).isoformat(), "ip_address": "10.1.2.3", "hobbies": ["hiking", "swimming"], "admin": False, } +def mock_post_request_to_create_user() -> None: + """ + Mock the database for the post request to create a user. + """ + import examples.src.flask + + examples.src.flask.FAKE_DB = MagicMock() + examples.src.flask.FAKE_DB.__len__.return_value = 124 + examples.src.flask.FAKE_DB.__setitem__.return_value = None + examples.src.flask.FAKE_DB.__getitem__.return_value = { + "id": 124, + "created_on": (datetime.now(tz=UTC) - timedelta(days=261)).isoformat(), + "email": "jane@example.com", + "name": "Jane Doe", + } + + +def mock_delete_request_to_delete_user() -> None: + """ + Mock the database for the delete request to delete a user. + """ + import examples.src.flask + + db_values: dict[int, dict[str, Any]] = { + 123: { + "id": 123, + "name": "Verna Hampton", + "created_on": (datetime.now(tz=UTC) - timedelta(days=318)).isoformat(), + "ip_address": "10.1.2.3", + "hobbies": ["hiking", "swimming"], + "admin": False, + }, + 124: { + "id": 124, + "name": "Jane Doe", + "created_on": (datetime.now(tz=UTC) - timedelta(days=318)).isoformat(), + "ip_address": "10.1.2.5", + "hobbies": ["running", "dancing"], + "admin": False, + }, + } + + examples.src.flask.FAKE_DB = MagicMock() + examples.src.flask.FAKE_DB.__delitem__.side_effect = ( + lambda key: db_values.__delitem__(key) + ) + examples.src.flask.FAKE_DB.__getitem__.side_effect = lambda key: db_values[key] + examples.src.flask.FAKE_DB.__contains__.side_effect = lambda key: key in db_values + + def test_against_broker(broker: URL, verifier: Verifier) -> None: """ Test the provider against the broker. diff --git a/examples/tests/v3/test_00_consumer.py b/examples/tests/v3/test_00_consumer.py index 634ce7ffae..c69a53c3a5 100644 --- a/examples/tests/v3/test_00_consumer.py +++ b/examples/tests/v3/test_00_consumer.py @@ -131,3 +131,67 @@ def test_get_non_existent_user(pact: Pact) -> None: response = requests.get(f"{srv.url}/users/2", timeout=5) assert response.status_code == expected_response_code + + +def test_post_request_to_create_user(pact: Pact) -> None: + """ + Test the POST request for creating a new user. + + This test defines the expected interaction for a POST request to create + a new user. It sets up the expected request and response from the provider, + including the request body and headers, and verifies that the response + status code is 200 and the response body matches the expected user data. + """ + expected: Dict[str, Any] = { + "id": 124, + "name": "Jane Doe", + "email": "jane@example.com", + } + header = {"Content-Type": "application/json"} + body = {"name": "Jane Doe", "email": "jane@example.com"} + expected_response_code: int = 200 + + ( + pact.upon_receiving("a request to create a new user") + .given("the specified user doesn't exist") + .with_request(method="POST", path="/users/") + .with_body(json.dumps(body)) + .with_header("Content-Type", "application/json") + .will_respond_with(status=200) + .with_body(content_type="application/json", body=json.dumps(expected)) + ) + + with pact.serve() as srv: + response = requests.post( + f"{srv.url}/users/", headers=header, json=body, timeout=5 + ) + + assert response.status_code == expected_response_code + assert response.json() == { + "id": 124, + "name": "Jane Doe", + "email": "jane@example.com", + } + + +def test_delete_request_to_delete_user(pact: Pact) -> None: + """ + Test the DELETE request for deleting a user. + + This test defines the expected interaction for a DELETE request to delete + a user. It sets up the expected request and response from the provider, + including the request body and headers, and verifies that the response + status code is 200 and the response body matches the expected user data. + """ + expected_response_code: int = 204 + ( + pact.upon_receiving("a request for deleting user") + .given("user is present in DB") + .with_request(method="DELETE", path="/users/124") + .will_respond_with(204) + ) + + with pact.serve() as srv: + response = requests.delete(f"{srv.url}/users/124", timeout=5) + + assert response.status_code == expected_response_code diff --git a/examples/tests/v3/test_01_fastapi_provider.py b/examples/tests/v3/test_01_fastapi_provider.py index f77cc071f5..14392ad9c8 100644 --- a/examples/tests/v3/test_01_fastapi_provider.py +++ b/examples/tests/v3/test_01_fastapi_provider.py @@ -83,10 +83,14 @@ async def mock_pact_provider_states( mapping["setup"] = { "user doesn't exists": mock_user_doesnt_exist, "user exists": mock_user_exists, + "the specified user doesn't exist": mock_post_request_to_create_user, + "user is present in DB": mock_delete_request_to_delete_user, } mapping["teardown"] = { "user doesn't exists": verify_user_doesnt_exist_mock, "user exists": verify_user_exists_mock, + "the specified user doesn't exist": verify_mock_post_request_to_create_user, + "user is present in DB": verify_mock_delete_request_to_delete_user, } mapping[action][state]() @@ -203,6 +207,56 @@ def mock_user_exists() -> None: examples.src.fastapi.FAKE_DB = mock_db +def mock_post_request_to_create_user() -> None: + """ + Mock the database for the post request to create a user. + """ + import examples.src.fastapi + + mock_db = MagicMock() + mock_db.__len__.return_value = 124 + mock_db.__setitem__.return_value = None + mock_db.__getitem__.return_value = { + "id": 124, + "created_on": "2024-09-06T05:07:06.745719+00:00", + "email": "jane@example.com", + "name": "Jane Doe", + } + examples.src.fastapi.FAKE_DB = mock_db + + +def mock_delete_request_to_delete_user() -> None: + """ + Mock the database for the delete request to delete a user. + """ + import examples.src.fastapi + + db_values = { + 123: { + "id": 123, + "name": "Verna Hampton", + "created_on": "2024-08-29T04:53:07.337793+00:00", + "ip_address": "10.1.2.3", + "hobbies": ["hiking", "swimming"], + "admin": False, + }, + 124: { + "id": 124, + "name": "Jane Doe", + "created_on": "2024-08-29T04:53:07.337793+00:00", + "ip_address": "10.1.2.5", + "hobbies": ["running", "dancing"], + "admin": False, + }, + } + + mock_db = MagicMock() + mock_db.__delitem__.side_effect = lambda key: db_values.__delitem__(key) + mock_db.__getitem__.side_effect = lambda key: db_values[key] + mock_db.__contains__.side_effect = lambda key: key in db_values + examples.src.fastapi.FAKE_DB = mock_db + + def verify_user_doesnt_exist_mock() -> None: """ Verify the mock calls for the 'user doesn't exist' state. @@ -257,3 +311,48 @@ def verify_user_exists_mock() -> None: assert kwargs == {} examples.src.fastapi.FAKE_DB.reset_mock() + + +def verify_mock_post_request_to_create_user() -> None: + import examples.src.fastapi + + if TYPE_CHECKING: + examples.src.fastapi.FAKE_DB = MagicMock() + + examples.src.fastapi.FAKE_DB.__getitem__.assert_called() + + expected_return = { + "id": 124, + "created_on": "2024-09-06T05:07:06.745719+00:00", + "email": "jane@example.com", + "name": "Jane Doe", + } + + examples.src.fastapi.FAKE_DB.__len__.assert_called() + assert ( + examples.src.fastapi.FAKE_DB.__getitem__.return_value == expected_return + ), "Unexpected return value from __getitem__()" + + args, _ = examples.src.fastapi.FAKE_DB.__getitem__.call_args + assert isinstance( + args[0], int + ), f"Expected get() to be called with an integer, but got {type(args[0])}" + assert args[0] == 124, f"Expected get(124), but got get({args[0]})" + + examples.src.fastapi.FAKE_DB.reset_mock() + + +def verify_mock_delete_request_to_delete_user() -> None: + import examples.src.fastapi + + if TYPE_CHECKING: + examples.src.fastapi.FAKE_DB = MagicMock() + + examples.src.fastapi.FAKE_DB.__delitem__.assert_called() + + args, _ = examples.src.fastapi.FAKE_DB.__delitem__.call_args + assert isinstance( + args[0], int + ), f"Expected __delitem__() to be called with an integer, but got {type(args[0])}" + + examples.src.fastapi.FAKE_DB.reset_mock()