-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
21 changed files
with
10,905 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
data/ | ||
__pycache__/ | ||
bin/ | ||
lib/ | ||
pyvenv.cfg | ||
.venv/ | ||
node_modules/ | ||
neo4j.json | ||
config.json |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
test: | ||
python -m pytest | ||
|
||
deps: | ||
python -m venv .venv | ||
. .venv/bin/activate && pip install -r requirements.txt | ||
|
||
lint: | ||
@flake8 *.py app/*.py tests/*.py --select=E9,F63,F7,F82 --show-source --statistics | ||
@flake8 *.py app/*.py tests/*.py --ignore=C901 --exit-zero --max-complexity=10 --max-line-length=127 --statistics | ||
|
||
fix: | ||
@autopep8 --in-place *.py app/*.py tests/*.py |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,64 @@ | ||
# n4o-graph-api | ||
|
||
> API and minimal web interface to [NFDI4Objects Knowledge Graph](https://nfdi4objects.github.io/n4o-graph/). | ||
This repository implements a public web API to the NFDI4Objects Knowledge Graph. See the [Knowledge Graph Manual](https://nfdi4objects.github.io/n4o-graph/) (in German) for details. | ||
|
||
## Installation | ||
|
||
Install required Python dependencies with virtualenv: | ||
|
||
~~~sh | ||
python -m venv .venv | ||
. .venv/bin/activate | ||
pip install -r requirements.txt | ||
~~~ | ||
|
||
Then locally run for testing: | ||
|
||
~~~sh | ||
python app.py --help | ||
~~~ | ||
|
||
And deploy [by method of your choice](https://flask.palletsprojects.com/en/2.0.x/deploying/#self-hosted-options). | ||
|
||
## Configuration | ||
|
||
A local file `config.json` is needed with configuration. Use this as boilerplate: | ||
|
||
~~~json | ||
{ | ||
"cypher": { | ||
"uri": "bolt://esx-120.gbv.de:7687", | ||
"user": "", | ||
"password": "", | ||
"timeout": 30, | ||
"example": "MATCH (n:E21_Person) RETURN n LIMIT 10" | ||
}, | ||
"sparql": { | ||
"endpoint": "http://example.org/sparql" | ||
} | ||
} | ||
~~~ | ||
|
||
Make sure the Neo4j (or compatible) database is read-only because this application does not guarantee to filter out write queries! | ||
|
||
## Usage | ||
|
||
### SPARQL API | ||
|
||
... | ||
|
||
### Cypher Propert Graph API | ||
|
||
The property graph API expects a HTTP GET query parameter `query` with a CYPHER query and returns a (possibly empty) JSON array of result objects on success. On failure, an error object is returned. Each response objects is maps query variables to values. Each value is one of: | ||
|
||
- number, string, boolean, or null | ||
- array of values | ||
- [PG-JSONL]() node or edge object for nodes and edges | ||
- [PG-JSON]() graph object for pathes | ||
|
||
## Development | ||
|
||
Please run `make lint` to detect Python coding style violations and `make fix` to fix some of these violations. | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,101 @@ | ||
import json | ||
from flask import Flask, render_template, request, make_response | ||
from waitress import serve | ||
import argparse | ||
|
||
from app import CypherBackend, SparqlProxy, ApiError | ||
|
||
|
||
def jsonify(data, status=200, indent=3, sort_keys=False): | ||
response = make_response(json.dumps(data, indent=indent, sort_keys=sort_keys)) | ||
response.headers['Content-Type'] = 'application/json; charset=utf-8' | ||
response.headers['mimetype'] = 'application/json' | ||
response.headers['Access-Control-Allow-Origin'] = '*' | ||
response.status_code = status | ||
return response | ||
|
||
|
||
app = Flask(__name__) | ||
|
||
|
||
@app.errorhandler(ApiError) | ||
def handle_api_error(error): | ||
response = jsonify(error.to_dict()) | ||
response.status_code = error.status | ||
return response | ||
|
||
|
||
@app.errorhandler(Exception) | ||
def handle_exception(error): | ||
if hasattr(error, 'message'): | ||
message = error.message | ||
else: | ||
message = str(error) | ||
return handle_api_error(ApiError(message)) | ||
|
||
|
||
@app.route('/') | ||
def index(): | ||
return render_template('index.html') | ||
|
||
|
||
@app.route('/api/cypher', methods=('GET', 'POST')) | ||
def cypher_api(): | ||
query = '' | ||
if 'query' in request.args: # GET | ||
query = request.args.get('query') | ||
elif request.data: # POST | ||
query = request.data.decode('UTF-8') | ||
|
||
if query and query != '': | ||
answer = app.config["cypher-backend"].execute(query) | ||
else: | ||
raise ApiError('missing or empty "query" parameter', 400) | ||
|
||
return jsonify(answer) | ||
|
||
|
||
@app.route('/api/sparql', methods=('GET', 'POST')) | ||
def sparql_api(): | ||
if app.config["sparql-proxy"]: | ||
return app.config["sparql-proxy"].request(request) | ||
else: | ||
raise ApiError("SPARQL not configured!", 503) | ||
|
||
|
||
@app.route('/cypher') | ||
def cypher_form(): | ||
return render_template('cypher.html', query=app.config["cypher-example"]) | ||
|
||
|
||
@app.route('/sparql') | ||
def sparql_form(): | ||
return render_template('sparql.html', **config["sparql"]) | ||
|
||
|
||
if __name__ == '__main__': | ||
parser = argparse.ArgumentParser() | ||
parser.add_argument('-p', '--port', type=int, | ||
default=8000, help="Server port") | ||
parser.add_argument( | ||
'-w', '--wsgi', action=argparse.BooleanOptionalAction, help="Use WSGI server") | ||
parser.add_argument('-c', '--config', type=str, | ||
default="config.json", help="Config file") | ||
parser.add_argument('-d', '--debug', action=argparse.BooleanOptionalAction) | ||
args = parser.parse_args() | ||
|
||
opts = {"port": args.port} | ||
if args.debug: | ||
opts["debug"] = True | ||
|
||
with open(args.config) as fp: | ||
config = json.load(fp) | ||
app.config["cypher-backend"] = CypherBackend(config['cypher']) | ||
app.config["cypher-example"] = config["cypher"]["example"] if "example" in config["cypher"] else "" | ||
app.config["sparql-proxy"] = SparqlProxy( | ||
config["sparql"]["endpoint"]) if "sparql" in config else None | ||
|
||
if args.wsgi: | ||
serve(app, host="0.0.0.0", **opts) | ||
else: | ||
app.run(host="0.0.0.0", **opts) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
from .cypher_backend import CypherBackend | ||
from .sparql_proxy import SparqlProxy | ||
from .error import ApiError | ||
|
||
__all__ = [CypherBackend, SparqlProxy, ApiError] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,61 @@ | ||
from neo4j import GraphDatabase, graph | ||
from neo4j.exceptions import CypherSyntaxError, CypherTypeError, ConstraintError, DriverError, Neo4jError | ||
from .error import ApiError | ||
|
||
|
||
class CypherBackend: | ||
|
||
def __init__(self, config): | ||
self.driver = GraphDatabase.driver( | ||
config["uri"], auth=(config["user"], config["password"]), | ||
connection_timeout=2, # backend should be reachable, so 2s is enough | ||
) | ||
self.timeout = config["timeout"] if "timeout" in config else None | ||
|
||
def close(self): | ||
self.driver.close() | ||
|
||
def execute(self, cmd): | ||
with self.driver.session() as session: | ||
try: | ||
result = session.run(cmd, timeout=self.timeout) | ||
except (CypherSyntaxError, CypherTypeError, ConstraintError) as error: | ||
raise ApiError(error.message, 400) # Bad Request | ||
except (DriverError, Neo4jError) as error: | ||
# Bad configuration or network error | ||
raise ApiError(type(error).__name__, 503) | ||
except Exception as error: | ||
raise ApiError(error, 500) | ||
return [outputDict(record) for record in result] | ||
|
||
|
||
def node2dict(node: graph.Node): | ||
props = dict((key, value) for key, value in node.items()) | ||
return {'id': node.element_id, 'labels': list(node.labels), 'type': 'node', 'properties': props} | ||
|
||
|
||
def rs2dict(rs: graph.Relationship): | ||
props = dict((key, value) for key, value in rs.items()) | ||
return { | ||
'type': 'edge', 'id': rs.element_id, | ||
'labels': [rs.type], 'properties': props, 'from': rs.start_node.element_id, 'to': rs.end_node.element_id} | ||
|
||
|
||
def path2dict(path: graph.Path): | ||
nodes = [node2dict(x) for x in path.nodes] | ||
rss = [rs2dict(x) for x in path.relationships] | ||
return {'type': 'graph', 'nodes': nodes, 'edges': rss} | ||
|
||
|
||
def outputDict(record): | ||
resultDict = {} | ||
for key, value in record.items(): | ||
if isinstance(value, graph.Node): | ||
resultDict[key] = node2dict(value) | ||
elif isinstance(value, graph.Relationship): | ||
resultDict[key] = rs2dict(value) | ||
elif isinstance(value, graph.Path): | ||
resultDict[key] = path2dict(value) | ||
else: | ||
resultDict[key] = value | ||
return resultDict |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
class ApiError(Exception): | ||
def __init__(self, message, status=None): | ||
Exception.__init__(self) | ||
self.message = message | ||
self.status = status if status is not None else 500 | ||
|
||
def to_dict(self): | ||
return {"message": self.message, "code": self.status} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
# from werkzeug.wsgi import wrap_file | ||
import requests | ||
|
||
|
||
class SparqlProxy: | ||
|
||
def __init__(self, api): | ||
self.api = api | ||
|
||
def request(self, request): | ||
# Supported parameters as defined by SPARQL protocol specification | ||
query = request.values.get("query", "") | ||
# TODO: support POST with full body: see https://www.w3.org/TR/2013/REC-sparql11-protocol-20130321/#query-operation | ||
|
||
headers = { | ||
"Content-Type": "application/sparql-query" | ||
} | ||
# Copy selected headers from original request (TODO: which more?) | ||
copyHeaders = ["Accept", "Accept-Encoding", "Accept-Language"] | ||
for name in copyHeaders: | ||
if name in request.headers: | ||
headers[name] = request.headers[name] | ||
|
||
params = {} | ||
copyParams = ["default-graph-uri", "named-graph-uri"] | ||
for name in copyParams: | ||
if name in request.values: | ||
params[name] = request.values[name] | ||
|
||
# TODO: pass result via werkzeug.wsgi.wrap_file to avoid re-parsing of response | ||
resp = requests.post(self.api, data=query, | ||
params=params, headers=headers, stream=True) | ||
return resp.raw.read(), resp.status_code, resp.headers.items() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
typing_extensions | ||
portion | ||
jsonpath_ng | ||
neo4j | ||
Flask | ||
waitress | ||
pytest | ||
requests |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,59 @@ | ||
async function cypherQuery(api, query) { | ||
return fetch(api, { method: "POST", body: query }).then(res => res.json()) | ||
} | ||
|
||
function showCypherResult(result, elem) { | ||
if (!Array.isArray(result)) { | ||
elem.innerHTML = "Invalid response!" | ||
return | ||
} | ||
elem.innerHTML = `Got ${result.length} records` | ||
|
||
if (!result.length) return | ||
|
||
const table = document.createElement('table'); | ||
table.className = "table" | ||
elem.appendChild(table) | ||
|
||
const keys = Object.keys(result[0]) | ||
const tr = table.insertRow() | ||
keys.forEach(key => { | ||
const th = document.createElement('th') | ||
th.textContent = key | ||
tr.appendChild(th) | ||
}) | ||
|
||
result.forEach(row => { | ||
const tr = table.insertRow() | ||
keys.forEach(key => { | ||
var td = tr.insertCell() | ||
var value | ||
// TODO: show depending in type | ||
if (typeof row[key] == "object") { | ||
// TODO: serialize in PG format | ||
value = JSON.stringify(row[key]) | ||
} else { | ||
value = row[key] | ||
} | ||
td.textContent = value | ||
}) | ||
}) | ||
} | ||
|
||
document.addEventListener('DOMContentLoaded', () => { | ||
const form = document.getElementById('cypherForm') | ||
const resultElem = document.getElementById('cypherResult') | ||
const cypherApi = "/api/cypher" | ||
if (form) { | ||
form.addEventListener('submit', async e => { | ||
e.preventDefault() | ||
resultElem.innerHTML = '' | ||
const fields = Object.fromEntries(new FormData(form)) | ||
cypherQuery(cypherApi, fields.query).then(result => { | ||
console.log(resultElem) | ||
showCypherResult(result, resultElem) | ||
}) | ||
return | ||
}) | ||
} | ||
}) |
Oops, something went wrong.