108 lines
5.3 KiB
Python
108 lines
5.3 KiB
Python
"""Bounded read-only reservation/day rate lookup from the published Edge0.7 contract.
|
|
|
|
Standalone integration primitive. It does not change v1 captures, build XML or
|
|
submit Finance. GET is kept for diagnostic compatibility; new lookups use POST.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from decimal import Decimal
|
|
import json
|
|
import re
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
|
|
from . import collect_arr_source as source
|
|
|
|
|
|
POST = "searchRateInfo"
|
|
GET = "getRateInfo"
|
|
PATH = "/api/v1/reservations/rate-info"
|
|
require = source.require
|
|
|
|
|
|
def request(reservation_id: str, day: str, operation: str = POST):
|
|
source.Options(day, "request-validation").validate()
|
|
require(isinstance(reservation_id, str) and bool(re.fullmatch(r"[A-Za-z0-9_-]{1,128}", reservation_id)),
|
|
"invalid_reservation_id")
|
|
body = {"id": reservation_id, "type": "Reservation", "summaryInfo": False, "detailDate": day}
|
|
if operation == POST:
|
|
return "POST", PATH + "/searches", source.json_bytes(body), body
|
|
require(operation == GET, "operation_not_allowed")
|
|
query = {**body, "summaryInfo": "false"}
|
|
return "GET", PATH + "?" + urllib.parse.urlencode(query), None, None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DayRateCandidate:
|
|
effective_rate: Decimal
|
|
currency: str
|
|
# Neither reservation/date echo nor same-source report equivalence is implied.
|
|
report_equivalence_verified: bool = False
|
|
|
|
|
|
def day_rate_candidate(data: dict, *, expected_currency: str | None = None) -> DayRateCandidate:
|
|
require(isinstance(data, dict) and bool(data), "empty_rate_info")
|
|
source.check_warnings(data)
|
|
require("summary" not in data, "unexpected_rate_summary")
|
|
detail = data.get("detail")
|
|
require(isinstance(detail, dict) and bool(detail), "missing_rate_detail")
|
|
if "rateSuppressed" in detail:
|
|
require(type(detail["rateSuppressed"]) is bool, "invalid_rate_suppression_flag")
|
|
require(not detail["rateSuppressed"], "rate_suppressed")
|
|
amount = detail.get("totalRateAmount")
|
|
require(type(amount) in {int, Decimal}, "missing_or_invalid_effective_rate")
|
|
value = Decimal(str(amount))
|
|
require(value.is_finite() and value >= 0, "missing_or_invalid_effective_rate")
|
|
revenue = detail.get("revenue")
|
|
currency = revenue.get("currencyCode") if isinstance(revenue, dict) else None
|
|
require(isinstance(currency, str) and bool(re.fullmatch(r"[A-Z]{3}", currency)), "missing_or_invalid_rate_currency")
|
|
require(expected_currency is None or currency == expected_currency, "rate_currency_mismatch")
|
|
return DayRateCandidate(value, currency)
|
|
|
|
|
|
class RateInfoReader:
|
|
def __init__(self, archive: source.Archive, hotel_id: str, transport, *, key: str = "", sleep=time.sleep):
|
|
source.Options("2000-01-01", hotel_id).validate()
|
|
self.archive, self.hotel_id, self.transport = archive, hotel_id, transport
|
|
self._key, self.sleep = key, sleep
|
|
self.request_count = 0
|
|
|
|
def read(self, reservation_id: str, day: str, *, operation: str = POST) -> dict:
|
|
method, path, payload, body = request(reservation_id, day, operation)
|
|
for attempt in range(1, 4):
|
|
self.request_count += 1
|
|
label = f"rate-{self.request_count:06d}"
|
|
self.archive.write(label + ".json", {"operation_id": operation, "method": method,
|
|
"path": path, "body": body, "attempt": attempt, "started_at": source.utc_now()})
|
|
try:
|
|
status, headers, raw = self.transport(method, path, payload)
|
|
except (urllib.error.URLError, TimeoutError, ConnectionError):
|
|
self.archive.write(label + ".meta.json", {"error": "transport_failure", "ended_at": source.utc_now()})
|
|
if attempt == 3:
|
|
raise source.CollectionError("transport_retry_exhausted") from None
|
|
self.sleep(float(2 ** (attempt - 1)))
|
|
continue
|
|
require(not self._key or self._key.encode() not in raw, "secret_in_response")
|
|
headers = {k.lower(): v for k, v in headers.items()}
|
|
self.archive.write(label + ".response.bin", raw)
|
|
self.archive.write(label + ".meta.json", {"http_status": status, "ended_at": source.utc_now(),
|
|
"oversized": len(raw) > source.MAX_RESPONSE_BYTES,
|
|
"retry_after": headers.get("retry-after"), "edge_request_id": headers.get("x-request-id")})
|
|
require(len(raw) <= source.MAX_RESPONSE_BYTES, "response_too_large")
|
|
if status in source.RETRY_STATUSES and attempt < 3:
|
|
self.sleep(source.retry_delay(headers, attempt))
|
|
continue
|
|
require(status == 200, "http_failure")
|
|
source.strict_json(raw) # Enforce duplicate-key/constant/shape guards.
|
|
document = json.loads(raw, parse_float=Decimal) # Preserve decimal monetary bytes.
|
|
require(document.get("operation_id") == operation, "operation_mismatch")
|
|
require(document.get("hotel_id") == self.hotel_id, "hotel_mismatch")
|
|
require(isinstance(document.get("oracle_request_id"), str) and bool(document["oracle_request_id"]),
|
|
"missing_oracle_request_id")
|
|
require(isinstance(document.get("data"), dict), "invalid_data_envelope")
|
|
source.check_warnings(document)
|
|
return document["data"]
|
|
raise source.CollectionError("retry_exhausted")
|