feat: Implement webhook retry logic and server examples
- Added retry logic for webhook event delivery in `retry.py` with exponential backoff. - Created example webhook server implementations in `server.py` for Flask and FastAPI. - Developed comprehensive examples for using the Elytra webhooks subpackage in `webhook_examples.py`. - Introduced unit tests for webhook functionality, including event handling, authentication, and retry logic in `test_webhooks.py`.
This commit is contained in:
parent
4fd9f13f36
commit
b8f889f224
9 changed files with 2487 additions and 0 deletions
420
elytra_client/webhooks/README.md
Normal file
420
elytra_client/webhooks/README.md
Normal file
|
|
@ -0,0 +1,420 @@
|
||||||
|
"""
|
||||||
|
# Elytra Webhooks Subpackage Documentation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The `elytra_client.webhooks` subpackage provides comprehensive tools for handling CloudEvents-based webhooks from the Elytra PIM Event API. It includes:
|
||||||
|
|
||||||
|
- **CloudEvents Models**: Strongly-typed Pydantic models for CloudEvents specification
|
||||||
|
- **Authentication**: Support for Basic, Bearer, and API Key authentication
|
||||||
|
- **Validation**: Robust webhook payload validation
|
||||||
|
- **Event Handling**: Event dispatching and routing
|
||||||
|
- **Retry Logic**: Exponential backoff retry policies
|
||||||
|
- **Framework Integration**: Ready-to-use examples for Flask and FastAPI
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
The webhooks subpackage is included with the elytra-pim-client package. No additional installation is needed.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from elytra_client.webhooks import CloudEvent, parse_webhook_payload
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Basic Event Parsing
|
||||||
|
|
||||||
|
Parse incoming webhook payloads without authentication validation:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from elytra_client.webhooks import parse_webhook_payload
|
||||||
|
|
||||||
|
# In your webhook endpoint
|
||||||
|
event = parse_webhook_payload(request.json())
|
||||||
|
print(f"Event type: {event.eventtype}")
|
||||||
|
print(f"Object ID: {event.get_object_id()}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Authentication
|
||||||
|
|
||||||
|
Add authentication validation:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from elytra_client.webhooks import (
|
||||||
|
WebhookValidator,
|
||||||
|
BasicAuth
|
||||||
|
)
|
||||||
|
|
||||||
|
auth = BasicAuth(username="user", password="pass")
|
||||||
|
validator = WebhookValidator(auth_type="basic", auth=auth)
|
||||||
|
|
||||||
|
event = validator.validate_and_parse(
|
||||||
|
payload=request.json(),
|
||||||
|
headers=dict(request.headers)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Core Components
|
||||||
|
|
||||||
|
### CloudEvent Model
|
||||||
|
|
||||||
|
The `CloudEvent` class represents an event following the CNCF CloudEvents specification v1.0.
|
||||||
|
|
||||||
|
**Key Fields:**
|
||||||
|
- `specversion`: CloudEvents spec version (always "1.0")
|
||||||
|
- `id`: Unique event identifier
|
||||||
|
- `source`: Event source (e.g., "elytra-pim")
|
||||||
|
- `type`: Event type in format `com.elytra.<event_type>`
|
||||||
|
- `eventtype`: Elytra-specific event type (product, product_attribute_value, etc.)
|
||||||
|
- `operation`: Operation type (add, modify, remove, link, unlink)
|
||||||
|
- `data`: Event payload containing object data and metadata
|
||||||
|
|
||||||
|
**Useful Methods:**
|
||||||
|
- `get_event_type()`: Returns parsed EventType enum
|
||||||
|
- `get_operation()`: Returns parsed Operation enum
|
||||||
|
- `get_object_data()`: Extracts objectData from payload
|
||||||
|
- `get_object_id()`: Extracts object ID from payload
|
||||||
|
- `get_object_type()`: Extracts object type from payload
|
||||||
|
|
||||||
|
### Event Types and Operations
|
||||||
|
|
||||||
|
**EventType Enum** includes all Elytra event types:
|
||||||
|
- Product events: `PRODUCT`, `PRODUCT_GROUP`, `PRODUCT_FOREST`, etc.
|
||||||
|
- Attribute events: `PRODUCT_ATTRIBUTES`, `PRODUCT_ATTRIBUTE_VALUE`, etc.
|
||||||
|
- Structure events: `PRODUCT_STRUCTURE`, `PRODUCT_TEXT`, etc.
|
||||||
|
- And many more...
|
||||||
|
|
||||||
|
**Operation Enum** includes standard operations:
|
||||||
|
- `ADD`: Object was created
|
||||||
|
- `MODIFY`: Object was modified
|
||||||
|
- `REMOVE`: Object was deleted
|
||||||
|
- `LINK`: Objects were linked
|
||||||
|
- `UNLINK`: Objects were unlinked
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
The package supports three authentication methods:
|
||||||
|
|
||||||
|
#### Basic Authentication
|
||||||
|
|
||||||
|
```python
|
||||||
|
from elytra_client.webhooks import BasicAuth, WebhookValidator
|
||||||
|
|
||||||
|
auth = BasicAuth(username="admin", password="secret")
|
||||||
|
validator = WebhookValidator(auth_type="basic", auth=auth)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Bearer Token Authentication
|
||||||
|
|
||||||
|
```python
|
||||||
|
from elytra_client.webhooks import BearerAuth, WebhookValidator
|
||||||
|
|
||||||
|
auth = BearerAuth(token="eyJhbGciOiJIUzI1NiIs...")
|
||||||
|
validator = WebhookValidator(auth_type="bearer", auth=auth)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### API Key Authentication
|
||||||
|
|
||||||
|
```python
|
||||||
|
from elytra_client.webhooks import APIKeyAuth, WebhookValidator
|
||||||
|
|
||||||
|
auth = APIKeyAuth(
|
||||||
|
api_key="sk_live_secret123",
|
||||||
|
header_name="X-API-Key" # Custom header name
|
||||||
|
)
|
||||||
|
validator = WebhookValidator(auth_type="apikey", auth=auth)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Webhook Validation
|
||||||
|
|
||||||
|
The `WebhookValidator` class handles authentication and payload validation:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from elytra_client.webhooks import (
|
||||||
|
WebhookValidator,
|
||||||
|
WebhookValidationError,
|
||||||
|
BasicAuth
|
||||||
|
)
|
||||||
|
|
||||||
|
auth = BasicAuth(username="user", password="pass")
|
||||||
|
validator = WebhookValidator(auth_type="basic", auth=auth)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Validate authentication and CloudEvent format
|
||||||
|
event = validator.validate_and_parse(
|
||||||
|
payload=incoming_json,
|
||||||
|
headers=incoming_headers
|
||||||
|
)
|
||||||
|
print(f"Valid event received: {event.id}")
|
||||||
|
except WebhookValidationError as e:
|
||||||
|
print(f"Validation failed: {e}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Event Routing and Dispatching
|
||||||
|
|
||||||
|
Use `SimpleWebhookEventDispatcher` to route events to different handlers:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from elytra_client.webhooks import EventType, Operation
|
||||||
|
from elytra_client.webhooks.server import SimpleWebhookEventDispatcher
|
||||||
|
|
||||||
|
dispatcher = SimpleWebhookEventDispatcher()
|
||||||
|
|
||||||
|
# Register handler for specific event type and operation
|
||||||
|
dispatcher.register_handler(
|
||||||
|
callback=handle_product_added,
|
||||||
|
event_type=EventType.PRODUCT,
|
||||||
|
operation=Operation.ADD
|
||||||
|
)
|
||||||
|
|
||||||
|
# Register handler for all operations of a type
|
||||||
|
dispatcher.register_handler(
|
||||||
|
callback=handle_product_changes,
|
||||||
|
event_type=EventType.PRODUCT
|
||||||
|
)
|
||||||
|
|
||||||
|
# Register default fallback handler
|
||||||
|
dispatcher.register_default_handler(handle_other_events)
|
||||||
|
|
||||||
|
# Dispatch events
|
||||||
|
dispatcher.dispatch(event)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Retry Logic
|
||||||
|
|
||||||
|
Configure and use retry policies for webhook delivery:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from elytra_client.webhooks import (
|
||||||
|
RetryConfig,
|
||||||
|
ExponentialBackoffRetry,
|
||||||
|
execute_with_retry
|
||||||
|
)
|
||||||
|
|
||||||
|
# Configure retry policy
|
||||||
|
retry_config = RetryConfig(
|
||||||
|
max_retries=5,
|
||||||
|
initial_delay=1000, # 1 second
|
||||||
|
backoff_multiplier=2.0,
|
||||||
|
max_delay=300000 # 5 minutes max
|
||||||
|
)
|
||||||
|
|
||||||
|
policy = ExponentialBackoffRetry(retry_config)
|
||||||
|
|
||||||
|
# Execute function with retries
|
||||||
|
try:
|
||||||
|
result = execute_with_retry(
|
||||||
|
some_function,
|
||||||
|
policy,
|
||||||
|
arg1="value"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed after retries: {e}")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Retry Delay Calculation:**
|
||||||
|
The delay follows exponential backoff: `delay = min(initial_delay * (multiplier ^ attempt), max_delay)`
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- Attempt 0: 1000ms (1 second)
|
||||||
|
- Attempt 1: 2000ms (2 seconds)
|
||||||
|
- Attempt 2: 4000ms (4 seconds)
|
||||||
|
- Attempt 3: 8000ms (8 seconds)
|
||||||
|
- etc., capped at max_delay
|
||||||
|
|
||||||
|
## Framework Integration
|
||||||
|
|
||||||
|
### Flask Example
|
||||||
|
|
||||||
|
```python
|
||||||
|
from flask import Flask, request, jsonify
|
||||||
|
from elytra_client.webhooks import BasicAuth, WebhookValidator
|
||||||
|
from elytra_client.webhooks.server import FlaskWebhookExample
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
# Setup
|
||||||
|
auth = BasicAuth(username="webhook_user", password="secret")
|
||||||
|
validator = WebhookValidator(auth_type="basic", auth=auth)
|
||||||
|
webhook_handler = FlaskWebhookExample(validator)
|
||||||
|
|
||||||
|
@app.route("/webhook", methods=["POST"])
|
||||||
|
def handle_webhook():
|
||||||
|
response, status = webhook_handler.handle_request(
|
||||||
|
request.json,
|
||||||
|
dict(request.headers)
|
||||||
|
)
|
||||||
|
return jsonify(response), status
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(port=5000)
|
||||||
|
```
|
||||||
|
|
||||||
|
### FastAPI Example
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from elytra_client.webhooks import APIKeyAuth, WebhookValidator
|
||||||
|
from elytra_client.webhooks.server import FastAPIWebhookExample
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
# Setup
|
||||||
|
auth = APIKeyAuth(api_key="sk_live_secret", header_name="X-API-Key")
|
||||||
|
validator = WebhookValidator(auth_type="apikey", auth=auth)
|
||||||
|
webhook_handler = FastAPIWebhookExample(validator)
|
||||||
|
|
||||||
|
@app.post("/webhook")
|
||||||
|
async def handle_webhook(request: Request):
|
||||||
|
payload = await request.json()
|
||||||
|
return webhook_handler.handle_request(
|
||||||
|
payload,
|
||||||
|
dict(request.headers)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Production Example
|
||||||
|
|
||||||
|
```python
|
||||||
|
from flask import Flask, request, jsonify
|
||||||
|
from elytra_client.webhooks import (
|
||||||
|
BasicAuth,
|
||||||
|
WebhookValidator,
|
||||||
|
EventType,
|
||||||
|
Operation
|
||||||
|
)
|
||||||
|
from elytra_client.webhooks.server import (
|
||||||
|
FlaskWebhookExample,
|
||||||
|
SimpleWebhookEventDispatcher
|
||||||
|
)
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
# Setup authentication
|
||||||
|
auth = BasicAuth(username="elytra", password="secure_password")
|
||||||
|
validator = WebhookValidator(auth_type="basic", auth=auth)
|
||||||
|
|
||||||
|
# Setup event routing
|
||||||
|
dispatcher = SimpleWebhookEventDispatcher()
|
||||||
|
|
||||||
|
def sync_product_to_db(event):
|
||||||
|
product_id = event.get_object_id()
|
||||||
|
print(f"Syncing product {product_id} to database...")
|
||||||
|
# Your sync logic here
|
||||||
|
|
||||||
|
dispatcher.register_handler(
|
||||||
|
sync_product_to_db,
|
||||||
|
EventType.PRODUCT,
|
||||||
|
Operation.ADD
|
||||||
|
)
|
||||||
|
|
||||||
|
# Setup webhook handler
|
||||||
|
webhook_handler = FlaskWebhookExample(validator, dispatcher)
|
||||||
|
|
||||||
|
@app.route("/webhook/elytra", methods=["POST"])
|
||||||
|
def elytra_webhook():
|
||||||
|
response, status = webhook_handler.handle_request(
|
||||||
|
request.json,
|
||||||
|
dict(request.headers)
|
||||||
|
)
|
||||||
|
return jsonify(response), status
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host="0.0.0.0", port=5000, debug=False)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration XML Reference
|
||||||
|
|
||||||
|
While this subpackage handles receiving webhooks, you'll configure them in Elytra's web client. Here's what the XML configuration looks like:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<event-consumer>
|
||||||
|
<type>webhook</type>
|
||||||
|
<events>
|
||||||
|
<event>
|
||||||
|
<types>
|
||||||
|
<type>product</type>
|
||||||
|
<type>product_attribute_value</type>
|
||||||
|
</types>
|
||||||
|
<operations>
|
||||||
|
<operation>add</operation>
|
||||||
|
<operation>modify</operation>
|
||||||
|
<operation>remove</operation>
|
||||||
|
</operations>
|
||||||
|
<webhook>
|
||||||
|
<url>https://your-app.com/webhook/elytra</url>
|
||||||
|
<method>post</method>
|
||||||
|
<auth-type>basic</auth-type>
|
||||||
|
<username>elytra</username>
|
||||||
|
<password>secure_password</password>
|
||||||
|
</webhook>
|
||||||
|
</event>
|
||||||
|
</events>
|
||||||
|
<retry maxRetries="5" initialDelay="1000" backoffMultiplier="2.0"/>
|
||||||
|
<cloudevents source="elytra-pim" typePrefix="com.elytra"/>
|
||||||
|
</event-consumer>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
The main exception to handle is `WebhookValidationError`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from elytra_client.webhooks import WebhookValidationError
|
||||||
|
|
||||||
|
try:
|
||||||
|
event = validator.validate_and_parse(payload, headers)
|
||||||
|
except WebhookValidationError as e:
|
||||||
|
# Handle validation error
|
||||||
|
log.error(f"Webhook validation failed: {e}")
|
||||||
|
return {"status": "error"}, 400
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Always Validate**: Use `WebhookValidator` to validate both authentication and event structure
|
||||||
|
2. **Use Type Hints**: Leverage the strongly-typed models for better IDE support
|
||||||
|
3. **Handle Errors**: Implement proper error handling and logging
|
||||||
|
4. **Implement Idempotency**: Since webhooks may be retried, ensure your handlers are idempotent
|
||||||
|
5. **Acknowledge Quickly**: Return success response immediately; do heavy processing async
|
||||||
|
6. **Log Events**: Log all webhook events for debugging and audit trails
|
||||||
|
7. **Secure Credentials**: Store authentication credentials securely (environment variables, secrets manager)
|
||||||
|
8. **Use HTTPS**: Always use HTTPS URLs for webhook endpoints
|
||||||
|
9. **Implement Timeouts**: Set reasonable timeouts for webhook processing
|
||||||
|
10. **Monitor**: Set up monitoring and alerts for webhook failures
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### CloudEvent
|
||||||
|
- `specversion: str` - CloudEvents spec version
|
||||||
|
- `id: str` - Unique event identifier
|
||||||
|
- `source: str` - Event source
|
||||||
|
- `type: str` - Event type (com.elytra.*)
|
||||||
|
- `datacontenttype: str` - Content type
|
||||||
|
- `time: datetime | None` - Event timestamp
|
||||||
|
- `eventtype: str | None` - Elytra event type
|
||||||
|
- `operation: str | None` - Operation type
|
||||||
|
- `data: dict | None` - Event payload
|
||||||
|
- `subject: str | None` - Event subject
|
||||||
|
- `dataschema: str | None` - Data schema URI
|
||||||
|
|
||||||
|
### WebhookValidator
|
||||||
|
- `validate(payload, headers=None) -> bool` - Validate without parsing
|
||||||
|
- `parse(payload) -> CloudEvent` - Parse without validating auth
|
||||||
|
- `validate_and_parse(payload, headers=None) -> CloudEvent` - Full validation and parsing
|
||||||
|
|
||||||
|
### RetryConfig
|
||||||
|
- `max_retries: int` (default: 5) - Maximum retry attempts
|
||||||
|
- `initial_delay: int` (default: 1000) - Initial delay in milliseconds
|
||||||
|
- `backoff_multiplier: float` (default: 2.0) - Exponential backoff multiplier
|
||||||
|
- `max_delay: int` (default: 300000) - Maximum delay in milliseconds
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [CloudEvents Specification](https://cloudevents.io/)
|
||||||
|
- [Elytra PIM Documentation](https://www.elytra.ch/)
|
||||||
|
- [examples/webhook_examples.py](../examples/webhook_examples.py) - Comprehensive usage examples
|
||||||
|
"""
|
||||||
|
|
||||||
|
# This is a documentation file - it can be read as a docstring or converted to markdown
|
||||||
|
__doc__ = __doc__
|
||||||
75
elytra_client/webhooks/__init__.py
Normal file
75
elytra_client/webhooks/__init__.py
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
"""Webhook helper subpackage for Elytra Event API integration.
|
||||||
|
|
||||||
|
This package provides utilities for handling CloudEvents-based webhooks
|
||||||
|
from the Elytra PIM Event API, including event parsing, validation,
|
||||||
|
authentication, and retry logic.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
Basic webhook event handling::
|
||||||
|
|
||||||
|
from elytra_client.webhooks import CloudEvent, parse_webhook_payload
|
||||||
|
|
||||||
|
# Parse incoming webhook request
|
||||||
|
event = parse_webhook_payload(request_json)
|
||||||
|
|
||||||
|
# Access event data
|
||||||
|
if event.type == "com.elytra.product.attribute.value":
|
||||||
|
print(f"Product attribute changed: {event.data}")
|
||||||
|
|
||||||
|
With authentication::
|
||||||
|
|
||||||
|
from elytra_client.webhooks import WebhookValidator
|
||||||
|
from elytra_client.webhooks.auth import BasicAuth
|
||||||
|
|
||||||
|
auth = BasicAuth(username="user", password="pass")
|
||||||
|
validator = WebhookValidator(auth=auth)
|
||||||
|
|
||||||
|
# Validate incoming webhook
|
||||||
|
is_valid = validator.validate(
|
||||||
|
payload=request_json,
|
||||||
|
headers=request_headers
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .auth import APIKeyAuthProvider, AuthProvider, BasicAuthProvider, BearerAuthProvider
|
||||||
|
from .handlers import WebhookValidator, parse_webhook_payload, validate_cloud_event
|
||||||
|
from .models import (
|
||||||
|
APIKeyAuth,
|
||||||
|
BasicAuth,
|
||||||
|
BearerAuth,
|
||||||
|
CloudEvent,
|
||||||
|
EventType,
|
||||||
|
Operation,
|
||||||
|
RetryConfig,
|
||||||
|
WebhookAuth,
|
||||||
|
WebhookConfig,
|
||||||
|
)
|
||||||
|
from .retry import ExponentialBackoffRetry, RetryPolicy, calculate_retry_delay
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Models
|
||||||
|
"CloudEvent",
|
||||||
|
"WebhookConfig",
|
||||||
|
"WebhookAuth",
|
||||||
|
"BasicAuth",
|
||||||
|
"BearerAuth",
|
||||||
|
"APIKeyAuth",
|
||||||
|
"EventType",
|
||||||
|
"Operation",
|
||||||
|
"RetryConfig",
|
||||||
|
# Handlers
|
||||||
|
"WebhookValidator",
|
||||||
|
"parse_webhook_payload",
|
||||||
|
"validate_cloud_event",
|
||||||
|
# Auth
|
||||||
|
"AuthProvider",
|
||||||
|
"BasicAuthProvider",
|
||||||
|
"BearerAuthProvider",
|
||||||
|
"APIKeyAuthProvider",
|
||||||
|
# Retry
|
||||||
|
"RetryPolicy",
|
||||||
|
"ExponentialBackoffRetry",
|
||||||
|
"calculate_retry_delay",
|
||||||
|
]
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
193
elytra_client/webhooks/auth.py
Normal file
193
elytra_client/webhooks/auth.py
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
"""Webhook authentication helpers for constructing and validating authentication headers."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
from .models import APIKeyAuth, BasicAuth, BearerAuth, WebhookAuth
|
||||||
|
|
||||||
|
|
||||||
|
class AuthProvider(ABC):
|
||||||
|
"""Abstract base class for webhook authentication providers."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_headers(self) -> Dict[str, str]:
|
||||||
|
"""Return the authentication headers to include in webhook requests."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def validate_header(self, headers: Dict[str, str]) -> bool:
|
||||||
|
"""Validate authentication headers from an incoming webhook request."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class BasicAuthProvider(AuthProvider):
|
||||||
|
"""HTTP Basic Authentication provider."""
|
||||||
|
|
||||||
|
def __init__(self, auth: BasicAuth):
|
||||||
|
"""Initialize with BasicAuth credentials.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
auth: BasicAuth model with username and password
|
||||||
|
"""
|
||||||
|
self.auth = auth
|
||||||
|
|
||||||
|
def get_headers(self) -> Dict[str, str]:
|
||||||
|
"""Generate Basic Authentication header.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with Authorization header
|
||||||
|
"""
|
||||||
|
credentials = f"{self.auth.username}:{self.auth.password}"
|
||||||
|
encoded = base64.b64encode(credentials.encode()).decode()
|
||||||
|
return {"Authorization": f"Basic {encoded}"}
|
||||||
|
|
||||||
|
def validate_header(self, headers: Dict[str, str]) -> bool:
|
||||||
|
"""Validate Basic Authentication header from incoming request.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
headers: Request headers dict (case-insensitive keys recommended)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if authentication is valid, False otherwise
|
||||||
|
"""
|
||||||
|
auth_header = None
|
||||||
|
for key, value in headers.items():
|
||||||
|
if key.lower() == "authorization":
|
||||||
|
auth_header = value
|
||||||
|
break
|
||||||
|
|
||||||
|
if not auth_header or not auth_header.startswith("Basic "):
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
encoded = auth_header.split(" ")[1]
|
||||||
|
decoded = base64.b64decode(encoded).decode()
|
||||||
|
username, password = decoded.split(":", 1)
|
||||||
|
return (
|
||||||
|
username == self.auth.username and password == self.auth.password
|
||||||
|
)
|
||||||
|
except (IndexError, ValueError, UnicodeDecodeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class BearerAuthProvider(AuthProvider):
|
||||||
|
"""HTTP Bearer Token Authentication provider."""
|
||||||
|
|
||||||
|
def __init__(self, auth: BearerAuth):
|
||||||
|
"""Initialize with BearerAuth token.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
auth: BearerAuth model with token
|
||||||
|
"""
|
||||||
|
self.auth = auth
|
||||||
|
|
||||||
|
def get_headers(self) -> Dict[str, str]:
|
||||||
|
"""Generate Bearer Authentication header.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with Authorization header
|
||||||
|
"""
|
||||||
|
return {"Authorization": f"Bearer {self.auth.token}"}
|
||||||
|
|
||||||
|
def validate_header(self, headers: Dict[str, str]) -> bool:
|
||||||
|
"""Validate Bearer Authentication header from incoming request.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
headers: Request headers dict (case-insensitive keys recommended)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if token matches, False otherwise
|
||||||
|
"""
|
||||||
|
auth_header = None
|
||||||
|
for key, value in headers.items():
|
||||||
|
if key.lower() == "authorization":
|
||||||
|
auth_header = value
|
||||||
|
break
|
||||||
|
|
||||||
|
if not auth_header or not auth_header.startswith("Bearer "):
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
token = auth_header.split(" ")[1]
|
||||||
|
return token == self.auth.token
|
||||||
|
except IndexError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class APIKeyAuthProvider(AuthProvider):
|
||||||
|
"""API Key Authentication provider."""
|
||||||
|
|
||||||
|
def __init__(self, auth: APIKeyAuth):
|
||||||
|
"""Initialize with APIKeyAuth credentials.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
auth: APIKeyAuth model with api_key and header_name
|
||||||
|
"""
|
||||||
|
self.auth = auth
|
||||||
|
|
||||||
|
def get_headers(self) -> Dict[str, str]:
|
||||||
|
"""Generate API Key header.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with API key header
|
||||||
|
"""
|
||||||
|
return {self.auth.header_name: self.auth.api_key}
|
||||||
|
|
||||||
|
def validate_header(self, headers: Dict[str, str]) -> bool:
|
||||||
|
"""Validate API Key header from incoming request.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
headers: Request headers dict (case-insensitive keys recommended)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if API key matches, False otherwise
|
||||||
|
"""
|
||||||
|
for key, value in headers.items():
|
||||||
|
if key.lower() == self.auth.header_name.lower():
|
||||||
|
return value == self.auth.api_key
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class NoAuthProvider(AuthProvider):
|
||||||
|
"""No authentication provider (for unauthenticated webhooks)."""
|
||||||
|
|
||||||
|
def get_headers(self) -> Dict[str, str]:
|
||||||
|
"""Return empty headers dict."""
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def validate_header(self, headers: Dict[str, str]) -> bool:
|
||||||
|
"""Always return True for no authentication."""
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def get_auth_provider(auth_type: str, auth: Optional[WebhookAuth] = None) -> AuthProvider:
|
||||||
|
"""Factory function to get the appropriate authentication provider.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
auth_type: Type of authentication ("none", "basic", "bearer", "apikey")
|
||||||
|
auth: Authentication credentials model
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AuthProvider instance appropriate for the auth_type
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If auth_type is unknown or auth is missing for non-none types
|
||||||
|
"""
|
||||||
|
if auth_type == "none":
|
||||||
|
return NoAuthProvider()
|
||||||
|
elif auth_type == "basic":
|
||||||
|
if not isinstance(auth, BasicAuth):
|
||||||
|
raise ValueError(f"Expected BasicAuth for auth_type 'basic', got {type(auth)}")
|
||||||
|
return BasicAuthProvider(auth)
|
||||||
|
elif auth_type == "bearer":
|
||||||
|
if not isinstance(auth, BearerAuth):
|
||||||
|
raise ValueError(f"Expected BearerAuth for auth_type 'bearer', got {type(auth)}")
|
||||||
|
return BearerAuthProvider(auth)
|
||||||
|
elif auth_type == "apikey":
|
||||||
|
if not isinstance(auth, APIKeyAuth):
|
||||||
|
raise ValueError(f"Expected APIKeyAuth for auth_type 'apikey', got {type(auth)}")
|
||||||
|
return APIKeyAuthProvider(auth)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown auth_type: {auth_type}")
|
||||||
193
elytra_client/webhooks/handlers.py
Normal file
193
elytra_client/webhooks/handlers.py
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
"""Webhook request handlers and validators for CloudEvents from Elytra Event API."""
|
||||||
|
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from .auth import AuthProvider, get_auth_provider
|
||||||
|
from .models import CloudEvent, WebhookAuth
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookValidationError(Exception):
|
||||||
|
"""Raised when webhook validation fails."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookValidator:
|
||||||
|
"""Validates incoming webhook requests and extracts CloudEvent data.
|
||||||
|
|
||||||
|
Provides authentication validation and CloudEvent format validation.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> from elytra_client.webhooks import WebhookValidator, BasicAuth
|
||||||
|
>>> auth = BasicAuth(username="user", password="pass")
|
||||||
|
>>> validator = WebhookValidator(auth_type="basic", auth=auth)
|
||||||
|
>>> is_valid = validator.validate(payload, headers)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
auth_type: str = "none",
|
||||||
|
auth: Optional[WebhookAuth] = None,
|
||||||
|
):
|
||||||
|
"""Initialize the webhook validator.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
auth_type: Type of authentication ("none", "basic", "bearer", "apikey")
|
||||||
|
auth: Authentication credentials (required if auth_type is not "none")
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If auth configuration is invalid
|
||||||
|
"""
|
||||||
|
self.auth_type = auth_type
|
||||||
|
self.auth = auth
|
||||||
|
self.auth_provider = get_auth_provider(auth_type, auth)
|
||||||
|
|
||||||
|
def validate(
|
||||||
|
self,
|
||||||
|
payload: Dict[str, Any],
|
||||||
|
headers: Optional[Dict[str, str]] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Validate an incoming webhook request.
|
||||||
|
|
||||||
|
Validates both authentication and CloudEvent structure.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
payload: The request body as a dictionary
|
||||||
|
headers: Optional request headers dict for auth validation
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if validation succeeds
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
WebhookValidationError: If validation fails
|
||||||
|
"""
|
||||||
|
# Validate authentication if headers provided
|
||||||
|
if headers is not None and self.auth_type != "none":
|
||||||
|
if not self.auth_provider.validate_header(headers):
|
||||||
|
raise WebhookValidationError("Authentication validation failed")
|
||||||
|
|
||||||
|
# Validate CloudEvent structure
|
||||||
|
try:
|
||||||
|
CloudEvent(**payload)
|
||||||
|
except ValidationError as e:
|
||||||
|
raise WebhookValidationError(f"Invalid CloudEvent format: {str(e)}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def parse(self, payload: Dict[str, Any]) -> CloudEvent:
|
||||||
|
"""Parse and validate a webhook payload into a CloudEvent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
payload: The request body as a dictionary
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CloudEvent instance
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
WebhookValidationError: If payload is not a valid CloudEvent
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return CloudEvent(**payload)
|
||||||
|
except ValidationError as e:
|
||||||
|
raise WebhookValidationError(f"Invalid CloudEvent format: {str(e)}")
|
||||||
|
|
||||||
|
def validate_and_parse(
|
||||||
|
self,
|
||||||
|
payload: Dict[str, Any],
|
||||||
|
headers: Optional[Dict[str, str]] = None,
|
||||||
|
) -> CloudEvent:
|
||||||
|
"""Validate and parse a webhook request in one step.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
payload: The request body as a dictionary
|
||||||
|
headers: Optional request headers dict for auth validation
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CloudEvent instance if validation succeeds
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
WebhookValidationError: If validation or parsing fails
|
||||||
|
"""
|
||||||
|
self.validate(payload, headers)
|
||||||
|
return self.parse(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_webhook_payload(payload: Dict[str, Any]) -> CloudEvent:
|
||||||
|
"""Parse a webhook payload into a CloudEvent without authentication validation.
|
||||||
|
|
||||||
|
A convenience function for simple webhook parsing without authentication checks.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
payload: The request body as a dictionary
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CloudEvent instance
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
WebhookValidationError: If payload is not a valid CloudEvent
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> from elytra_client.webhooks import parse_webhook_payload
|
||||||
|
>>> event = parse_webhook_payload(request.json())
|
||||||
|
>>> print(f"Event type: {event.eventtype}")
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return CloudEvent(**payload)
|
||||||
|
except ValidationError as e:
|
||||||
|
raise WebhookValidationError(f"Invalid CloudEvent format: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_cloud_event(event: CloudEvent) -> bool:
|
||||||
|
"""Validate that a CloudEvent meets minimum requirements.
|
||||||
|
|
||||||
|
Checks that required fields are present and valid.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event: CloudEvent instance to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if event is valid
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
WebhookValidationError: If validation fails
|
||||||
|
"""
|
||||||
|
if not event.id:
|
||||||
|
raise WebhookValidationError("CloudEvent must have an 'id' field")
|
||||||
|
|
||||||
|
if not event.source:
|
||||||
|
raise WebhookValidationError("CloudEvent must have a 'source' field")
|
||||||
|
|
||||||
|
if not event.type:
|
||||||
|
raise WebhookValidationError("CloudEvent must have a 'type' field")
|
||||||
|
|
||||||
|
if not event.type.startswith("com.elytra."):
|
||||||
|
raise WebhookValidationError(
|
||||||
|
"CloudEvent type must start with 'com.elytra.' prefix"
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def extract_auth_headers(auth_type: str, auth: Optional[WebhookAuth]) -> Dict[str, str]:
|
||||||
|
"""Extract authentication headers for webhook requests.
|
||||||
|
|
||||||
|
Helper function to generate the appropriate auth headers when sending webhook requests.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
auth_type: Type of authentication ("none", "basic", "bearer", "apikey")
|
||||||
|
auth: Authentication credentials
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary of headers to include in webhook request
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> from elytra_client.webhooks.handlers import extract_auth_headers
|
||||||
|
>>> from elytra_client.webhooks import BasicAuth
|
||||||
|
>>> auth = BasicAuth(username="user", password="pass")
|
||||||
|
>>> headers = extract_auth_headers("basic", auth)
|
||||||
|
>>> # headers = {"Authorization": "Basic dXNlcjpwYXNz"}
|
||||||
|
"""
|
||||||
|
provider = get_auth_provider(auth_type, auth)
|
||||||
|
return provider.get_headers()
|
||||||
287
elytra_client/webhooks/models.py
Normal file
287
elytra_client/webhooks/models.py
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
"""Data models for CloudEvents-based webhooks following the CNCF CloudEvents specification.
|
||||||
|
|
||||||
|
Reference: https://cloudevents.io/
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Dict, Literal, Optional, Union
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, HttpUrl, validator
|
||||||
|
|
||||||
|
|
||||||
|
class Operation(str, Enum):
|
||||||
|
"""Supported event operation types."""
|
||||||
|
|
||||||
|
ADD = "add"
|
||||||
|
MODIFY = "modify"
|
||||||
|
REMOVE = "remove"
|
||||||
|
LINK = "link"
|
||||||
|
UNLINK = "unlink"
|
||||||
|
|
||||||
|
|
||||||
|
class EventType(str, Enum):
|
||||||
|
"""Elytra Event API event types."""
|
||||||
|
|
||||||
|
NONE = "none"
|
||||||
|
PROJECT = "project"
|
||||||
|
CUSTOMER = "customer"
|
||||||
|
TODO = "todo"
|
||||||
|
USER = "user"
|
||||||
|
CONVERSATION = "conversation"
|
||||||
|
USERGROUP = "usergroup"
|
||||||
|
CALENDAR = "calendar"
|
||||||
|
RIGHTS = "rights"
|
||||||
|
LANGUAGE = "language"
|
||||||
|
DOCUMENT = "document"
|
||||||
|
DOCUMENTFOLDER = "documentfolder"
|
||||||
|
PRODUCT_ATTRIBUTES = "product_attributes"
|
||||||
|
PRODUCT_ATTRIBUTE_GROUPS = "product_attribute_groups"
|
||||||
|
PRODUCT = "product"
|
||||||
|
PRODUCT_GROUP = "product_group"
|
||||||
|
PRODUCT_FOREST = "product_forest"
|
||||||
|
PRODUCT_ATTRIBUTE_VALUE = "product_attribute_value"
|
||||||
|
PRODUCT_TEXT = "product_text"
|
||||||
|
PRODUCT_COLLECTOR = "product_collector"
|
||||||
|
PRODUCT_LANGUAGE_FALLBACK = "product_language_fallback"
|
||||||
|
PRODUCT_PICTURE = "product_picture"
|
||||||
|
PRODUCT_STRUCTURE = "product_structure"
|
||||||
|
PRODUCT_TEXT_STYLE = "product_text_style"
|
||||||
|
PRODUCT_LANGUAGE = "product_language"
|
||||||
|
CATALOG_CONTENT = "catalog_content"
|
||||||
|
PRODUCT_IMPORT = "product_import"
|
||||||
|
PRODUCT_SEARCH_FILTER = "product_search_filter"
|
||||||
|
PRODUCT_PRODUCT_LINK = "product_product_link"
|
||||||
|
ATTRIBUTE_FORMAT = "attribute_format"
|
||||||
|
CLASSIFICATION_FEATUREMAPPING = "classification_featuremapping"
|
||||||
|
CONFIGURATION_FILES = "configuration_files"
|
||||||
|
WORKFLOW = "workflow"
|
||||||
|
WORKFLOW_TASK = "workflow_task"
|
||||||
|
PAGEPLAN_GRID = "pageplan_grid"
|
||||||
|
MEDIA_EXPLORER = "media_explorer"
|
||||||
|
ATTRIBUTE_UNIT = "attribute_unit"
|
||||||
|
JMS_CONNECTION_TEST = "jms_connection_test"
|
||||||
|
SCHEDULER_TREE_UPDATE = "scheduler_tree_update"
|
||||||
|
SCHEDULER_ACTIVE_TABLE_UPDATE = "scheduler_active_table_update"
|
||||||
|
JMS_PERFORMANCE_TEST = "jms_performance_test"
|
||||||
|
CONFIGFILE_HISTORY_REMOVE = "configfile_history_remove"
|
||||||
|
MIMETYPE_PLUGIN = "mimetype_plugin"
|
||||||
|
PAGEPLAN_PREVIEW = "pageplan_preview"
|
||||||
|
PAGEPLAN = "pageplan"
|
||||||
|
SHORTCUT = "shortcut"
|
||||||
|
PAGEPLAN_GRIDELEMENT = "pageplan_gridelement"
|
||||||
|
INTEGRATION_TEST = "integration_test"
|
||||||
|
SHORTCUT_DELETEALLFORUSER = "shortcut_deleteallforuser"
|
||||||
|
OBJECT_WORKFLOW = "object_workflow"
|
||||||
|
RULESET = "ruleset"
|
||||||
|
USER_PROPERTIES = "user_properties"
|
||||||
|
USER_ATTRIBUTEFILTER = "user_attributefilter"
|
||||||
|
ATTRIBUTE_RULE = "attribute_rule"
|
||||||
|
SERVER_CACHE_ATTRIBUTRULES_CHANGED = "server_cache_attributrules_changed"
|
||||||
|
FORMULA_UPDATE = "formula_update"
|
||||||
|
OBJECT_WORKFLOW_TEMPLATE = "object_workflow_template"
|
||||||
|
ASSETBROWSER_NODE_EVENT = "assetbrowser_node_event"
|
||||||
|
CLIENT_TEXT_MESSAGE = "client_text_message"
|
||||||
|
SESSION_CLOSED = "session_closed"
|
||||||
|
OBJECT_WORKFLOW_COMMENT = "object_workflow_comment"
|
||||||
|
ENUMERATION_ATTRIBUTE_CHAIN = "enumeration_attribute_chain"
|
||||||
|
ENUMERATION_VALUE_CHAIN = "enumeration_value_chain"
|
||||||
|
USER_FAVORITES = "user_favorites"
|
||||||
|
OBJECT_COLLECTION = "object_collection"
|
||||||
|
SCHEDULER_JOB = "scheduler_job"
|
||||||
|
ATTRIBUTE_UNITTYPE = "attribute_unittype"
|
||||||
|
PRODUCT_MASS_MODIFY = "product_mass_modify"
|
||||||
|
PRODUCT_SEARCH_SUBSCRIPTION = "product_search_subscription"
|
||||||
|
SCHEDULER_JOB_ACTIVE_UPDATE = "scheduler_job_active_update"
|
||||||
|
CONFIG = "config"
|
||||||
|
SCHEDULER_JOB_EXECUTION_STATUS_CHANGED = "scheduler_job_execution_status_changed"
|
||||||
|
INDEXUTILS_CACHE_CHANGED = "indexutils_cache_changed"
|
||||||
|
|
||||||
|
|
||||||
|
class CloudEvent(BaseModel):
|
||||||
|
"""CloudEvent model following CNCF CloudEvents specification v1.0.
|
||||||
|
|
||||||
|
This represents an event that comes from the Elytra PIM Event API.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
specversion: CloudEvents specification version (always "1.0")
|
||||||
|
id: Unique event identifier
|
||||||
|
source: Identifies the context in which an event happened
|
||||||
|
type: Describes the type of event in the format: com.elytra.<event_type>
|
||||||
|
datacontenttype: Content type of data (e.g., "application/json")
|
||||||
|
time: Timestamp of when the event occurred
|
||||||
|
eventtype: Elytra-specific event type
|
||||||
|
operation: Type of operation (add, modify, remove, link, unlink)
|
||||||
|
data: Event payload containing object data and metadata
|
||||||
|
subject: Optional subject of the event
|
||||||
|
"""
|
||||||
|
|
||||||
|
specversion: str = Field(default="1.0", description="CloudEvents spec version")
|
||||||
|
id: str = Field(..., description="Unique event identifier")
|
||||||
|
source: str = Field(..., description="Event source identifier")
|
||||||
|
type: str = Field(..., description="Event type in format com.elytra.<type>")
|
||||||
|
datacontenttype: str = Field(
|
||||||
|
default="application/json", description="Content type of the data"
|
||||||
|
)
|
||||||
|
time: Optional[datetime] = Field(
|
||||||
|
default=None, description="Timestamp when the event occurred"
|
||||||
|
)
|
||||||
|
eventtype: Optional[str] = Field(
|
||||||
|
default=None, description="Elytra-specific event type"
|
||||||
|
)
|
||||||
|
operation: Optional[str] = Field(
|
||||||
|
default=None, description="Operation type (add, modify, remove, link, unlink)"
|
||||||
|
)
|
||||||
|
data: Optional[Dict[str, Any]] = Field(
|
||||||
|
default=None, description="Event payload data"
|
||||||
|
)
|
||||||
|
subject: Optional[str] = Field(default=None, description="Event subject")
|
||||||
|
dataschema: Optional[str] = Field(default=None, description="Data schema URI")
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
"""Pydantic config."""
|
||||||
|
|
||||||
|
json_schema_extra = {
|
||||||
|
"example": {
|
||||||
|
"specversion": "1.0",
|
||||||
|
"id": "385d27ef-ac6d-4d93-904b-ea9801fc1bde",
|
||||||
|
"source": "elytra-pim",
|
||||||
|
"type": "com.elytra.product.attribute.value",
|
||||||
|
"datacontenttype": "application/json",
|
||||||
|
"time": "2025-12-31T04:19:30.860984129+01:00",
|
||||||
|
"eventtype": "product_attribute_value",
|
||||||
|
"operation": "modify",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_event_type(self) -> Optional[EventType]:
|
||||||
|
"""Extract and return the Elytra event type from the type field."""
|
||||||
|
if not self.eventtype:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return EventType(self.eventtype)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_operation(self) -> Optional[Operation]:
|
||||||
|
"""Extract and return the operation type."""
|
||||||
|
if not self.operation:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return Operation(self.operation)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_object_data(self) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Extract the objectData from the event payload."""
|
||||||
|
if not self.data:
|
||||||
|
return None
|
||||||
|
return self.data.get("objectData")
|
||||||
|
|
||||||
|
def get_object_id(self) -> Optional[Union[str, int]]:
|
||||||
|
"""Extract the object ID from the event payload."""
|
||||||
|
if not self.data:
|
||||||
|
return None
|
||||||
|
return self.data.get("objectId")
|
||||||
|
|
||||||
|
def get_object_type(self) -> Optional[str]:
|
||||||
|
"""Extract the object type from the event payload."""
|
||||||
|
if not self.data:
|
||||||
|
return None
|
||||||
|
return self.data.get("objectType")
|
||||||
|
|
||||||
|
|
||||||
|
class BasicAuth(BaseModel):
|
||||||
|
"""HTTP Basic Authentication credentials."""
|
||||||
|
|
||||||
|
username: str = Field(..., description="Username for authentication")
|
||||||
|
password: str = Field(..., description="Password for authentication")
|
||||||
|
|
||||||
|
|
||||||
|
class BearerAuth(BaseModel):
|
||||||
|
"""HTTP Bearer Token Authentication."""
|
||||||
|
|
||||||
|
token: str = Field(..., description="Bearer token for authentication")
|
||||||
|
|
||||||
|
|
||||||
|
class APIKeyAuth(BaseModel):
|
||||||
|
"""API Key Authentication."""
|
||||||
|
|
||||||
|
api_key: str = Field(..., description="API key value")
|
||||||
|
header_name: str = Field(
|
||||||
|
default="api-key", description="Header name for the API key"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
WebhookAuth = Union[BasicAuth, BearerAuth, APIKeyAuth]
|
||||||
|
|
||||||
|
|
||||||
|
class RetryConfig(BaseModel):
|
||||||
|
"""Webhook retry configuration.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
max_retries: Maximum number of retry attempts (default: 5)
|
||||||
|
initial_delay: Initial delay before retry in milliseconds (default: 1000)
|
||||||
|
backoff_multiplier: Multiplier for exponential backoff (default: 2.0)
|
||||||
|
max_delay: Maximum delay between retries in milliseconds (default: 300000 = 5 min)
|
||||||
|
"""
|
||||||
|
|
||||||
|
max_retries: int = Field(default=5, ge=0, description="Maximum retry attempts")
|
||||||
|
initial_delay: int = Field(
|
||||||
|
default=1000, ge=100, description="Initial delay in milliseconds"
|
||||||
|
)
|
||||||
|
backoff_multiplier: float = Field(
|
||||||
|
default=2.0, ge=1.0, description="Exponential backoff multiplier"
|
||||||
|
)
|
||||||
|
max_delay: int = Field(
|
||||||
|
default=300000, ge=1000, description="Max delay in milliseconds"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookConfig(BaseModel):
|
||||||
|
"""Webhook configuration for receiving Elytra events.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
url: The webhook URL where events will be posted
|
||||||
|
method: HTTP method (post, patch, put)
|
||||||
|
auth_type: Authentication method (none, basic, bearer, apikey)
|
||||||
|
auth: Authentication credentials (if auth_type is not "none")
|
||||||
|
event_types: List of event types to subscribe to (empty = all)
|
||||||
|
operations: List of operations to subscribe to (empty = all)
|
||||||
|
retry: Retry configuration
|
||||||
|
"""
|
||||||
|
|
||||||
|
url: HttpUrl = Field(..., description="Webhook URL")
|
||||||
|
method: Literal["post", "patch", "put"] = Field(
|
||||||
|
default="post", description="HTTP method"
|
||||||
|
)
|
||||||
|
auth_type: Literal["none", "basic", "bearer", "apikey"] = Field(
|
||||||
|
default="none", description="Authentication type"
|
||||||
|
)
|
||||||
|
auth: Optional[WebhookAuth] = Field(
|
||||||
|
default=None, description="Authentication credentials"
|
||||||
|
)
|
||||||
|
event_types: list[EventType] = Field(
|
||||||
|
default_factory=list, description="Event types to subscribe to"
|
||||||
|
)
|
||||||
|
operations: list[Operation] = Field(
|
||||||
|
default_factory=list, description="Operations to subscribe to"
|
||||||
|
)
|
||||||
|
retry: RetryConfig = Field(
|
||||||
|
default_factory=RetryConfig, description="Retry configuration"
|
||||||
|
)
|
||||||
|
active: bool = Field(default=True, description="Whether the webhook is active")
|
||||||
|
|
||||||
|
@validator("auth", always=True)
|
||||||
|
def validate_auth(cls, v: Optional[WebhookAuth], values: Dict[str, Any]) -> Optional[WebhookAuth]:
|
||||||
|
"""Validate that auth is provided when auth_type is not 'none'."""
|
||||||
|
auth_type = values.get("auth_type")
|
||||||
|
if auth_type != "none" and v is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"auth is required when auth_type is '{auth_type}'"
|
||||||
|
)
|
||||||
|
if auth_type == "none" and v is not None:
|
||||||
|
raise ValueError("auth should be None when auth_type is 'none'")
|
||||||
|
return v
|
||||||
178
elytra_client/webhooks/retry.py
Normal file
178
elytra_client/webhooks/retry.py
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
"""Retry logic and utilities for webhook event delivery."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Callable, Optional, Type, TypeVar
|
||||||
|
|
||||||
|
from .models import RetryConfig
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_retry_delay(
|
||||||
|
attempt: int,
|
||||||
|
initial_delay: int = 1000,
|
||||||
|
backoff_multiplier: float = 2.0,
|
||||||
|
max_delay: int = 300000,
|
||||||
|
) -> int:
|
||||||
|
"""Calculate the delay before the next retry attempt using exponential backoff.
|
||||||
|
|
||||||
|
Formula: delay = min(initial_delay * (backoff_multiplier ^ attempt), max_delay)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
attempt: Current retry attempt number (0-indexed)
|
||||||
|
initial_delay: Initial delay in milliseconds (default: 1000)
|
||||||
|
backoff_multiplier: Multiplier for exponential backoff (default: 2.0)
|
||||||
|
max_delay: Maximum delay in milliseconds (default: 300000 = 5 minutes)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Delay in milliseconds before next attempt
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> calculate_retry_delay(0) # First retry
|
||||||
|
1000
|
||||||
|
>>> calculate_retry_delay(1) # Second retry
|
||||||
|
2000
|
||||||
|
>>> calculate_retry_delay(2) # Third retry
|
||||||
|
4000
|
||||||
|
"""
|
||||||
|
if attempt < 0:
|
||||||
|
return initial_delay
|
||||||
|
|
||||||
|
delay = initial_delay * (backoff_multiplier ** attempt)
|
||||||
|
return int(min(delay, max_delay))
|
||||||
|
|
||||||
|
|
||||||
|
class RetryPolicy(ABC):
|
||||||
|
"""Abstract base class for retry policies."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def should_retry(self, attempt: int, exception: Optional[Exception] = None) -> bool:
|
||||||
|
"""Determine if a retry should be attempted.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
attempt: Current attempt number (0-indexed)
|
||||||
|
exception: Optional exception that caused the failure
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if retry should be attempted, False otherwise
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_delay(self, attempt: int) -> int:
|
||||||
|
"""Get delay in milliseconds before the next retry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
attempt: Current attempt number (0-indexed)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Delay in milliseconds
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def wait(self, attempt: int) -> None:
|
||||||
|
"""Wait before the next retry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
attempt: Current attempt number (0-indexed)
|
||||||
|
"""
|
||||||
|
delay_ms = self.get_delay(attempt)
|
||||||
|
time.sleep(delay_ms / 1000.0)
|
||||||
|
|
||||||
|
|
||||||
|
class ExponentialBackoffRetry(RetryPolicy):
|
||||||
|
"""Exponential backoff retry policy."""
|
||||||
|
|
||||||
|
def __init__(self, config: RetryConfig):
|
||||||
|
"""Initialize ExponentialBackoffRetry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: RetryConfig with max_retries, initial_delay, backoff_multiplier, max_delay
|
||||||
|
"""
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
def should_retry(self, attempt: int, exception: Optional[Exception] = None) -> bool:
|
||||||
|
"""Check if retry should be attempted.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
attempt: Current attempt number (0-indexed)
|
||||||
|
exception: Optional exception that caused the failure
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if attempts remain, False otherwise
|
||||||
|
"""
|
||||||
|
return attempt < self.config.max_retries
|
||||||
|
|
||||||
|
def get_delay(self, attempt: int) -> int:
|
||||||
|
"""Get delay using exponential backoff.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
attempt: Current attempt number (0-indexed)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Delay in milliseconds
|
||||||
|
"""
|
||||||
|
return calculate_retry_delay(
|
||||||
|
attempt=attempt,
|
||||||
|
initial_delay=self.config.initial_delay,
|
||||||
|
backoff_multiplier=self.config.backoff_multiplier,
|
||||||
|
max_delay=self.config.max_delay,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NoRetryPolicy(RetryPolicy):
|
||||||
|
"""No retry policy (fail immediately)."""
|
||||||
|
|
||||||
|
def should_retry(self, attempt: int, exception: Optional[Exception] = None) -> bool:
|
||||||
|
"""Never retry."""
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_delay(self, attempt: int) -> int:
|
||||||
|
"""Always return 0 delay."""
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def execute_with_retry(
|
||||||
|
func: Callable[..., T],
|
||||||
|
retry_policy: RetryPolicy,
|
||||||
|
*args,
|
||||||
|
**kwargs,
|
||||||
|
) -> T:
|
||||||
|
"""Execute a function with retry logic.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
func: The function to execute
|
||||||
|
retry_policy: RetryPolicy instance defining retry behavior
|
||||||
|
*args: Positional arguments to pass to func
|
||||||
|
**kwargs: Keyword arguments to pass to func
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The return value of func
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
The last exception raised if all retries are exhausted
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> from elytra_client.webhooks import ExponentialBackoffRetry
|
||||||
|
>>> config = RetryConfig(max_retries=3)
|
||||||
|
>>> policy = ExponentialBackoffRetry(config)
|
||||||
|
>>> result = execute_with_retry(
|
||||||
|
... some_func,
|
||||||
|
... policy,
|
||||||
|
... arg1="value",
|
||||||
|
... )
|
||||||
|
"""
|
||||||
|
attempt = 0
|
||||||
|
last_exception = None
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
except Exception as e:
|
||||||
|
last_exception = e
|
||||||
|
if not retry_policy.should_retry(attempt, e):
|
||||||
|
raise
|
||||||
|
|
||||||
|
retry_policy.wait(attempt)
|
||||||
|
attempt += 1
|
||||||
332
elytra_client/webhooks/server.py
Normal file
332
elytra_client/webhooks/server.py
Normal file
|
|
@ -0,0 +1,332 @@
|
||||||
|
"""Example webhook server implementations for handling Elytra Event API webhooks.
|
||||||
|
|
||||||
|
This module provides example implementations of webhook servers that can receive
|
||||||
|
CloudEvents from the Elytra PIM Event API. Choose the framework that best fits
|
||||||
|
your application.
|
||||||
|
|
||||||
|
Note: These are example implementations. Production deployments should add
|
||||||
|
additional error handling, logging, and security measures.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any, Callable, Dict, Optional
|
||||||
|
|
||||||
|
from .handlers import WebhookValidationError, WebhookValidator, parse_webhook_payload
|
||||||
|
from .models import CloudEvent, EventType, Operation
|
||||||
|
|
||||||
|
|
||||||
|
class EventHandler:
|
||||||
|
"""Base class for handling Elytra CloudEvents.
|
||||||
|
|
||||||
|
Subclass this to implement custom event handling logic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def handle_event(self, event: CloudEvent) -> None:
|
||||||
|
"""Handle a CloudEvent from Elytra.
|
||||||
|
|
||||||
|
Override this method to implement your event handling logic.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event: The CloudEvent to handle
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def handle_product_added(self, event: CloudEvent) -> None:
|
||||||
|
"""Handle product add events."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def handle_product_modified(self, event: CloudEvent) -> None:
|
||||||
|
"""Handle product modify events."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def handle_product_removed(self, event: CloudEvent) -> None:
|
||||||
|
"""Handle product remove events."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def handle_attribute_value_changed(self, event: CloudEvent) -> None:
|
||||||
|
"""Handle product attribute value change events."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SimpleWebhookEventDispatcher:
|
||||||
|
"""Simple event dispatcher that routes events to registered handlers.
|
||||||
|
|
||||||
|
Routes events based on event type and operation.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> dispatcher = SimpleWebhookEventDispatcher()
|
||||||
|
>>> dispatcher.register_handler(
|
||||||
|
... event_type=EventType.PRODUCT,
|
||||||
|
... operation=Operation.ADD,
|
||||||
|
... callback=my_handler.handle_product_added,
|
||||||
|
... )
|
||||||
|
>>> dispatcher.dispatch(event)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize the event dispatcher."""
|
||||||
|
self.handlers: Dict[
|
||||||
|
tuple[Optional[str], Optional[str]], list[Callable[[CloudEvent], None]]
|
||||||
|
] = {}
|
||||||
|
self.default_handlers: list[Callable[[CloudEvent], None]] = []
|
||||||
|
|
||||||
|
def register_handler(
|
||||||
|
self,
|
||||||
|
callback: Callable[[CloudEvent], None],
|
||||||
|
event_type: Optional[EventType] = None,
|
||||||
|
operation: Optional[Operation] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Register a handler for a specific event type and/or operation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
callback: Callable that accepts a CloudEvent
|
||||||
|
event_type: Filter by event type (None = all types)
|
||||||
|
operation: Filter by operation (None = all operations)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> dispatcher.register_handler(
|
||||||
|
... callback=handle_product_changes,
|
||||||
|
... event_type=EventType.PRODUCT,
|
||||||
|
... )
|
||||||
|
>>> dispatcher.register_handler(
|
||||||
|
... callback=handle_all_events,
|
||||||
|
... )
|
||||||
|
"""
|
||||||
|
key = (
|
||||||
|
event_type.value if event_type else None,
|
||||||
|
operation.value if operation else None,
|
||||||
|
)
|
||||||
|
if key not in self.handlers:
|
||||||
|
self.handlers[key] = []
|
||||||
|
self.handlers[key].append(callback)
|
||||||
|
|
||||||
|
def register_default_handler(
|
||||||
|
self, callback: Callable[[CloudEvent], None]
|
||||||
|
) -> None:
|
||||||
|
"""Register a default handler for events not matching any registered handler.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
callback: Callable that accepts a CloudEvent
|
||||||
|
"""
|
||||||
|
self.default_handlers.append(callback)
|
||||||
|
|
||||||
|
def dispatch(self, event: CloudEvent) -> None:
|
||||||
|
"""Dispatch an event to all matching handlers.
|
||||||
|
|
||||||
|
Handlers are called in the following order:
|
||||||
|
1. Handlers matching both event type and operation
|
||||||
|
2. Handlers matching event type only
|
||||||
|
3. Handlers matching operation only
|
||||||
|
4. Default handlers (if no others matched)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event: The CloudEvent to dispatch
|
||||||
|
"""
|
||||||
|
event_type = event.get_event_type()
|
||||||
|
operation = event.get_operation()
|
||||||
|
|
||||||
|
event_type_value = event_type.value if event_type else None
|
||||||
|
operation_value = operation.value if operation else None
|
||||||
|
|
||||||
|
handlers_called = False
|
||||||
|
|
||||||
|
# Try exact match (event type + operation)
|
||||||
|
key = (event_type_value, operation_value)
|
||||||
|
if key in self.handlers:
|
||||||
|
for handler in self.handlers[key]:
|
||||||
|
handler(event)
|
||||||
|
handlers_called = True
|
||||||
|
|
||||||
|
# Try event type only
|
||||||
|
key = (event_type_value, None)
|
||||||
|
if key in self.handlers:
|
||||||
|
for handler in self.handlers[key]:
|
||||||
|
handler(event)
|
||||||
|
handlers_called = True
|
||||||
|
|
||||||
|
# Try operation only
|
||||||
|
key = (None, operation_value)
|
||||||
|
if key in self.handlers:
|
||||||
|
for handler in self.handlers[key]:
|
||||||
|
handler(event)
|
||||||
|
handlers_called = True
|
||||||
|
|
||||||
|
# Call default handlers if nothing matched
|
||||||
|
if not handlers_called:
|
||||||
|
for handler in self.default_handlers:
|
||||||
|
handler(event)
|
||||||
|
|
||||||
|
|
||||||
|
class FlaskWebhookExample:
|
||||||
|
"""Example Flask webhook endpoint implementation.
|
||||||
|
|
||||||
|
This is a minimal example. Production code should add logging, monitoring,
|
||||||
|
error handling, and proper HTTP status codes.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> from flask import Flask, request
|
||||||
|
>>> app = Flask(__name__)
|
||||||
|
>>> webhook = FlaskWebhookExample(validator)
|
||||||
|
>>>
|
||||||
|
>>> @app.route("/webhook", methods=["POST"])
|
||||||
|
>>> def handle_webhook():
|
||||||
|
... return webhook.handle_request(request.json, request.headers)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
validator: WebhookValidator,
|
||||||
|
dispatcher: Optional[SimpleWebhookEventDispatcher] = None,
|
||||||
|
):
|
||||||
|
"""Initialize the Flask webhook handler.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
validator: WebhookValidator instance for authentication and validation
|
||||||
|
dispatcher: Optional event dispatcher for routing events
|
||||||
|
"""
|
||||||
|
self.validator = validator
|
||||||
|
self.dispatcher = dispatcher
|
||||||
|
|
||||||
|
def handle_request(
|
||||||
|
self,
|
||||||
|
payload: Dict[str, Any],
|
||||||
|
headers: Optional[Dict[str, str]] = None,
|
||||||
|
) -> tuple[Dict[str, Any], int]:
|
||||||
|
"""Handle an incoming webhook request from Flask.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
payload: Request JSON body
|
||||||
|
headers: Request headers
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (response_dict, http_status_code)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> @app.route("/webhook", methods=["POST"])
|
||||||
|
>>> def webhook():
|
||||||
|
... response, status = webhook_handler.handle_request(
|
||||||
|
... request.json, dict(request.headers)
|
||||||
|
... )
|
||||||
|
... return response, status
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Validate and parse the webhook
|
||||||
|
event = self.validator.validate_and_parse(payload, headers)
|
||||||
|
|
||||||
|
# Dispatch to handlers if provided
|
||||||
|
if self.dispatcher:
|
||||||
|
self.dispatcher.dispatch(event)
|
||||||
|
|
||||||
|
return {"status": "success", "event_id": event.id}, 200
|
||||||
|
|
||||||
|
except WebhookValidationError as e:
|
||||||
|
return {"status": "error", "message": str(e)}, 400
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": "error", "message": "Internal server error"}, 500
|
||||||
|
|
||||||
|
|
||||||
|
class FastAPIWebhookExample:
|
||||||
|
"""Example FastAPI webhook endpoint implementation.
|
||||||
|
|
||||||
|
This is a minimal example. Production code should add logging, monitoring,
|
||||||
|
error handling, and proper HTTP status codes.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> from fastapi import FastAPI, Request
|
||||||
|
>>> app = FastAPI()
|
||||||
|
>>> webhook = FastAPIWebhookExample(validator)
|
||||||
|
>>>
|
||||||
|
>>> @app.post("/webhook")
|
||||||
|
>>> async def handle_webhook(request: Request):
|
||||||
|
... payload = await request.json()
|
||||||
|
... return webhook.handle_request(
|
||||||
|
... payload,
|
||||||
|
... dict(request.headers)
|
||||||
|
... )
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
validator: WebhookValidator,
|
||||||
|
dispatcher: Optional[SimpleWebhookEventDispatcher] = None,
|
||||||
|
):
|
||||||
|
"""Initialize the FastAPI webhook handler.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
validator: WebhookValidator instance for authentication and validation
|
||||||
|
dispatcher: Optional event dispatcher for routing events
|
||||||
|
"""
|
||||||
|
self.validator = validator
|
||||||
|
self.dispatcher = dispatcher
|
||||||
|
|
||||||
|
def handle_request(
|
||||||
|
self,
|
||||||
|
payload: Dict[str, Any],
|
||||||
|
headers: Optional[Dict[str, str]] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Handle an incoming webhook request from FastAPI.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
payload: Request JSON body
|
||||||
|
headers: Request headers
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response dictionary
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> @app.post("/webhook")
|
||||||
|
>>> async def webhook(request: Request):
|
||||||
|
... payload = await request.json()
|
||||||
|
... return webhook_handler.handle_request(
|
||||||
|
... payload,
|
||||||
|
... dict(request.headers)
|
||||||
|
... )
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Validate and parse the webhook
|
||||||
|
event = self.validator.validate_and_parse(payload, headers)
|
||||||
|
|
||||||
|
# Dispatch to handlers if provided
|
||||||
|
if self.dispatcher:
|
||||||
|
self.dispatcher.dispatch(event)
|
||||||
|
|
||||||
|
return {"status": "success", "event_id": event.id}
|
||||||
|
|
||||||
|
except WebhookValidationError as e:
|
||||||
|
return {"status": "error", "message": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
# Example usage functions
|
||||||
|
|
||||||
|
def example_basic_event_handler(event: CloudEvent) -> None:
|
||||||
|
"""Simple example event handler that logs event info.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event: The CloudEvent to handle
|
||||||
|
"""
|
||||||
|
print(f"Received event: {event.id}")
|
||||||
|
print(f"Event type: {event.eventtype}")
|
||||||
|
print(f"Operation: {event.operation}")
|
||||||
|
if event.data:
|
||||||
|
print(f"Object type: {event.get_object_type()}")
|
||||||
|
print(f"Object ID: {event.get_object_id()}")
|
||||||
|
|
||||||
|
|
||||||
|
def example_product_change_handler(event: CloudEvent) -> None:
|
||||||
|
"""Example handler for product-related events.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event: The CloudEvent to handle
|
||||||
|
"""
|
||||||
|
operation = event.get_operation()
|
||||||
|
object_data = event.get_object_data()
|
||||||
|
|
||||||
|
if operation == Operation.ADD:
|
||||||
|
print(f"Product added: {event.get_object_id()}")
|
||||||
|
elif operation == Operation.MODIFY:
|
||||||
|
print(f"Product modified: {event.get_object_id()}")
|
||||||
|
elif operation == Operation.REMOVE:
|
||||||
|
print(f"Product removed: {event.get_object_id()}")
|
||||||
|
|
||||||
|
if object_data:
|
||||||
|
print(f"Object data: {json.dumps(object_data, indent=2)}")
|
||||||
372
examples/webhook_examples.py
Normal file
372
examples/webhook_examples.py
Normal file
|
|
@ -0,0 +1,372 @@
|
||||||
|
"""Comprehensive examples of using the Elytra webhooks subpackage."""
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Example 1: Simple Webhook Parsing Without Authentication
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
from elytra_client.webhooks import CloudEvent, EventType, Operation, parse_webhook_payload
|
||||||
|
|
||||||
|
|
||||||
|
def example_simple_parsing():
|
||||||
|
"""Parse a webhook payload without authentication validation."""
|
||||||
|
|
||||||
|
# Typical webhook payload from Elytra
|
||||||
|
webhook_payload = {
|
||||||
|
"specversion": "1.0",
|
||||||
|
"id": "385d27ef-ac6d-4d93-904b-ea9801fc1bde",
|
||||||
|
"source": "elytra-pim",
|
||||||
|
"type": "com.elytra.product.attribute.value",
|
||||||
|
"datacontenttype": "application/json",
|
||||||
|
"time": "2025-12-31T04:19:30.860984129+01:00",
|
||||||
|
"eventtype": "product_attribute_value",
|
||||||
|
"operation": "modify",
|
||||||
|
"data": {
|
||||||
|
"objectData": {
|
||||||
|
"type": 2,
|
||||||
|
"value": "example value",
|
||||||
|
"attributeID": 50728,
|
||||||
|
},
|
||||||
|
"objectId": 80194,
|
||||||
|
"objectType": "DAOAttributeValue",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parse the webhook
|
||||||
|
event: CloudEvent = parse_webhook_payload(webhook_payload)
|
||||||
|
|
||||||
|
# Access event properties
|
||||||
|
print(f"Event ID: {event.id}")
|
||||||
|
print(f"Event Type: {event.get_event_type()}")
|
||||||
|
print(f"Operation: {event.get_operation()}")
|
||||||
|
print(f"Object ID: {event.get_object_id()}")
|
||||||
|
print(f"Object Type: {event.get_object_type()}")
|
||||||
|
|
||||||
|
# Conditional handling based on event type
|
||||||
|
if event.get_event_type() == EventType.PRODUCT_ATTRIBUTE_VALUE:
|
||||||
|
if event.get_operation() == Operation.MODIFY:
|
||||||
|
print("Product attribute value was modified")
|
||||||
|
object_data = event.get_object_data()
|
||||||
|
if object_data:
|
||||||
|
print(f"New value: {object_data.get('value')}")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Example 2: Webhook with Basic Authentication
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
from elytra_client.webhooks import BasicAuth, WebhookValidationError, WebhookValidator
|
||||||
|
|
||||||
|
|
||||||
|
def example_basic_auth():
|
||||||
|
"""Validate webhook with HTTP Basic Authentication."""
|
||||||
|
|
||||||
|
# Create validator with basic auth
|
||||||
|
auth = BasicAuth(username="admin", password="secret")
|
||||||
|
validator = WebhookValidator(auth_type="basic", auth=auth)
|
||||||
|
|
||||||
|
# Incoming webhook payload
|
||||||
|
payload = {
|
||||||
|
"specversion": "1.0",
|
||||||
|
"id": "test-event-id",
|
||||||
|
"source": "elytra-pim",
|
||||||
|
"type": "com.elytra.product",
|
||||||
|
"eventtype": "product",
|
||||||
|
"operation": "add",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Headers from the incoming request
|
||||||
|
headers = {
|
||||||
|
"Authorization": "Basic YWRtaW46c2VjcmV0" # base64 encoded admin:secret
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Validate and parse in one call
|
||||||
|
event = validator.validate_and_parse(payload, headers)
|
||||||
|
print(f"Webhook validated successfully. Event ID: {event.id}")
|
||||||
|
except WebhookValidationError as e:
|
||||||
|
print(f"Validation failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Example 3: Webhook with Bearer Token Authentication
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
from elytra_client.webhooks import BearerAuth
|
||||||
|
|
||||||
|
|
||||||
|
def example_bearer_auth():
|
||||||
|
"""Validate webhook with Bearer token authentication."""
|
||||||
|
|
||||||
|
auth = BearerAuth(token="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")
|
||||||
|
validator = WebhookValidator(auth_type="bearer", auth=auth)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"specversion": "1.0",
|
||||||
|
"id": "test-event-id",
|
||||||
|
"source": "elytra-pim",
|
||||||
|
"type": "com.elytra.product",
|
||||||
|
"eventtype": "product",
|
||||||
|
"operation": "add",
|
||||||
|
}
|
||||||
|
|
||||||
|
headers = {"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}
|
||||||
|
|
||||||
|
try:
|
||||||
|
event = validator.validate_and_parse(payload, headers)
|
||||||
|
print(f"Bearer token validated. Event ID: {event.id}")
|
||||||
|
except WebhookValidationError as e:
|
||||||
|
print(f"Validation failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Example 4: Webhook with API Key Authentication
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
from elytra_client.webhooks import APIKeyAuth
|
||||||
|
|
||||||
|
|
||||||
|
def example_api_key_auth():
|
||||||
|
"""Validate webhook with API key authentication."""
|
||||||
|
|
||||||
|
auth = APIKeyAuth(api_key="sk_live_abc123xyz789", header_name="X-API-Key")
|
||||||
|
validator = WebhookValidator(auth_type="apikey", auth=auth)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"specversion": "1.0",
|
||||||
|
"id": "test-event-id",
|
||||||
|
"source": "elytra-pim",
|
||||||
|
"type": "com.elytra.product",
|
||||||
|
"eventtype": "product",
|
||||||
|
"operation": "add",
|
||||||
|
}
|
||||||
|
|
||||||
|
headers = {"X-API-Key": "sk_live_abc123xyz789"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
event = validator.validate_and_parse(payload, headers)
|
||||||
|
print(f"API key validated. Event ID: {event.id}")
|
||||||
|
except WebhookValidationError as e:
|
||||||
|
print(f"Validation failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Example 5: Event Filtering and Routing
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
from elytra_client.webhooks.server import EventHandler, SimpleWebhookEventDispatcher
|
||||||
|
|
||||||
|
|
||||||
|
class MyEventHandler(EventHandler):
|
||||||
|
"""Custom event handler for specific event types."""
|
||||||
|
|
||||||
|
def handle_product_added(self, event: CloudEvent) -> None:
|
||||||
|
"""Handle product added events."""
|
||||||
|
print(f"🎉 New product added: {event.get_object_id()}")
|
||||||
|
|
||||||
|
def handle_attribute_value_changed(self, event: CloudEvent) -> None:
|
||||||
|
"""Handle attribute value changes."""
|
||||||
|
print(f"📝 Attribute value changed for product: {event.get_object_id()}")
|
||||||
|
|
||||||
|
|
||||||
|
def example_event_routing():
|
||||||
|
"""Route events to different handlers based on type and operation."""
|
||||||
|
|
||||||
|
# Create dispatcher and handler
|
||||||
|
dispatcher = SimpleWebhookEventDispatcher()
|
||||||
|
handler = MyEventHandler()
|
||||||
|
|
||||||
|
# Register specific handlers
|
||||||
|
dispatcher.register_handler(
|
||||||
|
callback=handler.handle_product_added,
|
||||||
|
event_type=EventType.PRODUCT,
|
||||||
|
operation=Operation.ADD,
|
||||||
|
)
|
||||||
|
|
||||||
|
dispatcher.register_handler(
|
||||||
|
callback=handler.handle_attribute_value_changed,
|
||||||
|
event_type=EventType.PRODUCT_ATTRIBUTE_VALUE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Register a catch-all handler
|
||||||
|
dispatcher.register_default_handler(
|
||||||
|
lambda event: print(f"Other event received: {event.eventtype}")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Dispatch events
|
||||||
|
event1 = CloudEvent(
|
||||||
|
id="1",
|
||||||
|
source="elytra-pim",
|
||||||
|
type="com.elytra.product",
|
||||||
|
eventtype="product",
|
||||||
|
operation="add",
|
||||||
|
)
|
||||||
|
dispatcher.dispatch(event1) # Calls handle_product_added
|
||||||
|
|
||||||
|
event2 = CloudEvent(
|
||||||
|
id="2",
|
||||||
|
source="elytra-pim",
|
||||||
|
type="com.elytra.product.attribute.value",
|
||||||
|
eventtype="product_attribute_value",
|
||||||
|
operation="modify",
|
||||||
|
)
|
||||||
|
dispatcher.dispatch(event2) # Calls handle_attribute_value_changed
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Example 6: Retry Configuration and Execution
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from elytra_client.webhooks import ExponentialBackoffRetry, RetryConfig, execute_with_retry
|
||||||
|
|
||||||
|
|
||||||
|
def example_retry_with_webhook_delivery():
|
||||||
|
"""Retry webhook delivery with exponential backoff."""
|
||||||
|
|
||||||
|
# Configure retry policy
|
||||||
|
retry_config = RetryConfig(
|
||||||
|
max_retries=5,
|
||||||
|
initial_delay=1000, # 1 second
|
||||||
|
backoff_multiplier=2.0,
|
||||||
|
max_delay=300000, # 5 minutes
|
||||||
|
)
|
||||||
|
|
||||||
|
retry_policy = ExponentialBackoffRetry(retry_config)
|
||||||
|
|
||||||
|
def send_webhook(url: str, data: dict) -> requests.Response:
|
||||||
|
"""Send webhook with retry on failure."""
|
||||||
|
return requests.post(url, json=data, timeout=10)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# This will retry with exponential backoff on failure
|
||||||
|
response = execute_with_retry(
|
||||||
|
send_webhook,
|
||||||
|
retry_policy,
|
||||||
|
url="https://example.com/webhook",
|
||||||
|
data={"event": "product_added"},
|
||||||
|
)
|
||||||
|
print(f"Webhook sent successfully: {response.status_code}")
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
print(f"Webhook delivery failed after retries: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Example 7: Flask Integration
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def example_flask_integration():
|
||||||
|
"""Flask webhook endpoint with authentication and validation."""
|
||||||
|
|
||||||
|
from flask import Flask, jsonify, request
|
||||||
|
|
||||||
|
from elytra_client.webhooks.server import FlaskWebhookExample
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
# Setup webhook handler with authentication
|
||||||
|
auth = BasicAuth(username="webhook_user", password="webhook_pass")
|
||||||
|
validator = WebhookValidator(auth_type="basic", auth=auth)
|
||||||
|
webhook_handler = FlaskWebhookExample(validator)
|
||||||
|
|
||||||
|
@app.route("/webhook/elytra", methods=["POST"])
|
||||||
|
def handle_elytra_webhook():
|
||||||
|
"""Handle incoming Elytra webhook events."""
|
||||||
|
response, status_code = webhook_handler.handle_request(
|
||||||
|
request.json, dict(request.headers)
|
||||||
|
)
|
||||||
|
return jsonify(response), status_code
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Example 8: FastAPI Integration
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def example_fastapi_integration():
|
||||||
|
"""FastAPI webhook endpoint with authentication and validation."""
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
|
||||||
|
from elytra_client.webhooks.server import FastAPIWebhookExample
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
# Setup webhook handler with API key authentication
|
||||||
|
auth = APIKeyAuth(api_key="sk_live_secret123", header_name="X-Webhook-Key")
|
||||||
|
validator = WebhookValidator(auth_type="apikey", auth=auth)
|
||||||
|
dispatcher = SimpleWebhookEventDispatcher()
|
||||||
|
webhook_handler = FastAPIWebhookExample(validator, dispatcher)
|
||||||
|
|
||||||
|
@app.post("/webhook/elytra")
|
||||||
|
async def handle_elytra_webhook(request: Request):
|
||||||
|
"""Handle incoming Elytra webhook events."""
|
||||||
|
payload = await request.json()
|
||||||
|
return webhook_handler.handle_request(payload, dict(request.headers))
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Example 9: Complete Production Example
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def example_complete_setup():
|
||||||
|
"""Complete production-ready setup with all features."""
|
||||||
|
|
||||||
|
from elytra_client.webhooks import BasicAuth, EventType, Operation, WebhookValidator
|
||||||
|
from elytra_client.webhooks.server import FlaskWebhookExample, SimpleWebhookEventDispatcher
|
||||||
|
|
||||||
|
# 1. Setup authentication
|
||||||
|
auth = BasicAuth(username="elytra_webhook", password="secure_password_here")
|
||||||
|
|
||||||
|
# 2. Create validator
|
||||||
|
validator = WebhookValidator(auth_type="basic", auth=auth)
|
||||||
|
|
||||||
|
# 3. Create event dispatcher
|
||||||
|
dispatcher = SimpleWebhookEventDispatcher()
|
||||||
|
|
||||||
|
# 4. Register handlers for different event types
|
||||||
|
def on_product_added(event: CloudEvent) -> None:
|
||||||
|
print(f"Product {event.get_object_id()} was added")
|
||||||
|
# TODO: Implement your product sync logic
|
||||||
|
|
||||||
|
def on_attribute_changed(event: CloudEvent) -> None:
|
||||||
|
print(f"Attribute changed for product {event.get_object_id()}")
|
||||||
|
# TODO: Implement your attribute sync logic
|
||||||
|
|
||||||
|
dispatcher.register_handler(
|
||||||
|
on_product_added, EventType.PRODUCT, Operation.ADD
|
||||||
|
)
|
||||||
|
dispatcher.register_handler(
|
||||||
|
on_attribute_changed, EventType.PRODUCT_ATTRIBUTE_VALUE
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5. Create Flask webhook handler
|
||||||
|
webhook_handler = FlaskWebhookExample(validator, dispatcher)
|
||||||
|
|
||||||
|
# 6. Use in Flask
|
||||||
|
from flask import Flask, jsonify, request
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
@app.route("/webhook", methods=["POST"])
|
||||||
|
def webhook():
|
||||||
|
response, status = webhook_handler.handle_request(
|
||||||
|
request.json, dict(request.headers)
|
||||||
|
)
|
||||||
|
return jsonify(response), status
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("Elytra Webhooks Examples")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
print("\n1. Simple Parsing:")
|
||||||
|
example_simple_parsing()
|
||||||
|
|
||||||
|
print("\n2. Event Routing:")
|
||||||
|
example_event_routing()
|
||||||
437
tests/test_webhooks.py
Normal file
437
tests/test_webhooks.py
Normal file
|
|
@ -0,0 +1,437 @@
|
||||||
|
"""Unit tests for the Elytra webhooks subpackage."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from elytra_client.webhooks import (
|
||||||
|
APIKeyAuth,
|
||||||
|
BasicAuth,
|
||||||
|
BearerAuth,
|
||||||
|
CloudEvent,
|
||||||
|
EventType,
|
||||||
|
ExponentialBackoffRetry,
|
||||||
|
Operation,
|
||||||
|
RetryConfig,
|
||||||
|
calculate_retry_delay,
|
||||||
|
)
|
||||||
|
from elytra_client.webhooks.auth import APIKeyAuthProvider, BasicAuthProvider, BearerAuthProvider
|
||||||
|
from elytra_client.webhooks.handlers import (
|
||||||
|
WebhookValidationError,
|
||||||
|
WebhookValidator,
|
||||||
|
parse_webhook_payload,
|
||||||
|
validate_cloud_event,
|
||||||
|
)
|
||||||
|
from elytra_client.webhooks.server import EventHandler, SimpleWebhookEventDispatcher
|
||||||
|
|
||||||
|
|
||||||
|
class TestCloudEvent:
|
||||||
|
"""Test CloudEvent model."""
|
||||||
|
|
||||||
|
def test_cloud_event_creation(self):
|
||||||
|
"""Test creating a CloudEvent."""
|
||||||
|
event = CloudEvent(
|
||||||
|
id="test-id",
|
||||||
|
source="elytra-pim",
|
||||||
|
type="com.elytra.product",
|
||||||
|
eventtype="product",
|
||||||
|
operation="add",
|
||||||
|
)
|
||||||
|
assert event.id == "test-id"
|
||||||
|
assert event.source == "elytra-pim"
|
||||||
|
assert event.specversion == "1.0"
|
||||||
|
|
||||||
|
def test_get_event_type(self):
|
||||||
|
"""Test parsing event type."""
|
||||||
|
event = CloudEvent(
|
||||||
|
id="test-id",
|
||||||
|
source="elytra-pim",
|
||||||
|
type="com.elytra.product",
|
||||||
|
eventtype="product",
|
||||||
|
)
|
||||||
|
assert event.get_event_type() == EventType.PRODUCT
|
||||||
|
|
||||||
|
def test_get_operation(self):
|
||||||
|
"""Test parsing operation."""
|
||||||
|
event = CloudEvent(
|
||||||
|
id="test-id",
|
||||||
|
source="elytra-pim",
|
||||||
|
type="com.elytra.product",
|
||||||
|
operation="modify",
|
||||||
|
)
|
||||||
|
assert event.get_operation() == Operation.MODIFY
|
||||||
|
|
||||||
|
def test_get_event_type_invalid(self):
|
||||||
|
"""Test invalid event type returns None."""
|
||||||
|
event = CloudEvent(
|
||||||
|
id="test-id",
|
||||||
|
source="elytra-pim",
|
||||||
|
type="com.elytra.unknown",
|
||||||
|
eventtype="invalid_type",
|
||||||
|
)
|
||||||
|
assert event.get_event_type() is None
|
||||||
|
|
||||||
|
def test_get_object_data(self):
|
||||||
|
"""Test extracting object data."""
|
||||||
|
event = CloudEvent(
|
||||||
|
id="test-id",
|
||||||
|
source="elytra-pim",
|
||||||
|
type="com.elytra.product",
|
||||||
|
data={"objectData": {"id": 123, "name": "Product"}, "objectId": 123},
|
||||||
|
)
|
||||||
|
assert event.get_object_data() == {"id": 123, "name": "Product"}
|
||||||
|
|
||||||
|
def test_get_object_id(self):
|
||||||
|
"""Test extracting object ID."""
|
||||||
|
event = CloudEvent(
|
||||||
|
id="test-id",
|
||||||
|
source="elytra-pim",
|
||||||
|
type="com.elytra.product",
|
||||||
|
data={"objectId": 456},
|
||||||
|
)
|
||||||
|
assert event.get_object_id() == 456
|
||||||
|
|
||||||
|
def test_get_object_type(self):
|
||||||
|
"""Test extracting object type."""
|
||||||
|
event = CloudEvent(
|
||||||
|
id="test-id",
|
||||||
|
source="elytra-pim",
|
||||||
|
type="com.elytra.product",
|
||||||
|
data={"objectType": "Product"},
|
||||||
|
)
|
||||||
|
assert event.get_object_type() == "Product"
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebhookValidation:
|
||||||
|
"""Test webhook validation."""
|
||||||
|
|
||||||
|
def test_parse_valid_webhook(self):
|
||||||
|
"""Test parsing valid webhook payload."""
|
||||||
|
payload = {
|
||||||
|
"specversion": "1.0",
|
||||||
|
"id": "test-id",
|
||||||
|
"source": "elytra-pim",
|
||||||
|
"type": "com.elytra.product",
|
||||||
|
"eventtype": "product",
|
||||||
|
"operation": "add",
|
||||||
|
}
|
||||||
|
event = parse_webhook_payload(payload)
|
||||||
|
assert event.id == "test-id"
|
||||||
|
assert event.eventtype == "product"
|
||||||
|
|
||||||
|
def test_parse_invalid_webhook(self):
|
||||||
|
"""Test parsing invalid webhook raises error."""
|
||||||
|
payload = {
|
||||||
|
"id": "test-id",
|
||||||
|
# Missing required 'source' and 'type'
|
||||||
|
}
|
||||||
|
with pytest.raises(WebhookValidationError):
|
||||||
|
parse_webhook_payload(payload)
|
||||||
|
|
||||||
|
def test_validate_cloud_event_success(self):
|
||||||
|
"""Test successful cloud event validation."""
|
||||||
|
event = CloudEvent(
|
||||||
|
id="test-id",
|
||||||
|
source="elytra-pim",
|
||||||
|
type="com.elytra.product",
|
||||||
|
)
|
||||||
|
assert validate_cloud_event(event) is True
|
||||||
|
|
||||||
|
def test_validate_cloud_event_missing_id(self):
|
||||||
|
"""Test validation fails with missing ID."""
|
||||||
|
event = CloudEvent(
|
||||||
|
id="",
|
||||||
|
source="elytra-pim",
|
||||||
|
type="com.elytra.product",
|
||||||
|
)
|
||||||
|
with pytest.raises(WebhookValidationError):
|
||||||
|
validate_cloud_event(event)
|
||||||
|
|
||||||
|
def test_validate_cloud_event_invalid_type_prefix(self):
|
||||||
|
"""Test validation fails with invalid type prefix."""
|
||||||
|
event = CloudEvent(
|
||||||
|
id="test-id",
|
||||||
|
source="elytra-pim",
|
||||||
|
type="com.other.product", # Wrong prefix
|
||||||
|
)
|
||||||
|
with pytest.raises(WebhookValidationError):
|
||||||
|
validate_cloud_event(event)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBasicAuth:
|
||||||
|
"""Test Basic authentication."""
|
||||||
|
|
||||||
|
def test_basic_auth_get_headers(self):
|
||||||
|
"""Test Basic auth header generation."""
|
||||||
|
auth = BasicAuth(username="admin", password="secret")
|
||||||
|
provider = BasicAuthProvider(auth)
|
||||||
|
headers = provider.get_headers()
|
||||||
|
assert "Authorization" in headers
|
||||||
|
assert headers["Authorization"].startswith("Basic ")
|
||||||
|
|
||||||
|
def test_basic_auth_validate_valid(self):
|
||||||
|
"""Test Basic auth validation with valid credentials."""
|
||||||
|
auth = BasicAuth(username="admin", password="secret")
|
||||||
|
provider = BasicAuthProvider(auth)
|
||||||
|
headers = provider.get_headers()
|
||||||
|
assert provider.validate_header(headers) is True
|
||||||
|
|
||||||
|
def test_basic_auth_validate_invalid(self):
|
||||||
|
"""Test Basic auth validation with invalid credentials."""
|
||||||
|
auth = BasicAuth(username="admin", password="secret")
|
||||||
|
provider = BasicAuthProvider(auth)
|
||||||
|
headers = {"Authorization": "Basic dGVzdDp3cm9uZw=="} # test:wrong
|
||||||
|
assert provider.validate_header(headers) is False
|
||||||
|
|
||||||
|
def test_basic_auth_validate_missing(self):
|
||||||
|
"""Test Basic auth validation with missing header."""
|
||||||
|
auth = BasicAuth(username="admin", password="secret")
|
||||||
|
provider = BasicAuthProvider(auth)
|
||||||
|
assert provider.validate_header({}) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestBearerAuth:
|
||||||
|
"""Test Bearer token authentication."""
|
||||||
|
|
||||||
|
def test_bearer_auth_get_headers(self):
|
||||||
|
"""Test Bearer auth header generation."""
|
||||||
|
auth = BearerAuth(token="my_token_123")
|
||||||
|
provider = BearerAuthProvider(auth)
|
||||||
|
headers = provider.get_headers()
|
||||||
|
assert headers["Authorization"] == "Bearer my_token_123"
|
||||||
|
|
||||||
|
def test_bearer_auth_validate_valid(self):
|
||||||
|
"""Test Bearer auth validation with valid token."""
|
||||||
|
auth = BearerAuth(token="my_token_123")
|
||||||
|
provider = BearerAuthProvider(auth)
|
||||||
|
headers = {"Authorization": "Bearer my_token_123"}
|
||||||
|
assert provider.validate_header(headers) is True
|
||||||
|
|
||||||
|
def test_bearer_auth_validate_invalid(self):
|
||||||
|
"""Test Bearer auth validation with invalid token."""
|
||||||
|
auth = BearerAuth(token="my_token_123")
|
||||||
|
provider = BearerAuthProvider(auth)
|
||||||
|
headers = {"Authorization": "Bearer wrong_token"}
|
||||||
|
assert provider.validate_header(headers) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestAPIKeyAuth:
|
||||||
|
"""Test API Key authentication."""
|
||||||
|
|
||||||
|
def test_api_key_auth_get_headers(self):
|
||||||
|
"""Test API Key header generation."""
|
||||||
|
auth = APIKeyAuth(api_key="sk_live_123", header_name="X-API-Key")
|
||||||
|
provider = APIKeyAuthProvider(auth)
|
||||||
|
headers = provider.get_headers()
|
||||||
|
assert headers["X-API-Key"] == "sk_live_123"
|
||||||
|
|
||||||
|
def test_api_key_auth_default_header_name(self):
|
||||||
|
"""Test API Key with default header name."""
|
||||||
|
auth = APIKeyAuth(api_key="sk_live_123")
|
||||||
|
provider = APIKeyAuthProvider(auth)
|
||||||
|
headers = provider.get_headers()
|
||||||
|
assert "api-key" in headers
|
||||||
|
|
||||||
|
def test_api_key_auth_validate_valid(self):
|
||||||
|
"""Test API Key validation with valid key."""
|
||||||
|
auth = APIKeyAuth(api_key="sk_live_123", header_name="X-API-Key")
|
||||||
|
provider = APIKeyAuthProvider(auth)
|
||||||
|
headers = {"X-API-Key": "sk_live_123"}
|
||||||
|
assert provider.validate_header(headers) is True
|
||||||
|
|
||||||
|
def test_api_key_auth_validate_invalid(self):
|
||||||
|
"""Test API Key validation with invalid key."""
|
||||||
|
auth = APIKeyAuth(api_key="sk_live_123", header_name="X-API-Key")
|
||||||
|
provider = APIKeyAuthProvider(auth)
|
||||||
|
headers = {"X-API-Key": "wrong_key"}
|
||||||
|
assert provider.validate_header(headers) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebhookValidator:
|
||||||
|
"""Test WebhookValidator class."""
|
||||||
|
|
||||||
|
def test_validator_no_auth(self):
|
||||||
|
"""Test validator without authentication."""
|
||||||
|
validator = WebhookValidator(auth_type="none")
|
||||||
|
payload = {
|
||||||
|
"specversion": "1.0",
|
||||||
|
"id": "test-id",
|
||||||
|
"source": "elytra-pim",
|
||||||
|
"type": "com.elytra.product",
|
||||||
|
}
|
||||||
|
event = validator.validate_and_parse(payload)
|
||||||
|
assert event.id == "test-id"
|
||||||
|
|
||||||
|
def test_validator_basic_auth(self):
|
||||||
|
"""Test validator with basic auth."""
|
||||||
|
auth = BasicAuth(username="user", password="pass")
|
||||||
|
validator = WebhookValidator(auth_type="basic", auth=auth)
|
||||||
|
payload = {
|
||||||
|
"specversion": "1.0",
|
||||||
|
"id": "test-id",
|
||||||
|
"source": "elytra-pim",
|
||||||
|
"type": "com.elytra.product",
|
||||||
|
}
|
||||||
|
# Valid header
|
||||||
|
headers = {"Authorization": "Basic dXNlcjpwYXNz"}
|
||||||
|
event = validator.validate_and_parse(payload, headers)
|
||||||
|
assert event.id == "test-id"
|
||||||
|
|
||||||
|
def test_validator_auth_validation_fails(self):
|
||||||
|
"""Test validator fails on invalid auth."""
|
||||||
|
auth = BasicAuth(username="user", password="pass")
|
||||||
|
validator = WebhookValidator(auth_type="basic", auth=auth)
|
||||||
|
payload = {
|
||||||
|
"specversion": "1.0",
|
||||||
|
"id": "test-id",
|
||||||
|
"source": "elytra-pim",
|
||||||
|
"type": "com.elytra.product",
|
||||||
|
}
|
||||||
|
# Invalid header
|
||||||
|
headers = {"Authorization": "Basic invalid"}
|
||||||
|
with pytest.raises(WebhookValidationError):
|
||||||
|
validator.validate_and_parse(payload, headers)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRetryLogic:
|
||||||
|
"""Test retry logic."""
|
||||||
|
|
||||||
|
def test_calculate_retry_delay_exponential(self):
|
||||||
|
"""Test exponential backoff delay calculation."""
|
||||||
|
# First attempt: 1000ms
|
||||||
|
assert calculate_retry_delay(0, initial_delay=1000) == 1000
|
||||||
|
# Second attempt: 2000ms
|
||||||
|
assert calculate_retry_delay(1, initial_delay=1000, backoff_multiplier=2.0) == 2000
|
||||||
|
# Third attempt: 4000ms
|
||||||
|
assert calculate_retry_delay(2, initial_delay=1000, backoff_multiplier=2.0) == 4000
|
||||||
|
|
||||||
|
def test_calculate_retry_delay_max_cap(self):
|
||||||
|
"""Test retry delay is capped at max."""
|
||||||
|
# Should be capped at max_delay
|
||||||
|
delay = calculate_retry_delay(
|
||||||
|
10,
|
||||||
|
initial_delay=1000,
|
||||||
|
backoff_multiplier=2.0,
|
||||||
|
max_delay=300000,
|
||||||
|
)
|
||||||
|
assert delay <= 300000
|
||||||
|
|
||||||
|
def test_exponential_backoff_retry_should_retry(self):
|
||||||
|
"""Test ExponentialBackoffRetry.should_retry."""
|
||||||
|
config = RetryConfig(max_retries=3)
|
||||||
|
policy = ExponentialBackoffRetry(config)
|
||||||
|
assert policy.should_retry(0) is True
|
||||||
|
assert policy.should_retry(1) is True
|
||||||
|
assert policy.should_retry(2) is True
|
||||||
|
assert policy.should_retry(3) is False
|
||||||
|
|
||||||
|
def test_exponential_backoff_retry_delay(self):
|
||||||
|
"""Test ExponentialBackoffRetry.get_delay."""
|
||||||
|
config = RetryConfig(
|
||||||
|
max_retries=3,
|
||||||
|
initial_delay=1000,
|
||||||
|
backoff_multiplier=2.0,
|
||||||
|
)
|
||||||
|
policy = ExponentialBackoffRetry(config)
|
||||||
|
assert policy.get_delay(0) == 1000
|
||||||
|
assert policy.get_delay(1) == 2000
|
||||||
|
assert policy.get_delay(2) == 4000
|
||||||
|
|
||||||
|
|
||||||
|
class TestEventDispatcher:
|
||||||
|
"""Test event dispatcher."""
|
||||||
|
|
||||||
|
def test_register_and_dispatch_exact_match(self):
|
||||||
|
"""Test handler is called on exact event type and operation match."""
|
||||||
|
dispatcher = SimpleWebhookEventDispatcher()
|
||||||
|
called_with = []
|
||||||
|
|
||||||
|
def handler(event: CloudEvent):
|
||||||
|
called_with.append(event)
|
||||||
|
|
||||||
|
dispatcher.register_handler(
|
||||||
|
handler, EventType.PRODUCT, Operation.ADD
|
||||||
|
)
|
||||||
|
|
||||||
|
event = CloudEvent(
|
||||||
|
id="test",
|
||||||
|
source="elytra-pim",
|
||||||
|
type="com.elytra.product",
|
||||||
|
eventtype="product",
|
||||||
|
operation="add",
|
||||||
|
)
|
||||||
|
|
||||||
|
dispatcher.dispatch(event)
|
||||||
|
assert len(called_with) == 1
|
||||||
|
assert called_with[0].id == "test"
|
||||||
|
|
||||||
|
def test_register_and_dispatch_type_only(self):
|
||||||
|
"""Test handler is called on event type match."""
|
||||||
|
dispatcher = SimpleWebhookEventDispatcher()
|
||||||
|
called_with = []
|
||||||
|
|
||||||
|
def handler(event: CloudEvent):
|
||||||
|
called_with.append(event)
|
||||||
|
|
||||||
|
dispatcher.register_handler(handler, EventType.PRODUCT)
|
||||||
|
|
||||||
|
event = CloudEvent(
|
||||||
|
id="test",
|
||||||
|
source="elytra-pim",
|
||||||
|
type="com.elytra.product",
|
||||||
|
eventtype="product",
|
||||||
|
operation="modify",
|
||||||
|
)
|
||||||
|
|
||||||
|
dispatcher.dispatch(event)
|
||||||
|
assert len(called_with) == 1
|
||||||
|
|
||||||
|
def test_default_handler_called(self):
|
||||||
|
"""Test default handler is called when no specific match."""
|
||||||
|
dispatcher = SimpleWebhookEventDispatcher()
|
||||||
|
called_with = []
|
||||||
|
|
||||||
|
def handler(event: CloudEvent):
|
||||||
|
called_with.append(event)
|
||||||
|
|
||||||
|
dispatcher.register_default_handler(handler)
|
||||||
|
|
||||||
|
event = CloudEvent(
|
||||||
|
id="test",
|
||||||
|
source="elytra-pim",
|
||||||
|
type="com.elytra.unknown",
|
||||||
|
eventtype="unknown",
|
||||||
|
)
|
||||||
|
|
||||||
|
dispatcher.dispatch(event)
|
||||||
|
assert len(called_with) == 1
|
||||||
|
|
||||||
|
def test_multiple_handlers_called(self):
|
||||||
|
"""Test multiple handlers are called for same event."""
|
||||||
|
dispatcher = SimpleWebhookEventDispatcher()
|
||||||
|
called = []
|
||||||
|
|
||||||
|
def handler1(event: CloudEvent):
|
||||||
|
called.append("handler1")
|
||||||
|
|
||||||
|
def handler2(event: CloudEvent):
|
||||||
|
called.append("handler2")
|
||||||
|
|
||||||
|
dispatcher.register_handler(handler1, EventType.PRODUCT)
|
||||||
|
dispatcher.register_default_handler(handler2)
|
||||||
|
|
||||||
|
event = CloudEvent(
|
||||||
|
id="test",
|
||||||
|
source="elytra-pim",
|
||||||
|
type="com.elytra.product",
|
||||||
|
eventtype="product",
|
||||||
|
)
|
||||||
|
|
||||||
|
dispatcher.dispatch(event)
|
||||||
|
assert "handler1" in called
|
||||||
|
assert "handler2" not in called # Only called if no matching handler
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
pytest.main([__file__, "-v"])
|
||||||
Loading…
Add table
Add a link
Reference in a new issue