From f23c03a3375cce6c16ba9255abaddf03efca01eb Mon Sep 17 00:00:00 2001 From: Jon Bergli Heier Date: Fri, 13 Nov 2020 16:58:36 +0100 Subject: Initial commit --- .gitignore | 3 ++ inventory/__init__.py | 13 +++++ inventory/api.py | 147 ++++++++++++++++++++++++++++++++++++++++++++++++++ inventory/schema.py | 40 ++++++++++++++ run.sh | 3 ++ setup.cfg | 3 ++ 6 files changed, 209 insertions(+) create mode 100644 .gitignore create mode 100644 inventory/__init__.py create mode 100644 inventory/api.py create mode 100644 inventory/schema.py create mode 100755 run.sh create mode 100644 setup.cfg 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/') +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/', 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/', 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 -- cgit v1.2.3