Files
motia-iii/services/advoware.py
bsiggel a0cf845877 Refactor and enhance logging in webhook handlers and Redis client
- Translated comments and docstrings from German to English for better clarity.
- Improved logging consistency across various webhook handlers for create, delete, and update operations.
- Centralized logging functionality by utilizing a dedicated logger utility.
- Added new enums for file and XAI sync statuses in models.
- Updated Redis client factory to use a centralized logger and improved error handling.
- Enhanced API responses to include more descriptive messages and status codes.
2026-03-08 21:50:34 +00:00

354 lines
13 KiB
Python

"""Advoware API client for Motia III"""
import aiohttp
import asyncio
import time
import uuid
import hmac
import hashlib
import base64
import os
import datetime
from typing import Optional, Dict, Any
from services.exceptions import (
AdvowareAPIError,
AdvowareAuthError,
AdvowareTimeoutError,
RetryableError
)
from services.redis_client import get_redis_client
from services.config import ADVOWARE_CONFIG, API_CONFIG
from services.logging_utils import get_service_logger
class AdvowareAPI:
"""
Advoware API client with token caching via Redis.
Environment variables required:
- ADVOWARE_API_BASE_URL
- ADVOWARE_PRODUCT_ID
- ADVOWARE_APP_ID
- ADVOWARE_API_KEY (base64 encoded)
- ADVOWARE_KANZLEI
- ADVOWARE_DATABASE
- ADVOWARE_USER
- ADVOWARE_ROLE
- ADVOWARE_PASSWORD
"""
def __init__(self, context=None):
"""
Initialize Advoware API client.
Args:
context: Motia FlowContext for logging (optional)
"""
self.context = context
self.logger = get_service_logger('advoware', context)
self.logger.debug("AdvowareAPI initializing")
# Load configuration from environment
self.API_BASE_URL = os.getenv('ADVOWARE_API_BASE_URL', 'https://www2.advo-net.net:90/')
self.product_id = int(os.getenv('ADVOWARE_PRODUCT_ID', '64'))
self.app_id = os.getenv('ADVOWARE_APP_ID', '')
self.api_key = os.getenv('ADVOWARE_API_KEY', '')
self.kanzlei = os.getenv('ADVOWARE_KANZLEI', '')
self.database = os.getenv('ADVOWARE_DATABASE', '')
self.user = os.getenv('ADVOWARE_USER', '')
self.role = int(os.getenv('ADVOWARE_ROLE', '2'))
self.password = os.getenv('ADVOWARE_PASSWORD', '')
self.token_lifetime_minutes = ADVOWARE_CONFIG.token_lifetime_minutes
self.api_timeout_seconds = API_CONFIG.default_timeout_seconds
# Initialize Redis for token caching (centralized)
self.redis_client = get_redis_client(strict=False)
if self.redis_client:
self.logger.info("Connected to Redis for token caching")
else:
self.logger.warning("⚠️ Redis unavailable - token caching disabled!")
self.logger.info("AdvowareAPI initialized")
self._session: Optional[aiohttp.ClientSession] = None
async def _get_session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession()
return self._session
async def close(self) -> None:
if self._session and not self._session.closed:
await self._session.close()
def _generate_hmac(self, request_time_stamp: str, nonce: Optional[str] = None) -> str:
"""Generate HMAC-SHA512 signature for authentication"""
if not nonce:
nonce = str(uuid.uuid4())
message = f"{self.product_id}:{self.app_id}:{nonce}:{request_time_stamp}".encode('utf-8')
try:
api_key_bytes = base64.b64decode(self.api_key)
self.logger.debug("API Key decoded from base64")
except Exception as e:
self._log(f"API Key not base64-encoded, using as-is: {e}", level='debug')
api_key_bytes = self.api_key.encode('utf-8') if isinstance(self.api_key, str) else self.api_key
signature = hmac.new(api_key_bytes, message, hashlib.sha512)
return base64.b64encode(signature.digest()).decode('utf-8')
def _fetch_new_access_token(self) -> str:
"""Fetch new access token from Advoware Auth API"""
self.logger.info("Fetching new access token from Advoware")
nonce = str(uuid.uuid4())
request_time_stamp = datetime.datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
hmac_signature = self._generate_hmac(request_time_stamp, nonce)
headers = {'Content-Type': 'application/json'}
data = {
"AppID": self.app_id,
"Kanzlei": self.kanzlei,
"Database": self.database,
"User": self.user,
"Role": self.role,
"Product": self.product_id,
"Password": self.password,
"Nonce": nonce,
"HMAC512Signature": hmac_signature,
"RequestTimeStamp": request_time_stamp
}
self.logger.debug(f"Token request: AppID={self.app_id}, User={self.user}")
# Using synchronous requests for token fetch (called from sync context)
# TODO: Convert to async in future version
import requests
try:
response = requests.post(
ADVOWARE_CONFIG.auth_url,
json=data,
headers=headers,
timeout=self.api_timeout_seconds
)
self.logger.debug(f"Token response status: {response.status_code}")
if response.status_code == 401:
raise AdvowareAuthError(
"Authentication failed - check credentials",
status_code=401
)
response.raise_for_status()
except requests.Timeout:
raise AdvowareTimeoutError(
"Token request timed out",
status_code=408
)
except requests.RequestException as e:
raise AdvowareAPIError(
f"Token request failed: {str(e)}",
status_code=getattr(e.response, 'status_code', None) if hasattr(e, 'response') else None
)
result = response.json()
access_token = result.get("access_token")
if not access_token:
self.logger.error("No access_token in response")
raise AdvowareAuthError("No access_token received from Advoware")
self.logger.info("Access token fetched successfully")
# Cache token in Redis
if self.redis_client:
effective_ttl = max(1, (self.token_lifetime_minutes - 2) * 60)
self.redis_client.set(ADVOWARE_CONFIG.token_cache_key, access_token, ex=effective_ttl)
self.redis_client.set(ADVOWARE_CONFIG.token_timestamp_key, str(time.time()), ex=effective_ttl)
self.logger.debug(f"Token cached in Redis with TTL {effective_ttl}s")
return access_token
def get_access_token(self, force_refresh: bool = False) -> str:
"""
Get valid access token (from cache or fetch new).
Args:
force_refresh: Force token refresh even if cached
Returns:
Valid access token
"""
self.logger.debug("Getting access token")
if not self.redis_client:
self.logger.info("No Redis available, fetching new token")
return self._fetch_new_access_token()
if force_refresh:
self.logger.info("Force refresh requested, fetching new token")
return self._fetch_new_access_token()
# Check cache
cached_token = self.redis_client.get(ADVOWARE_CONFIG.token_cache_key)
token_timestamp = self.redis_client.get(ADVOWARE_CONFIG.token_timestamp_key)
if cached_token and token_timestamp:
try:
# Redis decode_responses=True returns strings
timestamp = float(token_timestamp)
age_seconds = time.time() - timestamp
if age_seconds < (self.token_lifetime_minutes - 1) * 60:
self.logger.debug(f"Using cached token (age: {age_seconds:.0f}s)")
return cached_token
except (ValueError, AttributeError, TypeError) as e:
self.logger.debug(f"Error reading cached token: {e}")
self.logger.info("Cached token expired or invalid, fetching new")
return self._fetch_new_access_token()
async def api_call(
self,
endpoint: str,
method: str = 'GET',
headers: Optional[Dict] = None,
params: Optional[Dict] = None,
json_data: Optional[Dict] = None,
files: Optional[Any] = None,
data: Optional[Any] = None,
timeout_seconds: Optional[int] = None
) -> Any:
"""
Make async API call to Advoware.
Args:
endpoint: API endpoint (without base URL)
method: HTTP method
headers: Optional headers
params: Optional query parameters
json_data: Optional JSON body
files: Optional files (not implemented)
data: Optional raw data (overrides json_data)
timeout_seconds: Optional timeout override
Returns:
JSON response or None
Raises:
AdvowareAuthError: Authentication failed
AdvowareTimeoutError: Request timed out
AdvowareAPIError: Other API errors
"""
# Clean endpoint
endpoint = endpoint.lstrip('/')
url = self.API_BASE_URL.rstrip('/') + '/' + endpoint
effective_timeout = aiohttp.ClientTimeout(
total=timeout_seconds or self.api_timeout_seconds
)
# Get auth token
try:
token = self.get_access_token()
except AdvowareAuthError:
raise
except Exception as e:
raise AdvowareAPIError(f"Failed to get access token: {str(e)}")
# Prepare headers
effective_headers = headers.copy() if headers else {}
effective_headers['Authorization'] = f'Bearer {token}'
effective_headers.setdefault('Content-Type', 'application/json')
# Use 'data' parameter if provided, otherwise 'json_data'
json_payload = data if data is not None else json_data
session = await self._get_session()
try:
with self.logger.api_call(endpoint, method):
async with session.request(
method,
url,
headers=effective_headers,
params=params,
json=json_payload,
timeout=effective_timeout
) as response:
# Handle 401 - retry with fresh token
if response.status == 401:
self.logger.warning("401 Unauthorized, refreshing token")
token = self.get_access_token(force_refresh=True)
effective_headers['Authorization'] = f'Bearer {token}'
async with session.request(
method,
url,
headers=effective_headers,
params=params,
json=json_payload,
timeout=effective_timeout
) as retry_response:
if retry_response.status == 401:
raise AdvowareAuthError(
"Authentication failed even after token refresh",
status_code=401
)
if retry_response.status >= 500:
error_text = await retry_response.text()
raise RetryableError(
f"Server error {retry_response.status}: {error_text}"
)
retry_response.raise_for_status()
return await self._parse_response(retry_response)
# Handle other error codes
if response.status == 404:
error_text = await response.text()
raise AdvowareAPIError(
f"Resource not found: {endpoint}",
status_code=404,
response_body=error_text
)
if response.status >= 500:
error_text = await response.text()
raise RetryableError(
f"Server error {response.status}: {error_text}"
)
if response.status >= 400:
error_text = await response.text()
raise AdvowareAPIError(
f"API error {response.status}: {error_text}",
status_code=response.status,
response_body=error_text
)
return await self._parse_response(response)
except asyncio.TimeoutError:
raise AdvowareTimeoutError(
f"Request timed out after {effective_timeout.total}s",
status_code=408
)
except aiohttp.ClientError as e:
self.logger.error(f"API call failed: {e}")
raise AdvowareAPIError(f"Request failed: {str(e)}")
async def _parse_response(self, response: aiohttp.ClientResponse) -> Any:
"""Parse API response"""
if response.content_type == 'application/json':
try:
return await response.json()
except Exception as e:
self.logger.debug(f"JSON parse error: {e}")
return None
return None