Add a new `/dbapi` router package providing a generic database API over `dataset`, restricted to admin sessions, admin API keys, and the internal gateway key. Includes: - `tables.py`: list all tables and inspect table schemas - `crud.py`: full CRUD operations (GET, POST, PATCH, DELETE) with soft-delete awareness, born-live inserts, `?include_deleted`, `.../restore`, and `?hard=true` purge - `query.py`: validated read-only SELECT execution via sqlglot parsing, classification, and EXPLAIN dry-run; async query jobs with WebSocket streaming via `DbApiJobService` - `nl.py`: natural-language-to-SQL conversion using the platform AI gateway with re-prompting until validation passes Also register `DbApiJobService` and `PubSubService` in the service manager, add `DBAPI_DIR` to config data paths, and force cleartext `http://` connections to HTTP/1.1 in `curl_transport` to fix large request failures against uvicorn's HTTP/1.1-only internal gateway.
153 lines
4.5 KiB
Python
153 lines
4.5 KiB
Python
# retoor <retoor@molodetz.nl>
|
|
|
|
from fastapi import APIRouter, Request
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from devplacepy.schemas import DbMutationOut, DbRowOut, DbRowsOut
|
|
from devplacepy.services.dbapi import crud
|
|
from devplacepy.services.dbapi.crud import DbApiError
|
|
|
|
from ._shared import (
|
|
assert_table,
|
|
error,
|
|
parse_filters,
|
|
read_body,
|
|
require_dbapi_caller,
|
|
row_payload,
|
|
)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def _target_uid(row: dict, value: str) -> str:
|
|
return (row or {}).get("uid") or str(value)
|
|
|
|
|
|
@router.get("/{table}")
|
|
async def dbapi_list(
|
|
request: Request,
|
|
table: str,
|
|
limit: int = 25,
|
|
before: str = None,
|
|
search: str = "",
|
|
include_deleted: bool = False,
|
|
):
|
|
require_dbapi_caller(request)
|
|
assert_table(table)
|
|
filters, comparisons = parse_filters(request)
|
|
rows, next_cursor = crud.list_rows(
|
|
table,
|
|
filters=filters,
|
|
comparisons=comparisons,
|
|
search=search,
|
|
before=before,
|
|
limit=limit,
|
|
include_deleted=include_deleted,
|
|
)
|
|
return JSONResponse(
|
|
DbRowsOut(
|
|
table=table, rows=rows, count=len(rows), next_cursor=next_cursor
|
|
).model_dump(mode="json")
|
|
)
|
|
|
|
|
|
@router.get("/{table}/{key}/{value}")
|
|
async def dbapi_get(request: Request, table: str, key: str, value: str):
|
|
require_dbapi_caller(request)
|
|
assert_table(table)
|
|
try:
|
|
row = crud.get_row(table, key, value)
|
|
except DbApiError as exc:
|
|
return error(400, str(exc))
|
|
if row is None:
|
|
return error(404, "Row not found")
|
|
return JSONResponse(DbRowOut(table=table, row=row).model_dump(mode="json"))
|
|
|
|
|
|
@router.post("/{table}")
|
|
async def dbapi_insert(request: Request, table: str):
|
|
caller = require_dbapi_caller(request)
|
|
assert_table(table)
|
|
body = await read_body(request)
|
|
try:
|
|
row = crud.insert_row(table, row_payload(body), caller.uid)
|
|
except DbApiError as exc:
|
|
return error(400, str(exc))
|
|
_audit(request, "database.row.insert", table, _target_uid(row, ""), caller)
|
|
return JSONResponse(
|
|
DbMutationOut(table=table, ok=True, mode="insert", row=row).model_dump(mode="json")
|
|
)
|
|
|
|
|
|
@router.patch("/{table}/{key}/{value}")
|
|
async def dbapi_update(request: Request, table: str, key: str, value: str):
|
|
caller = require_dbapi_caller(request)
|
|
assert_table(table)
|
|
body = await read_body(request)
|
|
try:
|
|
row = crud.update_row(table, key, value, row_payload(body), caller.uid)
|
|
except DbApiError as exc:
|
|
return error(400, str(exc))
|
|
if row is None:
|
|
return error(404, "Row not found")
|
|
_audit(request, "database.row.update", table, _target_uid(row, value), caller)
|
|
return JSONResponse(
|
|
DbMutationOut(table=table, ok=True, mode="update", row=row).model_dump(mode="json")
|
|
)
|
|
|
|
|
|
@router.delete("/{table}/{key}/{value}")
|
|
async def dbapi_delete(
|
|
request: Request, table: str, key: str, value: str, hard: bool = False
|
|
):
|
|
caller = require_dbapi_caller(request)
|
|
assert_table(table)
|
|
try:
|
|
outcome = crud.delete_row(table, key, value, caller.uid, hard=hard)
|
|
except DbApiError as exc:
|
|
return error(400, str(exc))
|
|
if outcome is None:
|
|
return error(404, "Row not found")
|
|
_audit(
|
|
request,
|
|
"database.row.delete",
|
|
table,
|
|
_target_uid(outcome.get("row", {}), value),
|
|
caller,
|
|
metadata={"mode": outcome["mode"]},
|
|
)
|
|
return JSONResponse(
|
|
DbMutationOut(
|
|
table=table, ok=True, mode=outcome["mode"], row=outcome.get("row")
|
|
).model_dump(mode="json")
|
|
)
|
|
|
|
|
|
@router.post("/{table}/{key}/{value}/restore")
|
|
async def dbapi_restore(request: Request, table: str, key: str, value: str):
|
|
caller = require_dbapi_caller(request)
|
|
assert_table(table)
|
|
try:
|
|
row = crud.restore_row(table, key, value, caller.uid)
|
|
except DbApiError as exc:
|
|
return error(400, str(exc))
|
|
if row is None:
|
|
return error(404, "Row not found")
|
|
_audit(request, "database.row.restore", table, _target_uid(row, value), caller)
|
|
return JSONResponse(
|
|
DbMutationOut(table=table, ok=True, mode="restore", row=row).model_dump(mode="json")
|
|
)
|
|
|
|
|
|
def _audit(request, event_key, table, target_uid, caller, metadata=None):
|
|
from devplacepy.services.audit import record as audit
|
|
|
|
audit.record(
|
|
request,
|
|
event_key,
|
|
target_type=table,
|
|
target_uid=target_uid,
|
|
summary=f"{caller.username or caller.kind} {event_key} on {table}/{target_uid}",
|
|
metadata={"table": table, "caller": caller.kind, **(metadata or {})},
|
|
)
|