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:
parent
4c69a86fcc
commit
dc2509ba65
|
@ -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)
|
||||
|
||||
|
|
|
@ -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(),
|
||||
}
|
||||
|
|
|
@ -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
|
||||
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',
|
|
@ -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
|
|
@ -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
|
|
@ -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,
|
||||
}
|
|
@ -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."""
|
||||
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',
|
||||
|
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
@ -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,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()
|
||||
|
||||
|
||||
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'
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
#"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::
|
||||
: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
|
||||
-----------------------
|
||||
=======================
|
||||
|
||||
.. rest-controller:: cloudkitty.rating.hash.controllers.root:HashMapConfigController
|
||||
:webprefix: /v1/rating/module_config/hashmap
|
|
@ -1,5 +1,6 @@
|
|||
=========================
|
||||
PyScripts Module REST API
|
||||
-------------------------
|
||||
=========================
|
||||
|
||||
.. rest-controller:: cloudkitty.rating.pyscripts.controllers.root:PyScriptsConfigController
|
||||
:webprefix: /v1/rating/module_config/pyscripts
|
|
@ -67,9 +67,6 @@ Rating
|
|||
.. autotype:: cloudkitty.api.v1.datamodels.rating.CloudkittyResourceCollection
|
||||
:members:
|
||||
|
||||
.. include:: rating/hashmap.rst
|
||||
|
||||
.. include:: rating/pyscripts.rst
|
||||
|
||||
Report
|
||||
======
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
../http_status.yml
|
|
@ -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.
|
|
@ -0,0 +1,3 @@
|
|||
.. rest_expand_all::
|
||||
|
||||
.. include:: example/example.inc
|
|
@ -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',
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
=====
|
||||
API
|
||||
=====
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
|
||||
tutorial
|
||||
utils
|
|
@ -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/
|
|
@ -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
|
||||
storage
|
||||
api/index
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue