lcp-sdk 1.0.0__tar.gz
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-1.0.0/.gitignore +16 -0
- lcp_sdk-1.0.0/PKG-INFO +11 -0
- lcp_sdk-1.0.0/README.md +149 -0
- lcp_sdk-1.0.0/generated/models.py +160 -0
- lcp_sdk-1.0.0/generated/schema-manifest.json +21 -0
- lcp_sdk-1.0.0/lcp_sdk/__init__.py +24 -0
- lcp_sdk-1.0.0/lcp_sdk/client.py +163 -0
- lcp_sdk-1.0.0/lcp_sdk/envelope.py +54 -0
- lcp_sdk-1.0.0/lcp_sdk/schema-bundle.json +3230 -0
- lcp_sdk-1.0.0/lcp_sdk/schemas/ack.json +50 -0
- lcp_sdk-1.0.0/lcp_sdk/schemas/bid.json +55 -0
- lcp_sdk-1.0.0/lcp_sdk/schemas/call.json +222 -0
- lcp_sdk-1.0.0/lcp_sdk/schemas/core.json +247 -0
- lcp_sdk-1.0.0/lcp_sdk/schemas/envelope.json +97 -0
- lcp_sdk-1.0.0/lcp_sdk/schemas/event.json +34 -0
- lcp_sdk-1.0.0/lcp_sdk/schemas/lead.json +167 -0
- lcp_sdk-1.0.0/lcp_sdk/schemas/offer.json +312 -0
- lcp_sdk-1.0.0/lcp_sdk/schemas/ping.json +182 -0
- lcp_sdk-1.0.0/lcp_sdk/schemas/post.json +167 -0
- lcp_sdk-1.0.0/lcp_sdk/signing.py +80 -0
- lcp_sdk-1.0.0/lcp_sdk/validation.py +172 -0
- lcp_sdk-1.0.0/lcp_sdk/verticals/home_services.json +110 -0
- lcp_sdk-1.0.0/lcp_sdk/verticals/insurance.json +116 -0
- lcp_sdk-1.0.0/lcp_sdk/verticals/legal.json +73 -0
- lcp_sdk-1.0.0/lcp_sdk/verticals/mortgage.json +191 -0
- lcp_sdk-1.0.0/lcp_sdk/verticals/mva.json +122 -0
- lcp_sdk-1.0.0/lcp_sdk/verticals/solar.json +76 -0
- lcp_sdk-1.0.0/lcp_sdk/webhook.py +52 -0
- lcp_sdk-1.0.0/pyproject.toml +27 -0
- lcp_sdk-1.0.0/tests/test_sdk.py +124 -0
lcp_sdk-1.0.0/.gitignore
ADDED
lcp_sdk-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: lcp-sdk
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: LCP (Lead Context Protocol) SDK for validation, signing, and HTTP integrations.
|
|
5
|
+
Project-URL: Documentation, https://github.com/SpearSystems/LCP
|
|
6
|
+
Project-URL: Source, https://github.com/SpearSystems/LCP
|
|
7
|
+
License-Expression: Apache-2.0
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Requires-Dist: httpx>=0.27.0
|
|
10
|
+
Requires-Dist: jsonschema>=4.20.0
|
|
11
|
+
Requires-Dist: referencing>=0.35.0
|
lcp_sdk-1.0.0/README.md
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# LCP Python SDK
|
|
2
|
+
|
|
3
|
+
Tier 1 maintained reference SDK for LCP publishers, buyers, platforms, and
|
|
4
|
+
webhook receivers. It is independent of MCP and does not contain platform
|
|
5
|
+
routing or storage logic.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
From the repository while the package is unreleased:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
python3 -m pip install -e implementations/sdk/python
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
When a release is published:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
python3 -m pip install lcp-sdk
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The package targets Python 3.10+ and bundles the canonical schemas in released
|
|
22
|
+
wheels. See the [SDK support policy](../../../docs/SDK-ROADMAP.md) for
|
|
23
|
+
compatibility and release gates.
|
|
24
|
+
|
|
25
|
+
## Build and validate messages
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from lcp_sdk import SchemaValidator, build_envelope
|
|
29
|
+
|
|
30
|
+
lead = build_envelope(
|
|
31
|
+
"lead",
|
|
32
|
+
sender_id="publisher_001",
|
|
33
|
+
receiver_id="platform_001",
|
|
34
|
+
payload={
|
|
35
|
+
"lead_id": "lead_123",
|
|
36
|
+
"status": "NEW",
|
|
37
|
+
"channel": "form",
|
|
38
|
+
"consumer": {"phone": "+61412345678"},
|
|
39
|
+
"location": {"country_code": "AU", "postal_code": "2000"},
|
|
40
|
+
"attributes": {"vertical": "mortgage", "schema_version": "1.0.0"},
|
|
41
|
+
},
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
SchemaValidator().require_valid_envelope(lead)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`SchemaValidator` validates the envelope, message payload, offer documents,
|
|
48
|
+
and the ping-safe tags in vertical schemas. The released wheel bundles the
|
|
49
|
+
canonical Draft 2020-12 schemas and generated `TypedDict` models in
|
|
50
|
+
`lcp_sdk.generated.models`; set `LCP_SCHEMA_DIR` when using an external schema
|
|
51
|
+
release instead.
|
|
52
|
+
|
|
53
|
+
## HTTP client
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from lcp_sdk import LCPClient, SchemaValidator
|
|
57
|
+
|
|
58
|
+
client = LCPClient(
|
|
59
|
+
"https://platform.example",
|
|
60
|
+
sender_id="publisher_001",
|
|
61
|
+
hmac_secret="secret-from-your-secret-manager",
|
|
62
|
+
validator=SchemaValidator(),
|
|
63
|
+
max_retries=2,
|
|
64
|
+
)
|
|
65
|
+
ack = client.submit_lead(lead)
|
|
66
|
+
status = client.query_lead_status("lead_123")
|
|
67
|
+
offers = client.list_offers(vertical="mortgage")
|
|
68
|
+
capabilities = client.get_capabilities()
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The client provides:
|
|
72
|
+
|
|
73
|
+
| Method | Operation |
|
|
74
|
+
|---|---|
|
|
75
|
+
| `submit_lead` | Submit a lead envelope |
|
|
76
|
+
| `submit_call` | Submit a call envelope |
|
|
77
|
+
| `submit_bid` | Submit a bid envelope |
|
|
78
|
+
| `query_lead_status` | Query lead lifecycle/status |
|
|
79
|
+
| `get_schema` | Retrieve a core or vertical schema |
|
|
80
|
+
| `get_capabilities` | Discover endpoint capabilities |
|
|
81
|
+
| `list_offers` | Discover active offers |
|
|
82
|
+
| `request` | Make an authenticated LCP HTTP request |
|
|
83
|
+
|
|
84
|
+
`429`, `500`, `502`, `503`, `504`, and transport errors are retried with
|
|
85
|
+
exponential backoff. Mutating requests must use a stable envelope idempotency
|
|
86
|
+
key when retrying. Non-success responses raise `LCPHTTPError` with the status
|
|
87
|
+
code and parsed error body.
|
|
88
|
+
|
|
89
|
+
## Authentication and webhook verification
|
|
90
|
+
|
|
91
|
+
HMAC requests use the approved canonical input:
|
|
92
|
+
|
|
93
|
+
```text
|
|
94
|
+
<timestamp>\n<idempotency-key>\n<raw-request-body>
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The body must be the exact bytes sent on the wire. The SDK sends
|
|
98
|
+
`X-LCP-Timestamp`, `X-LCP-Idempotency-Key`, `X-LCP-Signature`, and
|
|
99
|
+
`X-LCP-Sender-Id`. Bearer authentication is also supported.
|
|
100
|
+
|
|
101
|
+
For a webhook or framework receiver, verify the raw body before JSON parsing:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from lcp_sdk import verify_http_request
|
|
105
|
+
|
|
106
|
+
headers = {
|
|
107
|
+
"X-LCP-Signature": request.headers["X-LCP-Signature"],
|
|
108
|
+
"X-LCP-Timestamp": request.headers["X-LCP-Timestamp"],
|
|
109
|
+
"X-LCP-Idempotency-Key": request.headers["X-LCP-Idempotency-Key"],
|
|
110
|
+
}
|
|
111
|
+
verify_http_request("partner-secret", headers, request.get_data())
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`verify_http_request` enforces the signature and replay window. Applications
|
|
115
|
+
must still perform durable idempotency claims, authorization, schema
|
|
116
|
+
validation, and PII-safe logging.
|
|
117
|
+
|
|
118
|
+
Low-level helpers are also available: `canonical_signing_bytes`, `sign_hmac`,
|
|
119
|
+
and `verify_hmac`.
|
|
120
|
+
|
|
121
|
+
## Test mode
|
|
122
|
+
|
|
123
|
+
Pass `test=True` to mutating client methods when sending synthetic traffic.
|
|
124
|
+
The SDK emits `X-LCP-Test: true`; the envelope should also have `test=True`.
|
|
125
|
+
Production receivers should reject mismatched or unexpected test traffic.
|
|
126
|
+
|
|
127
|
+
## Tests
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
PYTHONPATH=implementations/sdk/python \
|
|
131
|
+
python3 -m unittest discover -s implementations/sdk/python/tests -v
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The SDK tests include the shared cross-language HMAC fixture in
|
|
135
|
+
`test-vectors/sdk/hmac.json`.
|
|
136
|
+
|
|
137
|
+
## Publishing
|
|
138
|
+
|
|
139
|
+
The repository includes a GitHub Actions Trusted Publishing workflow for the
|
|
140
|
+
SDK, MCP adapter, and reference platform. Before tagging a release, configure
|
|
141
|
+
one PyPI Trusted Publisher for each package and keep package versions aligned
|
|
142
|
+
with the release process. The workflow publishes on `v*` tags; it does not
|
|
143
|
+
require a long-lived PyPI token in GitHub secrets.
|
|
144
|
+
|
|
145
|
+
## Scope
|
|
146
|
+
|
|
147
|
+
The SDK intentionally does not implement buyer offer matching, auctions,
|
|
148
|
+
webhook queues, CRM handoff, or persistence. Those belong to the reference
|
|
149
|
+
platform or the operator's deployment.
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""GENERATED FROM schemas/ — do not edit manually.
|
|
2
|
+
|
|
3
|
+
These TypedDicts are generated from the canonical schema set. Runtime
|
|
4
|
+
acceptance remains the JSON Schema validator's responsibility.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any, NotRequired, TypedDict
|
|
9
|
+
|
|
10
|
+
JsonObject = dict[str, Any]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MessageModel(TypedDict, total=False):
|
|
14
|
+
id: str
|
|
15
|
+
type: str
|
|
16
|
+
timestamp: str
|
|
17
|
+
sender_id: str
|
|
18
|
+
receiver_id: str
|
|
19
|
+
correlation_id: str | None
|
|
20
|
+
idempotency_key: str
|
|
21
|
+
test: NotRequired[bool]
|
|
22
|
+
security: NotRequired[JsonObject]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class EnvelopeModel(TypedDict):
|
|
26
|
+
lcp: dict[str, Any]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class LeadPayload(TypedDict, total=False):
|
|
30
|
+
lead_id: str
|
|
31
|
+
external_id: NotRequired[str]
|
|
32
|
+
submitted_at: NotRequired[str]
|
|
33
|
+
status: str
|
|
34
|
+
channel: str
|
|
35
|
+
consumer: JsonObject
|
|
36
|
+
location: JsonObject
|
|
37
|
+
compliance: NotRequired[JsonObject]
|
|
38
|
+
provenance: NotRequired[JsonObject]
|
|
39
|
+
attributes: JsonObject
|
|
40
|
+
exclusivity: NotRequired[JsonObject]
|
|
41
|
+
contact_window: NotRequired[JsonObject]
|
|
42
|
+
lead_quality: NotRequired[JsonObject]
|
|
43
|
+
expiry: NotRequired[JsonObject]
|
|
44
|
+
attachments: NotRequired[list[JsonObject]]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CallPayload(TypedDict, total=False):
|
|
48
|
+
lead_id: str
|
|
49
|
+
external_id: NotRequired[str]
|
|
50
|
+
submitted_at: NotRequired[str]
|
|
51
|
+
status: str
|
|
52
|
+
channel: str
|
|
53
|
+
consumer: JsonObject
|
|
54
|
+
location: JsonObject
|
|
55
|
+
call: JsonObject
|
|
56
|
+
compliance: NotRequired[JsonObject]
|
|
57
|
+
provenance: NotRequired[JsonObject]
|
|
58
|
+
attributes: NotRequired[JsonObject]
|
|
59
|
+
expiry: NotRequired[JsonObject]
|
|
60
|
+
exclusivity: NotRequired[JsonObject]
|
|
61
|
+
attachments: NotRequired[list[JsonObject]]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class PingPayload(TypedDict, total=False):
|
|
65
|
+
ping_id: str
|
|
66
|
+
lead_reference: str
|
|
67
|
+
publisher_id: NotRequired[str]
|
|
68
|
+
offer_id: NotRequired[str]
|
|
69
|
+
phone_hash: str
|
|
70
|
+
email_hash: NotRequired[str]
|
|
71
|
+
country_code: str
|
|
72
|
+
state_region: NotRequired[str]
|
|
73
|
+
postal_code: NotRequired[str]
|
|
74
|
+
vertical: str
|
|
75
|
+
lead_age_minutes: NotRequired[int]
|
|
76
|
+
attributes: NotRequired[JsonObject]
|
|
77
|
+
compliance_flags: NotRequired[JsonObject]
|
|
78
|
+
quality_flags: NotRequired[JsonObject]
|
|
79
|
+
floor_price_cents: int
|
|
80
|
+
currency: str
|
|
81
|
+
exclusivity: NotRequired[str]
|
|
82
|
+
dedup_window_hours: NotRequired[int]
|
|
83
|
+
expires_at: NotRequired[str]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class PostPayload(TypedDict, total=False):
|
|
87
|
+
lead_id: str
|
|
88
|
+
delivered_at: str
|
|
89
|
+
submitted_at: NotRequired[str]
|
|
90
|
+
offer_id: NotRequired[str]
|
|
91
|
+
price_cents: int
|
|
92
|
+
currency: str
|
|
93
|
+
buyer_id: str
|
|
94
|
+
buyer_reference: NotRequired[str]
|
|
95
|
+
pricing: NotRequired[JsonObject]
|
|
96
|
+
matched_preferences: NotRequired[JsonObject]
|
|
97
|
+
consumer: JsonObject
|
|
98
|
+
location: JsonObject
|
|
99
|
+
compliance: NotRequired[JsonObject]
|
|
100
|
+
attributes: JsonObject
|
|
101
|
+
provenance: NotRequired[JsonObject]
|
|
102
|
+
exclusivity: NotRequired[JsonObject]
|
|
103
|
+
call: NotRequired[JsonObject]
|
|
104
|
+
attachments: NotRequired[list[JsonObject]]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class BidPayload(TypedDict, total=False):
|
|
108
|
+
ping_id: str
|
|
109
|
+
decision: str
|
|
110
|
+
bid_price_cents: int
|
|
111
|
+
currency: str
|
|
112
|
+
estimated_contact_seconds: NotRequired[int]
|
|
113
|
+
buyer_reference: NotRequired[str]
|
|
114
|
+
reject_reason: NotRequired[str]
|
|
115
|
+
capacity_remaining: NotRequired[int]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class AckPayload(TypedDict, total=False):
|
|
119
|
+
original_message_id: str
|
|
120
|
+
status: str
|
|
121
|
+
errors: NotRequired[list[JsonObject]]
|
|
122
|
+
lead_id: NotRequired[str]
|
|
123
|
+
request_id: NotRequired[str]
|
|
124
|
+
rejection_reason: NotRequired[str]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class EventPayload(TypedDict, total=False):
|
|
128
|
+
lead_id: str
|
|
129
|
+
event: str
|
|
130
|
+
timestamp: str
|
|
131
|
+
details: NotRequired[JsonObject]
|
|
132
|
+
external_reference: NotRequired[str]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class OfferModel(TypedDict, total=False):
|
|
136
|
+
offer_id: str
|
|
137
|
+
buyer_id: str
|
|
138
|
+
tenant_id: NotRequired[str]
|
|
139
|
+
active: NotRequired[bool]
|
|
140
|
+
routing_mode: NotRequired[str]
|
|
141
|
+
vertical: str
|
|
142
|
+
schema_version: NotRequired[str]
|
|
143
|
+
countries: list[str]
|
|
144
|
+
state_regions: NotRequired[list[str]]
|
|
145
|
+
postal_codes: NotRequired[list[str]]
|
|
146
|
+
channels: NotRequired[list[str]]
|
|
147
|
+
floor_price_cents: int
|
|
148
|
+
currency: str
|
|
149
|
+
extensions: NotRequired[JsonObject]
|
|
150
|
+
allowed_publisher_ids: NotRequired[list[str]]
|
|
151
|
+
allowed_brand_ids: NotRequired[list[str]]
|
|
152
|
+
attribute_equals: NotRequired[JsonObject]
|
|
153
|
+
attribute_in: NotRequired[JsonObject]
|
|
154
|
+
monthly_minimum_payable: NotRequired[int]
|
|
155
|
+
monthly_maximum_payable: NotRequired[int]
|
|
156
|
+
monthly_quota_timezone: NotRequired[str]
|
|
157
|
+
monthly_quota_policy: NotRequired[str]
|
|
158
|
+
payable_rules: NotRequired[JsonObject]
|
|
159
|
+
call_routing_mode: NotRequired[str]
|
|
160
|
+
connect_timeout_seconds: NotRequired[int]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"protocol_version": "1.0.0",
|
|
3
|
+
"schema_files": {
|
|
4
|
+
"schemas/ack.json": "7946d73afff4653c68ef4842986d9e5a4839ab2765346c706b8a60f46c060e4c",
|
|
5
|
+
"schemas/bid.json": "c7a1c901c562e303ec7cceb9418d5d40d33f47d1360779ea5b8ec80a9e6bce05",
|
|
6
|
+
"schemas/call.json": "142aa654c964493cd67721921f1614a9f8635807732b816671bf5d0053bf2fe2",
|
|
7
|
+
"schemas/core.json": "8385f2a7b02aa7c1e5bde67afa4247dfe8c6c1d2e5bdb5783f70546b01445cac",
|
|
8
|
+
"schemas/envelope.json": "69bde360f9a985662ffef8d0f63dc3acbcf8d773c3fa763b70cc9e75f69a2606",
|
|
9
|
+
"schemas/event.json": "e3c93ab2aa464a8401392ece2093abe207050bc372086763f373498048fd63fc",
|
|
10
|
+
"schemas/lead.json": "dd5989503d9d8d2cf65114578d06e6300a767d8f9ae5c14b43bab456bd2bf2e6",
|
|
11
|
+
"schemas/offer.json": "bfaf6a67d1fc00aff8d79965aad4456130d5960b56e8e011cba9a069ad0cb6e3",
|
|
12
|
+
"schemas/ping.json": "d4c12a61f534383744636756e0952a4afe092d2c8070602289bedc51356e7d16",
|
|
13
|
+
"schemas/post.json": "3fc42124c299df5534d0b46be14385e7dfec8771a804946e907c90577bf7bfdb",
|
|
14
|
+
"verticals/home_services.json": "1f022bef90a8f989157e77ac3cebd5d054285bf45d788147adcf398a4e8967ff",
|
|
15
|
+
"verticals/insurance.json": "ba9fd38fbad18b2016bffa54722ad71295de5d309397a84122aa7bb906a3f810",
|
|
16
|
+
"verticals/legal.json": "b4f428230cce6b8a008bca5c01f27069ed9bea37736f3d699194457b2e1f02cf",
|
|
17
|
+
"verticals/mortgage.json": "3570b113753453055af2d7a027e0a13afab8c2e3a054e347395c1900a99aa03c",
|
|
18
|
+
"verticals/mva.json": "07138d68241e9f1e96f531d62bfa53bdf0d5b5dc1a6153fa32e88a3616e927d3",
|
|
19
|
+
"verticals/solar.json": "a2c0079ead33360dd6c0449a4799b31913956cd2b25b69d952d4bf1f708744cb"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -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"
|
|
@@ -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)
|
|
@@ -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
|