refactor: extract shared nowcast helpers into nowcast_common
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.
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user