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