Implement REST API.

Change-Id: I7b63c692f153d766ddd3ab261bdc0c1715934c0b
This commit is contained in:
Dina Belova 2013-09-02 19:47:26 +04:00
parent d8cd612d8e
commit 7b6cd50ecd
13 changed files with 756 additions and 13 deletions

14
climate/api/__init__.py Normal file
View File

@ -0,0 +1,14 @@
# Copyright (c) 2013 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.

121
climate/api/app.py Normal file
View File

@ -0,0 +1,121 @@
# Copyright (c) 2013 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.
import eventlet
import flask
from keystoneclient.middleware import auth_token
from oslo.config import cfg
from werkzeug import exceptions as werkzeug_exceptions
from climate.api import utils as api_utils
from climate.api import v1_0 as api_v1_0
from climate import context
from climate.openstack.common import log
from climate.openstack.common.middleware import debug
LOG = log.getLogger(__name__)
eventlet.monkey_patch(
os=True, select=True, socket=True, thread=True, time=True)
opts = [
cfg.StrOpt('os_auth_protocol',
default='http',
help='Protocol used to access OpenStack Identity service'),
cfg.StrOpt('os_auth_host',
default='127.0.0.1',
help='IP or hostname of machine on which OpenStack Identity '
'service is located'),
cfg.StrOpt('os_auth_port',
default='35357',
help='Port of OpenStack Identity service'),
cfg.StrOpt('os_admin_username',
default='admin',
help='This OpenStack user is used to verify provided tokens. '
'The user must have admin role in <os_admin_tenant_name> '
'tenant'),
cfg.StrOpt('os_admin_password',
default='nova',
help='Password of the admin user'),
cfg.StrOpt('os_admin_tenant_name',
default='admin',
help='Name of tenant where the user is admin'),
cfg.StrOpt('os_auth_version',
default='v2.0',
help='By default use Keystone API v2.0.'),
]
CONF = cfg.CONF
CONF.register_opts(opts)
def make_json_error(ex):
if isinstance(ex, werkzeug_exceptions.HTTPException):
status_code = ex.code
description = ex.description
else:
status_code = 500
description = str(ex)
return api_utils.render({'error': status_code,
'error_message': description},
status=status_code)
def version_list():
return api_utils.render({
"versions": [
{"id": "v1.0", "status": "CURRENT"},
],
})
def teardown_request(_ex=None):
context.Context.clear()
def make_app():
"""App builder (wsgi).
Entry point for Climate REST API server.
"""
app = flask.Flask('climate.api')
app.route('/', methods=['GET'])(version_list)
app.teardown_request(teardown_request)
app.register_blueprint(api_v1_0.rest, url_prefix='/v1')
for code in werkzeug_exceptions.default_exceptions.iterkeys():
app.error_handler_spec[None][code] = make_json_error
if CONF.debug and not CONF.log_exchange:
LOG.debug('Logging of request/response exchange could be enabled using'
' flag --log-exchange')
if CONF.log_exchange:
app.wsgi_app = debug.Debug.factory(app.config)(app.wsgi_app)
app.wsgi_app = auth_token.filter_factory(
app.config,
auth_host=CONF.os_auth_host,
auth_port=CONF.os_auth_port,
auth_protocol=CONF.os_auth_protocol,
admin_user=CONF.os_admin_username,
admin_password=CONF.os_admin_password,
admin_tenant_name=CONF.os_admin_tenant_name,
auth_version=CONF.os_auth_version,
)(app.wsgi_app)
return app

28
climate/api/context.py Normal file
View File

@ -0,0 +1,28 @@
# Copyright (c) 2013 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 climate import context
def ctx_from_headers(headers):
return context.Context(
user_id=headers['X-User-Id'],
tenant_id=headers['X-Tenant-Id'],
auth_token=headers['X-Auth-Token'],
service_catalog=headers['X-Service-Catalog'],
user_name=headers['X-User-Name'],
tenant_name=headers['X-Tenant-Name'],
roles=map(unicode.strip, headers['X-Roles'].split(',')),
)

71
climate/api/service.py Normal file
View File

@ -0,0 +1,71 @@
# Copyright (c) 2013 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 climate.openstack.common import log as logging
LOG = logging.getLogger(__name__)
## Leases operations
def get_leases():
"""List all existing leases."""
pass
def create_lease(data):
"""Create new lease.
:param data: New lease characteristics.
:type data: dict
"""
pass
def get_lease(lease_id):
"""Get lease by its ID.
:param lease_id: ID of the lease in Climate DB.
:type lease_id: str
"""
pass
def update_lease(lease_id, data):
"""Update lease. Only name changing and prolonging may be proceeded.
:param lease_id: ID of the lease in Climate DB.
:type lease_id: str
:param data: New lease characteristics.
:type data: dict
"""
pass
def delete_lease(lease_id):
"""Delete specified lease.
:param lease_id: ID of the lease in Climate DB.
:type lease_id: str
"""
pass
## Plugins operations
def get_plugins():
"""List all possible plugins."""
pass

227
climate/api/utils.py Normal file
View File

@ -0,0 +1,227 @@
# Copyright (c) 2013 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.
import traceback
import flask
from werkzeug import datastructures
from climate.api import context
from climate import exceptions as ex
from climate.openstack.common.deprecated import wsgi
from climate.openstack.common import log as logging
LOG = logging.getLogger(__name__)
class Rest(flask.Blueprint):
"""REST helper class."""
def get(self, rule, status_code=200):
return self._mroute('GET', rule, status_code)
def post(self, rule, status_code=202):
return self._mroute('POST', rule, status_code)
def put(self, rule, status_code=202):
return self._mroute('PUT', rule, status_code)
def delete(self, rule, status_code=204):
return self._mroute('DELETE', rule, status_code)
def _mroute(self, methods, rule, status_code=None, **kw):
"""Route helper method."""
if type(methods) is str:
methods = [methods]
return self.route(rule, methods=methods, status_code=status_code, **kw)
def route(self, rule, **options):
"""Routes REST method and its params to the actual request."""
status = options.pop('status_code', None)
file_upload = options.pop('file_upload', False)
def decorator(func):
endpoint = options.pop('endpoint', func.__name__)
def handler(**kwargs):
LOG.debug("Rest.route.decorator.handler, kwargs=%s", kwargs)
_init_resp_type(file_upload)
# update status code
if status:
flask.request.status_code = status
if flask.request.method in ['POST', 'PUT']:
kwargs['data'] = request_data()
with context.ctx_from_headers(flask.request.headers):
try:
return func(**kwargs)
except ex.ClimateException as e:
return bad_request(e)
except Exception as e:
return internal_error(500, 'Internal Server Error', e)
self.add_url_rule(rule, endpoint, handler, **options)
self.add_url_rule(rule + '.json', endpoint, handler, **options)
return func
return decorator
RT_JSON = datastructures.MIMEAccept([("application/json", 1)])
def _init_resp_type(file_upload):
"""Extracts response content type."""
# get content type from Accept header
resp_type = flask.request.accept_mimetypes
# url /foo.json
if flask.request.path.endswith('.json'):
resp_type = RT_JSON
flask.request.resp_type = resp_type
# set file upload flag
flask.request.file_upload = file_upload
def render(result=None, response_type=None, status=None, **kwargs):
"""Render response to return."""
if not result:
result = {}
if type(result) is dict:
result.update(kwargs)
elif kwargs:
# can't merge kwargs into the non-dict res
abort_and_log(500, "Non-dict and non-empty kwargs passed to render.")
status_code = getattr(flask.request, 'status_code', None)
if status:
status_code = status
if not status_code:
status_code = 200
if not response_type:
response_type = getattr(flask.request, 'resp_type', RT_JSON)
serializer = None
if "application/json" in response_type:
response_type = RT_JSON
serializer = wsgi.JSONDictSerializer()
else:
abort_and_log(400, "Content type '%s' isn't supported" % response_type)
body = serializer.serialize(result)
response_type = str(response_type)
return flask.Response(response=body, status=status_code,
mimetype=response_type)
def request_data():
"""Method called to process POST and PUT REST methods."""
if hasattr(flask.request, 'parsed_data'):
return flask.request.parsed_data
if not flask.request.content_length > 0:
LOG.debug("Empty body provided in request")
return dict()
if flask.request.file_upload:
return flask.request.data
deserializer = None
content_type = flask.request.mimetype
if not content_type or content_type in RT_JSON:
deserializer = wsgi.JSONDeserializer()
else:
abort_and_log(400, "Content type '%s' isn't supported" % content_type)
# parsed request data to avoid unwanted re-parsings
parsed_data = deserializer.deserialize(flask.request.data)['body']
flask.request.parsed_data = parsed_data
return flask.request.parsed_data
def get_request_args():
return flask.request.args
def abort_and_log(status_code, descr, exc=None):
"""Process occurred errors."""
LOG.error("Request aborted with status code %s and message '%s'",
status_code, descr)
if exc is not None:
LOG.error(traceback.format_exc())
flask.abort(status_code, description=descr)
def render_error_message(error_code, error_message, error_name):
"""Render nice error message."""
message = {
"error_code": error_code,
"error_message": error_message,
"error_name": error_name
}
resp = render(message)
resp.status_code = error_code
return resp
def internal_error(status_code, descr, exc=None):
"""Called if internal error occurred."""
LOG.error("Request aborted with status code %s and message '%s'",
status_code, descr)
if exc is not None:
LOG.error(traceback.format_exc())
error_code = "INTERNAL_SERVER_ERROR"
if status_code == 501:
error_code = "NOT_IMPLEMENTED_ERROR"
return render_error_message(status_code, descr, error_code)
def bad_request(error):
"""Called if Climate exception occurred."""
error_code = 400
LOG.debug("Validation Error occurred: "
"error_code=%s, error_message=%s, error_name=%s",
error_code, error.message, error.code)
return render_error_message(error_code, error.message, error.code)
def not_found(error):
"""Called if object was not found."""
error_code = 404
LOG.debug("Not Found exception occurred: "
"error_code=%s, error_message=%s, error_name=%s",
error_code, error.message, error.code)
return render_error_message(error_code, error.message, error.code)

67
climate/api/v1_0.py Normal file
View File

@ -0,0 +1,67 @@
# Copyright (c) 2013 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 climate.api import service
from climate.api import utils as api_utils
from climate.api import validation
from climate.openstack.common import log as logging
LOG = logging.getLogger(__name__)
rest = api_utils.Rest('v1_0', __name__)
## Leases operations
@rest.get('/leases')
def leases_list():
"""List all existing leases."""
return api_utils.render(leases=service.get_leases())
@rest.post('/leases')
def leases_create(data):
"""Create new lease."""
return api_utils.render(lease=service.create_lease(data))
@rest.get('/leases/<lease_id>')
@validation.check_exists(service.get_lease, 'lease_id')
def leases_get(lease_id):
"""Get lease by its ID."""
return api_utils.render(lease=service.get_lease(lease_id))
@rest.put('/leases/<lease_id>')
@validation.check_exists(service.get_lease, 'lease_id')
def leases_update(lease_id, data):
"""Update lease. Only name changing and prolonging may be proceeded."""
return api_utils.render(lease=service.update_lease(lease_id, data))
@rest.delete('/leases/<lease_id>')
@validation.check_exists(service.get_lease, 'lease_id')
def leases_delete(lease_id):
"""Delete specified lease."""
service.delete_lease(lease_id)
return api_utils.render()
## Plugins operations
@rest.get('/plugins')
def plugins_list():
"""List all possible plugins."""
return api_utils.render(plugins=service.get_plugins())

62
climate/api/validation.py Normal file
View File

@ -0,0 +1,62 @@
# Copyright (c) 2013 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.
import functools
import six
from climate.api import utils as api_utils
from climate import exceptions
def check_exists(get_function, object_id=None, **get_args):
"""Check object exists.
:param get_function: Method to call to get object.
:type get_function: function
:param object_id: ID of the object to get.
:type object_id: str
:param get_args: Other params to pass to the get_function method.
:type get_args: dict
"""
def decorator(func):
"""Decorate method to check object existing."""
if object_id is not None:
get_args['id'] = object_id
@functools.wraps(func)
def handler(*args, **kwargs):
"""Decorator handler."""
get_kwargs = {}
for k, v in six.iteritems(get_args):
get_kwargs[k] = kwargs[v]
try:
obj = get_function(**get_kwargs)
except exceptions.NotFound:
obj = None
if obj is None:
e = exceptions.NotFound(
get_kwargs,
template='Object with %s not found',
)
return api_utils.not_found(e)
return func(*args, **kwargs)
return handler
return decorator

72
climate/cmd/api.py Normal file
View File

@ -0,0 +1,72 @@
# Copyright (c) 2013 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.
import gettext
import os
import socket
import sys
import eventlet
from eventlet import wsgi
from oslo.config import cfg
gettext.install('climate', unicode=1)
from climate.api import app as api_app
from climate import config
from climate.openstack.common import log as logging
from climate.utils import service as service_utils
opts = [
cfg.StrOpt('host', default=socket.getfqdn(),
help='Name of this node. This can be an opaque identifier. '
'It is not necessarily a hostname, FQDN, or IP address. '
'However, the node name must be valid within '
'an AMQP key, and if using ZeroMQ, a valid '
'hostname, FQDN, or IP address'),
cfg.IntOpt('port', default=1234,
help='Port that will be used to listen on'),
]
LOG = logging.getLogger(__name__)
CONF = cfg.CONF
CONF.register_cli_opts(opts)
def main():
"""Entry point to start Climate API wsgi server."""
possible_topdir = os.path.join(os.path.abspath(sys.argv[0]),
os.pardir, os.pardir)
possible_topdir = os.path.normpath(possible_topdir)
dev_conf = os.path.join(possible_topdir, 'etc', 'climate', 'climate.conf')
config_files = None
if os.path.exists(dev_conf):
config_files = [dev_conf]
config.parse_configs(sys.argv[1:], config_files)
service_utils.prepare_service(sys.argv)
logging.setup("climate")
app = api_app.make_app()
wsgi.server(eventlet.listen((CONF.host, CONF.port), backlog=500), app)
if __name__ == '__main__':
main()

39
climate/config.py Normal file
View File

@ -0,0 +1,39 @@
# Copyright (c) 2013 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
cli_opts = [
cfg.BoolOpt('log-exchange', default=False,
help='Log request/response exchange details: environ, '
'headers and bodies'),
]
CONF = cfg.CONF
CONF.register_cli_opts(cli_opts)
ARGV = []
def parse_configs(argv=None, conf_files=None):
"""Parse Climate configuration file."""
if argv is not None:
global ARGV
ARGV = argv
try:
CONF(ARGV, project='climate', default_config_files=conf_files)
except cfg.RequiredOptError as roe:
raise RuntimeError("Option '%s' is required for config group "
"'%s'" % (roe.opt_name, roe.group.name))

50
climate/exceptions.py Normal file
View File

@ -0,0 +1,50 @@
# Copyright (c) 2013 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.
class ClimateException(Exception):
"""Base Exception for the Climate.
To correctly use this class, inherit from it and define
a 'message' and 'code' properties.
"""
template = "An unknown exception occurred"
code = "UNKNOWN_EXCEPTION"
def __init__(self, *args, **kwargs):
super(ClimateException, self).__init__(*args)
template = kwargs.pop('template', None)
if template:
self.template = template
def __str__(self):
return self.template % self.args
def __repr__(self):
if self.template != type(self).template:
tmpl = ", template=%r" % (self.template,)
else:
tmpl = ""
args = ", ".join(map(repr, self.args))
return "%s(%s%s)" % (type(self).__name__, args, tmpl)
class NotFound(ClimateException):
"""Object not found exception."""
template = "Object not found"
code = "NOT_FOUND"
def __init__(self, *args, **kwargs):
super(NotFound, self).__init__(*args, **kwargs)

View File

@ -15,25 +15,12 @@
# License for the specific language governing permissions and limitations
# under the License.
import socket
from oslo.config import cfg
from climate.openstack.common import log
from climate.openstack.common import rpc
cfg.CONF.register_opts([
cfg.StrOpt('host',
default=socket.getfqdn(),
help='Name of this node. This can be an opaque identifier. '
'It is not necessarily a hostname, FQDN, or IP address. '
'However, the node name must be valid within '
'an AMQP key, and if using ZeroMQ, a valid '
'hostname, FQDN, or IP address'),
])
def prepare_service(argv=[]):
rpc.set_defaults(control_exchange='climate')
cfg.set_defaults(log.log_opts,

View File

@ -1,8 +1,12 @@
pbr>=0.5.21,<1.0
eventlet>=0.13.0
Flask>=0.10,<1.0a0
iso8601>=0.1.4
oslo.config>=1.2.0
python-novaclient>=2.15.0
netaddr
python-keystoneclient>=0.3.2
Routes>=1.12.3
SQLAlchemy>=0.7.8,<=0.7.99
WebOb>=1.2.3,<1.3a0

View File

@ -28,6 +28,7 @@ packages =
[entry_points]
console_scripts =
climate-api=climate.cmd.api:main
climate-scheduler=climate.cmd.scheduler:main
climate-rpc-zmq-receiver=climate.cmd.rpc_zmq_receiver:main