Implement category management API

Adds new API calls, responsible for add,
browse and delete categories.

Implements blueprint enable-category-management

Change-Id: I9da0680cfa244ef225be0706a54f492644c0dcba
This commit is contained in:
Ekaterina Chernova 2014-12-31 16:02:43 +03:00
parent 42c320a085
commit 2c23f73e72
8 changed files with 372 additions and 14 deletions

View File

@ -25,7 +25,8 @@ General information
Murano Service API is a programmatic interface used for interaction with Murano Service API is a programmatic interface used for interaction with
Murano. Other interaction mechanisms like Murano Dashboard or Murano CLI Murano. Other interaction mechanisms like Murano Dashboard or Murano CLI
should use API as underlying protocol for interaction. should use API as underlying protocol for interaction.
* **Allowed HTTPs requests**
* **Allowed HTTPs requests**
* *POST* : To create a resource * *POST* : To create a resource
* *GET* : Get a resource or list of resources * *GET* : Get a resource or list of resources

View File

@ -235,6 +235,10 @@ See the full specification `here <http://tools.ietf.org/html/rfc6902>`_.
``id`` (required) Hexadecimal `id` (or fully qualified name) of the package ``id`` (required) Hexadecimal `id` (or fully qualified name) of the package
**Content type**
application/murano-packages-json-patch
Allowed operations: Allowed operations:
:: ::
@ -305,7 +309,7 @@ Delete application definition from the catalog
**Parameters** **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** **Response 404**
@ -321,7 +325,7 @@ Get application definition package
**Parameters** **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)** **Response 200 (application/octetstream)**
@ -341,7 +345,7 @@ Retrieve UI definition for a application which described in a package with provi
**Parameters** **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)** **Response 200 (application/octet-stream)**
@ -369,7 +373,7 @@ Retrieve application logo which described in a package with provided id
**Parameters** **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)** **Response 200 (application/octet-stream)**
@ -377,26 +381,208 @@ The sequence of bytes representing application logo
**Response 403** **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** **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 Categories
========== ==========
Provides category management. Categories are used in the Application Catalog
to group application for easy browsing and search.
List categories 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"] "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/<category_id> [GET]`
Return detailed information for a provided category
*Request*
+----------+-----------------------------------+----------------------------------+
| Method | URI | Description |
+==========+===================================+==================================+
| GET | /catalog/categories/<category_id> | 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/<category_id> | 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 |
+----------------+-----------------------------------------------------------+

View File

@ -5,6 +5,8 @@
"update_package": "rule:admin_api", "update_package": "rule:admin_api",
"upload_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"
} }

View File

@ -268,12 +268,45 @@ class Controller(object):
db_api.package_delete(package_id, req.context) 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): def show_categories(self, req):
policy.check("show_categories", req.context) policy.check("show_categories", req.context)
categories = db_api.categories_list() categories = db_api.categories_list()
return {'categories': [category.name for category in categories]} 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): class PackageSerializer(wsgi.ResponseSerializer):
def serialize(self, action_result, accept, action): def serialize(self, action_result, accept, action):

View File

@ -235,7 +235,22 @@ class API(wsgi.Router):
controller=catalog_resource, controller=catalog_resource,
action='download', action='download',
conditions={'method': ['GET']}) 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() req_stats_resource = request_statistics.create_resource()
mapper.connect('/stats', mapper.connect('/stats',
controller=req_stats_resource, controller=req_stats_resource,

View File

@ -200,6 +200,27 @@ def _do_remove(package, change):
return package 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): def package_update(pkg_id_or_name, changes, context):
"""Update package information """Update package information
:param changes: parameters to update :param changes: parameters to update
@ -357,6 +378,24 @@ def package_delete(package_id_or_name, context):
session.delete(package) 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(): def categories_list():
session = db_session.get_session() session = db_session.get_session()
return session.query(models.Category).all() return session.query(models.Category).all()
@ -380,3 +419,16 @@ def category_add(category_name):
category.save(session) category.save(session)
return category 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)

View File

@ -285,6 +285,10 @@ class Category(Base, TimestampMixin):
default=uuidutils.generate_uuid) default=uuidutils.generate_uuid)
name = sa.Column(sa.String(80), nullable=False, index=True, unique=True) 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): class Tag(Base, TimestampMixin):
"""Represents tags in the datastore.""" """Represents tags in the datastore."""

View File

@ -16,15 +16,19 @@
import cgi import cgi
import cStringIO import cStringIO
import imghdr import imghdr
import json
import os import os
import mock import mock
from oslo.utils import timeutils
from murano.api.v1 import catalog from murano.api.v1 import catalog
from murano.common import policy from murano.common import policy
from murano.db.catalog import api as db_catalog_api from murano.db.catalog import api as db_catalog_api
from murano.db import models
from murano.packages import load_utils from murano.packages import load_utils
import murano.tests.unit.api.base as test_base 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): class TestCatalogApi(test_base.ControllerTest, test_base.MuranoApiTestCase):
@ -132,3 +136,64 @@ Content-Type: application/json
content_type='multipart/form-data; ; boundary=BOUNDARY', content_type='multipart/form-data; ; boundary=BOUNDARY',
params={"is_public": "true"}) params={"is_public": "true"})
res = req.get_response(self.api) 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)