diff --git a/cloudkitty/api/app.py b/cloudkitty/api/app.py index 9a708502..a94d6491 100644 --- a/cloudkitty/api/app.py +++ b/cloudkitty/api/app.py @@ -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__) @@ -48,43 +50,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 @@ -100,7 +90,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) diff --git a/cloudkitty/api/root.py b/cloudkitty/api/root.py index 90be9fa6..ff4d0301 100644 --- a/cloudkitty/api/root.py +++ b/cloudkitty/api/root.py @@ -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(), + } diff --git a/cloudkitty/api/v1/__init__.py b/cloudkitty/api/v1/__init__.py index e69de29b..1f1cfcb7 100644 --- a/cloudkitty/api/v1/__init__.py +++ b/cloudkitty/api/v1/__init__.py @@ -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, + ) diff --git a/cloudkitty/api/config.py b/cloudkitty/api/v1/config.py similarity index 93% rename from cloudkitty/api/config.py rename to cloudkitty/api/v1/config.py index 1dd1501f..5202b5fc 100644 --- a/cloudkitty/api/config.py +++ b/cloudkitty/api/v1/config.py @@ -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', diff --git a/cloudkitty/api/hooks.py b/cloudkitty/api/v1/hooks.py similarity index 100% rename from cloudkitty/api/hooks.py rename to cloudkitty/api/v1/hooks.py diff --git a/cloudkitty/api/v2/__init__.py b/cloudkitty/api/v2/__init__.py new file mode 100644 index 00000000..6d39cac4 --- /dev/null +++ b/cloudkitty/api/v2/__init__.py @@ -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 diff --git a/cloudkitty/api/v2/example/__init__.py b/cloudkitty/api/v2/example/__init__.py new file mode 100644 index 00000000..4c530473 --- /dev/null +++ b/cloudkitty/api/v2/example/__init__.py @@ -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 diff --git a/cloudkitty/api/v2/example/example.py b/cloudkitty/api/v2/example/example.py new file mode 100644 index 00000000..5537c6ec --- /dev/null +++ b/cloudkitty/api/v2/example/example.py @@ -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, + } diff --git a/cloudkitty/api/v2/utils.py b/cloudkitty/api/v2/utils.py new file mode 100644 index 00000000..75304599 --- /dev/null +++ b/cloudkitty/api/v2/utils.py @@ -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] diff --git a/cloudkitty/common/defaults.py b/cloudkitty/common/defaults.py index 1fc69893..7023beab 100644 --- a/cloudkitty/common/defaults.py +++ b/cloudkitty/common/defaults.py @@ -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', diff --git a/cloudkitty/common/policies/__init__.py b/cloudkitty/common/policies/__init__.py index ddf47f39..16605e43 100644 --- a/cloudkitty/common/policies/__init__.py +++ b/cloudkitty/common/policies/__init__.py @@ -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(), ) diff --git a/cloudkitty/common/policies/v1/__init__.py b/cloudkitty/common/policies/v1/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cloudkitty/common/policies/collector.py b/cloudkitty/common/policies/v1/collector.py similarity index 100% rename from cloudkitty/common/policies/collector.py rename to cloudkitty/common/policies/v1/collector.py diff --git a/cloudkitty/common/policies/info.py b/cloudkitty/common/policies/v1/info.py similarity index 100% rename from cloudkitty/common/policies/info.py rename to cloudkitty/common/policies/v1/info.py diff --git a/cloudkitty/common/policies/rating.py b/cloudkitty/common/policies/v1/rating.py similarity index 100% rename from cloudkitty/common/policies/rating.py rename to cloudkitty/common/policies/v1/rating.py diff --git a/cloudkitty/common/policies/report.py b/cloudkitty/common/policies/v1/report.py similarity index 100% rename from cloudkitty/common/policies/report.py rename to cloudkitty/common/policies/v1/report.py diff --git a/cloudkitty/common/policies/storage.py b/cloudkitty/common/policies/v1/storage.py similarity index 100% rename from cloudkitty/common/policies/storage.py rename to cloudkitty/common/policies/v1/storage.py diff --git a/cloudkitty/common/policies/v2/__init__.py b/cloudkitty/common/policies/v2/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cloudkitty/common/policies/v2/example.py b/cloudkitty/common/policies/v2/example.py new file mode 100644 index 00000000..eab5054b --- /dev/null +++ b/cloudkitty/common/policies/v2/example.py @@ -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 diff --git a/cloudkitty/tests/api/v2/__init__.py b/cloudkitty/tests/api/v2/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cloudkitty/tests/api/v2/test_utils.py b/cloudkitty/tests/api/v2/test_utils.py new file mode 100644 index 00000000..3af612b5 --- /dev/null +++ b/cloudkitty/tests/api/v2/test_utils.py @@ -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') diff --git a/cloudkitty/tests/gabbi/fixtures.py b/cloudkitty/tests/gabbi/fixtures.py index a44b3f5a..4b67bad4 100644 --- a/cloudkitty/tests/gabbi/fixtures.py +++ b/cloudkitty/tests/gabbi/fixtures.py @@ -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' diff --git a/cloudkitty/tests/gabbi/gabbits/root-v1-storage.yaml b/cloudkitty/tests/gabbi/gabbits/root-v1-storage.yaml new file mode 100644 index 00000000..f94afc01 --- /dev/null +++ b/cloudkitty/tests/gabbi/gabbits/root-v1-storage.yaml @@ -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 diff --git a/cloudkitty/tests/gabbi/gabbits/root-v2-storage.yaml b/cloudkitty/tests/gabbi/gabbits/root-v2-storage.yaml new file mode 100644 index 00000000..8515c1f0 --- /dev/null +++ b/cloudkitty/tests/gabbi/gabbits/root-v2-storage.yaml @@ -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 diff --git a/cloudkitty/tests/gabbi/gabbits/root.yaml b/cloudkitty/tests/gabbi/gabbits/root.yaml deleted file mode 100644 index 960e88ee..00000000 --- a/cloudkitty/tests/gabbi/gabbits/root.yaml +++ /dev/null @@ -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 diff --git a/cloudkitty/tests/gabbi/gabbits/v2-example.yaml b/cloudkitty/tests/gabbi/gabbits/v2-example.yaml new file mode 100644 index 00000000..a86469fc --- /dev/null +++ b/cloudkitty/tests/gabbi/gabbits/v2-example.yaml @@ -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 diff --git a/doc/source/_static/cloudkitty.policy.yaml.sample b/doc/source/_static/cloudkitty.policy.yaml.sample index 522986a0..86dd12dc 100644 --- a/doc/source/_static/cloudkitty.policy.yaml.sample +++ b/doc/source/_static/cloudkitty.policy.yaml.sample @@ -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": "" + diff --git a/doc/source/api-reference/index.rst b/doc/source/api-reference/index.rst index bf4de08e..ec45ccf2 100644 --- a/doc/source/api-reference/index.rst +++ b/doc/source/api-reference/index.rst @@ -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/* diff --git a/doc/source/api-reference/root.rst b/doc/source/api-reference/root.rst deleted file mode 100644 index d602b4f8..00000000 --- a/doc/source/api-reference/root.rst +++ /dev/null @@ -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: diff --git a/doc/source/api-reference/rating/hashmap.rst b/doc/source/api-reference/v1/rating/hashmap.rst similarity index 97% rename from doc/source/api-reference/rating/hashmap.rst rename to doc/source/api-reference/v1/rating/hashmap.rst index 88364042..c24abbb0 100644 --- a/doc/source/api-reference/rating/hashmap.rst +++ b/doc/source/api-reference/v1/rating/hashmap.rst @@ -1,5 +1,6 @@ +======================= HashMap Module REST API ------------------------ +======================= .. rest-controller:: cloudkitty.rating.hash.controllers.root:HashMapConfigController :webprefix: /v1/rating/module_config/hashmap diff --git a/doc/source/api-reference/rating/pyscripts.rst b/doc/source/api-reference/v1/rating/pyscripts.rst similarity index 90% rename from doc/source/api-reference/rating/pyscripts.rst rename to doc/source/api-reference/v1/rating/pyscripts.rst index 20af7540..8fd00883 100644 --- a/doc/source/api-reference/rating/pyscripts.rst +++ b/doc/source/api-reference/v1/rating/pyscripts.rst @@ -1,5 +1,6 @@ +========================= PyScripts Module REST API -------------------------- +========================= .. rest-controller:: cloudkitty.rating.pyscripts.controllers.root:PyScriptsConfigController :webprefix: /v1/rating/module_config/pyscripts diff --git a/doc/source/api-reference/v1.rst b/doc/source/api-reference/v1/v1.rst similarity index 97% rename from doc/source/api-reference/v1.rst rename to doc/source/api-reference/v1/v1.rst index d6900871..6977ad7f 100644 --- a/doc/source/api-reference/v1.rst +++ b/doc/source/api-reference/v1/v1.rst @@ -67,9 +67,6 @@ Rating .. autotype:: cloudkitty.api.v1.datamodels.rating.CloudkittyResourceCollection :members: -.. include:: rating/hashmap.rst - -.. include:: rating/pyscripts.rst Report ====== diff --git a/doc/source/api-reference/v2/example/example.inc b/doc/source/api-reference/v2/example/example.inc new file mode 100644 index 00000000..02d6b60b --- /dev/null +++ b/doc/source/api-reference/v2/example/example.inc @@ -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 diff --git a/doc/source/api-reference/v2/example/example_parameters.yml b/doc/source/api-reference/v2/example/example_parameters.yml new file mode 100644 index 00000000..67225e75 --- /dev/null +++ b/doc/source/api-reference/v2/example/example_parameters.yml @@ -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 " + type: string + required: true diff --git a/doc/source/api-reference/v2/example/http_status.yml b/doc/source/api-reference/v2/example/http_status.yml new file mode 120000 index 00000000..81a848d3 --- /dev/null +++ b/doc/source/api-reference/v2/example/http_status.yml @@ -0,0 +1 @@ +../http_status.yml \ No newline at end of file diff --git a/doc/source/api-reference/v2/http_status.yml b/doc/source/api-reference/v2/http_status.yml new file mode 100644 index 00000000..1abab29f --- /dev/null +++ b/doc/source/api-reference/v2/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. diff --git a/doc/source/api-reference/v2/index.rst b/doc/source/api-reference/v2/index.rst new file mode 100644 index 00000000..b12c7fdf --- /dev/null +++ b/doc/source/api-reference/v2/index.rst @@ -0,0 +1,3 @@ +.. rest_expand_all:: + +.. include:: example/example.inc diff --git a/doc/source/conf.py b/doc/source/conf.py index f59f56a0..e113c9e8 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -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', diff --git a/doc/source/developer/api/index.rst b/doc/source/developer/api/index.rst new file mode 100644 index 00000000..690eda29 --- /dev/null +++ b/doc/source/developer/api/index.rst @@ -0,0 +1,9 @@ +===== + API +===== + +.. toctree:: + :maxdepth: 2 + + tutorial + utils diff --git a/doc/source/developer/api/tutorial.rst b/doc/source/developer/api/tutorial.rst new file mode 100644 index 00000000..c939b54e --- /dev/null +++ b/doc/source/developer/api/tutorial.rst @@ -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/`` 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//``. + +.. _os_api_ref: https://docs.openstack.org/os-api-ref/latest/ diff --git a/doc/source/developer/api/utils.rst b/doc/source/developer/api/utils.rst new file mode 100644 index 00000000..1d095f91 --- /dev/null +++ b/doc/source/developer/api/utils.rst @@ -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 diff --git a/doc/source/developer/index.rst b/doc/source/developer/index.rst index 4004c56b..be03176b 100644 --- a/doc/source/developer/index.rst +++ b/doc/source/developer/index.rst @@ -7,3 +7,4 @@ Developer Documentation collector storage + api/index diff --git a/etc/cloudkitty/api_paste.ini b/etc/cloudkitty/api_paste.ini index 56107a72..0ff20277 100644 --- a/etc/cloudkitty/api_paste.ini +++ b/etc/cloudkitty/api_paste.ini @@ -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] diff --git a/lower-constraints.txt b/lower-constraints.txt index 7be8c564..db7d66fc 100644 --- a/lower-constraints.txt +++ b/lower-constraints.txt @@ -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 @@ -45,3 +47,4 @@ reno==1.8.0 # Apache2 sphinxcontrib-httpdomain==1.6.0 # Apache-2.0 doc8==0.6.0 # Apache-2.0 Pygments==2.2.0 # BSD +os-api-ref==1.0.0 # Apache-2.0 diff --git a/releasenotes/notes/added-v2-api-1ef829355c2feea4.yaml b/releasenotes/notes/added-v2-api-1ef829355c2feea4.yaml new file mode 100644 index 00000000..ea4b91ea --- /dev/null +++ b/releasenotes/notes/added-v2-api-1ef829355c2feea4.yaml @@ -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 diff --git a/requirements.txt b/requirements.txt index 616826f0..334eb429 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/test-requirements.txt b/test-requirements.txt index 1d028b4b..4705526f 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -20,3 +20,4 @@ sphinxcontrib-pecanwsme>=0.8 # Apache-2.0 reno>=1.8.0 # Apache-2.0 doc8>=0.6.0 # Apache-2.0 Pygments>=2.2.0 # BSD license +os-api-ref>=1.0.0 # Apache-2.0