Repository: dice-group/owlapy (language: python)

Issue:
Serving owlapy within a web framework (FastAPI or LitServe)
`owlapy-serve`


Discussion / hints:

Fix patch:
diff --git a/owlapy/scripts/owlapy_serve.py b/owlapy/scripts/owlapy_serve.py
new file mode 100644
index 00000000..3fc5a317
--- /dev/null
+++ b/owlapy/scripts/owlapy_serve.py
@@ -0,0 +1,128 @@
+import os
+import argparse
+import uvicorn
+from fastapi import FastAPI
+from pydantic import BaseModel
+from owlapy.owl_ontology_manager import SyncOntologyManager
+from owlapy.owl_reasoner import SyncReasoner
+from owlapy.class_expression import OWLClass
+from owlapy.static_funcs import stopJVM
+from contextlib import asynccontextmanager
+from enum import Enum
+
+ontology = None
+reasoner = None
+
+class InferenceType(str, Enum):
+    InferredClassAssertionAxiomGenerator = "InferredClassAssertionAxiomGenerator"
+    InferredSubClassAxiomGenerator = "InferredSubClassAxiomGenerator"
+    InferredDisjointClassesAxiomGenerator = "InferredDisjointClassesAxiomGenerator"
+    InferredEquivalentClassAxiomGenerator = "InferredEquivalentClassAxiomGenerator"
+    InferredEquivalentDataPropertiesAxiomGenerator = "InferredEquivalentDataPropertiesAxiomGenerator"
+    InferredEquivalentObjectPropertyAxiomGenerator = "InferredEquivalentObjectPropertyAxiomGenerator"
+    InferredInverseObjectPropertiesAxiomGenerator = "InferredInverseObjectPropertiesAxiomGenerator"
+    InferredSubDataPropertyAxiomGenerator = "InferredSubDataPropertyAxiomGenerator"
+    InferredSubObjectPropertyAxiomGenerator = "InferredSubObjectPropertyAxiomGenerator"
+    InferredDataPropertyCharacteristicAxiomGenerator = "InferredDataPropertyCharacteristicAxiomGenerator"
+    InferredObjectPropertyCharacteristicAxiomGenerator = "InferredObjectPropertyCharacteristicAxiomGenerator"
+    All = "all"
+
+class InfrenceTypeRequest(BaseModel):
+    inference_type: InferenceType
+
+class ClassIRIRequest(BaseModel):
+    class_iri: str
+
+class AxiomRequest(BaseModel):
+    axiom: str
+
+def create_app(ontology_path: str, reasoner_name: str):
+    @asynccontextmanager
+    async def lifespan(app: FastAPI):
+        global ontology, reasoner
+        # Startup logic
+        # Load the ontology
+        if not os.path.exists(ontology_path):
+            raise FileNotFoundError(f"Ontology file not found at {ontology_path}")
+        ontology = SyncOntologyManager().load_ontology(ontology_path)
+
+        # Validate and initialize the reasoner
+        valid_reasoners = ['Pellet', 'HermiT', 'JFact', 'Openllet']
+        if reasoner_name not in valid_reasoners:
+            raise ValueError(f"Invalid reasoner '{reasoner_name}'. Valid options are: {', '.join(valid_reasoners)}")
+        reasoner = SyncReasoner(ontology=ontology, reasoner=reasoner_name)
+
+        yield
+        stopJVM()
+
+    app = FastAPI(title="OWLAPY API", lifespan=lifespan)
+
+    @app.post("/instances")
+    async def get_instances(request: ClassIRIRequest):
+        class_iri = request.class_iri
+        owl_class = OWLClass(class_iri)
+        instances = reasoner.instances(owl_class, direct=False)
+        instance_iris = [ind.__str__() for ind in instances]
+        return {"instances": instance_iris}
+
+    @app.get("/classes")
+    async def get_classes():
+        classes = [cls.__str__() for cls in ontology.classes_in_signature()]
+        return {"classes": classes}
+
+    @app.get("/object_properties")
+    async def get_object_properties():
+        object_properties = [op.__str__() for op in ontology.object_properties_in_signature()]
+        return {"object_properties": object_properties}
+    
+    @app.get("/data_properties")
+    async def get_data_properties():
+        data_properties = [dp.__str__() for dp in ontology.data_properties_in_signature()]
+        return {"data_properties": data_properties}
+    
+    @app.get("/individuals")
+    async def get_individuals():
+        individuals = [ind.__str__() for ind in ontology.individuals_in_signature()]
+        return {"individuals": individuals}
+
+    @app.get("/abox")
+    async def get_abox():
+        abox = ontology.get_abox_axioms()
+        return {"abox": [axiom.__str__() for axiom in abox]}
+
+    @app.get("/tbox")
+    async def get_tbox():
+        tbox = ontology.get_tbox_axioms()
+        return {"tbox": [axiom.__str__() for axiom in tbox]}
+        
+    @app.post("/infer_axioms")
+    async def infer_axioms(request: InfrenceTypeRequest):
+        inference_type = request.inference_type
+        if inference_type == InferenceType.All:
+            inferred_axioms = []
+            for inference_type in {it for it in InferenceType if it != InferenceType.All}:
+                inferred_axioms.extend(reasoner.infer_axioms(inference_type.value))
+        else:
+            inferred_axioms = reasoner.infer_axioms(request.inference_type.value)
+
+        return {"inferred_axioms": [axiom.__str__() for axiom in inferred_axioms]}
+
+    return app
+
+def main():
+    parser = argparse.ArgumentParser(description='Start OWLAPY API server.')
+    parser.add_argument('--path_kb', type=str, required=True,
+                        help='Path to the ontology file')
+    parser.add_argument('--reasoner', type=str, default='HermiT',
+                        help='Reasoner to use (Pellet, HermiT, JFact, Openllet)')
+    parser.add_argument('--host', type=str, default='0.0.0.0',
+                        help='Host to listen on')
+    parser.add_argument('--port', type=int, default=8000,
+                        help='Port to listen on')
+    args = parser.parse_args()
+
+    app = create_app(args.path_kb, args.reasoner)
+    uvicorn.run(app, host=args.host, port=args.port)
+
+if __name__ == '__main__':
+    main()
diff --git a/setup.py b/setup.py
index 0ef0e6db..4f8b4e98 100644
--- a/setup.py
+++ b/setup.py
@@ -20,7 +20,10 @@
         "sortedcontainers>=2.4.0",
         "owlready2>=0.40",
         "JPype1>=1.5.0",
-        "tqdm>=4.66.5"],
+        "tqdm>=4.66.5",
+        "fastapi>=0.115.5",
+        "httpx>=0.27.2",
+        "uvicorn>=0.32.1"],
     author='Caglar Demir',
     author_email='caglardemir8@gmail.com',
     url='https://github.com/dice-group/owlapy',
@@ -29,7 +32,7 @@
         "License :: OSI Approved :: MIT License",
         "Topic :: Scientific/Engineering"],
     python_requires='>=3.10.13',
-    entry_points={"console_scripts": ["owlapy=owlapy.scripts.run:main"]},
+    entry_points={"console_scripts": ["owlapy=owlapy.scripts.run:main", "owlapy-serve=owlapy.scripts.owlapy_serve:main"]},
     long_description=long_description,
     long_description_content_type="text/markdown",
 )


Test patch:
diff --git a/tests/test_owlapy_serve.py b/tests/test_owlapy_serve.py
new file mode 100644
index 00000000..2815dff6
--- /dev/null
+++ b/tests/test_owlapy_serve.py
@@ -0,0 +1,102 @@
+import pytest
+from unittest.mock import patch
+from fastapi.testclient import TestClient
+from owlapy.owl_ontology_manager import SyncOntologyManager
+from owlapy.owl_reasoner import SyncReasoner
+from owlapy.class_expression import OWLClass
+from owlapy.scripts.owlapy_serve import create_app
+from owlapy.scripts.owlapy_serve import InferenceType
+
+ontology_path = "KGs/Family/family-benchmark_rich_background.owl"
+reasoner_name = "HermiT"
+ontology = SyncOntologyManager().load_ontology(ontology_path)
+reasoner = SyncReasoner(ontology=ontology, reasoner=reasoner_name)
+
+@pytest.fixture()
+def mock_stop_jvm():
+    patcher = patch("owlapy.scripts.owlapy_serve.stopJVM")
+    patcher.start()
+    yield 
+    patcher.stop()
+
+def test_get_classes (mock_stop_jvm):
+    with TestClient(create_app(ontology_path, reasoner_name)) as client:
+        response = client.get("/classes")
+        expected_classes = [cls.__str__() for cls in ontology.classes_in_signature()]
+        assert response.status_code == 200
+        assert set(response.json()["classes"]) == set(expected_classes)
+
+def test_get_object_properties(mock_stop_jvm):
+    with TestClient(create_app(ontology_path, reasoner_name)) as client:
+        response = client.get("/object_properties")
+        expected_object_properties = [op.__str__() for op in ontology.object_properties_in_signature()]
+        assert response.status_code == 200
+        assert set(response.json()["object_properties"]) == set(expected_object_properties)
+
+def test_get_data_properties(mock_stop_jvm):
+    with TestClient(create_app(ontology_path, reasoner_name)) as client:
+        response = client.get("/data_properties")
+        expected_data_properties = [dp.__str__() for dp in ontology.data_properties_in_signature()]
+        assert response.status_code == 200
+        assert set(response.json()["data_properties"]) == set(expected_data_properties)
+
+def test_get_individuals(mock_stop_jvm):
+    with TestClient(create_app(ontology_path, reasoner_name)) as client:
+        response = client.get("/individuals")
+        expected_individuals = [ind.__str__() for ind in ontology.individuals_in_signature()]
+        assert response.status_code == 200
+        assert set(response.json()["individuals"]) == set(expected_individuals)
+
+def test_get_abox(mock_stop_jvm):
+    with TestClient(create_app(ontology_path, reasoner_name)) as client:
+        response = client.get("/abox")
+        expected_abox = [axiom.__str__() for axiom in ontology.get_abox_axioms()]
+        assert response.status_code == 200
+        assert set(response.json()["abox"]) == set(expected_abox)
+
+def test_get_tbox(mock_stop_jvm):
+    with TestClient(create_app(ontology_path, reasoner_name)) as client:
+        response = client.get("/tbox")
+        expected_tbox = [axiom.__str__() for axiom in ontology.get_tbox_axioms()]
+        assert response.status_code == 200
+        assert set(response.json()["tbox"]) == set(expected_tbox)
+
+def test_get_instances(mock_stop_jvm):
+    with TestClient(create_app(ontology_path, reasoner_name)) as client:
+        test_class_iri = "http://www.benchmark.org/family#Child"  
+        owl_class = OWLClass(test_class_iri)
+        expected_instances = [ind.__str__() for ind in reasoner.instances(owl_class, direct=False)]
+        response = client.post("/instances", json={"class_iri": test_class_iri})
+        assert response.status_code == 200
+        assert set(response.json()["instances"]) == set(expected_instances)
+
+def test_infer_axioms(mock_stop_jvm):
+    with TestClient(create_app(ontology_path, reasoner_name)) as client:
+        valid_inference_type = "InferredClassAssertionAxiomGenerator"
+        response = client.post(
+            "/infer_axioms", 
+            json={"inference_type": valid_inference_type}
+        )
+        assert response.status_code == 200
+        expected_axioms = [
+            axiom.__str__() for axiom in reasoner.infer_axioms(valid_inference_type)
+        ]
+        assert set(response.json()["inferred_axioms"]) == set(expected_axioms)
+        invalid_inference_type = "InvalidAxiomGenerator"
+        response = client.post(
+            "/infer_axioms", 
+            json={"inference_type": invalid_inference_type}
+        )
+        assert response.status_code == 422
+
+def test_infer_axioms_all(mock_stop_jvm):
+    with TestClient(create_app(ontology_path, reasoner_name)) as client:
+        response = client.post("/infer_axioms", json={"inference_type": "all"})
+        assert response.status_code == 200
+        
+        expected_axioms = []
+        for inference_type in InferenceType:
+            if inference_type != InferenceType.All:
+                expected_axioms.extend(reasoner.infer_axioms(inference_type.value))
+        
+        assert set(response.json()["inferred_axioms"]) == set([axiom.__str__() for axiom in expected_axioms])