diff --git a/examples/event_notification_webhook_handler.py b/examples/event_notification_webhook_handler.py new file mode 100644 index 000000000..3e5ffc869 --- /dev/null +++ b/examples/event_notification_webhook_handler.py @@ -0,0 +1,55 @@ +""" +event_notification_webhook_handler.py - receive and process thin events like the +v1.billing.meter.error_report_triggered event. + +In this example, we: + - create a StripeClient called client + - use client.parse_event_notification() to parse the received notification webhook body + - if its type is "v1.billing.meter.error_report_triggered": + - call event_notification.fetch_event() to retrieve the full event object + - call event_notification.fetch_related_object() to retrieve the Meter that failed + - log info about the failure +""" + +import os +from stripe import StripeClient + +from flask import Flask, request, jsonify + +app = Flask(__name__) +api_key = os.environ.get("STRIPE_API_KEY", "") +webhook_secret = os.environ.get("WEBHOOK_SECRET", "") + +client = StripeClient(api_key) + + +@app.route("/webhook", methods=["POST"]) +def webhook(): + webhook_body = request.data + sig_header = request.headers.get("Stripe-Signature") + + try: + event_notification = client.parse_event_notification( + webhook_body, sig_header, webhook_secret + ) + + # Fetch the event data to understand the failure + if ( + event_notification.type + == "v1.billing.meter.error_report_triggered" + ): + meter = event_notification.fetch_related_object() + event = event_notification.fetch_event() + print( + f"Err! Meter {meter.id} had a problem (more info: {event.data.developer_message_summary})" + ) + # Record the failures and alert your team + # Add your logic here + + return jsonify(success=True), 200 + except Exception as e: + return jsonify(error=str(e)), 400 + + +if __name__ == "__main__": + app.run(port=4242) diff --git a/examples/thinevent_webhook_handler.py b/examples/thinevent_webhook_handler.py deleted file mode 100644 index f93e0a560..000000000 --- a/examples/thinevent_webhook_handler.py +++ /dev/null @@ -1,53 +0,0 @@ -""" -thinevent_webhook_handler.py - receive and process thin events like the -v1.billing.meter.error_report_triggered event. - -In this example, we: - - create a StripeClient called client - - use client.parse_thin_event to parse the received thin event webhook body - - call client.v2.core.events.retrieve to retrieve the full event object - - if it is a V1BillingMeterErrorReportTriggeredEvent event type, call - event.fetchRelatedObject to retrieve the Billing Meter object associated - with the event. -""" - -import os -from stripe import StripeClient -from stripe.events import V1BillingMeterErrorReportTriggeredEvent - -from flask import Flask, request, jsonify - -app = Flask(__name__) -api_key = os.environ.get("STRIPE_API_KEY") -webhook_secret = os.environ.get("WEBHOOK_SECRET") - -client = StripeClient(api_key) - - -@app.route("/webhook", methods=["POST"]) -def webhook(): - webhook_body = request.data - sig_header = request.headers.get("Stripe-Signature") - - try: - thin_event = client.parse_thin_event( - webhook_body, sig_header, webhook_secret - ) - - # Fetch the event data to understand the failure - event = client.v2.core.events.retrieve(thin_event.id) - if isinstance(event, V1BillingMeterErrorReportTriggeredEvent): - meter = event.fetch_related_object() - meter_id = meter.id - print("Success! " + str(meter_id)) - - # Record the failures and alert your team - # Add your logic here - - return jsonify(success=True), 200 - except Exception as e: - return jsonify(error=str(e)), 400 - - -if __name__ == "__main__": - app.run(port=4242) diff --git a/flake8_stripe/flake8_stripe.py b/flake8_stripe/flake8_stripe.py index 4cdca2b40..762efc4f3 100644 --- a/flake8_stripe/flake8_stripe.py +++ b/flake8_stripe/flake8_stripe.py @@ -35,6 +35,7 @@ class TypingImportsChecker: "Unpack", "Awaitable", "Never", + "override", ] allowed_typing_imports = [ diff --git a/justfile b/justfile index 4d6302adf..4feee0186 100644 --- a/justfile +++ b/justfile @@ -15,6 +15,11 @@ test *args: install-test-deps # configured in pyproject.toml pytest "$@" +# run a single test by name +test-one test_name: install-test-deps + # don't use all cores, there's a spin up time to that and we're only using one test + pytest -k "{{ test_name }}" -n 0 + # ⭐ check for potential mistakes lint: install-dev-deps python -m flake8 --show-source stripe tests diff --git a/pyproject.toml b/pyproject.toml index 46c0d8203..1106da021 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,6 +60,7 @@ include = [ "tests/test_generated_examples.py", "tests/test_exports.py", "tests/test_http_client.py", + "tests/test_v2_event.py", ] exclude = ["build", "**/__pycache__"] reportMissingTypeArgument = true diff --git a/stripe/__init__.py b/stripe/__init__.py index d0b8b61f2..7ed8d3648 100644 --- a/stripe/__init__.py +++ b/stripe/__init__.py @@ -100,8 +100,6 @@ def _warn_if_mismatched_proxy(): # StripeClient from stripe._stripe_client import StripeClient as StripeClient # noqa -from stripe.v2._event import ThinEvent as ThinEvent # noqa - # Sets some basic information about the running application that's sent along # with API requests. Useful for plugin authors to identify their plugin when diff --git a/stripe/_api_requestor.py b/stripe/_api_requestor.py index 3d5964c33..5c09e081a 100644 --- a/stripe/_api_requestor.py +++ b/stripe/_api_requestor.py @@ -827,7 +827,7 @@ async def request_raw_async( return rcontent, rcode, rheaders - def _should_handle_code_as_error(self, rcode): + def _should_handle_code_as_error(self, rcode: int) -> bool: return not 200 <= rcode < 300 def _interpret_response( diff --git a/stripe/_stripe_client.py b/stripe/_stripe_client.py index 4b167ec8d..1c17aabbf 100644 --- a/stripe/_stripe_client.py +++ b/stripe/_stripe_client.py @@ -27,9 +27,10 @@ from stripe._util import _convert_to_stripe_object, get_api_mode, deprecated # noqa: F401 from stripe._webhook import Webhook, WebhookSignature from stripe._event import Event -from stripe.v2._event import ThinEvent +from stripe.v2._event import EventNotification from typing import Any, Dict, Optional, Union, cast +from typing_extensions import TYPE_CHECKING # Non-generated services from stripe._oauth_service import OAuthService @@ -113,6 +114,9 @@ from stripe._webhook_endpoint_service import WebhookEndpointService # services: The end of the section generated from our OpenAPI spec +if TYPE_CHECKING: + from stripe.events._event_classes import ALL_EVENT_NOTIFICATIONS + class StripeClient(object): def __init__( @@ -192,13 +196,18 @@ def __init__( self.v2 = V2Services(self._requestor) # top-level services: The end of the section generated from our OpenAPI spec - def parse_thin_event( + def parse_event_notification( self, raw: Union[bytes, str, bytearray], sig_header: str, secret: str, tolerance: int = Webhook.DEFAULT_TOLERANCE, - ) -> ThinEvent: + ) -> "ALL_EVENT_NOTIFICATIONS": + """ + This should be your main method for interacting with `EventNotifications`. It's the V2 equivalent of `construct_event()`, but with better typing support. + + 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. + """ payload = ( cast(Union[bytes, bytearray], raw).decode("utf-8") if hasattr(raw, "decode") @@ -207,7 +216,10 @@ def parse_thin_event( WebhookSignature.verify_header(payload, sig_header, secret, tolerance) - return ThinEvent(payload) + return cast( + "ALL_EVENT_NOTIFICATIONS", + EventNotification.from_json(payload, self), + ) def construct_event( self, @@ -238,6 +250,9 @@ def raw_request(self, method_: str, url_: str, **params): stripe_context = params.pop("stripe_context", None) + # we manually pass usage in event internals, so use those if available + usage = params.pop("usage", ["raw_request"]) + # stripe-context goes *here* and not in api_requestor. Properties # go on api_requestor when you want them to persist onto requests # made when you call instance methods on APIResources that come from @@ -254,7 +269,7 @@ def raw_request(self, method_: str, url_: str, **params): options=options, base_address=base_address, api_mode=api_mode, - usage=["raw_request"], + usage=usage, ) return self._requestor._interpret_response( @@ -288,6 +303,9 @@ def deserialize( *, api_mode: ApiMode, ) -> StripeObject: + """ + Used to translate the result of a `raw_request` into a StripeObject. + """ return _convert_to_stripe_object( resp=resp, params=params, diff --git a/stripe/_util.py b/stripe/_util.py index f3016b313..953958994 100644 --- a/stripe/_util.py +++ b/stripe/_util.py @@ -9,7 +9,7 @@ from stripe._api_mode import ApiMode -from urllib.parse import parse_qsl, quote_plus # noqa: F401 +from urllib.parse import parse_qsl, quote_plus, urlparse # noqa: F401 from typing_extensions import Type, TYPE_CHECKING from typing import ( @@ -192,12 +192,6 @@ def secure_compare(val1, val2): return result == 0 -def get_thin_event_classes(): - from stripe.events._event_classes import THIN_EVENT_CLASSES - - return THIN_EVENT_CLASSES - - def get_object_classes(api_mode): # This is here to avoid a circular dependency if api_mode == "V2": @@ -331,8 +325,10 @@ def _convert_to_stripe_object( klass = DeletedObject elif api_mode == "V2" and klass_name == "v2.core.event": + from stripe.events._event_classes import V2_EVENT_CLASS_LOOKUP + event_name = resp.get("type", "") - klass = get_thin_event_classes().get( + klass = V2_EVENT_CLASS_LOOKUP.get( event_name, stripe.StripeObject ) else: @@ -426,9 +422,11 @@ def sanitize_id(id): return quotedId -def get_api_mode(url): +def get_api_mode(url: str) -> ApiMode: if url.startswith("/v2"): return "V2" + + # if urls aren't explicitly marked as v1, they're assumed to be v1 else: return "V1" diff --git a/stripe/events/__init__.py b/stripe/events/__init__.py index efa6bc4d5..caaad8364 100644 --- a/stripe/events/__init__.py +++ b/stripe/events/__init__.py @@ -1,11 +1,17 @@ # -*- coding: utf-8 -*- # File generated from our OpenAPI spec +from stripe.events._event_classes import ( + ALL_EVENT_NOTIFICATIONS as ALL_EVENT_NOTIFICATIONS, +) from stripe.events._v1_billing_meter_error_report_triggered_event import ( V1BillingMeterErrorReportTriggeredEvent as V1BillingMeterErrorReportTriggeredEvent, + V1BillingMeterErrorReportTriggeredEventNotification as V1BillingMeterErrorReportTriggeredEventNotification, ) from stripe.events._v1_billing_meter_no_meter_found_event import ( V1BillingMeterNoMeterFoundEvent as V1BillingMeterNoMeterFoundEvent, + V1BillingMeterNoMeterFoundEventNotification as V1BillingMeterNoMeterFoundEventNotification, ) from stripe.events._v2_core_event_destination_ping_event import ( V2CoreEventDestinationPingEvent as V2CoreEventDestinationPingEvent, + V2CoreEventDestinationPingEventNotification as V2CoreEventDestinationPingEventNotification, ) diff --git a/stripe/events/_event_classes.py b/stripe/events/_event_classes.py index a47231d7b..0204d4b60 100644 --- a/stripe/events/_event_classes.py +++ b/stripe/events/_event_classes.py @@ -1,18 +1,34 @@ # -*- coding: utf-8 -*- # File generated from our OpenAPI spec +from typing import Union from stripe.events._v1_billing_meter_error_report_triggered_event import ( V1BillingMeterErrorReportTriggeredEvent, + V1BillingMeterErrorReportTriggeredEventNotification, ) from stripe.events._v1_billing_meter_no_meter_found_event import ( V1BillingMeterNoMeterFoundEvent, + V1BillingMeterNoMeterFoundEventNotification, ) from stripe.events._v2_core_event_destination_ping_event import ( V2CoreEventDestinationPingEvent, + V2CoreEventDestinationPingEventNotification, ) -THIN_EVENT_CLASSES = { +V2_EVENT_CLASS_LOOKUP = { V1BillingMeterErrorReportTriggeredEvent.LOOKUP_TYPE: V1BillingMeterErrorReportTriggeredEvent, V1BillingMeterNoMeterFoundEvent.LOOKUP_TYPE: V1BillingMeterNoMeterFoundEvent, V2CoreEventDestinationPingEvent.LOOKUP_TYPE: V2CoreEventDestinationPingEvent, } + +V2_EVENT_NOTIFICATION_CLASS_LOOKUP = { + V1BillingMeterErrorReportTriggeredEventNotification.LOOKUP_TYPE: V1BillingMeterErrorReportTriggeredEventNotification, + V1BillingMeterNoMeterFoundEventNotification.LOOKUP_TYPE: V1BillingMeterNoMeterFoundEventNotification, + V2CoreEventDestinationPingEventNotification.LOOKUP_TYPE: V2CoreEventDestinationPingEventNotification, +} + +ALL_EVENT_NOTIFICATIONS = Union[ + V1BillingMeterErrorReportTriggeredEventNotification, + V1BillingMeterNoMeterFoundEventNotification, + V2CoreEventDestinationPingEventNotification, +] diff --git a/stripe/events/_v1_billing_meter_error_report_triggered_event.py b/stripe/events/_v1_billing_meter_error_report_triggered_event.py index a3af378fb..11d8e138f 100644 --- a/stripe/events/_v1_billing_meter_error_report_triggered_event.py +++ b/stripe/events/_v1_billing_meter_error_report_triggered_event.py @@ -2,12 +2,75 @@ # File generated from our OpenAPI spec from stripe._api_mode import ApiMode from stripe._api_requestor import _APIRequestor +from stripe._stripe_client import StripeClient from stripe._stripe_object import StripeObject from stripe._stripe_response import StripeResponse +from stripe._util import get_api_mode from stripe.billing._meter import Meter -from stripe.v2._event import Event +from stripe.v2._event import Event, EventNotification, RelatedObject from typing import Any, Dict, List, Optional, cast -from typing_extensions import Literal +from typing_extensions import Literal, override + + +class V1BillingMeterErrorReportTriggeredEventNotification(EventNotification): + LOOKUP_TYPE = "v1.billing.meter.error_report_triggered" + type: Literal["v1.billing.meter.error_report_triggered"] + related_object: RelatedObject + + def __init__( + self, parsed_body: Dict[str, Any], client: StripeClient + ) -> None: + super().__init__( + parsed_body, + client, + ) + self.related_object = RelatedObject(parsed_body["related_object"]) + + @override + def fetch_event(self) -> "V1BillingMeterErrorReportTriggeredEvent": + return cast( + "V1BillingMeterErrorReportTriggeredEvent", + super().fetch_event(), + ) + + def fetch_related_object(self) -> "Meter": + response = self._client.raw_request( + "get", + self.related_object.url, + stripe_context=self.context, + usage=["fetch_related_object"], + ) + return cast( + "Meter", + self._client.deserialize( + response, + api_mode=get_api_mode(self.related_object.url), + ), + ) + + @override + async def fetch_event_async( + self, + ) -> "V1BillingMeterErrorReportTriggeredEvent": + return cast( + "V1BillingMeterErrorReportTriggeredEvent", + await super().fetch_event_async(), + ) + + async def fetch_related_object_async(self) -> "Meter": + response = await self._client.raw_request_async( + "get", + self.related_object.url, + stripe_context=self.context, + usage=["fetch_related_object"], + ) + return cast( + "Meter", + self._client.deserialize( + response, + api_mode=get_api_mode(self.related_object.url), + ), + ) class V1BillingMeterErrorReportTriggeredEvent(Event): @@ -144,6 +207,6 @@ def fetch_related_object(self) -> Meter: "get", self.related_object.url, base_address="api", - options={"stripe_account": self.context}, + options={"stripe_context": self.context}, ), ) diff --git a/stripe/events/_v1_billing_meter_no_meter_found_event.py b/stripe/events/_v1_billing_meter_no_meter_found_event.py index 5f8d64479..d45cab994 100644 --- a/stripe/events/_v1_billing_meter_no_meter_found_event.py +++ b/stripe/events/_v1_billing_meter_no_meter_found_event.py @@ -4,9 +4,28 @@ from stripe._api_requestor import _APIRequestor from stripe._stripe_object import StripeObject from stripe._stripe_response import StripeResponse -from stripe.v2._event import Event -from typing import Any, Dict, List, Optional -from typing_extensions import Literal +from stripe.v2._event import Event, EventNotification +from typing import Any, Dict, List, Optional, cast +from typing_extensions import Literal, override + + +class V1BillingMeterNoMeterFoundEventNotification(EventNotification): + LOOKUP_TYPE = "v1.billing.meter.no_meter_found" + type: Literal["v1.billing.meter.no_meter_found"] + + @override + def fetch_event(self) -> "V1BillingMeterNoMeterFoundEvent": + return cast( + "V1BillingMeterNoMeterFoundEvent", + super().fetch_event(), + ) + + @override + async def fetch_event_async(self) -> "V1BillingMeterNoMeterFoundEvent": + return cast( + "V1BillingMeterNoMeterFoundEvent", + await super().fetch_event_async(), + ) class V1BillingMeterNoMeterFoundEvent(Event): diff --git a/stripe/events/_v2_core_event_destination_ping_event.py b/stripe/events/_v2_core_event_destination_ping_event.py index f5280152b..343e30adc 100644 --- a/stripe/events/_v2_core_event_destination_ping_event.py +++ b/stripe/events/_v2_core_event_destination_ping_event.py @@ -1,10 +1,71 @@ # -*- coding: utf-8 -*- # File generated from our OpenAPI spec +from stripe._stripe_client import StripeClient from stripe._stripe_object import StripeObject -from stripe.v2._event import Event +from stripe._util import get_api_mode +from stripe.v2._event import Event, EventNotification, RelatedObject from stripe.v2._event_destination import EventDestination -from typing import cast -from typing_extensions import Literal +from typing import Any, Dict, cast +from typing_extensions import Literal, override + + +class V2CoreEventDestinationPingEventNotification(EventNotification): + LOOKUP_TYPE = "v2.core.event_destination.ping" + type: Literal["v2.core.event_destination.ping"] + related_object: RelatedObject + + def __init__( + self, parsed_body: Dict[str, Any], client: StripeClient + ) -> None: + super().__init__( + parsed_body, + client, + ) + self.related_object = RelatedObject(parsed_body["related_object"]) + + @override + def fetch_event(self) -> "V2CoreEventDestinationPingEvent": + return cast( + "V2CoreEventDestinationPingEvent", + super().fetch_event(), + ) + + def fetch_related_object(self) -> "EventDestination": + response = self._client.raw_request( + "get", + self.related_object.url, + stripe_context=self.context, + usage=["fetch_related_object"], + ) + return cast( + "EventDestination", + self._client.deserialize( + response, + api_mode=get_api_mode(self.related_object.url), + ), + ) + + @override + async def fetch_event_async(self) -> "V2CoreEventDestinationPingEvent": + return cast( + "V2CoreEventDestinationPingEvent", + await super().fetch_event_async(), + ) + + async def fetch_related_object_async(self) -> "EventDestination": + response = await self._client.raw_request_async( + "get", + self.related_object.url, + stripe_context=self.context, + usage=["fetch_related_object"], + ) + return cast( + "EventDestination", + self._client.deserialize( + response, + api_mode=get_api_mode(self.related_object.url), + ), + ) class V2CoreEventDestinationPingEvent(Event): @@ -40,6 +101,6 @@ def fetch_related_object(self) -> EventDestination: "get", self.related_object.url, base_address="api", - options={"stripe_account": self.context}, + options={"stripe_context": self.context}, ), ) diff --git a/stripe/v2/__init__.py b/stripe/v2/__init__.py index d3821e5a6..8e1532fb8 100644 --- a/stripe/v2/__init__.py +++ b/stripe/v2/__init__.py @@ -1,5 +1,12 @@ from stripe.v2._list_object import ListObject as ListObject from stripe.v2._amount import Amount as Amount, AmountParam as AmountParam +from stripe.v2._event import ( + EventNotification as EventNotification, + UnknownEventNotification as UnknownEventNotification, + RelatedObject as RelatedObject, + Reason as Reason, + ReasonRequest as ReasonRequest, +) # The beginning of the section generated from our OpenAPI spec diff --git a/stripe/v2/_event.py b/stripe/v2/_event.py index 0e46a2b66..7a3b26578 100644 --- a/stripe/v2/_event.py +++ b/stripe/v2/_event.py @@ -1,14 +1,15 @@ # -*- coding: utf-8 -*- import json -from typing import ClassVar, Optional +from typing import Any, ClassVar, Dict, Optional, cast -from typing_extensions import Literal +from typing_extensions import Literal, TYPE_CHECKING from stripe._stripe_object import StripeObject +from stripe._util import get_api_mode -# This describes the common format for the pull payload of a V2 ThinEvent -# more specific classes will add `data` and `fetch_related_objects()` as needed +if TYPE_CHECKING: + from stripe._stripe_client import StripeClient # The beginning of the section generated from our OpenAPI spec @@ -74,7 +75,7 @@ class Request(StripeObject): # The end of the section generated from our OpenAPI spec -class Reason: +class ReasonRequest: id: str idempotency_key: str @@ -83,7 +84,20 @@ def __init__(self, d) -> None: self.idempotency_key = d["idempotency_key"] def __repr__(self) -> str: - return f"" + return f"" + + +class Reason: + type: Literal["request"] + request: Optional[ReasonRequest] = None + + def __init__(self, d) -> None: + self.type = d["type"] + if self.type == "request": + self.request = ReasonRequest(d["request"]) + + def __repr__(self) -> str: + return f"" class RelatedObject: @@ -100,9 +114,9 @@ def __repr__(self) -> str: return f"" -class ThinEvent: +class EventNotification: """ - ThinEvent represents the json that's delivered from an Event Destination. It's a basic `dict` with no additional methods or properties. Use it to check basic information about a delivered event. If you want more details, use `stripe.v2.Event.retrieve(thin_event.id)` to fetch the full event object. + EventNotification represents the json that's delivered from an Event Destination. It's a basic struct-like object with a few convenience methods. Use `fetch_event()` to get the full event object. """ id: str @@ -115,37 +129,121 @@ class ThinEvent: """ created: str """ - Livemode indicates if the event is from a production(true) or test(false) account. + Time at which the object was created. """ livemode: bool """ - Time at which the object was created. + Livemode indicates if the event is from a production(true) or test(false) account. """ context: Optional[str] = None """ [Optional] Authentication context needed to fetch the event or related object. """ - related_object: Optional[RelatedObject] = None - """ - [Optional] Object containing the reference to API resource relevant to the event. - """ reason: Optional[Reason] = None """ [Optional] Reason for the event. """ - def __init__(self, payload: str) -> None: - parsed = json.loads(payload) + def __init__( + self, parsed_body: Dict[str, Any], client: "StripeClient" + ) -> None: + self.id = parsed_body["id"] + self.type = parsed_body["type"] + self.created = parsed_body["created"] + self.livemode = bool(parsed_body.get("livemode")) + self.context = parsed_body.get("context") + + if parsed_body.get("reason"): + self.reason = Reason(parsed_body["reason"]) - self.id = parsed["id"] - self.type = parsed["type"] - self.created = parsed["created"] - self.livemode = parsed.get("livemode") - self.context = parsed.get("context") - if parsed.get("related_object"): - self.related_object = RelatedObject(parsed["related_object"]) - if parsed.get("reason"): - self.reason = Reason(parsed["reason"]) + self._client = client + + @staticmethod + def from_json(payload: str, client: "StripeClient") -> "EventNotification": + """ + Helper for constructing an Event Notification. Doesn't perform signature validation, so you + should use StripeClient.parseEventNotification() instead for initial handling. + This is useful in unit tests and working with EventNotifications that you've already validated the authenticity of. + """ + parsed_body = json.loads(payload) + + # circular import busting + from stripe.events._event_classes import ( + V2_EVENT_NOTIFICATION_CLASS_LOOKUP, + ) + + event_class = V2_EVENT_NOTIFICATION_CLASS_LOOKUP.get( + parsed_body["type"], UnknownEventNotification + ) + + return event_class(parsed_body, client) def __repr__(self) -> str: - return f"" + return f"" + + def fetch_event(self) -> Event: + response = self._client.raw_request( + "get", + f"/v2/core/events/{self.id}", + stripe_context=self.context, + usage=["pushed_event_pull"], + ) + return cast(Event, self._client.deserialize(response, api_mode="V2")) + + async def fetch_event_async(self) -> Event: + response = await self._client.raw_request_async( + "get", + f"/v2/core/events/{self.id}", + stripe_context=self.context, + usage=["pushed_event_pull", "pushed_event_pull_async"], + ) + return cast(Event, self._client.deserialize(response, api_mode="V2")) + + +class UnknownEventNotification(EventNotification): + """ + Represents an EventNotification payload that the SDK doesn't have types for. May have a related object. + """ + + related_object: Optional[RelatedObject] = None + """ + [Optional] Object containing the reference to API resource relevant to the event. + """ + + def __init__( + self, parsed_body: Dict[str, Any], client: "StripeClient" + ) -> None: + super().__init__(parsed_body, client) + + if parsed_body.get("related_object"): + self.related_object = RelatedObject(parsed_body["related_object"]) + + def fetch_related_object(self) -> Optional[StripeObject]: + if self.related_object is None: + return None + + response = self._client.raw_request( + "get", + self.related_object.url, + stripe_context=self.context, + usage=["fetch_related_object", "unknown_event"], + ) + return self._client.deserialize( + response, + api_mode=get_api_mode(self.related_object.url), + ) + + async def fetch_related_object_async(self) -> Optional[StripeObject]: + if self.related_object is None: + return None + + response = await self._client.raw_request_async( + "get", + self.related_object.url, + stripe_context=self.context, + usage=["fetch_related_object", "unknown_event"], + ) + return self._client.deserialize( + response, + api_mode=get_api_mode(self.related_object.url), + ) diff --git a/tests/test_util.py b/tests/test_util.py index 93b75c84f..aa01eed5d 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,8 +1,12 @@ import sys from collections import namedtuple +import pytest + import stripe from stripe import util +from stripe._api_mode import ApiMode +from stripe._util import get_api_mode LogTestCase = namedtuple("LogTestCase", "env flag should_output") FmtTestCase = namedtuple("FmtTestCase", "props expected") @@ -152,3 +156,16 @@ def test_sanitize_id(self): if isinstance(sanitized_id, bytes): sanitized_id = sanitized_id.decode("utf-8", "strict") assert sanitized_id == "cu++%25x+123" + + @pytest.mark.parametrize( + ["url", "expected"], + [ + ("/v2/core/events", "V2"), + ("/v2/v1/core/events", "V2"), + ("/v1/events", "V1"), + ("/oauth/authorize", "V1"), + ("something/v2/core/events", "V1"), + ], + ) + def test_get_api_mode(self, url: str, expected: ApiMode): + assert get_api_mode(url) == expected diff --git a/tests/test_v2_event.py b/tests/test_v2_event.py index 32bd3fabb..ea9b0b9bc 100644 --- a/tests/test_v2_event.py +++ b/tests/test_v2_event.py @@ -1,16 +1,24 @@ import json +import sys from typing import Callable import pytest +from stripe.billing._meter import Meter +from tests.http_client_mock import HTTPClientMock -import stripe -from stripe import ThinEvent + +from stripe import DEFAULT_API_BASE +from stripe._error import SignatureVerificationError +from stripe._stripe_client import StripeClient from stripe.events._v1_billing_meter_error_report_triggered_event import ( + V1BillingMeterErrorReportTriggeredEventNotification, V1BillingMeterErrorReportTriggeredEvent, ) +from stripe.v2._event import UnknownEventNotification +from stripe.events._event_classes import ALL_EVENT_NOTIFICATIONS from tests.test_webhook import DUMMY_WEBHOOK_SECRET, generate_header -EventParser = Callable[[str], ThinEvent] +EventParser = Callable[[str], ALL_EVENT_NOTIFICATIONS] class TestV2Event(object): @@ -22,16 +30,16 @@ def v2_payload_no_data(self): "object": "v2.core.event", "type": "v1.billing.meter.error_report_triggered", "livemode": True, + "context": "acct_123", "created": "2022-02-15T00:27:45.330Z", "related_object": { "id": "mtr_123", "type": "billing.meter", "url": "/v1/billing/meters/mtr_123", - "stripe_context": "acct_123", }, "reason": { - "id": "foo", - "idempotency_key": "bar", + "type": "request", + "request": {"id": "foo", "idempotency_key": "bar"}, }, } ) @@ -61,56 +69,83 @@ def v2_payload_with_data(self): @pytest.fixture(scope="function") def stripe_client(self, http_client_mock): - return stripe.StripeClient( - api_key="keyinfo_test_123", - stripe_context="wksp_123", + return StripeClient( + api_key="sk_test_1234", http_client=http_client_mock.get_mock_http_client(), ) @pytest.fixture(scope="function") - def parse_thin_event( - self, stripe_client: stripe.StripeClient - ) -> EventParser: + def parse_event_notif(self, stripe_client: StripeClient) -> EventParser: """ helper to simplify parsing and validating events given a payload returns a function that has the client pre-bound """ - def _parse_thin_event(payload: str): - return stripe_client.parse_thin_event( + def _parse_event_notif(payload: str): + return stripe_client.parse_event_notification( payload, generate_header(payload=payload), DUMMY_WEBHOOK_SECRET ) - return _parse_thin_event + return _parse_event_notif - def test_parses_thin_event( - self, parse_thin_event: EventParser, v2_payload_no_data: str + def test_parses_event_notif( + self, parse_event_notif: EventParser, v2_payload_no_data: str ): - event = parse_thin_event(v2_payload_no_data) + notif = parse_event_notif(v2_payload_no_data) - assert isinstance(event, ThinEvent) - assert event.id == "evt_234" + assert isinstance( + notif, V1BillingMeterErrorReportTriggeredEventNotification + ) + assert notif.id == "evt_234" - assert event.related_object - assert event.related_object.id == "mtr_123" + assert notif.related_object + assert notif.related_object.id == "mtr_123" - assert event.reason - assert event.reason.id == "foo" + assert notif.reason + assert notif.reason.type == "request" + assert notif.reason.request + assert notif.reason.request.id == "foo" + assert notif.reason.request.idempotency_key == "bar" - def test_parses_thin_event_with_data( - self, parse_thin_event: EventParser, v2_payload_with_data: str + def test_parses_event_notif_with_data( + self, parse_event_notif: EventParser, v2_payload_with_data: str ): - event = parse_thin_event(v2_payload_with_data) + notif = parse_event_notif(v2_payload_with_data) + + assert isinstance( + notif, V1BillingMeterErrorReportTriggeredEventNotification + ) + # this isn't for constructing events, it's for parsing thin ones + assert not hasattr(notif, "data") + assert notif.reason is None - assert isinstance(event, ThinEvent) - assert not hasattr(event, "data") - assert event.reason is None + def test_parses_unknown_event_notif(self, parse_event_notif: EventParser): + event = parse_event_notif( + json.dumps( + { + "id": "evt_234", + "object": "v2.core.event", + "type": "uknown.event.type", + "livemode": True, + "created": "2022-02-15T00:27:45.330Z", + "context": "acct_456", + "related_object": { + "id": "mtr_123", + "type": "billing.meter", + "url": "/v1/billing/meters/mtr_123", + }, + } + ) + ) + + assert type(event) is UnknownEventNotification + assert event.related_object def test_validates_signature( - self, stripe_client: stripe.StripeClient, v2_payload_no_data + self, stripe_client: StripeClient, v2_payload_no_data ): - with pytest.raises(stripe.error.SignatureVerificationError): - stripe_client.parse_thin_event( + with pytest.raises(SignatureVerificationError): + stripe_client.parse_event_notification( v2_payload_no_data, "bad header", DUMMY_WEBHOOK_SECRET ) @@ -124,17 +159,19 @@ def test_v2_events_data_type(self, http_client_mock, v2_payload_with_data): rcode=200, rheaders={}, ) - client = stripe.StripeClient( - api_key="keyinfo_test_123", + client = StripeClient( + api_key="sk_test_1234", + stripe_context="org_456", http_client=http_client_mock.get_mock_http_client(), ) event = client.v2.core.events.retrieve("evt_123") http_client_mock.assert_requested( method, - api_base=stripe.DEFAULT_API_BASE, + api_base=DEFAULT_API_BASE, path=path, - api_key="keyinfo_test_123", + api_key="sk_test_1234", + stripe_context="org_456", ) assert event.id is not None assert isinstance(event, V1BillingMeterErrorReportTriggeredEvent) @@ -144,3 +181,87 @@ def test_v2_events_data_type(self, http_client_mock, v2_payload_with_data): V1BillingMeterErrorReportTriggeredEvent.V1BillingMeterErrorReportTriggeredEventData, ) assert event.data.reason.error_count == 1 + + # an "integration" shaped test with all the bells and whistles + def test_v2_events_integration( + self, + http_client_mock: HTTPClientMock, + v2_payload_no_data, + v2_payload_with_data, + # use the real client so we get the real types + stripe_client: StripeClient, + ): + method = "get" + path = "/v2/core/events/evt_234" + meter_path = "/v1/billing/meters/mtr_123" + + http_client_mock.stub_request( + method, + path=path, + rbody=v2_payload_with_data, + rcode=200, + rheaders={}, + ) + http_client_mock.stub_request( + method, + path=meter_path, + rbody=json.dumps( + { + "id": "mtr_123", + "object": "billing.meter", + "event_name": "cool event", + } + ), + rcode=200, + rheaders={}, + ) + + event_notif = stripe_client.parse_event_notification( + v2_payload_no_data, + generate_header(payload=v2_payload_no_data), + DUMMY_WEBHOOK_SECRET, + ) + assert event_notif.type == "v1.billing.meter.error_report_triggered" + + event = event_notif.fetch_event() + meter = event_notif.fetch_related_object() + + if sys.version_info >= (3, 7): + from typing_extensions import assert_type # noqa: SPY103 - this is only available on 3.6 pythons because of typing_extensions version restrictions + + # these are purely type-level checks to ensure our narrowing works for users + assert_type( + event_notif, + V1BillingMeterErrorReportTriggeredEventNotification, + ) + + assert_type(event, V1BillingMeterErrorReportTriggeredEvent) + assert_type(meter, Meter) + + assert isinstance(event, V1BillingMeterErrorReportTriggeredEvent) + + http_client_mock.assert_requested( + method, + api_base=DEFAULT_API_BASE, + path=path, + api_key="sk_test_1234", + # context read from event + stripe_context="acct_123", + ) + http_client_mock.assert_requested( + method, + api_base=DEFAULT_API_BASE, + path=meter_path, + api_key="sk_test_1234", + # context read from event + stripe_context="acct_123", + ) + + assert isinstance( + event.data, + V1BillingMeterErrorReportTriggeredEvent.V1BillingMeterErrorReportTriggeredEventData, + ) + assert event.data.reason.error_count == 1 + + assert isinstance(meter, Meter) + assert meter.event_name == "cool event"