Skip to content

Commit a37055e

Browse files
authored
[ENH] Add /imaging-modalities route for getting available imaging modality term instances & labels (#513)
* feat: add imaging modalities endpoints and term metadata support * feat: wire imaging modalities router into app * feat: include imaging metadata in term instance responses * feat: add imaging modality enum entry * test: add tests for imaging modalities routes * fix: fetch imaging modalities separetely * chore: addressed PR review comments
1 parent df58307 commit a37055e

File tree

5 files changed

+163
-18
lines changed

5 files changed

+163
-18
lines changed

app/api/crud.py

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from . import sparql_models
1212
from . import utility as util
1313
from .env_settings import settings
14-
from .models import QueryModel, SessionResponse
14+
from .models import DataElementURI, QueryModel, SessionResponse
1515

1616
ALL_SUBJECT_ATTRIBUTES = list(SessionResponse.model_fields.keys()) + [
1717
"dataset_uuid",
@@ -415,7 +415,8 @@ async def get_terms(
415415
dict
416416
Dictionary where the key is the Neurobagel class and the value is a list of dictionaries
417417
corresponding to the available (i.e. used) instances of that class in the graph. Each instance dictionary
418-
has two items: the 'TermURL' and the human-readable 'Label' for the term.
418+
contains the 'TermURL' and the human-readable 'Label' for the term, and may include additional
419+
metadata fields (e.g., 'abbreviation', 'data_type' for imaging modalities) when available.
419420
"""
420421
db_results = await post_query_to_graph(
421422
util.create_terms_query(data_element_URI)
@@ -424,7 +425,7 @@ async def get_terms(
424425
if std_trm_vocab is None:
425426
std_trm_vocab = []
426427

427-
term_label_dicts = []
428+
term_metadata = []
428429
for result in db_results:
429430
term_url = result["termURL"]
430431
# First, check whether the found instance of the standardized variable contains a recognized namespace
@@ -443,29 +444,31 @@ async def get_terms(
443444
),
444445
[],
445446
)
446-
term_label = next(
447-
(
448-
term["name"]
449-
for term in namespace_terms
450-
if term["id"] == term_id
451-
),
447+
matched_term = next(
448+
(term for term in namespace_terms if term["id"] == term_id),
452449
None,
453450
)
454-
term_label_dicts.append(
455-
{
456-
"TermURL": util.replace_namespace_uri_with_prefix(
457-
term_url
458-
),
459-
"Label": term_label,
460-
}
461-
)
451+
term_entry = {
452+
"TermURL": util.replace_namespace_uri_with_prefix(term_url),
453+
"Label": matched_term.get("name") if matched_term else None,
454+
}
455+
if data_element_URI == DataElementURI.image.value:
456+
term_entry["Abbreviation"] = (
457+
matched_term.get("abbreviation", None)
458+
if matched_term
459+
else None
460+
)
461+
term_entry["DataType"] = (
462+
matched_term.get("data_type") if matched_term else None
463+
)
464+
term_metadata.append(term_entry)
462465
else:
463466
warnings.warn(
464467
f"The controlled term {term_url} was found in the graph but does not come from a vocabulary recognized by Neurobagel."
465468
"This term will be ignored."
466469
)
467470

468-
term_instances = {data_element_URI: term_label_dicts}
471+
term_instances = {data_element_URI: term_metadata}
469472

470473
return term_instances
471474

app/api/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ class DataElementURI(str, Enum):
142142

143143
assessment = "nb:Assessment"
144144
diagnosis = "nb:Diagnosis"
145+
image = "nb:Image"
145146

146147

147148
class StandardizedTermVocabularyNamespace(BaseModel):
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from fastapi import APIRouter
2+
3+
from ..models import DataElementURI, StandardizedTermVocabularyResponse
4+
from . import route_factory
5+
6+
router = APIRouter(prefix="/imaging-modalities", tags=["imaging-modalities"])
7+
8+
router.add_api_route(
9+
path="",
10+
endpoint=route_factory.create_get_instances_handler(
11+
data_element_uri=DataElementURI.image.value
12+
),
13+
methods=["GET"],
14+
)
15+
router.add_api_route(
16+
path="/vocab",
17+
endpoint=route_factory.create_get_vocab_handler(
18+
data_element_uri=DataElementURI.image.value
19+
),
20+
methods=["GET"],
21+
response_model=StandardizedTermVocabularyResponse,
22+
)

app/main.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
attributes,
1818
datasets,
1919
diagnoses,
20+
imaging_modalities,
2021
pipelines,
2122
query,
2223
subjects,
@@ -104,6 +105,21 @@ def fetch_vocabularies(config_name: str) -> dict:
104105
)
105106
all_std_trm_vocabs[var_uri] = std_trm_vocab
106107

108+
# The imaging modalities vocab is not configurable but is still an external file we need to fetch.
109+
# Since it is not configurable across communities, the vocab file is not listed in config.json under a standardized variable.
110+
# So, for now we always fetch it from the Neurobagel config directory.
111+
# TODO revisit the prefix for this specific variable once we support custom standardized variables.
112+
imaging_vocab_uri = f"{std_var_config['namespace_prefix']}:Image"
113+
imaging_vocab_url = util.create_gh_raw_content_url(
114+
env_settings.NEUROBAGEL_CONFIG_REPO,
115+
"configs/Neurobagel/imaging_modalities.json",
116+
)
117+
imaging_vocab = util.request_data(
118+
imaging_vocab_url,
119+
f"Failed to fetch standardized term vocabulary for {imaging_vocab_uri}.",
120+
)
121+
all_std_trm_vocabs[imaging_vocab_uri] = imaging_vocab
122+
107123
return all_std_trm_vocabs
108124

109125

@@ -282,6 +298,7 @@ def overridden_redoc(request: Request):
282298
app.include_router(attributes.router)
283299
app.include_router(assessments.router)
284300
app.include_router(diagnoses.router)
301+
app.include_router(imaging_modalities.router)
285302
app.include_router(pipelines.router)
286303

287304
# Automatically start uvicorn server on execution of main.py

tests/test_attribute_factory_routes.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import pytest
33

44
from app.api import env_settings
5+
from app.api.models import DataElementURI
56

67

78
def test_get_instances_endpoint_with_vocab_lookup(
@@ -88,6 +89,90 @@ async def mock_httpx_post(self, **kwargs):
8889
}
8990

9091

92+
def test_get_imaging_modalities_with_vocab_lookup(
93+
test_app,
94+
monkeypatch,
95+
disable_auth,
96+
mock_context,
97+
):
98+
"""
99+
Given a GET request to /imaging-modalities, test that the endpoint returns graph instances
100+
with labels and imaging-specific metadata (abbreviation, data_type) from the vocabulary.
101+
"""
102+
monkeypatch.setattr(
103+
env_settings,
104+
"ALL_VOCABS",
105+
{
106+
DataElementURI.image.value: [
107+
{
108+
"namespace_prefix": "nidm",
109+
"namespace_url": "http://purl.org/nidash/nidm#",
110+
"vocabulary_name": "Test vocabulary of imaging modalities",
111+
"version": "1.0.0",
112+
"terms": [
113+
{
114+
"id": "T1Weighted",
115+
"name": "T1-weighted image",
116+
"abbreviation": "T1w",
117+
"data_type": "anat",
118+
},
119+
{
120+
"id": "FlowWeighted",
121+
"name": "Blood-Oxygen-Level Dependent image",
122+
"abbreviation": "bold",
123+
"data_type": "func",
124+
},
125+
],
126+
}
127+
]
128+
},
129+
)
130+
131+
mock_response_json = {
132+
"head": {"vars": ["termURL"]},
133+
"results": {
134+
"bindings": [
135+
{
136+
"termURL": {
137+
"type": "uri",
138+
"value": "http://purl.org/nidash/nidm#T1Weighted",
139+
}
140+
},
141+
{
142+
"termURL": {
143+
"type": "uri",
144+
"value": "http://purl.org/nidash/nidm#FlowWeighted",
145+
}
146+
},
147+
]
148+
},
149+
}
150+
151+
async def mock_httpx_post(self, **kwargs):
152+
return httpx.Response(status_code=200, json=mock_response_json)
153+
154+
monkeypatch.setattr(httpx.AsyncClient, "post", mock_httpx_post)
155+
156+
response = test_app.get("/imaging-modalities")
157+
158+
assert response.json() == {
159+
"nb:Image": [
160+
{
161+
"TermURL": "nidm:T1Weighted",
162+
"Label": "T1-weighted image",
163+
"Abbreviation": "T1w",
164+
"DataType": "anat",
165+
},
166+
{
167+
"TermURL": "nidm:FlowWeighted",
168+
"Label": "Blood-Oxygen-Level Dependent image",
169+
"Abbreviation": "bold",
170+
"DataType": "func",
171+
},
172+
]
173+
}
174+
175+
91176
def test_get_instances_endpoint_without_vocab_lookup(
92177
test_app,
93178
monkeypatch,
@@ -146,6 +231,7 @@ async def mock_httpx_post(self, **kwargs):
146231
[
147232
("assessments", "nb:Assessment"),
148233
("diagnoses", "nb:Diagnosis"),
234+
("imaging-modalities", "nb:Image"),
149235
],
150236
)
151237
def test_get_vocab_endpoint(
@@ -190,6 +276,22 @@ def test_get_vocab_endpoint(
190276
],
191277
},
192278
],
279+
"nb:Image": [
280+
{
281+
"namespace_prefix": "nidm",
282+
"namespace_url": "http://purl.org/nidash/nidm#",
283+
"vocabulary_name": "Test vocab of imaging modalities",
284+
"version": "1.0.0",
285+
"terms": [
286+
{
287+
"id": "T1Weighted",
288+
"name": "T1-weighted image",
289+
"Abbreviation": "T1w",
290+
"DataType": "anat",
291+
}
292+
],
293+
}
294+
],
193295
}
194296

195297
monkeypatch.setattr(env_settings, "ALL_VOCABS", mock_all_vocabs)

0 commit comments

Comments
 (0)