diff --git a/pkg_api/pkg.py b/pkg_api/pkg.py index 9883d37..f3a351b 100644 --- a/pkg_api/pkg.py +++ b/pkg_api/pkg.py @@ -9,6 +9,7 @@ """ import io +import os import logging import uuid from collections import defaultdict @@ -29,7 +30,10 @@ from pkg_api.core.pkg_types import URI from pkg_api.mapping_vocab import MappingVocab -DEFAULT_VISUALIZATION_PATH = "data/pkg_visualizations" +ROOT_DIR = os.path.dirname( + os.path.abspath(os.path.dirname(os.path.abspath(__file__))) +) +DEFAULT_VISUALIZATION_PATH = ROOT_DIR + "/data/pkg_visualizations" class PKG: diff --git a/pkg_api/server/auth.py b/pkg_api/server/auth.py index 28a4623..48ae802 100644 --- a/pkg_api/server/auth.py +++ b/pkg_api/server/auth.py @@ -10,7 +10,7 @@ # TODO: Retrieve namespace from the mapping class # See issue: https://github.com/iai-group/pkg-api/issues/13 -NS = "http://example.org/pkg/" +NS = "http://example.com#" def create_user_uri(username: str) -> str: diff --git a/pkg_api/server/pkg_exploration.py b/pkg_api/server/pkg_exploration.py index bc16f67..85a8da8 100644 --- a/pkg_api/server/pkg_exploration.py +++ b/pkg_api/server/pkg_exploration.py @@ -1,20 +1,21 @@ """PKG Exploration Resource.""" -from typing import Any, Dict, Tuple +from typing import Any, Dict, Tuple, Union -from flask import request +import flask +from flask import Response, request from flask_restful import Resource from pkg_api.server.utils import open_pkg, parse_query_request_data class PKGExplorationResource(Resource): - def get(self) -> Tuple[Dict[str, Any], int]: + def get(self) -> Union[Response, Tuple[Dict[str, Any], int]]: """Returns the PKG visualization. Returns: - A dictionary with the path to PKG visualization and the status code. + A response containing the image of the PKG graph. """ - data = request.json + data = dict(request.args) try: pkg = open_pkg(data) except Exception as e: @@ -23,10 +24,7 @@ def get(self) -> Tuple[Dict[str, Any], int]: graph_img_path = pkg.visualize_graph() pkg.close() - return { - "message": "PKG visualized successfully.", - "img_path": graph_img_path, - }, 200 + return flask.send_file(graph_img_path, mimetype="image/png") def post(self) -> Tuple[Dict[str, Any], int]: """Executes the SPARQL query. @@ -44,7 +42,9 @@ def post(self) -> Tuple[Dict[str, Any], int]: sparql_query = parse_query_request_data(data) if "SELECT" in sparql_query: - result = str(pkg.execute_sparql_query(sparql_query)) + result = pkg.execute_sparql_query(sparql_query) + # TODO: Update pkg.visualize_graph() to return partial graph based + # on the query result else: return { "message": ( @@ -57,5 +57,5 @@ def post(self) -> Tuple[Dict[str, Any], int]: return { "message": "SPARQL query executed successfully.", - "data": result, + "result": str(result.bindings), }, 200 diff --git a/pkg_client/package.json b/pkg_client/package.json index bd4b27d..ccc2cfb 100644 --- a/pkg_client/package.json +++ b/pkg_client/package.json @@ -15,6 +15,8 @@ "react": "^18.2.0", "react-bootstrap": "^2.9.1", "react-dom": "^18.2.0", + "react-icons": "^4.12.0", + "react-router-dom": "^6.21.0", "react-scripts": "5.0.1", "typescript": "^4.9.5", "web-vitals": "^2.1.4" @@ -43,4 +45,4 @@ "last 1 safari version" ] } -} +} \ No newline at end of file diff --git a/pkg_client/src/App.css b/pkg_client/src/App.css index 74b5e05..482d15b 100644 --- a/pkg_client/src/App.css +++ b/pkg_client/src/App.css @@ -1,7 +1,3 @@ -.App { - text-align: center; -} - .App-logo { height: 40vmin; pointer-events: none; @@ -32,7 +28,8 @@ from { transform: rotate(0deg); } + to { transform: rotate(360deg); } -} +} \ No newline at end of file diff --git a/pkg_client/src/App.tsx b/pkg_client/src/App.tsx index baae969..f3cb36a 100644 --- a/pkg_client/src/App.tsx +++ b/pkg_client/src/App.tsx @@ -2,7 +2,7 @@ import React, { useContext } from "react"; import "./App.css"; import APIHandler from "./components/APIHandler"; import LoginForm from "./components/LoginForm"; -import Container from 'react-bootstrap/Container' +import Container from 'react-bootstrap/Container'; import { UserContext } from "./contexts/UserContext"; function App() { @@ -13,9 +13,7 @@ function App() { return ( -
- {content} -
+
{content}
); diff --git a/pkg_client/src/components/APIHandler.tsx b/pkg_client/src/components/APIHandler.tsx index 4f2f070..3c2ee1e 100644 --- a/pkg_client/src/components/APIHandler.tsx +++ b/pkg_client/src/components/APIHandler.tsx @@ -1,50 +1,38 @@ -import React, { useContext, useEffect, useState } from "react"; -import axios from "axios"; +import React, { useContext } from "react"; import { UserContext } from "../contexts/UserContext"; import Container from "react-bootstrap/Container"; +import { BrowserRouter, Routes, Route } from "react-router-dom"; +import Layout from "./Layout"; +import PKGVisualization from "./PKGVisualization"; const APIHandler: React.FC = () => { const { user } = useContext(UserContext); - // State tracker for service management data. - const [serviceData, setServiceData] = useState(null); - // State tracker for personal facts data. - const [factsData, setFactsData] = useState(null); - // State tracker for PKG exploration data. Data presentation, graphs, etc. - const [exploreData, setExploreData] = useState(null); - - useEffect(() => { - const baseURL = - (window as any)["PKG_API_BASE_URL"] || "http://localhost:5000"; - - axios - .get(`${baseURL}/service`) - .then((response) => setServiceData(response.data)); - axios - .get(`${baseURL}/facts`) - .then((response) => setFactsData(response.data)); - axios - .get(`${baseURL}/explore`) - .then((response) => setExploreData(response.data)); - }, []); return ( -

Personal Knowledge Graph API

-
-

Welcome {JSON.stringify(user, null, 2)}

-
-
-

Service Management Data

-
{JSON.stringify(serviceData, null, 2)}
-
-
-

Personal Facts Data

-
{JSON.stringify(factsData, null, 2)}
-
-
-

PKG Exploration Data

-
{JSON.stringify(exploreData, null, 2)}
-
+

Personal Knowledge Graph

+ + + + }> + +
Welcome {user?.username}.
+ + } + /> + Service Management} /> + Population form} /> + Personal Preferences} + /> + } /> + +
+
); }; diff --git a/pkg_client/src/components/Layout.tsx b/pkg_client/src/components/Layout.tsx new file mode 100644 index 0000000..30208aa --- /dev/null +++ b/pkg_client/src/components/Layout.tsx @@ -0,0 +1,47 @@ +// Layout component for the application includes a navigation bar and content +// of the current tab. +import { Outlet, Link } from "react-router-dom"; +import Nav from "react-bootstrap/Nav"; +import { useState } from "react"; + +const Layout = () => { + const [activeKey, setActiveKey] = useState("/"); + + const handleSelect = (eventKey: string | null) => { + if (eventKey !== null) { + setActiveKey(eventKey); + } + }; + + return ( + <> + + +
+ + + ); +}; + +export default Layout; \ No newline at end of file diff --git a/pkg_client/src/components/PKGVisualization.tsx b/pkg_client/src/components/PKGVisualization.tsx new file mode 100644 index 0000000..346d28b --- /dev/null +++ b/pkg_client/src/components/PKGVisualization.tsx @@ -0,0 +1,83 @@ +// Natural language to PKG component + +import { Container } from "react-bootstrap"; +import { UserContext } from "../contexts/UserContext"; +import { useContext, useEffect, useState } from "react"; +import axios from "axios"; +import QueryForm from "./QueryForm"; +import Button from "react-bootstrap/Button"; + +const PKGVisualization = () => { + const { user } = useContext(UserContext); + const [error, setError] = useState(""); + const [image_path, setImagePath] = useState(""); + const [query_info, setQueryInfo] = useState(""); + const [result, setResult] = useState(""); + const baseURL = + (window as any)["PKG_API_BASE_URL"] || "http://127.0.0.1:5000"; + + useEffect(() => { + getImage(); + }, []); + + const getImage = () => { + axios + .get(`${baseURL}/explore`, { + params: { + owner_username: user?.username, + owner_uri: user?.uri, + }, + responseType: "blob", + }) + .then((response) => { + setError(""); + const imageURL = URL.createObjectURL( + new Blob([response.data], { + type: "image/png", + }) + ); + setImagePath(imageURL); + }) + .catch((error) => { + setError(error.message); + throw error; + }); + }; + + const executeQuery = (query: string) => { + return axios + .post(`${baseURL}/explore`, { + sparql_query: query, + owner_username: user?.username, + owner_uri: user?.uri, + }) + .then((response) => { + setError(""); + console.log(response.data); + setQueryInfo(response.data.message); + setResult(response.data.result); + }) + .catch((error) => { + setError(error.message); + throw error; + }); + }; + + return ( + +
+ Here you can execute your own SPARQL queries to manage your PKG. +
+ +
Query execution status: {query_info}
+
Query execution result: {result}
+
+ This is your current PKG: +
+ {/*
[Only for testing] Local path to the image: {image_path}
*/} + PKG +
+ ); +}; + +export default PKGVisualization; diff --git a/pkg_client/src/components/QueryForm.tsx b/pkg_client/src/components/QueryForm.tsx new file mode 100644 index 0000000..65c66f5 --- /dev/null +++ b/pkg_client/src/components/QueryForm.tsx @@ -0,0 +1,66 @@ +// Form comprising a single text input and a submit button. + +import { useState } from "react"; +import { Alert, Button, Spinner } from "react-bootstrap"; +import Form from "react-bootstrap/Form"; + +interface QueryFormProps { + handleSubmit: (query: string) => Promise; + error: string; +} + +const QueryForm: React.FC = ({ handleSubmit, error = "" }) => { + const [query, setQuery] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleClick = async (query: string) => { + try { + setIsSubmitting(true); + await handleSubmit(query); + setQuery(""); + } catch (error) { + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+ {error && {error}} +
+
+ setQuery(e.target.value)} + /> + +
+
+
+ ); +}; + +export default QueryForm; \ No newline at end of file diff --git a/pkg_client/src/contexts/UserContext.tsx b/pkg_client/src/contexts/UserContext.tsx index 0e00073..15fb0f2 100644 --- a/pkg_client/src/contexts/UserContext.tsx +++ b/pkg_client/src/contexts/UserContext.tsx @@ -9,9 +9,16 @@ type UserProviderProps = { children: React.ReactNode; }; -export const UserContext = React.createContext<{ user: User | null; setUser: React.Dispatch>; }>({ user: null, setUser: () => { } }); +export const UserContext = React.createContext<{ + user: User | null; + setUser: React.Dispatch>; +}>({ user: null, setUser: () => { } }); -export const UserProvider: React.FC = ({ children, }: { children: React.ReactNode }) => { +export const UserProvider: React.FC = ({ + children, +}: { + children: React.ReactNode +}) => { const [user, setUser] = useState(null); return ( diff --git a/tests/pkg_api/server/test_auth.py b/tests/pkg_api/server/test_auth.py index 93d446f..6aac06d 100644 --- a/tests/pkg_api/server/test_auth.py +++ b/tests/pkg_api/server/test_auth.py @@ -20,7 +20,7 @@ def test_auth_endpoint_register(client) -> None: ) assert response.status_code == 200 assert response.get_json() == { - "user": {"username": "user1", "uri": "http://example.org/pkg/user1"}, + "user": {"username": "user1", "uri": "http://example.com#user1"}, "message": "Login successful", } @@ -51,7 +51,7 @@ def test_auth_endpoint_login(client) -> None: ) assert response.status_code == 200 assert response.get_json() == { - "user": {"username": "user1", "uri": "http://example.org/pkg/user1"}, + "user": {"username": "user1", "uri": "http://example.com#user1"}, "message": "Login successful", } diff --git a/tests/pkg_api/server/test_pkg_exploration.py b/tests/pkg_api/server/test_pkg_exploration.py index 5cffe0d..6640d49 100644 --- a/tests/pkg_api/server/test_pkg_exploration.py +++ b/tests/pkg_api/server/test_pkg_exploration.py @@ -1,9 +1,27 @@ """Tests for the pkg exploration endpoints.""" import os +from io import StringIO +import pytest from flask import Flask +from pkg_api.connector import RDFStore +from pkg_api.core.annotation import PKGData, Triple, TripleElement +from pkg_api.core.pkg_types import URI +from pkg_api.pkg import PKG + + +@pytest.fixture +def user_pkg() -> PKG: + """Returns a PKG instance.""" + return PKG( + "http://example.com#test", + RDFStore.MEMORY, + "tests/data/RDFStore/test", + "tests/data/pkg_visualizations", + ) + def test_pkg_exploration_endpoint_errors(client: Flask) -> None: """Tests /explore endpoints with invalid data.""" @@ -50,22 +68,42 @@ def test_pkg_exploration_endpoint_errors(client: Flask) -> None: ) -def test_pkg_visualization(client: Flask) -> None: +def test_pkg_visualization(client: Flask, user_pkg: PKG) -> None: """Tests the GET /explore endpoint.""" if not os.path.exists("tests/data/pkg_visualizations/"): os.makedirs("tests/data/pkg_visualizations/", exist_ok=True) if not os.path.exists("tests/data/RDFStore/"): os.makedirs("tests/data/RDFStore/", exist_ok=True) + + pkg_data = PKGData( + id="f47ac10b-34fd-4372-a567-0e02b2c3d479", + statement="I live in Stavanger.", + triple=Triple( + TripleElement("I", URI("http://example.com#test")), + TripleElement("live", "live"), + TripleElement( + "Stavanger", URI("https://dbpedia.org/page/Stavanger") + ), + ), + logging_data={"authoredBy": URI("http://example.com#test")}, + ) + + user_pkg.add_statement(pkg_data) + user_pkg._connector.save_graph() + response = client.get( "/explore", - json={ + query_string={ "owner_uri": "http://example.com#test", "owner_username": "test", }, ) - assert response.status_code == 200 - assert response.json["message"] == "PKG visualized successfully." - assert response.json["img_path"] == "tests/data/pkg_visualizations/test.png" + + with open("tests/data/pkg_visualizations/test.png", "rb") as img: + test_image = StringIO(img.read()) + test_image.seek(0) + + assert response.data == test_image.read() def test_pkg_sparql_query(client: Flask) -> None: