schemawatch-api 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- schemawatch_api/__init__.py +3 -0
- schemawatch_api/__main__.py +45 -0
- schemawatch_api/app.py +445 -0
- schemawatch_api/config.py +101 -0
- schemawatch_api/database.py +30 -0
- schemawatch_api/models.py +112 -0
- schemawatch_api/py.typed +1 -0
- schemawatch_api/schemas.py +149 -0
- schemawatch_api/scripts/export_openapi.py +34 -0
- schemawatch_api/scripts/seed_demo.py +256 -0
- schemawatch_api/service.py +523 -0
- schemawatch_api/sse.py +67 -0
- schemawatch_api/store.py +177 -0
- schemawatch_api-0.1.0.dist-info/METADATA +39 -0
- schemawatch_api-0.1.0.dist-info/RECORD +18 -0
- schemawatch_api-0.1.0.dist-info/WHEEL +4 -0
- schemawatch_api-0.1.0.dist-info/entry_points.txt +2 -0
- schemawatch_api-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import uvicorn
|
|
9
|
+
|
|
10
|
+
from schemawatch_api.config import Settings
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main() -> None:
|
|
14
|
+
"""Entry point for schemawatch-api CLI / python -m schemawatch_api."""
|
|
15
|
+
parser = argparse.ArgumentParser(description="Run schemawatch API server")
|
|
16
|
+
parser.add_argument("--host", default="127.0.0.1", help="Bind socket host")
|
|
17
|
+
parser.add_argument("--port", type=int, default=8000, help="Bind socket port")
|
|
18
|
+
parser.add_argument("--dev", action="store_true", help="Run with dev mode and reload")
|
|
19
|
+
args = parser.parse_args()
|
|
20
|
+
|
|
21
|
+
if args.dev:
|
|
22
|
+
os.environ["SCHEMAWATCH_DEV_MODE"] = "true"
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
settings = Settings()
|
|
26
|
+
settings.validate_token_requirement()
|
|
27
|
+
except ValueError as exc:
|
|
28
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
29
|
+
sys.exit(1)
|
|
30
|
+
|
|
31
|
+
logging.basicConfig(level=logging.INFO)
|
|
32
|
+
logger = logging.getLogger("schemawatch.api")
|
|
33
|
+
logger.info("Starting schemawatch API bound to %s:%d", args.host, args.port)
|
|
34
|
+
|
|
35
|
+
uvicorn.run(
|
|
36
|
+
"schemawatch_api.app:create_app",
|
|
37
|
+
factory=True,
|
|
38
|
+
host=args.host,
|
|
39
|
+
port=args.port,
|
|
40
|
+
reload=args.dev,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
if __name__ == "__main__":
|
|
45
|
+
main()
|
schemawatch_api/app.py
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import hmac
|
|
5
|
+
import logging
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import AsyncGenerator
|
|
9
|
+
from contextlib import asynccontextmanager
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response, status
|
|
15
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
16
|
+
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
|
17
|
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
18
|
+
|
|
19
|
+
from schemawatch_api import __version__
|
|
20
|
+
from schemawatch_api.config import Settings
|
|
21
|
+
from schemawatch_api.database import create_db_engine, create_session_factory, init_db
|
|
22
|
+
from schemawatch_api.schemas import (
|
|
23
|
+
AcceptResponse,
|
|
24
|
+
AckResponse,
|
|
25
|
+
BaselineResponse,
|
|
26
|
+
ConfigResponse,
|
|
27
|
+
DriftEventResponse,
|
|
28
|
+
EndpointResponse,
|
|
29
|
+
HealthResponse,
|
|
30
|
+
IngestRequest,
|
|
31
|
+
IngestResponse,
|
|
32
|
+
PaginatedResponse,
|
|
33
|
+
StatsResponse,
|
|
34
|
+
)
|
|
35
|
+
from schemawatch_api.service import SchemawatchService
|
|
36
|
+
from schemawatch_api.sse import SSEManager
|
|
37
|
+
|
|
38
|
+
bearer_scheme = HTTPBearer(auto_error=False)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def create_app(settings: Settings | None = None) -> FastAPI:
|
|
42
|
+
"""Application factory for schemawatch FastAPI backend.
|
|
43
|
+
|
|
44
|
+
Creates and configures routes, authentication, database connection, and SSE manager.
|
|
45
|
+
Zero global state ensures total isolation across test runs and environments.
|
|
46
|
+
"""
|
|
47
|
+
if settings is None:
|
|
48
|
+
settings = Settings()
|
|
49
|
+
settings.validate_token_requirement()
|
|
50
|
+
|
|
51
|
+
logger = logging.getLogger("schemawatch.api")
|
|
52
|
+
if settings.dev_mode:
|
|
53
|
+
logger.warning(
|
|
54
|
+
"SECURITY WARNING: SCHEMAWATCH_DEV_MODE is enabled. API authentication is bypassed!"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Initialize persistence and engine
|
|
58
|
+
engine = create_db_engine(settings.database_url)
|
|
59
|
+
session_factory = create_session_factory(engine)
|
|
60
|
+
init_db(engine)
|
|
61
|
+
|
|
62
|
+
# Initialize SSE manager and core service
|
|
63
|
+
sse_manager = SSEManager()
|
|
64
|
+
service = SchemawatchService(
|
|
65
|
+
settings=settings,
|
|
66
|
+
session_factory=session_factory,
|
|
67
|
+
sse_manager=sse_manager,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
@asynccontextmanager
|
|
71
|
+
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|
72
|
+
# Associate running asyncio event loop with service for thread-safe SSE bridge
|
|
73
|
+
loop = asyncio.get_running_loop()
|
|
74
|
+
service.set_loop(loop)
|
|
75
|
+
yield
|
|
76
|
+
|
|
77
|
+
app = FastAPI(
|
|
78
|
+
title="schemawatch API",
|
|
79
|
+
description="Open-source API contract drift monitor REST API",
|
|
80
|
+
version=__version__,
|
|
81
|
+
lifespan=lifespan,
|
|
82
|
+
debug=False,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Attach service and settings to app state
|
|
86
|
+
app.state.settings = settings
|
|
87
|
+
app.state.service = service
|
|
88
|
+
app.state.engine = engine
|
|
89
|
+
|
|
90
|
+
# Security Headers Middleware (CSP, X-Content-Type-Options, Referrer-Policy, X-Frame-Options)
|
|
91
|
+
@app.middleware("http")
|
|
92
|
+
async def add_security_headers(request: Request, call_next: Any) -> Response:
|
|
93
|
+
response: Response = await call_next(request)
|
|
94
|
+
response.headers["Content-Security-Policy"] = (
|
|
95
|
+
"default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'"
|
|
96
|
+
)
|
|
97
|
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
98
|
+
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
|
99
|
+
response.headers["X-Frame-Options"] = "DENY"
|
|
100
|
+
return response
|
|
101
|
+
|
|
102
|
+
# Global Exception Handler (prevents leaking internal tracebacks in 500 responses)
|
|
103
|
+
@app.exception_handler(Exception)
|
|
104
|
+
async def handle_unhandled_exception(request: Request, exc: Exception) -> JSONResponse:
|
|
105
|
+
logger.exception("Internal unhandled error: %s", exc)
|
|
106
|
+
return JSONResponse(
|
|
107
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
108
|
+
content={"detail": "Internal server error"},
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Configure CORS if allowed origins specified
|
|
112
|
+
if settings.cors_origins:
|
|
113
|
+
app.add_middleware(
|
|
114
|
+
CORSMiddleware,
|
|
115
|
+
allow_origins=settings.cors_origins,
|
|
116
|
+
allow_credentials=True,
|
|
117
|
+
allow_methods=["*"],
|
|
118
|
+
allow_headers=["*"],
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# Ingest rate limiting state
|
|
122
|
+
ingest_rate_limits: dict[str, list[float]] = {}
|
|
123
|
+
rate_limit_lock = threading.Lock()
|
|
124
|
+
max_ingest_per_minute = 600
|
|
125
|
+
|
|
126
|
+
# ---------------------------------------------------------
|
|
127
|
+
# Authentication Dependency
|
|
128
|
+
# ---------------------------------------------------------
|
|
129
|
+
def authenticate(
|
|
130
|
+
request: Request,
|
|
131
|
+
auth: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
|
132
|
+
) -> None:
|
|
133
|
+
"""Verify API token using constant-time comparison.
|
|
134
|
+
|
|
135
|
+
Enforces Authorization: Bearer <token>.
|
|
136
|
+
Tokens are never accepted in query strings or logged.
|
|
137
|
+
"""
|
|
138
|
+
if settings.dev_mode and not settings.api_token:
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
if (
|
|
142
|
+
not auth
|
|
143
|
+
or not auth.credentials
|
|
144
|
+
or not hmac.compare_digest(auth.credentials, settings.api_token)
|
|
145
|
+
):
|
|
146
|
+
raise HTTPException(
|
|
147
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
148
|
+
detail="Unauthorized: invalid or missing API token",
|
|
149
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# ---------------------------------------------------------
|
|
153
|
+
# Routes
|
|
154
|
+
# ---------------------------------------------------------
|
|
155
|
+
@app.get(
|
|
156
|
+
"/healthz",
|
|
157
|
+
response_model=HealthResponse,
|
|
158
|
+
summary="Service Health Check",
|
|
159
|
+
operation_id="getHealth",
|
|
160
|
+
tags=["System"],
|
|
161
|
+
)
|
|
162
|
+
def healthz() -> HealthResponse:
|
|
163
|
+
"""Public health check endpoint (no authentication required)."""
|
|
164
|
+
return HealthResponse(status="ok", version=__version__)
|
|
165
|
+
|
|
166
|
+
@app.get(
|
|
167
|
+
"/api/config",
|
|
168
|
+
response_model=ConfigResponse,
|
|
169
|
+
summary="Get Public System Configuration",
|
|
170
|
+
operation_id="getConfig",
|
|
171
|
+
dependencies=[Depends(authenticate)],
|
|
172
|
+
tags=["Configuration"],
|
|
173
|
+
)
|
|
174
|
+
def get_config() -> ConfigResponse:
|
|
175
|
+
"""Get read-only configuration booleans. Never returns tokens, URLs, or secrets."""
|
|
176
|
+
db_type = "postgres" if "postgres" in settings.database_url.lower() else "sqlite"
|
|
177
|
+
return ConfigResponse(
|
|
178
|
+
slack_configured=bool(settings.slack_webhook_url),
|
|
179
|
+
webhook_configured=bool(settings.alert_webhook_url),
|
|
180
|
+
dev_mode=settings.dev_mode,
|
|
181
|
+
min_alert_severity=settings.min_alert_severity,
|
|
182
|
+
learn_samples=settings.learn_samples,
|
|
183
|
+
sample_rate=settings.sample_rate,
|
|
184
|
+
cooldown_seconds=settings.cooldown_seconds,
|
|
185
|
+
database_type=db_type,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
@app.get(
|
|
189
|
+
"/api/stats",
|
|
190
|
+
response_model=StatsResponse,
|
|
191
|
+
summary="Get System and Alert Statistics",
|
|
192
|
+
operation_id="getStats",
|
|
193
|
+
dependencies=[Depends(authenticate)],
|
|
194
|
+
tags=["Statistics"],
|
|
195
|
+
)
|
|
196
|
+
def get_stats() -> StatsResponse:
|
|
197
|
+
"""Get aggregate system statistics and hourly drift counts for the last 12 hours."""
|
|
198
|
+
data = service.get_stats()
|
|
199
|
+
return StatsResponse(**data)
|
|
200
|
+
|
|
201
|
+
@app.get(
|
|
202
|
+
"/api/endpoints",
|
|
203
|
+
response_model=PaginatedResponse[EndpointResponse],
|
|
204
|
+
summary="List Monitored Endpoints",
|
|
205
|
+
operation_id="listEndpoints",
|
|
206
|
+
dependencies=[Depends(authenticate)],
|
|
207
|
+
tags=["Endpoints"],
|
|
208
|
+
)
|
|
209
|
+
def list_endpoints(
|
|
210
|
+
cursor: int | None = Query(None, description="Cursor for next page"),
|
|
211
|
+
limit: int = Query(50, ge=1, le=100, description="Items per page"),
|
|
212
|
+
) -> PaginatedResponse[EndpointResponse]:
|
|
213
|
+
"""List all monitored third-party API endpoints with health status and alert counts."""
|
|
214
|
+
items, next_cursor, total = service.list_endpoints(cursor=cursor, limit=limit)
|
|
215
|
+
return PaginatedResponse(
|
|
216
|
+
items=[EndpointResponse(**item) for item in items],
|
|
217
|
+
next_cursor=next_cursor,
|
|
218
|
+
total=total,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
@app.get(
|
|
222
|
+
"/api/endpoints/{id}",
|
|
223
|
+
response_model=EndpointResponse,
|
|
224
|
+
summary="Get Endpoint Details",
|
|
225
|
+
operation_id="getEndpoint",
|
|
226
|
+
dependencies=[Depends(authenticate)],
|
|
227
|
+
tags=["Endpoints"],
|
|
228
|
+
)
|
|
229
|
+
def get_endpoint(id: int) -> EndpointResponse:
|
|
230
|
+
"""Get detailed metadata for a single endpoint."""
|
|
231
|
+
ep = service.get_endpoint(id)
|
|
232
|
+
if ep is None:
|
|
233
|
+
raise HTTPException(status_code=404, detail=f"Endpoint {id} not found")
|
|
234
|
+
return EndpointResponse(**ep)
|
|
235
|
+
|
|
236
|
+
@app.get(
|
|
237
|
+
"/api/endpoints/{id}/baseline",
|
|
238
|
+
response_model=BaselineResponse,
|
|
239
|
+
summary="Get Endpoint Baseline Schema",
|
|
240
|
+
operation_id="getEndpointBaseline",
|
|
241
|
+
dependencies=[Depends(authenticate)],
|
|
242
|
+
tags=["Endpoints"],
|
|
243
|
+
)
|
|
244
|
+
def get_endpoint_baseline(
|
|
245
|
+
id: int,
|
|
246
|
+
version: int | None = Query(None, description="Specific baseline version to retrieve"),
|
|
247
|
+
) -> BaselineResponse:
|
|
248
|
+
"""Retrieve the active or specified baseline schema for an endpoint."""
|
|
249
|
+
b = service.get_baseline(id, version=version)
|
|
250
|
+
if b is None:
|
|
251
|
+
raise HTTPException(status_code=404, detail=f"Baseline not found for endpoint {id}")
|
|
252
|
+
return BaselineResponse(**b)
|
|
253
|
+
|
|
254
|
+
@app.post(
|
|
255
|
+
"/api/endpoints/{id}/accept",
|
|
256
|
+
response_model=AcceptResponse,
|
|
257
|
+
summary="Accept Observed Schema as New Baseline",
|
|
258
|
+
operation_id="acceptEndpointBaseline",
|
|
259
|
+
dependencies=[Depends(authenticate)],
|
|
260
|
+
tags=["Endpoints"],
|
|
261
|
+
)
|
|
262
|
+
def accept_endpoint_baseline(id: int) -> AcceptResponse:
|
|
263
|
+
"""Adopt latest observed schema as new baseline and acknowledge all open events."""
|
|
264
|
+
res = service.accept_baseline(id)
|
|
265
|
+
if res is None:
|
|
266
|
+
raise HTTPException(status_code=404, detail=f"Endpoint {id} not found")
|
|
267
|
+
new_version, acks_count = res
|
|
268
|
+
return AcceptResponse(
|
|
269
|
+
status="ok",
|
|
270
|
+
endpoint_id=id,
|
|
271
|
+
new_baseline_version=new_version,
|
|
272
|
+
events_acknowledged=acks_count,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
@app.get(
|
|
276
|
+
"/api/events",
|
|
277
|
+
response_model=PaginatedResponse[DriftEventResponse],
|
|
278
|
+
summary="List Drift Events",
|
|
279
|
+
operation_id="listEvents",
|
|
280
|
+
dependencies=[Depends(authenticate)],
|
|
281
|
+
tags=["Events"],
|
|
282
|
+
)
|
|
283
|
+
def list_events(
|
|
284
|
+
severity: str | None = Query(None, description="Severity: breaking, warning, info"),
|
|
285
|
+
vendor: str | None = Query(None, description="Filter by vendor name"),
|
|
286
|
+
endpoint_id: int | None = Query(None, description="Filter by endpoint ID"),
|
|
287
|
+
acknowledged: bool | None = Query(None, description="Filter by acknowledgment"),
|
|
288
|
+
since: datetime | None = Query(None, description="Events detected after timestamp"),
|
|
289
|
+
cursor: int | None = Query(None, description="Cursor for next page"),
|
|
290
|
+
limit: int = Query(50, ge=1, le=100, description="Items per page"),
|
|
291
|
+
) -> PaginatedResponse[DriftEventResponse]:
|
|
292
|
+
"""Query contract drift events with multi-field filtering and pagination."""
|
|
293
|
+
items, next_cursor, total = service.list_events(
|
|
294
|
+
severity=severity,
|
|
295
|
+
vendor=vendor,
|
|
296
|
+
endpoint_id=endpoint_id,
|
|
297
|
+
acknowledged=acknowledged,
|
|
298
|
+
since=since,
|
|
299
|
+
cursor=cursor,
|
|
300
|
+
limit=limit,
|
|
301
|
+
)
|
|
302
|
+
return PaginatedResponse(
|
|
303
|
+
items=[DriftEventResponse(**item) for item in items],
|
|
304
|
+
next_cursor=next_cursor,
|
|
305
|
+
total=total,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
@app.post(
|
|
309
|
+
"/api/events/{id}/ack",
|
|
310
|
+
response_model=AckResponse,
|
|
311
|
+
summary="Acknowledge Drift Event",
|
|
312
|
+
operation_id="acknowledgeEvent",
|
|
313
|
+
dependencies=[Depends(authenticate)],
|
|
314
|
+
tags=["Events"],
|
|
315
|
+
)
|
|
316
|
+
def acknowledge_event(id: int) -> AckResponse:
|
|
317
|
+
"""Mark a contract drift event as acknowledged."""
|
|
318
|
+
res = service.set_event_acknowledgment(id, acknowledged=True)
|
|
319
|
+
if res is None:
|
|
320
|
+
raise HTTPException(status_code=404, detail=f"Event {id} not found")
|
|
321
|
+
return AckResponse(status="ok", event_id=id, acknowledged=True)
|
|
322
|
+
|
|
323
|
+
@app.post(
|
|
324
|
+
"/api/events/{id}/unack",
|
|
325
|
+
response_model=AckResponse,
|
|
326
|
+
summary="Unacknowledge Drift Event",
|
|
327
|
+
operation_id="unacknowledgeEvent",
|
|
328
|
+
dependencies=[Depends(authenticate)],
|
|
329
|
+
tags=["Events"],
|
|
330
|
+
)
|
|
331
|
+
def unacknowledge_event(id: int) -> AckResponse:
|
|
332
|
+
"""Reopen an acknowledged contract drift event."""
|
|
333
|
+
res = service.set_event_acknowledgment(id, acknowledged=False)
|
|
334
|
+
if res is None:
|
|
335
|
+
raise HTTPException(status_code=404, detail=f"Event {id} not found")
|
|
336
|
+
return AckResponse(status="ok", event_id=id, acknowledged=False)
|
|
337
|
+
|
|
338
|
+
@app.post(
|
|
339
|
+
"/api/ingest",
|
|
340
|
+
response_model=IngestResponse,
|
|
341
|
+
summary="Ingest Sample Payload",
|
|
342
|
+
operation_id="ingestPayload",
|
|
343
|
+
dependencies=[Depends(authenticate)],
|
|
344
|
+
tags=["Ingest"],
|
|
345
|
+
)
|
|
346
|
+
async def ingest_payload(request: Request) -> IngestResponse:
|
|
347
|
+
"""Ingest third-party API response payload for contract drift monitoring.
|
|
348
|
+
|
|
349
|
+
Enforces request payload size limits and verifies payload safety.
|
|
350
|
+
"""
|
|
351
|
+
# Enforce body size limit
|
|
352
|
+
content_length = request.headers.get("content-length")
|
|
353
|
+
if content_length and int(content_length) > settings.max_ingest_body_bytes:
|
|
354
|
+
raise HTTPException(
|
|
355
|
+
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
|
|
356
|
+
detail=f"Payload exceeds limit of {settings.max_ingest_body_bytes} bytes",
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
body_bytes = await request.body()
|
|
360
|
+
if len(body_bytes) > settings.max_ingest_body_bytes:
|
|
361
|
+
raise HTTPException(
|
|
362
|
+
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
|
|
363
|
+
detail=f"Payload exceeds limit of {settings.max_ingest_body_bytes} bytes",
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
# Enforce client rate limiting on /api/ingest
|
|
367
|
+
client_host = request.client.host if request.client else "unknown"
|
|
368
|
+
now_ts = time.monotonic()
|
|
369
|
+
with rate_limit_lock:
|
|
370
|
+
history = ingest_rate_limits.setdefault(client_host, [])
|
|
371
|
+
history[:] = [t for t in history if now_ts - t < 60.0]
|
|
372
|
+
if len(history) >= max_ingest_per_minute:
|
|
373
|
+
raise HTTPException(
|
|
374
|
+
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
375
|
+
detail=f"Rate limit exceeded (max {max_ingest_per_minute}/min)",
|
|
376
|
+
)
|
|
377
|
+
history.append(now_ts)
|
|
378
|
+
|
|
379
|
+
try:
|
|
380
|
+
body_json = await request.json()
|
|
381
|
+
ingest_req = IngestRequest(**body_json)
|
|
382
|
+
except Exception as exc:
|
|
383
|
+
raise HTTPException(
|
|
384
|
+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
385
|
+
detail=f"Invalid ingest JSON body: {exc}",
|
|
386
|
+
) from exc
|
|
387
|
+
|
|
388
|
+
# Process payload through watchdog off the main thread
|
|
389
|
+
service.set_loop(asyncio.get_running_loop())
|
|
390
|
+
observed, changes, event_ids = await asyncio.to_thread(
|
|
391
|
+
service.ingest_payload,
|
|
392
|
+
ingest_req.vendor,
|
|
393
|
+
ingest_req.endpoint,
|
|
394
|
+
ingest_req.payload,
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
return IngestResponse(
|
|
398
|
+
status="ok",
|
|
399
|
+
observed=observed,
|
|
400
|
+
changes_detected=len(changes),
|
|
401
|
+
event_ids=event_ids,
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
@app.get(
|
|
405
|
+
"/api/events/stream",
|
|
406
|
+
summary="Real-time Server-Sent Events Stream",
|
|
407
|
+
operation_id="streamEvents",
|
|
408
|
+
dependencies=[Depends(authenticate)],
|
|
409
|
+
tags=["Events"],
|
|
410
|
+
)
|
|
411
|
+
async def stream_events() -> StreamingResponse:
|
|
412
|
+
"""Subscribe to real-time contract drift alerts via SSE.
|
|
413
|
+
|
|
414
|
+
Emits 'event: drift' on new changes, with keep-alive pings every 15s.
|
|
415
|
+
"""
|
|
416
|
+
service.set_loop(asyncio.get_running_loop())
|
|
417
|
+
return StreamingResponse(
|
|
418
|
+
sse_manager.subscribe(),
|
|
419
|
+
media_type="text/event-stream",
|
|
420
|
+
headers={
|
|
421
|
+
"Cache-Control": "no-cache",
|
|
422
|
+
"Connection": "keep-alive",
|
|
423
|
+
"X-Accel-Buffering": "no",
|
|
424
|
+
},
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
# ---------------------------------------------------------
|
|
428
|
+
# Frontend Static Files Mount & SPA Fallback (Production)
|
|
429
|
+
# ---------------------------------------------------------
|
|
430
|
+
frontend_dist = Path(__file__).resolve().parent.parent.parent / "web" / "dist"
|
|
431
|
+
if frontend_dist.is_dir():
|
|
432
|
+
|
|
433
|
+
@app.get("/{full_path:path}", include_in_schema=False)
|
|
434
|
+
def serve_frontend_spa(full_path: str) -> Response:
|
|
435
|
+
# If path points to an existing file in dist, serve it
|
|
436
|
+
target = frontend_dist / full_path
|
|
437
|
+
if full_path and target.is_file():
|
|
438
|
+
return FileResponse(target)
|
|
439
|
+
# Otherwise return index.html for SPA client-side routing
|
|
440
|
+
index_path = frontend_dist / "index.html"
|
|
441
|
+
if index_path.is_file():
|
|
442
|
+
return FileResponse(index_path)
|
|
443
|
+
return Response("Frontend build index.html not found", status_code=404)
|
|
444
|
+
|
|
445
|
+
return app
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import Field, field_validator
|
|
6
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
7
|
+
|
|
8
|
+
VALID_MIN_SEVERITIES = frozenset({"breaking", "warning", "info"})
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
PLACEHOLDER_TOKEN = "your-random-secure-token-here"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Settings(BaseSettings):
|
|
15
|
+
"""Configuration for schemawatch API server, loaded from environment variables."""
|
|
16
|
+
|
|
17
|
+
model_config = SettingsConfigDict(
|
|
18
|
+
env_prefix="SCHEMAWATCH_",
|
|
19
|
+
env_file=".env",
|
|
20
|
+
extra="ignore",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
database_url: str = Field(
|
|
24
|
+
default="sqlite:///./schemawatch.db",
|
|
25
|
+
description="Database connection URL (SQLite default, PostgreSQL supported)",
|
|
26
|
+
)
|
|
27
|
+
redis_url: str | None = Field(
|
|
28
|
+
default=None,
|
|
29
|
+
description="Optional Redis connection URL for deduplication and caching",
|
|
30
|
+
)
|
|
31
|
+
api_token: str = Field(
|
|
32
|
+
default="",
|
|
33
|
+
description="Bearer token required for API authentication unless dev_mode is true",
|
|
34
|
+
)
|
|
35
|
+
dev_mode: bool = Field(
|
|
36
|
+
default=False,
|
|
37
|
+
description="Enable developer mode (allows unauthenticated access when api_token is unset)",
|
|
38
|
+
)
|
|
39
|
+
learn_samples: int = Field(
|
|
40
|
+
default=5,
|
|
41
|
+
description="Number of initial samples required to establish a baseline",
|
|
42
|
+
)
|
|
43
|
+
sample_rate: float = Field(
|
|
44
|
+
default=1.0,
|
|
45
|
+
description="Sampling rate for post-learning observations (0.0 to 1.0)",
|
|
46
|
+
)
|
|
47
|
+
cooldown_seconds: int = Field(
|
|
48
|
+
default=3600,
|
|
49
|
+
description="Alert deduplication cooldown period in seconds",
|
|
50
|
+
)
|
|
51
|
+
slack_webhook_url: str | None = Field(
|
|
52
|
+
default=None,
|
|
53
|
+
description="Optional Slack webhook URL for drift alert dispatch",
|
|
54
|
+
)
|
|
55
|
+
alert_webhook_url: str | None = Field(
|
|
56
|
+
default=None,
|
|
57
|
+
description="Optional generic webhook URL for drift alert dispatch",
|
|
58
|
+
)
|
|
59
|
+
alert_webhook_secret: str | None = Field(
|
|
60
|
+
default=None,
|
|
61
|
+
description="Optional shared secret for HMAC-SHA256 signing of webhook alert payloads",
|
|
62
|
+
)
|
|
63
|
+
min_alert_severity: str = Field(
|
|
64
|
+
default="info",
|
|
65
|
+
description="Minimum severity required for alerts (breaking, warning, info)",
|
|
66
|
+
)
|
|
67
|
+
cors_origins: list[str] = Field(
|
|
68
|
+
default_factory=list,
|
|
69
|
+
description="Allowed CORS origins list. CORS is disabled if empty",
|
|
70
|
+
)
|
|
71
|
+
max_ingest_body_bytes: int = Field(
|
|
72
|
+
default=1_000_000,
|
|
73
|
+
description="Maximum allowed request payload size in bytes for /api/ingest",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
@field_validator("api_token")
|
|
77
|
+
@classmethod
|
|
78
|
+
def validate_api_token(cls, v: str, info: Any) -> str:
|
|
79
|
+
# If DEV_MODE is not true, API_TOKEN must not be empty or placeholder
|
|
80
|
+
# Handled in validate_token_requirement
|
|
81
|
+
return v
|
|
82
|
+
|
|
83
|
+
@field_validator("min_alert_severity")
|
|
84
|
+
@classmethod
|
|
85
|
+
def validate_min_severity(cls, v: str) -> str:
|
|
86
|
+
v_lower = v.lower()
|
|
87
|
+
if v_lower not in VALID_MIN_SEVERITIES:
|
|
88
|
+
raise ValueError(f"min_alert_severity must be one of {sorted(VALID_MIN_SEVERITIES)}")
|
|
89
|
+
return v_lower
|
|
90
|
+
|
|
91
|
+
def validate_token_requirement(self) -> None:
|
|
92
|
+
"""Check that api_token is set and secure when dev_mode is false."""
|
|
93
|
+
if not self.dev_mode:
|
|
94
|
+
token = self.api_token.strip() if self.api_token else ""
|
|
95
|
+
if not token or token == PLACEHOLDER_TOKEN:
|
|
96
|
+
raise ValueError(
|
|
97
|
+
"SCHEMAWATCH_API_TOKEN is required and cannot be empty or the placeholder "
|
|
98
|
+
f"'{PLACEHOLDER_TOKEN}' when SCHEMAWATCH_DEV_MODE is not 'true'. "
|
|
99
|
+
"Generate a secure token with:\n"
|
|
100
|
+
' python -c "import secrets; print(secrets.token_urlsafe(32))"'
|
|
101
|
+
)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import Engine, create_engine
|
|
4
|
+
from sqlalchemy.orm import Session, sessionmaker
|
|
5
|
+
from sqlalchemy.pool import StaticPool
|
|
6
|
+
|
|
7
|
+
from schemawatch_api.models import Base
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def create_db_engine(database_url: str) -> Engine:
|
|
11
|
+
"""Create a synchronous SQLAlchemy engine for SQLite or PostgreSQL."""
|
|
12
|
+
connect_args: dict[str, object] = {}
|
|
13
|
+
kwargs: dict[str, object] = {}
|
|
14
|
+
|
|
15
|
+
if database_url.startswith("sqlite"):
|
|
16
|
+
connect_args["check_same_thread"] = False
|
|
17
|
+
if ":memory:" in database_url:
|
|
18
|
+
kwargs["poolclass"] = StaticPool
|
|
19
|
+
|
|
20
|
+
return create_engine(database_url, connect_args=connect_args, **kwargs)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def create_session_factory(engine: Engine) -> sessionmaker[Session]:
|
|
24
|
+
"""Create a scoped or standard sessionmaker bound to engine."""
|
|
25
|
+
return sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def init_db(engine: Engine) -> None:
|
|
29
|
+
"""Initialize database tables."""
|
|
30
|
+
Base.metadata.create_all(bind=engine)
|