Skip to content

Commit dd9aaa7

Browse files
committed
Added endpoints
1 parent f443305 commit dd9aaa7

7 files changed

Lines changed: 342 additions & 9 deletions

File tree

pyproject.toml

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,18 @@ line-length = 120
44
[tool.pytest.ini_options]
55
pythonpath = ["src/"]
66
testpaths = "tests"
7+
asyncio_mode = "auto"
8+
markers = [
9+
"APP_PORT: Sets the APP_PORT env variable variable at test startup",
10+
"SERVER_URL: Sets the SERVER_URL env variable variable at test startup",
11+
"MOUNT_POINT: Sets the MOUNT_POINT env variable variable at test startup",
12+
"MAX_IDLE_DURATION_SECONDS: Sets the MAX_IDLE_DURATION_SECONDS env variable variable at test startup",
13+
"MAX_DURATION_SECONDS: Sets the MAX_DURATION_SECONDS env variable variable at test startup",
14+
"MAX_ACTIVE_ENDPOINTS: Sets the MAX_ACTIVE_ENDPOINTS env variable variable at test startup",
15+
"MAX_ENDPOINT_NOTIFICATIONS: Sets the MAX_ENDPOINT_NOTIFICATIONS env variable variable at test startup",
16+
"CLEANUP_FREQUENCY_SECONDS: Sets the CLEANUP_FREQUENCY_SECONDS env variable variable at test startup",
17+
]
18+
719

820
[tool.isort]
921
profile = "black"
@@ -39,7 +51,7 @@ dependencies = [
3951
[project.optional-dependencies]
4052
server = ["aiohttp>=3.11.12,<4"]
4153
dev = ["bandit", "black", "coverage", "flake8", "flake8-bugbear", "mypy"]
42-
test = ["assertical", "pytest", "pytest-aiohttp", "freezegun"]
54+
test = ["assertical", "pytest", "pytest-asyncio", "pytest-aiohttp", "freezegun"]
4355

4456
[tool.setuptools.package-data]
4557
"cactus_client_notifications" = ["py.typed", "**/*.xsd"]

src/cactus_client_notifications/server/handler.py

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@
1010
CreateEndpointRequest,
1111
CreateEndpointResponse,
1212
)
13-
from cactus_client_notifications.server.endpoint_store import NotificationException
13+
from cactus_client_notifications.server.endpoint_store import (
14+
NotificationException,
15+
generate_collected_notification,
16+
)
1417
from cactus_client_notifications.server.settings import ServerSettings
1518
from cactus_client_notifications.server.shared import (
1619
APPKEY_NOTIFICATION_STORE,
@@ -86,6 +89,7 @@ async def post_manage_endpoint_list(request: web.Request) -> web.Response:
8689
try:
8790
endpoint_id = await request.app[APPKEY_NOTIFICATION_STORE].create_endpoint()
8891
except NotificationException as exc:
92+
logger.error("Error creating endpoint", exc_info=exc)
8993
return web.Response(status=exc.status_code, text=str(exc))
9094

9195
create_response = CreateEndpointResponse(
@@ -119,6 +123,7 @@ async def get_manage_endpoint(request: web.Request) -> web.Response:
119123
try:
120124
collected_notifications = await request.app[APPKEY_NOTIFICATION_STORE].collect_notifications(endpoint_id)
121125
except NotificationException as exc:
126+
logger.error(f"Error updating config for {endpoint_id}", exc_info=exc)
122127
return web.Response(status=exc.status_code, text=str(exc))
123128

124129
collect_response = CollectEndpointResponse(
@@ -159,6 +164,75 @@ async def put_manage_endpoint(request: web.Request) -> web.Response:
159164
try:
160165
await request.app[APPKEY_NOTIFICATION_STORE].update_endpoint(endpoint_id, enabled=configure_request.enabled)
161166
except NotificationException as exc:
167+
logger.error(f"Error configuring {endpoint_id} with {configure_request}", exc_info=exc)
168+
return web.Response(status=exc.status_code, text=str(exc))
169+
170+
return web.Response(status=http.HTTPStatus.NO_CONTENT)
171+
172+
173+
async def delete_manage_endpoint(request: web.Request) -> web.Response:
174+
"""Deletes an existing endpoint based on the endpoint_id in the path. All uncollected notifications will be lost.
175+
176+
Args:
177+
request: An aiohttp.web.Request instance.
178+
179+
Returns:
180+
aiohttp.web.Response: Encodes a CreateEndpointResponse on success
181+
182+
a 204 (NO_CONTENT) on success.
183+
a 404 (NOT_FOUND) if the endpoint has been deleted or the endpoint_id is invalid
184+
"""
185+
186+
endpoint_id = request.match_info.get("endpoint_id")
187+
if not endpoint_id:
188+
return web.Response(status=http.HTTPStatus.BAD_REQUEST, text="endpoint_id couldn't be extracted from the path.")
189+
190+
logger.info(f"Deleting endpoint {endpoint_id} for {request.remote}")
191+
192+
try:
193+
await request.app[APPKEY_NOTIFICATION_STORE].try_delete_endpoint(endpoint_id)
194+
except NotificationException as exc:
195+
logger.error(f"Error deleting {endpoint_id}", exc_info=exc)
162196
return web.Response(status=exc.status_code, text=str(exc))
163197

164198
return web.Response(status=http.HTTPStatus.NO_CONTENT)
199+
200+
201+
async def webhook_endpoint(request: web.Request) -> web.Response:
202+
"""This is the endpoint that will handle ALL incoming 2030.5 notifications from the utility server. It will try
203+
to report success for everything and log the contents of the incoming request.
204+
205+
Args:
206+
request: An aiohttp.web.Request instance.
207+
208+
Returns:
209+
aiohttp.web.Response: Encodes a CreateEndpointResponse on success
210+
211+
a 200 (OK) on success.
212+
a 404 (NOT_FOUND) if the endpoint has been deleted or the endpoint_id is invalid
213+
a 500 (INTERNAL_SERVER_ERROR) if the endpoint has been disabled
214+
a 507 (INSUFFICIENT_STORAGE) if the endpoint has too many uncollected notifications
215+
"""
216+
217+
endpoint_id = request.match_info.get("endpoint_id")
218+
if not endpoint_id:
219+
return web.Response(status=http.HTTPStatus.NOT_FOUND)
220+
221+
try:
222+
collected_notification = await generate_collected_notification(request)
223+
except Exception as exc:
224+
logger.error(f"Error parsing incoming webhook request for {endpoint_id} from {request.remote}", exc_info=exc)
225+
return web.Response(status=http.HTTPStatus.BAD_REQUEST)
226+
227+
logger.info(
228+
f"{collected_notification.method} notification ({len(collected_notification.body)}) at {endpoint_id}"
229+
+ f"from {request.remote}."
230+
)
231+
232+
try:
233+
await request.app[APPKEY_NOTIFICATION_STORE].add_notification(endpoint_id, collected_notification)
234+
except NotificationException as exc:
235+
logger.error(f"Error adding notification to {endpoint_id}", exc_info=exc)
236+
return web.Response(status=exc.status_code, text=str(exc))
237+
238+
return web.Response(status=http.HTTPStatus.OK)

src/cactus_client_notifications/server/main.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,17 @@ async def periodic_task(app: web.Application) -> None:
2828
store = app[shared.APPKEY_NOTIFICATION_STORE]
2929

3030
while True:
31+
32+
# Sleep first - we don't need to initiate a cleanup immediately
33+
await asyncio.sleep(server_settings.cleanup_frequency.total_seconds())
34+
3135
try:
3236
await store.cleanup_expired_endpoints(
3337
utc_now(), server_settings.max_endpoint_idle_duration, server_settings.max_endpoint_duration
3438
)
35-
except Exception as e:
39+
except Exception as exc:
3640
# Catch and log uncaught exceptions to prevent periodic task from hanging
37-
logger.error(f"Uncaught exception in periodic task: {repr(e)}")
38-
39-
await asyncio.sleep(server_settings.cleanup_frequency.total_seconds())
41+
logger.error("Uncaught exception in periodic task", exc_info=exc)
4042

4143

4244
async def setup_periodic_task(app: web.Application) -> AsyncGenerator:
@@ -62,11 +64,11 @@ def create_app() -> web.Application:
6264
ENV_APP_PORT = int(os.getenv("APP_PORT", 8080))
6365
ENV_PUBLIC_SERVER_URL = os.getenv("SERVER_URL", f"http://localhost:{ENV_APP_PORT}")
6466
ENV_MOUNT_POINT = os.getenv("MOUNT_POINT", "/")
65-
ENV_MAX_IDLE_DURATION_SECONDS = int(os.getenv("MAX_IDLE_DURATION_SECONDS", 3600))
66-
ENV_MAX_DURATION_SECONDS = int(os.getenv("MAX_DURATION_SECONDS", (3600 * 24 * 3) + 3600))
67+
ENV_MAX_IDLE_DURATION_SECONDS = float(os.getenv("MAX_IDLE_DURATION_SECONDS", 3600))
68+
ENV_MAX_DURATION_SECONDS = float(os.getenv("MAX_DURATION_SECONDS", (3600 * 24 * 3) + 3600))
6769
ENV_MAX_ACTIVE_ENDPOINTS = int(os.getenv("MAX_ACTIVE_ENDPOINTS", 1024))
6870
ENV_MAX_ENDPOINT_NOTIFICATIONS = int(os.getenv("MAX_ENDPOINT_NOTIFICATIONS", 100))
69-
ENV_CLEANUP_FREQUENCY_SECONDS = int(os.getenv("CLEANUP_FREQUENCY_SECONDS", 120))
71+
ENV_CLEANUP_FREQUENCY_SECONDS = float(os.getenv("CLEANUP_FREQUENCY_SECONDS", 120))
7072
server_settings = ServerSettings(
7173
port=ENV_APP_PORT,
7274
public_server_url=ENV_PUBLIC_SERVER_URL,
@@ -92,6 +94,8 @@ def create_app() -> web.Application:
9294
)
9395
app.router.add_route("GET", handler.path_join(mount, schema.URI_MANAGE_ENDPOINT), handler.get_manage_endpoint)
9496
app.router.add_route("PUT", handler.path_join(mount, schema.URI_MANAGE_ENDPOINT), handler.put_manage_endpoint)
97+
app.router.add_route("DELETE", handler.path_join(mount, schema.URI_MANAGE_ENDPOINT), handler.delete_manage_endpoint)
98+
app.router.add_route("*", handler.path_join(mount, schema.URI_ENDPOINT), handler.webhook_endpoint)
9599

96100
# Start the periodic task
97101
app.cleanup_ctx.append(setup_periodic_task)

tests/conftest.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import os
2+
3+
import pytest
4+
from aiohttp import ClientSession, ClientTimeout
5+
from assertical.fixtures.environment import environment_snapshot
6+
7+
from cactus_client_notifications.server.main import create_app
8+
9+
10+
def marker_to_env(request: pytest.FixtureRequest, var_name: str) -> None:
11+
marker = request.node.get_closest_marker(var_name)
12+
if marker is not None:
13+
os.environ[var_name] = str(marker.args[0])
14+
15+
16+
@pytest.fixture
17+
async def client_session(aiohttp_client, request: pytest.FixtureRequest):
18+
with environment_snapshot():
19+
marker_to_env(request, "APP_PORT")
20+
marker_to_env(request, "SERVER_URL")
21+
marker_to_env(request, "MOUNT_POINT")
22+
marker_to_env(request, "MAX_IDLE_DURATION_SECONDS")
23+
marker_to_env(request, "MAX_DURATION_SECONDS")
24+
marker_to_env(request, "MAX_ACTIVE_ENDPOINTS")
25+
marker_to_env(request, "MAX_ENDPOINT_NOTIFICATIONS")
26+
marker_to_env(request, "CLEANUP_FREQUENCY_SECONDS")
27+
28+
async with await aiohttp_client(create_app()) as app:
29+
async with ClientSession(base_url=app.make_url("/"), timeout=ClientTimeout(30)) as session:
30+
yield session

tests/integration/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)