diff --git a/cognee/api/v1/ontologies/routers/get_ontology_router.py b/cognee/api/v1/ontologies/routers/get_ontology_router.py index ee31c683f..77667d88d 100644 --- a/cognee/api/v1/ontologies/routers/get_ontology_router.py +++ b/cognee/api/v1/ontologies/routers/get_ontology_router.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, File, Form, UploadFile, Depends, HTTPException +from fastapi import APIRouter, File, Form, UploadFile, Depends, Request from fastapi.responses import JSONResponse from typing import Optional, List @@ -15,28 +15,25 @@ def get_ontology_router() -> APIRouter: @router.post("", response_model=dict) async def upload_ontology( + request: Request, ontology_key: str = Form(...), - ontology_file: List[UploadFile] = File(...), - descriptions: Optional[str] = Form(None), + ontology_file: UploadFile = File(...), + description: Optional[str] = Form(None), user: User = Depends(get_authenticated_user), ): """ - Upload ontology files with their respective keys for later use in cognify operations. - - Supports both single and multiple file uploads: - - Single file: ontology_key=["key"], ontology_file=[file] - - Multiple files: ontology_key=["key1", "key2"], ontology_file=[file1, file2] + Upload a single ontology file for later use in cognify operations. ## Request Parameters - - **ontology_key** (str): JSON array string of user-defined identifiers for the ontologies - - **ontology_file** (List[UploadFile]): OWL format ontology files - - **descriptions** (Optional[str]): JSON array string of optional descriptions + - **ontology_key** (str): User-defined identifier for the ontology. + - **ontology_file** (UploadFile): Single OWL format ontology file + - **description** (Optional[str]): Optional description for the ontology. ## Response - Returns metadata about uploaded ontologies including keys, filenames, sizes, and upload timestamps. + Returns metadata about the uploaded ontology including key, filename, size, and upload timestamp. ## Error Codes - - **400 Bad Request**: Invalid file format, duplicate keys, array length mismatches, file size exceeded + - **400 Bad Request**: Invalid file format, duplicate key, multiple files uploaded - **500 Internal Server Error**: File system or processing errors """ send_telemetry( @@ -49,16 +46,22 @@ def get_ontology_router() -> APIRouter: ) try: - import json + # Enforce: exactly one uploaded file for "ontology_file" + form = await request.form() + uploaded_files = form.getlist("ontology_file") + if len(uploaded_files) != 1: + raise ValueError("Only one ontology_file is allowed") - ontology_keys = json.loads(ontology_key) - description_list = json.loads(descriptions) if descriptions else None + if ontology_key.strip().startswith(("[", "{")): + raise ValueError("ontology_key must be a string") + if description is not None and description.strip().startswith(("[", "{")): + raise ValueError("description must be a string") - if not isinstance(ontology_keys, list): - raise ValueError("ontology_key must be a JSON array") - - results = await ontology_service.upload_ontologies( - ontology_keys, ontology_file, user, description_list + result = await ontology_service.upload_ontology( + ontology_key=ontology_key, + file=ontology_file, + user=user, + description=description, ) return { @@ -70,10 +73,9 @@ def get_ontology_router() -> APIRouter: "uploaded_at": result.uploaded_at, "description": result.description, } - for result in results ] } - except (json.JSONDecodeError, ValueError) as e: + except ValueError as e: return JSONResponse(status_code=400, content={"error": str(e)}) except Exception as e: return JSONResponse(status_code=500, content={"error": str(e)}) diff --git a/cognee/tests/test_cognee_server_start.py b/cognee/tests/test_cognee_server_start.py index fece88240..a626088a3 100644 --- a/cognee/tests/test_cognee_server_start.py +++ b/cognee/tests/test_cognee_server_start.py @@ -148,8 +148,8 @@ class TestCogneeServerStart(unittest.TestCase): headers=headers, files=[("ontology_file", ("test.owl", ontology_content, "application/xml"))], data={ - "ontology_key": json.dumps([ontology_key]), - "description": json.dumps(["Test ontology"]), + "ontology_key": ontology_key, + "description": "Test ontology", }, ) self.assertEqual(ontology_response.status_code, 200) diff --git a/cognee/tests/unit/api/test_ontology_endpoint.py b/cognee/tests/unit/api/test_ontology_endpoint.py index af3a4d90e..e072ceda8 100644 --- a/cognee/tests/unit/api/test_ontology_endpoint.py +++ b/cognee/tests/unit/api/test_ontology_endpoint.py @@ -1,17 +1,28 @@ import pytest import uuid from fastapi.testclient import TestClient -from unittest.mock import patch, Mock, AsyncMock +from unittest.mock import Mock from types import SimpleNamespace -import importlib from cognee.api.client import app +from cognee.modules.users.methods import get_authenticated_user -gau_mod = importlib.import_module("cognee.modules.users.methods.get_authenticated_user") + +@pytest.fixture(scope="session") +def test_client(): + # Keep a single TestClient (and event loop) for the whole module. + # Re-creating TestClient repeatedly can break async DB connections (asyncpg loop mismatch). + with TestClient(app) as c: + yield c @pytest.fixture -def client(): - return TestClient(app) +def client(test_client, mock_default_user): + async def override_get_authenticated_user(): + return mock_default_user + + app.dependency_overrides[get_authenticated_user] = override_get_authenticated_user + yield test_client + app.dependency_overrides.pop(get_authenticated_user, None) @pytest.fixture @@ -32,12 +43,8 @@ def mock_default_user(): ) -@patch.object(gau_mod, "get_default_user", new_callable=AsyncMock) -def test_upload_ontology_success(mock_get_default_user, client, mock_default_user): +def test_upload_ontology_success(client): """Test successful ontology upload""" - import json - - mock_get_default_user.return_value = mock_default_user ontology_content = ( b"" ) @@ -46,7 +53,7 @@ def test_upload_ontology_success(mock_get_default_user, client, mock_default_use response = client.post( "/api/v1/ontologies", files=[("ontology_file", ("test.owl", ontology_content, "application/xml"))], - data={"ontology_key": json.dumps([unique_key]), "description": json.dumps(["Test"])}, + data={"ontology_key": unique_key, "description": "Test"}, ) assert response.status_code == 200 @@ -55,10 +62,8 @@ def test_upload_ontology_success(mock_get_default_user, client, mock_default_use assert "uploaded_at" in data["uploaded_ontologies"][0] -@patch.object(gau_mod, "get_default_user", new_callable=AsyncMock) -def test_upload_ontology_invalid_file(mock_get_default_user, client, mock_default_user): +def test_upload_ontology_invalid_file(client): """Test 400 response for non-.owl files""" - mock_get_default_user.return_value = mock_default_user unique_key = f"test_ontology_{uuid.uuid4().hex[:8]}" response = client.post( "/api/v1/ontologies", @@ -68,14 +73,10 @@ def test_upload_ontology_invalid_file(mock_get_default_user, client, mock_defaul assert response.status_code == 400 -@patch.object(gau_mod, "get_default_user", new_callable=AsyncMock) -def test_upload_ontology_missing_data(mock_get_default_user, client, mock_default_user): +def test_upload_ontology_missing_data(client): """Test 400 response for missing file or key""" - import json - - mock_get_default_user.return_value = mock_default_user # Missing file - response = client.post("/api/v1/ontologies", data={"ontology_key": json.dumps(["test"])}) + response = client.post("/api/v1/ontologies", data={"ontology_key": "test"}) assert response.status_code == 400 # Missing key @@ -85,34 +86,25 @@ def test_upload_ontology_missing_data(mock_get_default_user, client, mock_defaul assert response.status_code == 400 -@patch.object(gau_mod, "get_default_user", new_callable=AsyncMock) -def test_upload_ontology_unauthorized(mock_get_default_user, client, mock_default_user): - """Test behavior when default user is provided (no explicit authentication)""" - import json - +def test_upload_ontology_without_auth_header(client): + """Test behavior when no explicit authentication header is provided.""" unique_key = f"test_ontology_{uuid.uuid4().hex[:8]}" - mock_get_default_user.return_value = mock_default_user response = client.post( "/api/v1/ontologies", files=[("ontology_file", ("test.owl", b"", "application/xml"))], - data={"ontology_key": json.dumps([unique_key])}, + data={"ontology_key": unique_key}, ) - # The current system provides a default user when no explicit authentication is given - # This test verifies the system works with conditional authentication assert response.status_code == 200 data = response.json() assert data["uploaded_ontologies"][0]["ontology_key"] == unique_key assert "uploaded_at" in data["uploaded_ontologies"][0] -@patch.object(gau_mod, "get_default_user", new_callable=AsyncMock) -def test_upload_multiple_ontologies(mock_get_default_user, client, mock_default_user): - """Test uploading multiple ontology files in single request""" +def test_upload_multiple_ontologies_in_single_request_is_rejected(client): + """Uploading multiple ontology files in a single request should fail.""" import io - mock_get_default_user.return_value = mock_default_user - # Create mock files file1_content = b"" file2_content = b"" @@ -120,45 +112,34 @@ def test_upload_multiple_ontologies(mock_get_default_user, client, mock_default_ ("ontology_file", ("vehicles.owl", io.BytesIO(file1_content), "application/xml")), ("ontology_file", ("manufacturers.owl", io.BytesIO(file2_content), "application/xml")), ] - data = { - "ontology_key": '["vehicles", "manufacturers"]', - "descriptions": '["Base vehicles", "Car manufacturers"]', - } + data = {"ontology_key": "vehicles", "description": "Base vehicles"} response = client.post("/api/v1/ontologies", files=files, data=data) - assert response.status_code == 200 - result = response.json() - assert "uploaded_ontologies" in result - assert len(result["uploaded_ontologies"]) == 2 - assert result["uploaded_ontologies"][0]["ontology_key"] == "vehicles" - assert result["uploaded_ontologies"][1]["ontology_key"] == "manufacturers" + assert response.status_code == 400 + assert "Only one ontology_file is allowed" in response.json()["error"] -@patch.object(gau_mod, "get_default_user", new_callable=AsyncMock) -def test_upload_endpoint_accepts_arrays(mock_get_default_user, client, mock_default_user): - """Test that upload endpoint accepts array parameters""" +def test_upload_endpoint_rejects_array_style_fields(client): + """Array-style form values should be rejected (no backwards compatibility).""" import io import json - mock_get_default_user.return_value = mock_default_user file_content = b"" files = [("ontology_file", ("single.owl", io.BytesIO(file_content), "application/xml"))] data = { "ontology_key": json.dumps(["single_key"]), - "descriptions": json.dumps(["Single ontology"]), + "description": json.dumps(["Single ontology"]), } response = client.post("/api/v1/ontologies", files=files, data=data) - assert response.status_code == 200 - result = response.json() - assert result["uploaded_ontologies"][0]["ontology_key"] == "single_key" + assert response.status_code == 400 + assert "ontology_key must be a string" in response.json()["error"] -@patch.object(gau_mod, "get_default_user", new_callable=AsyncMock) -def test_cognify_with_multiple_ontologies(mock_get_default_user, client, mock_default_user): +def test_cognify_with_multiple_ontologies(client): """Test cognify endpoint accepts multiple ontology keys""" payload = { "datasets": ["test_dataset"], @@ -172,14 +153,11 @@ def test_cognify_with_multiple_ontologies(mock_get_default_user, client, mock_de assert response.status_code in [200, 400, 409] # May fail for other reasons, not type -@patch.object(gau_mod, "get_default_user", new_callable=AsyncMock) -def test_complete_multifile_workflow(mock_get_default_user, client, mock_default_user): - """Test complete workflow: upload multiple ontologies → cognify with multiple keys""" +def test_complete_multifile_workflow(client): + """Test workflow: upload ontologies one-by-one → cognify with multiple keys""" import io - import json - mock_get_default_user.return_value = mock_default_user - # Step 1: Upload multiple ontologies + # Step 1: Upload two ontologies (one-by-one) file1_content = b""" @@ -192,17 +170,21 @@ def test_complete_multifile_workflow(mock_get_default_user, client, mock_default """ - files = [ - ("ontology_file", ("vehicles.owl", io.BytesIO(file1_content), "application/xml")), - ("ontology_file", ("manufacturers.owl", io.BytesIO(file2_content), "application/xml")), - ] - data = { - "ontology_key": json.dumps(["vehicles", "manufacturers"]), - "descriptions": json.dumps(["Vehicle ontology", "Manufacturer ontology"]), - } + upload_response_1 = client.post( + "/api/v1/ontologies", + files=[("ontology_file", ("vehicles.owl", io.BytesIO(file1_content), "application/xml"))], + data={"ontology_key": "vehicles", "description": "Vehicle ontology"}, + ) + assert upload_response_1.status_code == 200 - upload_response = client.post("/api/v1/ontologies", files=files, data=data) - assert upload_response.status_code == 200 + upload_response_2 = client.post( + "/api/v1/ontologies", + files=[ + ("ontology_file", ("manufacturers.owl", io.BytesIO(file2_content), "application/xml")) + ], + data={"ontology_key": "manufacturers", "description": "Manufacturer ontology"}, + ) + assert upload_response_2.status_code == 200 # Step 2: Verify ontologies are listed list_response = client.get("/api/v1/ontologies") @@ -223,44 +205,42 @@ def test_complete_multifile_workflow(mock_get_default_user, client, mock_default assert cognify_response.status_code != 400 # Not a validation error -@patch.object(gau_mod, "get_default_user", new_callable=AsyncMock) -def test_multifile_error_handling(mock_get_default_user, client, mock_default_user): - """Test error handling for invalid multifile uploads""" +def test_upload_error_handling(client): + """Test error handling for invalid uploads (single-file endpoint).""" import io import json - # Test mismatched array lengths + # Array-style key should be rejected file_content = b"" files = [("ontology_file", ("test.owl", io.BytesIO(file_content), "application/xml"))] data = { - "ontology_key": json.dumps(["key1", "key2"]), # 2 keys, 1 file - "descriptions": json.dumps(["desc1"]), + "ontology_key": json.dumps(["key1", "key2"]), + "description": "desc1", } response = client.post("/api/v1/ontologies", files=files, data=data) assert response.status_code == 400 - assert "Number of keys must match number of files" in response.json()["error"] + assert "ontology_key must be a string" in response.json()["error"] - # Test duplicate keys - files = [ - ("ontology_file", ("test1.owl", io.BytesIO(file_content), "application/xml")), - ("ontology_file", ("test2.owl", io.BytesIO(file_content), "application/xml")), - ] - data = { - "ontology_key": json.dumps(["duplicate", "duplicate"]), - "descriptions": json.dumps(["desc1", "desc2"]), - } + # Duplicate key should be rejected + response_1 = client.post( + "/api/v1/ontologies", + files=[("ontology_file", ("test1.owl", io.BytesIO(file_content), "application/xml"))], + data={"ontology_key": "duplicate", "description": "desc1"}, + ) + assert response_1.status_code == 200 - response = client.post("/api/v1/ontologies", files=files, data=data) - assert response.status_code == 400 - assert "Duplicate ontology keys not allowed" in response.json()["error"] + response_2 = client.post( + "/api/v1/ontologies", + files=[("ontology_file", ("test2.owl", io.BytesIO(file_content), "application/xml"))], + data={"ontology_key": "duplicate", "description": "desc2"}, + ) + assert response_2.status_code == 400 + assert "already exists" in response_2.json()["error"] -@patch.object(gau_mod, "get_default_user", new_callable=AsyncMock) -def test_cognify_missing_ontology_key(mock_get_default_user, client, mock_default_user): +def test_cognify_missing_ontology_key(client): """Test cognify with non-existent ontology key""" - mock_get_default_user.return_value = mock_default_user - payload = { "datasets": ["test_dataset"], "ontology_key": ["nonexistent_key"],