initial skeleton of WSGI server

Technically supports both sqlite and ES, but let's stick with sqlite for
now
diff --git a/api/handler.py b/api/handler.py
new file mode 100644
index 0000000..03b4d5c
--- /dev/null
+++ b/api/handler.py
@@ -0,0 +1,199 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+This is the main WSGI handler file for Apache Warble.
+It compiles a list of valid URLs from the 'pages' library folder,
+and if a URL matches it runs the specific submodule's run() function. It
+also handles CGI parsing and exceptions in the applications.
+"""
+
+
+# Main imports
+import cgi
+import re
+import sys
+import traceback
+import yaml
+import json
+import plugins.session
+import plugins.database
+import plugins.openapi
+
+WARBLE_VERSION = '0.1.0'
+
+# Compile valid API URLs from the pages library
+
+urls = []
+if __name__ != '__main__':
+    import pages
+    for page in pages.handlers:
+        urls.append((r"^(/api/%s)(/.+)?$" % page, pages.handlers[page].run))
+
+
+# Load Warble master configuration
+config = yaml.load(open("yaml/warble.yaml"))
+
+# Instantiate database connections
+DB = plugins.database.WarbleDatabase(config)
+
+# Load Open API specifications
+WarbleOpenAPI = plugins.openapi.OpenAPI("yaml/openapi.yaml")
+
+class WarbleHTTPError(Exception):
+    def __init__(self, code, message):
+        self.code = code
+        self.message = message
+
+
+class WarbleAPIWrapper:
+    """
+    Middleware wrapper for exceptions in the application
+    """
+    def __init__(self, path, func):
+        self.func = func
+        self.API = WarbleOpenAPI
+        self.path = path
+        self.exception = WarbleHTTPError
+
+    def __call__(self, environ, start_response, session):
+        """Run the function, return response OR return stacktrace"""
+        response = None
+        try:
+            # Read JSON client data if any
+            try:
+                request_size = int(environ.get('CONTENT_LENGTH', 0))
+            except (ValueError):
+                request_size = 0
+            requestBody = environ['wsgi.input'].read(request_size)
+            formdata = {}
+            if requestBody and len(requestBody) > 0:
+                try:
+                    formdata = json.loads(requestBody.decode('utf-8'))
+                except json.JSONDecodeError as err:
+                    start_response('400 Invalid request', [
+                               ('Content-Type', 'application/json')])
+                    yield json.dumps({
+                        "code": 400,
+                        "reason": "Invalid JSON: %s" % err,
+                        'server': "Apache Warble/%s" % WARBLE_VERSION
+                    })
+                    return
+
+            # Validate URL against OpenAPI specs
+            try:
+                self.API.validate(environ['REQUEST_METHOD'], self.path, formdata)
+            except plugins.openapi.OpenAPIException as err:
+                session.headers.append(('Content-Type', 'application/json'))
+                start_response('400 Invalid request', 
+                            session.headers)
+                yield json.dumps({
+                    "code": 400,
+                    "reason": err.message,
+                    'server': "Apache Warble/%s" % WARBLE_VERSION
+                })
+                return
+
+            # Call page with env, SR and form data
+            try:
+                response = self.func(self, environ, formdata, session)
+                if response:
+                    for bucket in response:
+                        yield bucket
+            except WarbleHTTPError as err:
+                errHeaders = {
+                    403: '403 Authentication failed',
+                    404: '404 Resource not found',
+                    500: '500 Internal Server Error',
+                    501: '501 Gateway error'
+                }
+                errHeader = errHeaders[err.code] if err.code in errHeaders else "400 Bad request"
+                session.headers.append(('Content-Type', 'application/json'))
+                start_response(errHeader, session.headers)
+                yield json.dumps({
+                    "code": err.code,
+                    "reason": err.message,
+                    'server': "Apache Warble/%s" % WARBLE_VERSION
+                }, indent = 4) + "\n"
+                return
+
+        except:
+            err_type, err_value, tb = sys.exc_info()
+            traceback_output = ['API traceback:']
+            traceback_output += traceback.format_tb(tb)
+            traceback_output.append('%s: %s' % (err_type.__name__, err_value))
+            # We don't know if response has been given yet, try giving one, fail gracefully.
+            try:
+                session.headers.append(('Content-Type', 'application/json'))
+                start_response('500 Internal Server Error',
+                               session.headers)
+            except:
+                pass
+            yield json.dumps({
+                "code": "500",
+                "reason": '\n'.join(traceback_output),
+                'server': "Apache Warble/%s" % WARBLE_VERSION
+            })
+
+
+def fourohfour(environ, start_response):
+    """A very simple 404 handler"""
+    start_response("404 Not Found", [
+                ('Content-Type', 'application/json')])
+    yield json.dumps({
+        "code": 404,
+        "reason": "API endpoint not found",
+        'server': "Apache Warble/%s" % WARBLE_VERSION
+    }, indent = 4) + "\n"
+    return
+
+
+def application(environ, start_response):
+    """
+    This is the main handler. Every API call goes through here.
+    Checks against the pages library, and if submod found, runs
+    it and returns the output.
+    """
+    path = environ.get('PATH_INFO', '')
+    for regex, function in urls:
+        m = re.match(regex, path)
+        if m:
+            callback = WarbleAPIWrapper(path, function)
+            session = plugins.session.WarbleSession(DB, environ, config)
+            a = 0
+            for bucket in callback(environ, start_response, session):
+                if a == 0:
+                    session.headers.append(bucket)
+                    try:
+                        start_response("200 Okay", session.headers)
+                    except:
+                        pass
+                a += 1
+                # WSGI prefers byte strings, so convert if regular py3 string
+                if isinstance(bucket, str):
+                    yield bytes(bucket, encoding = 'utf-8')
+                elif isinstance(bucket, bytes):
+                    yield bucket
+            return
+
+    for bucket in fourohfour(environ, start_response):
+        yield bytes(bucket, encoding = 'utf-8')
+
+
+
+if __name__ == '__main__':
+    WarbleOpenAPI.toHTML()
diff --git a/api/pages/__init__.py b/api/pages/__init__.py
new file mode 100644
index 0000000..e412905
--- /dev/null
+++ b/api/pages/__init__.py
@@ -0,0 +1,46 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+"""
+Warble API scripts library:
+
+    oauth:          oauth manager
+
+"""
+
+import importlib
+import os
+# Define all the submodules we have
+
+rootpath = os.path.dirname(__file__)
+print("Reading pages from %s" % rootpath)
+
+# Import each submodule into a hash called 'handlers'
+handlers = {}
+
+def loadPage(path):
+    for el in os.listdir(path):
+        filepath = os.path.join(path, el)
+        if el.find("__") == -1:
+            if os.path.isdir(filepath):
+                loadPage(filepath)
+            else:
+                p = filepath.replace(rootpath, "")[1:].replace('/', '.')[:-3]
+                xp = p.replace('.', '/')
+                print("Loading endpoint pages.%s as %s" % (p, xp))
+                handlers[xp] = importlib.import_module("pages.%s" % p)
+    
+loadPage(rootpath)
diff --git a/api/pages/account.py b/api/pages/account.py
new file mode 100644
index 0000000..4857773
--- /dev/null
+++ b/api/pages/account.py
@@ -0,0 +1,220 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+########################################################################
+# OPENAPI-URI: /api/account
+########################################################################
+# delete:
+#   requestBody:
+#     content:
+#       application/json:
+#         schema:
+#           $ref: '#/components/schemas/UserName'
+#     description: User ID
+#     required: true
+#   responses:
+#     '200':
+#       content:
+#         application/json:
+#           schema:
+#             $ref: '#/components/schemas/ActionCompleted'
+#       description: 200 response
+#     default:
+#       content:
+#         application/json:
+#           schema:
+#             $ref: '#/components/schemas/Error'
+#       description: unexpected error
+#   security:
+#   - cookieAuth: []
+#   summary: Delete an account
+# patch:
+#   requestBody:
+#     content:
+#       application/json:
+#         schema:
+#           $ref: '#/components/schemas/UserAccountEdit'
+#     description: User credentials
+#     required: true
+#   responses:
+#     '200':
+#       content:
+#         application/json:
+#           schema:
+#             $ref: '#/components/schemas/ActionCompleted'
+#       description: 200 response
+#     default:
+#       content:
+#         application/json:
+#           schema:
+#             $ref: '#/components/schemas/Error'
+#       description: unexpected error
+#   summary: Edit an account
+# put:
+#   requestBody:
+#     content:
+#       application/json:
+#         schema:
+#           $ref: '#/components/schemas/UserAccount'
+#     description: User credentials
+#     required: true
+#   responses:
+#     '200':
+#       content:
+#         application/json:
+#           schema:
+#             $ref: '#/components/schemas/ActionCompleted'
+#       description: 200 response
+#     default:
+#       content:
+#         application/json:
+#           schema:
+#             $ref: '#/components/schemas/Error'
+#       description: unexpected error
+#   summary: Create a new account
+# 
+########################################################################
+
+
+
+
+
+"""
+This is the user account handler for Kibble.
+adds, removes and edits accounts.
+"""
+
+import json
+import re
+import time
+import bcrypt
+import hashlib
+import smtplib
+import email.message
+
+
+def sendCode(session, addr, code):
+    msg = email.message.EmailMessage()
+    msg['To'] = addr
+    msg['From'] = session.config['mail']['sender']
+    msg['Subject'] = "Please verify your account"
+    msg.set_content("""\
+Hi there!
+Please verify your account by visiting:
+%s/api/verify/%s/%s
+
+With regards,
+Apache Kibble.
+""" % (session.url, addr, code)
+    )
+    s = smtplib.SMTP("%s:%s" % (session.config['mail']['mailhost'], session.config['mail']['mailport']))
+    s.send_message(msg)
+    s.quit()
+
+def run(API, environ, indata, session):
+    
+    method = environ['REQUEST_METHOD']
+
+    # Add a new account??
+    if method == "PUT":
+        u = indata['email']
+        p = indata['password']
+        d = indata['displayname']
+        
+        # Are new accounts allowed? (admin can always make accounts, of course)
+        if not session.config['accounts'].get('allowSignup', False):
+            if not (session.user and session.user['level'] == 'admin'):
+                raise API.exception(403, "New account requests have been administratively disabled.")
+        
+        # Check if we already have that username in use
+        if session.DB.ES.exists(index=session.DB.dbname, doc_type='useraccount', id = u):
+            raise API.exception(403, "Username already in use")
+        
+        # We require a username, displayName password of at least 3 chars each
+        if len(p) < 3 or len(u) < 3 or len(d) < 3:
+            raise API.exception(400, "Username, display-name and password must each be at elast 3 characters long.")
+        
+        # We loosely check that the email is an email
+        if not re.match(r"^\S+@\S+\.\S+$", u):
+            raise API.exception(400, "Invalid email address presented.")
+        
+        # Okay, let's make an account...I guess
+        salt = bcrypt.gensalt()
+        pwd = bcrypt.hashpw(p.encode('utf-8'), salt).decode('ascii')
+        
+        # Verification code, if needed
+        vsalt = bcrypt.gensalt()
+        vcode = hashlib.sha1(vsalt).hexdigest()
+        
+        # Auto-verify unless verification is enabled.
+        # This is so previously unverified accounts don'thave to verify
+        # if we later turn verification on.
+        verified = True
+        if session.config['accounts'].get('verify'):
+            verified = False
+            sendCode(session, u, vcode) # Send verification email
+            # If verification email fails, skip account creation.
+        
+        doc = {
+            'email': u,                         # Username (email)
+            'password': pwd,                    # Hashed password
+            'displayName': d,                   # Display Name
+            'organisations': [],                # Orgs user belongs to (default is none)
+            'ownerships': [],                   # Orgs user owns (default is none)
+            'defaultOrganisation': None,        # Default org for user
+            'verified': verified,               # Account verified via email?
+            'vcode': vcode,                     # Verification code
+            'userlevel': "user"                 # User level (user/admin)
+        }
+        
+        
+        # If we have auto-invite on, check if there are orgs to invite to
+        if 'autoInvite' in session.config['accounts']:
+            dom = u.split('@')[-1].lower()
+            for ai in session.config['accounts']['autoInvite']:
+                if ai['domain'] == dom:
+                    doc['organisations'].append(ai['organisation'])
+                
+        session.DB.ES.index(index=session.DB.dbname, doc_type='useraccount', id = u, body = doc)
+        yield json.dumps({"message": "Account created!", "verified": verified})
+        return
+    
+    # We need to be logged in for the rest of this!
+    if not session.user:
+        raise API.exception(403, "You must be logged in to use this API endpoint! %s")
+    
+    
+    # Patch (edit) an account
+    if method == "PATCH":
+        userid = session.user['email']
+        if indata.get('email') and session.user['userlevel'] == "admin":
+            userid = indata.get('email')
+        doc = session.DB.ES.get(index=session.DB.dbname, doc_type='useraccount', id = userid)
+        udoc = doc['_source']
+        if indata.get('defaultOrganisation'):
+            # Make sure user is a member or admin here..
+            if session.user['userlevel'] == "admin" or indata.get('defaultOrganisation') in udoc['organisations']:
+                udoc['defaultOrganisation'] = indata.get('defaultOrganisation')
+        # Changing pasword?
+        if indata.get('password'):
+            p = indata.get('password')
+            salt = bcrypt.gensalt()
+            pwd = bcrypt.hashpw(p.encode('utf-8'), salt).decode('ascii')
+        # Update user doc
+        session.DB.ES.index(index=session.DB.dbname, doc_type='useraccount', id = userid, body = udoc)
+        yield json.dumps({"message": "Account updated!"})
+        return
+    
\ No newline at end of file
diff --git a/api/pages/session.py b/api/pages/session.py
new file mode 100644
index 0000000..87f8938
--- /dev/null
+++ b/api/pages/session.py
@@ -0,0 +1,185 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+########################################################################
+# OPENAPI-URI: /api/session
+########################################################################
+# delete:
+#   requestBody:
+#     content:
+#       application/json:
+#         schema:
+#           $ref: '#/components/schemas/Empty'
+#     description: Nada
+#     required: true
+#   responses:
+#     '200':
+#       content:
+#         application/json:
+#           schema:
+#             $ref: '#/components/schemas/ActionCompleted'
+#       description: Logout successful
+#     default:
+#       content:
+#         application/json:
+#           schema:
+#             $ref: '#/components/schemas/Error'
+#       description: unexpected error
+#   security:
+#   - cookieAuth: []
+#   summary: Log out (remove session)
+# get:
+#   responses:
+#     '200':
+#       content:
+#         application/json:
+#           schema:
+#             $ref: '#/components/schemas/UserData'
+#       description: 200 response
+#     default:
+#       content:
+#         application/json:
+#           schema:
+#             $ref: '#/components/schemas/Error'
+#       description: unexpected error
+#   security:
+#   - cookieAuth: []
+#   summary: Display your login details
+# put:
+#   requestBody:
+#     content:
+#       application/json:
+#         schema:
+#           $ref: '#/components/schemas/UserCredentials'
+#     description: User credentials
+#     required: true
+#   responses:
+#     '200':
+#       content:
+#         application/json:
+#           schema:
+#             $ref: '#/components/schemas/ActionCompleted'
+#       description: Login successful
+#       headers:
+#         Set-Cookie:
+#           schema:
+#             example: 77488a26-23c2-4e29-94a1-6a0738f6a3ff
+#             type: string
+#     default:
+#       content:
+#         application/json:
+#           schema:
+#             $ref: '#/components/schemas/Error'
+#       description: unexpected error
+#   summary: Log in
+# 
+########################################################################
+
+
+
+
+
+"""
+This is the user session handler for Warble
+"""
+
+import json
+import re
+import time
+import bcrypt
+import hashlib
+
+def run(API, environ, indata, session):
+    
+    method = environ['REQUEST_METHOD']
+    
+    # Logging in?
+    if method == "PUT":
+        u = indata['username']
+        p = indata['password']
+        if session.DB.dbtype == 'sqlite':
+            session_conn = session.DB.sqlite.open('sessions.db')
+            account_conn = session.DB.sqlite.open('accounts.db')
+            sc = session_conn.cursor()
+            ac = account_conn.cursor()
+            ac.execute("SELECT * FROM `accounts` WHERE `userid` = ? LIMIT 1", (u,))
+            sdoc = ac.fetchone()
+            if sdoc:
+                mypass = sdoc['password']
+                theirpass = bcrypt.hashpw(p.encode('utf-8'), mypass.encode('utf-8')).decode('ascii')
+                if mypass == theirpass:
+                    sc.execute("INSERT INTO `sessions` (`userid`, `cookie`, `timestamp`) VALUES (?, ?, ?)",
+                               (u, session.cookie, int(time.time())))
+                    session_conn.commit()
+                    session_conn.close()
+                    account_conn.close()
+                    yield json.dumps({"message": "Logged in OK!"}, indent = 2)
+                    return
+            # Fall back to a 403 if username and password did not match
+            raise API.exception(403, "Wrong username or password supplied!")
+        
+        elif session.DB.dbtype == 'elasticsearch':
+            if session.DB.ES.exists(index=session.DB.dbname, doc_type='useraccount', id = u):
+                doc = session.DB.ES.get(index=session.DB.dbname, doc_type='useraccount', id = u)
+                hp = doc['_source']['password']
+                if bcrypt.hashpw(p.encode('utf-8'), hp.encode('utf-8')).decode('ascii') == hp:
+                    # If verification is enabled, make sure account is verified
+                    if session.config['accounts'].get('verify'):
+                        if doc['_source']['verified'] == False:
+                            raise API.exception(403, "Your account needs to be verified first. Check your inbox!")
+                    sessionDoc = {
+                        'cid': u,
+                        'id': session.cookie,
+                        'timestamp': int(time.time())
+                    }
+                    session.DB.ES.index(index=session.DB.dbname, doc_type='uisession', id = session.cookie, body = sessionDoc)
+                    yield json.dumps({"message": "Logged in OK!"})
+                    return
+            
+            # Fall back to a 403 if username and password did not match
+            raise API.exception(403, "Wrong username or password supplied!")
+    
+    
+    # We need to be logged in for the rest of this!
+    if not session.user:
+        raise API.exception(403, "You must be logged in to use this API endpoint!")
+    
+    # Delete a session (log out)
+    if method == "DELETE":
+        if self.DB.dbtype == 'sqlite':
+            c = self.DB.sqlite.open('sessions.db')
+            cur = c.cursor()
+            cur.execute("DELETE FROM `sessions` WHERE `cookie` = ? LIMIT 1", (session.cookie,))
+            c.commit()
+            c.close()
+        elif self.DB.dbtype == 'elasticsearch':
+            session.DB.ES.delete(index=session.DB.dbname, doc_type='uisession', id = session.cookie)
+        session.newCookie()
+        yield json.dumps({"message": "Logged out, bye bye!"})
+    
+    # Display the user data for this session
+    if method == "GET":
+        
+        JSON_OUT = {
+            'userid': session.user['userid'],
+            'userlevel': session.user['userlevel']
+        }
+        yield json.dumps(JSON_OUT, indent = 2)
+        return
+    
+    # Finally, if we hit a method we don't know, balk!
+    yield API.exception(400, "I don't know this request method!!")
+    
diff --git a/api/pages/widgets.py b/api/pages/widgets.py
new file mode 100644
index 0000000..d25d0c7
--- /dev/null
+++ b/api/pages/widgets.py
@@ -0,0 +1,67 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+########################################################################
+# OPENAPI-URI: /api/widgets/{pageid}
+########################################################################
+#    get:
+#      summary: Shows the widget layout for a specific page
+#      security:
+#        - cookieAuth: []
+#      parameters:
+#        - name: pageid
+#          in: path
+#          description: Page ID to fetch design for
+#          required: true
+#          schema:
+#            type: string
+#      responses:
+#        '200':
+#          description: 200 Response
+#          content:
+#            application/json:
+#              schema:
+#                $ref: '#/components/schemas/WidgetDesign'
+#        default:
+#          description: unexpected error
+#          content:
+#            application/json:
+#              schema:
+#                $ref: '#/components/schemas/Error'
+########################################################################
+"""
+This is the widget design handler for Warble
+"""
+
+import yaml
+import json
+
+def run(API, environ, indata, session):
+    
+    if not session.user:
+        raise API.exception(403, "You must be logged in to use this API endpoint!")
+    
+    widgets = yaml.load(open("yaml/widgets.yaml"))
+    
+    page = indata['pageid']
+    if not page or page == '0':
+        page = widgets.get('defaultWidget', 'repos')
+    if page in widgets['widgets']:
+        yield json.dumps(widgets['widgets'][page])
+    else:
+        raise API.exception(404, "Widget design not found!")
+    
+    
diff --git a/api/plugins/database.py b/api/plugins/database.py
new file mode 100644
index 0000000..53eb2cd
--- /dev/null
+++ b/api/plugins/database.py
@@ -0,0 +1,103 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+This is the ES/sqlite library for Apache Warble.
+It stores the elasticsearch handler and config options.
+"""
+
+
+# Main imports
+import os
+import sqlite3
+
+class WarbleESWrapper(object):
+    """
+       Class for rewriting old-style queries to the new ones,
+       where doc_type is an integral part of the DB name
+    """
+    def __init__(self, ES):
+        self.ES = ES
+
+    def get(self, index, doc_type, id):
+        return self.ES.get(index = index+'_'+doc_type, doc_type = '_doc', id = id)
+    def exists(self, index, doc_type, id):
+        return self.ES.exists(index = index+'_'+doc_type, doc_type = '_doc', id = id)
+    def delete(self, index, doc_type, id):
+        return self.ES.delete(index = index+'_'+doc_type, doc_type = '_doc', id = id)
+    def index(self, index, doc_type, id, body):
+        return self.ES.index(index = index+'_'+doc_type, doc_type = '_doc', id = id, body = body)
+    def update(self, index, doc_type, id, body):
+        return self.ES.update(index = index+'_'+doc_type, doc_type = '_doc', id = id, body = body)
+    def search(self, index, doc_type, size = 100, _source_include = None, body = None):
+        return self.ES.search(
+            index = index+'_'+doc_type,
+            doc_type = '_doc',
+            size = size,
+            _source_include = _source_include,
+            body = body
+            )
+    def count(self, index, doc_type, body = None):
+        return self.ES.count(
+            index = index+'_'+doc_type,
+            doc_type = '_doc',
+            body = body
+            )
+
+class WarbleSqlite(object):
+    
+    def __init__(self, path):
+        self.path = path
+        
+    def open(self, file):
+        c = sqlite3.connect(os.path.join(self.path, file))
+        c.row_factory = sqlite3.Row
+        return c
+
+
+class WarbleDatabase(object):
+    def __init__(self, config):
+        self.config = config
+        
+        # sqlite driver?
+        if self.config['database']['driver'] == 'sqlite':
+            self.dbtype = 'sqlite'
+            self.sqlite = WarbleSqlite(self.config['database']['path'])
+            
+        # ES driver?
+        if self.config['database']['driver'] == 'elasticsearch':
+            import elasticsearch
+            self.dbtype = 'elasticsearch'
+            self.dbname = config['elasticsearch']['dbname']
+            self.ES = elasticsearch.Elasticsearch([{
+                    'host': config['elasticsearch']['host'],
+                    'port': int(config['elasticsearch']['port']),
+                    'use_ssl': config['elasticsearch']['ssl'],
+                    'verify_certs': False,
+                    'url_prefix': config['elasticsearch']['uri'] if 'uri' in config['elasticsearch'] else '',
+                    'http_auth': config['elasticsearch']['auth'] if 'auth' in config['elasticsearch'] else None
+                }],
+                    max_retries=5,
+                    retry_on_timeout=True
+                )
+    
+            # IMPORTANT BIT: Figure out if this is ES 6.x or newer.
+            # If so, we're using the new ES DB mappings, and need to adjust ALL
+            # ES calls to match this.
+            es6 = True if int(self.ES.info()['version']['number'].split('.')[0]) >= 6 else False
+            if es6:
+                self.ES = WarbleESWrapper(self.ES)
diff --git a/api/plugins/openapi.py b/api/plugins/openapi.py
new file mode 100644
index 0000000..71a7117
--- /dev/null
+++ b/api/plugins/openapi.py
@@ -0,0 +1,259 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+This is the OpenAPI validator library.
+Validates input using the OpenAPI specification version 3 from
+https://github.com/OAI/OpenAPI-Specification (a simplified version, ahem)
+"""
+
+import yaml
+import json
+import functools
+import operator
+import re
+
+class OpenAPIException(Exception):
+    def __init__(self, message):
+        self.message = message
+
+# Python type names to JSON type names
+py2JSON = {
+    'int':      'integer',
+    'float':    'float',
+    'str':      'string',
+    'list':     'array',
+    'dict':     'object',
+    'bool':     'boolean'
+}
+
+mcolors = {
+    'PUT':      '#fca130',
+    'DELETE':   '#f93e3e',
+    'GET':      '#61affe',
+    'POST':     '#49cc5c',
+    'PATCH':    '#d5a37e'
+}
+
+class OpenAPI():
+    def __init__(self, APIFile):
+        """ Instantiates an OpenAPI validator given a YAML specification"""
+        if APIFile.endswith(".json") or APIFile.endswith(".js"):
+            self.API = json.load(open(APIFile))
+        else:
+            self.API = yaml.load(open(APIFile))
+
+    def validateType(self, field, value, ftype):
+        """ Validate a single field value against an expected type """
+
+        # Get type of value, convert to JSON name of type.
+        pyType = type(value).__name__
+        jsonType = py2JSON[pyType] if pyType in py2JSON else pyType
+
+        # Check if type matches
+        if ftype != jsonType:
+            raise OpenAPIException("OpenAPI mismatch: Field '%s' was expected to be %s, but was really %s!" % (field, ftype, jsonType))
+
+    def validateSchema(self, pdef, formdata, schema = None):
+        """ Validate (sub)parameters against OpenAPI specs """
+
+        # allOf: list of schemas to validate against
+        if 'allOf' in pdef:
+            for subdef in pdef['allOf']:
+                self.validateSchema(subdef, formdata)
+
+        where = "JSON body"
+        # Symbolic link??
+        if 'schema' in pdef:
+            schema = pdef['schema']['$ref']
+        if '$ref' in pdef:
+            schema = pdef['$ref']
+        if schema:
+            # #/foo/bar/baz --> dict['foo']['bar']['baz']
+            pdef = functools.reduce(operator.getitem, schema.split('/')[1:], self.API)
+            where = "item matching schema %s" % schema
+
+        # Check that all required fields are present
+        if 'required' in pdef:
+            for field in pdef['required']:
+                if not field in formdata:
+                    raise OpenAPIException("OpenAPI mismatch: Missing input field '%s' in %s!" % (field, where))
+
+        # Now check for valid format of input data
+        for field in formdata:
+            if 'properties' not in pdef or field not in pdef['properties'] :
+                raise OpenAPIException("Unknown input field '%s' in %s!" % (field, where))
+            if 'type' not in pdef['properties'][field]:
+                raise OpenAPIException("OpenAPI mismatch: Field '%s' was found in api.yaml, but no format was specified in specs!" % field)
+            ftype = pdef['properties'][field]['type']
+            self.validateType(field, formdata[field], ftype)
+
+            # Validate sub-arrays
+            if ftype == 'array' and 'items' in pdef['properties'][field]:
+                for item in formdata[field]:
+                    if '$ref' in pdef['properties'][field]['items']:
+                        self.validateSchema(pdef['properties'][field]['items'], item)
+                    else:
+                        self.validateType(field, formdata[field], pdef['properties'][field]['items']['type'])
+
+            # Validate sub-hashes
+            if ftype == 'hash' and 'schema' in pdef['properties'][field]:
+                self.validateSchema(pdef['properties'][field], formdata[field])
+    def validateParameters(self, defs, formdata):
+        #
+        pass
+
+    def validate(self, method = "GET", path = "/foo", formdata = None):
+        """ Validate the request method and input data against the OpenAPI specification """
+
+        # Make sure we're not dealing with a dynamic URL.
+        # If we find /foo/{key}, we fold that into the form data
+        # and process as if it's a json input field for now.
+        if not self.API['paths'].get(path):
+            for xpath in self.API['paths']:
+                pathRE = re.sub(r"\{(.+?)\}", r"(?P<\1>[^/]+)", xpath)
+                m = re.match(pathRE, path)
+                if m:
+                    for k,v  in m.groupdict().items():
+                        formdata[k] = v
+                    path = xpath
+                    break
+
+        if self.API['paths'].get(path):
+            defs = self.API['paths'].get(path)
+            method = method.lower()
+            if method in defs:
+                mdefs = defs[method]
+                if formdata and 'parameters' in mdefs:
+                    self.validateParameters(mdefs['parameters'], formdata)
+                elif formdata and 'requestBody' not in mdefs:
+                    raise OpenAPIException("OpenAPI mismatch: JSON data is now allowed for this request type")
+                elif formdata and 'requestBody' in mdefs and 'content' in mdefs['requestBody']:
+
+                    # SHORTCUT: We only care about JSON input for Warble! Disregard other types
+                    if not 'application/json' in mdefs['requestBody']['content']:
+                        raise OpenAPIException ("OpenAPI mismatch: API endpoint accepts input, but no application/json definitions found in api.yaml!")
+                    jdefs = mdefs['requestBody']['content']['application/json']
+
+                    # Check that required params are here
+                    self.validateSchema(jdefs, formdata)
+
+            else:
+                raise OpenAPIException ("OpenAPI mismatch: Method %s is not registered for this API" % method)
+        else:
+            raise OpenAPIException("OpenAPI mismatch: Unknown API path '%s'!" % path)
+
+    def dumpExamples(self, pdef, array = False):
+        schema = None
+        if 'schema' in pdef:
+            if 'type' in pdef['schema'] and pdef['schema']['type'] == 'array':
+                array = True
+                schema = pdef['schema']['items']['$ref']
+            else:
+                schema = pdef['schema']['$ref']
+        if '$ref' in pdef:
+            schema = pdef['$ref']
+        if schema:
+            # #/foo/bar/baz --> dict['foo']['bar']['baz']
+            pdef = functools.reduce(operator.getitem, schema.split('/')[1:], self.API)
+        js = {}
+        desc = {}
+        if 'properties' in pdef:
+            for k, v in pdef['properties'].items():
+                if 'description' in v:
+                    desc[k] = [v['type'], v['description']]
+                if 'example' in v:
+                    js[k] = v['example']
+                elif 'items' in v:
+                    if v['type'] == 'array':
+                        js[k], foo = self.dumpExamples(v['items'], True)
+                    else:
+                        js[k], foo = self.dumpExamples(v['items'])
+        return [js if not array else [js], desc]
+
+    def toHTML(self):
+        """ Blurps out the specs in a pretty HTML blob """
+        print("""
+<!DOCTYPE html>
+<html lang="en">
+<head>
+</head>
+<body>
+""")
+        li = "<h3>Overview:</h3><ul style='font-size: 12px; font-family: Open Sans, sans-serif;'>"
+        for path, spec in sorted(self.API['paths'].items()):
+            for method, mspec in sorted(spec.items()):
+                method = method.upper()
+                summary = mspec.get('summary', 'No summary available')
+                linkname = "%s%s" % (method.lower(), path.replace('/', '-'))
+                li += "<li><a href='#%s'>%s %s</a>: %s</li>\n" % (linkname, method, path, summary)
+        li += "</ul>"
+        print(li)
+        for path, spec in sorted(self.API['paths'].items()):
+            for method, mspec in sorted(spec.items()):
+                method = method.upper()
+                summary = mspec.get('summary', 'No summary available')
+                resp = ""
+                inp = ""
+                inpvars = ""
+                linkname = "%s%s" % (method.lower(), path.replace('/', '-'))
+                if 'responses' in mspec:
+                    for code, cresp in sorted(mspec['responses'].items()):
+                        for ctype, pdef in cresp['content'].items():
+                            xjs, desc = self.dumpExamples(pdef)
+                            js = json.dumps(xjs, indent = 4)
+                            resp += "<div style='float: left; width: 90%%;'><pre style='width: 600px;'><b>%s</b>:\n%s</pre>\n</div>\n" % (code, js)
+
+                if 'requestBody' in mspec:
+                    for ctype, pdef in mspec['requestBody']['content'].items():
+                        xjs, desc = self.dumpExamples(pdef)
+                        if desc:
+                            for k, v in desc.items():
+                                inpvars += "<kbd><b>%s:</b></kbd> (%s) <span style='font-size: 12px; font-family: Open Sans, sans-serif;'>%s</span><br/>\n" % (k, v[0], v[1])
+                        js = json.dumps(xjs, indent = 4)
+                        inp += "<div style='float: left; width: 90%%;'><h4>Input examples:</h4><blockquote><pre style='width: 600px;'><b>%s</b>:\n%s</pre></blockquote>\n</div>" % (ctype, js)
+
+                if inpvars:
+                    inpvars = "<div style='float: left; width: 90%%;'><blockquote><pre style='width: 600px;'>%s</pre>\n</blockquote></div>" % inpvars
+
+
+                print("""
+                      <div id="%s" style="margin: 20px; display: flex; box-sizing: border-box; width: 900px; border-radius: 6px; border: 1px solid %s; font-family: sans-serif; background: %s30;">
+                        <div style="min-height: 32px;">
+                          <!-- method -->
+
+                          <div style="float: left; align-items: center; margin: 4px; border-radius: 5px; text-align: center; padding-top: 4px; height: 20px; width: 100px; color: #FFF; font-weight: bold; background: %s;">%s</div>
+
+                          <!-- path and summary -->
+                          <span style="display: flex; padding-top: 6px;"><kbd><strong>%s</strong></kbd></span>
+                          <div style="box-sizing: border-box; flex: 1; font-size: 13px; font-family: Open Sans, sans-serif; float: left; padding-top: 6px; margin-left: 20px;">
+                          %s</div>
+                          <div style="float: left; width: 90%%;display: %s; ">
+                            <h4>JSON parameters:</h4>
+                            %s
+                            <br/>
+                            %s
+                          </div>
+                          <div style="float: left; width: 90%%; ">
+                            <h4>Response examples:</h4>
+                            <blockquote>%s</blockquote>
+                          </div>
+                        </div>
+                      </div>
+                      """ % (linkname, mcolors[method], mcolors[method], mcolors[method], method, path, summary, "block" if inp else "none", inpvars, inp, resp))
+                #print("%s %s: %s" % (method.upper(), path, mspec['summary']))
+        print("</body></html>")
diff --git a/api/plugins/session.py b/api/plugins/session.py
new file mode 100644
index 0000000..ba20291
--- /dev/null
+++ b/api/plugins/session.py
@@ -0,0 +1,154 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+This is the session library for Apache Warble.
+It handles setting/getting cookies and user prefs
+"""
+
+
+# Main imports
+import cgi
+import re
+import sys
+import traceback
+import http.cookies
+import uuid
+import time
+
+class WarbleSession(object):
+
+    def logout(self):
+        """Log out user and wipe cookie"""
+        if self.user and self.cookie:
+            cookies = http.cookies.SimpleCookie()
+            cookies['warble_session'] = "null"
+            self.headers.append(('Set-Cookie', cookies['warble_session'].OutputString()))
+            try:
+                if self.DB.dbtype == 'sqlite':
+                    c = self.DB.sqlite.open('sessions.db')
+                    cur = c.cursor()
+                    cur.execute("DELETE FROM `sessions` WHERE `cookie` = ? LIMIT 1", (self.cookie,))
+                    c.commit()
+                    c.close()
+                elif self.DB.dbtype == 'elasticsearch':
+                    self.DB.ES.delete(index=self.DB.dbname, doc_type='uisession', id = self.cookie)
+                    
+                self.cookie = None
+                self.user = None
+            except:
+                pass
+    def newCookie(self):
+        cookie = uuid.uuid4()
+        cookies = http.cookies.SimpleCookie()
+        cookies['warble_session'] = cookie
+        cookies['warble_session']['expires'] = 86400 * 365 # Expire one year from now
+        self.headers.append(('Set-Cookie', cookies['warble_session'].OutputString()))
+        
+    def __init__(self, DB, environ, config):
+        """
+        Loads the current user session or initiates a new session if
+        none was found.
+        """
+        self.config = config
+        self.user = None
+        self.DB = DB
+        self.headers = [('Content-Type', 'application/json')]
+        self.cookie = None
+
+        # Construct the URL we're visiting
+        self.url = "%s://%s" % (environ['wsgi.url_scheme'], environ.get('HTTP_HOST', environ.get('SERVER_NAME')))
+        self.url += environ.get('SCRIPT_NAME', '/')
+
+        # Get Warble cookie
+        cookie = None
+        cookies = None
+        
+        if 'HTTP_COOKIE' in environ:
+            cookies = http.cookies.SimpleCookie(environ['HTTP_COOKIE'])
+        if cookies and 'warble_session' in cookies:
+            cookie = cookies['warble_session'].value
+            try:
+                if re.match(r"^[-a-f0-9]+$", cookie): # Validate cookie, must follow UUID4 specs
+                    doc = None
+                    if self.DB.dbtype == 'sqlite':
+                        session_conn = self.DB.sqlite.open('sessions.db')
+                        account_conn = self.DB.sqlite.open('accounts.db')
+                        sc = session_conn.cursor()
+                        ac = account_conn.cursor()
+                        sc.execute("SELECT * FROM `sessions` WHERE `cookie` = ?", (cookie,))
+                        sdoc = sc.fetchone()
+                        if sdoc:
+                            userid = sdoc['userid']
+                            if userid:
+                                sc.execute("SELECT * FROM `accounts` WHERE `userid` = ?", (userid,))
+                                doc = sc.fetchone()
+                        if doc:
+                            # Make sure this cookie has been used in the past 7 days, else nullify it.
+                            # Further more, run an update of the session if >1 hour ago since last update.
+                            age = time.time() - sdoc['timestamp']
+                            if age > (7*86400):
+                                sc.execute("DELETE FROM `sessions` WHERE `cookie` = ? LIMIT 1", (self.cookie,))
+                                sdoc = None # Wipe it!
+                                doc = None
+                            elif age > 3600:
+                                sdoc['timestamp'] = int(time.time()) # Update timestamp in session DB
+                                sc.execute("UPDATE `sessions` SET `timestamp` = ? WHERE `cookie` = ? LIMIT 1", (sdoc['timestamp'], cookie,))
+                            if doc:
+                                self.user = doc
+                                self.user['userlevel'] = 'superuser'if doc['superuser'] else 'normal'
+                        session_conn.commit()
+                        session_conn.close()
+                        account_conn.close()
+
+                    if self.DB.dbtype == 'elasticsearch':
+                        sdoc = self.DB.ES.get(index=self.DB.dbname, doc_type='uisession', id = cookie)
+                        if sdoc and 'cid' in sdoc['_source']:
+                            doc = self.DB.ES.get(index=self.DB.dbname, doc_type='useraccount', id = sdoc['_source']['cid'])
+                        if doc and '_source' in doc:
+                            # Make sure this cookie has been used in the past 7 days, else nullify it.
+                            # Further more, run an update of the session if >1 hour ago since last update.
+                            age = time.time() - sdoc['_source']['timestamp']
+                            if age > (7*86400):
+                                self.DB.ES.delete(index=self.DB.dbname, doc_type='uisession', id = cookie)
+                                sdoc['_source'] = None # Wipe it!
+                                doc = None
+                            elif age > 3600:
+                                sdoc['_source']['timestamp'] = int(time.time()) # Update timestamp in session DB
+                                self.DB.ES.update(index=self.DB.dbname, doc_type='uisession', id = cookie, body = {'doc':sdoc['_source']})
+                            if doc:
+                                self.user = doc['_source']
+                else:
+                    cookie = None
+            except Exception as err:
+                print(err)
+        # Non-human (node/agent) API Key auth
+        elif 'HTTP_APIKEY' in environ:
+            cookie = environ['HTTP_APIKEY']
+            if re.match(r"^[-a-f0-9]+$", cookie): # Validate cookie, must follow UUID4 specs
+                registry_conn = self.DB.sqlite.open('nodes.db')
+                rc = registry_conn.cursor()
+                rc.execute("SELECT * FROM `registry` WHERE `apikey` = ? LIMIT 1", (cookie,))
+                ndoc = rc.fetchone()
+                if ndoc:
+                    self.user = {k:ndoc[k] for k in ndoc.keys()}
+                    self.user['human'] = False
+                    self.user['userid'] = 'node:%s' % ndoc['id']
+                    self.user['userlevel'] = 'robbit'
+        if not cookie:
+            self.newCookie()
+        self.cookie = cookie
diff --git a/api/yaml/openapi/combine.py b/api/yaml/openapi/combine.py
new file mode 100644
index 0000000..30f9806
--- /dev/null
+++ b/api/yaml/openapi/combine.py
@@ -0,0 +1,145 @@
+#!/usr/bin/env python3
+
+import yaml
+import os
+import sys
+import re
+
+license = """#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""
+
+baseyaml = """
+# THIS IS PULLED FROM SCRIPTS AND AUTOGENERATED!
+# Please use openapi/combine.py to regenerate!
+openapi: 3.0.0
+info:
+  version: 1.0.0
+  description: This is the API specifications for interacting with the Warble Server.
+  title: Apache Warble API
+  license:
+    name: Apache 2.0
+    url: 'http://www.apache.org/licenses/LICENSE-2.0.html'
+"""
+
+bpath = os.path.dirname(os.path.abspath(__file__))
+
+
+def deconstruct():
+    yml = yaml.load(open(bpath + "/../openapi.yaml"))
+    noDefs = 0
+    print("Dumping paths into pages...")
+    for endpoint, defs in yml['paths'].items():
+        noDefs += 1
+        xendpoint = endpoint.replace("/api/", "")
+        ypath = os.path.abspath("%s/../../pages/%s.py" % (bpath, xendpoint))
+        print(ypath)
+        if os.path.isfile(ypath):
+            print("Editing %s" % ypath)
+            contents = open(ypath, "r").read()
+            contents = re.sub(r"^([#\n](?!\s*\"\"\")[^\r\n]*\n?)+", "", contents, re.MULTILINE)
+            odefs = yaml.dump(defs, default_flow_style=False)
+            odefs = "\n".join(["# %s" % line for line in odefs.split("\n")])
+            with open(ypath, "w") as f:
+                f.write(license)
+                f.write("########################################################################\n")
+                f.write("# OPENAPI-URI: %s\n" % endpoint)
+                f.write("########################################################################\n")
+                f.write(odefs)
+                f.write("\n########################################################################\n")
+                f.write("\n\n")
+                f.write(contents)
+                f.close()
+        
+    print("Dumping security components...")
+    for basetype, bdefs in yml['components'].items():
+        for schema, defs in bdefs.items():
+            noDefs += 1
+            ypath = "%s/components/%s/%s.yaml" % (bpath, basetype, schema)
+            ydir = os.path.dirname(ypath)
+            if not os.path.isdir(ydir):
+                print("Making directory %s" % ydir)
+                os.makedirs(ydir, exist_ok = True)
+            with open(ypath, "w") as f:
+                f.write("########################################################################\n")
+                f.write("# %-68s #\n" % defs.get('summary', schema))
+                f.write("########################################################################\n")
+                f.write(yaml.dump(defs, default_flow_style=False))
+                f.close()
+    print("Dumped %u definitions." % noDefs)
+    
+def construct():
+    yml = {}
+    yml['paths'] = {}
+    yml['components'] = {}
+    apidir = os.path.abspath("%s/../../pages/" % bpath)
+    print("Scanning %s" % apidir)
+    for d in os.listdir(apidir):
+        cdir = os.path.abspath("%s/%s" % (apidir, d))
+        if os.path.isdir(cdir):
+            print("Scanning %s" % cdir)
+            for fname in os.listdir(cdir):
+                if fname.endswith(".py"):
+                    fpath = "%s/%s" % (cdir, fname)
+                    print("Scanning %s" % fpath)
+                    contents = open(fpath, "r").read()
+                    m = re.search(r"OPENAPI-URI: (\S+)\n##+\n([\s\S]+?)##+", contents)
+                    if m:
+                        apath = m.group(1)
+                        cyml = m.group(2)
+                        print("Weaving in API path %s" % apath)
+                        cyml = "\n".join([line[2:] for line in cyml.split("\n")])
+                        defs = yaml.load(cyml)
+                        yml['paths'][apath] = defs
+        else:
+            fname = d
+            if fname.endswith(".py"):
+                fpath = "%s/%s" % (apidir, fname)
+                print("Scanning %s" % fpath)
+                contents = open(fpath, "r").read()
+                m = re.search(r"OPENAPI-URI: (\S+)\n##+\n([\s\S]+?)##+", contents)
+                if m:
+                    apath = m.group(1)
+                    cyml = m.group(2)
+                    print("Weaving in API path %s" % apath)
+                    cyml = "\n".join([line[2:] for line in cyml.split("\n")])
+                    defs = yaml.load(cyml)
+                    yml['paths'][apath] = defs
+    apidir = os.path.abspath("%s/components" % bpath)
+    print("Scanning %s" % apidir)
+    for d in os.listdir(apidir):
+        cdir = os.path.abspath("%s/%s" % (apidir, d))
+        if os.path.isdir(cdir):
+            print("Scanning %s" % cdir)
+            for fname in os.listdir(cdir):
+                if fname.endswith(".yaml"):
+                    yml['components'][d] = yml['components'].get(d, {})
+                    fpath = "%s/%s" % (cdir, fname)
+                    print("Scanning %s" % fpath)
+                    defs = yaml.load(open(fpath))
+                    yml['components'][d][fname.replace(".yaml", "")] = defs
+    ypath = os.path.abspath("%s/../openapi.yaml" % bpath)
+    with open(ypath, "w") as f:
+        f.write(baseyaml)
+        f.write(yaml.dump(yml, default_flow_style=False))
+        f.close()
+    print("All done!")
+    
+if len(sys.argv) > 1 and sys.argv[1] == 'deconstruct':
+    deconstruct()
+else:
+    construct()
diff --git a/api/yaml/openapi/components/schemas/ActionCompleted.yaml b/api/yaml/openapi/components/schemas/ActionCompleted.yaml
new file mode 100644
index 0000000..41f4d54
--- /dev/null
+++ b/api/yaml/openapi/components/schemas/ActionCompleted.yaml
@@ -0,0 +1,10 @@
+########################################################################
+# ActionCompleted                                                      #
+########################################################################
+properties:
+  message:
+    description: Acknowledgement message
+    example: Action completed
+    type: string
+required:
+- message
diff --git a/api/yaml/openapi/components/schemas/Empty.yaml b/api/yaml/openapi/components/schemas/Empty.yaml
new file mode 100644
index 0000000..5092518
--- /dev/null
+++ b/api/yaml/openapi/components/schemas/Empty.yaml
@@ -0,0 +1,11 @@
+########################################################################
+# Empty                                                                #
+########################################################################
+properties:
+  id:
+    description: optional object ID
+    type: string
+  page:
+    description: optional page id
+    type: string
+required: []
diff --git a/api/yaml/openapi/components/schemas/Error.yaml b/api/yaml/openapi/components/schemas/Error.yaml
new file mode 100644
index 0000000..ed7c9d1
--- /dev/null
+++ b/api/yaml/openapi/components/schemas/Error.yaml
@@ -0,0 +1,16 @@
+########################################################################
+# Error                                                                #
+########################################################################
+properties:
+  code:
+    description: HTTP Error Code
+    example: 403
+    format: int16
+    type: integer
+  reason:
+    description: Human readable error message
+    example: You need to be logged in to view this endpoint!
+    type: string
+required:
+- code
+- reason
diff --git a/api/yaml/openapi/components/schemas/Timeseries.yaml b/api/yaml/openapi/components/schemas/Timeseries.yaml
new file mode 100644
index 0000000..63d64be
--- /dev/null
+++ b/api/yaml/openapi/components/schemas/Timeseries.yaml
@@ -0,0 +1,12 @@
+########################################################################
+# Timeseries                                                           #
+########################################################################
+properties:
+  interval:
+    type: string
+  okay:
+    type: boolean
+  timeseries:
+    items:
+      $ref: '#/components/schemas/TimeseriesObject'
+    type: array
diff --git a/api/yaml/openapi/components/schemas/TimeseriesObject.yaml b/api/yaml/openapi/components/schemas/TimeseriesObject.yaml
new file mode 100644
index 0000000..850f450
--- /dev/null
+++ b/api/yaml/openapi/components/schemas/TimeseriesObject.yaml
@@ -0,0 +1,18 @@
+########################################################################
+# TimeseriesObject                                                     #
+########################################################################
+properties:
+  $item:
+    description: A timeseries object
+    example: 50
+    type: integer
+  $otheritem:
+    description: A timeseries object
+    example: 26
+    type: integer
+  date:
+    description: Seconds since UNIX epoch
+    example: 1508273
+    type: integer
+required:
+- date
diff --git a/api/yaml/openapi/components/schemas/UserAccount.yaml b/api/yaml/openapi/components/schemas/UserAccount.yaml
new file mode 100644
index 0000000..30e6341
--- /dev/null
+++ b/api/yaml/openapi/components/schemas/UserAccount.yaml
@@ -0,0 +1,20 @@
+########################################################################
+# UserAccount                                                          #
+########################################################################
+properties:
+  displayname:
+    description: A display name (e.g. full name) for the account
+    example: Warble User
+    type: string
+  email:
+    description: Desired username (email address)
+    example: guest@warble.xyz
+    type: string
+  password:
+    description: Desired password for the account
+    example: warbledemo
+    type: string
+required:
+- email
+- password
+- displayname
diff --git a/api/yaml/openapi/components/schemas/UserCredentials.yaml b/api/yaml/openapi/components/schemas/UserCredentials.yaml
new file mode 100644
index 0000000..c6f4fdd
--- /dev/null
+++ b/api/yaml/openapi/components/schemas/UserCredentials.yaml
@@ -0,0 +1,15 @@
+########################################################################
+# UserCredentials                                                      #
+########################################################################
+properties:
+  username:
+    description: Username (email?)
+    example: admin
+    type: string
+  password:
+    description: User password
+    example: warbledemo
+    type: string
+required:
+- username
+- password
diff --git a/api/yaml/openapi/components/schemas/WidgetApp.yaml b/api/yaml/openapi/components/schemas/WidgetApp.yaml
new file mode 100644
index 0000000..687ef0f
--- /dev/null
+++ b/api/yaml/openapi/components/schemas/WidgetApp.yaml
@@ -0,0 +1,37 @@
+########################################################################
+# WidgetApp                                                            #
+########################################################################
+properties:
+  blocks:
+    description: Size (width) in UI blocks of the app
+    example: 4
+    type: integer
+  datatype:
+    description: The top category of this data
+    example: repo
+    type: string
+  name:
+    description: The title of the widget app
+    example: Widget Title
+    type: string
+  representation:
+    description: The visual representation style of this widget
+    example: donut
+    type: string
+  source:
+    description: The API endpoint to get data from
+    example: code-evolution
+    type: string
+  target:
+    type: string
+  text:
+    description: Text to insert into the widget (if paragraph type widget)
+    type: string
+  type:
+    description: The type of widget
+    example: My Widget
+    type: string
+required:
+- type
+- name
+- blocks
diff --git a/api/yaml/openapi/components/schemas/WidgetDesign.yaml b/api/yaml/openapi/components/schemas/WidgetDesign.yaml
new file mode 100644
index 0000000..a7ab1f2
--- /dev/null
+++ b/api/yaml/openapi/components/schemas/WidgetDesign.yaml
@@ -0,0 +1,10 @@
+########################################################################
+# WidgetDesign                                                         #
+########################################################################
+properties:
+  rows:
+    items:
+      $ref: '#/components/schemas/WidgetRow'
+    type: array
+  title:
+    type: string
diff --git a/api/yaml/openapi/components/schemas/WidgetRow.yaml b/api/yaml/openapi/components/schemas/WidgetRow.yaml
new file mode 100644
index 0000000..4d3d97d
--- /dev/null
+++ b/api/yaml/openapi/components/schemas/WidgetRow.yaml
@@ -0,0 +1,10 @@
+########################################################################
+# WidgetRow                                                            #
+########################################################################
+properties:
+  children:
+    items:
+      $ref: '#/components/schemas/WidgetApp'
+    type: array
+  name:
+    type: string
diff --git a/api/yaml/openapi/components/schemas/defaultWidgetArgs.yaml b/api/yaml/openapi/components/schemas/defaultWidgetArgs.yaml
new file mode 100644
index 0000000..5facff9
--- /dev/null
+++ b/api/yaml/openapi/components/schemas/defaultWidgetArgs.yaml
@@ -0,0 +1,79 @@
+########################################################################
+# defaultWidgetArgs                                                    #
+########################################################################
+properties:
+  author:
+    description: Turns on author view for code results, as opposed to the default
+      committer view
+    type: boolean
+  collapse:
+    description: for some widgets, this collapses sources based on a regex
+    type: string
+  email:
+    description: filter sources based on an email (a person)
+    type: string
+  from:
+    description: If specified, compile data from this timestamp onwards
+    example: 1400000000
+    type: integer
+  interval:
+    description: If fetching histograms, this specifies the interval to pack figures
+      into. Can be day, week, month, quarter or year
+    example: month
+    type: string
+  links:
+    description: for relationship maps, this denotes the minimum link strength (no.
+      of connections) that makes up a link.
+    type: integer
+  page:
+    type: string
+  quick:
+    description: Turns on quick data for some endpoints, returning only sparse data
+      (thus less traffic)
+    example: false
+    type: boolean
+  search:
+    description: for some widgets, this enables sub-filtering based on searches
+    type: string
+  source:
+    description: If specified, only compile data on this specific sourceID
+    example: abcdef12345678
+    type: string
+  sources:
+    description: for some widget, this fetches all sources
+    type: boolean
+  span:
+    description: For factor charts, denotes the number of months to base factors on
+      from
+    example: 2
+    type: integer
+  subfilter:
+    description: Quickly defined view by sub-filtering the existing view and matching
+      on sourceURLs
+    type: string
+  to:
+    description: If specified, only compile data up until here
+    example: 1503483273
+    type: integer
+  types:
+    description: If set, only return data from sources matching these types
+    example:
+    - jira
+    - bugzilla
+    type: array
+  unique:
+    description: Only compile data from unique commits, ignore duplicates
+    type: boolean
+  view:
+    description: ID Of optional view to use
+    example: abcdef12345678
+    type: string
+  distinguish:
+    description: Enables distinguishing different types of data objects, subject to the individual API endpoint
+    type: boolean
+    example: false
+  relative:
+    description: Enables relative comparison mode for API endpoints that have this feature.
+    type: boolean
+    example: false
+    
\ No newline at end of file
diff --git a/api/yaml/openapi/components/securitySchemes/APIKeyAuth.yaml b/api/yaml/openapi/components/securitySchemes/APIKeyAuth.yaml
new file mode 100644
index 0000000..fa29952
--- /dev/null
+++ b/api/yaml/openapi/components/securitySchemes/APIKeyAuth.yaml
@@ -0,0 +1,6 @@
+########################################################################
+# APIKeyAuth                                                           #
+########################################################################
+in: headers
+name: APIKey
+type: apiKey
diff --git a/api/yaml/openapi/components/securitySchemes/cookieAuth.yaml b/api/yaml/openapi/components/securitySchemes/cookieAuth.yaml
new file mode 100644
index 0000000..c5b9d51
--- /dev/null
+++ b/api/yaml/openapi/components/securitySchemes/cookieAuth.yaml
@@ -0,0 +1,6 @@
+########################################################################
+# cookieAuth                                                           #
+########################################################################
+in: cookie
+name: warble_session
+type: apiKey