From 73f585ce2a95b12c48a9bdc40aff2bf0a46e3287 Mon Sep 17 00:00:00 2001 From: Edmond Chuc Date: Mon, 31 Jul 2023 16:25:03 +1000 Subject: [PATCH 1/6] Initial /count endpoint --- prez/routers/object.py | 42 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/prez/routers/object.py b/prez/routers/object.py index 7a76966e..5ea519e2 100644 --- a/prez/routers/object.py +++ b/prez/routers/object.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Request +from fastapi import APIRouter, Request, HTTPException, status, Query from starlette.responses import PlainTextResponse from prez.models import SpatialItem, VocabItem, CatalogItem @@ -6,6 +6,42 @@ router = APIRouter(tags=["Object"]) +@router.get("/count", summary="Get object's statement count") +def count_route( + curie: str, + inbound: str = Query( + None, + examples={ + "skos:inScheme": { + "summary": "skos:inScheme", + "value": "http://www.w3.org/2004/02/skos/core#inScheme", + }, + "skos:topConceptOf": { + "summary": "skos:topConceptOf", + "value": "http://www.w3.org/2004/02/skos/core#topConceptOf", + }, + }, + ), + outbound: str = Query( + None, + examples={ + "skos:inScheme": { + "summary": "skos:hasTopConcept", + "value": "http://www.w3.org/2004/02/skos/core#hasTopConcept", + }, + }, + ), +): + """Get an Object's statements count based on the inbound or outbound predicate""" + if inbound is None and outbound is None: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + "At least 'inbound' or 'outbound' is supplied a valid IRI.", + ) + + return f"{curie} {inbound} {outbound}" + + @router.get("/object", summary="Object") async def object( request: Request, @@ -29,7 +65,9 @@ async def object( try: item = prez_items[prez](uri=uri, url_path="/object") returned_items[prez] = item - except Exception: # will get exception if URI does not exist with classes in prez flavour's SPARQL endpoint + except ( + Exception + ): # will get exception if URI does not exist with classes in prez flavour's SPARQL endpoint pass if len(returned_items) == 0: return PlainTextResponse( From d48bde0947eb629e324c3010894af391095585df Mon Sep 17 00:00:00 2001 From: Edmond Chuc Date: Mon, 31 Jul 2023 20:22:58 +1000 Subject: [PATCH 2/6] Add count endpoint --- prez/queries/object.py | 33 +++++++++++++++++ prez/routers/object.py | 26 ++++++++++++-- tests/object/test_count.py | 73 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 prez/queries/object.py create mode 100644 tests/object/test_count.py diff --git a/prez/queries/object.py b/prez/queries/object.py new file mode 100644 index 00000000..3ed69a5b --- /dev/null +++ b/prez/queries/object.py @@ -0,0 +1,33 @@ +from textwrap import dedent + +from jinja2 import Template + + +def object_inbound_query(iri: str, predicate: str) -> str: + query = Template( + """ + SELECT (COUNT(?iri) as ?count) + WHERE { + BIND(<{{ iri }}> as ?iri) + + ?other <{{ predicate }}> ?iri . + } + """ + ).render(iri=iri, predicate=predicate) + + return dedent(query) + + +def object_outbound_query(iri: str, predicate: str) -> str: + query = Template( + """ + SELECT (COUNT(?iri) as ?count) + WHERE { + BIND(<{{ iri }}> as ?iri) + + ?iri <{{ predicate }}> ?other . + } + """ + ).render(iri=iri, predicate=predicate) + + return dedent(query) diff --git a/prez/routers/object.py b/prez/routers/object.py index 5ea519e2..82e793c9 100644 --- a/prez/routers/object.py +++ b/prez/routers/object.py @@ -2,6 +2,9 @@ from starlette.responses import PlainTextResponse from prez.models import SpatialItem, VocabItem, CatalogItem +from prez.routers.curie import get_iri_route +from prez.sparql.methods import sparql_query_non_async +from prez.queries.object import object_inbound_query, object_outbound_query router = APIRouter(tags=["Object"]) @@ -20,12 +23,14 @@ def count_route( "summary": "skos:topConceptOf", "value": "http://www.w3.org/2004/02/skos/core#topConceptOf", }, + "empty": {"summary": "Empty", "value": None}, }, ), outbound: str = Query( None, examples={ - "skos:inScheme": { + "empty": {"summary": "Empty", "value": None}, + "skos:hasTopConcept": { "summary": "skos:hasTopConcept", "value": "http://www.w3.org/2004/02/skos/core#hasTopConcept", }, @@ -33,13 +38,30 @@ def count_route( ), ): """Get an Object's statements count based on the inbound or outbound predicate""" + iri = get_iri_route(curie) + if inbound is None and outbound is None: raise HTTPException( status.HTTP_400_BAD_REQUEST, "At least 'inbound' or 'outbound' is supplied a valid IRI.", ) - return f"{curie} {inbound} {outbound}" + if inbound and outbound: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + "Only provide one value for either 'inbound' or 'outbound', not both.", + ) + + if inbound is not None: + query = object_inbound_query(iri, inbound) + _, rows = sparql_query_non_async(query) + for row in rows: + return row["count"]["value"] + + query = object_outbound_query(iri, outbound) + _, rows = sparql_query_non_async(query) + for row in rows: + return row["count"]["value"] @router.get("/object", summary="Object") diff --git a/tests/object/test_count.py b/tests/object/test_count.py new file mode 100644 index 00000000..ee45683c --- /dev/null +++ b/tests/object/test_count.py @@ -0,0 +1,73 @@ +import os +import subprocess +from time import sleep + +import pytest +from fastapi.testclient import TestClient + +PREZ_DIR = os.getenv("PREZ_DIR") +LOCAL_SPARQL_STORE = os.getenv("LOCAL_SPARQL_STORE") + + +@pytest.fixture(scope="module") +def test_client(request): + print("Run Local SPARQL Store") + p1 = subprocess.Popen(["python", str(LOCAL_SPARQL_STORE), "-p", "3031"]) + sleep(1) + + def teardown(): + print("\nDoing teardown") + p1.kill() + + request.addfinalizer(teardown) + + # must only import app after config.py has been altered above so config is retained + from prez.app import app + + return TestClient(app) + + +def get_curie(test_client: TestClient, iri: str) -> str: + with test_client as client: + response = client.get(f"/identifier/curie/{iri}") + if response.status_code != 200: + raise ValueError(f"Failed to retrieve curie for {iri}. {response.text}") + return response.text + + +@pytest.mark.parametrize( + "iri, inbound, outbound, count", + [ + [ + "http://linked.data.gov.au/def/borehole-purpose", + "http://www.w3.org/2004/02/skos/core#inScheme", + None, + 0, + ], + [ + "http://linked.data.gov.au/def/borehole-purpose-no-children", + "http://www.w3.org/2004/02/skos/core#inScheme", + None, + 0, + ], + [ + "http://linked.data.gov.au/def/borehole-purpose", + None, + "http://www.w3.org/2004/02/skos/core#hasTopConcept", + 0, + ], + ], +) +def test_count( + test_client: TestClient, + iri: str, + inbound: str | None, + outbound: str | None, + count: int, +): + curie = get_curie(test_client, iri) + + with test_client as client: + params = {"curie": curie, "inbound": inbound, "outbound": outbound} + response = client.get(f"/count", params=params) + assert int(response.text) == count From d671317e220d73c82ec1df1b8f5ba7c7f2c01504 Mon Sep 17 00:00:00 2001 From: Edmond Chuc Date: Mon, 31 Jul 2023 21:13:30 +1000 Subject: [PATCH 3/6] Run tests under object dir --- .github/workflows/on_push_to_feature.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/on_push_to_feature.yaml b/.github/workflows/on_push_to_feature.yaml index c37b07f7..5fec49f7 100644 --- a/.github/workflows/on_push_to_feature.yaml +++ b/.github/workflows/on_push_to_feature.yaml @@ -61,4 +61,5 @@ jobs: cd ../catprez && poetry run pytest cd ../profiles && poetry run pytest cd ../services && poetry run pytest + cd ../object && poetry run pytest # cd ../local_sparql_store && poetry run pytest From 185ae6fb42e253b34e08d953b6b1ee3b13e05f3e Mon Sep 17 00:00:00 2001 From: Edmond Chuc Date: Mon, 31 Jul 2023 21:57:50 +1000 Subject: [PATCH 4/6] Revert "Run tests under object dir" This reverts commit d671317e220d73c82ec1df1b8f5ba7c7f2c01504. --- .github/workflows/on_push_to_feature.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/on_push_to_feature.yaml b/.github/workflows/on_push_to_feature.yaml index 5fec49f7..c37b07f7 100644 --- a/.github/workflows/on_push_to_feature.yaml +++ b/.github/workflows/on_push_to_feature.yaml @@ -61,5 +61,4 @@ jobs: cd ../catprez && poetry run pytest cd ../profiles && poetry run pytest cd ../services && poetry run pytest - cd ../object && poetry run pytest # cd ../local_sparql_store && poetry run pytest From 02336414edc62bd42ab35b4619812f81bb5e6f22 Mon Sep 17 00:00:00 2001 From: Edmond Chuc Date: Tue, 1 Aug 2023 15:59:08 +1000 Subject: [PATCH 5/6] Add /v/vocab/{curie}/all endpoint --- prez/models/vocprez_item.py | 3 +-- prez/routers/vocprez.py | 5 +++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/prez/models/vocprez_item.py b/prez/models/vocprez_item.py index c1d22461..2f7f76d6 100644 --- a/prez/models/vocprez_item.py +++ b/prez/models/vocprez_item.py @@ -29,7 +29,6 @@ def __hash__(self): @root_validator def populate(cls, values): url_path = values.get("url_path") - uri = values.get("uri") concept_curie = values.get("concept_curie") scheme_curie = values.get("scheme_curie") collection_curie = values.get("collection_curie") @@ -38,7 +37,7 @@ def populate(cls, values): return values if url_path in ["/object", "/v/object"]: values["link_constructor"] = f"/v/object?uri=" - elif len(url_parts) == 5: # concepts + elif len(url_parts) == 5 and "/all" not in url_path: # concepts values["general_class"] = SKOS.Concept if scheme_curie: values["curie_id"] = concept_curie diff --git a/prez/routers/vocprez.py b/prez/routers/vocprez.py index 46b13554..019bd685 100644 --- a/prez/routers/vocprez.py +++ b/prez/routers/vocprez.py @@ -74,6 +74,11 @@ async def schemes_endpoint( ) +@router.get("/v/vocab/{scheme_curie}/all", summary="Get ConceptScheme") +async def vocprez_scheme(request: Request, scheme_curie: str): + return await item_endpoint(request) + + @router.get( "/v/vocab/{concept_scheme_curie}", summary="Get a SKOS Concept Scheme", From 23b63fae601a4ceb0fa4653f5eb3850afb990d52 Mon Sep 17 00:00:00 2001 From: Edmond Chuc Date: Tue, 1 Aug 2023 16:04:06 +1000 Subject: [PATCH 6/6] Add description to /v/vocab/{curie}/all endpoint --- prez/routers/vocprez.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/prez/routers/vocprez.py b/prez/routers/vocprez.py index 019bd685..8b50d71c 100644 --- a/prez/routers/vocprez.py +++ b/prez/routers/vocprez.py @@ -74,8 +74,14 @@ async def schemes_endpoint( ) -@router.get("/v/vocab/{scheme_curie}/all", summary="Get ConceptScheme") +@router.get( + "/v/vocab/{scheme_curie}/all", summary="Get Concept Scheme and all its concepts" +) async def vocprez_scheme(request: Request, scheme_curie: str): + """Get a SKOS Concept Scheme and all of its concepts. + + Note: This may be a very expensive operation depending on the size of the concept scheme. + """ return await item_endpoint(request)