diff --git a/mistral/api/controllers/resource.py b/mistral/api/controllers/resource.py index 307653c00..eaeeb3cba 100644 --- a/mistral/api/controllers/resource.py +++ b/mistral/api/controllers/resource.py @@ -14,6 +14,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +import json + from wsme import types as wtypes @@ -58,6 +60,27 @@ class Resource(wtypes.Base): return res + "]" + def to_string(self): + return json.dumps(self.to_dict()) + + +class ResourceList(Resource): + """Resource containing the list of other resources.""" + + def to_dict(self): + d = {} + + for attr in self._wsme_attributes: + attr_val = getattr(self, attr.name) + + if isinstance(attr_val, list): + if isinstance(attr_val[0], Resource): + d[attr.name] = [v.to_dict() for v in attr_val] + elif not isinstance(attr_val, wtypes.UnsetType): + d[attr.name] = attr_val + + return d + class Link(Resource): """Web link.""" diff --git a/mistral/api/controllers/v2/action.py b/mistral/api/controllers/v2/action.py index 002f46e76..b0f57c845 100644 --- a/mistral/api/controllers/v2/action.py +++ b/mistral/api/controllers/v2/action.py @@ -14,11 +14,14 @@ # See the License for the specific language governing permissions and # limitations under the License. +import pecan +from pecan import hooks from pecan import rest from wsme import types as wtypes import wsmeext.pecan as wsme_pecan from mistral.api.controllers import resource +from mistral.api.hooks import content_type as ct_hook from mistral.db.v2 import api as db_api from mistral import exceptions as exc from mistral.openstack.common import log as logging @@ -62,7 +65,7 @@ class Action(resource.Resource): updated_at='1970-01-01T00:00:00.000000') -class Actions(resource.Resource): +class Actions(resource.ResourceList): """A collection of Actions.""" actions = [Action] @@ -72,7 +75,12 @@ class Actions(resource.Resource): return cls(actions=[Action.sample()]) -class ActionsController(rest.RestController): +class ActionsController(rest.RestController, hooks.HookController): + # TODO(nmakhotkin): Have a discussion with pecan/WSME folks in order + # to have requests and response of different content types. Then + # delete ContentTypeHook. + __hooks__ = [ct_hook.ContentTypeHook("application/json", ['POST', 'PUT'])] + @rest_utils.wrap_wsme_controller_exception @wsme_pecan.wsexpose(Action, wtypes.text) def get(self, name): @@ -83,39 +91,43 @@ class ActionsController(rest.RestController): return Action.from_dict(db_model.to_dict()) - @rest_utils.wrap_wsme_controller_exception - @wsme_pecan.wsexpose(Actions, body=Action) - def put(self, action): + @rest_utils.wrap_pecan_controller_exception + @pecan.expose(content_type="text/plain") + def put(self): """Update one or more actions. - NOTE: Field 'definition' is allowed to have definitions + NOTE: This text is allowed to have definitions of multiple actions. In this case they all will be updated. """ - LOG.debug("Update action(s) [definition=%s]" % action.definition) + definition = pecan.request.text + LOG.debug("Update action(s) [definition=%s]" % definition) - db_models = actions.update_actions(action.definition) + db_acts = actions.update_actions(definition) + models_dicts = [db_act.to_dict() for db_act in db_acts] - actions_list = [Action.from_dict(db_model.to_dict()) - for db_model in db_models] + action_list = [Action.from_dict(act) for act in models_dicts] - return Actions(actions=actions_list) + return Actions(actions=action_list).to_string() - @rest_utils.wrap_wsme_controller_exception - @wsme_pecan.wsexpose(Actions, body=Action, status_code=201) - def post(self, action): + @rest_utils.wrap_pecan_controller_exception + @pecan.expose(content_type="text/plain") + def post(self): """Create a new action. - NOTE: Field 'definition' is allowed to have definitions + NOTE: This text is allowed to have definitions of multiple actions. In this case they all will be created. """ - LOG.debug("Create action(s) [definition=%s]" % action.definition) + definition = pecan.request.text + pecan.response.status = 201 - db_models = actions.create_actions(action.definition) + LOG.debug("Create action(s) [definition=%s]" % definition) - actions_list = [Action.from_dict(db_model.to_dict()) - for db_model in db_models] + db_acts = actions.create_actions(definition) + models_dicts = [db_act.to_dict() for db_act in db_acts] - return Actions(actions=actions_list) + action_list = [Action.from_dict(act) for act in models_dicts] + + return Actions(actions=action_list).to_string() @rest_utils.wrap_wsme_controller_exception @wsme_pecan.wsexpose(None, wtypes.text, status_code=204) diff --git a/mistral/api/controllers/v2/workbook.py b/mistral/api/controllers/v2/workbook.py index a255c4063..32d2617b6 100644 --- a/mistral/api/controllers/v2/workbook.py +++ b/mistral/api/controllers/v2/workbook.py @@ -14,11 +14,14 @@ # See the License for the specific language governing permissions and # limitations under the License. +import pecan +from pecan import hooks from pecan import rest from wsme import types as wtypes import wsmeext.pecan as wsme_pecan from mistral.api.controllers import resource +from mistral.api.hooks import content_type as ct_hook from mistral.db.v2 import api as db_api from mistral.openstack.common import log as logging from mistral.services import workbooks @@ -65,7 +68,9 @@ class Workbooks(resource.Resource): return cls(workbooks=[Workbook.sample()]) -class WorkbooksController(rest.RestController): +class WorkbooksController(rest.RestController, hooks.HookController): + __hooks__ = [ct_hook.ContentTypeHook("application/json", ['POST', 'PUT'])] + @rest_utils.wrap_wsme_controller_exception @wsme_pecan.wsexpose(Workbook, wtypes.text) def get(self, name): @@ -76,25 +81,28 @@ class WorkbooksController(rest.RestController): return Workbook.from_dict(db_model.to_dict()) - @rest_utils.wrap_wsme_controller_exception - @wsme_pecan.wsexpose(Workbook, body=Workbook) - def put(self, workbook): + @rest_utils.wrap_pecan_controller_exception + @pecan.expose(content_type="text/plain") + def put(self): """Update a workbook.""" - LOG.debug("Update workbook [workbook=%s]" % workbook) + definition = pecan.request.text + LOG.debug("Update workbook [definition=%s]" % definition) - db_model = workbooks.update_workbook_v2(workbook.to_dict()) + wb_db = workbooks.update_workbook_v2({'definition': definition}) - return Workbook.from_dict(db_model.to_dict()) + return Workbook.from_dict(wb_db.to_dict()).to_string() - @rest_utils.wrap_wsme_controller_exception - @wsme_pecan.wsexpose(Workbook, body=Workbook, status_code=201) - def post(self, workbook): + @rest_utils.wrap_pecan_controller_exception + @pecan.expose(content_type="text/plain") + def post(self): """Create a new workbook.""" - LOG.debug("Create workbook [workbook=%s]" % workbook) + definition = pecan.request.text + LOG.debug("Create workbook [definition=%s]" % definition) - db_model = workbooks.create_workbook_v2(workbook.to_dict()) + wb_db = workbooks.create_workbook_v2({'definition': definition}) + pecan.response.status = 201 - return Workbook.from_dict(db_model.to_dict()) + return Workbook.from_dict(wb_db.to_dict()).to_string() @rest_utils.wrap_wsme_controller_exception @wsme_pecan.wsexpose(None, wtypes.text, status_code=204) diff --git a/mistral/api/controllers/v2/workflow.py b/mistral/api/controllers/v2/workflow.py index 125ed2b52..70a4c93ef 100644 --- a/mistral/api/controllers/v2/workflow.py +++ b/mistral/api/controllers/v2/workflow.py @@ -14,11 +14,14 @@ # See the License for the specific language governing permissions and # limitations under the License. +import pecan +from pecan import hooks from pecan import rest from wsme import types as wtypes import wsmeext.pecan as wsme_pecan from mistral.api.controllers import resource +from mistral.api.hooks import content_type as ct_hook from mistral.db.v2 import api as db_api from mistral.openstack.common import log as logging from mistral.services import workflows @@ -70,7 +73,7 @@ class Workflow(resource.Resource): return e -class Workflows(resource.Resource): +class Workflows(resource.ResourceList): """A collection of workflows.""" workflows = [Workflow] @@ -80,7 +83,12 @@ class Workflows(resource.Resource): return cls(workflows=[Workflow.sample()]) -class WorkflowsController(rest.RestController): +class WorkflowsController(rest.RestController, hooks.HookController): + # TODO(nmakhotkin): Have a discussion with pecan/WSME folks in order + # to have requests and response of different content types. Then + # delete ContentTypeHook. + __hooks__ = [ct_hook.ContentTypeHook("application/json", ['POST', 'PUT'])] + @rest_utils.wrap_wsme_controller_exception @wsme_pecan.wsexpose(Workflow, wtypes.text) def get(self, name): @@ -91,41 +99,46 @@ class WorkflowsController(rest.RestController): return Workflow.from_dict(db_model.to_dict()) - @rest_utils.wrap_wsme_controller_exception - @wsme_pecan.wsexpose(Workflows, body=Workflow) - def put(self, workflow): + @rest_utils.wrap_pecan_controller_exception + @pecan.expose(content_type="text/plain") + def put(self): """Update one or more workflows. - NOTE: Field 'definition' is allowed to have definitions + NOTE: The text is allowed to have definitions of multiple workflows. In this case they all will be updated. """ - LOG.debug("Update workflow(s) [definition=%s]" % workflow.definition) + definition = pecan.request.text - db_models = workflows.update_workflows(workflow.definition) + LOG.debug("Update workflow(s) [definition=%s]" % definition) - workflows_list = [Workflow.from_dict(db_model.to_dict()) - for db_model in db_models] + db_wfs = workflows.update_workflows(definition) + models_dicts = [db_wf.to_dict() for db_wf in db_wfs] - return Workflows(workflows=workflows_list) + workflow_list = [Workflow.from_dict(wf) for wf in models_dicts] - @rest_utils.wrap_wsme_controller_exception - @wsme_pecan.wsexpose(Workflows, body=Workflow, status_code=201) - def post(self, workflow): + return Workflows(workflows=workflow_list).to_string() + + @rest_utils.wrap_pecan_controller_exception + @pecan.expose(content_type="text/plain") + def post(self): """Create a new workflow. - NOTE: Field 'definition' is allowed to have definitions + NOTE: The text is allowed to have definitions of multiple workflows. In this case they all will be created. """ - LOG.debug("Create workflow(s) [definition=%s]" % workflow.definition) + definition = pecan.request.text + pecan.response.status = 201 - db_models = workflows.create_workflows(workflow.definition) + LOG.debug("Create workflow(s) [definition=%s]" % definition) - workflows_list = [Workflow.from_dict(db_model.to_dict()) - for db_model in db_models] + db_wfs = workflows.create_workflows(definition) + models_dicts = [db_wf.to_dict() for db_wf in db_wfs] - return Workflows(workflows=workflows_list) + workflow_list = [Workflow.from_dict(wf) for wf in models_dicts] - @rest_utils.wrap_wsme_controller_exception + return Workflows(workflows=workflow_list).to_string() + + @rest_utils.wrap_pecan_controller_exception @wsme_pecan.wsexpose(None, wtypes.text, status_code=204) def delete(self, name): """Delete the named workflow.""" diff --git a/mistral/api/hooks/content_type.py b/mistral/api/hooks/content_type.py new file mode 100644 index 000000000..029b75759 --- /dev/null +++ b/mistral/api/hooks/content_type.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2014 - Mirantis, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from pecan import hooks + + +class ContentTypeHook(hooks.PecanHook): + def __init__(self, content_type, methods=['GET']): + """Content type hook is needed for changing content type of + responses but only for some HTTP methods. This is kind of + 'hack' but it seems impossible using pecan/WSME to set different + content types on request and response. + + :param content_type: Content-Type that response should has. + :type content_type: str + :param methods: HTTP methods that should have response + with given content_type. + :type methods: list + """ + self.content_type = content_type + self.methods = methods + + def after(self, state): + if state.request.method in self.methods: + state.response.content_type = self.content_type diff --git a/mistral/tests/unit/api/v2/test_actions.py b/mistral/tests/unit/api/v2/test_actions.py index 106fd7b51..348ff75c7 100644 --- a/mistral/tests/unit/api/v2/test_actions.py +++ b/mistral/tests/unit/api/v2/test_actions.py @@ -110,20 +110,22 @@ class TestActionsController(base.FunctionalTest): @mock.patch.object(db_api, "get_action", MOCK_ACTION) @mock.patch.object(db_api, "create_or_update_action", MOCK_UPDATED_ACTION) def test_put(self): - resp = self.app.put_json('/v2/actions', UPDATED_ACTION) + resp = self.app.put( + '/v2/actions', + UPDATED_ACTION_DEFINITION, + headers={'Content-Type': 'text/plain'} + ) self.assertEqual(resp.status_int, 200) - self.assertDictEqual( - {'actions': [UPDATED_ACTION]}, - resp.json - ) + self.assertEqual({"actions": [UPDATED_ACTION]}, resp.json) @mock.patch.object(db_api, "create_or_update_action", MOCK_NOT_FOUND) def test_put_not_found(self): - resp = self.app.put_json( + resp = self.app.put( '/v2/actions', - UPDATED_ACTION, + UPDATED_ACTION_DEFINITION, + headers={'Content-Type': 'text/plain'}, expect_errors=True ) @@ -131,27 +133,29 @@ class TestActionsController(base.FunctionalTest): @mock.patch.object(db_api, "get_action", MOCK_SYSTEM_ACTION) def test_put_system(self): - resp = self.app.put_json( + resp = self.app.put( '/v2/actions', - SYSTEM_ACTION, + SYSTEM_ACTION_DEFINITION, + headers={'Content-Type': 'text/plain'}, expect_errors=True ) self.assertEqual(resp.status_int, 400) self.assertIn('Attempt to modify a system action: std.echo', - resp.json['faultstring']) + resp.text) @mock.patch.object(db_api, "create_action") def test_post(self, mock_mtd): mock_mtd.return_value = ACTION_DB - resp = self.app.post_json('/v2/actions', ACTION) + resp = self.app.post( + '/v2/actions', + ACTION_DEFINITION, + headers={'Content-Type': 'text/plain'} + ) self.assertEqual(resp.status_int, 201) - self.assertDictEqual( - {'actions': [ACTION]}, - resp.json - ) + self.assertEqual({"actions": [ACTION]}, resp.json) mock_mtd.assert_called_once() @@ -166,8 +170,11 @@ class TestActionsController(base.FunctionalTest): @mock.patch.object(db_api, "create_action", MOCK_DUPLICATE) def test_post_dup(self): - resp = self.app.post_json( - '/v2/actions', ACTION, expect_errors=True + resp = self.app.post( + '/v2/actions', + ACTION_DEFINITION, + headers={'Content-Type': 'text/plain'}, + expect_errors=True ) self.assertEqual(resp.status_int, 409) diff --git a/mistral/tests/unit/api/v2/test_workbooks.py b/mistral/tests/unit/api/v2/test_workbooks.py index 294fe93b6..85854df29 100644 --- a/mistral/tests/unit/api/v2/test_workbooks.py +++ b/mistral/tests/unit/api/v2/test_workbooks.py @@ -24,10 +24,14 @@ from mistral import exceptions as exc from mistral.services import workbooks from mistral.tests.unit.api import base +WORKBOOK_DEF = '---' + +UPDATED_WORKBOOK_DEF = '---\nVersion: 2.0' + WORKBOOK_DB = models.Workbook( id='123', name='book', - definition='---', + definition=WORKBOOK_DEF, tags=['deployment', 'demo'], scope="public", created_at=datetime.datetime(1970, 1, 1), @@ -37,7 +41,7 @@ WORKBOOK_DB = models.Workbook( WORKBOOK = { 'id': '123', 'name': 'book', - 'definition': '---', + 'definition': WORKBOOK_DEF, 'tags': ['deployment', 'demo'], 'scope': 'public', 'created_at': '1970-01-01 00:00:00', @@ -45,9 +49,9 @@ WORKBOOK = { } UPDATED_WORKBOOK_DB = copy.copy(WORKBOOK_DB) -UPDATED_WORKBOOK_DB['definition'] = '---\nVersion: 2.0' +UPDATED_WORKBOOK_DB['definition'] = UPDATED_WORKBOOK_DEF UPDATED_WORKBOOK = copy.copy(WORKBOOK) -UPDATED_WORKBOOK['definition'] = '---\nVersion: 2.0' +UPDATED_WORKBOOK['definition'] = UPDATED_WORKBOOK_DEF MOCK_WORKBOOK = mock.MagicMock(return_value=WORKBOOK_DB) MOCK_WORKBOOKS = mock.MagicMock(return_value=[WORKBOOK_DB]) @@ -74,29 +78,45 @@ class TestWorkbooksController(base.FunctionalTest): @mock.patch.object(workbooks, "update_workbook_v2", MOCK_UPDATED_WORKBOOK) def test_put(self): - resp = self.app.put_json('/v2/workbooks', UPDATED_WORKBOOK) + resp = self.app.put( + '/v2/workbooks', + UPDATED_WORKBOOK_DEF, + headers={'Content-Type': 'text/plain'} + ) self.assertEqual(resp.status_int, 200) - self.assertDictEqual(UPDATED_WORKBOOK, resp.json) + self.assertEqual(UPDATED_WORKBOOK, resp.json) @mock.patch.object(workbooks, "update_workbook_v2", MOCK_NOT_FOUND) def test_put_not_found(self): - resp = self.app.put_json('/v2/workbooks', UPDATED_WORKBOOK, - expect_errors=True) + resp = self.app.put_json( + '/v2/workbooks', + UPDATED_WORKBOOK_DEF, + headers={'Content-Type': 'text/plain'}, + expect_errors=True + ) self.assertEqual(resp.status_int, 404) @mock.patch.object(workbooks, "create_workbook_v2", MOCK_WORKBOOK) def test_post(self): - resp = self.app.post_json('/v2/workbooks', WORKBOOK) + resp = self.app.post( + '/v2/workbooks', + WORKBOOK_DEF, + headers={'Content-Type': 'text/plain'} + ) self.assertEqual(resp.status_int, 201) - self.assertDictEqual(WORKBOOK, resp.json) + self.assertEqual(WORKBOOK, resp.json) @mock.patch.object(workbooks, "create_workbook_v2", MOCK_DUPLICATE) def test_post_dup(self): - resp = self.app.post_json('/v2/workbooks', WORKBOOK, - expect_errors=True) + resp = self.app.post( + '/v2/workbooks', + WORKBOOK_DEF, + headers={'Content-Type': 'text/plain'}, + expect_errors=True + ) self.assertEqual(resp.status_int, 409) diff --git a/mistral/tests/unit/api/v2/test_workflows.py b/mistral/tests/unit/api/v2/test_workflows.py index 127d84953..1715ffb53 100644 --- a/mistral/tests/unit/api/v2/test_workflows.py +++ b/mistral/tests/unit/api/v2/test_workflows.py @@ -98,22 +98,24 @@ class TestWorkflowsController(base.FunctionalTest): @mock.patch.object(db_api, "create_or_update_workflow", MOCK_UPDATED_WF) def test_put(self): - resp = self.app.put_json('/v2/workflows', UPDATED_WF) + resp = self.app.put( + '/v2/workflows', + UPDATED_WF_DEFINITION, + headers={'Content-Type': 'text/plain'} + ) self.maxDiff = None self.assertEqual(resp.status_int, 200) - self.assertDictEqual( - {'workflows': [UPDATED_WF]}, - resp.json - ) + self.assertDictEqual({'workflows': [UPDATED_WF]}, resp.json) @mock.patch.object(db_api, "create_or_update_workflow", MOCK_NOT_FOUND) def test_put_not_found(self): - resp = self.app.put_json( + resp = self.app.put( '/v2/workflows', - UPDATED_WF, - expect_errors=True + UPDATED_WF_DEFINITION, + headers={'Content-Type': 'text/plain'}, + expect_errors=True, ) self.assertEqual(resp.status_int, 404) @@ -122,13 +124,14 @@ class TestWorkflowsController(base.FunctionalTest): def test_post(self, mock_mtd): mock_mtd.return_value = WF_DB - resp = self.app.post_json('/v2/workflows', WF) + resp = self.app.post( + '/v2/workflows', + WF_DEFINITION, + headers={'Content-Type': 'text/plain'} + ) self.assertEqual(resp.status_int, 201) - self.assertDictEqual( - {'workflows': [WF]}, - resp.json - ) + self.assertDictEqual({'workflows': [WF]}, resp.json) mock_mtd.assert_called_once() @@ -139,7 +142,12 @@ class TestWorkflowsController(base.FunctionalTest): @mock.patch.object(db_api, "create_workflow", MOCK_DUPLICATE) def test_post_dup(self): - resp = self.app.post_json('/v2/workflows', WF, expect_errors=True) + resp = self.app.post( + '/v2/workflows', + WF_DEFINITION, + headers={'Content-Type': 'text/plain'}, + expect_errors=True + ) self.assertEqual(resp.status_int, 409) diff --git a/requirements.txt b/requirements.txt index 7595402bb..780bcbaa5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ pbr>=0.6,!=0.7,<1.0 eventlet>=0.15.0 PyYAML>=3.1.0 -pecan>=0.5.0 +pecan>=0.8.0 WSME>=0.6 amqplib>=0.6.1 # This is not in global requirements (master branch) argparse