Merge "Initial commit for repository API support"
This commit is contained in:
commit
3621c57404
@ -28,6 +28,9 @@ use_syslog = False
|
|||||||
#Syslog facility to receive log lines
|
#Syslog facility to receive log lines
|
||||||
syslog_log_facility = LOG_LOCAL0
|
syslog_log_facility = LOG_LOCAL0
|
||||||
|
|
||||||
|
# Role used to identify an authenticated user as administrator
|
||||||
|
#admin_role = admin
|
||||||
|
|
||||||
# Use durable queues in amqp. (boolean value)
|
# Use durable queues in amqp. (boolean value)
|
||||||
# Deprecated group/name - [DEFAULT]/rabbit_durable_queues
|
# Deprecated group/name - [DEFAULT]/rabbit_durable_queues
|
||||||
#amqp_durable_queues=false
|
#amqp_durable_queues=false
|
||||||
|
@ -15,9 +15,17 @@
|
|||||||
from oslo.config import cfg
|
from oslo.config import cfg
|
||||||
|
|
||||||
import muranoapi.context
|
import muranoapi.context
|
||||||
|
from muranoapi.openstack.common.gettextutils import _ # noqa
|
||||||
import muranoapi.openstack.common.log as logging
|
import muranoapi.openstack.common.log as logging
|
||||||
from muranoapi.openstack.common import wsgi
|
from muranoapi.openstack.common import wsgi
|
||||||
|
|
||||||
|
context_opts = [
|
||||||
|
cfg.StrOpt('admin_role', default='admin',
|
||||||
|
help=_('Role used to identify an authenticated user as '
|
||||||
|
'administrator.'))]
|
||||||
|
|
||||||
|
CONF = cfg.CONF
|
||||||
|
CONF.register_opts(context_opts)
|
||||||
LOG = logging.getLogger(__name__)
|
LOG = logging.getLogger(__name__)
|
||||||
CONF = cfg.CONF
|
CONF = cfg.CONF
|
||||||
|
|
||||||
@ -32,11 +40,14 @@ class ContextMiddleware(wsgi.Middleware):
|
|||||||
|
|
||||||
:param req: wsgi request object that will be given the context object
|
:param req: wsgi request object that will be given the context object
|
||||||
"""
|
"""
|
||||||
|
|
||||||
kwargs = {
|
kwargs = {
|
||||||
'user': req.headers.get('X-User-Id'),
|
'user': req.headers.get('X-User-Id'),
|
||||||
'tenant': req.headers.get('X-Tenant-Id'),
|
'tenant': req.headers.get('X-Tenant-Id'),
|
||||||
'auth_token': req.headers.get('X-Auth-Token'),
|
'auth_token': req.headers.get('X-Auth-Token'),
|
||||||
'session': req.headers.get('X-Configuration-Session')
|
'session': req.headers.get('X-Configuration-Session'),
|
||||||
|
'is_admin': CONF.admin_role in [
|
||||||
|
role.strip() for role in req.headers.get('X-Roles').split(',')]
|
||||||
}
|
}
|
||||||
req.context = muranoapi.context.RequestContext(**kwargs)
|
req.context = muranoapi.context.RequestContext(**kwargs)
|
||||||
|
|
||||||
|
71
muranoapi/api/v1/catalog.py
Normal file
71
muranoapi/api/v1/catalog.py
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
# Copyright (c) 2014 Mirantis, Inc.
|
||||||
|
#
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
from oslo.config import cfg
|
||||||
|
from webob import exc
|
||||||
|
|
||||||
|
from muranoapi.db.catalog import api as db_api
|
||||||
|
from muranoapi.openstack.common import exception
|
||||||
|
from muranoapi.openstack.common.gettextutils import _ # noqa
|
||||||
|
from muranoapi.openstack.common import log as logging
|
||||||
|
from muranoapi.openstack.common import wsgi
|
||||||
|
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
CONF = cfg.CONF
|
||||||
|
|
||||||
|
|
||||||
|
def _check_content_type(req, content_type):
|
||||||
|
try:
|
||||||
|
req.get_content_type((content_type,))
|
||||||
|
except exception.InvalidContentType:
|
||||||
|
msg = _("Content-Type must be '{0}'").format(content_type)
|
||||||
|
LOG.debug(msg)
|
||||||
|
raise exc.HTTPBadRequest(explanation=msg)
|
||||||
|
|
||||||
|
|
||||||
|
class Controller(object):
|
||||||
|
"""
|
||||||
|
WSGI controller for application catalog resource in Murano v1 API
|
||||||
|
"""
|
||||||
|
|
||||||
|
def update(self, req, body, package_id):
|
||||||
|
"""
|
||||||
|
List of allowed changes:
|
||||||
|
{ "op": "add", "path": "/tags", "value": [ "foo", "bar" ] }
|
||||||
|
{ "op": "add", "path": "/categories", "value": [ "foo", "bar" ] }
|
||||||
|
{ "op": "remove", "path": "/tags" }
|
||||||
|
{ "op": "replace", "path": "/tags", "value": ["foo", "bar"] }
|
||||||
|
{ "op": "replace", "path": "/is_public", "value": true }
|
||||||
|
{ "op": "replace", "path": "/description",
|
||||||
|
"value":"New description" }
|
||||||
|
{ "op": "replace", "path": "/name", "value": "New name" }
|
||||||
|
"""
|
||||||
|
_check_content_type(req, 'application/murano-packages-json-patch')
|
||||||
|
if not isinstance(body, list):
|
||||||
|
msg = _('Request body must be a JSON array of operation objects.')
|
||||||
|
LOG.error(msg)
|
||||||
|
raise exc.HTTPBadRequest(explanation=msg)
|
||||||
|
package = db_api.package_update(package_id, body, req.context)
|
||||||
|
|
||||||
|
return package.to_dict()
|
||||||
|
|
||||||
|
def get(self, req, package_id):
|
||||||
|
package = db_api.package_get(package_id, req.context)
|
||||||
|
return package.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
def create_resource():
|
||||||
|
return wsgi.Resource(Controller())
|
@ -13,6 +13,7 @@
|
|||||||
# under the License.
|
# under the License.
|
||||||
import routes
|
import routes
|
||||||
|
|
||||||
|
from muranoapi.api.v1 import catalog
|
||||||
from muranoapi.api.v1 import deployments
|
from muranoapi.api.v1 import deployments
|
||||||
from muranoapi.api.v1 import environments
|
from muranoapi.api.v1 import environments
|
||||||
from muranoapi.api.v1 import services
|
from muranoapi.api.v1 import services
|
||||||
@ -119,4 +120,13 @@ class API(wsgi.Router):
|
|||||||
action='deploy',
|
action='deploy',
|
||||||
conditions={'method': ['POST']})
|
conditions={'method': ['POST']})
|
||||||
|
|
||||||
|
catalog_resource = catalog.create_resource()
|
||||||
|
mapper.connect('/catalog/packages/{package_id}',
|
||||||
|
controller=catalog_resource,
|
||||||
|
action='get',
|
||||||
|
conditions={'method': ['GET']})
|
||||||
|
mapper.connect('/catalog/packages/{package_id}',
|
||||||
|
controller=catalog_resource,
|
||||||
|
action='update',
|
||||||
|
conditions={'method': ['PATCH']})
|
||||||
super(API, self).__init__(mapper)
|
super(API, self).__init__(mapper)
|
||||||
|
@ -23,3 +23,28 @@ ENV_SCHEMA = {
|
|||||||
},
|
},
|
||||||
"required": ["id", "name"]
|
"required": ["id", "name"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PKG_UPDATE_SCHEMA = {
|
||||||
|
"$schema": "http://json-schema.org/draft-04/schema#",
|
||||||
|
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"tags": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"uniqueItems": True
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"uniqueItems": True
|
||||||
|
},
|
||||||
|
"description": {"type": "string"},
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"is_public": {"type": "boolean"},
|
||||||
|
"enabled": {"type": "boolean"}
|
||||||
|
},
|
||||||
|
"additionalProperties": False,
|
||||||
|
"minProperties": 1,
|
||||||
|
}
|
||||||
|
@ -19,11 +19,13 @@ class RequestContext(object):
|
|||||||
accesses the system, as well as additional request information.
|
accesses the system, as well as additional request information.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, auth_token=None, user=None, tenant=None, session=None):
|
def __init__(self, auth_token=None, user=None,
|
||||||
|
tenant=None, session=None, is_admin=None):
|
||||||
self.auth_token = auth_token
|
self.auth_token = auth_token
|
||||||
self.user = user
|
self.user = user
|
||||||
self.tenant = tenant
|
self.tenant = tenant
|
||||||
self.session = session
|
self.session = session
|
||||||
|
self.is_admin = is_admin
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
return {
|
return {
|
||||||
|
0
muranoapi/db/catalog/__init__.py
Normal file
0
muranoapi/db/catalog/__init__.py
Normal file
206
muranoapi/db/catalog/api.py
Normal file
206
muranoapi/db/catalog/api.py
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
# Copyright (c) 2014 Mirantis, Inc.
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
from webob import exc
|
||||||
|
|
||||||
|
from muranoapi.db import models
|
||||||
|
from muranoapi.db import session as db_session
|
||||||
|
from muranoapi.openstack.common.gettextutils import _ # noqa
|
||||||
|
from muranoapi.openstack.common import log as logging
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_category_by_name(name):
|
||||||
|
session = db_session.get_session()
|
||||||
|
return session.query(models.Category).filter_by(name=name).first()
|
||||||
|
|
||||||
|
|
||||||
|
def category_get_names():
|
||||||
|
session = db_session.get_session()
|
||||||
|
categories = []
|
||||||
|
for row in session.query(models.Category.name).all():
|
||||||
|
for name in row:
|
||||||
|
categories.append(name)
|
||||||
|
return categories
|
||||||
|
|
||||||
|
|
||||||
|
def get_tag_by_name(name):
|
||||||
|
session = db_session.get_session()
|
||||||
|
return session.query(models.Tag).filter_by(name=name).first()
|
||||||
|
|
||||||
|
|
||||||
|
def _package_get(package_id, session):
|
||||||
|
package = session.query(models.Package).get(package_id)
|
||||||
|
if not package:
|
||||||
|
msg = _("Package id '{0}' is not found".format(package_id))
|
||||||
|
LOG.error(msg)
|
||||||
|
raise exc.HTTPNotFound(msg)
|
||||||
|
return package
|
||||||
|
|
||||||
|
|
||||||
|
def _authorize_package(package, context, allow_public=False):
|
||||||
|
if context.is_admin:
|
||||||
|
return
|
||||||
|
|
||||||
|
if package.owner_id != context.tenant:
|
||||||
|
if not allow_public:
|
||||||
|
msg = _("Package '{0}' is not owned by "
|
||||||
|
"tenant '{1}'".format(package.id, context.tenant))
|
||||||
|
LOG.error(msg)
|
||||||
|
raise exc.HTTPForbidden(msg)
|
||||||
|
if not package.is_public:
|
||||||
|
msg = _("Package '{0}' is not public and not owned by "
|
||||||
|
"tenant '{1}' ".format(package.id, context.tenant))
|
||||||
|
LOG.error(msg)
|
||||||
|
raise exc.HTTPForbidden(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def package_get(package_id, context):
|
||||||
|
"""
|
||||||
|
Return package details
|
||||||
|
:param package_id: ID of a package, string
|
||||||
|
:returns: detailed information about package, dict
|
||||||
|
"""
|
||||||
|
session = db_session.get_session()
|
||||||
|
package = _package_get(package_id, session)
|
||||||
|
_authorize_package(package, context, allow_public=True)
|
||||||
|
return package
|
||||||
|
|
||||||
|
|
||||||
|
def _get_categories(category_names):
|
||||||
|
"""
|
||||||
|
Return existing category objects or raise an exception
|
||||||
|
:param category_names: name of categories to associate with package, list
|
||||||
|
:returns: list of Category objects to associate with package, list
|
||||||
|
"""
|
||||||
|
categories = []
|
||||||
|
for ctg_name in category_names:
|
||||||
|
ctg_obj = get_category_by_name(ctg_name)
|
||||||
|
if not ctg_obj:
|
||||||
|
# it's not allowed to specify non-existent categories
|
||||||
|
raise exc.HTTPBadRequest(
|
||||||
|
"Category '{name}' doesn't exist".format(name=ctg_name))
|
||||||
|
categories.append(ctg_obj)
|
||||||
|
return categories
|
||||||
|
|
||||||
|
|
||||||
|
def _get_tags(tag_names):
|
||||||
|
"""
|
||||||
|
Return existing tags object or create new ones
|
||||||
|
:param tag_names: name of tags to associate with package, list
|
||||||
|
:returns: list of Tag objects to associate with package, list
|
||||||
|
"""
|
||||||
|
tags = []
|
||||||
|
if tag_names:
|
||||||
|
for tag_name in tag_names:
|
||||||
|
tag_obj = get_tag_by_name(tag_name)
|
||||||
|
if tag_obj:
|
||||||
|
tags.append(tag_obj)
|
||||||
|
else:
|
||||||
|
tag_record = models.Tag(name=tag_name)
|
||||||
|
tags.append(tag_record)
|
||||||
|
return tags
|
||||||
|
|
||||||
|
|
||||||
|
def _do_replace(package, change):
|
||||||
|
path = change['path'][0]
|
||||||
|
value = change['value']
|
||||||
|
calculate = {'categories': _get_categories,
|
||||||
|
'tags': _get_tags}
|
||||||
|
if path in ('categories', 'tags'):
|
||||||
|
existing_items = getattr(package, path)
|
||||||
|
|
||||||
|
duplicates = list(set(i.name for i in existing_items) & set(value))
|
||||||
|
unique_values = [x for x in value if x not in duplicates]
|
||||||
|
items_to_replace = calculate[path](unique_values)
|
||||||
|
|
||||||
|
# NOTE(efedorova): Replacing duplicate entities is not allowed,
|
||||||
|
# so need to remove anything, but duplicates
|
||||||
|
# and append everything but duplicates
|
||||||
|
for item in list(existing_items):
|
||||||
|
if item.name not in duplicates:
|
||||||
|
existing_items.remove(item)
|
||||||
|
|
||||||
|
existing_items.extend(items_to_replace)
|
||||||
|
else:
|
||||||
|
setattr(package, path, value)
|
||||||
|
|
||||||
|
return package
|
||||||
|
|
||||||
|
|
||||||
|
def _do_add(package, change):
|
||||||
|
# Only categories and tags support addition
|
||||||
|
path = change['path'][0]
|
||||||
|
value = change['value']
|
||||||
|
|
||||||
|
calculate = {'categories': _get_categories,
|
||||||
|
'tags': _get_tags}
|
||||||
|
items_to_add = calculate[path](value)
|
||||||
|
for item in items_to_add:
|
||||||
|
try:
|
||||||
|
getattr(package, path).append(item)
|
||||||
|
except AssertionError:
|
||||||
|
msg = _('One of the specified {0} is already '
|
||||||
|
'associated with a package. Doing nothing.')
|
||||||
|
LOG.warning(msg.format(path))
|
||||||
|
return package
|
||||||
|
|
||||||
|
|
||||||
|
def _do_remove(package, change):
|
||||||
|
# Only categories and tags support removal
|
||||||
|
def find(seq, predicate):
|
||||||
|
for elt in seq:
|
||||||
|
if predicate(elt):
|
||||||
|
return elt
|
||||||
|
|
||||||
|
path = change['path'][0]
|
||||||
|
values = change['value']
|
||||||
|
|
||||||
|
current_values = getattr(package, path)
|
||||||
|
for value in values:
|
||||||
|
if value not in [i.name for i in current_values]:
|
||||||
|
msg = _("Value '{0}' of property '{1}' "
|
||||||
|
"does not exist.").format(value, path)
|
||||||
|
LOG.error(msg)
|
||||||
|
raise exc.HTTPNotFound(msg)
|
||||||
|
if path == 'categories' and len(current_values) == 1:
|
||||||
|
msg = _("At least one category should be assigned to the package")
|
||||||
|
LOG.error(msg)
|
||||||
|
raise exc.HTTPBadRequest(msg)
|
||||||
|
item_to_remove = find(current_values, lambda i: i.name == value)
|
||||||
|
current_values.remove(item_to_remove)
|
||||||
|
return package
|
||||||
|
|
||||||
|
|
||||||
|
def package_update(pkg_id, changes, context):
|
||||||
|
"""
|
||||||
|
Update package information
|
||||||
|
:param changes: parameters to update
|
||||||
|
:returns: detailed information about new package, dict
|
||||||
|
"""
|
||||||
|
|
||||||
|
operation_methods = {'add': _do_add,
|
||||||
|
'replace': _do_replace,
|
||||||
|
'remove': _do_remove}
|
||||||
|
session = db_session.get_session()
|
||||||
|
pkg = _package_get(pkg_id, session)
|
||||||
|
_authorize_package(pkg, context)
|
||||||
|
|
||||||
|
for change in changes:
|
||||||
|
pkg = operation_methods[change['op']](pkg, change)
|
||||||
|
|
||||||
|
with session.begin():
|
||||||
|
session.add(pkg)
|
||||||
|
return pkg
|
@ -257,6 +257,20 @@ class Package(BASE, ModelBase):
|
|||||||
lazy='joined')
|
lazy='joined')
|
||||||
class_definition = sa_orm.relationship("Class")
|
class_definition = sa_orm.relationship("Class")
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
d = self.__dict__.copy()
|
||||||
|
not_serializable = ['_sa_instance_state',
|
||||||
|
'archive',
|
||||||
|
'logo',
|
||||||
|
'ui_definition']
|
||||||
|
nested_objects = ['categories', 'tags']
|
||||||
|
for key in not_serializable:
|
||||||
|
if key in d.keys():
|
||||||
|
del d[key]
|
||||||
|
for key in nested_objects:
|
||||||
|
d[key] = [a.name for a in d.get(key)]
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
class Category(BASE, ModelBase):
|
class Category(BASE, ModelBase):
|
||||||
"""
|
"""
|
||||||
|
@ -63,7 +63,7 @@ def _create_facade_lazily():
|
|||||||
|
|
||||||
if _FACADE is None:
|
if _FACADE is None:
|
||||||
_FACADE = db_session.EngineFacade(
|
_FACADE = db_session.EngineFacade(
|
||||||
CONF.database.connection,
|
CONF.database.connection, sqlite_fk=True,
|
||||||
**dict(CONF.database.iteritems())
|
**dict(CONF.database.iteritems())
|
||||||
)
|
)
|
||||||
return _FACADE
|
return _FACADE
|
||||||
|
@ -22,6 +22,8 @@ eventlet.patcher.monkey_patch(all=False, socket=True)
|
|||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
import errno
|
import errno
|
||||||
|
import re
|
||||||
|
import jsonschema
|
||||||
import socket
|
import socket
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
@ -35,6 +37,7 @@ import webob.exc
|
|||||||
from xml.dom import minidom
|
from xml.dom import minidom
|
||||||
from xml.parsers import expat
|
from xml.parsers import expat
|
||||||
|
|
||||||
|
from muranoapi.api.v1 import schemas
|
||||||
from muranoapi.openstack.common import exception
|
from muranoapi.openstack.common import exception
|
||||||
from muranoapi.openstack.common.gettextutils import _
|
from muranoapi.openstack.common.gettextutils import _
|
||||||
from muranoapi.openstack.common import jsonutils
|
from muranoapi.openstack.common import jsonutils
|
||||||
@ -108,7 +111,7 @@ class Service(service.Service):
|
|||||||
eventlet.sleep(0.1)
|
eventlet.sleep(0.1)
|
||||||
if not sock:
|
if not sock:
|
||||||
raise RuntimeError(_("Could not bind to %(host)s:%(port)s "
|
raise RuntimeError(_("Could not bind to %(host)s:%(port)s "
|
||||||
"after trying for 30 seconds") %
|
"after trying for 30 seconds") %
|
||||||
{'host': host, 'port': port})
|
{'host': host, 'port': port})
|
||||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
# sockets can hang around forever without keepalive
|
# sockets can hang around forever without keepalive
|
||||||
@ -233,7 +236,6 @@ class Debug(Middleware):
|
|||||||
|
|
||||||
|
|
||||||
class Router(object):
|
class Router(object):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
WSGI middleware that maps incoming requests to WSGI apps.
|
WSGI middleware that maps incoming requests to WSGI apps.
|
||||||
"""
|
"""
|
||||||
@ -292,7 +294,9 @@ class Router(object):
|
|||||||
class Request(webob.Request):
|
class Request(webob.Request):
|
||||||
"""Add some Openstack API-specific logic to the base webob.Request."""
|
"""Add some Openstack API-specific logic to the base webob.Request."""
|
||||||
|
|
||||||
default_request_content_types = ('application/json', 'application/xml')
|
default_request_content_types = ('application/json',
|
||||||
|
'application/xml',
|
||||||
|
'application/murano-packages-json-patch')
|
||||||
default_accept_types = ('application/json', 'application/xml')
|
default_accept_types = ('application/json', 'application/xml')
|
||||||
default_accept_type = 'application/json'
|
default_accept_type = 'application/json'
|
||||||
|
|
||||||
@ -350,6 +354,7 @@ class Resource(object):
|
|||||||
may raise a webob.exc exception or return a dict, which will be
|
may raise a webob.exc exception or return a dict, which will be
|
||||||
serialized by requested content type.
|
serialized by requested content type.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, controller, deserializer=None, serializer=None):
|
def __init__(self, controller, deserializer=None, serializer=None):
|
||||||
"""
|
"""
|
||||||
:param controller: object that implement methods created by routes lib
|
:param controller: object that implement methods created by routes lib
|
||||||
@ -452,11 +457,11 @@ class JSONDictSerializer(DictSerializer):
|
|||||||
_dtime = obj - datetime.timedelta(microseconds=obj.microsecond)
|
_dtime = obj - datetime.timedelta(microseconds=obj.microsecond)
|
||||||
return _dtime.isoformat()
|
return _dtime.isoformat()
|
||||||
return unicode(obj)
|
return unicode(obj)
|
||||||
|
|
||||||
return jsonutils.dumps(data, default=sanitizer)
|
return jsonutils.dumps(data, default=sanitizer)
|
||||||
|
|
||||||
|
|
||||||
class XMLDictSerializer(DictSerializer):
|
class XMLDictSerializer(DictSerializer):
|
||||||
|
|
||||||
def __init__(self, metadata=None, xmlns=None):
|
def __init__(self, metadata=None, xmlns=None):
|
||||||
"""
|
"""
|
||||||
:param metadata: information needed to deserialize xml into
|
:param metadata: information needed to deserialize xml into
|
||||||
@ -627,6 +632,7 @@ class RequestDeserializer(object):
|
|||||||
self.body_deserializers = {
|
self.body_deserializers = {
|
||||||
'application/xml': XMLDeserializer(),
|
'application/xml': XMLDeserializer(),
|
||||||
'application/json': JSONDeserializer(),
|
'application/json': JSONDeserializer(),
|
||||||
|
'application/murano-packages-json-patch': JSONPatchDeserializer()
|
||||||
}
|
}
|
||||||
self.body_deserializers.update(body_deserializers or {})
|
self.body_deserializers.update(body_deserializers or {})
|
||||||
|
|
||||||
@ -718,7 +724,6 @@ class TextDeserializer(ActionDispatcher):
|
|||||||
|
|
||||||
|
|
||||||
class JSONDeserializer(TextDeserializer):
|
class JSONDeserializer(TextDeserializer):
|
||||||
|
|
||||||
def _from_json(self, datastring):
|
def _from_json(self, datastring):
|
||||||
try:
|
try:
|
||||||
return jsonutils.loads(datastring)
|
return jsonutils.loads(datastring)
|
||||||
@ -730,8 +735,142 @@ class JSONDeserializer(TextDeserializer):
|
|||||||
return {'body': self._from_json(datastring)}
|
return {'body': self._from_json(datastring)}
|
||||||
|
|
||||||
|
|
||||||
class XMLDeserializer(TextDeserializer):
|
class JSONPatchDeserializer(TextDeserializer):
|
||||||
|
allowed_operations = {"categories": ["add", "replace", "remove"],
|
||||||
|
"tags": ["add", "replace", "remove"],
|
||||||
|
"is_public": ["replace"],
|
||||||
|
"enabled": ["replace"],
|
||||||
|
"description": ["replace"],
|
||||||
|
"name": ["replace"]}
|
||||||
|
|
||||||
|
def _from_json_patch(self, datastring):
|
||||||
|
try:
|
||||||
|
operations = jsonutils.loads(datastring)
|
||||||
|
except ValueError:
|
||||||
|
msg = _("cannot understand JSON")
|
||||||
|
raise exception.MalformedRequestBody(reason=msg)
|
||||||
|
|
||||||
|
changes = []
|
||||||
|
for raw_change in operations:
|
||||||
|
if not isinstance(raw_change, dict):
|
||||||
|
msg = _('Operations must be JSON objects.')
|
||||||
|
raise webob.exc.HTTPBadRequest(explanation=msg)
|
||||||
|
|
||||||
|
(op, path) = self._parse_json_schema_change(raw_change)
|
||||||
|
|
||||||
|
self._validate_path(path)
|
||||||
|
change = {'op': op, 'path': path}
|
||||||
|
|
||||||
|
change['value'] = self._get_change_value(raw_change, op)
|
||||||
|
self._validate_change(change)
|
||||||
|
|
||||||
|
changes.append(change)
|
||||||
|
return changes
|
||||||
|
|
||||||
|
def _get_change_value(self, raw_change, op):
|
||||||
|
if 'value' not in raw_change:
|
||||||
|
msg = _('Operation "%s" requires a member named "value".')
|
||||||
|
raise webob.exc.HTTPBadRequest(explanation=msg % op)
|
||||||
|
return raw_change['value']
|
||||||
|
|
||||||
|
def _get_change_operation(self, raw_change):
|
||||||
|
try:
|
||||||
|
return raw_change['op']
|
||||||
|
except KeyError:
|
||||||
|
msg = _("Unable to find '%s' in JSON Schema change") % 'op'
|
||||||
|
raise webob.exc.HTTPBadRequest(explanation=msg)
|
||||||
|
|
||||||
|
def _get_change_path(self, raw_change):
|
||||||
|
try:
|
||||||
|
return raw_change['path']
|
||||||
|
except KeyError:
|
||||||
|
msg = _("Unable to find '%s' in JSON Schema change") % 'path'
|
||||||
|
raise webob.exc.HTTPBadRequest(explanation=msg)
|
||||||
|
|
||||||
|
def _validate_change(self, change):
|
||||||
|
change_path = change['path'][0]
|
||||||
|
change_op = change['op']
|
||||||
|
allowed_methods = self.allowed_operations.get(change_path)
|
||||||
|
|
||||||
|
if not allowed_methods:
|
||||||
|
msg = _("Attribute '{0}' is invalid").format(change_path)
|
||||||
|
raise webob.exc.HTTPForbidden(explanation=unicode(msg))
|
||||||
|
|
||||||
|
if change_op not in allowed_methods:
|
||||||
|
msg = _("Method '{method}' is not allowed for a path with name "
|
||||||
|
"'{name}'. Allowed operations are: '{ops}'").format(
|
||||||
|
method=change_op,
|
||||||
|
name=change_path,
|
||||||
|
ops=', '.join(allowed_methods))
|
||||||
|
|
||||||
|
raise webob.exc.HTTPForbidden(explanation=unicode(msg))
|
||||||
|
|
||||||
|
property_to_update = {change_path: change['value']}
|
||||||
|
|
||||||
|
try:
|
||||||
|
jsonschema.validate(property_to_update, schemas.PKG_UPDATE_SCHEMA)
|
||||||
|
except jsonschema.ValidationError as e:
|
||||||
|
LOG.exception(e)
|
||||||
|
raise webob.exc.HTTPBadRequest(explanation=e.message)
|
||||||
|
|
||||||
|
def _decode_json_pointer(self, pointer):
|
||||||
|
"""Parse a json pointer.
|
||||||
|
|
||||||
|
Json Pointers are defined in
|
||||||
|
http://tools.ietf.org/html/draft-pbryan-zyp-json-pointer .
|
||||||
|
The pointers use '/' for separation between object attributes, such
|
||||||
|
that '/A/B' would evaluate to C in {"A": {"B": "C"}}. A '/'
|
||||||
|
character
|
||||||
|
in an attribute name is encoded as "~1" and a '~' character is
|
||||||
|
encoded
|
||||||
|
as "~0".
|
||||||
|
"""
|
||||||
|
self._validate_json_pointer(pointer)
|
||||||
|
ret = []
|
||||||
|
for part in pointer.lstrip('/').split('/'):
|
||||||
|
ret.append(part.replace('~1', '/').replace('~0', '~').strip())
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_json_pointer(self, pointer):
|
||||||
|
"""Validate a json pointer.
|
||||||
|
|
||||||
|
Only limited form of json pointers is accepted.
|
||||||
|
"""
|
||||||
|
if not pointer.startswith('/'):
|
||||||
|
msg = _('Pointer `%s` does not start with "/".') % pointer
|
||||||
|
raise webob.exc.HTTPBadRequest(explanation=msg)
|
||||||
|
if re.search('/\s*?/', pointer[1:]):
|
||||||
|
msg = _('Pointer `%s` contains adjacent "/".') % pointer
|
||||||
|
raise webob.exc.HTTPBadRequest(explanation=msg)
|
||||||
|
if len(pointer) > 1 and pointer.endswith('/'):
|
||||||
|
msg = _('Pointer `%s` end with "/".') % pointer
|
||||||
|
raise webob.exc.HTTPBadRequest(explanation=msg)
|
||||||
|
if pointer[1:].strip() == '/':
|
||||||
|
msg = _('Pointer `%s` does not contains valid token.') % pointer
|
||||||
|
raise webob.exc.HTTPBadRequest(explanation=msg)
|
||||||
|
if re.search('~[^01]', pointer) or pointer.endswith('~'):
|
||||||
|
msg = _('Pointer `%s` contains "~" not part of'
|
||||||
|
' a recognized escape sequence.') % pointer
|
||||||
|
raise webob.exc.HTTPBadRequest(explanation=msg)
|
||||||
|
|
||||||
|
def _parse_json_schema_change(self, raw_change):
|
||||||
|
op = self._get_change_operation(raw_change)
|
||||||
|
path = self._get_change_path(raw_change)
|
||||||
|
|
||||||
|
path_list = self._decode_json_pointer(path)
|
||||||
|
return op, path_list
|
||||||
|
|
||||||
|
def _validate_path(self, path):
|
||||||
|
if len(path) > 1:
|
||||||
|
msg = _('Nested paths are not allowed')
|
||||||
|
raise webob.exc.HTTPBadRequest(explanation=msg)
|
||||||
|
|
||||||
|
def default(self, datastring):
|
||||||
|
return {'body': self._from_json_patch(datastring)}
|
||||||
|
|
||||||
|
|
||||||
|
class XMLDeserializer(TextDeserializer):
|
||||||
def __init__(self, metadata=None):
|
def __init__(self, metadata=None):
|
||||||
"""
|
"""
|
||||||
:param metadata: information needed to deserialize xml into
|
:param metadata: information needed to deserialize xml into
|
||||||
|
Loading…
Reference in New Issue
Block a user