rafikidb 0.1.0__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.
- rafikidb-0.1.0/.gitignore +10 -0
- rafikidb-0.1.0/PKG-INFO +162 -0
- rafikidb-0.1.0/README.md +145 -0
- rafikidb-0.1.0/pyproject.toml +28 -0
- rafikidb-0.1.0/rafikidb/__init__.py +78 -0
- rafikidb-0.1.0/rafikidb/auth.py +107 -0
- rafikidb-0.1.0/rafikidb/client.py +115 -0
- rafikidb-0.1.0/rafikidb/data.py +220 -0
- rafikidb-0.1.0/rafikidb/modules.py +185 -0
- rafikidb-0.1.0/rafikidb/realtime.py +174 -0
- rafikidb-0.1.0/rafikidb/types.py +175 -0
- rafikidb-0.1.0/tests/test_sdk.py +139 -0
rafikidb-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rafikidb
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Typed Python client for RafikiDB - data, auth, realtime, storage, env vars, secrets, webhooks, edge functions and payments.
|
|
5
|
+
Author: RafikiDB
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: baas,backend,database,rafikidb,realtime
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Provides-Extra: realtime
|
|
13
|
+
Requires-Dist: websockets>=12.0; extra == 'realtime'
|
|
14
|
+
Provides-Extra: test
|
|
15
|
+
Requires-Dist: pytest>=8.0; extra == 'test'
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# rafikidb
|
|
19
|
+
|
|
20
|
+
Typed Python client for [RafikiDB](https://rafikidb.com) - data, auth,
|
|
21
|
+
realtime, storage, environment variables, secrets, webhooks, edge functions
|
|
22
|
+
and payments.
|
|
23
|
+
|
|
24
|
+
- **Zero required dependencies** (stdlib HTTP)
|
|
25
|
+
- Optional `websockets` for the WebSocket realtime transport
|
|
26
|
+
- Same envelope and error model as the REST API
|
|
27
|
+
- Python 3.10+
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install rafikidb # core
|
|
33
|
+
pip install rafikidb[realtime] # with WebSocket realtime
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Quick start
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from rafikidb import create_client
|
|
40
|
+
|
|
41
|
+
db = create_client(
|
|
42
|
+
project_id="5c73dab0-...",
|
|
43
|
+
api_key="raf_live_...",
|
|
44
|
+
base_url="https://api.rafikidb.com/api/v1", # optional
|
|
45
|
+
)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Every call returns an `Envelope`:
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
rows = db.from_("profiles").select("id, nationality").eq("nationality", "Tanzania").limit(25).execute()
|
|
52
|
+
rows.success # True
|
|
53
|
+
rows.data # list of dicts
|
|
54
|
+
rows.message # "Request was successful"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Failures raise `RafikiDBError` with `message`, `status`, `code` and `errors`.
|
|
58
|
+
|
|
59
|
+
## Data
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
# Filters: eq, neq, gt, gte, lt, lte, like, ilike, is_null, is_not_null, in_
|
|
63
|
+
db.from_("profiles").eq("nationality", "Tanzania").is_not_null("user_id")
|
|
64
|
+
|
|
65
|
+
# Ordering and pagination
|
|
66
|
+
db.from_("profiles").order("created_at", ascending=False).limit(50).offset(100)
|
|
67
|
+
|
|
68
|
+
# Keyset pagination (O(1) per page on large tables)
|
|
69
|
+
db.from_("logs").select("id").limit(100).cursor(last_id)
|
|
70
|
+
|
|
71
|
+
# Joins (column-based, no SQL)
|
|
72
|
+
from rafikidb import JoinSpec
|
|
73
|
+
rows = (
|
|
74
|
+
db.from_("messages")
|
|
75
|
+
.select("id as message_id, message, users.full_name as full_name")
|
|
76
|
+
.join(JoinSpec(table="users", from_column="user_id", to_column="id"))
|
|
77
|
+
.limit(20)
|
|
78
|
+
).execute()
|
|
79
|
+
# rows.data[i]["full_name"]
|
|
80
|
+
|
|
81
|
+
# Row operations
|
|
82
|
+
db.from_("profiles").get(record_id)
|
|
83
|
+
db.from_("profiles").single()
|
|
84
|
+
db.from_("profiles").head() # {"count": n}
|
|
85
|
+
db.from_("profiles").insert({...}) # single or list
|
|
86
|
+
db.from_("profiles").update({...}).eq("id", record_id)
|
|
87
|
+
db.from_("profiles").delete().eq("id", record_id)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Auth
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
session = db.auth.signup(
|
|
94
|
+
email="user@example.com", password="strong-pass1",
|
|
95
|
+
full_name="Asha Mwinyi", phone="+255712345678",
|
|
96
|
+
)
|
|
97
|
+
session = db.auth.login(email="user@example.com", password="strong-pass1")
|
|
98
|
+
|
|
99
|
+
# The session is stored on the client and sent with every request,
|
|
100
|
+
# so RLS sees the logged-in user.
|
|
101
|
+
db.client.access_token # the project-user JWT
|
|
102
|
+
|
|
103
|
+
db.auth.sign_out()
|
|
104
|
+
|
|
105
|
+
# OTP
|
|
106
|
+
db.auth.otp_request("+255712345678")
|
|
107
|
+
db.auth.otp_verify(phone="+255712345678", code="123456", full_name="Asha Mwinyi")
|
|
108
|
+
|
|
109
|
+
# Password reset
|
|
110
|
+
db.auth.reset_password("user@example.com")
|
|
111
|
+
db.auth.confirm_reset_password(email="user@example.com", code="123456", new_password="brand-new-pass1")
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Realtime
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
def on_event(event):
|
|
118
|
+
print(event.type, event.table, event.record)
|
|
119
|
+
|
|
120
|
+
sub = db.realtime.subscribe("messages", on_event, events=["INSERT", "DELETE"])
|
|
121
|
+
|
|
122
|
+
# Stop listening (or sub.wait() to block)
|
|
123
|
+
sub.close()
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Default transport: WebSocket when `websockets` is installed, SSE otherwise.
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
db.realtime.subscribe("messages", on_event, transport="sse")
|
|
130
|
+
db.realtime.subscribe("messages", on_event, transport="websocket")
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Storage / env / secrets / webhooks / functions / payments
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
db.storage.create_bucket(name="Avatars", slug="avatars", is_public=True)
|
|
137
|
+
db.storage.signed_upload_url(bucket_id=..., object_name="user-1.png")
|
|
138
|
+
|
|
139
|
+
db.env.set("STRIPE_KEY", "sk_test_123", environment="production")
|
|
140
|
+
db.env.bulk_set("development", {"DEBUG": "true"})
|
|
141
|
+
|
|
142
|
+
db.secrets.set(key="API_SECRET", value="s3cr3t")
|
|
143
|
+
db.secrets.reveal(secret_id)
|
|
144
|
+
|
|
145
|
+
db.webhooks.create(name="order.created", url="https://myapp.com/hooks/orders", events=["order.created"])
|
|
146
|
+
db.webhooks.list_deliveries(webhook_id)
|
|
147
|
+
|
|
148
|
+
db.functions.create(name="hello", runtime="javascript", code="...")
|
|
149
|
+
db.functions.deploy(fn_id)
|
|
150
|
+
db.functions.invoke(fn_id, body='{"name":"Asha"}')
|
|
151
|
+
|
|
152
|
+
db.payments.stk_push(phone="+255712345678", amount=5000, description="Order #123")
|
|
153
|
+
db.payments.snippe_status(reference)
|
|
154
|
+
db.payments.list_transactions()
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Development
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
pip install -e ".[test]"
|
|
161
|
+
python -m unittest discover -s tests -q
|
|
162
|
+
```
|
rafikidb-0.1.0/README.md
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# rafikidb
|
|
2
|
+
|
|
3
|
+
Typed Python client for [RafikiDB](https://rafikidb.com) - data, auth,
|
|
4
|
+
realtime, storage, environment variables, secrets, webhooks, edge functions
|
|
5
|
+
and payments.
|
|
6
|
+
|
|
7
|
+
- **Zero required dependencies** (stdlib HTTP)
|
|
8
|
+
- Optional `websockets` for the WebSocket realtime transport
|
|
9
|
+
- Same envelope and error model as the REST API
|
|
10
|
+
- Python 3.10+
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install rafikidb # core
|
|
16
|
+
pip install rafikidb[realtime] # with WebSocket realtime
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from rafikidb import create_client
|
|
23
|
+
|
|
24
|
+
db = create_client(
|
|
25
|
+
project_id="5c73dab0-...",
|
|
26
|
+
api_key="raf_live_...",
|
|
27
|
+
base_url="https://api.rafikidb.com/api/v1", # optional
|
|
28
|
+
)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Every call returns an `Envelope`:
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
rows = db.from_("profiles").select("id, nationality").eq("nationality", "Tanzania").limit(25).execute()
|
|
35
|
+
rows.success # True
|
|
36
|
+
rows.data # list of dicts
|
|
37
|
+
rows.message # "Request was successful"
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Failures raise `RafikiDBError` with `message`, `status`, `code` and `errors`.
|
|
41
|
+
|
|
42
|
+
## Data
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
# Filters: eq, neq, gt, gte, lt, lte, like, ilike, is_null, is_not_null, in_
|
|
46
|
+
db.from_("profiles").eq("nationality", "Tanzania").is_not_null("user_id")
|
|
47
|
+
|
|
48
|
+
# Ordering and pagination
|
|
49
|
+
db.from_("profiles").order("created_at", ascending=False).limit(50).offset(100)
|
|
50
|
+
|
|
51
|
+
# Keyset pagination (O(1) per page on large tables)
|
|
52
|
+
db.from_("logs").select("id").limit(100).cursor(last_id)
|
|
53
|
+
|
|
54
|
+
# Joins (column-based, no SQL)
|
|
55
|
+
from rafikidb import JoinSpec
|
|
56
|
+
rows = (
|
|
57
|
+
db.from_("messages")
|
|
58
|
+
.select("id as message_id, message, users.full_name as full_name")
|
|
59
|
+
.join(JoinSpec(table="users", from_column="user_id", to_column="id"))
|
|
60
|
+
.limit(20)
|
|
61
|
+
).execute()
|
|
62
|
+
# rows.data[i]["full_name"]
|
|
63
|
+
|
|
64
|
+
# Row operations
|
|
65
|
+
db.from_("profiles").get(record_id)
|
|
66
|
+
db.from_("profiles").single()
|
|
67
|
+
db.from_("profiles").head() # {"count": n}
|
|
68
|
+
db.from_("profiles").insert({...}) # single or list
|
|
69
|
+
db.from_("profiles").update({...}).eq("id", record_id)
|
|
70
|
+
db.from_("profiles").delete().eq("id", record_id)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Auth
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
session = db.auth.signup(
|
|
77
|
+
email="user@example.com", password="strong-pass1",
|
|
78
|
+
full_name="Asha Mwinyi", phone="+255712345678",
|
|
79
|
+
)
|
|
80
|
+
session = db.auth.login(email="user@example.com", password="strong-pass1")
|
|
81
|
+
|
|
82
|
+
# The session is stored on the client and sent with every request,
|
|
83
|
+
# so RLS sees the logged-in user.
|
|
84
|
+
db.client.access_token # the project-user JWT
|
|
85
|
+
|
|
86
|
+
db.auth.sign_out()
|
|
87
|
+
|
|
88
|
+
# OTP
|
|
89
|
+
db.auth.otp_request("+255712345678")
|
|
90
|
+
db.auth.otp_verify(phone="+255712345678", code="123456", full_name="Asha Mwinyi")
|
|
91
|
+
|
|
92
|
+
# Password reset
|
|
93
|
+
db.auth.reset_password("user@example.com")
|
|
94
|
+
db.auth.confirm_reset_password(email="user@example.com", code="123456", new_password="brand-new-pass1")
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Realtime
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
def on_event(event):
|
|
101
|
+
print(event.type, event.table, event.record)
|
|
102
|
+
|
|
103
|
+
sub = db.realtime.subscribe("messages", on_event, events=["INSERT", "DELETE"])
|
|
104
|
+
|
|
105
|
+
# Stop listening (or sub.wait() to block)
|
|
106
|
+
sub.close()
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Default transport: WebSocket when `websockets` is installed, SSE otherwise.
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
db.realtime.subscribe("messages", on_event, transport="sse")
|
|
113
|
+
db.realtime.subscribe("messages", on_event, transport="websocket")
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Storage / env / secrets / webhooks / functions / payments
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
db.storage.create_bucket(name="Avatars", slug="avatars", is_public=True)
|
|
120
|
+
db.storage.signed_upload_url(bucket_id=..., object_name="user-1.png")
|
|
121
|
+
|
|
122
|
+
db.env.set("STRIPE_KEY", "sk_test_123", environment="production")
|
|
123
|
+
db.env.bulk_set("development", {"DEBUG": "true"})
|
|
124
|
+
|
|
125
|
+
db.secrets.set(key="API_SECRET", value="s3cr3t")
|
|
126
|
+
db.secrets.reveal(secret_id)
|
|
127
|
+
|
|
128
|
+
db.webhooks.create(name="order.created", url="https://myapp.com/hooks/orders", events=["order.created"])
|
|
129
|
+
db.webhooks.list_deliveries(webhook_id)
|
|
130
|
+
|
|
131
|
+
db.functions.create(name="hello", runtime="javascript", code="...")
|
|
132
|
+
db.functions.deploy(fn_id)
|
|
133
|
+
db.functions.invoke(fn_id, body='{"name":"Asha"}')
|
|
134
|
+
|
|
135
|
+
db.payments.stk_push(phone="+255712345678", amount=5000, description="Order #123")
|
|
136
|
+
db.payments.snippe_status(reference)
|
|
137
|
+
db.payments.list_transactions()
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Development
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
pip install -e ".[test]"
|
|
144
|
+
python -m unittest discover -s tests -q
|
|
145
|
+
```
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "rafikidb"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Typed Python client for RafikiDB - data, auth, realtime, storage, env vars, secrets, webhooks, edge functions and payments."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "RafikiDB" }]
|
|
13
|
+
keywords = ["rafikidb", "baas", "backend", "database", "realtime"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
realtime = ["websockets>=12.0"]
|
|
22
|
+
test = ["pytest>=8.0"]
|
|
23
|
+
|
|
24
|
+
[tool.hatch.build.targets.wheel]
|
|
25
|
+
packages = ["rafikidb"]
|
|
26
|
+
|
|
27
|
+
[tool.pytest.ini_options]
|
|
28
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""RafikiDB Python SDK - typed client for data, auth, realtime, storage, env
|
|
2
|
+
vars, secrets, webhooks, edge functions and payments."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Any, Dict, Optional, TypeVar
|
|
7
|
+
|
|
8
|
+
from .client import DEFAULT_BASE, RafikiDBClient
|
|
9
|
+
from .types import (
|
|
10
|
+
AuthSession,
|
|
11
|
+
Envelope,
|
|
12
|
+
JoinSpec,
|
|
13
|
+
ProjectTokens,
|
|
14
|
+
ProjectUser,
|
|
15
|
+
RafikiDBError,
|
|
16
|
+
RealtimeEvent,
|
|
17
|
+
Record,
|
|
18
|
+
)
|
|
19
|
+
from .data import DataBuilder
|
|
20
|
+
from .auth import Auth
|
|
21
|
+
from .realtime import Realtime, Subscription
|
|
22
|
+
from .modules import Storage, Env, Secrets, Webhooks, Functions, Payments
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"create_client",
|
|
26
|
+
"RafikiDBError",
|
|
27
|
+
"Envelope",
|
|
28
|
+
"AuthSession",
|
|
29
|
+
"ProjectUser",
|
|
30
|
+
"ProjectTokens",
|
|
31
|
+
"RealtimeEvent",
|
|
32
|
+
"Record",
|
|
33
|
+
"JoinSpec",
|
|
34
|
+
"DEFAULT_BASE",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
R = TypeVar("R", bound=Record)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class RafikiDB:
|
|
41
|
+
"""Facade returned by create_client()."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, client: RafikiDBClient):
|
|
44
|
+
self.client = client
|
|
45
|
+
self.auth = Auth(client)
|
|
46
|
+
self.realtime = Realtime(client)
|
|
47
|
+
self.storage = Storage(client)
|
|
48
|
+
self.env = Env(client)
|
|
49
|
+
self.secrets = Secrets(client)
|
|
50
|
+
self.webhooks = Webhooks(client)
|
|
51
|
+
self.functions = Functions(client)
|
|
52
|
+
self.payments = Payments(client)
|
|
53
|
+
|
|
54
|
+
def from_(self, table: str) -> DataBuilder:
|
|
55
|
+
"""Typed data builder: `db.from_("messages").select(...).eq(...)`"""
|
|
56
|
+
return DataBuilder(self.client, table)
|
|
57
|
+
|
|
58
|
+
def request(self, path: str, method: str = "GET", body: Optional[Dict[str, Any]] = None) -> Envelope[Any]:
|
|
59
|
+
return self.client.request(path, method=method, body=body)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def create_client(
|
|
63
|
+
project_id: str,
|
|
64
|
+
api_key: str,
|
|
65
|
+
base_url: Optional[str] = None,
|
|
66
|
+
session: Optional[Dict[str, Any]] = None,
|
|
67
|
+
timeout: float = 30.0,
|
|
68
|
+
) -> RafikiDB:
|
|
69
|
+
"""Create a typed RafikiDB client.
|
|
70
|
+
|
|
71
|
+
Example:
|
|
72
|
+
db = create_client(
|
|
73
|
+
project_id="5c73dab0-...",
|
|
74
|
+
api_key="raf_live_...",
|
|
75
|
+
base_url="https://api.rafikidb.com/api/v1",
|
|
76
|
+
)
|
|
77
|
+
"""
|
|
78
|
+
return RafikiDB(RafikiDBClient(project_id, api_key, base_url, session, timeout))
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Auth flows for the RafikiDB Python SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
from .client import RafikiDBClient
|
|
8
|
+
from .types import AuthSession, Envelope, ProjectTokens
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Auth:
|
|
12
|
+
"""Project auth: signup, login, OTP, password reset. Sessions are stored
|
|
13
|
+
on the client automatically and sent with every request."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, client: RafikiDBClient):
|
|
16
|
+
self._client = client
|
|
17
|
+
|
|
18
|
+
def signup(
|
|
19
|
+
self,
|
|
20
|
+
email: str,
|
|
21
|
+
password: str,
|
|
22
|
+
full_name: str,
|
|
23
|
+
phone: str,
|
|
24
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
25
|
+
) -> Envelope[AuthSession]:
|
|
26
|
+
env = self._client.post(
|
|
27
|
+
f"/projects/{self._client.project_id}/auth/signup",
|
|
28
|
+
body={
|
|
29
|
+
"email": email,
|
|
30
|
+
"password": password,
|
|
31
|
+
"full_name": full_name,
|
|
32
|
+
"phone": phone,
|
|
33
|
+
"metadata": metadata,
|
|
34
|
+
},
|
|
35
|
+
)
|
|
36
|
+
session_env = self._to_session(env)
|
|
37
|
+
self._store_session(session_env)
|
|
38
|
+
return session_env
|
|
39
|
+
|
|
40
|
+
def login(self, email: str, password: str) -> Envelope[AuthSession]:
|
|
41
|
+
"""Sign an end-user of THIS project in (POST /auth/signin)."""
|
|
42
|
+
env = self._client.post(
|
|
43
|
+
f"/projects/{self._client.project_id}/auth/signin",
|
|
44
|
+
body={"email": email, "password": password},
|
|
45
|
+
)
|
|
46
|
+
session_env = self._to_session(env)
|
|
47
|
+
self._store_session(session_env)
|
|
48
|
+
return session_env
|
|
49
|
+
|
|
50
|
+
signin = login
|
|
51
|
+
|
|
52
|
+
def otp_request(self, phone: str) -> Envelope[Dict[str, Any]]:
|
|
53
|
+
return self._client.post(
|
|
54
|
+
f"/projects/{self._client.project_id}/auth/otp/request",
|
|
55
|
+
body={"phone": phone},
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def otp_verify(self, phone: str, code: str, full_name: str, metadata: Optional[Dict[str, Any]] = None) -> Envelope[AuthSession]:
|
|
59
|
+
env = self._client.post(
|
|
60
|
+
f"/projects/{self._client.project_id}/auth/otp/verify",
|
|
61
|
+
body={"phone": phone, "code": code, "full_name": full_name, "metadata": metadata},
|
|
62
|
+
)
|
|
63
|
+
session_env = self._to_session(env)
|
|
64
|
+
self._store_session(session_env)
|
|
65
|
+
return session_env
|
|
66
|
+
|
|
67
|
+
def reset_password(self, email: str) -> Envelope[Dict[str, Any]]:
|
|
68
|
+
return self._client.post(
|
|
69
|
+
f"/projects/{self._client.project_id}/auth/reset-password",
|
|
70
|
+
body={"email": email},
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def confirm_reset_password(self, email: str, code: str, new_password: str) -> Envelope[Dict[str, Any]]:
|
|
74
|
+
return self._client.post(
|
|
75
|
+
f"/projects/{self._client.project_id}/auth/reset-password/confirm",
|
|
76
|
+
body={"email": email, "code": code, "new_password": new_password},
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def sign_out(self) -> None:
|
|
80
|
+
"""Clear the stored project-user session."""
|
|
81
|
+
self._client.set_session(None)
|
|
82
|
+
|
|
83
|
+
def refresh(self, refresh_token: str) -> Envelope[ProjectTokens]:
|
|
84
|
+
return self._client.post("/auth/refresh", body={"refresh_token": refresh_token})
|
|
85
|
+
|
|
86
|
+
def logout(self, refresh_token: str) -> Envelope[None]:
|
|
87
|
+
return self._client.post("/auth/logout", body={"refresh_token": refresh_token})
|
|
88
|
+
|
|
89
|
+
# -- internals ----------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
def _store_session(self, env: Envelope[AuthSession]) -> None:
|
|
92
|
+
if env.data and env.data.tokens.access_token:
|
|
93
|
+
self._client.set_session(
|
|
94
|
+
{
|
|
95
|
+
"user": {
|
|
96
|
+
k: v for k, v in env.data.user.__dict__.items()
|
|
97
|
+
if not k.startswith("_")
|
|
98
|
+
},
|
|
99
|
+
"tokens": env.data.tokens.__dict__,
|
|
100
|
+
}
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def _to_session(self, env: Envelope[Any]) -> Envelope[AuthSession]:
|
|
104
|
+
if env.data and isinstance(env.data, dict):
|
|
105
|
+
session = AuthSession.from_payload(env.data)
|
|
106
|
+
return Envelope(env.success, env.message, session, env.errors)
|
|
107
|
+
return Envelope(env.success, env.message, env.data, env.errors)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""HTTP client for the RafikiDB Python SDK (stdlib only)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.parse
|
|
8
|
+
import urllib.request
|
|
9
|
+
from typing import Any, Dict, Optional
|
|
10
|
+
|
|
11
|
+
from .types import Envelope, RafikiDBError
|
|
12
|
+
|
|
13
|
+
DEFAULT_BASE = "http://localhost:8080/api/v1"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RafikiDBClient:
|
|
17
|
+
"""Low-level HTTP client. Sends the API key and optional session token."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
project_id: str,
|
|
22
|
+
api_key: str,
|
|
23
|
+
base_url: Optional[str] = None,
|
|
24
|
+
session: Optional[Dict[str, Any]] = None,
|
|
25
|
+
timeout: float = 30.0,
|
|
26
|
+
) -> None:
|
|
27
|
+
self.project_id = project_id
|
|
28
|
+
self.api_key = api_key
|
|
29
|
+
self.base_url = (base_url or DEFAULT_BASE).rstrip("/")
|
|
30
|
+
self.timeout = timeout
|
|
31
|
+
self._session: Optional[Dict[str, Any]] = session
|
|
32
|
+
|
|
33
|
+
# -- session ------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def session(self) -> Optional[Dict[str, Any]]:
|
|
37
|
+
return self._session
|
|
38
|
+
|
|
39
|
+
def set_session(self, session: Optional[Dict[str, Any]]) -> None:
|
|
40
|
+
self._session = session
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def access_token(self) -> Optional[str]:
|
|
44
|
+
if self._session:
|
|
45
|
+
return self._session.get("tokens", {}).get("access_token")
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
# -- request ------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
def request(
|
|
51
|
+
self,
|
|
52
|
+
path: str,
|
|
53
|
+
method: str = "GET",
|
|
54
|
+
body: Optional[Dict[str, Any]] = None,
|
|
55
|
+
query: Optional[Dict[str, str]] = None,
|
|
56
|
+
) -> Envelope[Any]:
|
|
57
|
+
url = self.base_url + path
|
|
58
|
+
if query:
|
|
59
|
+
url += "?" + urllib.parse.urlencode(query)
|
|
60
|
+
|
|
61
|
+
headers = {
|
|
62
|
+
"X-AFRIBASE-API-Key": self.api_key,
|
|
63
|
+
"Content-Type": "application/json",
|
|
64
|
+
"Accept": "application/json",
|
|
65
|
+
}
|
|
66
|
+
token = self.access_token
|
|
67
|
+
if token:
|
|
68
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
69
|
+
|
|
70
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
71
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
72
|
+
|
|
73
|
+
status: Optional[int] = None
|
|
74
|
+
payload: Dict[str, Any] = {}
|
|
75
|
+
try:
|
|
76
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
77
|
+
status = resp.status
|
|
78
|
+
raw = resp.read()
|
|
79
|
+
if raw:
|
|
80
|
+
payload = json.loads(raw)
|
|
81
|
+
except urllib.error.HTTPError as err:
|
|
82
|
+
status = err.code
|
|
83
|
+
raw = err.read()
|
|
84
|
+
try:
|
|
85
|
+
payload = json.loads(raw)
|
|
86
|
+
except json.JSONDecodeError:
|
|
87
|
+
payload = {}
|
|
88
|
+
except urllib.error.URLError as err:
|
|
89
|
+
raise RafikiDBError(f"Connection error: {err.reason}", 0) from err
|
|
90
|
+
|
|
91
|
+
envelope = Envelope.from_payload(payload)
|
|
92
|
+
if not envelope.success or (status is not None and status >= 400):
|
|
93
|
+
raise RafikiDBError(
|
|
94
|
+
envelope.message or f"Request failed ({status})",
|
|
95
|
+
status or 500,
|
|
96
|
+
envelope.errors,
|
|
97
|
+
)
|
|
98
|
+
return envelope
|
|
99
|
+
|
|
100
|
+
# -- typed convenience ----------------------------------------------
|
|
101
|
+
|
|
102
|
+
def get(self, path: str, query: Optional[Dict[str, str]] = None) -> Envelope[Any]:
|
|
103
|
+
return self.request(path, "GET", query=query)
|
|
104
|
+
|
|
105
|
+
def post(self, path: str, body: Optional[Dict[str, Any]] = None) -> Envelope[Any]:
|
|
106
|
+
return self.request(path, "POST", body=body)
|
|
107
|
+
|
|
108
|
+
def patch(self, path: str, body: Optional[Dict[str, Any]] = None) -> Envelope[Any]:
|
|
109
|
+
return self.request(path, "PATCH", body=body)
|
|
110
|
+
|
|
111
|
+
def put(self, path: str, body: Optional[Dict[str, Any]] = None) -> Envelope[Any]:
|
|
112
|
+
return self.request(path, "PUT", body=body)
|
|
113
|
+
|
|
114
|
+
def delete(self, path: str) -> Envelope[Any]:
|
|
115
|
+
return self.request(path, "DELETE")
|