diff --git a/AUTHORS.md b/AUTHORS.md index 28c69cbde..e85996125 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -43,3 +43,4 @@ Moto is written by Steve Pulec with contributions from: * [Dustin J. Mitchell](https://github.com/djmitche) * [Jean-Baptiste Barth](https://github.com/jbbarth) * [Tom Viner](https://github.com/tomviner) +* [Justin Wiley](https://github.com/SectorNine50) diff --git a/moto/backends.py b/moto/backends.py index d1262a7cb..0cbcf4810 100644 --- a/moto/backends.py +++ b/moto/backends.py @@ -1,28 +1,30 @@ from __future__ import unicode_literals + from moto.apigateway import apigateway_backend from moto.autoscaling import autoscaling_backend from moto.awslambda import lambda_backend -from moto.cloudwatch import cloudwatch_backend from moto.cloudformation import cloudformation_backend +from moto.cloudwatch import cloudwatch_backend from moto.datapipeline import datapipeline_backend from moto.dynamodb import dynamodb_backend from moto.dynamodb2 import dynamodb_backend2 from moto.ec2 import ec2_backend from moto.elb import elb_backend from moto.emr import emr_backend +from moto.events import events_backend from moto.glacier import glacier_backend from moto.iam import iam_backend -from moto.opsworks import opsworks_backend from moto.kinesis import kinesis_backend from moto.kms import kms_backend +from moto.opsworks import opsworks_backend from moto.rds import rds_backend from moto.redshift import redshift_backend +from moto.route53 import route53_backend from moto.s3 import s3_backend from moto.ses import ses_backend from moto.sns import sns_backend from moto.sqs import sqs_backend from moto.sts import sts_backend -from moto.route53 import route53_backend BACKENDS = { 'apigateway': apigateway_backend, @@ -34,6 +36,7 @@ BACKENDS = { 'dynamodb2': dynamodb_backend2, 'ec2': ec2_backend, 'elb': elb_backend, + 'events': events_backend, 'emr': emr_backend, 'glacier': glacier_backend, 'iam': iam_backend, diff --git a/moto/events/__init__.py b/moto/events/__init__.py new file mode 100644 index 000000000..8b15e852a --- /dev/null +++ b/moto/events/__init__.py @@ -0,0 +1,5 @@ +from __future__ import unicode_literals + +from .models import events_backend + +mock_events = events_backend.decorator diff --git a/moto/events/models.py b/moto/events/models.py new file mode 100644 index 000000000..94cca5ee7 --- /dev/null +++ b/moto/events/models.py @@ -0,0 +1,191 @@ +import os +import re + +from moto.core import BaseBackend + + +class Rule(object): + + def _generate_arn(self, name): + return 'arn:aws:events:us-west-2:111111111111:rule/' + name + + def __init__(self, name, **kwargs): + self.name = name + self.arn = kwargs.get('Arn') or self._generate_arn(name) + self.event_pattern = kwargs.get('EventPattern') + self.schedule_exp = kwargs.get('ScheduleExpression') + self.state = kwargs.get('State') or 'ENABLED' + self.description = kwargs.get('Description') + self.role_arn = kwargs.get('RoleArn') + self.targets = [] + + def enable(self): + self.state = 'ENABLED' + + def disable(self): + self.state = 'DISABLED' + + # This song and dance for targets is because we need order for Limits and NextTokens, but can't use OrderedDicts + # with Python 2.6, so tracking it with an array it is. + def _check_target_exists(self, target_id): + for i in range(0, len(self.targets)): + if target_id == self.targets[i]['Id']: + return i + return None + + def put_targets(self, targets): + # Not testing for valid ARNs. + for target in targets: + index = self._check_target_exists(target['Id']) + if index is not None: + self.targets[index] = target + else: + self.targets.append(target) + + def remove_targets(self, ids): + for target_id in ids: + index = self._check_target_exists(target_id) + if index is not None: + self.targets.pop(index) + + +class EventsBackend(BaseBackend): + + def __init__(self): + self.rules = {} + # This array tracks the order in which the rules have been added, since 2.6 doesn't have OrderedDicts. + self.rules_order = [] + self.next_tokens = {} + + def _get_rule_by_index(self, i): + return self.rules.get(self.rules_order[i]) + + def _gen_next_token(self, index): + token = os.urandom(128).encode('base64') + self.next_tokens[token] = index + return token + + def _process_token_and_limits(self, array_len, next_token=None, limit=None): + start_index = 0 + end_index = array_len + new_next_token = None + + if next_token: + start_index = self.next_tokens.pop(next_token, 0) + + if limit is not None: + new_end_index = start_index + int(limit) + if new_end_index < end_index: + end_index = new_end_index + new_next_token = self._gen_next_token(end_index) + + return start_index, end_index, new_next_token + + def delete_rule(self, name): + self.rules_order.pop(self.rules_order.index(name)) + return self.rules.pop(name) is not None + + def describe_rule(self, name): + return self.rules.get(name) + + def disable_rule(self, name): + if name in self.rules: + self.rules[name].disable() + return True + + return False + + def enable_rule(self, name): + if name in self.rules: + self.rules[name].enable() + return True + + return False + + def list_rule_names_by_target(self, target_arn, next_token=None, limit=None): + matching_rules = [] + return_obj = {} + + start_index, end_index, new_next_token = self._process_token_and_limits(len(self.rules), next_token, limit) + + for i in range(start_index, end_index): + rule = self._get_rule_by_index(i) + for target in rule.targets: + if target['Arn'] == target_arn: + matching_rules.append(rule.name) + + return_obj['RuleNames'] = matching_rules + if new_next_token is not None: + return_obj['NextToken'] = new_next_token + + return return_obj + + def list_rules(self, prefix=None, next_token=None, limit=None): + match_string = '.*' + if prefix is not None: + match_string = '^' + prefix + match_string + + match_regex = re.compile(match_string) + + matching_rules = [] + return_obj = {} + + start_index, end_index, new_next_token = self._process_token_and_limits(len(self.rules), next_token, limit) + + for i in range(start_index, end_index): + rule = self._get_rule_by_index(i) + if match_regex.match(rule.name): + matching_rules.append(rule) + + return_obj['Rules'] = matching_rules + if new_next_token is not None: + return_obj['NextToken'] = new_next_token + + return return_obj + + def list_targets_by_rule(self, rule, next_token=None, limit=None): + # We'll let a KeyError exception be thrown for response to handle if rule doesn't exist. + rule = self.rules[rule] + + start_index, end_index, new_next_token = self._process_token_and_limits(len(rule.targets), next_token, limit) + + returned_targets = [] + return_obj = {} + + for i in range(start_index, end_index): + returned_targets.append(rule.targets[i]) + + return_obj['Targets'] = returned_targets + if new_next_token is not None: + return_obj['NextToken'] = new_next_token + + return return_obj + + def put_rule(self, name, **kwargs): + rule = Rule(name, **kwargs) + self.rules[rule.name] = rule + self.rules_order.append(rule.name) + return rule.arn + + def put_targets(self, name, targets): + rule = self.rules.get(name) + + if rule: + rule.put_targets(targets) + return True + + return False + + def remove_targets(self, name, ids): + rule = self.rules.get(name) + + if rule: + rule.remove_targets(ids) + return True + + return False + + def test_event_pattern(self): + raise NotImplementedError() + +events_backend = EventsBackend() diff --git a/moto/events/responses.py b/moto/events/responses.py new file mode 100644 index 000000000..7d63388b7 --- /dev/null +++ b/moto/events/responses.py @@ -0,0 +1,195 @@ +import json +import re + +from moto.core.responses import BaseResponse +from moto.events import events_backend + + +class EventsHandler(BaseResponse): + + def _generate_rule_dict(self, rule): + return { + 'Name': rule.name, + 'Arn': rule.arn, + 'EventPattern': rule.event_pattern, + 'State': rule.state, + 'Description': rule.description, + 'ScheduleExpression': rule.schedule_exp, + 'RoleArn': rule.role_arn + } + + def load_body(self): + decoded_body = self.body.decode('utf-8') + return json.loads(decoded_body or '{}') + + def error(self, type_, message='', status=400): + headers = self.response_headers + headers['status'] = status + return json.dumps({'__type': type_, 'message': message}), headers, + + def delete_rule(self): + body = self.load_body() + name = body.get('NamePrefix') + + if not name: + return self.error('ValidationException', 'Parameter Name is required.') + + return '', self.response_headers + + def describe_rule(self): + body = self.load_body() + name = body.get('Name') + + if not name: + return self.error('ValidationException', 'Parameter Name is required.') + + rule = events_backend.describe_rule(name) + + if not rule: + return self.error('ResourceNotFoundException', 'Rule test does not exist.') + + rule_dict = self._generate_rule_dict(rule) + return json.dumps(rule_dict), self.response_headers + + def disable_rule(self): + body = self.load_body() + name = body.get('Name') + + if not name: + return self.error('ValidationException', 'Parameter Name is required.') + + if not events_backend.disable_rule(name): + return self.error('ResourceNotFoundException', 'Rule ' + name + ' does not exist.') + + return '', self.response_headers + + def enable_rule(self): + body = self.load_body() + name = body.get('Name') + + if not name: + return self.error('ValidationException', 'Parameter Name is required.') + + if not events_backend.enable_rule(name): + return self.error('ResourceNotFoundException', 'Rule ' + name + ' does not exist.') + + return '', self.response_headers + + def generate_presigned_url(self): + pass + + def list_rule_names_by_target(self): + body = self.load_body() + target_arn = body.get('TargetArn') + next_token = body.get('NextToken') + limit = body.get('Limit') + + if not target_arn: + return self.error('ValidationException', 'Parameter TargetArn is required.') + + rule_names = events_backend.list_rule_names_by_target(target_arn, next_token, limit) + + return json.dumps(rule_names), self.response_headers + + def list_rules(self): + body = self.load_body() + prefix = body.get('NamePrefix') + next_token = body.get('NextToken') + limit = body.get('Limit') + + rules = events_backend.list_rules(prefix, next_token, limit) + rules_obj = {'Rules': []} + + for rule in rules['Rules']: + rules_obj['Rules'].append(self._generate_rule_dict(rule)) + + if rules.get('NextToken'): + rules_obj['NextToken'] = rules['NextToken'] + + return json.dumps(rules_obj), self.response_headers + + def list_targets_by_rule(self): + body = self.load_body() + rule_name = body.get('Rule') + next_token = body.get('NextToken') + limit = body.get('Limit') + + if not rule_name: + return self.error('ValidationException', 'Parameter Rule is required.') + + try: + targets = events_backend.list_targets_by_rule(rule_name, next_token, limit) + except KeyError: + return self.error('ResourceNotFoundException', 'Rule ' + rule_name + ' does not exist.') + + return json.dumps(targets), self.response_headers + + def put_events(self): + return '', self.response_headers + + def put_rule(self): + body = self.load_body() + name = body.get('Name') + event_pattern = body.get('EventPattern') + sched_exp = body.get('ScheduleExpression') + + if not name: + return self.error('ValidationException', 'Parameter Name is required.') + + if event_pattern: + try: + json.loads(event_pattern) + except ValueError: + # Not quite as informative as the real error, but it'll work for now. + return self.error('InvalidEventPatternException', 'Event pattern is not valid.') + + if sched_exp: + if not (re.match('^cron\(.*\)', sched_exp) or + re.match('^rate\(\d*\s(minute|minutes|hour|hours|day|days)\)', sched_exp)): + return self.error('ValidationException', 'Parameter ScheduleExpression is not valid.') + + rule_arn = events_backend.put_rule( + name, + ScheduleExpression=sched_exp, + EventPattern=event_pattern, + State=body.get('State'), + Description=body.get('Description'), + RoleArn=body.get('RoleArn') + ) + + return json.dumps({'RuleArn': rule_arn}), self.response_headers + + def put_targets(self): + body = self.load_body() + rule_name = body.get('Rule') + targets = body.get('Targets') + + if not rule_name: + return self.error('ValidationException', 'Parameter Rule is required.') + + if not targets: + return self.error('ValidationException', 'Parameter Targets is required.') + + if not events_backend.put_targets(rule_name, targets): + return self.error('ResourceNotFoundException', 'Rule ' + rule_name + ' does not exist.') + + return '', self.response_headers + + def remove_targets(self): + body = self.load_body() + rule_name = body.get('Rule') + ids = body.get('Ids') + + if not rule_name: + return self.error('ValidationException', 'Parameter Rule is required.') + + if not ids: + return self.error('ValidationException', 'Parameter Ids is required.') + + if not events_backend.remove_targets(rule_name, ids): + return self.error('ResourceNotFoundException', 'Rule ' + rule_name + ' does not exist.') + + return '', self.response_headers + + def test_event_pattern(self): + pass diff --git a/moto/events/urls.py b/moto/events/urls.py new file mode 100644 index 000000000..bff05da3f --- /dev/null +++ b/moto/events/urls.py @@ -0,0 +1,11 @@ +from __future__ import unicode_literals + +from .responses import EventsHandler + +url_bases = [ + "https://events.(.+).amazonaws.com" +] + +url_paths = { + "{0}/": EventsHandler.dispatch, +} diff --git a/tests/test_events/test_events.py b/tests/test_events/test_events.py new file mode 100644 index 000000000..a2d5a5d47 --- /dev/null +++ b/tests/test_events/test_events.py @@ -0,0 +1,173 @@ +import random + +import boto3 + +from moto.events import mock_events + + +RULES = [ + {'Name': 'test1', 'ScheduleExpression': 'rate(5 minutes)'}, + {'Name': 'test2', 'ScheduleExpression': 'rate(1 minute)'}, + {'Name': 'test3', 'EventPattern': '{"source": ["test-source"]}'} +] + +TARGETS = { + 'test-target-1': { + 'Id': 'test-target-1', + 'Arn': 'arn:aws:lambda:us-west-2:111111111111:function:test-function-1', + 'Rules': ['test1', 'test2'] + }, + 'test-target-2': { + 'Id': 'test-target-2', + 'Arn': 'arn:aws:lambda:us-west-2:111111111111:function:test-function-2', + 'Rules': ['test1', 'test3'] + }, + 'test-target-3': { + 'Id': 'test-target-3', + 'Arn': 'arn:aws:lambda:us-west-2:111111111111:function:test-function-3', + 'Rules': ['test1', 'test2'] + }, + 'test-target-4': { + 'Id': 'test-target-4', + 'Arn': 'arn:aws:lambda:us-west-2:111111111111:function:test-function-4', + 'Rules': ['test1', 'test3'] + }, + 'test-target-5': { + 'Id': 'test-target-5', + 'Arn': 'arn:aws:lambda:us-west-2:111111111111:function:test-function-5', + 'Rules': ['test1', 'test2'] + }, + 'test-target-6': { + 'Id': 'test-target-6', + 'Arn': 'arn:aws:lambda:us-west-2:111111111111:function:test-function-6', + 'Rules': ['test1', 'test3'] + } +} + + +def get_random_rule(): + return RULES[random.randint(0, len(RULES) - 1)] + + +@mock_events +def generate_environment(): + client = boto3.client('events', 'us-west-2') + + for rule in RULES: + client.put_rule( + Name=rule['Name'], + ScheduleExpression=rule.get('ScheduleExpression', ''), + EventPattern=rule.get('EventPattern', '') + ) + + targets = [] + for target in TARGETS: + if rule['Name'] in TARGETS[target].get('Rules'): + targets.append({'Id': target, 'Arn': TARGETS[target]['Arn']}) + + client.put_targets(Rule=rule['Name'], Targets=targets) + + return client + + +@mock_events +def test_list_rules(): + client = generate_environment() + response = client.list_rules() + + assert(response is not None) + assert(len(response['Rules']) > 0) + + +@mock_events +def test_describe_rule(): + rule_name = get_random_rule()['Name'] + client = generate_environment() + response = client.describe_rule(Name=rule_name) + + assert(response is not None) + assert(response.get('Name') == rule_name) + assert(response.get('Arn') is not None) + + +@mock_events +def test_enable_disable_rule(): + rule_name = get_random_rule()['Name'] + client = generate_environment() + + # Rules should start out enabled in these tests. + rule = client.describe_rule(Name=rule_name) + assert(rule['State'] == 'ENABLED') + + client.disable_rule(Name=rule_name) + rule = client.describe_rule(Name=rule_name) + assert(rule['State'] == 'DISABLED') + + client.enable_rule(Name=rule_name) + rule = client.describe_rule(Name=rule_name) + assert(rule['State'] == 'ENABLED') + + +@mock_events +def test_list_rule_names_by_target(): + test_1_target = TARGETS['test-target-1'] + test_2_target = TARGETS['test-target-2'] + client = generate_environment() + + rules = client.list_rule_names_by_target(TargetArn=test_1_target['Arn']) + assert(len(rules) == len(test_1_target['Rules'])) + for rule in rules['RuleNames']: + assert(rule in test_1_target['Rules']) + + rules = client.list_rule_names_by_target(TargetArn=test_2_target['Arn']) + assert(len(rules) == len(test_2_target['Rules'])) + for rule in rules['RuleNames']: + assert(rule in test_2_target['Rules']) + + +@mock_events +def test_list_rules(): + client = generate_environment() + + rules = client.list_rules() + assert(len(rules['Rules']) == len(RULES)) + + +@mock_events +def test_list_targets_by_rule(): + rule_name = get_random_rule()['Name'] + client = generate_environment() + targets = client.list_targets_by_rule(Rule=rule_name) + + expected_targets = [] + for target in TARGETS: + if rule_name in TARGETS[target].get('Rules'): + expected_targets.append(target) + + assert(len(targets['Targets']) == len(expected_targets)) + + +@mock_events +def test_remove_targets(): + rule_name = get_random_rule()['Name'] + client = generate_environment() + + targets = client.list_targets_by_rule(Rule=rule_name)['Targets'] + targets_before = len(targets) + assert(targets_before > 0) + + client.remove_targets(Rule=rule_name, Ids=[targets[0]['Id']]) + + targets = client.list_targets_by_rule(Rule=rule_name)['Targets'] + targets_after = len(targets) + assert(targets_before - 1 == targets_after) + + +if __name__ == '__main__': + test_list_rules() + test_describe_rule() + test_enable_disable_rule() + test_list_rule_names_by_target() + test_list_rules() + test_list_targets_by_rule() + test_remove_targets()