From 8c0e892fc587b0f74c79c4939d7c7c48cc3988d3 Mon Sep 17 00:00:00 2001 From: "LORENZO\\pacio" Date: Fri, 26 Sep 2025 15:29:31 +0200 Subject: [PATCH] init --- Dockerfile | 27 ++++++++++++++++ app/db.py | 78 ++++++++++++++++++++++++++++++++++++++++++++++ app/main.py | 36 +++++++++++++++++++++ app/schemas.py | 25 +++++++++++++++ docker-compose.yml | 7 +++++ requirements.txt | 4 +++ 6 files changed, 177 insertions(+) create mode 100644 Dockerfile create mode 100644 app/db.py create mode 100644 app/main.py create mode 100644 app/schemas.py create mode 100644 docker-compose.yml create mode 100644 requirements.txt diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3dd2667 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..cef36d1 --- /dev/null +++ b/app/db.py @@ -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} diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..f9d0ecc --- /dev/null +++ b/app/main.py @@ -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)) \ No newline at end of file diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..dc93cf7 --- /dev/null +++ b/app/schemas.py @@ -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) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..15c382b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,7 @@ +services: + mssql-odbc-service: + build: . + container_name: mssql-odbc-service + ports: + - "38500:8000" + restart: unless-stopped diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..deb1378 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.115.2 +uvicorn[standard]==0.30.6 +SQLAlchemy==2.0.35 +pyodbc==5.2.0