Add webhook utilities and FastAPI integration for eBay notifications; enhance tests for request handling
This commit is contained in:
parent
10008a0edc
commit
30b62dedab
5 changed files with 293 additions and 2 deletions
11
README.md
11
README.md
|
|
@ -24,3 +24,14 @@ To generate only one API package:
|
||||||
```
|
```
|
||||||
|
|
||||||
This regenerates Pydantic v2 models into `ebay_client/generated/<api>/models.py`.
|
This regenerates Pydantic v2 models into `ebay_client/generated/<api>/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`.
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
from ebay_client.notification.client import NotificationClient
|
from ebay_client.notification.client import NotificationClient
|
||||||
from ebay_client.notification.webhook import (
|
from ebay_client.notification.webhook import (
|
||||||
WebhookChallengeHandler,
|
WebhookChallengeHandler,
|
||||||
|
WebhookDispatchResult,
|
||||||
WebhookEventEnvelope,
|
WebhookEventEnvelope,
|
||||||
|
WebhookHttpResponse,
|
||||||
WebhookPublicKeyResolver,
|
WebhookPublicKeyResolver,
|
||||||
|
WebhookRequestHandler,
|
||||||
WebhookSignatureParser,
|
WebhookSignatureParser,
|
||||||
WebhookSignatureValidator,
|
WebhookSignatureValidator,
|
||||||
)
|
)
|
||||||
|
|
@ -10,8 +13,11 @@ from ebay_client.notification.webhook import (
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"NotificationClient",
|
"NotificationClient",
|
||||||
"WebhookChallengeHandler",
|
"WebhookChallengeHandler",
|
||||||
|
"WebhookDispatchResult",
|
||||||
"WebhookEventEnvelope",
|
"WebhookEventEnvelope",
|
||||||
|
"WebhookHttpResponse",
|
||||||
"WebhookPublicKeyResolver",
|
"WebhookPublicKeyResolver",
|
||||||
|
"WebhookRequestHandler",
|
||||||
"WebhookSignatureParser",
|
"WebhookSignatureParser",
|
||||||
"WebhookSignatureValidator",
|
"WebhookSignatureValidator",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,37 @@ class WebhookEventEnvelope(BaseModel):
|
||||||
topic_id: str | None = None
|
topic_id: str | None = None
|
||||||
data: dict[str, Any] | list[Any] | 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)
|
@dataclass(slots=True)
|
||||||
class ParsedSignatureHeader:
|
class ParsedSignatureHeader:
|
||||||
|
|
@ -184,3 +215,70 @@ class WebhookSignatureValidator:
|
||||||
if decoded is None:
|
if decoded is None:
|
||||||
return None
|
return None
|
||||||
return load_der_public_key(decoded)
|
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"),
|
||||||
|
)
|
||||||
|
|
|
||||||
71
examples/fastapi_notification_webhook.py
Normal file
71
examples/fastapi_notification_webhook.py
Normal file
|
|
@ -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"),
|
||||||
|
)
|
||||||
|
|
@ -8,7 +8,11 @@ from cryptography.hazmat.primitives.asymmetric import ec
|
||||||
|
|
||||||
from ebay_client.generated.notification.models import PublicKey
|
from ebay_client.generated.notification.models import PublicKey
|
||||||
from ebay_client.notification.webhook import WebhookChallengeHandler, WebhookSignatureParser
|
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:
|
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)
|
validator = WebhookSignatureValidator(resolver)
|
||||||
|
|
||||||
assert validator.validate(header_value=header, body=body) is True
|
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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue