beexar 1.0.1__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.
- beexar/__init__.py +86 -0
- beexar/asgi.py +113 -0
- beexar/errors.py +200 -0
- beexar/launcher.py +119 -0
- beexar/money.py +194 -0
- beexar/py.typed +0 -0
- beexar/signature.py +36 -0
- beexar/types.py +179 -0
- beexar/wallet.py +317 -0
- beexar-1.0.1.dist-info/METADATA +195 -0
- beexar-1.0.1.dist-info/RECORD +13 -0
- beexar-1.0.1.dist-info/WHEEL +4 -0
- beexar-1.0.1.dist-info/licenses/LICENSE +21 -0
beexar/__init__.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Beexar operator SDK.
|
|
2
|
+
|
|
3
|
+
Two halves:
|
|
4
|
+
|
|
5
|
+
* :class:`~beexar.launcher.Client` — the calls you send to Beexar.
|
|
6
|
+
* :class:`~beexar.wallet.WalletServer` — the four callbacks Beexar sends you.
|
|
7
|
+
|
|
8
|
+
See https://docs.beexar.com for the full contract.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from .errors import ApiCode, ApiError, TwirpCode, WalletError, is_funds_related_code
|
|
14
|
+
from .launcher import PRODUCTION_BASE_URL, Client
|
|
15
|
+
from .money import (
|
|
16
|
+
MAX_CLIENT_DECIMAL_LEN,
|
|
17
|
+
MAX_SCALE,
|
|
18
|
+
CURRENCY_PATTERN,
|
|
19
|
+
Money,
|
|
20
|
+
MoneyError,
|
|
21
|
+
is_valid_currency,
|
|
22
|
+
)
|
|
23
|
+
from .signature import SIGNATURE_HEADER, sign, verify
|
|
24
|
+
from .types import (
|
|
25
|
+
ROUTES,
|
|
26
|
+
BalanceRequest,
|
|
27
|
+
BalanceResult,
|
|
28
|
+
BetWinRequest,
|
|
29
|
+
BetWinResult,
|
|
30
|
+
BetWinTransaction,
|
|
31
|
+
BetWinTransactionResult,
|
|
32
|
+
FinishRequest,
|
|
33
|
+
FinishResult,
|
|
34
|
+
RequestContext,
|
|
35
|
+
RollbackRequest,
|
|
36
|
+
RollbackResult,
|
|
37
|
+
RollbackTransaction,
|
|
38
|
+
RollbackTransactionResult,
|
|
39
|
+
Route,
|
|
40
|
+
WalletHandler,
|
|
41
|
+
)
|
|
42
|
+
from .wallet import MAX_BODY_BYTES, DispatchResponse, WalletServer, route_from_path
|
|
43
|
+
|
|
44
|
+
#: Stamped at release time from sdk/VERSION. The same number is published for
|
|
45
|
+
#: the Node, PHP, Go and Python SDKs and always means the same contract snapshot.
|
|
46
|
+
__version__ = "1.0.1"
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"__version__",
|
|
50
|
+
"ApiCode",
|
|
51
|
+
"ApiError",
|
|
52
|
+
"TwirpCode",
|
|
53
|
+
"WalletError",
|
|
54
|
+
"is_funds_related_code",
|
|
55
|
+
"Client",
|
|
56
|
+
"PRODUCTION_BASE_URL",
|
|
57
|
+
"Money",
|
|
58
|
+
"MoneyError",
|
|
59
|
+
"MAX_SCALE",
|
|
60
|
+
"MAX_CLIENT_DECIMAL_LEN",
|
|
61
|
+
"CURRENCY_PATTERN",
|
|
62
|
+
"is_valid_currency",
|
|
63
|
+
"sign",
|
|
64
|
+
"verify",
|
|
65
|
+
"SIGNATURE_HEADER",
|
|
66
|
+
"ROUTES",
|
|
67
|
+
"Route",
|
|
68
|
+
"RequestContext",
|
|
69
|
+
"BalanceRequest",
|
|
70
|
+
"BalanceResult",
|
|
71
|
+
"BetWinTransaction",
|
|
72
|
+
"BetWinRequest",
|
|
73
|
+
"BetWinTransactionResult",
|
|
74
|
+
"BetWinResult",
|
|
75
|
+
"RollbackTransaction",
|
|
76
|
+
"RollbackRequest",
|
|
77
|
+
"RollbackTransactionResult",
|
|
78
|
+
"RollbackResult",
|
|
79
|
+
"FinishRequest",
|
|
80
|
+
"FinishResult",
|
|
81
|
+
"WalletHandler",
|
|
82
|
+
"WalletServer",
|
|
83
|
+
"DispatchResponse",
|
|
84
|
+
"MAX_BODY_BYTES",
|
|
85
|
+
"route_from_path",
|
|
86
|
+
]
|
beexar/asgi.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Framework bindings: ASGI, FastAPI and Flask.
|
|
2
|
+
|
|
3
|
+
Each one exists to solve the same problem in that framework's own way — getting
|
|
4
|
+
the SDK the **raw bytes** of the request. The signature is an HMAC over those
|
|
5
|
+
bytes; a framework that parses the JSON and hands you a dict has already
|
|
6
|
+
destroyed them, and no care afterwards can bring them back.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Callable, Dict, Optional
|
|
12
|
+
|
|
13
|
+
from .signature import SIGNATURE_HEADER
|
|
14
|
+
from .wallet import WalletServer, route_from_path
|
|
15
|
+
|
|
16
|
+
__all__ = ["asgi_app", "fastapi_router", "flask_blueprint"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def asgi_app(server: WalletServer) -> Callable[..., Any]:
|
|
20
|
+
"""A bare ASGI application serving the four callbacks.
|
|
21
|
+
|
|
22
|
+
Mount it under any prefix; the routes are matched on the path suffix.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
async def app(scope: Dict[str, Any], receive: Callable[..., Any], send: Callable[..., Any]) -> None:
|
|
26
|
+
if scope.get("type") != "http":
|
|
27
|
+
raise RuntimeError("beexar: the wallet app only handles HTTP scopes")
|
|
28
|
+
|
|
29
|
+
route = route_from_path(scope.get("path", "/"))
|
|
30
|
+
if route is None or scope.get("method") != "POST":
|
|
31
|
+
await _send_json(send, 404, b'{"code":"invalid_argument","msg":"not found",'
|
|
32
|
+
b'"meta":{"api_code":"404","api_message":"not found"}}')
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
# Read the body as bytes. Never decode it here: the bytes are what the
|
|
36
|
+
# signature covers.
|
|
37
|
+
body = b""
|
|
38
|
+
more = True
|
|
39
|
+
while more:
|
|
40
|
+
message = await receive()
|
|
41
|
+
body += message.get("body", b"")
|
|
42
|
+
more = message.get("more_body", False)
|
|
43
|
+
|
|
44
|
+
headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope.get("headers", [])}
|
|
45
|
+
out = server.dispatch(route, body, headers.get(SIGNATURE_HEADER.lower()), headers)
|
|
46
|
+
await _send_json(send, out.status, out.body)
|
|
47
|
+
|
|
48
|
+
return app
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def _send_json(send: Callable[..., Any], status: int, body: bytes) -> None:
|
|
52
|
+
await send(
|
|
53
|
+
{
|
|
54
|
+
"type": "http.response.start",
|
|
55
|
+
"status": status,
|
|
56
|
+
"headers": [(b"content-type", b"application/json")],
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
await send({"type": "http.response.body", "body": body})
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def fastapi_router(server: WalletServer, prefix: str = "") -> Any:
|
|
63
|
+
"""A FastAPI ``APIRouter`` with the four callbacks.
|
|
64
|
+
|
|
65
|
+
The handlers declare ``Request`` and call ``await request.body()`` rather
|
|
66
|
+
than a typed model. Declaring a model would make Starlette parse the JSON,
|
|
67
|
+
and the bytes the signature covers would be gone before the SDK ever saw
|
|
68
|
+
them. Starlette caches the body, so reading it here is safe.
|
|
69
|
+
"""
|
|
70
|
+
from fastapi import APIRouter, Request, Response # imported lazily: FastAPI is optional
|
|
71
|
+
|
|
72
|
+
router = APIRouter(prefix=prefix)
|
|
73
|
+
|
|
74
|
+
def make(route: str) -> Callable[..., Any]:
|
|
75
|
+
async def endpoint(request: Request) -> Response:
|
|
76
|
+
body = await request.body()
|
|
77
|
+
headers = {k.lower(): v for k, v in request.headers.items()}
|
|
78
|
+
out = server.dispatch(route, body, headers.get(SIGNATURE_HEADER.lower()), headers)
|
|
79
|
+
return Response(content=out.body, status_code=out.status, media_type="application/json")
|
|
80
|
+
|
|
81
|
+
return endpoint
|
|
82
|
+
|
|
83
|
+
for route in ("/balance", "/betwin", "/rollback", "/finish"):
|
|
84
|
+
router.add_api_route(route, make(route), methods=["POST"])
|
|
85
|
+
|
|
86
|
+
return router
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def flask_blueprint(server: WalletServer, name: str = "beexar_wallet", url_prefix: Optional[str] = None) -> Any:
|
|
90
|
+
"""A Flask ``Blueprint`` with the four callbacks.
|
|
91
|
+
|
|
92
|
+
Uses ``request.get_data(cache=True)``, which yields ``bytes``.
|
|
93
|
+
``get_data(as_text=True)`` re-decodes the body and is the usual way a Flask
|
|
94
|
+
integration loses its signature.
|
|
95
|
+
"""
|
|
96
|
+
from flask import Blueprint, Response, request # imported lazily: Flask is optional
|
|
97
|
+
|
|
98
|
+
blueprint = Blueprint(name, __name__, url_prefix=url_prefix)
|
|
99
|
+
|
|
100
|
+
def make(route: str) -> Callable[..., Any]:
|
|
101
|
+
def endpoint() -> Any:
|
|
102
|
+
body = request.get_data(cache=True)
|
|
103
|
+
headers = {k.lower(): v for k, v in request.headers.items()}
|
|
104
|
+
out = server.dispatch(route, body, headers.get(SIGNATURE_HEADER.lower()), headers)
|
|
105
|
+
return Response(out.body, status=out.status, mimetype="application/json")
|
|
106
|
+
|
|
107
|
+
endpoint.__name__ = "beexar" + route.replace("/", "_")
|
|
108
|
+
return endpoint
|
|
109
|
+
|
|
110
|
+
for route in ("/balance", "/betwin", "/rollback", "/finish"):
|
|
111
|
+
blueprint.add_url_rule(route, view_func=make(route), methods=["POST"])
|
|
112
|
+
|
|
113
|
+
return blueprint
|
beexar/errors.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""The api_code registry and the two error types this contract uses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
from .money import Money
|
|
8
|
+
|
|
9
|
+
__all__ = ["ApiCode", "TwirpCode", "is_funds_related_code", "WalletError", "ApiError"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ApiCode:
|
|
13
|
+
"""SoftSwiss API codes, ported verbatim from the platform's own registry.
|
|
14
|
+
|
|
15
|
+
Two things about this contract surprise everyone once:
|
|
16
|
+
|
|
17
|
+
1. There are only two HTTP statuses (400 and 500) and two ``code`` values
|
|
18
|
+
(``invalid_argument`` and ``internal``). All meaning lives in
|
|
19
|
+
``meta.api_code``. Branch on that, never on the status.
|
|
20
|
+
2. A signature failure is HTTP 400 with api_code ``403``, not HTTP 403.
|
|
21
|
+
Answering 403 breaks the contract.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
INSUFFICIENT_FUNDS = "100"
|
|
25
|
+
INVALID_PLAYER = "101"
|
|
26
|
+
BET_LIMIT_REACHED = "105"
|
|
27
|
+
MAX_BET_EXCEEDED = "106"
|
|
28
|
+
GAME_FORBIDDEN = "107"
|
|
29
|
+
PLAYER_DISABLED = "110"
|
|
30
|
+
COUNTRY_RESTRICTED = "153"
|
|
31
|
+
CURRENCY_NOT_ALLOWED = "154"
|
|
32
|
+
FIELD_IMMUTABLE = "155"
|
|
33
|
+
|
|
34
|
+
BAD_REQUEST = "400"
|
|
35
|
+
FORBIDDEN = "403"
|
|
36
|
+
NOT_FOUND = "404"
|
|
37
|
+
#: Beexar extension. Return it from /betwin to reject a transaction whose
|
|
38
|
+
#: id_provider was already rolled back — including a rollback that arrived
|
|
39
|
+
#: BEFORE the bet it reverses.
|
|
40
|
+
ALREADY_ROLLED_BACK = "409"
|
|
41
|
+
|
|
42
|
+
GAME_NOT_AVAILABLE = "405"
|
|
43
|
+
CASINO_DISABLED = "410"
|
|
44
|
+
|
|
45
|
+
UNKNOWN_ERROR = "500"
|
|
46
|
+
SERVICE_UNAVAILABLE = "503"
|
|
47
|
+
REQUEST_TIMEOUT = "504"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TwirpCode:
|
|
51
|
+
"""The only two ``code`` values this contract uses."""
|
|
52
|
+
|
|
53
|
+
INVALID_ARGUMENT = "invalid_argument"
|
|
54
|
+
INTERNAL = "internal"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def is_funds_related_code(code: str) -> bool:
|
|
58
|
+
"""Whether an api_code must carry the player's balance in ``meta.balance``.
|
|
59
|
+
|
|
60
|
+
The platform will not complain if you omit it — it reads the field with the
|
|
61
|
+
error swallowed — so the player simply sees a wrong balance. That is exactly
|
|
62
|
+
why this SDK refuses to build such an error without one.
|
|
63
|
+
"""
|
|
64
|
+
return code in (ApiCode.INSUFFICIENT_FUNDS, ApiCode.BET_LIMIT_REACHED, ApiCode.MAX_BET_EXCEEDED)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class WalletError(Exception):
|
|
68
|
+
"""Raise this from your handler to answer with a specific api_code.
|
|
69
|
+
|
|
70
|
+
Anything else you raise becomes an opaque 500 — your message is never echoed
|
|
71
|
+
to the platform.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
status: int,
|
|
77
|
+
twirp_code: str,
|
|
78
|
+
api_code: str,
|
|
79
|
+
message: str,
|
|
80
|
+
balance: Optional[Money] = None,
|
|
81
|
+
) -> None:
|
|
82
|
+
super().__init__(message)
|
|
83
|
+
if is_funds_related_code(api_code) and balance is None:
|
|
84
|
+
raise TypeError(
|
|
85
|
+
"api_code {} must carry the player's balance; use insufficient_funds(), "
|
|
86
|
+
"bet_limit_reached() or max_bet_exceeded()".format(api_code)
|
|
87
|
+
)
|
|
88
|
+
self.status = status
|
|
89
|
+
self.twirp_code = twirp_code
|
|
90
|
+
self.api_code = api_code
|
|
91
|
+
self.message = message
|
|
92
|
+
self.balance = balance
|
|
93
|
+
|
|
94
|
+
# --- funds-related: the balance is a required argument, by design ------
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def insufficient_funds(cls, balance: Money, message: str = "insufficient funds") -> "WalletError":
|
|
98
|
+
return cls(400, TwirpCode.INVALID_ARGUMENT, ApiCode.INSUFFICIENT_FUNDS, message, balance)
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def bet_limit_reached(cls, balance: Money, message: str = "bet limit reached") -> "WalletError":
|
|
102
|
+
return cls(400, TwirpCode.INVALID_ARGUMENT, ApiCode.BET_LIMIT_REACHED, message, balance)
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def max_bet_exceeded(cls, balance: Money, message: str = "max bet exceeded") -> "WalletError":
|
|
106
|
+
return cls(400, TwirpCode.INVALID_ARGUMENT, ApiCode.MAX_BET_EXCEEDED, message, balance)
|
|
107
|
+
|
|
108
|
+
# --- player / request --------------------------------------------------
|
|
109
|
+
|
|
110
|
+
@classmethod
|
|
111
|
+
def invalid_player(cls, message: str = "invalid player") -> "WalletError":
|
|
112
|
+
return cls(400, TwirpCode.INVALID_ARGUMENT, ApiCode.INVALID_PLAYER, message)
|
|
113
|
+
|
|
114
|
+
@classmethod
|
|
115
|
+
def player_disabled(cls, message: str = "player is disabled") -> "WalletError":
|
|
116
|
+
return cls(400, TwirpCode.INVALID_ARGUMENT, ApiCode.PLAYER_DISABLED, message)
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
def game_forbidden(cls, message: str = "game is forbidden to the player") -> "WalletError":
|
|
120
|
+
return cls(400, TwirpCode.INVALID_ARGUMENT, ApiCode.GAME_FORBIDDEN, message)
|
|
121
|
+
|
|
122
|
+
@classmethod
|
|
123
|
+
def currency_not_allowed(cls, message: str = "currency is not allowed for the player") -> "WalletError":
|
|
124
|
+
return cls(400, TwirpCode.INVALID_ARGUMENT, ApiCode.CURRENCY_NOT_ALLOWED, message)
|
|
125
|
+
|
|
126
|
+
@classmethod
|
|
127
|
+
def bad_request(cls, message: str = "bad request") -> "WalletError":
|
|
128
|
+
return cls(400, TwirpCode.INVALID_ARGUMENT, ApiCode.BAD_REQUEST, message)
|
|
129
|
+
|
|
130
|
+
@classmethod
|
|
131
|
+
def invalid_signature(cls, message: str = "invalid signature") -> "WalletError":
|
|
132
|
+
"""HTTP 400 with api_code 403 — the wallet contract never answers HTTP 403."""
|
|
133
|
+
return cls(400, TwirpCode.INVALID_ARGUMENT, ApiCode.FORBIDDEN, message)
|
|
134
|
+
|
|
135
|
+
@classmethod
|
|
136
|
+
def not_found(cls, message: str = "not found") -> "WalletError":
|
|
137
|
+
return cls(400, TwirpCode.INVALID_ARGUMENT, ApiCode.NOT_FOUND, message)
|
|
138
|
+
|
|
139
|
+
@classmethod
|
|
140
|
+
def already_rolled_back(cls, message: str = "action already rolled back") -> "WalletError":
|
|
141
|
+
"""The transaction's ``id_provider`` was already rolled back (tombstoned)."""
|
|
142
|
+
return cls(400, TwirpCode.INVALID_ARGUMENT, ApiCode.ALREADY_ROLLED_BACK, message)
|
|
143
|
+
|
|
144
|
+
# --- server ------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
@classmethod
|
|
147
|
+
def internal(cls, message: str = "internal error") -> "WalletError":
|
|
148
|
+
return cls(500, TwirpCode.INTERNAL, ApiCode.UNKNOWN_ERROR, message)
|
|
149
|
+
|
|
150
|
+
@classmethod
|
|
151
|
+
def service_unavailable(cls, message: str = "service unavailable") -> "WalletError":
|
|
152
|
+
return cls(500, TwirpCode.INTERNAL, ApiCode.SERVICE_UNAVAILABLE, message)
|
|
153
|
+
|
|
154
|
+
@classmethod
|
|
155
|
+
def with_api_code(
|
|
156
|
+
cls, api_code: str, message: str, balance: Optional[Money] = None
|
|
157
|
+
) -> "WalletError":
|
|
158
|
+
"""Escape hatch for an api_code without a dedicated constructor.
|
|
159
|
+
|
|
160
|
+
It runs the same balance check, so it cannot be used to bypass it.
|
|
161
|
+
"""
|
|
162
|
+
status = 500 if api_code.startswith("5") else 400
|
|
163
|
+
twirp = TwirpCode.INTERNAL if status >= 500 else TwirpCode.INVALID_ARGUMENT
|
|
164
|
+
return cls(status, twirp, api_code, message, balance)
|
|
165
|
+
|
|
166
|
+
def to_envelope(self) -> Dict[str, Any]:
|
|
167
|
+
meta: Dict[str, str] = {"api_code": self.api_code, "api_message": self.message}
|
|
168
|
+
if self.balance is not None:
|
|
169
|
+
meta["balance"] = str(self.balance)
|
|
170
|
+
return {"code": self.twirp_code, "msg": self.message, "meta": meta}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class ApiError(Exception):
|
|
174
|
+
"""An error answer from the Beexar gateway to one of YOUR calls."""
|
|
175
|
+
|
|
176
|
+
def __init__(self, http_status: int, code: str, api_code: str, message: str) -> None:
|
|
177
|
+
super().__init__(message)
|
|
178
|
+
self.http_status = http_status
|
|
179
|
+
self.code = code
|
|
180
|
+
self.api_code = api_code
|
|
181
|
+
self.api_message = message
|
|
182
|
+
|
|
183
|
+
@classmethod
|
|
184
|
+
def from_envelope(cls, status: int, envelope: Dict[str, Any]) -> "ApiError":
|
|
185
|
+
meta = envelope.get("meta") if isinstance(envelope.get("meta"), dict) else {}
|
|
186
|
+
message = meta.get("api_message") or envelope.get("msg") or "unknown error"
|
|
187
|
+
return cls(
|
|
188
|
+
status,
|
|
189
|
+
str(envelope.get("code", TwirpCode.INTERNAL)),
|
|
190
|
+
str(meta.get("api_code", status)),
|
|
191
|
+
str(message),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
def is_retryable(self) -> bool:
|
|
195
|
+
"""5xx only. A 400 on this contract is terminal."""
|
|
196
|
+
return self.http_status >= 500
|
|
197
|
+
|
|
198
|
+
def is_signature_error(self) -> bool:
|
|
199
|
+
"""Keyed on api_code, because the status alone is ambiguous."""
|
|
200
|
+
return self.api_code == ApiCode.FORBIDDEN
|
beexar/launcher.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""The calls you send to Beexar: launch a session, list the catalogue."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.parse
|
|
8
|
+
import urllib.request
|
|
9
|
+
from typing import Any, Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
from .errors import ApiError
|
|
12
|
+
from .signature import SIGNATURE_HEADER, sign
|
|
13
|
+
|
|
14
|
+
__all__ = ["Client", "PRODUCTION_BASE_URL"]
|
|
15
|
+
|
|
16
|
+
PRODUCTION_BASE_URL = "https://gateway.beexar.com"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Client:
|
|
20
|
+
"""Launch sessions and read the catalogue.
|
|
21
|
+
|
|
22
|
+
Every POST is signed with ``hex(HMAC-SHA256(body, api_secret))`` over the
|
|
23
|
+
exact bytes that go on the wire — the body is encoded once, signed, and
|
|
24
|
+
sent. Encoding twice is how signatures mysteriously stop matching.
|
|
25
|
+
|
|
26
|
+
Transport is :mod:`urllib` from the standard library, so the SDK pulls in no
|
|
27
|
+
HTTP client. Pass ``transport`` to use requests, httpx, or anything else; it
|
|
28
|
+
receives the final bytes and headers.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
casino_id: str,
|
|
34
|
+
api_secret: str,
|
|
35
|
+
base_url: str = PRODUCTION_BASE_URL,
|
|
36
|
+
timeout: float = 10.0,
|
|
37
|
+
transport: Optional[Any] = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
if not casino_id:
|
|
40
|
+
raise ValueError("Client: casino_id is required")
|
|
41
|
+
if not api_secret:
|
|
42
|
+
raise ValueError("Client: api_secret is required")
|
|
43
|
+
self._casino_id = casino_id
|
|
44
|
+
self._api_secret = api_secret
|
|
45
|
+
self._base_url = base_url.rstrip("/")
|
|
46
|
+
self._timeout = timeout
|
|
47
|
+
# transport(method, url, headers, body) -> (status, response_bytes)
|
|
48
|
+
self._transport = transport
|
|
49
|
+
|
|
50
|
+
def launch_real(self, request: Dict[str, Any]) -> str:
|
|
51
|
+
"""Launch a real-money session and return the URL to embed."""
|
|
52
|
+
return self._post_signed("/api/v1/softswiss/launcher/real", request)
|
|
53
|
+
|
|
54
|
+
def launch_demo(self, request: Dict[str, Any]) -> str:
|
|
55
|
+
"""Launch a demo session on a virtual balance. No wallet callbacks."""
|
|
56
|
+
return self._post_signed("/api/v1/softswiss/launcher/demo", request)
|
|
57
|
+
|
|
58
|
+
def list_games(
|
|
59
|
+
self, operator: Optional[str] = None, active: Optional[bool] = None
|
|
60
|
+
) -> List[Dict[str, Any]]:
|
|
61
|
+
"""The games enabled for your operator. Public — no signature."""
|
|
62
|
+
query = {"operator": operator or self._casino_id}
|
|
63
|
+
if active is not None:
|
|
64
|
+
query["active"] = "true" if active else "false"
|
|
65
|
+
url = "{}/api/v1/operator/games?{}".format(self._base_url, urllib.parse.urlencode(query))
|
|
66
|
+
|
|
67
|
+
status, body = self._send("GET", url, {"Accept": "application/json"}, None)
|
|
68
|
+
decoded = _decode(body)
|
|
69
|
+
if status != 200:
|
|
70
|
+
raise ApiError.from_envelope(status, decoded)
|
|
71
|
+
games = decoded.get("games") or []
|
|
72
|
+
return list(games)
|
|
73
|
+
|
|
74
|
+
def _post_signed(self, path: str, payload: Dict[str, Any]) -> str:
|
|
75
|
+
body_dict = dict(payload)
|
|
76
|
+
body_dict["casino_id"] = self._casino_id
|
|
77
|
+
|
|
78
|
+
# Encode ONCE. The signature and the request body are the same bytes, so
|
|
79
|
+
# they cannot disagree.
|
|
80
|
+
body = json.dumps(body_dict, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
81
|
+
|
|
82
|
+
status, response = self._send(
|
|
83
|
+
"POST",
|
|
84
|
+
self._base_url + path,
|
|
85
|
+
{
|
|
86
|
+
"Content-Type": "application/json",
|
|
87
|
+
"Accept": "application/json",
|
|
88
|
+
SIGNATURE_HEADER: sign(body, self._api_secret),
|
|
89
|
+
},
|
|
90
|
+
body,
|
|
91
|
+
)
|
|
92
|
+
decoded = _decode(response)
|
|
93
|
+
if status != 200:
|
|
94
|
+
raise ApiError.from_envelope(status, decoded)
|
|
95
|
+
return str(decoded.get("launch_url", ""))
|
|
96
|
+
|
|
97
|
+
def _send(
|
|
98
|
+
self, method: str, url: str, headers: Dict[str, str], body: Optional[bytes]
|
|
99
|
+
) -> "tuple[int, bytes]":
|
|
100
|
+
if self._transport is not None:
|
|
101
|
+
return self._transport(method, url, headers, body)
|
|
102
|
+
|
|
103
|
+
request = urllib.request.Request(url, data=body, headers=headers, method=method)
|
|
104
|
+
try:
|
|
105
|
+
with urllib.request.urlopen(request, timeout=self._timeout) as response:
|
|
106
|
+
return response.status, response.read()
|
|
107
|
+
except urllib.error.HTTPError as error:
|
|
108
|
+
# A 400 here is an answer, not a transport failure — read it.
|
|
109
|
+
return error.code, error.read()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _decode(body: bytes) -> Dict[str, Any]:
|
|
113
|
+
if not body:
|
|
114
|
+
return {}
|
|
115
|
+
try:
|
|
116
|
+
decoded = json.loads(body.decode("utf-8"))
|
|
117
|
+
except (ValueError, UnicodeDecodeError):
|
|
118
|
+
return {"msg": body.decode("utf-8", "replace")}
|
|
119
|
+
return decoded if isinstance(decoded, dict) else {"msg": str(decoded)}
|