glare/glare/api/v1/resource.py

633 lines
24 KiB
Python

# Copyright (c) 2016 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.
"""WSGI Resource definition for Glare. Defines Glare API and serialization/
deserialization of incoming requests."""
import json
import jsonpatch
import jsonschema
from oslo_config import cfg
from oslo_log import log as logging
from oslo_utils import encodeutils
import six
from six.moves import http_client
import six.moves.urllib.parse as urlparse
from glare.api.v1 import api_versioning
from glare.common import exception as exc
from glare.common import wsgi
from glare import engine
from glare.i18n import _
LOG = logging.getLogger(__name__)
CONF = cfg.CONF
list_configs = [
cfg.IntOpt('default_api_limit', default=25,
help=_('Default value for the number of items returned by a '
'request if not specified explicitly in the request')),
cfg.IntOpt('max_api_limit', default=1000,
help=_('Maximum permissible number of items that could be '
'returned by a request')),
]
CONF.register_opts(list_configs)
supported_versions = api_versioning.VersionedResource.supported_versions
QUOTA_SCHEMA = {
'type': 'object',
'properties': {
'quota_name': {
u'maxLength': 255,
u'minLength': 1,
u'pattern': u'^[^:]*:?[^:]*$', # can have only 1 or 0 ':'
u'type': u'string'},
'quota_value': {'type': 'integer', u'minimum': -1},
},
'required': ['quota_name', 'quota_value']
}
QUOTA_INPUT_SCHEMA = {
"$schema": "http://json-schema.org/draft-04/schema#",
"items": {
"properties": {
"project_id": {
u'maxLength': 255,
u'minLength': 1,
"type": "string"
},
"project_quotas": {
"items": QUOTA_SCHEMA,
"type": "array"
}
},
"type": "object",
"required": ["project_id", "project_quotas"]
},
"type": "array"
}
class RequestDeserializer(api_versioning.VersionedResource,
wsgi.JSONRequestDeserializer):
"""Glare deserializer for incoming webob requests.
Deserializer checks and converts incoming request into a bunch of Glare
primitives. So other service components don't work with requests at all.
Deserializer also performs primary API validation without any knowledge
about concrete artifact type structure.
"""
ALLOWED_LOCATION_TYPES = ('external', 'internal')
@staticmethod
def _get_content_type(req, expected=None):
"""Determine content type of the request body."""
if "Content-Type" not in req.headers:
msg = _("Content-Type must be specified.")
LOG.error(msg)
raise exc.BadRequest(msg)
content_type = req.content_type
if expected is not None and content_type not in expected:
msg = (_('Invalid content type: %(ct)s. Expected: %(exp)s') %
{'ct': content_type, 'exp': ', '.join(expected)})
raise exc.UnsupportedMediaType(message=msg)
return content_type
@staticmethod
def _get_content_length(req):
"""Determine content length of the request body."""
if req.content_length is None:
return
try:
content_length = int(req.content_length)
if content_length < 0:
raise ValueError
except ValueError:
msg = _("Content-Length must be a non negative integer.")
LOG.error(msg)
raise exc.BadRequest(msg)
return content_length
def _get_request_body(self, req):
"""Get request json body and convert it to python structures."""
return self.from_json(req.body)
@supported_versions(min_ver='1.0')
def create(self, req):
self._get_content_type(req, expected=['application/json'])
body = self._get_request_body(req)
if not isinstance(body, dict):
msg = _("Dictionary expected as body value. Got %s.") % type(body)
raise exc.BadRequest(msg)
return {'values': body}
@supported_versions(min_ver='1.0')
def list(self, req):
params = req.params.copy()
marker = params.pop('marker', None)
query_params = {}
# step 1 - apply marker to query if exists
if marker is not None:
query_params['marker'] = marker
# step 2 - apply limit (if exists OR setup default limit)
limit = params.pop('limit', CONF.default_api_limit)
try:
limit = int(limit)
except ValueError:
msg = _("Limit param must be an integer.")
raise exc.BadRequest(message=msg)
if limit < 0:
msg = _("Limit param must be positive.")
raise exc.BadRequest(message=msg)
query_params['limit'] = min(CONF.max_api_limit, limit)
# step 3 - parse sort parameters
if 'sort' in params:
sort = []
for sort_param in params.pop('sort').strip().split(','):
key, _sep, direction = sort_param.partition(':')
if direction and direction not in ('asc', 'desc'):
raise exc.BadRequest('Sort direction must be one of '
'["asc", "desc"]. Got %s direction'
% direction)
sort.append((key, direction or 'desc'))
query_params['sort'] = sort
# step 4 - parse filter parameters
filters = []
for fname, fval in params.items():
if fname == 'version' and fval == 'latest':
query_params['latest'] = True
else:
filters.append((fname, fval))
query_params['filters'] = filters
return query_params
@supported_versions(min_ver='1.0')
def update(self, req):
self._get_content_type(
req, expected=['application/json-patch+json'])
body = self._get_request_body(req)
patch = jsonpatch.JsonPatch(body)
try:
# Initially patch object doesn't validate input. It's only checked
# when we call get operation on each method
tuple(map(patch._get_operation, patch.patch))
except (jsonpatch.InvalidJsonPatch, TypeError, AttributeError,
jsonpatch.JsonPointerException):
msg = _("Json Patch body is malformed")
raise exc.BadRequest(msg)
return {'patch': patch}
@supported_versions(min_ver='1.0')
def upload_blob(self, req):
content_type = self._get_content_type(req)
content_length = self._get_content_length(req)
if content_type == ('application/vnd+openstack.glare-custom-location'
'+json'):
data = self._get_request_body(req)
if 'url' not in data:
msg = _("url is required when specifying external location. "
"Cannot find 'url' in request body: %s") % str(data)
raise exc.BadRequest(msg)
location_type = data.get('location_type', 'external')
if location_type not in self.ALLOWED_LOCATION_TYPES:
msg = (_("Incorrect location type '%(location_type)s'. It "
"must be one of the following %(allowed)s") %
{'location_type': location_type,
'allowed': ', '.join(self.ALLOWED_LOCATION_TYPES)})
raise exc.BadRequest(msg)
if location_type == 'external':
url = data.get('url')
if not url.startswith('http'):
msg = _("Url '%s' doesn't have http(s) scheme") % url
raise exc.BadRequest(msg)
if 'md5' not in data:
msg = _("Incorrect blob metadata. MD5 must be specified "
"for external location in artifact blob.")
raise exc.BadRequest(msg)
else:
data = req.body_file
if self.is_valid_encoding(req) and self.is_valid_method(req):
req.is_body_readable = True
return {'data': data,
'content_type': content_type,
'content_length': content_length}
@supported_versions(min_ver='1.1')
def set_quotas(self, req):
self._get_content_type(req, expected=['application/json'])
body = self._get_request_body(req)
try:
jsonschema.validate(body, QUOTA_INPUT_SCHEMA)
except jsonschema.exceptions.ValidationError as e:
raise exc.BadRequest(e)
values = {}
for item in body:
project_id = item['project_id']
values[project_id] = {}
for quota in item['project_quotas']:
values[project_id][quota['quota_name']] = quota['quota_value']
return {'values': values}
# TODO(mfedosin) add pagination to list of quotas
def log_request_progress(f):
def log_decorator(self, req, *args, **kwargs):
LOG.debug("Request %(request_id)s for %(api_method)s successfully "
"deserialized. Pass request parameters to Engine",
{'request_id': req.context.request_id,
'api_method': f.__name__})
result = f(self, req, *args, **kwargs)
LOG.info(
"Request %(request_id)s for artifact %(api_method)s "
"successfully executed.", {'request_id': req.context.request_id,
'api_method': f.__name__})
return result
return log_decorator
class ArtifactsController(api_versioning.VersionedResource):
"""API controller for Glare Artifacts.
Artifact Controller prepares incoming data for Glare Engine and redirects
data to the appropriate engine method. Once the response data is returned
from the engine Controller passes it next to Response Serializer.
"""
def __init__(self):
self.engine = engine.Engine()
@supported_versions(min_ver='1.0')
@log_request_progress
def list_type_schemas(self, req):
"""List of detailed descriptions of enabled artifact types.
:param req: user request
:return: list of json-schemas of all enabled artifact types.
"""
return self.engine.show_type_schemas(req.context)
@supported_versions(min_ver='1.0')
@log_request_progress
def show_type_schema(self, req, type_name):
"""Get detailed artifact type description.
:param req: user request
:param type_name: artifact type name
:return: json-schema representation of artifact type
"""
type_schema = self.engine.show_type_schemas(req.context, type_name)
return {type_name: type_schema}
@supported_versions(min_ver='1.0')
@log_request_progress
def create(self, req, type_name, values):
"""Create artifact record in Glare.
:param req: user request
:param type_name: artifact type name
:param values: dict with artifact fields
:return: definition of created artifact
"""
if req.context.project_id is None or req.context.read_only:
msg = _("It's forbidden to anonymous users to create artifacts.")
raise exc.Forbidden(msg)
if not values.get('name'):
msg = _("Name must be specified at creation.")
raise exc.BadRequest(msg)
for field in ('visibility', 'status'):
if field in values:
msg = _("%s is not allowed in a request at creation.") % field
raise exc.BadRequest(msg)
return self.engine.create(req.context, type_name, values)
@supported_versions(min_ver='1.0')
@log_request_progress
def update(self, req, type_name, artifact_id, patch):
"""Update artifact record in Glare.
:param req: User request
:param type_name: Artifact type name
:param artifact_id: id of artifact to update
:param patch: json patch with artifact changes
:return: definition of updated artifact
"""
return self.engine.save(req.context, type_name, artifact_id, patch)
@supported_versions(min_ver='1.0')
@log_request_progress
def delete(self, req, type_name, artifact_id):
"""Delete artifact from Glare.
:param req: User request
:param type_name: Artifact type name
:param artifact_id: id of artifact to delete
"""
return self.engine.delete(req.context, type_name, artifact_id)
@supported_versions(min_ver='1.0')
@log_request_progress
def show(self, req, type_name, artifact_id):
"""Show detailed artifact info.
:param req: User request
:param type_name: Artifact type name
:param artifact_id: id of artifact to show
:return: definition of requested artifact
"""
return self.engine.show(req.context, type_name, artifact_id)
@supported_versions(min_ver='1.0')
@log_request_progress
def list(self, req, type_name, filters=None, marker=None, limit=None,
sort=None, latest=False):
"""List available artifacts.
:param req: User request
:param type_name: Artifact type name
:param filters: filters that need to be applied to artifact
:param marker: the artifact that considered as begin of the list
so all artifacts before marker (including marker itself) will not be
added to artifact list
:param limit: maximum number of items in list
:param sort: sorting options
:param latest: flag that indicates, that only artifacts with highest
versions should be returned in output
:return: list of requested artifact definitions
"""
artifacts_data = self.engine.list(req.context, type_name, filters,
marker, limit, sort, latest)
artifacts = artifacts_data["artifacts"]
result = {'artifacts': artifacts,
'type_name': type_name,
'total_count': artifacts_data['total_count']}
if len(artifacts) != 0 and len(artifacts) == limit:
result['next_marker'] = artifacts[-1]['id']
return result
@staticmethod
def _parse_blob_path(blob_path):
field_name, _sep, blob_key = blob_path.partition('/')
if not blob_key:
blob_key = None
return field_name, blob_key
@supported_versions(min_ver='1.0')
@log_request_progress
def upload_blob(self, req, type_name, artifact_id, blob_path, data,
content_type, content_length=None):
"""Upload blob into Glare repo.
:param req: User request
:param type_name: Artifact type name
:param artifact_id: id of artifact where to perform upload
:param blob_path: path to artifact blob
:param data: blob payload
:param content_type: data content-type
:param content_length: amount of data user wants to upload
:return: definition of requested artifact with uploaded blob
"""
field_name, blob_key = self._parse_blob_path(blob_path)
if content_type == ('application/vnd+openstack.glare-custom-location'
'+json'):
url = data.pop('url')
return self.engine.add_blob_location(
req.context, type_name, artifact_id, field_name, url, data,
blob_key)
else:
return self.engine.upload_blob(
req.context, type_name, artifact_id, field_name, data,
content_type, content_length, blob_key)
@supported_versions(min_ver='1.0')
@log_request_progress
def download_blob(self, req, type_name, artifact_id, blob_path):
"""Download blob data from Artifact.
:param req: User request
:param type_name: artifact type name
:param artifact_id: id of artifact from where to perform download
:param blob_path: path to artifact blob
:return: requested blob data
"""
field_name, blob_key = self._parse_blob_path(blob_path)
data, meta = self.engine.download_blob(
req.context, type_name, artifact_id, field_name, blob_key)
result = {'data': data, 'meta': meta}
return result
@supported_versions(min_ver='1.1')
@log_request_progress
def delete_external_blob(self, req, type_name, artifact_id, blob_path):
"""Delete blob with external location from Glare repo.
:param req: User request
:param type_name: Artifact type name
:param artifact_id: id of artifact with the blob to delete
:param blob_path: path to artifact blob
"""
field_name, blob_key = self._parse_blob_path(blob_path)
return self.engine.delete_external_blob(
req.context, type_name, artifact_id, field_name, blob_key)
@supported_versions(min_ver='1.1')
@log_request_progress
def set_quotas(self, req, values):
"""Set quota records in Glare.
:param req: user request
:param values: list with quota values to set
"""
self.engine.set_quotas(req.context, values)
@supported_versions(min_ver='1.1')
@log_request_progress
def list_all_quotas(self, req):
"""Get detailed info about all available quotas.
:param req: user request
:return: definition of requested quotas for the project
"""
return self.engine.list_all_quotas(req.context)
@supported_versions(min_ver='1.1')
@log_request_progress
def list_project_quotas(self, req, project_id=None):
"""Get detailed info about project quotas.
:param req: user request
:param project_id: id of the project for which to show quotas
:return: definition of requested quotas for the project
"""
return self.engine.list_project_quotas(req.context, project_id)
class ResponseSerializer(api_versioning.VersionedResource,
wsgi.JSONResponseSerializer):
"""Glare serializer for outgoing responses.
Converts data received from the engine to WSGI responses. It also
specifies proper response status and content type as declared in the API.
"""
@staticmethod
def _prepare_json_response(response, result,
content_type='application/json'):
body = json.dumps(result, ensure_ascii=False)
response.text = six.text_type(body)
response.content_type = content_type + '; charset=UTF-8'
def list_type_schemas(self, response, type_schemas):
self._prepare_json_response(response,
{'schemas': type_schemas},
content_type='application/schema+json')
def show_type_schema(self, response, type_schema):
self._prepare_json_response(response,
{'schemas': type_schema},
content_type='application/schema+json')
@supported_versions(min_ver='1.0')
def list_schemas(self, response, type_list):
self._prepare_json_response(response, {'types': type_list})
@supported_versions(min_ver='1.0')
def create(self, response, artifact):
self._prepare_json_response(response, artifact)
response.status_int = http_client.CREATED
@supported_versions(min_ver='1.0')
def show(self, response, artifact):
self._prepare_json_response(response, artifact)
@supported_versions(min_ver='1.0')
def update(self, response, artifact):
self._prepare_json_response(response, artifact)
@supported_versions(min_ver='1.0')
def list(self, response, af_list):
params = dict(response.request.params)
params.pop('marker', None)
encode_params = {}
for key, value in params.items():
encode_params[key] = encodeutils.safe_encode(value)
query = urlparse.urlencode(encode_params)
type_name = af_list['type_name']
body = {
'type_name': type_name,
'artifacts': af_list['artifacts'],
'first': '/artifacts/%s' % type_name,
'schema': '/schemas/%s' % type_name,
'total_count': af_list['total_count']
}
if query:
body['first'] = '%s?%s' % (body['first'], query)
if 'next_marker' in af_list:
params['marker'] = af_list['next_marker']
next_query = urlparse.urlencode(params)
body['next'] = '/artifacts/%s?%s' % (type_name, next_query)
self._prepare_json_response(response, body)
@supported_versions(min_ver='1.0')
def delete(self, response, result):
response.status_int = http_client.NO_CONTENT
@supported_versions(min_ver='1.0')
def upload_blob(self, response, artifact):
self._prepare_json_response(response, artifact)
@staticmethod
def _serialize_blob(response, result):
data, meta = result['data'], result['meta']
response.app_iter = iter(data)
response.headers['Content-Type'] = meta['content_type']
response.headers['Content-MD5'] = meta['md5']
response.headers['X-Openstack-Glare-Content-SHA1'] = meta['sha1']
response.headers['X-Openstack-Glare-Content-SHA256'] = meta['sha256']
response.content_length = str(meta['size'])
@staticmethod
def _serialize_location(response, result):
data, meta = result['data'], result['meta']
response.headers['Content-MD5'] = meta['md5']
response.headers['X-Openstack-Glare-Content-SHA1'] = meta['sha1']
response.headers['X-Openstack-Glare-Content-SHA256'] = meta['sha256']
response.location = data['url']
response.content_type = 'application/json'
response.status = http_client.MOVED_PERMANENTLY
response.content_length = 0
@supported_versions(min_ver='1.0')
def download_blob(self, response, result):
external = result['meta']['external']
if external:
self._serialize_location(response, result)
else:
self._serialize_blob(response, result)
@supported_versions(min_ver='1.1')
def delete_external_blob(self, response, result):
self._prepare_json_response(response, result)
@staticmethod
def _serialize_quota(quotas):
res = []
for project_id, project_quotas in quotas.items():
quota_list = []
for quota_name, quota_value in project_quotas.items():
quota_list.append({
'quota_name': quota_name,
'quota_value': quota_value,
})
res.append({
'project_id': project_id,
'project_quotas': quota_list
})
return res
@supported_versions(min_ver='1.1')
def list_all_quotas(self, response, quotas):
quotas['quotas'] = self._serialize_quota(quotas['quotas'])
self._prepare_json_response(response, quotas)
@supported_versions(min_ver='1.1')
def list_project_quotas(self, response, quotas):
quotas = self._serialize_quota(quotas)
self._prepare_json_response(response, quotas)
def create_resource():
"""Artifact resource factory method."""
deserializer = RequestDeserializer()
serializer = ResponseSerializer()
controller = ArtifactsController()
return wsgi.Resource(controller, deserializer, serializer)