truthound-dashboard 1.3.0__py3-none-any.whl → 1.4.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.
Files changed (169) hide show
  1. truthound_dashboard/api/alerts.py +258 -0
  2. truthound_dashboard/api/anomaly.py +1302 -0
  3. truthound_dashboard/api/cross_alerts.py +352 -0
  4. truthound_dashboard/api/deps.py +143 -0
  5. truthound_dashboard/api/drift_monitor.py +540 -0
  6. truthound_dashboard/api/lineage.py +1151 -0
  7. truthound_dashboard/api/maintenance.py +363 -0
  8. truthound_dashboard/api/middleware.py +373 -1
  9. truthound_dashboard/api/model_monitoring.py +805 -0
  10. truthound_dashboard/api/notifications_advanced.py +2452 -0
  11. truthound_dashboard/api/plugins.py +2096 -0
  12. truthound_dashboard/api/profile.py +211 -14
  13. truthound_dashboard/api/reports.py +853 -0
  14. truthound_dashboard/api/router.py +147 -0
  15. truthound_dashboard/api/rule_suggestions.py +310 -0
  16. truthound_dashboard/api/schema_evolution.py +231 -0
  17. truthound_dashboard/api/sources.py +47 -3
  18. truthound_dashboard/api/triggers.py +190 -0
  19. truthound_dashboard/api/validations.py +13 -0
  20. truthound_dashboard/api/validators.py +333 -4
  21. truthound_dashboard/api/versioning.py +309 -0
  22. truthound_dashboard/api/websocket.py +301 -0
  23. truthound_dashboard/core/__init__.py +27 -0
  24. truthound_dashboard/core/anomaly.py +1395 -0
  25. truthound_dashboard/core/anomaly_explainer.py +633 -0
  26. truthound_dashboard/core/cache.py +206 -0
  27. truthound_dashboard/core/cached_services.py +422 -0
  28. truthound_dashboard/core/charts.py +352 -0
  29. truthound_dashboard/core/connections.py +1069 -42
  30. truthound_dashboard/core/cross_alerts.py +837 -0
  31. truthound_dashboard/core/drift_monitor.py +1477 -0
  32. truthound_dashboard/core/drift_sampling.py +669 -0
  33. truthound_dashboard/core/i18n/__init__.py +42 -0
  34. truthound_dashboard/core/i18n/detector.py +173 -0
  35. truthound_dashboard/core/i18n/messages.py +564 -0
  36. truthound_dashboard/core/lineage.py +971 -0
  37. truthound_dashboard/core/maintenance.py +443 -5
  38. truthound_dashboard/core/model_monitoring.py +1043 -0
  39. truthound_dashboard/core/notifications/channels.py +1020 -1
  40. truthound_dashboard/core/notifications/deduplication/__init__.py +143 -0
  41. truthound_dashboard/core/notifications/deduplication/policies.py +274 -0
  42. truthound_dashboard/core/notifications/deduplication/service.py +400 -0
  43. truthound_dashboard/core/notifications/deduplication/stores.py +2365 -0
  44. truthound_dashboard/core/notifications/deduplication/strategies.py +422 -0
  45. truthound_dashboard/core/notifications/dispatcher.py +43 -0
  46. truthound_dashboard/core/notifications/escalation/__init__.py +149 -0
  47. truthound_dashboard/core/notifications/escalation/backends.py +1384 -0
  48. truthound_dashboard/core/notifications/escalation/engine.py +429 -0
  49. truthound_dashboard/core/notifications/escalation/models.py +336 -0
  50. truthound_dashboard/core/notifications/escalation/scheduler.py +1187 -0
  51. truthound_dashboard/core/notifications/escalation/state_machine.py +330 -0
  52. truthound_dashboard/core/notifications/escalation/stores.py +2896 -0
  53. truthound_dashboard/core/notifications/events.py +49 -0
  54. truthound_dashboard/core/notifications/metrics/__init__.py +115 -0
  55. truthound_dashboard/core/notifications/metrics/base.py +528 -0
  56. truthound_dashboard/core/notifications/metrics/collectors.py +583 -0
  57. truthound_dashboard/core/notifications/routing/__init__.py +169 -0
  58. truthound_dashboard/core/notifications/routing/combinators.py +184 -0
  59. truthound_dashboard/core/notifications/routing/config.py +375 -0
  60. truthound_dashboard/core/notifications/routing/config_parser.py +867 -0
  61. truthound_dashboard/core/notifications/routing/engine.py +382 -0
  62. truthound_dashboard/core/notifications/routing/expression_engine.py +1269 -0
  63. truthound_dashboard/core/notifications/routing/jinja2_engine.py +774 -0
  64. truthound_dashboard/core/notifications/routing/rules.py +625 -0
  65. truthound_dashboard/core/notifications/routing/validator.py +678 -0
  66. truthound_dashboard/core/notifications/service.py +2 -0
  67. truthound_dashboard/core/notifications/stats_aggregator.py +850 -0
  68. truthound_dashboard/core/notifications/throttling/__init__.py +83 -0
  69. truthound_dashboard/core/notifications/throttling/builder.py +311 -0
  70. truthound_dashboard/core/notifications/throttling/stores.py +1859 -0
  71. truthound_dashboard/core/notifications/throttling/throttlers.py +633 -0
  72. truthound_dashboard/core/openlineage.py +1028 -0
  73. truthound_dashboard/core/plugins/__init__.py +39 -0
  74. truthound_dashboard/core/plugins/docs/__init__.py +39 -0
  75. truthound_dashboard/core/plugins/docs/extractor.py +703 -0
  76. truthound_dashboard/core/plugins/docs/renderers.py +804 -0
  77. truthound_dashboard/core/plugins/hooks/__init__.py +63 -0
  78. truthound_dashboard/core/plugins/hooks/decorators.py +367 -0
  79. truthound_dashboard/core/plugins/hooks/manager.py +403 -0
  80. truthound_dashboard/core/plugins/hooks/protocols.py +265 -0
  81. truthound_dashboard/core/plugins/lifecycle/__init__.py +41 -0
  82. truthound_dashboard/core/plugins/lifecycle/hot_reload.py +584 -0
  83. truthound_dashboard/core/plugins/lifecycle/machine.py +419 -0
  84. truthound_dashboard/core/plugins/lifecycle/states.py +266 -0
  85. truthound_dashboard/core/plugins/loader.py +504 -0
  86. truthound_dashboard/core/plugins/registry.py +810 -0
  87. truthound_dashboard/core/plugins/reporter_executor.py +588 -0
  88. truthound_dashboard/core/plugins/sandbox/__init__.py +59 -0
  89. truthound_dashboard/core/plugins/sandbox/code_validator.py +243 -0
  90. truthound_dashboard/core/plugins/sandbox/engines.py +770 -0
  91. truthound_dashboard/core/plugins/sandbox/protocols.py +194 -0
  92. truthound_dashboard/core/plugins/sandbox.py +617 -0
  93. truthound_dashboard/core/plugins/security/__init__.py +68 -0
  94. truthound_dashboard/core/plugins/security/analyzer.py +535 -0
  95. truthound_dashboard/core/plugins/security/policies.py +311 -0
  96. truthound_dashboard/core/plugins/security/protocols.py +296 -0
  97. truthound_dashboard/core/plugins/security/signing.py +842 -0
  98. truthound_dashboard/core/plugins/security.py +446 -0
  99. truthound_dashboard/core/plugins/validator_executor.py +401 -0
  100. truthound_dashboard/core/plugins/versioning/__init__.py +51 -0
  101. truthound_dashboard/core/plugins/versioning/constraints.py +377 -0
  102. truthound_dashboard/core/plugins/versioning/dependencies.py +541 -0
  103. truthound_dashboard/core/plugins/versioning/semver.py +266 -0
  104. truthound_dashboard/core/profile_comparison.py +601 -0
  105. truthound_dashboard/core/report_history.py +570 -0
  106. truthound_dashboard/core/reporters/__init__.py +57 -0
  107. truthound_dashboard/core/reporters/base.py +296 -0
  108. truthound_dashboard/core/reporters/csv_reporter.py +155 -0
  109. truthound_dashboard/core/reporters/html_reporter.py +598 -0
  110. truthound_dashboard/core/reporters/i18n/__init__.py +65 -0
  111. truthound_dashboard/core/reporters/i18n/base.py +494 -0
  112. truthound_dashboard/core/reporters/i18n/catalogs.py +930 -0
  113. truthound_dashboard/core/reporters/json_reporter.py +160 -0
  114. truthound_dashboard/core/reporters/junit_reporter.py +233 -0
  115. truthound_dashboard/core/reporters/markdown_reporter.py +207 -0
  116. truthound_dashboard/core/reporters/pdf_reporter.py +209 -0
  117. truthound_dashboard/core/reporters/registry.py +272 -0
  118. truthound_dashboard/core/rule_generator.py +2088 -0
  119. truthound_dashboard/core/scheduler.py +822 -12
  120. truthound_dashboard/core/schema_evolution.py +858 -0
  121. truthound_dashboard/core/services.py +152 -9
  122. truthound_dashboard/core/statistics.py +718 -0
  123. truthound_dashboard/core/streaming_anomaly.py +883 -0
  124. truthound_dashboard/core/triggers/__init__.py +45 -0
  125. truthound_dashboard/core/triggers/base.py +226 -0
  126. truthound_dashboard/core/triggers/evaluators.py +609 -0
  127. truthound_dashboard/core/triggers/factory.py +363 -0
  128. truthound_dashboard/core/unified_alerts.py +870 -0
  129. truthound_dashboard/core/validation_limits.py +509 -0
  130. truthound_dashboard/core/versioning.py +709 -0
  131. truthound_dashboard/core/websocket/__init__.py +59 -0
  132. truthound_dashboard/core/websocket/manager.py +512 -0
  133. truthound_dashboard/core/websocket/messages.py +130 -0
  134. truthound_dashboard/db/__init__.py +30 -0
  135. truthound_dashboard/db/models.py +3375 -3
  136. truthound_dashboard/main.py +22 -0
  137. truthound_dashboard/schemas/__init__.py +396 -1
  138. truthound_dashboard/schemas/anomaly.py +1258 -0
  139. truthound_dashboard/schemas/base.py +4 -0
  140. truthound_dashboard/schemas/cross_alerts.py +334 -0
  141. truthound_dashboard/schemas/drift_monitor.py +890 -0
  142. truthound_dashboard/schemas/lineage.py +428 -0
  143. truthound_dashboard/schemas/maintenance.py +154 -0
  144. truthound_dashboard/schemas/model_monitoring.py +374 -0
  145. truthound_dashboard/schemas/notifications_advanced.py +1363 -0
  146. truthound_dashboard/schemas/openlineage.py +704 -0
  147. truthound_dashboard/schemas/plugins.py +1293 -0
  148. truthound_dashboard/schemas/profile.py +420 -34
  149. truthound_dashboard/schemas/profile_comparison.py +242 -0
  150. truthound_dashboard/schemas/reports.py +285 -0
  151. truthound_dashboard/schemas/rule_suggestion.py +434 -0
  152. truthound_dashboard/schemas/schema_evolution.py +164 -0
  153. truthound_dashboard/schemas/source.py +117 -2
  154. truthound_dashboard/schemas/triggers.py +511 -0
  155. truthound_dashboard/schemas/unified_alerts.py +223 -0
  156. truthound_dashboard/schemas/validation.py +25 -1
  157. truthound_dashboard/schemas/validators/__init__.py +11 -0
  158. truthound_dashboard/schemas/validators/base.py +151 -0
  159. truthound_dashboard/schemas/versioning.py +152 -0
  160. truthound_dashboard/static/index.html +2 -2
  161. {truthound_dashboard-1.3.0.dist-info → truthound_dashboard-1.4.0.dist-info}/METADATA +142 -18
  162. truthound_dashboard-1.4.0.dist-info/RECORD +239 -0
  163. truthound_dashboard/static/assets/index-BCA8H1hO.js +0 -574
  164. truthound_dashboard/static/assets/index-BNsSQ2fN.css +0 -1
  165. truthound_dashboard/static/assets/unmerged_dictionaries-CsJWCRx9.js +0 -1
  166. truthound_dashboard-1.3.0.dist-info/RECORD +0 -110
  167. {truthound_dashboard-1.3.0.dist-info → truthound_dashboard-1.4.0.dist-info}/WHEEL +0 -0
  168. {truthound_dashboard-1.3.0.dist-info → truthound_dashboard-1.4.0.dist-info}/entry_points.txt +0 -0
  169. {truthound_dashboard-1.3.0.dist-info → truthound_dashboard-1.4.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1151 @@
1
+ """Lineage API endpoints.
2
+
3
+ This module provides API endpoints for data lineage visualization and management.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Annotated, Any
9
+
10
+ from fastapi import APIRouter, HTTPException, Path, Query, Body
11
+
12
+ from truthound_dashboard.schemas.lineage import (
13
+ AnomalyImpactResponse,
14
+ AnomalyStatus,
15
+ AutoDiscoverRequest,
16
+ AutoDiscoverResponse,
17
+ ImpactAnalysisRequest,
18
+ ImpactAnalysisResponse,
19
+ ImpactDirection,
20
+ ImpactedNode,
21
+ LineageEdgeCreate,
22
+ LineageEdgeListResponse,
23
+ LineageEdgeResponse,
24
+ LineageGraphResponse,
25
+ LineageGraphWithAnomaliesResponse,
26
+ LineageNodeCreate,
27
+ LineageNodeListResponse,
28
+ LineageNodeResponse,
29
+ LineageNodeUpdate,
30
+ LineageNodeWithAnomaly,
31
+ PositionUpdateRequest,
32
+ PositionUpdateResponse,
33
+ PropagationEdge,
34
+ )
35
+ from truthound_dashboard.schemas.openlineage import (
36
+ OpenLineageExportRequest,
37
+ OpenLineageExportResponse,
38
+ OpenLineageEmitRequest,
39
+ OpenLineageEmitResponse,
40
+ OpenLineageExportFormat,
41
+ OpenLineageEvent,
42
+ WebhookCreate,
43
+ WebhookUpdate,
44
+ WebhookResponse,
45
+ WebhookListResponse,
46
+ WebhookTestRequest,
47
+ WebhookTestResult,
48
+ )
49
+ from truthound_dashboard.schemas import MessageResponse
50
+
51
+ from .deps import LineageServiceDep, OpenLineageEmitterServiceDep, OpenLineageWebhookServiceDep
52
+
53
+ router = APIRouter()
54
+
55
+
56
+ # =============================================================================
57
+ # Graph Endpoints
58
+ # =============================================================================
59
+
60
+
61
+ @router.get(
62
+ "",
63
+ response_model=LineageGraphResponse,
64
+ summary="Get lineage graph",
65
+ description="Get the complete lineage graph or filtered by source",
66
+ )
67
+ async def get_lineage_graph(
68
+ service: LineageServiceDep,
69
+ source_id: Annotated[
70
+ str | None, Query(description="Filter by source ID")
71
+ ] = None,
72
+ ) -> LineageGraphResponse:
73
+ """Get the lineage graph.
74
+
75
+ Args:
76
+ service: Injected lineage service.
77
+ source_id: Optional source ID to filter by.
78
+
79
+ Returns:
80
+ Complete lineage graph.
81
+ """
82
+ graph = await service.get_graph(source_id=source_id)
83
+ return LineageGraphResponse(**graph)
84
+
85
+
86
+ @router.get(
87
+ "/sources/{source_id}",
88
+ response_model=LineageGraphResponse,
89
+ summary="Get source lineage",
90
+ description="Get lineage graph for a specific data source",
91
+ )
92
+ async def get_source_lineage(
93
+ service: LineageServiceDep,
94
+ source_id: Annotated[str, Path(description="Source ID")],
95
+ ) -> LineageGraphResponse:
96
+ """Get lineage for a specific source.
97
+
98
+ Args:
99
+ service: Injected lineage service.
100
+ source_id: Source ID to get lineage for.
101
+
102
+ Returns:
103
+ Lineage graph for the source.
104
+ """
105
+ graph = await service.get_graph(source_id=source_id)
106
+ return LineageGraphResponse(**graph)
107
+
108
+
109
+ # =============================================================================
110
+ # Node Endpoints
111
+ # =============================================================================
112
+
113
+
114
+ @router.get(
115
+ "/nodes",
116
+ response_model=LineageNodeListResponse,
117
+ summary="List nodes",
118
+ description="Get a paginated list of lineage nodes",
119
+ )
120
+ async def list_nodes(
121
+ service: LineageServiceDep,
122
+ offset: Annotated[int, Query(ge=0, description="Offset for pagination")] = 0,
123
+ limit: Annotated[
124
+ int, Query(ge=1, le=500, description="Maximum items to return")
125
+ ] = 100,
126
+ ) -> LineageNodeListResponse:
127
+ """List all lineage nodes.
128
+
129
+ Args:
130
+ service: Injected lineage service.
131
+ offset: Number of items to skip.
132
+ limit: Maximum items to return.
133
+
134
+ Returns:
135
+ Paginated list of nodes.
136
+ """
137
+ graph = await service.get_graph()
138
+ nodes = graph["nodes"][offset : offset + limit]
139
+ return LineageNodeListResponse(
140
+ data=[LineageNodeResponse(**n) for n in nodes],
141
+ total=graph["total_nodes"],
142
+ offset=offset,
143
+ limit=limit,
144
+ )
145
+
146
+
147
+ @router.post(
148
+ "/nodes",
149
+ response_model=LineageNodeResponse,
150
+ status_code=201,
151
+ summary="Create node",
152
+ description="Create a new lineage node",
153
+ )
154
+ async def create_node(
155
+ service: LineageServiceDep,
156
+ node: LineageNodeCreate,
157
+ ) -> LineageNodeResponse:
158
+ """Create a new lineage node.
159
+
160
+ Args:
161
+ service: Injected lineage service.
162
+ node: Node creation data.
163
+
164
+ Returns:
165
+ Created node.
166
+ """
167
+ created = await service.create_node(
168
+ name=node.name,
169
+ node_type=node.node_type,
170
+ source_id=node.source_id,
171
+ metadata=node.metadata,
172
+ position_x=node.position_x,
173
+ position_y=node.position_y,
174
+ )
175
+ return LineageNodeResponse(
176
+ id=created.id,
177
+ name=created.name,
178
+ node_type=created.node_type,
179
+ source_id=created.source_id,
180
+ source_name=created.source.name if created.source else None,
181
+ metadata=created.metadata_json,
182
+ position_x=created.position_x,
183
+ position_y=created.position_y,
184
+ upstream_count=created.upstream_count,
185
+ downstream_count=created.downstream_count,
186
+ created_at=created.created_at.isoformat() if created.created_at else "",
187
+ updated_at=created.updated_at.isoformat() if created.updated_at else None,
188
+ )
189
+
190
+
191
+ @router.get(
192
+ "/nodes/{node_id}",
193
+ response_model=LineageNodeResponse,
194
+ summary="Get node",
195
+ description="Get a specific lineage node by ID",
196
+ )
197
+ async def get_node(
198
+ service: LineageServiceDep,
199
+ node_id: Annotated[str, Path(description="Node ID")],
200
+ ) -> LineageNodeResponse:
201
+ """Get a specific lineage node.
202
+
203
+ Args:
204
+ service: Injected lineage service.
205
+ node_id: Node unique identifier.
206
+
207
+ Returns:
208
+ Node details.
209
+
210
+ Raises:
211
+ HTTPException: 404 if node not found.
212
+ """
213
+ node = await service.get_node(node_id)
214
+ if node is None:
215
+ raise HTTPException(status_code=404, detail="Node not found")
216
+ return LineageNodeResponse(
217
+ id=node.id,
218
+ name=node.name,
219
+ node_type=node.node_type,
220
+ source_id=node.source_id,
221
+ source_name=node.source.name if node.source else None,
222
+ metadata=node.metadata_json,
223
+ position_x=node.position_x,
224
+ position_y=node.position_y,
225
+ upstream_count=node.upstream_count,
226
+ downstream_count=node.downstream_count,
227
+ created_at=node.created_at.isoformat() if node.created_at else "",
228
+ updated_at=node.updated_at.isoformat() if node.updated_at else None,
229
+ )
230
+
231
+
232
+ @router.put(
233
+ "/nodes/{node_id}",
234
+ response_model=LineageNodeResponse,
235
+ summary="Update node",
236
+ description="Update an existing lineage node",
237
+ )
238
+ async def update_node(
239
+ service: LineageServiceDep,
240
+ node_id: Annotated[str, Path(description="Node ID")],
241
+ update: LineageNodeUpdate,
242
+ ) -> LineageNodeResponse:
243
+ """Update an existing lineage node.
244
+
245
+ Args:
246
+ service: Injected lineage service.
247
+ node_id: Node unique identifier.
248
+ update: Update data.
249
+
250
+ Returns:
251
+ Updated node.
252
+
253
+ Raises:
254
+ HTTPException: 404 if node not found.
255
+ """
256
+ updated = await service.update_node(
257
+ node_id,
258
+ name=update.name,
259
+ metadata=update.metadata,
260
+ position_x=update.position_x,
261
+ position_y=update.position_y,
262
+ )
263
+ if updated is None:
264
+ raise HTTPException(status_code=404, detail="Node not found")
265
+ return LineageNodeResponse(
266
+ id=updated.id,
267
+ name=updated.name,
268
+ node_type=updated.node_type,
269
+ source_id=updated.source_id,
270
+ source_name=updated.source.name if updated.source else None,
271
+ metadata=updated.metadata_json,
272
+ position_x=updated.position_x,
273
+ position_y=updated.position_y,
274
+ upstream_count=updated.upstream_count,
275
+ downstream_count=updated.downstream_count,
276
+ created_at=updated.created_at.isoformat() if updated.created_at else "",
277
+ updated_at=updated.updated_at.isoformat() if updated.updated_at else None,
278
+ )
279
+
280
+
281
+ @router.delete(
282
+ "/nodes/{node_id}",
283
+ response_model=MessageResponse,
284
+ summary="Delete node",
285
+ description="Delete a lineage node and its edges",
286
+ )
287
+ async def delete_node(
288
+ service: LineageServiceDep,
289
+ node_id: Annotated[str, Path(description="Node ID")],
290
+ ) -> MessageResponse:
291
+ """Delete a lineage node.
292
+
293
+ Args:
294
+ service: Injected lineage service.
295
+ node_id: Node unique identifier.
296
+
297
+ Returns:
298
+ Success message.
299
+
300
+ Raises:
301
+ HTTPException: 404 if node not found.
302
+ """
303
+ deleted = await service.delete_node(node_id)
304
+ if not deleted:
305
+ raise HTTPException(status_code=404, detail="Node not found")
306
+ return MessageResponse(message="Node deleted successfully")
307
+
308
+
309
+ # =============================================================================
310
+ # Edge Endpoints
311
+ # =============================================================================
312
+
313
+
314
+ @router.get(
315
+ "/edges",
316
+ response_model=LineageEdgeListResponse,
317
+ summary="List edges",
318
+ description="Get a paginated list of lineage edges",
319
+ )
320
+ async def list_edges(
321
+ service: LineageServiceDep,
322
+ offset: Annotated[int, Query(ge=0, description="Offset for pagination")] = 0,
323
+ limit: Annotated[
324
+ int, Query(ge=1, le=1000, description="Maximum items to return")
325
+ ] = 100,
326
+ ) -> LineageEdgeListResponse:
327
+ """List all lineage edges.
328
+
329
+ Args:
330
+ service: Injected lineage service.
331
+ offset: Number of items to skip.
332
+ limit: Maximum items to return.
333
+
334
+ Returns:
335
+ Paginated list of edges.
336
+ """
337
+ graph = await service.get_graph()
338
+ edges = graph["edges"][offset : offset + limit]
339
+ return LineageEdgeListResponse(
340
+ data=[LineageEdgeResponse(**e) for e in edges],
341
+ total=graph["total_edges"],
342
+ offset=offset,
343
+ limit=limit,
344
+ )
345
+
346
+
347
+ @router.post(
348
+ "/edges",
349
+ response_model=LineageEdgeResponse,
350
+ status_code=201,
351
+ summary="Create edge",
352
+ description="Create a new lineage edge between nodes",
353
+ )
354
+ async def create_edge(
355
+ service: LineageServiceDep,
356
+ edge: LineageEdgeCreate,
357
+ ) -> LineageEdgeResponse:
358
+ """Create a new lineage edge.
359
+
360
+ Args:
361
+ service: Injected lineage service.
362
+ edge: Edge creation data.
363
+
364
+ Returns:
365
+ Created edge.
366
+
367
+ Raises:
368
+ HTTPException: 400 if nodes not found or edge already exists.
369
+ """
370
+ try:
371
+ created = await service.create_edge(
372
+ source_node_id=edge.source_node_id,
373
+ target_node_id=edge.target_node_id,
374
+ edge_type=edge.edge_type,
375
+ metadata=edge.metadata,
376
+ )
377
+ return LineageEdgeResponse(
378
+ id=created.id,
379
+ source_node_id=created.source_node_id,
380
+ target_node_id=created.target_node_id,
381
+ source_node_name=created.source_node.name if created.source_node else None,
382
+ target_node_name=created.target_node.name if created.target_node else None,
383
+ edge_type=created.edge_type,
384
+ metadata=created.metadata_json,
385
+ created_at=created.created_at.isoformat() if created.created_at else "",
386
+ )
387
+ except ValueError as e:
388
+ raise HTTPException(status_code=400, detail=str(e))
389
+
390
+
391
+ @router.delete(
392
+ "/edges/{edge_id}",
393
+ response_model=MessageResponse,
394
+ summary="Delete edge",
395
+ description="Delete a lineage edge",
396
+ )
397
+ async def delete_edge(
398
+ service: LineageServiceDep,
399
+ edge_id: Annotated[str, Path(description="Edge ID")],
400
+ ) -> MessageResponse:
401
+ """Delete a lineage edge.
402
+
403
+ Args:
404
+ service: Injected lineage service.
405
+ edge_id: Edge unique identifier.
406
+
407
+ Returns:
408
+ Success message.
409
+
410
+ Raises:
411
+ HTTPException: 404 if edge not found.
412
+ """
413
+ deleted = await service.delete_edge(edge_id)
414
+ if not deleted:
415
+ raise HTTPException(status_code=404, detail="Edge not found")
416
+ return MessageResponse(message="Edge deleted successfully")
417
+
418
+
419
+ # =============================================================================
420
+ # Impact Analysis Endpoints
421
+ # =============================================================================
422
+
423
+
424
+ @router.get(
425
+ "/nodes/{node_id}/impact",
426
+ response_model=ImpactAnalysisResponse,
427
+ summary="Analyze impact",
428
+ description="Analyze upstream/downstream impact from a node",
429
+ )
430
+ async def analyze_impact(
431
+ service: LineageServiceDep,
432
+ node_id: Annotated[str, Path(description="Node ID")],
433
+ direction: Annotated[
434
+ ImpactDirection, Query(description="Analysis direction")
435
+ ] = "both",
436
+ max_depth: Annotated[
437
+ int, Query(ge=1, le=50, description="Maximum traversal depth")
438
+ ] = 10,
439
+ ) -> ImpactAnalysisResponse:
440
+ """Analyze impact from a node.
441
+
442
+ Args:
443
+ service: Injected lineage service.
444
+ node_id: Starting node ID.
445
+ direction: Direction of analysis (upstream, downstream, both).
446
+ max_depth: Maximum traversal depth.
447
+
448
+ Returns:
449
+ Impact analysis results.
450
+
451
+ Raises:
452
+ HTTPException: 404 if node not found.
453
+ """
454
+ try:
455
+ result = await service.analyze_impact(
456
+ node_id=node_id,
457
+ direction=direction,
458
+ max_depth=max_depth,
459
+ )
460
+ return ImpactAnalysisResponse(**result)
461
+ except ValueError as e:
462
+ raise HTTPException(status_code=404, detail=str(e))
463
+
464
+
465
+ # =============================================================================
466
+ # Anomaly Integration Endpoints
467
+ # =============================================================================
468
+
469
+
470
+ @router.get(
471
+ "/graph/with-anomalies",
472
+ response_model=LineageGraphWithAnomaliesResponse,
473
+ summary="Get lineage graph with anomaly overlay",
474
+ description="""
475
+ Get the lineage graph with anomaly detection status for each node.
476
+
477
+ Nodes linked to data sources will include their latest anomaly detection
478
+ status, allowing visualization of data quality issues across the lineage.
479
+
480
+ Anomaly status levels:
481
+ - **unknown**: No anomaly detection run
482
+ - **clean**: No anomalies detected
483
+ - **low**: 0-5% anomaly rate
484
+ - **medium**: 5-15% anomaly rate
485
+ - **high**: 15%+ anomaly rate
486
+ """,
487
+ tags=["anomaly-integration"],
488
+ )
489
+ async def get_lineage_with_anomalies(
490
+ service: LineageServiceDep,
491
+ source_id: Annotated[
492
+ str | None, Query(description="Optional source ID to filter by")
493
+ ] = None,
494
+ ) -> LineageGraphWithAnomaliesResponse:
495
+ """Get lineage graph with anomaly status overlay.
496
+
497
+ Args:
498
+ service: Injected lineage service.
499
+ source_id: Optional source ID to filter by.
500
+
501
+ Returns:
502
+ Graph with anomaly status for each node.
503
+ """
504
+ result = await service.get_graph_with_anomalies(source_id=source_id)
505
+
506
+ # Convert nodes to response model
507
+ nodes = [
508
+ LineageNodeWithAnomaly(
509
+ **{k: v for k, v in n.items() if k != "anomaly_status"},
510
+ anomaly_status=AnomalyStatus(**n.get("anomaly_status", {})),
511
+ )
512
+ for n in result["nodes"]
513
+ ]
514
+
515
+ return LineageGraphWithAnomaliesResponse(
516
+ nodes=nodes,
517
+ edges=[LineageEdgeResponse(**e) for e in result["edges"]],
518
+ total_nodes=result["total_nodes"],
519
+ total_edges=result["total_edges"],
520
+ )
521
+
522
+
523
+ @router.get(
524
+ "/nodes/{node_id}/anomaly-impact",
525
+ response_model=AnomalyImpactResponse,
526
+ summary="Get anomaly impact analysis",
527
+ description="""
528
+ Analyze the downstream impact of anomalies detected in a source.
529
+
530
+ This endpoint identifies all downstream nodes that could be affected by
531
+ data quality issues in the specified source, including:
532
+ - Impact severity for each downstream node
533
+ - Overall severity assessment
534
+ - Propagation path showing how anomalies flow through the lineage
535
+ """,
536
+ tags=["anomaly-integration"],
537
+ )
538
+ async def get_anomaly_impact(
539
+ service: LineageServiceDep,
540
+ node_id: Annotated[str, Path(description="Node ID to analyze")],
541
+ max_depth: Annotated[
542
+ int, Query(ge=1, le=50, description="Maximum traversal depth")
543
+ ] = 10,
544
+ ) -> AnomalyImpactResponse:
545
+ """Get downstream impact of anomalies from a node.
546
+
547
+ Args:
548
+ service: Injected lineage service.
549
+ node_id: Starting node ID (must be linked to a source).
550
+ max_depth: Maximum traversal depth.
551
+
552
+ Returns:
553
+ Impact analysis results.
554
+
555
+ Raises:
556
+ HTTPException: 404 if node not found or not linked to a source.
557
+ """
558
+ # First get the node to find its source_id
559
+ node = await service.get_node(node_id)
560
+ if node is None:
561
+ raise HTTPException(status_code=404, detail="Node not found")
562
+
563
+ if not node.source_id:
564
+ raise HTTPException(
565
+ status_code=400,
566
+ detail="Node is not linked to a data source. "
567
+ "Anomaly impact analysis requires a linked source.",
568
+ )
569
+
570
+ try:
571
+ result = await service.get_impacted_by_anomaly(
572
+ source_id=node.source_id,
573
+ max_depth=max_depth,
574
+ )
575
+
576
+ # Convert to response model
577
+ return AnomalyImpactResponse(
578
+ source_node_id=result["source_node_id"],
579
+ source_node_name=result["source_node_name"],
580
+ source_id=result["source_id"],
581
+ source_anomaly_status=(
582
+ AnomalyStatus(**result["source_anomaly_status"])
583
+ if result.get("source_anomaly_status")
584
+ else None
585
+ ),
586
+ impacted_nodes=[
587
+ ImpactedNode(
588
+ id=n["id"],
589
+ name=n["name"],
590
+ node_type=n["node_type"],
591
+ source_id=n.get("source_id"),
592
+ anomaly_status=(
593
+ AnomalyStatus(**n["anomaly_status"])
594
+ if n.get("anomaly_status")
595
+ else None
596
+ ),
597
+ impact_severity=n.get("impact_severity", "unknown"),
598
+ )
599
+ for n in result.get("impacted_nodes", [])
600
+ ],
601
+ impacted_count=result.get("impacted_count", 0),
602
+ overall_severity=result.get("overall_severity", "unknown"),
603
+ propagation_path=[
604
+ PropagationEdge(**e)
605
+ for e in result.get("propagation_path", [])
606
+ ],
607
+ )
608
+ except ValueError as e:
609
+ raise HTTPException(status_code=404, detail=str(e))
610
+
611
+
612
+ # =============================================================================
613
+ # Auto-Discovery Endpoints
614
+ # =============================================================================
615
+
616
+
617
+ @router.post(
618
+ "/auto-discover",
619
+ response_model=AutoDiscoverResponse,
620
+ summary="Auto-discover lineage",
621
+ description="Auto-discover lineage from a data source",
622
+ )
623
+ async def auto_discover(
624
+ service: LineageServiceDep,
625
+ request: AutoDiscoverRequest,
626
+ ) -> AutoDiscoverResponse:
627
+ """Auto-discover lineage from a source.
628
+
629
+ Args:
630
+ service: Injected lineage service.
631
+ request: Auto-discovery request.
632
+
633
+ Returns:
634
+ Discovery results.
635
+
636
+ Raises:
637
+ HTTPException: 404 if source not found.
638
+ """
639
+ try:
640
+ result = await service.auto_discover(
641
+ source_id=request.source_id,
642
+ include_fk_relations=request.include_fk_relations,
643
+ max_depth=request.max_depth,
644
+ )
645
+ return AutoDiscoverResponse(
646
+ source_id=result["source_id"],
647
+ discovered_nodes=result["discovered_nodes"],
648
+ discovered_edges=result["discovered_edges"],
649
+ graph=LineageGraphResponse(**result["graph"]),
650
+ )
651
+ except ValueError as e:
652
+ raise HTTPException(status_code=404, detail=str(e))
653
+
654
+
655
+ # =============================================================================
656
+ # Position Update Endpoints
657
+ # =============================================================================
658
+
659
+
660
+ @router.post(
661
+ "/positions",
662
+ response_model=PositionUpdateResponse,
663
+ summary="Update positions",
664
+ description="Batch update node positions for visualization",
665
+ )
666
+ async def update_positions(
667
+ service: LineageServiceDep,
668
+ request: PositionUpdateRequest,
669
+ ) -> PositionUpdateResponse:
670
+ """Batch update node positions.
671
+
672
+ Args:
673
+ service: Injected lineage service.
674
+ request: Position update request.
675
+
676
+ Returns:
677
+ Number of positions updated.
678
+ """
679
+ positions = [
680
+ {"id": p.id, "x": p.x, "y": p.y}
681
+ for p in request.positions
682
+ ]
683
+ updated_count = await service.update_positions(positions)
684
+ return PositionUpdateResponse(updated_count=updated_count)
685
+
686
+
687
+ # =============================================================================
688
+ # OpenLineage Export Endpoints
689
+ # =============================================================================
690
+
691
+
692
+ @router.post(
693
+ "/openlineage/export",
694
+ response_model=OpenLineageExportResponse,
695
+ summary="Export as OpenLineage",
696
+ description="""
697
+ Export lineage graph as OpenLineage events.
698
+
699
+ OpenLineage is an open standard for lineage metadata interoperability.
700
+ This endpoint converts the dashboard's lineage graph into OpenLineage
701
+ events that can be consumed by tools like:
702
+ - Marquez
703
+ - DataHub
704
+ - Atlan
705
+ - OpenMetadata
706
+ - Apache Atlas
707
+
708
+ The export includes:
709
+ - Job information (namespace, name)
710
+ - Input datasets (source nodes)
711
+ - Output datasets (transform/sink nodes)
712
+ - Dataset facets (schema, quality metrics)
713
+ """,
714
+ tags=["openlineage"],
715
+ )
716
+ async def export_openlineage(
717
+ service: OpenLineageEmitterServiceDep,
718
+ request: OpenLineageExportRequest,
719
+ ) -> OpenLineageExportResponse:
720
+ """Export lineage as OpenLineage events.
721
+
722
+ Args:
723
+ service: Injected OpenLineage emitter service.
724
+ request: Export request with options.
725
+
726
+ Returns:
727
+ OpenLineage events and metadata.
728
+ """
729
+ result = await service.export_as_openlineage(
730
+ job_namespace=request.job_namespace,
731
+ job_name=request.job_name,
732
+ source_id=request.source_id,
733
+ include_schema=request.include_schema,
734
+ granular=False,
735
+ )
736
+
737
+ return OpenLineageExportResponse(
738
+ events=[OpenLineageEvent(**e) for e in result["events"]],
739
+ total_events=result["total_events"],
740
+ total_datasets=result["total_datasets"],
741
+ total_jobs=result["total_jobs"],
742
+ export_time=result["export_time"],
743
+ )
744
+
745
+
746
+ @router.post(
747
+ "/openlineage/export/granular",
748
+ response_model=OpenLineageExportResponse,
749
+ summary="Export as OpenLineage (granular)",
750
+ description="""
751
+ Export lineage graph as granular OpenLineage events.
752
+
753
+ Unlike the standard export which creates a single job for the entire
754
+ lineage graph, this endpoint creates a separate job for each transformation
755
+ in the lineage, providing finer-grained lineage tracking.
756
+
757
+ This is useful when you want to track the lineage of each individual
758
+ transformation step rather than the entire pipeline.
759
+ """,
760
+ tags=["openlineage"],
761
+ )
762
+ async def export_openlineage_granular(
763
+ service: OpenLineageEmitterServiceDep,
764
+ request: OpenLineageExportRequest,
765
+ ) -> OpenLineageExportResponse:
766
+ """Export lineage as granular OpenLineage events (one job per transform).
767
+
768
+ Args:
769
+ service: Injected OpenLineage emitter service.
770
+ request: Export request with options.
771
+
772
+ Returns:
773
+ OpenLineage events and metadata.
774
+ """
775
+ result = await service.export_as_openlineage(
776
+ job_namespace=request.job_namespace,
777
+ job_name=request.job_name,
778
+ source_id=request.source_id,
779
+ include_schema=request.include_schema,
780
+ granular=True,
781
+ )
782
+
783
+ return OpenLineageExportResponse(
784
+ events=[OpenLineageEvent(**e) for e in result["events"]],
785
+ total_events=result["total_events"],
786
+ total_datasets=result["total_datasets"],
787
+ total_jobs=result["total_jobs"],
788
+ export_time=result["export_time"],
789
+ )
790
+
791
+
792
+ @router.post(
793
+ "/openlineage/emit",
794
+ response_model=OpenLineageEmitResponse,
795
+ summary="Emit to OpenLineage endpoint",
796
+ description="""
797
+ Emit OpenLineage events to an external endpoint.
798
+
799
+ This endpoint sends the lineage data directly to an OpenLineage-compatible
800
+ API endpoint such as:
801
+ - Marquez: `http://localhost:5000/api/v1/lineage`
802
+ - DataHub: `http://localhost:8080/openlineage`
803
+ - Custom webhook endpoints
804
+
805
+ Authentication can be provided via API key (Bearer token) or custom headers.
806
+ """,
807
+ tags=["openlineage"],
808
+ )
809
+ async def emit_openlineage(
810
+ service: OpenLineageEmitterServiceDep,
811
+ request: OpenLineageEmitRequest,
812
+ ) -> OpenLineageEmitResponse:
813
+ """Emit OpenLineage events to an external endpoint.
814
+
815
+ Args:
816
+ service: Injected OpenLineage emitter service.
817
+ request: Emit request with webhook configuration.
818
+
819
+ Returns:
820
+ Emission result with success status.
821
+ """
822
+ result = await service.emit_to_endpoint(
823
+ url=request.webhook.url,
824
+ api_key=request.webhook.api_key,
825
+ headers=request.webhook.headers,
826
+ job_namespace=request.job_namespace,
827
+ job_name=request.job_name,
828
+ source_id=request.source_id,
829
+ timeout=request.webhook.timeout_seconds,
830
+ )
831
+
832
+ return OpenLineageEmitResponse(
833
+ success=result["success"],
834
+ events_sent=result["events_sent"],
835
+ failed_events=result["failed_events"],
836
+ error_message=result.get("error_message"),
837
+ )
838
+
839
+
840
+ @router.get(
841
+ "/openlineage/spec",
842
+ summary="Get OpenLineage specification info",
843
+ description="Get information about the supported OpenLineage specification version and features.",
844
+ tags=["openlineage"],
845
+ )
846
+ async def get_openlineage_spec() -> dict[str, Any]:
847
+ """Get OpenLineage specification information.
848
+
849
+ Returns:
850
+ Specification details including version and supported features.
851
+ """
852
+ return {
853
+ "spec_version": "1.0.5",
854
+ "producer": "https://github.com/truthound/truthound-dashboard",
855
+ "supported_facets": {
856
+ "dataset": [
857
+ "schema",
858
+ "dataQualityMetrics",
859
+ "dataQualityAssertions",
860
+ "documentation",
861
+ "ownership",
862
+ "columnLineage",
863
+ "lifecycleStateChange",
864
+ ],
865
+ "job": [
866
+ "sourceCode",
867
+ "sql",
868
+ "documentation",
869
+ ],
870
+ "run": [
871
+ "errorMessage",
872
+ "parent",
873
+ "nominalTime",
874
+ "processingEngine",
875
+ ],
876
+ },
877
+ "supported_event_types": ["START", "RUNNING", "COMPLETE", "FAIL", "ABORT"],
878
+ "export_formats": ["json", "ndjson"],
879
+ "documentation_url": "https://openlineage.io/docs/",
880
+ }
881
+
882
+
883
+ # =============================================================================
884
+ # OpenLineage Webhook Endpoints
885
+ # =============================================================================
886
+
887
+
888
+ @router.get(
889
+ "/openlineage/webhooks",
890
+ response_model=WebhookListResponse,
891
+ summary="List webhooks",
892
+ description="Get all configured OpenLineage webhooks.",
893
+ tags=["openlineage"],
894
+ )
895
+ async def list_webhooks(
896
+ service: OpenLineageWebhookServiceDep,
897
+ active_only: Annotated[
898
+ bool, Query(description="Only return active webhooks")
899
+ ] = False,
900
+ ) -> WebhookListResponse:
901
+ """List all configured webhooks.
902
+
903
+ Args:
904
+ service: Injected webhook service.
905
+ active_only: If True, only return active webhooks.
906
+
907
+ Returns:
908
+ List of webhooks.
909
+ """
910
+ webhooks = await service.list_webhooks(active_only=active_only)
911
+ return WebhookListResponse(
912
+ data=[
913
+ WebhookResponse(
914
+ id=w.id,
915
+ name=w.name,
916
+ url=w.url,
917
+ is_active=w.is_active,
918
+ headers=w.headers,
919
+ event_types=w.event_types,
920
+ batch_size=w.batch_size,
921
+ timeout_seconds=w.timeout_seconds,
922
+ last_sent_at=w.last_sent_at.isoformat() if w.last_sent_at else None,
923
+ success_count=w.success_count,
924
+ failure_count=w.failure_count,
925
+ last_error=w.last_error,
926
+ created_at=w.created_at.isoformat() if w.created_at else "",
927
+ updated_at=w.updated_at.isoformat() if w.updated_at else None,
928
+ )
929
+ for w in webhooks
930
+ ],
931
+ total=len(webhooks),
932
+ )
933
+
934
+
935
+ @router.post(
936
+ "/openlineage/webhooks",
937
+ response_model=WebhookResponse,
938
+ status_code=201,
939
+ summary="Create webhook",
940
+ description="Create a new OpenLineage webhook configuration.",
941
+ tags=["openlineage"],
942
+ )
943
+ async def create_webhook(
944
+ service: OpenLineageWebhookServiceDep,
945
+ webhook: WebhookCreate,
946
+ ) -> WebhookResponse:
947
+ """Create a new webhook configuration.
948
+
949
+ Args:
950
+ service: Injected webhook service.
951
+ webhook: Webhook creation data.
952
+
953
+ Returns:
954
+ Created webhook.
955
+ """
956
+ created = await service.create_webhook(
957
+ name=webhook.name,
958
+ url=webhook.url,
959
+ is_active=webhook.is_active,
960
+ headers=webhook.headers,
961
+ api_key=webhook.api_key,
962
+ event_types=webhook.event_types.value,
963
+ batch_size=webhook.batch_size,
964
+ timeout_seconds=webhook.timeout_seconds,
965
+ )
966
+ return WebhookResponse(
967
+ id=created.id,
968
+ name=created.name,
969
+ url=created.url,
970
+ is_active=created.is_active,
971
+ headers=created.headers,
972
+ event_types=created.event_types,
973
+ batch_size=created.batch_size,
974
+ timeout_seconds=created.timeout_seconds,
975
+ last_sent_at=created.last_sent_at.isoformat() if created.last_sent_at else None,
976
+ success_count=created.success_count,
977
+ failure_count=created.failure_count,
978
+ last_error=created.last_error,
979
+ created_at=created.created_at.isoformat() if created.created_at else "",
980
+ updated_at=created.updated_at.isoformat() if created.updated_at else None,
981
+ )
982
+
983
+
984
+ @router.get(
985
+ "/openlineage/webhooks/{webhook_id}",
986
+ response_model=WebhookResponse,
987
+ summary="Get webhook",
988
+ description="Get a specific webhook by ID.",
989
+ tags=["openlineage"],
990
+ )
991
+ async def get_webhook(
992
+ service: OpenLineageWebhookServiceDep,
993
+ webhook_id: Annotated[str, Path(description="Webhook ID")],
994
+ ) -> WebhookResponse:
995
+ """Get a specific webhook.
996
+
997
+ Args:
998
+ service: Injected webhook service.
999
+ webhook_id: Webhook unique identifier.
1000
+
1001
+ Returns:
1002
+ Webhook details.
1003
+
1004
+ Raises:
1005
+ HTTPException: 404 if webhook not found.
1006
+ """
1007
+ webhook = await service.get_webhook(webhook_id)
1008
+ if not webhook:
1009
+ raise HTTPException(status_code=404, detail="Webhook not found")
1010
+ return WebhookResponse(
1011
+ id=webhook.id,
1012
+ name=webhook.name,
1013
+ url=webhook.url,
1014
+ is_active=webhook.is_active,
1015
+ headers=webhook.headers,
1016
+ event_types=webhook.event_types,
1017
+ batch_size=webhook.batch_size,
1018
+ timeout_seconds=webhook.timeout_seconds,
1019
+ last_sent_at=webhook.last_sent_at.isoformat() if webhook.last_sent_at else None,
1020
+ success_count=webhook.success_count,
1021
+ failure_count=webhook.failure_count,
1022
+ last_error=webhook.last_error,
1023
+ created_at=webhook.created_at.isoformat() if webhook.created_at else "",
1024
+ updated_at=webhook.updated_at.isoformat() if webhook.updated_at else None,
1025
+ )
1026
+
1027
+
1028
+ @router.put(
1029
+ "/openlineage/webhooks/{webhook_id}",
1030
+ response_model=WebhookResponse,
1031
+ summary="Update webhook",
1032
+ description="Update an existing webhook configuration.",
1033
+ tags=["openlineage"],
1034
+ )
1035
+ async def update_webhook(
1036
+ service: OpenLineageWebhookServiceDep,
1037
+ webhook_id: Annotated[str, Path(description="Webhook ID")],
1038
+ update: WebhookUpdate,
1039
+ ) -> WebhookResponse:
1040
+ """Update an existing webhook.
1041
+
1042
+ Args:
1043
+ service: Injected webhook service.
1044
+ webhook_id: Webhook unique identifier.
1045
+ update: Update data.
1046
+
1047
+ Returns:
1048
+ Updated webhook.
1049
+
1050
+ Raises:
1051
+ HTTPException: 404 if webhook not found.
1052
+ """
1053
+ updated = await service.update_webhook(
1054
+ webhook_id,
1055
+ name=update.name,
1056
+ url=update.url,
1057
+ is_active=update.is_active,
1058
+ headers=update.headers,
1059
+ api_key=update.api_key,
1060
+ event_types=update.event_types.value if update.event_types else None,
1061
+ batch_size=update.batch_size,
1062
+ timeout_seconds=update.timeout_seconds,
1063
+ )
1064
+ if not updated:
1065
+ raise HTTPException(status_code=404, detail="Webhook not found")
1066
+ return WebhookResponse(
1067
+ id=updated.id,
1068
+ name=updated.name,
1069
+ url=updated.url,
1070
+ is_active=updated.is_active,
1071
+ headers=updated.headers,
1072
+ event_types=updated.event_types,
1073
+ batch_size=updated.batch_size,
1074
+ timeout_seconds=updated.timeout_seconds,
1075
+ last_sent_at=updated.last_sent_at.isoformat() if updated.last_sent_at else None,
1076
+ success_count=updated.success_count,
1077
+ failure_count=updated.failure_count,
1078
+ last_error=updated.last_error,
1079
+ created_at=updated.created_at.isoformat() if updated.created_at else "",
1080
+ updated_at=updated.updated_at.isoformat() if updated.updated_at else None,
1081
+ )
1082
+
1083
+
1084
+ @router.delete(
1085
+ "/openlineage/webhooks/{webhook_id}",
1086
+ response_model=MessageResponse,
1087
+ summary="Delete webhook",
1088
+ description="Delete a webhook configuration.",
1089
+ tags=["openlineage"],
1090
+ )
1091
+ async def delete_webhook(
1092
+ service: OpenLineageWebhookServiceDep,
1093
+ webhook_id: Annotated[str, Path(description="Webhook ID")],
1094
+ ) -> MessageResponse:
1095
+ """Delete a webhook configuration.
1096
+
1097
+ Args:
1098
+ service: Injected webhook service.
1099
+ webhook_id: Webhook unique identifier.
1100
+
1101
+ Returns:
1102
+ Success message.
1103
+
1104
+ Raises:
1105
+ HTTPException: 404 if webhook not found.
1106
+ """
1107
+ deleted = await service.delete_webhook(webhook_id)
1108
+ if not deleted:
1109
+ raise HTTPException(status_code=404, detail="Webhook not found")
1110
+ return MessageResponse(message="Webhook deleted successfully")
1111
+
1112
+
1113
+ @router.post(
1114
+ "/openlineage/webhooks/test",
1115
+ response_model=WebhookTestResult,
1116
+ summary="Test webhook",
1117
+ description="""
1118
+ Test a webhook endpoint connectivity.
1119
+
1120
+ Sends a test OpenLineage event to verify the endpoint is reachable
1121
+ and accepts events. The test event is marked with a special header
1122
+ to identify it as a test.
1123
+ """,
1124
+ tags=["openlineage"],
1125
+ )
1126
+ async def test_webhook(
1127
+ service: OpenLineageWebhookServiceDep,
1128
+ request: WebhookTestRequest,
1129
+ ) -> WebhookTestResult:
1130
+ """Test webhook connectivity.
1131
+
1132
+ Args:
1133
+ service: Injected webhook service.
1134
+ request: Test request with URL and configuration.
1135
+
1136
+ Returns:
1137
+ Test result with success status and details.
1138
+ """
1139
+ result = await service.test_webhook(
1140
+ url=request.url,
1141
+ headers=request.headers,
1142
+ api_key=request.api_key,
1143
+ timeout_seconds=request.timeout_seconds,
1144
+ )
1145
+ return WebhookTestResult(
1146
+ success=result["success"],
1147
+ status_code=result.get("status_code"),
1148
+ response_time_ms=result.get("response_time_ms"),
1149
+ error_message=result.get("error_message"),
1150
+ response_body=result.get("response_body"),
1151
+ )