Initial commit of minimal Graph API for Python
authorBenjamin Braatz <benjamin.braatz@graph-it.com>
Thu, 11 Jul 2019 10:49:20 +0000 (12:49 +0200)
committerBenjamin Braatz <benjamin.braatz@graph-it.com>
Thu, 11 Jul 2019 10:49:20 +0000 (12:49 +0200)
.gitignore [new file with mode: 0644]
config.json.example [new file with mode: 0644]
doc/index.md [new file with mode: 0644]
graph.py [new file with mode: 0644]
test.py [new file with mode: 0755]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..e0bb05d
--- /dev/null
@@ -0,0 +1,3 @@
+graph.crt
+config.json
+*-briefbogen.pdf
diff --git a/config.json.example b/config.json.example
new file mode 100644 (file)
index 0000000..18a60f3
--- /dev/null
@@ -0,0 +1,5 @@
+{
+  "name": "graph.example.com",
+  "unix_url": "unix:///var/lib/graph/var/run/graphserver.sock",
+  "tls_url": "tls://graph.example.com:4339"
+}
diff --git a/doc/index.md b/doc/index.md
new file mode 100644 (file)
index 0000000..a565635
--- /dev/null
@@ -0,0 +1,60 @@
+# Python Graph API
+
+This is a minimal Python API for the Graph.
+It works with Python 2 as well as Python 3.
+
+## Prerequisites
+
+The Graph API uses [msgpack](https://msgpack.org/).
+So, the Python binding for it has to be installed.
+
+For Debian-based distributions these can be done by:
+```sh
+# apt install python-msgpack python3-msgpack
+```
+
+With pip, it can be done by:
+```sh
+# pip install msgpack
+```
+
+In order to use TLS connections to graphs, a client certificate chain has to
+be available in a file.
+
+## Usage
+
+A graph connection is initialised by giving a URI and optionally the filename
+of the certificate chain to the constructor:
+```python
+>>> import graph
+>>> gc = graph.Connection('tls://graph.de.screwerk.com:4437', 'graph.crt')
+```
+
+Then all functions of the Graph API can be called on the connection object:
+```python
+>>> graphmodul_guid = gc.attributsknoten('graphmodul_name', 'graph')
+>>> knotens = gc.listeattribute(graphmodul_guid, 'knoten', 'typ, dokumentation, anzahlattribute', 'typ asc')
+```
+
+Observe that all string data returned by the API are bytestrings (even the
+keys in `*attribute` results) and have to be decoded to be used as Python
+strings:
+```python
+>>> for knoten in knotens.values():
+...     print("{}: {} ({})".format(knoten[b'typ'].decode(), knoten[b'dokumentation'].decode(), knoten[b'anzahlattribute'].decode()))
+... 
+aktion: <p>Auszuf&uuml;hrende Aktion</p>
+ (5)
+aktionfunktion: <p>Auszuführende Funktion, die an einem Knoten hinterlegt werden und von Änderung an lokalen Attributen getriggert werden kann.</p>
+ (22)
+attribut: <p>Allgemeines Merkmal</p>
+ (16)
+[…]
+```
+
+## Test script
+
+The `test.py` script reads the configuration of a main graph from
+`config.json`, queries this main graph for remote graphs configured in it,
+prints a summary of all graph modules in the main graph and all remote graphs
+and downloads stationeries in all the graphs if they are configured there.
diff --git a/graph.py b/graph.py
new file mode 100644 (file)
index 0000000..4f03051
--- /dev/null
+++ b/graph.py
@@ -0,0 +1,90 @@
+from collections import OrderedDict
+from socket import socket, AF_UNIX, AF_INET, SOCK_STREAM
+from ssl import SSLContext, PROTOCOL_TLSv1
+from struct import pack, unpack
+try:
+    from urllib.parse import urlparse
+except ImportError:
+    from urlparse import urlparse
+
+from msgpack import packb, unpackb
+
+
+class Connection:
+    def __init__(self, url, crt_filename=''):
+        self.__url = url
+        self.__crt_filename = crt_filename
+        self.__cid = 0
+        self.__sock = False
+
+    def __del__(self):
+        if self.__sock:
+            self.__sock.close()
+
+    def __init(self):
+        res = urlparse(self.__url)
+        if res.scheme == 'unix':
+            self.__sock = socket(AF_UNIX, SOCK_STREAM)
+            self.__sock.connect(res.path)
+        if res.scheme == 'tcp':
+            self.__sock = socket(AF_INET, SOCK_STREAM)
+            self.__sock.connect((res.hostname, res.port))
+        if res.scheme == 'tls':
+            ssl_ctx = SSLContext(PROTOCOL_TLSv1)
+            ssl_ctx.load_cert_chain(self.__crt_filename)
+            self.__sock = socket(AF_INET, SOCK_STREAM)
+            self.__sock = ssl_ctx.wrap_socket(self.__sock)
+            self.__sock.connect((res.hostname, res.port))
+        self.__read()
+
+    def __read(self):
+        size = self.__sock.recv(4)
+        size = unpack('<L', size)[0]
+        mesg = b''
+        while len(mesg) < size:
+            to_be_read = size - len(mesg)
+            mesg += self.__sock.recv(to_be_read)
+        return unpackb(
+            mesg,
+            object_pairs_hook=lambda pair_list: OrderedDict(pair_list),
+            raw=True)
+
+    def __write(self, mesg):
+        mesg = packb(mesg, use_bin_type=False)
+        size = pack('<L', len(mesg))
+        self.__sock.sendall(size)
+        self.__sock.sendall(mesg)
+
+    def __encode(self, struct):
+        if isinstance(struct, dict):
+            return {self.__encode(key): self.__encode(value)
+                    for key, value in struct.items()}
+        elif isinstance(struct, list):
+            return [self.__encode(element)
+                    for element in struct]
+        elif isinstance(struct, str):
+            return struct.encode()
+        else:
+            return struct
+
+    def __call(self, method, params):
+        if self.__cid == 0:
+            self.__init()
+        self.__cid += 1
+        req = {b'jsonrpc': b'2.0',
+               b'method': self.__encode(method),
+               b'params': self.__encode(params),
+               b'id': self.__cid}
+        self.__write(req)
+        res = self.__read()
+        if b'jsonrpc' not in res or res[b'jsonrpc'] != req[b'jsonrpc']:
+            raise Exception('Not a JSON-RPC 2.0 response!')
+        if b'error' in res:
+            err = res[b'error'].decode()
+            raise Exception('JSON-RPC: Remote error: {0}'.format(err))
+        if b'id' not in res or res[b'id'] != req[b'id']:
+            raise Exception('JSON-RPC id missing or invalid')
+        return res[b'result']
+
+    def __getattr__(self, attr):
+        return lambda *args: self.__call(attr, args)
diff --git a/test.py b/test.py
new file mode 100755 (executable)
index 0000000..c527a0a
--- /dev/null
+++ b/test.py
@@ -0,0 +1,55 @@
+#!/usr/bin/python3 -Bu
+# (also works in Python 2)
+import json
+import os
+import textwrap
+
+import graph
+
+crt_filename = os.path.dirname(os.path.abspath(__file__)) + '/graph.crt'
+config_filename = os.path.dirname(os.path.abspath(__file__)) + '/config.json'
+with open(config_filename) as config_file:
+    config = json.load(config_file)
+
+wrapper = textwrap.TextWrapper()
+wrapper.width = 80
+wrapper.initial_indent = '    '
+wrapper.subsequent_indent = '    '
+
+# Test if main graph is reachable by Unix domain socket:
+try:
+    graphs = [(config['name'], config['unix_url'])]
+    gc = graph.Connection(config['unix_url'])
+    gc.attributsknoten('knoten_typ', 'knoten')
+except Exception:
+    graphs = [(config['name'], config['tls_url'])]
+    gc = graph.Connection(config['tls_url'], crt_filename)
+
+# All remote graphs, sorted by url:
+remotegraphs = gc.alleattribute(
+    'remotegraph',
+    'name, url',
+    'url',
+    'name != :l and name != :g',
+    {':l': 'local', ':g': config['name']})
+for remotegraph_guid, remotegraph in remotegraphs.items():
+    graphs.append((remotegraph[b'name'].decode(),
+                   remotegraph[b'url'].decode()))
+
+# For all graphs, print name and url and list of installed graph modules:
+for name, url in graphs:
+    print(name + ': ' + url)
+    gc = graph.Connection(url, crt_filename)
+
+    graphmodule = gc.alleattribute('graphmodul', 'name', 'name')
+    graphmodule = ', '.join([gm[b'name'].decode()
+                             for gm in graphmodule.values()])
+    print(wrapper.fill(graphmodule))
+
+    aa_guid = gc.attributsknoten('auftragsabwicklung_name',
+                                 'auftragsabwicklung')
+    if aa_guid:
+        aa_briefbogen = gc.attribut(aa_guid, 'auftragsabwicklung_briefbogen')
+        with open(name + '-briefbogen.pdf', 'wb') as pdf_file:
+            pdf_file.write(aa_briefbogen)
+        print('    ' + name + '-briefbogen.pdf written')