finopal-py-sdk 0.1.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.
- finopal/__init__.py +106 -0
- finopal/errors/__init__.py +17 -0
- finopal/errors/exceptions.py +124 -0
- finopal/finopal.py +140 -0
- finopal/http/__init__.py +6 -0
- finopal/http/client.py +69 -0
- finopal/http/transport.py +176 -0
- finopal/models.py +224 -0
- finopal/py.typed +0 -0
- finopal/resources/__init__.py +9 -0
- finopal/resources/fees.py +70 -0
- finopal/resources/inquiries.py +77 -0
- finopal/resources/payments.py +168 -0
- finopal/resources/reversals.py +68 -0
- finopal/resources/verifications.py +120 -0
- finopal/utils/__init__.py +41 -0
- finopal/utils/amount.py +72 -0
- finopal/utils/callback.py +102 -0
- finopal/utils/config.py +87 -0
- finopal/utils/enums.py +66 -0
- finopal/utils/error_codes.py +101 -0
- finopal/utils/response.py +75 -0
- finopal/utils/validator.py +172 -0
- finopal_py_sdk-0.1.0.dist-info/METADATA +428 -0
- finopal_py_sdk-0.1.0.dist-info/RECORD +28 -0
- finopal_py_sdk-0.1.0.dist-info/WHEEL +5 -0
- finopal_py_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
- finopal_py_sdk-0.1.0.dist-info/top_level.txt +1 -0
finopal/__init__.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""FinoPal Python SDK — a friendly client for the FinoPal payment gateway.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
from finopal import FinoPal, Config
|
|
6
|
+
|
|
7
|
+
finopal = FinoPal(Config(merchant_id="fino-xxxx-xxxx-xxxx-xxxx", sandbox=True))
|
|
8
|
+
|
|
9
|
+
payment = finopal.payments.create({
|
|
10
|
+
"amount": 1_100, # Rials
|
|
11
|
+
"callback_url": "https://shop.example.com/callback",
|
|
12
|
+
"order_id": "ORD-1001",
|
|
13
|
+
})
|
|
14
|
+
print(payment.payment_url)
|
|
15
|
+
|
|
16
|
+
# ... buyer returns to callback_url ...
|
|
17
|
+
|
|
18
|
+
from finopal import parse_callback
|
|
19
|
+
|
|
20
|
+
cb = parse_callback(request.args)
|
|
21
|
+
if cb.is_success:
|
|
22
|
+
result = finopal.verifications.verify({
|
|
23
|
+
"authority": cb.authority,
|
|
24
|
+
"amount": 1_100,
|
|
25
|
+
})
|
|
26
|
+
print(result.ref_id)
|
|
27
|
+
|
|
28
|
+
Docs: https://finopal.ir/docs
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from finopal.errors import (
|
|
32
|
+
ApiError,
|
|
33
|
+
FinoPalError,
|
|
34
|
+
FinoPalException,
|
|
35
|
+
TransportError,
|
|
36
|
+
ValidationError,
|
|
37
|
+
)
|
|
38
|
+
from finopal.finopal import FinoPal
|
|
39
|
+
from finopal.http import CallableTransport, RequestsTransport, Transport
|
|
40
|
+
from finopal.models import (
|
|
41
|
+
ApiResponse,
|
|
42
|
+
FeeResponse,
|
|
43
|
+
InquiryResponse,
|
|
44
|
+
PaymentRequestResponse,
|
|
45
|
+
ReverseResponse,
|
|
46
|
+
VerificationResponse,
|
|
47
|
+
)
|
|
48
|
+
from finopal.utils import (
|
|
49
|
+
MAX_AMOUNT_RIAL,
|
|
50
|
+
MIN_AMOUNT_RIAL,
|
|
51
|
+
CallbackResult,
|
|
52
|
+
CallbackStatus,
|
|
53
|
+
Config,
|
|
54
|
+
Currency,
|
|
55
|
+
FeeType,
|
|
56
|
+
ResultCode,
|
|
57
|
+
TransactionStatus,
|
|
58
|
+
Validator,
|
|
59
|
+
describe,
|
|
60
|
+
is_success_status,
|
|
61
|
+
parse_callback,
|
|
62
|
+
to_rial,
|
|
63
|
+
to_toman,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
__version__ = "0.1.0"
|
|
67
|
+
|
|
68
|
+
__all__ = [
|
|
69
|
+
# client
|
|
70
|
+
"FinoPal",
|
|
71
|
+
"Config",
|
|
72
|
+
# transports
|
|
73
|
+
"Transport",
|
|
74
|
+
"RequestsTransport",
|
|
75
|
+
"CallableTransport",
|
|
76
|
+
# errors
|
|
77
|
+
"FinoPalError",
|
|
78
|
+
"ValidationError",
|
|
79
|
+
"TransportError",
|
|
80
|
+
"FinoPalException",
|
|
81
|
+
"ApiError",
|
|
82
|
+
# response models
|
|
83
|
+
"ApiResponse",
|
|
84
|
+
"PaymentRequestResponse",
|
|
85
|
+
"VerificationResponse",
|
|
86
|
+
"InquiryResponse",
|
|
87
|
+
"ReverseResponse",
|
|
88
|
+
"FeeResponse",
|
|
89
|
+
# helpers
|
|
90
|
+
"Validator",
|
|
91
|
+
"to_rial",
|
|
92
|
+
"to_toman",
|
|
93
|
+
"MIN_AMOUNT_RIAL",
|
|
94
|
+
"MAX_AMOUNT_RIAL",
|
|
95
|
+
"parse_callback",
|
|
96
|
+
"is_success_status",
|
|
97
|
+
"CallbackResult",
|
|
98
|
+
"describe",
|
|
99
|
+
# enums
|
|
100
|
+
"Currency",
|
|
101
|
+
"TransactionStatus",
|
|
102
|
+
"CallbackStatus",
|
|
103
|
+
"FeeType",
|
|
104
|
+
"ResultCode",
|
|
105
|
+
"__version__",
|
|
106
|
+
]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Error types raised by the FinoPal SDK."""
|
|
2
|
+
|
|
3
|
+
from finopal.errors.exceptions import (
|
|
4
|
+
ApiError,
|
|
5
|
+
FinoPalError,
|
|
6
|
+
FinoPalException,
|
|
7
|
+
TransportError,
|
|
8
|
+
ValidationError,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"FinoPalError",
|
|
13
|
+
"ValidationError",
|
|
14
|
+
"TransportError",
|
|
15
|
+
"FinoPalException",
|
|
16
|
+
"ApiError",
|
|
17
|
+
]
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Exception hierarchy for the FinoPal SDK.
|
|
2
|
+
|
|
3
|
+
Every exception raised by this package derives from :class:`FinoPalError`, so
|
|
4
|
+
``except FinoPalError`` is always a safe catch-all::
|
|
5
|
+
|
|
6
|
+
from finopal import FinoPalError, FinoPalException, ValidationError
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
payment = finopal.payments.create({...})
|
|
10
|
+
except ValidationError as exc:
|
|
11
|
+
# Bad input caught before the HTTP call.
|
|
12
|
+
...
|
|
13
|
+
except FinoPalException as exc:
|
|
14
|
+
# The gateway answered with success=false or a non-100 code.
|
|
15
|
+
print(exc.code, exc.describe())
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import Any, Dict, Mapping, Optional
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"FinoPalError",
|
|
24
|
+
"ValidationError",
|
|
25
|
+
"TransportError",
|
|
26
|
+
"FinoPalException",
|
|
27
|
+
"ApiError",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class FinoPalError(Exception):
|
|
32
|
+
"""Base class for every error raised by the SDK."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ValidationError(FinoPalError):
|
|
36
|
+
"""Raised when input fails client-side validation.
|
|
37
|
+
|
|
38
|
+
No HTTP request is made when this is raised.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TransportError(FinoPalError):
|
|
43
|
+
"""Raised when the gateway could not be reached or answered with garbage.
|
|
44
|
+
|
|
45
|
+
Covers DNS/TLS/connection failures, timeouts, non-JSON bodies and 5xx
|
|
46
|
+
responses. It means "we do not know what happened", which is exactly why
|
|
47
|
+
the caller should retry the *inquiry* endpoint rather than blindly
|
|
48
|
+
re-issuing a payment request.
|
|
49
|
+
|
|
50
|
+
:ivar status_code: HTTP status if a response was received, else ``None``.
|
|
51
|
+
:ivar response: Decoded body if available, else ``{}``.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
message: str,
|
|
57
|
+
status_code: Optional[int] = None,
|
|
58
|
+
response: Optional[Mapping[str, Any]] = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
super().__init__(message)
|
|
61
|
+
self.message = message
|
|
62
|
+
self.status_code = status_code
|
|
63
|
+
self.response: Dict[str, Any] = dict(response or {})
|
|
64
|
+
|
|
65
|
+
def __str__(self) -> str: # pragma: no cover - trivial
|
|
66
|
+
if self.status_code is not None:
|
|
67
|
+
return "{} (HTTP {})".format(self.message, self.status_code)
|
|
68
|
+
return self.message
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class FinoPalException(FinoPalError):
|
|
72
|
+
"""Raised when the gateway rejects a request.
|
|
73
|
+
|
|
74
|
+
Carries the numeric gateway ``code`` so callers can branch on it::
|
|
75
|
+
|
|
76
|
+
from finopal.utils.error_codes import describe
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
finopal.verifications.verify({"authority": authority, "amount": amount})
|
|
80
|
+
except FinoPalException as exc:
|
|
81
|
+
if exc.code == -50:
|
|
82
|
+
... # amount mismatch
|
|
83
|
+
elif exc.code == 101:
|
|
84
|
+
... # already verified — treat as success
|
|
85
|
+
|
|
86
|
+
:ivar code: Numeric gateway code, or ``None`` if none was returned.
|
|
87
|
+
:ivar message: Human-readable message from the gateway.
|
|
88
|
+
:ivar response: The full decoded payload, useful for logging.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
message: str,
|
|
94
|
+
code: Optional[int] = None,
|
|
95
|
+
response: Optional[Mapping[str, Any]] = None,
|
|
96
|
+
) -> None:
|
|
97
|
+
super().__init__(message)
|
|
98
|
+
self.message = message
|
|
99
|
+
self.code = code
|
|
100
|
+
self.response: Dict[str, Any] = dict(response or {})
|
|
101
|
+
|
|
102
|
+
def describe(self) -> str:
|
|
103
|
+
"""English description of :attr:`code` from the published error list."""
|
|
104
|
+
# Imported lazily: ``finopal.utils`` pulls in the validator, which in
|
|
105
|
+
# turn imports this module — a module-level import would be circular.
|
|
106
|
+
from finopal.utils.error_codes import describe
|
|
107
|
+
|
|
108
|
+
return describe(self.code)
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def scope(self) -> str:
|
|
112
|
+
"""API surface the code belongs to (``PaymentRequest``, ...)."""
|
|
113
|
+
from finopal.utils.error_codes import scope_of
|
|
114
|
+
|
|
115
|
+
return scope_of(self.code)
|
|
116
|
+
|
|
117
|
+
def __str__(self) -> str:
|
|
118
|
+
if self.code is None:
|
|
119
|
+
return self.message
|
|
120
|
+
return "{} (code={})".format(self.message, self.code)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
#: Backwards/forwards-friendly alias — ``ApiError`` reads better in some codebases.
|
|
124
|
+
ApiError = FinoPalException
|
finopal/finopal.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""The :class:`FinoPal` entry point.
|
|
2
|
+
|
|
3
|
+
Mirrors the ergonomics of the ZarinPal Python SDK — one client object, lazily
|
|
4
|
+
created resource namespaces — while speaking FinoPal's REST API exactly as
|
|
5
|
+
documented at https://finopal.ir/docs.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Dict, Mapping, Optional
|
|
11
|
+
|
|
12
|
+
from finopal.http.client import HttpClient
|
|
13
|
+
from finopal.http.transport import RequestsTransport, Transport
|
|
14
|
+
from finopal.resources.fees import Fees
|
|
15
|
+
from finopal.resources.inquiries import Inquiries
|
|
16
|
+
from finopal.resources.payments import Payments
|
|
17
|
+
from finopal.resources.reversals import Reversals
|
|
18
|
+
from finopal.resources.verifications import Verifications
|
|
19
|
+
from finopal.utils.config import Config
|
|
20
|
+
|
|
21
|
+
__all__ = ["FinoPal"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class FinoPal:
|
|
25
|
+
"""Client for the FinoPal online payment gateway.
|
|
26
|
+
|
|
27
|
+
:param config: A :class:`~finopal.utils.config.Config`.
|
|
28
|
+
:param transport: Optional transport override (proxies, stubs, an async
|
|
29
|
+
adapter...). Defaults to :class:`~finopal.http.transport.RequestsTransport`.
|
|
30
|
+
|
|
31
|
+
Example::
|
|
32
|
+
|
|
33
|
+
from finopal import FinoPal, Config
|
|
34
|
+
|
|
35
|
+
finopal = FinoPal(Config(merchant_id="fino-1234-5678-9012-3456"))
|
|
36
|
+
|
|
37
|
+
payment = finopal.payments.create({
|
|
38
|
+
"amount": 1_100,
|
|
39
|
+
"callback_url": "https://shop.example.com/payment/callback",
|
|
40
|
+
"order_id": "ORD-1001",
|
|
41
|
+
})
|
|
42
|
+
print(payment.payment_url)
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, config: Config, transport: Optional[Transport] = None) -> None:
|
|
46
|
+
if not isinstance(config, Config):
|
|
47
|
+
raise TypeError(
|
|
48
|
+
"config must be a finopal.Config instance, got {}.".format(
|
|
49
|
+
type(config).__name__
|
|
50
|
+
)
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
self.config = config
|
|
54
|
+
self.transport: Transport = transport or RequestsTransport(
|
|
55
|
+
timeout=config.timeout,
|
|
56
|
+
verify=config.verify_ssl,
|
|
57
|
+
max_retries=config.max_retries,
|
|
58
|
+
)
|
|
59
|
+
self.http = HttpClient(config, self.transport)
|
|
60
|
+
|
|
61
|
+
self._payments: Optional[Payments] = None
|
|
62
|
+
self._verifications: Optional[Verifications] = None
|
|
63
|
+
self._inquiries: Optional[Inquiries] = None
|
|
64
|
+
self._reversals: Optional[Reversals] = None
|
|
65
|
+
self._fees: Optional[Fees] = None
|
|
66
|
+
|
|
67
|
+
# --------------------------------------------------------------- resources
|
|
68
|
+
@property
|
|
69
|
+
def payments(self) -> Payments:
|
|
70
|
+
"""Create payment requests. ``POST /api/v1/payment/request``."""
|
|
71
|
+
if self._payments is None:
|
|
72
|
+
self._payments = Payments(self.http)
|
|
73
|
+
return self._payments
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def verifications(self) -> Verifications:
|
|
77
|
+
"""Verify payments. ``POST /api/v1/payment/verify``."""
|
|
78
|
+
if self._verifications is None:
|
|
79
|
+
self._verifications = Verifications(self.http)
|
|
80
|
+
return self._verifications
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def inquiries(self) -> Inquiries:
|
|
84
|
+
"""Read-only status lookups. ``POST /api/v1/payment/inquiry``."""
|
|
85
|
+
if self._inquiries is None:
|
|
86
|
+
self._inquiries = Inquiries(self.http)
|
|
87
|
+
return self._inquiries
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def reversals(self) -> Reversals:
|
|
91
|
+
"""Reverse verified transactions. ``POST /api/v1/payment/reverse``."""
|
|
92
|
+
if self._reversals is None:
|
|
93
|
+
self._reversals = Reversals(self.http)
|
|
94
|
+
return self._reversals
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def fees(self) -> Fees:
|
|
98
|
+
"""Preview gateway fees. ``POST /api/v1/payment/feeCalculation``."""
|
|
99
|
+
if self._fees is None:
|
|
100
|
+
self._fees = Fees(self.http)
|
|
101
|
+
return self._fees
|
|
102
|
+
|
|
103
|
+
# -------------------------------------------------------------- primitives
|
|
104
|
+
@property
|
|
105
|
+
def base_url(self) -> str:
|
|
106
|
+
"""Effective gateway host for this client."""
|
|
107
|
+
return self.config.base_url
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def is_sandbox(self) -> bool:
|
|
111
|
+
"""Whether this client points at the sandbox host."""
|
|
112
|
+
return bool(self.config.sandbox)
|
|
113
|
+
|
|
114
|
+
def request(
|
|
115
|
+
self,
|
|
116
|
+
method: str,
|
|
117
|
+
path: str,
|
|
118
|
+
payload: Optional[Mapping[str, Any]] = None,
|
|
119
|
+
) -> Dict[str, Any]:
|
|
120
|
+
"""Escape hatch for endpoints the SDK does not wrap yet.
|
|
121
|
+
|
|
122
|
+
``merchant_id`` is injected automatically. Only ``POST`` is supported
|
|
123
|
+
today because that is all the FinoPal gateway exposes::
|
|
124
|
+
|
|
125
|
+
finopal.request("POST", "/api/v1/payment/inquiry",
|
|
126
|
+
{"authority": "FP_..."})
|
|
127
|
+
"""
|
|
128
|
+
if str(method).upper() != "POST":
|
|
129
|
+
raise ValueError(
|
|
130
|
+
"The FinoPal gateway only exposes POST endpoints; got {!r}.".format(
|
|
131
|
+
method
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
return self.http.post(path, payload)
|
|
135
|
+
|
|
136
|
+
def __repr__(self) -> str: # pragma: no cover - trivial
|
|
137
|
+
mode = "sandbox" if self.is_sandbox else "live"
|
|
138
|
+
return "FinoPal(merchant_id={!r}, mode={!r})".format(
|
|
139
|
+
self.config.merchant_id, mode
|
|
140
|
+
)
|
finopal/http/__init__.py
ADDED
finopal/http/client.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Thin HTTP client shared by every resource.
|
|
2
|
+
|
|
3
|
+
Responsibilities:
|
|
4
|
+
|
|
5
|
+
* build the absolute URL from :class:`~finopal.utils.config.Config`;
|
|
6
|
+
* inject ``merchant_id`` (callers may override it per call);
|
|
7
|
+
* send the standard FinoPal headers;
|
|
8
|
+
* guarantee a ``dict`` comes back, or raise :class:`TransportError`.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any, Dict, Mapping, Optional
|
|
14
|
+
|
|
15
|
+
from finopal.errors.exceptions import TransportError
|
|
16
|
+
from finopal.http.transport import Transport
|
|
17
|
+
from finopal.utils.config import Config
|
|
18
|
+
|
|
19
|
+
__all__ = ["HttpClient"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class HttpClient:
|
|
23
|
+
"""Wraps a :class:`Transport` with FinoPal-specific request shaping."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, config: Config, transport: Transport) -> None:
|
|
26
|
+
self.config = config
|
|
27
|
+
self.transport = transport
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def base_url(self) -> str:
|
|
31
|
+
return self.config.base_url
|
|
32
|
+
|
|
33
|
+
def headers(self, extra: Optional[Mapping[str, str]] = None) -> Dict[str, str]:
|
|
34
|
+
headers = {
|
|
35
|
+
"Content-Type": "application/json",
|
|
36
|
+
"Accept": "application/json",
|
|
37
|
+
"User-Agent": self.config.user_agent,
|
|
38
|
+
}
|
|
39
|
+
headers.update(dict(extra or {}))
|
|
40
|
+
return headers
|
|
41
|
+
|
|
42
|
+
def post(
|
|
43
|
+
self,
|
|
44
|
+
path: str,
|
|
45
|
+
payload: Optional[Mapping[str, Any]] = None,
|
|
46
|
+
*,
|
|
47
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
48
|
+
) -> Dict[str, Any]:
|
|
49
|
+
"""POST ``payload`` to ``path`` with ``merchant_id`` injected.
|
|
50
|
+
|
|
51
|
+
A ``merchant_id`` present in ``payload`` wins, so a single client can
|
|
52
|
+
serve several terminals when needed.
|
|
53
|
+
"""
|
|
54
|
+
url = self.config.endpoint(path)
|
|
55
|
+
body: Dict[str, Any] = {"merchant_id": self.config.merchant_id}
|
|
56
|
+
body.update(dict(payload or {}))
|
|
57
|
+
|
|
58
|
+
response = self.transport.post_json(url, body, self.headers(headers))
|
|
59
|
+
|
|
60
|
+
if not isinstance(response, Mapping):
|
|
61
|
+
raise TransportError(
|
|
62
|
+
"Malformed response from {}: expected a JSON object.".format(url)
|
|
63
|
+
)
|
|
64
|
+
return dict(response)
|
|
65
|
+
|
|
66
|
+
def __repr__(self) -> str: # pragma: no cover - trivial
|
|
67
|
+
return "HttpClient(base_url={!r}, transport={})".format(
|
|
68
|
+
self.base_url, type(self.transport).__name__
|
|
69
|
+
)
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Transport layer.
|
|
2
|
+
|
|
3
|
+
The SDK talks to the gateway through a tiny abstraction — :class:`Transport` —
|
|
4
|
+
so the HTTP implementation can be swapped without touching the resource
|
|
5
|
+
classes. This mirrors ``FinoPal\\Sdk\\Http\\TransportInterface`` in the PHP SDK
|
|
6
|
+
and makes the whole package testable without mocking ``requests``.
|
|
7
|
+
|
|
8
|
+
Built-in implementations:
|
|
9
|
+
|
|
10
|
+
* :class:`RequestsTransport` — default, backed by ``requests.Session``.
|
|
11
|
+
* :class:`CallableTransport` — wraps any ``fn(url, body, headers) -> dict``,
|
|
12
|
+
which is what the test-suite and most integrations use for stubs.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import time
|
|
18
|
+
from typing import Any, Callable, Dict, Mapping, Optional
|
|
19
|
+
|
|
20
|
+
import requests
|
|
21
|
+
|
|
22
|
+
from finopal.errors.exceptions import TransportError
|
|
23
|
+
|
|
24
|
+
__all__ = ["Transport", "RequestsTransport", "CallableTransport"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Transport:
|
|
28
|
+
"""Interface every transport must implement."""
|
|
29
|
+
|
|
30
|
+
def post_json(
|
|
31
|
+
self,
|
|
32
|
+
url: str,
|
|
33
|
+
body: Mapping[str, Any],
|
|
34
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
35
|
+
) -> Dict[str, Any]:
|
|
36
|
+
"""POST ``body`` as JSON to ``url`` and return the decoded response.
|
|
37
|
+
|
|
38
|
+
:raises TransportError: on connection failure, timeout, non-JSON body
|
|
39
|
+
or a 5xx response.
|
|
40
|
+
"""
|
|
41
|
+
raise NotImplementedError
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class RequestsTransport(Transport):
|
|
45
|
+
"""Default transport built on ``requests.Session``.
|
|
46
|
+
|
|
47
|
+
A single session is reused so TCP connections and TLS handshakes are
|
|
48
|
+
pooled across calls.
|
|
49
|
+
|
|
50
|
+
:param timeout: Per-request timeout in seconds.
|
|
51
|
+
:param verify: TLS verification. Keep ``True`` in production.
|
|
52
|
+
:param max_retries: Extra attempts for connection errors only.
|
|
53
|
+
:param session: Inject a pre-configured session (proxies, adapters...).
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
timeout: float = 30.0,
|
|
59
|
+
verify: bool = True,
|
|
60
|
+
max_retries: int = 0,
|
|
61
|
+
session: Optional[requests.Session] = None,
|
|
62
|
+
) -> None:
|
|
63
|
+
self.timeout = float(timeout)
|
|
64
|
+
self.verify = bool(verify)
|
|
65
|
+
self.max_retries = max(0, int(max_retries))
|
|
66
|
+
self.session = session or requests.Session()
|
|
67
|
+
|
|
68
|
+
def post_json(
|
|
69
|
+
self,
|
|
70
|
+
url: str,
|
|
71
|
+
body: Mapping[str, Any],
|
|
72
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
73
|
+
) -> Dict[str, Any]:
|
|
74
|
+
payload = dict(body)
|
|
75
|
+
request_headers = {
|
|
76
|
+
"Content-Type": "application/json",
|
|
77
|
+
"Accept": "application/json",
|
|
78
|
+
}
|
|
79
|
+
request_headers.update(dict(headers or {}))
|
|
80
|
+
|
|
81
|
+
last_error: Optional[Exception] = None
|
|
82
|
+
|
|
83
|
+
for attempt in range(self.max_retries + 1):
|
|
84
|
+
try:
|
|
85
|
+
response = self.session.post(
|
|
86
|
+
url,
|
|
87
|
+
json=payload,
|
|
88
|
+
headers=request_headers,
|
|
89
|
+
timeout=self.timeout,
|
|
90
|
+
verify=self.verify,
|
|
91
|
+
)
|
|
92
|
+
except requests.RequestException as exc:
|
|
93
|
+
last_error = exc
|
|
94
|
+
# Only connection-level problems are worth retrying; the SDK
|
|
95
|
+
# never retries a request the gateway may have already handled.
|
|
96
|
+
if attempt < self.max_retries:
|
|
97
|
+
time.sleep(0.5 * (attempt + 1))
|
|
98
|
+
continue
|
|
99
|
+
raise TransportError(
|
|
100
|
+
"Could not reach the FinoPal gateway at {}: {}".format(url, exc)
|
|
101
|
+
) from exc
|
|
102
|
+
|
|
103
|
+
return self._decode(response, url)
|
|
104
|
+
|
|
105
|
+
raise TransportError( # pragma: no cover - loop always raises or returns
|
|
106
|
+
"Could not reach the FinoPal gateway at {}: {}".format(url, last_error)
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
@staticmethod
|
|
110
|
+
def _decode(response: requests.Response, url: str) -> Dict[str, Any]:
|
|
111
|
+
try:
|
|
112
|
+
data = response.json()
|
|
113
|
+
except ValueError as exc:
|
|
114
|
+
snippet = (response.text or "")[:300]
|
|
115
|
+
raise TransportError(
|
|
116
|
+
"The FinoPal gateway returned a non-JSON response "
|
|
117
|
+
"(HTTP {}) from {}: {!r}".format(response.status_code, url, snippet),
|
|
118
|
+
status_code=response.status_code,
|
|
119
|
+
) from exc
|
|
120
|
+
|
|
121
|
+
if not isinstance(data, dict):
|
|
122
|
+
raise TransportError(
|
|
123
|
+
"Unexpected response shape from {} (expected a JSON object, "
|
|
124
|
+
"got {}).".format(url, type(data).__name__),
|
|
125
|
+
status_code=response.status_code,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# 5xx means the gateway itself failed — surface it as a transport error.
|
|
129
|
+
# 4xx bodies are usually structured FinoPal errors, so let the resource
|
|
130
|
+
# layer interpret them.
|
|
131
|
+
if response.status_code >= 500:
|
|
132
|
+
raise TransportError(
|
|
133
|
+
"The FinoPal gateway returned HTTP {} from {}.".format(
|
|
134
|
+
response.status_code, url
|
|
135
|
+
),
|
|
136
|
+
status_code=response.status_code,
|
|
137
|
+
response=data,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
return data
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class CallableTransport(Transport):
|
|
144
|
+
"""Adapt a plain function into a transport.
|
|
145
|
+
|
|
146
|
+
Handy in tests and in frameworks that already own an HTTP client::
|
|
147
|
+
|
|
148
|
+
def fake(url, body, headers):
|
|
149
|
+
return {"success": True, "code": 100,
|
|
150
|
+
"data": {"authority": "FP_1", "payment_url": "https://x"}}
|
|
151
|
+
|
|
152
|
+
finopal = FinoPal(config, transport=CallableTransport(fake))
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
def __init__(
|
|
156
|
+
self,
|
|
157
|
+
handler: Callable[[str, Mapping[str, Any], Mapping[str, str]], Mapping[str, Any]],
|
|
158
|
+
) -> None:
|
|
159
|
+
if not callable(handler):
|
|
160
|
+
raise TypeError("handler must be callable")
|
|
161
|
+
self._handler = handler
|
|
162
|
+
|
|
163
|
+
def post_json(
|
|
164
|
+
self,
|
|
165
|
+
url: str,
|
|
166
|
+
body: Mapping[str, Any],
|
|
167
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
168
|
+
) -> Dict[str, Any]:
|
|
169
|
+
result = self._handler(url, dict(body), dict(headers or {}))
|
|
170
|
+
if not isinstance(result, Mapping):
|
|
171
|
+
raise TransportError(
|
|
172
|
+
"CallableTransport handler must return a mapping, got {}.".format(
|
|
173
|
+
type(result).__name__
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
return dict(result)
|