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:
@@ -17,15 +17,17 @@
|
|||||||
#
|
#
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
import flask
|
||||||
|
import flask_restful
|
||||||
from oslo_config import cfg
|
from oslo_config import cfg
|
||||||
from oslo_log import log
|
from oslo_log import log
|
||||||
from paste import deploy
|
from paste import deploy
|
||||||
import pecan
|
from werkzeug import wsgi
|
||||||
|
|
||||||
from cloudkitty.api import config as api_config
|
from cloudkitty.api import root as api_root
|
||||||
from cloudkitty.api import hooks
|
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 service
|
||||||
from cloudkitty import storage
|
|
||||||
|
|
||||||
|
|
||||||
LOG = log.getLogger(__name__)
|
LOG = log.getLogger(__name__)
|
||||||
@@ -45,43 +47,31 @@ api_opts = [
|
|||||||
cfg.PortOpt('port',
|
cfg.PortOpt('port',
|
||||||
default=8889,
|
default=8889,
|
||||||
help='The port for the cloudkitty API server.'),
|
help='The port for the cloudkitty API server.'),
|
||||||
cfg.BoolOpt('pecan_debug',
|
|
||||||
default=False,
|
|
||||||
help='Toggle Pecan Debug Middleware.'),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
CONF = cfg.CONF
|
CONF = cfg.CONF
|
||||||
|
CONF.import_opt('version', 'cloudkitty.storage', 'storage')
|
||||||
|
|
||||||
CONF.register_opts(auth_opts)
|
CONF.register_opts(auth_opts)
|
||||||
CONF.register_opts(api_opts, group='api')
|
CONF.register_opts(api_opts, group='api')
|
||||||
|
|
||||||
|
|
||||||
def get_pecan_config():
|
def setup_app():
|
||||||
# Set up the pecan configuration
|
root_app = flask.Flask('cloudkitty')
|
||||||
filename = api_config.__file__.replace('.pyc', '.py')
|
root_api = flask_restful.Api(root_app)
|
||||||
return pecan.configuration.conf_from_file(filename)
|
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):
|
# Disabling v2 api in case v1 storage is used
|
||||||
|
if CONF.storage.version < 2:
|
||||||
app_conf = get_pecan_config()
|
LOG.warning('v1 storage is used, disabling v2 API')
|
||||||
storage_backend = storage.get_storage()
|
dispatch_dict.pop('/v2')
|
||||||
|
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
|
app = wsgi.DispatcherMiddleware(root_app, dispatch_dict)
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
@@ -97,7 +87,7 @@ def load_app():
|
|||||||
raise cfg.ConfigFilesNotFoundError([cfg.CONF.api_paste_config])
|
raise cfg.ConfigFilesNotFoundError([cfg.CONF.api_paste_config])
|
||||||
LOG.info("Full WSGI config used: %s", cfg_file)
|
LOG.info("Full WSGI config used: %s", cfg_file)
|
||||||
appname = "cloudkitty+{}".format(cfg.CONF.auth_strategy)
|
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)
|
cfg.CONF.auth_strategy)
|
||||||
return deploy.loadapp("config:" + cfg_file, name=appname)
|
return deploy.loadapp("config:" + cfg_file, name=appname)
|
||||||
|
|
||||||
|
|||||||
@@ -13,133 +13,72 @@
|
|||||||
# License for the specific language governing permissions and limitations
|
# License for the specific language governing permissions and limitations
|
||||||
# under the License.
|
# under the License.
|
||||||
#
|
#
|
||||||
# @author: Stéphane Albert
|
from flask import request
|
||||||
#
|
import flask_restful
|
||||||
from oslo_config import cfg
|
from oslo_config import cfg
|
||||||
import pecan
|
import voluptuous
|
||||||
from pecan import rest
|
|
||||||
from wsme import types as wtypes
|
from cloudkitty.api.v2 import utils as api_utils
|
||||||
import wsmeext.pecan as wsme_pecan
|
|
||||||
|
|
||||||
from cloudkitty.api.v1 import controllers as v1_api
|
|
||||||
|
|
||||||
CONF = cfg.CONF
|
CONF = cfg.CONF
|
||||||
CONF.import_opt('port', 'cloudkitty.api.app', 'api')
|
CONF.import_opt('version', 'cloudkitty.storage', 'storage')
|
||||||
|
|
||||||
|
API_VERSION_SCHEMA = voluptuous.Schema({
|
||||||
class APILink(wtypes.Base):
|
voluptuous.Required('id'): str,
|
||||||
"""API link description.
|
voluptuous.Required('links'): [
|
||||||
|
voluptuous.Schema({
|
||||||
"""
|
voluptuous.Required('href'): str,
|
||||||
|
voluptuous.Required('rel', default='self'): 'self',
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class APIMediaType(wtypes.Base):
|
|
||||||
"""Media type description.
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
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=[]
|
voluptuous.Required('status'): voluptuous.Any(
|
||||||
)
|
'CURRENT',
|
||||||
|
'SUPPORTED',
|
||||||
|
'EXPERIMENTAL',
|
||||||
|
'DEPRECATED',
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
versions = []
|
|
||||||
versions.append(ver1)
|
|
||||||
|
|
||||||
return versions
|
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]
|
||||||
|
|
||||||
|
return apis
|
||||||
|
|
||||||
|
|
||||||
|
class CloudkittyAPIRoot(flask_restful.Resource):
|
||||||
|
|
||||||
|
@api_utils.add_output_schema(voluptuous.Schema({
|
||||||
|
'versions': [API_VERSION_SCHEMA],
|
||||||
|
}))
|
||||||
|
def get(self):
|
||||||
|
return {
|
||||||
|
'versions': get_api_versions(),
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from cloudkitty import config # noqa
|
|||||||
|
|
||||||
# Pecan Application Configurations
|
# Pecan Application Configurations
|
||||||
app = {
|
app = {
|
||||||
'root': 'cloudkitty.api.root.RootController',
|
'root': 'cloudkitty.api.v1.controllers.V1Controller',
|
||||||
'modules': ['cloudkitty.api'],
|
'modules': ['cloudkitty.api'],
|
||||||
'static_root': '%(confdir)s/public',
|
'static_root': '%(confdir)s/public',
|
||||||
'template_path': '%(confdir)s/templates',
|
'template_path': '%(confdir)s/templates',
|
||||||
63
cloudkitty/api/v2/__init__.py
Normal file
63
cloudkitty/api/v2/__init__.py
Normal 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
|
||||||
26
cloudkitty/api/v2/example/__init__.py
Normal file
26
cloudkitty/api/v2/example/__init__.py
Normal 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
|
||||||
68
cloudkitty/api/v2/example/example.py
Normal file
68
cloudkitty/api/v2/example/example.py
Normal 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
240
cloudkitty/api/v2/utils.py
Normal 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]
|
||||||
@@ -25,10 +25,11 @@ def set_cors_middleware_defaults():
|
|||||||
"""Update default configuration options for oslo.middleware."""
|
"""Update default configuration options for oslo.middleware."""
|
||||||
cors.set_defaults(
|
cors.set_defaults(
|
||||||
allow_headers=['X-Auth-Token',
|
allow_headers=['X-Auth-Token',
|
||||||
'X-Identity-Status',
|
'X-Subject-Token',
|
||||||
'X-Roles',
|
'X-Roles',
|
||||||
'X-Service-Catalog',
|
|
||||||
'X-User-Id',
|
'X-User-Id',
|
||||||
|
'X-Domain-Id',
|
||||||
|
'X-Project-Id',
|
||||||
'X-Tenant-Id',
|
'X-Tenant-Id',
|
||||||
'X-OpenStack-Request-ID'],
|
'X-OpenStack-Request-ID'],
|
||||||
expose_headers=['X-Auth-Token',
|
expose_headers=['X-Auth-Token',
|
||||||
|
|||||||
@@ -16,19 +16,21 @@
|
|||||||
import itertools
|
import itertools
|
||||||
|
|
||||||
from cloudkitty.common.policies import base
|
from cloudkitty.common.policies import base
|
||||||
from cloudkitty.common.policies import collector
|
from cloudkitty.common.policies.v1 import collector as v1_collector
|
||||||
from cloudkitty.common.policies import info
|
from cloudkitty.common.policies.v1 import info as v1_info
|
||||||
from cloudkitty.common.policies import rating
|
from cloudkitty.common.policies.v1 import rating as v1_rating
|
||||||
from cloudkitty.common.policies import report
|
from cloudkitty.common.policies.v1 import report as v1_report
|
||||||
from cloudkitty.common.policies import storage
|
from cloudkitty.common.policies.v1 import storage as v1_storage
|
||||||
|
from cloudkitty.common.policies.v2 import example as v2_example
|
||||||
|
|
||||||
|
|
||||||
def list_rules():
|
def list_rules():
|
||||||
return itertools.chain(
|
return itertools.chain(
|
||||||
base.list_rules(),
|
base.list_rules(),
|
||||||
collector.list_rules(),
|
v1_collector.list_rules(),
|
||||||
info.list_rules(),
|
v1_info.list_rules(),
|
||||||
rating.list_rules(),
|
v1_rating.list_rules(),
|
||||||
report.list_rules(),
|
v1_report.list_rules(),
|
||||||
storage.list_rules()
|
v1_storage.list_rules(),
|
||||||
|
v2_example.list_rules(),
|
||||||
)
|
)
|
||||||
|
|||||||
0
cloudkitty/common/policies/v1/__init__.py
Normal file
0
cloudkitty/common/policies/v1/__init__.py
Normal file
0
cloudkitty/common/policies/v2/__init__.py
Normal file
0
cloudkitty/common/policies/v2/__init__.py
Normal file
36
cloudkitty/common/policies/v2/example.py
Normal file
36
cloudkitty/common/policies/v2/example.py
Normal 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
|
||||||
0
cloudkitty/tests/api/v2/__init__.py
Normal file
0
cloudkitty/tests/api/v2/__init__.py
Normal file
178
cloudkitty/tests/api/v2/test_utils.py
Normal file
178
cloudkitty/tests/api/v2/test_utils.py
Normal 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')
|
||||||
@@ -215,6 +215,14 @@ class ConfigFixture(fixture.GabbiFixture):
|
|||||||
db.get_engine().dispose()
|
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):
|
class ConfigFixtureKeystoneAuth(ConfigFixture):
|
||||||
auth_strategy = 'keystone'
|
auth_strategy = 'keystone'
|
||||||
|
|
||||||
|
|||||||
20
cloudkitty/tests/gabbi/gabbits/root-v1-storage.yaml
Normal file
20
cloudkitty/tests/gabbi/gabbits/root-v1-storage.yaml
Normal 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
|
||||||
22
cloudkitty/tests/gabbi/gabbits/root-v2-storage.yaml
Normal file
22
cloudkitty/tests/gabbi/gabbits/root-v2-storage.yaml
Normal 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
|
||||||
@@ -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
|
|
||||||
40
cloudkitty/tests/gabbi/gabbits/v2-example.yaml
Normal file
40
cloudkitty/tests/gabbi/gabbits/v2-example.yaml
Normal 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
|
||||||
@@ -84,3 +84,11 @@
|
|||||||
# GET /v1/storage/dataframes
|
# GET /v1/storage/dataframes
|
||||||
#"storage:list_data_frames": ""
|
#"storage:list_data_frames": ""
|
||||||
|
|
||||||
|
# Get an example message
|
||||||
|
# GET /v2/example
|
||||||
|
#"example:get_example": ""
|
||||||
|
|
||||||
|
# Submit a fruit
|
||||||
|
# POST /v2/example
|
||||||
|
#"example:submit_fruit": ""
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,23 @@
|
|||||||
========================
|
#############
|
||||||
CloudKitty API reference
|
API Reference
|
||||||
========================
|
#############
|
||||||
|
|
||||||
|
This is a complete reference of Cloudkitty's API.
|
||||||
|
|
||||||
|
API v1
|
||||||
|
======
|
||||||
|
|
||||||
.. toctree::
|
.. toctree::
|
||||||
:glob:
|
:glob:
|
||||||
|
|
||||||
root
|
v1/*
|
||||||
v1
|
v1/rating/*
|
||||||
|
|
||||||
|
API v2
|
||||||
|
======
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
:maxdepth: 2
|
||||||
|
:glob:
|
||||||
|
|
||||||
|
v2/*
|
||||||
|
|||||||
@@ -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:
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
|
=======================
|
||||||
HashMap Module REST API
|
HashMap Module REST API
|
||||||
-----------------------
|
=======================
|
||||||
|
|
||||||
.. rest-controller:: cloudkitty.rating.hash.controllers.root:HashMapConfigController
|
.. rest-controller:: cloudkitty.rating.hash.controllers.root:HashMapConfigController
|
||||||
:webprefix: /v1/rating/module_config/hashmap
|
:webprefix: /v1/rating/module_config/hashmap
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
|
=========================
|
||||||
PyScripts Module REST API
|
PyScripts Module REST API
|
||||||
-------------------------
|
=========================
|
||||||
|
|
||||||
.. rest-controller:: cloudkitty.rating.pyscripts.controllers.root:PyScriptsConfigController
|
.. rest-controller:: cloudkitty.rating.pyscripts.controllers.root:PyScriptsConfigController
|
||||||
:webprefix: /v1/rating/module_config/pyscripts
|
:webprefix: /v1/rating/module_config/pyscripts
|
||||||
@@ -67,9 +67,6 @@ Rating
|
|||||||
.. autotype:: cloudkitty.api.v1.datamodels.rating.CloudkittyResourceCollection
|
.. autotype:: cloudkitty.api.v1.datamodels.rating.CloudkittyResourceCollection
|
||||||
:members:
|
:members:
|
||||||
|
|
||||||
.. include:: rating/hashmap.rst
|
|
||||||
|
|
||||||
.. include:: rating/pyscripts.rst
|
|
||||||
|
|
||||||
Report
|
Report
|
||||||
======
|
======
|
||||||
61
doc/source/api-reference/v2/example/example.inc
Normal file
61
doc/source/api-reference/v2/example/example.inc
Normal 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
|
||||||
20
doc/source/api-reference/v2/example/example_parameters.yml
Normal file
20
doc/source/api-reference/v2/example/example_parameters.yml
Normal 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
|
||||||
1
doc/source/api-reference/v2/example/http_status.yml
Symbolic link
1
doc/source/api-reference/v2/example/http_status.yml
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../http_status.yml
|
||||||
15
doc/source/api-reference/v2/http_status.yml
Normal file
15
doc/source/api-reference/v2/http_status.yml
Normal 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.
|
||||||
3
doc/source/api-reference/v2/index.rst
Normal file
3
doc/source/api-reference/v2/index.rst
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.. rest_expand_all::
|
||||||
|
|
||||||
|
.. include:: example/example.inc
|
||||||
@@ -44,13 +44,14 @@ extensions = ['sphinx.ext.coverage',
|
|||||||
'sphinx.ext.graphviz',
|
'sphinx.ext.graphviz',
|
||||||
'stevedore.sphinxext',
|
'stevedore.sphinxext',
|
||||||
'oslo_config.sphinxext',
|
'oslo_config.sphinxext',
|
||||||
'sphinx.ext.autodoc',
|
|
||||||
'sphinx.ext.viewcode',
|
'sphinx.ext.viewcode',
|
||||||
'oslo_config.sphinxconfiggen',
|
'oslo_config.sphinxconfiggen',
|
||||||
'sphinx.ext.mathjax',
|
'sphinx.ext.mathjax',
|
||||||
'wsmeext.sphinxext',
|
'wsmeext.sphinxext',
|
||||||
|
'sphinx.ext.autodoc',
|
||||||
'sphinxcontrib.pecanwsme.rest',
|
'sphinxcontrib.pecanwsme.rest',
|
||||||
'sphinxcontrib.httpdomain',
|
'sphinxcontrib.httpdomain',
|
||||||
|
'os_api_ref',
|
||||||
'openstackdocstheme',
|
'openstackdocstheme',
|
||||||
'oslo_policy.sphinxext',
|
'oslo_policy.sphinxext',
|
||||||
'oslo_policy.sphinxpolicygen',
|
'oslo_policy.sphinxpolicygen',
|
||||||
|
|||||||
9
doc/source/developer/api/index.rst
Normal file
9
doc/source/developer/api/index.rst
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
=====
|
||||||
|
API
|
||||||
|
=====
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
:maxdepth: 2
|
||||||
|
|
||||||
|
tutorial
|
||||||
|
utils
|
||||||
293
doc/source/developer/api/tutorial.rst
Normal file
293
doc/source/developer/api/tutorial.rst
Normal 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/
|
||||||
13
doc/source/developer/api/utils.rst
Normal file
13
doc/source/developer/api/utils.rst
Normal 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
|
||||||
@@ -7,3 +7,4 @@ Developer Documentation
|
|||||||
|
|
||||||
collector
|
collector
|
||||||
storage
|
storage
|
||||||
|
api/index
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
[pipeline:cloudkitty+noauth]
|
[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: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
|
paste.app_factory = cloudkitty.api.app:app_factory
|
||||||
|
|
||||||
[filter:authtoken]
|
[filter:authtoken]
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ stevedore==1.5.0 # Apache-2.0
|
|||||||
tooz==1.28.0 # Apache-2.0
|
tooz==1.28.0 # Apache-2.0
|
||||||
voluptuous==0.11.1 # BSD-3
|
voluptuous==0.11.1 # BSD-3
|
||||||
influxdb==5.1.0 # MIT
|
influxdb==5.1.0 # MIT
|
||||||
|
Flask==1.0.2 # BSD
|
||||||
|
Flask-RESTful==0.3.5 # BSD
|
||||||
|
|
||||||
# test-requirements
|
# test-requirements
|
||||||
coverage==3.6 # Apache-2.0
|
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
|
doc8==0.6.0 # Apache-2.0
|
||||||
Pygments==2.2.0 # BSD
|
Pygments==2.2.0 # BSD
|
||||||
bandit==1.1.0 # Apache-2.0
|
bandit==1.1.0 # Apache-2.0
|
||||||
|
os-api-ref==1.0.0 # Apache-2.0
|
||||||
|
|||||||
11
releasenotes/notes/added-v2-api-1ef829355c2feea4.yaml
Normal file
11
releasenotes/notes/added-v2-api-1ef829355c2feea4.yaml
Normal 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
|
||||||
@@ -30,3 +30,5 @@ stevedore>=1.5.0 # Apache-2.0
|
|||||||
tooz>=1.28.0 # Apache-2.0
|
tooz>=1.28.0 # Apache-2.0
|
||||||
voluptuous>=0.11.1 # BSD License
|
voluptuous>=0.11.1 # BSD License
|
||||||
influxdb>=5.1.0,!=5.2.0 # MIT
|
influxdb>=5.1.0,!=5.2.0 # MIT
|
||||||
|
Flask>=1.0.2 # BSD
|
||||||
|
Flask-RESTful>=0.3.5 # BSD
|
||||||
|
|||||||
@@ -21,3 +21,4 @@ reno>=1.8.0 # Apache-2.0
|
|||||||
doc8>=0.6.0 # Apache-2.0
|
doc8>=0.6.0 # Apache-2.0
|
||||||
Pygments>=2.2.0 # BSD license
|
Pygments>=2.2.0 # BSD license
|
||||||
bandit>=1.1.0 # Apache-2.0
|
bandit>=1.1.0 # Apache-2.0
|
||||||
|
os-api-ref>=1.0.0 # Apache-2.0
|
||||||
|
|||||||
Reference in New Issue
Block a user