"""Client Python (3.9+) pour l'API ClientZen v1 — bibliothèque standard uniquement. from clientzen import ClientZen cz = ClientZen(os.environ["CLIENTZEN_TOKEN"]) for c in cz.each("/customers", {"q": "Petit"}): print(c["name"]) created = cz.post("/customers", {"type": "company", "name": "Boulangerie Petit"}, idempotency_key="form-42") """ import hashlib import hmac import json import time import urllib.error import urllib.parse import urllib.request class ClientZenApiError(Exception): def __init__(self, status, problem): super().__init__(f"ClientZen API {status} : {problem.get('title', 'erreur')}" + (f" — {problem['detail']}" if problem.get("detail") else "")) self.status, self.problem = status, problem class ClientZen: def __init__(self, token, base_url="https://clientzen.fr/api/v1", timeout=15): self.token, self.base_url, self.timeout = token, base_url, timeout def get(self, path, query=None): qs = urllib.parse.urlencode({k: v for k, v in (query or {}).items() if v not in (None, "")}) return self._json("GET", path + (f"?{qs}" if qs else "")) def post(self, path, body, idempotency_key=None): return self._json("POST", path, body, idempotency_key) def patch(self, path, body): return self._json("PATCH", path, body) def download(self, path): status, raw = self._raw("GET", path, None, None, "application/pdf") if status >= 400: raise ClientZenApiError(status, _problem(raw)) return raw def each(self, path, query=None): page = 1 while True: res = self.get(path, {**(query or {}), "page": page, "per_page": 100}) yield from res.get("data", []) if page >= res.get("meta", {}).get("last_page", 1): return page += 1 def _json(self, method, path, body=None, idempotency_key=None): status, raw = self._raw(method, path, body, idempotency_key, "application/json") if status >= 400: raise ClientZenApiError(status, _problem(raw)) return json.loads(raw or b"{}") def _raw(self, method, path, body, idempotency_key, accept): headers = {"Authorization": f"Bearer {self.token}", "Accept": accept, "User-Agent": "ClientZen-Python-SDK/1"} data = None if body is not None: headers["Content-Type"] = "application/json" data = json.dumps(body).encode() if idempotency_key: headers["Idempotency-Key"] = idempotency_key req = urllib.request.Request(self.base_url + path, data=data, method=method, headers=headers) try: with urllib.request.urlopen(req, timeout=self.timeout) as resp: return resp.status, resp.read() except urllib.error.HTTPError as e: return e.code, e.read() def _problem(raw): try: return json.loads(raw or b"{}") except ValueError: return {"title": "Réponse non JSON"} def verify_webhook_signature(raw_body: bytes, signature_header: str, secret: str, tolerance_seconds: int = 300) -> bool: """Vérifie `X-ClientZen-Signature: t=…,v1=…` (HMAC-SHA256 de `t.corps`).""" try: t, v1 = (part.split("=", 1)[1] for part in signature_header.split(",")) except (ValueError, AttributeError): return False if abs(time.time() - int(t)) > tolerance_seconds: return False expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, v1)