Skip to content

Commit 68c0a2b

Browse files
⚠️ Add strongly typed EventNotifications (#1538) (#1591)
* wip generating push payload events [skip ci] * improve generation [skip ci] * generate related object method * Return a union from parse thin event & add UnknownThinEvent * fix unused import * fix related_object generation and add big test * Some name cleanup * rename thin_event * small fixes * export UnknownEventNotification * fix reason parsing * rename thin_event * fix example & re-export event notifications * move some imports * swap event fetch_related_object to stripe-context * update docstring * update comment Co-authored-by: David Brownman <[email protected]> Co-authored-by: Ramya Rao <[email protected]>
2 parents 51c826c + 8da2d0d commit 68c0a2b

File tree

329 files changed

+21983
-1417
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

329 files changed

+21983
-1417
lines changed
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""
2+
event_notification_webhook_handler.py - receive and process thin events like the
3+
v1.billing.meter.error_report_triggered event.
4+
5+
In this example, we:
6+
- create a StripeClient called client
7+
- use client.parse_event_notification() to parse the received notification webhook body
8+
- if its type is "v1.billing.meter.error_report_triggered":
9+
- call event_notification.fetch_event() to retrieve the full event object
10+
- call event_notification.fetch_related_object() to retrieve the Meter that failed
11+
- log info about the failure
12+
"""
13+
14+
import os
15+
from stripe import StripeClient
16+
17+
from flask import Flask, request, jsonify
18+
19+
app = Flask(__name__)
20+
api_key = os.environ.get("STRIPE_API_KEY", "")
21+
webhook_secret = os.environ.get("WEBHOOK_SECRET", "")
22+
23+
client = StripeClient(api_key)
24+
25+
26+
@app.route("/webhook", methods=["POST"])
27+
def webhook():
28+
webhook_body = request.data
29+
sig_header = request.headers.get("Stripe-Signature")
30+
31+
try:
32+
event_notification = client.parse_event_notification(
33+
webhook_body, sig_header, webhook_secret
34+
)
35+
36+
# Fetch the event data to understand the failure
37+
if (
38+
event_notification.type
39+
== "v1.billing.meter.error_report_triggered"
40+
):
41+
meter = event_notification.fetch_related_object()
42+
event = event_notification.fetch_event()
43+
print(
44+
f"Err! Meter {meter.id} had a problem (more info: {event.data.developer_message_summary})"
45+
)
46+
# Record the failures and alert your team
47+
# Add your logic here
48+
49+
return jsonify(success=True), 200
50+
except Exception as e:
51+
return jsonify(error=str(e)), 400
52+
53+
54+
if __name__ == "__main__":
55+
app.run(port=4242)

examples/thinevent_webhook_handler.py

Lines changed: 0 additions & 53 deletions
This file was deleted.

flake8_stripe/flake8_stripe.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ class TypingImportsChecker:
3535
"Unpack",
3636
"Awaitable",
3737
"Never",
38+
"override",
3839
]
3940

4041
allowed_typing_imports = [

justfile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ test *args: install-test-deps
1515
# configured in pyproject.toml
1616
pytest "$@"
1717

18+
# run a single test by name
19+
test-one test_name: install-test-deps
20+
# don't use all cores, there's a spin up time to that and we're only using one test
21+
pytest -k "{{ test_name }}" -n 0
22+
1823
# ⭐ check for potential mistakes
1924
lint: install-dev-deps
2025
python -m flake8 --show-source stripe tests

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ include = [
6060
"tests/test_generated_examples.py",
6161
"tests/test_exports.py",
6262
"tests/test_http_client.py",
63+
"tests/test_v2_event.py",
6364
]
6465
exclude = ["build", "**/__pycache__"]
6566
reportMissingTypeArgument = true

stripe/__init__.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,6 @@ def _warn_if_mismatched_proxy():
100100
# StripeClient
101101
from stripe._stripe_client import StripeClient as StripeClient # noqa
102102

103-
from stripe.v2._event import ThinEvent as ThinEvent # noqa
104-
105103

106104
# Sets some basic information about the running application that's sent along
107105
# with API requests. Useful for plugin authors to identify their plugin when

stripe/_api_requestor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -861,7 +861,7 @@ async def request_raw_async(
861861

862862
return rcontent, rcode, rheaders
863863

864-
def _should_handle_code_as_error(self, rcode):
864+
def _should_handle_code_as_error(self, rcode: int) -> bool:
865865
return not 200 <= rcode < 300
866866

867867
def _interpret_response(

stripe/_stripe_client.py

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,10 @@
2727
from stripe._util import _convert_to_stripe_object, get_api_mode, deprecated # noqa: F401
2828
from stripe._webhook import Webhook, WebhookSignature
2929
from stripe._event import Event
30-
from stripe.v2._event import ThinEvent
30+
from stripe.v2._event import EventNotification
3131

3232
from typing import Any, Dict, Optional, Union, cast
33+
from typing_extensions import TYPE_CHECKING
3334

3435
# Non-generated services
3536
from stripe._oauth_service import OAuthService
@@ -122,6 +123,9 @@
122123
from stripe._webhook_endpoint_service import WebhookEndpointService
123124
# services: The end of the section generated from our OpenAPI spec
124125

126+
if TYPE_CHECKING:
127+
from stripe.events._event_classes import ALL_EVENT_NOTIFICATIONS
128+
125129

126130
class StripeClient(object):
127131
def __init__(
@@ -201,13 +205,18 @@ def __init__(
201205
self.v2 = V2Services(self._requestor)
202206
# top-level services: The end of the section generated from our OpenAPI spec
203207

204-
def parse_thin_event(
208+
def parse_event_notification(
205209
self,
206210
raw: Union[bytes, str, bytearray],
207211
sig_header: str,
208212
secret: str,
209213
tolerance: int = Webhook.DEFAULT_TOLERANCE,
210-
) -> ThinEvent:
214+
) -> "ALL_EVENT_NOTIFICATIONS":
215+
"""
216+
This should be your main method for interacting with `EventNotifications`. It's the V2 equivalent of `construct_event()`, but with better typing support.
217+
218+
It returns a union representing all known `EventNotification` classes. They have a `type` property that can be used for narrowing, which will get you very specific type support. If parsing an event the SDK isn't familiar with, it'll instead return `UnknownEventNotification`. That's not reflected in the return type of the function (because it messes up type narrowing) but is otherwise intended.
219+
"""
211220
payload = (
212221
cast(Union[bytes, bytearray], raw).decode("utf-8")
213222
if hasattr(raw, "decode")
@@ -216,7 +225,10 @@ def parse_thin_event(
216225

217226
WebhookSignature.verify_header(payload, sig_header, secret, tolerance)
218227

219-
return ThinEvent(payload)
228+
return cast(
229+
"ALL_EVENT_NOTIFICATIONS",
230+
EventNotification.from_json(payload, self),
231+
)
220232

221233
def construct_event(
222234
self,
@@ -247,6 +259,9 @@ def raw_request(self, method_: str, url_: str, **params):
247259

248260
stripe_context = params.pop("stripe_context", None)
249261

262+
# we manually pass usage in event internals, so use those if available
263+
usage = params.pop("usage", ["raw_request"])
264+
250265
# stripe-context goes *here* and not in api_requestor. Properties
251266
# go on api_requestor when you want them to persist onto requests
252267
# made when you call instance methods on APIResources that come from
@@ -263,7 +278,7 @@ def raw_request(self, method_: str, url_: str, **params):
263278
options=options,
264279
base_address=base_address,
265280
api_mode=api_mode,
266-
usage=["raw_request"],
281+
usage=usage,
267282
)
268283

269284
return self._requestor._interpret_response(
@@ -297,6 +312,9 @@ def deserialize(
297312
*,
298313
api_mode: ApiMode,
299314
) -> StripeObject:
315+
"""
316+
Used to translate the result of a `raw_request` into a StripeObject.
317+
"""
300318
return _convert_to_stripe_object(
301319
resp=resp,
302320
params=params,

stripe/_util.py

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -192,12 +192,6 @@ def secure_compare(val1, val2):
192192
return result == 0
193193

194194

195-
def get_thin_event_classes():
196-
from stripe.events._event_classes import THIN_EVENT_CLASSES
197-
198-
return THIN_EVENT_CLASSES
199-
200-
201195
def get_object_classes(api_mode):
202196
# This is here to avoid a circular dependency
203197
if api_mode == "V2":
@@ -331,8 +325,10 @@ def _convert_to_stripe_object(
331325

332326
klass = DeletedObject
333327
elif api_mode == "V2" and klass_name == "v2.core.event":
328+
from stripe.events._event_classes import V2_EVENT_CLASS_LOOKUP
329+
334330
event_name = resp.get("type", "")
335-
klass = get_thin_event_classes().get(
331+
klass = V2_EVENT_CLASS_LOOKUP.get(
336332
event_name, stripe.StripeObject
337333
)
338334
else:
@@ -426,9 +422,11 @@ def sanitize_id(id):
426422
return quotedId
427423

428424

429-
def get_api_mode(url):
425+
def get_api_mode(url: str) -> ApiMode:
430426
if url.startswith("/v2"):
431427
return "V2"
428+
429+
# if urls aren't explicitly marked as v1, they're assumed to be v1
432430
else:
433431
return "V1"
434432

0 commit comments

Comments
 (0)