From 2c23f73e726d5998c300fca82b5db657abdd8267 Mon Sep 17 00:00:00 2001 From: Ekaterina Chernova Date: Wed, 31 Dec 2014 16:02:43 +0300 Subject: [PATCH] Implement category management API Adds new API calls, responsible for add, browse and delete categories. Implements blueprint enable-category-management Change-Id: I9da0680cfa244ef225be0706a54f492644c0dcba --- doc/source/specification/index.rst | 3 +- .../specification/murano-repository.rst | 206 +++++++++++++++++- etc/murano/policy.json | 4 +- murano/api/v1/catalog.py | 35 ++- murano/api/v1/router.py | 17 +- murano/db/catalog/api.py | 52 +++++ murano/db/models.py | 4 + murano/tests/unit/api/v1/test_catalog.py | 65 ++++++ 8 files changed, 372 insertions(+), 14 deletions(-) diff --git a/doc/source/specification/index.rst b/doc/source/specification/index.rst index 9b0dc7c07..f61d0f5f5 100644 --- a/doc/source/specification/index.rst +++ b/doc/source/specification/index.rst @@ -25,7 +25,8 @@ General information Murano Service API is a programmatic interface used for interaction with Murano. Other interaction mechanisms like Murano Dashboard or Murano CLI should use API as underlying protocol for interaction. - * **Allowed HTTPs requests** + +* **Allowed HTTPs requests** * *POST* : To create a resource * *GET* : Get a resource or list of resources diff --git a/doc/source/specification/murano-repository.rst b/doc/source/specification/murano-repository.rst index 722b12a2c..b5c338adf 100644 --- a/doc/source/specification/murano-repository.rst +++ b/doc/source/specification/murano-repository.rst @@ -235,6 +235,10 @@ See the full specification `here `_. ``id`` (required) Hexadecimal `id` (or fully qualified name) of the package +**Content type** + +application/murano-packages-json-patch + Allowed operations: :: @@ -305,7 +309,7 @@ Delete application definition from the catalog **Parameters** -``id`` (required) Hexadecimal `id` (or fully qualified name) of the package to delete +* ``id`` (required) Hexadecimal `id` (or fully qualified name) of the package to delete **Response 404** @@ -321,7 +325,7 @@ Get application definition package **Parameters** -* id (required) Hexadecimal `id` (or fully qualified name) of the package +* ``id`` (required) Hexadecimal `id` (or fully qualified name) of the package **Response 200 (application/octetstream)** @@ -341,7 +345,7 @@ Retrieve UI definition for a application which described in a package with provi **Parameters** -* id (required) Hexadecimal `id` (or fully qualified name) of the package +* ``id`` (required) Hexadecimal `id` (or fully qualified name) of the package **Response 200 (application/octet-stream)** @@ -369,7 +373,7 @@ Retrieve application logo which described in a package with provided id **Parameters** -id (required) Hexadecimal `id` (or fully qualified name) of the package +``id`` (required) Hexadecimal `id` (or fully qualified name) of the package **Response 200 (application/octet-stream)** @@ -377,26 +381,208 @@ The sequence of bytes representing application logo **Response 403** -Specified package is not public and not owned by user tenant, performing the request +Specified package is not public and not owned by user tenant, +performing the request **Response 404** -Specified package is not public and not owned by user tenant, performing the request +Specified package is not public and not owned by user tenant, +performing the request Categories ========== +Provides category management. Categories are used in the Application Catalog +to group application for easy browsing and search. + List categories --------------- -`/v1/catalog/packages/categories [GET]` +* `/v1/catalog/packages/categories [GET]` -Retrieve list of all available application categories + !DEPRECATED (Plan to remove in L release) Retrieve list of all available application categories -**Response 200 (application/json)** + **Response 200 (application/json)** -:: + A list, containing category names + + *Content-Type* + application/json + + :: { "categories": ["Web service", "Directory", "Database", "Storage"] } + + +* `/v1/catalog/categories [GET]` + + +----------+----------------------------------+----------------------------------+ + | Method | URI | Description | + +==========+==================================+==================================+ + | GET | /catalog/categories | Get list of existing categories | + +----------+----------------------------------+----------------------------------+ + + + Retrieve list of all available application categories + + **Response 200 (application/json)** + + A list, containing detailed information about each category + + *Content-Type* + application/json + + :: + + {"categories": [ + { + "id": "0420045dce7445fabae7e5e61fff9e2f", + "updated": "2014-12-26T13:57:04", + "name": "Web", + "created": "2014-12-26T13:57:04" + }, + { + "id": "3dd486b1e26f40ac8f35416b63f52042", + "updated": "2014-12-26T13:57:04", + "name": "Databases", + "created": "2014-12-26T13:57:04" + }] + } + + + +Get category details +-------------------- + +`/catalog/categories/ [GET]` + + Return detailed information for a provided category + +*Request* + ++----------+-----------------------------------+----------------------------------+ +| Method | URI | Description | ++==========+===================================+==================================+ +| GET | /catalog/categories/ | Get category detail | ++----------+-----------------------------------+----------------------------------+ + +*Parameters* + +* ``category_id`` - required, category ID, required + +*Response* + + *Content-Type* + application/json + +:: + + { + "id": "b308f7fa8a2f4a5eb419970c827f4466", + "updated": "2015-01-28T17:00:19", + "packages": [ + { + "fully_qualified_name": "io.murano.apps.ZabbixServer", + "id": "4dfb566e69e6445fbd4aea5099fe95e9", + "name": "Zabbix Server" + } + ], + "name": "Web", + "created": "2015-01-28T17:00:19" + } + ++----------------+-----------------------------------------------------------+ +| Code | Description | ++================+===========================================================+ +| 200 | OK. Category deleted successfully | ++----------------+-----------------------------------------------------------+ +| 401 | User is not authorized to access this session | ++----------------+-----------------------------------------------------------+ +| 404 | Not found. Specified category doesn`t exist | ++----------------+-----------------------------------------------------------+ + +Add new category +---------------- + +`/catalog/categories [POST]` + + Add new category to the Application Catalog + +*Parameters* + ++----------------------+------------+--------------------------------------------------------+ +| Attribute | Type | Description | ++======================+============+========================================================+ +| name | string | Environment name; only alphanumeric characters and '-' | ++----------------------+------------+--------------------------------------------------------+ + +*Request* + ++----------+----------------------------------+----------------------------------+ +| Method | URI | Description | ++==========+==================================+==================================+ +| POST | /catalog/categories | Create new category | ++----------+----------------------------------+----------------------------------+ + + *Content-Type* + application/json + + *Example* + {"name": "category_name"} + +*Response* + +:: + + { + "id": "ce373a477f211e187a55404a662f968", + "name": "category_name", + "created": "2013-11-30T03:23:42Z", + "updated": "2013-11-30T03:23:44Z", + } + + ++----------------+-----------------------------------------------------------+ +| Code | Description | ++================+===========================================================+ +| 200 | OK. Category created successfully | ++----------------+-----------------------------------------------------------+ +| 401 | User is not authorized to access this session | ++----------------+-----------------------------------------------------------+ +| 409 | Conflict. Category with specified name already exist | ++----------------+-----------------------------------------------------------+ + + +Delete category +--------------- + +`/catalog/categories [DELETE]` + +*Request* + ++----------+-----------------------------------+-----------------------------------+ +| Method | URI | Description | ++==========+===================================+===================================+ +| DELETE | /catalog/categories/ | Delete category with specified id | ++----------+-----------------------------------+-----------------------------------+ + +*Parameters:* + +* ``category_id`` - required, category ID, required + +*Response* + ++----------------+-----------------------------------------------------------+ +| Code | Description | ++================+===========================================================+ +| 200 | OK. Category deleted successfully | ++----------------+-----------------------------------------------------------+ +| 401 | User is not authorized to access this session | ++----------------+-----------------------------------------------------------+ +| 404 | Not found. Specified category doesn`t exist | ++----------------+-----------------------------------------------------------+ +| 403 | Forbidden. Category with specified name is assigned to | +| | the package, presented in the catalog | ++----------------+-----------------------------------------------------------+ diff --git a/etc/murano/policy.json b/etc/murano/policy.json index 6eaa70bde..6c037704d 100644 --- a/etc/murano/policy.json +++ b/etc/murano/policy.json @@ -5,6 +5,8 @@ "update_package": "rule:admin_api", "upload_package": "rule:admin_api", - "delete_package": "rule:admin_api" + "delete_package": "rule:admin_api", + "delete_category": "rule:admin_api", + "add_category": "rule:admin_api" } diff --git a/murano/api/v1/catalog.py b/murano/api/v1/catalog.py index b0d0f1fab..fdb359c3e 100644 --- a/murano/api/v1/catalog.py +++ b/murano/api/v1/catalog.py @@ -268,12 +268,45 @@ class Controller(object): db_api.package_delete(package_id, req.context) + def get_category(self, req, category_id): + policy.check("get_category", req.context) + category = db_api.category_get(category_id, packages=True) + return category.to_dict() + def show_categories(self, req): policy.check("show_categories", req.context) - categories = db_api.categories_list() return {'categories': [category.name for category in categories]} + def list_categories(self, req): + policy.check("list_categories", req.context) + categories = db_api.categories_list() + return {'categories': [category.to_dict() for category in categories]} + + def add_category(self, req, body=None): + policy.check("add_category", req.context) + + if not body.get('name'): + raise exc.HTTPBadRequest( + explanation='Please, specify a name of the category to create') + try: + category = db_api.category_add(body['name']) + except db_exc.DBDuplicateEntry: + msg = _('Category with specified name is already exist') + LOG.error(msg) + raise exc.HTTPConflict(explanation=msg) + return category.to_dict() + + def delete_category(self, req, category_id): + target = {'category_id': category_id} + policy.check("delete_category", req.context, target) + category = db_api.category_get(category_id, packages=True) + if category.packages: + msg = _("It's impossible to delete categories assigned" + " to the package, uploaded to the catalog") + raise exc.HTTPForbidden(explanation=msg) + db_api.category_delete(category_id) + class PackageSerializer(wsgi.ResponseSerializer): def serialize(self, action_result, accept, action): diff --git a/murano/api/v1/router.py b/murano/api/v1/router.py index 658beef45..c6cba057c 100644 --- a/murano/api/v1/router.py +++ b/murano/api/v1/router.py @@ -235,7 +235,22 @@ class API(wsgi.Router): controller=catalog_resource, action='download', conditions={'method': ['GET']}) - + mapper.connect('/catalog/categories', + controller=catalog_resource, + action='list_categories', + conditions={'method': ['GET']}) + mapper.connect('/catalog/categories/{category_id}', + controller=catalog_resource, + action='get_category', + conditions={'method': ['GET']}) + mapper.connect('/catalog/categories', + controller=catalog_resource, + action='add_category', + conditions={'method': ['POST']}) + mapper.connect('/catalog/categories/{category_id}', + controller=catalog_resource, + action='delete_category', + conditions={'method': ['DELETE']}) req_stats_resource = request_statistics.create_resource() mapper.connect('/stats', controller=req_stats_resource, diff --git a/murano/db/catalog/api.py b/murano/db/catalog/api.py index 65f1be720..9bfab04c8 100644 --- a/murano/db/catalog/api.py +++ b/murano/db/catalog/api.py @@ -200,6 +200,27 @@ def _do_remove(package, change): return package +def _get_packages_for_category(session, category_id): + """Return detailed list of packages, belonging to the provided category + :param session: + :param category_id: + :return: list of dictionaries, containing id, name and package frn + """ + pkg = models.Package + packages = (session.query(pkg.id, pkg.name, pkg.fully_qualified_name) + .filter(pkg.categories + .any(models.Category.id == category_id)) + .all()) + + result_packages = [] + for package in packages: + id, name, fqn = package + result_packages.append({'id': id, + 'name': name, + 'fully_qualified_name': fqn}) + return result_packages + + def package_update(pkg_id_or_name, changes, context): """Update package information :param changes: parameters to update @@ -357,6 +378,24 @@ def package_delete(package_id_or_name, context): session.delete(package) +def category_get(category_id, session=None, packages=False): + """Return category details + :param category_id: ID of a category, string + :returns: detailed information about category, dict + """ + if not session: + session = db_session.get_session() + + category = session.query(models.Category).get(category_id) + if not category: + msg = _("Category id or '{0}' not found").format(category_id) + LOG.error(msg) + raise exc.HTTPNotFound(msg) + if packages: + category.packages = _get_packages_for_category(session, category_id) + return category + + def categories_list(): session = db_session.get_session() return session.query(models.Category).all() @@ -380,3 +419,16 @@ def category_add(category_name): category.save(session) return category + + +def category_delete(category_id): + """Delete a category by ID.""" + session = db_session.get_session() + + with session.begin(): + category = session.query(models.Category).get(category_id) + if not category: + msg = _("Category id '{0}' not found").format(category_id) + LOG.error(msg) + raise exc.HTTPNotFound(msg) + session.delete(category) diff --git a/murano/db/models.py b/murano/db/models.py index b02302429..a5b25e1f6 100644 --- a/murano/db/models.py +++ b/murano/db/models.py @@ -285,6 +285,10 @@ class Category(Base, TimestampMixin): default=uuidutils.generate_uuid) name = sa.Column(sa.String(80), nullable=False, index=True, unique=True) + def to_dict(self): + dictionary = super(Category, self).to_dict() + return dictionary + class Tag(Base, TimestampMixin): """Represents tags in the datastore.""" diff --git a/murano/tests/unit/api/v1/test_catalog.py b/murano/tests/unit/api/v1/test_catalog.py index 07844954f..71d2997a9 100644 --- a/murano/tests/unit/api/v1/test_catalog.py +++ b/murano/tests/unit/api/v1/test_catalog.py @@ -16,15 +16,19 @@ import cgi import cStringIO import imghdr +import json import os import mock +from oslo.utils import timeutils from murano.api.v1 import catalog from murano.common import policy from murano.db.catalog import api as db_catalog_api +from murano.db import models from murano.packages import load_utils import murano.tests.unit.api.base as test_base +import murano.tests.unit.utils as test_utils class TestCatalogApi(test_base.ControllerTest, test_base.MuranoApiTestCase): @@ -132,3 +136,64 @@ Content-Type: application/json content_type='multipart/form-data; ; boundary=BOUNDARY', params={"is_public": "true"}) res = req.get_response(self.api) + self.assertEqual(403, res.status_code) + + def test_add_category(self): + """Check that category added successfully + """ + + self._set_policy_rules({'add_category': '@'}) + self.expect_policy_check('add_category') + + fake_now = timeutils.utcnow() + timeutils.utcnow.override_time = fake_now + + expected = {'name': 'new_category', + 'created': timeutils.isotime(fake_now)[:-1], + 'updated': timeutils.isotime(fake_now)[:-1]} + + body = {'name': 'new_category'} + req = self._post('/catalog/categories', json.dumps(body)) + result = req.get_response(self.api) + processed_result = json.loads(result.body) + self.assertIn('id', processed_result.keys()) + expected['id'] = processed_result['id'] + self.assertDictEqual(expected, processed_result) + + def test_delete_category(self): + """Check that category deleted successfully + """ + + self._set_policy_rules({'delete_category': '@'}) + self.expect_policy_check('delete_category', + {'category_id': '12345'}) + + fake_now = timeutils.utcnow() + expected = {'name': 'new_category', + 'created': fake_now, + 'updated': fake_now, + 'id': '12345'} + + e = models.Category(**expected) + test_utils.save_models(e) + + req = self._delete('/catalog/categories/12345') + processed_result = req.get_response(self.api) + self.assertEqual('', processed_result.body) + self.assertEqual(200, processed_result.status_code) + + def test_add_category_failed_for_non_admin(self): + """Check that non admin user couldn't add new category + """ + + self._set_policy_rules({'add_category': 'role:context_admin'}) + self.is_admin = False + self.expect_policy_check('add_category') + + fake_now = timeutils.utcnow() + timeutils.utcnow.override_time = fake_now + + body = {'name': 'new_category'} + req = self._post('/catalog/categories', json.dumps(body)) + result = req.get_response(self.api) + self.assertEqual(403, result.status_code)