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.
@@ -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
+ ]
@@ -0,0 +1,7 @@
1
+ import reflex as rx
2
+
3
+
4
+ class JunctionBase(rx.Component):
5
+ """Base component for Junction health data integration."""
6
+
7
+ library = "@tryvital/vital-link"
@@ -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
+ [![CI](https://github.com/Syntropy-Health/reflex-junction/actions/workflows/ci.yml/badge.svg)](https://github.com/Syntropy-Health/reflex-junction/actions/workflows/ci.yml)
25
+ [![PyPI](https://img.shields.io/pypi/v/reflex-junction.svg)](https://pypi.org/project/reflex-junction/)
26
+ [![Python](https://img.shields.io/pypi/pyversions/reflex-junction.svg)](https://pypi.org/project/reflex-junction/)
27
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
28
+ [![Docs](https://img.shields.io/badge/docs-mkdocs-blue)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.