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 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)}