beexar 1.0.1__tar.gz
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-1.0.1/.gitignore +4 -0
- beexar-1.0.1/CHANGELOG.md +26 -0
- beexar-1.0.1/LICENSE +21 -0
- beexar-1.0.1/PKG-INFO +195 -0
- beexar-1.0.1/README.md +170 -0
- beexar-1.0.1/beexar/__init__.py +86 -0
- beexar-1.0.1/beexar/asgi.py +113 -0
- beexar-1.0.1/beexar/errors.py +200 -0
- beexar-1.0.1/beexar/launcher.py +119 -0
- beexar-1.0.1/beexar/money.py +194 -0
- beexar-1.0.1/beexar/py.typed +0 -0
- beexar-1.0.1/beexar/signature.py +36 -0
- beexar-1.0.1/beexar/types.py +179 -0
- beexar-1.0.1/beexar/wallet.py +317 -0
- beexar-1.0.1/conformance/README.md +55 -0
- beexar-1.0.1/examples/inmemory_wallet.py +249 -0
- beexar-1.0.1/examples/server.py +63 -0
- beexar-1.0.1/openapi/common/schemas.yaml +79 -0
- beexar-1.0.1/openapi/gateway.yaml +268 -0
- beexar-1.0.1/openapi/providers/softswiss/wallet.yaml +560 -0
- beexar-1.0.1/openapi/softswiss/gateway.yaml +403 -0
- beexar-1.0.1/pyproject.toml +45 -0
- beexar-1.0.1/tests/conftest.py +34 -0
- beexar-1.0.1/tests/test_conformance.py +85 -0
- beexar-1.0.1/tests/test_contract.py +94 -0
- beexar-1.0.1/tests/test_money.py +104 -0
- beexar-1.0.1/tests/test_wallet.py +166 -0
beexar-1.0.1/.gitignore
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `beexar`. The version is shared across the Beexar SDKs
|
|
4
|
+
for Node, PHP, Go and Python — the same number always means the same contract
|
|
5
|
+
snapshot.
|
|
6
|
+
|
|
7
|
+
## 1.0.1 — 2026-09-18
|
|
8
|
+
|
|
9
|
+
No change to the code you consume. The release exists to move publishing onto
|
|
10
|
+
npm's and PyPI's trusted publishing, so no long-lived registry token is stored
|
|
11
|
+
anywhere any more.
|
|
12
|
+
|
|
13
|
+
## 1.0.0 — 2026-09-18
|
|
14
|
+
|
|
15
|
+
First public release.
|
|
16
|
+
|
|
17
|
+
- `Client` — `launch_real`, `launch_demo`, `list_games`, signed with HMAC-SHA256
|
|
18
|
+
over the exact request bytes.
|
|
19
|
+
- `WalletServer` — the four seamless-wallet callbacks as one pure
|
|
20
|
+
`dispatch(route, raw_body, signature)`.
|
|
21
|
+
- Bindings for ASGI, FastAPI and Flask, each dealing with that framework's own
|
|
22
|
+
way of destroying the raw body.
|
|
23
|
+
- `Money` — decimal amounts that cannot be built from a float.
|
|
24
|
+
- `WalletError` — the full api_code registry, with the balance a required
|
|
25
|
+
argument for codes 100, 105 and 106.
|
|
26
|
+
- No runtime dependencies.
|
beexar-1.0.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Beexar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
beexar-1.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: beexar
|
|
3
|
+
Version: 1.0.1
|
|
4
|
+
Summary: Beexar operator SDK — launch game sessions and serve the four seamless-wallet callbacks.
|
|
5
|
+
Project-URL: Documentation, https://docs.beexar.com/tools/sdk-python/
|
|
6
|
+
Project-URL: Source, https://github.com/beexar-games/beexar-python
|
|
7
|
+
Project-URL: Issues, https://github.com/beexar-games/beexar-python/issues
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: beexar,betwin,casino,game-aggregator,rollback,seamless-wallet,softswiss
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest==8.3.4; extra == 'dev'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# beexar
|
|
27
|
+
|
|
28
|
+
Beexar operator SDK for Python. Launch game sessions, and serve the four
|
|
29
|
+
seamless-wallet callbacks the platform calls during play.
|
|
30
|
+
|
|
31
|
+
**Zero runtime dependencies** — standard library only. Python 3.9+.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install beexar
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Full docs: **https://docs.beexar.com** · OpenAPI: **https://docs.beexar.com/api-reference/**
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## The integration in one picture
|
|
42
|
+
|
|
43
|
+
There are two halves, and the second one is the work.
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
you ── POST /api/v1/softswiss/launcher/real ──▶ Beexar (Client)
|
|
47
|
+
│
|
|
48
|
+
player plays │
|
|
49
|
+
▼
|
|
50
|
+
your wallet ◀── POST /balance /betwin /rollback /finish ── Beexar (WalletServer)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Half 1 — launching a game
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from beexar import Client
|
|
57
|
+
|
|
58
|
+
beexar = Client(os.environ["BEEXAR_CASINO_ID"], os.environ["BEEXAR_API_SECRET"])
|
|
59
|
+
|
|
60
|
+
launch_url = beexar.launch_real({
|
|
61
|
+
"game": "dice",
|
|
62
|
+
"account": {"id": "player_123", "currency": "EUR"},
|
|
63
|
+
"locale": "en",
|
|
64
|
+
})
|
|
65
|
+
# put launch_url in an iframe
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`launch_demo()` does the same on a virtual balance and makes no wallet calls.
|
|
69
|
+
`list_games()` returns the catalogue enabled for you.
|
|
70
|
+
|
|
71
|
+
## Half 2 — serving the wallet
|
|
72
|
+
|
|
73
|
+
Implement four methods against your ledger. Everything else — signature
|
|
74
|
+
verification, parsing, validation, the error envelope — is handled.
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from beexar import BetWinResult, BetWinTransactionResult, WalletError, WalletServer
|
|
78
|
+
|
|
79
|
+
class Wallet:
|
|
80
|
+
def bet_win(self, request, context):
|
|
81
|
+
with self.db.transaction(): # ONE transaction. See "The boundary".
|
|
82
|
+
results = []
|
|
83
|
+
for t in request.transactions:
|
|
84
|
+
seen = self.lookup(t.id_provider)
|
|
85
|
+
if seen:
|
|
86
|
+
results.append(BetWinTransactionResult(t.id_provider, seen.id))
|
|
87
|
+
continue
|
|
88
|
+
if self.is_rolled_back(t.id_provider):
|
|
89
|
+
raise WalletError.already_rolled_back()
|
|
90
|
+
if t.type == "bet" and self.balance().compare(t.amount) < 0:
|
|
91
|
+
raise WalletError.insufficient_funds(self.balance())
|
|
92
|
+
results.append(BetWinTransactionResult(t.id_provider, self.apply(t)))
|
|
93
|
+
return BetWinResult(self.round_id(request.round_id), self.balance(), results)
|
|
94
|
+
|
|
95
|
+
# … balance(), rollback(), finish()
|
|
96
|
+
|
|
97
|
+
server = WalletServer(Wallet(), os.environ["BEEXAR_API_SECRET"])
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Mount it:
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from beexar.asgi import fastapi_router
|
|
104
|
+
app.include_router(fastapi_router(server, prefix="/wallet")) # FastAPI
|
|
105
|
+
|
|
106
|
+
from beexar.asgi import flask_blueprint
|
|
107
|
+
app.register_blueprint(flask_blueprint(server, url_prefix="/wallet")) # Flask
|
|
108
|
+
|
|
109
|
+
from beexar.asgi import asgi_app
|
|
110
|
+
application = asgi_app(server) # bare ASGI
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Then point the four callback URLs in the backoffice at
|
|
114
|
+
`https://your-host/wallet/{balance,betwin,rollback,finish}` and run the
|
|
115
|
+
[Integration Test Game](https://docs.beexar.com/guides/testing/) — 29 scenarios
|
|
116
|
+
against your implementation.
|
|
117
|
+
|
|
118
|
+
A complete, correct wallet you can read in one sitting:
|
|
119
|
+
[`examples/inmemory_wallet.py`](./examples/inmemory_wallet.py). A runnable
|
|
120
|
+
server: [`examples/server.py`](./examples/server.py).
|
|
121
|
+
|
|
122
|
+
## The raw body — read this one
|
|
123
|
+
|
|
124
|
+
The signature is an HMAC over the **exact bytes** of the request. If anything
|
|
125
|
+
parses the JSON and serialises it again before the SDK sees it, those bytes are
|
|
126
|
+
gone — key order, escaping and number rendering all change — and no signature
|
|
127
|
+
can ever match again.
|
|
128
|
+
|
|
129
|
+
| Framework | The trap | What the binding does |
|
|
130
|
+
|---|---|---|
|
|
131
|
+
| FastAPI | declaring a Pydantic model makes Starlette parse the body and you never see bytes | declares `Request`, calls `await request.body()` |
|
|
132
|
+
| Flask | `request.get_data(as_text=True)` re-decodes the body | `request.get_data(cache=True)` → bytes |
|
|
133
|
+
| ASGI | building the body by concatenating decoded chunks corrupts multi-byte characters | concatenates `bytes` |
|
|
134
|
+
|
|
135
|
+
`dispatch()` refuses a `str` outright — a decode is not always reversible, and
|
|
136
|
+
verifying a lossy copy is worse than failing. When a signature fails and the SDK
|
|
137
|
+
can tell why, the `on_warning` callback receives the reason in plain words.
|
|
138
|
+
|
|
139
|
+
## Money
|
|
140
|
+
|
|
141
|
+
Amounts and balances are decimal strings in the currency's main unit — `"0.90"`
|
|
142
|
+
is ninety cents. `Money` cannot be built from a float, raises on `float()`, and
|
|
143
|
+
refuses `+` and `-` so an accidental mix with a number is impossible.
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
str(Money.parse("100.00").sub(Money.parse("0.30"))) # "99.70"
|
|
147
|
+
str(Money.from_minor_units(9970, 2)) # "99.70" from an integer ledger
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
`"1E2000000000"` and anything past 16 decimals is rejected on shape, before any
|
|
151
|
+
arithmetic touches it.
|
|
152
|
+
|
|
153
|
+
## Errors
|
|
154
|
+
|
|
155
|
+
Two HTTP statuses exist on this contract, 400 and 500, and the meaning lives in
|
|
156
|
+
`meta.api_code`. Raise a `WalletError` and the envelope is built for you:
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
raise WalletError.insufficient_funds(current_balance) # 400 / api_code 100
|
|
160
|
+
raise WalletError.already_rolled_back() # 400 / api_code 409
|
|
161
|
+
raise WalletError.invalid_player() # 400 / api_code 101
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Codes 100, 105 and 106 **must** carry the player's balance, so those
|
|
165
|
+
constructors take it as a required argument — there is no way to build one
|
|
166
|
+
without it. Anything else you raise becomes an opaque 500 and your message never
|
|
167
|
+
leaves the process.
|
|
168
|
+
|
|
169
|
+
## The boundary
|
|
170
|
+
|
|
171
|
+
The SDK does **not** do idempotency or tombstones for you, and it will not
|
|
172
|
+
pretend to. Both have to happen in the same database transaction as the balance
|
|
173
|
+
update, and no library can join your transaction. What you must do:
|
|
174
|
+
|
|
175
|
+
1. Store every `id_provider` with a unique index, and check it **inside** the
|
|
176
|
+
transaction that moves the money.
|
|
177
|
+
2. Store the response you returned — a repeat must return the id and balance you
|
|
178
|
+
gave the first time, not today's.
|
|
179
|
+
3. On rollback, record a tombstone for `original_id_provider` **whether or not**
|
|
180
|
+
the original exists. Out-of-order delivery is normal; a later `/betwin` for a
|
|
181
|
+
tombstoned id must be refused with `WalletError.already_rolled_back()`.
|
|
182
|
+
|
|
183
|
+
Your handler has **15 seconds**. The platform retries 5xx and timeouts for up to
|
|
184
|
+
30 seconds with the same `id_provider`; it does not retry insufficient funds,
|
|
185
|
+
bet limits, bad requests or signature failures.
|
|
186
|
+
|
|
187
|
+
## Types come from the spec
|
|
188
|
+
|
|
189
|
+
The dataclasses track the published OpenAPI documents (`openapi/` in this repo).
|
|
190
|
+
`tests/test_contract.py` reads a normalised description of those specs and fails
|
|
191
|
+
if a property the contract declares never reaches your handler.
|
|
192
|
+
|
|
193
|
+
## License
|
|
194
|
+
|
|
195
|
+
MIT
|
beexar-1.0.1/README.md
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# beexar
|
|
2
|
+
|
|
3
|
+
Beexar operator SDK for Python. Launch game sessions, and serve the four
|
|
4
|
+
seamless-wallet callbacks the platform calls during play.
|
|
5
|
+
|
|
6
|
+
**Zero runtime dependencies** — standard library only. Python 3.9+.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install beexar
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Full docs: **https://docs.beexar.com** · OpenAPI: **https://docs.beexar.com/api-reference/**
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## The integration in one picture
|
|
17
|
+
|
|
18
|
+
There are two halves, and the second one is the work.
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
you ── POST /api/v1/softswiss/launcher/real ──▶ Beexar (Client)
|
|
22
|
+
│
|
|
23
|
+
player plays │
|
|
24
|
+
▼
|
|
25
|
+
your wallet ◀── POST /balance /betwin /rollback /finish ── Beexar (WalletServer)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Half 1 — launching a game
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from beexar import Client
|
|
32
|
+
|
|
33
|
+
beexar = Client(os.environ["BEEXAR_CASINO_ID"], os.environ["BEEXAR_API_SECRET"])
|
|
34
|
+
|
|
35
|
+
launch_url = beexar.launch_real({
|
|
36
|
+
"game": "dice",
|
|
37
|
+
"account": {"id": "player_123", "currency": "EUR"},
|
|
38
|
+
"locale": "en",
|
|
39
|
+
})
|
|
40
|
+
# put launch_url in an iframe
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`launch_demo()` does the same on a virtual balance and makes no wallet calls.
|
|
44
|
+
`list_games()` returns the catalogue enabled for you.
|
|
45
|
+
|
|
46
|
+
## Half 2 — serving the wallet
|
|
47
|
+
|
|
48
|
+
Implement four methods against your ledger. Everything else — signature
|
|
49
|
+
verification, parsing, validation, the error envelope — is handled.
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from beexar import BetWinResult, BetWinTransactionResult, WalletError, WalletServer
|
|
53
|
+
|
|
54
|
+
class Wallet:
|
|
55
|
+
def bet_win(self, request, context):
|
|
56
|
+
with self.db.transaction(): # ONE transaction. See "The boundary".
|
|
57
|
+
results = []
|
|
58
|
+
for t in request.transactions:
|
|
59
|
+
seen = self.lookup(t.id_provider)
|
|
60
|
+
if seen:
|
|
61
|
+
results.append(BetWinTransactionResult(t.id_provider, seen.id))
|
|
62
|
+
continue
|
|
63
|
+
if self.is_rolled_back(t.id_provider):
|
|
64
|
+
raise WalletError.already_rolled_back()
|
|
65
|
+
if t.type == "bet" and self.balance().compare(t.amount) < 0:
|
|
66
|
+
raise WalletError.insufficient_funds(self.balance())
|
|
67
|
+
results.append(BetWinTransactionResult(t.id_provider, self.apply(t)))
|
|
68
|
+
return BetWinResult(self.round_id(request.round_id), self.balance(), results)
|
|
69
|
+
|
|
70
|
+
# … balance(), rollback(), finish()
|
|
71
|
+
|
|
72
|
+
server = WalletServer(Wallet(), os.environ["BEEXAR_API_SECRET"])
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Mount it:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from beexar.asgi import fastapi_router
|
|
79
|
+
app.include_router(fastapi_router(server, prefix="/wallet")) # FastAPI
|
|
80
|
+
|
|
81
|
+
from beexar.asgi import flask_blueprint
|
|
82
|
+
app.register_blueprint(flask_blueprint(server, url_prefix="/wallet")) # Flask
|
|
83
|
+
|
|
84
|
+
from beexar.asgi import asgi_app
|
|
85
|
+
application = asgi_app(server) # bare ASGI
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Then point the four callback URLs in the backoffice at
|
|
89
|
+
`https://your-host/wallet/{balance,betwin,rollback,finish}` and run the
|
|
90
|
+
[Integration Test Game](https://docs.beexar.com/guides/testing/) — 29 scenarios
|
|
91
|
+
against your implementation.
|
|
92
|
+
|
|
93
|
+
A complete, correct wallet you can read in one sitting:
|
|
94
|
+
[`examples/inmemory_wallet.py`](./examples/inmemory_wallet.py). A runnable
|
|
95
|
+
server: [`examples/server.py`](./examples/server.py).
|
|
96
|
+
|
|
97
|
+
## The raw body — read this one
|
|
98
|
+
|
|
99
|
+
The signature is an HMAC over the **exact bytes** of the request. If anything
|
|
100
|
+
parses the JSON and serialises it again before the SDK sees it, those bytes are
|
|
101
|
+
gone — key order, escaping and number rendering all change — and no signature
|
|
102
|
+
can ever match again.
|
|
103
|
+
|
|
104
|
+
| Framework | The trap | What the binding does |
|
|
105
|
+
|---|---|---|
|
|
106
|
+
| FastAPI | declaring a Pydantic model makes Starlette parse the body and you never see bytes | declares `Request`, calls `await request.body()` |
|
|
107
|
+
| Flask | `request.get_data(as_text=True)` re-decodes the body | `request.get_data(cache=True)` → bytes |
|
|
108
|
+
| ASGI | building the body by concatenating decoded chunks corrupts multi-byte characters | concatenates `bytes` |
|
|
109
|
+
|
|
110
|
+
`dispatch()` refuses a `str` outright — a decode is not always reversible, and
|
|
111
|
+
verifying a lossy copy is worse than failing. When a signature fails and the SDK
|
|
112
|
+
can tell why, the `on_warning` callback receives the reason in plain words.
|
|
113
|
+
|
|
114
|
+
## Money
|
|
115
|
+
|
|
116
|
+
Amounts and balances are decimal strings in the currency's main unit — `"0.90"`
|
|
117
|
+
is ninety cents. `Money` cannot be built from a float, raises on `float()`, and
|
|
118
|
+
refuses `+` and `-` so an accidental mix with a number is impossible.
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
str(Money.parse("100.00").sub(Money.parse("0.30"))) # "99.70"
|
|
122
|
+
str(Money.from_minor_units(9970, 2)) # "99.70" from an integer ledger
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`"1E2000000000"` and anything past 16 decimals is rejected on shape, before any
|
|
126
|
+
arithmetic touches it.
|
|
127
|
+
|
|
128
|
+
## Errors
|
|
129
|
+
|
|
130
|
+
Two HTTP statuses exist on this contract, 400 and 500, and the meaning lives in
|
|
131
|
+
`meta.api_code`. Raise a `WalletError` and the envelope is built for you:
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
raise WalletError.insufficient_funds(current_balance) # 400 / api_code 100
|
|
135
|
+
raise WalletError.already_rolled_back() # 400 / api_code 409
|
|
136
|
+
raise WalletError.invalid_player() # 400 / api_code 101
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Codes 100, 105 and 106 **must** carry the player's balance, so those
|
|
140
|
+
constructors take it as a required argument — there is no way to build one
|
|
141
|
+
without it. Anything else you raise becomes an opaque 500 and your message never
|
|
142
|
+
leaves the process.
|
|
143
|
+
|
|
144
|
+
## The boundary
|
|
145
|
+
|
|
146
|
+
The SDK does **not** do idempotency or tombstones for you, and it will not
|
|
147
|
+
pretend to. Both have to happen in the same database transaction as the balance
|
|
148
|
+
update, and no library can join your transaction. What you must do:
|
|
149
|
+
|
|
150
|
+
1. Store every `id_provider` with a unique index, and check it **inside** the
|
|
151
|
+
transaction that moves the money.
|
|
152
|
+
2. Store the response you returned — a repeat must return the id and balance you
|
|
153
|
+
gave the first time, not today's.
|
|
154
|
+
3. On rollback, record a tombstone for `original_id_provider` **whether or not**
|
|
155
|
+
the original exists. Out-of-order delivery is normal; a later `/betwin` for a
|
|
156
|
+
tombstoned id must be refused with `WalletError.already_rolled_back()`.
|
|
157
|
+
|
|
158
|
+
Your handler has **15 seconds**. The platform retries 5xx and timeouts for up to
|
|
159
|
+
30 seconds with the same `id_provider`; it does not retry insufficient funds,
|
|
160
|
+
bet limits, bad requests or signature failures.
|
|
161
|
+
|
|
162
|
+
## Types come from the spec
|
|
163
|
+
|
|
164
|
+
The dataclasses track the published OpenAPI documents (`openapi/` in this repo).
|
|
165
|
+
`tests/test_contract.py` reads a normalised description of those specs and fails
|
|
166
|
+
if a property the contract declares never reaches your handler.
|
|
167
|
+
|
|
168
|
+
## License
|
|
169
|
+
|
|
170
|
+
MIT
|
|
@@ -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
|
+
]
|
|
@@ -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
|