diff --git a/dev b/dev index 38ffc14..dbc5766 160000 --- a/dev +++ b/dev @@ -1 +1 @@ -Subproject commit 38ffc1495e5736a5c77e6e8c35aced687bbe7932 +Subproject commit dbc5766f2dfd1b7ee23f18b71ebaa768e81c7571 diff --git a/scidk/web/routes/__init__.py b/scidk/web/routes/__init__.py index f65f78c..d9dfecf 100644 --- a/scidk/web/routes/__init__.py +++ b/scidk/web/routes/__init__.py @@ -11,6 +11,7 @@ - api_admin: Health, metrics, logs - api_interpreters: Interpreter configuration - api_providers: Filesystem provider management +- api_annotations: Annotations and relationships management All blueprints are registered in create_app() in scidk/web/__init__.py """ @@ -32,6 +33,7 @@ def register_blueprints(app): from . import api_admin from . import api_interpreters from . import api_providers + from . import api_annotations # Register UI blueprint app.register_blueprint(ui.bp) @@ -45,3 +47,4 @@ def register_blueprints(app): app.register_blueprint(api_admin.bp) app.register_blueprint(api_interpreters.bp) app.register_blueprint(api_providers.bp) + app.register_blueprint(api_annotations.bp) diff --git a/scidk/web/routes/api_annotations.py b/scidk/web/routes/api_annotations.py new file mode 100644 index 0000000..c4cedca --- /dev/null +++ b/scidk/web/routes/api_annotations.py @@ -0,0 +1,295 @@ +""" +Blueprint for Annotations API routes. + +Provides REST endpoints for: +- Relationships CRUD +- Sync queue management +""" +from flask import Blueprint, jsonify, request +import time + +from ...core import annotations_sqlite as ann + +bp = Blueprint('annotations', __name__, url_prefix='/api') + + +@bp.route('/relationships', methods=['POST']) +def create_relationship(): + """ + Create a new relationship between entities. + + Request body: + { + "from_id": "file_abc123", + "to_id": "file_def456", + "type": "GENERATED_BY", + "properties": {"confidence": 0.95, "method": "auto"} # optional + } + + Returns: + { + "id": 123, + "from_id": "file_abc123", + "to_id": "file_def456", + "type": "GENERATED_BY", + "properties_json": "{...}", + "created": 1234567890.123 + } + """ + data = request.get_json(force=True, silent=True) or {} + + from_id = data.get('from_id') + to_id = data.get('to_id') + rel_type = data.get('type') + + if not from_id or not to_id or not rel_type: + return jsonify({ + 'status': 'error', + 'error': 'Missing required fields: from_id, to_id, type' + }), 400 + + # Serialize properties if present + properties = data.get('properties') + properties_json = None + if properties is not None: + import json + try: + properties_json = json.dumps(properties) + except Exception as e: + return jsonify({ + 'status': 'error', + 'error': f'Invalid properties format: {str(e)}' + }), 400 + + created_ts = time.time() + + try: + result = ann.create_relationship( + from_id=from_id, + to_id=to_id, + rel_type=rel_type, + properties_json=properties_json, + created_ts=created_ts + ) + return jsonify(result), 201 + except Exception as e: + return jsonify({ + 'status': 'error', + 'error': f'Failed to create relationship: {str(e)}' + }), 500 + + +@bp.route('/relationships', methods=['GET']) +def list_relationships(): + """ + List relationships for a given entity/file. + + Query parameters: + - file_id: Required. Entity/file ID to query relationships for. + + Returns: + { + "relationships": [ + { + "id": 123, + "from_id": "file_abc123", + "to_id": "file_def456", + "type": "GENERATED_BY", + "properties_json": "{...}", + "created": 1234567890.123 + }, + ... + ], + "count": 2, + "file_id": "file_abc123" + } + """ + file_id = request.args.get('file_id') + + if not file_id: + return jsonify({ + 'status': 'error', + 'error': 'Missing required query parameter: file_id' + }), 400 + + try: + relationships = ann.list_relationships(entity_id=file_id) + return jsonify({ + 'relationships': relationships, + 'count': len(relationships), + 'file_id': file_id + }) + except Exception as e: + return jsonify({ + 'status': 'error', + 'error': f'Failed to list relationships: {str(e)}' + }), 500 + + +@bp.route('/relationships/', methods=['DELETE']) +def delete_relationship(rel_id): + """ + Delete a relationship by ID. + + Returns: + { + "status": "deleted", + "id": 123 + } + """ + try: + deleted = ann.delete_relationship(rel_id=rel_id) + if deleted: + return jsonify({'status': 'deleted', 'id': rel_id}) + else: + return jsonify({ + 'status': 'error', + 'error': 'Relationship not found' + }), 404 + except Exception as e: + return jsonify({ + 'status': 'error', + 'error': f'Failed to delete relationship: {str(e)}' + }), 500 + + +@bp.route('/sync', methods=['POST']) +def enqueue_sync(): + """ + Enqueue an item for background sync/projection. + + Request body: + { + "entity_type": "relationship", + "entity_id": "123", + "action": "create", + "payload": {"target": "neo4j", "batch": true} # optional + } + + Returns: + { + "status": "enqueued", + "sync_id": 456, + "entity_type": "relationship", + "entity_id": "123", + "action": "create" + } + """ + data = request.get_json(force=True, silent=True) or {} + + entity_type = data.get('entity_type') + entity_id = data.get('entity_id') + action = data.get('action') + + if not entity_type or not entity_id or not action: + return jsonify({ + 'status': 'error', + 'error': 'Missing required fields: entity_type, entity_id, action' + }), 400 + + # Serialize payload if present + payload = data.get('payload') + payload_str = None + if payload is not None: + import json + try: + payload_str = json.dumps(payload) + except Exception as e: + return jsonify({ + 'status': 'error', + 'error': f'Invalid payload format: {str(e)}' + }), 400 + + created_ts = time.time() + + try: + sync_id = ann.enqueue_sync( + entity_type=entity_type, + entity_id=entity_id, + action=action, + payload=payload_str, + created_ts=created_ts + ) + return jsonify({ + 'status': 'enqueued', + 'sync_id': sync_id, + 'entity_type': entity_type, + 'entity_id': entity_id, + 'action': action + }), 201 + except Exception as e: + return jsonify({ + 'status': 'error', + 'error': f'Failed to enqueue sync: {str(e)}' + }), 500 + + +@bp.route('/sync/queue', methods=['GET']) +def list_sync_queue(): + """ + List unprocessed items in the sync queue. + + Query parameters: + - limit: Maximum number of items to return (default: 100) + + Returns: + { + "queue": [ + { + "id": 456, + "entity_type": "relationship", + "entity_id": "123", + "action": "create", + "payload": "{...}", + "created": 1234567890.123 + }, + ... + ], + "count": 5 + } + """ + limit = request.args.get('limit', 100, type=int) + + try: + queue = ann.dequeue_unprocessed(limit=limit) + return jsonify({ + 'queue': queue, + 'count': len(queue) + }) + except Exception as e: + return jsonify({ + 'status': 'error', + 'error': f'Failed to list sync queue: {str(e)}' + }), 500 + + +@bp.route('/sync//mark-processed', methods=['POST']) +def mark_sync_processed(sync_id): + """ + Mark a sync queue item as processed. + + Returns: + { + "status": "marked_processed", + "id": 456 + } + """ + try: + processed_ts = time.time() + marked = ann.mark_processed(item_id=sync_id, processed_ts=processed_ts) + if marked: + return jsonify({ + 'status': 'marked_processed', + 'id': sync_id, + 'processed_at': processed_ts + }) + else: + return jsonify({ + 'status': 'error', + 'error': 'Sync item not found' + }), 404 + except Exception as e: + return jsonify({ + 'status': 'error', + 'error': f'Failed to mark as processed: {str(e)}' + }), 500 diff --git a/tests/test_annotations_rest_endpoints.py b/tests/test_annotations_rest_endpoints.py index c2d24fa..545ff82 100644 --- a/tests/test_annotations_rest_endpoints.py +++ b/tests/test_annotations_rest_endpoints.py @@ -1,5 +1,18 @@ +""" +Tests for annotations REST API endpoints. + +Tests cover: +- Annotations CRUD (existing) +- POST /api/relationships - create relationships +- GET /api/relationships?file_id= - list relationships +- DELETE /api/relationships/ - delete relationship +- POST /api/sync - enqueue sync items +- GET /api/sync/queue - list sync queue +- POST /api/sync//mark-processed - mark sync item as processed +""" import json + def test_annotations_crud_endpoints(client): # Create annotation payload = {"file_id": "fileX", "kind": "tag", "label": "Interesting", "note": "n1", "data_json": json.dumps({"k":1})} @@ -36,3 +49,177 @@ def test_annotations_crud_endpoints(client): assert rdel.status_code == 200 rget2 = client.get(f'/api/annotations/{ann_id}') assert rget2.status_code == 404 + + +# --- Relationships endpoints tests --- + +def test_create_relationship_success(client): + """Test creating a relationship with valid data.""" + response = client.post('/api/relationships', json={ + 'from_id': 'file_abc123', + 'to_id': 'file_def456', + 'type': 'GENERATED_BY', + 'properties': {'confidence': 0.95, 'method': 'auto'} + }) + + assert response.status_code == 201 + data = response.get_json() + assert 'id' in data + assert data['from_id'] == 'file_abc123' + assert data['to_id'] == 'file_def456' + assert data['type'] == 'GENERATED_BY' + assert data['properties_json'] is not None + + +def test_create_relationship_missing_fields(client): + """Test creating relationship with missing required fields.""" + response = client.post('/api/relationships', json={ + 'from_id': 'file_abc123', + # missing to_id and type + }) + + assert response.status_code == 400 + data = response.get_json() + assert data['status'] == 'error' + assert 'Missing required fields' in data['error'] + + +def test_list_relationships_success(client): + """Test listing relationships for a file.""" + import uuid + unique_id = f'file_test_{uuid.uuid4().hex[:8]}' + + # Create some relationships first + client.post('/api/relationships', json={ + 'from_id': unique_id + '_1', + 'to_id': unique_id, + 'type': 'LINKS_TO' + }) + client.post('/api/relationships', json={ + 'from_id': unique_id, + 'to_id': unique_id + '_2', + 'type': 'LINKS_TO' + }) + + # Query relationships for unique_id (appears in both) + response = client.get(f'/api/relationships?file_id={unique_id}') + + assert response.status_code == 200 + data = response.get_json() + assert 'relationships' in data + assert data['count'] == 2 + assert data['file_id'] == unique_id + + +def test_list_relationships_missing_file_id(client): + """Test listing relationships without file_id parameter.""" + response = client.get('/api/relationships') + + assert response.status_code == 400 + data = response.get_json() + assert data['status'] == 'error' + + +def test_delete_relationship_success(client): + """Test deleting a relationship.""" + # Create a relationship + create_response = client.post('/api/relationships', json={ + 'from_id': 'file_delete1', + 'to_id': 'file_delete2', + 'type': 'TEST_RELATION' + }) + rel_id = create_response.get_json()['id'] + + # Delete it + delete_response = client.delete(f'/api/relationships/{rel_id}') + + assert delete_response.status_code == 200 + data = delete_response.get_json() + assert data['status'] == 'deleted' + assert data['id'] == rel_id + + +# --- Sync queue endpoints tests --- + +def test_enqueue_sync_success(client): + """Test enqueueing a sync item.""" + response = client.post('/api/sync', json={ + 'entity_type': 'relationship', + 'entity_id': '123', + 'action': 'create', + 'payload': {'target': 'neo4j', 'batch': True} + }) + + assert response.status_code == 201 + data = response.get_json() + assert data['status'] == 'enqueued' + assert 'sync_id' in data + assert data['entity_type'] == 'relationship' + assert data['entity_id'] == '123' + assert data['action'] == 'create' + + +def test_enqueue_sync_missing_fields(client): + """Test enqueueing sync with missing required fields.""" + response = client.post('/api/sync', json={ + 'entity_type': 'relationship', + # missing entity_id and action + }) + + assert response.status_code == 400 + data = response.get_json() + assert data['status'] == 'error' + + +def test_list_sync_queue(client): + """Test listing sync queue items.""" + # Enqueue some items + for i in range(3): + client.post('/api/sync', json={ + 'entity_type': 'test', + 'entity_id': f'item_{i}', + 'action': 'test_action' + }) + + # List queue + response = client.get('/api/sync/queue') + + assert response.status_code == 200 + data = response.get_json() + assert 'queue' in data + assert data['count'] >= 3 + + +def test_mark_sync_processed_success(client): + """Test marking a sync item as processed.""" + # Enqueue an item + enqueue_response = client.post('/api/sync', json={ + 'entity_type': 'test', + 'entity_id': 'mark_test', + 'action': 'test_action' + }) + sync_id = enqueue_response.get_json()['sync_id'] + + # Mark as processed + mark_response = client.post(f'/api/sync/{sync_id}/mark-processed') + + assert mark_response.status_code == 200 + data = mark_response.get_json() + assert data['status'] == 'marked_processed' + assert data['id'] == sync_id + + +def test_privacy_guardrails_documented(client): + """ + Note: Privacy guardrails for annotations endpoints. + + In production, these endpoints should enforce: + 1. Authentication: Only authenticated users can access + 2. Authorization: Users can only access their own data or shared projects + 3. Rate limiting: Prevent abuse + 4. Input validation: Sanitize all inputs + 5. Audit logging: Track all relationship changes + + This test documents the requirement. Future phases should add security layers. + """ + assert True, "Privacy guardrails documented for future implementation"