Skip to content

Commit 5c81f84

Browse files
JohnRDOrazioclaude
andcommitted
fix: use query params for graph routes to avoid greedy :path capture
The {class_iri:path}/graph route pattern was broken because FastAPI's :path converter greedily captures /graph as part of the IRI. Move class_iri to a query parameter and reorder routes before path-parameter routes. Also removes the dead hierarchy endpoint (unimplemented stub), fixes mypy no-any-return errors with TYPE_CHECKING import, fixes ruff ARG001 unused depth param, and adds comprehensive tests for build_entity_graph. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 61ded69 commit 5c81f84

4 files changed

Lines changed: 558 additions & 119 deletions

File tree

ontokit/api/routes/classes.py

Lines changed: 33 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,39 @@ async def create_class(
5858
return await service.create_class(ontology_id, owl_class)
5959

6060

61+
@router.get(
62+
"/ontologies/{ontology_id}/classes/graph",
63+
response_model=EntityGraphResponse,
64+
)
65+
async def get_class_graph(
66+
ontology_id: UUID,
67+
service: Annotated[OntologyService, Depends(get_ontology_service)],
68+
class_iri: str = Query(description="IRI of the class to build the graph around"),
69+
branch: str = "main",
70+
ancestors_depth: int = Query(default=5, ge=0, le=10),
71+
descendants_depth: int = Query(default=2, ge=0, le=10),
72+
max_nodes: int = Query(default=200, ge=1, le=500),
73+
include_see_also: bool = True,
74+
) -> EntityGraphResponse:
75+
"""Build a multi-hop entity graph around a class via BFS.
76+
77+
Returns nodes and edges for visualization, with lineage-based node types
78+
for ontology-agnostic coloring (root, ancestor, focus, descendant, etc.).
79+
"""
80+
result = await service.build_entity_graph(
81+
ontology_id,
82+
class_iri,
83+
branch=branch,
84+
ancestors_depth=ancestors_depth,
85+
descendants_depth=descendants_depth,
86+
max_nodes=max_nodes,
87+
include_see_also=include_see_also,
88+
)
89+
if result is None:
90+
raise HTTPException(status_code=404, detail="Class not found")
91+
return result
92+
93+
6194
@router.get("/ontologies/{ontology_id}/classes/{class_iri:path}", response_model=OWLClassResponse)
6295
async def get_class(
6396
ontology_id: UUID,
@@ -98,59 +131,3 @@ async def delete_class(
98131
deleted = await service.delete_class(ontology_id, class_iri)
99132
if not deleted:
100133
raise HTTPException(status_code=404, detail="Class not found")
101-
102-
103-
@router.get("/ontologies/{ontology_id}/classes/{class_iri:path}/hierarchy")
104-
async def get_class_hierarchy(
105-
ontology_id: UUID,
106-
class_iri: str,
107-
service: Annotated[OntologyService, Depends(get_ontology_service)],
108-
direction: str = "both",
109-
depth: int = 3,
110-
) -> dict[str, object]:
111-
"""
112-
Get the class hierarchy around a specific class.
113-
114-
Args:
115-
direction: 'ancestors', 'descendants', or 'both'
116-
depth: Maximum depth to traverse
117-
"""
118-
return await service.get_class_hierarchy(
119-
ontology_id,
120-
class_iri,
121-
direction=direction,
122-
depth=depth,
123-
)
124-
125-
126-
@router.get(
127-
"/ontologies/{ontology_id}/classes/{class_iri:path}/graph",
128-
response_model=EntityGraphResponse,
129-
)
130-
async def get_class_graph(
131-
ontology_id: UUID,
132-
class_iri: str,
133-
service: Annotated[OntologyService, Depends(get_ontology_service)],
134-
branch: str = "main",
135-
ancestors_depth: int = Query(default=5, ge=0, le=10),
136-
descendants_depth: int = Query(default=2, ge=0, le=10),
137-
max_nodes: int = Query(default=200, ge=1, le=500),
138-
include_see_also: bool = True,
139-
) -> EntityGraphResponse:
140-
"""Build a multi-hop entity graph around a class via BFS.
141-
142-
Returns nodes and edges for visualization, with lineage-based node types
143-
for ontology-agnostic coloring (root, ancestor, focus, descendant, etc.).
144-
"""
145-
result = await service.build_entity_graph(
146-
ontology_id,
147-
class_iri,
148-
branch=branch,
149-
ancestors_depth=ancestors_depth,
150-
descendants_depth=descendants_depth,
151-
max_nodes=max_nodes,
152-
include_see_also=include_see_also,
153-
)
154-
if result is None:
155-
raise HTTPException(status_code=404, detail="Class not found")
156-
return result

ontokit/api/routes/projects.py

Lines changed: 41 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,47 @@ async def get_ontology_tree_children(
630630
return OWLClassTreeResponse(nodes=nodes, total_classes=total_classes)
631631

632632

633+
@router.get(
634+
"/{project_id}/ontology/classes/graph",
635+
response_model=EntityGraphResponse,
636+
)
637+
async def get_ontology_class_graph(
638+
project_id: UUID,
639+
service: Annotated[ProjectService, Depends(get_service)],
640+
ontology: Annotated[OntologyService, Depends(get_ontology)],
641+
git: Annotated[GitRepositoryService, Depends(get_git)],
642+
user: OptionalUser,
643+
class_iri: str = Query(description="IRI of the class to build the graph around"),
644+
branch: str | None = Query(default=None, description="Branch to read from"),
645+
ancestors_depth: int = Query(default=5, ge=0, le=10),
646+
descendants_depth: int = Query(default=2, ge=0, le=10),
647+
max_nodes: int = Query(default=200, ge=1, le=500),
648+
include_see_also: bool = Query(default=True),
649+
) -> EntityGraphResponse:
650+
"""Build a multi-hop entity graph around a class via BFS.
651+
652+
Returns nodes and edges for visualization, with lineage-based node types.
653+
"""
654+
resolved_branch = branch or git.get_default_branch(project_id)
655+
await _ensure_ontology_loaded(project_id, service, ontology, user, resolved_branch, git)
656+
657+
result = await ontology.build_entity_graph(
658+
project_id,
659+
class_iri,
660+
branch=resolved_branch,
661+
ancestors_depth=ancestors_depth,
662+
descendants_depth=descendants_depth,
663+
max_nodes=max_nodes,
664+
include_see_also=include_see_also,
665+
)
666+
if result is None:
667+
raise HTTPException(
668+
status_code=status.HTTP_404_NOT_FOUND,
669+
detail=f"Class not found: {class_iri}",
670+
)
671+
return result
672+
673+
633674
@router.get("/{project_id}/ontology/classes/{class_iri:path}", response_model=OWLClassResponse)
634675
async def get_ontology_class(
635676
project_id: UUID,
@@ -695,49 +736,6 @@ async def get_ontology_class_ancestors(
695736
return OWLClassTreeResponse(nodes=nodes, total_classes=total_classes)
696737

697738

698-
@router.get(
699-
"/{project_id}/ontology/classes/{class_iri:path}/graph",
700-
response_model=EntityGraphResponse,
701-
)
702-
async def get_ontology_class_graph(
703-
project_id: UUID,
704-
class_iri: str,
705-
service: Annotated[ProjectService, Depends(get_service)],
706-
ontology: Annotated[OntologyService, Depends(get_ontology)],
707-
git: Annotated[GitRepositoryService, Depends(get_git)],
708-
user: OptionalUser,
709-
branch: str | None = Query(default=None, description="Branch to read from"),
710-
ancestors_depth: int = Query(default=5, ge=0, le=10),
711-
descendants_depth: int = Query(default=2, ge=0, le=10),
712-
max_nodes: int = Query(default=200, ge=1, le=500),
713-
include_see_also: bool = Query(default=True),
714-
) -> EntityGraphResponse:
715-
"""Build a multi-hop entity graph around a class via BFS.
716-
717-
Returns nodes and edges for visualization, with lineage-based node types.
718-
"""
719-
resolved_branch = branch or git.get_default_branch(project_id)
720-
await _ensure_ontology_loaded(
721-
project_id, service, ontology, user, resolved_branch, git
722-
)
723-
724-
result = await ontology.build_entity_graph(
725-
project_id,
726-
class_iri,
727-
branch=resolved_branch,
728-
ancestors_depth=ancestors_depth,
729-
descendants_depth=descendants_depth,
730-
max_nodes=max_nodes,
731-
include_see_also=include_see_also,
732-
)
733-
if result is None:
734-
raise HTTPException(
735-
status_code=status.HTTP_404_NOT_FOUND,
736-
detail=f"Class not found: {class_iri}",
737-
)
738-
return result
739-
740-
741739
@router.get("/{project_id}/ontology/search", response_model=EntitySearchResponse)
742740
async def search_ontology_entities(
743741
project_id: UUID,

ontokit/services/ontology.py

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
"""Ontology service for managing OWL ontologies."""
22

3+
from __future__ import annotations
4+
35
from dataclasses import dataclass
4-
from typing import Any, cast
6+
from typing import TYPE_CHECKING, Any, cast
57
from typing import Literal as TypingLiteral
68
from uuid import UUID
79

@@ -33,6 +35,9 @@
3335
)
3436
from ontokit.services.storage import StorageService
3537

38+
if TYPE_CHECKING:
39+
from ontokit.schemas.graph import EntityGraphResponse
40+
3641
# Map file extensions to RDF formats
3742
FORMAT_MAP = {
3843
".owl": "xml",
@@ -120,7 +125,7 @@ class LabelPreference:
120125
language: str | None # None means any language or no language tag
121126

122127
@classmethod
123-
def parse(cls, pref_string: str) -> "LabelPreference | None":
128+
def parse(cls, pref_string: str) -> LabelPreference | None:
124129
"""
125130
Parse a preference string like 'rdfs:label@en' or 'skos:prefLabel'.
126131
@@ -340,17 +345,6 @@ async def delete_class(self, ontology_id: UUID, class_iri: str) -> bool:
340345
# TODO: Implement class deletion
341346
raise NotImplementedError("Class deletion pending")
342347

343-
async def get_class_hierarchy(
344-
self,
345-
ontology_id: UUID,
346-
class_iri: str,
347-
direction: str = "both",
348-
depth: int = 3,
349-
) -> dict[str, Any]:
350-
"""Get class hierarchy around a specific class."""
351-
# TODO: Implement hierarchy traversal
352-
raise NotImplementedError("Hierarchy implementation pending")
353-
354348
async def build_entity_graph(
355349
self,
356350
ontology_id: UUID,
@@ -361,7 +355,7 @@ async def build_entity_graph(
361355
max_nodes: int = 200,
362356
include_see_also: bool = True,
363357
max_see_also_per_node: int = 5,
364-
) -> "EntityGraphResponse | None":
358+
) -> EntityGraphResponse | None:
365359
"""Build a multi-hop graph around a class via BFS.
366360
367361
Traverses ancestors (subClassOf upward), descendants (subClassOf downward),
@@ -405,12 +399,13 @@ def _is_external(iri: str) -> bool:
405399

406400
def _is_root_class(uri: URIRef) -> bool:
407401
parents = [
408-
p for p in graph.objects(uri, RDFS.subClassOf)
402+
p
403+
for p in graph.objects(uri, RDFS.subClassOf)
409404
if isinstance(p, URIRef) and p != owl_thing
410405
]
411406
return len(parents) == 0
412407

413-
def _classify_node(uri: URIRef, is_focus: bool, depth: int) -> str:
408+
def _classify_node(uri: URIRef, is_focus: bool, _depth: int) -> str:
414409
iri = str(uri)
415410
if is_focus:
416411
return "focus"
@@ -419,7 +414,11 @@ def _classify_node(uri: URIRef, is_focus: bool, depth: int) -> str:
419414
# Check if individual (instance, not a class)
420415
if (uri, RDF.type, OWL.Class) not in graph:
421416
for rdf_type in graph.objects(uri, RDF.type):
422-
if rdf_type in (OWL.ObjectProperty, OWL.DatatypeProperty, OWL.AnnotationProperty):
417+
if rdf_type in (
418+
OWL.ObjectProperty,
419+
OWL.DatatypeProperty,
420+
OWL.AnnotationProperty,
421+
):
423422
return "property"
424423
return "individual"
425424
if _is_root_class(uri):
@@ -438,7 +437,8 @@ def _get_definition(uri: URIRef) -> str | None:
438437

439438
def _child_count(uri: URIRef) -> int:
440439
return sum(
441-
1 for s in graph.subjects(RDFS.subClassOf, uri)
440+
1
441+
for s in graph.subjects(RDFS.subClassOf, uri)
442442
if isinstance(s, URIRef) and (s, RDF.type, OWL.Class) in graph
443443
)
444444

@@ -449,7 +449,7 @@ def _make_node(uri: URIRef, depth: int) -> GraphNode | None:
449449
total_discovered[0] += 1
450450
if len(visited) >= max_nodes:
451451
return None
452-
is_focus = (uri == class_uri)
452+
is_focus = uri == class_uri
453453
node = GraphNode(
454454
id=iri,
455455
label=_get_label(uri),
@@ -469,7 +469,9 @@ def _add_edge(source: str, target: str, edge_type: str, label: str | None = None
469469
if eid in edge_ids:
470470
return
471471
edge_ids.add(eid)
472-
edges.append(GraphEdge(id=eid, source=source, target=target, edge_type=edge_type, label=label))
472+
edges.append(
473+
GraphEdge(id=eid, source=source, target=target, edge_type=edge_type, label=label)
474+
)
473475

474476
# Create focus node
475477
focus_node = _make_node(class_uri, 0)

0 commit comments

Comments
 (0)