Skip to content

Commit 816ae11

Browse files
amit828asJP-Ellis
authored andcommitted
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 <amit.828.as@gmail.com> Signed-off-by: JP-Ellis <josh@jpellis.me>
1 parent c938a13 commit 816ae11

10 files changed

+483
-15
lines changed

.pre-commit-config.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -81,5 +81,5 @@ repos:
8181
entry: hatch run mypy
8282
language: system
8383
types: [python]
84-
exclude: ^(src/pact|tests)/(?!v3/).*\.py$
84+
exclude: ^(src/pact|tests|examples/tests)/(?!v3/).*\.py$
8585
stages: [pre-push]

examples/.ruff.toml

+1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ ignore = [
55
"S101", # Forbid assert statements
66
"D103", # Require docstring in public function
77
"D104", # Require docstring in public package
8+
"PLR2004" # Forbid Magic Numbers
89
]
910

1011
[lint.per-file-ignores]

examples/src/consumer.py

+42-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222
from dataclasses import dataclass
2323
from datetime import datetime
24-
from typing import Any, Dict
24+
from typing import Any, Dict, Tuple
2525

2626
import requests
2727

@@ -102,3 +102,44 @@ def get_user(self, user_id: int) -> User:
102102
name=data["name"],
103103
created_on=datetime.fromisoformat(data["created_on"]),
104104
)
105+
106+
def create_user(
107+
self, user: Dict[str, Any], header: Dict[str, str]
108+
) -> Tuple[int, User]:
109+
"""
110+
Create a new user on the server.
111+
112+
Args:
113+
user: The user data to create.
114+
header: The headers to send with the request.
115+
116+
Returns:
117+
The user data including the ID assigned by the server; Error if user exists.
118+
"""
119+
uri = f"{self.base_uri}/users/"
120+
response = requests.post(uri, headers=header, json=user, timeout=5)
121+
response.raise_for_status()
122+
data: Dict[str, Any] = response.json()
123+
return (
124+
response.status_code,
125+
User(
126+
id=data["id"],
127+
name=data["name"],
128+
created_on=datetime.fromisoformat(data["created_on"]),
129+
),
130+
)
131+
132+
def delete_user(self, user_id: int) -> int:
133+
"""
134+
Delete a user by ID from the server.
135+
136+
Args:
137+
user_id: The ID of the user to delete.
138+
139+
Returns:
140+
The response status code.
141+
"""
142+
uri = f"{self.base_uri}/users/{user_id}"
143+
response = requests.delete(uri, timeout=5)
144+
response.raise_for_status()
145+
return response.status_code

examples/src/fastapi.py

+55-1
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,30 @@
1919

2020
from __future__ import annotations
2121

22+
import logging
2223
from typing import Any, Dict
2324

24-
from fastapi import FastAPI
25+
from pydantic import BaseModel
26+
27+
from fastapi import FastAPI, HTTPException
2528
from fastapi.responses import JSONResponse
2629

2730
app = FastAPI()
31+
logger = logging.getLogger(__name__)
32+
33+
34+
class User(BaseModel):
35+
"""
36+
User data class.
37+
38+
This class is used to represent a user in the application. It is used to
39+
validate the incoming data and to dump the data to a dictionary.
40+
"""
41+
42+
id: int | None = None
43+
name: str
44+
email: str
45+
2846

2947
"""
3048
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:
5270
if not user:
5371
return JSONResponse(status_code=404, content={"error": "User not found"})
5472
return JSONResponse(status_code=200, content=user)
73+
74+
75+
@app.post("/users/")
76+
async def create_new_user(user: User) -> JSONResponse:
77+
"""
78+
Create a new user .
79+
80+
Args:
81+
user: The user data to create
82+
83+
Returns:
84+
The status code 200 and user data if successfully created, HTTP 404 if not
85+
"""
86+
if user.id is not None:
87+
raise HTTPException(status_code=400, detail="ID should not be provided.")
88+
new_uid = len(FAKE_DB)
89+
FAKE_DB[new_uid] = user.model_dump()
90+
91+
return JSONResponse(status_code=200, content=FAKE_DB[new_uid])
92+
93+
94+
@app.delete("/users/{user_id}", status_code=204)
95+
async def delete_user(user_id: int): # noqa: ANN201
96+
"""
97+
Delete an existing user .
98+
99+
Args:
100+
user_id: The ID of the user to delete
101+
102+
Returns:
103+
The status code 204, HTTP 404 if not
104+
"""
105+
if user_id not in FAKE_DB:
106+
raise HTTPException(status_code=404, detail="User not found")
107+
108+
del FAKE_DB[user_id]

examples/src/flask.py

+28-4
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,14 @@
1919

2020
from __future__ import annotations
2121

22-
from typing import Any, Dict, Union
22+
import logging
23+
from typing import Any, Dict, Tuple, Union
2324

24-
from flask import Flask
25+
from flask import Flask, Response, abort, jsonify, request
2526

26-
app = Flask(__name__)
27+
logger = logging.getLogger(__name__)
2728

29+
app = Flask(__name__)
2830
"""
2931
As this is a simple example, we'll use a simple dict to represent a database.
3032
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]
4749
Returns:
4850
The user data if found, HTTP 404 if not
4951
"""
50-
user = FAKE_DB.get(uid)
52+
user = FAKE_DB.get(int(uid))
5153
if not user:
5254
return {"error": "User not found"}, 404
5355
return user
56+
57+
58+
@app.route("/users/", methods=["POST"])
59+
def create_user() -> Tuple[Response, int]:
60+
if request.json is None:
61+
abort(400, description="Invalid JSON data")
62+
63+
data: Dict[str, Any] = request.json
64+
new_uid: int = len(FAKE_DB)
65+
if new_uid in FAKE_DB:
66+
abort(400, description="User already exists")
67+
68+
FAKE_DB[new_uid] = {"id": new_uid, "name": data["name"], "email": data["email"]}
69+
return jsonify(FAKE_DB[new_uid]), 200
70+
71+
72+
@app.route("/users/<user_id>", methods=["DELETE"])
73+
def delete_user(user_id: int) -> Tuple[str, int]:
74+
if user_id not in FAKE_DB:
75+
abort(404, description="User not found")
76+
del FAKE_DB[user_id]
77+
return "", 204 # No Content status code

examples/tests/test_00_consumer.py

+78-4
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from __future__ import annotations
1717

1818
import logging
19+
from datetime import UTC, datetime, timedelta
1920
from http import HTTPStatus
2021
from typing import TYPE_CHECKING, Any, Dict, Generator
2122

@@ -24,18 +25,28 @@
2425
from yarl import URL
2526

2627
from examples.src.consumer import User, UserConsumer
27-
from pact import Consumer, Format, Like, Provider
28+
from pact import Consumer, Format, Like, Provider # type: ignore[attr-defined]
2829

2930
if TYPE_CHECKING:
3031
from pathlib import Path
3132

32-
from pact.pact import Pact
33+
from pact.pact import Pact # type: ignore[import-untyped]
3334

34-
log = logging.getLogger(__name__)
35+
logger = logging.getLogger(__name__)
3536

3637
MOCK_URL = URL("http://localhost:8080")
3738

3839

40+
@pytest.fixture(scope="session", autouse=True)
41+
def _setup_pact_logging() -> None:
42+
"""
43+
Set up logging for the pact package.
44+
"""
45+
from pact.v3 import ffi
46+
47+
ffi.log_to_stderr("INFO")
48+
49+
3950
@pytest.fixture
4051
def user_consumer() -> UserConsumer:
4152
"""
@@ -78,7 +89,7 @@ def pact(broker: URL, pact_dir: Path) -> Generator[Pact, Any, None]:
7889
pact = consumer.has_pact_with(
7990
Provider("UserProvider"),
8091
pact_dir=pact_dir,
81-
publish_to_broker=True,
92+
publish_to_broker=False,
8293
# Mock service configuration
8394
host_name=MOCK_URL.host,
8495
port=MOCK_URL.port,
@@ -142,3 +153,66 @@ def test_get_unknown_user(pact: Pact, user_consumer: UserConsumer) -> None:
142153
assert excinfo.value.response is not None
143154
assert excinfo.value.response.status_code == HTTPStatus.NOT_FOUND
144155
pact.verify()
156+
157+
158+
def test_post_request_to_create_user(pact: Pact, user_consumer: UserConsumer) -> None:
159+
"""
160+
Test the POST request for creating a new user.
161+
162+
This test defines the expected interaction for a POST request to create
163+
a new user. It sets up the expected request and response from the provider,
164+
including the request body and headers, and verifies that the response
165+
status code is 200 and the response body matches the expected user data.
166+
"""
167+
expected: Dict[str, Any] = {
168+
"id": 124,
169+
"name": "Jane Doe",
170+
"email": "jane@example.com",
171+
"created_on": Format().iso_8601_datetime(),
172+
}
173+
header = {"Content-Type": "application/json"}
174+
payload: dict[str, str] = {
175+
"name": "Jane Doe",
176+
"email": "jane@example.com",
177+
"created_on": (datetime.now(tz=UTC) - timedelta(days=318)).isoformat(),
178+
}
179+
expected_response_code: int = 200
180+
181+
(
182+
pact.given("create user 124")
183+
.upon_receiving("A request to create a new user")
184+
.with_request(method="POST", path="/users/", headers=header, body=payload)
185+
.will_respond_with(status=200, headers=header, body=Like(expected))
186+
)
187+
188+
with pact:
189+
response = user_consumer.create_user(user=payload, header=header)
190+
assert response[0] == expected_response_code
191+
assert response[1].id == 124
192+
assert response[1].name == "Jane Doe"
193+
194+
pact.verify()
195+
196+
197+
def test_delete_request_to_delete_user(pact: Pact, user_consumer: UserConsumer) -> None:
198+
"""
199+
Test the DELETE request for deleting a user.
200+
201+
This test defines the expected interaction for a DELETE request to delete
202+
a user. It sets up the expected request and response from the provider,
203+
including the request body and headers, and verifies that the response
204+
status code is 200 and the response body matches the expected user data.
205+
"""
206+
expected_response_code: int = 204
207+
(
208+
pact.given("delete the user 124")
209+
.upon_receiving("a request for deleting user")
210+
.with_request(method="DELETE", path="/users/124")
211+
.will_respond_with(204)
212+
)
213+
214+
with pact:
215+
response_status_code = user_consumer.delete_user(124)
216+
assert response_status_code == expected_response_code
217+
218+
pact.verify()

0 commit comments

Comments
 (0)