summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJon Bergli Heier <snakebite@jvnv.net>2020-11-13 16:58:36 +0100
committerJon Bergli Heier <snakebite@jvnv.net>2020-11-13 16:58:36 +0100
commitf23c03a3375cce6c16ba9255abaddf03efca01eb (patch)
tree68972b182df267d2825f05ce0d02d8eef6c818b7
Initial commit
-rw-r--r--.gitignore3
-rw-r--r--inventory/__init__.py13
-rw-r--r--inventory/api.py147
-rw-r--r--inventory/schema.py40
-rwxr-xr-xrun.sh3
-rw-r--r--setup.cfg3
6 files changed, 209 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..983d38c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+*.pyc
+__pycache__
+inventory.cfg
diff --git a/inventory/__init__.py b/inventory/__init__.py
new file mode 100644
index 0000000..7c12b51
--- /dev/null
+++ b/inventory/__init__.py
@@ -0,0 +1,13 @@
+from flask import Flask
+
+app = Flask(__name__)
+app.config.from_pyfile('inventory.cfg')
+
+with app.app_context():
+ from .api import app as api
+ app.register_blueprint(api, url_prefix='/api')
+
+
+@app.route('/')
+def index():
+ return 'foo'
diff --git a/inventory/api.py b/inventory/api.py
new file mode 100644
index 0000000..0fde577
--- /dev/null
+++ b/inventory/api.py
@@ -0,0 +1,147 @@
+import datetime
+
+from flask import Blueprint, jsonify, current_app, request, abort, url_for
+from flask_pymongo import PyMongo
+from marshmallow import ValidationError
+import pymongo
+import pytz
+
+from .schema import NodeSchema
+
+# Setup PyMongo
+mongo = PyMongo(current_app, tz_aware=True)
+mongo.db.nodes.create_index([('fields.value', pymongo.TEXT), ('name', pymongo.TEXT)], name='fields.value_text_name_text')
+mongo.db.nodes.create_index([('parent_id', pymongo.ASCENDING)], name='parent_id')
+
+app = Blueprint('api', __name__)
+
+
+# Error handling
+@app.errorhandler(400)
+def on_invalid_request(e):
+ return jsonify({'message': e.description or 'Invalid request'}), 400
+
+
+@app.errorhandler(404)
+def on_not_found(e):
+ return jsonify({'message': e.description or 'Not found'}), 404
+
+
+@app.errorhandler(500)
+def on_internal_error(e):
+ return jsonify({'message': e.description or 'Invalid request'}), 500
+
+
+@app.errorhandler(ValidationError)
+def on_validation_error(e):
+ return jsonify({'message': 'Validation error', 'fields': e.messages}), 400
+
+
+# Routes
+@app.route('/nodes')
+def root_nodes():
+ schema = NodeSchema(many=True)
+ data = schema.dump(mongo.db.nodes.find({'parent_id': None}))
+ return jsonify(data)
+
+
+@app.route('/nodes', methods=['POST'])
+def add_node():
+ data = request.json
+ if data is None or not isinstance(data, dict):
+ abort(400, 'Payload must be a JSON object')
+ schema = NodeSchema()
+ node = schema.load(data)
+ node['created_at'] = pytz.utc.localize(datetime.datetime.utcnow())
+ result = mongo.db.nodes.insert_one(node)
+ if not result.acknowledged:
+ abort(500, 'Write operation not acknowledged')
+ node_id = result.inserted_id
+ return jsonify({'id': str(node_id), 'url': url_for('.node', node_id=node_id)}), 201
+
+
+@app.route('/nodes/<ObjectId:node_id>')
+def node(node_id):
+ result = mongo.db.nodes.aggregate([
+ {'$match': {'_id': node_id}},
+ {
+ '$graphLookup': {
+ 'from': 'nodes',
+ 'startWith': '$parent_id',
+ 'connectFromField': 'parent_id',
+ 'connectToField': '_id',
+ 'as': 'parents',
+ },
+ },
+ {
+ '$graphLookup': {
+ 'from': 'nodes',
+ 'startWith': '$_id',
+ 'connectFromField': '_id',
+ 'connectToField': 'parent_id',
+ 'as': 'children',
+ 'maxDepth': 1,
+ },
+ },
+ ])
+ try:
+ node = result.next()
+ except StopIteration:
+ abort(404, 'No node found')
+
+ parents = dict((parent['_id'], parent) for parent in node.pop('parents'))
+ current = node
+ # Recursively assign each node their respective parent
+ while current:
+ # NOTE: Python assigns from left to right
+ current['parent'] = current = parents.get(current.get('parent_id'))
+
+ children = dict((child['_id'], child) for child in node.pop('children'))
+ children_list = node['children'] = []
+ for child in children.values():
+ # Direct children should be assigned to the top-most list
+ if child['parent_id'] == node['_id']:
+ children_list.append(child)
+ continue
+ # Otherwise assign to their respective parent
+ parent = children[child['parent_id']]
+ parent.setdefault('children', []).append(child)
+
+ schema = NodeSchema()
+ return jsonify(schema.dump(node))
+
+
+@app.route('/nodes/<ObjectId:node_id>', methods=['PUT'])
+def update_node(node_id):
+ data = request.json
+ if data is None or not isinstance(data, dict):
+ abort(400, 'Payload must be a JSON object')
+ schema = NodeSchema()
+ node = schema.load(data)
+ node['updated_at'] = pytz.utc.localize(datetime.datetime.utcnow())
+ result = mongo.db.nodes.update_one({'_id': node_id}, {'$set': node})
+ if not result.acknowledged:
+ abort(500, 'Write operation not acknowledged')
+ return '', 204
+
+
+@app.route('/nodes/<ObjectId:node_id>', methods=['DELETE'])
+def delete_node(node_id):
+ result = mongo.db.nodes.delete_one({'_id': node_id})
+ if result.deleted_count == 0:
+ abort(404, 'No node found')
+ return jsonify({}), 204
+
+
+@app.route('/search', methods=['POST'])
+def find_nodes():
+ if 'q' not in request.form:
+ abort(400, 'Missing q argument')
+ schema = NodeSchema(many=True)
+ data = schema.dump(mongo.db.nodes.find({'$text': {'$search': request.form['q']}}))
+ return jsonify(data)
+
+
+@app.route('/')
+def index():
+ return 'api'
diff --git a/inventory/schema.py b/inventory/schema.py
new file mode 100644
index 0000000..4837d58
--- /dev/null
+++ b/inventory/schema.py
@@ -0,0 +1,40 @@
+import bson
+from flask import url_for
+from marshmallow import Schema, fields
+
+# Map ObjectId to String
+Schema.TYPE_MAPPING[bson.ObjectId] = fields.String
+
+
+class ObjectId(fields.Field):
+ def _serialize(self, value, attr, obj, **kwargs):
+ if value is None:
+ return value
+ return str(value)
+
+ def _deserialize(self, value, attr, data, **kwargs):
+ if value is None:
+ return value
+ return bson.ObjectId(value)
+
+
+class FieldSchema(Schema):
+ name = fields.String(required=True)
+ value = fields.String(required=True)
+ # type
+
+
+class NodeSchema(Schema):
+ _id = ObjectId(dump_only=True, data_key='id')
+ name = fields.String(required=True)
+ parent_id = ObjectId(default=None)
+ _fields = fields.List(fields.Nested(FieldSchema()), default=[], attribute='fields', data_key='fields')
+
+ # These are not set by the caller, but by the API endpoint
+ created_at = fields.AwareDateTime(dump_only=True)
+ updated_at = fields.AwareDateTime(dump_only=True)
+
+ # Not actual stored fields
+ parent = fields.Nested(lambda: NodeSchema(), dump_only=True)
+ children = fields.List(fields.Nested(lambda: NodeSchema()), dump_only=True)
+ url = fields.Function(lambda obj: url_for('.node', node_id=obj['_id']), dump_only=True)
diff --git a/run.sh b/run.sh
new file mode 100755
index 0000000..170f473
--- /dev/null
+++ b/run.sh
@@ -0,0 +1,3 @@
+#!/bin/sh
+
+FLASK_APP=inventory FLASK_ENV=development flask run "$@"
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 0000000..bccdce2
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,3 @@
+[flake8]
+ignore = E128
+max-line-length = 128