xmlvalid 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.
- xmlvalid/__init__.py +54 -0
- xmlvalid/_client.py +638 -0
- xmlvalid/_version.py +1 -0
- xmlvalid/errors.py +103 -0
- xmlvalid/models.py +358 -0
- xmlvalid/py.typed +0 -0
- xmlvalid/webhooks.py +56 -0
- xmlvalid-1.0.0.dist-info/METADATA +199 -0
- xmlvalid-1.0.0.dist-info/RECORD +11 -0
- xmlvalid-1.0.0.dist-info/WHEEL +4 -0
- xmlvalid-1.0.0.dist-info/licenses/LICENSE +21 -0
xmlvalid/errors.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Every exception the client raises extends :class:`XmlvalidError`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from .models import Validation
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"ApiError",
|
|
12
|
+
"AuthenticationError",
|
|
13
|
+
"DocumentTooLargeError",
|
|
14
|
+
"InvalidRequestError",
|
|
15
|
+
"NotFinishedError",
|
|
16
|
+
"NotFoundError",
|
|
17
|
+
"PlanLimitError",
|
|
18
|
+
"QuotaExceededError",
|
|
19
|
+
"RateLimitedError",
|
|
20
|
+
"ServerError",
|
|
21
|
+
"SignatureVerificationError",
|
|
22
|
+
"TransportError",
|
|
23
|
+
"ValidationTimeoutError",
|
|
24
|
+
"XmlvalidError",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class XmlvalidError(Exception):
|
|
29
|
+
"""Anything that went wrong talking to xmlvalid."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ApiError(XmlvalidError):
|
|
33
|
+
"""The API refused the request."""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
message: str,
|
|
38
|
+
status_code: int,
|
|
39
|
+
error_code: str,
|
|
40
|
+
fields: dict[str, list[str]] | None = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
super().__init__(message)
|
|
43
|
+
self.status_code = status_code
|
|
44
|
+
"""The HTTP status."""
|
|
45
|
+
self.error_code = error_code
|
|
46
|
+
"""The API's code: unauthenticated, invalid_request, quota_exceeded, …"""
|
|
47
|
+
self.fields = fields or {}
|
|
48
|
+
"""For invalid_request: the problems per field."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class AuthenticationError(ApiError):
|
|
52
|
+
"""401: no key, or a revoked one."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class PlanLimitError(ApiError):
|
|
56
|
+
"""403: not in your plan."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class NotFoundError(ApiError):
|
|
60
|
+
"""404: no such validation, or it expired (24 hours)."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class NotFinishedError(ApiError):
|
|
64
|
+
"""409: the report of a validation still running."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class DocumentTooLargeError(ApiError):
|
|
68
|
+
"""413: over your plan's size limit."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class InvalidRequestError(ApiError):
|
|
72
|
+
"""422: an unknown schema, a bad URL, …; see ``fields``."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class QuotaExceededError(ApiError):
|
|
76
|
+
"""429: this month's validations are used up."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class RateLimitedError(ApiError):
|
|
80
|
+
"""429: too many requests a minute, still so after the retries."""
|
|
81
|
+
|
|
82
|
+
retry_after: int = 0
|
|
83
|
+
"""Seconds to wait before the next request."""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class ServerError(ApiError):
|
|
87
|
+
"""5xx: something broke on xmlvalid's side."""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class TransportError(XmlvalidError):
|
|
91
|
+
"""The request could not be sent, or its answer read."""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class ValidationTimeoutError(XmlvalidError, TimeoutError):
|
|
95
|
+
"""The validation was still running when the timeout ran out. It goes on on the server."""
|
|
96
|
+
|
|
97
|
+
def __init__(self, validation: Validation, timeout: float) -> None:
|
|
98
|
+
super().__init__(f"Validation {validation.id} was still {validation.status} after {timeout:g} seconds.")
|
|
99
|
+
self.validation = validation
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class SignatureVerificationError(XmlvalidError):
|
|
103
|
+
"""A webhook's signature is missing, too old or does not match."""
|
xmlvalid/models.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""What the API returns, as typed, read-only objects. Each keeps the JSON it came from in ``raw``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"ArchiveItem",
|
|
11
|
+
"Diagnostic",
|
|
12
|
+
"Event",
|
|
13
|
+
"ReportLink",
|
|
14
|
+
"Result",
|
|
15
|
+
"Schema",
|
|
16
|
+
"Usage",
|
|
17
|
+
"Validation",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _object(data: Any, key: str) -> dict[str, Any] | None:
|
|
22
|
+
value = data.get(key) if isinstance(data, dict) else None
|
|
23
|
+
return {str(k): v for k, v in value.items()} if isinstance(value, dict) else None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _objects(data: dict[str, Any], key: str) -> list[dict[str, Any]]:
|
|
27
|
+
value = data.get(key)
|
|
28
|
+
if not isinstance(value, list):
|
|
29
|
+
return []
|
|
30
|
+
return [{str(k): v for k, v in item.items()} for item in value if isinstance(item, dict)]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _str(data: dict[str, Any], key: str, default: str = "") -> str:
|
|
34
|
+
value = data.get(key)
|
|
35
|
+
return value if isinstance(value, str) else default
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _opt_str(data: dict[str, Any], key: str) -> str | None:
|
|
39
|
+
value = data.get(key)
|
|
40
|
+
return value if isinstance(value, str) else None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _int(data: dict[str, Any], key: str) -> int:
|
|
44
|
+
value = data.get(key)
|
|
45
|
+
return int(value) if isinstance(value, (int, float)) and not isinstance(value, bool) else 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _bool(data: dict[str, Any], key: str) -> bool:
|
|
49
|
+
return data.get(key) is True
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _date(data: dict[str, Any], key: str) -> datetime | None:
|
|
53
|
+
value = data.get(key)
|
|
54
|
+
if not isinstance(value, str):
|
|
55
|
+
return None
|
|
56
|
+
try:
|
|
57
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
58
|
+
except ValueError:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class Diagnostic:
|
|
64
|
+
"""One error or warning, with where it is and, when there is one, the rule that failed."""
|
|
65
|
+
|
|
66
|
+
line: int
|
|
67
|
+
"""0 when the message is about the whole document."""
|
|
68
|
+
column: int
|
|
69
|
+
"""0 when unknown; business rules point at an element's line only."""
|
|
70
|
+
level: str
|
|
71
|
+
"""warning, error or fatal."""
|
|
72
|
+
type: str
|
|
73
|
+
"""syntax, xsd, relaxng, dtd, schema, io, schematron (a business rule), archive or pdf."""
|
|
74
|
+
message: str
|
|
75
|
+
rule: str | None = None
|
|
76
|
+
"""The business rule that failed (BR-CO-15, PEPPOL-EN16931-R001) or the PDF check (PDF-AF)."""
|
|
77
|
+
message_en: str | None = None
|
|
78
|
+
"""The message in English, for a rule its publisher wrote in another language."""
|
|
79
|
+
help_url: str | None = None
|
|
80
|
+
"""A page explaining this kind of error and how to fix it."""
|
|
81
|
+
rule_url: str | None = None
|
|
82
|
+
"""The rule's page on xmlvalid.com."""
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def from_dict(cls, data: dict[str, Any]) -> Diagnostic:
|
|
86
|
+
return cls(
|
|
87
|
+
line=_int(data, "line"),
|
|
88
|
+
column=_int(data, "column"),
|
|
89
|
+
level=_str(data, "level", "error"),
|
|
90
|
+
type=_str(data, "type"),
|
|
91
|
+
message=_str(data, "message"),
|
|
92
|
+
rule=_opt_str(data, "rule"),
|
|
93
|
+
message_en=_opt_str(data, "message_en"),
|
|
94
|
+
help_url=_opt_str(data, "help_url"),
|
|
95
|
+
rule_url=_opt_str(data, "rule_url"),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def is_warning(self) -> bool:
|
|
100
|
+
return self.level == "warning"
|
|
101
|
+
|
|
102
|
+
def __str__(self) -> str:
|
|
103
|
+
"""``Line 139: [BR-CO-16] Amount due for payment …``"""
|
|
104
|
+
line = f"Line {self.line}: " if self.line > 0 else ""
|
|
105
|
+
rule = f"[{self.rule}] " if self.rule is not None else ""
|
|
106
|
+
return f"{line}{rule}{self.message_en or self.message}"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class ArchiveItem:
|
|
111
|
+
"""The result for one document of an archive."""
|
|
112
|
+
|
|
113
|
+
name: str
|
|
114
|
+
"""Its path in the archive."""
|
|
115
|
+
schema: str | None
|
|
116
|
+
outcome: str
|
|
117
|
+
"""valid, warnings, invalid, schema, input, failed or skipped."""
|
|
118
|
+
valid: bool
|
|
119
|
+
errors_total: int
|
|
120
|
+
warnings_total: int
|
|
121
|
+
errors: list[Diagnostic]
|
|
122
|
+
warnings: list[Diagnostic]
|
|
123
|
+
note: str | None = None
|
|
124
|
+
"""Why a skipped document was left unchecked."""
|
|
125
|
+
|
|
126
|
+
@classmethod
|
|
127
|
+
def from_dict(cls, data: dict[str, Any]) -> ArchiveItem:
|
|
128
|
+
return cls(
|
|
129
|
+
name=_str(data, "name"),
|
|
130
|
+
schema=_opt_str(data, "schema"),
|
|
131
|
+
outcome=_str(data, "outcome"),
|
|
132
|
+
valid=_bool(data, "valid"),
|
|
133
|
+
errors_total=_int(data, "errors_total"),
|
|
134
|
+
warnings_total=_int(data, "warnings_total"),
|
|
135
|
+
errors=[Diagnostic.from_dict(item) for item in _objects(data, "errors")],
|
|
136
|
+
warnings=[Diagnostic.from_dict(item) for item in _objects(data, "warnings")],
|
|
137
|
+
note=_opt_str(data, "note"),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass(frozen=True)
|
|
142
|
+
class Result:
|
|
143
|
+
"""What a finished validation found."""
|
|
144
|
+
|
|
145
|
+
valid: bool
|
|
146
|
+
"""True for the outcomes valid and warnings."""
|
|
147
|
+
outcome: str
|
|
148
|
+
"""valid, warnings, invalid, schema (the schema can't be used), input (the document can't be read) or failed."""
|
|
149
|
+
errors_total: int
|
|
150
|
+
warnings_total: int
|
|
151
|
+
errors_truncated: bool
|
|
152
|
+
"""More errors were found than are listed."""
|
|
153
|
+
errors: list[Diagnostic]
|
|
154
|
+
"""The first 500."""
|
|
155
|
+
warnings: list[Diagnostic]
|
|
156
|
+
"""The first 500."""
|
|
157
|
+
bytes: int
|
|
158
|
+
duration_ms: int
|
|
159
|
+
pdf: dict[str, Any] | None = None
|
|
160
|
+
"""For a PDF: the invoice found in it (attachment, attachments, guideline, profile)."""
|
|
161
|
+
documents: dict[str, Any] | None = None
|
|
162
|
+
"""For an archive: total, passed, failed, skipped."""
|
|
163
|
+
items: list[ArchiveItem] = field(default_factory=list)
|
|
164
|
+
"""For an archive: one result per document."""
|
|
165
|
+
|
|
166
|
+
@classmethod
|
|
167
|
+
def from_dict(cls, data: dict[str, Any]) -> Result:
|
|
168
|
+
return cls(
|
|
169
|
+
valid=_bool(data, "valid"),
|
|
170
|
+
outcome=_str(data, "outcome"),
|
|
171
|
+
errors_total=_int(data, "errors_total"),
|
|
172
|
+
warnings_total=_int(data, "warnings_total"),
|
|
173
|
+
errors_truncated=_bool(data, "errors_truncated"),
|
|
174
|
+
errors=[Diagnostic.from_dict(item) for item in _objects(data, "errors")],
|
|
175
|
+
warnings=[Diagnostic.from_dict(item) for item in _objects(data, "warnings")],
|
|
176
|
+
bytes=_int(data, "bytes"),
|
|
177
|
+
duration_ms=_int(data, "duration_ms"),
|
|
178
|
+
pdf=_object(data, "pdf"),
|
|
179
|
+
documents=_object(data, "documents"),
|
|
180
|
+
items=[ArchiveItem.from_dict(item) for item in _objects(data, "items")],
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def first_error(self) -> Diagnostic | None:
|
|
185
|
+
return self.errors[0] if self.errors else None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@dataclass(frozen=True)
|
|
189
|
+
class Validation:
|
|
190
|
+
"""A validation, finished or not. Results are kept for 24 hours."""
|
|
191
|
+
|
|
192
|
+
QUEUED = "queued"
|
|
193
|
+
RUNNING = "running"
|
|
194
|
+
DONE = "done"
|
|
195
|
+
FAILED = "failed"
|
|
196
|
+
CANCELLED = "cancelled"
|
|
197
|
+
|
|
198
|
+
id: str
|
|
199
|
+
"""val_…"""
|
|
200
|
+
status: str
|
|
201
|
+
"""queued, running, done, failed (on xmlvalid's side; see ``error``) or cancelled."""
|
|
202
|
+
progress: int
|
|
203
|
+
created_at: datetime | None
|
|
204
|
+
finished_at: datetime | None
|
|
205
|
+
document: dict[str, Any]
|
|
206
|
+
"""source, name, size, url."""
|
|
207
|
+
schema: dict[str, Any] | None
|
|
208
|
+
"""kind, catalog, saved, detected, name."""
|
|
209
|
+
result: Result | None
|
|
210
|
+
error: str | None
|
|
211
|
+
"""What went wrong on xmlvalid's side when the status is failed."""
|
|
212
|
+
report_url: str | None
|
|
213
|
+
"""The result as an HTML page, once finished (needs the API key)."""
|
|
214
|
+
raw: dict[str, Any] = field(default_factory=dict, repr=False)
|
|
215
|
+
|
|
216
|
+
@classmethod
|
|
217
|
+
def from_dict(cls, data: dict[str, Any]) -> Validation:
|
|
218
|
+
result = _object(data, "result")
|
|
219
|
+
return cls(
|
|
220
|
+
id=_str(data, "id"),
|
|
221
|
+
status=_str(data, "status"),
|
|
222
|
+
progress=_int(data, "progress"),
|
|
223
|
+
created_at=_date(data, "created_at"),
|
|
224
|
+
finished_at=_date(data, "finished_at"),
|
|
225
|
+
document=_object(data, "document") or {},
|
|
226
|
+
schema=_object(data, "schema"),
|
|
227
|
+
result=Result.from_dict(result) if result is not None else None,
|
|
228
|
+
error=_opt_str(data, "error"),
|
|
229
|
+
report_url=_opt_str(_object(data, "links") or {}, "report"),
|
|
230
|
+
raw=data,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
@property
|
|
234
|
+
def is_finished(self) -> bool:
|
|
235
|
+
"""No longer queued or running."""
|
|
236
|
+
return self.status not in (self.QUEUED, self.RUNNING)
|
|
237
|
+
|
|
238
|
+
@property
|
|
239
|
+
def is_valid(self) -> bool:
|
|
240
|
+
"""Finished, and the document passed (valid, or valid with warnings)."""
|
|
241
|
+
return self.result is not None and self.result.valid
|
|
242
|
+
|
|
243
|
+
@property
|
|
244
|
+
def errors(self) -> list[Diagnostic]:
|
|
245
|
+
"""The errors found (empty while running)."""
|
|
246
|
+
return self.result.errors if self.result is not None else []
|
|
247
|
+
|
|
248
|
+
@property
|
|
249
|
+
def warnings(self) -> list[Diagnostic]:
|
|
250
|
+
"""The warnings found (empty while running)."""
|
|
251
|
+
return self.result.warnings if self.result is not None else []
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@dataclass(frozen=True)
|
|
255
|
+
class Usage:
|
|
256
|
+
"""This month's validations and the plan's limits."""
|
|
257
|
+
|
|
258
|
+
plan: str
|
|
259
|
+
"""free or pro."""
|
|
260
|
+
period_start: datetime | None
|
|
261
|
+
reset_at: datetime | None
|
|
262
|
+
"""When the count resets (the 1st of next month, UTC)."""
|
|
263
|
+
used: int
|
|
264
|
+
limit: int
|
|
265
|
+
max_file_bytes: int
|
|
266
|
+
requests_per_minute: int
|
|
267
|
+
|
|
268
|
+
@classmethod
|
|
269
|
+
def from_dict(cls, data: dict[str, Any]) -> Usage:
|
|
270
|
+
period = _object(data, "period") or {}
|
|
271
|
+
validations = _object(data, "validations") or {}
|
|
272
|
+
limits = _object(data, "limits") or {}
|
|
273
|
+
return cls(
|
|
274
|
+
plan=_str(data, "plan"),
|
|
275
|
+
period_start=_date(period, "start"),
|
|
276
|
+
reset_at=_date(period, "reset_at"),
|
|
277
|
+
used=_int(validations, "used"),
|
|
278
|
+
limit=_int(validations, "limit"),
|
|
279
|
+
max_file_bytes=_int(limits, "max_file_bytes"),
|
|
280
|
+
requests_per_minute=_int(limits, "requests_per_minute"),
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
@property
|
|
284
|
+
def remaining(self) -> int:
|
|
285
|
+
return max(0, self.limit - self.used)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@dataclass(frozen=True)
|
|
289
|
+
class Schema:
|
|
290
|
+
"""A schema to validate against: one of the account's saved schemas or a catalog entry."""
|
|
291
|
+
|
|
292
|
+
schema: str
|
|
293
|
+
"""The value to pass as schema: a catalog slug (peppol-bis-3-invoice) or a saved schema (my-12)."""
|
|
294
|
+
name: str
|
|
295
|
+
kind: str
|
|
296
|
+
"""xsd, dtd, rng or rnc."""
|
|
297
|
+
namespace: str | None
|
|
298
|
+
saved: bool
|
|
299
|
+
version: str | None = None
|
|
300
|
+
rules: list[str] = field(default_factory=list)
|
|
301
|
+
"""Schematron rule sets run after the schema check, in order."""
|
|
302
|
+
|
|
303
|
+
@classmethod
|
|
304
|
+
def from_dict(cls, data: dict[str, Any], saved: bool) -> Schema:
|
|
305
|
+
rules = data.get("rules")
|
|
306
|
+
return cls(
|
|
307
|
+
schema=_str(data, "schema"),
|
|
308
|
+
name=_str(data, "name"),
|
|
309
|
+
kind=_str(data, "kind"),
|
|
310
|
+
namespace=_opt_str(data, "namespace"),
|
|
311
|
+
saved=saved,
|
|
312
|
+
version=_opt_str(data, "version"),
|
|
313
|
+
rules=[rule for rule in rules if isinstance(rule, str)] if isinstance(rules, list) else [],
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
@dataclass(frozen=True)
|
|
318
|
+
class ReportLink:
|
|
319
|
+
"""A public link to a report, for 30 days."""
|
|
320
|
+
|
|
321
|
+
url: str
|
|
322
|
+
expires_at: datetime | None
|
|
323
|
+
|
|
324
|
+
@classmethod
|
|
325
|
+
def from_dict(cls, data: dict[str, Any]) -> ReportLink:
|
|
326
|
+
return cls(url=_str(data, "url"), expires_at=_date(data, "expires_at"))
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
@dataclass(frozen=True)
|
|
330
|
+
class Event:
|
|
331
|
+
"""A webhook event: validation.completed, monitor.failed, monitor.recovered or ping.
|
|
332
|
+
|
|
333
|
+
A retry carries the same id, so it can be used to skip events already handled.
|
|
334
|
+
"""
|
|
335
|
+
|
|
336
|
+
VALIDATION_COMPLETED = "validation.completed"
|
|
337
|
+
MONITOR_FAILED = "monitor.failed"
|
|
338
|
+
MONITOR_RECOVERED = "monitor.recovered"
|
|
339
|
+
PING = "ping"
|
|
340
|
+
|
|
341
|
+
id: str
|
|
342
|
+
type: str
|
|
343
|
+
created_at: datetime | None
|
|
344
|
+
data: dict[str, Any]
|
|
345
|
+
|
|
346
|
+
@classmethod
|
|
347
|
+
def from_dict(cls, payload: dict[str, Any]) -> Event:
|
|
348
|
+
return cls(
|
|
349
|
+
id=_str(payload, "id"),
|
|
350
|
+
type=_str(payload, "type"),
|
|
351
|
+
created_at=_date(payload, "created_at"),
|
|
352
|
+
data=_object(payload, "data") or {},
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
@property
|
|
356
|
+
def validation(self) -> Validation | None:
|
|
357
|
+
"""The validation of a validation.completed event; None for other events."""
|
|
358
|
+
return Validation.from_dict(self.data) if self.type == self.VALIDATION_COMPLETED else None
|
xmlvalid/py.typed
ADDED
|
File without changes
|
xmlvalid/webhooks.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Checks and reads the webhooks xmlvalid sends (Dashboard → Webhooks)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
from .errors import SignatureVerificationError
|
|
12
|
+
from .models import Event
|
|
13
|
+
|
|
14
|
+
__all__ = ["verify"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def verify(
|
|
18
|
+
payload: bytes | str,
|
|
19
|
+
signature: str | None,
|
|
20
|
+
secret: str,
|
|
21
|
+
*,
|
|
22
|
+
tolerance: int = 300,
|
|
23
|
+
now: int | None = None,
|
|
24
|
+
) -> Event:
|
|
25
|
+
"""Checks a webhook's ``Xmlvalid-Signature`` header and returns its event.
|
|
26
|
+
|
|
27
|
+
The header is ``t=<unix time>,v1=<HMAC-SHA256 of "t.body" keyed with the endpoint's secret>``.
|
|
28
|
+
Pass the raw body as received. ``tolerance`` is how many seconds a signature stays valid,
|
|
29
|
+
against replays (0 to skip that check).
|
|
30
|
+
|
|
31
|
+
Raises :class:`~xmlvalid.errors.SignatureVerificationError`.
|
|
32
|
+
"""
|
|
33
|
+
body = payload.encode() if isinstance(payload, str) else payload
|
|
34
|
+
timestamp = re.search(r"(?:^|,)\s*t=(\d+)", signature or "")
|
|
35
|
+
hashes = re.findall(r"(?:^|,)\s*v1=([0-9a-f]+)", signature or "")
|
|
36
|
+
if timestamp is None or not hashes:
|
|
37
|
+
raise SignatureVerificationError("The Xmlvalid-Signature header is missing or malformed.")
|
|
38
|
+
|
|
39
|
+
sent = int(timestamp.group(1))
|
|
40
|
+
if tolerance > 0 and abs((now if now is not None else int(time.time())) - sent) > tolerance:
|
|
41
|
+
raise SignatureVerificationError("The webhook signature is too old.")
|
|
42
|
+
|
|
43
|
+
expected = hmac.new(secret.encode(), f"{sent}.".encode() + body, hashlib.sha256).hexdigest()
|
|
44
|
+
if not any(hmac.compare_digest(expected, candidate) for candidate in hashes):
|
|
45
|
+
raise SignatureVerificationError(
|
|
46
|
+
"The webhook signature does not match: check the endpoint's secret and pass the raw body."
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
data = json.loads(body)
|
|
51
|
+
except ValueError as e:
|
|
52
|
+
raise SignatureVerificationError("The webhook body is not JSON.") from e
|
|
53
|
+
if not isinstance(data, dict):
|
|
54
|
+
raise SignatureVerificationError("The webhook body is not a JSON object.")
|
|
55
|
+
|
|
56
|
+
return Event.from_dict({str(key): value for key, value in data.items()})
|