From 30b62dedabaeceaae7b3d2e7d250be73db67a293 Mon Sep 17 00:00:00 2001 From: claudi Date: Tue, 7 Apr 2026 09:49:06 +0200 Subject: [PATCH] Add webhook utilities and FastAPI integration for eBay notifications; enhance tests for request handling --- README.md | 11 +++ ebay_client/notification/__init__.py | 6 ++ ebay_client/notification/webhook.py | 98 ++++++++++++++++++++ examples/fastapi_notification_webhook.py | 71 +++++++++++++++ tests/test_notification_webhook.py | 109 ++++++++++++++++++++++- 5 files changed, 293 insertions(+), 2 deletions(-) create mode 100644 examples/fastapi_notification_webhook.py diff --git a/README.md b/README.md index d051473..7e24122 100644 --- a/README.md +++ b/README.md @@ -24,3 +24,14 @@ To generate only one API package: ``` This regenerates Pydantic v2 models into `ebay_client/generated//models.py`. + +## Webhook Helpers + +The Notification package also includes framework-agnostic webhook utilities for: + +- responding to eBay challenge requests +- parsing and validating the `X-EBAY-SIGNATURE` header +- verifying signed notification payloads against the cached public key +- turning a verified notification body into a normalized `WebhookEventEnvelope` + +A concrete FastAPI integration example is available in `examples/fastapi_notification_webhook.py`. diff --git a/ebay_client/notification/__init__.py b/ebay_client/notification/__init__.py index 81fc4bc..01753f7 100644 --- a/ebay_client/notification/__init__.py +++ b/ebay_client/notification/__init__.py @@ -1,8 +1,11 @@ from ebay_client.notification.client import NotificationClient from ebay_client.notification.webhook import ( WebhookChallengeHandler, + WebhookDispatchResult, WebhookEventEnvelope, + WebhookHttpResponse, WebhookPublicKeyResolver, + WebhookRequestHandler, WebhookSignatureParser, WebhookSignatureValidator, ) @@ -10,8 +13,11 @@ from ebay_client.notification.webhook import ( __all__ = [ "NotificationClient", "WebhookChallengeHandler", + "WebhookDispatchResult", "WebhookEventEnvelope", + "WebhookHttpResponse", "WebhookPublicKeyResolver", + "WebhookRequestHandler", "WebhookSignatureParser", "WebhookSignatureValidator", ] diff --git a/ebay_client/notification/webhook.py b/ebay_client/notification/webhook.py index 531b599..e38d276 100644 --- a/ebay_client/notification/webhook.py +++ b/ebay_client/notification/webhook.py @@ -24,6 +24,37 @@ class WebhookEventEnvelope(BaseModel): topic_id: str | None = None data: dict[str, Any] | list[Any] | None = None + @classmethod + def from_payload(cls, payload: dict[str, Any] | list[Any]) -> WebhookEventEnvelope: + if isinstance(payload, list): + return cls(data=payload) + + metadata = dict(payload.get("metadata") or {}) if isinstance(payload.get("metadata"), dict) else {} + for key, value in payload.items(): + if key not in {"metadata", "notificationId", "notification_id", "publishDate", "publish_date", "topicId", "topic_id", "data"}: + metadata[key] = value + + return cls( + metadata=metadata, + notification_id=payload.get("notificationId") or payload.get("notification_id"), + publish_date=payload.get("publishDate") or payload.get("publish_date"), + topic_id=payload.get("topicId") or payload.get("topic_id"), + data=payload.get("data"), + ) + + +@dataclass(slots=True) +class WebhookHttpResponse: + status_code: int + headers: dict[str, str] + body: bytes = b"" + + +@dataclass(slots=True) +class WebhookDispatchResult: + response: WebhookHttpResponse + event: WebhookEventEnvelope | None = None + @dataclass(slots=True) class ParsedSignatureHeader: @@ -184,3 +215,70 @@ class WebhookSignatureValidator: if decoded is None: return None return load_der_public_key(decoded) + + +class WebhookRequestHandler: + def __init__( + self, + *, + signature_validator: WebhookSignatureValidator, + challenge_handler: WebhookChallengeHandler | None = None, + event_parser: Callable[[bytes], WebhookEventEnvelope] | None = None, + ) -> None: + self.signature_validator = signature_validator + self.challenge_handler = challenge_handler or WebhookChallengeHandler() + self.event_parser = event_parser or self.parse_event + + def handle_challenge(self, *, challenge_code: str | None, verification_token: str, endpoint: str) -> WebhookHttpResponse: + if not challenge_code: + return self._json_response(400, {"error": "challenge_code is required"}) + + payload = self.challenge_handler.build_response_body( + challenge_code=challenge_code, + verification_token=verification_token, + endpoint=endpoint, + ) + return self._json_response(200, payload) + + def handle_notification( + self, + *, + signature_header: str | None, + body: bytes, + message_builder: Callable[[bytes], bytes] | None = None, + ) -> WebhookDispatchResult: + if not signature_header or not self.signature_validator.validate( + header_value=signature_header, + body=body, + message_builder=message_builder, + ): + return WebhookDispatchResult( + response=WebhookHttpResponse(status_code=412, headers={}), + ) + + try: + event = self.event_parser(body) + except (UnicodeDecodeError, ValueError, TypeError): + return WebhookDispatchResult( + response=self._json_response(400, {"error": "notification body is not valid JSON"}), + ) + + return WebhookDispatchResult( + response=WebhookHttpResponse(status_code=200, headers={}), + event=event, + ) + + @staticmethod + def parse_event(body: bytes) -> WebhookEventEnvelope: + payload = json.loads(body.decode("utf-8")) + if isinstance(payload, dict) or isinstance(payload, list): + return WebhookEventEnvelope.from_payload(payload) + raise ValueError("notification payload must be a JSON object or array") + + @staticmethod + def _json_response(status_code: int, payload: dict[str, Any]) -> WebhookHttpResponse: + return WebhookHttpResponse( + status_code=status_code, + headers={"Content-Type": "application/json"}, + body=json.dumps(payload, separators=(",", ":")).encode("utf-8"), + ) diff --git a/examples/fastapi_notification_webhook.py b/examples/fastapi_notification_webhook.py new file mode 100644 index 0000000..29654ee --- /dev/null +++ b/examples/fastapi_notification_webhook.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import os + +from fastapi import FastAPI, Header, Request, Response + +from ebay_client.core.auth.models import EbayOAuthConfig +from ebay_client.client import EbayClient +from ebay_client.notification import WebhookPublicKeyResolver, WebhookRequestHandler, WebhookSignatureValidator + + +app = FastAPI() + +oauth_config = EbayOAuthConfig( + client_id=os.environ["EBAY_CLIENT_ID"], + client_secret=os.environ["EBAY_CLIENT_SECRET"], + default_scopes=["https://api.ebay.com/oauth/api_scope"], +) +ebay_client = EbayClient(oauth_config) + +verification_token = os.environ["EBAY_NOTIFICATION_VERIFICATION_TOKEN"] +public_key_resolver = WebhookPublicKeyResolver(ebay_client.notification.get_public_key) +webhook_handler = WebhookRequestHandler( + signature_validator=WebhookSignatureValidator(public_key_resolver) +) + + +def process_event(topic_id: str | None, payload: dict | list | None) -> None: + if topic_id == "MARKETPLACE_ACCOUNT_DELETION": + return + + +@app.get("/webhooks/ebay") +async def ebay_challenge(challenge_code: str | None = None, request: Request | None = None) -> Response: + if request is None: + return Response(status_code=500) + + endpoint = str(request.url.replace(query="")) + result = webhook_handler.handle_challenge( + challenge_code=challenge_code, + verification_token=verification_token, + endpoint=endpoint, + ) + return Response( + content=result.body, + status_code=result.status_code, + headers=result.headers, + media_type=result.headers.get("Content-Type"), + ) + + +@app.post("/webhooks/ebay") +async def ebay_notification( + request: Request, + x_ebay_signature: str | None = Header(default=None, alias="X-EBAY-SIGNATURE"), +) -> Response: + body = await request.body() + result = webhook_handler.handle_notification( + signature_header=x_ebay_signature, + body=body, + ) + + if result.event is not None: + process_event(result.event.topic_id, result.event.data) + + return Response( + content=result.response.body, + status_code=result.response.status_code, + headers=result.response.headers, + media_type=result.response.headers.get("Content-Type"), + ) \ No newline at end of file diff --git a/tests/test_notification_webhook.py b/tests/test_notification_webhook.py index 7e2653d..e4d9038 100644 --- a/tests/test_notification_webhook.py +++ b/tests/test_notification_webhook.py @@ -8,7 +8,11 @@ from cryptography.hazmat.primitives.asymmetric import ec from ebay_client.generated.notification.models import PublicKey from ebay_client.notification.webhook import WebhookChallengeHandler, WebhookSignatureParser -from ebay_client.notification.webhook import WebhookPublicKeyResolver, WebhookSignatureValidator +from ebay_client.notification.webhook import ( + WebhookPublicKeyResolver, + WebhookRequestHandler, + WebhookSignatureValidator, +) def test_challenge_handler_builds_sha256_response() -> None: @@ -82,4 +86,105 @@ def test_signature_validator_verifies_base64_json_header_and_der_key() -> None: ) validator = WebhookSignatureValidator(resolver) - assert validator.validate(header_value=header, body=body) is True \ No newline at end of file + assert validator.validate(header_value=header, body=body) is True + + +def test_request_handler_returns_json_challenge_response() -> None: + handler = WebhookRequestHandler( + signature_validator=WebhookSignatureValidator( + WebhookPublicKeyResolver(lambda _: PublicKey(key="", algorithm="ECDSA", digest="SHA256")) + ) + ) + + response = handler.handle_challenge( + challenge_code="challenge", + verification_token="verification", + endpoint="https://example.test/webhook", + ) + + assert response.status_code == 200 + assert response.headers["Content-Type"] == "application/json" + body = json.loads(response.body.decode("utf-8")) + assert "challengeResponse" in body + + +def test_request_handler_accepts_verified_notification_and_parses_event() -> None: + private_key = ec.generate_private_key(ec.SECP256R1()) + public_key = private_key.public_key() + public_key_der = public_key.public_bytes( + encoding=serialization.Encoding.DER, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + body = json.dumps( + { + "notificationId": "abc-123", + "publishDate": "2026-04-07T00:00:00.000Z", + "topicId": "MARKETPLACE_ACCOUNT_DELETION", + "data": {"userId": "user-1"}, + "schemaVersion": "1.0", + } + ).encode("utf-8") + signature = private_key.sign(body, ec.ECDSA(hashes.SHA256())) + header = base64.b64encode( + json.dumps( + { + "alg": "ECDSA", + "kid": "public-key-1", + "signature": base64.b64encode(signature).decode("ascii"), + "digest": "SHA256", + } + ).encode("utf-8") + ).decode("ascii") + + resolver = WebhookPublicKeyResolver( + lambda key_id: PublicKey( + key=base64.b64encode(public_key_der).decode("ascii"), + algorithm="ECDSA", + digest="SHA256", + ) + ) + handler = WebhookRequestHandler(signature_validator=WebhookSignatureValidator(resolver)) + + result = handler.handle_notification(signature_header=header, body=body) + + assert result.response.status_code == 200 + assert result.event is not None + assert result.event.notification_id == "abc-123" + assert result.event.topic_id == "MARKETPLACE_ACCOUNT_DELETION" + assert result.event.metadata["schemaVersion"] == "1.0" + + +def test_request_handler_returns_412_for_invalid_signature() -> None: + private_key = ec.generate_private_key(ec.SECP256R1()) + public_key = private_key.public_key() + public_key_der = public_key.public_bytes( + encoding=serialization.Encoding.DER, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + body = b'{"notificationId":"abc-123"}' + wrong_body = b'{"notificationId":"def-456"}' + signature = private_key.sign(body, ec.ECDSA(hashes.SHA256())) + header = base64.b64encode( + json.dumps( + { + "alg": "ECDSA", + "kid": "public-key-1", + "signature": base64.b64encode(signature).decode("ascii"), + "digest": "SHA256", + } + ).encode("utf-8") + ).decode("ascii") + + resolver = WebhookPublicKeyResolver( + lambda key_id: PublicKey( + key=base64.b64encode(public_key_der).decode("ascii"), + algorithm="ECDSA", + digest="SHA256", + ) + ) + handler = WebhookRequestHandler(signature_validator=WebhookSignatureValidator(resolver)) + + result = handler.handle_notification(signature_header=header, body=wrong_body) + + assert result.response.status_code == 412 + assert result.event is None \ No newline at end of file