diff --git a/moto/swf/constants.py b/moto/swf/constants.py new file mode 100644 index 000000000..75a3de5a9 --- /dev/null +++ b/moto/swf/constants.py @@ -0,0 +1,85 @@ +# List decision fields and if they're required or not +# +# See http://docs.aws.amazon.com/amazonswf/latest/apireference/API_RespondDecisionTaskCompleted.html +# and subsequent docs for each decision type. +DECISIONS_FIELDS = { + "cancelTimerDecisionAttributes": { + "timerId": { "type": "string", "required": True } + }, + "cancelWorkflowExecutionDecisionAttributes": { + "details": { "type": "string", "required": False } + }, + "completeWorkflowExecutionDecisionAttributes": { + "result": { "type": "string", "required": False } + }, + "continueAsNewWorkflowExecutionDecisionAttributes": { + "childPolicy": { "type": "string", "required": False }, + "executionStartToCloseTimeout": { "type": "string", "required": False }, + "input": { "type": "string", "required": False }, + "lambdaRole": { "type": "string", "required": False }, + "tagList": { "type": "string", "array": True, "required": False }, + "taskList": { "type": "TaskList", "required": False }, + "taskPriority": { "type": "string", "required": False }, + "taskStartToCloseTimeout": { "type": "string", "required": False }, + "workflowTypeVersion": { "type": "string", "required": False } + }, + "failWorkflowExecutionDecisionAttributes": { + "details": { "type": "string", "required": False }, + "reason": { "type": "string", "required": False } + }, + "recordMarkerDecisionAttributes": { + "details": { "type": "string", "required": False }, + "markerName": { "type": "string", "required": True } + }, + "requestCancelActivityTaskDecisionAttributes": { + "activityId": { "type": "string", "required": True } + }, + "requestCancelExternalWorkflowExecutionDecisionAttributes": { + "control": { "type": "string", "required": False }, + "runId": { "type": "string", "required": False }, + "workflowId": { "type": "string", "required": True } + }, + "scheduleActivityTaskDecisionAttributes": { + "activityId": { "type": "string", "required": True }, + "activityType": { "type": "ActivityType", "required": True }, + "control": { "type": "string", "required": False }, + "heartbeatTimeout": { "type": "string", "required": False }, + "input": { "type": "string", "required": False }, + "scheduleToCloseTimeout": { "type": "string", "required": False }, + "scheduleToStartTimeout": { "type": "string", "required": False }, + "startToCloseTimeout": { "type": "string", "required": False }, + "taskList": { "type": "TaskList", "required": False }, + "taskPriority": { "type": "string", "required": False } + }, + "scheduleLambdaFunctionDecisionAttributes": { + "id": { "type": "string", "required": True }, + "input": { "type": "string", "required": False }, + "name": { "type": "string", "required": True }, + "startToCloseTimeout": { "type": "string", "required": False } + }, + "signalExternalWorkflowExecutionDecisionAttributes": { + "control": { "type": "string", "required": False }, + "input": { "type": "string", "required": False }, + "runId": { "type": "string", "required": False }, + "signalName": { "type": "string", "required": True }, + "workflowId": { "type": "string", "required": True } + }, + "startChildWorkflowExecutionDecisionAttributes": { + "childPolicy": { "type": "string", "required": False }, + "control": { "type": "string", "required": False }, + "executionStartToCloseTimeout": { "type": "string", "required": False }, + "input": { "type": "string", "required": False }, + "lambdaRole": { "type": "string", "required": False }, + "tagList": { "type": "string", "array": True, "required": False }, + "taskList": { "type": "TaskList", "required": False }, + "taskPriority": { "type": "string", "required": False }, + "taskStartToCloseTimeout": { "type": "string", "required": False }, + "workflowId": { "type": "string", "required": True }, + "workflowType": { "type": "WorkflowType", "required": True } + }, + "startTimerDecisionAttributes": { + "control": { "type": "string", "required": False }, + "startToFireTimeout": { "type": "string", "required": True }, + "timerId": { "type": "string", "required": True } + } +} diff --git a/moto/swf/models/history_event.py b/moto/swf/models/history_event.py index 4c7168b06..87e8f1e35 100644 --- a/moto/swf/models/history_event.py +++ b/moto/swf/models/history_event.py @@ -2,6 +2,8 @@ from __future__ import unicode_literals from datetime import datetime from time import mktime +from ..utils import decapitalize + class HistoryEvent(object): def __init__(self, event_id, event_type, **kwargs): @@ -23,8 +25,7 @@ class HistoryEvent(object): def _attributes_key(self): key = "{}EventAttributes".format(self.event_type) - key = key[0].lower() + key[1:] - return key + return decapitalize(key) def event_attributes(self): if self.event_type == "WorkflowExecutionStarted": diff --git a/moto/swf/models/workflow_execution.py b/moto/swf/models/workflow_execution.py index a9530ef70..8d297975a 100644 --- a/moto/swf/models/workflow_execution.py +++ b/moto/swf/models/workflow_execution.py @@ -3,11 +3,15 @@ import uuid from moto.core.utils import camelcase_to_underscores +from ..constants import ( + DECISIONS_FIELDS, +) from ..exceptions import ( SWFDefaultUndefinedFault, SWFValidationException, SWFDecisionValidationException, ) +from ..utils import decapitalize from .decision_task import DecisionTask from .history_event import HistoryEvent @@ -189,6 +193,19 @@ class WorkflowExecution(object): dt.complete() self.handle_decisions(evt.event_id, decisions) + def _check_decision_attributes(self, kind, value, decision_id): + problems = [] + constraints = DECISIONS_FIELDS.get(kind, {}) + for key, constraint in constraints.iteritems(): + if constraint["required"] and not value.get(key): + problems.append({ + "type": "null_value", + "where": "decisions.{}.member.{}.{}".format( + decision_id, kind, key + ) + }) + return problems + def validate_decisions(self, decisions): """ Performs some basic validations on decisions. The real SWF service @@ -202,7 +219,7 @@ class WorkflowExecution(object): problems = [] # check close decision is last - # TODO: see what happens on real SWF service if we ask for 2 close decisions + # (the real SWF service also works that way if you provide 2 close decision tasks) for dcs in decisions[:-1]: close_decision_types = [ "CompleteWorkflowExecution", @@ -217,7 +234,16 @@ class WorkflowExecution(object): decision_number = 0 for dcs in decisions: decision_number += 1 - # TODO: check decision types mandatory attributes + # check decision types mandatory attributes + # NB: the real SWF service seems to check attributes even for attributes list + # that are not in line with the decisionType, so we do the same + attrs_to_check = filter(lambda x: x.endswith("DecisionAttributes"), dcs.keys()) + if dcs["decisionType"] in self.KNOWN_DECISION_TYPES: + decision_type = dcs["decisionType"] + decision_attr = "{}DecisionAttributes".format(decapitalize(decision_type)) + attrs_to_check.append(decision_attr) + for attr in attrs_to_check: + problems += self._check_decision_attributes(attr, dcs.get(attr, {}), decision_number) # check decision type is correct if dcs["decisionType"] not in self.KNOWN_DECISION_TYPES: problems.append({ @@ -243,9 +269,7 @@ class WorkflowExecution(object): # handle each decision separately, in order for decision in decisions: decision_type = decision["decisionType"] - attributes_key = "{}{}EventAttributes".format( - decision_type[0].lower(), decision_type[1:] - ) + attributes_key = "{}EventAttributes".format(decapitalize(decision_type)) attributes = decision.get(attributes_key, {}) if decision_type == "CompleteWorkflowExecution": self.complete(event_id, attributes.get("result")) diff --git a/moto/swf/utils.py b/moto/swf/utils.py new file mode 100644 index 000000000..1b85f4ca9 --- /dev/null +++ b/moto/swf/utils.py @@ -0,0 +1,2 @@ +def decapitalize(key): + return key[0].lower() + key[1:] diff --git a/tests/test_swf/test_decision_tasks.py b/tests/test_swf/test_decision_tasks.py index cf83ec404..b67575b3f 100644 --- a/tests/test_swf/test_decision_tasks.py +++ b/tests/test_swf/test_decision_tasks.py @@ -191,3 +191,42 @@ def test_respond_decision_task_completed_with_invalid_decision_type(): SWFDecisionValidationException, r"Value 'BadDecisionType' at 'decisions.1.member.decisionType'" ) + +@mock_swf +def test_respond_decision_task_completed_with_missing_attributes(): + conn = setup_workflow() + resp = conn.poll_for_decision_task("test-domain", "queue") + task_token = resp["taskToken"] + + decisions = [ + { + "decisionType": "should trigger even with incorrect decision type", + "startTimerDecisionAttributes": {} + }, + ] + + conn.respond_decision_task_completed.when.called_with( + task_token, decisions=decisions + ).should.throw( + SWFDecisionValidationException, + r"Value null at 'decisions.1.member.startTimerDecisionAttributes.timerId' " \ + r"failed to satisfy constraint: Member must not be null" + ) + +@mock_swf +def test_respond_decision_task_completed_with_missing_attributes_totally(): + conn = setup_workflow() + resp = conn.poll_for_decision_task("test-domain", "queue") + task_token = resp["taskToken"] + + decisions = [ + { "decisionType": "StartTimer" }, + ] + + conn.respond_decision_task_completed.when.called_with( + task_token, decisions=decisions + ).should.throw( + SWFDecisionValidationException, + r"Value null at 'decisions.1.member.startTimerDecisionAttributes.timerId' " \ + r"failed to satisfy constraint: Member must not be null" + ) diff --git a/tests/test_swf/test_utils.py b/tests/test_swf/test_utils.py new file mode 100644 index 000000000..6d11ba5fc --- /dev/null +++ b/tests/test_swf/test_utils.py @@ -0,0 +1,11 @@ +from sure import expect +from moto.swf.utils import decapitalize + +def test_decapitalize(): + cases = { + "fooBar": "fooBar", + "FooBar": "fooBar", + "FOO BAR": "fOO BAR", + } + for before, after in cases.iteritems(): + decapitalize(before).should.equal(after)