""" TypeDB REST API for Heritage Custodian Data FastAPI backend providing TypeQL query interface for bronhouder.nl Endpoints: - GET / - Health check and statistics - GET /status - Server status - POST /query - Execute TypeQL match query (read-only) - GET /databases - List all databases - POST /databases/{n} - Create new database - GET /schema - Get database schema types - POST /schema/load - Load TypeQL schema from file - POST /data/insert - Execute insert query - POST /database/reset/{n} - Reset (delete/recreate) database - GET /stats - Get detailed statistics Updated for TypeDB 2.x driver API (with sessions) """ import os from datetime import datetime from typing import Optional, List, Dict, Any, Union from contextlib import asynccontextmanager import asyncio from concurrent.futures import ThreadPoolExecutor from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel, Field from typedb.driver import TypeDB, SessionType, TransactionType # ============================================================================ # Configuration # ============================================================================ class Settings(BaseModel): """TypeDB server settings""" host: str = os.getenv("TYPEDB_HOST", "localhost") port: int = int(os.getenv("TYPEDB_PORT", "1729")) database: str = os.getenv("TYPEDB_DATABASE", "glam") # Server settings api_host: str = os.getenv("API_HOST", "0.0.0.0") api_port: int = int(os.getenv("API_PORT", "8003")) settings = Settings() # ============================================================================ # Pydantic Models # ============================================================================ class QueryRequest(BaseModel): """TypeQL query request""" query: str = Field(..., description="TypeQL query to execute") database: Optional[str] = Field(None, description="Database name (defaults to 'glam')") class QueryResponse(BaseModel): """TypeQL query response""" results: List[Dict[str, Any]] result_count: int execution_time_ms: float query_type: str class DatabaseInfo(BaseModel): """Database metadata""" name: str class StatusResponse(BaseModel): """Server status response""" status: str databases: List[str] default_database: str uptime_seconds: float typedb_version: str # Fields for frontend compatibility connected: bool = False database: str = "" version: str = "" # ============================================================================ # Global State # ============================================================================ _driver: Any = None _executor = ThreadPoolExecutor(max_workers=4) _start_time: datetime = datetime.now() def get_driver() -> Any: """Get or create TypeDB driver""" global _driver if _driver is None: # TypeDB 2.x: TypeDB.core_driver() returns a Driver instance _driver = TypeDB.core_driver(f"{settings.host}:{settings.port}") return _driver # ============================================================================ # Helper Functions # ============================================================================ def serialize_concept(concept: Any) -> Dict[str, Any]: """Convert TypeDB concept to JSON-serializable dict""" result: Dict[str, Any] = {} # Get type label if hasattr(concept, 'get_type'): concept_type = concept.get_type() if hasattr(concept_type, 'get_label'): label = concept_type.get_label() result['_type'] = label.name if hasattr(label, 'name') else str(label) result['type'] = result['_type'] # Handle IID if hasattr(concept, 'get_iid'): iid = concept.get_iid() if iid is not None: result['_iid'] = iid.hex() if hasattr(iid, 'hex') else str(iid) result['id'] = result['_iid'] # Handle value (for attributes) if hasattr(concept, 'get_value'): result['value'] = concept.get_value() # Handle entity/relation - get attributes if hasattr(concept, 'get_has'): try: attrs = list(concept.get_has()) if attrs: for attr in attrs: attr_type = attr.get_type().get_label() attr_name = attr_type.name if hasattr(attr_type, 'name') else str(attr_type) result[attr_name] = attr.get_value() except Exception: pass return result def serialize_concept_map(concept_map: Any) -> Dict[str, Any]: """Convert TypeDB ConceptMap to JSON-serializable dict""" result: Dict[str, Any] = {} # TypeDB 2.x uses ConceptMap with variables() if hasattr(concept_map, 'variables'): for var in concept_map.variables(): concept = concept_map.get(var) if concept: result[var] = serialize_concept(concept) return result def execute_read_query(database: str, query: str) -> tuple: """Execute a read query in TypeDB 2.x (blocking)""" driver = get_driver() results: List[Dict[str, Any]] = [] query_type = "unknown" # Determine query type query_stripped = query.strip().lower() if query_stripped.startswith("match"): query_type = "match" elif query_stripped.startswith("define"): query_type = "define" elif query_stripped.startswith("insert"): query_type = "insert" # TypeDB 2.x: Session -> Transaction -> Query with driver.session(database, SessionType.DATA) as session: with session.transaction(TransactionType.READ) as tx: # Execute query using get() for match queries # TypeDB 2.x uses tx.query.get() for "match ... get ..." queries answer = tx.query.get(query) # Iterate results for concept_map in answer: results.append(serialize_concept_map(concept_map)) return results, query_type def get_databases() -> List[str]: """Get list of databases""" driver = get_driver() return [db.name for db in driver.databases.all()] def get_schema_types(database: str) -> Dict[str, Any]: """Get schema types from database using TypeDB 2.x concepts API""" driver = get_driver() schema: Dict[str, Any] = {"entity_types": [], "relation_types": [], "attribute_types": []} try: # TypeDB 2.x: Use SCHEMA session type with concepts API with driver.session(database, SessionType.SCHEMA) as session: with session.transaction(TransactionType.READ) as tx: # Get entity types using concepts API try: entity_type = tx.concepts.get_root_entity_type() if entity_type: for sub in entity_type.get_subtypes(tx): label = sub.get_label() label_str = label.name if hasattr(label, 'name') else str(label) if label_str != "entity": schema["entity_types"].append(label_str) except Exception: pass # Get relation types using concepts API try: relation_type = tx.concepts.get_root_relation_type() if relation_type: for sub in relation_type.get_subtypes(tx): label = sub.get_label() label_str = label.name if hasattr(label, 'name') else str(label) if label_str != "relation": schema["relation_types"].append(label_str) except Exception: pass # Get attribute types using concepts API try: attr_type = tx.concepts.get_root_attribute_type() if attr_type: for sub in attr_type.get_subtypes(tx): label = sub.get_label() label_str = label.name if hasattr(label, 'name') else str(label) if label_str != "attribute": schema["attribute_types"].append(label_str) except Exception: pass except Exception as e: schema["error"] = str(e) return schema # ============================================================================ # FastAPI App # ============================================================================ @asynccontextmanager async def lifespan(app: FastAPI): """Application lifespan handler""" # Startup: Initialize driver get_driver() yield # Shutdown: Close driver global _driver if _driver: _driver.close() _driver = None _executor.shutdown(wait=True) app = FastAPI( title="TypeDB Heritage API", description="REST API for heritage institution TypeQL queries", version="1.0.0", lifespan=lifespan, ) # CORS middleware app.add_middleware( CORSMiddleware, allow_origins=["*"], # Configure for production allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # ============================================================================ # Schema and Data Loading Functions # ============================================================================ def load_schema_from_file(database: str, filepath: str) -> Dict[str, Any]: """Load TypeQL schema from a .tql file""" driver = get_driver() # Read the schema file with open(filepath, 'r', encoding='utf-8') as f: schema_content = f.read() # TypeDB 2.x: Schema operations use SCHEMA session with driver.session(database, SessionType.SCHEMA) as session: with session.transaction(TransactionType.WRITE) as tx: tx.query.define(schema_content) tx.commit() return {"status": "success", "file": filepath, "database": database} def execute_write_query(database: str, query: str) -> Dict[str, Any]: """Execute a write query (insert/delete) in TypeDB 2.x""" driver = get_driver() result: Dict[str, Any] = {"status": "success", "inserted": 0} # TypeDB 2.x: Data operations use DATA session with driver.session(database, SessionType.DATA) as session: with session.transaction(TransactionType.WRITE) as tx: query_stripped = query.strip().lower() if query_stripped.startswith("insert"): answer = tx.query.insert(query) # Count inserted concepts count = sum(1 for _ in answer) result["inserted"] = count elif query_stripped.startswith("match") and "insert" in query_stripped: # Match-insert pattern answer = tx.query.insert(query) count = sum(1 for _ in answer) result["inserted"] = count elif query_stripped.startswith("delete"): tx.query.delete(query) result["deleted"] = True else: raise ValueError(f"Unsupported write query type: {query[:50]}...") tx.commit() return result def reset_database(database: str) -> Dict[str, Any]: """Delete and recreate a database""" driver = get_driver() # Delete if exists try: if driver.databases.contains(database): driver.databases.get(database).delete() except Exception: pass # Create new driver.databases.create(database) return {"status": "success", "database": database, "action": "reset"} # ============================================================================ # API Endpoints # ============================================================================ @app.get("/", response_model=StatusResponse) async def get_root_status() -> StatusResponse: """Get server status and statistics (root endpoint)""" return await get_status() @app.get("/status") async def get_status() -> StatusResponse: """Get server status and statistics""" loop = asyncio.get_event_loop() try: databases = await loop.run_in_executor(_executor, get_databases) except Exception as e: databases = [] uptime = (datetime.now() - _start_time).total_seconds() return StatusResponse( status="healthy" if databases is not None else "error", databases=databases, default_database=settings.database, uptime_seconds=uptime, typedb_version="2.28.0", connected=len(databases) > 0, database=settings.database, version="2.28.0", ) @app.post("/query", response_model=QueryResponse) async def execute_query(request: QueryRequest) -> QueryResponse: """Execute a TypeQL query (read-only)""" loop = asyncio.get_event_loop() database = request.database or settings.database # Security: Only allow match queries for now query_stripped = request.query.strip().lower() if not query_stripped.startswith("match"): raise HTTPException( status_code=403, detail="Only match queries are allowed for read operations." ) start_time = datetime.now() try: results, query_type = await loop.run_in_executor( _executor, execute_read_query, database, request.query ) execution_time = (datetime.now() - start_time).total_seconds() * 1000 return QueryResponse( results=results, result_count=len(results), execution_time_ms=round(execution_time, 2), query_type=query_type, ) except Exception as e: raise HTTPException(status_code=400, detail=str(e)) @app.get("/databases", response_model=List[DatabaseInfo]) async def list_databases() -> List[DatabaseInfo]: """List all databases""" loop = asyncio.get_event_loop() try: databases = await loop.run_in_executor(_executor, get_databases) return [DatabaseInfo(name=db) for db in databases] except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @app.get("/schema") async def get_schema(database: Optional[str] = None) -> Dict[str, Any]: """Get schema for a database""" loop = asyncio.get_event_loop() db = database or settings.database try: # Check database exists databases = await loop.run_in_executor(_executor, get_databases) if db not in databases: raise HTTPException(status_code=404, detail=f"Database '{db}' not found") schema = await loop.run_in_executor(_executor, get_schema_types, db) return {"database": db, "schema": schema} except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @app.post("/databases/{name}") async def create_database(name: str) -> Dict[str, str]: """Create a new database""" loop = asyncio.get_event_loop() def _create_db() -> str: driver = get_driver() driver.databases.create(name) return name try: await loop.run_in_executor(_executor, _create_db) return {"status": "created", "database": name} except Exception as e: raise HTTPException(status_code=400, detail=str(e)) @app.post("/schema/load") async def load_schema(filepath: str = "/var/www/backend/typedb/01_custodian_name.tql", database: Optional[str] = None) -> Dict[str, Any]: """Load TypeQL schema from a file on the server""" loop = asyncio.get_event_loop() db = database or settings.database # Security: Only allow files in the backend directory if not filepath.startswith("/var/www/backend/typedb/"): raise HTTPException(status_code=403, detail="Schema files must be in /var/www/backend/typedb/") try: result = await loop.run_in_executor(_executor, load_schema_from_file, db, filepath) return result except FileNotFoundError: raise HTTPException(status_code=404, detail=f"Schema file not found: {filepath}") except Exception as e: raise HTTPException(status_code=400, detail=str(e)) @app.post("/data/insert") async def insert_data(request: QueryRequest) -> Dict[str, Any]: """Execute an insert query to add data""" loop = asyncio.get_event_loop() database = request.database or settings.database # Security: Only allow insert queries query_stripped = request.query.strip().lower() if not (query_stripped.startswith("insert") or (query_stripped.startswith("match") and "insert" in query_stripped)): raise HTTPException( status_code=403, detail="Only insert queries are allowed for this endpoint." ) start_time = datetime.now() try: result = await loop.run_in_executor(_executor, execute_write_query, database, request.query) execution_time = (datetime.now() - start_time).total_seconds() * 1000 result["execution_time_ms"] = round(execution_time, 2) return result except Exception as e: raise HTTPException(status_code=400, detail=str(e)) @app.post("/database/reset/{name}") async def reset_db(name: str) -> Dict[str, Any]: """Reset (delete and recreate) a database""" loop = asyncio.get_event_loop() try: result = await loop.run_in_executor(_executor, reset_database, name) return result except Exception as e: raise HTTPException(status_code=400, detail=str(e)) @app.get("/stats") async def get_stats(database: Optional[str] = None) -> Dict[str, Any]: """Get database statistics including entity/relation/attribute counts""" loop = asyncio.get_event_loop() db = database or settings.database def _get_stats() -> Dict[str, Any]: driver = get_driver() stats: Dict[str, Any] = { "database": db, "entity_types": [], "relation_types": [], "attribute_types": [], "total_entities": 0, "total_relations": 0, } # Get schema types using SCHEMA session with concepts API with driver.session(db, SessionType.SCHEMA) as session: with session.transaction(TransactionType.READ) as tx: # Entity types using concepts API try: entity_type = tx.concepts.get_root_entity_type() if entity_type: for sub in entity_type.get_subtypes(tx): label = sub.get_label() label_str = label.name if hasattr(label, 'name') else str(label) if label_str != "entity": is_abstract = sub.is_abstract() stats["entity_types"].append({ "label": label_str, "abstract": is_abstract }) except Exception: pass # Relation types using concepts API try: relation_type = tx.concepts.get_root_relation_type() if relation_type: for sub in relation_type.get_subtypes(tx): label = sub.get_label() label_str = label.name if hasattr(label, 'name') else str(label) if label_str != "relation": stats["relation_types"].append({ "label": label_str, "roles": [] }) except Exception: pass # Attribute types using concepts API try: attr_type = tx.concepts.get_root_attribute_type() if attr_type: for sub in attr_type.get_subtypes(tx): label = sub.get_label() label_str = label.name if hasattr(label, 'name') else str(label) if label_str != "attribute": # Get value type value_type = sub.get_value_type() vt_str = value_type.name if value_type else "unknown" stats["attribute_types"].append({ "label": label_str, "valueType": vt_str }) except Exception: pass # Count instances using DATA session # Note: TypeDB 2.x returns Promises that need .resolve() with driver.session(db, SessionType.DATA) as session: with session.transaction(TransactionType.READ) as tx: for et in stats["entity_types"]: if et.get("abstract"): et["count"] = 0 continue try: # TypeDB 2.x: get_entity_type returns Promise, need .resolve() entity_type = tx.concepts.get_entity_type(et["label"]).resolve() if entity_type: instances = list(entity_type.get_instances(tx)) et["count"] = len(instances) stats["total_entities"] += len(instances) else: et["count"] = 0 except Exception: et["count"] = 0 for rt in stats["relation_types"]: try: # TypeDB 2.x: get_relation_type returns Promise, need .resolve() relation_type = tx.concepts.get_relation_type(rt["label"]).resolve() if relation_type: instances = list(relation_type.get_instances(tx)) rt["count"] = len(instances) stats["total_relations"] += len(instances) else: rt["count"] = 0 except Exception: rt["count"] = 0 for at in stats["attribute_types"]: try: # TypeDB 2.x: get_attribute_type returns Promise, need .resolve() attr_type = tx.concepts.get_attribute_type(at["label"]).resolve() if attr_type: instances = list(attr_type.get_instances(tx)) at["count"] = len(instances) else: at["count"] = 0 except Exception: at["count"] = 0 return stats try: stats = await loop.run_in_executor(_executor, _get_stats) return stats except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @app.get("/graph/{entity_type}") async def get_graph_data(entity_type: str, limit: int = 100, database: Optional[str] = None) -> Dict[str, Any]: """Get graph data for visualization (nodes + edges) for a specific entity type""" loop = asyncio.get_event_loop() db = database or settings.database def _get_graph() -> Dict[str, Any]: driver = get_driver() nodes: List[Dict[str, Any]] = [] edges: List[Dict[str, Any]] = [] node_ids: set = set() with driver.session(db, SessionType.DATA) as session: with session.transaction(TransactionType.READ) as tx: # Get entity instances with their attributes entity_type_obj = tx.concepts.get_entity_type(entity_type).resolve() if not entity_type_obj: return {"nodes": [], "edges": [], "nodeCount": 0, "edgeCount": 0} instances = list(entity_type_obj.get_instances(tx))[:limit] for instance in instances: iid = instance.get_iid() node_id = iid.hex() if hasattr(iid, 'hex') else str(iid) # Get attributes for this instance attributes: Dict[str, Any] = {} label = node_id[:8] # Default label try: for attr in instance.get_has(tx): attr_type = attr.get_type().get_label() attr_name = attr_type.name if hasattr(attr_type, 'name') else str(attr_type) attr_value = attr.get_value() attributes[attr_name] = attr_value # Use certain attributes as label if attr_name in ('name', 'label', 'observed-name', 'legal-name', 'id'): label = str(attr_value) except Exception: pass node_ids.add(node_id) nodes.append({ "id": node_id, "label": label, "type": "entity", "entityType": entity_type, "attributes": attributes, }) # Get relations involving these entities # Query relations where these entities participate try: relation_root = tx.concepts.get_root_relation_type() for rel_type in relation_root.get_subtypes(tx): rel_label = rel_type.get_label() rel_name = rel_label.name if hasattr(rel_label, 'name') else str(rel_label) if rel_name == "relation": continue for rel_instance in rel_type.get_instances(tx): rel_iid = rel_instance.get_iid() rel_id = rel_iid.hex() if hasattr(rel_iid, 'hex') else str(rel_iid) # Get role players players = [] try: # TypeDB 2.x: get_players(tx) returns a dict {role_type: [player_list]} role_players_dict = rel_instance.get_players(tx) for role_type, player_list in role_players_dict.items(): role_label = role_type.get_label() role_name = role_label.name if hasattr(role_label, 'name') else str(role_label) for player in player_list: player_iid = player.get_iid() player_id = player_iid.hex() if hasattr(player_iid, 'hex') else str(player_iid) players.append({"role": role_name, "id": player_id}) except Exception as e: print(f"Error getting role players for relation {rel_name}: {e}") # Create edges if we have players from our node set matching_players = [p for p in players if p["id"] in node_ids] if len(matching_players) >= 1 and len(players) >= 2: # Create edge between first two players edges.append({ "id": rel_id, "source": players[0]["id"], "target": players[1]["id"] if len(players) > 1 else players[0]["id"], "relationType": rel_name, "role": f"{players[0]['role']} -> {players[1]['role'] if len(players) > 1 else players[0]['role']}", "attributes": {}, }) except Exception as e: print(f"Error getting relations: {e}") return { "nodes": nodes, "edges": edges, "nodeCount": len(nodes), "edgeCount": len(edges), } try: result = await loop.run_in_executor(_executor, _get_graph) return result except Exception as e: raise HTTPException(status_code=500, detail=str(e)) # ============================================================================ # Main Entry Point # ============================================================================ if __name__ == "__main__": import uvicorn uvicorn.run( "main:app", host=settings.api_host, port=settings.api_port, reload=True, )