lcp-sdk 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lcp_sdk/__init__.py +24 -0
- lcp_sdk/client.py +163 -0
- lcp_sdk/envelope.py +54 -0
- lcp_sdk/schema-bundle.json +3230 -0
- lcp_sdk/schemas/ack.json +50 -0
- lcp_sdk/schemas/bid.json +55 -0
- lcp_sdk/schemas/call.json +222 -0
- lcp_sdk/schemas/core.json +247 -0
- lcp_sdk/schemas/envelope.json +97 -0
- lcp_sdk/schemas/event.json +34 -0
- lcp_sdk/schemas/lead.json +167 -0
- lcp_sdk/schemas/offer.json +312 -0
- lcp_sdk/schemas/ping.json +182 -0
- lcp_sdk/schemas/post.json +167 -0
- lcp_sdk/signing.py +80 -0
- lcp_sdk/validation.py +172 -0
- lcp_sdk/verticals/home_services.json +110 -0
- lcp_sdk/verticals/insurance.json +116 -0
- lcp_sdk/verticals/legal.json +73 -0
- lcp_sdk/verticals/mortgage.json +191 -0
- lcp_sdk/verticals/mva.json +122 -0
- lcp_sdk/verticals/solar.json +76 -0
- lcp_sdk/webhook.py +52 -0
- lcp_sdk-1.0.0.dist-info/METADATA +11 -0
- lcp_sdk-1.0.0.dist-info/RECORD +26 -0
- lcp_sdk-1.0.0.dist-info/WHEEL +4 -0
lcp_sdk/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Standalone LCP SDK for Python integrations."""
|
|
2
|
+
|
|
3
|
+
from .client import LCPClient, LCPHTTPError
|
|
4
|
+
from .envelope import build_envelope, message_metadata, utc_timestamp
|
|
5
|
+
from .signing import SignatureError, canonical_signing_bytes, sign_hmac, verify_hmac
|
|
6
|
+
from .validation import SchemaValidator, ValidationError
|
|
7
|
+
from .webhook import verify_http_request
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"LCPClient",
|
|
11
|
+
"LCPHTTPError",
|
|
12
|
+
"SchemaValidator",
|
|
13
|
+
"SignatureError",
|
|
14
|
+
"ValidationError",
|
|
15
|
+
"build_envelope",
|
|
16
|
+
"canonical_signing_bytes",
|
|
17
|
+
"message_metadata",
|
|
18
|
+
"sign_hmac",
|
|
19
|
+
"utc_timestamp",
|
|
20
|
+
"verify_hmac",
|
|
21
|
+
"verify_http_request",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
__version__ = "1.0.0"
|
lcp_sdk/client.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""HTTP client for LCP-compatible endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.parse import quote
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from .envelope import message_metadata, utc_timestamp
|
|
13
|
+
from .signing import sign_hmac
|
|
14
|
+
from .validation import SchemaValidator
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LCPHTTPError(RuntimeError):
|
|
18
|
+
"""Raised when an LCP endpoint returns a non-success response."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, status_code: int, body: Any):
|
|
21
|
+
self.status_code = status_code
|
|
22
|
+
self.body = body
|
|
23
|
+
super().__init__(f"LCP HTTP {status_code}: {body}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class LCPClient:
|
|
27
|
+
"""Small, dependency-light client for the LCP HTTP binding."""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
endpoint: str,
|
|
32
|
+
*,
|
|
33
|
+
sender_id: str | None = None,
|
|
34
|
+
api_key: str | None = None,
|
|
35
|
+
hmac_secret: str | None = None,
|
|
36
|
+
timeout: float = 30.0,
|
|
37
|
+
max_retries: int = 2,
|
|
38
|
+
validator: SchemaValidator | None = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
self.endpoint = endpoint.rstrip("/")
|
|
41
|
+
self.sender_id = sender_id
|
|
42
|
+
self.api_key = api_key
|
|
43
|
+
self.hmac_secret = hmac_secret
|
|
44
|
+
self.timeout = timeout
|
|
45
|
+
self.max_retries = max(0, max_retries)
|
|
46
|
+
self.validator = validator
|
|
47
|
+
|
|
48
|
+
def _headers(
|
|
49
|
+
self,
|
|
50
|
+
body: bytes,
|
|
51
|
+
*,
|
|
52
|
+
idempotency_key: str | None,
|
|
53
|
+
test: bool = False,
|
|
54
|
+
) -> dict[str, str]:
|
|
55
|
+
headers = {
|
|
56
|
+
"Content-Type": "application/json",
|
|
57
|
+
"Accept": "application/json",
|
|
58
|
+
}
|
|
59
|
+
if self.sender_id:
|
|
60
|
+
headers["X-LCP-Sender-Id"] = self.sender_id
|
|
61
|
+
if self.api_key:
|
|
62
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
63
|
+
if self.hmac_secret:
|
|
64
|
+
timestamp = utc_timestamp()
|
|
65
|
+
headers["X-LCP-Timestamp"] = timestamp
|
|
66
|
+
if idempotency_key:
|
|
67
|
+
headers["X-LCP-Idempotency-Key"] = idempotency_key
|
|
68
|
+
headers["X-LCP-Signature"] = sign_hmac(
|
|
69
|
+
self.hmac_secret, timestamp, idempotency_key, body
|
|
70
|
+
)
|
|
71
|
+
elif idempotency_key:
|
|
72
|
+
headers["X-LCP-Idempotency-Key"] = idempotency_key
|
|
73
|
+
if test:
|
|
74
|
+
headers["X-LCP-Test"] = "true"
|
|
75
|
+
return headers
|
|
76
|
+
|
|
77
|
+
def request(
|
|
78
|
+
self,
|
|
79
|
+
method: str,
|
|
80
|
+
path: str,
|
|
81
|
+
*,
|
|
82
|
+
payload: dict[str, Any] | None = None,
|
|
83
|
+
params: dict[str, Any] | None = None,
|
|
84
|
+
idempotency_key: str | None = None,
|
|
85
|
+
test: bool = False,
|
|
86
|
+
) -> dict[str, Any]:
|
|
87
|
+
body = b""
|
|
88
|
+
if payload is not None:
|
|
89
|
+
body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode(
|
|
90
|
+
"utf-8"
|
|
91
|
+
)
|
|
92
|
+
if idempotency_key is None:
|
|
93
|
+
try:
|
|
94
|
+
idempotency_key = message_metadata(payload)["idempotency_key"]
|
|
95
|
+
except (KeyError, ValueError):
|
|
96
|
+
pass
|
|
97
|
+
headers = self._headers(body, idempotency_key=idempotency_key, test=test)
|
|
98
|
+
url = f"{self.endpoint}/{path.lstrip('/')}"
|
|
99
|
+
retryable = method.upper() in {"GET", "HEAD", "PUT", "DELETE", "POST"}
|
|
100
|
+
last_error: Exception | None = None
|
|
101
|
+
|
|
102
|
+
for attempt in range(self.max_retries + 1):
|
|
103
|
+
try:
|
|
104
|
+
with httpx.Client(timeout=self.timeout) as client:
|
|
105
|
+
response = client.request(
|
|
106
|
+
method,
|
|
107
|
+
url,
|
|
108
|
+
content=body or None,
|
|
109
|
+
headers=headers,
|
|
110
|
+
params=params,
|
|
111
|
+
)
|
|
112
|
+
except httpx.RequestError as exc:
|
|
113
|
+
last_error = exc
|
|
114
|
+
if not retryable or attempt >= self.max_retries:
|
|
115
|
+
raise
|
|
116
|
+
time.sleep(2**attempt)
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
data = response.json()
|
|
121
|
+
except ValueError:
|
|
122
|
+
data = {"raw": response.text}
|
|
123
|
+
if response.is_success:
|
|
124
|
+
return data
|
|
125
|
+
if response.status_code in {429, 500, 502, 503, 504} and attempt < self.max_retries:
|
|
126
|
+
retry_after = response.headers.get("Retry-After")
|
|
127
|
+
delay = float(retry_after) if retry_after and retry_after.isdigit() else 2**attempt
|
|
128
|
+
time.sleep(delay)
|
|
129
|
+
continue
|
|
130
|
+
raise LCPHTTPError(response.status_code, data)
|
|
131
|
+
|
|
132
|
+
if last_error:
|
|
133
|
+
raise last_error
|
|
134
|
+
raise RuntimeError("LCP request failed without a response")
|
|
135
|
+
|
|
136
|
+
def submit_lead(self, envelope: dict[str, Any], *, test: bool = False) -> dict[str, Any]:
|
|
137
|
+
self._validate(envelope)
|
|
138
|
+
return self.request("POST", "/v1/lcp/leads", payload=envelope, test=test)
|
|
139
|
+
|
|
140
|
+
def submit_call(self, envelope: dict[str, Any], *, test: bool = False) -> dict[str, Any]:
|
|
141
|
+
self._validate(envelope)
|
|
142
|
+
return self.request("POST", "/v1/lcp/calls", payload=envelope, test=test)
|
|
143
|
+
|
|
144
|
+
def submit_bid(self, envelope: dict[str, Any], *, test: bool = False) -> dict[str, Any]:
|
|
145
|
+
self._validate(envelope)
|
|
146
|
+
return self.request("POST", "/v1/lcp/bids", payload=envelope, test=test)
|
|
147
|
+
|
|
148
|
+
def query_lead_status(self, lead_id: str) -> dict[str, Any]:
|
|
149
|
+
return self.request("GET", f"/v1/lcp/leads/{quote(lead_id, safe='')}")
|
|
150
|
+
|
|
151
|
+
def get_schema(self, name: str) -> dict[str, Any]:
|
|
152
|
+
return self.request("GET", f"/v1/lcp/schemas/{quote(name, safe='/')}")
|
|
153
|
+
|
|
154
|
+
def get_capabilities(self) -> dict[str, Any]:
|
|
155
|
+
return self.request("GET", "/v1/lcp/capabilities")
|
|
156
|
+
|
|
157
|
+
def list_offers(self, vertical: str | None = None) -> dict[str, Any]:
|
|
158
|
+
params = {"vertical": vertical} if vertical else None
|
|
159
|
+
return self.request("GET", "/v1/lcp/offers", params=params)
|
|
160
|
+
|
|
161
|
+
def _validate(self, envelope: dict[str, Any]) -> None:
|
|
162
|
+
if self.validator:
|
|
163
|
+
self.validator.require_valid_envelope(envelope)
|
lcp_sdk/envelope.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Helpers for constructing LCP envelopes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any
|
|
7
|
+
from uuid import uuid4
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def utc_timestamp() -> str:
|
|
11
|
+
"""Return the current UTC time in the canonical LCP format."""
|
|
12
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def build_envelope(
|
|
16
|
+
message_type: str,
|
|
17
|
+
sender_id: str,
|
|
18
|
+
receiver_id: str,
|
|
19
|
+
payload: dict[str, Any],
|
|
20
|
+
*,
|
|
21
|
+
version: str = "1.0.0",
|
|
22
|
+
message_id: str | None = None,
|
|
23
|
+
correlation_id: str | None = None,
|
|
24
|
+
idempotency_key: str | None = None,
|
|
25
|
+
timestamp: str | None = None,
|
|
26
|
+
test: bool = False,
|
|
27
|
+
) -> dict[str, Any]:
|
|
28
|
+
"""Build a complete LCP envelope around a message payload."""
|
|
29
|
+
message_id = message_id or str(uuid4())
|
|
30
|
+
idempotency_key = idempotency_key or f"{sender_id}-{message_type}-{uuid4().hex}"
|
|
31
|
+
return {
|
|
32
|
+
"lcp": {
|
|
33
|
+
"version": version,
|
|
34
|
+
"message": {
|
|
35
|
+
"id": message_id,
|
|
36
|
+
"type": message_type,
|
|
37
|
+
"timestamp": timestamp or utc_timestamp(),
|
|
38
|
+
"sender_id": sender_id,
|
|
39
|
+
"receiver_id": receiver_id,
|
|
40
|
+
"correlation_id": correlation_id,
|
|
41
|
+
"idempotency_key": idempotency_key,
|
|
42
|
+
"test": test,
|
|
43
|
+
},
|
|
44
|
+
"payload": payload,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def message_metadata(envelope: dict[str, Any]) -> dict[str, Any]:
|
|
50
|
+
"""Return the message header or raise for a malformed envelope."""
|
|
51
|
+
try:
|
|
52
|
+
return envelope["lcp"]["message"]
|
|
53
|
+
except (KeyError, TypeError) as exc:
|
|
54
|
+
raise ValueError("Envelope must contain lcp.message") from exc
|