reflex-junction 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.
- reflex_junction/__init__.py +33 -0
- reflex_junction/base.py +7 -0
- reflex_junction/fastapi_helpers.py +83 -0
- reflex_junction/junction_provider.py +375 -0
- reflex_junction/models.py +39 -0
- reflex_junction-0.1.0.dist-info/METADATA +193 -0
- reflex_junction-0.1.0.dist-info/RECORD +9 -0
- reflex_junction-0.1.0.dist-info/WHEEL +4 -0
- reflex_junction-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
2
|
+
|
|
3
|
+
from .fastapi_helpers import (
|
|
4
|
+
create_webhook_router,
|
|
5
|
+
register_webhook_api,
|
|
6
|
+
)
|
|
7
|
+
from .junction_provider import (
|
|
8
|
+
JunctionState,
|
|
9
|
+
JunctionUser,
|
|
10
|
+
junction_provider,
|
|
11
|
+
on_load,
|
|
12
|
+
register_on_auth_change_handler,
|
|
13
|
+
wrap_app,
|
|
14
|
+
)
|
|
15
|
+
from .models import (
|
|
16
|
+
JunctionConfig,
|
|
17
|
+
LinkConfig,
|
|
18
|
+
ProviderInfo,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"JunctionConfig",
|
|
23
|
+
"JunctionState",
|
|
24
|
+
"JunctionUser",
|
|
25
|
+
"LinkConfig",
|
|
26
|
+
"ProviderInfo",
|
|
27
|
+
"create_webhook_router",
|
|
28
|
+
"junction_provider",
|
|
29
|
+
"on_load",
|
|
30
|
+
"register_on_auth_change_handler",
|
|
31
|
+
"register_webhook_api",
|
|
32
|
+
"wrap_app",
|
|
33
|
+
]
|
reflex_junction/base.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""FastAPI helpers for Junction webhook handling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import reflex as rx
|
|
10
|
+
from fastapi import APIRouter, FastAPI, Request
|
|
11
|
+
from fastapi.responses import JSONResponse
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def create_webhook_router(
|
|
17
|
+
prefix: str = "/junction",
|
|
18
|
+
secret: str = "",
|
|
19
|
+
tags: Sequence[str] | None = None,
|
|
20
|
+
) -> APIRouter:
|
|
21
|
+
"""Create a FastAPI router for Junction webhook handling.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
prefix: URL prefix for webhook endpoints.
|
|
25
|
+
secret: Svix webhook signing secret for signature verification.
|
|
26
|
+
tags: OpenAPI tags for the router.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
A FastAPI APIRouter with webhook endpoints.
|
|
30
|
+
"""
|
|
31
|
+
# secret stored for Phase 5 Svix signature verification
|
|
32
|
+
_webhook_secret = secret
|
|
33
|
+
logger.warning(
|
|
34
|
+
"Junction webhook signature verification is not yet implemented. "
|
|
35
|
+
"All requests to %s/webhooks will be accepted without verification.",
|
|
36
|
+
prefix,
|
|
37
|
+
)
|
|
38
|
+
if tags is None:
|
|
39
|
+
tags = ["junction"]
|
|
40
|
+
router = APIRouter(prefix=prefix, tags=list(tags))
|
|
41
|
+
|
|
42
|
+
@router.post("/webhooks")
|
|
43
|
+
async def junction_webhook_handler(request: Request) -> JSONResponse:
|
|
44
|
+
"""Handle incoming Junction webhook events.
|
|
45
|
+
|
|
46
|
+
Full implementation with Svix signature verification
|
|
47
|
+
will be added in Phase 5.
|
|
48
|
+
"""
|
|
49
|
+
body: dict[str, Any] = await request.json()
|
|
50
|
+
event_type = body.get("event_type", "unknown")
|
|
51
|
+
logger.info("Received Junction webhook: %s", event_type)
|
|
52
|
+
return JSONResponse(content={"status": "ok"}, status_code=200)
|
|
53
|
+
|
|
54
|
+
return router
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def register_webhook_api(
|
|
58
|
+
app: rx.App,
|
|
59
|
+
secret: str,
|
|
60
|
+
prefix: str = "/junction",
|
|
61
|
+
tags: list[str] | None = None,
|
|
62
|
+
) -> APIRouter:
|
|
63
|
+
"""Register the Junction webhook API on a Reflex app.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
app: The Reflex app to register webhooks on.
|
|
67
|
+
secret: Svix webhook signing secret.
|
|
68
|
+
prefix: URL prefix for webhook endpoints.
|
|
69
|
+
tags: OpenAPI tags.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
The registered APIRouter.
|
|
73
|
+
"""
|
|
74
|
+
router = create_webhook_router(prefix=prefix, secret=secret, tags=tags)
|
|
75
|
+
|
|
76
|
+
if isinstance(app.api_transformer, FastAPI):
|
|
77
|
+
app.api_transformer.include_router(router)
|
|
78
|
+
else:
|
|
79
|
+
fastapi_app = FastAPI()
|
|
80
|
+
fastapi_app.include_router(router)
|
|
81
|
+
app.api_transformer = fastapi_app
|
|
82
|
+
|
|
83
|
+
return router
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
"""Core Junction state management and app integration for Reflex."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from typing import Any, ClassVar
|
|
10
|
+
|
|
11
|
+
import reflex as rx
|
|
12
|
+
from reflex.event import EventCallback, EventType
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
# Environment mapping: string name -> VitalEnvironment enum value
|
|
17
|
+
_ENVIRONMENT_MAP: dict[str, str] = {
|
|
18
|
+
"sandbox": "sandbox",
|
|
19
|
+
"production": "production",
|
|
20
|
+
"sandbox_eu": "sandbox_eu",
|
|
21
|
+
"production_eu": "production_eu",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class MissingApiKeyError(ValueError):
|
|
26
|
+
"""Raised when the Junction API key is not set."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class JunctionState(rx.State):
|
|
30
|
+
"""Core state for Junction health data integration.
|
|
31
|
+
|
|
32
|
+
Manages the Junction API client, user mapping, and provider connections.
|
|
33
|
+
Configuration is stored as ClassVars (process-level singletons).
|
|
34
|
+
Per-session state tracks the current user's Junction data.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
# Per-session state vars (serialized by Reflex)
|
|
38
|
+
junction_user_id: str = ""
|
|
39
|
+
client_user_id: str = ""
|
|
40
|
+
connected_sources: list[dict[str, Any]] = []
|
|
41
|
+
is_initialized: bool = False
|
|
42
|
+
_link_token: str = ""
|
|
43
|
+
_link_web_url: str = ""
|
|
44
|
+
|
|
45
|
+
# ClassVars — process-level singletons, NOT per-session
|
|
46
|
+
_api_key: ClassVar[str | None] = None
|
|
47
|
+
_environment: ClassVar[str] = "sandbox"
|
|
48
|
+
_client: ClassVar[Any | None] = None # AsyncVital, typed as Any to avoid import at module level
|
|
49
|
+
_on_load_events: ClassVar[dict[uuid.UUID, list[EventType[()]]]] = {}
|
|
50
|
+
_dependent_handlers: ClassVar[dict[int, EventCallback]] = {}
|
|
51
|
+
_init_wait_timeout_seconds: ClassVar[float] = 1.0
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def _set_api_key(cls, api_key: str) -> None:
|
|
55
|
+
"""Set the Junction API key (process-level singleton)."""
|
|
56
|
+
if not api_key:
|
|
57
|
+
raise MissingApiKeyError("api_key must be set (and not empty)")
|
|
58
|
+
cls._api_key = api_key
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def _set_environment(cls, environment: str) -> None:
|
|
62
|
+
"""Set the Junction environment."""
|
|
63
|
+
if environment not in _ENVIRONMENT_MAP:
|
|
64
|
+
logger.warning(
|
|
65
|
+
"Unknown environment '%s', defaulting to 'sandbox'. "
|
|
66
|
+
"Valid options: %s",
|
|
67
|
+
environment,
|
|
68
|
+
list(_ENVIRONMENT_MAP.keys()),
|
|
69
|
+
)
|
|
70
|
+
environment = "sandbox"
|
|
71
|
+
cls._environment = environment
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def _set_client(cls) -> None:
|
|
75
|
+
"""Initialize the AsyncVital client (lazy, called once)."""
|
|
76
|
+
from vital.client import AsyncVital
|
|
77
|
+
from vital.environment import VitalEnvironment
|
|
78
|
+
|
|
79
|
+
if cls._api_key is None:
|
|
80
|
+
raise MissingApiKeyError(
|
|
81
|
+
"Junction API key not set. Call wrap_app() or junction_provider() first."
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
env_map = {
|
|
85
|
+
"sandbox": VitalEnvironment.SANDBOX,
|
|
86
|
+
"production": VitalEnvironment.PRODUCTION,
|
|
87
|
+
"sandbox_eu": VitalEnvironment.SANDBOX_EU,
|
|
88
|
+
"production_eu": VitalEnvironment.PRODUCTION_EU,
|
|
89
|
+
}
|
|
90
|
+
vital_env = env_map.get(cls._environment, VitalEnvironment.SANDBOX)
|
|
91
|
+
cls._client = AsyncVital(api_key=cls._api_key, environment=vital_env)
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def client(self) -> Any:
|
|
95
|
+
"""Get the AsyncVital client, initializing lazily if needed."""
|
|
96
|
+
if self._client is None:
|
|
97
|
+
self._set_client()
|
|
98
|
+
return self._client
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def register_dependent_handler(cls, handler: EventCallback) -> None:
|
|
102
|
+
"""Register a handler to be called after initialization.
|
|
103
|
+
|
|
104
|
+
Uses hash-based dedup to prevent double-registration.
|
|
105
|
+
"""
|
|
106
|
+
if not isinstance(handler, rx.EventHandler):
|
|
107
|
+
raise TypeError(f"Expected EventHandler, got {type(handler)}")
|
|
108
|
+
hash_id = hash((handler.state_full_name, handler.fn))
|
|
109
|
+
cls._dependent_handlers[hash_id] = handler
|
|
110
|
+
|
|
111
|
+
@classmethod
|
|
112
|
+
def _set_on_load_events(
|
|
113
|
+
cls, uid: uuid.UUID, events: list[EventType[()]]
|
|
114
|
+
) -> None:
|
|
115
|
+
"""Store on_load events by UUID for later retrieval."""
|
|
116
|
+
cls._on_load_events[uid] = events
|
|
117
|
+
|
|
118
|
+
@rx.event
|
|
119
|
+
async def initialize(self) -> list[EventCallback]:
|
|
120
|
+
"""Initialize the Junction state. Sets is_initialized and fires dependent handlers."""
|
|
121
|
+
self.is_initialized = True
|
|
122
|
+
return list(self._dependent_handlers.values())
|
|
123
|
+
|
|
124
|
+
@rx.event(background=True)
|
|
125
|
+
async def wait_for_init(self, uid: str) -> list[EventType[()]]:
|
|
126
|
+
"""Wait for Junction state to be initialized, then return stored on_load events.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
uid: String UUID identifying the on_load event batch.
|
|
130
|
+
"""
|
|
131
|
+
parsed_uid = uuid.UUID(uid) if isinstance(uid, str) else uid
|
|
132
|
+
on_loads = self._on_load_events.get(parsed_uid, [])
|
|
133
|
+
|
|
134
|
+
start = time.monotonic()
|
|
135
|
+
while time.monotonic() - start < self._init_wait_timeout_seconds:
|
|
136
|
+
async with self:
|
|
137
|
+
if self.is_initialized:
|
|
138
|
+
return on_loads
|
|
139
|
+
await asyncio.sleep(0.05)
|
|
140
|
+
|
|
141
|
+
logger.warning(
|
|
142
|
+
"Junction init wait timed out after %.1fs. "
|
|
143
|
+
"Proceeding with on_load handlers anyway.",
|
|
144
|
+
self._init_wait_timeout_seconds,
|
|
145
|
+
)
|
|
146
|
+
return on_loads
|
|
147
|
+
|
|
148
|
+
@rx.event
|
|
149
|
+
async def create_user(self, client_user_id: str) -> None:
|
|
150
|
+
"""Create a Junction user mapped to the given client user ID.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
client_user_id: Your application's internal user identifier.
|
|
154
|
+
"""
|
|
155
|
+
result = await self.client.user.create(client_user_id=client_user_id)
|
|
156
|
+
self.junction_user_id = str(result.user_id)
|
|
157
|
+
self.client_user_id = client_user_id
|
|
158
|
+
|
|
159
|
+
@rx.event
|
|
160
|
+
async def get_connected_providers(self) -> None:
|
|
161
|
+
"""Fetch and update the list of connected providers for the current user."""
|
|
162
|
+
if not self.junction_user_id:
|
|
163
|
+
logger.warning("No junction_user_id set. Call create_user() first.")
|
|
164
|
+
return
|
|
165
|
+
result = await self.client.user.get_connected_providers(
|
|
166
|
+
user_id=self.junction_user_id
|
|
167
|
+
)
|
|
168
|
+
# Flatten the provider status dict into a list of dicts
|
|
169
|
+
providers = []
|
|
170
|
+
for source_type, source_list in result.items():
|
|
171
|
+
for source in source_list:
|
|
172
|
+
providers.append(
|
|
173
|
+
{
|
|
174
|
+
"source_type": source_type,
|
|
175
|
+
"name": getattr(source, "name", ""),
|
|
176
|
+
"slug": getattr(source, "slug", ""),
|
|
177
|
+
"logo": getattr(source, "logo", ""),
|
|
178
|
+
"status": getattr(source, "status", ""),
|
|
179
|
+
}
|
|
180
|
+
)
|
|
181
|
+
self.connected_sources = providers
|
|
182
|
+
|
|
183
|
+
@rx.event
|
|
184
|
+
async def disconnect_provider(self, provider: str) -> EventCallback | None:
|
|
185
|
+
"""Disconnect a specific provider for the current user.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
provider: The provider slug to disconnect (e.g., 'oura').
|
|
189
|
+
"""
|
|
190
|
+
if not self.junction_user_id:
|
|
191
|
+
logger.warning("No junction_user_id set. Call create_user() first.")
|
|
192
|
+
return
|
|
193
|
+
await self.client.user.deregister_provider(
|
|
194
|
+
user_id=self.junction_user_id,
|
|
195
|
+
provider=provider,
|
|
196
|
+
)
|
|
197
|
+
# Refresh the connected sources list
|
|
198
|
+
return JunctionState.get_connected_providers # type: ignore[return-value]
|
|
199
|
+
|
|
200
|
+
@rx.event
|
|
201
|
+
async def refresh_data(self) -> None:
|
|
202
|
+
"""Trigger a data refresh for the current user from all connected providers."""
|
|
203
|
+
if not self.junction_user_id:
|
|
204
|
+
logger.warning("No junction_user_id set. Call create_user() first.")
|
|
205
|
+
return
|
|
206
|
+
await self.client.user.refresh(user_id=self.junction_user_id)
|
|
207
|
+
|
|
208
|
+
@rx.event
|
|
209
|
+
async def create_link_token(self, redirect_url: str = "") -> None:
|
|
210
|
+
"""Generate a Junction Link token for the current user.
|
|
211
|
+
|
|
212
|
+
The token and web URL are stored in state for use by the Link widget.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
redirect_url: URL to redirect to after provider connection.
|
|
216
|
+
"""
|
|
217
|
+
if not self.junction_user_id:
|
|
218
|
+
logger.warning("No junction_user_id set. Call create_user() first.")
|
|
219
|
+
return
|
|
220
|
+
kwargs: dict[str, Any] = {"user_id": self.junction_user_id}
|
|
221
|
+
if redirect_url:
|
|
222
|
+
kwargs["redirect_url"] = redirect_url
|
|
223
|
+
result = await self.client.link.token(**kwargs)
|
|
224
|
+
self._link_token = str(result.link_token)
|
|
225
|
+
self._link_web_url = str(result.link_web_url)
|
|
226
|
+
|
|
227
|
+
@rx.var
|
|
228
|
+
def has_connections(self) -> bool:
|
|
229
|
+
"""Whether the current user has any connected providers."""
|
|
230
|
+
return len(self.connected_sources) > 0
|
|
231
|
+
|
|
232
|
+
@rx.var
|
|
233
|
+
def provider_slugs(self) -> list[str]:
|
|
234
|
+
"""List of connected provider slugs."""
|
|
235
|
+
return [p.get("slug", "") for p in self.connected_sources]
|
|
236
|
+
|
|
237
|
+
@rx.var
|
|
238
|
+
def link_token(self) -> str:
|
|
239
|
+
"""The current link token for the Link widget."""
|
|
240
|
+
return self._link_token
|
|
241
|
+
|
|
242
|
+
@rx.var
|
|
243
|
+
def link_web_url(self) -> str:
|
|
244
|
+
"""The current link web URL for redirect-based flow."""
|
|
245
|
+
return self._link_web_url
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class JunctionUser(JunctionState):
|
|
249
|
+
"""Extended Junction state with health data summaries.
|
|
250
|
+
|
|
251
|
+
Inherits from JunctionState and adds per-user health data fields.
|
|
252
|
+
Register via register_on_auth_change_handler(JunctionUser.load_user).
|
|
253
|
+
"""
|
|
254
|
+
|
|
255
|
+
activity_summary: list[dict[str, Any]] = []
|
|
256
|
+
sleep_summary: list[dict[str, Any]] = []
|
|
257
|
+
body_summary: list[dict[str, Any]] = []
|
|
258
|
+
profile: dict[str, Any] = {}
|
|
259
|
+
meal_summary: list[dict[str, Any]] = []
|
|
260
|
+
workout_summary: list[dict[str, Any]] = []
|
|
261
|
+
|
|
262
|
+
@rx.event
|
|
263
|
+
async def load_user(self) -> None:
|
|
264
|
+
"""Load the current user's connected providers and profile.
|
|
265
|
+
|
|
266
|
+
This is typically registered as a dependent handler via
|
|
267
|
+
register_on_auth_change_handler(JunctionUser.load_user).
|
|
268
|
+
"""
|
|
269
|
+
if not self.junction_user_id:
|
|
270
|
+
return
|
|
271
|
+
try:
|
|
272
|
+
await self.get_connected_providers()
|
|
273
|
+
except Exception:
|
|
274
|
+
logger.exception("Failed to load connected providers")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def junction_provider(
|
|
278
|
+
*children: rx.Component,
|
|
279
|
+
api_key: str,
|
|
280
|
+
environment: str = "sandbox",
|
|
281
|
+
register_user_state: bool = False,
|
|
282
|
+
) -> rx.Component:
|
|
283
|
+
"""Configure Junction integration and return a component wrapping children.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
*children: Child components to wrap.
|
|
287
|
+
api_key: Junction API key.
|
|
288
|
+
environment: Junction environment (sandbox, production, sandbox_eu, production_eu).
|
|
289
|
+
register_user_state: If True, registers JunctionUser.load_user as a dependent handler.
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
A Reflex component wrapping the children with Junction configuration.
|
|
293
|
+
"""
|
|
294
|
+
JunctionState._set_api_key(api_key)
|
|
295
|
+
JunctionState._set_environment(environment)
|
|
296
|
+
|
|
297
|
+
if register_user_state:
|
|
298
|
+
register_on_auth_change_handler(JunctionUser.load_user)
|
|
299
|
+
|
|
300
|
+
# In Phase 1, we just return children wrapped in a fragment.
|
|
301
|
+
# Phase 2 will add the actual JunctionLinkProvider React component.
|
|
302
|
+
return rx.fragment(*children)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def wrap_app(
|
|
306
|
+
app: rx.App,
|
|
307
|
+
api_key: str,
|
|
308
|
+
environment: str = "sandbox",
|
|
309
|
+
register_user_state: bool = False,
|
|
310
|
+
register_webhooks: bool = False,
|
|
311
|
+
webhook_secret: str | None = None,
|
|
312
|
+
webhook_prefix: str = "/junction",
|
|
313
|
+
) -> rx.App:
|
|
314
|
+
"""Wrap a Reflex app with Junction health data integration.
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
app: The Reflex app to wrap.
|
|
318
|
+
api_key: Junction API key.
|
|
319
|
+
environment: Junction environment (sandbox, production, sandbox_eu, production_eu).
|
|
320
|
+
register_user_state: If True, registers JunctionUser.load_user as dependent handler.
|
|
321
|
+
register_webhooks: If True, registers the webhook API endpoint.
|
|
322
|
+
webhook_secret: Svix webhook secret for signature verification.
|
|
323
|
+
webhook_prefix: URL prefix for the webhook endpoint.
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
The wrapped Reflex app.
|
|
327
|
+
"""
|
|
328
|
+
# Priority 1 makes this the first wrapper around the content
|
|
329
|
+
app.app_wraps[(1, "JunctionProvider")] = lambda _: junction_provider(
|
|
330
|
+
api_key=api_key,
|
|
331
|
+
environment=environment,
|
|
332
|
+
register_user_state=register_user_state,
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
if register_webhooks:
|
|
336
|
+
if not webhook_secret:
|
|
337
|
+
raise ValueError(
|
|
338
|
+
"webhook_secret is required when register_webhooks=True"
|
|
339
|
+
)
|
|
340
|
+
from .fastapi_helpers import register_webhook_api
|
|
341
|
+
|
|
342
|
+
register_webhook_api(app, secret=webhook_secret, prefix=webhook_prefix)
|
|
343
|
+
|
|
344
|
+
return app
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def on_load(
|
|
348
|
+
on_load_list: list[EventType[()]],
|
|
349
|
+
) -> list[EventType[()]]:
|
|
350
|
+
"""Wrap on_load handlers to ensure Junction state is initialized first.
|
|
351
|
+
|
|
352
|
+
Usage:
|
|
353
|
+
app.add_page(
|
|
354
|
+
my_page,
|
|
355
|
+
on_load=[*junction.on_load([MyState.load_data]), ...],
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
Args:
|
|
359
|
+
on_load_list: List of event handlers to run after Junction initializes.
|
|
360
|
+
|
|
361
|
+
Returns:
|
|
362
|
+
A list containing the wait_for_init event handler.
|
|
363
|
+
"""
|
|
364
|
+
uid = uuid.uuid4()
|
|
365
|
+
JunctionState._set_on_load_events(uid, on_load_list)
|
|
366
|
+
return [JunctionState.wait_for_init(str(uid))] # type: ignore[list-item]
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def register_on_auth_change_handler(handler: EventCallback) -> None:
|
|
370
|
+
"""Register an event handler to be called after Junction initialization.
|
|
371
|
+
|
|
372
|
+
Args:
|
|
373
|
+
handler: An rx.EventHandler to call after initialization.
|
|
374
|
+
"""
|
|
375
|
+
JunctionState.register_dependent_handler(handler)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from reflex.components.props import PropsBase
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class JunctionConfig(PropsBase):
|
|
7
|
+
"""Configuration for the Junction health data integration."""
|
|
8
|
+
|
|
9
|
+
environment: str = "sandbox"
|
|
10
|
+
"""The Junction environment. One of: sandbox, production, sandbox_eu, production_eu."""
|
|
11
|
+
|
|
12
|
+
region: str = "us"
|
|
13
|
+
"""The region for data storage. One of: us, eu."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LinkConfig(PropsBase):
|
|
17
|
+
"""Configuration for the Junction Link widget."""
|
|
18
|
+
|
|
19
|
+
redirect_url: str = ""
|
|
20
|
+
"""URL to redirect to after a successful provider connection."""
|
|
21
|
+
|
|
22
|
+
filter_on_providers: list[str] | None = None
|
|
23
|
+
"""Optional list of provider slugs to show in the Link widget."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ProviderInfo(PropsBase):
|
|
27
|
+
"""Information about a connected health data provider."""
|
|
28
|
+
|
|
29
|
+
name: str = ""
|
|
30
|
+
"""The display name of the provider (e.g., 'Oura')."""
|
|
31
|
+
|
|
32
|
+
slug: str = ""
|
|
33
|
+
"""The provider slug identifier (e.g., 'oura')."""
|
|
34
|
+
|
|
35
|
+
logo: str = ""
|
|
36
|
+
"""URL to the provider's logo image."""
|
|
37
|
+
|
|
38
|
+
auth_type: str = ""
|
|
39
|
+
"""The authentication type: oauth, password, email, sdk."""
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: reflex-junction
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Reflex custom component wrapping @tryvital/vital-link and integrating the Junction (Vital) health data SDK
|
|
5
|
+
Project-URL: repository, https://github.com/Syntropy-Health/reflex-junction
|
|
6
|
+
Author: Syntropy Health
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: health-data,junction,reflex,reflex-custom-components,vital
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Requires-Dist: fastapi>=0.115.0
|
|
17
|
+
Requires-Dist: reflex>=0.8.0
|
|
18
|
+
Requires-Dist: vital>=2.1.0
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: build; extra == 'dev'
|
|
21
|
+
Requires-Dist: twine; extra == 'dev'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
[](https://github.com/Syntropy-Health/reflex-junction/actions/workflows/ci.yml)
|
|
25
|
+
[](https://pypi.org/project/reflex-junction/)
|
|
26
|
+
[](https://pypi.org/project/reflex-junction/)
|
|
27
|
+
[](https://opensource.org/licenses/MIT)
|
|
28
|
+
[](https://syntropy-health.github.io/reflex-junction/)
|
|
29
|
+
|
|
30
|
+
# reflex-junction
|
|
31
|
+
|
|
32
|
+
A [Reflex](https://reflex.dev) custom component for integrating [Junction (Vital)](https://tryvital.io/) health data into your application. Connect wearables and health platforms (Oura, Fitbit, Apple Health, Garmin, etc.) with a few lines of Python.
|
|
33
|
+
|
|
34
|
+
## Features
|
|
35
|
+
|
|
36
|
+
- **JunctionState** — Reflex state management for the Vital API (user creation, provider connections, data refresh)
|
|
37
|
+
- **JunctionUser** — Extended state with health data summaries (activity, sleep, body, meals, workouts)
|
|
38
|
+
- **wrap_app()** — One-line integration that configures your entire Reflex app
|
|
39
|
+
- **junction_provider()** — Component-level integration for wrapping specific pages
|
|
40
|
+
- **Webhook support** — FastAPI router for receiving real-time health data events
|
|
41
|
+
- **Link widget support** — Token generation for the Vital Link provider connection UI
|
|
42
|
+
- **Multi-region** — US and EU sandbox/production environments
|
|
43
|
+
|
|
44
|
+
## Installation
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install reflex-junction
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Or with your preferred package manager:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
uv add reflex-junction
|
|
54
|
+
poetry add reflex-junction
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Quick Start
|
|
58
|
+
|
|
59
|
+
### 1. Get an API key
|
|
60
|
+
|
|
61
|
+
Sign up at [tryvital.io](https://tryvital.io/) and grab your API key from the dashboard.
|
|
62
|
+
|
|
63
|
+
### 2. Wrap your app
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
import os
|
|
67
|
+
import reflex as rx
|
|
68
|
+
import reflex_junction as junction
|
|
69
|
+
|
|
70
|
+
app = rx.App()
|
|
71
|
+
|
|
72
|
+
junction.wrap_app(
|
|
73
|
+
app,
|
|
74
|
+
api_key=os.environ["JUNCTION_API_KEY"],
|
|
75
|
+
environment="sandbox", # or "production"
|
|
76
|
+
register_user_state=True,
|
|
77
|
+
)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### 3. Use Junction state in your pages
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
def health_dashboard() -> rx.Component:
|
|
84
|
+
return rx.container(
|
|
85
|
+
rx.heading("Health Dashboard"),
|
|
86
|
+
rx.text(f"Connected: {junction.JunctionState.has_connections}"),
|
|
87
|
+
rx.foreach(
|
|
88
|
+
junction.JunctionState.connected_sources,
|
|
89
|
+
lambda p: rx.badge(p["name"]),
|
|
90
|
+
),
|
|
91
|
+
)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Usage
|
|
95
|
+
|
|
96
|
+
### Using `junction_provider` directly
|
|
97
|
+
|
|
98
|
+
For page-level integration instead of app-wide:
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
import reflex_junction as junction
|
|
102
|
+
|
|
103
|
+
def health_page() -> rx.Component:
|
|
104
|
+
return junction.junction_provider(
|
|
105
|
+
rx.container(
|
|
106
|
+
rx.text("Connected providers: "),
|
|
107
|
+
rx.text(junction.JunctionState.connected_sources.length()),
|
|
108
|
+
),
|
|
109
|
+
api_key=os.environ["JUNCTION_API_KEY"],
|
|
110
|
+
)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Webhook support
|
|
114
|
+
|
|
115
|
+
Receive real-time events when health data updates:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
junction.wrap_app(
|
|
119
|
+
app,
|
|
120
|
+
api_key=os.environ["JUNCTION_API_KEY"],
|
|
121
|
+
register_webhooks=True,
|
|
122
|
+
webhook_secret=os.environ["JUNCTION_WEBHOOK_SECRET"],
|
|
123
|
+
webhook_prefix="/junction", # POST /junction/webhooks
|
|
124
|
+
)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Environment options
|
|
128
|
+
|
|
129
|
+
| Environment | Description |
|
|
130
|
+
|------------------|--------------------|
|
|
131
|
+
| `sandbox` | US sandbox (default) |
|
|
132
|
+
| `production` | US production |
|
|
133
|
+
| `sandbox_eu` | EU sandbox |
|
|
134
|
+
| `production_eu` | EU production |
|
|
135
|
+
|
|
136
|
+
## API Reference
|
|
137
|
+
|
|
138
|
+
### State Classes
|
|
139
|
+
|
|
140
|
+
| Class | Description |
|
|
141
|
+
|-------|-------------|
|
|
142
|
+
| `JunctionState` | Core state — user creation, provider management, Link tokens |
|
|
143
|
+
| `JunctionUser` | Extended state — health data summaries (activity, sleep, body, meals, workouts) |
|
|
144
|
+
|
|
145
|
+
### Configuration Models
|
|
146
|
+
|
|
147
|
+
| Class | Description |
|
|
148
|
+
|-------|-------------|
|
|
149
|
+
| `JunctionConfig` | Environment and region settings |
|
|
150
|
+
| `LinkConfig` | Redirect URL and provider filter for the Link widget |
|
|
151
|
+
| `ProviderInfo` | Provider metadata (name, slug, logo, auth_type) |
|
|
152
|
+
|
|
153
|
+
### Functions
|
|
154
|
+
|
|
155
|
+
| Function | Description |
|
|
156
|
+
|----------|-------------|
|
|
157
|
+
| `wrap_app(app, api_key, ...)` | One-line app integration with optional webhooks |
|
|
158
|
+
| `junction_provider(*children, api_key, ...)` | Component wrapper for page-level integration |
|
|
159
|
+
| `on_load(handlers)` | Wrap page on_load handlers to wait for Junction init |
|
|
160
|
+
| `register_on_auth_change_handler(handler)` | Register handler to fire after Junction initializes |
|
|
161
|
+
| `create_webhook_router(prefix, secret)` | Create a standalone FastAPI webhook router |
|
|
162
|
+
| `register_webhook_api(app, secret, prefix)` | Register webhook endpoint on a Reflex app |
|
|
163
|
+
|
|
164
|
+
### JunctionState Events
|
|
165
|
+
|
|
166
|
+
| Event | Description |
|
|
167
|
+
|-------|-------------|
|
|
168
|
+
| `create_user(client_user_id)` | Create a Junction user mapped to your app's user |
|
|
169
|
+
| `get_connected_providers()` | Fetch connected health data providers |
|
|
170
|
+
| `disconnect_provider(provider)` | Disconnect a specific provider by slug |
|
|
171
|
+
| `refresh_data()` | Trigger data sync from all connected providers |
|
|
172
|
+
| `create_link_token(redirect_url)` | Generate a Link widget token |
|
|
173
|
+
|
|
174
|
+
See the [full documentation](https://syntropy-health.github.io/reflex-junction/) for detailed guides.
|
|
175
|
+
|
|
176
|
+
## Contributing
|
|
177
|
+
|
|
178
|
+
Contributions welcome! We use [Taskfile](https://taskfile.dev/) for common tasks:
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
task install # Install dev dependencies + pre-commit
|
|
182
|
+
task test # Run lint + typecheck + pytest
|
|
183
|
+
task run # Run the demo app
|
|
184
|
+
task run-docs # Serve docs locally at localhost:9000
|
|
185
|
+
task bump-patch # Bump patch version (bug fix)
|
|
186
|
+
task bump-minor # Bump minor version (new feature)
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Workflow: Fork → feature branch → add tests → submit PR.
|
|
190
|
+
|
|
191
|
+
## License
|
|
192
|
+
|
|
193
|
+
[MIT](LICENSE) — Copyright (c) 2025 Syntropy Health
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
reflex_junction/__init__.py,sha256=pcLZuRStSv7DCMJkfHirWj94vZBLzgs8opow7lkkeeM,614
|
|
2
|
+
reflex_junction/base.py,sha256=QgfMP_G01Jd0QlJDYcfcS51h-UxuucnmZJ6NpXAdDp8,157
|
|
3
|
+
reflex_junction/fastapi_helpers.py,sha256=6XgyLIJvg4vdssarOAlwQ451OJTO3HzGa6Pmq048xps,2447
|
|
4
|
+
reflex_junction/junction_provider.py,sha256=nxiQ5W8hag3xPpuR3N1ng0IL_JWnh48xqIZV245ooF0,13342
|
|
5
|
+
reflex_junction/models.py,sha256=wK-fIsJB0b9YC7Sz_WF38eX9fMYAB5G5GV1-R-ai6qY,1092
|
|
6
|
+
reflex_junction-0.1.0.dist-info/METADATA,sha256=_Y9VHcJD8dcw1kj2SwfdTHyLEDsWaGPfubYLNrXTPuk,6604
|
|
7
|
+
reflex_junction-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
8
|
+
reflex_junction-0.1.0.dist-info/licenses/LICENSE,sha256=Q33wpujwLLO_70ggNjdv3OoBE6qsiyJNuHHXIICpTFY,1072
|
|
9
|
+
reflex_junction-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Syntropy Health
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|