Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ENH] Implemented new endpoint to return known node APIs #29

Merged
merged 8 commits into from
Nov 17, 2023
21 changes: 13 additions & 8 deletions app/api/crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,13 @@ async def get(
if image_modal:
params["image_modal"] = image_modal

for node_url in util.parse_nodes_as_list(util.NEUROBAGEL_NODES):
nodes_dict = util.parse_nodes_as_dict(util.NEUROBAGEL_NODES)
for node_url, node_name in nodes_dict.items():
response = util.send_get_request(node_url + "query/", params)

for result in response:
result["node_name"] = node_name

cross_node_results += response

return cross_node_results
Expand All @@ -85,16 +89,17 @@ async def get_terms(data_element_URI: str):
cross_node_results = []
params = {data_element_URI: data_element_URI}

for node_url in util.parse_nodes_as_list(util.NEUROBAGEL_NODES):
for node_url in util.parse_nodes_as_dict(util.NEUROBAGEL_NODES).keys():
response = util.send_get_request(
node_url + "attributes/" + data_element_URI, params
)

cross_node_results.append(response)

unique_terms = set(
term
for list_of_terms in cross_node_results
for term in list_of_terms[data_element_URI]
)
return {data_element_URI: list(unique_terms)}
unique_terms_dict = {}

for list_of_terms in cross_node_results:
for term in list_of_terms[data_element_URI]:
unique_terms_dict[term["TermURL"]] = term
rmanaem marked this conversation as resolved.
Show resolved Hide resolved

return {data_element_URI: list(unique_terms_dict.values())}
18 changes: 18 additions & 0 deletions app/api/models.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
"""Data models."""

from typing import Optional, Union

from pydantic import BaseModel

CONTROLLED_TERM_REGEX = r"^[a-zA-Z]+[:]\S+$"
Expand All @@ -15,3 +18,18 @@ class QueryModel(BaseModel):
min_num_sessions: int = None
assessment: str = None
image_modal: str = None


class CohortQueryResponse(BaseModel):
"""Data model for query results for one matching dataset (i.e., a cohort)."""

node_name: str
dataset_uuid: str
# dataset_file_path: str # TODO: Revisit this field once we have datasets without imaging info/sessions.
dataset_name: str
dataset_portal_uri: Optional[str]
dataset_total_subjects: int
records_protected: bool
num_matching_subjects: int
subject_data: Union[list[dict], str]
image_modals: list
14 changes: 14 additions & 0 deletions app/api/routers/nodes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from fastapi import APIRouter

from .. import utility as util

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


@router.get("/")
async def get_nodes():
"""Returns a dict of all available nodes apis where key is node URL and value is node name."""
return [
{"NodeName": v, "ApiURL": k}
for k, v in util.parse_nodes_as_dict(util.NEUROBAGEL_NODES).items()
]
6 changes: 4 additions & 2 deletions app/api/routers/query.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
"""Router for query path operations."""

from typing import List

from fastapi import APIRouter, Depends

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

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


@router.get("/")
@router.get("/", response_model=List[CohortQueryResponse])
async def get_query(query: QueryModel = Depends(QueryModel)):
"""When a GET request is sent, return list of dicts corresponding to subject-level metadata aggregated by dataset."""
response = await crud.get(
Expand Down
24 changes: 15 additions & 9 deletions app/api/utility.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,29 @@
"""Constants for federation."""

import os
import re

import httpx
from fastapi import HTTPException

# Neurobagel nodes
NEUROBAGEL_NODES = os.environ.get("NB_NODES", "https://api.neurobagel.org/")
NEUROBAGEL_NODES = os.environ.get(
"LOCAL_NB_NODES", "(https://api.neurobagel.org/, OpenNeuro)"
)


def parse_nodes_as_list(nodes: str) -> list:
"""Returns user-defined Neurobagel nodes as a list.
Empty strings are filtered out, because they are falsy.
def parse_nodes_as_dict(nodes: str) -> list:
"""Returns user-defined Neurobagel nodes as a dict.
It uses a regular expression to match the url, name pairs.
Makes sure node URLs end with a slash."""
nodes_list = nodes.split(" ")
for i in range(len(nodes_list)):
if nodes_list[i] and not nodes_list[i].endswith("/"):
nodes_list[i] += "/"
return list(filter(None, nodes_list))
pattern = re.compile(r"\((?P<url>https?://[^\s]+), (?P<label>[^\)]+)\)")
matches = pattern.findall(nodes)
for i in range(len(matches)):
url, label = matches[i]
if not url.endswith("/"):
matches[i] = (url + "/", label)
nodes_dict = {url: label for url, label in matches}
return nodes_dict


def send_get_request(url: str, params: list):
Expand Down
3 changes: 2 additions & 1 deletion app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
from fastapi.responses import ORJSONResponse, RedirectResponse

from .api.routers import attributes, query
from .api.routers import attributes, nodes, query

app = FastAPI(
default_response_class=ORJSONResponse, docs_url=None, redoc_url=None
Expand Down Expand Up @@ -56,6 +56,7 @@ def overridden_redoc():

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

# Automatically start uvicorn server on execution of main.py
if __name__ == "__main__":
Expand Down
33 changes: 19 additions & 14 deletions tests/test_utility.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,30 +9,35 @@
"set_nodes, expected_nodes",
[
(
"https://firstnode.neurobagel.org/query",
["https://firstnode.neurobagel.org/query/"],
"(https://firstnode.neurobagel.org/query, firstnode)",
{"https://firstnode.neurobagel.org/query/": "firstnode"},
),
(
"https://firstnode.neurobagel.org/query/ https://secondnode.neurobagel.org/query",
[
"https://firstnode.neurobagel.org/query/",
"https://secondnode.neurobagel.org/query/",
],
"(https://firstnode.neurobagel.org/query/, firstnode) (https://secondnode.neurobagel.org/query, secondnode)",
{
"https://firstnode.neurobagel.org/query/": "firstnode",
"https://secondnode.neurobagel.org/query/": "secondnode",
},
),
(
" https://firstnode.neurobagel.org/query/ https://secondnode.neurobagel.org/query/ ",
[
"https://firstnode.neurobagel.org/query/",
"https://secondnode.neurobagel.org/query/",
],
"(firstnode.neurobagel.org/query/, firstnode) (https://secondnode.neurobagel.org/query, secondnode)",
{
"https://secondnode.neurobagel.org/query/": "secondnode",
},
rmanaem marked this conversation as resolved.
Show resolved Hide resolved
),
(
"( , firstnode) (https://secondnode.neurobagel.org/query, secondnode)",
rmanaem marked this conversation as resolved.
Show resolved Hide resolved
{
"https://secondnode.neurobagel.org/query/": "secondnode",
},
),
],
)
def test_parse_nodes_as_list(monkeypatch, set_nodes, expected_nodes):
def test_parse_nodes_as_dict(monkeypatch, set_nodes, expected_nodes):
"""Test that Neurobagel node URLs provided in a string environment variable are correctly parseed into a list."""
monkeypatch.setenv("NB_NODES", set_nodes)
assert sorted(
util.parse_nodes_as_list(
util.parse_nodes_as_dict(
os.environ.get("NB_NODES", "https://api.neurobagel.org/query/")
)
) == sorted(expected_nodes)