Skip to content

Commit

Permalink
Start rewrite
Browse files Browse the repository at this point in the history
  • Loading branch information
nichtich committed Aug 14, 2024
1 parent 19b0cc7 commit 8aaeb24
Show file tree
Hide file tree
Showing 21 changed files with 10,905 additions and 0 deletions.
9 changes: 9 additions & 0 deletions .gitignore
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
13 changes: 13 additions & 0 deletions Makefile
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
64 changes: 64 additions & 0 deletions README.md
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.

101 changes: 101 additions & 0 deletions app.py
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)
5 changes: 5 additions & 0 deletions app/__init__.py
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]
61 changes: 61 additions & 0 deletions app/cypher_backend.py
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
8 changes: 8 additions & 0 deletions app/error.py
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}
33 changes: 33 additions & 0 deletions app/sparql_proxy.py
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()
8 changes: 8 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
typing_extensions
portion
jsonpath_ng
neo4j
Flask
waitress
pytest
requests
59 changes: 59 additions & 0 deletions static/app.js
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
})
}
})
Loading

0 comments on commit 8aaeb24

Please sign in to comment.