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-15 01:00:30 +02:00
|
|
|
# retoor <retoor@molodetz.nl>
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
import logging
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect
|
|
|
|
|
from fastapi.responses import JSONResponse
|
|
|
|
|
|
|
|
|
|
from devplacepy.services.manager import service_manager
|
|
|
|
|
from devplacepy.services.pubsub import policy
|
|
|
|
|
from devplacepy.services.pubsub.hub import pubsub
|
|
|
|
|
|
2026-06-19 10:06:09 +02:00
|
|
|
from devplacepy.routers.dbapi._shared import error, read_body
|
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-15 01:00:30 +02:00
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
|
|
|
|
|
2026-06-19 10:06:09 +02:00
|
|
|
def _require_privileged(request):
|
|
|
|
|
actor = policy.resolve_actor(request)
|
|
|
|
|
if not actor.privileged:
|
|
|
|
|
return None
|
|
|
|
|
return actor
|
|
|
|
|
|
|
|
|
|
|
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-15 01:00:30 +02:00
|
|
|
def _now() -> str:
|
|
|
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _message(topic: str, data) -> dict:
|
|
|
|
|
return {"type": "message", "topic": topic, "data": data, "ts": _now()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.websocket("/ws")
|
|
|
|
|
async def pubsub_ws(websocket: WebSocket):
|
|
|
|
|
await websocket.accept()
|
|
|
|
|
svc = service_manager.get_service("pubsub")
|
|
|
|
|
if svc is None or not svc.is_enabled():
|
|
|
|
|
await websocket.close(code=1013)
|
|
|
|
|
return
|
|
|
|
|
if not service_manager.owns_lock():
|
|
|
|
|
await websocket.close(code=4013)
|
|
|
|
|
return
|
|
|
|
|
actor = policy.resolve_actor(websocket)
|
|
|
|
|
if actor.kind == "guest" and not policy.guests_enabled():
|
|
|
|
|
await websocket.close(code=1008)
|
|
|
|
|
return
|
|
|
|
|
await websocket.send_json({"type": "ready", "actor": actor.kind})
|
|
|
|
|
try:
|
|
|
|
|
while True:
|
|
|
|
|
data = await websocket.receive_json()
|
|
|
|
|
kind = data.get("type")
|
|
|
|
|
topic = str(data.get("topic", "")).strip()
|
|
|
|
|
if kind in ("subscribe", "unsubscribe", "publish") and not policy.valid_topic(
|
|
|
|
|
topic
|
|
|
|
|
):
|
|
|
|
|
await websocket.send_json({"type": "error", "message": "Invalid topic name."})
|
|
|
|
|
continue
|
|
|
|
|
if kind == "subscribe":
|
|
|
|
|
if policy.can_subscribe(actor, topic):
|
|
|
|
|
pubsub.subscribe(topic, websocket)
|
|
|
|
|
await websocket.send_json({"type": "subscribed", "topic": topic})
|
|
|
|
|
else:
|
|
|
|
|
await websocket.send_json(
|
|
|
|
|
{"type": "error", "message": f"Not allowed to subscribe to {topic}."}
|
|
|
|
|
)
|
|
|
|
|
elif kind == "unsubscribe":
|
|
|
|
|
pubsub.unsubscribe(topic, websocket)
|
|
|
|
|
await websocket.send_json({"type": "unsubscribed", "topic": topic})
|
|
|
|
|
elif kind == "publish":
|
|
|
|
|
if not policy.can_publish(actor, topic):
|
|
|
|
|
await websocket.send_json(
|
|
|
|
|
{"type": "error", "message": f"Not allowed to publish to {topic}."}
|
|
|
|
|
)
|
|
|
|
|
continue
|
|
|
|
|
payload = data.get("data")
|
|
|
|
|
if len(json.dumps(payload, default=str)) > policy.MAX_PAYLOAD_BYTES:
|
|
|
|
|
await websocket.send_json({"type": "error", "message": "Payload too large."})
|
|
|
|
|
continue
|
|
|
|
|
delivered = await pubsub.publish(topic, _message(topic, payload))
|
|
|
|
|
await websocket.send_json(
|
|
|
|
|
{"type": "ack", "topic": topic, "delivered": delivered}
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
await websocket.send_json({"type": "error", "message": "Unknown frame type."})
|
|
|
|
|
except WebSocketDisconnect:
|
|
|
|
|
pass
|
|
|
|
|
except Exception:
|
|
|
|
|
logger.exception("pubsub websocket loop failed")
|
|
|
|
|
finally:
|
|
|
|
|
pubsub.drop_socket(websocket)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/publish")
|
|
|
|
|
async def pubsub_http_publish(request: Request):
|
2026-06-19 10:06:09 +02:00
|
|
|
caller = _require_privileged(request)
|
|
|
|
|
if caller is None:
|
|
|
|
|
return error(403, "Admin or internal access required.")
|
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-15 01:00:30 +02:00
|
|
|
if not service_manager.owns_lock():
|
|
|
|
|
return error(409, "Pub/sub is served by the lock-owner worker. Retry.")
|
|
|
|
|
body = await read_body(request)
|
|
|
|
|
topic = str(body.get("topic", "")).strip()
|
|
|
|
|
if not policy.valid_topic(topic):
|
|
|
|
|
return error(400, "Invalid topic name.")
|
|
|
|
|
payload = body.get("data")
|
|
|
|
|
delivered = await pubsub.publish(topic, _message(topic, payload))
|
|
|
|
|
from devplacepy.services.audit import record as audit
|
|
|
|
|
|
|
|
|
|
audit.record(
|
|
|
|
|
request,
|
|
|
|
|
"pubsub.publish",
|
|
|
|
|
target_type="topic",
|
|
|
|
|
target_uid=topic,
|
|
|
|
|
summary=f"{caller.username or caller.kind} published to {topic}",
|
|
|
|
|
metadata={"topic": topic, "delivered": delivered, "caller": caller.kind},
|
|
|
|
|
)
|
|
|
|
|
return JSONResponse({"topic": topic, "delivered": delivered})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/topics")
|
|
|
|
|
async def pubsub_list_topics(request: Request):
|
2026-06-19 10:06:09 +02:00
|
|
|
if _require_privileged(request) is None:
|
|
|
|
|
return error(403, "Admin or internal access required.")
|
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-15 01:00:30 +02:00
|
|
|
return JSONResponse({"topics": pubsub.topics()})
|