justdeploy-sdk 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- justdeploy/__init__.py +59 -0
- justdeploy/_auth.py +361 -0
- justdeploy/_transport.py +230 -0
- justdeploy/_validation.py +22 -0
- justdeploy/_version.py +1 -0
- justdeploy/client.py +55 -0
- justdeploy/databases.py +121 -0
- justdeploy/errors.py +33 -0
- justdeploy/mail.py +101 -0
- justdeploy/py.typed +1 -0
- justdeploy/storages.py +247 -0
- justdeploy/types.py +183 -0
- justdeploy_sdk-0.1.0.dist-info/METADATA +131 -0
- justdeploy_sdk-0.1.0.dist-info/RECORD +16 -0
- justdeploy_sdk-0.1.0.dist-info/WHEEL +4 -0
- justdeploy_sdk-0.1.0.dist-info/licenses/LICENSE +201 -0
justdeploy/__init__.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from ._version import __version__
|
|
2
|
+
from .client import AsyncJustDeploy, JustDeploy
|
|
3
|
+
from .errors import JustDeployAuthenticationError, JustDeployConfigurationError, JustDeployError, JustDeployValidationError
|
|
4
|
+
from .types import (
|
|
5
|
+
AsyncFileDownload,
|
|
6
|
+
AsyncUploadBody,
|
|
7
|
+
Column,
|
|
8
|
+
ColumnDefinition,
|
|
9
|
+
ColumnType,
|
|
10
|
+
CreateTableInput,
|
|
11
|
+
Database,
|
|
12
|
+
FileDownload,
|
|
13
|
+
FileInfo,
|
|
14
|
+
FilePage,
|
|
15
|
+
JsonObject,
|
|
16
|
+
JsonPrimitive,
|
|
17
|
+
JsonValue,
|
|
18
|
+
Mail,
|
|
19
|
+
MailPage,
|
|
20
|
+
MailStatus,
|
|
21
|
+
QueryResult,
|
|
22
|
+
Storage,
|
|
23
|
+
StoredFile,
|
|
24
|
+
SyncUploadBody,
|
|
25
|
+
Table,
|
|
26
|
+
UpdateTableInput,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"AsyncFileDownload",
|
|
31
|
+
"AsyncJustDeploy",
|
|
32
|
+
"AsyncUploadBody",
|
|
33
|
+
"Column",
|
|
34
|
+
"ColumnDefinition",
|
|
35
|
+
"ColumnType",
|
|
36
|
+
"CreateTableInput",
|
|
37
|
+
"Database",
|
|
38
|
+
"FileDownload",
|
|
39
|
+
"FileInfo",
|
|
40
|
+
"FilePage",
|
|
41
|
+
"JsonObject",
|
|
42
|
+
"JsonPrimitive",
|
|
43
|
+
"JsonValue",
|
|
44
|
+
"JustDeploy",
|
|
45
|
+
"JustDeployAuthenticationError",
|
|
46
|
+
"JustDeployConfigurationError",
|
|
47
|
+
"JustDeployError",
|
|
48
|
+
"JustDeployValidationError",
|
|
49
|
+
"Mail",
|
|
50
|
+
"MailPage",
|
|
51
|
+
"MailStatus",
|
|
52
|
+
"QueryResult",
|
|
53
|
+
"Storage",
|
|
54
|
+
"StoredFile",
|
|
55
|
+
"SyncUploadBody",
|
|
56
|
+
"Table",
|
|
57
|
+
"UpdateTableInput",
|
|
58
|
+
"__version__",
|
|
59
|
+
]
|
justdeploy/_auth.py
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import base64
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import stat
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Callable, Mapping
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Final
|
|
16
|
+
from urllib.parse import urlsplit
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
from cryptography.exceptions import UnsupportedAlgorithm
|
|
20
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
21
|
+
from cryptography.hazmat.primitives.serialization import load_der_private_key
|
|
22
|
+
|
|
23
|
+
from ._version import __version__
|
|
24
|
+
from .errors import JustDeployAuthenticationError, JustDeployConfigurationError
|
|
25
|
+
|
|
26
|
+
DEFAULT_API_ORIGIN: Final = "https://api.justdeploy.net"
|
|
27
|
+
DEFAULT_IDENTITY_PATH: Final = Path("/opt/justdeploy/identity.json")
|
|
28
|
+
REFRESH_WINDOW_SECONDS: Final = 3 * 60
|
|
29
|
+
AUTH_TIMEOUT_SECONDS: Final = 10.0
|
|
30
|
+
MAX_IDENTITY_BYTES: Final = 16 * 1024
|
|
31
|
+
SDK_HEADER: Final = f"python/{__version__}"
|
|
32
|
+
BUILD_UUID = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE)
|
|
33
|
+
PLATFORM_ID = re.compile(r"^[a-z0-9]{16}$")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class IdentityDocument:
|
|
38
|
+
protocol_version: int
|
|
39
|
+
build_id: str
|
|
40
|
+
private_key: str
|
|
41
|
+
api_origin: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class AuthSession:
|
|
46
|
+
token: str
|
|
47
|
+
organization_id: str
|
|
48
|
+
expires_at: float
|
|
49
|
+
api_origin: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True, slots=True)
|
|
53
|
+
class ResolvedAuthentication:
|
|
54
|
+
api_origin: str
|
|
55
|
+
credentials: tuple[str, str] | None
|
|
56
|
+
identity: IdentityDocument | None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _validate_api_origin(value: object) -> str:
|
|
60
|
+
if not isinstance(value, str):
|
|
61
|
+
raise JustDeployConfigurationError("The JustDeploy API URL in the deployment identity is invalid.")
|
|
62
|
+
try:
|
|
63
|
+
parsed = urlsplit(value)
|
|
64
|
+
_ = parsed.port
|
|
65
|
+
except ValueError as error:
|
|
66
|
+
raise JustDeployConfigurationError("The JustDeploy API URL in the deployment identity is invalid.") from error
|
|
67
|
+
if (
|
|
68
|
+
parsed.scheme != "https"
|
|
69
|
+
or not parsed.hostname
|
|
70
|
+
or parsed.username is not None
|
|
71
|
+
or parsed.password is not None
|
|
72
|
+
or parsed.path not in ("", "/")
|
|
73
|
+
or parsed.query
|
|
74
|
+
or parsed.fragment
|
|
75
|
+
):
|
|
76
|
+
raise JustDeployConfigurationError("The JustDeploy API URL in the deployment identity must be an HTTPS origin.")
|
|
77
|
+
return f"https://{parsed.netloc.lower()}"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _read_identity(path: Path) -> dict[str, Any] | None:
|
|
81
|
+
try:
|
|
82
|
+
initial_metadata = os.lstat(path)
|
|
83
|
+
except FileNotFoundError:
|
|
84
|
+
return None
|
|
85
|
+
except OSError as error:
|
|
86
|
+
raise JustDeployConfigurationError("The JustDeploy deployment identity could not be inspected.") from error
|
|
87
|
+
if not stat.S_ISREG(initial_metadata.st_mode) or stat.S_ISLNK(initial_metadata.st_mode):
|
|
88
|
+
raise JustDeployConfigurationError("The JustDeploy deployment identity must be a regular file, not a link or directory.")
|
|
89
|
+
|
|
90
|
+
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
|
91
|
+
try:
|
|
92
|
+
descriptor = os.open(path, flags)
|
|
93
|
+
except FileNotFoundError:
|
|
94
|
+
return None
|
|
95
|
+
except OSError as error:
|
|
96
|
+
raise JustDeployConfigurationError("The JustDeploy deployment identity could not be opened safely.") from error
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
metadata = os.fstat(descriptor)
|
|
100
|
+
if not stat.S_ISREG(metadata.st_mode) or metadata.st_dev != initial_metadata.st_dev or metadata.st_ino != initial_metadata.st_ino:
|
|
101
|
+
raise JustDeployConfigurationError("The JustDeploy deployment identity must be a regular file, not a link or directory.")
|
|
102
|
+
permissions = stat.S_IMODE(metadata.st_mode)
|
|
103
|
+
if permissions & 0o333 or not permissions & 0o444:
|
|
104
|
+
raise JustDeployConfigurationError(
|
|
105
|
+
"The JustDeploy deployment identity must be readable and have no write or execute permissions."
|
|
106
|
+
)
|
|
107
|
+
if metadata.st_size <= 0 or metadata.st_size > MAX_IDENTITY_BYTES:
|
|
108
|
+
raise JustDeployConfigurationError("The JustDeploy deployment identity has an invalid size.")
|
|
109
|
+
chunks = bytearray()
|
|
110
|
+
while len(chunks) <= metadata.st_size:
|
|
111
|
+
chunk = os.read(descriptor, metadata.st_size + 1 - len(chunks))
|
|
112
|
+
if not chunk:
|
|
113
|
+
break
|
|
114
|
+
chunks.extend(chunk)
|
|
115
|
+
if len(chunks) != metadata.st_size:
|
|
116
|
+
raise JustDeployConfigurationError("The JustDeploy deployment identity changed while it was being read.")
|
|
117
|
+
raw = bytes(chunks)
|
|
118
|
+
finally:
|
|
119
|
+
os.close(descriptor)
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
value = json.loads(raw)
|
|
123
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
124
|
+
raise JustDeployConfigurationError("The JustDeploy deployment identity is not valid JSON.") from error
|
|
125
|
+
if not isinstance(value, dict):
|
|
126
|
+
raise JustDeployConfigurationError("The JustDeploy deployment identity is invalid.")
|
|
127
|
+
return value
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _parse_identity(path: Path, *, require_build_key: bool) -> IdentityDocument | None:
|
|
131
|
+
value = _read_identity(path)
|
|
132
|
+
if value is None:
|
|
133
|
+
return None
|
|
134
|
+
protocol_version = value.get("protocolVersion")
|
|
135
|
+
if type(protocol_version) is not int or protocol_version != 1:
|
|
136
|
+
raise JustDeployConfigurationError("The JustDeploy deployment identity protocol is not supported by this SDK.")
|
|
137
|
+
build_id = value.get("buildId", "")
|
|
138
|
+
private_key = value.get("privateKey", "")
|
|
139
|
+
if require_build_key and (
|
|
140
|
+
not isinstance(build_id, str) or BUILD_UUID.fullmatch(build_id) is None or not isinstance(private_key, str) or not private_key
|
|
141
|
+
):
|
|
142
|
+
raise JustDeployConfigurationError("The JustDeploy deployment identity is missing a valid build key.")
|
|
143
|
+
return IdentityDocument(protocol_version, str(build_id), str(private_key), _validate_api_origin(value.get("apiBaseUrl")))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _resolve_authentication(env: Mapping[str, str], identity_path: Path) -> ResolvedAuthentication:
|
|
147
|
+
access = env.get("JUSTDEPLOY_ACCESS_KEY")
|
|
148
|
+
secret = env.get("JUSTDEPLOY_SECRET_KEY")
|
|
149
|
+
has_access = access is not None
|
|
150
|
+
has_secret = secret is not None
|
|
151
|
+
if has_access != has_secret or (has_access and (not access or not secret)):
|
|
152
|
+
raise JustDeployAuthenticationError("Set both JUSTDEPLOY_ACCESS_KEY and JUSTDEPLOY_SECRET_KEY to non-empty values.")
|
|
153
|
+
|
|
154
|
+
identity = _parse_identity(identity_path, require_build_key=not has_access)
|
|
155
|
+
api_origin = identity.api_origin if identity else DEFAULT_API_ORIGIN
|
|
156
|
+
if has_access and has_secret:
|
|
157
|
+
assert access is not None and secret is not None
|
|
158
|
+
return ResolvedAuthentication(api_origin, (access, secret), identity)
|
|
159
|
+
if identity is None:
|
|
160
|
+
raise JustDeployAuthenticationError(
|
|
161
|
+
"JustDeploy authentication is not configured. Set JUSTDEPLOY_ACCESS_KEY and JUSTDEPLOY_SECRET_KEY for local development; "
|
|
162
|
+
"deployed JustDeploy applications receive an identity automatically."
|
|
163
|
+
)
|
|
164
|
+
return ResolvedAuthentication(api_origin, None, identity)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _build_request(resolved: ResolvedAuthentication, now: float) -> tuple[str, dict[str, str], dict[str, object]]:
|
|
168
|
+
common_headers = {"accept": "application/json", "content-type": "application/json", "x-justdeploy-sdk": SDK_HEADER}
|
|
169
|
+
if resolved.credentials:
|
|
170
|
+
access, secret = resolved.credentials
|
|
171
|
+
return (
|
|
172
|
+
f"{resolved.api_origin}/auth/credential",
|
|
173
|
+
{**common_headers, "authorization": f"Bearer {access}:{secret}"},
|
|
174
|
+
{},
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
identity = resolved.identity
|
|
178
|
+
if identity is None: # pragma: no cover - guarded by _resolve_authentication
|
|
179
|
+
raise JustDeployAuthenticationError("JustDeploy authentication is not configured.")
|
|
180
|
+
issued_at = int(now)
|
|
181
|
+
signing_input = f"justdeploy-build-auth-v1\n{resolved.api_origin}\nPOST\n/auth/build\n{identity.build_id}\n{issued_at}".encode()
|
|
182
|
+
try:
|
|
183
|
+
encoded_key = base64.b64decode(identity.private_key, validate=True)
|
|
184
|
+
private_key = load_der_private_key(encoded_key, password=None)
|
|
185
|
+
if not isinstance(private_key, Ed25519PrivateKey):
|
|
186
|
+
raise ValueError
|
|
187
|
+
signature = base64.urlsafe_b64encode(private_key.sign(signing_input)).rstrip(b"=").decode("ascii")
|
|
188
|
+
except (TypeError, ValueError, UnsupportedAlgorithm):
|
|
189
|
+
raise JustDeployConfigurationError("The JustDeploy deployment identity contains an invalid Ed25519 private key.") from None
|
|
190
|
+
return (
|
|
191
|
+
f"{resolved.api_origin}/auth/build",
|
|
192
|
+
{**common_headers, "x-justdeploy-build-signature": signature},
|
|
193
|
+
{"buildId": identity.build_id, "issuedAt": issued_at},
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _session_from_response(response: httpx.Response, api_origin: str, now: float) -> AuthSession:
|
|
198
|
+
if not response.is_success:
|
|
199
|
+
message = "JustDeploy authentication was rejected."
|
|
200
|
+
details: dict[str, Any] = {}
|
|
201
|
+
try:
|
|
202
|
+
payload = response.json()
|
|
203
|
+
if isinstance(payload, dict):
|
|
204
|
+
details = payload
|
|
205
|
+
if isinstance(payload.get("message"), str) and payload["message"]:
|
|
206
|
+
message = payload["message"]
|
|
207
|
+
except (ValueError, UnicodeDecodeError):
|
|
208
|
+
pass
|
|
209
|
+
retry_after = details.get("retryAfter")
|
|
210
|
+
request_id = details.get("requestId")
|
|
211
|
+
raise JustDeployAuthenticationError(
|
|
212
|
+
message,
|
|
213
|
+
status=response.status_code,
|
|
214
|
+
retry_after=retry_after if isinstance(retry_after, int) and not isinstance(retry_after, bool) else None,
|
|
215
|
+
request_id=request_id if isinstance(request_id, str) else response.headers.get("x-request-id"),
|
|
216
|
+
details=details,
|
|
217
|
+
)
|
|
218
|
+
try:
|
|
219
|
+
payload = response.json()
|
|
220
|
+
token = payload["token"]
|
|
221
|
+
organization_id = payload["organizationId"]
|
|
222
|
+
raw_expiry = payload["expiresAt"]
|
|
223
|
+
if not isinstance(raw_expiry, str):
|
|
224
|
+
raise ValueError
|
|
225
|
+
parsed_expiry = datetime.fromisoformat(raw_expiry.replace("Z", "+00:00"))
|
|
226
|
+
if parsed_expiry.tzinfo is None or parsed_expiry.utcoffset() is None:
|
|
227
|
+
raise ValueError
|
|
228
|
+
expires_at = parsed_expiry.timestamp()
|
|
229
|
+
except (KeyError, TypeError, ValueError, UnicodeDecodeError):
|
|
230
|
+
raise JustDeployAuthenticationError(
|
|
231
|
+
"JustDeploy returned an invalid authentication response.",
|
|
232
|
+
status=response.status_code,
|
|
233
|
+
request_id=response.headers.get("x-request-id"),
|
|
234
|
+
) from None
|
|
235
|
+
if (
|
|
236
|
+
not isinstance(token, str)
|
|
237
|
+
or not token
|
|
238
|
+
or not isinstance(organization_id, str)
|
|
239
|
+
or PLATFORM_ID.fullmatch(organization_id) is None
|
|
240
|
+
or expires_at <= now
|
|
241
|
+
):
|
|
242
|
+
raise JustDeployAuthenticationError(
|
|
243
|
+
"JustDeploy returned an invalid authentication response.",
|
|
244
|
+
status=response.status_code,
|
|
245
|
+
request_id=response.headers.get("x-request-id"),
|
|
246
|
+
)
|
|
247
|
+
return AuthSession(token, organization_id, expires_at, api_origin)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class SyncAuthManager:
|
|
251
|
+
def __init__(
|
|
252
|
+
self,
|
|
253
|
+
client: httpx.Client,
|
|
254
|
+
*,
|
|
255
|
+
env: Mapping[str, str] | None = None,
|
|
256
|
+
identity_path: Path = DEFAULT_IDENTITY_PATH,
|
|
257
|
+
clock: Callable[[], float] = time.time,
|
|
258
|
+
) -> None:
|
|
259
|
+
self._client = client
|
|
260
|
+
self._env = dict(os.environ if env is None else env)
|
|
261
|
+
self._identity_path = identity_path
|
|
262
|
+
self._clock = clock
|
|
263
|
+
self._session: AuthSession | None = None
|
|
264
|
+
self._credential_authentication: ResolvedAuthentication | None = None
|
|
265
|
+
self._lock = threading.Lock()
|
|
266
|
+
|
|
267
|
+
def _valid_session(self) -> AuthSession | None:
|
|
268
|
+
if self._session and self._session.expires_at - self._clock() > REFRESH_WINDOW_SECONDS:
|
|
269
|
+
return self._session
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
def get_session(self) -> AuthSession:
|
|
273
|
+
if session := self._valid_session():
|
|
274
|
+
return session
|
|
275
|
+
with self._lock:
|
|
276
|
+
if session := self._valid_session():
|
|
277
|
+
return session
|
|
278
|
+
self._session = self._exchange()
|
|
279
|
+
return self._session
|
|
280
|
+
|
|
281
|
+
def refresh_after_unauthorized(self, stale_token: str) -> AuthSession:
|
|
282
|
+
with self._lock:
|
|
283
|
+
if self._session and self._session.token != stale_token and (session := self._valid_session()):
|
|
284
|
+
return session
|
|
285
|
+
self._session = self._exchange()
|
|
286
|
+
return self._session
|
|
287
|
+
|
|
288
|
+
def _exchange(self) -> AuthSession:
|
|
289
|
+
resolved = self._resolve()
|
|
290
|
+
url, headers, body = _build_request(resolved, self._clock())
|
|
291
|
+
try:
|
|
292
|
+
response = self._client.post(url, headers=headers, json=body, timeout=AUTH_TIMEOUT_SECONDS, follow_redirects=False)
|
|
293
|
+
except httpx.HTTPError:
|
|
294
|
+
raise JustDeployAuthenticationError("JustDeploy authentication failed before the server returned a response.") from None
|
|
295
|
+
return _session_from_response(response, resolved.api_origin, self._clock())
|
|
296
|
+
|
|
297
|
+
def _resolve(self) -> ResolvedAuthentication:
|
|
298
|
+
if self._credential_authentication:
|
|
299
|
+
return self._credential_authentication
|
|
300
|
+
resolved = _resolve_authentication(self._env, self._identity_path)
|
|
301
|
+
if resolved.credentials:
|
|
302
|
+
self._credential_authentication = ResolvedAuthentication(resolved.api_origin, resolved.credentials, None)
|
|
303
|
+
return self._credential_authentication
|
|
304
|
+
return resolved
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class AsyncAuthManager:
|
|
308
|
+
def __init__(
|
|
309
|
+
self,
|
|
310
|
+
client: httpx.AsyncClient,
|
|
311
|
+
*,
|
|
312
|
+
env: Mapping[str, str] | None = None,
|
|
313
|
+
identity_path: Path = DEFAULT_IDENTITY_PATH,
|
|
314
|
+
clock: Callable[[], float] = time.time,
|
|
315
|
+
) -> None:
|
|
316
|
+
self._client = client
|
|
317
|
+
self._env = dict(os.environ if env is None else env)
|
|
318
|
+
self._identity_path = identity_path
|
|
319
|
+
self._clock = clock
|
|
320
|
+
self._session: AuthSession | None = None
|
|
321
|
+
self._credential_authentication: ResolvedAuthentication | None = None
|
|
322
|
+
self._lock = asyncio.Lock()
|
|
323
|
+
|
|
324
|
+
def _valid_session(self) -> AuthSession | None:
|
|
325
|
+
if self._session and self._session.expires_at - self._clock() > REFRESH_WINDOW_SECONDS:
|
|
326
|
+
return self._session
|
|
327
|
+
return None
|
|
328
|
+
|
|
329
|
+
async def get_session(self) -> AuthSession:
|
|
330
|
+
if session := self._valid_session():
|
|
331
|
+
return session
|
|
332
|
+
async with self._lock:
|
|
333
|
+
if session := self._valid_session():
|
|
334
|
+
return session
|
|
335
|
+
self._session = await self._exchange()
|
|
336
|
+
return self._session
|
|
337
|
+
|
|
338
|
+
async def refresh_after_unauthorized(self, stale_token: str) -> AuthSession:
|
|
339
|
+
async with self._lock:
|
|
340
|
+
if self._session and self._session.token != stale_token and (session := self._valid_session()):
|
|
341
|
+
return session
|
|
342
|
+
self._session = await self._exchange()
|
|
343
|
+
return self._session
|
|
344
|
+
|
|
345
|
+
async def _exchange(self) -> AuthSession:
|
|
346
|
+
resolved = self._resolve()
|
|
347
|
+
url, headers, body = _build_request(resolved, self._clock())
|
|
348
|
+
try:
|
|
349
|
+
response = await self._client.post(url, headers=headers, json=body, timeout=AUTH_TIMEOUT_SECONDS, follow_redirects=False)
|
|
350
|
+
except httpx.HTTPError:
|
|
351
|
+
raise JustDeployAuthenticationError("JustDeploy authentication failed before the server returned a response.") from None
|
|
352
|
+
return _session_from_response(response, resolved.api_origin, self._clock())
|
|
353
|
+
|
|
354
|
+
def _resolve(self) -> ResolvedAuthentication:
|
|
355
|
+
if self._credential_authentication:
|
|
356
|
+
return self._credential_authentication
|
|
357
|
+
resolved = _resolve_authentication(self._env, self._identity_path)
|
|
358
|
+
if resolved.credentials:
|
|
359
|
+
self._credential_authentication = ResolvedAuthentication(resolved.api_origin, resolved.credentials, None)
|
|
360
|
+
return self._credential_authentication
|
|
361
|
+
return resolved
|
justdeploy/_transport.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from typing import Any, Literal, cast
|
|
6
|
+
from urllib.parse import quote, urlsplit
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from ._auth import SDK_HEADER, AsyncAuthManager, AuthSession, SyncAuthManager
|
|
11
|
+
from .errors import JustDeployError, JustDeployValidationError
|
|
12
|
+
from .types import AsyncUploadBody, SyncUploadBody
|
|
13
|
+
|
|
14
|
+
Method = Literal["GET", "POST", "PUT", "DELETE"]
|
|
15
|
+
API_TIMEOUT_SECONDS = 30.0
|
|
16
|
+
TRANSFER_FORBIDDEN_HEADERS = ("authorization", "x-justdeploy-sdk", "cookie")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _api_error(response: httpx.Response, payload: object) -> JustDeployError:
|
|
20
|
+
details = payload if isinstance(payload, dict) else {}
|
|
21
|
+
message = details.get("message")
|
|
22
|
+
if not isinstance(message, str) or not message:
|
|
23
|
+
message = f"JustDeploy request failed with status {response.status_code}."
|
|
24
|
+
retry_after = details.get("retryAfter")
|
|
25
|
+
request_id = details.get("requestId")
|
|
26
|
+
if not isinstance(request_id, str):
|
|
27
|
+
request_id = response.headers.get("x-request-id")
|
|
28
|
+
return JustDeployError(
|
|
29
|
+
message,
|
|
30
|
+
status=response.status_code,
|
|
31
|
+
retry_after=retry_after if isinstance(retry_after, int) and not isinstance(retry_after, bool) else None,
|
|
32
|
+
request_id=request_id,
|
|
33
|
+
details=cast(Mapping[str, Any], details),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _payload(response: httpx.Response) -> object:
|
|
38
|
+
if not response.content:
|
|
39
|
+
return None
|
|
40
|
+
try:
|
|
41
|
+
return response.json()
|
|
42
|
+
except (ValueError, UnicodeDecodeError):
|
|
43
|
+
raise JustDeployError("JustDeploy returned a response that was not valid JSON.", status=response.status_code) from None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _json_content(value: object) -> bytes:
|
|
47
|
+
try:
|
|
48
|
+
return json.dumps(value, ensure_ascii=False, allow_nan=False, separators=(",", ":")).encode()
|
|
49
|
+
except (TypeError, ValueError, UnicodeEncodeError):
|
|
50
|
+
raise JustDeployValidationError("The request contains a value that cannot be encoded as JSON.") from None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _validate_presigned_url(url: str) -> str:
|
|
54
|
+
try:
|
|
55
|
+
parsed = urlsplit(url)
|
|
56
|
+
_ = parsed.port
|
|
57
|
+
except (TypeError, ValueError):
|
|
58
|
+
raise JustDeployError("JustDeploy returned an invalid file URL.") from None
|
|
59
|
+
if parsed.scheme != "https" or not parsed.hostname or parsed.username is not None or parsed.password is not None or parsed.fragment:
|
|
60
|
+
raise JustDeployError("JustDeploy returned an invalid file URL.")
|
|
61
|
+
return url
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _api_url(session: AuthSession, path: str) -> str:
|
|
65
|
+
if not path.startswith("/") or path.startswith("//") or "://" in path or "\\" in path:
|
|
66
|
+
raise RuntimeError("Invalid internal JustDeploy API path.")
|
|
67
|
+
organization_id = quote(session.organization_id, safe="")
|
|
68
|
+
return f"{session.api_origin}/organizations/{organization_id}{path}"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class SyncTransport:
|
|
72
|
+
def __init__(self, client: httpx.Client, auth: SyncAuthManager) -> None:
|
|
73
|
+
self.client = client
|
|
74
|
+
self.auth = auth
|
|
75
|
+
|
|
76
|
+
def organization_request(
|
|
77
|
+
self,
|
|
78
|
+
method: Method,
|
|
79
|
+
path: str,
|
|
80
|
+
*,
|
|
81
|
+
json_body: object = None,
|
|
82
|
+
headers: Mapping[str, str] | None = None,
|
|
83
|
+
) -> object:
|
|
84
|
+
session = self.auth.get_session()
|
|
85
|
+
return self._send(method, path, session, json_body=json_body, headers=headers, replayed=False)
|
|
86
|
+
|
|
87
|
+
def _send(
|
|
88
|
+
self,
|
|
89
|
+
method: Method,
|
|
90
|
+
path: str,
|
|
91
|
+
session: AuthSession,
|
|
92
|
+
*,
|
|
93
|
+
json_body: object,
|
|
94
|
+
headers: Mapping[str, str] | None,
|
|
95
|
+
replayed: bool,
|
|
96
|
+
) -> object:
|
|
97
|
+
request_headers = {"accept": "application/json", "authorization": f"Bearer {session.token}", "x-justdeploy-sdk": SDK_HEADER}
|
|
98
|
+
for name, value in (headers or {}).items():
|
|
99
|
+
if name.lower() in {"authorization", "host", "x-justdeploy-sdk"}:
|
|
100
|
+
raise RuntimeError(f"The internal header {name} cannot be overridden.")
|
|
101
|
+
request_headers[name.lower()] = value
|
|
102
|
+
content = _json_content(json_body) if json_body is not None else None
|
|
103
|
+
if content is not None:
|
|
104
|
+
request_headers["content-type"] = "application/json"
|
|
105
|
+
try:
|
|
106
|
+
url = _api_url(session, path)
|
|
107
|
+
response = self.client.request(
|
|
108
|
+
method,
|
|
109
|
+
url,
|
|
110
|
+
headers=request_headers,
|
|
111
|
+
content=content,
|
|
112
|
+
timeout=API_TIMEOUT_SECONDS,
|
|
113
|
+
follow_redirects=False,
|
|
114
|
+
)
|
|
115
|
+
except httpx.HTTPError:
|
|
116
|
+
raise JustDeployError("The JustDeploy request failed before the server returned a response.") from None
|
|
117
|
+
if response.status_code == 401 and method == "GET" and not replayed:
|
|
118
|
+
response.close()
|
|
119
|
+
refreshed = self.auth.refresh_after_unauthorized(session.token)
|
|
120
|
+
return self._send(method, path, refreshed, json_body=json_body, headers=headers, replayed=True)
|
|
121
|
+
payload = _payload(response)
|
|
122
|
+
if not response.is_success:
|
|
123
|
+
raise _api_error(response, payload)
|
|
124
|
+
return payload
|
|
125
|
+
|
|
126
|
+
def presigned_upload(self, url: str, *, mime: str, data: SyncUploadBody, size: int) -> httpx.Response:
|
|
127
|
+
validated_url = _validate_presigned_url(url)
|
|
128
|
+
try:
|
|
129
|
+
request = self.client.build_request(
|
|
130
|
+
"PUT",
|
|
131
|
+
validated_url,
|
|
132
|
+
headers={"content-type": mime, "content-length": str(size)},
|
|
133
|
+
content=data,
|
|
134
|
+
)
|
|
135
|
+
for name in TRANSFER_FORBIDDEN_HEADERS:
|
|
136
|
+
request.headers.pop(name, None)
|
|
137
|
+
return self.client.send(request, follow_redirects=False)
|
|
138
|
+
except Exception:
|
|
139
|
+
raise JustDeployError("The file transfer failed before the server returned a response.") from None
|
|
140
|
+
|
|
141
|
+
def presigned_download(self, url: str) -> httpx.Response:
|
|
142
|
+
validated_url = _validate_presigned_url(url)
|
|
143
|
+
try:
|
|
144
|
+
request = self.client.build_request("GET", validated_url)
|
|
145
|
+
for name in TRANSFER_FORBIDDEN_HEADERS:
|
|
146
|
+
request.headers.pop(name, None)
|
|
147
|
+
return self.client.send(request, stream=True, follow_redirects=False)
|
|
148
|
+
except Exception:
|
|
149
|
+
raise JustDeployError("The file transfer failed before the server returned a response.") from None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class AsyncTransport:
|
|
153
|
+
def __init__(self, client: httpx.AsyncClient, auth: AsyncAuthManager) -> None:
|
|
154
|
+
self.client = client
|
|
155
|
+
self.auth = auth
|
|
156
|
+
|
|
157
|
+
async def organization_request(
|
|
158
|
+
self,
|
|
159
|
+
method: Method,
|
|
160
|
+
path: str,
|
|
161
|
+
*,
|
|
162
|
+
json_body: object = None,
|
|
163
|
+
headers: Mapping[str, str] | None = None,
|
|
164
|
+
) -> object:
|
|
165
|
+
session = await self.auth.get_session()
|
|
166
|
+
return await self._send(method, path, session, json_body=json_body, headers=headers, replayed=False)
|
|
167
|
+
|
|
168
|
+
async def _send(
|
|
169
|
+
self,
|
|
170
|
+
method: Method,
|
|
171
|
+
path: str,
|
|
172
|
+
session: AuthSession,
|
|
173
|
+
*,
|
|
174
|
+
json_body: object,
|
|
175
|
+
headers: Mapping[str, str] | None,
|
|
176
|
+
replayed: bool,
|
|
177
|
+
) -> object:
|
|
178
|
+
request_headers = {"accept": "application/json", "authorization": f"Bearer {session.token}", "x-justdeploy-sdk": SDK_HEADER}
|
|
179
|
+
for name, value in (headers or {}).items():
|
|
180
|
+
if name.lower() in {"authorization", "host", "x-justdeploy-sdk"}:
|
|
181
|
+
raise RuntimeError(f"The internal header {name} cannot be overridden.")
|
|
182
|
+
request_headers[name.lower()] = value
|
|
183
|
+
content = _json_content(json_body) if json_body is not None else None
|
|
184
|
+
if content is not None:
|
|
185
|
+
request_headers["content-type"] = "application/json"
|
|
186
|
+
try:
|
|
187
|
+
url = _api_url(session, path)
|
|
188
|
+
response = await self.client.request(
|
|
189
|
+
method,
|
|
190
|
+
url,
|
|
191
|
+
headers=request_headers,
|
|
192
|
+
content=content,
|
|
193
|
+
timeout=API_TIMEOUT_SECONDS,
|
|
194
|
+
follow_redirects=False,
|
|
195
|
+
)
|
|
196
|
+
except httpx.HTTPError:
|
|
197
|
+
raise JustDeployError("The JustDeploy request failed before the server returned a response.") from None
|
|
198
|
+
if response.status_code == 401 and method == "GET" and not replayed:
|
|
199
|
+
await response.aclose()
|
|
200
|
+
refreshed = await self.auth.refresh_after_unauthorized(session.token)
|
|
201
|
+
return await self._send(method, path, refreshed, json_body=json_body, headers=headers, replayed=True)
|
|
202
|
+
payload = _payload(response)
|
|
203
|
+
if not response.is_success:
|
|
204
|
+
raise _api_error(response, payload)
|
|
205
|
+
return payload
|
|
206
|
+
|
|
207
|
+
async def presigned_upload(self, url: str, *, mime: str, data: AsyncUploadBody, size: int) -> httpx.Response:
|
|
208
|
+
validated_url = _validate_presigned_url(url)
|
|
209
|
+
try:
|
|
210
|
+
request = self.client.build_request(
|
|
211
|
+
"PUT",
|
|
212
|
+
validated_url,
|
|
213
|
+
headers={"content-type": mime, "content-length": str(size)},
|
|
214
|
+
content=data,
|
|
215
|
+
)
|
|
216
|
+
for name in TRANSFER_FORBIDDEN_HEADERS:
|
|
217
|
+
request.headers.pop(name, None)
|
|
218
|
+
return await self.client.send(request, follow_redirects=False)
|
|
219
|
+
except Exception:
|
|
220
|
+
raise JustDeployError("The file transfer failed before the server returned a response.") from None
|
|
221
|
+
|
|
222
|
+
async def presigned_download(self, url: str) -> httpx.Response:
|
|
223
|
+
validated_url = _validate_presigned_url(url)
|
|
224
|
+
try:
|
|
225
|
+
request = self.client.build_request("GET", validated_url)
|
|
226
|
+
for name in TRANSFER_FORBIDDEN_HEADERS:
|
|
227
|
+
request.headers.pop(name, None)
|
|
228
|
+
return await self.client.send(request, stream=True, follow_redirects=False)
|
|
229
|
+
except Exception:
|
|
230
|
+
raise JustDeployError("The file transfer failed before the server returned a response.") from None
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from urllib.parse import quote, urlencode
|
|
2
|
+
|
|
3
|
+
from .errors import JustDeployValidationError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def path_segment(value: str, label: str) -> str:
|
|
7
|
+
if not isinstance(value, str) or not value:
|
|
8
|
+
raise JustDeployValidationError(f"{label} must be a non-empty string.")
|
|
9
|
+
return quote(value, safe="")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def page_query(*, limit: int | None, cursor: int | None, max_limit: int) -> str:
|
|
13
|
+
values: dict[str, str] = {}
|
|
14
|
+
if limit is not None:
|
|
15
|
+
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= max_limit:
|
|
16
|
+
raise JustDeployValidationError(f"limit must be an integer between 1 and {max_limit}.")
|
|
17
|
+
values["limit"] = str(limit)
|
|
18
|
+
if cursor is not None:
|
|
19
|
+
if isinstance(cursor, bool) or not isinstance(cursor, int) or cursor <= 0:
|
|
20
|
+
raise JustDeployValidationError("cursor must be a positive integer.")
|
|
21
|
+
values["cursor"] = str(cursor)
|
|
22
|
+
return f"?{urlencode(values)}" if values else ""
|
justdeploy/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|