"""Generic OpenStack collection/item CRUD backed by os_api_objects.""" from __future__ import annotations import json import re from typing import Any from uuid import uuid4 from asyncpg import Connection from fastapi import APIRouter, Depends, Request, Response from fastapi.responses import JSONResponse from app.openstack.auth import TokenContext from app.openstack.deps import get_conn, require_project_token, require_token from app.openstack.errors import OpenStackError from app.openstack.surface import SERVICES, ServiceSpec _PATH_PARAM = re.compile(r"\{([^{}]+)\}") def _singular(collection_key: str) -> str: if collection_key.endswith("ies"): return collection_key[:-3] + "y" if collection_key.endswith("ses"): return collection_key[:-2] if collection_key.endswith("s") and not collection_key.endswith("ss"): return collection_key[:-1] return collection_key def _wrap_list(collection_key: str, items: list[dict[str, Any]]) -> dict[str, Any]: return {collection_key: items} def _wrap_item(collection_key: str, item: dict[str, Any]) -> dict[str, Any]: return {_singular(collection_key): item} def _row_to_item(row: Any) -> dict[str, Any]: data = row["data"] if isinstance(data, str): data = json.loads(data) item = dict(data or {}) item.setdefault("id", str(row["id"])) item.setdefault("name", row["name"]) item.setdefault("status", row["status"]) if row["project_id"] is not None: item.setdefault("project_id", str(row["project_id"])) item.setdefault("tenant_id", str(row["project_id"])) item.setdefault("created_at", row["created_at"].strftime("%Y-%m-%dT%H:%M:%SZ")) item.setdefault("updated_at", row["updated_at"].strftime("%Y-%m-%dT%H:%M:%SZ")) return item def _has_path_params(path: str) -> bool: return bool(_PATH_PARAM.search(path)) def build_generic_router(spec: ServiceSpec) -> APIRouter: router = APIRouter(tags=[spec.name.title()]) version_path = (spec.version_path or "").rstrip("/") if version_path and version_path != "/": service_name = spec.name async def version_discovery( conn: Connection = Depends(get_conn), ) -> dict[str, object]: from app.openstack.db_docs import require_doc return await require_doc( conn, service=service_name, resource_type="discovery_version", name="default" ) router.add_api_route( version_path, version_discovery, methods=["GET"], name=f"gen-{spec.name}-version", ) router.add_api_route( version_path + "/", version_discovery, methods=["GET"], name=f"gen-{spec.name}-version-slash", ) for resource_type, collection_path, collection_key in spec.resources: if collection_path in {"", "/"} or _has_path_params(collection_path): # Nested templates / bare roots need specialized routers. continue _register_collection( router, spec=spec, resource_type=resource_type, collection_path=collection_path, collection_key=collection_key, ) return router def _register_collection( router: APIRouter, *, spec: ServiceSpec, resource_type: str, collection_path: str, collection_key: str, ) -> None: item_path = f"{collection_path.rstrip('/')}/{{item_id}}" async def list_items( conn: Connection = Depends(get_conn), ctx: TokenContext = Depends(require_token), ) -> dict[str, Any]: if ctx.project_id is None: rows = await conn.fetch( """SELECT * FROM os_api_objects WHERE service = $1 AND resource_type = $2 ORDER BY created_at""", spec.name, resource_type, ) else: rows = await conn.fetch( """SELECT * FROM os_api_objects WHERE service = $1 AND resource_type = $2 AND (project_id = $3 OR project_id IS NULL) ORDER BY created_at""", spec.name, resource_type, ctx.project_id, ) return _wrap_list(collection_key, [_row_to_item(r) for r in rows]) async def create_item( request: Request, conn: Connection = Depends(get_conn), ctx: TokenContext = Depends(require_project_token), ) -> JSONResponse: payload = await request.json() body = payload.get(_singular(collection_key)) or payload.get(collection_key) or payload if not isinstance(body, dict): body = {"value": body} item_id = uuid4() name = str(body.get("name") or body.get("stack_name") or resource_type) status = str(body.get("status") or body.get("stack_status") or "ACTIVE") data = {**body, "id": str(item_id), "name": name, "status": status} row = await conn.fetchrow( """INSERT INTO os_api_objects(id, service, resource_type, project_id, name, status, data) VALUES($1, $2, $3, $4, $5, $6, $7::jsonb) RETURNING *""", item_id, spec.name, resource_type, ctx.project_id, name, status, json.dumps(data), ) return JSONResponse(status_code=201, content=_wrap_item(collection_key, _row_to_item(row))) async def show_item( item_id: str, conn: Connection = Depends(get_conn), ctx: TokenContext = Depends(require_token), ) -> dict[str, Any]: row = await conn.fetchrow( """SELECT * FROM os_api_objects WHERE service = $1 AND resource_type = $2 AND id::text = $3""", spec.name, resource_type, item_id, ) if row is None: # also allow name lookup row = await conn.fetchrow( """SELECT * FROM os_api_objects WHERE service = $1 AND resource_type = $2 AND name = $3 LIMIT 1""", spec.name, resource_type, item_id, ) if row is None: raise OpenStackError( "NotFound", f"{resource_type} {item_id} not found", status_code=404 ) if ( ctx.project_id is not None and row["project_id"] is not None and row["project_id"] != ctx.project_id and not ctx.is_admin ): raise OpenStackError( "NotFound", f"{resource_type} {item_id} not found", status_code=404 ) return _wrap_item(collection_key, _row_to_item(row)) async def update_item( item_id: str, request: Request, conn: Connection = Depends(get_conn), ctx: TokenContext = Depends(require_project_token), ) -> dict[str, Any]: payload = await request.json() body = payload.get(_singular(collection_key), payload) if not isinstance(body, dict): raise OpenStackError("BadRequest", "JSON object required", status_code=400) row = await conn.fetchrow( """SELECT * FROM os_api_objects WHERE service = $1 AND resource_type = $2 AND id::text = $3 AND project_id = $4""", spec.name, resource_type, item_id, ctx.project_id, ) if row is None: raise OpenStackError( "NotFound", f"{resource_type} {item_id} not found", status_code=404 ) data = row["data"] if isinstance(data, str): data = json.loads(data) data = {**(data or {}), **body, "id": str(row["id"])} name = str(data.get("name") or row["name"]) status = str(data.get("status") or row["status"]) updated = await conn.fetchrow( """UPDATE os_api_objects SET name = $1, status = $2, data = $3::jsonb, updated_at = now() WHERE id = $4 RETURNING *""", name, status, json.dumps(data), row["id"], ) return _wrap_item(collection_key, _row_to_item(updated)) async def delete_item( item_id: str, conn: Connection = Depends(get_conn), ctx: TokenContext = Depends(require_project_token), ) -> Response: result = await conn.execute( """DELETE FROM os_api_objects WHERE service = $1 AND resource_type = $2 AND id::text = $3 AND project_id = $4""", spec.name, resource_type, item_id, ctx.project_id, ) if result.endswith("0"): raise OpenStackError( "NotFound", f"{resource_type} {item_id} not found", status_code=404 ) return Response(status_code=204) # Bind with defaults to capture loop variables. router.add_api_route( collection_path, list_items, methods=["GET"], name=f"gen-{spec.name}-{resource_type}-list" ) router.add_api_route( collection_path, create_item, methods=["POST"], name=f"gen-{spec.name}-{resource_type}-create", ) router.add_api_route( item_path, show_item, methods=["GET"], name=f"gen-{spec.name}-{resource_type}-show" ) router.add_api_route( item_path, update_item, methods=["PUT", "PATCH"], name=f"gen-{spec.name}-{resource_type}-update", ) router.add_api_route( item_path, delete_item, methods=["DELETE"], name=f"gen-{spec.name}-{resource_type}-delete" ) def mount_generic_services(app: Any, *, skip: set[str] | None = None) -> int: """Mount generic routers under /_os/ for every service not in skip.""" skipped = skip or set() count = 0 for spec in SERVICES: if spec.name in skipped: continue app.include_router(build_generic_router(spec), prefix=f"/_os/{spec.name}") count += 1 + sum( 1 for _, path, _ in spec.resources if path not in {"", "/"} and not _has_path_params(path) ) return count