sql-dialect-master 1.1.1__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.
- backend/__init__.py +1 -0
- backend/api/__init__.py +1 -0
- backend/api/main.py +732 -0
- backend/api/middleware.py +274 -0
- backend/api/rate_limit_store.py +105 -0
- backend/api/readiness.py +178 -0
- backend/core/__init__.py +57 -0
- backend/core/cache.py +261 -0
- backend/core/column_hint_validation.py +22 -0
- backend/core/config.py +546 -0
- backend/core/constants.py +84 -0
- backend/core/exceptions.py +124 -0
- backend/core/function_call_scanner.py +64 -0
- backend/core/function_semantics.py +148 -0
- backend/core/functions_db.json +601 -0
- backend/core/functions_lookup.py +332 -0
- backend/core/metadata.py +71 -0
- backend/core/nl2sql.py +85 -0
- backend/core/nl2sql_components/__init__.py +72 -0
- backend/core/nl2sql_components/aggregations.py +35 -0
- backend/core/nl2sql_components/boolean_conditions.py +279 -0
- backend/core/nl2sql_components/core_extract.py +106 -0
- backend/core/nl2sql_components/evidence.py +122 -0
- backend/core/nl2sql_components/mappings.py +185 -0
- backend/core/nl2sql_components/relations.py +180 -0
- backend/core/nl2sql_components/templates.py +130 -0
- backend/core/nl2sql_components/tokenizer.py +187 -0
- backend/core/nl2sql_legacy.py +22 -0
- backend/core/nl2sql_pipeline/__init__.py +13 -0
- backend/core/nl2sql_pipeline/aggregations.py +26 -0
- backend/core/nl2sql_pipeline/clause_extract.py +97 -0
- backend/core/nl2sql_pipeline/dialect.py +139 -0
- backend/core/nl2sql_pipeline/intent.py +85 -0
- backend/core/nl2sql_pipeline/ir.py +70 -0
- backend/core/nl2sql_pipeline/models.py +30 -0
- backend/core/nl2sql_pipeline/orchestrator.py +576 -0
- backend/core/nl2sql_pipeline/predicates.py +182 -0
- backend/core/nl2sql_pipeline/renderer.py +248 -0
- backend/core/nl2sql_pipeline/resolution.py +69 -0
- backend/core/nl2sql_pipeline/suggestions.py +30 -0
- backend/core/nl2sql_pipeline/templates.py +371 -0
- backend/core/nl2sql_pipeline/validation.py +28 -0
- backend/core/p1_sql_scanner.py +196 -0
- backend/core/p2_data_validation.py +72 -0
- backend/core/parser.py +280 -0
- backend/core/post_processor.py +129 -0
- backend/core/rules.py +808 -0
- backend/core/semantic_diff.py +385 -0
- backend/core/transpiler.py +791 -0
- backend/core/type_mapping.json +593 -0
- backend/core/type_mapping.py +765 -0
- backend/utils/__init__.py +2 -0
- backend/utils/validators.py +407 -0
- frontend/__init__.py +10 -0
- frontend/app_context_v2.py +201 -0
- frontend/core/__init__.py +33 -0
- frontend/core/design_tokens.py +127 -0
- frontend/core/escaping.py +13 -0
- frontend/core/navigation.py +135 -0
- frontend/core/state.py +612 -0
- frontend/core/styles.py +523 -0
- frontend/core/themes.py +69 -0
- frontend/core/viewmodels.py +499 -0
- frontend/pages/__init__.py +0 -0
- frontend/pages/convert.py +283 -0
- frontend/pages/diff.py +157 -0
- frontend/pages/functions.py +100 -0
- frontend/pages/history.py +144 -0
- frontend/pages/lineage.py +168 -0
- frontend/pages/nl2sql.py +305 -0
- frontend/pages/query_analysis.py +120 -0
- frontend/pages/runtime.py +66 -0
- frontend/pages/settings.py +60 -0
- frontend/pages/templates.py +67 -0
- frontend/pages/types.py +110 -0
- frontend/templates_v2.py +53 -0
- frontend/ui/__init__.py +0 -0
- frontend/ui/command_palette.py +153 -0
- frontend/ui/diff.py +275 -0
- frontend/ui/editor.py +102 -0
- frontend/ui/status.py +255 -0
- sql_dialect_master-1.1.1.dist-info/METADATA +360 -0
- sql_dialect_master-1.1.1.dist-info/RECORD +86 -0
- sql_dialect_master-1.1.1.dist-info/WHEEL +5 -0
- sql_dialect_master-1.1.1.dist-info/licenses/LICENSE +21 -0
- sql_dialect_master-1.1.1.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Middleware for SQL Dialect Master API.
|
|
3
|
+
|
|
4
|
+
Provides rate limiting, request-size protection, logging, and security headers.
|
|
5
|
+
"""
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from typing import Dict
|
|
13
|
+
|
|
14
|
+
from fastapi import Request, Response
|
|
15
|
+
from fastapi.responses import JSONResponse
|
|
16
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
17
|
+
|
|
18
|
+
from backend.core.exceptions import ErrorCode
|
|
19
|
+
|
|
20
|
+
from .rate_limit_store import RateLimitStore, create_rate_limit_store
|
|
21
|
+
from .readiness import health_probe_response
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
DEFAULT_MAX_REQUEST_BODY_BYTES = 512 * 1024
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _sanitize_log_value(value: str, max_length: int = 500) -> str:
|
|
28
|
+
"""Prevent control characters from forging or corrupting log records."""
|
|
29
|
+
return value.replace("\r", "\\r").replace("\n", "\\n")[:max_length]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class RateLimitEntry:
|
|
34
|
+
"""Legacy compatibility entry."""
|
|
35
|
+
requests: int = 0
|
|
36
|
+
window_start: float = field(default_factory=time.time)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class RateLimiter:
|
|
40
|
+
"""Rate limiter backed by a local or shared store."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, requests_per_window: int = 100, window_seconds: int = 60, store: RateLimitStore = None):
|
|
43
|
+
if requests_per_window <= 0:
|
|
44
|
+
raise ValueError("requests_per_window must be positive")
|
|
45
|
+
if window_seconds <= 0:
|
|
46
|
+
raise ValueError("window_seconds must be positive")
|
|
47
|
+
self._requests_per_window = requests_per_window
|
|
48
|
+
self._window_seconds = window_seconds
|
|
49
|
+
self._store = store or create_rate_limit_store()
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def _limits(self):
|
|
53
|
+
"""Read-only compatibility view for the in-memory backend."""
|
|
54
|
+
return getattr(self._store, "_entries", {})
|
|
55
|
+
|
|
56
|
+
def is_allowed(self, client_id: str) -> tuple:
|
|
57
|
+
return self._store.check(client_id, self._requests_per_window, self._window_seconds)
|
|
58
|
+
|
|
59
|
+
def get_stats(self) -> Dict:
|
|
60
|
+
stats = self._store.stats()
|
|
61
|
+
stats.update({"requests_per_window": self._requests_per_window, "window_seconds": self._window_seconds})
|
|
62
|
+
return stats
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class RateLimitMiddleware(BaseHTTPMiddleware):
|
|
66
|
+
"""Rate limiting middleware with a bounded request-body guard."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, app, limiter: RateLimiter = None, enabled: bool = True, max_body_bytes: int = DEFAULT_MAX_REQUEST_BODY_BYTES):
|
|
69
|
+
super().__init__(app)
|
|
70
|
+
if max_body_bytes <= 0:
|
|
71
|
+
raise ValueError("max_body_bytes must be positive")
|
|
72
|
+
self.limiter = limiter or RateLimiter()
|
|
73
|
+
self.enabled = enabled
|
|
74
|
+
self.max_body_bytes = max_body_bytes
|
|
75
|
+
|
|
76
|
+
async def _read_bounded_body(self, request: Request) -> Response | None:
|
|
77
|
+
"""Consume and cache request bodies without trusting Content-Length."""
|
|
78
|
+
if request.method in {"GET", "HEAD"}:
|
|
79
|
+
return None
|
|
80
|
+
content_length = request.headers.get("content-length")
|
|
81
|
+
if content_length:
|
|
82
|
+
try:
|
|
83
|
+
declared_length = int(content_length)
|
|
84
|
+
except ValueError:
|
|
85
|
+
return JSONResponse(status_code=400, content={"success": False, "error": {"code": 400, "message": "Invalid Content-Length"}, "timestamp": datetime.now().isoformat()})
|
|
86
|
+
if declared_length > self.max_body_bytes:
|
|
87
|
+
return JSONResponse(status_code=413, content={"success": False, "error": {"code": 413, "message": "Request body exceeds the maximum allowed size", "max_bytes": self.max_body_bytes}, "timestamp": datetime.now().isoformat()})
|
|
88
|
+
|
|
89
|
+
total = 0
|
|
90
|
+
chunks = []
|
|
91
|
+
async for chunk in request.stream():
|
|
92
|
+
total += len(chunk)
|
|
93
|
+
if total > self.max_body_bytes:
|
|
94
|
+
return JSONResponse(status_code=413, content={"success": False, "error": {"code": 413, "message": "Request body exceeds the maximum allowed size", "max_bytes": self.max_body_bytes}, "timestamp": datetime.now().isoformat()})
|
|
95
|
+
chunks.append(chunk)
|
|
96
|
+
request._body = b"".join(chunks)
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
async def dispatch(self, request: Request, call_next) -> Response:
|
|
100
|
+
body_error = await self._read_bounded_body(request)
|
|
101
|
+
if body_error is not None:
|
|
102
|
+
return body_error
|
|
103
|
+
|
|
104
|
+
if request.url.path == "/api/nl2sql" and request.method == "POST":
|
|
105
|
+
try:
|
|
106
|
+
body = await request.body()
|
|
107
|
+
payload = json.loads(body.decode("utf-8")) if body else {}
|
|
108
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
109
|
+
payload = None
|
|
110
|
+
if isinstance(payload, dict):
|
|
111
|
+
from backend.core.column_hint_validation import validate_column_hints
|
|
112
|
+
error = validate_column_hints(payload.get("column_hints"))
|
|
113
|
+
if error:
|
|
114
|
+
return JSONResponse(
|
|
115
|
+
status_code=422,
|
|
116
|
+
content={"success": False, "error": {"code": "VALIDATION_FAILED", "message": error}},
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
if not self.enabled:
|
|
120
|
+
return await call_next(request)
|
|
121
|
+
if request.url.path in {"/health", "/", "/ready", "/health/deep"} and request.method in {"GET", "HEAD"}:
|
|
122
|
+
if request.url.path in {"/ready", "/health/deep"}:
|
|
123
|
+
return await health_probe_response(request)
|
|
124
|
+
return await call_next(request)
|
|
125
|
+
|
|
126
|
+
client_id = self._get_client_id(request)
|
|
127
|
+
is_allowed, remaining, reset_time = self.limiter.is_allowed(client_id)
|
|
128
|
+
if not is_allowed:
|
|
129
|
+
logger.warning("Rate limit exceeded for client: %s", _sanitize_log_value(client_id))
|
|
130
|
+
return JSONResponse(
|
|
131
|
+
status_code=429,
|
|
132
|
+
content={"success": False, "error": {"code": 429, "message": "Rate limit exceeded", "retry_after": reset_time}, "timestamp": datetime.now().isoformat()},
|
|
133
|
+
headers={"X-RateLimit-Limit": str(self.limiter._requests_per_window), "X-RateLimit-Remaining": "0", "X-RateLimit-Reset": str(reset_time), "Retry-After": str(reset_time)},
|
|
134
|
+
)
|
|
135
|
+
response = await call_next(request)
|
|
136
|
+
response.headers["X-RateLimit-Limit"] = str(self.limiter._requests_per_window)
|
|
137
|
+
response.headers["X-RateLimit-Remaining"] = str(remaining)
|
|
138
|
+
response.headers["X-RateLimit-Reset"] = str(reset_time)
|
|
139
|
+
return response
|
|
140
|
+
|
|
141
|
+
def _get_client_id(self, request: Request) -> str:
|
|
142
|
+
return request.client.host if request.client else "unknown"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class StructuredLoggingMiddleware(BaseHTTPMiddleware):
|
|
146
|
+
"""Structured request logging and request-id propagation."""
|
|
147
|
+
|
|
148
|
+
def __init__(self, app, log_body: bool = False):
|
|
149
|
+
super().__init__(app)
|
|
150
|
+
self.log_body = log_body
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
def _get_request_id(request: Request) -> str:
|
|
154
|
+
"""Reuse a valid RFC 4122 request ID; otherwise issue a fresh UUID."""
|
|
155
|
+
candidate = request.headers.get("X-Request-ID")
|
|
156
|
+
if candidate:
|
|
157
|
+
try:
|
|
158
|
+
parsed = uuid.UUID(candidate)
|
|
159
|
+
if parsed.variant == uuid.RFC_4122:
|
|
160
|
+
return candidate
|
|
161
|
+
except (ValueError, AttributeError, TypeError):
|
|
162
|
+
pass
|
|
163
|
+
return str(uuid.uuid4())
|
|
164
|
+
|
|
165
|
+
@staticmethod
|
|
166
|
+
async def _normalize_error_response(response: Response, request_id: str) -> Response:
|
|
167
|
+
"""Normalize standard HTTP 4xx JSON responses while preserving domain-specific codes."""
|
|
168
|
+
code_by_status = {
|
|
169
|
+
400: ErrorCode.BAD_REQUEST.value,
|
|
170
|
+
404: ErrorCode.NOT_FOUND.value,
|
|
171
|
+
405: ErrorCode.METHOD_NOT_ALLOWED.value,
|
|
172
|
+
413: ErrorCode.PAYLOAD_TOO_LARGE.value,
|
|
173
|
+
422: ErrorCode.VALIDATION_FAILED.value,
|
|
174
|
+
429: ErrorCode.RATE_LIMITED.value,
|
|
175
|
+
}
|
|
176
|
+
if response.status_code not in code_by_status:
|
|
177
|
+
return response
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
if hasattr(response, "body"):
|
|
181
|
+
raw_body = response.body
|
|
182
|
+
else:
|
|
183
|
+
chunks = []
|
|
184
|
+
async for chunk in response.body_iterator:
|
|
185
|
+
chunks.append(chunk)
|
|
186
|
+
raw_body = b"".join(chunks)
|
|
187
|
+
payload = json.loads(raw_body.decode("utf-8"))
|
|
188
|
+
except (AttributeError, UnicodeDecodeError, json.JSONDecodeError):
|
|
189
|
+
return response
|
|
190
|
+
|
|
191
|
+
error = payload.get("error") if isinstance(payload, dict) else None
|
|
192
|
+
expected_code = code_by_status[response.status_code]
|
|
193
|
+
if not isinstance(error, dict):
|
|
194
|
+
detail = payload.get("detail") if isinstance(payload, dict) else None
|
|
195
|
+
if response.status_code == 422:
|
|
196
|
+
error = {
|
|
197
|
+
"code": expected_code,
|
|
198
|
+
"message": "Request validation failed",
|
|
199
|
+
"details": detail if isinstance(detail, list) else ([detail] if detail else []),
|
|
200
|
+
}
|
|
201
|
+
elif response.status_code == 413:
|
|
202
|
+
error = {
|
|
203
|
+
"code": expected_code,
|
|
204
|
+
"message": str(detail or "Request body exceeds the maximum allowed size"),
|
|
205
|
+
"details": {},
|
|
206
|
+
}
|
|
207
|
+
elif response.status_code == 429:
|
|
208
|
+
error = {
|
|
209
|
+
"code": expected_code,
|
|
210
|
+
"message": str(detail or "Rate limit exceeded"),
|
|
211
|
+
"details": {},
|
|
212
|
+
}
|
|
213
|
+
elif response.status_code == 405:
|
|
214
|
+
error = {
|
|
215
|
+
"code": expected_code,
|
|
216
|
+
"message": str(detail or "Method not allowed"),
|
|
217
|
+
"details": {},
|
|
218
|
+
}
|
|
219
|
+
else:
|
|
220
|
+
error = {
|
|
221
|
+
"code": expected_code,
|
|
222
|
+
"message": str(detail or ("Resource not found" if response.status_code == 404 else "Bad request")),
|
|
223
|
+
"details": {},
|
|
224
|
+
}
|
|
225
|
+
else:
|
|
226
|
+
code = error.get("code")
|
|
227
|
+
if not isinstance(code, str):
|
|
228
|
+
error["code"] = expected_code
|
|
229
|
+
error.setdefault("details", {})
|
|
230
|
+
|
|
231
|
+
normalized = {
|
|
232
|
+
"success": False,
|
|
233
|
+
"error": error,
|
|
234
|
+
"timestamp": payload.get("timestamp", datetime.now().isoformat()) if isinstance(payload, dict) else datetime.now().isoformat(),
|
|
235
|
+
"request_id": request_id,
|
|
236
|
+
}
|
|
237
|
+
headers = dict(response.headers)
|
|
238
|
+
headers.pop("content-length", None)
|
|
239
|
+
headers.pop("content-type", None)
|
|
240
|
+
return JSONResponse(status_code=response.status_code, content=normalized, headers=headers)
|
|
241
|
+
|
|
242
|
+
async def dispatch(self, request: Request, call_next) -> Response:
|
|
243
|
+
start_time = time.time()
|
|
244
|
+
request_id = self._get_request_id(request)
|
|
245
|
+
request.state.request_id = request_id
|
|
246
|
+
status_code = 500
|
|
247
|
+
error = None
|
|
248
|
+
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.client.host if request.client else "unknown"), "timestamp": datetime.now().isoformat()}
|
|
249
|
+
logger.info(json.dumps(log_data))
|
|
250
|
+
try:
|
|
251
|
+
response = await call_next(request)
|
|
252
|
+
response = await self._normalize_error_response(response, request_id)
|
|
253
|
+
status_code = response.status_code
|
|
254
|
+
except Exception as exc:
|
|
255
|
+
error = _sanitize_log_value(str(exc))
|
|
256
|
+
raise
|
|
257
|
+
finally:
|
|
258
|
+
process_time = time.time() - start_time
|
|
259
|
+
log_data = {"event": "request_end", "request_id": request_id, "method": _sanitize_log_value(request.method), "path": _sanitize_log_value(request.url.path), "status_code": status_code, "process_time_ms": round(process_time * 1000, 2), "error": error, "timestamp": datetime.now().isoformat()}
|
|
260
|
+
(logger.warning if status_code >= 400 else logger.info)(json.dumps(log_data))
|
|
261
|
+
response.headers["X-Request-ID"] = request_id
|
|
262
|
+
return response
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|
266
|
+
"""Add security headers to responses."""
|
|
267
|
+
|
|
268
|
+
async def dispatch(self, request: Request, call_next) -> Response:
|
|
269
|
+
response = await call_next(request)
|
|
270
|
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
271
|
+
response.headers["X-Frame-Options"] = "DENY"
|
|
272
|
+
response.headers["X-XSS-Protection"] = "1; mode=block"
|
|
273
|
+
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
|
274
|
+
return response
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Shared rate-limit backends.
|
|
2
|
+
|
|
3
|
+
Redis is opt-in. The in-memory backend remains the default for local/single-process use.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import time
|
|
10
|
+
from threading import Lock
|
|
11
|
+
from typing import Protocol
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RateLimitStore(Protocol):
|
|
15
|
+
"""Minimal storage contract used by RateLimiter."""
|
|
16
|
+
|
|
17
|
+
def check(self, key: str, limit: int, window_seconds: int) -> tuple[bool, int, int]:
|
|
18
|
+
"""Return allowed, remaining, and reset seconds."""
|
|
19
|
+
|
|
20
|
+
def stats(self) -> dict:
|
|
21
|
+
"""Return storage metadata."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class InMemoryRateLimitStore:
|
|
25
|
+
"""Thread-safe fixed-window store for a single process."""
|
|
26
|
+
|
|
27
|
+
def __init__(self) -> None:
|
|
28
|
+
self._entries: dict[str, tuple[int, float]] = {}
|
|
29
|
+
self._expirations: dict[str, float] = {}
|
|
30
|
+
self._next_cleanup_at = 0.0
|
|
31
|
+
self._lock = Lock()
|
|
32
|
+
|
|
33
|
+
def _prune_expired(self, now: float) -> None:
|
|
34
|
+
if now < self._next_cleanup_at:
|
|
35
|
+
return
|
|
36
|
+
expired_keys = [
|
|
37
|
+
key for key, expires_at in self._expirations.items() if expires_at <= now
|
|
38
|
+
]
|
|
39
|
+
for key in expired_keys:
|
|
40
|
+
self._entries.pop(key, None)
|
|
41
|
+
self._expirations.pop(key, None)
|
|
42
|
+
self._next_cleanup_at = min(self._expirations.values(), default=float("inf"))
|
|
43
|
+
|
|
44
|
+
def check(self, key: str, limit: int, window_seconds: int) -> tuple[bool, int, int]:
|
|
45
|
+
with self._lock:
|
|
46
|
+
now = time.time()
|
|
47
|
+
self._prune_expired(now)
|
|
48
|
+
count, window_start = self._entries.get(key, (0, now))
|
|
49
|
+
if now - window_start >= window_seconds:
|
|
50
|
+
count, window_start = 0, now
|
|
51
|
+
count += 1
|
|
52
|
+
expires_at = window_start + window_seconds
|
|
53
|
+
self._entries[key] = (count, window_start)
|
|
54
|
+
self._expirations[key] = expires_at
|
|
55
|
+
self._next_cleanup_at = min(self._next_cleanup_at, expires_at)
|
|
56
|
+
return count <= limit, max(0, limit - count), max(0, int(expires_at - now))
|
|
57
|
+
|
|
58
|
+
def stats(self) -> dict:
|
|
59
|
+
with self._lock:
|
|
60
|
+
self._prune_expired(time.time())
|
|
61
|
+
return {"backend": "memory", "active_clients": len(self._entries)}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class RedisRateLimitStore:
|
|
65
|
+
"""Atomic fixed-window store backed by Redis for multi-worker deployments."""
|
|
66
|
+
|
|
67
|
+
_SCRIPT = """
|
|
68
|
+
local count = redis.call('INCR', KEYS[1])
|
|
69
|
+
if count == 1 then
|
|
70
|
+
redis.call('EXPIRE', KEYS[1], ARGV[1])
|
|
71
|
+
end
|
|
72
|
+
local ttl = redis.call('TTL', KEYS[1])
|
|
73
|
+
return {count, ttl}
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(self, url: str, namespace: str = "sdm:rate-limit:") -> None:
|
|
77
|
+
if not url:
|
|
78
|
+
raise ValueError("Redis URL is required when rate-limit backend is redis")
|
|
79
|
+
try:
|
|
80
|
+
import redis
|
|
81
|
+
except ImportError as exc:
|
|
82
|
+
raise RuntimeError("Redis rate limiting requires the optional 'redis' package") from exc
|
|
83
|
+
self._client = redis.Redis.from_url(url, decode_responses=False)
|
|
84
|
+
self._client.ping()
|
|
85
|
+
self._script = self._client.register_script(self._SCRIPT)
|
|
86
|
+
self._namespace = namespace
|
|
87
|
+
|
|
88
|
+
def check(self, key: str, limit: int, window_seconds: int) -> tuple[bool, int, int]:
|
|
89
|
+
values = self._script(keys=[f"{self._namespace}{key}"], args=[window_seconds])
|
|
90
|
+
count = int(values[0])
|
|
91
|
+
ttl = max(0, int(values[1]))
|
|
92
|
+
return count <= limit, max(0, limit - count), ttl
|
|
93
|
+
|
|
94
|
+
def stats(self) -> dict:
|
|
95
|
+
return {"backend": "redis", "namespace": self._namespace}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def create_rate_limit_store() -> RateLimitStore:
|
|
99
|
+
"""Create the configured store; never silently downgrade Redis to memory."""
|
|
100
|
+
backend = os.getenv("SDM_RATE_LIMIT_BACKEND", "memory").strip().lower()
|
|
101
|
+
if backend == "memory":
|
|
102
|
+
return InMemoryRateLimitStore()
|
|
103
|
+
if backend == "redis":
|
|
104
|
+
return RedisRateLimitStore(os.getenv("SDM_REDIS_URL", ""))
|
|
105
|
+
raise ValueError("Unsupported SDM_RATE_LIMIT_BACKEND; expected 'memory' or 'redis'")
|
backend/api/readiness.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Dependency-backed readiness probe for the API service."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import secrets
|
|
9
|
+
import time
|
|
10
|
+
import weakref
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from fastapi import Request
|
|
15
|
+
from fastapi.responses import JSONResponse
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
_READINESS_CACHE_TTL_SECONDS = 5.0
|
|
20
|
+
_READINESS_TIMEOUT_SECONDS = 3.0
|
|
21
|
+
_READINESS_LOCKS: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, asyncio.Lock] = weakref.WeakKeyDictionary()
|
|
22
|
+
_READINESS_LOCKS_GUARD = __import__("threading").Lock()
|
|
23
|
+
_READINESS_CACHE = {"checks": None, "cached_at": 0.0}
|
|
24
|
+
_HEALTH_PROBE_PATHS = {"/ready", "/health/deep"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _probe_allowed(request: Request) -> bool:
|
|
28
|
+
"""Allow health probes only from loopback or with the configured probe secret."""
|
|
29
|
+
token = os.getenv("SDM_HEALTH_PROBE_TOKEN", "").strip()
|
|
30
|
+
supplied = request.headers.get("X-Health-Probe-Token", "")
|
|
31
|
+
if token and supplied and secrets.compare_digest(supplied, token):
|
|
32
|
+
return True
|
|
33
|
+
client = request.client
|
|
34
|
+
return bool(client and client.host in {"127.0.0.1", "::1", "localhost"})
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _health_probe_denied_response() -> JSONResponse:
|
|
38
|
+
"""Return a stable denial response for public health probe requests."""
|
|
39
|
+
return JSONResponse(
|
|
40
|
+
status_code=403,
|
|
41
|
+
content={
|
|
42
|
+
"success": False,
|
|
43
|
+
"error": {"code": 403, "message": "health probe access denied"},
|
|
44
|
+
},
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def health_probe_response(request: Request) -> JSONResponse:
|
|
49
|
+
"""Authorize and dispatch a readiness/deep-health probe request."""
|
|
50
|
+
if request.url.path not in _HEALTH_PROBE_PATHS or request.method not in {"GET", "HEAD"}:
|
|
51
|
+
raise ValueError("health_probe_response called for a non-probe request")
|
|
52
|
+
if not _probe_allowed(request):
|
|
53
|
+
return _health_probe_denied_response()
|
|
54
|
+
if request.url.path == "/ready":
|
|
55
|
+
return await readiness_response(request)
|
|
56
|
+
return await deep_health_response(request)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _get_readiness_lock() -> asyncio.Lock:
|
|
60
|
+
loop = asyncio.get_running_loop()
|
|
61
|
+
with _READINESS_LOCKS_GUARD:
|
|
62
|
+
lock = _READINESS_LOCKS.get(loop)
|
|
63
|
+
if lock is None:
|
|
64
|
+
lock = asyncio.Lock()
|
|
65
|
+
_READINESS_LOCKS[loop] = lock
|
|
66
|
+
return lock
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _run_checks() -> dict[str, dict[str, Any]]:
|
|
70
|
+
"""Run the lightweight-but-real component checks synchronously."""
|
|
71
|
+
from backend.core.functions_lookup import FunctionEncyclopedia
|
|
72
|
+
from backend.core.nl2sql import NL2SQLGenerator
|
|
73
|
+
from backend.core.transpiler import SQLTranspiler
|
|
74
|
+
from backend.core.type_mapping import TypeMapper
|
|
75
|
+
|
|
76
|
+
transpiler = SQLTranspiler()
|
|
77
|
+
function_encyclopedia = FunctionEncyclopedia()
|
|
78
|
+
type_mapper = TypeMapper()
|
|
79
|
+
nl2sql_generator = NL2SQLGenerator()
|
|
80
|
+
|
|
81
|
+
checks: dict[str, dict[str, Any]] = {}
|
|
82
|
+
try:
|
|
83
|
+
result = transpiler.transpile("SELECT 1 AS readiness_probe", "mysql", "postgres")
|
|
84
|
+
checks["transpiler"] = {"status": "ok" if result.success else "error", "test_result": result.success}
|
|
85
|
+
except Exception:
|
|
86
|
+
logger.exception("Readiness transpiler probe failed")
|
|
87
|
+
checks["transpiler"] = {"status": "error", "code": "probe_failed"}
|
|
88
|
+
try:
|
|
89
|
+
function = function_encyclopedia.get_function("CONCAT")
|
|
90
|
+
checks["functions"] = {"status": "ok" if function else "error", "sample_lookup": "CONCAT" if function else None}
|
|
91
|
+
except Exception:
|
|
92
|
+
logger.exception("Readiness function probe failed")
|
|
93
|
+
checks["functions"] = {"status": "error", "code": "probe_failed"}
|
|
94
|
+
try:
|
|
95
|
+
mapping = type_mapper.map_type("VARCHAR", "mysql", "postgres")
|
|
96
|
+
checks["types"] = {"status": "ok" if mapping.get("success") else "error", "sample_mapping": mapping.get("target_type")}
|
|
97
|
+
except Exception:
|
|
98
|
+
logger.exception("Readiness type probe failed")
|
|
99
|
+
checks["types"] = {"status": "error", "code": "probe_failed"}
|
|
100
|
+
try:
|
|
101
|
+
generated = nl2sql_generator.generate("查询所有用户", "mysql")
|
|
102
|
+
checks["nl2sql"] = {"status": "ok" if generated.success else "error", "confidence": generated.confidence}
|
|
103
|
+
except Exception:
|
|
104
|
+
logger.exception("Readiness NL2SQL probe failed")
|
|
105
|
+
checks["nl2sql"] = {"status": "error", "code": "probe_failed"}
|
|
106
|
+
return checks
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async def _get_checks() -> dict[str, dict[str, Any]]:
|
|
110
|
+
"""Run at most one expensive probe at a time per event loop and reuse it briefly."""
|
|
111
|
+
now = time.monotonic()
|
|
112
|
+
cached_checks = globals().get("_cached_checks", _READINESS_CACHE["checks"])
|
|
113
|
+
cached_at = globals().get("_cached_at", _READINESS_CACHE["cached_at"])
|
|
114
|
+
if cached_checks is not None and now - cached_at < _READINESS_CACHE_TTL_SECONDS:
|
|
115
|
+
return cached_checks
|
|
116
|
+
async with _get_readiness_lock():
|
|
117
|
+
now = time.monotonic()
|
|
118
|
+
cached_checks = globals().get("_cached_checks", _READINESS_CACHE["checks"])
|
|
119
|
+
cached_at = globals().get("_cached_at", _READINESS_CACHE["cached_at"])
|
|
120
|
+
if cached_checks is not None and now - cached_at < _READINESS_CACHE_TTL_SECONDS:
|
|
121
|
+
return cached_checks
|
|
122
|
+
try:
|
|
123
|
+
checks = await asyncio.wait_for(asyncio.to_thread(_run_checks), timeout=_READINESS_TIMEOUT_SECONDS)
|
|
124
|
+
except asyncio.TimeoutError:
|
|
125
|
+
logger.error("Readiness probe timed out after %.1fs", _READINESS_TIMEOUT_SECONDS)
|
|
126
|
+
checks = {name: {"status": "error", "code": "probe_timeout"} for name in ("transpiler", "functions", "types", "nl2sql")}
|
|
127
|
+
except Exception:
|
|
128
|
+
logger.exception("Readiness probe failed unexpectedly")
|
|
129
|
+
checks = {"probe": {"status": "error", "code": "probe_failed"}}
|
|
130
|
+
_READINESS_CACHE["checks"] = checks
|
|
131
|
+
_READINESS_CACHE["cached_at"] = time.monotonic()
|
|
132
|
+
globals()["_cached_checks"] = checks
|
|
133
|
+
globals()["_cached_at"] = _READINESS_CACHE["cached_at"]
|
|
134
|
+
return checks
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _response_status(checks: dict[str, dict[str, Any]]) -> tuple[int, bool]:
|
|
138
|
+
"""Return the HTTP status and health flag for a probe result."""
|
|
139
|
+
failed = [name for name, check in checks.items() if check.get("status") != "ok"]
|
|
140
|
+
return (200 if not failed else 503, not failed)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
async def readiness_response(request: Request) -> JSONResponse:
|
|
144
|
+
"""Return HTTP 200 only when all core service probes pass."""
|
|
145
|
+
checks = await _get_checks()
|
|
146
|
+
status_code, healthy = _response_status(checks)
|
|
147
|
+
return JSONResponse(
|
|
148
|
+
status_code=status_code,
|
|
149
|
+
content={
|
|
150
|
+
"status": "ready" if healthy else "not_ready",
|
|
151
|
+
"version": _api_version(),
|
|
152
|
+
"probe": "readiness",
|
|
153
|
+
"checks": checks,
|
|
154
|
+
"timestamp": datetime.now().isoformat(),
|
|
155
|
+
},
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
async def deep_health_response(request: Request) -> JSONResponse:
|
|
160
|
+
"""Return deep health using the same cached dependency probes as readiness."""
|
|
161
|
+
checks = await _get_checks()
|
|
162
|
+
status_code, healthy = _response_status(checks)
|
|
163
|
+
return JSONResponse(
|
|
164
|
+
status_code=status_code,
|
|
165
|
+
content={
|
|
166
|
+
"status": "✅ healthy" if healthy else "❌ unhealthy",
|
|
167
|
+
"version": _api_version(),
|
|
168
|
+
"checks": checks,
|
|
169
|
+
"timestamp": datetime.now().isoformat(),
|
|
170
|
+
},
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _api_version() -> str:
|
|
175
|
+
"""Resolve API version without importing the API module back into readiness."""
|
|
176
|
+
from backend.core.config import settings
|
|
177
|
+
|
|
178
|
+
return settings.api_version
|
backend/core/__init__.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# SQL Dialect Master - Core Module
|
|
2
|
+
from .config import (
|
|
3
|
+
setup_logging,
|
|
4
|
+
SUPPORTED_DIALECTS,
|
|
5
|
+
DIALECT_METADATA,
|
|
6
|
+
settings,
|
|
7
|
+
AppSettings,
|
|
8
|
+
get_dialect_ui_info,
|
|
9
|
+
get_dialect_api_info,
|
|
10
|
+
get_dialect_label,
|
|
11
|
+
)
|
|
12
|
+
from .parser import SQLParser, ParseResult
|
|
13
|
+
from .transpiler import SQLTranspiler, TranspileResult
|
|
14
|
+
from .semantic_diff import SQLSemanticDiffer, SemanticDiff, diff_sql_ast
|
|
15
|
+
from .post_processor import PostProcessor
|
|
16
|
+
from .functions_lookup import FunctionEncyclopedia
|
|
17
|
+
from .type_mapping import TypeMapper
|
|
18
|
+
from .nl2sql import NL2SQLGenerator, NL2SQLResult
|
|
19
|
+
from .cache import TTLCache
|
|
20
|
+
from .exceptions import (
|
|
21
|
+
SDMException,
|
|
22
|
+
UnsupportedDialectError,
|
|
23
|
+
TranspileError,
|
|
24
|
+
ParseError,
|
|
25
|
+
SecurityViolationError,
|
|
26
|
+
ValidationError,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"setup_logging",
|
|
31
|
+
"SUPPORTED_DIALECTS",
|
|
32
|
+
"DIALECT_METADATA",
|
|
33
|
+
"settings",
|
|
34
|
+
"AppSettings",
|
|
35
|
+
"get_dialect_ui_info",
|
|
36
|
+
"get_dialect_api_info",
|
|
37
|
+
"get_dialect_label",
|
|
38
|
+
"SQLParser",
|
|
39
|
+
"ParseResult",
|
|
40
|
+
"SQLTranspiler",
|
|
41
|
+
"TranspileResult",
|
|
42
|
+
"SQLSemanticDiffer",
|
|
43
|
+
"SemanticDiff",
|
|
44
|
+
"diff_sql_ast",
|
|
45
|
+
"TTLCache",
|
|
46
|
+
"PostProcessor",
|
|
47
|
+
"FunctionEncyclopedia",
|
|
48
|
+
"TypeMapper",
|
|
49
|
+
"NL2SQLGenerator",
|
|
50
|
+
"NL2SQLResult",
|
|
51
|
+
"SDMException",
|
|
52
|
+
"UnsupportedDialectError",
|
|
53
|
+
"TranspileError",
|
|
54
|
+
"ParseError",
|
|
55
|
+
"SecurityViolationError",
|
|
56
|
+
"ValidationError",
|
|
57
|
+
]
|