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`.
|
||||
|
||||
## 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.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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
)
|
||||
|
|
|
|||
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.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
|
||||
Loading…
Add table
Add a link
Reference in a new issue