aronline-sdk 0.3.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.
- aronline/__init__.py +119 -0
- aronline/client.py +75 -0
- aronline/errors.py +60 -0
- aronline/http/__init__.py +5 -0
- aronline/http/error_envelope.py +61 -0
- aronline/http/json_body.py +30 -0
- aronline/http/transport.py +158 -0
- aronline/legacy/__init__.py +32 -0
- aronline/legacy/area.py +127 -0
- aronline/legacy/errors.py +53 -0
- aronline/legacy/models/__init__.py +91 -0
- aronline/legacy/models/envio.py +127 -0
- aronline/legacy/models/full.py +64 -0
- aronline/legacy/models/gw_template.py +65 -0
- aronline/legacy/models/regua.py +13 -0
- aronline/legacy/models/sending_proof.py +27 -0
- aronline/legacy/models/status.py +149 -0
- aronline/legacy/models/webhook.py +77 -0
- aronline/legacy/resources/__init__.py +7 -0
- aronline/legacy/resources/base.py +14 -0
- aronline/legacy/resources/status.py +68 -0
- aronline/legacy/resources/templates.py +79 -0
- aronline/legacy/transport.py +229 -0
- aronline/models/__init__.py +25 -0
- aronline/models/allowlist_entry.py +15 -0
- aronline/models/channel.py +17 -0
- aronline/models/freshness.py +25 -0
- aronline/models/tag.py +16 -0
- aronline/models/template.py +35 -0
- aronline/models/version.py +15 -0
- aronline/py.typed +0 -0
- aronline/resources/__init__.py +17 -0
- aronline/resources/allowlist.py +24 -0
- aronline/resources/base.py +14 -0
- aronline/resources/freshness.py +24 -0
- aronline/resources/tags.py +29 -0
- aronline/resources/templates.py +37 -0
- aronline/resources/version.py +23 -0
- aronline_sdk-0.3.0.dist-info/METADATA +430 -0
- aronline_sdk-0.3.0.dist-info/RECORD +42 -0
- aronline_sdk-0.3.0.dist-info/WHEEL +4 -0
- aronline_sdk-0.3.0.dist-info/licenses/LICENSE +201 -0
aronline/__init__.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Official SDK for the AR Online API.
|
|
2
|
+
|
|
3
|
+
The SDK speaks two surfaces. The /v3 resources at the top of ``Client`` are the
|
|
4
|
+
clean contract. ``client.legacy`` speaks the old gateway exactly as its public
|
|
5
|
+
documentation describes it -- idiosyncrasies included -- because an integration
|
|
6
|
+
written against the old API needs typed calls today, and normalizing the old
|
|
7
|
+
contract would break the callers the area exists to keep working.
|
|
8
|
+
|
|
9
|
+
The /v1 and /v2 mirrors are not covered: they answer the old contracts byte for
|
|
10
|
+
byte, and a typed client that "improved" them would break the same callers.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from aronline.client import Client
|
|
14
|
+
from aronline.errors import ApiError, ErrorDetail
|
|
15
|
+
from aronline.http.transport import DEFAULT_BASE_URL, DEFAULT_TIMEOUT
|
|
16
|
+
from aronline.legacy.area import LegacyArea
|
|
17
|
+
from aronline.legacy.errors import LegacyApiError
|
|
18
|
+
from aronline.legacy.models import (
|
|
19
|
+
GW_TEMPLATE_TYPES,
|
|
20
|
+
SMS_TYPE_SENDS,
|
|
21
|
+
Anexo,
|
|
22
|
+
CanalCarta,
|
|
23
|
+
CanalSms,
|
|
24
|
+
CanalVoz,
|
|
25
|
+
CanalWhatsapp,
|
|
26
|
+
EnvioRequest,
|
|
27
|
+
EnvioResponse,
|
|
28
|
+
EnvioValidation,
|
|
29
|
+
FinalizarReguaResult,
|
|
30
|
+
FullChannelDetail,
|
|
31
|
+
GwTemplate,
|
|
32
|
+
GwTemplateType,
|
|
33
|
+
GwTemplateWriteResult,
|
|
34
|
+
SendingProof,
|
|
35
|
+
SmsAnswer,
|
|
36
|
+
SmsTypeSend,
|
|
37
|
+
StatusCarta,
|
|
38
|
+
StatusEmail,
|
|
39
|
+
StatusEvent,
|
|
40
|
+
StatusFull,
|
|
41
|
+
StatusHistory,
|
|
42
|
+
StatusLast,
|
|
43
|
+
StatusSms,
|
|
44
|
+
StatusVoz,
|
|
45
|
+
StatusWhatsapp,
|
|
46
|
+
UpdateGwTemplate,
|
|
47
|
+
WebhookChannel,
|
|
48
|
+
WebhookMetadata,
|
|
49
|
+
WebhookPayloadV1,
|
|
50
|
+
WebhookPayloadV2,
|
|
51
|
+
WebhookStatusPayload,
|
|
52
|
+
)
|
|
53
|
+
from aronline.legacy.transport import DEFAULT_LEGACY_BASE_URL
|
|
54
|
+
from aronline.models import (
|
|
55
|
+
CHANNELS,
|
|
56
|
+
AllowlistEntry,
|
|
57
|
+
Channel,
|
|
58
|
+
Freshness,
|
|
59
|
+
Tag,
|
|
60
|
+
Template,
|
|
61
|
+
TemplateVariable,
|
|
62
|
+
Version,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
#: This package's version -- the same string ``pyproject.toml`` carries.
|
|
66
|
+
VERSION = "0.3.0"
|
|
67
|
+
|
|
68
|
+
__all__ = [
|
|
69
|
+
"CHANNELS",
|
|
70
|
+
"DEFAULT_BASE_URL",
|
|
71
|
+
"DEFAULT_LEGACY_BASE_URL",
|
|
72
|
+
"DEFAULT_TIMEOUT",
|
|
73
|
+
"GW_TEMPLATE_TYPES",
|
|
74
|
+
"SMS_TYPE_SENDS",
|
|
75
|
+
"VERSION",
|
|
76
|
+
"AllowlistEntry",
|
|
77
|
+
"Anexo",
|
|
78
|
+
"ApiError",
|
|
79
|
+
"CanalCarta",
|
|
80
|
+
"CanalSms",
|
|
81
|
+
"CanalVoz",
|
|
82
|
+
"CanalWhatsapp",
|
|
83
|
+
"Channel",
|
|
84
|
+
"Client",
|
|
85
|
+
"EnvioRequest",
|
|
86
|
+
"EnvioResponse",
|
|
87
|
+
"EnvioValidation",
|
|
88
|
+
"ErrorDetail",
|
|
89
|
+
"FinalizarReguaResult",
|
|
90
|
+
"Freshness",
|
|
91
|
+
"FullChannelDetail",
|
|
92
|
+
"GwTemplate",
|
|
93
|
+
"GwTemplateType",
|
|
94
|
+
"GwTemplateWriteResult",
|
|
95
|
+
"LegacyApiError",
|
|
96
|
+
"LegacyArea",
|
|
97
|
+
"SendingProof",
|
|
98
|
+
"SmsAnswer",
|
|
99
|
+
"SmsTypeSend",
|
|
100
|
+
"StatusCarta",
|
|
101
|
+
"StatusEmail",
|
|
102
|
+
"StatusEvent",
|
|
103
|
+
"StatusFull",
|
|
104
|
+
"StatusHistory",
|
|
105
|
+
"StatusLast",
|
|
106
|
+
"StatusSms",
|
|
107
|
+
"StatusVoz",
|
|
108
|
+
"StatusWhatsapp",
|
|
109
|
+
"Tag",
|
|
110
|
+
"Template",
|
|
111
|
+
"TemplateVariable",
|
|
112
|
+
"UpdateGwTemplate",
|
|
113
|
+
"Version",
|
|
114
|
+
"WebhookChannel",
|
|
115
|
+
"WebhookMetadata",
|
|
116
|
+
"WebhookPayloadV1",
|
|
117
|
+
"WebhookPayloadV2",
|
|
118
|
+
"WebhookStatusPayload",
|
|
119
|
+
]
|
aronline/client.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""The client -- the one thing you construct."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import urllib.request
|
|
6
|
+
|
|
7
|
+
from aronline.http.transport import DEFAULT_BASE_URL, DEFAULT_TIMEOUT, Transport
|
|
8
|
+
from aronline.legacy.area import LegacyArea
|
|
9
|
+
from aronline.legacy.transport import DEFAULT_LEGACY_BASE_URL, LegacyTransport
|
|
10
|
+
from aronline.resources.allowlist import AllowlistResource
|
|
11
|
+
from aronline.resources.freshness import FreshnessResource
|
|
12
|
+
from aronline.resources.tags import TagsResource
|
|
13
|
+
from aronline.resources.templates import TemplatesResource
|
|
14
|
+
from aronline.resources.version import VersionResource
|
|
15
|
+
|
|
16
|
+
__all__ = ["Client"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Client:
|
|
20
|
+
"""The AR Online API client.
|
|
21
|
+
|
|
22
|
+
::
|
|
23
|
+
|
|
24
|
+
from aronline import Client
|
|
25
|
+
|
|
26
|
+
client = Client(token=os.environ["AR_TOKEN"])
|
|
27
|
+
templates = client.templates.list(channel="whatsapp")
|
|
28
|
+
|
|
29
|
+
It owns the transport and hands it to each resource; the resources are the
|
|
30
|
+
public surface. Nothing above this line knows that HTTP is involved.
|
|
31
|
+
|
|
32
|
+
``client.legacy`` is the second surface: the old gateway's contract, spoken
|
|
33
|
+
exactly as documented, with its own address and its own credential
|
|
34
|
+
(``legacy_token``). It exists so an integration on the old API gets typed
|
|
35
|
+
calls today and migrates to /v3 by SDK update, not by rewrite.
|
|
36
|
+
|
|
37
|
+
Each credential is optional: pass only the one for the surface you use.
|
|
38
|
+
Neither leaks into the other's calls.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
*,
|
|
44
|
+
token: str | None = None,
|
|
45
|
+
legacy_token: str | None = None,
|
|
46
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
47
|
+
legacy_base_url: str = DEFAULT_LEGACY_BASE_URL,
|
|
48
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
49
|
+
opener: urllib.request.OpenerDirector | None = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
transport = Transport(
|
|
52
|
+
token=token,
|
|
53
|
+
base_url=base_url,
|
|
54
|
+
timeout=timeout,
|
|
55
|
+
opener=opener,
|
|
56
|
+
)
|
|
57
|
+
legacy_transport = LegacyTransport(
|
|
58
|
+
token=legacy_token,
|
|
59
|
+
base_url=legacy_base_url,
|
|
60
|
+
timeout=timeout,
|
|
61
|
+
opener=opener,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
#: Message templates.
|
|
65
|
+
self.templates = TemplatesResource(transport)
|
|
66
|
+
#: Your labels.
|
|
67
|
+
self.tags = TagsResource(transport)
|
|
68
|
+
#: Your allowed recipients.
|
|
69
|
+
self.allowlist = AllowlistResource(transport)
|
|
70
|
+
#: How fresh the copy of the data is.
|
|
71
|
+
self.freshness = FreshnessResource(transport)
|
|
72
|
+
#: Which version is running. The one route that needs no token.
|
|
73
|
+
self.version = VersionResource(transport)
|
|
74
|
+
#: The legacy gateway surface -- today's contract, idiosyncrasies included.
|
|
75
|
+
self.legacy = LegacyArea(legacy_transport)
|
aronline/errors.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""What a refusal from the API looks like on this side."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TypedDict
|
|
6
|
+
|
|
7
|
+
__all__ = ["ApiError", "ErrorDetail"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ErrorDetail(TypedDict):
|
|
11
|
+
"""One field-level complaint inside a validation error."""
|
|
12
|
+
|
|
13
|
+
field: str
|
|
14
|
+
code: str
|
|
15
|
+
message: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ApiError(Exception):
|
|
19
|
+
"""A refusal from the API, raised so a failed call cannot pass for a good one.
|
|
20
|
+
|
|
21
|
+
``request_id`` is a first-class attribute on purpose: it is the first thing
|
|
22
|
+
support asks for, and an SDK that swallowed it would force whoever hit the
|
|
23
|
+
failure to reproduce it with curl just to find the number.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
*,
|
|
29
|
+
status: int,
|
|
30
|
+
code: str,
|
|
31
|
+
message: str,
|
|
32
|
+
request_id: str | None,
|
|
33
|
+
field: str | None = None,
|
|
34
|
+
details: list[ErrorDetail] | None = None,
|
|
35
|
+
retry_after_seconds: float | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
super().__init__(message)
|
|
38
|
+
|
|
39
|
+
#: The HTTP status. Zero when the API was never reached.
|
|
40
|
+
self.status = status
|
|
41
|
+
#: The catalog code -- ``not_found``, ``forbidden``, ``rate_limited``, ...
|
|
42
|
+
self.code = code
|
|
43
|
+
#: The message the API sent, in pt-BR.
|
|
44
|
+
self.message = message
|
|
45
|
+
#: From the error body, or from the ``X-Request-Id`` header when absent.
|
|
46
|
+
self.request_id = request_id
|
|
47
|
+
#: The field at fault, when the refusal is about one.
|
|
48
|
+
self.field = field
|
|
49
|
+
#: One entry per rejected field, on validation errors.
|
|
50
|
+
self.details = details
|
|
51
|
+
#: From the ``Retry-After`` header, on 429 and 503.
|
|
52
|
+
self.retry_after_seconds = retry_after_seconds
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def retryable(self) -> bool:
|
|
56
|
+
"""True for the statuses the API says are worth trying again."""
|
|
57
|
+
return self.status in (429, 503)
|
|
58
|
+
|
|
59
|
+
def __repr__(self) -> str:
|
|
60
|
+
return f"ApiError(status={self.status}, code={self.code!r}, request_id={self.request_id!r})"
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Turning a refused response into an ``ApiError``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from aronline.errors import ApiError
|
|
8
|
+
|
|
9
|
+
__all__ = ["to_api_error"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def to_api_error(
|
|
13
|
+
status: int,
|
|
14
|
+
body: Any,
|
|
15
|
+
request_id: str | None,
|
|
16
|
+
retry_after: str | None,
|
|
17
|
+
) -> ApiError:
|
|
18
|
+
"""Build the error a refusal becomes.
|
|
19
|
+
|
|
20
|
+
A refusal that does not carry the envelope is still a refusal: a proxy
|
|
21
|
+
answering 502 in HTML has to fail the same way, or whoever hit it goes
|
|
22
|
+
looking for a bug in their own parsing code.
|
|
23
|
+
"""
|
|
24
|
+
envelope = _read_envelope(body)
|
|
25
|
+
|
|
26
|
+
return ApiError(
|
|
27
|
+
status=status,
|
|
28
|
+
code=envelope.get("code") or "invalid_response",
|
|
29
|
+
message=envelope.get("message") or f"a API respondeu {status} sem o corpo de erro esperado",
|
|
30
|
+
request_id=envelope.get("request_id") or request_id,
|
|
31
|
+
field=envelope.get("field"),
|
|
32
|
+
details=envelope.get("details"),
|
|
33
|
+
retry_after_seconds=_read_retry_after(retry_after),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _read_envelope(body: Any) -> dict[str, Any]:
|
|
38
|
+
if not isinstance(body, dict):
|
|
39
|
+
return {}
|
|
40
|
+
|
|
41
|
+
error = body.get("error")
|
|
42
|
+
|
|
43
|
+
return error if isinstance(error, dict) else {}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _read_retry_after(header: str | None) -> float | None:
|
|
47
|
+
"""Seconds, or nothing.
|
|
48
|
+
|
|
49
|
+
``Retry-After`` also has an HTTP-date form. The API only ever sends
|
|
50
|
+
seconds, so anything else is dropped rather than guessed at -- a wrong
|
|
51
|
+
delay is worse than no delay.
|
|
52
|
+
"""
|
|
53
|
+
if header is None:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
seconds = float(header)
|
|
58
|
+
except ValueError:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
return seconds if seconds >= 0 else None
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Reading a response body that claims to be JSON.
|
|
2
|
+
|
|
3
|
+
Both transports need the same distinction: "the body was not JSON" is not the
|
|
4
|
+
same thing as "the body was ``null``". A proxy answering HTML has to fail like
|
|
5
|
+
any other refusal, while a route that legitimately answers ``null`` has to keep
|
|
6
|
+
answering it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
__all__ = ["UNPARSED", "parse_json"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _Unparsed:
|
|
18
|
+
"""Tells "the body was not JSON" apart from "the body was ``null``"."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
#: What :func:`parse_json` returns when the text is not JSON at all.
|
|
22
|
+
UNPARSED = _Unparsed()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse_json(text: str) -> Any:
|
|
26
|
+
"""The parsed body, or :data:`UNPARSED` when the text is not JSON."""
|
|
27
|
+
try:
|
|
28
|
+
return json.loads(text)
|
|
29
|
+
except ValueError:
|
|
30
|
+
return UNPARSED
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""The HTTP layer, and the only place in the SDK that knows it exists."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.parse
|
|
7
|
+
import urllib.request
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from aronline.errors import ApiError
|
|
11
|
+
from aronline.http.error_envelope import to_api_error
|
|
12
|
+
from aronline.http.json_body import UNPARSED, parse_json
|
|
13
|
+
|
|
14
|
+
__all__ = ["DEFAULT_BASE_URL", "DEFAULT_TIMEOUT", "Transport"]
|
|
15
|
+
|
|
16
|
+
#: Where /v3 lives. Override it for staging or for a local process.
|
|
17
|
+
DEFAULT_BASE_URL = "https://v3.ar-online.com.br"
|
|
18
|
+
|
|
19
|
+
#: How long a call waits before giving up, in seconds.
|
|
20
|
+
DEFAULT_TIMEOUT = 30.0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Transport:
|
|
24
|
+
"""Builds the request, reads the answer, and raises what went wrong.
|
|
25
|
+
|
|
26
|
+
Everything above it -- the resources, the client -- deals in functions and
|
|
27
|
+
dictionaries. That is the whole point of the package: whoever installs it
|
|
28
|
+
should never build a URL, set a header, read a status or unwrap an
|
|
29
|
+
envelope.
|
|
30
|
+
|
|
31
|
+
It uses ``urllib`` from the standard library on purpose: an SDK with no
|
|
32
|
+
dependencies is one that never conflicts with what the application already
|
|
33
|
+
pinned.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
*,
|
|
39
|
+
token: str | None = None,
|
|
40
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
41
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
42
|
+
opener: urllib.request.OpenerDirector | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
self._base_url = base_url.rstrip("/")
|
|
45
|
+
self._token = token
|
|
46
|
+
self._timeout = timeout
|
|
47
|
+
self._opener = opener or urllib.request.build_opener()
|
|
48
|
+
|
|
49
|
+
def envelope(
|
|
50
|
+
self,
|
|
51
|
+
path: str,
|
|
52
|
+
*,
|
|
53
|
+
query: dict[str, str | None] | None = None,
|
|
54
|
+
authenticated: bool = True,
|
|
55
|
+
) -> Any:
|
|
56
|
+
"""For the routes that answer ``{"data": ...}`` -- templates, tags, allowlist.
|
|
57
|
+
|
|
58
|
+
Not every route wraps its answer, which is the one trap of this API:
|
|
59
|
+
freshness and version answer the object itself. Unwrapping all of them,
|
|
60
|
+
or none, breaks half the calls, so the choice is made per route by
|
|
61
|
+
whichever method the resource calls.
|
|
62
|
+
"""
|
|
63
|
+
body = self.bare(path, query=query, authenticated=authenticated)
|
|
64
|
+
|
|
65
|
+
if not isinstance(body, dict) or "data" not in body:
|
|
66
|
+
raise ApiError(
|
|
67
|
+
status=200,
|
|
68
|
+
code="invalid_response",
|
|
69
|
+
message=f"{path} respondeu sem o envelope 'data' que a rota promete",
|
|
70
|
+
request_id=None,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
return body["data"]
|
|
74
|
+
|
|
75
|
+
def bare(
|
|
76
|
+
self,
|
|
77
|
+
path: str,
|
|
78
|
+
*,
|
|
79
|
+
query: dict[str, str | None] | None = None,
|
|
80
|
+
authenticated: bool = True,
|
|
81
|
+
) -> Any:
|
|
82
|
+
"""For the routes that answer the object directly -- freshness, version."""
|
|
83
|
+
# Refused here, before the socket: a 401 round trip teaches nothing
|
|
84
|
+
# that the missing token does not already say.
|
|
85
|
+
if authenticated and self._token is None:
|
|
86
|
+
raise ApiError(
|
|
87
|
+
status=401,
|
|
88
|
+
code="unauthenticated",
|
|
89
|
+
message=f"{path} exige um token; construa o cliente com token=...",
|
|
90
|
+
request_id=None,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
request = urllib.request.Request(
|
|
94
|
+
self.url(path, query),
|
|
95
|
+
method="GET",
|
|
96
|
+
headers=self._headers(authenticated),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
status, text, headers = self._send(request)
|
|
100
|
+
body = parse_json(text)
|
|
101
|
+
request_id = headers.get("X-Request-Id")
|
|
102
|
+
|
|
103
|
+
if status >= 400:
|
|
104
|
+
raise to_api_error(status, body, request_id, headers.get("Retry-After"))
|
|
105
|
+
|
|
106
|
+
# A 200 that is not JSON is something other than the API answering. It
|
|
107
|
+
# fails like any other refusal -- a raw JSONDecodeError leaking out
|
|
108
|
+
# here would send whoever hit it looking for a bug in their own code.
|
|
109
|
+
if body is UNPARSED:
|
|
110
|
+
raise ApiError(
|
|
111
|
+
status=status,
|
|
112
|
+
code="invalid_response",
|
|
113
|
+
message="a resposta não é JSON — algo respondeu no lugar da API",
|
|
114
|
+
request_id=request_id,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
return body
|
|
118
|
+
|
|
119
|
+
def url(self, path: str, query: dict[str, str | None] | None = None) -> str:
|
|
120
|
+
"""The absolute URL of a path. Exposed because the tests assert on it."""
|
|
121
|
+
filled = {key: value for key, value in (query or {}).items() if value is not None}
|
|
122
|
+
suffix = f"?{urllib.parse.urlencode(filled)}" if filled else ""
|
|
123
|
+
|
|
124
|
+
return f"{self._base_url}{path}{suffix}"
|
|
125
|
+
|
|
126
|
+
def _headers(self, authenticated: bool) -> dict[str, str]:
|
|
127
|
+
headers = {"Accept": "application/json"}
|
|
128
|
+
|
|
129
|
+
if authenticated and self._token is not None:
|
|
130
|
+
headers["Authorization"] = f"Bearer {self._token}"
|
|
131
|
+
|
|
132
|
+
return headers
|
|
133
|
+
|
|
134
|
+
def _send(self, request: urllib.request.Request) -> tuple[int, str, Any]:
|
|
135
|
+
try:
|
|
136
|
+
with self._opener.open(request, timeout=self._timeout) as response:
|
|
137
|
+
return response.status, _read(response), response.headers
|
|
138
|
+
except urllib.error.HTTPError as error:
|
|
139
|
+
# urllib raises on 4xx and 5xx. They are answers, not failures:
|
|
140
|
+
# the body carries the catalog, and the caller needs it.
|
|
141
|
+
with error:
|
|
142
|
+
return error.code, _read(error), error.headers
|
|
143
|
+
except (urllib.error.URLError, OSError) as error:
|
|
144
|
+
# Timeout and connection refused arrive as opaque platform errors.
|
|
145
|
+
# They become the SDK's own error so a caller has one type to catch
|
|
146
|
+
# instead of three.
|
|
147
|
+
raise ApiError(
|
|
148
|
+
status=0,
|
|
149
|
+
code="unreachable",
|
|
150
|
+
message=f"não foi possível falar com {self._base_url}: {error}",
|
|
151
|
+
request_id=None,
|
|
152
|
+
) from error
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _read(response: Any) -> str:
|
|
156
|
+
payload: bytes = response.read()
|
|
157
|
+
|
|
158
|
+
return payload.decode("utf-8", errors="replace")
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""The legacy gateway surface -- today's contract, idiosyncrasies included.
|
|
2
|
+
|
|
3
|
+
The /v3 resources at the top of the package are the clean contract. This area
|
|
4
|
+
speaks the old gateway exactly as its public documentation describes it, so an
|
|
5
|
+
integration written against the old API gets typed calls today; when a /v3
|
|
6
|
+
equivalent lands, the legacy function swaps transport without changing its
|
|
7
|
+
signature.
|
|
8
|
+
|
|
9
|
+
The names here follow the **legacy vocabulary** -- ``laudo``, ``regua``,
|
|
10
|
+
``voz``, ``carta``, ``EnvioRequest``. It is a deliberate exception to the
|
|
11
|
+
project's English rule: translating them would create a vocabulary that exists
|
|
12
|
+
in no documentation anywhere. Only the case convention is adapted to Python.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from aronline.legacy.area import LegacyArea
|
|
16
|
+
from aronline.legacy.errors import LegacyApiError
|
|
17
|
+
from aronline.legacy.resources import (
|
|
18
|
+
LegacyResource,
|
|
19
|
+
LegacyStatusResource,
|
|
20
|
+
LegacyTemplatesResource,
|
|
21
|
+
)
|
|
22
|
+
from aronline.legacy.transport import DEFAULT_LEGACY_BASE_URL, LegacyTransport
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"DEFAULT_LEGACY_BASE_URL",
|
|
26
|
+
"LegacyApiError",
|
|
27
|
+
"LegacyArea",
|
|
28
|
+
"LegacyResource",
|
|
29
|
+
"LegacyStatusResource",
|
|
30
|
+
"LegacyTemplatesResource",
|
|
31
|
+
"LegacyTransport",
|
|
32
|
+
]
|
aronline/legacy/area.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""The legacy gateway, as functions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import binascii
|
|
7
|
+
import urllib.parse
|
|
8
|
+
from typing import Any, cast
|
|
9
|
+
|
|
10
|
+
from aronline.legacy.errors import LegacyApiError
|
|
11
|
+
from aronline.legacy.models.envio import EnvioRequest, EnvioResponse
|
|
12
|
+
from aronline.legacy.models.regua import FinalizarReguaResult
|
|
13
|
+
from aronline.legacy.models.sending_proof import SendingProof
|
|
14
|
+
from aronline.legacy.resources.status import LegacyStatusResource
|
|
15
|
+
from aronline.legacy.resources.templates import LegacyTemplatesResource
|
|
16
|
+
from aronline.legacy.transport import LegacyTransport
|
|
17
|
+
|
|
18
|
+
__all__ = ["LegacyArea"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LegacyArea:
|
|
22
|
+
"""Everything docs.ar-online.com.br documents of the gateway, spoken exactly
|
|
23
|
+
as the old API speaks it.
|
|
24
|
+
|
|
25
|
+
::
|
|
26
|
+
|
|
27
|
+
from aronline import Client
|
|
28
|
+
|
|
29
|
+
client = Client(legacy_token=os.environ["AR_GW_TOKEN"])
|
|
30
|
+
sent = client.legacy.send({"nameTo": "João", "subject": "…", "content": "…"})
|
|
31
|
+
status = client.legacy.status.email(sent["idEmail"])
|
|
32
|
+
|
|
33
|
+
This area exists so an integration written against the old contract gets
|
|
34
|
+
typed calls today. As /v3 grows an equivalent for a route, the function here
|
|
35
|
+
swaps its transport without changing shape -- the migration happens under
|
|
36
|
+
your feet, not in your code. Each function's documentation names its /v3
|
|
37
|
+
equivalent when one exists.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, transport: LegacyTransport) -> None:
|
|
41
|
+
self._transport = transport
|
|
42
|
+
|
|
43
|
+
#: Per-channel status and the consolidated forensic view.
|
|
44
|
+
self.status = LegacyStatusResource(transport)
|
|
45
|
+
#: The gateway's template routes. The /v3 equivalent for reads is
|
|
46
|
+
#: ``client.templates``.
|
|
47
|
+
self.templates = LegacyTemplatesResource(transport)
|
|
48
|
+
|
|
49
|
+
def send(self, envio: EnvioRequest) -> EnvioResponse:
|
|
50
|
+
"""Sends a notification -- ``POST /gw/email``, the multichannel route
|
|
51
|
+
despite the name.
|
|
52
|
+
|
|
53
|
+
Processing is asynchronous: keep the returned ``idEmail``, it is the
|
|
54
|
+
handle for every status and proof question later.
|
|
55
|
+
|
|
56
|
+
No /v3 equivalent yet.
|
|
57
|
+
"""
|
|
58
|
+
return cast("EnvioResponse", self._transport.json("POST", "/gw/email", body=envio))
|
|
59
|
+
|
|
60
|
+
def sending_proof(self, id_email: str) -> SendingProof:
|
|
61
|
+
"""The sending proof as a PDF.
|
|
62
|
+
|
|
63
|
+
The wire carries it in base64 inside JSON; this decodes it for you and
|
|
64
|
+
keeps the raw string reachable. While the e-mail has no delivery status
|
|
65
|
+
the gateway answers a message instead, and ``pdf`` comes back ``None``
|
|
66
|
+
-- ask again later. That is an answer, not a refusal.
|
|
67
|
+
|
|
68
|
+
No /v3 equivalent yet.
|
|
69
|
+
"""
|
|
70
|
+
body: Any = self._transport.json("GET", f"/gw/sending-proof/{_quote(id_email)}")
|
|
71
|
+
content = body.get("content") if isinstance(body, dict) else None
|
|
72
|
+
|
|
73
|
+
if not isinstance(content, str):
|
|
74
|
+
message = body.get("message") if isinstance(body, dict) else None
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
"pdf": None,
|
|
78
|
+
"content_base64": None,
|
|
79
|
+
"message": message if isinstance(message, str) else None,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {"pdf": _decode_base64(content), "content_base64": content, "message": None}
|
|
83
|
+
|
|
84
|
+
def laudo(self, id_email: str) -> bytes:
|
|
85
|
+
"""The expert-evidence report -- the one route that answers the PDF
|
|
86
|
+
binary directly, no base64, no JSON.
|
|
87
|
+
|
|
88
|
+
No /v3 equivalent yet.
|
|
89
|
+
"""
|
|
90
|
+
return self._transport.binary(f"/gw/email/laudo/{_quote(id_email)}")
|
|
91
|
+
|
|
92
|
+
def finalizar_regua(self, id_email: str) -> FinalizarReguaResult:
|
|
93
|
+
"""Stops the notification ladder for this send.
|
|
94
|
+
|
|
95
|
+
A GET with a side effect -- that is the old contract, and the SDK does
|
|
96
|
+
not "fix" it to POST. A caller who saw ``GET`` and assumed it was safe
|
|
97
|
+
to repeat would be repeating a write.
|
|
98
|
+
|
|
99
|
+
No /v3 equivalent yet.
|
|
100
|
+
"""
|
|
101
|
+
return cast(
|
|
102
|
+
"FinalizarReguaResult",
|
|
103
|
+
self._transport.json("GET", f"/regua-notificacao/finalizar/{_quote(id_email)}"),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _quote(id_email: str) -> str:
|
|
108
|
+
"""Escaped, so a crooked id cannot become another path."""
|
|
109
|
+
return urllib.parse.quote(id_email, safe="")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _decode_base64(content: str) -> bytes:
|
|
113
|
+
"""The proof, decoded, or the SDK's own error.
|
|
114
|
+
|
|
115
|
+
``validate=True`` on purpose: without it, base64 that is not base64 decodes
|
|
116
|
+
to garbage bytes, and the caller writes a corrupt PDF to disk instead of
|
|
117
|
+
finding out that the gateway answered something unexpected.
|
|
118
|
+
"""
|
|
119
|
+
try:
|
|
120
|
+
return base64.b64decode(content, validate=True)
|
|
121
|
+
except (binascii.Error, ValueError) as error:
|
|
122
|
+
raise LegacyApiError(
|
|
123
|
+
status=200,
|
|
124
|
+
http_status=200,
|
|
125
|
+
message="o comprovante veio com base64 ilegível",
|
|
126
|
+
body={"content": content},
|
|
127
|
+
) from error
|