Files
wyndham-ARR/arr_ingestion/contracts.py
2026-07-31 15:11:42 +08:00

276 lines
9.7 KiB
Python

"""Strict, privacy-safe contracts for ARR processing deliveries."""
from __future__ import annotations
import re
from dataclasses import dataclass
from datetime import date
from pathlib import PurePosixPath
from typing import Any, Dict, Mapping, Optional
DELIVERY_SCHEMA_VERSION = "1.0"
RESULT_SCHEMA_VERSION = "3.0"
XLSX_MIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
OPAQUE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
PROCESSOR_VERSION_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$")
TOP_LEVEL_FIELDS = {
"delivery_schema_version",
"delivery_id",
"job_id",
"attempt_no",
"status",
"processor_version",
"rule_set_sha256",
"result_schema_version",
"business_date",
"artifacts",
}
ARTIFACT_FIELDS = {
"object_key",
"original_filename",
"sha256",
"byte_size",
"mime_type",
}
ARTIFACT_ROLES = {
"source_xml",
"daily_report",
"result_json",
"structured_result_json",
"exception_report",
}
ROLE_CONTRACTS = {
"source_xml": ("opera_xml", ".xml", "application/xml"),
"booking_source": ("booking_excel", ".xlsx", XLSX_MIME),
"daily_report": ("daily_xlsx", ".xlsx", XLSX_MIME),
"result_json": ("result_json", ".json", "application/json"),
"structured_result_json": (
"structured_result_json",
".json",
"application/json",
),
"exception_report": ("exception_xlsx", ".xlsx", XLSX_MIME),
}
ARTIFACT_SIZE_LIMITS = {
"source_xml": 100 * 1024 * 1024,
"booking_source": 25 * 1024 * 1024,
"daily_report": 100 * 1024 * 1024,
"result_json": 5 * 1024 * 1024,
"structured_result_json": 50 * 1024 * 1024,
"exception_report": 20 * 1024 * 1024,
}
class IngestionError(RuntimeError):
"""A stable, non-sensitive validation or persistence error."""
def __init__(
self,
code: str,
safe_message: str,
*,
retryable: bool = False,
):
super().__init__(safe_message)
self.code = code
self.safe_message = safe_message
self.retryable = retryable
def _fail(code: str, safe_message: str) -> None:
raise IngestionError(code, safe_message)
def _strict_mapping(value: Any, expected: set[str], label: str) -> Mapping[str, Any]:
if not isinstance(value, Mapping) or set(value) != expected:
_fail("DELIVERY_INVALID", f"{label} field set is invalid")
return value
def _valid_object_key(value: Any) -> bool:
if not isinstance(value, str) or not value or len(value) > 1024:
return False
if (
value.startswith("/")
or value.endswith("/")
or "\\" in value
or "?" in value
or "#" in value
or "//" in value
or any(ord(char) < 32 or ord(char) == 127 for char in value)
):
return False
path = PurePosixPath(value)
return not path.is_absolute() and ".." not in path.parts and "." not in path.parts
def _valid_basename(value: Any) -> bool:
return (
isinstance(value, str)
and 0 < len(value) <= 255
and PurePosixPath(value).name == value
and value not in {".", ".."}
and "/" not in value
and "\\" not in value
and not any(ord(char) < 32 or ord(char) == 127 for char in value)
)
@dataclass(frozen=True)
class ArtifactRef:
role: str
file_kind: str
object_key: str
original_filename: str
sha256: str
byte_size: int
mime_type: str
@classmethod
def from_dict(cls, role: str, payload: Any) -> "ArtifactRef":
if role not in ROLE_CONTRACTS:
_fail("DELIVERY_INVALID", "artifact role is invalid")
values = _strict_mapping(payload, ARTIFACT_FIELDS, f"{role} artifact")
file_kind, extension, expected_mime = ROLE_CONTRACTS[role]
object_key = values.get("object_key")
original_filename = values.get("original_filename")
sha256 = values.get("sha256")
byte_size = values.get("byte_size")
mime_type = values.get("mime_type")
if (
not _valid_object_key(object_key)
or not _valid_basename(original_filename)
or not str(original_filename).lower().endswith(extension)
or not isinstance(sha256, str)
or not SHA256_RE.fullmatch(sha256)
or not isinstance(byte_size, int)
or isinstance(byte_size, bool)
or byte_size < 0
or mime_type != expected_mime
):
_fail("ARTIFACT_REFERENCE_INVALID", f"{role} metadata is invalid")
return cls(
role=role,
file_kind=file_kind,
object_key=str(object_key),
original_filename=str(original_filename),
sha256=sha256,
byte_size=byte_size,
mime_type=str(mime_type),
)
def to_dict(self) -> Dict[str, Any]:
return {
"object_key": self.object_key,
"original_filename": self.original_filename,
"sha256": self.sha256,
"byte_size": self.byte_size,
"mime_type": self.mime_type,
}
@dataclass(frozen=True)
class DeliveryEnvelope:
delivery_id: str
job_id: str
attempt_no: int
status: str
processor_version: str
rule_set_sha256: str
result_schema_version: str
business_date: Optional[date]
artifacts: Mapping[str, Optional[ArtifactRef]]
@classmethod
def from_dict(cls, payload: Any) -> "DeliveryEnvelope":
values = _strict_mapping(payload, TOP_LEVEL_FIELDS, "delivery")
if values.get("delivery_schema_version") != DELIVERY_SCHEMA_VERSION:
_fail("DELIVERY_INVALID", "delivery schema version is unsupported")
delivery_id = values.get("delivery_id")
job_id = values.get("job_id")
attempt_no = values.get("attempt_no")
status = values.get("status")
processor_version = values.get("processor_version")
rule_set_sha256 = values.get("rule_set_sha256")
result_schema_version = values.get("result_schema_version")
if not isinstance(delivery_id, str) or not OPAQUE_ID_RE.fullmatch(delivery_id):
_fail("DELIVERY_INVALID", "delivery identifier is invalid")
if not isinstance(job_id, str) or not OPAQUE_ID_RE.fullmatch(job_id):
_fail("DELIVERY_INVALID", "job identifier is invalid")
if not isinstance(attempt_no, int) or isinstance(attempt_no, bool) or attempt_no < 1:
_fail("DELIVERY_INVALID", "attempt number is invalid")
if status not in {"success", "failed"}:
_fail("DELIVERY_INVALID", "delivery status is invalid")
if (
not isinstance(processor_version, str)
or not PROCESSOR_VERSION_RE.fullmatch(processor_version)
):
_fail("DELIVERY_INVALID", "processor version is invalid")
if not isinstance(rule_set_sha256, str) or not SHA256_RE.fullmatch(rule_set_sha256):
_fail("DELIVERY_INVALID", "rule set identity is invalid")
if result_schema_version != RESULT_SCHEMA_VERSION:
_fail("DELIVERY_INVALID", "result schema version is unsupported")
raw_business_date = values.get("business_date")
parsed_date: Optional[date]
if raw_business_date is None:
parsed_date = None
elif isinstance(raw_business_date, str):
try:
parsed_date = date.fromisoformat(raw_business_date)
except ValueError:
_fail("DELIVERY_INVALID", "business date is invalid")
else:
_fail("DELIVERY_INVALID", "business date is invalid")
raw_artifacts = _strict_mapping(values.get("artifacts"), ARTIFACT_ROLES, "artifact")
artifacts: Dict[str, Optional[ArtifactRef]] = {}
for role in sorted(ARTIFACT_ROLES):
raw_artifact = raw_artifacts.get(role)
artifacts[role] = (
None if raw_artifact is None else ArtifactRef.from_dict(role, raw_artifact)
)
always_required = ("source_xml", "result_json", "structured_result_json")
if any(artifacts[role] is None for role in always_required):
_fail("DELIVERY_INVALID", "required delivery artifact is missing")
if status == "success":
if (
parsed_date is None
or artifacts["daily_report"] is None
or artifacts["exception_report"] is not None
):
_fail("DELIVERY_INVALID", "success delivery artifact shape is invalid")
elif artifacts["daily_report"] is not None or artifacts["exception_report"] is None:
_fail("DELIVERY_INVALID", "failed delivery artifact shape is invalid")
return cls(
delivery_id=delivery_id,
job_id=job_id,
attempt_no=attempt_no,
status=status,
processor_version=processor_version,
rule_set_sha256=rule_set_sha256,
result_schema_version=result_schema_version,
business_date=parsed_date,
artifacts=artifacts,
)
def to_dict(self) -> Dict[str, Any]:
return {
"delivery_schema_version": DELIVERY_SCHEMA_VERSION,
"delivery_id": self.delivery_id,
"job_id": self.job_id,
"attempt_no": self.attempt_no,
"status": self.status,
"processor_version": self.processor_version,
"rule_set_sha256": self.rule_set_sha256,
"result_schema_version": self.result_schema_version,
"business_date": self.business_date.isoformat() if self.business_date else None,
"artifacts": {
role: reference.to_dict() if reference is not None else None
for role, reference in self.artifacts.items()
},
}