Files
devplacepy/devplacepy/routers/dbapi/crud.py
T
retoor d31ccba6c1 feat: add admin/internal database API with CRUD, read-only query, and natural-language SQL endpoints
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.
2026-06-14 23:00:30 +00:00

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 {})},
)