Bootstrap the v2 API

This adds a v2 API root to CloudKitty. An example endpoint along with some
developer documentation has been added. The API root is now served with Flask
instead of pecan.

Change-Id: I4ede52dae531631f7fe708400def01bc08a64dec
Story: 2004208
Task: 27717
Task: 27718
Task: 27719
This commit is contained in:
Luka Peschke 2019-02-11 11:12:41 +01:00
parent 4c69a86fcc
commit dc2509ba65
47 changed files with 1323 additions and 206 deletions

View File

@ -17,15 +17,17 @@
#
import os
import flask
import flask_restful
from oslo_config import cfg
from oslo_log import log
from paste import deploy
import pecan
from werkzeug import wsgi
from cloudkitty.api import config as api_config
from cloudkitty.api import hooks
from cloudkitty.api import root as api_root
from cloudkitty.api.v1 import get_api_app as get_v1_app
from cloudkitty.api.v2 import get_api_app as get_v2_app
from cloudkitty import service
from cloudkitty import storage
LOG = log.getLogger(__name__)
@ -45,43 +47,31 @@ api_opts = [
cfg.PortOpt('port',
default=8889,
help='The port for the cloudkitty API server.'),
cfg.BoolOpt('pecan_debug',
default=False,
help='Toggle Pecan Debug Middleware.'),
]
CONF = cfg.CONF
CONF.import_opt('version', 'cloudkitty.storage', 'storage')
CONF.register_opts(auth_opts)
CONF.register_opts(api_opts, group='api')
def get_pecan_config():
# Set up the pecan configuration
filename = api_config.__file__.replace('.pyc', '.py')
return pecan.configuration.conf_from_file(filename)
def setup_app():
root_app = flask.Flask('cloudkitty')
root_api = flask_restful.Api(root_app)
root_api.add_resource(api_root.CloudkittyAPIRoot, '/')
dispatch_dict = {
'/v1': get_v1_app(),
'/v2': get_v2_app(),
}
def setup_app(pecan_config=None, extra_hooks=None):
app_conf = get_pecan_config()
storage_backend = storage.get_storage()
app_hooks = [
hooks.RPCHook(),
hooks.StorageHook(storage_backend),
hooks.ContextHook(),
]
app = pecan.make_app(
app_conf.app.root,
static_root=app_conf.app.static_root,
template_path=app_conf.app.template_path,
debug=CONF.api.pecan_debug,
force_canonical=getattr(app_conf.app, 'force_canonical', True),
hooks=app_hooks,
guess_content_type_from_ext=False
)
# Disabling v2 api in case v1 storage is used
if CONF.storage.version < 2:
LOG.warning('v1 storage is used, disabling v2 API')
dispatch_dict.pop('/v2')
app = wsgi.DispatcherMiddleware(root_app, dispatch_dict)
return app
@ -97,7 +87,7 @@ def load_app():
raise cfg.ConfigFilesNotFoundError([cfg.CONF.api_paste_config])
LOG.info("Full WSGI config used: %s", cfg_file)
appname = "cloudkitty+{}".format(cfg.CONF.auth_strategy)
LOG.info("Cloudkitty api with '%s' auth type will be loaded.",
LOG.info("Cloudkitty API with '%s' auth type will be loaded.",
cfg.CONF.auth_strategy)
return deploy.loadapp("config:" + cfg_file, name=appname)

View File

@ -13,133 +13,72 @@
# License for the specific language governing permissions and limitations
# under the License.
#
# @author: Stéphane Albert
#
from flask import request
import flask_restful
from oslo_config import cfg
import pecan
from pecan import rest
from wsme import types as wtypes
import wsmeext.pecan as wsme_pecan
import voluptuous
from cloudkitty.api.v2 import utils as api_utils
from cloudkitty.api.v1 import controllers as v1_api
CONF = cfg.CONF
CONF.import_opt('port', 'cloudkitty.api.app', 'api')
CONF.import_opt('version', 'cloudkitty.storage', 'storage')
API_VERSION_SCHEMA = voluptuous.Schema({
voluptuous.Required('id'): str,
voluptuous.Required('links'): [
voluptuous.Schema({
voluptuous.Required('href'): str,
voluptuous.Required('rel', default='self'): 'self',
}),
],
voluptuous.Required('status'): voluptuous.Any(
'CURRENT',
'SUPPORTED',
'EXPERIMENTAL',
'DEPRECATED',
),
})
class APILink(wtypes.Base):
"""API link description.
def get_api_versions():
"""Returns a list of all existing API versions."""
apis = [
{
'id': 'v1',
'links': [{
'href': '{scheme}://{host}/v1'.format(
scheme=request.scheme,
host=request.host,
),
}],
'status': 'CURRENT',
},
{
'id': 'v2',
'links': [{
'href': '{scheme}://{host}/v2'.format(
scheme=request.scheme,
host=request.host,
),
}],
'status': 'EXPERIMENTAL',
},
]
"""
# v2 api is disabled when using v1 storage
if CONF.storage.version < 2:
apis = apis[:1]
type = wtypes.text
"""Type of link."""
rel = wtypes.text
"""Relationship with this link."""
href = wtypes.text
"""URL of the link."""
@classmethod
def sample(cls):
version = 'v1'
sample = cls(
rel='self',
type='text/html',
href='http://127.0.0.1:{port}/{id}'.format(
port=CONF.api.port,
id=version))
return sample
return apis
class APIMediaType(wtypes.Base):
"""Media type description.
class CloudkittyAPIRoot(flask_restful.Resource):
"""
base = wtypes.text
"""Base type of this media type."""
type = wtypes.text
"""Type of this media type."""
@classmethod
def sample(cls):
sample = cls(
base='application/json',
type='application/vnd.openstack.cloudkitty-v1+json')
return sample
VERSION_STATUS = wtypes.Enum(wtypes.text, 'EXPERIMENTAL', 'STABLE')
class APIVersion(wtypes.Base):
"""API Version description.
"""
id = wtypes.text
"""ID of the version."""
status = VERSION_STATUS
"""Status of the version."""
updated = wtypes.text
"Last update in iso8601 format."
links = [APILink]
"""List of links to API resources."""
media_types = [APIMediaType]
"""Types accepted by this API."""
@classmethod
def sample(cls):
version = 'v1'
updated = '2014-08-11T16:00:00Z'
links = [APILink.sample()]
media_types = [APIMediaType.sample()]
sample = cls(id=version,
status='STABLE',
updated=updated,
links=links,
media_types=media_types)
return sample
class RootController(rest.RestController):
"""Root REST Controller exposing versions of the API.
"""
v1 = v1_api.V1Controller()
@wsme_pecan.wsexpose([APIVersion])
def index(self):
"""Return the version list
"""
# TODO(sheeprine): Maybe we should store all the API version
# informations in every API modules
ver1 = APIVersion(
id='v1',
status='EXPERIMENTAL',
updated='2015-03-09T16:00:00Z',
links=[
APILink(
rel='self',
href='{scheme}://{host}/v1'.format(
scheme=pecan.request.scheme,
host=pecan.request.host,
)
)
],
media_types=[]
)
versions = []
versions.append(ver1)
return versions
@api_utils.add_output_schema(voluptuous.Schema({
'versions': [API_VERSION_SCHEMA],
}))
def get(self):
return {
'versions': get_api_versions(),
}

View File

@ -0,0 +1,57 @@
# Copyright 2018 Objectif Libre
#
# 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
import pecan
from cloudkitty.api.v1 import config as api_config
from cloudkitty.api.v1 import hooks
from cloudkitty import storage
api_opts = [
cfg.BoolOpt('pecan_debug',
default=False,
help='Toggle Pecan Debug Middleware.'),
]
CONF = cfg.CONF
CONF.register_opts(api_opts, group='api')
def get_pecan_config():
# Set up the pecan configuration
filename = api_config.__file__.replace('.pyc', '.py')
return pecan.configuration.conf_from_file(filename)
def get_api_app():
app_conf = get_pecan_config()
storage_backend = storage.get_storage()
app_hooks = [
hooks.RPCHook(),
hooks.StorageHook(storage_backend),
hooks.ContextHook(),
]
return pecan.make_app(
app_conf.app.root,
static_root=app_conf.app.static_root,
template_path=app_conf.app.template_path,
debug=CONF.api.pecan_debug,
force_canonical=getattr(app_conf.app, 'force_canonical', True),
hooks=app_hooks,
guess_content_type_from_ext=False,
)

View File

@ -14,7 +14,7 @@ from cloudkitty import config # noqa
# Pecan Application Configurations
app = {
'root': 'cloudkitty.api.root.RootController',
'root': 'cloudkitty.api.v1.controllers.V1Controller',
'modules': ['cloudkitty.api'],
'static_root': '%(confdir)s/public',
'template_path': '%(confdir)s/templates',

View File

@ -0,0 +1,63 @@
# Copyright 2018 Objectif Libre
#
# 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 importlib
import flask
from oslo_context import context
import voluptuous
from cloudkitty.common import policy
RESOURCE_SCHEMA = voluptuous.Schema({
# python module containing the resource
voluptuous.Required('module'): str,
# Name of the resource class
voluptuous.Required('resource_class'): str,
# Url suffix of this specific resource
voluptuous.Required('url'): str,
})
API_MODULES = [
'cloudkitty.api.v2.example',
]
def _extend_request_context():
headers = flask.request.headers
roles = headers.get('X-Roles', '').split(',')
is_admin = policy.check_is_admin(roles)
ctx = {
'user_id': headers.get('X-User-Id', ''),
'auth_token': headers.get('X-Auth-Token', ''),
'is_admin': is_admin,
'roles': roles,
'project_id': headers.get('X-Project-Id', ''),
'domain_id': headers.get('X-Domain-Id', ''),
}
flask.request.context = context.RequestContext(**ctx)
def get_api_app():
app = flask.Flask(__name__)
for module_name in API_MODULES:
module = importlib.import_module(module_name)
module.init(app)
app.before_request(_extend_request_context)
return app

View File

@ -0,0 +1,26 @@
# Copyright 2018 Objectif Libre
#
# 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 cloudkitty.api.v2 import utils as api_utils
def init(app):
api_utils.do_init(app, 'example', [
{
'module': __name__ + '.' + 'example',
'resource_class': 'Example',
'url': '',
},
])
return app

View File

@ -0,0 +1,68 @@
# Copyright 2018 Objectif Libre
#
# 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 flask
import flask_restful
import voluptuous
from werkzeug import exceptions as http_exceptions
from cloudkitty.api.v2 import utils as api_utils
from cloudkitty.common import policy
class Example(flask_restful.Resource):
@api_utils.add_output_schema({
voluptuous.Required(
'message',
default='This is an example endpoint',
): api_utils.get_string_type(),
})
def get(self):
policy.authorize(flask.request.context, 'example:get_example', {})
return {}
@api_utils.add_input_schema('query', {
voluptuous.Required('fruit'): api_utils.SingleQueryParam(str),
})
def put(self, fruit=None):
policy.authorize(flask.request.context, 'example:submit_fruit', {})
if not fruit:
raise http_exceptions.BadRequest(
'You must submit a fruit',
)
if fruit not in ['banana', 'strawberry']:
raise http_exceptions.Forbidden(
'You submitted a forbidden fruit',
)
return {
'message': 'Your fruit is a ' + fruit,
}
@api_utils.add_input_schema('body', {
voluptuous.Required('fruit'): api_utils.get_string_type(),
})
def post(self, fruit=None):
policy.authorize(flask.request.context, 'example:submit_fruit', {})
if not fruit:
raise http_exceptions.BadRequest(
'You must submit a fruit',
)
if fruit not in ['banana', 'strawberry']:
raise http_exceptions.Forbidden(
'You submitted a forbidden fruit',
)
return {
'message': 'Your fruit is a ' + fruit,
}

240
cloudkitty/api/v2/utils.py Normal file
View File

@ -0,0 +1,240 @@
# Copyright 2018 Objectif Libre
#
# 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 importlib
import flask
import flask_restful
import six
import voluptuous
from werkzeug import exceptions
from cloudkitty.api import v2 as v2_api
from cloudkitty import json_utils as json
class SingleQueryParam(object):
"""Voluptuous validator allowing to validate unique query parameters.
This validator checks that a URL query parameter is provided only once,
verifies its type and returns it directly, instead of returning a list
containing a single element.
Note that this validator uses ``voluptuous.Coerce`` internally and thus
should not be used together with ``api_utils.get_string_type`` in python2.
:param param_type: Type of the query parameter
"""
def __init__(self, param_type):
self._validate = voluptuous.Coerce(param_type)
def __call__(self, v):
if not isinstance(v, list):
v = [v]
if len(v) != 1:
raise voluptuous.LengthInvalid('length of value must be 1')
output = v[0]
return self._validate(output)
def add_input_schema(location, schema):
"""Add a voluptuous schema validation on a method's input
Takes a dict which can be converted to a volptuous schema as parameter,
and validates the parameters with this schema. The "location" parameter
is used to specify the parameters' location. Note that for query
parameters, a ``MultiDict`` is returned by Flask. Thus, each dict key will
contain a list. In order to ease interaction with unique query parameters,
the ``SingleQueryParam`` voluptuous validator can be used::
from cloudkitty.api.v2 import utils as api_utils
@api_utils.add_input_schema('query', {
voluptuous.Required('fruit'): api_utils.SingleQueryParam(str),
})
def put(self, fruit=None):
return fruit
To accept a list of query parameters, the following syntax can be used::
from cloudkitty.api.v2 import utils as api_utils
@api_utils.add_input_schema('query', {
voluptuous.Required('fruit'): [str],
})
def put(self, fruit=[]):
for f in fruit:
# Do something with the fruit
:param location: Location of the args. Must be one of ['body', 'query']
:type location: str
:param schema: Schema to apply to the method's kwargs
:type schema: dict
"""
def decorator(f):
try:
s = getattr(f, 'input_schema')
s = s.extend(schema)
# The previous schema must be deleted or it will be called... [1/2]
delattr(f, 'input_schema')
except AttributeError:
s = voluptuous.Schema(schema)
def wrap(self, **kwargs):
if hasattr(wrap, 'input_schema'):
if location == 'body':
args = flask.request.get_json()
elif location == 'query':
args = dict(flask.request.args)
try:
# ...here [2/2]
kwargs.update(wrap.input_schema(args))
except voluptuous.Invalid as e:
raise exceptions.BadRequest(
"Invalid data '{a}' : {m} (path: '{p}')".format(
a=args, m=e.msg, p=str(e.path)))
return f(self, **kwargs)
wrap.input_schema = s
return wrap
return decorator
def paginated(func):
"""Helper function for pagination.
Adds two parameters to the decorated function:
* ``offset``: int >=0. Defaults to 0.
* ``limit``: int >=1. Defaults to 100.
Example usage::
class Example(flask_restful.Resource):
@api_utils.paginated
@api_utils.add_output_schema({
voluptuous.Required(
'message',
default='This is an example endpoint',
): api_utils.get_string_type(),
})
def get(self, offset=0, limit=100):
# [...]
"""
return add_input_schema('query', {
voluptuous.Required('offset', default=0): voluptuous.All(
SingleQueryParam(int), voluptuous.Range(min=0)),
voluptuous.Required('limit', default=100): voluptuous.All(
SingleQueryParam(int), voluptuous.Range(min=1)),
})(func)
def add_output_schema(schema):
"""Add a voluptuous schema validation on a method's output
Example usage::
class Example(flask_restful.Resource):
@api_utils.add_output_schema({
voluptuous.Required(
'message',
default='This is an example endpoint',
): api_utils.get_string_type(),
})
def get(self):
return {}
:param schema: Schema to apply to the method's output
:type schema: dict
"""
schema = voluptuous.Schema(schema)
def decorator(f):
def wrap(*args, **kwargs):
resp = f(*args, **kwargs)
return schema(resp)
return wrap
return decorator
class ResourceNotFound(Exception):
"""Exception raised when a resource is not found"""
def __init__(self, module, resource_class):
msg = 'Resource {r} was not found in module {m}'.format(
r=resource_class,
m=module,
)
super(ResourceNotFound, self).__init__(msg)
def _load_resource(module, resource_class):
try:
module = importlib.import_module(module)
except ImportError:
raise ResourceNotFound(module, resource_class)
resource = getattr(module, resource_class, None)
if resource is None:
raise ResourceNotFound(module, resource_class)
return resource
def output_json(data, code, headers=None):
"""Helper function for api endpoint json serialization"""
resp = flask.make_response(json.dumps(data), code)
resp.headers.extend(headers or {})
return resp
def _get_blueprint_and_api(module_name):
endpoint_name = module_name.split('.')[-1]
blueprint = flask.Blueprint(endpoint_name, module_name)
api = flask_restful.Api(blueprint)
# Using cloudkitty.json instead of json for serialization
api.representation('application/json')(output_json)
return blueprint, api
def do_init(app, blueprint_name, resources):
"""Registers a new Blueprint containing one or several resources to app.
:param app: Flask app in which the Blueprint should be registered
:type app: flask.Flask
:param blueprint_name: Name of the blueprint to create
:type blueprint_name: str
:param resources: Resources to add to the Blueprint's Api
:type resources: list of dicts matching
``cloudkitty.api.v2.RESOURCE_SCHEMA``
"""
blueprint, api = _get_blueprint_and_api(blueprint_name)
schema = voluptuous.Schema([v2_api.RESOURCE_SCHEMA])
for resource_info in schema(resources):
resource = _load_resource(resource_info['module'],
resource_info['resource_class'])
api.add_resource(resource, resource_info['url'])
if not blueprint_name.startswith('/'):
blueprint_name = '/' + blueprint_name
app.register_blueprint(blueprint, url_prefix=blueprint_name)
def get_string_type():
"""Returns ``basestring`` in python2 and ``str`` in python3."""
return six.string_types[0]

View File

@ -25,10 +25,11 @@ def set_cors_middleware_defaults():
"""Update default configuration options for oslo.middleware."""
cors.set_defaults(
allow_headers=['X-Auth-Token',
'X-Identity-Status',
'X-Subject-Token',
'X-Roles',
'X-Service-Catalog',
'X-User-Id',
'X-Domain-Id',
'X-Project-Id',
'X-Tenant-Id',
'X-OpenStack-Request-ID'],
expose_headers=['X-Auth-Token',

View File

@ -16,19 +16,21 @@
import itertools
from cloudkitty.common.policies import base
from cloudkitty.common.policies import collector
from cloudkitty.common.policies import info
from cloudkitty.common.policies import rating
from cloudkitty.common.policies import report
from cloudkitty.common.policies import storage
from cloudkitty.common.policies.v1 import collector as v1_collector
from cloudkitty.common.policies.v1 import info as v1_info
from cloudkitty.common.policies.v1 import rating as v1_rating
from cloudkitty.common.policies.v1 import report as v1_report
from cloudkitty.common.policies.v1 import storage as v1_storage
from cloudkitty.common.policies.v2 import example as v2_example
def list_rules():
return itertools.chain(
base.list_rules(),
collector.list_rules(),
info.list_rules(),
rating.list_rules(),
report.list_rules(),
storage.list_rules()
v1_collector.list_rules(),
v1_info.list_rules(),
v1_rating.list_rules(),
v1_report.list_rules(),
v1_storage.list_rules(),
v2_example.list_rules(),
)

View File

@ -0,0 +1,36 @@
# Copyright 2018 Objectif Libre
#
# 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_policy import policy
from cloudkitty.common.policies import base
example_policies = [
policy.DocumentedRuleDefault(
name='example:get_example',
check_str=base.UNPROTECTED,
description='Get an example message',
operations=[{'path': '/v2/example',
'method': 'GET'}]),
policy.DocumentedRuleDefault(
name='example:submit_fruit',
check_str=base.UNPROTECTED,
description='Submit a fruit',
operations=[{'path': '/v2/example',
'method': 'POST'}]),
]
def list_rules():
return example_policies

View File

View File

@ -0,0 +1,178 @@
# Copyright 2018 Objectif Libre
#
# 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 flask
import mock
import voluptuous
from werkzeug.exceptions import BadRequest
from cloudkitty.api.v2 import utils as api_utils
from cloudkitty import tests
class ApiUtilsDoInitTest(tests.TestCase):
def test_do_init_valid_app_and_resources(self):
app = flask.Flask('cloudkitty')
resources = [
{
'module': 'cloudkitty.api.v2.example.example',
'resource_class': 'Example',
'url': '/example',
},
]
api_utils.do_init(app, 'example', resources)
def test_do_init_invalid_resource(self):
app = flask.Flask('cloudkitty')
resources = [
{
'module': 'cloudkitty.api.v2.invalid',
'resource_class': 'Invalid',
'url': '/invalid',
},
]
self.assertRaises(
api_utils.ResourceNotFound,
api_utils.do_init,
app, 'invalid', resources,
)
class SingleQueryParamTest(tests.TestCase):
def test_single_int_to_int(self):
self.assertEqual(api_utils.SingleQueryParam(int)(42), 42)
def test_single_str_to_int(self):
self.assertEqual(api_utils.SingleQueryParam(str)(42), '42')
def test_int_list_to_int(self):
self.assertEqual(api_utils.SingleQueryParam(int)([42]), 42)
def test_str_list_to_int(self):
self.assertEqual(api_utils.SingleQueryParam(str)([42]), '42')
def test_raises_length_invalid_empty_list(self):
validator = api_utils.SingleQueryParam(int)
self.assertRaises(
voluptuous.LengthInvalid,
validator,
[],
)
def test_raises_length_invalid_long_list(self):
validator = api_utils.SingleQueryParam(int)
self.assertRaises(
voluptuous.LengthInvalid,
validator,
[0, 1],
)
class AddInputSchemaTest(tests.TestCase):
def test_paginated(self):
@api_utils.paginated
def test_func(self, offset=None, limit=None):
self.assertEqual(offset, 0)
self.assertEqual(limit, 100)
self.assertIn('offset', test_func.input_schema.schema.keys())
self.assertIn('limit', test_func.input_schema.schema.keys())
self.assertEqual(2, len(test_func.input_schema.schema.keys()))
with mock.patch('flask.request') as m:
m.args = {}
test_func(self)
m.args = {'offset': 0, 'limit': 100}
test_func(self)
m.args = {'offset': 1}
self.assertRaises(AssertionError, test_func, self)
m.args = {'limit': 99}
self.assertRaises(AssertionError, test_func, self)
m.args = {'offset': -1}
self.assertRaises(BadRequest, test_func, self)
m.args = {'limit': 0}
self.assertRaises(BadRequest, test_func, self)
def test_simple_add_input_schema_query(self):
@api_utils.add_input_schema('query', {
voluptuous.Required(
'arg_one', default='one'): api_utils.SingleQueryParam(str),
})
def test_func(self, arg_one=None):
self.assertEqual(arg_one, 'one')
self.assertEqual(len(test_func.input_schema.schema.keys()), 1)
self.assertEqual(
list(test_func.input_schema.schema.keys())[0], 'arg_one')
with mock.patch('flask.request') as m:
m.args = {}
test_func(self)
m.args = {'arg_one': 'one'}
test_func(self)
def test_simple_add_input_schema_body(self):
@api_utils.add_input_schema('body', {
voluptuous.Required(
'arg_one', default='one'): api_utils.SingleQueryParam(str),
})
def test_func(self, arg_one=None):
self.assertEqual(arg_one, 'one')
self.assertEqual(len(test_func.input_schema.schema.keys()), 1)
self.assertEqual(
list(test_func.input_schema.schema.keys())[0], 'arg_one')
with mock.patch('flask.request') as m:
m.get_json.return_value = {}
test_func(self)
with mock.patch('flask.request') as m:
m.get_json.return_value = {'arg_one': 'one'}
test_func(self)
def _test_multiple_add_input_schema_x(self, location):
@api_utils.add_input_schema(location, {
voluptuous.Required(
'arg_one', default='one'):
api_utils.SingleQueryParam(str) if location == 'query' else str,
})
@api_utils.add_input_schema(location, {
voluptuous.Required(
'arg_two', default='two'):
api_utils.SingleQueryParam(str) if location == 'query' else str,
})
def test_func(self, arg_one=None, arg_two=None):
self.assertEqual(arg_one, 'one')
self.assertEqual(arg_two, 'two')
self.assertEqual(len(test_func.input_schema.schema.keys()), 2)
self.assertEqual(
sorted(list(test_func.input_schema.schema.keys())),
['arg_one', 'arg_two'],
)
def test_multiple_add_input_schema_query(self):
self._test_multiple_add_input_schema_x('query')
def test_multiple_add_input_schema_body(self):
self._test_multiple_add_input_schema_x('body')

View File

@ -215,6 +215,14 @@ class ConfigFixture(fixture.GabbiFixture):
db.get_engine().dispose()
class ConfigFixtureStorageV2(ConfigFixture):
def start_fixture(self):
super(ConfigFixtureStorageV2, self).start_fixture()
self.conf.set_override('backend', 'influxdb', 'storage')
self.conf.set_override('version', '2', 'storage')
class ConfigFixtureKeystoneAuth(ConfigFixture):
auth_strategy = 'keystone'

View File

@ -0,0 +1,20 @@
fixtures:
- ConfigFixture
tests:
- name: test if / is publicly available
url: /
status: 200
- name: test if HEAD / is available
url: /
status: 200
method: HEAD
- name: test that only one APIs is available
url: /
status: 200
response_json_paths:
$.versions.`len`: 1
$.versions[0].id: v1
$.versions[0].status: CURRENT

View File

@ -0,0 +1,22 @@
fixtures:
- ConfigFixtureStorageV2
tests:
- name: test if / is publicly available
url: /
status: 200
- name: test if HEAD / is available
url: /
status: 200
method: HEAD
- name: test if both APIs are available
url: /
status: 200
response_json_paths:
$.versions.`len`: 2
$.versions[0].id: v1
$.versions[1].id: v2
$.versions[0].status: CURRENT
$.versions[1].status: EXPERIMENTAL

View File

@ -1,12 +0,0 @@
fixtures:
- ConfigFixture
tests:
- name: test if / is publicly available
url: /
status: 200
- name: test if HEAD / is available
url: /
status: 200
method: HEAD

View File

@ -0,0 +1,40 @@
fixtures:
- ConfigFixtureStorageV2
tests:
- name: get an example resource
url: /v2/example
status: 200
response_json_paths:
$.message: This is an example endpoint
- name: submit a banana
url: /v2/example
status: 200
method: POST
request_headers:
content-type: application/json
data:
fruit: banana
response_json_paths:
$.message: Your fruit is a banana
- name: submit a forbidden fruit
url: /v2/example
status: 403
method: POST
request_headers:
content-type: application/json
data:
fruit: forbidden
response_json_paths:
$.message: You submitted a forbidden fruit
- name: submit invalid data
url: /v2/example
status: 400
method: POST
request_headers:
content-type: application/json
data:
invalid: invalid

View File

@ -84,3 +84,11 @@
# GET /v1/storage/dataframes
#"storage:list_data_frames": ""
# Get an example message
# GET /v2/example
#"example:get_example": ""
# Submit a fruit
# POST /v2/example
#"example:submit_fruit": ""

View File

@ -1,9 +1,23 @@
========================
CloudKitty API reference
========================
#############
API Reference
#############
This is a complete reference of Cloudkitty's API.
API v1
======
.. toctree::
:glob:
root
v1
v1/*
v1/rating/*
API v2
======
.. toctree::
:maxdepth: 2
:glob:
v2/*

View File

@ -1,16 +0,0 @@
==========================
CloudKitty REST API (root)
==========================
.. rest-controller:: cloudkitty.api.root:RootController
:webprefix: / /
.. Dirty hack till the bug is fixed so we can specify root path
.. autotype:: cloudkitty.api.root.APILink
:members:
.. autotype:: cloudkitty.api.root.APIMediaType
:members:
.. autotype:: cloudkitty.api.root.APIVersion
:members:

View File

@ -1,5 +1,6 @@
=======================
HashMap Module REST API
-----------------------
=======================
.. rest-controller:: cloudkitty.rating.hash.controllers.root:HashMapConfigController
:webprefix: /v1/rating/module_config/hashmap

View File

@ -1,5 +1,6 @@
=========================
PyScripts Module REST API
-------------------------
=========================
.. rest-controller:: cloudkitty.rating.pyscripts.controllers.root:PyScriptsConfigController
:webprefix: /v1/rating/module_config/pyscripts

View File

@ -67,9 +67,6 @@ Rating
.. autotype:: cloudkitty.api.v1.datamodels.rating.CloudkittyResourceCollection
:members:
.. include:: rating/hashmap.rst
.. include:: rating/pyscripts.rst
Report
======

View File

@ -0,0 +1,61 @@
================
Example endpoint
================
Get an example message
======================
Returns an example message.
.. rest_method:: GET /v2/example
Status codes
------------
.. rest_status_code:: success http_status.yml
- 200
.. rest_status_code:: error http_status.yml
- 405
Response
--------
.. rest_parameters:: example/example_parameters.yml
- msg: example_msg
Submit a fruit
==============
Returns the fruit you sent.
.. rest_method:: POST /v2/example
.. rest_parameters:: example/example_parameters.yml
- fruit: fruit
Status codes
------------
.. rest_status_code:: success http_status.yml
- 200
- 400
.. rest_status_code:: error http_status.yml
- 400
- 403: fruit_error
- 405
Response
--------
.. rest_parameters:: example/example_parameters.yml
- msg: fruit_msg

View File

@ -0,0 +1,20 @@
fruit:
in: body
description: |
A fruit. Must one of [**banana**, **strawberry**]
type: string
required: true
example_msg:
in: body
description: |
Contains "This is an example endpoint"
type: string
required: true
fruit_msg:
in: body
description: |
Contains "Your fruit is a <fruit>"
type: string
required: true

View File

@ -0,0 +1 @@
../http_status.yml

View File

@ -0,0 +1,15 @@
200:
default: Request was successful.
201:
default: Resource was successfully created.
400:
default: Invalid request.
403:
default: Forbidden operation for the authentified user.
fruit_error: This fruit is forbidden.
405:
default: The method is not allowed for the requested URL.

View File

@ -0,0 +1,3 @@
.. rest_expand_all::
.. include:: example/example.inc

View File

@ -44,13 +44,14 @@ extensions = ['sphinx.ext.coverage',
'sphinx.ext.graphviz',
'stevedore.sphinxext',
'oslo_config.sphinxext',
'sphinx.ext.autodoc',
'sphinx.ext.viewcode',
'oslo_config.sphinxconfiggen',
'sphinx.ext.mathjax',
'wsmeext.sphinxext',
'sphinx.ext.autodoc',
'sphinxcontrib.pecanwsme.rest',
'sphinxcontrib.httpdomain',
'os_api_ref',
'openstackdocstheme',
'oslo_policy.sphinxext',
'oslo_policy.sphinxpolicygen',

View File

@ -0,0 +1,9 @@
=====
API
=====
.. toctree::
:maxdepth: 2
tutorial
utils

View File

@ -0,0 +1,293 @@
====================================
Tutorial: creating an API endpoint
====================================
This section of the document details how to create an endpoint for CloudKitty's
v2 API. The v1 API is frozen, no endpoint should be added.
Setting up the layout for a new resource
========================================
In this section, we will create an ``example`` endpoint. Create the following
files and subdirectories in ``cloudkitty/api/v2/``:
.. code-block:: console
cloudkitty/api/v2/
└── example
├── example.py
└── __init__.py
Creating a custom resource
==========================
Each v2 API endpoint is based on a Flask Blueprint and one Flask-RESTful
resource per sub-endpoint. This allows to have a logical grouping of the
resources. Let's take the ``/rating/hashmap`` route as an example. Each of
the hashmap module's resources should be a Flask-RESTful resource (eg.
``/rating/hashmap/service``, ``/rating/hashmap/field`` etc...).
.. note:: There should be a distinction between endpoints refering to a single
resource and to several ones. For example, if you want an endpoint
allowing to list resources of some kind, you should implement the
following:
* A ``MyResource`` resource with support for ``GET``, ``POST``
and ``PUT`` HTTP methods on the ``/myresource/<uuid:>`` route.
* A ``MyResourceList`` resource with support for the ``GET``HTTP
method on the ``/myresource`` route.
* A blueprint containing these resources.
Basic resource
--------------
We'll create an ``/example/`` endpoint, used to manipulate fruits. We'll create
an ``Example`` resource, supporting ``GET`` and ``POST`` HTTP methods. First
of all, we'll create a class with ``get`` and ``post`` methods in
``cloudkitty/api/v2/example/example.py``:
.. code-block:: python
import flask_restful
class Example(flask_restful.Resource):
def get(self):
pass
def post(self):
pass
Validating a method's parameters and output
-------------------------------------------
A ``GET`` request on our resource will simply return **{"message": "This is an
example endpoint"}**. The ``add_output_schema`` decorator adds voluptuous
validation to a method's output. This allows to set defaults.
.. autofunction:: cloudkitty.api.v2.utils.add_output_schema
Let's update our ``get`` method in order to use this decorator:
.. code-block:: python
import flask_restful
import voluptuous
from cloudkitty.api.v2 import utils as api_utils
class Example(flask_restful.Resource):
@api_utils.add_output_schema({
voluptuous.Required(
'message',
default='This is an example endpoint',
): api_utils.get_string_type(),
})
def get(self):
return {}
.. note:: In this snippet, ``get_string_type`` returns ``basestring`` in
python2 and ``str`` in python3.
.. code-block:: console
$ curl 'http://cloudkitty-api:8889/v2/example'
{"message": "This is an example endpoint"}
It is now time to implement the ``post`` method. This function will take a
parameter. In order to validate it, we'll use the ``add_input_schema``
decorator:
.. autofunction:: cloudkitty.api.v2.utils.add_input_schema
Arguments validated by the input schema are passed as named arguments to the
decorated function. Let's implement the post method. We'll use Werkzeug
exceptions for HTTP return codes.
.. code-block:: python
@api_utils.add_input_schema('body', {
voluptuous.Required('fruit'): api_utils.get_string_type(),
})
def post(self, fruit=None):
policy.authorize(flask.request.context, 'example:submit_fruit', {})
if not fruit:
raise http_exceptions.BadRequest(
'You must submit a fruit',
)
if fruit not in ['banana', 'strawberry']:
raise http_exceptions.Forbidden(
'You submitted a forbidden fruit',
)
return {
'message': 'Your fruit is a ' + fruit,
}
Here, ``fruit`` is expected to be found in the request body:
.. code-block:: console
$ curl -X POST -H 'Content-Type: application/json' 'http://cloudkitty-api:8889/v2/example' -d '{"fruit": "banana"}'
{"message": "Your fruit is a banana"}
In order to retrieve ``fruit`` from the query, the function should have been
decorated like this:
.. code-block:: python
@api_utils.add_input_schema('query', {
voluptuous.Required('fruit'): api_utils.SingleQueryParam(str),
})
def post(self, fruit=None):
Note that a ``SingleQueryParam`` is used here: given that query parameters can
be specified several times (eg ``xxx?groupby=a&groupby=b``), Flask provides
query parameters as lists. The ``SingleQueryParam`` helper checks that a
parameter is provided only once, and returns it.
.. autoclass:: cloudkitty.api.v2.utils.SingleQueryParam
.. warning:: ``SingleQueryParam`` uses ``voluptuous.Coerce`` internally for
type checking. Thus, ``api_utils.get_string_type`` cannot be used
as ``basestring`` can't be instantiated.
Authorising methods
-------------------
The ``Example`` resource is still missing some authorisations. We'll create a
policy per method, configurable via the ``policy.yaml`` file. Create a
``cloudkitty/common/policies/v2/example.py`` file with the following content:
.. code-block:: python
from oslo_policy import policy
from cloudkitty.common.policies import base
example_policies = [
policy.DocumentedRuleDefault(
name='example:get_example',
check_str=base.UNPROTECTED,
description='Get an example message',
operations=[{'path': '/v2/example',
'method': 'GET'}]),
policy.DocumentedRuleDefault(
name='example:submit_fruit',
check_str=base.UNPROTECTED,
description='Submit a fruit',
operations=[{'path': '/v2/example',
'method': 'POST'}]),
]
def list_rules():
return example_policies
Add the following lines to ``cloudkitty/common/policies/__init__.py``:
.. code-block:: python
# [...]
from cloudkitty.common.policies.v2 import example as v2_example
def list_rules():
return itertools.chain(
base.list_rules(),
# [...]
v2_example.list_rules(),
)
This registers two documented policies, ``get_example`` and ``submit_fruit``.
They are unprotected by default, which means that everybody can access them.
However, they can be overriden in ``policy.yaml``. Call them the following way:
.. code-block:: python
# [...]
import flask
from cloudkitty.common import policy
class Example(flask_restful.Resource):
# [...]
def get(self):
policy.authorize(flask.request.context, 'example:get_example', {})
return {}
# [...]
def post(self):
policy.authorize(flask.request.context, 'example:submit_fruit', {})
# [...]
Registering resources
=====================
Each endpoint should provide an ``init`` method taking a Flask app as only
parameter. This method should call ``do_init``:
.. autofunction:: cloudkitty.api.v2.utils.do_init
Add the following to ``cloudkitty/api/v2/example/__init__.py``:
.. code-block:: python
from cloudkitty.api.v2 import utils as api_utils
def init(app):
api_utils.do_init(app, 'example', [
{
'module': __name__ + '.' + 'example',
'resource_class': 'Example',
'url': '',
},
])
return app
Here, we call ``do_init`` with the flask app passed as parameter, a blueprint
name, and a list of resources. The blueprint name will prefix the URLs of all
resources. Each resource is represented by a dict with the following
attributes:
* ``module``: name of the python module containing the resource class
* ``resource_class``: class of the resource
* ``url``: url suffix
In our case, the ``Example`` resource will be served at ``/example`` (blueprint
name + URL suffix).
.. note:: In case you need to add a resource to an existing endpoint, just add
it to the list.
.. warning:: If you created a new module, you'll have to add it to
``API_MODULES`` in ``cloudkitty/api/v2/__init__.py``:
.. code-block:: python
API_MODULES = [
'cloudkitty.api.v2.example',
]
Documenting your endpoint
=========================
The v2 API is documented with `os_api_ref`_ . Each v2 API endpoint must be
documented in ``doc/source/api-reference/v2/<endpoint_name>/``.
.. _os_api_ref: https://docs.openstack.org/os-api-ref/latest/

View File

@ -0,0 +1,13 @@
=================
Utils reference
=================
.. note::
This section of the documentation is a reference of the
``cloudkitty.api.v2.utils`` module. It is generated from the docstrings
of the functions. Please report any documentation bug you encounter on this
page
.. automodule:: cloudkitty.api.v2.utils
:members: SingleQueryParam, add_input_schema, paginated, add_output_schema, do_init, get_string_type

View File

@ -7,3 +7,4 @@ Developer Documentation
collector
storage
api/index

View File

@ -1,10 +1,10 @@
[pipeline:cloudkitty+noauth]
pipeline = cors http_proxy_to_wsgi request_id ck_api_v1
pipeline = cors http_proxy_to_wsgi request_id ck_api
[pipeline:cloudkitty+keystone]
pipeline = cors http_proxy_to_wsgi request_id authtoken ck_api_v1
pipeline = cors http_proxy_to_wsgi request_id authtoken ck_api
[app:ck_api_v1]
[app:ck_api]
paste.app_factory = cloudkitty.api.app:app_factory
[filter:authtoken]

View File

@ -28,6 +28,8 @@ stevedore==1.5.0 # Apache-2.0
tooz==1.28.0 # Apache-2.0
voluptuous==0.11.1 # BSD-3
influxdb==5.1.0 # MIT
Flask==1.0.2 # BSD
Flask-RESTful==0.3.5 # BSD
# test-requirements
coverage==3.6 # Apache-2.0
@ -46,3 +48,4 @@ sphinxcontrib-httpdomain==1.6.0 # Apache-2.0
doc8==0.6.0 # Apache-2.0
Pygments==2.2.0 # BSD
bandit==1.1.0 # Apache-2.0
os-api-ref==1.0.0 # Apache-2.0

View File

@ -0,0 +1,11 @@
---
features:
- |
A v2 API has been bootstrapped. It is compatible with the v2 storage and
will be the base for all upcoming API endpoints. It is marked as
``EXPERIMENTAL`` for now.
upgrade:
- |
The v1 API is now marked as ``CURRENT``. The API root is now built with
Flask instead of pecan

View File

@ -30,3 +30,5 @@ stevedore>=1.5.0 # Apache-2.0
tooz>=1.28.0 # Apache-2.0
voluptuous>=0.11.1 # BSD License
influxdb>=5.1.0,!=5.2.0 # MIT
Flask>=1.0.2 # BSD
Flask-RESTful>=0.3.5 # BSD

View File

@ -21,3 +21,4 @@ reno>=1.8.0 # Apache-2.0
doc8>=0.6.0 # Apache-2.0
Pygments>=2.2.0 # BSD license
bandit>=1.1.0 # Apache-2.0
os-api-ref>=1.0.0 # Apache-2.0