Move webhook config loading, UUID and warning helpers and the atomic semicolon CSV writer out of geo_alarm so the upcoming point alarm clients can reuse them. Behavior is unchanged and the existing geo tests still pass.
157 lines
5.5 KiB
Python
157 lines
5.5 KiB
Python
"""Helpers shared by the nowcast alarm clients (geo and point)."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import csv
|
||
import json
|
||
import os
|
||
import tempfile
|
||
from copy import deepcopy
|
||
from pathlib import Path
|
||
from typing import Any
|
||
from urllib.parse import urlsplit
|
||
from uuid import UUID
|
||
|
||
from common import ConfigurationError
|
||
|
||
|
||
AUTHENTICATION_FIELDS = {
|
||
"BASIC": ("username", "password"),
|
||
"JWT_TOKEN": ("jwtToken",),
|
||
"API_KEY": ("apiKey", "asQueryParameter", "asHeader"),
|
||
"HMAC_SIGNATURE": ("clientId", "secret", "algorithm"),
|
||
}
|
||
|
||
|
||
def read_json_object(path: Path, description: str) -> dict[str, Any]:
|
||
try:
|
||
data = json.loads(path.read_text(encoding="utf-8"))
|
||
except FileNotFoundError as exc:
|
||
raise ConfigurationError(f"{description} dosyası bulunamadı: {path}") from exc
|
||
except json.JSONDecodeError as exc:
|
||
raise ConfigurationError(
|
||
f"{description} geçerli JSON değil ({path}:{exc.lineno})."
|
||
) from exc
|
||
if not isinstance(data, dict):
|
||
raise ConfigurationError(f"{description} kök değeri JSON nesnesi olmalıdır.")
|
||
return data
|
||
|
||
|
||
def required_config_value(data: dict[str, Any], key: str, location: str) -> Any:
|
||
value = data.get(key)
|
||
if value is None or (isinstance(value, str) and not value.strip()):
|
||
raise ConfigurationError(f"{location}.{key} eksik veya boş.")
|
||
if isinstance(value, str) and "change-me" in value.strip().lower():
|
||
raise ConfigurationError(f"{location}.{key} placeholder değeri değiştirilmemiş.")
|
||
return value
|
||
|
||
|
||
def load_webhook_config(path: Path) -> dict[str, Any]:
|
||
config = read_json_object(path, "Webhook config")
|
||
url = str(required_config_value(config, "url", "webhook"))
|
||
parsed_url = urlsplit(url)
|
||
if parsed_url.scheme != "https" or not parsed_url.netloc:
|
||
raise ConfigurationError("webhook.url geçerli bir HTTPS adresi olmalıdır.")
|
||
|
||
http_method = str(required_config_value(config, "httpMethod", "webhook")).upper()
|
||
if http_method not in {"POST", "PUT", "GET"}:
|
||
raise ConfigurationError("webhook.httpMethod POST, PUT veya GET olmalıdır.")
|
||
|
||
authentication = config.get("authentication")
|
||
if not isinstance(authentication, dict):
|
||
raise ConfigurationError("webhook.authentication JSON nesnesi olmalıdır.")
|
||
selected = str(
|
||
required_config_value(authentication, "selected", "webhook.authentication")
|
||
).upper()
|
||
if selected not in AUTHENTICATION_FIELDS:
|
||
allowed = ", ".join(AUTHENTICATION_FIELDS)
|
||
raise ConfigurationError(
|
||
f"webhook.authentication.selected şu değerlerden biri olmalıdır: {allowed}."
|
||
)
|
||
options = authentication.get("options")
|
||
if not isinstance(options, dict) or not isinstance(options.get(selected), dict):
|
||
raise ConfigurationError(
|
||
f"webhook.authentication.options.{selected} JSON nesnesi olmalıdır."
|
||
)
|
||
selected_options = options[selected]
|
||
auth_payload: dict[str, Any] = {"type": selected}
|
||
for field in AUTHENTICATION_FIELDS[selected]:
|
||
auth_payload[field] = required_config_value(
|
||
selected_options, field, f"webhook.authentication.options.{selected}"
|
||
)
|
||
|
||
payload: dict[str, Any] = {
|
||
"url": url,
|
||
"httpMethod": http_method,
|
||
"contentType": str(
|
||
required_config_value(config, "contentType", "webhook")
|
||
),
|
||
"authentication": auth_payload,
|
||
}
|
||
for optional_field in ("requestHeaders", "deliveryPolicy"):
|
||
if optional_field in config:
|
||
if not isinstance(config[optional_field], dict):
|
||
raise ConfigurationError(
|
||
f"webhook.{optional_field} JSON nesnesi olmalıdır."
|
||
)
|
||
payload[optional_field] = deepcopy(config[optional_field])
|
||
return payload
|
||
|
||
|
||
def as_uuid(value: Any, *, version: int | None = None) -> str | None:
|
||
if not isinstance(value, str):
|
||
return None
|
||
try:
|
||
parsed = UUID(value)
|
||
except ValueError:
|
||
return None
|
||
if version is not None and parsed.version != version:
|
||
return None
|
||
return value
|
||
|
||
|
||
def response_warnings(*objects: Any) -> list[str]:
|
||
warnings: list[str] = []
|
||
for data in objects:
|
||
if not isinstance(data, dict):
|
||
continue
|
||
for key in ("warning", "warnings"):
|
||
value = data.get(key)
|
||
if isinstance(value, str) and value.strip():
|
||
warnings.append(value.strip())
|
||
elif isinstance(value, list):
|
||
warnings.extend(str(item) for item in value if str(item).strip())
|
||
return warnings
|
||
|
||
|
||
def write_csv_atomic(
|
||
path: Path, rows: list[dict[str, Any]], fieldnames: list[str]
|
||
) -> None:
|
||
"""Rewrite a semicolon separated CSV through a temp file in the same directory."""
|
||
temp_path: Path | None = None
|
||
try:
|
||
with tempfile.NamedTemporaryFile(
|
||
"w",
|
||
encoding="utf-8-sig",
|
||
newline="",
|
||
dir=path.parent,
|
||
prefix=f".{path.name}.",
|
||
suffix=".tmp",
|
||
delete=False,
|
||
) as temp_file:
|
||
temp_path = Path(temp_file.name)
|
||
writer = csv.DictWriter(
|
||
temp_file,
|
||
fieldnames=fieldnames,
|
||
delimiter=";",
|
||
extrasaction="ignore",
|
||
)
|
||
writer.writeheader()
|
||
writer.writerows(rows)
|
||
temp_file.flush()
|
||
os.fsync(temp_file.fileno())
|
||
os.replace(temp_path, path)
|
||
finally:
|
||
if temp_path and temp_path.exists():
|
||
temp_path.unlink()
|