Skip to content

Commit 336b5ad

Browse files
authored
Feat/optimize webhook payload detection (#947)
* feat: implement select_best_model function * feat: return model class and instance * fix: update webhook handler to use consistent parameter naming * feat: add payload webhooks validation function * feat: setup dev script to add mock webhooks * fix: handle models with all optional fields * chore: fmt * fix: update UpptimePayload to require 'text' field * refactor: simplify validate_payload function * refactor: update webhook handling to improve payload validation and error handling * refactor: enhance webhook payload handling with type casting * refactor: improve test structure and utilize test_client fixture for consistency * fix: update tag to proper value Webhooks * refactor: update webhook handling to return structured results * chore: update docstring for clarity * feat: add WebhookResult model * feat: enhance payload validation with logging * chore: lint
1 parent 9a375f3 commit 336b5ad

File tree

8 files changed

+784
-305
lines changed

8 files changed

+784
-305
lines changed

app/api/v1/routes/webhooks.py

Lines changed: 164 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -1,147 +1,199 @@
1-
import requests # type: ignore
21
import json
3-
from fastapi import APIRouter, Request, HTTPException
4-
from models.webhooks import AwsSnsPayload, WebhookPayload
5-
from core.logging import get_module_logger
2+
from typing import Union, Dict, Any, cast
3+
4+
import requests # type: ignore
65
from api.dependencies.rate_limits import get_limiter
6+
from core.logging import get_module_logger
7+
from fastapi import APIRouter, HTTPException, Request, Body
78
from integrations.sentinel import log_to_sentinel
9+
from models.webhooks import (
10+
AwsSnsPayload,
11+
WebhookPayload,
12+
AccessRequest,
13+
UpptimePayload,
14+
WebhookResult,
15+
)
816
from modules.slack import webhooks
17+
from modules.webhooks.base import validate_payload
918
from server.event_handlers import aws
10-
from server.utils import (
11-
log_ops_message,
12-
)
19+
from server.utils import log_ops_message
1320

1421

1522
logger = get_module_logger()
1623
router = APIRouter(tags=["Access"])
1724
limiter = get_limiter()
1825

1926

20-
@router.post("/hook/{id}")
27+
@router.post("/hook/{webhook_id}")
2128
@limiter.limit(
2229
"30/minute"
2330
) # since some slack channels use this for alerting, we want to be generous with the rate limiting on this one
2431
def handle_webhook(
25-
id: str,
26-
payload: WebhookPayload | str,
32+
webhook_id: str,
2733
request: Request,
34+
payload: Union[Dict[Any, Any], str] = Body(...),
2835
):
29-
webhook = webhooks.get_webhook(id)
30-
webhook_payload = WebhookPayload()
31-
if webhook:
32-
hook_type: str = webhook.get("hook_type", {"S": "alert"})["S"]
33-
# if the webhook is active, then send forward the response to the webhook
34-
if webhooks.is_active(id):
35-
webhooks.increment_invocation_count(id)
36-
if isinstance(payload, str):
37-
processed_payload = handle_string_payload(payload, request)
38-
if isinstance(processed_payload, dict):
39-
return processed_payload
40-
else:
41-
logger.info(
42-
"payload_processed",
43-
payload=processed_payload,
44-
webhook_id=id,
45-
)
46-
webhook_payload = processed_payload
47-
else:
48-
webhook_payload = payload
49-
webhook_payload.channel = webhook["channel"]["S"]
50-
if hook_type == "alert":
51-
webhook_payload = append_incident_buttons(webhook_payload, id)
52-
try:
53-
webhook_payload_parsed = webhook_payload.model_dump(exclude_none=True)
54-
request.state.bot.client.api_call(
55-
"chat.postMessage", json=webhook_payload_parsed
56-
)
57-
log_to_sentinel(
58-
"webhook_sent",
59-
{"webhook": webhook, "payload": webhook_payload_parsed},
60-
)
61-
return {"ok": True}
62-
except Exception as e:
63-
logger.exception(
64-
"webhook_posting_error",
65-
webhook_id=id,
66-
webhook_payload=webhook_payload,
67-
error=str(e),
68-
)
69-
body = webhook_payload.model_dump(exclude_none=True)
70-
log_ops_message(
71-
request.state.bot.client, f"Error posting message: ```{body}```"
72-
)
73-
raise HTTPException(
74-
status_code=500, detail="Failed to send message"
75-
) from e
76-
else:
77-
logger.info(
78-
"webhook_not_active",
79-
webhook_id=id,
80-
webhook_payload=webhook_payload,
81-
error="Webhook is not active",
82-
)
83-
raise HTTPException(status_code=404, detail="Webhook not active")
36+
"""Handle incoming webhook requests and post to Slack channel.
37+
38+
Args:
39+
webhook_id (str): The ID of the webhook to handle.
40+
request (Request): The incoming HTTP request.
41+
payload (Union[Dict[Any, Any], str]): The incoming webhook payload, either as
42+
a JSON string or a dictionary.
43+
44+
Raises:
45+
HTTPException: If the webhook is not found, not active, or if there are issues
46+
with payload validation or posting to Slack.
47+
Returns:
48+
dict: A dictionary indicating success if the message was posted successfully.
49+
"""
50+
if isinstance(payload, dict):
51+
payload_dict = payload
8452
else:
53+
try:
54+
payload_dict = json.loads(payload)
55+
except json.JSONDecodeError as e:
56+
logger.error("payload_validation_error", error=str(e), payload=str(payload))
57+
raise HTTPException(status_code=400, detail=str(e)) from e
58+
59+
webhook = webhooks.get_webhook(webhook_id)
60+
if not webhook:
8561
raise HTTPException(status_code=404, detail="Webhook not found")
8662

63+
if not webhook.get("active", {}).get("BOOL", False):
64+
logger.info(
65+
"webhook_not_active",
66+
webhook_id=webhook_id,
67+
error="Webhook is not active",
68+
)
69+
raise HTTPException(status_code=404, detail="Webhook not active")
70+
webhooks.increment_invocation_count(webhook_id)
8771

88-
def handle_string_payload(
89-
payload: str,
72+
webhook_result = handle_webhook_payload(payload_dict, request)
73+
74+
if webhook_result.status == "error":
75+
raise HTTPException(status_code=400, detail="Invalid payload")
76+
77+
if webhook_result.action == "post" and isinstance(
78+
webhook_result.payload, WebhookPayload
79+
):
80+
webhook_payload = webhook_result.payload
81+
webhook_payload.channel = webhook["channel"]["S"]
82+
hook_type = webhook["hook_type"]["S"]
83+
if hook_type == "alert":
84+
webhook_payload = append_incident_buttons(webhook_payload, webhook_id)
85+
86+
webhook_payload_parsed = webhook_payload.model_dump(exclude_none=True)
87+
88+
try:
89+
request.state.bot.client.api_call(
90+
"chat.postMessage", json=webhook_payload_parsed
91+
)
92+
log_to_sentinel(
93+
"webhook_sent",
94+
{"webhook": webhook, "payload": webhook_payload_parsed},
95+
)
96+
97+
except Exception as e:
98+
logger.exception(
99+
"webhook_posting_error",
100+
webhook_id=webhook_id,
101+
error=str(e),
102+
)
103+
raise HTTPException(status_code=500, detail="Failed to send message") from e
104+
105+
return {"ok": True}
106+
107+
108+
def handle_webhook_payload(
109+
payload_dict: dict,
90110
request: Request,
91-
) -> WebhookPayload | dict:
111+
) -> WebhookResult:
112+
"""Process and validate the webhook payload.
92113
93-
string_payload_type, validated_payload = webhooks.validate_string_payload_type(
94-
payload
95-
)
96-
logger.info(
97-
"string_payload_type",
98-
payload=payload,
99-
string_payload_type=string_payload_type,
100-
validated_payload=validated_payload,
114+
Returns:
115+
dict: A dictionary containing:
116+
- status (str): The status of the operation (e.g., "success", "error").
117+
- action (Literal["post", "log", "none"]): The action to take.
118+
- payload (Optional[WebhookPayload]): The payload to post, if applicable.
119+
"""
120+
logger.info("processing_webhook_payload", payload=payload_dict)
121+
payload_validation_result = validate_payload(payload_dict)
122+
123+
webhook_result = WebhookResult(
124+
status="error", message="Failed to process payload for unknown reasons"
101125
)
102-
match string_payload_type:
126+
if payload_validation_result is not None:
127+
payload_type, validated_payload = payload_validation_result
128+
else:
129+
error_message = "No matching model found for payload"
130+
return WebhookResult(status="error", message=error_message)
131+
132+
match payload_type.__name__:
103133
case "WebhookPayload":
104-
webhook_payload = WebhookPayload(**validated_payload)
134+
webhook_result = WebhookResult(
135+
status="success", action="post", payload=validated_payload
136+
)
105137
case "AwsSnsPayload":
106-
awsSnsPayload = aws.validate_sns_payload(
107-
AwsSnsPayload(**validated_payload),
138+
aws_sns_payload_instance = cast(AwsSnsPayload, validated_payload)
139+
aws_sns_payload = aws.validate_sns_payload(
140+
aws_sns_payload_instance,
108141
request.state.bot.client,
109142
)
110-
if awsSnsPayload.Type == "SubscriptionConfirmation":
111-
requests.get(awsSnsPayload.SubscribeURL, timeout=60)
143+
144+
if aws_sns_payload.Type == "SubscriptionConfirmation":
145+
requests.get(aws_sns_payload.SubscribeURL, timeout=60)
112146
logger.info(
113147
"subscribed_webhook_to_topic",
114-
webhook_id=awsSnsPayload.TopicArn,
115-
subscribed_topic=awsSnsPayload.TopicArn,
148+
webhook_id=aws_sns_payload.TopicArn,
149+
subscribed_topic=aws_sns_payload.TopicArn,
116150
)
117151
log_ops_message(
118152
request.state.bot.client,
119-
f"Subscribed webhook {id} to topic {awsSnsPayload.TopicArn}",
153+
f"Subscribed webhook {id} to topic {aws_sns_payload.TopicArn}",
154+
)
155+
webhook_result = WebhookResult(
156+
status="success", action="log", payload=None
120157
)
121-
return {"ok": True}
122-
if awsSnsPayload.Type == "UnsubscribeConfirmation":
158+
159+
if aws_sns_payload.Type == "UnsubscribeConfirmation":
123160
log_ops_message(
124161
request.state.bot.client,
125-
f"{awsSnsPayload.TopicArn} unsubscribed from webhook {id}",
162+
f"{aws_sns_payload.TopicArn} unsubscribed from webhook {id}",
163+
)
164+
webhook_result = WebhookResult(
165+
status="success", action="log", payload=None
126166
)
127-
return {"ok": True}
128-
if awsSnsPayload.Type == "Notification":
129-
blocks = aws.parse(awsSnsPayload, request.state.bot.client)
130-
# if we have an empty message, log that we have an empty
131-
# message and return without posting to slack
167+
168+
if aws_sns_payload.Type == "Notification":
169+
blocks = aws.parse(aws_sns_payload, request.state.bot.client)
132170
if not blocks:
133171
logger.info(
134172
"payload_empty_message",
173+
payload_type="AwsSnsPayload",
174+
sns_type=aws_sns_payload.Type,
135175
)
136-
return {"ok": True}
137-
webhook_payload = WebhookPayload(blocks=blocks)
176+
return WebhookResult(
177+
status="error",
178+
action="none",
179+
message="Empty AWS SNS Notification message",
180+
)
181+
webhook_result = WebhookResult(
182+
status="success",
183+
action="post",
184+
payload=WebhookPayload(blocks=blocks),
185+
)
186+
138187
case "AccessRequest":
139-
# Temporary fix for the Access Request payloads
140-
message = json.dumps(validated_payload)
141-
webhook_payload = WebhookPayload(text=message)
188+
message = str(cast(AccessRequest, validated_payload).model_dump())
189+
webhook_result = WebhookResult(
190+
status="success",
191+
action="post",
192+
payload=WebhookPayload(text=message),
193+
)
194+
142195
case "UpptimePayload":
143-
# Temporary fix for Upptime payloads
144-
text = validated_payload.get("text", "")
196+
text = cast(UpptimePayload, validated_payload).text
145197
header_text = "📈 Web Application Status Changed!"
146198
blocks = [
147199
{"type": "section", "text": {"type": "mrkdwn", "text": " "}},
@@ -157,16 +209,22 @@ def handle_string_payload(
157209
},
158210
},
159211
]
160-
webhook_payload = WebhookPayload(blocks=blocks)
212+
webhook_result = WebhookResult(
213+
status="success",
214+
action="post",
215+
payload=WebhookPayload(blocks=blocks),
216+
)
217+
161218
case _:
162-
raise HTTPException(
163-
status_code=500,
164-
detail="Invalid payload type. Must be a WebhookPayload object or a recognized string payload type.",
219+
webhook_result = WebhookResult(
220+
status="error",
221+
message="No matching model found for payload",
165222
)
166-
return WebhookPayload(**webhook_payload.model_dump(exclude_none=True))
223+
224+
return webhook_result
167225

168226

169-
def append_incident_buttons(payload: WebhookPayload, webhook_id):
227+
def append_incident_buttons(payload: WebhookPayload, webhook_id) -> WebhookPayload:
170228
if payload.attachments is None:
171229
payload.attachments = []
172230
elif isinstance(payload.attachments, str):

app/bin/add_dev_webhooks.sh

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
#!/bin/bash
2+
3+
add_webhook() {
4+
local TABLE_NAME=$1
5+
local CHANNEL=$2
6+
local USER_ID=$3
7+
local NAME=$4
8+
local HOOK_TYPE=${5:-"alert"}
9+
local CREATED_AT=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
10+
local ID=$(date +%s%N | sha256sum | head -c 32) # Generate a unique ID
11+
12+
aws dynamodb put-item \
13+
--table-name $TABLE_NAME \
14+
--item "{\"id\": {\"S\": \"$ID\"}, \"channel\": {\"S\": \"$CHANNEL\"}, \"name\": {\"S\": \"$NAME\"}, \"created_at\": {\"S\": \"$CREATED_AT\"}, \"active\": {\"BOOL\": true}, \"user_id\": {\"S\": \"$USER_ID\"}, \"invocation_count\": {\"N\": \"0\"}, \"acknowledged_count\": {\"N\": \"0\"}, \"hook_type\": {\"S\": \"$HOOK_TYPE\"}}" \
15+
--endpoint-url http://dynamodb-local:8000 \
16+
--no-cli-pager
17+
18+
if [ $? -eq 0 ]; then
19+
echo "Webhook added with ID: $ID"
20+
else
21+
echo "Failed to add webhook"
22+
fi
23+
}
24+
25+
if [ "$#" -lt 4 ]; then
26+
echo "Usage: $0 <table_name> <channel> <user_id> <name> [hook_type]"
27+
exit 1
28+
fi
29+
30+
TABLE_NAME=$1
31+
CHANNEL=$2
32+
USER_ID=$3
33+
NAME=$4
34+
HOOK_TYPE=$5
35+
36+
add_webhook $TABLE_NAME $CHANNEL $USER_ID $NAME $HOOK_TYPE

0 commit comments

Comments
 (0)