sql-dialect-master 1.1.6__tar.gz → 1.1.8__tar.gz
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.
- {sql_dialect_master-1.1.6/sql_dialect_master.egg-info → sql_dialect_master-1.1.8}/PKG-INFO +17 -2
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/README.md +15 -1
- sql_dialect_master-1.1.8/backend/api/client_identity.py +57 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/api/main.py +39 -5
- sql_dialect_master-1.1.8/backend/api/metrics.py +171 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/api/middleware.py +51 -7
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/api/rate_limit_store.py +2 -2
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/api/workers.py +34 -2
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/config.py +48 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/metadata.py +2 -2
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/pyproject.toml +18 -1
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8/sql_dialect_master.egg-info}/PKG-INFO +17 -2
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/sql_dialect_master.egg-info/SOURCES.txt +2 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/sql_dialect_master.egg-info/requires.txt +1 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/LICENSE +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/__init__.py +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/api/__init__.py +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/api/auth.py +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/api/readiness.py +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/__init__.py +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/cache.py +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/column_hint_validation.py +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/constants.py +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/exceptions.py +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/function_call_scanner.py +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/function_semantics.py +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/functions_db.json +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/functions_lookup.py +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql.py +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_components/__init__.py +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_components/aggregations.py +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_components/boolean_conditions.py +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_components/core_extract.py +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_components/evidence.py +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_components/mappings.py +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_components/relations.py +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_components/templates.py +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_components/tokenizer.py +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_legacy.py +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_pipeline/__init__.py +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_pipeline/aggregations.py +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_pipeline/clause_extract.py +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_pipeline/dialect.py +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_pipeline/intent.py +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_pipeline/ir.py +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_pipeline/models.py +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_pipeline/orchestrator.py +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_pipeline/predicates.py +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_pipeline/renderer.py +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_pipeline/resolution.py +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_pipeline/suggestions.py +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_pipeline/templates.py +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_pipeline/validation.py +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/p1_sql_scanner.py +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/p2_data_validation.py +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/parser.py +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/post_processor.py +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/rules.py +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/semantic_diff.py +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/sql_statements.py +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/transpiler.py +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/type_mapping.json +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/type_mapping.py +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/utils/__init__.py +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/utils/validators.py +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/setup.cfg +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/sql_dialect_master.egg-info/dependency_links.txt +0 -0
- {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/sql_dialect_master.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sql-dialect-master
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.8
|
|
4
4
|
Summary: SQL Dialect Master - Multi-database SQL conversion engine
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -10,6 +10,7 @@ Requires-Dist: fastapi>=0.115.0
|
|
|
10
10
|
Requires-Dist: uvicorn>=0.30.0
|
|
11
11
|
Requires-Dist: pydantic>=2.9.0
|
|
12
12
|
Requires-Dist: pydantic-settings>=2.0.0
|
|
13
|
+
Requires-Dist: prometheus-client<1.0.0,>=0.20.0
|
|
13
14
|
Provides-Extra: dev
|
|
14
15
|
Requires-Dist: hypothesis>=6.112.0; extra == "dev"
|
|
15
16
|
Requires-Dist: pytest>=8.3.0; extra == "dev"
|
|
@@ -35,7 +36,7 @@ Dynamic: license-file
|
|
|
35
36
|
|
|
36
37
|
Enterprise-grade multi-database SQL conversion engine supporting 12 database dialects.
|
|
37
38
|
|
|
38
|
-
Current release: 1.1.
|
|
39
|
+
Current release: 1.1.8
|
|
39
40
|
|
|
40
41
|
[](https://www.python.org/downloads/)
|
|
41
42
|
[](https://opensource.org/licenses/MIT)
|
|
@@ -359,3 +360,17 @@ Rate limiting is on by default. The two backends are:
|
|
|
359
360
|
- `SDM_RATE_LIMIT_BACKEND=redis` — shared, atomic limits across all workers. Set `SDM_REDIS_URL` when using this backend. The store connects and pings Redis at startup and fails fast if it is unreachable; the `redis` package is required (install with `pip install redis`).
|
|
360
361
|
|
|
361
362
|
`SDM_RATE_LIMIT_ENABLED=false` disables enforcement entirely. Use the in-memory backend for local development and the Redis backend for any deployment with more than one worker or instance that must enforce a single global limit.
|
|
363
|
+
|
|
364
|
+
By default, the API does not trust `X-Forwarded-For`; rate limiting and logs
|
|
365
|
+
use the direct peer address. Set `SDM_TRUSTED_PROXIES` to the proxy IPs/CIDRs
|
|
366
|
+
only when the direct peer is a trusted proxy, after which the rightmost
|
|
367
|
+
non-trusted address in the XFF chain is used. Deployments behind a proxy must
|
|
368
|
+
also configure `SDM_HEALTH_PROBE_TOKEN` for readiness probes; readiness never
|
|
369
|
+
uses XFF for authorization.
|
|
370
|
+
|
|
371
|
+
SQL-bearing POST responses and health probes use `Cache-Control: no-store`;
|
|
372
|
+
public catalog responses use short `private` caching. Full deployment details
|
|
373
|
+
will be documented in `docs/deployment.md`.
|
|
374
|
+
|
|
375
|
+
See [`docs/deployment.md`](docs/deployment.md) for production deployment guidance.
|
|
376
|
+
Metrics configuration and scraping guidance are documented in the same guide.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Enterprise-grade multi-database SQL conversion engine supporting 12 database dialects.
|
|
4
4
|
|
|
5
|
-
Current release: 1.1.
|
|
5
|
+
Current release: 1.1.8
|
|
6
6
|
|
|
7
7
|
[](https://www.python.org/downloads/)
|
|
8
8
|
[](https://opensource.org/licenses/MIT)
|
|
@@ -326,3 +326,17 @@ Rate limiting is on by default. The two backends are:
|
|
|
326
326
|
- `SDM_RATE_LIMIT_BACKEND=redis` — shared, atomic limits across all workers. Set `SDM_REDIS_URL` when using this backend. The store connects and pings Redis at startup and fails fast if it is unreachable; the `redis` package is required (install with `pip install redis`).
|
|
327
327
|
|
|
328
328
|
`SDM_RATE_LIMIT_ENABLED=false` disables enforcement entirely. Use the in-memory backend for local development and the Redis backend for any deployment with more than one worker or instance that must enforce a single global limit.
|
|
329
|
+
|
|
330
|
+
By default, the API does not trust `X-Forwarded-For`; rate limiting and logs
|
|
331
|
+
use the direct peer address. Set `SDM_TRUSTED_PROXIES` to the proxy IPs/CIDRs
|
|
332
|
+
only when the direct peer is a trusted proxy, after which the rightmost
|
|
333
|
+
non-trusted address in the XFF chain is used. Deployments behind a proxy must
|
|
334
|
+
also configure `SDM_HEALTH_PROBE_TOKEN` for readiness probes; readiness never
|
|
335
|
+
uses XFF for authorization.
|
|
336
|
+
|
|
337
|
+
SQL-bearing POST responses and health probes use `Cache-Control: no-store`;
|
|
338
|
+
public catalog responses use short `private` caching. Full deployment details
|
|
339
|
+
will be documented in `docs/deployment.md`.
|
|
340
|
+
|
|
341
|
+
See [`docs/deployment.md`](docs/deployment.md) for production deployment guidance.
|
|
342
|
+
Metrics configuration and scraping guidance are documented in the same guide.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Resolve client IPs without trusting spoofable forwarding headers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ipaddress
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Iterable
|
|
8
|
+
|
|
9
|
+
from fastapi import Request
|
|
10
|
+
|
|
11
|
+
_BRACKETED_IPV6 = re.compile(r"^\[([^\]]+)\](?::\d+)?$")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _parse_ip(value: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
|
|
15
|
+
candidate = value.strip()
|
|
16
|
+
if not candidate or "%" in candidate:
|
|
17
|
+
return None
|
|
18
|
+
bracketed = _BRACKETED_IPV6.fullmatch(candidate)
|
|
19
|
+
if bracketed:
|
|
20
|
+
candidate = bracketed.group(1)
|
|
21
|
+
elif candidate.count(":") == 1:
|
|
22
|
+
host, port = candidate.rsplit(":", 1)
|
|
23
|
+
if port.isdigit():
|
|
24
|
+
candidate = host
|
|
25
|
+
try:
|
|
26
|
+
return ipaddress.ip_address(candidate)
|
|
27
|
+
except ValueError:
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _is_trusted(
|
|
32
|
+
address: ipaddress.IPv4Address | ipaddress.IPv6Address,
|
|
33
|
+
trusted_networks: Iterable,
|
|
34
|
+
) -> bool:
|
|
35
|
+
return any(
|
|
36
|
+
address.version == network.version and address in network
|
|
37
|
+
for network in trusted_networks
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def resolve_client_ip(request: Request, trusted_networks: tuple = ()) -> str:
|
|
42
|
+
"""Resolve the client address, trusting XFF only behind trusted peers."""
|
|
43
|
+
peer = request.client.host if request.client else "unknown"
|
|
44
|
+
if not trusted_networks or peer == "unknown":
|
|
45
|
+
return peer
|
|
46
|
+
|
|
47
|
+
peer_address = _parse_ip(peer)
|
|
48
|
+
if peer_address is None or not _is_trusted(peer_address, trusted_networks):
|
|
49
|
+
return peer
|
|
50
|
+
|
|
51
|
+
forwarded_for = request.headers.get("X-Forwarded-For", "")
|
|
52
|
+
for value in reversed(forwarded_for.split(",")):
|
|
53
|
+
address = _parse_ip(value)
|
|
54
|
+
if address is None or _is_trusted(address, trusted_networks):
|
|
55
|
+
continue
|
|
56
|
+
return str(address)
|
|
57
|
+
return peer
|
|
@@ -30,6 +30,7 @@ from fastapi.responses import JSONResponse
|
|
|
30
30
|
from pydantic import BaseModel, ConfigDict, Field
|
|
31
31
|
|
|
32
32
|
from backend.api.auth import require_api_access
|
|
33
|
+
from backend.api.metrics import MetricsMiddleware, configure_runtime_metrics, metrics_response, record_transpile_request
|
|
33
34
|
from backend.api.rate_limit_store import InMemoryRateLimitStore
|
|
34
35
|
from backend.api.readiness import health_probe_response
|
|
35
36
|
from backend.api.workers import BoundedWorkerPool, WorkerCapacityError
|
|
@@ -235,6 +236,7 @@ app = FastAPI(
|
|
|
235
236
|
|
|
236
237
|
# Import middleware
|
|
237
238
|
from backend.api.middleware import (
|
|
239
|
+
CacheControlMiddleware,
|
|
238
240
|
RateLimiter,
|
|
239
241
|
RateLimitMiddleware,
|
|
240
242
|
SecurityHeadersMiddleware,
|
|
@@ -244,6 +246,7 @@ from backend.core.exceptions import SDMException, UnsupportedDialectError, Error
|
|
|
244
246
|
|
|
245
247
|
# Configure CORS from the centralized Pydantic settings source.
|
|
246
248
|
ALLOWED_ORIGINS = [origin.strip() for origin in settings.allowed_origins.split(",") if origin.strip()]
|
|
249
|
+
TRUSTED_PROXY_NETWORKS = settings.trusted_proxy_networks
|
|
247
250
|
|
|
248
251
|
app.add_middleware(
|
|
249
252
|
CORSMiddleware,
|
|
@@ -285,10 +288,15 @@ app.add_middleware(
|
|
|
285
288
|
RateLimitMiddleware,
|
|
286
289
|
limiter=rate_limiter,
|
|
287
290
|
enabled=settings.rate_limit_enabled,
|
|
291
|
+
trusted_proxies=TRUSTED_PROXY_NETWORKS,
|
|
288
292
|
)
|
|
289
293
|
|
|
290
294
|
# Structured request logging must wrap rate limiting so rejected requests also get IDs.
|
|
291
|
-
app.add_middleware(
|
|
295
|
+
app.add_middleware(
|
|
296
|
+
StructuredLoggingMiddleware,
|
|
297
|
+
trusted_proxies=TRUSTED_PROXY_NETWORKS,
|
|
298
|
+
)
|
|
299
|
+
app.add_middleware(CacheControlMiddleware)
|
|
292
300
|
|
|
293
301
|
# Initialize services
|
|
294
302
|
transpiler = SQLTranspiler()
|
|
@@ -296,6 +304,12 @@ func_encyclopedia = FunctionEncyclopedia()
|
|
|
296
304
|
type_mapper = TypeMapper()
|
|
297
305
|
nl2sql_generator = NL2SQLGenerator()
|
|
298
306
|
|
|
307
|
+
configure_runtime_metrics(
|
|
308
|
+
transpiler_provider=lambda: transpiler,
|
|
309
|
+
worker_provider=_get_worker_pool,
|
|
310
|
+
limiter_provider=lambda: rate_limiter,
|
|
311
|
+
)
|
|
312
|
+
|
|
299
313
|
# Request timing middleware with logging
|
|
300
314
|
@app.middleware("http")
|
|
301
315
|
async def add_process_time_header(request: Request, call_next):
|
|
@@ -313,6 +327,11 @@ async def add_process_time_header(request: Request, call_next):
|
|
|
313
327
|
response.status_code, process_time)
|
|
314
328
|
return response
|
|
315
329
|
|
|
330
|
+
|
|
331
|
+
# This is the outermost application middleware, so it also observes short
|
|
332
|
+
# circuit responses returned by rate limiting and worker-capacity guards.
|
|
333
|
+
app.add_middleware(MetricsMiddleware)
|
|
334
|
+
|
|
316
335
|
# Dialect info for enhanced responses (from centralized config)
|
|
317
336
|
DIALECT_INFO = get_dialect_api_info()
|
|
318
337
|
|
|
@@ -453,6 +472,10 @@ class APIResponse(BaseModel):
|
|
|
453
472
|
|
|
454
473
|
# === API Endpoints ===
|
|
455
474
|
|
|
475
|
+
@app.get("/metrics", include_in_schema=False)
|
|
476
|
+
async def metrics_endpoint(request: Request):
|
|
477
|
+
return await metrics_response(request)
|
|
478
|
+
|
|
456
479
|
@app.get("/", tags=["system"])
|
|
457
480
|
async def root():
|
|
458
481
|
"""
|
|
@@ -553,11 +576,13 @@ async def convert_sql(request: ConvertRequest):
|
|
|
553
576
|
source_dialect = request.source_dialect.strip().lower()
|
|
554
577
|
target_dialect = request.target_dialect.strip().lower()
|
|
555
578
|
if source_dialect not in SUPPORTED_DIALECTS:
|
|
579
|
+
record_transpile_request("unknown", "unknown", "validation_error")
|
|
556
580
|
raise HTTPException(
|
|
557
581
|
status_code=400,
|
|
558
582
|
detail=f"Unsupported source dialect: {request.source_dialect}. Supported: {SUPPORTED_DIALECTS}"
|
|
559
583
|
)
|
|
560
584
|
if target_dialect not in SUPPORTED_DIALECTS:
|
|
585
|
+
record_transpile_request(source_dialect, "unknown", "validation_error")
|
|
561
586
|
raise HTTPException(
|
|
562
587
|
status_code=400,
|
|
563
588
|
detail=f"Unsupported target dialect: {request.target_dialect}. Supported: {SUPPORTED_DIALECTS}"
|
|
@@ -565,12 +590,21 @@ async def convert_sql(request: ConvertRequest):
|
|
|
565
590
|
|
|
566
591
|
# sqlglot parsing/transpilation is CPU-bound. Keep it off the event loop
|
|
567
592
|
# so one large request cannot stall unrelated async requests.
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
593
|
+
try:
|
|
594
|
+
result = await _run_cpu_bound(
|
|
595
|
+
transpiler.transpile,
|
|
596
|
+
request.sql,
|
|
597
|
+
source_dialect,
|
|
598
|
+
target_dialect,
|
|
599
|
+
request.pretty,
|
|
600
|
+
)
|
|
601
|
+
except HTTPException:
|
|
602
|
+
record_transpile_request(source_dialect, target_dialect, "error")
|
|
603
|
+
raise
|
|
604
|
+
record_transpile_request(
|
|
571
605
|
source_dialect,
|
|
572
606
|
target_dialect,
|
|
573
|
-
|
|
607
|
+
"success" if result.success else "error",
|
|
574
608
|
)
|
|
575
609
|
return ConvertResponse(
|
|
576
610
|
success=result.success,
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Protected Prometheus metrics for the API process."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import secrets
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
|
|
8
|
+
from fastapi import Request
|
|
9
|
+
from fastapi.responses import PlainTextResponse, Response
|
|
10
|
+
from prometheus_client import (
|
|
11
|
+
CONTENT_TYPE_LATEST,
|
|
12
|
+
CollectorRegistry,
|
|
13
|
+
Counter,
|
|
14
|
+
Gauge,
|
|
15
|
+
Histogram,
|
|
16
|
+
generate_latest,
|
|
17
|
+
)
|
|
18
|
+
from prometheus_client.core import CounterMetricFamily, GaugeMetricFamily
|
|
19
|
+
|
|
20
|
+
from backend.core.config import settings
|
|
21
|
+
from backend.core.metadata import PROJECT_VERSION
|
|
22
|
+
|
|
23
|
+
REGISTRY = CollectorRegistry(auto_describe=True)
|
|
24
|
+
|
|
25
|
+
BUILD_INFO = Gauge("sdm_build_info", "SQL Dialect Master build information", ["version"], registry=REGISTRY)
|
|
26
|
+
BUILD_INFO.labels(version=PROJECT_VERSION).set(1)
|
|
27
|
+
|
|
28
|
+
HTTP_REQUESTS = Counter(
|
|
29
|
+
"sdm_http_requests_total",
|
|
30
|
+
"HTTP requests handled by the API",
|
|
31
|
+
["method", "route", "status_class"],
|
|
32
|
+
registry=REGISTRY,
|
|
33
|
+
)
|
|
34
|
+
HTTP_DURATION = Histogram(
|
|
35
|
+
"sdm_http_request_duration_seconds",
|
|
36
|
+
"HTTP request duration in seconds",
|
|
37
|
+
["method", "route", "status_class"],
|
|
38
|
+
registry=REGISTRY,
|
|
39
|
+
)
|
|
40
|
+
RATE_LIMIT_REJECTIONS = Counter(
|
|
41
|
+
"sdm_rate_limit_rejections_total",
|
|
42
|
+
"Rate-limited requests",
|
|
43
|
+
registry=REGISTRY,
|
|
44
|
+
)
|
|
45
|
+
TRANSPILE_REQUESTS = Counter(
|
|
46
|
+
"sdm_transpile_requests_total",
|
|
47
|
+
"Conversion requests by dialect pair and result",
|
|
48
|
+
["source", "target", "result"],
|
|
49
|
+
registry=REGISTRY,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
_transpiler_provider: Callable[[], object] | None = None
|
|
53
|
+
_worker_provider: Callable[[], object] | None = None
|
|
54
|
+
_limiter_provider: Callable[[], object] | None = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def configure_runtime_metrics(*, transpiler_provider=None, worker_provider=None, limiter_provider=None) -> None:
|
|
58
|
+
"""Register process-local runtime providers used when /metrics is scraped."""
|
|
59
|
+
global _transpiler_provider, _worker_provider, _limiter_provider
|
|
60
|
+
_transpiler_provider = transpiler_provider
|
|
61
|
+
_worker_provider = worker_provider
|
|
62
|
+
_limiter_provider = limiter_provider
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class RuntimeCollector:
|
|
66
|
+
"""Read low-frequency runtime snapshots without copying them per request."""
|
|
67
|
+
|
|
68
|
+
def collect(self):
|
|
69
|
+
if _transpiler_provider is not None:
|
|
70
|
+
try:
|
|
71
|
+
stats = _transpiler_provider().get_stats()
|
|
72
|
+
cache = stats.get("cache", {})
|
|
73
|
+
if cache.get("enabled", True) is not False:
|
|
74
|
+
yield CounterMetricFamily("sdm_transpile_cache_hits_total", "Transpiler cache hits", value=cache.get("hits", 0))
|
|
75
|
+
yield CounterMetricFamily("sdm_transpile_cache_misses_total", "Transpiler cache misses", value=cache.get("misses", 0))
|
|
76
|
+
yield CounterMetricFamily("sdm_transpile_cache_evictions_total", "Transpiler cache evictions", value=cache.get("evictions", 0))
|
|
77
|
+
yield GaugeMetricFamily("sdm_transpile_cache_size", "Current transpiler cache size", value=cache.get("size", 0))
|
|
78
|
+
except Exception:
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
if _worker_provider is not None:
|
|
82
|
+
try:
|
|
83
|
+
stats = _worker_provider().stats()
|
|
84
|
+
yield GaugeMetricFamily("sdm_api_worker_capacity", "API worker pool capacity", value=stats["capacity"])
|
|
85
|
+
yield GaugeMetricFamily("sdm_api_worker_in_use", "API worker slots in use", value=stats["in_use"])
|
|
86
|
+
yield CounterMetricFamily("sdm_api_worker_rejections_total", "API worker capacity rejections", value=stats["rejections"])
|
|
87
|
+
yield CounterMetricFamily("sdm_api_request_timeouts_total", "API request timeouts", value=stats["timeouts"])
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
if _limiter_provider is not None:
|
|
92
|
+
try:
|
|
93
|
+
stats = _limiter_provider().get_stats()
|
|
94
|
+
if stats.get("backend") == "memory":
|
|
95
|
+
yield GaugeMetricFamily("sdm_rate_limit_active_clients", "Active in-memory rate-limit clients", value=stats.get("active_clients", 0))
|
|
96
|
+
except Exception:
|
|
97
|
+
pass
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
REGISTRY.register(RuntimeCollector())
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def normalize_method(method: str) -> str:
|
|
104
|
+
return method if method in {"GET", "POST", "HEAD", "OPTIONS"} else "other"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def route_template(request: Request) -> str:
|
|
108
|
+
route = request.scope.get("route")
|
|
109
|
+
template = getattr(route, "path_format", None) or getattr(route, "path", None)
|
|
110
|
+
return template if template else "other"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def status_class(status_code: int) -> str:
|
|
114
|
+
return f"{status_code // 100}xx" if 100 <= status_code < 600 else "other"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def record_http_request(request: Request, status_code: int, duration: float) -> None:
|
|
118
|
+
if request.url.path == "/metrics":
|
|
119
|
+
return
|
|
120
|
+
labels = (normalize_method(request.method), route_template(request), status_class(status_code))
|
|
121
|
+
HTTP_REQUESTS.labels(*labels).inc()
|
|
122
|
+
HTTP_DURATION.labels(*labels).observe(duration)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def record_rate_limit_rejection() -> None:
|
|
126
|
+
RATE_LIMIT_REJECTIONS.inc()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def record_transpile_request(source: str, target: str, result: str) -> None:
|
|
130
|
+
TRANSPILE_REQUESTS.labels(source=source, target=target, result=result).inc()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _authorized(request: Request) -> bool:
|
|
134
|
+
configured = settings.metrics_token
|
|
135
|
+
expected = configured.get_secret_value().strip() if configured else ""
|
|
136
|
+
supplied = request.headers.get("X-Metrics-Token", "")
|
|
137
|
+
return bool(expected and supplied and secrets.compare_digest(supplied, expected))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
async def metrics_response(request: Request) -> Response:
|
|
141
|
+
if not settings.metrics_enabled:
|
|
142
|
+
return Response(status_code=404)
|
|
143
|
+
if not _authorized(request):
|
|
144
|
+
return Response(status_code=401, content="Metrics token required")
|
|
145
|
+
return PlainTextResponse(generate_latest(REGISTRY), media_type=CONTENT_TYPE_LATEST)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class MetricsMiddleware:
|
|
149
|
+
"""ASGI middleware that records final HTTP status and elapsed time."""
|
|
150
|
+
|
|
151
|
+
def __init__(self, app):
|
|
152
|
+
self.app = app
|
|
153
|
+
|
|
154
|
+
async def __call__(self, scope, receive, send):
|
|
155
|
+
if scope.get("type") != "http":
|
|
156
|
+
await self.app(scope, receive, send)
|
|
157
|
+
return
|
|
158
|
+
import time
|
|
159
|
+
|
|
160
|
+
started = time.perf_counter()
|
|
161
|
+
status_code = 500
|
|
162
|
+
|
|
163
|
+
async def send_wrapper(message):
|
|
164
|
+
nonlocal status_code
|
|
165
|
+
if message.get("type") == "http.response.start":
|
|
166
|
+
status_code = message.get("status", 500)
|
|
167
|
+
await send(message)
|
|
168
|
+
|
|
169
|
+
await self.app(scope, receive, send_wrapper)
|
|
170
|
+
request = Request(scope)
|
|
171
|
+
record_http_request(request, status_code, time.perf_counter() - started)
|
|
@@ -19,6 +19,8 @@ from starlette.concurrency import run_in_threadpool
|
|
|
19
19
|
|
|
20
20
|
from backend.core.exceptions import ErrorCode
|
|
21
21
|
|
|
22
|
+
from .client_identity import resolve_client_ip
|
|
23
|
+
from .metrics import record_rate_limit_rejection
|
|
22
24
|
from .rate_limit_store import InMemoryRateLimitStore, RateLimitStore, create_rate_limit_store
|
|
23
25
|
from .readiness import health_probe_response
|
|
24
26
|
|
|
@@ -35,7 +37,7 @@ def _sanitize_log_value(value: str, max_length: int = 500) -> str:
|
|
|
35
37
|
class RateLimitEntry:
|
|
36
38
|
"""Legacy compatibility entry."""
|
|
37
39
|
requests: int = 0
|
|
38
|
-
window_start: float = field(default_factory=time.
|
|
40
|
+
window_start: float = field(default_factory=time.monotonic)
|
|
39
41
|
|
|
40
42
|
|
|
41
43
|
class RateLimiter:
|
|
@@ -67,13 +69,14 @@ class RateLimiter:
|
|
|
67
69
|
class RateLimitMiddleware(BaseHTTPMiddleware):
|
|
68
70
|
"""Rate limiting middleware with a bounded request-body guard."""
|
|
69
71
|
|
|
70
|
-
def __init__(self, app, limiter: RateLimiter = None, enabled: bool = True, max_body_bytes: int = DEFAULT_MAX_REQUEST_BODY_BYTES):
|
|
72
|
+
def __init__(self, app, limiter: RateLimiter = None, enabled: bool = True, max_body_bytes: int = DEFAULT_MAX_REQUEST_BODY_BYTES, trusted_proxies: tuple = ()):
|
|
71
73
|
super().__init__(app)
|
|
72
74
|
if max_body_bytes <= 0:
|
|
73
75
|
raise ValueError("max_body_bytes must be positive")
|
|
74
76
|
self.limiter = limiter or RateLimiter(store=None if enabled else InMemoryRateLimitStore())
|
|
75
77
|
self.enabled = enabled
|
|
76
78
|
self.max_body_bytes = max_body_bytes
|
|
79
|
+
self.trusted_proxies = trusted_proxies
|
|
77
80
|
|
|
78
81
|
async def _read_bounded_body(self, request: Request) -> Response | None:
|
|
79
82
|
"""Consume and cache request bodies without trusting Content-Length."""
|
|
@@ -106,7 +109,7 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|
|
106
109
|
|
|
107
110
|
async def dispatch(self, request: Request, call_next) -> Response:
|
|
108
111
|
is_health_probe = (
|
|
109
|
-
request.url.path in {"/health", "/", "/ready", "/health/deep"}
|
|
112
|
+
request.url.path in {"/health", "/", "/ready", "/health/deep", "/metrics"}
|
|
110
113
|
and request.method in {"GET", "HEAD"}
|
|
111
114
|
)
|
|
112
115
|
# Browser CORS preflight requests carry no business payload and are
|
|
@@ -131,6 +134,7 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|
|
131
134
|
is_allowed, remaining, reset_time = await run_in_threadpool(self.limiter.is_allowed, client_id)
|
|
132
135
|
rate_limit_checked = True
|
|
133
136
|
if not is_allowed:
|
|
137
|
+
record_rate_limit_rejection()
|
|
134
138
|
logger.warning("Rate limit exceeded for client: %s", _sanitize_log_value(client_id))
|
|
135
139
|
return JSONResponse(
|
|
136
140
|
status_code=429,
|
|
@@ -162,6 +166,9 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|
|
162
166
|
if is_health_probe:
|
|
163
167
|
if request.url.path in {"/ready", "/health/deep"}:
|
|
164
168
|
return await health_probe_response(request)
|
|
169
|
+
if request.url.path == "/metrics":
|
|
170
|
+
from .metrics import metrics_response
|
|
171
|
+
return await metrics_response(request)
|
|
165
172
|
return await call_next(request)
|
|
166
173
|
response = await call_next(request)
|
|
167
174
|
if rate_limit_checked:
|
|
@@ -171,15 +178,16 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|
|
171
178
|
return response
|
|
172
179
|
|
|
173
180
|
def _get_client_id(self, request: Request) -> str:
|
|
174
|
-
return request
|
|
181
|
+
return resolve_client_ip(request, self.trusted_proxies)
|
|
175
182
|
|
|
176
183
|
|
|
177
184
|
class StructuredLoggingMiddleware(BaseHTTPMiddleware):
|
|
178
185
|
"""Structured request logging and request-id propagation."""
|
|
179
186
|
|
|
180
|
-
def __init__(self, app, log_body: bool = False):
|
|
187
|
+
def __init__(self, app, log_body: bool = False, trusted_proxies: tuple = ()):
|
|
181
188
|
super().__init__(app)
|
|
182
189
|
self.log_body = log_body
|
|
190
|
+
self.trusted_proxies = trusted_proxies
|
|
183
191
|
|
|
184
192
|
@staticmethod
|
|
185
193
|
def _get_request_id(request: Request) -> str:
|
|
@@ -346,7 +354,7 @@ class StructuredLoggingMiddleware(BaseHTTPMiddleware):
|
|
|
346
354
|
request.state.request_id = request_id
|
|
347
355
|
status_code = 500
|
|
348
356
|
error = None
|
|
349
|
-
log_data = {"event": "request_start", "request_id": request_id, "method": _sanitize_log_value(request.method), "path": _sanitize_log_value(request.url.path), "query": _sanitize_log_value(str(request.query_params)), "client": _sanitize_log_value(request
|
|
357
|
+
log_data = {"event": "request_start", "request_id": request_id, "method": _sanitize_log_value(request.method), "path": _sanitize_log_value(request.url.path), "query": _sanitize_log_value(str(request.query_params)), "client": _sanitize_log_value(resolve_client_ip(request, self.trusted_proxies)), "timestamp": datetime.now().isoformat()}
|
|
350
358
|
logger.info(json.dumps(log_data))
|
|
351
359
|
try:
|
|
352
360
|
response = await call_next(request)
|
|
@@ -370,6 +378,42 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|
|
370
378
|
response = await call_next(request)
|
|
371
379
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
372
380
|
response.headers["X-Frame-Options"] = "DENY"
|
|
373
|
-
response.headers["X-XSS-Protection"] = "
|
|
381
|
+
response.headers["X-XSS-Protection"] = "0"
|
|
374
382
|
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
|
375
383
|
return response
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
class CacheControlMiddleware(BaseHTTPMiddleware):
|
|
387
|
+
"""Apply explicit browser/proxy caching policy to API responses."""
|
|
388
|
+
|
|
389
|
+
@staticmethod
|
|
390
|
+
def _policy(request: Request) -> str | None:
|
|
391
|
+
if request.method == "OPTIONS":
|
|
392
|
+
return None
|
|
393
|
+
path = request.url.path
|
|
394
|
+
if path == "/metrics":
|
|
395
|
+
return "no-store"
|
|
396
|
+
if path in {"/docs", "/redoc", "/openapi.json"}:
|
|
397
|
+
return None
|
|
398
|
+
if request.method == "POST" and path.startswith("/api/"):
|
|
399
|
+
return "no-store"
|
|
400
|
+
if request.method in {"GET", "HEAD"}:
|
|
401
|
+
if path in {"/health", "/ready", "/health/deep"}:
|
|
402
|
+
return "no-store"
|
|
403
|
+
if path == "/api/dialects":
|
|
404
|
+
return "private, max-age=60"
|
|
405
|
+
if path == "/api/functions" or path.startswith("/api/functions/"):
|
|
406
|
+
return "private, max-age=60"
|
|
407
|
+
if path == "/api/types" or path.startswith("/api/types/"):
|
|
408
|
+
return "private, max-age=60"
|
|
409
|
+
if path in {"/", "/api/stats"}:
|
|
410
|
+
return "private, max-age=15"
|
|
411
|
+
return None
|
|
412
|
+
|
|
413
|
+
async def dispatch(self, request: Request, call_next) -> Response:
|
|
414
|
+
response = await call_next(request)
|
|
415
|
+
if not response.headers.get("Cache-Control"):
|
|
416
|
+
policy = self._policy(request)
|
|
417
|
+
if policy:
|
|
418
|
+
response.headers["Cache-Control"] = policy
|
|
419
|
+
return response
|
|
@@ -44,7 +44,7 @@ class InMemoryRateLimitStore:
|
|
|
44
44
|
|
|
45
45
|
def check(self, key: str, limit: int, window_seconds: int) -> tuple[bool, int, int]:
|
|
46
46
|
with self._lock:
|
|
47
|
-
now = time.
|
|
47
|
+
now = time.monotonic()
|
|
48
48
|
self._prune_expired(now)
|
|
49
49
|
count, window_start = self._entries.get(key, (0, now))
|
|
50
50
|
if now - window_start >= window_seconds:
|
|
@@ -58,7 +58,7 @@ class InMemoryRateLimitStore:
|
|
|
58
58
|
|
|
59
59
|
def stats(self) -> dict:
|
|
60
60
|
with self._lock:
|
|
61
|
-
self._prune_expired(time.
|
|
61
|
+
self._prune_expired(time.monotonic())
|
|
62
62
|
return {"backend": "memory", "active_clients": len(self._entries)}
|
|
63
63
|
|
|
64
64
|
|
|
@@ -24,22 +24,54 @@ class BoundedWorkerPool:
|
|
|
24
24
|
self._slots = BoundedSemaphore(workers + queue_size)
|
|
25
25
|
self._executor = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="sdm-api")
|
|
26
26
|
self._shutdown_lock = Lock()
|
|
27
|
+
self._stats_lock = Lock()
|
|
28
|
+
self._capacity = workers + queue_size
|
|
29
|
+
self._in_use = 0
|
|
30
|
+
self._rejections = 0
|
|
31
|
+
self._timeouts = 0
|
|
27
32
|
self._shutdown = False
|
|
28
33
|
|
|
29
34
|
async def run(self, function, *args, timeout: float):
|
|
30
35
|
if not self._slots.acquire(blocking=False):
|
|
36
|
+
with self._stats_lock:
|
|
37
|
+
self._rejections += 1
|
|
31
38
|
raise WorkerCapacityError("Request processing capacity is exhausted")
|
|
39
|
+
with self._stats_lock:
|
|
40
|
+
self._in_use += 1
|
|
32
41
|
try:
|
|
33
42
|
future = self._executor.submit(function, *args)
|
|
34
43
|
except BaseException:
|
|
35
44
|
self._slots.release()
|
|
45
|
+
with self._stats_lock:
|
|
46
|
+
self._in_use -= 1
|
|
36
47
|
raise
|
|
37
|
-
|
|
48
|
+
|
|
49
|
+
def release_slot(_future):
|
|
50
|
+
self._slots.release()
|
|
51
|
+
with self._stats_lock:
|
|
52
|
+
self._in_use -= 1
|
|
53
|
+
|
|
54
|
+
future.add_done_callback(release_slot)
|
|
38
55
|
wrapped = asyncio.wrap_future(future)
|
|
39
56
|
# A timed-out caller no longer awaits this result. Retrieve exceptions
|
|
40
57
|
# to avoid an unhandled-future warning when background work later fails.
|
|
41
58
|
wrapped.add_done_callback(lambda completed: None if completed.cancelled() else completed.exception())
|
|
42
|
-
|
|
59
|
+
try:
|
|
60
|
+
return await asyncio.wait_for(asyncio.shield(wrapped), timeout=timeout)
|
|
61
|
+
except asyncio.TimeoutError:
|
|
62
|
+
with self._stats_lock:
|
|
63
|
+
self._timeouts += 1
|
|
64
|
+
raise
|
|
65
|
+
|
|
66
|
+
def stats(self) -> dict[str, int]:
|
|
67
|
+
"""Return a thread-safe snapshot of pool capacity counters."""
|
|
68
|
+
with self._stats_lock:
|
|
69
|
+
return {
|
|
70
|
+
"capacity": self._capacity,
|
|
71
|
+
"in_use": self._in_use,
|
|
72
|
+
"rejections": self._rejections,
|
|
73
|
+
"timeouts": self._timeouts,
|
|
74
|
+
}
|
|
43
75
|
|
|
44
76
|
def shutdown(self) -> None:
|
|
45
77
|
"""Cancel queued work; already-running calls retain their slots."""
|
|
@@ -10,6 +10,7 @@ Supports configuration via:
|
|
|
10
10
|
- Default values
|
|
11
11
|
"""
|
|
12
12
|
import logging
|
|
13
|
+
import ipaddress
|
|
13
14
|
import sys
|
|
14
15
|
from enum import Enum
|
|
15
16
|
from typing import Any, Dict, List, Literal, Optional, TypedDict
|
|
@@ -225,6 +226,7 @@ class AppSettings(BaseSettings):
|
|
|
225
226
|
"http://localhost:5174,http://127.0.0.1:5174,"
|
|
226
227
|
"http://localhost:4174,http://127.0.0.1:4174"
|
|
227
228
|
)
|
|
229
|
+
trusted_proxies: str = ""
|
|
228
230
|
max_batch_size: int = Field(100, gt=0)
|
|
229
231
|
request_timeout: int = Field(30, gt=0)
|
|
230
232
|
|
|
@@ -274,6 +276,10 @@ class AppSettings(BaseSettings):
|
|
|
274
276
|
# Readiness probes. SecretStr keeps the value out of settings reprs and
|
|
275
277
|
# structured logs while still allowing deployment through SDM_ env vars.
|
|
276
278
|
health_probe_token: Optional[SecretStr] = None
|
|
279
|
+
|
|
280
|
+
# Prometheus metrics are opt-in and use a dedicated secret.
|
|
281
|
+
metrics_enabled: bool = False
|
|
282
|
+
metrics_token: Optional[SecretStr] = None
|
|
277
283
|
|
|
278
284
|
# Security settings
|
|
279
285
|
security_check_enabled: bool = True
|
|
@@ -290,6 +296,14 @@ class AppSettings(BaseSettings):
|
|
|
290
296
|
self.parser_max_sql_length = self.transpiler_max_sql_length
|
|
291
297
|
return self
|
|
292
298
|
|
|
299
|
+
@model_validator(mode="after")
|
|
300
|
+
def _validate_metrics_token(self) -> "AppSettings":
|
|
301
|
+
"""Require a non-blank dedicated token when metrics are enabled."""
|
|
302
|
+
token = self.metrics_token.get_secret_value().strip() if self.metrics_token else ""
|
|
303
|
+
if self.metrics_enabled and not token:
|
|
304
|
+
raise ValueError("metrics_token is required when metrics_enabled is true")
|
|
305
|
+
return self
|
|
306
|
+
|
|
293
307
|
@field_validator("log_level", mode="before")
|
|
294
308
|
@classmethod
|
|
295
309
|
def _normalize_log_level(cls, value: str) -> str:
|
|
@@ -299,6 +313,40 @@ class AppSettings(BaseSettings):
|
|
|
299
313
|
"log_level must be one of DEBUG, INFO, WARNING, ERROR, CRITICAL"
|
|
300
314
|
)
|
|
301
315
|
return normalized
|
|
316
|
+
|
|
317
|
+
@field_validator("trusted_proxies", mode="before")
|
|
318
|
+
@classmethod
|
|
319
|
+
def _normalize_trusted_proxies(cls, value: str) -> str:
|
|
320
|
+
if value is None:
|
|
321
|
+
return ""
|
|
322
|
+
if not isinstance(value, str):
|
|
323
|
+
raise ValueError("trusted_proxies must be a comma-separated string")
|
|
324
|
+
normalized = []
|
|
325
|
+
for raw_value in value.split(","):
|
|
326
|
+
candidate = raw_value.strip()
|
|
327
|
+
if not candidate:
|
|
328
|
+
continue
|
|
329
|
+
try:
|
|
330
|
+
network = ipaddress.ip_network(candidate, strict=False)
|
|
331
|
+
except ValueError as exc:
|
|
332
|
+
raise ValueError(
|
|
333
|
+
f"trusted_proxies contains an invalid IP/CIDR: {candidate!r}"
|
|
334
|
+
) from exc
|
|
335
|
+
if network.prefixlen == 0:
|
|
336
|
+
raise ValueError("trusted_proxies must not contain a catch-all network")
|
|
337
|
+
normalized.append(str(network))
|
|
338
|
+
return ",".join(normalized)
|
|
339
|
+
|
|
340
|
+
@property
|
|
341
|
+
def trusted_proxy_networks(self) -> tuple:
|
|
342
|
+
"""Return parsed trusted proxy networks for request identity checks."""
|
|
343
|
+
if not self.trusted_proxies:
|
|
344
|
+
return ()
|
|
345
|
+
return tuple(
|
|
346
|
+
ipaddress.ip_network(value, strict=False)
|
|
347
|
+
for value in self.trusted_proxies.split(",")
|
|
348
|
+
if value
|
|
349
|
+
)
|
|
302
350
|
|
|
303
351
|
def to_dict(self) -> Dict[str, Any]:
|
|
304
352
|
"""Convert settings to dictionary."""
|
|
@@ -17,8 +17,8 @@ Design rules:
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
19
|
# ── Release version facts (sole source of truth) ────────────────────────
|
|
20
|
-
PROJECT_VERSION = "1.1.
|
|
21
|
-
API_VERSION = "1.1.
|
|
20
|
+
PROJECT_VERSION = "1.1.8"
|
|
21
|
+
API_VERSION = "1.1.8"
|
|
22
22
|
# Frontend v2 architecture — independent of the product release version.
|
|
23
23
|
FRONTEND_ARCHITECTURE_VERSION = 2
|
|
24
24
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sql-dialect-master"
|
|
3
|
-
version = "1.1.
|
|
3
|
+
version = "1.1.8"
|
|
4
4
|
description = "SQL Dialect Master - Multi-database SQL conversion engine"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -10,6 +10,7 @@ dependencies = [
|
|
|
10
10
|
"uvicorn>=0.30.0",
|
|
11
11
|
"pydantic>=2.9.0",
|
|
12
12
|
"pydantic-settings>=2.0.0",
|
|
13
|
+
"prometheus-client>=0.20.0,<1.0.0",
|
|
13
14
|
]
|
|
14
15
|
|
|
15
16
|
[project.optional-dependencies]
|
|
@@ -51,3 +52,19 @@ exclude = ["backend.tests*", "backend.benchmarks*"]
|
|
|
51
52
|
testpaths = ["backend/tests"]
|
|
52
53
|
python_files = ["test_*.py"]
|
|
53
54
|
addopts = "-v --tb=short"
|
|
55
|
+
|
|
56
|
+
[tool.coverage.run]
|
|
57
|
+
source = ["backend"]
|
|
58
|
+
omit = [
|
|
59
|
+
"backend/tests/*",
|
|
60
|
+
"backend/benchmarks/*",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
[tool.coverage.report]
|
|
64
|
+
show_missing = true
|
|
65
|
+
skip_covered = false
|
|
66
|
+
fail_under = 93
|
|
67
|
+
exclude_lines = [
|
|
68
|
+
"pragma: no cover",
|
|
69
|
+
"if __name__ == .__main__.:",
|
|
70
|
+
]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sql-dialect-master
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.8
|
|
4
4
|
Summary: SQL Dialect Master - Multi-database SQL conversion engine
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -10,6 +10,7 @@ Requires-Dist: fastapi>=0.115.0
|
|
|
10
10
|
Requires-Dist: uvicorn>=0.30.0
|
|
11
11
|
Requires-Dist: pydantic>=2.9.0
|
|
12
12
|
Requires-Dist: pydantic-settings>=2.0.0
|
|
13
|
+
Requires-Dist: prometheus-client<1.0.0,>=0.20.0
|
|
13
14
|
Provides-Extra: dev
|
|
14
15
|
Requires-Dist: hypothesis>=6.112.0; extra == "dev"
|
|
15
16
|
Requires-Dist: pytest>=8.3.0; extra == "dev"
|
|
@@ -35,7 +36,7 @@ Dynamic: license-file
|
|
|
35
36
|
|
|
36
37
|
Enterprise-grade multi-database SQL conversion engine supporting 12 database dialects.
|
|
37
38
|
|
|
38
|
-
Current release: 1.1.
|
|
39
|
+
Current release: 1.1.8
|
|
39
40
|
|
|
40
41
|
[](https://www.python.org/downloads/)
|
|
41
42
|
[](https://opensource.org/licenses/MIT)
|
|
@@ -359,3 +360,17 @@ Rate limiting is on by default. The two backends are:
|
|
|
359
360
|
- `SDM_RATE_LIMIT_BACKEND=redis` — shared, atomic limits across all workers. Set `SDM_REDIS_URL` when using this backend. The store connects and pings Redis at startup and fails fast if it is unreachable; the `redis` package is required (install with `pip install redis`).
|
|
360
361
|
|
|
361
362
|
`SDM_RATE_LIMIT_ENABLED=false` disables enforcement entirely. Use the in-memory backend for local development and the Redis backend for any deployment with more than one worker or instance that must enforce a single global limit.
|
|
363
|
+
|
|
364
|
+
By default, the API does not trust `X-Forwarded-For`; rate limiting and logs
|
|
365
|
+
use the direct peer address. Set `SDM_TRUSTED_PROXIES` to the proxy IPs/CIDRs
|
|
366
|
+
only when the direct peer is a trusted proxy, after which the rightmost
|
|
367
|
+
non-trusted address in the XFF chain is used. Deployments behind a proxy must
|
|
368
|
+
also configure `SDM_HEALTH_PROBE_TOKEN` for readiness probes; readiness never
|
|
369
|
+
uses XFF for authorization.
|
|
370
|
+
|
|
371
|
+
SQL-bearing POST responses and health probes use `Cache-Control: no-store`;
|
|
372
|
+
public catalog responses use short `private` caching. Full deployment details
|
|
373
|
+
will be documented in `docs/deployment.md`.
|
|
374
|
+
|
|
375
|
+
See [`docs/deployment.md`](docs/deployment.md) for production deployment guidance.
|
|
376
|
+
Metrics configuration and scraping guidance are documented in the same guide.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/column_hint_validation.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_components/__init__.py
RENAMED
|
File without changes
|
{sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_components/aggregations.py
RENAMED
|
File without changes
|
|
File without changes
|
{sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_components/core_extract.py
RENAMED
|
File without changes
|
{sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_components/evidence.py
RENAMED
|
File without changes
|
{sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_components/mappings.py
RENAMED
|
File without changes
|
{sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_components/relations.py
RENAMED
|
File without changes
|
{sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_components/templates.py
RENAMED
|
File without changes
|
{sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_components/tokenizer.py
RENAMED
|
File without changes
|
|
File without changes
|
{sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_pipeline/__init__.py
RENAMED
|
File without changes
|
{sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_pipeline/aggregations.py
RENAMED
|
File without changes
|
{sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_pipeline/clause_extract.py
RENAMED
|
File without changes
|
{sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_pipeline/dialect.py
RENAMED
|
File without changes
|
{sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_pipeline/intent.py
RENAMED
|
File without changes
|
|
File without changes
|
{sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_pipeline/models.py
RENAMED
|
File without changes
|
{sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_pipeline/orchestrator.py
RENAMED
|
File without changes
|
{sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_pipeline/predicates.py
RENAMED
|
File without changes
|
{sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_pipeline/renderer.py
RENAMED
|
File without changes
|
{sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_pipeline/resolution.py
RENAMED
|
File without changes
|
{sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_pipeline/suggestions.py
RENAMED
|
File without changes
|
{sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_pipeline/templates.py
RENAMED
|
File without changes
|
{sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_pipeline/validation.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/sql_dialect_master.egg-info/top_level.txt
RENAMED
|
File without changes
|