foil-server 0.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.
foil_server/client.py ADDED
@@ -0,0 +1,1032 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from typing import Any, Iterator
5
+
6
+ import httpx
7
+
8
+ from .errors import FoilApiError, FoilConfigurationError
9
+ from .types import (
10
+ ApiKey,
11
+ AgentTokenVerification,
12
+ Attribution,
13
+ Decision,
14
+ DecisionManipulation,
15
+ Event,
16
+ EventSubject,
17
+ GateDashboardLogin,
18
+ GateDeliveryBundle,
19
+ GateDeliveryEnvelope,
20
+ GateLoginSession,
21
+ GateManagedService,
22
+ GateRegistryEntry,
23
+ GateServiceBranding,
24
+ GateServiceConsent,
25
+ GateServiceEnvVar,
26
+ GateServiceSdkInstall,
27
+ GateSessionCreate,
28
+ GateSessionDeliveryAcknowledgement,
29
+ GateSessionPollData,
30
+ IssuedApiKey,
31
+ ListResult,
32
+ RequestContext,
33
+ ScoreBreakdown,
34
+ SessionDetail,
35
+ SessionDecision,
36
+ SessionDetailRequest,
37
+ SessionSummary,
38
+ Organization,
39
+ VerifiedFoilSignal,
40
+ VerificationResult,
41
+ VerifiedFoilToken,
42
+ VisitorFingerprintActivity,
43
+ VisitorFingerprintAnchors,
44
+ VisitorFingerprintComponents,
45
+ VisitorFingerprintDetail,
46
+ VisitorFingerprintLatestRequest,
47
+ VisitorFingerprintLifecycle,
48
+ VisitorFingerprintLink,
49
+ VisitorFingerprintSessionSummary,
50
+ VisitorFingerprintStorage,
51
+ VisitorFingerprintSummary,
52
+ WebhookDelivery,
53
+ WebhookEndpoint,
54
+ WebhookTest,
55
+ )
56
+
57
+ DEFAULT_BASE_URL = "https://api.usefoil.com"
58
+ DEFAULT_TIMEOUT = 30.0
59
+ SDK_CLIENT_HEADER = "foil-server-python/0.1.0"
60
+
61
+
62
+ def _compact_query(query: dict[str, Any]) -> dict[str, Any]:
63
+ return {key: value for key, value in query.items() if value is not None}
64
+
65
+
66
+ def _parse_decision(data: dict[str, Any]) -> Decision:
67
+ manipulation_raw = data.get("manipulation")
68
+ manipulation = None
69
+ if isinstance(manipulation_raw, dict):
70
+ manipulation = DecisionManipulation(
71
+ score=int(manipulation_raw["score"]) if isinstance(manipulation_raw.get("score"), int) else None,
72
+ verdict=manipulation_raw.get("verdict")
73
+ if isinstance(manipulation_raw.get("verdict"), str) or manipulation_raw.get("verdict") is None
74
+ else None,
75
+ )
76
+
77
+ return Decision(
78
+ event_id=str(data["event_id"]),
79
+ verdict=str(data["verdict"]),
80
+ risk_score=int(data["risk_score"]),
81
+ phase=data.get("phase") if isinstance(data.get("phase"), str) or data.get("phase") is None else None,
82
+ is_provisional=data.get("is_provisional")
83
+ if isinstance(data.get("is_provisional"), bool) or data.get("is_provisional") is None
84
+ else None,
85
+ manipulation=manipulation,
86
+ evaluation_duration_ms=int(data["evaluation_duration_ms"])
87
+ if isinstance(data.get("evaluation_duration_ms"), int)
88
+ else None,
89
+ evaluated_at=str(data["evaluated_at"]),
90
+ )
91
+
92
+
93
+ def _parse_request_context(data: dict[str, Any]) -> RequestContext:
94
+ return RequestContext(
95
+ user_agent=str(data["user_agent"]),
96
+ url=str(data["url"]),
97
+ screen_size=data.get("screen_size")
98
+ if isinstance(data.get("screen_size"), str) or data.get("screen_size") is None
99
+ else None,
100
+ is_touch_capable=data.get("is_touch_capable")
101
+ if isinstance(data.get("is_touch_capable"), bool) or data.get("is_touch_capable") is None
102
+ else None,
103
+ ip_address=str(data["ip_address"]),
104
+ )
105
+
106
+
107
+ def _parse_session_detail_request(data: dict[str, Any]) -> SessionDetailRequest:
108
+ return SessionDetailRequest(
109
+ url=str(data["url"]),
110
+ referrer=data.get("referrer")
111
+ if isinstance(data.get("referrer"), str) or data.get("referrer") is None
112
+ else None,
113
+ user_agent=str(data["user_agent"]),
114
+ )
115
+
116
+
117
+ def _parse_session_decision(data: dict[str, Any]) -> SessionDecision:
118
+ return SessionDecision(
119
+ event_id=str(data["event_id"]),
120
+ automation_status=str(data["automation_status"]),
121
+ risk_score=int(data["risk_score"]),
122
+ evaluation_phase=data.get("evaluation_phase")
123
+ if isinstance(data.get("evaluation_phase"), str) or data.get("evaluation_phase") is None
124
+ else None,
125
+ decision_status=str(data["decision_status"]),
126
+ evaluated_at=str(data["evaluated_at"]),
127
+ )
128
+
129
+
130
+ def _parse_visitor_fingerprint_link(data: dict[str, Any] | None) -> VisitorFingerprintLink | None:
131
+ if data is None:
132
+ return None
133
+ return VisitorFingerprintLink(
134
+ object=str(data["object"]),
135
+ id=str(data["id"]),
136
+ confidence=int(data["confidence"]) if isinstance(data.get("confidence"), int) else None,
137
+ identified_at=data.get("identified_at")
138
+ if isinstance(data.get("identified_at"), str) or data.get("identified_at") is None
139
+ else None,
140
+ )
141
+
142
+
143
+ def _parse_session_summary(data: dict[str, Any]) -> SessionSummary:
144
+ return SessionSummary(
145
+ object=str(data["object"]),
146
+ id=str(data["id"]),
147
+ created_at=data.get("created_at"),
148
+ latest_decision=_parse_decision(dict(data["latest_decision"])),
149
+ visitor_fingerprint=_parse_visitor_fingerprint_link(data.get("visitor_fingerprint")),
150
+ )
151
+
152
+
153
+ def _parse_session_detail(data: dict[str, Any]) -> SessionDetail:
154
+ return SessionDetail(
155
+ object=str(data["object"]),
156
+ id=str(data["id"]),
157
+ created_at=data.get("created_at"),
158
+ decision=_parse_session_decision(dict(data["decision"])),
159
+ highlights=[dict(item) for item in data.get("highlights", []) if isinstance(item, dict)],
160
+ attribution=dict(data.get("attribution")) if isinstance(data.get("attribution"), dict) else None,
161
+ web_bot_auth=dict(data.get("web_bot_auth")) if isinstance(data.get("web_bot_auth"), dict) else None,
162
+ network=dict(data.get("network", {})) if isinstance(data.get("network"), dict) else {},
163
+ runtime_integrity=dict(data.get("runtime_integrity", {}))
164
+ if isinstance(data.get("runtime_integrity"), dict)
165
+ else {},
166
+ native_runtime_integrity=dict(data.get("native_runtime_integrity"))
167
+ if isinstance(data.get("native_runtime_integrity"), dict)
168
+ else None,
169
+ native_app=dict(data.get("native_app")) if isinstance(data.get("native_app"), dict) else None,
170
+ native_carrier=dict(data.get("native_carrier")) if isinstance(data.get("native_carrier"), dict) else None,
171
+ native_motion_print=dict(data.get("native_motion_print"))
172
+ if isinstance(data.get("native_motion_print"), dict)
173
+ else None,
174
+ device_identity=dict(data.get("device_identity")) if isinstance(data.get("device_identity"), dict) else None,
175
+ install_id=data.get("install_id") if isinstance(data.get("install_id"), str) else None,
176
+ visitor_fingerprint=dict(data.get("visitor_fingerprint"))
177
+ if isinstance(data.get("visitor_fingerprint"), dict)
178
+ else None,
179
+ connection_fingerprint=dict(data.get("connection_fingerprint", {}))
180
+ if isinstance(data.get("connection_fingerprint"), dict)
181
+ else {},
182
+ previous_decisions=[_parse_session_decision(dict(item)) for item in data.get("previous_decisions", [])],
183
+ request=_parse_session_detail_request(dict(data["request"])),
184
+ browser=dict(data.get("browser", {})) if isinstance(data.get("browser"), dict) else {},
185
+ device=dict(data.get("device", {})) if isinstance(data.get("device"), dict) else {},
186
+ analysis_coverage={
187
+ str(key): bool(value)
188
+ for key, value in dict(data.get("analysis_coverage", {})).items()
189
+ if isinstance(key, str)
190
+ }
191
+ if isinstance(data.get("analysis_coverage"), dict)
192
+ else {},
193
+ signals_fired=[dict(item) for item in data.get("signals_fired", []) if isinstance(item, dict)],
194
+ client_telemetry=dict(data.get("client_telemetry", {}))
195
+ if isinstance(data.get("client_telemetry"), dict)
196
+ else {},
197
+ )
198
+
199
+
200
+ def _parse_visitor_fingerprint_summary(data: dict[str, Any]) -> VisitorFingerprintSummary:
201
+ lifecycle_raw = dict(data["lifecycle"])
202
+ latest_request_raw = dict(data["latest_request"])
203
+ storage_raw = dict(data["storage"])
204
+ anchors_raw = dict(data["anchors"])
205
+
206
+ return VisitorFingerprintSummary(
207
+ object=str(data["object"]),
208
+ id=str(data["id"]),
209
+ lifecycle=VisitorFingerprintLifecycle(
210
+ first_seen_at=str(lifecycle_raw["first_seen_at"]),
211
+ last_seen_at=str(lifecycle_raw["last_seen_at"]),
212
+ seen_count=int(lifecycle_raw["seen_count"]),
213
+ expires_at=str(lifecycle_raw["expires_at"]),
214
+ ),
215
+ latest_request=VisitorFingerprintLatestRequest(
216
+ user_agent=str(latest_request_raw["user_agent"]),
217
+ ip_address=str(latest_request_raw["ip_address"]),
218
+ ),
219
+ storage=VisitorFingerprintStorage(
220
+ cookies=bool(storage_raw["cookies"]),
221
+ local_storage=bool(storage_raw["local_storage"]),
222
+ indexed_db=bool(storage_raw["indexed_db"]),
223
+ service_worker=bool(storage_raw["service_worker"]),
224
+ window_name=bool(storage_raw["window_name"]),
225
+ ),
226
+ anchors=VisitorFingerprintAnchors(
227
+ webgl_hash=anchors_raw.get("webgl_hash")
228
+ if isinstance(anchors_raw.get("webgl_hash"), str) or anchors_raw.get("webgl_hash") is None
229
+ else None,
230
+ parameters_hash=anchors_raw.get("parameters_hash")
231
+ if isinstance(anchors_raw.get("parameters_hash"), str) or anchors_raw.get("parameters_hash") is None
232
+ else None,
233
+ audio_hash=anchors_raw.get("audio_hash")
234
+ if isinstance(anchors_raw.get("audio_hash"), str) or anchors_raw.get("audio_hash") is None
235
+ else None,
236
+ ),
237
+ )
238
+
239
+
240
+ def _parse_score_breakdown(data: dict[str, Any]) -> ScoreBreakdown:
241
+ categories_raw = data.get("categories", {})
242
+ categories = (
243
+ {str(key): int(value) for key, value in dict(categories_raw).items()}
244
+ if isinstance(categories_raw, dict)
245
+ else {}
246
+ )
247
+ return ScoreBreakdown(categories=categories)
248
+
249
+
250
+ def _parse_visitor_fingerprint_detail(data: dict[str, Any]) -> VisitorFingerprintDetail:
251
+ summary = _parse_visitor_fingerprint_summary(data)
252
+ components_raw = dict(data.get("components", {}))
253
+ activity_raw = dict(data.get("activity", {}))
254
+
255
+ sessions = [
256
+ VisitorFingerprintSessionSummary(
257
+ session_id=str(item["session_id"]),
258
+ decision=_parse_decision(dict(item["decision"])),
259
+ request=_parse_request_context(dict(item["request"])),
260
+ score_breakdown=_parse_score_breakdown(dict(item["score_breakdown"])),
261
+ )
262
+ for item in activity_raw.get("sessions", [])
263
+ ]
264
+
265
+ return VisitorFingerprintDetail(
266
+ **summary.__dict__,
267
+ components=VisitorFingerprintComponents(
268
+ vector=[int(value) for value in components_raw.get("vector", [])],
269
+ ),
270
+ activity=VisitorFingerprintActivity(sessions=sessions),
271
+ )
272
+
273
+
274
+ def _parse_organization(data: dict[str, Any]) -> Organization:
275
+ return Organization(
276
+ object=str(data["object"]),
277
+ id=str(data["id"]),
278
+ name=str(data["name"]),
279
+ slug=str(data["slug"]),
280
+ status=str(data["status"]),
281
+ created_at=str(data["created_at"]),
282
+ updated_at=data.get("updated_at"),
283
+ )
284
+
285
+
286
+ def _parse_api_key(data: dict[str, Any]) -> ApiKey:
287
+ return ApiKey(
288
+ object=str(data["object"]),
289
+ id=str(data["id"]),
290
+ type=str(data["type"]),
291
+ name=str(data["name"]),
292
+ environment=str(data["environment"]),
293
+ allowed_origins=[str(value) for value in data.get("allowed_origins", [])]
294
+ if data.get("allowed_origins") is not None
295
+ else None,
296
+ scopes=[str(value) for value in data.get("scopes", [])] if data.get("scopes") is not None else None,
297
+ rate_limit=int(data["rate_limit"]) if isinstance(data.get("rate_limit"), int) else None,
298
+ status=str(data["status"]),
299
+ key_preview=str(data["key_preview"]),
300
+ display_key=data.get("display_key"),
301
+ last_used_at=data.get("last_used_at"),
302
+ created_at=str(data["created_at"]),
303
+ rotated_at=data.get("rotated_at"),
304
+ revoked_at=data.get("revoked_at"),
305
+ grace_expires_at=data.get("grace_expires_at"),
306
+ )
307
+
308
+
309
+ def _parse_issued_api_key(data: dict[str, Any]) -> IssuedApiKey:
310
+ api_key = _parse_api_key(data)
311
+ return IssuedApiKey(**api_key.__dict__, revealed_key=str(data["revealed_key"]))
312
+
313
+
314
+ def _parse_gate_service_env_var(data: dict[str, Any]) -> GateServiceEnvVar:
315
+ return GateServiceEnvVar(
316
+ name=str(data["name"]),
317
+ key=str(data["key"]),
318
+ secret=bool(data["secret"]),
319
+ )
320
+
321
+
322
+ def _parse_gate_service_sdk_install(data: dict[str, Any]) -> GateServiceSdkInstall:
323
+ return GateServiceSdkInstall(
324
+ label=str(data["label"]),
325
+ install=str(data["install"]),
326
+ url=str(data["url"]),
327
+ )
328
+
329
+
330
+ def _parse_gate_service_branding(data: dict[str, Any] | None) -> GateServiceBranding:
331
+ payload = data or {}
332
+ return GateServiceBranding(
333
+ verified=bool(payload.get("verified", False)),
334
+ logo_url=payload.get("logo_url") if isinstance(payload.get("logo_url"), str) else None,
335
+ primary_color=payload.get("primary_color") if isinstance(payload.get("primary_color"), str) else None,
336
+ secondary_color=payload.get("secondary_color") if isinstance(payload.get("secondary_color"), str) else None,
337
+ ascii_art=payload.get("ascii_art") if isinstance(payload.get("ascii_art"), str) else None,
338
+ )
339
+
340
+
341
+ def _parse_gate_service_consent(data: dict[str, Any] | None) -> GateServiceConsent:
342
+ payload = data or {}
343
+ return GateServiceConsent(
344
+ terms_url=payload.get("terms_url") if isinstance(payload.get("terms_url"), str) else None,
345
+ privacy_url=payload.get("privacy_url") if isinstance(payload.get("privacy_url"), str) else None,
346
+ )
347
+
348
+
349
+ def _parse_gate_registry_entry(data: dict[str, Any]) -> GateRegistryEntry:
350
+ return GateRegistryEntry(
351
+ id=str(data["id"]),
352
+ status=str(data["status"]),
353
+ discoverable=bool(data["discoverable"]),
354
+ name=str(data["name"]),
355
+ description=str(data["description"]),
356
+ website=str(data["website"]),
357
+ env_vars=[_parse_gate_service_env_var(dict(item)) for item in data.get("env_vars", [])],
358
+ docs_url=str(data["docs_url"]),
359
+ sdks=[_parse_gate_service_sdk_install(dict(item)) for item in data.get("sdks", [])],
360
+ branding=_parse_gate_service_branding(dict(data["branding"])) if isinstance(data.get("branding"), dict) else _parse_gate_service_branding(None),
361
+ consent=_parse_gate_service_consent(dict(data["consent"])) if isinstance(data.get("consent"), dict) else _parse_gate_service_consent(None),
362
+ dashboard_login_url=data.get("dashboard_login_url")
363
+ if isinstance(data.get("dashboard_login_url"), str) or data.get("dashboard_login_url") is None
364
+ else None,
365
+ )
366
+
367
+
368
+ def _parse_gate_managed_service(data: dict[str, Any]) -> GateManagedService:
369
+ entry = _parse_gate_registry_entry(data)
370
+ return GateManagedService(
371
+ **entry.__dict__,
372
+ object=str(data["object"]),
373
+ webhook_endpoint_id=data.get("webhook_endpoint_id")
374
+ if isinstance(data.get("webhook_endpoint_id"), str) or data.get("webhook_endpoint_id") is None
375
+ else None,
376
+ created_at=str(data["created_at"]),
377
+ updated_at=str(data["updated_at"]),
378
+ )
379
+
380
+
381
+ def _parse_webhook_endpoint(data: dict[str, Any]) -> WebhookEndpoint:
382
+ return WebhookEndpoint(
383
+ object=str(data["object"]),
384
+ id=str(data["id"]),
385
+ name=str(data["name"]),
386
+ url=str(data["url"]),
387
+ status=str(data["status"]),
388
+ event_types=[str(item) for item in data.get("event_types", [])],
389
+ signing_secret=data.get("signing_secret") if isinstance(data.get("signing_secret"), str) else None,
390
+ created_at=str(data["created_at"]),
391
+ updated_at=str(data["updated_at"]),
392
+ )
393
+
394
+
395
+ def _parse_webhook_delivery(data: dict[str, Any]) -> WebhookDelivery:
396
+ return WebhookDelivery(
397
+ object=str(data["object"]),
398
+ id=str(data["id"]),
399
+ event_id=str(data["event_id"]),
400
+ endpoint_id=str(data["endpoint_id"]),
401
+ event_type=str(data["event_type"]),
402
+ status=str(data["status"]),
403
+ attempts=int(data["attempts"]),
404
+ response_status=int(data["response_status"]) if isinstance(data.get("response_status"), int) else None,
405
+ response_body=data.get("response_body")
406
+ if isinstance(data.get("response_body"), str) or data.get("response_body") is None
407
+ else None,
408
+ error=data.get("error") if isinstance(data.get("error"), str) or data.get("error") is None else None,
409
+ created_at=str(data["created_at"]),
410
+ updated_at=str(data["updated_at"]),
411
+ )
412
+
413
+
414
+ def _parse_event_subject(data: dict[str, Any]) -> EventSubject:
415
+ return EventSubject(
416
+ type=str(data["type"]),
417
+ id=str(data["id"]),
418
+ )
419
+
420
+
421
+ def _parse_event(data: dict[str, Any]) -> Event:
422
+ return Event(
423
+ object=str(data["object"]),
424
+ id=str(data["id"]),
425
+ type=str(data["type"]),
426
+ subject=_parse_event_subject(dict(data["subject"])),
427
+ data=dict(data.get("data", {})) if isinstance(data.get("data"), dict) else {},
428
+ webhook_deliveries=[
429
+ _parse_webhook_delivery(dict(item))
430
+ for item in data.get("webhook_deliveries", [])
431
+ if isinstance(item, dict)
432
+ ],
433
+ created_at=str(data["created_at"]),
434
+ )
435
+
436
+
437
+ def _parse_webhook_test(data: dict[str, Any]) -> WebhookTest:
438
+ latest_delivery = data.get("latest_delivery")
439
+ return WebhookTest(
440
+ object=str(data["object"]),
441
+ event_id=str(data["event_id"]),
442
+ delivery_ids=[str(item) for item in data.get("delivery_ids", [])],
443
+ latest_delivery=_parse_webhook_delivery(dict(latest_delivery)) if isinstance(latest_delivery, dict) else None,
444
+ )
445
+
446
+
447
+ def _parse_gate_delivery_envelope(data: dict[str, Any]) -> GateDeliveryEnvelope:
448
+ return GateDeliveryEnvelope(
449
+ version=int(data["version"]),
450
+ algorithm=str(data["algorithm"]),
451
+ key_id=str(data["key_id"]),
452
+ ephemeral_public_key=str(data["ephemeral_public_key"]),
453
+ salt=str(data["salt"]),
454
+ iv=str(data["iv"]),
455
+ ciphertext=str(data["ciphertext"]),
456
+ tag=str(data["tag"]),
457
+ )
458
+
459
+
460
+ def _parse_gate_delivery_bundle(data: dict[str, Any] | None) -> GateDeliveryBundle | None:
461
+ if data is None:
462
+ return None
463
+ return GateDeliveryBundle(
464
+ integrator=_parse_gate_delivery_envelope(dict(data["integrator"])),
465
+ gate=_parse_gate_delivery_envelope(dict(data["gate"])),
466
+ )
467
+
468
+
469
+ def _parse_gate_session_create(data: dict[str, Any]) -> GateSessionCreate:
470
+ return GateSessionCreate(
471
+ object=str(data["object"]),
472
+ id=str(data["id"]),
473
+ status=str(data["status"]),
474
+ poll_token=str(data["poll_token"]),
475
+ consent_url=str(data["consent_url"]),
476
+ expires_at=str(data["expires_at"]),
477
+ )
478
+
479
+
480
+ def _parse_gate_session_poll(data: dict[str, Any]) -> GateSessionPollData:
481
+ return GateSessionPollData(
482
+ object=str(data["object"]),
483
+ id=str(data["id"]),
484
+ status=str(data["status"]),
485
+ expires_at=data.get("expires_at") if isinstance(data.get("expires_at"), str) or data.get("expires_at") is None else None,
486
+ gate_account_id=data.get("gate_account_id") if isinstance(data.get("gate_account_id"), str) or data.get("gate_account_id") is None else None,
487
+ account_name=data.get("account_name") if isinstance(data.get("account_name"), str) or data.get("account_name") is None else None,
488
+ delivery_bundle=_parse_gate_delivery_bundle(dict(data["delivery_bundle"])) if isinstance(data.get("delivery_bundle"), dict) else None,
489
+ docs_url=data.get("docs_url") if isinstance(data.get("docs_url"), str) or data.get("docs_url") is None else None,
490
+ )
491
+
492
+
493
+ def _parse_gate_session_delivery_acknowledgement(data: dict[str, Any]) -> GateSessionDeliveryAcknowledgement:
494
+ return GateSessionDeliveryAcknowledgement(
495
+ object=str(data["object"]),
496
+ gate_session_id=str(data["gate_session_id"]),
497
+ status=str(data["status"]),
498
+ )
499
+
500
+
501
+ def _parse_gate_login_session(data: dict[str, Any]) -> GateLoginSession:
502
+ return GateLoginSession(
503
+ object=str(data["object"]),
504
+ id=str(data["id"]),
505
+ status=str(data["status"]),
506
+ consent_url=str(data["consent_url"]),
507
+ expires_at=str(data["expires_at"]),
508
+ )
509
+
510
+
511
+ def _parse_gate_dashboard_login(data: dict[str, Any]) -> GateDashboardLogin:
512
+ return GateDashboardLogin(
513
+ object=str(data["object"]),
514
+ gate_account_id=str(data["gate_account_id"]),
515
+ account_name=str(data["account_name"]),
516
+ )
517
+
518
+
519
+ def _parse_agent_token_verification(data: dict[str, Any]) -> AgentTokenVerification:
520
+ return AgentTokenVerification(
521
+ valid=bool(data["valid"]),
522
+ gate_account_id=data.get("gate_account_id") if isinstance(data.get("gate_account_id"), str) or data.get("gate_account_id") is None else None,
523
+ status=data.get("status") if isinstance(data.get("status"), str) or data.get("status") is None else None,
524
+ created_at=data.get("created_at") if isinstance(data.get("created_at"), str) or data.get("created_at") is None else None,
525
+ expires_at=data.get("expires_at") if isinstance(data.get("expires_at"), str) or data.get("expires_at") is None else None,
526
+ )
527
+
528
+
529
+ def _normalize_list(items: list[Any], pagination: dict[str, Any]) -> ListResult[Any]:
530
+ return ListResult(
531
+ items=items,
532
+ limit=int(pagination["limit"]),
533
+ has_more=bool(pagination["has_more"]),
534
+ next_cursor=pagination.get("next_cursor"),
535
+ )
536
+
537
+
538
+ class _BaseAPI:
539
+ def __init__(self, client: "Foil") -> None:
540
+ self._client = client
541
+
542
+
543
+ class SessionsAPI(_BaseAPI):
544
+ def list(
545
+ self,
546
+ *,
547
+ limit: int | None = None,
548
+ cursor: str | None = None,
549
+ verdict: str | None = None,
550
+ search: str | None = None,
551
+ ) -> ListResult[SessionSummary]:
552
+ response = self._client._request_json(
553
+ "GET",
554
+ "/v1/sessions",
555
+ query=_compact_query(
556
+ {
557
+ "limit": limit,
558
+ "cursor": cursor,
559
+ "verdict": verdict,
560
+ "search": search,
561
+ }
562
+ ),
563
+ )
564
+ return _normalize_list(
565
+ [_parse_session_summary(dict(item)) for item in response["data"]],
566
+ dict(response["pagination"]),
567
+ )
568
+
569
+ def get(self, session_id: str) -> SessionDetail:
570
+ response = self._client._request_json("GET", f"/v1/sessions/{session_id}")
571
+ return _parse_session_detail(dict(response["data"]))
572
+
573
+ def iter(
574
+ self,
575
+ *,
576
+ limit: int | None = None,
577
+ verdict: str | None = None,
578
+ search: str | None = None,
579
+ ) -> Iterator[SessionSummary]:
580
+ cursor: str | None = None
581
+ while True:
582
+ page = self.list(limit=limit, cursor=cursor, verdict=verdict, search=search)
583
+ for item in page.items:
584
+ yield item
585
+ if not page.has_more or not page.next_cursor:
586
+ break
587
+ cursor = page.next_cursor
588
+
589
+
590
+ class FingerprintsAPI(_BaseAPI):
591
+ def list(
592
+ self,
593
+ *,
594
+ limit: int | None = None,
595
+ cursor: str | None = None,
596
+ search: str | None = None,
597
+ sort: str | None = None,
598
+ ) -> ListResult[VisitorFingerprintSummary]:
599
+ response = self._client._request_json(
600
+ "GET",
601
+ "/v1/fingerprints",
602
+ query=_compact_query(
603
+ {
604
+ "limit": limit,
605
+ "cursor": cursor,
606
+ "search": search,
607
+ "sort": sort,
608
+ }
609
+ ),
610
+ )
611
+ return _normalize_list(
612
+ [_parse_visitor_fingerprint_summary(dict(item)) for item in response["data"]],
613
+ dict(response["pagination"]),
614
+ )
615
+
616
+ def get(self, visitor_id: str) -> VisitorFingerprintDetail:
617
+ response = self._client._request_json("GET", f"/v1/fingerprints/{visitor_id}")
618
+ return _parse_visitor_fingerprint_detail(dict(response["data"]))
619
+
620
+ def iter(
621
+ self,
622
+ *,
623
+ limit: int | None = None,
624
+ search: str | None = None,
625
+ sort: str | None = None,
626
+ ) -> Iterator[VisitorFingerprintSummary]:
627
+ cursor: str | None = None
628
+ while True:
629
+ page = self.list(limit=limit, cursor=cursor, search=search, sort=sort)
630
+ for item in page.items:
631
+ yield item
632
+ if not page.has_more or not page.next_cursor:
633
+ break
634
+ cursor = page.next_cursor
635
+
636
+
637
+ class ApiKeysAPI(_BaseAPI):
638
+ def create(
639
+ self,
640
+ organization_id: str,
641
+ *,
642
+ name: str,
643
+ type: str | None = None,
644
+ environment: str | None = None,
645
+ allowed_origins: list[str] | None = None,
646
+ scopes: list[str] | None = None,
647
+ ) -> IssuedApiKey:
648
+ response = self._client._request_json(
649
+ "POST",
650
+ f"/v1/organizations/{organization_id}/api-keys",
651
+ body=_compact_query(
652
+ {
653
+ "name": name,
654
+ "type": type,
655
+ "environment": environment,
656
+ "allowed_origins": allowed_origins,
657
+ "scopes": scopes,
658
+ }
659
+ ),
660
+ )
661
+ return _parse_issued_api_key(dict(response["data"]))
662
+
663
+ def list(
664
+ self,
665
+ organization_id: str,
666
+ *,
667
+ limit: int | None = None,
668
+ cursor: str | None = None,
669
+ ) -> ListResult[ApiKey]:
670
+ response = self._client._request_json(
671
+ "GET",
672
+ f"/v1/organizations/{organization_id}/api-keys",
673
+ query=_compact_query({"limit": limit, "cursor": cursor}),
674
+ )
675
+ return _normalize_list(
676
+ [_parse_api_key(dict(item)) for item in response["data"]],
677
+ dict(response["pagination"]),
678
+ )
679
+
680
+ def update(
681
+ self,
682
+ organization_id: str,
683
+ key_id: str,
684
+ *,
685
+ name: str | None = None,
686
+ allowed_origins: list[str] | None = None,
687
+ scopes: list[str] | None = None,
688
+ ) -> ApiKey:
689
+ response = self._client._request_json(
690
+ "PATCH",
691
+ f"/v1/organizations/{organization_id}/api-keys/{key_id}",
692
+ body=_compact_query({"name": name, "allowed_origins": allowed_origins, "scopes": scopes}),
693
+ )
694
+ return _parse_api_key(dict(response["data"]))
695
+
696
+ def revoke(self, organization_id: str, key_id: str) -> ApiKey:
697
+ response = self._client._request_json(
698
+ "DELETE",
699
+ f"/v1/organizations/{organization_id}/api-keys/{key_id}",
700
+ )
701
+ return _parse_api_key(dict(response["data"]))
702
+
703
+ def rotate(self, organization_id: str, key_id: str) -> IssuedApiKey:
704
+ response = self._client._request_json(
705
+ "POST",
706
+ f"/v1/organizations/{organization_id}/api-keys/{key_id}/rotations",
707
+ )
708
+ return _parse_issued_api_key(dict(response["data"]))
709
+
710
+
711
+ class OrganizationsAPI(_BaseAPI):
712
+ def __init__(self, client: "Foil") -> None:
713
+ super().__init__(client)
714
+ self.api_keys = ApiKeysAPI(client)
715
+
716
+ def create(self, *, name: str, slug: str) -> Organization:
717
+ response = self._client._request_json(
718
+ "POST",
719
+ "/v1/organizations",
720
+ body={"name": name, "slug": slug},
721
+ )
722
+ return _parse_organization(dict(response["data"]))
723
+
724
+ def get(self, organization_id: str) -> Organization:
725
+ response = self._client._request_json("GET", f"/v1/organizations/{organization_id}")
726
+ return _parse_organization(dict(response["data"]))
727
+
728
+ def update(
729
+ self,
730
+ organization_id: str,
731
+ *,
732
+ name: str | None = None,
733
+ status: str | None = None,
734
+ ) -> Organization:
735
+ response = self._client._request_json(
736
+ "PATCH",
737
+ f"/v1/organizations/{organization_id}",
738
+ body=_compact_query({"name": name, "status": status}),
739
+ )
740
+ return _parse_organization(dict(response["data"]))
741
+
742
+
743
+ class GateRegistryAPI(_BaseAPI):
744
+ def list(self) -> list[GateRegistryEntry]:
745
+ response = self._client._request_json("GET", "/v1/gate/registry", auth_mode="none")
746
+ return [_parse_gate_registry_entry(dict(item)) for item in response["data"]]
747
+
748
+ def get(self, service_id: str) -> GateRegistryEntry:
749
+ response = self._client._request_json("GET", f"/v1/gate/registry/{service_id}", auth_mode="none")
750
+ return _parse_gate_registry_entry(dict(response["data"]))
751
+
752
+
753
+ class GateServicesAPI(_BaseAPI):
754
+ def list(self) -> list[GateManagedService]:
755
+ response = self._client._request_json("GET", "/v1/gate/services")
756
+ return [_parse_gate_managed_service(dict(item)) for item in response["data"]]
757
+
758
+ def get(self, service_id: str) -> GateManagedService:
759
+ response = self._client._request_json("GET", f"/v1/gate/services/{service_id}")
760
+ return _parse_gate_managed_service(dict(response["data"]))
761
+
762
+ def create(self, **body: Any) -> GateManagedService:
763
+ response = self._client._request_json("POST", "/v1/gate/services", body=body)
764
+ return _parse_gate_managed_service(dict(response["data"]))
765
+
766
+ def update(self, service_id: str, **body: Any) -> GateManagedService:
767
+ response = self._client._request_json("PATCH", f"/v1/gate/services/{service_id}", body=body)
768
+ return _parse_gate_managed_service(dict(response["data"]))
769
+
770
+ def disable(self, service_id: str) -> GateManagedService:
771
+ response = self._client._request_json("DELETE", f"/v1/gate/services/{service_id}")
772
+ return _parse_gate_managed_service(dict(response["data"]))
773
+
774
+
775
+ class GateSessionsAPI(_BaseAPI):
776
+ def create(
777
+ self,
778
+ *,
779
+ service_id: str,
780
+ account_name: str,
781
+ delivery: dict[str, Any],
782
+ metadata: dict[str, Any] | None = None,
783
+ ) -> GateSessionCreate:
784
+ body = {
785
+ "service_id": service_id,
786
+ "account_name": account_name,
787
+ "delivery": delivery,
788
+ }
789
+ if metadata is not None:
790
+ body["metadata"] = metadata
791
+ response = self._client._request_json("POST", "/v1/gate/sessions", body=body, auth_mode="none")
792
+ return _parse_gate_session_create(dict(response["data"]))
793
+
794
+ def poll(self, gate_session_id: str, *, poll_token: str) -> GateSessionPollData:
795
+ response = self._client._request_json(
796
+ "GET",
797
+ f"/v1/gate/sessions/{gate_session_id}",
798
+ auth_mode="bearer",
799
+ bearer_token=poll_token,
800
+ )
801
+ return _parse_gate_session_poll(dict(response["data"]))
802
+
803
+ def acknowledge(self, gate_session_id: str, *, poll_token: str, ack_token: str) -> GateSessionDeliveryAcknowledgement:
804
+ response = self._client._request_json(
805
+ "POST",
806
+ f"/v1/gate/sessions/{gate_session_id}/ack",
807
+ body={"ack_token": ack_token},
808
+ auth_mode="bearer",
809
+ bearer_token=poll_token,
810
+ )
811
+ return _parse_gate_session_delivery_acknowledgement(dict(response["data"]))
812
+
813
+
814
+ class GateLoginSessionsAPI(_BaseAPI):
815
+ def create(self, *, service_id: str, agent_token: str) -> GateLoginSession:
816
+ response = self._client._request_json(
817
+ "POST",
818
+ "/v1/gate/login-sessions",
819
+ body={"service_id": service_id},
820
+ auth_mode="bearer",
821
+ bearer_token=agent_token,
822
+ )
823
+ return _parse_gate_login_session(dict(response["data"]))
824
+
825
+ def consume(self, *, code: str) -> GateDashboardLogin:
826
+ response = self._client._request_json(
827
+ "POST",
828
+ "/v1/gate/login-sessions/consume",
829
+ body={"code": code},
830
+ )
831
+ return _parse_gate_dashboard_login(dict(response["data"]))
832
+
833
+
834
+ class GateAgentTokensAPI(_BaseAPI):
835
+ def verify(self, *, agent_token: str) -> AgentTokenVerification:
836
+ response = self._client._request_json(
837
+ "POST",
838
+ "/v1/gate/agent-tokens/verify",
839
+ body={"agent_token": agent_token},
840
+ )
841
+ return _parse_agent_token_verification(dict(response["data"]))
842
+
843
+ def revoke(self, *, agent_token: str) -> None:
844
+ self._client._request_json(
845
+ "POST",
846
+ "/v1/gate/agent-tokens/revoke",
847
+ body={"agent_token": agent_token},
848
+ expect_content=False,
849
+ )
850
+
851
+
852
+ class GateAPI(_BaseAPI):
853
+ def __init__(self, client: "Foil") -> None:
854
+ super().__init__(client)
855
+ self.registry = GateRegistryAPI(client)
856
+ self.services = GateServicesAPI(client)
857
+ self.sessions = GateSessionsAPI(client)
858
+ self.login_sessions = GateLoginSessionsAPI(client)
859
+ self.agent_tokens = GateAgentTokensAPI(client)
860
+
861
+
862
+ class WebhooksAPI(_BaseAPI):
863
+ def list_endpoints(self, organization_id: str) -> ListResult[WebhookEndpoint]:
864
+ response = self._client._request_json("GET", f"/v1/organizations/{organization_id}/webhooks/endpoints")
865
+ return _normalize_list(
866
+ [_parse_webhook_endpoint(dict(item)) for item in response["data"]],
867
+ dict(response["pagination"]),
868
+ )
869
+
870
+ def create_endpoint(
871
+ self,
872
+ organization_id: str,
873
+ *,
874
+ name: str,
875
+ url: str,
876
+ event_types: list[str],
877
+ ) -> WebhookEndpoint:
878
+ response = self._client._request_json(
879
+ "POST",
880
+ f"/v1/organizations/{organization_id}/webhooks/endpoints",
881
+ body={"name": name, "url": url, "event_types": event_types},
882
+ )
883
+ return _parse_webhook_endpoint(dict(response["data"]))
884
+
885
+ def update_endpoint(self, organization_id: str, endpoint_id: str, **body: Any) -> WebhookEndpoint:
886
+ response = self._client._request_json(
887
+ "PATCH",
888
+ f"/v1/organizations/{organization_id}/webhooks/endpoints/{endpoint_id}",
889
+ body=body,
890
+ )
891
+ return _parse_webhook_endpoint(dict(response["data"]))
892
+
893
+ def disable_endpoint(self, organization_id: str, endpoint_id: str) -> WebhookEndpoint:
894
+ response = self._client._request_json(
895
+ "DELETE",
896
+ f"/v1/organizations/{organization_id}/webhooks/endpoints/{endpoint_id}",
897
+ )
898
+ return _parse_webhook_endpoint(dict(response["data"]))
899
+
900
+ def rotate_secret(self, organization_id: str, endpoint_id: str) -> WebhookEndpoint:
901
+ response = self._client._request_json(
902
+ "POST",
903
+ f"/v1/organizations/{organization_id}/webhooks/endpoints/{endpoint_id}/rotations",
904
+ )
905
+ return _parse_webhook_endpoint(dict(response["data"]))
906
+
907
+ def send_test(self, organization_id: str, endpoint_id: str) -> WebhookTest:
908
+ response = self._client._request_json(
909
+ "POST",
910
+ f"/v1/organizations/{organization_id}/webhooks/endpoints/{endpoint_id}/test",
911
+ )
912
+ return _parse_webhook_test(dict(response["data"]))
913
+
914
+ def list_events(
915
+ self,
916
+ organization_id: str,
917
+ *,
918
+ endpoint_id: str | None = None,
919
+ type: str | None = None,
920
+ limit: int | None = None,
921
+ ) -> ListResult[Event]:
922
+ response = self._client._request_json(
923
+ "GET",
924
+ f"/v1/organizations/{organization_id}/events",
925
+ query=_compact_query({"endpoint_id": endpoint_id, "type": type, "limit": limit}),
926
+ )
927
+ return _normalize_list(
928
+ [_parse_event(dict(item)) for item in response["data"]],
929
+ dict(response["pagination"]),
930
+ )
931
+
932
+ def retrieve_event(self, organization_id: str, event_id: str) -> Event:
933
+ response = self._client._request_json("GET", f"/v1/organizations/{organization_id}/events/{event_id}")
934
+ return _parse_event(dict(response["data"]))
935
+
936
+
937
+ class Foil:
938
+ def __init__(
939
+ self,
940
+ *,
941
+ secret_key: str | None = None,
942
+ base_url: str = DEFAULT_BASE_URL,
943
+ timeout: float = DEFAULT_TIMEOUT,
944
+ user_agent: str | None = None,
945
+ transport: httpx.BaseTransport | None = None,
946
+ ) -> None:
947
+ resolved_secret = secret_key or os.getenv("FOIL_SECRET_KEY")
948
+ headers = {
949
+ "Accept": "application/json",
950
+ "X-Foil-Client": SDK_CLIENT_HEADER,
951
+ }
952
+ if user_agent:
953
+ headers["User-Agent"] = user_agent
954
+
955
+ self._secret_key = resolved_secret
956
+ self._client = httpx.Client(
957
+ base_url=base_url or DEFAULT_BASE_URL,
958
+ timeout=timeout,
959
+ transport=transport,
960
+ headers=headers,
961
+ )
962
+ self.sessions = SessionsAPI(self)
963
+ self.fingerprints = FingerprintsAPI(self)
964
+ self.organizations = OrganizationsAPI(self)
965
+ self.gate = GateAPI(self)
966
+ self.webhooks = WebhooksAPI(self)
967
+
968
+ def close(self) -> None:
969
+ self._client.close()
970
+
971
+ def __enter__(self) -> "Foil":
972
+ return self
973
+
974
+ def __exit__(self, *_args: object) -> None:
975
+ self.close()
976
+
977
+ def _request_json(
978
+ self,
979
+ method: str,
980
+ path: str,
981
+ *,
982
+ query: dict[str, Any] | None = None,
983
+ body: dict[str, Any] | None = None,
984
+ expect_content: bool = True,
985
+ auth_mode: str = "secret",
986
+ bearer_token: str | None = None,
987
+ ) -> dict[str, Any]:
988
+ headers: dict[str, str] = {}
989
+ if auth_mode == "bearer":
990
+ if not bearer_token:
991
+ raise FoilConfigurationError("Missing bearer token for this Foil request.")
992
+ headers["Authorization"] = f"Bearer {bearer_token}"
993
+ elif auth_mode == "secret":
994
+ if not self._secret_key:
995
+ raise FoilConfigurationError(
996
+ "Missing Foil secret key. Pass secret_key explicitly or set FOIL_SECRET_KEY."
997
+ )
998
+ headers["Authorization"] = f"Bearer {self._secret_key}"
999
+ response = self._client.request(method, path, params=query, json=body, headers=headers)
1000
+ request_id = response.headers.get("x-request-id")
1001
+
1002
+ if response.status_code >= 400:
1003
+ payload: dict[str, Any]
1004
+ try:
1005
+ payload = response.json()
1006
+ except ValueError:
1007
+ payload = {}
1008
+
1009
+ if isinstance(payload.get("error"), dict):
1010
+ error = payload["error"]
1011
+ details = error.get("details")
1012
+ raise FoilApiError(
1013
+ status=response.status_code,
1014
+ code=str(error.get("code", "request.failed")),
1015
+ message=str(error.get("message", response.text or response.reason_phrase)),
1016
+ request_id=request_id or error.get("request_id"),
1017
+ field_errors=list(details.get("fields", [])) if isinstance(details, dict) else [],
1018
+ docs_url=error.get("docs_url"),
1019
+ body=payload,
1020
+ )
1021
+
1022
+ raise FoilApiError(
1023
+ status=response.status_code,
1024
+ code="request.failed",
1025
+ message=response.text or response.reason_phrase,
1026
+ request_id=request_id,
1027
+ body=payload,
1028
+ )
1029
+
1030
+ if not expect_content or response.status_code == 204:
1031
+ return {}
1032
+ return response.json()