Add webhook utilities and FastAPI integration for eBay notifications; enhance tests for request handling

This commit is contained in:
claudi 2026-04-07 09:49:06 +02:00
parent 10008a0edc
commit 30b62dedab
5 changed files with 293 additions and 2 deletions

View file

@ -24,3 +24,14 @@ To generate only one API package:
```
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`.

View file

@ -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",
]

View file

@ -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"),
)

View 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"),
)

View file

@ -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
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