Skip to content

Commit

Permalink
[ENH] Add endpoint returning queryable attributes + refine existing e…
Browse files Browse the repository at this point in the history
…ndpoint for attribute instances (#194)

* create router and GET path operation for fetching attribute classes

* add CRUD function for new /attributes/ endpoint

* move endpoint for getting terms to behind the /attributes endpoint

* switch response to a list

* add test of new endpoint response

* update terms query string to return only term IRIs for controlled term subclasses

* add utility for abbreviating namespaces in term URLs

* test namespace abbrieviation behaviour in the API response
  • Loading branch information
alyssadai committed Oct 3, 2023
1 parent 03f5d5d commit 8a5efa1
Show file tree
Hide file tree
Showing 7 changed files with 161 additions and 21 deletions.
45 changes: 44 additions & 1 deletion app/api/crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,9 +192,52 @@ async def get_terms(data_element_URI: str):

results_dict = {
data_element_URI: [
result["termURL"]["value"]
util.replace_namespace_uri(result["termURL"]["value"])
for result in results["results"]["bindings"]
]
}

return results_dict


async def get_controlled_term_attributes():
"""
Makes a POST query to Stardog API for all Neurobagel classes representing controlled term attributes.
Returns
-------
dict
Dictionary with value corresponding to all available controlled term attributes.
"""
attributes_query = f"""
{util.DEFAULT_CONTEXT}
SELECT DISTINCT ?attribute
WHERE {{
?attribute rdfs:subClassOf nb:ControlledTerm .
}}
"""

response = httpx.post(
url=util.QUERY_URL,
content=attributes_query,
headers=util.QUERY_HEADER,
auth=httpx.BasicAuth(
os.environ.get(util.GRAPH_USERNAME.name),
os.environ.get(util.GRAPH_PASSWORD.name),
),
)

if not response.is_success:
raise HTTPException(
status_code=response.status_code,
detail=f"{response.reason_phrase}: {response.text}",
)

results = response.json()
results_list = [
util.replace_namespace_uri(result["attribute"]["value"])
for result in results["results"]["bindings"]
]

return results_list
23 changes: 23 additions & 0 deletions app/api/routers/attributes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from fastapi import APIRouter
from pydantic import constr

from .. import crud
from ..models import CONTROLLED_TERM_REGEX

router = APIRouter(prefix="/attributes", tags=["attributes"])


@router.get("/{data_element_URI}")
async def get_terms(data_element_URI: constr(regex=CONTROLLED_TERM_REGEX)):
"""When a GET request is sent, return a dict with the only key corresponding to controlled term of a neurobagel class and value corresponding to all the available terms."""
response = await crud.get_terms(data_element_URI)

return response


@router.get("/", response_model=list)
async def get_attributes():
"""When a GET request is sent, return a list of the harmonized controlled term attributes."""
response = await crud.get_controlled_term_attributes()

return response
11 changes: 1 addition & 10 deletions app/api/routers/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,9 @@
from typing import List

from fastapi import APIRouter, Depends
from pydantic import constr

from .. import crud
from ..models import CONTROLLED_TERM_REGEX, CohortQueryResponse, QueryModel
from ..models import CohortQueryResponse, QueryModel

router = APIRouter(prefix="/query", tags=["query"])

Expand All @@ -26,11 +25,3 @@ async def get_query(query: QueryModel = Depends(QueryModel)):
)

return response


@router.get("/attributes/{data_element_URI}")
async def get_terms(data_element_URI: constr(regex=CONTROLLED_TERM_REGEX)):
"""When a GET request is sent, return a dict with the only key corresponding to controlled term of a neurobagel class and value corresponding to all the available terms."""
response = await crud.get_terms(data_element_URI)

return response
36 changes: 35 additions & 1 deletion app/api/utility.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
}

# SPARQL query context
# TODO: Refactor into a function.
DEFAULT_CONTEXT = """
PREFIX cogatlas: <https://www.cognitiveatlas.org/task/id/>
PREFIX nb: <http://neurobagel.org/vocab/>
Expand All @@ -47,6 +48,15 @@
PREFIX snomed: <http://purl.bioontology.org/ontology/SNOMEDCT/>
"""

CONTEXT = {
"cogatlas": "https://www.cognitiveatlas.org/task/id/",
"nb": "http://neurobagel.org/vocab/",
"nbg": "http://neurobagel.org/graph/", # TODO: Check if we still need this namespace.
"ncit": "http://ncicb.nci.nih.gov/xml/owl/EVS/Thesaurus.owl#",
"nidm": "http://purl.org/nidash/nidm#",
"snomed": "http://purl.bioontology.org/ontology/SNOMEDCT/",
}

# Store domains in named tuples
Domain = namedtuple("Domain", ["var", "pred"])
# Core domains
Expand Down Expand Up @@ -219,6 +229,7 @@ def create_terms_query(data_element_URI: str) -> str:
-------
str
The SPARQL query.
Examples
--------
get_terms_query("nb:Assessment")
Expand All @@ -227,8 +238,31 @@ def create_terms_query(data_element_URI: str) -> str:
query_string = f"""
SELECT DISTINCT ?termURL
WHERE {{
?termURL a {data_element_URI}.
?termURL a {data_element_URI} .
{data_element_URI} rdfs:subClassOf nb:ControlledTerm .
}}
"""

return "\n".join([DEFAULT_CONTEXT, query_string])


def replace_namespace_uri(url: str) -> str:
"""
Replaces namespace URIs in term URLs with corresponding prefixes from the context.
Parameters
----------
url : str
A controlled term URL.
Returns
-------
str
The term with namespace URIs replaced with prefixes if found in the context, or the original URL.
"""
for prefix, uri in CONTEXT.items():
if uri in url:
return url.replace(uri, f"{prefix}:")

# If no match found within the context, return original URL
return url
3 changes: 2 additions & 1 deletion app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from fastapi.responses import ORJSONResponse

from .api import utility as util
from .api.routers import query
from .api.routers import attributes, query

app = FastAPI(default_response_class=ORJSONResponse)

Expand Down Expand Up @@ -48,6 +48,7 @@ async def allowed_origins_check():


app.include_router(query.router)
app.include_router(attributes.router)

# Automatically start uvicorn server on execution of main.py
if __name__ == "__main__":
Expand Down
10 changes: 5 additions & 5 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,11 +106,11 @@ def terms_test_data():
"""Create toy data for terms for testing."""
return {
"nb:NeurobagelClass": [
"http://neurobagel.org/vocab/term1",
"http://neurobagel.org/vocab/term2",
"http://neurobagel.org/vocab/term3",
"http://neurobagel.org/vocab/term4",
"http://neurobagel.org/vocab/term5",
"nb:term1",
"nb:term2",
"nb:term3",
"nb:term4",
"nb:term5",
]
}

Expand Down
54 changes: 51 additions & 3 deletions tests/test_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,7 @@ def test_get_terms_valid_data_element_URI(
"""Given a valid data element URI, returns a 200 status code and a non-empty list of terms for that data element."""

monkeypatch.setattr(crud, "get_terms", mock_successful_get_terms)
response = test_app.get(f"/query/attributes/{valid_data_element_URI}")
response = test_app.get(f"/attributes/{valid_data_element_URI}")
assert response.status_code == 200
first_key = next(iter(response.json()))
assert response.json()[first_key] != []
Expand All @@ -417,9 +417,57 @@ def test_get_terms_valid_data_element_URI(
["apple", "some_thing:cool"],
)
def test_get_terms_invalid_data_element_URI(
test_app, invalid_data_element_URI, monkeypatch
test_app, invalid_data_element_URI
):
"""Given an invalid data element URI, returns a 422 status code as the validation of the data element URI fails."""

response = test_app.get(f"/query/attributes/{invalid_data_element_URI}")
response = test_app.get(f"/attributes/{invalid_data_element_URI}")
assert response.status_code == 422


def test_get_attributes(
test_app,
monkeypatch,
):
"""Given a GET request to the /attributes/ endpoint, successfully returns controlled term attributes with namespaces abbrieviated and as a list."""

monkeypatch.setenv(util.GRAPH_USERNAME.name, "SomeUser")
monkeypatch.setenv(util.GRAPH_PASSWORD.name, "SomePassword")

mock_response_json = {
"head": {"vars": ["attribute"]},
"results": {
"bindings": [
{
"attribute": {
"type": "uri",
"value": "http://neurobagel.org/vocab/ControlledTerm1",
}
},
{
"attribute": {
"type": "uri",
"value": "http://neurobagel.org/vocab/ControlledTerm2",
}
},
{
"attribute": {
"type": "uri",
"value": "http://neurobagel.org/vocab/ControlledTerm3",
}
},
]
},
}

def mock_httpx_post(**kwargs):
return httpx.Response(status_code=200, json=mock_response_json)

monkeypatch.setattr(httpx, "post", mock_httpx_post)
response = test_app.get("/attributes/")

assert response.json() == [
"nb:ControlledTerm1",
"nb:ControlledTerm2",
"nb:ControlledTerm3",
]

0 comments on commit 8a5efa1

Please sign in to comment.