|
import os |
|
import duckdb |
|
from fastapi import FastAPI, HTTPException, Body |
|
from fastapi.responses import FileResponse, JSONResponse |
|
from pydantic import BaseModel, Field |
|
from pathlib import Path |
|
import logging |
|
import time |
|
import asyncio |
|
|
|
|
|
DB_DIR = Path("data") |
|
DB_FILENAME = "mydatabase.db" |
|
DB_FILE = DB_DIR / DB_FILENAME |
|
UI_EXPECTED_PORT = 8080 |
|
|
|
|
|
DB_DIR.mkdir(parents=True, exist_ok=True) |
|
|
|
|
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') |
|
logger = logging.getLogger(__name__) |
|
|
|
|
|
app = FastAPI( |
|
title="DuckDB API & UI Host", |
|
description="Interact with DuckDB via API (/query, /download) and access the official DuckDB Web UI.", |
|
version="1.0.0" |
|
) |
|
|
|
|
|
class QueryRequest(BaseModel): |
|
sql: str = Field(..., description="The SQL query to execute against DuckDB.") |
|
|
|
class QueryResponse(BaseModel): |
|
columns: list[str] | None = None |
|
rows: list[dict] | None = None |
|
message: str | None = None |
|
error: str | None = None |
|
|
|
|
|
def execute_duckdb_query(sql_query: str, db_path: str = str(DB_FILE)): |
|
"""Connects to DuckDB, executes a query, and returns results or error.""" |
|
con = None |
|
try: |
|
logger.info(f"Connecting to database: {db_path}") |
|
con = duckdb.connect(database=db_path, read_only=False) |
|
logger.info(f"Executing SQL: {sql_query[:200]}{'...' if len(sql_query) > 200 else ''}") |
|
|
|
con.begin() |
|
result_relation = con.execute(sql_query) |
|
response_data = {"columns": None, "rows": None, "message": None, "error": None} |
|
|
|
if result_relation.description: |
|
columns = [desc[0] for desc in result_relation.description] |
|
rows_raw = result_relation.fetchall() |
|
rows_dict = [dict(zip(columns, row)) for row in rows_raw] |
|
response_data["columns"] = columns |
|
response_data["rows"] = rows_dict |
|
response_data["message"] = f"Query executed successfully. Fetched {len(rows_dict)} row(s)." |
|
logger.info(f"Query successful, returned {len(rows_dict)} rows.") |
|
else: |
|
response_data["message"] = "Query executed successfully (no data returned)." |
|
logger.info("Query successful (no data returned).") |
|
|
|
con.commit() |
|
return response_data |
|
|
|
except duckdb.Error as e: |
|
logger.error(f"DuckDB Error: {e}") |
|
if con: con.rollback() |
|
return {"columns": None, "rows": None, "message": None, "error": str(e)} |
|
except Exception as e: |
|
logger.error(f"General Error: {e}") |
|
if con: con.rollback() |
|
return {"columns": None, "rows": None, "message": None, "error": f"An unexpected error occurred: {e}"} |
|
finally: |
|
if con: |
|
con.close() |
|
logger.info("Database connection closed.") |
|
|
|
|
|
|
|
@app.on_event("startup") |
|
async def startup_event(): |
|
logger.info("Application startup: Initializing DuckDB UI...") |
|
con = None |
|
try: |
|
|
|
|
|
|
|
if not DB_FILE.parent.exists(): |
|
DB_FILE.parent.mkdir(parents=True, exist_ok=True) |
|
|
|
|
|
|
|
|
|
logger.info(f"Attempting to connect to {DB_FILE} for UI setup.") |
|
con = duckdb.connect(database=str(DB_FILE), read_only=False) |
|
|
|
logger.info("Installing and loading 'ui' extension...") |
|
con.execute("INSTALL ui;") |
|
con.execute("LOAD ui;") |
|
|
|
logger.info("Calling start_ui()... This will start a separate web server.") |
|
|
|
|
|
con.execute("CALL start_ui();") |
|
|
|
|
|
|
|
await asyncio.sleep(2) |
|
|
|
logger.info(f"DuckDB UI server startup initiated. It usually listens on port {UI_EXPECTED_PORT}.") |
|
logger.info("Check container logs for the exact URL if it differs.") |
|
logger.info("API server (FastAPI/Uvicorn) is running on port 8000.") |
|
|
|
except duckdb.Error as e: |
|
logger.error(f"CRITICAL: Failed to install/load/start DuckDB UI extension: {e}") |
|
logger.error("The DuckDB UI will likely not be available.") |
|
except Exception as e: |
|
logger.error(f"CRITICAL: An unexpected error occurred during UI startup: {e}") |
|
logger.error("The DuckDB UI will likely not be available.") |
|
finally: |
|
if con: |
|
con.close() |
|
logger.info("UI setup connection closed.") |
|
|
|
|
|
@app.get("/", summary="Root Endpoint / Info", tags=["General"]) |
|
async def read_root(): |
|
"""Provides links to the API docs and the DuckDB UI.""" |
|
|
|
|
|
return JSONResponse({ |
|
"message": "DuckDB API and UI Host", |
|
"api_details": { |
|
"docs": "/docs", |
|
"query_endpoint": "/query (POST)", |
|
"download_endpoint": "/download (GET)" |
|
}, |
|
"duckdb_ui": { |
|
"message": f"Access the official DuckDB Web UI. It should be running on port {UI_EXPECTED_PORT} inside the container.", |
|
"typical_access_url": f"http://localhost:{UI_EXPECTED_PORT}", |
|
"notes": f"Ensure you have mapped port {UI_EXPECTED_PORT} from the container when running `docker run` (e.g., -p {UI_EXPECTED_PORT}:{UI_EXPECTED_PORT})." |
|
}, |
|
"database_file_container_path": str(DB_FILE) |
|
}) |
|
|
|
@app.post("/query", response_model=QueryResponse, summary="Execute SQL Query", tags=["Database API"]) |
|
async def execute_query_endpoint(query_request: QueryRequest): |
|
""" |
|
Executes a given SQL query against the DuckDB database via the API. |
|
Handles SELECT, INSERT, UPDATE, DELETE, CREATE TABLE, etc. |
|
""" |
|
result = execute_duckdb_query(query_request.sql) |
|
if result["error"]: |
|
raise HTTPException(status_code=400, detail=result["error"]) |
|
return JSONResponse(content=result) |
|
|
|
|
|
@app.get("/download", summary="Download Database File", tags=["Database API"]) |
|
async def download_database_file(): |
|
""" |
|
Allows downloading the current DuckDB database file via the API. |
|
""" |
|
if not DB_FILE.is_file(): |
|
logger.error(f"Download request failed: Database file not found at {DB_FILE}") |
|
raise HTTPException(status_code=404, detail="Database file not found.") |
|
|
|
logger.info(f"Serving database file for download: {DB_FILE}") |
|
return FileResponse( |
|
path=str(DB_FILE), |
|
filename=DB_FILENAME, |
|
media_type='application/octet-stream' |
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|