From 3d6a02cdb5643ea344600bd977a3977e9a596dd9 Mon Sep 17 00:00:00 2001 From: Nikolay Stanchev <ns17@it-innovation.soton.ac.uk> Date: Tue, 3 Jul 2018 15:13:12 +0100 Subject: [PATCH] Config CRUD API for service function chains --- src/service/clmcservice/__init__.py | 6 +- src/service/clmcservice/configapi/conftest.py | 40 +++ src/service/clmcservice/configapi/tests.py | 325 ++++++++++++++++++ .../clmcservice/configapi/utilities.py | 68 ++++ src/service/clmcservice/configapi/views.py | 192 +++++++++++ src/service/clmcservice/initialize_db.py | 27 +- src/service/clmcservice/models/__init__.py | 3 + .../clmcservice/models/config_models.py | 99 ++++++ src/service/clmcservice/models/meta.py | 107 ++++++ .../{models.py => models/whoami_models.py} | 111 ++---- src/service/clmcservice/whoamiapi/conftest.py | 26 +- src/service/clmcservice/whoamiapi/tests.py | 26 +- .../clmcservice/whoamiapi/utilities.py | 6 +- src/service/clmcservice/whoamiapi/views.py | 4 +- 14 files changed, 946 insertions(+), 94 deletions(-) create mode 100644 src/service/clmcservice/configapi/conftest.py create mode 100644 src/service/clmcservice/configapi/utilities.py create mode 100644 src/service/clmcservice/models/__init__.py create mode 100644 src/service/clmcservice/models/config_models.py create mode 100644 src/service/clmcservice/models/meta.py rename src/service/clmcservice/{models.py => models/whoami_models.py} (57%) diff --git a/src/service/clmcservice/__init__.py b/src/service/clmcservice/__init__.py index d3cbc29..90d5015 100644 --- a/src/service/clmcservice/__init__.py +++ b/src/service/clmcservice/__init__.py @@ -24,7 +24,7 @@ from pyramid.config import Configurator from sqlalchemy import engine_from_config -from clmcservice.models import DBSession, Base +from clmcservice.models.meta import DBSession, Base from clmcservice.aggregationapi.utilities import validate_conf_file, RUNNING_FLAG, MALFORMED_FLAG, CONF_FILE_ATTRIBUTE, CONF_OBJECT, AGGREGATOR_CONFIG_SECTION @@ -55,5 +55,9 @@ def main(global_config, **settings): config.add_route('whoami_endpoints', '/whoami/endpoints') config.add_route('whoami_endpoints_instance', 'whoami/endpoints/instance') + # add routes of the CONFIG API + config.add_route('config_sfc', '/config/sf-chains') + config.add_route('config_sfc_instance', '/config/sf-chains/instance') + config.scan() # This method scans the packages and finds any views related to the routes added in the app configuration return config.make_wsgi_app() diff --git a/src/service/clmcservice/configapi/conftest.py b/src/service/clmcservice/configapi/conftest.py new file mode 100644 index 0000000..105ebe8 --- /dev/null +++ b/src/service/clmcservice/configapi/conftest.py @@ -0,0 +1,40 @@ +#!/usr/bin/python3 +""" +// © University of Southampton IT Innovation Centre, 2018 +// +// Copyright in this software belongs to University of Southampton +// IT Innovation Centre of Gamma House, Enterprise Road, +// Chilworth Science Park, Southampton, SO16 7NS, UK. +// +// This software may not be used, sold, licensed, transferred, copied +// or reproduced in whole or in part in any manner or form or in or +// on any media by any person other than in accordance with the terms +// of the Licence Agreement supplied with the software, or otherwise +// without the prior written consent of the copyright owners. +// +// This software is distributed WITHOUT ANY WARRANTY, without even the +// implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR +// PURPOSE, except where stated in the Licence Agreement supplied with +// the software. +// +// Created By : Nikolay Stanchev +// Created Date : 03-07-2018 +// Created for Project : FLAME +""" + + +import pytest +from clmcservice.whoamiapi.conftest import create_test_database, initialise_database, drop_test_database + + +@pytest.fixture(scope='module', autouse=True) +def testing_db_session(): + test_database = "configtestdb" + create_test_database(test_database) # create a database used for executing the unit tests + db_session, engine = initialise_database(test_database) # initialise the database with the models and retrieve a db session + + yield db_session # return the db session if needed in any of the tests + + db_session.remove() # remove the db session + engine.dispose() # dispose from the engine + drop_test_database(test_database) # remove the test database diff --git a/src/service/clmcservice/configapi/tests.py b/src/service/clmcservice/configapi/tests.py index e69de29..3824047 100644 --- a/src/service/clmcservice/configapi/tests.py +++ b/src/service/clmcservice/configapi/tests.py @@ -0,0 +1,325 @@ +#!/usr/bin/python3 +""" +// © University of Southampton IT Innovation Centre, 2018 +// +// Copyright in this software belongs to University of Southampton +// IT Innovation Centre of Gamma House, Enterprise Road, +// Chilworth Science Park, Southampton, SO16 7NS, UK. +// +// This software may not be used, sold, licensed, transferred, copied +// or reproduced in whole or in part in any manner or form or in or +// on any media by any person other than in accordance with the terms +// of the Licence Agreement supplied with the software, or otherwise +// without the prior written consent of the copyright owners. +// +// This software is distributed WITHOUT ANY WARRANTY, without even the +// implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR +// PURPOSE, except where stated in the Licence Agreement supplied with +// the software. +// +// Created By : Nikolay Stanchev +// Created Date : 02-07-2018 +// Created for Project : FLAME +""" + +import pytest +from json import dumps +from pyramid import testing +from pyramid.httpexceptions import HTTPBadRequest, HTTPNotFound, HTTPConflict +from clmcservice.models import ServiceFunctionChain +from clmcservice.configapi.views import SFCConfigAPI + + +class TestSFCConfigAPI(object): + """ + A pytest-implementation test for the Config API endpoints for service function chains + """ + + @pytest.fixture(autouse=True) + def app_config(self): + """ + A fixture to implement setUp/tearDown functionality for all tests by initializing configuration structure for the web service and db connection + """ + + self.registry = testing.setUp() + + yield + + testing.tearDown() + ServiceFunctionChain.delete_all() # clear the instances of the model in the test database + + def test_get_all(self): + """ + Tests the GET all method of the config API for service function chains - returns a list of all service function chains from the database. + """ + + request = testing.DummyRequest() + response = SFCConfigAPI(request).get_all() + assert response == [], "Initially there mustn't be any service function chains in the database." + + sfc = ServiceFunctionChain(sfc="sfc1", sfc_i="sfc_i1", chain={"nginx": ["minio"]}) + expected_response_data = [sfc.json] + ServiceFunctionChain.add(sfc) # adds the new instance of the model to the database + + request = testing.DummyRequest() + response = SFCConfigAPI(request).get_all() + assert response == expected_response_data, "Incorrect response data with 1 service function chain." + + sfc = ServiceFunctionChain(sfc="sfc2", sfc_i="sfc_i1", chain={"nginx": ["minio"]}) + expected_response_data.append(sfc.json) + ServiceFunctionChain.add(sfc) + sfc = ServiceFunctionChain(sfc="sfc2", sfc_i="sfc_i2", chain={"nginx": ["minio"]}) + expected_response_data.append(sfc.json) + ServiceFunctionChain.add(sfc) + + request = testing.DummyRequest() + response = SFCConfigAPI(request).get_all() + assert response == expected_response_data, "Incorrect response data with more than 1 service function chains." + + def test_get_one(self): + """ + Tests the GET one method of the config API for service function chains - returns an instance of a service function chain from the database. + """ + + request = testing.DummyRequest() + response = SFCConfigAPI(request).get_all() + assert response == [], "Initially there mustn't be any service function chains in the database." + + self._validation_of_url_parameters_test("get_one") + + sfc = ServiceFunctionChain(sfc="sfc1", sfc_i="sfc_i1", chain={"nginx": ["minio"]}) + expected_response_data = sfc.json + ServiceFunctionChain.add(sfc) # adds the new instance of the model to the database + + request = testing.DummyRequest() + request.params["sfc"] = "sfc1" + request.params["sfc_i"] = "sfc_i1" + response = SFCConfigAPI(request).get_one() + assert response == expected_response_data, "Invalid data returned in the response of GET instance" + + request = testing.DummyRequest() + request.params["sfc"] = "sfc2" + request.params["sfc_i"] = "sfc_i2" + error_raised = False + try: + SFCConfigAPI(request).get_one() + except HTTPNotFound: + error_raised = True + assert error_raised, "Not found error must be raised in case of a non existing service function chain" + + def test_post(self): + """ + Tests the POST method of the config API for service function chains - creates an instance of a service function chain in the database. + """ + + request = testing.DummyRequest() + response = SFCConfigAPI(request).get_all() + assert response == [], "Initially there mustn't be any service function chains in the database." + + resource = dict(sfc="sfc1", sfc_i="sfc_i1", chain={"nginx": ["minio"]}) + json_data = dumps(resource) + request = testing.DummyRequest() + request.body = json_data.encode(request.charset) + response = SFCConfigAPI(request).post() + assert response == resource, "POST request must return the created resource" + assert ServiceFunctionChain.exists("sfc1", "sfc_i1"), "POST request must have created the resource" + + resource["chain"] = {} + json_data = dumps(resource) + request = testing.DummyRequest() + request.body = json_data.encode(request.charset) + error_raised = False + try: + SFCConfigAPI(request).post() + except HTTPConflict: + error_raised = True + assert error_raised, "An error must be raised when trying to create a resource which breaks the unique constraint" + + @pytest.mark.parametrize("body, valid", [ + ('{"sfc": "sfc1", "sfc_i": "sfc_i1", "chain":{"nginx":["minio"]}}', True), + ('{"sfc": "sfc2", "sfc_i": "sfc_i2", "chain":{}}', True), + ('{"sfc": "sfc1", "sfc_i": "sfc_i1", "chain":[]}', False), + ('{}', False), + ('{"sfc": "sfc3", "sfc_i": "sfc_i3"', False), + ('{"sf": "sfc2", "sf_i": "sfc_i2", "chain":{}', False), + ('{invalid json}', False), + ]) + def test_post_body_validation(self, body, valid): + """ + Tests the POST request validation of the body content. + + :param body: The request body to be validated + :param valid: True if body is valid, False otherwise + """ + + request = testing.DummyRequest() + request.body = body.encode(request.charset) + error_raised = False + try: + SFCConfigAPI(request).post() + except HTTPBadRequest: + error_raised = True + assert error_raised == (not valid), "An error must be raised in case of an invalid request body" + + def test_put(self): + """ + Tests the PUT method of the Config API for service function chains - overwrites an instance of a service function chain from the database. + """ + + request = testing.DummyRequest() + response = SFCConfigAPI(request).get_all() + assert response == [], "Initially there mustn't be any service function chains in the database." + + self._validation_of_url_parameters_test("put") + + resource = dict(sfc="sfc1", sfc_i="sfc_i1", chain={"nginx": ["minio"]}) + body = dumps(resource) + request = testing.DummyRequest() + request.params["sfc"] = "sfc1" + request.params["sfc_i"] = "sfc_i1" + request.body = body.encode(request.charset) + error_raised = False + try: + SFCConfigAPI(request).put() + except HTTPNotFound: + error_raised = True + assert error_raised, "Not found error must be raised in case of a non existing service function chain" + + sfc = ServiceFunctionChain(sfc="sfc1", sfc_i="sfc_i1", chain={"nginx": ["minio"]}) + ServiceFunctionChain.add(sfc) # adds the new instance of the model to the database + + resource = dict(sfc="sfc1", sfc_i="sfc_i1", chain={}) + body = dumps(resource) + request = testing.DummyRequest() + request.params["sfc"] = "sfc1" + request.params["sfc_i"] = "sfc_i1" + request.body = body.encode(request.charset) + response = SFCConfigAPI(request).put() + assert response == resource, "PUT request must return the updated resource" + assert ServiceFunctionChain.get("sfc1", "sfc_i1").json["chain"] == {} + + resource = dict(sfc="sfc2", sfc_i="sfc_i2", chain={"nginx": ["minio"]}) + body = dumps(resource) + request = testing.DummyRequest() + request.params["sfc"] = "sfc1" + request.params["sfc_i"] = "sfc_i1" + request.body = body.encode(request.charset) + response = SFCConfigAPI(request).put() + assert response == resource, "PUT request must return the updated resource" + assert not ServiceFunctionChain.exists("sfc1", "sfc_i1"), "Resource has not been updated" + assert ServiceFunctionChain.exists("sfc2", "sfc_i2"), "Resource has not been updated" + + sfc = ServiceFunctionChain(sfc="sfc1", sfc_i="sfc_i1", chain={"nginx": ["minio"]}) + ServiceFunctionChain.add(sfc) # adds the new instance of the model to the database + + resource = dict(sfc="sfc2", sfc_i="sfc_i2", chain={"nginx": ["minio"]}) + body = dumps(resource) + request = testing.DummyRequest() + request.params["sfc"] = "sfc1" + request.params["sfc_i"] = "sfc_i1" + request.body = body.encode(request.charset) + error_raised = False + try: + SFCConfigAPI(request).put() + except HTTPConflict: + error_raised = True + assert error_raised, "PUT request breaks unique constraint" + + @pytest.mark.parametrize("body, valid", [ + ('{"sfc": "sfc1", "sfc_i": "sfc_i1", "chain":{"nginx":["minio"]}}', True), + ('{"sfc": "sfc2", "sfc_i": "sfc_i2", "chain":{}}', True), + ('{"sfc": "sfc1", "sfc_i": "sfc_i1", "chain":[]}', False), + ('{}', False), + ('{"sfc": "sfc3", "sfc_i": "sfc_i3"', False), + ('{"sf": "sfc2", "sf_i": "sfc_i2", "chain":{}', False), + ('{invalid json}', False), + ]) + def test_put_body_validation(self, body, valid): + """ + Tests the PUT request validation of the body content. + + :param body: The request body to be validated + :param valid: True if body is valid, False otherwise + """ + + sfc = ServiceFunctionChain(sfc="sfc1", sfc_i="sfc_i1", chain={"nginx": ["minio"]}) + ServiceFunctionChain.add(sfc) # adds the new instance of the model to the database + + request = testing.DummyRequest() + request.params["sfc"] = "sfc1" + request.params["sfc_i"] = "sfc_i1" + request.body = body.encode(request.charset) + error_raised = False + try: + SFCConfigAPI(request).put() + except HTTPBadRequest: + error_raised = True + assert error_raised == (not valid), "An error must be raised in case of an invalid request body" + + def test_delete(self): + """ + Tests the DELETE method of the config API for service function chains - deletes an instance of a service function chain from the database. + """ + + request = testing.DummyRequest() + response = SFCConfigAPI(request).get_all() + assert response == [], "Initially there mustn't be any service function chains in the database." + + self._validation_of_url_parameters_test("delete") + + sfc = ServiceFunctionChain(sfc="sfc1", sfc_i="sfc_i1", chain={"nginx": ["minio"]}) + to_delete = sfc.json + ServiceFunctionChain.add(sfc) # adds the new instance of the model to the database + + assert ServiceFunctionChain.exists("sfc1", "sfc_i1") + + request = testing.DummyRequest() + request.params["sfc"] = "sfc1" + request.params["sfc_i"] = "sfc_i1" + response = SFCConfigAPI(request).delete() + assert response == to_delete, "DELETE must return the deleted object if successful" + + assert not ServiceFunctionChain.exists("sfc1", "sfc_i1"), "Resource must be deleted after the delete API method has been called." + + request = testing.DummyRequest() + request.params["sfc"] = "sfc1" + request.params["sfc_i"] = "sfc_i1" + error_raised = False + try: + SFCConfigAPI(request).delete() + except HTTPNotFound: + error_raised = True + assert error_raised, "Not found error must be raised in case of a non existing service function chain" + + @staticmethod + def _validation_of_url_parameters_test(method): + """ + Validates the way a config API method handles url query parameters for service function chains + + :param method: the method to test + """ + + request = testing.DummyRequest() + error_raised = False + try: + getattr(SFCConfigAPI(request), method).__call__() + except HTTPBadRequest: + error_raised = True + assert error_raised, "Error must be raised in case of no URL parameters" + + request = testing.DummyRequest() + request.params["sfc"] = "sfc1" + try: + getattr(SFCConfigAPI(request), method).__call__() + except HTTPBadRequest: + error_raised = True + assert error_raised, "Error must be raised in case of insufficient number of arguments" + + request = testing.DummyRequest() + request.params["sf"] = "sfc1" # argument should be sfc + request.params["sfc_i"] = "sfc_i1" + try: + getattr(SFCConfigAPI(request), method).__call__() + except HTTPBadRequest: + error_raised = True + assert error_raised, "Error must be raised in case of invalid naming of arguments" diff --git a/src/service/clmcservice/configapi/utilities.py b/src/service/clmcservice/configapi/utilities.py new file mode 100644 index 0000000..8a3babc --- /dev/null +++ b/src/service/clmcservice/configapi/utilities.py @@ -0,0 +1,68 @@ +#!/usr/bin/python3 +""" +// © University of Southampton IT Innovation Centre, 2018 +// +// Copyright in this software belongs to University of Southampton +// IT Innovation Centre of Gamma House, Enterprise Road, +// Chilworth Science Park, Southampton, SO16 7NS, UK. +// +// This software may not be used, sold, licensed, transferred, copied +// or reproduced in whole or in part in any manner or form or in or +// on any media by any person other than in accordance with the terms +// of the Licence Agreement supplied with the software, or otherwise +// without the prior written consent of the copyright owners. +// +// This software is distributed WITHOUT ANY WARRANTY, without even the +// implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR +// PURPOSE, except where stated in the Licence Agreement supplied with +// the software. +// +// Created By : Nikolay Stanchev +// Created Date : 02-07-2018 +// Created for Project : FLAME +""" + +from json import loads +from clmcservice.models import ServiceFunctionChain + + +def validate_sfchain_body(body): + """ + Validates the request body used to create an service function chain resource in the database. + + :param body: the request body to validate + :return the validated sfc dictionary object + :raise AssertionError: if the body is not a valid service function chain + """ + + try: + body = loads(body) + except: + raise AssertionError("Service function chain must be represented by a JSON object.") + + # the database table has one more column which is a UID integer + assert len(body) == len(ServiceFunctionChain.__table__.columns) - 1, "Service function chain JSON object mustn't contain a different number of attributes than the number of required ones." + + # validate that all required attributes are given in the body + for attribute in ServiceFunctionChain.required_columns(): + assert attribute in body, "Required attribute not found in the request content." + + assert type(body["chain"]) == dict, "The chain attribute of a service function chain must be a graph representing the relations between service functions." + + return body + + +def validate_sfchain_params(params): + """ + Validates the request parameters to retrieve an service function chain resource from the database. + + :param params: the parameters dictionary to validate + :return: the validated parameters + :raise AssertionError: for invalid parameters + """ + + constrained_cols = ServiceFunctionChain.constrained_columns() + + assert len(params) == len(constrained_cols), "Incorrect url query parameters." + + return params diff --git a/src/service/clmcservice/configapi/views.py b/src/service/clmcservice/configapi/views.py index e69de29..571655f 100644 --- a/src/service/clmcservice/configapi/views.py +++ b/src/service/clmcservice/configapi/views.py @@ -0,0 +1,192 @@ +#!/usr/bin/python3 +""" +// © University of Southampton IT Innovation Centre, 2018 +// +// Copyright in this software belongs to University of Southampton +// IT Innovation Centre of Gamma House, Enterprise Road, +// Chilworth Science Park, Southampton, SO16 7NS, UK. +// +// This software may not be used, sold, licensed, transferred, copied +// or reproduced in whole or in part in any manner or form or in or +// on any media by any person other than in accordance with the terms +// of the Licence Agreement supplied with the software, or otherwise +// without the prior written consent of the copyright owners. +// +// This software is distributed WITHOUT ANY WARRANTY, without even the +// implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR +// PURPOSE, except where stated in the Licence Agreement supplied with +// the software. +// +// Created By : Nikolay Stanchev +// Created Date : 02-07-2018 +// Created for Project : FLAME +""" + + +from pyramid.httpexceptions import HTTPBadRequest, HTTPConflict, HTTPNotFound +from pyramid.view import view_defaults, view_config +from clmcservice.models import ServiceFunctionChain +from clmcservice.configapi.utilities import validate_sfchain_body, validate_sfchain_params + + +@view_defaults(renderer='json') +class SFCConfigAPI(object): + """ + A class-based view for posting and retrieving configuration data for service function chains to the CLMC service + """ + + def __init__(self, request): + """ + Initialises the instance of the view with the request argument. + + :param request: client's call request + """ + + self.request = request + + @view_config(route_name='config_sfc', request_method='GET') + def get_all(self): + """ + GET API call for all resources. + + :return: A list of all service function chains found in the database. + """ + + return [instance.json for instance in ServiceFunctionChain.query()] + + @view_config(route_name='config_sfc_instance', request_method='GET') + def get_one(self): + """ + GET API call for a single resources. + + :return: One service function chain instance retrieved from the database by querying the uniquely constrained columns. + :raises HTTPBadRequest: if the request parameters are invalid(invalid url query string) + :raises HTTPNotFound: if a resource with the given parameters doesn't exist in the database + """ + + sf_chain = self._get_sf_chain_from_url_string() + if sf_chain is None: + raise HTTPNotFound("A service function chain with the given parameters doesn't exist.") + else: + return sf_chain.json + + @view_config(route_name='config_sfc', request_method='POST') + def post(self): + """ + A POST API call to create a new service function chain. + + :return: A JSON response to the POST call - essentially with the data of the new resource + :raises HTTPBadRequest: if request body is not a valid JSON for the service function chain + :raises HTTPConflict: if the unique constraints are not preserved after the creation of a new instance + """ + + # create an instance of the model and add it to the database table + sf_chain = self._validate_and_create() + json_data = sf_chain.json + ServiceFunctionChain.add(sf_chain) + + self.request.response.status = 201 + + return json_data + + @view_config(route_name='config_sfc_instance', request_method='PUT') + def put(self): + """ + A PUT API call to update a service function chain. + + :return: A JSON response representing the updated object + :raises HTTPBadRequest: if the request parameters are invalid(invalid url query string) + :raises HTTPNotFound: if a resource with the given parameters doesn't exist in the database + """ + + sf_chain = self._get_sf_chain_from_url_string() + if sf_chain is None: + raise HTTPNotFound("A service function chain with the given parameters doesn't exist.") + else: + try: + body = self.request.body.decode(self.request.charset) + validated_body = validate_sfchain_body(body) # validate the content and receive a json dictionary object + except AssertionError as e: + raise HTTPBadRequest("Bad request content. Service function chain format is incorrect: {0}".format(e.args)) + + new_resource = validated_body + old_resource = sf_chain.json + updating = new_resource["sfc"] == old_resource["sfc"] and new_resource["sfc_i"] == old_resource["sfc_i"] + + if updating: + ServiceFunctionChain.delete(sf_chain) + new_sf_chain = ServiceFunctionChain(**validated_body) + ServiceFunctionChain.add(new_sf_chain) + else: + resource_exists = ServiceFunctionChain.exists(new_resource["sfc"], new_resource["sfc_i"]) + if resource_exists: + raise HTTPConflict("Service function chain with this data already exists.") # error 409 in case of resource conflict + + new_sf_chain = ServiceFunctionChain(**validated_body) + ServiceFunctionChain.replace(sf_chain, new_sf_chain) + + return validated_body + + @view_config(route_name='config_sfc_instance', request_method='DELETE') + def delete(self): + """ + Deletes an instance of a service function chain in the database. + + :return: An content of the object that has been deleted + :raises HTTPBadRequest: if the request parameters are invalid(invalid url query string) + :raises HTTPNotFound: if a resource with the given parameters doesn't exist in the database + """ + + sf_chain = self._get_sf_chain_from_url_string() + if sf_chain is None: + raise HTTPNotFound("A service function chain with the given parameters doesn't exist.") + else: + deleted = sf_chain.json + ServiceFunctionChain.delete(sf_chain) + return deleted + + def _get_sf_chain_from_url_string(self): + """ + Retrieves a service function chain from the database by validating and then using the request url parameters. + + :return: An instance of a service function chain or None if not existing + """ + + params = {} + for attribute in ServiceFunctionChain.constrained_columns(): + if attribute in self.request.params: + params[attribute] = self.request.params.get(attribute) + + try: + params = validate_sfchain_params(params) + except AssertionError as e: + raise HTTPBadRequest("Request format is incorrect: {0}".format(e.args)) + + sf_chain = ServiceFunctionChain.get(**params) + return sf_chain + + def _validate_and_create(self): + """ + Validates the request body and checks if a resource with the given attributes already exists. + + :return: a new instance of the model, if the resource doesn't exist + :raises HTTPBadRequest: if request body is not a valid JSON for the service function chain + :raises HTTPConflict: if the unique constraints are not preserved after the creation of a new instance + """ + + try: + body = self.request.body.decode(self.request.charset) + validated_body = validate_sfchain_body(body) # validate the content and receive a json dictionary object + except AssertionError as e: + raise HTTPBadRequest("Bad request content. Service function chain format is incorrect: {0}".format(e.args)) + + resource = validated_body + + resource_exists = ServiceFunctionChain.exists(resource["sfc"], resource["sfc_i"]) + if resource_exists: + raise HTTPConflict("Service function chain with this data already exists.") # error 409 in case of resource conflict + + # create an instance of the model + sf_chain = ServiceFunctionChain(**resource) + + return sf_chain diff --git a/src/service/clmcservice/initialize_db.py b/src/service/clmcservice/initialize_db.py index a0b1a09..c6987d6 100644 --- a/src/service/clmcservice/initialize_db.py +++ b/src/service/clmcservice/initialize_db.py @@ -1,8 +1,32 @@ +#!/usr/bin/python3 +""" +// © University of Southampton IT Innovation Centre, 2018 +// +// Copyright in this software belongs to University of Southampton +// IT Innovation Centre of Gamma House, Enterprise Road, +// Chilworth Science Park, Southampton, SO16 7NS, UK. +// +// This software may not be used, sold, licensed, transferred, copied +// or reproduced in whole or in part in any manner or form or in or +// on any media by any person other than in accordance with the terms +// of the Licence Agreement supplied with the software, or otherwise +// without the prior written consent of the copyright owners. +// +// This software is distributed WITHOUT ANY WARRANTY, without even the +// implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR +// PURPOSE, except where stated in the Licence Agreement supplied with +// the software. +// +// Created By : Nikolay Stanchev +// Created Date : 25-06-2018 +// Created for Project : FLAME +""" + import os import sys from sqlalchemy import engine_from_config from pyramid.paster import get_appsettings, setup_logging -from clmcservice.models import DBSession, Base +from clmcservice.models.meta import Base def usage(argv): @@ -34,5 +58,4 @@ def main(argv=sys.argv): settings = get_appsettings(config_uri) # get application specific settings engine = engine_from_config(settings, 'sqlalchemy.') # create the db engine from the sqlalchemy setting configured in the .ini file - DBSession.configure(bind=engine) Base.metadata.create_all(engine) # creates all model tables diff --git a/src/service/clmcservice/models/__init__.py b/src/service/clmcservice/models/__init__.py new file mode 100644 index 0000000..6ceb55d --- /dev/null +++ b/src/service/clmcservice/models/__init__.py @@ -0,0 +1,3 @@ +from .meta import DBSession +from .whoami_models import ServiceFunctionEndpoint +from .config_models import ServiceFunctionChain diff --git a/src/service/clmcservice/models/config_models.py b/src/service/clmcservice/models/config_models.py new file mode 100644 index 0000000..4790043 --- /dev/null +++ b/src/service/clmcservice/models/config_models.py @@ -0,0 +1,99 @@ +#!/usr/bin/python3 +""" +// © University of Southampton IT Innovation Centre, 2018 +// +// Copyright in this software belongs to University of Southampton +// IT Innovation Centre of Gamma House, Enterprise Road, +// Chilworth Science Park, Southampton, SO16 7NS, UK. +// +// This software may not be used, sold, licensed, transferred, copied +// or reproduced in whole or in part in any manner or form or in or +// on any media by any person other than in accordance with the terms +// of the Licence Agreement supplied with the software, or otherwise +// without the prior written consent of the copyright owners. +// +// This software is distributed WITHOUT ANY WARRANTY, without even the +// implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR +// PURPOSE, except where stated in the Licence Agreement supplied with +// the software. +// +// Created By : Nikolay Stanchev +// Created Date : 02-07-2018 +// Created for Project : FLAME +""" + +from sqlalchemy import Column, String, Integer, UniqueConstraint, and_ +from sqlalchemy.dialects.postgresql import JSONB +from clmcservice.models.meta import Base + + +class ServiceFunctionChain(Base): + """ + This class defines the service function chain model of the config API, declaring the relations between individual service functions per service function chain. + """ + + __tablename__ = 'sfchain' # table name in the PostgreSQL database + + __table_args__ = (UniqueConstraint('sfc', 'sfc_i'),) # defines a unique constraint across 2 columns - sfc, sfc_i + + uid = Column(Integer, primary_key=True, autoincrement=True, nullable=False) # a primary key integer field (auto incremented) + + sfc = Column(String, nullable=False) # service function chain label + sfc_i = Column(String, nullable=False) # service function chain instance identifier + chain = Column(JSONB, nullable=False) # the service function chain graph represented by a python dictionary (JSON object essentially) + + @property + def json(self): + """ + Converts an instance of a ServiceFunctionChain to JSON format. + + :return: a python dictionary object + """ + + fields = {c.name: getattr(self, c.name) for c in self.__table__.columns} + fields.pop("uid") + + return fields + + @staticmethod + def required_columns(): + """ + Returns the required columns for constructing a valid instance. + :return: a generator object + """ + + return tuple(column.name for column in ServiceFunctionChain.__table__.columns if column.name != "uid") + + @staticmethod + def constrained_columns(): + """ + :return: the columns that are uniquely identifying an instance of this model. + """ + + return tuple(column.name for column in ServiceFunctionChain.__table_args__[0].columns) + + @staticmethod + def get(sfc, sfc_i): + """ + Gets the instance matching the unique constraint or None if not existing. + + :param sfc: service function chain id + :param sfc_i: service function chain instance id + + :return: the first object from the result set that matches the unique constraint or None + """ + + return ServiceFunctionChain.query().filter(and_(ServiceFunctionChain.sfc == sfc, ServiceFunctionChain.sfc_i == sfc_i)).first() + + @staticmethod + def exists(sfc, sfc_i): + """ + Checks if an instance matching the unique constraint exists. + + :param sfc: service function chain id + :param sfc_i: service function chain instance id + + :return: True if exists, False otherwise + """ + + return ServiceFunctionChain.get(sfc, sfc_i) is not None diff --git a/src/service/clmcservice/models/meta.py b/src/service/clmcservice/models/meta.py new file mode 100644 index 0000000..698d750 --- /dev/null +++ b/src/service/clmcservice/models/meta.py @@ -0,0 +1,107 @@ +#!/usr/bin/python3 +""" +// © University of Southampton IT Innovation Centre, 2018 +// +// Copyright in this software belongs to University of Southampton +// IT Innovation Centre of Gamma House, Enterprise Road, +// Chilworth Science Park, Southampton, SO16 7NS, UK. +// +// This software may not be used, sold, licensed, transferred, copied +// or reproduced in whole or in part in any manner or form or in or +// on any media by any person other than in accordance with the terms +// of the Licence Agreement supplied with the software, or otherwise +// without the prior written consent of the copyright owners. +// +// This software is distributed WITHOUT ANY WARRANTY, without even the +// implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR +// PURPOSE, except where stated in the Licence Agreement supplied with +// the software. +// +// Created By : Nikolay Stanchev +// Created Date : 02-07-2018 +// Created for Project : FLAME +""" + + +import transaction +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import scoped_session, sessionmaker +from zope.sqlalchemy import ZopeTransactionExtension + + +DBSession = scoped_session(sessionmaker(extension=ZopeTransactionExtension())) # initialise a ORM session, ought to be reused across the different modules + + +class ORMClass(object): + """ + Declares a parent class for all models which eases querying + """ + + @classmethod + def query(cls): + """ + Pass down the class name when using the DBSession.query method and use ModelClass.query() instead of DBSession.query(ModelClass) + + :return: the query result object + """ + + global DBSession + + return DBSession.query(cls) + + @staticmethod + def add(instance): + """ + Adds an instance of a model to the database. + + :param instance: the instance to be created in the db. + """ + + global DBSession + + with transaction.manager: + DBSession.add(instance) + + @staticmethod + def delete(instance): + """ + Deletes an instance of a model from the database. + + :param instance: the instance to be deleted from the db. + """ + + global DBSession + + with transaction.manager: + DBSession.delete(instance) + + @staticmethod + def replace(old_instance, new_instance): + """ + Replaces an instance of a model from the database with a new instance. + + :param old_instance: the instance to be replaced from the db. + :param new_instance: the new instance + """ + + global DBSession + + with transaction.manager: + DBSession.add(new_instance) + DBSession.delete(old_instance) + + @classmethod + def delete_all(cls): + """ + Deletes all instances of a model from the database. + """ + + global DBSession + + with transaction.manager: + deleted_rows = DBSession.query(cls).delete() + + return deleted_rows + + +Base = declarative_base(cls=ORMClass) # initialise a declarative Base instance to use for the web app models (inherits from the base ORM class defined above) diff --git a/src/service/clmcservice/models.py b/src/service/clmcservice/models/whoami_models.py similarity index 57% rename from src/service/clmcservice/models.py rename to src/service/clmcservice/models/whoami_models.py index 78085d8..30466cc 100644 --- a/src/service/clmcservice/models.py +++ b/src/service/clmcservice/models/whoami_models.py @@ -1,85 +1,29 @@ -import transaction -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import scoped_session, sessionmaker -from zope.sqlalchemy import ZopeTransactionExtension -from sqlalchemy import Column, String, Integer, UniqueConstraint, and_ - -DBSession = scoped_session(sessionmaker(extension=ZopeTransactionExtension())) # initialise a ORM session, ought to be reused across the different modules - - -class ORMClass(object): - """ - Declares a parent class for all models which eases querying - """ - - @classmethod - def query(cls): - """ - Pass down the class name when using the DBSession.query method and use ModelClass.query() instead of DBSession.query(ModelClass) - - :return: the query result object - """ - - global DBSession - - return DBSession.query(cls) - - @staticmethod - def add(instance): - """ - Adds an instance of a model to the database. - - :param instance: the instance to be created in the db. - """ - - global DBSession - - with transaction.manager: - DBSession.add(instance) - - @staticmethod - def delete(instance): - """ - Deletes an instance of a model from the database. +#!/usr/bin/python3 +""" +// © University of Southampton IT Innovation Centre, 2018 +// +// Copyright in this software belongs to University of Southampton +// IT Innovation Centre of Gamma House, Enterprise Road, +// Chilworth Science Park, Southampton, SO16 7NS, UK. +// +// This software may not be used, sold, licensed, transferred, copied +// or reproduced in whole or in part in any manner or form or in or +// on any media by any person other than in accordance with the terms +// of the Licence Agreement supplied with the software, or otherwise +// without the prior written consent of the copyright owners. +// +// This software is distributed WITHOUT ANY WARRANTY, without even the +// implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR +// PURPOSE, except where stated in the Licence Agreement supplied with +// the software. +// +// Created By : Nikolay Stanchev +// Created Date : 02-07-2018 +// Created for Project : FLAME +""" - :param instance: the instance to be deleted from the db. - """ - - global DBSession - - with transaction.manager: - DBSession.delete(instance) - - @staticmethod - def replace(old_instance, new_instance): - """ - Replaces an instance of a model from the database with a new instance. - - :param old_instance: the instance to be replaced from the db. - :param new_instance: the new instance - """ - - global DBSession - - with transaction.manager: - DBSession.add(new_instance) - DBSession.delete(old_instance) - - @classmethod - def delete_all(cls): - """ - Deletes all instances of a model from the database. - """ - - global DBSession - - with transaction.manager: - deleted_rows = DBSession.query(cls).delete() - - return deleted_rows - - -Base = declarative_base(cls=ORMClass) # initialise a declarative Base instance to use for the web app models (inherits from the base ORM class defined above) +from sqlalchemy import Column, String, Integer, UniqueConstraint, and_ +from clmcservice.models.meta import Base class ServiceFunctionEndpoint(Base): @@ -87,7 +31,7 @@ class ServiceFunctionEndpoint(Base): This class defines the main model of the WHOAMI API, declaring the global tags for a specific service function on a specific endpoint. """ - __tablename__ = 'sfendpoints' # table name in the PostgreSQL database + __tablename__ = 'sfendpoint' # table name in the PostgreSQL database __table_args__ = (UniqueConstraint('sf_i', 'sf_endpoint', 'sr'),) # defines a unique constraint across 3 columns - sf_i, sf_endpoint, sr @@ -118,6 +62,7 @@ class ServiceFunctionEndpoint(Base): def required_columns(): """ Returns the required columns for constructing a valid instance. + :return: a generator object """ @@ -139,6 +84,7 @@ class ServiceFunctionEndpoint(Base): :param sf_i: service function instance :param sf_endpoint: service function endpoint :param sr: service router + :return: the first object from the result set that matches the unique constraint or None """ @@ -152,6 +98,7 @@ class ServiceFunctionEndpoint(Base): :param sf_i: service function instance :param sf_endpoint: service function endpoint :param sr: service router + :return: True if exists, False otherwise """ diff --git a/src/service/clmcservice/whoamiapi/conftest.py b/src/service/clmcservice/whoamiapi/conftest.py index f6ddd2c..9a53129 100644 --- a/src/service/clmcservice/whoamiapi/conftest.py +++ b/src/service/clmcservice/whoamiapi/conftest.py @@ -1,7 +1,31 @@ +#!/usr/bin/python3 +""" +// © University of Southampton IT Innovation Centre, 2018 +// +// Copyright in this software belongs to University of Southampton +// IT Innovation Centre of Gamma House, Enterprise Road, +// Chilworth Science Park, Southampton, SO16 7NS, UK. +// +// This software may not be used, sold, licensed, transferred, copied +// or reproduced in whole or in part in any manner or form or in or +// on any media by any person other than in accordance with the terms +// of the Licence Agreement supplied with the software, or otherwise +// without the prior written consent of the copyright owners. +// +// This software is distributed WITHOUT ANY WARRANTY, without even the +// implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR +// PURPOSE, except where stated in the Licence Agreement supplied with +// the software. +// +// Created By : Nikolay Stanchev +// Created Date : 25-06-2018 +// Created for Project : FLAME +""" + import pytest from sqlalchemy import create_engine from sqlalchemy.exc import ProgrammingError, OperationalError -from clmcservice.models import DBSession, Base +from clmcservice.models.meta import DBSession, Base def create_test_database(db_name): diff --git a/src/service/clmcservice/whoamiapi/tests.py b/src/service/clmcservice/whoamiapi/tests.py index e35b5b9..f508866 100644 --- a/src/service/clmcservice/whoamiapi/tests.py +++ b/src/service/clmcservice/whoamiapi/tests.py @@ -1,3 +1,27 @@ +#!/usr/bin/python3 +""" +// © University of Southampton IT Innovation Centre, 2018 +// +// Copyright in this software belongs to University of Southampton +// IT Innovation Centre of Gamma House, Enterprise Road, +// Chilworth Science Park, Southampton, SO16 7NS, UK. +// +// This software may not be used, sold, licensed, transferred, copied +// or reproduced in whole or in part in any manner or form or in or +// on any media by any person other than in accordance with the terms +// of the Licence Agreement supplied with the software, or otherwise +// without the prior written consent of the copyright owners. +// +// This software is distributed WITHOUT ANY WARRANTY, without even the +// implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR +// PURPOSE, except where stated in the Licence Agreement supplied with +// the software. +// +// Created By : Nikolay Stanchev +// Created Date : 25-06-2018 +// Created for Project : FLAME +""" + import pytest from json import dumps from pyramid import testing @@ -259,7 +283,7 @@ class TestWhoamiAPI(object): request.params["sf_i"] = "sf_i1" request.params["sr"] = "sr1" response = WhoamiAPI(request).delete() - assert response == to_delete, "DELETE must return an empty body if successful" + assert response == to_delete, "DELETE must return the deleted object if successful" assert not ServiceFunctionEndpoint.exists("sf_i1", "sf_endpoint1", "sr1"), "Resource must be deleted after the delete API method has been called." diff --git a/src/service/clmcservice/whoamiapi/utilities.py b/src/service/clmcservice/whoamiapi/utilities.py index e75439e..fd141d6 100644 --- a/src/service/clmcservice/whoamiapi/utilities.py +++ b/src/service/clmcservice/whoamiapi/utilities.py @@ -61,11 +61,7 @@ def validate_sfendpoint_params(params): constrained_cols = ServiceFunctionEndpoint.constrained_columns() - assert len(params) == len(constrained_cols), "Incorrect number of arguments." - - # validate that all required attributes are given in the dictionary - for attribute in constrained_cols: - assert attribute in params, "Required attribute not found in the request parameters." + assert len(params) == len(constrained_cols), "Incorrect url query parameters." return params diff --git a/src/service/clmcservice/whoamiapi/views.py b/src/service/clmcservice/whoamiapi/views.py index d4e6501..3e3a4e6 100644 --- a/src/service/clmcservice/whoamiapi/views.py +++ b/src/service/clmcservice/whoamiapi/views.py @@ -74,7 +74,7 @@ class WhoamiAPI(object): """ A POST API call to create a new service function endpoint. - :return: A JSON response to the POST call - essentially with the new configured data and comment of the state of the aggregator + :return: A JSON response to the POST call - essentially with the data of the new resource :raises HTTPBadRequest: if request body is not a valid JSON for the configuration :raises HTTPConflict: if the unique constraints are not preserved after the creation of a new instance """ @@ -131,7 +131,7 @@ class WhoamiAPI(object): """ Deletes an instance of a service function endpoint configuration in the database. - :return: An empty body indicating the the content has been deleted - status code 204. + :return: A content of the object that has been deleted :raises HTTPBadRequest: if the request parameters are invalid(invalid url query string) :raises HTTPNotFound: if a resource with the given parameters doesn't exist in the database """ -- GitLab