yirifi-ops-auth-client 3.2.3__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,409 @@
1
+ """Federation client for discovering entity definitions from remote microsites.
2
+
3
+ Enables cross-microsite deep linking by:
4
+ 1. Querying auth service for list of microsites and their URLs
5
+ 2. Querying each microsite's /api/v1/references for entity definitions
6
+ 3. Caching results for fast lookup
7
+
8
+ Usage:
9
+ from yirifi_ops_auth.deeplink import configure_federation, FederationConfig
10
+
11
+ # Configure at app startup
12
+ configure_federation(FederationConfig(
13
+ auth_service_url="https://auth.ops.yirifi.ai",
14
+ ))
15
+
16
+ # Entity lookup automatically checks federation cache
17
+ resolve_link('risk_item', 'r_yid_123') # Works even if not locally registered
18
+ """
19
+
20
+ from dataclasses import dataclass, field
21
+ from typing import Dict, List, Optional, Any
22
+ from datetime import datetime, timedelta
23
+ import logging
24
+ import threading
25
+ import os
26
+
27
+ import httpx
28
+
29
+ from .environment import Environment
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ class FederationError(Exception):
35
+ """Base exception for federation errors."""
36
+ pass
37
+
38
+
39
+ class FederationTimeoutError(FederationError):
40
+ """Remote microsite did not respond in time."""
41
+ pass
42
+
43
+
44
+ class FederationConnectionError(FederationError):
45
+ """Could not connect to remote microsite."""
46
+ pass
47
+
48
+
49
+ @dataclass
50
+ class FederationConfig:
51
+ """Configuration for federation behavior."""
52
+
53
+ # Auth service URL (required for discovery)
54
+ auth_service_url: str = ""
55
+
56
+ # Timing
57
+ refresh_interval_seconds: int = 300 # 5 minutes
58
+ request_timeout_seconds: float = 5.0
59
+ startup_fetch: bool = True
60
+
61
+ # Caching
62
+ cache_ttl_seconds: int = 300
63
+
64
+ # Error handling
65
+ max_retries: int = 2
66
+ retry_delay_seconds: float = 1.0
67
+ fail_open: bool = True # Continue if remote unavailable
68
+
69
+ # Skip self (don't query your own microsite)
70
+ self_microsite_id: Optional[str] = None
71
+
72
+ def __post_init__(self) -> None:
73
+ if not self.auth_service_url:
74
+ # Try to get from environment
75
+ self.auth_service_url = os.getenv("AUTH_SERVICE_URL", "")
76
+
77
+
78
+ @dataclass
79
+ class CachedMicrosite:
80
+ """Cached microsite information from auth service."""
81
+ id: str
82
+ name: str
83
+ url: Optional[str] # Production URL
84
+ is_active: bool
85
+
86
+
87
+ @dataclass
88
+ class CachedDefinitions:
89
+ """Cached entity definitions from a microsite."""
90
+ microsite_id: str
91
+ microsite_name: str
92
+ urls: Dict[str, str] # {"dev": "...", "uat": "...", "prd": "..."}
93
+ entities: Dict[str, Dict[str, Any]] # entity_type -> {path, description}
94
+ fetched_at: datetime
95
+ etag: Optional[str] = None
96
+
97
+ def is_stale(self, ttl_seconds: int) -> bool:
98
+ return datetime.utcnow() - self.fetched_at > timedelta(seconds=ttl_seconds)
99
+
100
+
101
+ class FederationClient:
102
+ """Client for fetching entity definitions from remote microsites.
103
+
104
+ Thread-safe client that:
105
+ 1. Discovers microsites from auth service
106
+ 2. Fetches entity definitions from each microsite's /api/v1/references
107
+ 3. Caches results with configurable TTL
108
+ 4. Supports background refresh
109
+ """
110
+
111
+ def __init__(self, config: FederationConfig):
112
+ self.config = config
113
+ self._microsites: Dict[str, CachedMicrosite] = {}
114
+ self._cache: Dict[str, CachedDefinitions] = {}
115
+ self._lock = threading.RLock()
116
+ self._http_client: Optional[httpx.Client] = None
117
+ self._refresh_thread: Optional[threading.Thread] = None
118
+ self._running = False
119
+ self._started = False
120
+
121
+ def start(self) -> None:
122
+ """Start the federation client with initial fetch and background refresh."""
123
+ if self._started:
124
+ return
125
+
126
+ self._http_client = httpx.Client(
127
+ timeout=self.config.request_timeout_seconds,
128
+ follow_redirects=True,
129
+ )
130
+
131
+ self._started = True
132
+ self._running = True
133
+
134
+ if self.config.startup_fetch:
135
+ self._fetch_all()
136
+
137
+ if self.config.refresh_interval_seconds > 0:
138
+ self._start_background_refresh()
139
+
140
+ def stop(self) -> None:
141
+ """Stop the federation client and cleanup resources."""
142
+ self._running = False
143
+ self._started = False
144
+
145
+ if self._http_client:
146
+ self._http_client.close()
147
+ self._http_client = None
148
+
149
+ def get_entity(self, entity_type: str) -> Optional[Dict[str, Any]]:
150
+ """Get entity definition from federated sources.
151
+
152
+ Returns dict with: entity_type, microsite, microsite_name, urls, path_template, description
153
+ """
154
+ with self._lock:
155
+ for microsite_id, cached in self._cache.items():
156
+ if entity_type in cached.entities:
157
+ entity = cached.entities[entity_type]
158
+ return {
159
+ "entity_type": entity_type,
160
+ "microsite": microsite_id,
161
+ "microsite_name": cached.microsite_name,
162
+ "urls": cached.urls,
163
+ "path_template": entity["path"],
164
+ "description": entity.get("description"),
165
+ }
166
+ return None
167
+
168
+ def get_microsite_urls(self, microsite_id: str) -> Optional[Dict[str, str]]:
169
+ """Get URLs for a microsite from federation cache."""
170
+ with self._lock:
171
+ cached = self._cache.get(microsite_id)
172
+ if cached:
173
+ return cached.urls
174
+ return None
175
+
176
+ def list_remote_entities(self) -> List[str]:
177
+ """List all entity types available from federated sources."""
178
+ with self._lock:
179
+ entities = []
180
+ for cached in self._cache.values():
181
+ entities.extend(cached.entities.keys())
182
+ return entities
183
+
184
+ def list_remote_microsites(self) -> List[str]:
185
+ """List all microsite IDs in federation cache."""
186
+ with self._lock:
187
+ return list(self._cache.keys())
188
+
189
+ def _fetch_all(self) -> None:
190
+ """Fetch from auth service, then all microsites."""
191
+ # 1. Get microsite list from auth service
192
+ if not self._fetch_microsites():
193
+ logger.warning("Federation: failed to fetch microsites from auth service")
194
+ return
195
+
196
+ # 2. Fetch references from each active microsite
197
+ current_env = Environment.get()
198
+
199
+ with self._lock:
200
+ microsites_to_query = [
201
+ m for m in self._microsites.values()
202
+ if m.is_active and m.url and m.id != self.config.self_microsite_id
203
+ ]
204
+
205
+ for microsite in microsites_to_query:
206
+ try:
207
+ self._fetch_references(microsite.id, microsite.url)
208
+ except Exception as e:
209
+ logger.warning(f"Federation: failed to fetch from {microsite.id}: {e}")
210
+
211
+ def _fetch_microsites(self) -> bool:
212
+ """Fetch microsite list from auth service."""
213
+ if not self.config.auth_service_url:
214
+ logger.warning("Federation: auth_service_url not configured")
215
+ return False
216
+
217
+ url = f"{self.config.auth_service_url.rstrip('/')}/api/v1/microsites/"
218
+
219
+ try:
220
+ response = self._http_client.get(url)
221
+
222
+ if response.status_code != 200:
223
+ logger.warning(
224
+ f"Federation: auth service returned {response.status_code}"
225
+ )
226
+ return False
227
+
228
+ data = response.json()
229
+
230
+ if not data.get("success"):
231
+ logger.warning("Federation: auth service returned success=false")
232
+ return False
233
+
234
+ microsites_data = data.get("data", {}).get("microsites", [])
235
+
236
+ with self._lock:
237
+ self._microsites.clear()
238
+ for ms in microsites_data:
239
+ self._microsites[ms["id"]] = CachedMicrosite(
240
+ id=ms["id"],
241
+ name=ms["name"],
242
+ url=ms.get("url"),
243
+ is_active=ms.get("is_active", True),
244
+ )
245
+
246
+ logger.info(
247
+ f"Federation: discovered {len(self._microsites)} microsites from auth service"
248
+ )
249
+ return True
250
+
251
+ except httpx.TimeoutException as e:
252
+ logger.warning(f"Federation: auth service timeout: {e}")
253
+ if not self.config.fail_open:
254
+ raise FederationTimeoutError(str(e))
255
+ return False
256
+ except httpx.RequestError as e:
257
+ logger.warning(f"Federation: auth service connection error: {e}")
258
+ if not self.config.fail_open:
259
+ raise FederationConnectionError(str(e))
260
+ return False
261
+ except Exception as e:
262
+ logger.error(f"Federation: error fetching microsites: {e}")
263
+ return False
264
+
265
+ def _fetch_references(
266
+ self, microsite_id: str, base_url: str
267
+ ) -> Optional[CachedDefinitions]:
268
+ """Fetch entity definitions from a single microsite."""
269
+ url = f"{base_url.rstrip('/')}/api/v1/references"
270
+ headers = {}
271
+
272
+ # Conditional request if we have cached data
273
+ with self._lock:
274
+ cached = self._cache.get(microsite_id)
275
+ if cached and cached.etag:
276
+ headers["If-None-Match"] = cached.etag
277
+
278
+ try:
279
+ response = self._http_client.get(url, headers=headers)
280
+
281
+ if response.status_code == 304:
282
+ # Not modified, update timestamp
283
+ logger.debug(f"Federation: {microsite_id} not modified")
284
+ with self._lock:
285
+ if cached:
286
+ cached.fetched_at = datetime.utcnow()
287
+ return cached
288
+
289
+ if response.status_code == 404:
290
+ # Microsite doesn't expose references endpoint yet
291
+ logger.debug(f"Federation: {microsite_id} has no /api/v1/references")
292
+ return None
293
+
294
+ if response.status_code != 200:
295
+ logger.warning(
296
+ f"Federation: {microsite_id} returned {response.status_code}"
297
+ )
298
+ return None
299
+
300
+ data = response.json()
301
+
302
+ # Validate response structure
303
+ if "microsite" not in data or "entities" not in data:
304
+ logger.warning(f"Federation: {microsite_id} invalid response structure")
305
+ return None
306
+
307
+ # Parse response
308
+ microsite_data = data["microsite"]
309
+ definitions = CachedDefinitions(
310
+ microsite_id=microsite_data.get("id", microsite_id),
311
+ microsite_name=microsite_data.get("name", microsite_id.title()),
312
+ urls=microsite_data.get("urls", {}),
313
+ entities={e["type"]: e for e in data["entities"]},
314
+ fetched_at=datetime.utcnow(),
315
+ etag=response.headers.get("ETag"),
316
+ )
317
+
318
+ with self._lock:
319
+ self._cache[microsite_id] = definitions
320
+
321
+ logger.info(
322
+ f"Federation: fetched {len(data['entities'])} entities from {microsite_id}"
323
+ )
324
+ return definitions
325
+
326
+ except httpx.TimeoutException as e:
327
+ logger.warning(f"Federation: {microsite_id} timeout: {e}")
328
+ if not self.config.fail_open:
329
+ raise FederationTimeoutError(str(e))
330
+ return None
331
+ except httpx.RequestError as e:
332
+ logger.warning(f"Federation: failed to reach {microsite_id}: {e}")
333
+ if not self.config.fail_open:
334
+ raise FederationConnectionError(str(e))
335
+ return None
336
+ except Exception as e:
337
+ logger.error(f"Federation: error processing {microsite_id}: {e}")
338
+ return None
339
+
340
+ def _start_background_refresh(self) -> None:
341
+ """Start background thread for periodic refresh."""
342
+ self._refresh_thread = threading.Thread(
343
+ target=self._refresh_loop,
344
+ daemon=True,
345
+ name="deeplink-federation-refresh",
346
+ )
347
+ self._refresh_thread.start()
348
+
349
+ def _refresh_loop(self) -> None:
350
+ """Background refresh loop."""
351
+ import time
352
+
353
+ while self._running:
354
+ time.sleep(self.config.refresh_interval_seconds)
355
+ if self._running:
356
+ try:
357
+ self._fetch_all()
358
+ except Exception as e:
359
+ logger.error(f"Federation: background refresh error: {e}")
360
+
361
+ def clear_cache(self) -> None:
362
+ """Clear all cached data. Useful for testing."""
363
+ with self._lock:
364
+ self._microsites.clear()
365
+ self._cache.clear()
366
+
367
+
368
+ # Module-level singleton
369
+ _federation_client: Optional[FederationClient] = None
370
+
371
+
372
+ def get_federation_client() -> Optional[FederationClient]:
373
+ """Get the federation client singleton."""
374
+ return _federation_client
375
+
376
+
377
+ def configure_federation(config: FederationConfig) -> FederationClient:
378
+ """Configure and start the federation client.
379
+
380
+ Args:
381
+ config: FederationConfig with auth_service_url and other settings
382
+
383
+ Returns:
384
+ The configured FederationClient instance
385
+ """
386
+ global _federation_client
387
+
388
+ if _federation_client:
389
+ _federation_client.stop()
390
+
391
+ _federation_client = FederationClient(config)
392
+ _federation_client.start()
393
+
394
+ return _federation_client
395
+
396
+
397
+ def stop_federation() -> None:
398
+ """Stop the federation client."""
399
+ global _federation_client
400
+
401
+ if _federation_client:
402
+ _federation_client.stop()
403
+ _federation_client = None
404
+
405
+
406
+ def clear_federation_cache() -> None:
407
+ """Clear federation cache. Useful for testing."""
408
+ if _federation_client:
409
+ _federation_client.clear_cache()