sql-dialect-master 1.1.4__tar.gz → 1.1.6__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.4 → sql_dialect_master-1.1.6}/PKG-INFO +21 -44
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/README.md +20 -42
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/api/main.py +157 -20
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/api/middleware.py +71 -7
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/api/readiness.py +44 -13
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/api/workers.py +7 -1
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/core/cache.py +15 -3
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/core/config.py +3 -1
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/core/functions_lookup.py +57 -25
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/core/metadata.py +2 -2
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/core/p1_sql_scanner.py +30 -2
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/core/rules.py +68 -22
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/core/semantic_diff.py +0 -1
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/core/transpiler.py +617 -58
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/core/type_mapping.py +4 -4
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/pyproject.toml +2 -14
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/sql_dialect_master.egg-info/PKG-INFO +21 -44
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/sql_dialect_master.egg-info/SOURCES.txt +0 -35
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/sql_dialect_master.egg-info/requires.txt +0 -1
- sql_dialect_master-1.1.6/sql_dialect_master.egg-info/top_level.txt +1 -0
- sql_dialect_master-1.1.4/backend/frontend_entrypoint.py +0 -22
- sql_dialect_master-1.1.4/frontend/__init__.py +0 -10
- sql_dialect_master-1.1.4/frontend/app_context_v2.py +0 -201
- sql_dialect_master-1.1.4/frontend/core/__init__.py +0 -33
- sql_dialect_master-1.1.4/frontend/core/design_tokens.py +0 -131
- sql_dialect_master-1.1.4/frontend/core/escaping.py +0 -13
- sql_dialect_master-1.1.4/frontend/core/fingerprints.py +0 -24
- sql_dialect_master-1.1.4/frontend/core/navigation.py +0 -189
- sql_dialect_master-1.1.4/frontend/core/sql_inputs.py +0 -6
- sql_dialect_master-1.1.4/frontend/core/state.py +0 -702
- sql_dialect_master-1.1.4/frontend/core/statuses.py +0 -34
- sql_dialect_master-1.1.4/frontend/core/styles.py +0 -523
- sql_dialect_master-1.1.4/frontend/core/themes.py +0 -75
- sql_dialect_master-1.1.4/frontend/core/type_status.py +0 -26
- sql_dialect_master-1.1.4/frontend/core/viewmodels.py +0 -502
- sql_dialect_master-1.1.4/frontend/pages/__init__.py +0 -0
- sql_dialect_master-1.1.4/frontend/pages/convert.py +0 -371
- sql_dialect_master-1.1.4/frontend/pages/diff.py +0 -185
- sql_dialect_master-1.1.4/frontend/pages/functions.py +0 -99
- sql_dialect_master-1.1.4/frontend/pages/history.py +0 -148
- sql_dialect_master-1.1.4/frontend/pages/lineage.py +0 -185
- sql_dialect_master-1.1.4/frontend/pages/nl2sql.py +0 -317
- sql_dialect_master-1.1.4/frontend/pages/query_analysis.py +0 -128
- sql_dialect_master-1.1.4/frontend/pages/runtime.py +0 -66
- sql_dialect_master-1.1.4/frontend/pages/settings.py +0 -60
- sql_dialect_master-1.1.4/frontend/pages/templates.py +0 -86
- sql_dialect_master-1.1.4/frontend/pages/types.py +0 -106
- sql_dialect_master-1.1.4/frontend/templates_v2.py +0 -53
- sql_dialect_master-1.1.4/frontend/ui/__init__.py +0 -0
- sql_dialect_master-1.1.4/frontend/ui/command_palette.py +0 -172
- sql_dialect_master-1.1.4/frontend/ui/diff.py +0 -269
- sql_dialect_master-1.1.4/frontend/ui/editor.py +0 -106
- sql_dialect_master-1.1.4/frontend/ui/status.py +0 -252
- sql_dialect_master-1.1.4/sdm_local_v2.py +0 -219
- sql_dialect_master-1.1.4/sql_dialect_master.egg-info/entry_points.txt +0 -2
- sql_dialect_master-1.1.4/sql_dialect_master.egg-info/top_level.txt +0 -3
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/LICENSE +0 -0
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/__init__.py +0 -0
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/api/__init__.py +0 -0
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/api/auth.py +0 -0
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/api/rate_limit_store.py +0 -0
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/core/__init__.py +0 -0
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/core/column_hint_validation.py +0 -0
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/core/constants.py +0 -0
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/core/exceptions.py +0 -0
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/core/function_call_scanner.py +0 -0
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/core/function_semantics.py +0 -0
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/core/functions_db.json +0 -0
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/core/nl2sql.py +0 -0
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/core/nl2sql_components/__init__.py +0 -0
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/core/nl2sql_components/aggregations.py +0 -0
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/core/nl2sql_components/boolean_conditions.py +0 -0
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/core/nl2sql_components/core_extract.py +0 -0
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/core/nl2sql_components/evidence.py +0 -0
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/core/nl2sql_components/mappings.py +0 -0
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/core/nl2sql_components/relations.py +0 -0
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/core/nl2sql_components/templates.py +0 -0
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/core/nl2sql_components/tokenizer.py +0 -0
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/core/nl2sql_legacy.py +0 -0
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/core/nl2sql_pipeline/__init__.py +0 -0
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/core/nl2sql_pipeline/aggregations.py +0 -0
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/core/nl2sql_pipeline/clause_extract.py +0 -0
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/core/nl2sql_pipeline/dialect.py +0 -0
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/core/nl2sql_pipeline/intent.py +0 -0
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/core/nl2sql_pipeline/ir.py +0 -0
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/core/nl2sql_pipeline/models.py +0 -0
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/core/nl2sql_pipeline/orchestrator.py +0 -0
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/core/nl2sql_pipeline/predicates.py +0 -0
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/core/nl2sql_pipeline/renderer.py +0 -0
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/core/nl2sql_pipeline/resolution.py +0 -0
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/core/nl2sql_pipeline/suggestions.py +0 -0
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/core/nl2sql_pipeline/templates.py +0 -0
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/core/nl2sql_pipeline/validation.py +0 -0
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/core/p2_data_validation.py +0 -0
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/core/parser.py +0 -0
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/core/post_processor.py +0 -0
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/core/sql_statements.py +0 -0
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/core/type_mapping.json +0 -0
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/utils/__init__.py +0 -0
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/backend/utils/validators.py +0 -0
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/setup.cfg +0 -0
- {sql_dialect_master-1.1.4 → sql_dialect_master-1.1.6}/sql_dialect_master.egg-info/dependency_links.txt +0 -0
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sql-dialect-master
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.6
|
|
4
4
|
Summary: SQL Dialect Master - Multi-database SQL conversion engine
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
7
7
|
License-File: LICENSE
|
|
8
8
|
Requires-Dist: sqlglot<31.0.0,>=30.18.0
|
|
9
|
-
Requires-Dist: streamlit>=1.38.0
|
|
10
9
|
Requires-Dist: fastapi>=0.115.0
|
|
11
10
|
Requires-Dist: uvicorn>=0.30.0
|
|
12
11
|
Requires-Dist: pydantic>=2.9.0
|
|
@@ -36,12 +35,12 @@ Dynamic: license-file
|
|
|
36
35
|
|
|
37
36
|
Enterprise-grade multi-database SQL conversion engine supporting 12 database dialects.
|
|
38
37
|
|
|
39
|
-
Current release: 1.1.
|
|
38
|
+
Current release: 1.1.6
|
|
40
39
|
|
|
41
40
|
[](https://www.python.org/downloads/)
|
|
42
41
|
[](https://opensource.org/licenses/MIT)
|
|
43
42
|
[](https://fastapi.tiangolo.com/)
|
|
44
|
-
[](https://react.dev/)
|
|
45
44
|
|
|
46
45
|
## ✨ Features
|
|
47
46
|
|
|
@@ -58,7 +57,7 @@ Current release: 1.1.4
|
|
|
58
57
|
| 🔧 **Rule Engine** | Deterministic priority ordering, compiled patterns, dialect-aware conflict detection |
|
|
59
58
|
| 🛡️ **Security Validation** | Statement-level stacked-query detection with configurable blocking policy |
|
|
60
59
|
| ⚡ **Performance** | Rule-aware cache versioning, bounded async batch conversion, and reproducible benchmark |
|
|
61
|
-
| 🎨 **Modern UI** |
|
|
60
|
+
| 🎨 **Modern UI** | React developer workspace with dark / light themes, responsive navigation, and command palette |
|
|
62
61
|
|
|
63
62
|
## 💾 Supported Databases
|
|
64
63
|
|
|
@@ -96,20 +95,24 @@ For database-backed semantic tests:
|
|
|
96
95
|
.venv/bin/python -m pip install -e ".[dev,semantic]"
|
|
97
96
|
```
|
|
98
97
|
|
|
99
|
-
### Run
|
|
98
|
+
### Run the React UI
|
|
100
99
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
```
|
|
104
|
-
|
|
105
|
-
After installing the package, the same workspace can be launched from any
|
|
106
|
-
working directory with:
|
|
100
|
+
The React workspace is the product frontend and runs independently from the
|
|
101
|
+
Python package. Start the API first, then run:
|
|
107
102
|
|
|
108
103
|
```bash
|
|
109
|
-
|
|
104
|
+
cd frontend-react
|
|
105
|
+
npm install
|
|
106
|
+
npm run dev
|
|
110
107
|
```
|
|
111
108
|
|
|
112
|
-
Open http://localhost:
|
|
109
|
+
Open http://localhost:5174. The Vite development proxy forwards `/api`,
|
|
110
|
+
`/health`, and `/ready` to `http://127.0.0.1:8000`. Set `VITE_API_BASE_URL`
|
|
111
|
+
for a separately deployed API, or configure the URL and optional API key in
|
|
112
|
+
the React Settings page.
|
|
113
|
+
|
|
114
|
+
React implementation goals and gates are tracked in
|
|
115
|
+
[`docs/react-frontend-refactor-plan.md`](docs/react-frontend-refactor-plan.md).
|
|
113
116
|
|
|
114
117
|
### Run FastAPI Backend
|
|
115
118
|
|
|
@@ -270,42 +273,16 @@ sql-dialect-master/
|
|
|
270
273
|
│ │ └── validators.py # Input validation
|
|
271
274
|
│ │
|
|
272
275
|
│ └── tests/ # Test suite and regression coverage
|
|
273
|
-
├── frontend/ # Streamlit UI Components (v2 architecture)
|
|
274
|
-
│ ├── core/ # Cross-cutting concerns
|
|
275
|
-
│ │ ├── design_tokens.py # Theme model (dark/light) + semantic status
|
|
276
|
-
│ │ ├── navigation.py # NavigationIntent contract
|
|
277
|
-
│ │ ├── state.py # SessionState adapter
|
|
278
|
-
│ │ ├── themes.py # Theme switching (dark/light only)
|
|
279
|
-
│ │ ├── styles.py # CSS generation from design tokens
|
|
280
|
-
│ │ ├── escaping.py # HTML-safe rendering helpers
|
|
281
|
-
│ │ └── viewmodels.py # Typed view models
|
|
282
|
-
│ ├── pages/ # One module per workspace page
|
|
283
|
-
│ │ ├── convert.py
|
|
284
|
-
│ │ ├── nl2sql.py
|
|
285
|
-
│ │ ├── diff.py
|
|
286
|
-
│ │ ├── lineage.py
|
|
287
|
-
│ │ ├── runtime.py
|
|
288
|
-
│ │ ├── functions.py
|
|
289
|
-
│ │ ├── types.py
|
|
290
|
-
│ │ ├── templates.py
|
|
291
|
-
│ │ ├── history.py
|
|
292
|
-
│ │ ├── settings.py
|
|
293
|
-
│ │ └── query_analysis.py
|
|
294
|
-
│ ├── ui/ # Reusable UI widgets
|
|
295
|
-
│ │ ├── command_palette.py
|
|
296
|
-
│ │ ├── diff.py
|
|
297
|
-
│ │ ├── editor.py
|
|
298
|
-
│ │ └── status.py
|
|
299
|
-
│ ├── app_context_v2.py # Shared application context (v2)
|
|
300
|
-
│ └── templates_v2.py # SQL templates (v2)
|
|
301
276
|
├── examples/
|
|
302
277
|
│ ├── api_client.py # API usage examples
|
|
303
278
|
│ └── basic_usage.py # SDK usage examples
|
|
304
279
|
├── scripts/
|
|
305
280
|
│ └── verify_readme_consistency.py # README source-of-truth checks
|
|
306
|
-
├──
|
|
281
|
+
├── frontend-react/ # React + TypeScript product frontend
|
|
282
|
+
│ ├── src/
|
|
283
|
+
│ ├── package.json
|
|
284
|
+
│ └── vite.config.ts
|
|
307
285
|
├── pyproject.toml # Project configuration
|
|
308
|
-
├── requirements.txt # Legacy/runtime dependency list
|
|
309
286
|
├── .env.example # Environment template
|
|
310
287
|
├── LICENSE # MIT License
|
|
311
288
|
└── README.md
|
|
@@ -2,12 +2,12 @@
|
|
|
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.6
|
|
6
6
|
|
|
7
7
|
[](https://www.python.org/downloads/)
|
|
8
8
|
[](https://opensource.org/licenses/MIT)
|
|
9
9
|
[](https://fastapi.tiangolo.com/)
|
|
10
|
-
[](https://react.dev/)
|
|
11
11
|
|
|
12
12
|
## ✨ Features
|
|
13
13
|
|
|
@@ -24,7 +24,7 @@ Current release: 1.1.4
|
|
|
24
24
|
| 🔧 **Rule Engine** | Deterministic priority ordering, compiled patterns, dialect-aware conflict detection |
|
|
25
25
|
| 🛡️ **Security Validation** | Statement-level stacked-query detection with configurable blocking policy |
|
|
26
26
|
| ⚡ **Performance** | Rule-aware cache versioning, bounded async batch conversion, and reproducible benchmark |
|
|
27
|
-
| 🎨 **Modern UI** |
|
|
27
|
+
| 🎨 **Modern UI** | React developer workspace with dark / light themes, responsive navigation, and command palette |
|
|
28
28
|
|
|
29
29
|
## 💾 Supported Databases
|
|
30
30
|
|
|
@@ -62,20 +62,24 @@ For database-backed semantic tests:
|
|
|
62
62
|
.venv/bin/python -m pip install -e ".[dev,semantic]"
|
|
63
63
|
```
|
|
64
64
|
|
|
65
|
-
### Run
|
|
65
|
+
### Run the React UI
|
|
66
66
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
```
|
|
70
|
-
|
|
71
|
-
After installing the package, the same workspace can be launched from any
|
|
72
|
-
working directory with:
|
|
67
|
+
The React workspace is the product frontend and runs independently from the
|
|
68
|
+
Python package. Start the API first, then run:
|
|
73
69
|
|
|
74
70
|
```bash
|
|
75
|
-
|
|
71
|
+
cd frontend-react
|
|
72
|
+
npm install
|
|
73
|
+
npm run dev
|
|
76
74
|
```
|
|
77
75
|
|
|
78
|
-
Open http://localhost:
|
|
76
|
+
Open http://localhost:5174. The Vite development proxy forwards `/api`,
|
|
77
|
+
`/health`, and `/ready` to `http://127.0.0.1:8000`. Set `VITE_API_BASE_URL`
|
|
78
|
+
for a separately deployed API, or configure the URL and optional API key in
|
|
79
|
+
the React Settings page.
|
|
80
|
+
|
|
81
|
+
React implementation goals and gates are tracked in
|
|
82
|
+
[`docs/react-frontend-refactor-plan.md`](docs/react-frontend-refactor-plan.md).
|
|
79
83
|
|
|
80
84
|
### Run FastAPI Backend
|
|
81
85
|
|
|
@@ -236,42 +240,16 @@ sql-dialect-master/
|
|
|
236
240
|
│ │ └── validators.py # Input validation
|
|
237
241
|
│ │
|
|
238
242
|
│ └── tests/ # Test suite and regression coverage
|
|
239
|
-
├── frontend/ # Streamlit UI Components (v2 architecture)
|
|
240
|
-
│ ├── core/ # Cross-cutting concerns
|
|
241
|
-
│ │ ├── design_tokens.py # Theme model (dark/light) + semantic status
|
|
242
|
-
│ │ ├── navigation.py # NavigationIntent contract
|
|
243
|
-
│ │ ├── state.py # SessionState adapter
|
|
244
|
-
│ │ ├── themes.py # Theme switching (dark/light only)
|
|
245
|
-
│ │ ├── styles.py # CSS generation from design tokens
|
|
246
|
-
│ │ ├── escaping.py # HTML-safe rendering helpers
|
|
247
|
-
│ │ └── viewmodels.py # Typed view models
|
|
248
|
-
│ ├── pages/ # One module per workspace page
|
|
249
|
-
│ │ ├── convert.py
|
|
250
|
-
│ │ ├── nl2sql.py
|
|
251
|
-
│ │ ├── diff.py
|
|
252
|
-
│ │ ├── lineage.py
|
|
253
|
-
│ │ ├── runtime.py
|
|
254
|
-
│ │ ├── functions.py
|
|
255
|
-
│ │ ├── types.py
|
|
256
|
-
│ │ ├── templates.py
|
|
257
|
-
│ │ ├── history.py
|
|
258
|
-
│ │ ├── settings.py
|
|
259
|
-
│ │ └── query_analysis.py
|
|
260
|
-
│ ├── ui/ # Reusable UI widgets
|
|
261
|
-
│ │ ├── command_palette.py
|
|
262
|
-
│ │ ├── diff.py
|
|
263
|
-
│ │ ├── editor.py
|
|
264
|
-
│ │ └── status.py
|
|
265
|
-
│ ├── app_context_v2.py # Shared application context (v2)
|
|
266
|
-
│ └── templates_v2.py # SQL templates (v2)
|
|
267
243
|
├── examples/
|
|
268
244
|
│ ├── api_client.py # API usage examples
|
|
269
245
|
│ └── basic_usage.py # SDK usage examples
|
|
270
246
|
├── scripts/
|
|
271
247
|
│ └── verify_readme_consistency.py # README source-of-truth checks
|
|
272
|
-
├──
|
|
248
|
+
├── frontend-react/ # React + TypeScript product frontend
|
|
249
|
+
│ ├── src/
|
|
250
|
+
│ ├── package.json
|
|
251
|
+
│ └── vite.config.ts
|
|
273
252
|
├── pyproject.toml # Project configuration
|
|
274
|
-
├── requirements.txt # Legacy/runtime dependency list
|
|
275
253
|
├── .env.example # Environment template
|
|
276
254
|
├── LICENSE # MIT License
|
|
277
255
|
└── README.md
|
|
@@ -12,59 +12,135 @@ Exact counts are derived from the canonical data files at runtime —
|
|
|
12
12
|
see backend.core.metadata — and must not be restated here.
|
|
13
13
|
"""
|
|
14
14
|
import asyncio
|
|
15
|
+
import atexit
|
|
16
|
+
import copy
|
|
15
17
|
import logging
|
|
16
18
|
import time
|
|
17
19
|
from contextlib import asynccontextmanager
|
|
20
|
+
from dataclasses import asdict
|
|
18
21
|
from datetime import datetime
|
|
22
|
+
from functools import lru_cache
|
|
23
|
+
from threading import Lock
|
|
19
24
|
from typing import Any, Dict, List, Optional
|
|
20
25
|
|
|
21
26
|
from fastapi import Depends, FastAPI, HTTPException, Query, Request
|
|
22
27
|
from fastapi.middleware.cors import CORSMiddleware
|
|
28
|
+
from fastapi.middleware.gzip import GZipMiddleware
|
|
23
29
|
from fastapi.responses import JSONResponse
|
|
24
30
|
from pydantic import BaseModel, ConfigDict, Field
|
|
25
31
|
|
|
26
|
-
from backend.
|
|
32
|
+
from backend.api.auth import require_api_access
|
|
33
|
+
from backend.api.rate_limit_store import InMemoryRateLimitStore
|
|
34
|
+
from backend.api.readiness import health_probe_response
|
|
35
|
+
from backend.api.workers import BoundedWorkerPool, WorkerCapacityError
|
|
27
36
|
from backend.core import metadata as meta
|
|
37
|
+
from backend.core.config import get_dialect_api_info, settings, setup_logging
|
|
28
38
|
from backend.core.functions_lookup import FunctionEncyclopedia
|
|
29
39
|
from backend.core.nl2sql import NL2SQLGenerator
|
|
30
40
|
from backend.core.nl2sql_pipeline.orchestrator import SAFE_TABLE_HINT
|
|
31
41
|
from backend.core.parser import SUPPORTED_DIALECTS, SQLParser
|
|
42
|
+
from backend.core.semantic_diff import diff_sql_ast
|
|
32
43
|
from backend.core.transpiler import SQLTranspiler
|
|
33
44
|
from backend.core.type_mapping import TypeMapper
|
|
34
45
|
|
|
35
|
-
from backend.api.readiness import health_probe_response
|
|
36
|
-
from backend.api.auth import require_api_access
|
|
37
|
-
from backend.api.workers import BoundedWorkerPool, WorkerCapacityError
|
|
38
|
-
from backend.api.rate_limit_store import InMemoryRateLimitStore
|
|
39
|
-
|
|
40
46
|
# Configure logging for the application from the validated settings object.
|
|
41
47
|
# Keep an INFO fallback for defensive compatibility with custom settings
|
|
42
48
|
# objects, while normal AppSettings values are resolved from SDM_LOG_LEVEL.
|
|
43
49
|
_log_level = getattr(logging, settings.log_level.upper(), logging.INFO)
|
|
44
50
|
logger = setup_logging(level=_log_level, log_file=settings.log_file or None)
|
|
45
51
|
api_logger = logging.getLogger(__name__)
|
|
46
|
-
_worker_pool =
|
|
52
|
+
_worker_pool: BoundedWorkerPool | None = None
|
|
53
|
+
_worker_pool_lock = Lock()
|
|
54
|
+
_worker_pool_exit_hook = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _get_worker_pool() -> BoundedWorkerPool:
|
|
58
|
+
"""Return the process-wide pool, creating it lazily for non-ASGI callers.
|
|
59
|
+
|
|
60
|
+
``app`` is intentionally resolved when this helper is called, after the
|
|
61
|
+
module-level FastAPI application has been initialized. This keeps import
|
|
62
|
+
time free of executor creation while preserving the ASGI/non-ASGI split.
|
|
63
|
+
"""
|
|
64
|
+
state_pool = getattr(app.state, "worker_pool", None)
|
|
65
|
+
if state_pool is not None:
|
|
66
|
+
return state_pool
|
|
67
|
+
|
|
68
|
+
global _worker_pool, _worker_pool_exit_hook
|
|
69
|
+
with _worker_pool_lock:
|
|
70
|
+
if _worker_pool is None:
|
|
71
|
+
_worker_pool = BoundedWorkerPool(settings.api_workers, settings.api_queue_size)
|
|
72
|
+
# Direct callers (benchmarks/tests/scripts) do not enter the ASGI
|
|
73
|
+
# lifespan, so give their lazily-created pool a process shutdown
|
|
74
|
+
# hook instead of leaving executor threads alive indefinitely.
|
|
75
|
+
_worker_pool_exit_hook = _worker_pool.shutdown
|
|
76
|
+
atexit.register(_worker_pool_exit_hook)
|
|
77
|
+
return _worker_pool
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def shutdown_worker_pool() -> None:
|
|
81
|
+
"""Explicitly release a lazily-created non-ASGI worker pool.
|
|
82
|
+
|
|
83
|
+
Scripts and benchmarks should call this when their work is complete. The
|
|
84
|
+
atexit hook is retained only as a final safety net and is unregistered
|
|
85
|
+
before shutdown, so explicit and process-exit cleanup cannot run twice.
|
|
86
|
+
"""
|
|
87
|
+
global _worker_pool, _worker_pool_exit_hook
|
|
88
|
+
with _worker_pool_lock:
|
|
89
|
+
pool = _worker_pool
|
|
90
|
+
hook = _worker_pool_exit_hook
|
|
91
|
+
_worker_pool = None
|
|
92
|
+
_worker_pool_exit_hook = None
|
|
93
|
+
if hook is not None:
|
|
94
|
+
atexit.unregister(hook)
|
|
95
|
+
if pool is not None:
|
|
96
|
+
pool.shutdown()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@lru_cache(maxsize=len(SUPPORTED_DIALECTS))
|
|
100
|
+
def _get_sql_parser(dialect: str) -> SQLParser:
|
|
101
|
+
"""Return the immutable parser shell shared by parse requests.
|
|
102
|
+
|
|
103
|
+
``SQLParser`` stores only the normalized dialect and stateless extraction
|
|
104
|
+
helpers. Reusing one instance per dialect avoids constructing the same
|
|
105
|
+
object for every request while keeping request-specific AST/results local
|
|
106
|
+
to the worker that executes them.
|
|
107
|
+
"""
|
|
108
|
+
return SQLParser(dialect)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@lru_cache(maxsize=256)
|
|
112
|
+
def _parse_elements_cached(dialect: str, sql: str) -> dict[str, Any]:
|
|
113
|
+
"""Cache bounded parser extraction results for repeated API queries."""
|
|
114
|
+
return _get_sql_parser(dialect).extract_elements(sql)
|
|
47
115
|
|
|
48
116
|
|
|
49
117
|
@asynccontextmanager
|
|
50
118
|
async def lifespan(application: FastAPI):
|
|
51
119
|
"""Manage the service worker pool for a normal ASGI startup/shutdown."""
|
|
52
|
-
pool =
|
|
120
|
+
pool = _get_worker_pool()
|
|
53
121
|
application.state.worker_pool = pool
|
|
54
122
|
try:
|
|
55
123
|
yield
|
|
56
124
|
finally:
|
|
57
|
-
pool
|
|
58
|
-
|
|
125
|
+
if _worker_pool is pool:
|
|
126
|
+
shutdown_worker_pool()
|
|
127
|
+
else:
|
|
128
|
+
pool.shutdown()
|
|
129
|
+
if getattr(application.state, "worker_pool", None) is pool:
|
|
130
|
+
del application.state.worker_pool
|
|
59
131
|
|
|
60
132
|
|
|
61
133
|
async def _run_cpu_bound(func, *args):
|
|
62
134
|
"""Run parser/transpiler work off-loop with the configured timeout."""
|
|
63
135
|
try:
|
|
64
|
-
pool =
|
|
136
|
+
pool = _get_worker_pool()
|
|
65
137
|
return await pool.run(func, *args, timeout=settings.request_timeout)
|
|
66
138
|
except WorkerCapacityError as exc:
|
|
67
|
-
raise HTTPException(
|
|
139
|
+
raise HTTPException(
|
|
140
|
+
status_code=503,
|
|
141
|
+
detail=str(exc),
|
|
142
|
+
headers={"Retry-After": "1"},
|
|
143
|
+
) from exc
|
|
68
144
|
except asyncio.TimeoutError as exc:
|
|
69
145
|
raise HTTPException(
|
|
70
146
|
status_code=504,
|
|
@@ -174,9 +250,28 @@ app.add_middleware(
|
|
|
174
250
|
allow_origins=ALLOWED_ORIGINS,
|
|
175
251
|
allow_credentials=True,
|
|
176
252
|
allow_methods=["GET", "POST", "OPTIONS"], # Restrict to needed methods
|
|
177
|
-
|
|
253
|
+
# Keep preflight explicit so browser clients can send the correlation and
|
|
254
|
+
# API-key headers used by the React request layer. Expose operational
|
|
255
|
+
# response headers as well: without this list browsers silently hide
|
|
256
|
+
# Retry-After and request timing/correlation metadata from JavaScript even
|
|
257
|
+
# though the server emits them correctly.
|
|
258
|
+
allow_headers=["Accept", "Content-Type", "Authorization", "X-API-Key", "X-Request-ID"],
|
|
259
|
+
expose_headers=[
|
|
260
|
+
"Retry-After",
|
|
261
|
+
"X-API-Version",
|
|
262
|
+
"X-Process-Time",
|
|
263
|
+
"X-RateLimit-Limit",
|
|
264
|
+
"X-RateLimit-Remaining",
|
|
265
|
+
"X-RateLimit-Reset",
|
|
266
|
+
"X-Request-ID",
|
|
267
|
+
],
|
|
178
268
|
)
|
|
179
269
|
|
|
270
|
+
# Type/function matrices and conversion reports are often much larger than
|
|
271
|
+
# the request itself. Compress them at the ASGI boundary so browser clients
|
|
272
|
+
# spend less time waiting on the network without changing response schemas.
|
|
273
|
+
app.add_middleware(GZipMiddleware, minimum_size=1024, compresslevel=5)
|
|
274
|
+
|
|
180
275
|
# Add security headers
|
|
181
276
|
app.add_middleware(SecurityHeadersMiddleware)
|
|
182
277
|
|
|
@@ -204,9 +299,12 @@ nl2sql_generator = NL2SQLGenerator()
|
|
|
204
299
|
# Request timing middleware with logging
|
|
205
300
|
@app.middleware("http")
|
|
206
301
|
async def add_process_time_header(request: Request, call_next):
|
|
207
|
-
|
|
302
|
+
# ``perf_counter`` is monotonic and high resolution. Wall-clock time can
|
|
303
|
+
# jump when NTP adjusts the host clock, which would otherwise produce a
|
|
304
|
+
# negative or wildly inflated duration header and log entry.
|
|
305
|
+
start_time = time.perf_counter()
|
|
208
306
|
response = await call_next(request)
|
|
209
|
-
process_time = time.
|
|
307
|
+
process_time = time.perf_counter() - start_time
|
|
210
308
|
response.headers["X-Process-Time"] = f"{process_time:.4f}s"
|
|
211
309
|
response.headers["X-API-Version"] = API_VERSION
|
|
212
310
|
|
|
@@ -323,6 +421,15 @@ class ParseRequest(BaseModel):
|
|
|
323
421
|
sql: str = Field(..., description="SQL statement to parse")
|
|
324
422
|
dialect: str = Field("hive", description="SQL dialect")
|
|
325
423
|
|
|
424
|
+
|
|
425
|
+
class DiffRequest(BaseModel):
|
|
426
|
+
"""Semantic SQL comparison request."""
|
|
427
|
+
|
|
428
|
+
source_sql: str = Field(..., description="Source SQL statement")
|
|
429
|
+
target_sql: str = Field(..., description="Target SQL statement")
|
|
430
|
+
source_dialect: str = Field(..., description="Source SQL dialect")
|
|
431
|
+
target_dialect: str = Field(..., description="Target SQL dialect")
|
|
432
|
+
|
|
326
433
|
class TypeMapRequest(BaseModel):
|
|
327
434
|
"""Type mapping request model."""
|
|
328
435
|
type_name: str = Field(..., description="Source data type name")
|
|
@@ -487,8 +594,8 @@ async def parse_sql(request: ParseRequest):
|
|
|
487
594
|
Extracts tables, columns, functions, and other elements from SQL.
|
|
488
595
|
"""
|
|
489
596
|
dialect = normalize_dialect(request.dialect)
|
|
490
|
-
|
|
491
|
-
elements =
|
|
597
|
+
elements = await _run_cpu_bound(_parse_elements_cached, dialect, request.sql)
|
|
598
|
+
elements = copy.deepcopy(elements)
|
|
492
599
|
if "error" in elements:
|
|
493
600
|
raise HTTPException(status_code=422, detail=elements["error"] or "SQL parsing failed")
|
|
494
601
|
return {
|
|
@@ -498,6 +605,25 @@ async def parse_sql(request: ParseRequest):
|
|
|
498
605
|
"timestamp": datetime.now().isoformat()
|
|
499
606
|
}
|
|
500
607
|
|
|
608
|
+
|
|
609
|
+
@app.post("/api/diff", tags=["conversion"])
|
|
610
|
+
async def compare_semantic_diff(request: DiffRequest):
|
|
611
|
+
"""Compare source and target SQL without executing either statement."""
|
|
612
|
+
source_dialect = normalize_dialect(request.source_dialect, "source dialect")
|
|
613
|
+
target_dialect = normalize_dialect(request.target_dialect, "target dialect")
|
|
614
|
+
result = await _run_cpu_bound(
|
|
615
|
+
diff_sql_ast,
|
|
616
|
+
request.source_sql,
|
|
617
|
+
request.target_sql,
|
|
618
|
+
source_dialect,
|
|
619
|
+
target_dialect,
|
|
620
|
+
)
|
|
621
|
+
payload = asdict(result)
|
|
622
|
+
payload["success"] = True
|
|
623
|
+
if result.parse_error:
|
|
624
|
+
payload["error"] = result.parse_error
|
|
625
|
+
return payload
|
|
626
|
+
|
|
501
627
|
@app.get("/api/functions", tags=["functions"])
|
|
502
628
|
async def list_functions(
|
|
503
629
|
search: Optional[str] = Query(None, description="Search term for function name"),
|
|
@@ -523,7 +649,10 @@ async def list_functions(
|
|
|
523
649
|
+ ", ".join(func_encyclopedia.CATEGORIES)),
|
|
524
650
|
)
|
|
525
651
|
if search:
|
|
526
|
-
|
|
652
|
+
# The search cache applies the category predicate before ranking and
|
|
653
|
+
# the public limit, so category-filtered queries do not scan or copy
|
|
654
|
+
# unrelated function records.
|
|
655
|
+
functions = func_encyclopedia.search(search, limit, normalized_category)
|
|
527
656
|
elif category:
|
|
528
657
|
functions = func_encyclopedia.list_by_category(normalized_category)[:limit]
|
|
529
658
|
else:
|
|
@@ -594,9 +723,13 @@ async def list_types(
|
|
|
594
723
|
if target is not None:
|
|
595
724
|
target = normalize_dialect(target, "target dialect")
|
|
596
725
|
matrix = type_mapper.get_matrix(source, target)
|
|
726
|
+
mappings = [
|
|
727
|
+
{"type": type_name, **mapping}
|
|
728
|
+
for type_name, mapping in matrix.items()
|
|
729
|
+
] if isinstance(matrix, dict) else list(matrix)
|
|
597
730
|
return {
|
|
598
731
|
"success": True,
|
|
599
|
-
"mappings":
|
|
732
|
+
"mappings": mappings,
|
|
600
733
|
"dialects": type_mapper.DIALECTS,
|
|
601
734
|
"type_count": len(matrix),
|
|
602
735
|
"precision_warnings": type_mapper.get_precision_warnings()
|
|
@@ -747,13 +880,17 @@ async def get_stats():
|
|
|
747
880
|
# Custom exception handler for better error responses
|
|
748
881
|
@app.exception_handler(HTTPException)
|
|
749
882
|
async def custom_http_exception_handler(request: Request, exc: HTTPException):
|
|
883
|
+
error_code_by_status = {
|
|
884
|
+
503: ErrorCode.SERVICE_UNAVAILABLE.value,
|
|
885
|
+
504: ErrorCode.REQUEST_TIMEOUT.value,
|
|
886
|
+
}
|
|
750
887
|
return JSONResponse(
|
|
751
888
|
status_code=exc.status_code,
|
|
752
889
|
headers=exc.headers,
|
|
753
890
|
content={
|
|
754
891
|
"success": False,
|
|
755
892
|
"error": {
|
|
756
|
-
"code": exc.status_code,
|
|
893
|
+
"code": error_code_by_status.get(exc.status_code, exc.status_code),
|
|
757
894
|
"message": exc.detail,
|
|
758
895
|
"type": "HTTPException"
|
|
759
896
|
},
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
Provides rate limiting, request-size protection, logging, and security headers.
|
|
5
5
|
"""
|
|
6
|
+
import gzip
|
|
6
7
|
import json
|
|
7
8
|
import logging
|
|
8
9
|
import time
|
|
@@ -108,13 +109,22 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|
|
108
109
|
request.url.path in {"/health", "/", "/ready", "/health/deep"}
|
|
109
110
|
and request.method in {"GET", "HEAD"}
|
|
110
111
|
)
|
|
112
|
+
# Browser CORS preflight requests carry no business payload and are
|
|
113
|
+
# generated automatically before the actual API call. Counting them
|
|
114
|
+
# against the application rate limit can make an otherwise healthy
|
|
115
|
+
# client fail before it reaches the endpoint, especially when each
|
|
116
|
+
# request uses a non-simple X-Request-ID or X-API-Key header.
|
|
117
|
+
is_cors_preflight = request.method == "OPTIONS" and bool(
|
|
118
|
+
request.headers.get("origin")
|
|
119
|
+
and request.headers.get("access-control-request-method")
|
|
120
|
+
)
|
|
111
121
|
|
|
112
122
|
# Apply rate limiting before JSON/body parsing. This prevents a
|
|
113
123
|
# client from spending unbounded CPU on invalid payloads while still
|
|
114
124
|
# keeping the request-size guard active when rate limiting is disabled.
|
|
115
125
|
rate_limit_checked = False
|
|
116
126
|
remaining = reset_time = None
|
|
117
|
-
if self.enabled and not is_health_probe:
|
|
127
|
+
if self.enabled and not is_health_probe and not is_cors_preflight:
|
|
118
128
|
client_id = self._get_client_id(request)
|
|
119
129
|
# Redis checks use a synchronous client; a stalled store must not
|
|
120
130
|
# block the event loop used by unrelated liveness/API requests.
|
|
@@ -173,11 +183,18 @@ class StructuredLoggingMiddleware(BaseHTTPMiddleware):
|
|
|
173
183
|
|
|
174
184
|
@staticmethod
|
|
175
185
|
def _get_request_id(request: Request) -> str:
|
|
176
|
-
"""Reuse a valid
|
|
186
|
+
"""Reuse a valid request ID; otherwise issue a fresh UUID.
|
|
187
|
+
|
|
188
|
+
The React client prefixes UUIDs with ``web-`` to identify browser
|
|
189
|
+
traffic. Keep accepting the prefix while still validating the UUID
|
|
190
|
+
payload so arbitrary header values cannot be copied into logs or
|
|
191
|
+
response headers.
|
|
192
|
+
"""
|
|
177
193
|
candidate = request.headers.get("X-Request-ID")
|
|
178
194
|
if candidate:
|
|
195
|
+
uuid_candidate = candidate.removeprefix("web-")
|
|
179
196
|
try:
|
|
180
|
-
parsed = uuid.UUID(
|
|
197
|
+
parsed = uuid.UUID(uuid_candidate)
|
|
181
198
|
if parsed.variant == uuid.RFC_4122:
|
|
182
199
|
return candidate
|
|
183
200
|
except (ValueError, AttributeError, TypeError):
|
|
@@ -203,6 +220,13 @@ class StructuredLoggingMiddleware(BaseHTTPMiddleware):
|
|
|
203
220
|
if "json" not in response.headers.get("content-type", "").lower():
|
|
204
221
|
return response
|
|
205
222
|
|
|
223
|
+
# BaseHTTPMiddleware often returns a streaming response even when the
|
|
224
|
+
# endpoint produced JSON. Buffer that stream exactly once for
|
|
225
|
+
# normalization, but replay the original bytes when the payload is
|
|
226
|
+
# malformed so an error response is never silently emptied.
|
|
227
|
+
encoded_with_gzip = "gzip" in response.headers.get("content-encoding", "").lower()
|
|
228
|
+
streamed_body = not hasattr(response, "body")
|
|
229
|
+
raw_body = b""
|
|
206
230
|
try:
|
|
207
231
|
if hasattr(response, "body"):
|
|
208
232
|
raw_body = response.body
|
|
@@ -211,9 +235,44 @@ class StructuredLoggingMiddleware(BaseHTTPMiddleware):
|
|
|
211
235
|
async for chunk in response.body_iterator:
|
|
212
236
|
chunks.append(chunk)
|
|
213
237
|
raw_body = b"".join(chunks)
|
|
214
|
-
|
|
215
|
-
|
|
238
|
+
# GZipMiddleware is inside this middleware. Error responses over
|
|
239
|
+
# its threshold therefore arrive compressed; decode them before
|
|
240
|
+
# applying the stable JSON envelope so large and small errors have
|
|
241
|
+
# identical browser-visible contracts.
|
|
242
|
+
if encoded_with_gzip:
|
|
243
|
+
raw_body = gzip.decompress(raw_body)
|
|
244
|
+
except (AttributeError, TypeError):
|
|
216
245
|
return response
|
|
246
|
+
except (OSError, EOFError):
|
|
247
|
+
# A malformed compressed stream must remain observable to the
|
|
248
|
+
# caller. If it was buffered from a streaming response, replay
|
|
249
|
+
# the original bytes instead of returning an already-consumed
|
|
250
|
+
# iterator.
|
|
251
|
+
if not streamed_body:
|
|
252
|
+
return response
|
|
253
|
+
headers = dict(response.headers)
|
|
254
|
+
headers.pop("content-length", None)
|
|
255
|
+
return Response(
|
|
256
|
+
content=raw_body,
|
|
257
|
+
status_code=response.status_code,
|
|
258
|
+
headers=headers,
|
|
259
|
+
background=response.background,
|
|
260
|
+
)
|
|
261
|
+
try:
|
|
262
|
+
payload = json.loads(raw_body.decode("utf-8"))
|
|
263
|
+
except (AttributeError, TypeError, UnicodeDecodeError, json.JSONDecodeError, OSError):
|
|
264
|
+
if hasattr(response, "body"):
|
|
265
|
+
return response
|
|
266
|
+
headers = dict(response.headers)
|
|
267
|
+
headers.pop("content-length", None)
|
|
268
|
+
if encoded_with_gzip:
|
|
269
|
+
headers.pop("content-encoding", None)
|
|
270
|
+
return Response(
|
|
271
|
+
content=raw_body,
|
|
272
|
+
status_code=response.status_code,
|
|
273
|
+
headers=headers,
|
|
274
|
+
background=response.background,
|
|
275
|
+
)
|
|
217
276
|
|
|
218
277
|
if response.status_code == 503 and isinstance(payload, dict) and "checks" in payload:
|
|
219
278
|
# Readiness has a public diagnostics schema of its own. Preserve
|
|
@@ -222,6 +281,7 @@ class StructuredLoggingMiddleware(BaseHTTPMiddleware):
|
|
|
222
281
|
headers = dict(response.headers)
|
|
223
282
|
headers.pop("content-length", None)
|
|
224
283
|
headers.pop("content-type", None)
|
|
284
|
+
headers.pop("content-encoding", None)
|
|
225
285
|
payload.setdefault("request_id", request_id)
|
|
226
286
|
return JSONResponse(status_code=503, content=payload, headers=headers)
|
|
227
287
|
|
|
@@ -274,10 +334,14 @@ class StructuredLoggingMiddleware(BaseHTTPMiddleware):
|
|
|
274
334
|
headers = dict(response.headers)
|
|
275
335
|
headers.pop("content-length", None)
|
|
276
336
|
headers.pop("content-type", None)
|
|
337
|
+
headers.pop("content-encoding", None)
|
|
277
338
|
return JSONResponse(status_code=response.status_code, content=normalized, headers=headers)
|
|
278
339
|
|
|
279
340
|
async def dispatch(self, request: Request, call_next) -> Response:
|
|
280
|
-
|
|
341
|
+
# Request duration is an elapsed interval, so use a monotonic clock.
|
|
342
|
+
# ``time.time()`` may move backwards/forwards during a wall-clock
|
|
343
|
+
# correction and corrupt both logs and latency metrics.
|
|
344
|
+
start_time = time.perf_counter()
|
|
281
345
|
request_id = self._get_request_id(request)
|
|
282
346
|
request.state.request_id = request_id
|
|
283
347
|
status_code = 500
|
|
@@ -292,7 +356,7 @@ class StructuredLoggingMiddleware(BaseHTTPMiddleware):
|
|
|
292
356
|
error = _sanitize_log_value(str(exc))
|
|
293
357
|
raise
|
|
294
358
|
finally:
|
|
295
|
-
process_time = time.
|
|
359
|
+
process_time = time.perf_counter() - start_time
|
|
296
360
|
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()}
|
|
297
361
|
(logger.warning if status_code >= 400 else logger.info)(json.dumps(log_data))
|
|
298
362
|
response.headers["X-Request-ID"] = request_id
|