Add products REST API

This patchset add base methods for products REST API as
described in spec.

Change-Id: I58e07c940886411705c143077b6871522baed9cb
Addresses-Spec: https://review.openstack.org/#/c/292526/
This commit is contained in:
Andrey Pavlov 2016-05-12 15:55:32 +03:00
parent ddecd2ccca
commit 9888cf2bc1
11 changed files with 495 additions and 19 deletions

View File

@ -0,0 +1,184 @@
# Copyright (c) 2015 Mirantis, Inc.
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
"""Product controller."""
import json
import uuid
from oslo_config import cfg
from oslo_log import log
import pecan
from pecan.secure import secure
import six
from refstack.api import constants as const
from refstack.api.controllers import validation
from refstack.api import utils as api_utils
from refstack.api import validators
from refstack import db
LOG = log.getLogger(__name__)
CONF = cfg.CONF
class ProductsController(validation.BaseRestControllerWithValidation):
"""/v1/products handler."""
__validator__ = validators.ProductValidator
_custom_actions = {
"action": ["POST"],
}
@pecan.expose('json')
def get(self):
"""Get information of all products."""
allowed_keys = ['id', 'name', 'description', 'product_id', 'type',
'product_type', 'public', 'organization_id']
user = api_utils.get_user_id()
is_admin = user in db.get_foundation_users()
try:
if is_admin:
products = db.get_products(allowed_keys=allowed_keys)
for s in products:
s['can_manage'] = True
else:
result = dict()
products = db.get_public_products(allowed_keys=allowed_keys)
for s in products:
_id = s['id']
result[_id] = s
result[_id]['can_manage'] = False
products = db.get_products_by_user(user,
allowed_keys=allowed_keys)
for s in products:
_id = s['id']
if _id not in result:
result[_id] = s
result[_id]['can_manage'] = True
products = result.values()
except Exception as ex:
LOG.exception('An error occurred during '
'operation with database: %s' % ex)
pecan.abort(400)
products.sort(key=lambda x: x['name'])
return {'products': products}
@pecan.expose('json')
def get_one(self, id):
"""Get information about product."""
product = db.get_product(id)
vendor_id = product['organization_id']
is_admin = (api_utils.check_user_is_foundation_admin() or
api_utils.check_user_is_vendor_admin(vendor_id))
if not is_admin and not product['public']:
pecan.abort(403, 'Forbidden.')
if not is_admin:
allowed_keys = ['id', 'name', 'description', 'product_id', 'type',
'product_type', 'public', 'organization_id']
for key in product.keys():
if key not in allowed_keys:
product.pop(key)
product['can_manage'] = is_admin
return product
@secure(api_utils.is_authenticated)
@pecan.expose('json')
def post(self):
"""'secure' decorator doesn't work at store_item. it must be here."""
return super(ProductsController, self).post()
@pecan.expose('json')
def store_item(self, product):
"""Handler for storing item. Should return new item id."""
creator = api_utils.get_user_id()
product['type'] = (const.SOFTWARE
if product['product_type'] == const.DISTRO
else const.CLOUD)
if product['type'] == const.SOFTWARE:
product['product_id'] = six.text_type(uuid.uuid4())
vendor_id = product.pop('organization_id', None)
if not vendor_id:
# find or create default vendor for new product
# TODO(andrey-mp): maybe just fill with info here and create
# at DB layer in one transaction
default_vendor_name = 'vendor_' + creator
vendors = db.get_organizations_by_user(creator)
for v in vendors:
if v['name'] == default_vendor_name:
vendor_id = v['id']
break
else:
vendor = {'name': default_vendor_name}
vendor = db.add_organization(vendor, creator)
vendor_id = vendor['id']
product['organization_id'] = vendor_id
product = db.add_product(product, creator)
return {'id': product['id']}
@secure(api_utils.is_authenticated)
@pecan.expose('json', method='PUT')
def put(self, id, **kw):
"""Handler for update item. Should return full info with updates."""
product = db.get_product(id)
vendor_id = product['organization_id']
vendor = db.get_organization(vendor_id)
is_admin = (api_utils.check_user_is_foundation_admin()
or api_utils.check_user_is_vendor_admin(vendor_id))
if not is_admin:
pecan.abort(403, 'Forbidden.')
product_info = {'id': id}
if 'name' in kw:
product_info['name'] = kw['name']
if 'description' in kw:
product_info['description'] = kw['description']
if 'product_id' in kw:
product_info['product_id'] = kw['product_id']
if 'public' in kw:
# user can mark product as public only if
# his/her vendor is public(official)
public = api_utils.str_to_bool(kw['public'])
if (vendor['type'] not in (const.OFFICIAL_VENDOR, const.FOUNDATION)
and public):
pecan.abort(403, 'Forbidden.')
product_info['public'] = public
if 'properties' in kw:
product_info['properties'] = json.dumps(kw['properties'])
db.update_product(product_info)
pecan.response.status = 200
product = db.get_product(id)
product['can_manage'] = True
return product
@secure(api_utils.is_authenticated)
@pecan.expose('json')
def delete(self, id):
"""Delete product."""
product = db.get_product(id)
vendor_id = product['organization_id']
if (not api_utils.check_user_is_foundation_admin() and
not api_utils.check_user_is_vendor_admin(vendor_id)):
pecan.abort(403, 'Forbidden.')
db.delete_product(id)
pecan.response.status = 204

View File

@ -17,6 +17,7 @@
from refstack.api.controllers import auth
from refstack.api.controllers import guidelines
from refstack.api.controllers import products
from refstack.api.controllers import results
from refstack.api.controllers import user
from refstack.api.controllers import vendors
@ -29,4 +30,5 @@ class V1Controller(object):
guidelines = guidelines.GuidelinesController()
auth = auth.AuthController()
profile = user.ProfileController()
products = products.ProductsController()
vendors = vendors.VendorsController()

View File

@ -128,7 +128,7 @@ class VendorsController(validation.BaseRestControllerWithValidation):
vendor_info['properties'] = json.dumps(kw['properties'])
db.update_organization(vendor_info)
pecan.response.status = 201
pecan.response.status = 200
vendor = db.get_organization(vendor_id)
vendor['can_manage'] = True
return vendor

View File

@ -92,6 +92,8 @@ def parse_input_params(expected_input_params):
def str_to_bool(param):
"""Check if a string value should be evaluated as True or False."""
if isinstance(param, bool):
return param
return param.lower() in ("true", "yes", "1")

View File

@ -16,6 +16,7 @@
"""Validators module."""
import binascii
import six
import uuid
import json
@ -72,6 +73,17 @@ class BaseValidator(object):
raise api_exc.ValidationError(
'Request doesn''t correspond to schema', e)
def check_emptyness(self, body, keys):
"""Check that all values are not empty."""
for key in keys:
value = body[key]
if isinstance(value, six.string_types):
value = value.strip()
if not value:
raise api_exc.ValidationError(key + ' should not be empty')
elif value is None:
raise api_exc.ValidationError(key + ' must be present')
class TestResultValidator(BaseValidator):
"""Validator for incoming test results."""
@ -195,6 +207,27 @@ class VendorValidator(BaseValidator):
super(VendorValidator, self).validate(request)
body = json.loads(request.body)
name = body['name'].strip()
if not name:
raise api_exc.ValidationError('Name should not be empty.')
self.check_emptyness(body, ['name'])
class ProductValidator(BaseValidator):
"""Validate uploaded product data."""
schema = {
'type': 'object',
'properties': {
'name': {'type': 'string'},
'description': {'type': 'string'},
'product_type': {'type': 'integer'},
'organization_id': {'type': 'string', 'format': 'uuid_hex'},
},
'required': ['name', 'product_type'],
'additionalProperties': False
}
def validate(self, request):
"""Validate uploaded test results."""
super(ProductValidator, self).validate(request)
body = json.loads(request.body)
self.check_emptyness(body, ['name', 'product_type'])

View File

@ -236,3 +236,18 @@ def get_organizations_by_user(user_openid, allowed_keys=None):
"""Get organizations for specified user."""
return IMPL.get_organizations_by_user(user_openid,
allowed_keys=allowed_keys)
def get_public_products(allowed_keys=None):
"""Get all public products."""
return IMPL.get_public_products(allowed_keys=allowed_keys)
def get_products(allowed_keys=None):
"""Get all products."""
return IMPL.get_products(allowed_keys=allowed_keys)
def get_products_by_user(user_openid, allowed_keys=None):
"""Get all products that user can manage."""
return IMPL.get_products_by_user(user_openid, allowed_keys=allowed_keys)

View File

@ -0,0 +1,21 @@
"""Make product_id nullable in product table.
Revision ID: 7093ca478d35
Revises: 7092392cbb8e
Create Date: 2016-05-12 13:10:00
"""
# revision identifiers, used by Alembic.
revision = '7093ca478d35'
down_revision = '7092392cbb8e'
MYSQL_CHARSET = 'utf8'
from alembic import op
import sqlalchemy as sa
def upgrade():
"""Upgrade DB."""
op.alter_column('product', 'product_id', nullable=True,
type_=sa.String(36))

View File

@ -437,7 +437,7 @@ def add_product(product_info, creator):
product = models.Product()
product.type = product_info['type']
product.product_type = product_info['product_type']
product.product_id = product_info['product_id']
product.product_id = product_info.get('product_id')
product.name = product_info['name']
product.description = product_info.get('description')
product.organization_id = product_info['organization_id']
@ -452,17 +452,17 @@ def add_product(product_info, creator):
def update_product(product_info):
"""Update product by product_id."""
"""Update product by id."""
session = get_session()
_id = product_info.get('product_id')
product = session.query(models.Product).filter_by(product_id=_id).first()
_id = product_info.get('id')
product = session.query(models.Product).filter_by(id=_id).first()
if product is None:
raise NotFound('Product with product_id %s not found' % _id)
raise NotFound('Product with id %s not found' % _id)
product.name = product_info.get('name', product.name)
product.description = product_info.get('description', product.description)
product.public = product_info.get('public', product.public)
product.properties = product_info.get('properties', product.properties)
keys = ['name', 'description', 'product_id', 'public', 'properties']
for key in keys:
if key in product_info:
setattr(product, key, product_info[key])
with session.begin():
product.save(session=session)
@ -551,3 +551,40 @@ def get_organizations_by_user(user_openid, allowed_keys=None):
.order_by(models.Organization.created_at.desc()).all())
items = [item[0] for item in items]
return _to_dict(items, allowed_keys=allowed_keys)
def get_public_products(allowed_keys=None):
"""Get public products."""
session = get_session()
items = (
session.query(models.Product)
.filter_by(public=True)
.order_by(models.Product.created_at.desc()).all())
return _to_dict(items, allowed_keys=allowed_keys)
def get_products(allowed_keys=None):
"""Get all products."""
session = get_session()
items = (
session.query(models.Product)
.order_by(models.Product.created_at.desc()).all())
return _to_dict(items, allowed_keys=allowed_keys)
def get_products_by_user(user_openid, allowed_keys=None):
"""Get all products that user can manage."""
session = get_session()
items = (
session.query(models.Product, models.Organization, models.Group,
models.UserToGroup)
.join(models.Organization,
models.Organization.id == models.Product.organization_id)
.join(models.Group,
models.Group.id == models.Organization.group_id)
.join(models.UserToGroup,
models.Group.id == models.UserToGroup.group_id)
.filter(models.UserToGroup.user_openid == user_openid)
.order_by(models.Organization.created_at.desc()).all())
items = [item[0] for item in items]
return _to_dict(items, allowed_keys=allowed_keys)

View File

@ -225,7 +225,7 @@ class Product(BASE, RefStackBase): # pragma: no cover
id = sa.Column(sa.String(36), primary_key=True,
default=lambda: six.text_type(uuid.uuid4()))
product_id = sa.Column(sa.String(36), nullable=False)
product_id = sa.Column(sa.String(36), nullable=True)
name = sa.Column(sa.String(80), nullable=False)
description = sa.Column(sa.Text())
organization_id = sa.Column(sa.String(36),
@ -241,5 +241,6 @@ class Product(BASE, RefStackBase): # pragma: no cover
@property
def default_allowed_keys(self):
"""Default keys."""
return ('id', 'product_id', 'name', 'description', 'organization_id',
'created_by_user', 'properties', 'type', 'product_type')
return ('id', 'name', 'description', 'product_id', 'product_type',
'public', 'properties', 'created_at', 'updated_at',
'organization_id', 'created_by_user', 'type')

View File

@ -0,0 +1,181 @@
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import json
import uuid
import mock
from oslo_config import fixture as config_fixture
import webtest.app
from refstack.api import constants as api_const
from refstack import db
from refstack.tests import api
FAKE_PRODUCT = {
'name': 'product name',
'description': 'product description',
'product_type': api_const.CLOUD,
}
class TestProductsEndpoint(api.FunctionalTest):
"""Test case for the 'products' API endpoint."""
URL = '/v1/products/'
def setUp(self):
super(TestProductsEndpoint, self).setUp()
self.config_fixture = config_fixture.Config()
self.CONF = self.useFixture(self.config_fixture).conf
self.user_info = {
'openid': 'test-open-id',
'email': 'foo@bar.com',
'fullname': 'Foo Bar'
}
db.user_save(self.user_info)
@mock.patch('refstack.api.utils.get_user_id', return_value='test-open-id')
def test_post(self, mock_get_user):
"""Test products endpoint with post request."""
product = json.dumps(FAKE_PRODUCT)
actual_response = self.post_json(self.URL, params=product)
self.assertIn('id', actual_response)
try:
uuid.UUID(actual_response.get('id'), version=4)
except ValueError:
self.fail("actual_response doesn't contain new item id")
@mock.patch('refstack.api.utils.get_user_id', return_value='test-open-id')
def test_post_with_empty_object(self, mock_get_user):
"""Test products endpoint with empty product request."""
results = json.dumps(dict())
self.assertRaises(webtest.app.AppError,
self.post_json,
self.URL,
params=results)
@mock.patch('refstack.api.utils.get_user_id', return_value='test-open-id')
def test_post_with_invalid_schema(self, mock_get_user):
"""Test post request with invalid schema."""
products = json.dumps({
'foo': 'bar',
})
self.assertRaises(webtest.app.AppError,
self.post_json,
self.URL,
params=products)
@mock.patch('refstack.api.utils.get_user_id', return_value='test-open-id')
def test_vendor_was_created(self, mock_get_user):
"""Test get_one request."""
product = json.dumps(FAKE_PRODUCT)
post_response = self.post_json(self.URL, params=product)
get_response = self.get_json(self.URL + post_response.get('id'))
vendor_id = get_response.get('organization_id')
self.assertIsNotNone(vendor_id)
# check vendor is present
get_response = self.get_json('/v1/vendors/' + vendor_id)
@mock.patch('refstack.api.utils.get_user_id', return_value='test-open-id')
def test_using_default_vendor(self, mock_get_user):
"""Test get_one request."""
product = json.dumps(FAKE_PRODUCT)
post_response = self.post_json(self.URL, params=product)
get_response = self.get_json(self.URL + post_response.get('id'))
vendor_id = get_response.get('organization_id')
self.assertIsNotNone(vendor_id)
# check vendor is present
get_response = self.get_json('/v1/vendors/' + vendor_id)
# create one more product
product = json.dumps(FAKE_PRODUCT)
post_response = self.post_json(self.URL, params=product)
@mock.patch('refstack.api.utils.get_user_id', return_value='test-open-id')
def test_get_one(self, mock_get_user):
"""Test get_one request."""
product = json.dumps(FAKE_PRODUCT)
post_response = self.post_json(self.URL, params=product)
get_response = self.get_json(self.URL + post_response.get('id'))
# some of these fields are only exposed to the owner/foundation.
self.assertIn('created_by_user', get_response)
self.assertIn('properties', get_response)
self.assertIn('created_at', get_response)
self.assertIn('updated_at', get_response)
self.assertEqual(FAKE_PRODUCT['name'],
get_response['name'])
self.assertEqual(FAKE_PRODUCT['description'],
get_response['description'])
self.assertEqual(api_const.PUBLIC_CLOUD,
get_response['type'])
self.assertEqual(api_const.CLOUD,
get_response['product_type'])
# reset auth and check return result for anonymous
mock_get_user.return_value = None
self.assertRaises(webtest.app.AppError,
self.get_json,
self.URL + post_response.get('id'))
@mock.patch('refstack.api.utils.get_user_id', return_value='test-open-id')
def test_delete(self, mock_get_user):
"""Test delete request."""
product = json.dumps(FAKE_PRODUCT)
post_response = self.post_json(self.URL, params=product)
self.delete(self.URL + post_response.get('id'))
@mock.patch('refstack.api.utils.get_user_id', return_value='test-open-id')
def test_update(self, mock_get_user):
"""Test put(update) request."""
product = json.dumps(FAKE_PRODUCT)
post_response = self.post_json(self.URL, params=product)
id = post_response.get('id')
# check update of properties
props = {'properties': {'fake01': 'value01'}}
post_response = self.put_json(self.URL + id,
params=json.dumps(props))
get_response = self.get_json(self.URL + id)
self.assertEqual(FAKE_PRODUCT['name'],
get_response['name'])
self.assertEqual(FAKE_PRODUCT['description'],
get_response['description'])
self.assertEqual(props['properties'],
json.loads(get_response['properties']))
# check second update of properties
props = {'properties': {'fake02': 'value03'}}
post_response = self.put_json(self.URL + id,
params=json.dumps(props))
get_response = self.get_json(self.URL + id)
self.assertEqual(props['properties'],
json.loads(get_response['properties']))
def test_get_one_invalid_url(self):
"""Test get request with invalid url."""
self.assertRaises(webtest.app.AppError,
self.get_json,
self.URL + 'fake_id')
def test_get_with_empty_database(self):
"""Test get(list) request with no items in DB."""
results = self.get_json(self.URL)
self.assertEqual([], results['products'])

View File

@ -707,15 +707,15 @@ class DBBackendTestCase(base.BaseTestCase):
query = session.query.return_value
filtered = query.filter_by.return_value
product = models.Product()
product.product_id = '123'
product.id = '123'
filtered.first.return_value = product
product_info = {'product_id': '098', 'name': 'a', 'description': 'b',
'creator_openid': 'abc', 'organization_id': '1',
'type': 0, 'product_type': 0}
'type': 0, 'product_type': 0, 'id': '123'}
api.update_product(product_info)
self.assertEqual('123', product.product_id)
self.assertEqual('098', product.product_id)
self.assertIsNone(product.created_by_user)
self.assertIsNone(product.organization_id)
self.assertIsNone(product.type)