init
This commit is contained in:
parent
6e34ffe2fe
commit
8c0e892fc5
27
Dockerfile
Normal file
27
Dockerfile
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
FROM python:3.12-slim-bookworm
|
||||||
|
|
||||||
|
# Base deps
|
||||||
|
RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||||
|
curl gnupg ca-certificates \
|
||||||
|
unixodbc unixodbc-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# MS ODBC Driver 17 for SQL Server
|
||||||
|
RUN mkdir -p /etc/apt/keyrings \
|
||||||
|
&& curl -fsSL https://packages.microsoft.com/keys/microsoft.asc \
|
||||||
|
| gpg --dearmor -o /etc/apt/keyrings/microsoft-prod.gpg \
|
||||||
|
&& echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/microsoft-prod.gpg] https://packages.microsoft.com/debian/12/prod bookworm main" \
|
||||||
|
> /etc/apt/sources.list.d/microsoft-prod.list \
|
||||||
|
&& apt-get update \
|
||||||
|
&& ACCEPT_EULA=Y apt-get install -y msodbcsql17 unixodbc unixodbc-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY app ./app
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
78
app/db.py
Normal file
78
app/db.py
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
from sqlalchemy.engine import Engine
|
||||||
|
from urllib.parse import quote_plus
|
||||||
|
|
||||||
|
def build_odbc_url(
|
||||||
|
server: str,
|
||||||
|
database: str,
|
||||||
|
username: str | None,
|
||||||
|
password: str | None,
|
||||||
|
encrypt: bool = False,
|
||||||
|
trust_server_certificate: bool = True,
|
||||||
|
timeout: int = 15,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Build SQLAlchemy ODBC URL for SQL Server using ODBC Driver 18.
|
||||||
|
Default Encrypt=no to support servers with encryption disabled.
|
||||||
|
If encrypt=True, we set Encrypt=yes and TrustServerCertificate accordingly.
|
||||||
|
"""
|
||||||
|
driver = "ODBC Driver 17 for SQL Server"
|
||||||
|
|
||||||
|
odbc_params = {
|
||||||
|
"DRIVER": driver,
|
||||||
|
"SERVER": server,
|
||||||
|
"DATABASE": database,
|
||||||
|
"Connection Timeout": str(timeout),
|
||||||
|
}
|
||||||
|
|
||||||
|
if encrypt:
|
||||||
|
odbc_params["Encrypt"] = "yes"
|
||||||
|
odbc_params["TrustServerCertificate"] = "yes" if trust_server_certificate else "no"
|
||||||
|
else:
|
||||||
|
# critical for servers with encryption disabled
|
||||||
|
odbc_params["Encrypt"] = "no"
|
||||||
|
|
||||||
|
if username and password:
|
||||||
|
odbc_params["UID"] = username
|
||||||
|
odbc_params["PWD"] = password
|
||||||
|
else:
|
||||||
|
# Trusted_Connection can work on Windows/domain; usually not in Linux containers.
|
||||||
|
odbc_params["Trusted_Connection"] = "yes"
|
||||||
|
|
||||||
|
conn_str = ";".join([f"{k}={v}" for k, v in odbc_params.items()])
|
||||||
|
return f"mssql+pyodbc:///?odbc_connect={quote_plus(conn_str)}"
|
||||||
|
|
||||||
|
def get_engine(payload) -> Engine:
|
||||||
|
url = build_odbc_url(
|
||||||
|
server=payload.server,
|
||||||
|
database=payload.database,
|
||||||
|
username=getattr(payload, "username", None),
|
||||||
|
password=getattr(payload, "password", None),
|
||||||
|
encrypt=getattr(payload, "encrypt", False),
|
||||||
|
trust_server_certificate=getattr(payload, "trust_server_certificate", True),
|
||||||
|
timeout=getattr(payload, "timeout", 15),
|
||||||
|
)
|
||||||
|
return create_engine(
|
||||||
|
url,
|
||||||
|
pool_size=5,
|
||||||
|
max_overflow=10,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
fast_executemany=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def run_query(engine: Engine, sql: str, params: dict, max_rows: int | None = None):
|
||||||
|
with engine.connect() as conn:
|
||||||
|
result = conn.execute(text(sql), params)
|
||||||
|
if result.returns_rows:
|
||||||
|
rows = result.fetchmany(max_rows) if max_rows else result.fetchall()
|
||||||
|
cols = result.keys()
|
||||||
|
data = [dict(zip(cols, row)) for row in rows]
|
||||||
|
return {"columns": list(cols), "rows": data}
|
||||||
|
return {"columns": [], "rows": []}
|
||||||
|
|
||||||
|
async def run_execute(engine: Engine, sql: str, params: dict, autocommit: bool):
|
||||||
|
with (engine.begin() if not autocommit else engine.connect()) as conn:
|
||||||
|
result = conn.execute(text(sql), params)
|
||||||
|
if autocommit:
|
||||||
|
conn.commit()
|
||||||
|
return {"rowcount": result.rowcount}
|
||||||
36
app/main.py
Normal file
36
app/main.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from fastapi.encoders import jsonable_encoder
|
||||||
|
from app.schemas import QueryPayload, ExecutePayload
|
||||||
|
from app.db import get_engine, run_query, run_execute
|
||||||
|
|
||||||
|
app = FastAPI(title="MSSQL ODBC Service", version="1.1.0")
|
||||||
|
|
||||||
|
def _trim_strings(obj):
|
||||||
|
if isinstance(obj, str):
|
||||||
|
return obj.strip()
|
||||||
|
if isinstance(obj, list):
|
||||||
|
return [_trim_strings(x) for x in obj]
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return {k: _trim_strings(v) for k, v in obj.items()}
|
||||||
|
return obj
|
||||||
|
|
||||||
|
@app.post("/query")
|
||||||
|
async def query(payload: QueryPayload):
|
||||||
|
try:
|
||||||
|
engine = get_engine(payload)
|
||||||
|
result = await run_query(engine, payload.sql, payload.params, payload.max_rows)
|
||||||
|
result = _trim_strings(result)
|
||||||
|
return JSONResponse(content=jsonable_encoder(result))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
@app.post("/execute")
|
||||||
|
async def execute(payload: ExecutePayload):
|
||||||
|
try:
|
||||||
|
engine = get_engine(payload)
|
||||||
|
result = await run_execute(engine, payload.sql, payload.params, payload.autocommit)
|
||||||
|
result = _trim_strings(result)
|
||||||
|
return JSONResponse(content=jsonable_encoder(result))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
25
app/schemas.py
Normal file
25
app/schemas.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
from typing import Any, Dict, Optional
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
class ConnectionPayload(BaseModel):
|
||||||
|
server: str = Field(..., example="mssql.example.local,1433")
|
||||||
|
database: str = Field(..., example="MyDb")
|
||||||
|
username: Optional[str] = Field(None, example="sa")
|
||||||
|
password: Optional[str] = Field(None, example="S3cret!")
|
||||||
|
# For servers with encryption disabled, set encrypt=False (default below)
|
||||||
|
encrypt: bool = Field(default=False, description="ODBC Encrypt setting: yes/no")
|
||||||
|
trust_server_certificate: bool = Field(
|
||||||
|
default=True,
|
||||||
|
description="If encrypt=True and you don’t have CA chain, set True"
|
||||||
|
)
|
||||||
|
timeout: int = Field(default=15, ge=1, le=120)
|
||||||
|
|
||||||
|
class QueryPayload(ConnectionPayload):
|
||||||
|
sql: str = Field(..., example="SELECT TOP 5 id, name FROM dbo.Customers WHERE city = :city")
|
||||||
|
params: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
max_rows: Optional[int] = Field(None, ge=1, example=1000)
|
||||||
|
|
||||||
|
class ExecutePayload(ConnectionPayload):
|
||||||
|
sql: str = Field(..., example="UPDATE dbo.Customers SET name = :name WHERE id = :id")
|
||||||
|
params: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
autocommit: bool = Field(default=False)
|
||||||
7
docker-compose.yml
Normal file
7
docker-compose.yml
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
services:
|
||||||
|
mssql-odbc-service:
|
||||||
|
build: .
|
||||||
|
container_name: mssql-odbc-service
|
||||||
|
ports:
|
||||||
|
- "38500:8000"
|
||||||
|
restart: unless-stopped
|
||||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
fastapi==0.115.2
|
||||||
|
uvicorn[standard]==0.30.6
|
||||||
|
SQLAlchemy==2.0.35
|
||||||
|
pyodbc==5.2.0
|
||||||
Loading…
Reference in New Issue
Block a user