Use YAML text instead of JSON in HTTP body

* Use YAML text instead of JSON for workbooks,
   workflows and actions in HTTP body.
 * Use hook for validating Content-Type
 * Changed pecan dependency on pecan>=0.8.0 due to
   hook bug in pecan v0.6.0

Implements blueprint mistral-yaml-request-body

Change-Id: I5a55aa438faeca42385e8e25770bce00da2e93a9
This commit is contained in:
Nikolay Mahotkin 2014-11-20 15:56:32 +03:00
parent 7ddbf2060a
commit b85c37b1c1
9 changed files with 227 additions and 98 deletions

View File

@ -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."""

View File

@ -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)

View File

@ -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)

View File

@ -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."""

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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