exportflow 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.
- exportflow/__init__.py +91 -0
- exportflow/_client.py +614 -0
- exportflow/_errors.py +140 -0
- exportflow/_models.py +305 -0
- exportflow/_version.py +11 -0
- exportflow/py.typed +0 -0
- exportflow/webhooks.py +87 -0
- exportflow-1.0.0.dist-info/METADATA +222 -0
- exportflow-1.0.0.dist-info/RECORD +10 -0
- exportflow-1.0.0.dist-info/WHEEL +4 -0
exportflow/_errors.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Errors.
|
|
2
|
+
|
|
3
|
+
Every failure the SDK can report is an :class:`ExportFlowError`. An error the
|
|
4
|
+
API answered with carries the API's own ``code`` -- stable, documented, and
|
|
5
|
+
the thing to branch on -- plus the HTTP status and the request id to quote to
|
|
6
|
+
support. The message is for people and may be reworded.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"APIConnectionError",
|
|
15
|
+
"APIError",
|
|
16
|
+
"APITimeoutError",
|
|
17
|
+
"AuthenticationError",
|
|
18
|
+
"ConflictError",
|
|
19
|
+
"ExportFlowError",
|
|
20
|
+
"IncompatibleAPIVersionError",
|
|
21
|
+
"InvalidRequestError",
|
|
22
|
+
"NotFoundError",
|
|
23
|
+
"PermissionDeniedError",
|
|
24
|
+
"ProcessingFailedError",
|
|
25
|
+
"QuotaExceededError",
|
|
26
|
+
"RateLimitError",
|
|
27
|
+
"SDKVersionUnsupportedError",
|
|
28
|
+
"ServerError",
|
|
29
|
+
"SignatureVerificationError",
|
|
30
|
+
"WaitTimeoutError",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ExportFlowError(Exception):
|
|
35
|
+
"""The base of every error this SDK raises."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class APIConnectionError(ExportFlowError):
|
|
39
|
+
"""The API could not be reached. The request may or may not have arrived;
|
|
40
|
+
repeating it with the same idempotency key is safe."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class APITimeoutError(APIConnectionError):
|
|
44
|
+
"""The API did not answer in time."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class APIError(ExportFlowError):
|
|
48
|
+
"""The API answered with an error."""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
message: str,
|
|
53
|
+
*,
|
|
54
|
+
status_code: int,
|
|
55
|
+
code: str,
|
|
56
|
+
request_id: Optional[str] = None,
|
|
57
|
+
) -> None:
|
|
58
|
+
super().__init__(message)
|
|
59
|
+
self.message = message
|
|
60
|
+
self.status_code = status_code
|
|
61
|
+
self.code = code
|
|
62
|
+
"""Machine-readable and stable, for example ``quota_exceeded``."""
|
|
63
|
+
self.request_id = request_id
|
|
64
|
+
"""Quote this to ExportFlow support."""
|
|
65
|
+
|
|
66
|
+
def __str__(self) -> str:
|
|
67
|
+
suffix = f" (request {self.request_id})" if self.request_id else ""
|
|
68
|
+
return f"{self.code}: {self.message}{suffix}"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class AuthenticationError(APIError):
|
|
72
|
+
"""401. The API key is missing, wrong, revoked or expired."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class PermissionDeniedError(APIError):
|
|
76
|
+
"""403. The key is valid and may not do this: a missing scope, a suspended
|
|
77
|
+
account, a disabled application or a network that is not allowed."""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class NotFoundError(APIError):
|
|
81
|
+
"""404. No such resource in this key's organisation and environment."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ConflictError(APIError):
|
|
85
|
+
"""409. The request cannot be done in the resource's current state, for
|
|
86
|
+
example verifying a shipment whose record has not been confirmed."""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class InvalidRequestError(APIError):
|
|
90
|
+
"""400 or 422. The request itself is wrong; repeating it will not help."""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class RateLimitError(APIError):
|
|
94
|
+
"""429. Too many requests. ``retry_after`` says how long to wait."""
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
message: str,
|
|
99
|
+
*,
|
|
100
|
+
status_code: int,
|
|
101
|
+
code: str,
|
|
102
|
+
request_id: Optional[str] = None,
|
|
103
|
+
retry_after: Optional[float] = None,
|
|
104
|
+
) -> None:
|
|
105
|
+
super().__init__(message, status_code=status_code, code=code, request_id=request_id)
|
|
106
|
+
self.retry_after = retry_after
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class QuotaExceededError(RateLimitError):
|
|
110
|
+
"""429. The account's allowance or spending limit for the period is used
|
|
111
|
+
up. Waiting a few seconds will not help; it resets with the period."""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class ServerError(APIError):
|
|
115
|
+
"""5xx. Something failed on ExportFlow's side. Safe to retry."""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class SDKVersionUnsupportedError(APIError):
|
|
119
|
+
"""426. This version of the SDK is too old for the API. Upgrade it."""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class IncompatibleAPIVersionError(ExportFlowError):
|
|
123
|
+
"""The server speaks a different major version of the API than this SDK."""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class SignatureVerificationError(ExportFlowError):
|
|
127
|
+
"""A webhook's signature did not verify. Do not trust the payload."""
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class WaitTimeoutError(ExportFlowError):
|
|
131
|
+
"""A ``wait`` helper gave up before the work finished. The work continues;
|
|
132
|
+
fetch the resource later or use a webhook."""
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class ProcessingFailedError(ExportFlowError):
|
|
136
|
+
"""A document's processing or a verification ended in failure."""
|
|
137
|
+
|
|
138
|
+
def __init__(self, message: str, *, resource_id: str) -> None:
|
|
139
|
+
super().__init__(message)
|
|
140
|
+
self.resource_id = resource_id
|
exportflow/_models.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""What the API returns, as plain typed objects.
|
|
2
|
+
|
|
3
|
+
Each model is built from the JSON by name and ignores fields it does not know.
|
|
4
|
+
That is deliberate: within a major version the API only ever adds fields, so
|
|
5
|
+
an SDK released today keeps working when the API grows tomorrow. The whole
|
|
6
|
+
response is kept on ``.raw`` for anything newer than this SDK.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import dataclasses
|
|
12
|
+
import datetime as dt
|
|
13
|
+
from typing import Any, Dict, List, Mapping, Optional, Type, TypeVar
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"Document",
|
|
17
|
+
"ExtractedField",
|
|
18
|
+
"Extraction",
|
|
19
|
+
"Finding",
|
|
20
|
+
"Identity",
|
|
21
|
+
"MeterUsage",
|
|
22
|
+
"ProcessingAccepted",
|
|
23
|
+
"Reading",
|
|
24
|
+
"Record",
|
|
25
|
+
"RecordField",
|
|
26
|
+
"Shipment",
|
|
27
|
+
"Usage",
|
|
28
|
+
"Verification",
|
|
29
|
+
"WebhookEndpoint",
|
|
30
|
+
"WebhookEvent",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
T = TypeVar("T", bound="_Model")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _moment(value: Any) -> Optional[dt.datetime]:
|
|
37
|
+
if not value:
|
|
38
|
+
return None
|
|
39
|
+
return dt.datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _day(value: Any) -> Optional[dt.date]:
|
|
43
|
+
return dt.date.fromisoformat(str(value)) if value else None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclasses.dataclass(frozen=True)
|
|
47
|
+
class _Model:
|
|
48
|
+
raw: Mapping[str, Any] = dataclasses.field(repr=False, compare=False)
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def _build(cls: Type[T], data: Mapping[str, Any]) -> T:
|
|
52
|
+
known = {f.name: f for f in dataclasses.fields(cls) if f.name != "raw"}
|
|
53
|
+
values: Dict[str, Any] = {}
|
|
54
|
+
for name, field in known.items():
|
|
55
|
+
value = data.get(name)
|
|
56
|
+
if name.endswith(("_at",)) or name in ("created_at", "uploaded_at"):
|
|
57
|
+
value = _moment(value)
|
|
58
|
+
elif name in ("period_start", "period_end"):
|
|
59
|
+
value = _day(value)
|
|
60
|
+
if value is None and field.default is not dataclasses.MISSING:
|
|
61
|
+
value = field.default
|
|
62
|
+
elif value is None and field.default_factory is not dataclasses.MISSING: # type: ignore[misc]
|
|
63
|
+
value = field.default_factory() # type: ignore[misc]
|
|
64
|
+
values[name] = value
|
|
65
|
+
return cls(raw=data, **values)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclasses.dataclass(frozen=True)
|
|
69
|
+
class Identity(_Model):
|
|
70
|
+
organisation_id: str = ""
|
|
71
|
+
organisation_name: str = ""
|
|
72
|
+
application_id: str = ""
|
|
73
|
+
application_name: str = ""
|
|
74
|
+
environment: str = ""
|
|
75
|
+
key_prefix: str = ""
|
|
76
|
+
scopes: List[str] = dataclasses.field(default_factory=list)
|
|
77
|
+
api_version: str = ""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclasses.dataclass(frozen=True)
|
|
81
|
+
class Shipment(_Model):
|
|
82
|
+
id: str = ""
|
|
83
|
+
reference: Optional[str] = None
|
|
84
|
+
commodity: Optional[str] = None
|
|
85
|
+
exporter_name: Optional[str] = None
|
|
86
|
+
origin_country: Optional[str] = None
|
|
87
|
+
destination_country: Optional[str] = None
|
|
88
|
+
destination_port: Optional[str] = None
|
|
89
|
+
hs_code: Optional[str] = None
|
|
90
|
+
environment: str = ""
|
|
91
|
+
created_at: Optional[dt.datetime] = None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclasses.dataclass(frozen=True)
|
|
95
|
+
class Document(_Model):
|
|
96
|
+
id: str = ""
|
|
97
|
+
shipment_id: str = ""
|
|
98
|
+
filename: Optional[str] = None
|
|
99
|
+
media_type: str = ""
|
|
100
|
+
sha256: str = ""
|
|
101
|
+
page_count: Optional[int] = None
|
|
102
|
+
document_type: Optional[str] = None
|
|
103
|
+
declared_type: Optional[str] = None
|
|
104
|
+
detected_type: Optional[str] = None
|
|
105
|
+
type_confidence: Optional[float] = None
|
|
106
|
+
status: str = ""
|
|
107
|
+
error: Optional[str] = None
|
|
108
|
+
parent_document_id: Optional[str] = None
|
|
109
|
+
duplicate: bool = False
|
|
110
|
+
uploaded_at: Optional[dt.datetime] = None
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def is_finished(self) -> bool:
|
|
114
|
+
"""True once processing has ended, successfully or not."""
|
|
115
|
+
return self.status in ("processed", "failed", "split")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclasses.dataclass(frozen=True)
|
|
119
|
+
class ProcessingAccepted(_Model):
|
|
120
|
+
document_id: str = ""
|
|
121
|
+
status: str = ""
|
|
122
|
+
queued: bool = False
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclasses.dataclass(frozen=True)
|
|
126
|
+
class ExtractedField(_Model):
|
|
127
|
+
name: str = ""
|
|
128
|
+
value: Optional[str] = None
|
|
129
|
+
unavailable_reason: Optional[str] = None
|
|
130
|
+
confidence: Optional[float] = None
|
|
131
|
+
page: Optional[int] = None
|
|
132
|
+
method: str = ""
|
|
133
|
+
extraction_id: str = ""
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclasses.dataclass(frozen=True)
|
|
137
|
+
class Extraction(_Model):
|
|
138
|
+
document_id: str = ""
|
|
139
|
+
status: str = ""
|
|
140
|
+
document_type: Optional[str] = None
|
|
141
|
+
fields: List[ExtractedField] = dataclasses.field(default_factory=list)
|
|
142
|
+
|
|
143
|
+
@classmethod
|
|
144
|
+
def _build(cls, data: Mapping[str, Any]) -> "Extraction":
|
|
145
|
+
built = super()._build(data)
|
|
146
|
+
return dataclasses.replace(
|
|
147
|
+
built, fields=[ExtractedField._build(item) for item in data.get("fields") or []]
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def value(self, name: str) -> Optional[str]:
|
|
151
|
+
"""The value read for ``name``, or None if it was not read."""
|
|
152
|
+
for field in self.fields:
|
|
153
|
+
if field.name == name and field.value is not None:
|
|
154
|
+
return field.value
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
def as_dict(self) -> Dict[str, Optional[str]]:
|
|
158
|
+
"""``{field name: value}`` for every field that has a value."""
|
|
159
|
+
return {f.name: f.value for f in self.fields if f.value is not None}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclasses.dataclass(frozen=True)
|
|
163
|
+
class Reading(_Model):
|
|
164
|
+
document_id: str = ""
|
|
165
|
+
value: Optional[str] = None
|
|
166
|
+
unavailable_reason: Optional[str] = None
|
|
167
|
+
extraction_id: str = ""
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@dataclasses.dataclass(frozen=True)
|
|
171
|
+
class RecordField(_Model):
|
|
172
|
+
name: str = ""
|
|
173
|
+
readings: List[Reading] = dataclasses.field(default_factory=list)
|
|
174
|
+
confirmed_value: Optional[str] = None
|
|
175
|
+
confirmed_absence: Optional[str] = None
|
|
176
|
+
confirmed_at: Optional[dt.datetime] = None
|
|
177
|
+
|
|
178
|
+
@classmethod
|
|
179
|
+
def _build(cls, data: Mapping[str, Any]) -> "RecordField":
|
|
180
|
+
built = super()._build(data)
|
|
181
|
+
return dataclasses.replace(
|
|
182
|
+
built, readings=[Reading._build(item) for item in data.get("readings") or []]
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def distinct_values(self) -> List[str]:
|
|
187
|
+
"""The different values the documents give. More than one is a
|
|
188
|
+
disagreement between documents."""
|
|
189
|
+
return sorted({r.value for r in self.readings if r.value is not None})
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@dataclasses.dataclass(frozen=True)
|
|
193
|
+
class Record(_Model):
|
|
194
|
+
shipment_id: str = ""
|
|
195
|
+
confirmed: bool = False
|
|
196
|
+
fields: List[RecordField] = dataclasses.field(default_factory=list)
|
|
197
|
+
|
|
198
|
+
@classmethod
|
|
199
|
+
def _build(cls, data: Mapping[str, Any]) -> "Record":
|
|
200
|
+
built = super()._build(data)
|
|
201
|
+
return dataclasses.replace(
|
|
202
|
+
built, fields=[RecordField._build(item) for item in data.get("fields") or []]
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@dataclasses.dataclass(frozen=True)
|
|
207
|
+
class Finding(_Model):
|
|
208
|
+
id: str = ""
|
|
209
|
+
rule: str = ""
|
|
210
|
+
rule_version: int = 0
|
|
211
|
+
severity: str = ""
|
|
212
|
+
document_id: Optional[str] = None
|
|
213
|
+
field: Optional[str] = None
|
|
214
|
+
found: Optional[str] = None
|
|
215
|
+
expected: Optional[str] = None
|
|
216
|
+
message: str = ""
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@dataclasses.dataclass(frozen=True)
|
|
220
|
+
class Verification(_Model):
|
|
221
|
+
id: str = ""
|
|
222
|
+
shipment_id: str = ""
|
|
223
|
+
status: str = ""
|
|
224
|
+
rule_set_version: Optional[int] = None
|
|
225
|
+
severity_counts: Dict[str, int] = dataclasses.field(default_factory=dict)
|
|
226
|
+
failure_reason: Optional[str] = None
|
|
227
|
+
created_at: Optional[dt.datetime] = None
|
|
228
|
+
completed_at: Optional[dt.datetime] = None
|
|
229
|
+
coverage: Optional[Dict[str, Any]] = None
|
|
230
|
+
findings: List[Finding] = dataclasses.field(default_factory=list)
|
|
231
|
+
|
|
232
|
+
@classmethod
|
|
233
|
+
def _build(cls, data: Mapping[str, Any]) -> "Verification":
|
|
234
|
+
built = super()._build(data)
|
|
235
|
+
return dataclasses.replace(
|
|
236
|
+
built, findings=[Finding._build(item) for item in data.get("findings") or []]
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
@property
|
|
240
|
+
def is_finished(self) -> bool:
|
|
241
|
+
return self.status in ("complete", "failed")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@dataclasses.dataclass(frozen=True)
|
|
245
|
+
class MeterUsage(_Model):
|
|
246
|
+
meter: str = ""
|
|
247
|
+
name: str = ""
|
|
248
|
+
unit: str = ""
|
|
249
|
+
succeeded: int = 0
|
|
250
|
+
failed: int = 0
|
|
251
|
+
billable_units: int = 0
|
|
252
|
+
estimated_charge_micro: int = 0
|
|
253
|
+
quota: Optional[int] = None
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@dataclasses.dataclass(frozen=True)
|
|
257
|
+
class Usage(_Model):
|
|
258
|
+
environment: str = ""
|
|
259
|
+
period_start: Optional[dt.date] = None
|
|
260
|
+
period_end: Optional[dt.date] = None
|
|
261
|
+
currency: str = ""
|
|
262
|
+
plan: str = ""
|
|
263
|
+
meters: List[MeterUsage] = dataclasses.field(default_factory=list)
|
|
264
|
+
estimated_charge_micro: int = 0
|
|
265
|
+
|
|
266
|
+
@classmethod
|
|
267
|
+
def _build(cls, data: Mapping[str, Any]) -> "Usage":
|
|
268
|
+
built = super()._build(data)
|
|
269
|
+
return dataclasses.replace(
|
|
270
|
+
built, meters=[MeterUsage._build(item) for item in data.get("meters") or []]
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
@property
|
|
274
|
+
def estimated_charge(self) -> float:
|
|
275
|
+
"""The estimate in whole units of ``currency``, for display."""
|
|
276
|
+
return self.estimated_charge_micro / 1_000_000
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@dataclasses.dataclass(frozen=True)
|
|
280
|
+
class WebhookEndpoint(_Model):
|
|
281
|
+
id: str = ""
|
|
282
|
+
application_id: str = ""
|
|
283
|
+
url: str = ""
|
|
284
|
+
description: Optional[str] = None
|
|
285
|
+
event_types: List[str] = dataclasses.field(default_factory=list)
|
|
286
|
+
status: str = ""
|
|
287
|
+
disabled_reason: Optional[str] = None
|
|
288
|
+
created_at: Optional[dt.datetime] = None
|
|
289
|
+
last_success_at: Optional[dt.datetime] = None
|
|
290
|
+
last_failure_at: Optional[dt.datetime] = None
|
|
291
|
+
signing_secret: Optional[str] = None
|
|
292
|
+
"""Present only on the endpoint returned by ``create``. Store it."""
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@dataclasses.dataclass(frozen=True)
|
|
296
|
+
class WebhookEvent(_Model):
|
|
297
|
+
"""A verified webhook delivery."""
|
|
298
|
+
|
|
299
|
+
id: str = ""
|
|
300
|
+
type: str = ""
|
|
301
|
+
created_at: Optional[dt.datetime] = None
|
|
302
|
+
api_version: str = ""
|
|
303
|
+
environment: str = ""
|
|
304
|
+
application_id: Optional[str] = None
|
|
305
|
+
data: Dict[str, Any] = dataclasses.field(default_factory=dict)
|
exportflow/_version.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""The SDK's version, and the API version it was built for."""
|
|
2
|
+
|
|
3
|
+
__version__ = "1.0.0"
|
|
4
|
+
|
|
5
|
+
API_MAJOR_VERSION = 1
|
|
6
|
+
"""The major version of the ExportFlow API this SDK speaks (``/public/v1``).
|
|
7
|
+
|
|
8
|
+
The server states its version on every response. A server on a different
|
|
9
|
+
major version is a different contract, and the SDK says so instead of
|
|
10
|
+
guessing at fields that may have changed meaning.
|
|
11
|
+
"""
|
exportflow/py.typed
ADDED
|
File without changes
|
exportflow/webhooks.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Verifying webhooks.
|
|
2
|
+
|
|
3
|
+
ExportFlow signs every delivery with your endpoint's signing secret. Verify
|
|
4
|
+
the signature before you trust the payload: the URL of a webhook endpoint is
|
|
5
|
+
not a secret, and anybody who learns it can post to it.
|
|
6
|
+
|
|
7
|
+
from exportflow import webhooks
|
|
8
|
+
|
|
9
|
+
event = webhooks.construct_event(
|
|
10
|
+
payload=request.body, # the raw bytes, not parsed JSON
|
|
11
|
+
signature=request.headers["ExportFlow-Signature"],
|
|
12
|
+
secret=os.environ["EXPORTFLOW_WEBHOOK_SECRET"],
|
|
13
|
+
)
|
|
14
|
+
if event.type == "document.processing.completed":
|
|
15
|
+
...
|
|
16
|
+
|
|
17
|
+
Deliveries are at least once: the same event can arrive twice. ``event.id`` is
|
|
18
|
+
the same both times; record the ids you have handled and skip a repeat.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import hashlib
|
|
24
|
+
import hmac
|
|
25
|
+
import json
|
|
26
|
+
import time
|
|
27
|
+
from typing import Optional, Union
|
|
28
|
+
|
|
29
|
+
from ._errors import SignatureVerificationError
|
|
30
|
+
from ._models import WebhookEvent
|
|
31
|
+
|
|
32
|
+
__all__ = ["SIGNATURE_HEADER", "TOLERANCE_SECONDS", "construct_event", "sign", "verify"]
|
|
33
|
+
|
|
34
|
+
SIGNATURE_HEADER = "ExportFlow-Signature"
|
|
35
|
+
TOLERANCE_SECONDS = 300
|
|
36
|
+
"""A delivery older than this is refused, so a captured one cannot be replayed."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _bytes(payload: Union[bytes, str]) -> bytes:
|
|
40
|
+
return payload.encode("utf-8") if isinstance(payload, str) else bytes(payload)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def sign(secret: str, payload: Union[bytes, str], *, timestamp: int) -> str:
|
|
44
|
+
"""The header ExportFlow would send for this payload. Useful in your tests."""
|
|
45
|
+
mac = hmac.new(
|
|
46
|
+
secret.encode("utf-8"), f"{timestamp}.".encode() + _bytes(payload), hashlib.sha256
|
|
47
|
+
)
|
|
48
|
+
return f"t={timestamp},v1={mac.hexdigest()}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def verify(
|
|
52
|
+
payload: Union[bytes, str],
|
|
53
|
+
signature: str,
|
|
54
|
+
secret: str,
|
|
55
|
+
*,
|
|
56
|
+
tolerance: int = TOLERANCE_SECONDS,
|
|
57
|
+
now: Optional[float] = None,
|
|
58
|
+
) -> bool:
|
|
59
|
+
"""True if ``signature`` is ExportFlow's signature of ``payload``, made
|
|
60
|
+
within ``tolerance`` seconds of now. Never raises for a bad signature."""
|
|
61
|
+
parts = dict(part.split("=", 1) for part in (signature or "").split(",") if "=" in part)
|
|
62
|
+
try:
|
|
63
|
+
timestamp = int(parts.get("t", ""))
|
|
64
|
+
except ValueError:
|
|
65
|
+
return False
|
|
66
|
+
current = time.time() if now is None else now
|
|
67
|
+
if abs(current - timestamp) > tolerance:
|
|
68
|
+
return False
|
|
69
|
+
expected = sign(secret, payload, timestamp=timestamp).split("v1=", 1)[1]
|
|
70
|
+
return hmac.compare_digest(expected, parts.get("v1", ""))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def construct_event(
|
|
74
|
+
payload: Union[bytes, str],
|
|
75
|
+
signature: str,
|
|
76
|
+
secret: str,
|
|
77
|
+
*,
|
|
78
|
+
tolerance: int = TOLERANCE_SECONDS,
|
|
79
|
+
) -> WebhookEvent:
|
|
80
|
+
"""Verify a delivery and return the event, or raise
|
|
81
|
+
:class:`~exportflow.SignatureVerificationError`."""
|
|
82
|
+
if not verify(payload, signature, secret, tolerance=tolerance):
|
|
83
|
+
raise SignatureVerificationError(
|
|
84
|
+
"The webhook signature did not verify. Check that the payload is the raw "
|
|
85
|
+
"request body and that the secret belongs to this endpoint."
|
|
86
|
+
)
|
|
87
|
+
return WebhookEvent._build(json.loads(_bytes(payload)))
|