noxaeapi-sdk 0.4.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.
- noxaeapi_sdk/__init__.py +41 -0
- noxaeapi_sdk/client.py +170 -0
- noxaeapi_sdk/errors.py +132 -0
- noxaeapi_sdk/http_engine.py +259 -0
- noxaeapi_sdk/modules/__init__.py +0 -0
- noxaeapi_sdk/modules/economy.py +75 -0
- noxaeapi_sdk/modules/leaderboard.py +39 -0
- noxaeapi_sdk/modules/luckperms.py +87 -0
- noxaeapi_sdk/modules/misc.py +29 -0
- noxaeapi_sdk/modules/network.py +91 -0
- noxaeapi_sdk/modules/network_hub.py +100 -0
- noxaeapi_sdk/modules/noxauth.py +35 -0
- noxaeapi_sdk/modules/players.py +63 -0
- noxaeapi_sdk/modules/plugins.py +37 -0
- noxaeapi_sdk/modules/server.py +126 -0
- noxaeapi_sdk/modules/skills.py +35 -0
- noxaeapi_sdk/modules/worlds.py +57 -0
- noxaeapi_sdk/py.typed +0 -0
- noxaeapi_sdk/socket.py +167 -0
- noxaeapi_sdk/types.py +305 -0
- noxaeapi_sdk-0.4.0.dist-info/METADATA +176 -0
- noxaeapi_sdk-0.4.0.dist-info/RECORD +24 -0
- noxaeapi_sdk-0.4.0.dist-info/WHEEL +4 -0
- noxaeapi_sdk-0.4.0.dist-info/licenses/LICENSE +21 -0
noxaeapi_sdk/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Typed Python SDK for the NoxAeApi REST + WebSocket API.
|
|
2
|
+
|
|
3
|
+
(Fabric, Bukkit/Spigot/Paper) and the NoxAeApi-Velocity network hub.
|
|
4
|
+
|
|
5
|
+
Example::
|
|
6
|
+
|
|
7
|
+
from noxaeapi_sdk import NoxAeApiClient
|
|
8
|
+
|
|
9
|
+
client = NoxAeApiClient(base_url="http://localhost:8080", api_key="your-api-key")
|
|
10
|
+
players = client.players.list()
|
|
11
|
+
balance = client.economy.get_balance(players[0]["uuid"])
|
|
12
|
+
client.server.broadcast("Hello from the SDK!")
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from .client import NoxAeApiClient, NoxAeApiNetworkHubClient
|
|
16
|
+
from .errors import (
|
|
17
|
+
NoxAeApiError,
|
|
18
|
+
NoxAeApiForbiddenError,
|
|
19
|
+
NoxAeApiNetworkError,
|
|
20
|
+
NoxAeApiNotFoundError,
|
|
21
|
+
NoxAeApiRateLimitError,
|
|
22
|
+
NoxAeApiServerError,
|
|
23
|
+
NoxAeApiUnauthorizedError,
|
|
24
|
+
)
|
|
25
|
+
from .http_engine import NoxAeApiClientOptions, RetryOptions
|
|
26
|
+
|
|
27
|
+
__version__ = "0.4.0"
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"NoxAeApiClient",
|
|
31
|
+
"NoxAeApiNetworkHubClient",
|
|
32
|
+
"NoxAeApiClientOptions",
|
|
33
|
+
"RetryOptions",
|
|
34
|
+
"NoxAeApiError",
|
|
35
|
+
"NoxAeApiUnauthorizedError",
|
|
36
|
+
"NoxAeApiForbiddenError",
|
|
37
|
+
"NoxAeApiNotFoundError",
|
|
38
|
+
"NoxAeApiRateLimitError",
|
|
39
|
+
"NoxAeApiServerError",
|
|
40
|
+
"NoxAeApiNetworkError",
|
|
41
|
+
]
|
noxaeapi_sdk/client.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
from .http_engine import HttpEngine, NoxAeApiClientOptions
|
|
7
|
+
from .modules.economy import EconomyModule
|
|
8
|
+
from .modules.leaderboard import LeaderboardModule
|
|
9
|
+
from .modules.luckperms import LuckPermsModule
|
|
10
|
+
from .modules.misc import AdvancementsModule, PlaceholdersModule
|
|
11
|
+
from .modules.network import NetworkModule
|
|
12
|
+
from .modules.network_hub import NetworkHubModule
|
|
13
|
+
from .modules.noxauth import NoxAuthModule
|
|
14
|
+
from .modules.players import PlayersModule
|
|
15
|
+
from .modules.plugins import PluginsModule
|
|
16
|
+
from .modules.server import ServerModule
|
|
17
|
+
from .modules.skills import SkillsModule
|
|
18
|
+
from .modules.worlds import WorldsModule
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class NoxAeApiClient:
|
|
22
|
+
"""Client for a NoxAeApi server (Fabric mod or Bukkit/Spigot/Paper plugin).
|
|
23
|
+
|
|
24
|
+
Example::
|
|
25
|
+
|
|
26
|
+
client = NoxAeApiClient(base_url="http://localhost:8080", api_key="your-api-key")
|
|
27
|
+
players = client.players.list()
|
|
28
|
+
balance = client.economy.get_balance(players[0]["uuid"])
|
|
29
|
+
client.server.broadcast("Hello from the SDK!")
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
base_url: Optional[str] = None,
|
|
35
|
+
api_key: Optional[str] = None,
|
|
36
|
+
*,
|
|
37
|
+
options: Optional[NoxAeApiClientOptions] = None,
|
|
38
|
+
**kwargs: Any,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Create a client.
|
|
41
|
+
|
|
42
|
+
Either pass ``base_url`` (and optionally ``api_key``,
|
|
43
|
+
``timeout``, ``retry``, ``headers``, ...) directly, or build a
|
|
44
|
+
:class:`~noxaeapi_sdk.http_engine.NoxAeApiClientOptions` yourself
|
|
45
|
+
and pass it as ``options=``.
|
|
46
|
+
"""
|
|
47
|
+
if options is None:
|
|
48
|
+
if not base_url:
|
|
49
|
+
raise ValueError("NoxAeApiClient requires base_url (or options=NoxAeApiClientOptions(...))")
|
|
50
|
+
options = NoxAeApiClientOptions(base_url=base_url, api_key=api_key, **kwargs)
|
|
51
|
+
|
|
52
|
+
self._options = options
|
|
53
|
+
self._http = HttpEngine(options)
|
|
54
|
+
|
|
55
|
+
self.players = PlayersModule(self._http)
|
|
56
|
+
self.economy = EconomyModule(self._http)
|
|
57
|
+
self.server = ServerModule(self._http)
|
|
58
|
+
self.worlds = WorldsModule(self._http)
|
|
59
|
+
self.plugins = PluginsModule(self._http)
|
|
60
|
+
self.advancements = AdvancementsModule(self._http)
|
|
61
|
+
self.placeholders = PlaceholdersModule(self._http)
|
|
62
|
+
self.luckperms = LuckPermsModule(self._http)
|
|
63
|
+
"""Only works if LuckPerms is loaded on the target server."""
|
|
64
|
+
self.noxauth = NoxAuthModule(self._http)
|
|
65
|
+
"""Only works if ``noxauth.enabled: true`` is set in the server config."""
|
|
66
|
+
self.skills = SkillsModule(self._http)
|
|
67
|
+
"""Requires mcMMO and/or AuraSkills to be loaded on the target server."""
|
|
68
|
+
self.leaderboards = LeaderboardModule(self._http)
|
|
69
|
+
"""Generic ranked leaderboards (economy currencies, mcMMO, AuraSkills, ...)."""
|
|
70
|
+
self.network = NetworkModule(self._http)
|
|
71
|
+
"""Only works if ``network.enabled: true`` is set in the server config.
|
|
72
|
+
|
|
73
|
+
This is NoxAeApi-main's built-in polling aggregator — it lives on
|
|
74
|
+
the *same* backend server you're already connected to and fans
|
|
75
|
+
requests out to the other backends listed in that server's own
|
|
76
|
+
config. If the network is running NoxAeApi-Velocity instead, use
|
|
77
|
+
:class:`NoxAeApiNetworkHubClient` (pointed at the proxy's hub
|
|
78
|
+
port) rather than this module — the hub replaces this aggregator
|
|
79
|
+
with a push model and its response shapes differ.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def from_env(cls, **overrides: Any) -> "NoxAeApiClient":
|
|
84
|
+
"""Build a client from environment variables: ``NOXAEAPI_BASE_URL`` and ``NOXAEAPI_KEY``.
|
|
85
|
+
|
|
86
|
+
Convenience for scripts. The SDK never reads ``.env`` files or
|
|
87
|
+
``os.environ`` implicitly outside of this method — use a library
|
|
88
|
+
like ``python-dotenv`` in your own app if you want that, then
|
|
89
|
+
call ``NoxAeApiClient.from_env()`` after it's loaded.
|
|
90
|
+
"""
|
|
91
|
+
base_url = overrides.pop("base_url", None) or os.environ.get("NOXAEAPI_BASE_URL")
|
|
92
|
+
api_key = overrides.pop("api_key", None) or os.environ.get("NOXAEAPI_KEY")
|
|
93
|
+
|
|
94
|
+
if not base_url:
|
|
95
|
+
raise ValueError(
|
|
96
|
+
"NoxAeApiClient.from_env(): NOXAEAPI_BASE_URL is not set and no base_url override was given."
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
return cls(base_url=base_url, api_key=api_key, **overrides)
|
|
100
|
+
|
|
101
|
+
def connect(self, route: str = "events", **kwargs: Any) -> Any:
|
|
102
|
+
"""Open a WebSocket connection to the server (console tail or event stream).
|
|
103
|
+
|
|
104
|
+
Requires the optional ``websocket-client`` package
|
|
105
|
+
(``pip install noxaeapi-sdk[ws]``).
|
|
106
|
+
"""
|
|
107
|
+
from .socket import NoxAeApiSocket, NoxAeApiWsOptions
|
|
108
|
+
|
|
109
|
+
return NoxAeApiSocket(
|
|
110
|
+
NoxAeApiWsOptions(
|
|
111
|
+
base_url=self._options.base_url,
|
|
112
|
+
api_key=self._options.api_key,
|
|
113
|
+
route=route,
|
|
114
|
+
**kwargs,
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class NoxAeApiNetworkHubClient:
|
|
120
|
+
"""Client for the **NoxAeApi-Velocity** network hub.
|
|
121
|
+
|
|
122
|
+
This is a separate plugin that runs on the Velocity proxy, not on any
|
|
123
|
+
individual backend server. Point ``base_url`` at the hub's own REST
|
|
124
|
+
port (``NetworkHubConfig``'s ``api-port``), not a backend's port, and
|
|
125
|
+
use ``NOXAEAPI_HUB_*`` env vars (via :meth:`from_env`) if you keep
|
|
126
|
+
that separate from a regular backend's ``NOXAEAPI_*`` vars.
|
|
127
|
+
|
|
128
|
+
Only exposes ``.network`` — the hub doesn't run any of the other REST
|
|
129
|
+
modules (players, economy, worlds, ...) that a backend
|
|
130
|
+
:class:`NoxAeApiClient` does. To reach a specific backend's own
|
|
131
|
+
routes through the hub, use ``hub.network.forward(id, ...)``.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
def __init__(
|
|
135
|
+
self,
|
|
136
|
+
base_url: Optional[str] = None,
|
|
137
|
+
api_key: Optional[str] = None,
|
|
138
|
+
*,
|
|
139
|
+
options: Optional[NoxAeApiClientOptions] = None,
|
|
140
|
+
**kwargs: Any,
|
|
141
|
+
) -> None:
|
|
142
|
+
if options is None:
|
|
143
|
+
if not base_url:
|
|
144
|
+
raise ValueError(
|
|
145
|
+
"NoxAeApiNetworkHubClient requires base_url (or options=NoxAeApiClientOptions(...))"
|
|
146
|
+
)
|
|
147
|
+
options = NoxAeApiClientOptions(base_url=base_url, api_key=api_key, **kwargs)
|
|
148
|
+
|
|
149
|
+
http = HttpEngine(options)
|
|
150
|
+
self.network = NetworkHubModule(http)
|
|
151
|
+
"""The network hub's aggregated view of every registered backend node."""
|
|
152
|
+
|
|
153
|
+
@classmethod
|
|
154
|
+
def from_env(cls, **overrides: Any) -> "NoxAeApiNetworkHubClient":
|
|
155
|
+
"""Build a hub client from environment variables: ``NOXAEAPI_HUB_BASE_URL`` / ``NOXAEAPI_HUB_KEY``.
|
|
156
|
+
|
|
157
|
+
Same convenience as ``NoxAeApiClient.from_env()``, under separate
|
|
158
|
+
env var names so a process can hold both a backend client and a
|
|
159
|
+
hub client at once without the two colliding.
|
|
160
|
+
"""
|
|
161
|
+
base_url = overrides.pop("base_url", None) or os.environ.get("NOXAEAPI_HUB_BASE_URL")
|
|
162
|
+
api_key = overrides.pop("api_key", None) or os.environ.get("NOXAEAPI_HUB_KEY")
|
|
163
|
+
|
|
164
|
+
if not base_url:
|
|
165
|
+
raise ValueError(
|
|
166
|
+
"NoxAeApiNetworkHubClient.from_env(): NOXAEAPI_HUB_BASE_URL is not set "
|
|
167
|
+
"and no base_url override was given."
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
return cls(base_url=base_url, api_key=api_key, **overrides)
|
noxaeapi_sdk/errors.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Exception hierarchy for the NoxAeApi SDK.
|
|
2
|
+
|
|
3
|
+
Every non-2xx HTTP response from a NoxAeApi server raises a subclass of
|
|
4
|
+
:class:`NoxAeApiError`. Network-level failures (DNS, connection refused,
|
|
5
|
+
timeouts) raise :class:`NoxAeApiNetworkError` instead, which is *not* a
|
|
6
|
+
subclass of :class:`NoxAeApiError` since no response was ever received.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class NoxAeApiError(Exception):
|
|
15
|
+
"""Base error for any non-2xx response from a NoxAeApi server.
|
|
16
|
+
|
|
17
|
+
Prefer catching one of the more specific subclasses below when you
|
|
18
|
+
need to branch on the failure reason.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
message: str,
|
|
24
|
+
*,
|
|
25
|
+
status: int,
|
|
26
|
+
method: str,
|
|
27
|
+
path: str,
|
|
28
|
+
body: Any = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
super().__init__(message)
|
|
31
|
+
self.status = status
|
|
32
|
+
self.method = method
|
|
33
|
+
self.path = path
|
|
34
|
+
self.body = body
|
|
35
|
+
|
|
36
|
+
def __repr__(self) -> str: # pragma: no cover - cosmetic
|
|
37
|
+
return f"{type(self).__name__}(status={self.status}, method={self.method!r}, path={self.path!r})"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class NoxAeApiUnauthorizedError(NoxAeApiError):
|
|
41
|
+
"""401 — the API key is missing or not recognized by the server."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, *, status: int, method: str, path: str, body: Any = None) -> None:
|
|
44
|
+
super().__init__(
|
|
45
|
+
f"Unauthorized: the API key was missing or invalid for {method} {path}",
|
|
46
|
+
status=status,
|
|
47
|
+
method=method,
|
|
48
|
+
path=path,
|
|
49
|
+
body=body,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class NoxAeApiForbiddenError(NoxAeApiError):
|
|
54
|
+
"""403 — the key is valid but isn't allowed to call this endpoint.
|
|
55
|
+
|
|
56
|
+
This is a server-side permission decision (e.g. a read-only key
|
|
57
|
+
hitting a write route); the SDK does not try to predict or enforce
|
|
58
|
+
it client-side.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, *, status: int, method: str, path: str, body: Any = None) -> None:
|
|
62
|
+
super().__init__(
|
|
63
|
+
f"Forbidden: this API key does not have permission to call {method} {path}",
|
|
64
|
+
status=status,
|
|
65
|
+
method=method,
|
|
66
|
+
path=path,
|
|
67
|
+
body=body,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class NoxAeApiNotFoundError(NoxAeApiError):
|
|
72
|
+
"""404 — the target resource (player, world, plugin, etc.) wasn't found."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, *, status: int, method: str, path: str, body: Any = None) -> None:
|
|
75
|
+
super().__init__(
|
|
76
|
+
f"Not found: {method} {path}",
|
|
77
|
+
status=status,
|
|
78
|
+
method=method,
|
|
79
|
+
path=path,
|
|
80
|
+
body=body,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class NoxAeApiRateLimitError(NoxAeApiError):
|
|
85
|
+
"""429 — rate limited.
|
|
86
|
+
|
|
87
|
+
``retry_after_ms`` is populated when the server sends a
|
|
88
|
+
``Retry-After`` header. The SDK auto-retries these by default; this
|
|
89
|
+
is only raised once retries are exhausted (or retries are disabled).
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
*,
|
|
95
|
+
status: int,
|
|
96
|
+
method: str,
|
|
97
|
+
path: str,
|
|
98
|
+
body: Any = None,
|
|
99
|
+
retry_after_ms: Optional[int] = None,
|
|
100
|
+
) -> None:
|
|
101
|
+
suffix = f" — retry after {retry_after_ms}ms" if retry_after_ms else ""
|
|
102
|
+
super().__init__(
|
|
103
|
+
f"Rate limited on {method} {path}{suffix}",
|
|
104
|
+
status=status,
|
|
105
|
+
method=method,
|
|
106
|
+
path=path,
|
|
107
|
+
body=body,
|
|
108
|
+
)
|
|
109
|
+
self.retry_after_ms = retry_after_ms
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class NoxAeApiServerError(NoxAeApiError):
|
|
113
|
+
"""5xx — the server errored out. Usually safe to retry, and auto-retried by default."""
|
|
114
|
+
|
|
115
|
+
def __init__(self, *, status: int, method: str, path: str, body: Any = None) -> None:
|
|
116
|
+
super().__init__(
|
|
117
|
+
f"Server error ({status}) on {method} {path}",
|
|
118
|
+
status=status,
|
|
119
|
+
method=method,
|
|
120
|
+
path=path,
|
|
121
|
+
body=body,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class NoxAeApiNetworkError(Exception):
|
|
126
|
+
"""The request could not complete at all (DNS, connection refused, timeout)."""
|
|
127
|
+
|
|
128
|
+
def __init__(self, message: str, *, method: str, path: str, cause: Optional[BaseException] = None) -> None:
|
|
129
|
+
super().__init__(message)
|
|
130
|
+
self.method = method
|
|
131
|
+
self.path = path
|
|
132
|
+
self.__cause__ = cause
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Low-level HTTP engine shared by every module.
|
|
2
|
+
|
|
3
|
+
Zero third-party runtime dependencies — built on ``urllib`` from the
|
|
4
|
+
standard library, mirroring the zero-dependency ethos of the original
|
|
5
|
+
JS/TS SDK (which uses native ``fetch``).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import random
|
|
12
|
+
import time
|
|
13
|
+
import urllib.error
|
|
14
|
+
import urllib.parse
|
|
15
|
+
import urllib.request
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Any, Callable, Dict, Optional, Union
|
|
18
|
+
|
|
19
|
+
from .errors import (
|
|
20
|
+
NoxAeApiError,
|
|
21
|
+
NoxAeApiForbiddenError,
|
|
22
|
+
NoxAeApiNetworkError,
|
|
23
|
+
NoxAeApiNotFoundError,
|
|
24
|
+
NoxAeApiRateLimitError,
|
|
25
|
+
NoxAeApiServerError,
|
|
26
|
+
NoxAeApiUnauthorizedError,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
Method = str # "GET" | "POST" | "PUT" | "DELETE" | "PATCH"
|
|
30
|
+
QueryValue = Union[str, int, float, bool, None]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class RetryOptions:
|
|
35
|
+
"""Retry behavior for network errors, 429s, and 5xx responses."""
|
|
36
|
+
|
|
37
|
+
attempts: int = 3
|
|
38
|
+
"""Max number of attempts including the first one. Default 3."""
|
|
39
|
+
|
|
40
|
+
base_delay_ms: int = 300
|
|
41
|
+
"""Base delay in ms used for exponential backoff."""
|
|
42
|
+
|
|
43
|
+
max_delay_ms: int = 5000
|
|
44
|
+
"""Upper bound for any single backoff delay."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class NoxAeApiClientOptions:
|
|
49
|
+
"""Options accepted by :class:`NoxAeApiClient` / :class:`NoxAeApiNetworkHubClient`."""
|
|
50
|
+
|
|
51
|
+
base_url: str
|
|
52
|
+
"""Base URL of the server, e.g. ``"http://localhost:8080"``."""
|
|
53
|
+
|
|
54
|
+
api_key: Optional[str] = None
|
|
55
|
+
"""The API key configured on the server (sent as the ``key`` header)."""
|
|
56
|
+
|
|
57
|
+
timeout: float = 10.0
|
|
58
|
+
"""Request timeout in seconds. Default 10."""
|
|
59
|
+
|
|
60
|
+
retry: Union[RetryOptions, bool, None] = True
|
|
61
|
+
"""Retry options, ``True`` for defaults, or ``False`` to disable retries."""
|
|
62
|
+
|
|
63
|
+
headers: Dict[str, str] = field(default_factory=dict)
|
|
64
|
+
"""Extra headers sent on every request."""
|
|
65
|
+
|
|
66
|
+
opener: Optional[Callable[[urllib.request.Request, float], Any]] = None
|
|
67
|
+
"""Override the low-level opener, mainly for testing.
|
|
68
|
+
|
|
69
|
+
Must be a callable ``(request, timeout) -> http.client.HTTPResponse``
|
|
70
|
+
(or a context-manager-compatible object with the same interface as
|
|
71
|
+
what ``urllib.request.urlopen`` returns). Defaults to
|
|
72
|
+
``urllib.request.urlopen``.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _resolve_retry(retry: Union[RetryOptions, bool, None]) -> Union[RetryOptions, None]:
|
|
77
|
+
if retry is False or retry is None:
|
|
78
|
+
return None
|
|
79
|
+
if retry is True:
|
|
80
|
+
return RetryOptions()
|
|
81
|
+
return retry
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _sleep(seconds: float) -> None:
|
|
85
|
+
if seconds > 0:
|
|
86
|
+
time.sleep(seconds)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _backoff_delay_seconds(attempt: int, opts: RetryOptions) -> float:
|
|
90
|
+
exp = min(opts.max_delay_ms, opts.base_delay_ms * (2 ** attempt))
|
|
91
|
+
# full jitter
|
|
92
|
+
return (random.random() * exp) / 1000.0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _encode_form_body(body: Any) -> bytes:
|
|
96
|
+
"""Encode a plain dict as ``application/x-www-form-urlencoded``.
|
|
97
|
+
|
|
98
|
+
Matches what Javalin's ``ctx.formParam(name)`` reads server-side.
|
|
99
|
+
``None`` values are omitted so optional fields can be left out
|
|
100
|
+
entirely rather than sent as the literal string "None".
|
|
101
|
+
"""
|
|
102
|
+
params: list[tuple[str, str]] = []
|
|
103
|
+
if isinstance(body, dict):
|
|
104
|
+
for key, value in body.items():
|
|
105
|
+
if value is None:
|
|
106
|
+
continue
|
|
107
|
+
if isinstance(value, bool):
|
|
108
|
+
params.append((key, "true" if value else "false"))
|
|
109
|
+
elif isinstance(value, (str, int, float)):
|
|
110
|
+
params.append((key, str(value)))
|
|
111
|
+
else:
|
|
112
|
+
params.append((key, json.dumps(value)))
|
|
113
|
+
return urllib.parse.urlencode(params).encode("utf-8")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _safe_json_parse(text: Optional[str]) -> Any:
|
|
117
|
+
if not text:
|
|
118
|
+
return None
|
|
119
|
+
try:
|
|
120
|
+
return json.loads(text)
|
|
121
|
+
except (json.JSONDecodeError, TypeError):
|
|
122
|
+
return text
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _parse_retry_after(header_value: str) -> Optional[int]:
|
|
126
|
+
try:
|
|
127
|
+
return int(float(header_value) * 1000)
|
|
128
|
+
except ValueError:
|
|
129
|
+
pass
|
|
130
|
+
try:
|
|
131
|
+
from email.utils import parsedate_to_datetime
|
|
132
|
+
|
|
133
|
+
dt = parsedate_to_datetime(header_value)
|
|
134
|
+
if dt is None:
|
|
135
|
+
return None
|
|
136
|
+
delta_ms = (dt.timestamp() - time.time()) * 1000
|
|
137
|
+
return max(0, int(delta_ms))
|
|
138
|
+
except (TypeError, ValueError):
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class HttpEngine:
|
|
143
|
+
"""Handles request building, encoding, retries, and error mapping."""
|
|
144
|
+
|
|
145
|
+
def __init__(self, options: NoxAeApiClientOptions) -> None:
|
|
146
|
+
if not options.base_url:
|
|
147
|
+
raise ValueError("NoxAeApiClient requires a non-empty base_url")
|
|
148
|
+
self._base_url = options.base_url.rstrip("/")
|
|
149
|
+
self._api_key = options.api_key
|
|
150
|
+
self._timeout = options.timeout
|
|
151
|
+
self._retry = _resolve_retry(options.retry)
|
|
152
|
+
self._extra_headers = dict(options.headers or {})
|
|
153
|
+
self._opener = options.opener or (
|
|
154
|
+
lambda req, timeout: urllib.request.urlopen(req, timeout=timeout)
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def _build_url(self, path: str, query: Optional[Dict[str, QueryValue]] = None) -> str:
|
|
158
|
+
url = f"{self._base_url}/v1/{path.lstrip('/')}"
|
|
159
|
+
if query:
|
|
160
|
+
filtered = {k: v for k, v in query.items() if v is not None}
|
|
161
|
+
if filtered:
|
|
162
|
+
url += "?" + urllib.parse.urlencode(filtered)
|
|
163
|
+
return url
|
|
164
|
+
|
|
165
|
+
def request(
|
|
166
|
+
self,
|
|
167
|
+
method: Method,
|
|
168
|
+
path: str,
|
|
169
|
+
*,
|
|
170
|
+
body: Any = None,
|
|
171
|
+
query: Optional[Dict[str, QueryValue]] = None,
|
|
172
|
+
form: bool = False,
|
|
173
|
+
) -> Any:
|
|
174
|
+
"""Perform a request and return the parsed JSON body (or ``None`` for empty/204 responses)."""
|
|
175
|
+
url = self._build_url(path, query)
|
|
176
|
+
max_attempts = self._retry.attempts if self._retry else 1
|
|
177
|
+
|
|
178
|
+
last_error: Optional[BaseException] = None
|
|
179
|
+
|
|
180
|
+
for attempt in range(max_attempts):
|
|
181
|
+
headers: Dict[str, str] = {"Accept": "application/json", **self._extra_headers}
|
|
182
|
+
if self._api_key:
|
|
183
|
+
headers["key"] = self._api_key
|
|
184
|
+
|
|
185
|
+
data: Optional[bytes] = None
|
|
186
|
+
if body is not None:
|
|
187
|
+
if form:
|
|
188
|
+
headers["Content-Type"] = "application/x-www-form-urlencoded"
|
|
189
|
+
data = _encode_form_body(body)
|
|
190
|
+
else:
|
|
191
|
+
headers["Content-Type"] = "application/json"
|
|
192
|
+
data = json.dumps(body).encode("utf-8")
|
|
193
|
+
|
|
194
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
with self._opener(req, self._timeout) as response:
|
|
198
|
+
status = response.status
|
|
199
|
+
raw = response.read()
|
|
200
|
+
text = raw.decode("utf-8") if raw else ""
|
|
201
|
+
if status == 204 or not text:
|
|
202
|
+
return None
|
|
203
|
+
return json.loads(text)
|
|
204
|
+
|
|
205
|
+
except urllib.error.HTTPError as http_err:
|
|
206
|
+
error_text = http_err.read().decode("utf-8", errors="replace") if http_err.fp else None
|
|
207
|
+
parsed_body = _safe_json_parse(error_text)
|
|
208
|
+
status = http_err.code
|
|
209
|
+
info = dict(status=status, method=method, path=path, body=parsed_body)
|
|
210
|
+
|
|
211
|
+
if status == 401:
|
|
212
|
+
raise NoxAeApiUnauthorizedError(**info) from None
|
|
213
|
+
if status == 403:
|
|
214
|
+
raise NoxAeApiForbiddenError(**info) from None
|
|
215
|
+
if status == 404:
|
|
216
|
+
raise NoxAeApiNotFoundError(**info) from None
|
|
217
|
+
|
|
218
|
+
if status == 429:
|
|
219
|
+
retry_after_header = http_err.headers.get("Retry-After") if http_err.headers else None
|
|
220
|
+
retry_after_ms = _parse_retry_after(retry_after_header) if retry_after_header else None
|
|
221
|
+
err = NoxAeApiRateLimitError(retry_after_ms=retry_after_ms, **info)
|
|
222
|
+
if self._retry and attempt < max_attempts - 1:
|
|
223
|
+
last_error = err
|
|
224
|
+
_sleep((retry_after_ms / 1000.0) if retry_after_ms else _backoff_delay_seconds(attempt, self._retry))
|
|
225
|
+
continue
|
|
226
|
+
raise err from None
|
|
227
|
+
|
|
228
|
+
if status >= 500:
|
|
229
|
+
err = NoxAeApiServerError(**info)
|
|
230
|
+
if self._retry and attempt < max_attempts - 1:
|
|
231
|
+
last_error = err
|
|
232
|
+
_sleep(_backoff_delay_seconds(attempt, self._retry))
|
|
233
|
+
continue
|
|
234
|
+
raise err from None
|
|
235
|
+
|
|
236
|
+
raise NoxAeApiError(f"Unexpected status {status} on {method} {path}", **info) from None
|
|
237
|
+
|
|
238
|
+
except NoxAeApiError:
|
|
239
|
+
raise
|
|
240
|
+
|
|
241
|
+
except Exception as err: # noqa: BLE001 - network/timeout/DNS errors of many types
|
|
242
|
+
is_timeout = isinstance(err, TimeoutError) or "timed out" in str(err).lower()
|
|
243
|
+
message = (
|
|
244
|
+
f"Request timed out after {self._timeout}s: {method} {path}"
|
|
245
|
+
if is_timeout
|
|
246
|
+
else f"Network error on {method} {path}: {err}"
|
|
247
|
+
)
|
|
248
|
+
network_err = NoxAeApiNetworkError(message, method=method, path=path, cause=err)
|
|
249
|
+
|
|
250
|
+
if self._retry and attempt < max_attempts - 1:
|
|
251
|
+
last_error = network_err
|
|
252
|
+
_sleep(_backoff_delay_seconds(attempt, self._retry))
|
|
253
|
+
continue
|
|
254
|
+
raise network_err from err
|
|
255
|
+
|
|
256
|
+
# Unreachable in practice.
|
|
257
|
+
if isinstance(last_error, BaseException):
|
|
258
|
+
raise last_error
|
|
259
|
+
raise RuntimeError("Request failed after retries")
|
|
File without changes
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import urllib.parse
|
|
4
|
+
from typing import List, Literal, Optional
|
|
5
|
+
|
|
6
|
+
from ..http_engine import HttpEngine
|
|
7
|
+
from ..types import CurrencyBalance, CurrencyTopEntry, EconomyInfo, PlayerBalance, TopBalanceEntry
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _q(value: str) -> str:
|
|
11
|
+
return urllib.parse.quote(value, safe="")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EconomyModule:
|
|
15
|
+
def __init__(self, http: HttpEngine) -> None:
|
|
16
|
+
self._http = http
|
|
17
|
+
|
|
18
|
+
def info(self) -> EconomyInfo:
|
|
19
|
+
"""Get info about the connected economy provider (Impactor on Fabric, Vault on Bukkit/Spigot/Paper)."""
|
|
20
|
+
return self._http.request("GET", "economy")
|
|
21
|
+
|
|
22
|
+
def get_balance(self, uuid: str) -> PlayerBalance:
|
|
23
|
+
"""Get a player's balance."""
|
|
24
|
+
return self._http.request("GET", f"economy/balance/{_q(uuid)}")
|
|
25
|
+
|
|
26
|
+
def get_top_balance(self, limit: Optional[int] = None) -> List[TopBalanceEntry]:
|
|
27
|
+
"""Get the top balances leaderboard."""
|
|
28
|
+
return self._http.request("GET", "economy/top", query={"limit": limit})
|
|
29
|
+
|
|
30
|
+
def pay(self, uuid: str, amount: float) -> None:
|
|
31
|
+
"""Pay an amount to a player (adds to their balance)."""
|
|
32
|
+
return self._http.request("POST", "economy/pay", body={"uuid": uuid, "amount": amount}, form=True)
|
|
33
|
+
|
|
34
|
+
def debit(self, uuid: str, amount: float) -> None:
|
|
35
|
+
"""Debit an amount from a player (subtracts from their balance)."""
|
|
36
|
+
return self._http.request("POST", "economy/debit", body={"uuid": uuid, "amount": amount}, form=True)
|
|
37
|
+
|
|
38
|
+
# --- ExcellentEconomy multi-currency (native API, not Vault) ----------
|
|
39
|
+
#
|
|
40
|
+
# These endpoints talk directly to ExcellentEconomy's Developer API
|
|
41
|
+
# rather than Vault, so they work with any currency configured on the
|
|
42
|
+
# server (coins, gems, tokens, ...) instead of only the single
|
|
43
|
+
# Vault-linked "primary" currency exposed above. They raise
|
|
44
|
+
# `NoxAeApiError` with a 424 status if ExcellentEconomy isn't installed
|
|
45
|
+
# on the target server, and a 404 if the given currency ID doesn't exist.
|
|
46
|
+
|
|
47
|
+
def list_currencies(self) -> List[str]:
|
|
48
|
+
"""List all currency IDs configured on ExcellentEconomy."""
|
|
49
|
+
return self._http.request("GET", "economy/currencies")
|
|
50
|
+
|
|
51
|
+
def get_currency_balance(self, currency: str, uuid: str) -> CurrencyBalance:
|
|
52
|
+
"""Get a player's balance for a specific ExcellentEconomy currency."""
|
|
53
|
+
return self._http.request("GET", f"economy/currency/{_q(currency)}/balance/{_q(uuid)}")
|
|
54
|
+
|
|
55
|
+
def pay_currency(self, currency: str, uuid: str, amount: float) -> Literal["success", "failure"]:
|
|
56
|
+
"""Pay a player in a specific currency (adds ``amount``). ``amount`` must be > 0."""
|
|
57
|
+
return self._http.request(
|
|
58
|
+
"POST", f"economy/currency/{_q(currency)}/pay", body={"uuid": uuid, "amount": amount}, form=True
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def debit_currency(self, currency: str, uuid: str, amount: float) -> Literal["success", "failure"]:
|
|
62
|
+
"""Debit a player in a specific currency (subtracts ``amount``). ``amount`` must be > 0."""
|
|
63
|
+
return self._http.request(
|
|
64
|
+
"POST", f"economy/currency/{_q(currency)}/debit", body={"uuid": uuid, "amount": amount}, form=True
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def set_currency_balance(self, currency: str, uuid: str, amount: float) -> Literal["success", "failure"]:
|
|
68
|
+
"""Set a player's balance for a specific currency to an exact amount (``amount`` must be >= 0)."""
|
|
69
|
+
return self._http.request(
|
|
70
|
+
"POST", f"economy/currency/{_q(currency)}/set", body={"uuid": uuid, "amount": amount}, form=True
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def get_currency_top(self, currency: str, limit: Optional[int] = None) -> List[CurrencyTopEntry]:
|
|
74
|
+
"""Get the top balances leaderboard for a specific currency."""
|
|
75
|
+
return self._http.request("GET", f"economy/currency/{_q(currency)}/top", query={"limit": limit})
|