From 77468350fcc5003c38bce5eb0a33bb139a9cb866 Mon Sep 17 00:00:00 2001
From: Benjamin Bertrand <benjamin.bertrand@esss.se>
Date: Wed, 20 Dec 2017 13:58:36 +0100
Subject: [PATCH] Refactor API

Split API in different blueprints like for the web UI:
- users (login)
- inventory
- network

Use version in API url (api/v1) to make future changes easier without
breaking backward compatibility
---
 app/api/inventory.py | 172 +++++++++++++++++++++++++++
 app/api/main.py      | 276 -------------------------------------------
 app/api/network.py   |  55 +++++++++
 app/api/users.py     |  40 +++++++
 app/api/utils.py     |  56 +++++++++
 app/factory.py       |   8 +-
 6 files changed, 329 insertions(+), 278 deletions(-)
 create mode 100644 app/api/inventory.py
 delete mode 100644 app/api/main.py
 create mode 100644 app/api/network.py
 create mode 100644 app/api/users.py
 create mode 100644 app/api/utils.py

diff --git a/app/api/inventory.py b/app/api/inventory.py
new file mode 100644
index 0000000..af34fa3
--- /dev/null
+++ b/app/api/inventory.py
@@ -0,0 +1,172 @@
+# -*- coding: utf-8 -*-
+"""
+app.api.inventory
+~~~~~~~~~~~~~~~~~
+
+This module implements the inventory API.
+
+:copyright: (c) 2017 European Spallation Source ERIC
+:license: BSD 2-Clause, see LICENSE for more details.
+
+"""
+from flask import Blueprint, jsonify, request
+from flask_jwt_extended import jwt_required
+from .. import utils, models
+from ..decorators import jwt_groups_accepted
+from .utils import commit, create_generic_model, get_generic_model
+
+bp = Blueprint('inventory_api', __name__)
+
+
+def get_item_by_id_or_ics_id(id_):
+    """Retrieve item by id or ICS id"""
+    try:
+        item_id = int(id_)
+    except ValueError:
+        # Assume id_ is an ics_id
+        item = models.Item.query.filter_by(ics_id=id_).first()
+    else:
+        item = models.Item.query.get(item_id)
+    if item is None:
+        raise utils.CSEntryError(f"Item id '{id_}' not found", status_code=404)
+    return item
+
+
+@bp.route('/items')
+@jwt_required
+def get_items():
+    # TODO: add pagination
+    query = utils.get_query(models.Item.query, request.args)
+    items = query.order_by(models.Item._created)
+    data = [item.to_dict() for item in items]
+    return jsonify(data)
+
+
+@bp.route('/items/<id_>')
+@jwt_required
+def get_item(id_):
+    """Retrieve item by id or ICS id"""
+    item = get_item_by_id_or_ics_id(id_)
+    return jsonify(item.to_dict())
+
+
+@bp.route('/items', methods=['POST'])
+@jwt_required
+@jwt_groups_accepted('admin', 'create')
+def create_item():
+    """Register a new item"""
+    # People should assign an ICS id to a serial number when creating
+    # an item so ics_id should also be a mandatory field.
+    # But there are existing items (in confluence and JIRA) that we want to
+    # import and associate after they have been created.
+    return create_generic_model(models.Item, mandatory_fields=('serial_number',))
+
+
+@bp.route('/items/<id_>', methods=['PATCH'])
+@jwt_required
+@jwt_groups_accepted('admin', 'create')
+def patch_item(id_):
+    """Patch an existing item
+
+    id_ can be the primary key or the ics_id field
+    Fields allowed to update are:
+        - ics_id ONLY if currently null (422 returned otherwise)
+        - manufacturer
+        - model
+        - location
+        - status
+        - parent
+
+    422 is returned if other fields are given.
+    """
+    data = request.get_json()
+    if data is None:
+        raise utils.CSEntryError('Body should be a JSON object')
+    if not data:
+        raise utils.CSEntryError('At least one field is required', status_code=422)
+    for key in data.keys():
+        if key not in ('ics_id', 'manufacturer', 'model',
+                       'location', 'status', 'parent'):
+            raise utils.CSEntryError(f"Invalid field '{key}'", status_code=422)
+    item = get_item_by_id_or_ics_id(id_)
+    # Only allow to set ICS id if it's null
+    if item.ics_id is None:
+        item.ics_id = data.get('ics_id')
+    elif 'ics_id' in data:
+        raise utils.CSEntryError("'ics_id' can't be changed", status_code=422)
+    item.manufacturer = utils.convert_to_model(data.get('manufacturer', item.manufacturer), models.Manufacturer)
+    item.model = utils.convert_to_model(data.get('model', item.model), models.Model)
+    item.location = utils.convert_to_model(data.get('location', item.location), models.Location)
+    item.status = utils.convert_to_model(data.get('status', item.status), models.Status)
+    parent_ics_id = data.get('parent')
+    if parent_ics_id is not None:
+        parent = models.Item.query.filter_by(ics_id=parent_ics_id).first()
+        if parent is not None:
+            item.parent_id = parent.id
+            # Update location and status with those from parent
+            item.location = parent.location
+            item.status = parent.status
+    # Update all children status and location
+    for child in item.children:
+        child.location = item.location
+        child.status = item.status
+    commit()
+    return jsonify(item.to_dict())
+
+
+@bp.route('/actions')
+@jwt_required
+def get_actions():
+    return get_generic_model(models.Action, request.args)
+
+
+@bp.route('/manufacturers')
+@jwt_required
+def get_manufacturers():
+    return get_generic_model(models.Manufacturer, request.args)
+
+
+@bp.route('/manufacturers', methods=['POST'])
+@jwt_required
+@jwt_groups_accepted('admin', 'create')
+def create_manufacturer():
+    return create_generic_model(models.Manufacturer)
+
+
+@bp.route('/models')
+@jwt_required
+def get_models():
+    return get_generic_model(models.Model, request.args)
+
+
+@bp.route('/models', methods=['POST'])
+@jwt_required
+@jwt_groups_accepted('admin', 'create')
+def create_model():
+    return create_generic_model(models.Model)
+
+
+@bp.route('/locations')
+@jwt_required
+def get_locations():
+    return get_generic_model(models.Location, request.args)
+
+
+@bp.route('/locations', methods=['POST'])
+@jwt_required
+@jwt_groups_accepted('admin', 'create')
+def create_locations():
+    return create_generic_model(models.Location)
+
+
+@bp.route('/status')
+@jwt_required
+def get_status():
+    return get_generic_model(models.Status, request.args)
+
+
+@bp.route('/status', methods=['POST'])
+@jwt_required
+@jwt_groups_accepted('admin', 'create')
+def create_status():
+    return create_generic_model(models.Status)
diff --git a/app/api/main.py b/app/api/main.py
deleted file mode 100644
index 5ace0ee..0000000
--- a/app/api/main.py
+++ /dev/null
@@ -1,276 +0,0 @@
-# -*- coding: utf-8 -*-
-"""
-app.api.items
-~~~~~~~~~~~~~
-
-This module implements the application API.
-
-:copyright: (c) 2017 European Spallation Source ERIC
-:license: BSD 2-Clause, see LICENSE for more details.
-
-"""
-import sqlalchemy as sa
-from flask import (current_app, Blueprint, jsonify, request)
-from flask_jwt_extended import jwt_required
-from flask_ldap3_login import AuthenticationResponseStatus
-from ..extensions import ldap_manager, db
-from ..models import Item, Manufacturer, Model, Location, Status, Action, Network, Interface
-from .. import utils, tokens
-from ..decorators import jwt_groups_accepted
-
-bp = Blueprint('api', __name__)
-
-
-def commit():
-    try:
-        db.session.commit()
-    except (sa.exc.IntegrityError, sa.exc.DataError) as e:
-        db.session.rollback()
-        raise utils.CSEntryError(str(e), status_code=422)
-
-
-def get_item_by_id_or_ics_id(id_):
-    """Retrieve item by id or ICS id"""
-    try:
-        item_id = int(id_)
-    except ValueError:
-        # Assume id_ is an ics_id
-        item = Item.query.filter_by(ics_id=id_).first()
-    else:
-        item = Item.query.get(item_id)
-    if item is None:
-        raise utils.CSEntryError(f"Item id '{id_}' not found", status_code=404)
-    return item
-
-
-def get_generic_model(model, args):
-    """Return data from model as json
-
-    :param model: model class
-    :param MultiDict args: args from the request
-    :returns: data from model as json
-    """
-    items = model.query.order_by(model.name)
-    qrcode = args.get('qrcode', 'false').lower() == 'true'
-    data = [item.to_dict(qrcode=qrcode) for item in items]
-    return jsonify(data)
-
-
-def create_generic_model(model, mandatory_fields=('name',)):
-    data = request.get_json()
-    if data is None:
-        raise utils.CSEntryError('Body should be a JSON object')
-    current_app.logger.debug(f'Received: {data}')
-    for mandatory_field in mandatory_fields:
-        if mandatory_field not in data:
-            raise utils.CSEntryError(f"Missing mandatory field '{mandatory_field}'", status_code=422)
-    try:
-        instance = model(**data)
-    except TypeError as e:
-        message = str(e).replace('__init__() got an ', '')
-        raise utils.CSEntryError(message, status_code=422)
-    except ValueError as e:
-        raise utils.CSEntryError(str(e), status_code=422)
-    db.session.add(instance)
-    commit()
-    return jsonify(instance.to_dict()), 201
-
-
-@bp.route('/login', methods=['POST'])
-def login():
-    data = request.get_json()
-    if data is None:
-        raise utils.CSEntryError('Body should be a JSON object')
-    try:
-        username = data['username']
-        password = data['password']
-    except KeyError:
-        raise utils.CSEntryError('Missing mandatory field (username or password)', status_code=422)
-    response = ldap_manager.authenticate(username, password)
-    if response.status == AuthenticationResponseStatus.success:
-        current_app.logger.debug(f'{username} successfully logged in')
-        user = ldap_manager._save_user(
-            response.user_dn,
-            response.user_id,
-            response.user_info,
-            response.user_groups)
-        payload = {'access_token': tokens.generate_access_token(identity=user.id)}
-        return jsonify(payload), 200
-    raise utils.CSEntryError('Invalid credentials', status_code=401)
-
-
-@bp.route('/items')
-@jwt_required
-def get_items():
-    # TODO: add pagination
-    query = utils.get_query(Item.query, request.args)
-    items = query.order_by(Item._created)
-    data = [item.to_dict() for item in items]
-    return jsonify(data)
-
-
-@bp.route('/items/<id_>')
-@jwt_required
-def get_item(id_):
-    """Retrieve item by id or ICS id"""
-    item = get_item_by_id_or_ics_id(id_)
-    return jsonify(item.to_dict())
-
-
-@bp.route('/items', methods=['POST'])
-@jwt_required
-@jwt_groups_accepted('admin', 'create')
-def create_item():
-    """Register a new item"""
-    # People should assign an ICS id to a serial number when creating
-    # an item so ics_id should also be a mandatory field.
-    # But there are existing items (in confluence and JIRA) that we want to
-    # import and associate after they have been created.
-    return create_generic_model(Item, mandatory_fields=('serial_number',))
-
-
-@bp.route('/items/<id_>', methods=['PATCH'])
-@jwt_required
-@jwt_groups_accepted('admin', 'create')
-def patch_item(id_):
-    """Patch an existing item
-
-    id_ can be the primary key or the ics_id field
-    Fields allowed to update are:
-        - ics_id ONLY if currently null (422 returned otherwise)
-        - manufacturer
-        - model
-        - location
-        - status
-        - parent
-
-    422 is returned if other fields are given.
-    """
-    data = request.get_json()
-    if data is None:
-        raise utils.CSEntryError('Body should be a JSON object')
-    if not data:
-        raise utils.CSEntryError('At least one field is required', status_code=422)
-    for key in data.keys():
-        if key not in ('ics_id', 'manufacturer', 'model',
-                       'location', 'status', 'parent'):
-            raise utils.CSEntryError(f"Invalid field '{key}'", status_code=422)
-    item = get_item_by_id_or_ics_id(id_)
-    # Only allow to set ICS id if it's null
-    if item.ics_id is None:
-        item.ics_id = data.get('ics_id')
-    elif 'ics_id' in data:
-        raise utils.CSEntryError("'ics_id' can't be changed", status_code=422)
-    item.manufacturer = utils.convert_to_model(data.get('manufacturer', item.manufacturer), Manufacturer)
-    item.model = utils.convert_to_model(data.get('model', item.model), Model)
-    item.location = utils.convert_to_model(data.get('location', item.location), Location)
-    item.status = utils.convert_to_model(data.get('status', item.status), Status)
-    parent_ics_id = data.get('parent')
-    if parent_ics_id is not None:
-        parent = Item.query.filter_by(ics_id=parent_ics_id).first()
-        if parent is not None:
-            item.parent_id = parent.id
-            # Update location and status with those from parent
-            item.location = parent.location
-            item.status = parent.status
-    # Update all children status and location
-    for child in item.children:
-        child.location = item.location
-        child.status = item.status
-    commit()
-    return jsonify(item.to_dict())
-
-
-@bp.route('/actions')
-@jwt_required
-def get_actions():
-    return get_generic_model(Action, request.args)
-
-
-@bp.route('/manufacturers')
-@jwt_required
-def get_manufacturers():
-    return get_generic_model(Manufacturer, request.args)
-
-
-@bp.route('/manufacturers', methods=['POST'])
-@jwt_required
-@jwt_groups_accepted('admin', 'create')
-def create_manufacturer():
-    return create_generic_model(Manufacturer)
-
-
-@bp.route('/models')
-@jwt_required
-def get_models():
-    return get_generic_model(Model, request.args)
-
-
-@bp.route('/models', methods=['POST'])
-@jwt_required
-@jwt_groups_accepted('admin', 'create')
-def create_model():
-    return create_generic_model(Model)
-
-
-@bp.route('/locations')
-@jwt_required
-def get_locations():
-    return get_generic_model(Location, request.args)
-
-
-@bp.route('/locations', methods=['POST'])
-@jwt_required
-@jwt_groups_accepted('admin', 'create')
-def create_locations():
-    return create_generic_model(Location)
-
-
-@bp.route('/status')
-@jwt_required
-def get_status():
-    return get_generic_model(Status, request.args)
-
-
-@bp.route('/status', methods=['POST'])
-@jwt_required
-@jwt_groups_accepted('admin', 'create')
-def create_status():
-    return create_generic_model(Status)
-
-
-@bp.route('/networks')
-@jwt_required
-def get_networks():
-    # TODO: add pagination
-    query = utils.get_query(Network.query, request.args)
-    networks = query.order_by(Network.address)
-    data = [network.to_dict() for network in networks]
-    return jsonify(data)
-
-
-@bp.route('/networks', methods=['POST'])
-@jwt_required
-@jwt_groups_accepted('admin')
-def create_network():
-    """Create a new network"""
-    return create_generic_model(Network, mandatory_fields=(
-        'vlan_name', 'vlan_id', 'address', 'first_ip', 'last_ip', 'scope'))
-
-
-@bp.route('/interfaces')
-@jwt_required
-def get_interfaces():
-    # TODO: add pagination
-    query = utils.get_query(Interface.query, request.args)
-    interfaces = query.order_by(Interface.ip)
-    data = [interface.to_dict() for interface in interfaces]
-    return jsonify(data)
-
-
-@bp.route('/interfaces', methods=['POST'])
-@jwt_required
-@jwt_groups_accepted('admin', 'create')
-def create_interface():
-    """Create a new interface"""
-    return create_generic_model(Interface, mandatory_fields=('network', 'ip', 'name'))
diff --git a/app/api/network.py b/app/api/network.py
new file mode 100644
index 0000000..835ce90
--- /dev/null
+++ b/app/api/network.py
@@ -0,0 +1,55 @@
+# -*- coding: utf-8 -*-
+"""
+app.api.network
+~~~~~~~~~~~~~~~
+
+This module implements the network API.
+
+:copyright: (c) 2017 European Spallation Source ERIC
+:license: BSD 2-Clause, see LICENSE for more details.
+
+"""
+from flask import Blueprint, jsonify, request
+from flask_jwt_extended import jwt_required
+from .. import utils, models
+from ..decorators import jwt_groups_accepted
+from .utils import create_generic_model
+
+bp = Blueprint('network_api', __name__)
+
+
+@bp.route('/networks')
+@jwt_required
+def get_networks():
+    # TODO: add pagination
+    query = utils.get_query(models.Network.query, request.args)
+    networks = query.order_by(models.Network.address)
+    data = [network.to_dict() for network in networks]
+    return jsonify(data)
+
+
+@bp.route('/networks', methods=['POST'])
+@jwt_required
+@jwt_groups_accepted('admin')
+def create_network():
+    """Create a new network"""
+    return create_generic_model(models.Network, mandatory_fields=(
+        'vlan_name', 'vlan_id', 'address', 'first_ip', 'last_ip', 'scope'))
+
+
+@bp.route('/interfaces')
+@jwt_required
+def get_interfaces():
+    # TODO: add pagination
+    query = utils.get_query(models.Interface.query, request.args)
+    interfaces = query.order_by(models.Interface.ip)
+    data = [interface.to_dict() for interface in interfaces]
+    return jsonify(data)
+
+
+@bp.route('/interfaces', methods=['POST'])
+@jwt_required
+@jwt_groups_accepted('admin', 'create')
+def create_interface():
+    """Create a new interface"""
+    return create_generic_model(models.Interface, mandatory_fields=('network', 'ip', 'name'))
diff --git a/app/api/users.py b/app/api/users.py
new file mode 100644
index 0000000..992b2c2
--- /dev/null
+++ b/app/api/users.py
@@ -0,0 +1,40 @@
+# -*- coding: utf-8 -*-
+"""
+app.api.users
+~~~~~~~~~~~~~
+
+This module implements the users API.
+
+:copyright: (c) 2017 European Spallation Source ERIC
+:license: BSD 2-Clause, see LICENSE for more details.
+
+"""
+from flask import current_app, Blueprint, jsonify, request
+from flask_ldap3_login import AuthenticationResponseStatus
+from ..extensions import ldap_manager
+from .. import utils, tokens
+
+bp = Blueprint('users_api', __name__)
+
+
+@bp.route('/login', methods=['POST'])
+def login():
+    data = request.get_json()
+    if data is None:
+        raise utils.CSEntryError('Body should be a JSON object')
+    try:
+        username = data['username']
+        password = data['password']
+    except KeyError:
+        raise utils.CSEntryError('Missing mandatory field (username or password)', status_code=422)
+    response = ldap_manager.authenticate(username, password)
+    if response.status == AuthenticationResponseStatus.success:
+        current_app.logger.debug(f'{username} successfully logged in')
+        user = ldap_manager._save_user(
+            response.user_dn,
+            response.user_id,
+            response.user_info,
+            response.user_groups)
+        payload = {'access_token': tokens.generate_access_token(identity=user.id)}
+        return jsonify(payload), 200
+    raise utils.CSEntryError('Invalid credentials', status_code=401)
diff --git a/app/api/utils.py b/app/api/utils.py
new file mode 100644
index 0000000..ffa9da2
--- /dev/null
+++ b/app/api/utils.py
@@ -0,0 +1,56 @@
+# -*- coding: utf-8 -*-
+"""
+app.api.utils
+~~~~~~~~~~~~~
+
+This module implements useful functions for the API.
+
+:copyright: (c) 2017 European Spallation Source ERIC
+:license: BSD 2-Clause, see LICENSE for more details.
+
+"""
+import sqlalchemy as sa
+from flask import current_app, jsonify, request
+from ..extensions import db
+from .. import utils
+
+
+def commit():
+    try:
+        db.session.commit()
+    except (sa.exc.IntegrityError, sa.exc.DataError) as e:
+        db.session.rollback()
+        raise utils.CSEntryError(str(e), status_code=422)
+
+
+def get_generic_model(model, args):
+    """Return data from model as json
+
+    :param model: model class
+    :param MultiDict args: args from the request
+    :returns: data from model as json
+    """
+    items = model.query.order_by(model.name)
+    qrcode = args.get('qrcode', 'false').lower() == 'true'
+    data = [item.to_dict(qrcode=qrcode) for item in items]
+    return jsonify(data)
+
+
+def create_generic_model(model, mandatory_fields=('name',)):
+    data = request.get_json()
+    if data is None:
+        raise utils.CSEntryError('Body should be a JSON object')
+    current_app.logger.debug(f'Received: {data}')
+    for mandatory_field in mandatory_fields:
+        if mandatory_field not in data:
+            raise utils.CSEntryError(f"Missing mandatory field '{mandatory_field}'", status_code=422)
+    try:
+        instance = model(**data)
+    except TypeError as e:
+        message = str(e).replace('__init__() got an ', '')
+        raise utils.CSEntryError(message, status_code=422)
+    except ValueError as e:
+        raise utils.CSEntryError(str(e), status_code=422)
+    db.session.add(instance)
+    commit()
+    return jsonify(instance.to_dict()), 201
diff --git a/app/factory.py b/app/factory.py
index 8d6799b..ec504ba 100644
--- a/app/factory.py
+++ b/app/factory.py
@@ -21,7 +21,9 @@ from .main.views import bp as main
 from .inventory.views import bp as inventory
 from .network.views import bp as network
 from .users.views import bp as users
-from .api.main import bp as api
+from .api.users import bp as users_api
+from .api.inventory import bp as inventory_api
+from .api.network import bp as network_api
 from .defaults import defaults
 
 
@@ -121,7 +123,9 @@ def create_app(config=None):
     app.register_blueprint(inventory, url_prefix='/inventory')
     app.register_blueprint(network, url_prefix='/network')
     app.register_blueprint(users, url_prefix='/users')
-    app.register_blueprint(api, url_prefix='/api')
+    app.register_blueprint(users_api, url_prefix='/api/v1/users')
+    app.register_blueprint(inventory_api, url_prefix='/api/v1/inventory')
+    app.register_blueprint(network_api, url_prefix='/api/v1/network')
 
     app.wsgi_app = WhiteNoise(app.wsgi_app, root='static/')
     app.wsgi_app.add_files(
-- 
GitLab