torii-backend 0.0.2__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.
@@ -0,0 +1,57 @@
1
+ """torii-backend — backend SDK for torii.
2
+
3
+ Verify JWTs networklessly, call ``/api/server/v1/**`` with a secret key,
4
+ and (soon) verify outbound webhook signatures. Framework-agnostic; an
5
+ optional FastAPI dependency adapter lives in ``torii_backend.fastapi``.
6
+ """
7
+
8
+ from torii_backend.client import ToriiClient, create_torii_client
9
+ from torii_backend.errors import ToriiApiError, ToriiAuthError
10
+
11
+ # Generated data types — re-exported under stable Torii* aliases so the
12
+ # public surface is independent of the generator's naming.
13
+ from torii_backend.generated.models import (
14
+ CreateUserRequest as ToriiCreateUserInput,
15
+ )
16
+ from torii_backend.generated.models import (
17
+ CursorPageResponseUserResponse as ToriiCursorPageUser,
18
+ )
19
+ from torii_backend.generated.models import (
20
+ ProblemDetail as ToriiProblemDetail,
21
+ )
22
+ from torii_backend.generated.models import (
23
+ UpdateUserRequest as ToriiUpdateUserInput,
24
+ )
25
+ from torii_backend.generated.models import (
26
+ UserResponse as ToriiUser,
27
+ )
28
+ from torii_backend.generated.models import (
29
+ UserSessionResponse as ToriiSession,
30
+ )
31
+ from torii_backend.types import ToriiAuth
32
+ from torii_backend.verify import (
33
+ authenticate_request,
34
+ clear_jwks_cache_for_tests,
35
+ verify_token,
36
+ verify_webhook,
37
+ )
38
+
39
+ __all__ = [
40
+ "ToriiApiError",
41
+ "ToriiAuth",
42
+ "ToriiAuthError",
43
+ "ToriiClient",
44
+ "ToriiCreateUserInput",
45
+ "ToriiCursorPageUser",
46
+ "ToriiProblemDetail",
47
+ "ToriiSession",
48
+ "ToriiUpdateUserInput",
49
+ "ToriiUser",
50
+ "authenticate_request",
51
+ "clear_jwks_cache_for_tests",
52
+ "create_torii_client",
53
+ "verify_token",
54
+ "verify_webhook",
55
+ ]
56
+
57
+ __version__ = "0.0.1"
@@ -0,0 +1,307 @@
1
+ """ToriiClient — entry point for the REST surface.
2
+
3
+ Wraps the openapi-generator output under ``torii_backend.generated``
4
+ behind a thin, hand-written facade so callers see ergonomic methods
5
+ instead of the generator's verbose ``_request_timeout``/``_headers``/...
6
+ parameter sprawl.
7
+
8
+ Types and endpoints come from the OpenAPI spec via ``openapi-generator``;
9
+ only the wrapper + auth helpers are hand-written. When the spec grows,
10
+ regenerate and add a one-line wrapper method per new endpoint.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from datetime import date, datetime
16
+ from typing import Any
17
+ from uuid import UUID
18
+
19
+ from torii_backend.errors import ToriiApiError
20
+ from torii_backend.generated import (
21
+ ApiClient,
22
+ ApiException,
23
+ Configuration,
24
+ ServerSessionsApi,
25
+ ServerUsersApi,
26
+ )
27
+ from torii_backend.generated.models import (
28
+ CreateUserRequest,
29
+ CursorPageResponseUserResponse,
30
+ ServerUserSearchRequest,
31
+ UpdateUserRequest,
32
+ UserResponse,
33
+ UserSessionResponse,
34
+ )
35
+
36
+ # Sentinel to distinguish "argument not passed" from "explicit None" in the
37
+ # keyword-arg flavour of ``users.update`` / ``users.create``. We must NOT use
38
+ # ``None`` for this purpose because PATCH semantics require ``None`` to mean
39
+ # "clear this field on the server". See README "PATCH semantics".
40
+ _UNSET: Any = object()
41
+
42
+ DEFAULT_API_URL = "https://api.torii.so"
43
+
44
+
45
+ class UsersClient:
46
+ def __init__(self, api: ServerUsersApi) -> None:
47
+ self._api = api
48
+
49
+ def list(
50
+ self,
51
+ *,
52
+ limit: int | None = None,
53
+ cursor: str | UUID | None = None,
54
+ name: str | None = None,
55
+ email: str | None = None,
56
+ statuses: list[str] | None = None,
57
+ created_after: str | datetime | None = None,
58
+ created_before: str | datetime | None = None,
59
+ ) -> CursorPageResponseUserResponse:
60
+ """Search users. Server-side cursor-paginated; loop with the
61
+ returned ``next_cursor`` until ``has_more`` is False."""
62
+ search = ServerUserSearchRequest.from_dict(
63
+ {
64
+ "name": name,
65
+ "email": email,
66
+ "statuses": statuses,
67
+ "createdAfter": created_after,
68
+ "createdBefore": created_before,
69
+ }
70
+ )
71
+ with _translate_api_error():
72
+ return self._api.search_users(
73
+ limit=limit,
74
+ cursor=_coerce_uuid(cursor),
75
+ server_user_search_request=search,
76
+ )
77
+
78
+ def get(self, user_id: str | UUID) -> UserResponse:
79
+ with _translate_api_error():
80
+ return self._api.get_user(_coerce_uuid(user_id))
81
+
82
+ def create(
83
+ self,
84
+ input: CreateUserRequest | dict[str, Any] | None = None,
85
+ *,
86
+ email: str | None = _UNSET,
87
+ password: str | None = _UNSET,
88
+ name: str | None = _UNSET,
89
+ phone: str | None = _UNSET,
90
+ address: str | None = _UNSET,
91
+ date_of_birth: str | date | None = _UNSET,
92
+ ) -> UserResponse:
93
+ """Create a user.
94
+
95
+ Two call shapes:
96
+ * Pass a ``CreateUserRequest`` / dict positionally; OR
97
+ * Pass keyword args directly (``email=...``, ``name=...``, etc.).
98
+ """
99
+ if input is not None:
100
+ body = (
101
+ input
102
+ if isinstance(input, CreateUserRequest)
103
+ else CreateUserRequest.from_dict(input)
104
+ )
105
+ else:
106
+ kwargs = {
107
+ "email": email,
108
+ "password": password,
109
+ "name": name,
110
+ "phone": phone,
111
+ "address": address,
112
+ "dateOfBirth": date_of_birth,
113
+ }
114
+ body = CreateUserRequest.model_validate(
115
+ {k: v for k, v in kwargs.items() if v is not _UNSET}
116
+ )
117
+ with _translate_api_error():
118
+ return self._api.create_user(body)
119
+
120
+ def update(
121
+ self,
122
+ user_id: str | UUID,
123
+ input: UpdateUserRequest | dict[str, Any] | None = None,
124
+ *,
125
+ name: str | None = _UNSET,
126
+ phone: str | None = _UNSET,
127
+ locale: str | None = _UNSET,
128
+ address: str | None = _UNSET,
129
+ date_of_birth: str | date | None = _UNSET,
130
+ ) -> UserResponse:
131
+ """Patch a user.
132
+
133
+ Tri-state PATCH semantics — only fields the caller explicitly set
134
+ are sent to the server:
135
+ * not passed → omitted from the JSON body → server leaves alone
136
+ * ``None`` → emitted as ``null`` → server clears
137
+ * value → emitted with value → server updates
138
+
139
+ Two call shapes:
140
+ * Pass an ``UpdateUserRequest`` / dict positionally; the request
141
+ model's ``model_fields_set`` drives which keys are sent; OR
142
+ * Pass keyword args directly (``name="Ada"``, ``phone=None``, ...).
143
+ Only the kwargs you pass appear on the wire.
144
+ """
145
+ if input is not None:
146
+ model = (
147
+ input
148
+ if isinstance(input, UpdateUserRequest)
149
+ else UpdateUserRequest.model_validate(input)
150
+ )
151
+ else:
152
+ kwargs = {
153
+ "name": name,
154
+ "phone": phone,
155
+ "locale": locale,
156
+ "address": address,
157
+ "dateOfBirth": date_of_birth,
158
+ }
159
+ model = UpdateUserRequest.model_validate(
160
+ {k: v for k, v in kwargs.items() if v is not _UNSET}
161
+ )
162
+ # Build the wire body from the model's *explicitly set* fields only.
163
+ # We must NOT pass the model through the generated ``update_user`` —
164
+ # it's wrapped in pydantic's ``@validate_call`` which would re-coerce
165
+ # the dict back into an ``UpdateUserRequest`` and then serialize via
166
+ # ``to_dict()`` (which uses ``exclude_none=True``). That collapses
167
+ # ``phone=None`` ("clear this field") into "omit", breaking tri-state.
168
+ # Drop down to the serializer + ``call_api`` directly so the dict we
169
+ # built survives untouched.
170
+ body = model.model_dump(exclude_unset=True, by_alias=True)
171
+ with _translate_api_error():
172
+ return self._patch_user(_coerce_uuid(user_id), body)
173
+
174
+ def _patch_user(self, user_id: Any, body: dict[str, Any]) -> UserResponse:
175
+ params = self._api._update_user_serialize(
176
+ user_id=user_id,
177
+ update_user_request=body,
178
+ _request_auth=None,
179
+ _content_type=None,
180
+ _headers=None,
181
+ _host_index=0,
182
+ )
183
+ response = self._api.api_client.call_api(*params)
184
+ response.read()
185
+ return self._api.api_client.response_deserialize(
186
+ response_data=response,
187
+ response_types_map={"200": "UserResponse"},
188
+ ).data
189
+
190
+ def delete(self, user_id: str | UUID) -> None:
191
+ with _translate_api_error():
192
+ self._api.delete_user(_coerce_uuid(user_id))
193
+
194
+ def ban(self, user_id: str | UUID) -> UserResponse:
195
+ with _translate_api_error():
196
+ return self._api.ban_user(_coerce_uuid(user_id))
197
+
198
+ def unban(self, user_id: str | UUID) -> UserResponse:
199
+ with _translate_api_error():
200
+ return self._api.unban_user(_coerce_uuid(user_id))
201
+
202
+
203
+ class SessionsClient:
204
+ def __init__(self, api: ServerSessionsApi) -> None:
205
+ self._api = api
206
+
207
+ def list_for_user(self, user_id: str | UUID) -> list[UserSessionResponse]:
208
+ with _translate_api_error():
209
+ return self._api.list_sessions(_coerce_uuid(user_id))
210
+
211
+ def revoke_all_for_user(self, user_id: str | UUID) -> None:
212
+ with _translate_api_error():
213
+ self._api.revoke_all_sessions(_coerce_uuid(user_id))
214
+
215
+ def revoke(self, user_id: str | UUID, session_id: str | UUID) -> None:
216
+ with _translate_api_error():
217
+ self._api.revoke_session(_coerce_uuid(user_id), _coerce_uuid(session_id))
218
+
219
+
220
+ class ToriiClient:
221
+ """Construct via :func:`create_torii_client`."""
222
+
223
+ def __init__(self, api_client: ApiClient) -> None:
224
+ self._api_client = api_client
225
+ self.users = UsersClient(ServerUsersApi(api_client))
226
+ self.sessions = SessionsClient(ServerSessionsApi(api_client))
227
+
228
+ def close(self) -> None:
229
+ self._api_client.close()
230
+
231
+ def __enter__(self) -> ToriiClient:
232
+ return self
233
+
234
+ def __exit__(self, *_exc: Any) -> None:
235
+ self.close()
236
+
237
+
238
+ def create_torii_client(
239
+ *,
240
+ secret_key: str,
241
+ api_url: str | None = None,
242
+ ) -> ToriiClient:
243
+ """Build a torii backend client.
244
+
245
+ Example::
246
+
247
+ torii = create_torii_client(secret_key=os.environ["TORII_SECRET_KEY"])
248
+ user = torii.users.get("user_abc")
249
+
250
+ ``api_url`` defaults to ``https://api.torii.so``. Override for staging
251
+ or self-hosted.
252
+ """
253
+ if not secret_key:
254
+ raise ValueError("create_torii_client: secret_key is required")
255
+ config = Configuration(host=(api_url or DEFAULT_API_URL).rstrip("/"))
256
+ api_client = ApiClient(configuration=config)
257
+ # Spec doesn't declare a securityScheme, so authorise via a default
258
+ # header on every request rather than the ``access_token`` config slot.
259
+ api_client.set_default_header("Authorization", f"Bearer {secret_key}")
260
+ return ToriiClient(api_client)
261
+
262
+
263
+ def _coerce_uuid(value: str | UUID | None) -> Any:
264
+ """Generated methods accept UUID for path params. We let callers pass
265
+ either ``str`` or ``UUID`` and coerce here."""
266
+ if value is None:
267
+ return None
268
+ if isinstance(value, UUID):
269
+ return value
270
+ return UUID(value)
271
+
272
+
273
+ class _translate_api_error:
274
+ """Context manager: re-raise the generator's ``ApiException`` as our
275
+ stable ``ToriiApiError`` so callers don't depend on generator
276
+ internals to catch failures."""
277
+
278
+ def __enter__(self) -> _translate_api_error:
279
+ return self
280
+
281
+ def __exit__(self, exc_type, exc, _tb) -> bool:
282
+ if exc is None or not isinstance(exc, ApiException):
283
+ return False
284
+ body: Any = exc.body
285
+ if isinstance(body, bytes):
286
+ try:
287
+ body = body.decode("utf-8")
288
+ except UnicodeDecodeError:
289
+ body = None
290
+ if isinstance(body, str):
291
+ try:
292
+ import json
293
+
294
+ body = json.loads(body)
295
+ except ValueError:
296
+ pass
297
+ message = _extract_message(body) or f"torii {exc.status} {exc.reason or ''}".strip()
298
+ raise ToriiApiError(message, exc.status or 0, body) from exc
299
+
300
+
301
+ def _extract_message(body: Any) -> str | None:
302
+ if isinstance(body, dict):
303
+ for key in ("detail", "title", "message"):
304
+ value = body.get(key)
305
+ if isinstance(value, str):
306
+ return value
307
+ return None
@@ -0,0 +1,42 @@
1
+ """Error types raised by torii_backend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ class ToriiApiError(Exception):
9
+ """Raised when ``/api/server/v1/**`` responds non-2xx.
10
+
11
+ Inspect ``status``, ``code`` (from the RFC 7807 error body if present),
12
+ and ``support_id`` (echoed from the server's correlation id) for
13
+ diagnostics. ``body`` contains the raw parsed response.
14
+ """
15
+
16
+ def __init__(
17
+ self,
18
+ message: str,
19
+ status: int,
20
+ body: Any = None,
21
+ ):
22
+ super().__init__(message)
23
+ self.status = status
24
+ self.body = body
25
+ self.code: str | None = None
26
+ self.support_id: str | None = None
27
+ if isinstance(body, dict):
28
+ code = body.get("code")
29
+ support_id = body.get("supportId") or body.get("support_id")
30
+ if isinstance(code, str):
31
+ self.code = code
32
+ if isinstance(support_id, str):
33
+ self.support_id = support_id
34
+
35
+
36
+ class ToriiAuthError(Exception):
37
+ """Raised by ``verify_token`` / ``authenticate_request`` when a token
38
+ cannot be verified (bad signature, wrong issuer, missing claims, ...)."""
39
+
40
+ def __init__(self, message: str, cause: BaseException | None = None):
41
+ super().__init__(message)
42
+ self.cause = cause
@@ -0,0 +1,58 @@
1
+ """FastAPI dependency adapter.
2
+
3
+ ``fastapi`` is an optional install — depend on ``torii-backend[fastapi]``
4
+ to pull it in. Importing this module without FastAPI raises a clear
5
+ error so the dependency requirement is obvious.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Callable
11
+
12
+ try:
13
+ from fastapi import HTTPException, Request
14
+ except ImportError as e: # pragma: no cover - import guard
15
+ raise ImportError(
16
+ "torii_backend.fastapi requires fastapi. Install with `pip install torii-backend[fastapi]`."
17
+ ) from e
18
+
19
+ from torii_backend.errors import ToriiAuthError
20
+ from torii_backend.types import ToriiAuth
21
+ from torii_backend.verify import authenticate_request
22
+
23
+
24
+ def require_auth(
25
+ *,
26
+ issuer: str,
27
+ audience: str | list[str] | None = None,
28
+ leeway: float = 30.0,
29
+ ) -> Callable[[Request], ToriiAuth]:
30
+ """Return a FastAPI dependency that authenticates the request.
31
+
32
+ Example::
33
+
34
+ from fastapi import Depends, FastAPI
35
+ from torii_backend.fastapi import require_auth
36
+
37
+ app = FastAPI()
38
+
39
+ @app.get("/me")
40
+ def me(auth = Depends(require_auth(issuer="https://acme.torii.so"))):
41
+ return {"user_id": auth.user_id}
42
+ """
43
+
44
+ def dependency(request: Request) -> ToriiAuth:
45
+ try:
46
+ return authenticate_request(
47
+ request.headers,
48
+ issuer=issuer,
49
+ audience=audience,
50
+ leeway=leeway,
51
+ )
52
+ except ToriiAuthError as exc:
53
+ raise HTTPException(
54
+ status_code=401,
55
+ detail={"code": "authentication_failed", "message": str(exc)},
56
+ ) from exc
57
+
58
+ return dependency
@@ -0,0 +1,64 @@
1
+ # coding: utf-8
2
+
3
+ # flake8: noqa
4
+
5
+ """
6
+ OpenAPI definition
7
+
8
+ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
9
+
10
+ The version of the OpenAPI document: v0
11
+ Generated by OpenAPI Generator (https://openapi-generator.tech)
12
+
13
+ Do not edit the class manually.
14
+ """ # noqa: E501
15
+
16
+
17
+ __version__ = "1.0.0"
18
+
19
+ # Define package exports
20
+ __all__ = [
21
+ "ServerSessionsApi",
22
+ "ServerUsersApi",
23
+ "ApiResponse",
24
+ "ApiClient",
25
+ "Configuration",
26
+ "OpenApiException",
27
+ "ApiTypeError",
28
+ "ApiValueError",
29
+ "ApiKeyError",
30
+ "ApiAttributeError",
31
+ "ApiException",
32
+ "CreateUserRequest",
33
+ "CursorPageResponseUserResponse",
34
+ "ProblemDetail",
35
+ "ServerUserSearchRequest",
36
+ "UpdateUserRequest",
37
+ "UserResponse",
38
+ "UserSessionResponse",
39
+ ]
40
+
41
+ # import apis into sdk package
42
+ from torii_backend.generated.api.server_sessions_api import ServerSessionsApi as ServerSessionsApi
43
+ from torii_backend.generated.api.server_users_api import ServerUsersApi as ServerUsersApi
44
+
45
+ # import ApiClient
46
+ from torii_backend.generated.api_response import ApiResponse as ApiResponse
47
+ from torii_backend.generated.api_client import ApiClient as ApiClient
48
+ from torii_backend.generated.configuration import Configuration as Configuration
49
+ from torii_backend.generated.exceptions import OpenApiException as OpenApiException
50
+ from torii_backend.generated.exceptions import ApiTypeError as ApiTypeError
51
+ from torii_backend.generated.exceptions import ApiValueError as ApiValueError
52
+ from torii_backend.generated.exceptions import ApiKeyError as ApiKeyError
53
+ from torii_backend.generated.exceptions import ApiAttributeError as ApiAttributeError
54
+ from torii_backend.generated.exceptions import ApiException as ApiException
55
+
56
+ # import models into sdk package
57
+ from torii_backend.generated.models.create_user_request import CreateUserRequest as CreateUserRequest
58
+ from torii_backend.generated.models.cursor_page_response_user_response import CursorPageResponseUserResponse as CursorPageResponseUserResponse
59
+ from torii_backend.generated.models.problem_detail import ProblemDetail as ProblemDetail
60
+ from torii_backend.generated.models.server_user_search_request import ServerUserSearchRequest as ServerUserSearchRequest
61
+ from torii_backend.generated.models.update_user_request import UpdateUserRequest as UpdateUserRequest
62
+ from torii_backend.generated.models.user_response import UserResponse as UserResponse
63
+ from torii_backend.generated.models.user_session_response import UserSessionResponse as UserSessionResponse
64
+
@@ -0,0 +1,6 @@
1
+ # flake8: noqa
2
+
3
+ # import apis into api package
4
+ from torii_backend.generated.api.server_sessions_api import ServerSessionsApi
5
+ from torii_backend.generated.api.server_users_api import ServerUsersApi
6
+