├── .codeclimate.yml ├── .gitignore ├── .travis.yml ├── Dockerfile ├── README.md ├── docker-compose.yml ├── docs ├── Makefile └── source │ ├── _static │ └── .gitkeep │ ├── _templates │ └── .gitkeep │ ├── api.rst │ ├── conf.py │ └── index.rst ├── esser ├── __init__.py ├── cli │ ├── __init__.py │ ├── boilerplate │ │ ├── __init__.py │ │ ├── app.py │ │ ├── cloudformation │ │ │ ├── config │ │ │ │ └── env │ │ │ │ │ └── dynamodb.yaml │ │ │ └── templates │ │ │ │ └── dynamodb.yaml │ │ └── requirements.txt │ └── run.py ├── commands.py ├── constants.py ├── contrib │ ├── __init__.py │ └── repositories │ │ └── __init__.py ├── entities.py ├── event_handler.py ├── exceptions.py ├── handlers │ └── __init__.py ├── registry.py ├── repositories │ ├── __init__.py │ ├── base.py │ └── dynamodb │ │ ├── __init__.py │ │ └── models.py ├── signals │ ├── __init__.py │ └── decorators.py ├── utils.py └── validators.py ├── examples ├── __init__.py ├── app.py ├── basket │ ├── __init__.py │ ├── aggregate.py │ └── commands.py ├── items │ ├── __init__.py │ ├── aggregate.py │ ├── commands.py │ └── receivers.py └── requirements.txt ├── requirements ├── base.txt └── testing.txt ├── runtest.sh ├── setup.py └── tests ├── __init__.py ├── base.py ├── test_entity.py ├── test_event.py ├── test_handlers.py ├── test_registry.py ├── test_snapshot.py └── test_stream.py /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | --- 2 | engines: 3 | duplication: 4 | enabled: true 5 | config: 6 | languages: 7 | - python 8 | fixme: 9 | enabled: true 10 | radon: 11 | enabled: true 12 | pep8: 13 | enabled: true 14 | ratings: 15 | paths: 16 | - "**.py" 17 | exclude_paths: 18 | - tests/ -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.coverage 3 | 4 | # build 5 | *esser.egg-info/ 6 | *dist/ 7 | *build/ 8 | 9 | # docs 10 | *docs/build/* 11 | *target 12 | 13 | *esser.sublime-* -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | language: python 3 | services: 4 | - docker 5 | 6 | # cache: 7 | # directories: 8 | # - $CACHE_DIR 9 | 10 | # before_install: 11 | # - if [ -f ${CACHE_FILE_PYTHON} ]; then gunzip -c ${CACHE_FILE_PYTHON} | docker load; fi 12 | # - if [ -f ${CACHE_FILE_DYNAMODB} ]; then gunzip -c ${CACHE_FILE_DYNAMODB} | docker load; fi 13 | 14 | # install: 15 | # - mkdir -p $CACHE_DIR 16 | # - if [ ! -f ${CACHE_FILE_PYTHON} ]; then docker save python:2.7.12 | gzip > ${CACHE_FILE_PYTHON}; fi 17 | # - if [ ! -f ${CACHE_FILE_DYNAMODB} ]; then docker save peopleperhour/dynamodb:latest | gzip > ${CACHE_FILE_DYNAMODB}; fi 18 | 19 | # env: 20 | # global: 21 | # - CACHE_DIR=$HOME/.cache/docker 22 | # - CACHE_FILE_PYTHON=$CACHE_DIR/python.tar.gz 23 | # - CACHE_FILE_DYNAMODB=$CACHE_DIR/dynamodb.tar.gz 24 | 25 | script: 26 | - docker-compose run app 27 | 28 | deploy: 29 | - provider: pypi 30 | distributions: sdist 31 | user: "geeknam" 32 | password: 33 | secure: "Fy6mxAyBDy/6GC7v+AMfNJrxLq4nZ4w5fR0Gueldfd279bHEsTtgv+Q1tHH0JpOv9jbteGTdNKnxBfD5p/WavGsL1FTXYISjTPwoK0Y5MVY5Zj3qadtdGRpophsiCcp52ZPcJtP1DadwLuUivaAQrJP1eDALrejSY5lOf9Qw2dBzDsLAvzI1JoCfqhOaIGNIORl6yjUw5qfaCuQ/WDtzwsuIP8idK/+KMz3PN/kLzUwoihALnd+e/kZ0VPqnkeXiLj0DtsL5+jyF38ow3QZKv0TVi/7dLLwHNewSbRjfrSTq1KeTEnZKKlD5P/zgeylrG51NZzHzF0s4w1E2XlDQo9sf1gIF192Clzpe7sasZPmB+d5KT5x+O6gNO2jTwWmup5C40+1D4bgPEAf7X5Ej2Hd8SIydxJcTX3C6kTEiBTEuKPkzF3R/r8/WcqyzPwzTF0D1GjqtCt8uXWttQr0jR7A+BPCwBdUaWvh0EYNxvAcE9gKeLUfQxJF+ahVVu2w32S5zM7te2Nr0+tUDgWQNo7YhcVtdso7PYZpLaTkEKhuXhh1xhYV6LOog+8RR2gwC5n+t2ACqZxI6697NMrmOBNIS+ophSzqz7iEVa4XmdFCZ3EG/7pcMc8LGfK4qlsDwB1L6V2nzWZyj3BFHyWB690uhQ3ZdJaNA8DEZCJQ2wNo=" 34 | on: 35 | branch: master 36 | tags: true 37 | 38 | addons: 39 | code_climate: 40 | repo_token: c8d563dc62945055087c7099e1a6383949c5b4b39ab6319406e0bc3139ba8acf 41 | 42 | notifications: 43 | slack: 44 | secure: oLOQhMeFPMPtkslGYnvzIAhuzgPU7IsAkK2dxGeLUqKZI/GACZtEBlgk3CUS6ibXYOmkwcvbBp3JS4AYZooubyqemqiQa3bA17ScFOSqUWj5A7QKuWiGFjVtO4MQJisda//6OA6igfiU8VRP0G0NYILZnNbiScEIk45ivSaposqDkvMZS/pup9saJiQ2WySgdzJ6QtlDv+VISo/avIneh5PLMUSBV4m/T07vD6sYsEgLx7bwXIHHR0Xu3tgLCqA0jXXJXM3EbIoVZTSTF1eai6VlTAd5EbgNMkx7C14iUvwlYqHATTsXaY8INTyGIiJElQOgnAUbjyzHvUhlGTpd8VZ1EBZslnLxQB5Bo9c2cTHfwLSNWrnnM/gXvfBjzy1ti1IN/uLpVbIFABki3/b3sRrcpgobOIeKnupPQR3oSERLnCBYRvbaNVBoXfVG9C1g7roR5U4ibcNprEgMKTiUeWl1W16q8a5srIJ6mLJEU4ub4wdD+3HxTlpH+GC2crt0swV++9H5zSyfmuhGf2w3q+ZafT+vjK/plJ4MGsGWlcU+YJ0DW2Iebq5RYn/F3PO0DP4tGOGhiBpYeY4Tke+ylbYtIleD1koBs2vVLPzSqB8Cdklyhy48FmWM6eJ6RyKW33DvUjRynyYr/tan0I1mIeGON49QI0kx3s/+70aYbW0= 45 | email: 46 | recipients: 47 | - namngology@gmail.com -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:2.7.12 2 | 3 | ADD requirements /code/requirements 4 | 5 | RUN pip install -r /code/requirements/testing.txt 6 | 7 | ADD . /code -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | esser - [E]vent [S]ourcing [Ser]verlessly 2 | ============================================ 3 | 4 | [![pypi version]( https://img.shields.io/pypi/v/esser.svg)]( https://pypi.python.org/pypi/esser) 5 | [![pypi package]( https://img.shields.io/pypi/dm/esser.svg)]( https://pypi.python.org/pypi/esser) 6 | [![Build Status](https://travis-ci.org/geeknam/esser.svg?branch=master)](https://travis-ci.org/geeknam/esser) 7 | [![Coverage Status](https://coveralls.io/repos/github/geeknam/esser/badge.svg?branch=master)](https://coveralls.io/github/geeknam/esser?branch=master) 8 | [![Code Issues](https://www.quantifiedcode.com/api/v1/project/2644f358dc5246da951352fb0550f84f/badge.svg)](https://www.quantifiedcode.com/app/project/2644f358dc5246da951352fb0550f84f) 9 | [![Slack](https://img.shields.io/badge/chat-slack-ff69b4.svg)](https://esser-py.slack.com/) 10 | 11 | 12 | - Serverless + Pay-As-You-Go 13 | - Aggregates 14 | - Snapshots 15 | - Projections 16 | 17 | Architectural Design 18 | ----------------------- 19 | 20 | [![Esser Diagram]( https://cloud.githubusercontent.com/assets/199628/24705037/6cbf50b0-1a4d-11e7-99d5-7ad32295912c.png)] 21 | 22 | Features 23 | -------------- 24 | 25 | - Command validation 26 | - Datastore agnostic read layer 27 | - Push based messaging via DynamoDB Stream 28 | - Built-in Snapshotting 29 | - Publish / subscribe style signalling 30 | - Generated Cloudformation templates (Infrastructure as Code) 31 | 32 | 33 | Components 34 | ----------------- 35 | 36 | - Runtime: AWS Lambda (Python) 37 | - Append Only Event Store: DynamoDB 38 | - Event Source Triggers: DynamoDB Stream 39 | - Read / Query Store: PostgreSQL / Elasticsearch (via contrib) 40 | 41 | Example Usage 42 | ------------------ 43 | 44 | #### Add first entity 45 | 46 | `items/aggregate.py` 47 | 48 | ```python 49 | from esser.entities import Entity 50 | from esser.registry import register 51 | from items import commands 52 | from items import receivers 53 | from items.event_handler import ItemEventHandler 54 | 55 | 56 | @register 57 | class Item(Entity): 58 | 59 | # set event handler to aggregate state 60 | event_handler = ItemEventHandler() 61 | created = commands.CreateItem() 62 | price_updated = commands.UpdatePrice() 63 | 64 | ``` 65 | 66 | #### Add commands that can be issued 67 | 68 | `items/commands.py` 69 | 70 | ```python 71 | from esser.commands import BaseCommand, CreateCommand 72 | 73 | 74 | class CreateItem(CreateCommand): 75 | 76 | event_name = 'ItemCreated' 77 | schema = { 78 | 'name': {'type': 'string'}, 79 | 'price': {'type': 'float'} 80 | } 81 | 82 | 83 | class UpdatePrice(BaseCommand): 84 | 85 | event_name = 'PriceUpdated' 86 | schema = { 87 | 'price': {'type': 'float', 'diff': True} 88 | } 89 | ``` 90 | 91 | #### Add event handler to fold event stream 92 | 93 | `items/event_handler.py` 94 | 95 | ```python 96 | from esser.event_handler import BaseEventHandler 97 | 98 | class ItemEventHandler(BaseEventHandler): 99 | 100 | def on_item_created(self, aggregate, next_event): 101 | return self.on_created(aggregate, next_event) 102 | 103 | def on_price_updated(self, aggregate, next_event): 104 | aggregate['price'] = next_event.event_data['price'] 105 | return aggregate 106 | 107 | ``` 108 | 109 | #### Subscribe to events 110 | 111 | `items/receivers.py` 112 | 113 | 114 | ```python 115 | from esser.signals.decorators import receiver 116 | from esser.signals import event_pre_save, event_received, event_post_save 117 | from esser.handlers import LambdaHandler 118 | from items.commands import UpdatePrice 119 | 120 | 121 | @receiver(event_pre_save, sender=UpdatePrice) 122 | def presave_price_updated(sender, **kwargs): 123 | # Do something before saving the event 124 | pass 125 | 126 | 127 | @receiver(event_received, sender=LambdaHandler) 128 | def received_command(sender, **kwargs): 129 | # when the command is received 130 | pass 131 | 132 | @receiver(event_post_save) 133 | def handle_event_saved(sender, **kwargs): 134 | # when the event has already been saved 135 | pass 136 | 137 | ``` 138 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | dynamodb: 4 | image: peopleperhour/dynamodb 5 | app: 6 | build: . 7 | working_dir: /code 8 | command: bash runtest.sh 9 | depends_on: 10 | - dynamodb 11 | volumes: 12 | - .:/code 13 | environment: 14 | AWS_ACCESS_KEY_ID: test 15 | AWS_SECRET_ACCESS_KEY: test 16 | AWS_REGION: ap-southeast-2 17 | PYTHONPATH: /code:/code/examples 18 | DYNAMODB_HOST: http://dynamodb:8000 19 | CODECLIMATE_REPO_TOKEN: ${CODECLIMATE_REPO_TOKEN} 20 | COVERALLS_REPO_TOKEN: ${COVERALLS_REPO_TOKEN} 21 | GH_TOKEN: ${GH_TOKEN} 22 | TRAVIS_BRANCH: ${TRAVIS_BRANCH} 23 | TRAVIS_PULL_REQUEST: ${TRAVIS_PULL_REQUEST} 24 | TRAVIS_REPO_SLUG: ${TRAVIS_REPO_SLUG} 25 | TRAVIS_TAG: ${TRAVIS_TAG} 26 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = esser 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/source/_static/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geeknam/esser/1669ef8fd979a73f606b625b5934d5da2c4711d7/docs/source/_static/.gitkeep -------------------------------------------------------------------------------- /docs/source/_templates/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geeknam/esser/1669ef8fd979a73f606b625b5934d5da2c4711d7/docs/source/_templates/.gitkeep -------------------------------------------------------------------------------- /docs/source/api.rst: -------------------------------------------------------------------------------- 1 | .. _api: 2 | 3 | Developer Interface 4 | =================== 5 | 6 | .. module:: esser 7 | 8 | This part of the documentation covers all the interfaces of `esser`. 9 | 10 | 11 | Entities / Aggregates 12 | ----------------------- 13 | 14 | .. autoclass:: esser.entities.Entity 15 | :members: 16 | :undoc-members: 17 | 18 | 19 | Repositories 20 | ================== 21 | 22 | Base Interface 23 | ------------------ 24 | 25 | .. automodule:: esser.repositories.base 26 | :members: 27 | :undoc-members: 28 | 29 | DynamoDB 30 | ----------------- 31 | 32 | .. automodule:: esser.repositories.dynamodb 33 | :members: 34 | :undoc-members: 35 | 36 | 37 | Commands 38 | ----------------------- 39 | 40 | .. autoclass:: esser.commands.BaseCommand 41 | :members: 42 | :undoc-members: 43 | 44 | .. autoclass:: esser.commands.CreateCommand 45 | :members: 46 | :undoc-members: 47 | 48 | .. autoclass:: esser.commands.DeleteCommand 49 | :members: 50 | :undoc-members: 51 | 52 | 53 | Event Handlers 54 | --------------------- 55 | 56 | .. autoclass:: esser.event_handler.BaseEventHandler 57 | :members: 58 | :undoc-members: 59 | 60 | 61 | 62 | Exceptions 63 | ----------------------- 64 | 65 | .. automodule:: esser.exceptions 66 | :members: 67 | :undoc-members: -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # esser documentation build configuration file, created by 4 | # sphinx-quickstart on Fri Mar 24 14:56:45 2017. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | # If extensions (or modules to document with autodoc) are in another directory, 16 | # add these directories to sys.path here. If the directory is relative to the 17 | # documentation root, use os.path.abspath to make it absolute, like shown here. 18 | # 19 | # import os 20 | # import sys 21 | # sys.path.insert(0, os.path.abspath('.')) 22 | 23 | 24 | # -- General configuration ------------------------------------------------ 25 | 26 | # If your documentation needs a minimal Sphinx version, state it here. 27 | # 28 | # needs_sphinx = '1.0' 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 32 | # ones. 33 | extensions = ['sphinx.ext.autodoc', 34 | 'sphinx.ext.intersphinx', 35 | 'sphinx.ext.coverage', 36 | 'sphinx.ext.githubpages'] 37 | 38 | # Add any paths that contain templates here, relative to this directory. 39 | templates_path = ['_templates'] 40 | 41 | # The suffix(es) of source filenames. 42 | # You can specify multiple suffix as a list of string: 43 | # 44 | # source_suffix = ['.rst', '.md'] 45 | source_suffix = '.rst' 46 | 47 | # The master toctree document. 48 | master_doc = 'index' 49 | 50 | # General information about the project. 51 | project = u'esser' 52 | copyright = u'2017, Nam Ngo' 53 | author = u'Nam Ngo' 54 | 55 | # The version info for the project you're documenting, acts as replacement for 56 | # |version| and |release|, also used in various other places throughout the 57 | # built documents. 58 | # 59 | # The short X.Y version. 60 | version = u'0.1' 61 | # The full version, including alpha/beta/rc tags. 62 | release = u'1' 63 | 64 | # The language for content autogenerated by Sphinx. Refer to documentation 65 | # for a list of supported languages. 66 | # 67 | # This is also used if you do content translation via gettext catalogs. 68 | # Usually you set "language" from the command line for these cases. 69 | language = None 70 | 71 | # List of patterns, relative to source directory, that match files and 72 | # directories to ignore when looking for source files. 73 | # This patterns also effect to html_static_path and html_extra_path 74 | exclude_patterns = [] 75 | 76 | # The name of the Pygments (syntax highlighting) style to use. 77 | pygments_style = 'sphinx' 78 | 79 | # If true, `todo` and `todoList` produce output, else they produce nothing. 80 | todo_include_todos = False 81 | 82 | 83 | # -- Options for HTML output ---------------------------------------------- 84 | 85 | # The theme to use for HTML and HTML Help pages. See the documentation for 86 | # a list of builtin themes. 87 | # 88 | html_theme = 'alabaster' 89 | 90 | # Theme options are theme-specific and customize the look and feel of a theme 91 | # further. For a list of options available for each theme, see the 92 | # documentation. 93 | # 94 | # html_theme_options = {} 95 | 96 | # Add any paths that contain custom static files (such as style sheets) here, 97 | # relative to this directory. They are copied after the builtin static files, 98 | # so a file named "default.css" will overwrite the builtin "default.css". 99 | html_static_path = ['_static'] 100 | 101 | 102 | # -- Options for HTMLHelp output ------------------------------------------ 103 | 104 | # Output file base name for HTML help builder. 105 | htmlhelp_basename = 'esserdoc' 106 | 107 | 108 | # -- Options for LaTeX output --------------------------------------------- 109 | 110 | latex_elements = { 111 | # The paper size ('letterpaper' or 'a4paper'). 112 | # 113 | # 'papersize': 'letterpaper', 114 | 115 | # The font size ('10pt', '11pt' or '12pt'). 116 | # 117 | # 'pointsize': '10pt', 118 | 119 | # Additional stuff for the LaTeX preamble. 120 | # 121 | # 'preamble': '', 122 | 123 | # Latex figure (float) alignment 124 | # 125 | # 'figure_align': 'htbp', 126 | } 127 | 128 | # Grouping the document tree into LaTeX files. List of tuples 129 | # (source start file, target name, title, 130 | # author, documentclass [howto, manual, or own class]). 131 | latex_documents = [ 132 | (master_doc, 'esser.tex', u'esser Documentation', 133 | u'Nam Ngo', 'manual'), 134 | ] 135 | 136 | 137 | # -- Options for manual page output --------------------------------------- 138 | 139 | # One entry per manual page. List of tuples 140 | # (source start file, name, description, authors, manual section). 141 | man_pages = [ 142 | (master_doc, 'esser', u'esser Documentation', 143 | [author], 1) 144 | ] 145 | 146 | 147 | # -- Options for Texinfo output ------------------------------------------- 148 | 149 | # Grouping the document tree into Texinfo files. List of tuples 150 | # (source start file, target name, title, author, 151 | # dir menu entry, description, category) 152 | texinfo_documents = [ 153 | (master_doc, 'esser', u'esser Documentation', 154 | author, 'esser', 'One line description of project.', 155 | 'Miscellaneous'), 156 | ] 157 | 158 | 159 | 160 | 161 | # Example configuration for intersphinx: refer to the Python standard library. 162 | intersphinx_mapping = {'https://docs.python.org/': None} 163 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. esser documentation master file, created by 2 | sphinx-quickstart on Fri Mar 24 14:56:45 2017. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | esser - [E]vent [S]ourcing [Ser]verlessly 7 | =========================================== 8 | .. image:: https://travis-ci.org/geeknam/esser.svg?branch=master 9 | :target: https://travis-ci.org/geeknam/esser 10 | .. image:: https://coveralls.io/repos/github/geeknam/esser/badge.svg?branch=master 11 | :target: https://coveralls.io/github/geeknam/esser?branch=master 12 | .. image:: https://www.quantifiedcode.com/api/v1/project/2644f358dc5246da951352fb0550f84f/badge.svg 13 | :target: https://www.quantifiedcode.com/app/project/2644f358dc5246da951352fb0550f84f 14 | 15 | .. toctree:: 16 | :maxdepth: 2 17 | :caption: Contents: 18 | 19 | 20 | The API Documentation / Guide 21 | ----------------------------- 22 | 23 | If you are looking for information on a specific function, class, or method, 24 | this part of the documentation is for you. 25 | 26 | .. toctree:: 27 | :maxdepth: 2 28 | 29 | api 30 | 31 | 32 | Indices and tables 33 | ================== 34 | 35 | * :ref:`genindex` 36 | * :ref:`modindex` 37 | * :ref:`search` 38 | -------------------------------------------------------------------------------- /esser/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geeknam/esser/1669ef8fd979a73f606b625b5934d5da2c4711d7/esser/__init__.py -------------------------------------------------------------------------------- /esser/cli/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | class BaseProject(object): 5 | 6 | base_files = ( 7 | '__init__.py', 8 | 'app.py', 9 | 'requirements.txt', 10 | 'cloudformation' 11 | ) 12 | 13 | def __init__(self, project_dir): 14 | self.project_dir = project_dir 15 | 16 | def create(self): 17 | for filename in self.base_files: 18 | if '.' in filename: 19 | file = open( 20 | os.path.join(self.project_dir, filename), 21 | 'wb' 22 | ) 23 | file.write('') 24 | 25 | return self.project_dir 26 | 27 | -------------------------------------------------------------------------------- /esser/cli/boilerplate/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geeknam/esser/1669ef8fd979a73f606b625b5934d5da2c4711d7/esser/cli/boilerplate/__init__.py -------------------------------------------------------------------------------- /esser/cli/boilerplate/app.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from esser.handlers import handle_event, handle_stream 3 | 4 | log = logging.getLogger(__name__) 5 | log.setLevel(logging.INFO) 6 | 7 | 8 | def on_event_received(event, context): 9 | try: 10 | event = handle_event(event, context) 11 | return event.event_data.as_dict() 12 | except Exception as exc: 13 | log.exception('Event: %s', event) 14 | log.exception('Exception: %s', exc) 15 | 16 | 17 | def on_event_saved(event, context): 18 | return handle_stream(event, context) 19 | 20 | 21 | def route(event, context): 22 | if 'EventName' in event: 23 | event = on_event_received(event, context) 24 | log.info(event) 25 | return event 26 | else: 27 | aggregates = on_event_saved(event, context) 28 | log.info(aggregates) 29 | return aggregates 30 | -------------------------------------------------------------------------------- /esser/cli/boilerplate/cloudformation/config/env/dynamodb.yaml: -------------------------------------------------------------------------------- 1 | template_path: templates/dynamodb.yaml 2 | parameters: 3 | TableName: !environment_variable EventTableName 4 | ReadCapacityUnits: "1" 5 | WriteCapacityUnits: "1" 6 | DynamoDBResourceNameExportName: DynamoDBEventTable 7 | DynamoDBStreamArnExportName: DynamoDBEventStreamArn -------------------------------------------------------------------------------- /esser/cli/boilerplate/cloudformation/templates/dynamodb.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | AWSTemplateFormatVersion: "2010-09-09" 3 | Description: DynamoDB Table storing events 4 | 5 | Parameters: 6 | ReadCapacityUnits: 7 | Description: Read capacity unit 8 | Type: Number 9 | Default: 1 10 | MinValue: 1 11 | WriteCapacityUnits: 12 | Description: Write capacity unit 13 | Type: Number 14 | Default: 1 15 | MinValue: 1 16 | TableName: 17 | Description: Dynamodb Table Name 18 | Type: String 19 | Default: events 20 | DynamoDBResourceNameExportName: 21 | Description: Export name for Table resource name 22 | Type: String 23 | Default: DynamoDBEventTable 24 | DynamoDBStreamArnExportName: 25 | Description: Export name for DynamoDB Stream Arn 26 | Type: String 27 | Default: DynamoDBEventStreamArn 28 | 29 | 30 | Resources: 31 | EsserEventDynamoDBTable: 32 | Type: AWS::DynamoDB::Table 33 | Properties: 34 | TableName: !Ref TableName 35 | AttributeDefinitions: 36 | - AttributeName: aggregate_name 37 | AttributeType: S 38 | - AttributeName: aggregate_key 39 | AttributeType: S 40 | KeySchema: 41 | - AttributeName: aggregate_name 42 | KeyType: HASH 43 | - AttributeName: aggregate_key 44 | KeyType: RANGE 45 | ProvisionedThroughput: 46 | ReadCapacityUnits: !Ref ReadCapacityUnits 47 | WriteCapacityUnits: !Ref WriteCapacityUnits 48 | StreamSpecification: 49 | StreamViewType: KEYS_ONLY 50 | 51 | Outputs: 52 | DynamoDBResourceName: 53 | Description: Resource name of DyanmoDB Table 54 | Value: !Ref EsserEventDynamoDBTable 55 | Export: 56 | Name: !Ref DynamoDBResourceNameExportName 57 | DynamoDBStreamArn: 58 | Description: Stream ARN of DyanmoDB Table 59 | Value: !GetAtt EsserEventDynamoDBTable.StreamArn 60 | Export: 61 | Name: !Ref DynamoDBStreamArnExportName 62 | -------------------------------------------------------------------------------- /esser/cli/boilerplate/requirements.txt: -------------------------------------------------------------------------------- 1 | esser -------------------------------------------------------------------------------- /esser/cli/run.py: -------------------------------------------------------------------------------- 1 | import click 2 | from esser.cli import BaseProject 3 | 4 | 5 | @click.group() 6 | def main(): 7 | pass 8 | 9 | 10 | @main.command() 11 | @click.argument('project_dir') 12 | def startproject(**kwargs): 13 | project = BaseProject(**kwargs) 14 | project.create() 15 | 16 | 17 | if __name__ == '__main__': 18 | main() 19 | -------------------------------------------------------------------------------- /esser/commands.py: -------------------------------------------------------------------------------- 1 | from esser.validators import EsserValidator 2 | from esser.exceptions import EventValidationException 3 | from esser.signals import event_pre_save 4 | 5 | 6 | class BaseCommand(object): 7 | """Base Command class.""" 8 | 9 | def __init__(self): 10 | """ 11 | Initialise the validator for the event based on schema. 12 | 13 | A valid Command class should have Command.schema 14 | """ 15 | self.validator = EsserValidator( 16 | self.schema, event=self 17 | ) 18 | 19 | @property 20 | def event_name(self): 21 | raise NotImplementedError('Implement event_name property') 22 | 23 | @property 24 | def command_name(self): 25 | """Command name by default is the class name.""" 26 | return self.__class__.__name__ 27 | 28 | def get_version(self): 29 | """Increment version of the event for the aggregate.""" 30 | return self.entity.get_last_aggregate_version() + 1 31 | 32 | def attach_entity(self, entity): 33 | """Allow entity to be attached to the event.""" 34 | setattr(self, 'entity', entity) 35 | setattr(self.validator, 'aggregate', entity) 36 | 37 | def persist(self, attrs): 38 | event_version = self.get_version() 39 | # dispatch the signal before persisting 40 | event_pre_save.send( 41 | sender=self.__class__, 42 | aggregate_name=self.entity.aggregate_name, 43 | aggregate_id=self.entity.aggregate_id, 44 | event_name=self.event_name, 45 | version=event_version, 46 | payload=attrs, 47 | ) 48 | return self.entity.repository.persist( 49 | aggregate_id=self.entity.aggregate_id, 50 | version=event_version, 51 | event_type=self.event_name, 52 | attrs=attrs 53 | ) 54 | 55 | def save(self, attrs): 56 | """Validate event input before saving.""" 57 | if not self.validator.validate(attrs): 58 | raise EventValidationException( 59 | errors=self.validator.errors 60 | ) 61 | normalized = self.validator.normalized(attrs) 62 | return self.persist(normalized) 63 | 64 | 65 | class CreateCommand(BaseCommand): 66 | """Command for creating an entity.""" 67 | 68 | def save(self, attrs): 69 | """Generate aggregate id using uuid.""" 70 | aggregate_id = self.entity.generate_new_guid() 71 | self.entity.aggregate_id = aggregate_id 72 | return super(CreateCommand, self).save(attrs) 73 | 74 | def get_version(self): 75 | return self.entity.INITIAL_VERSION 76 | 77 | 78 | class DeleteCommand(BaseCommand): 79 | 80 | @property 81 | def event_name(self): 82 | return 'Deleted' 83 | -------------------------------------------------------------------------------- /esser/constants.py: -------------------------------------------------------------------------------- 1 | 2 | AGGREGATE_KEY_DELIMITER = ':' -------------------------------------------------------------------------------- /esser/contrib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geeknam/esser/1669ef8fd979a73f606b625b5934d5da2c4711d7/esser/contrib/__init__.py -------------------------------------------------------------------------------- /esser/contrib/repositories/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geeknam/esser/1669ef8fd979a73f606b625b5934d5da2c4711d7/esser/contrib/repositories/__init__.py -------------------------------------------------------------------------------- /esser/entities.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from esser.commands import BaseCommand 4 | from esser.repositories.dynamodb import DynamoDBRepository 5 | from esser.utils import cached_property 6 | from esser.signals import event_pre_save 7 | 8 | 9 | class Entity(object): 10 | """ 11 | Class representing an Aggregate in Event Sourcing 12 | """ 13 | 14 | INITIAL_VERSION = 1 15 | 16 | repository_class = DynamoDBRepository 17 | 18 | def __init__(self, aggregate_id=None): 19 | """ 20 | If aggregate_id is None, only Created events can be fired 21 | """ 22 | self.aggregate_id = aggregate_id 23 | for name, value in self.__class__.__dict__.items(): 24 | if isinstance(value, BaseCommand): 25 | value.attach_entity(self) 26 | self.repository = self.repository_class(aggregate=self) 27 | 28 | @property 29 | def aggregate_name(self): 30 | """ 31 | Override this to decide how this Aggregate is named 32 | """ 33 | return self.__class__.__name__ 34 | 35 | def generate_new_guid(self): 36 | """ 37 | Override this to decide how new GUID is generated 38 | """ 39 | return str(uuid.uuid4()) 40 | 41 | def get_last_snapshot(self): 42 | return {} 43 | 44 | def get_initial_state(self): 45 | return {} 46 | 47 | def get_state_at(self, version): 48 | sequence = self.repository.get_events(version) 49 | initial_state = self.get_last_snapshot() or self.get_initial_state() 50 | return reduce(self.event_handler.apply, sequence, initial_state) 51 | 52 | def get_current_state(self): 53 | sequence = self.repository.get_all_events() 54 | initial_state = self.get_last_snapshot() or self.get_initial_state() 55 | return reduce(self.event_handler.apply, sequence, initial_state) 56 | 57 | current_state = property(get_current_state) 58 | cached_current_state = cached_property(get_current_state) 59 | 60 | def get_last_aggregate_version(self): 61 | last_event = self.repository.get_last_event() 62 | return last_event.version 63 | -------------------------------------------------------------------------------- /esser/event_handler.py: -------------------------------------------------------------------------------- 1 | import re 2 | from esser.exceptions import AggregateDeleted 3 | 4 | re_camel_case = re.compile( 5 | r'(((?<=[a-z])[A-Z])|([A-Z](?![A-Z]|$)))' 6 | ) 7 | 8 | 9 | def camel_case_to_spaces(value): 10 | """ 11 | Splits CamelCase and converts to lower case. Also strips leading and 12 | trailing whitespace. 13 | """ 14 | return re_camel_case.sub(r'_\1', value).strip('_').lower() 15 | 16 | 17 | class BaseEventHandler(object): 18 | """ 19 | Responsible for building up the read state from all events 20 | """ 21 | 22 | def apply(self, aggregate, next_event): 23 | """Called by reduce(), this method should find the 24 | corresponding handler based on the event name 25 | 26 | Args: 27 | aggregate (dict): current state of an aggregate 28 | next_event (esser.repositories.base.Event): next event to apply 29 | 30 | Returns: 31 | dict: aggregate state 32 | """ 33 | event_name = camel_case_to_spaces(next_event.event_type) 34 | self.update_version(aggregate, next_event) 35 | handler = getattr(self, 'on_%s' % event_name, None) 36 | if handler: 37 | return handler(aggregate, next_event) 38 | return aggregate 39 | 40 | def update_version(self, aggregate, next_event): 41 | aggregate['latest_version'] = next_event.version 42 | return aggregate 43 | 44 | def on_created(self, aggregate, next_event): 45 | aggregate.update(next_event.event_data) 46 | return aggregate 47 | 48 | def on_updated(self, aggregate, next_event): 49 | aggregate.update(next_event.event_data) 50 | return aggregate 51 | 52 | def on_deleted(self, aggregate, next_event): 53 | raise AggregateDeleted() 54 | 55 | def on_attribute_deleted(self, aggregate, next_event): 56 | for attr in next_event.event_data.get('attributes', []): 57 | aggregate.pop(attr, None) 58 | return aggregate 59 | -------------------------------------------------------------------------------- /esser/exceptions.py: -------------------------------------------------------------------------------- 1 | 2 | class EsserException(Exception): 3 | 4 | def __init__(self, errors, message=None): 5 | message = '%s: %s' % (message or self.message, errors) 6 | super(EsserException, self).__init__(message) 7 | self.errors = errors 8 | 9 | 10 | class IntegrityError(EsserException): 11 | 12 | message = 'Duplicate hash and range keys' 13 | 14 | 15 | class EventValidationException(EsserException): 16 | 17 | message = 'Event validation error' 18 | 19 | 20 | class AggregateDoesNotExist(Exception): 21 | pass 22 | 23 | 24 | class AggregateDeleted(Exception): 25 | """ 26 | Raised when aggregate contains a DeleteEvent 27 | """ 28 | pass 29 | -------------------------------------------------------------------------------- /esser/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import json 3 | from esser.signals import event_received, event_post_save 4 | from esser.registry import registry 5 | from esser.repositories.base import Event 6 | 7 | 8 | class LambdaHandler(object): 9 | 10 | @staticmethod 11 | def get_aggregate(aggregate_name, aggregate_id): 12 | """Given an aggregate name and id return Aggregate instance 13 | 14 | Args: 15 | aggregate_name (str): Aggregate Name 16 | aggregate_id (str): Aggregate ID 17 | 18 | Returns: 19 | esser.entities.Entity: aggregate / entity 20 | """ 21 | path = registry.get_path(aggregate_name) 22 | module, class_name = path.rsplit('.', 1) 23 | app_module = importlib.import_module(module) 24 | aggregate_class = getattr(app_module, class_name) 25 | aggregate = aggregate_class( 26 | aggregate_id=aggregate_id 27 | ) 28 | return aggregate 29 | 30 | @staticmethod 31 | def image_to_event(image): 32 | aggregate_id, version = image['aggregate_key']['S'].split(':') 33 | return Event( 34 | aggregate_name=image['aggregate_name']['S'], 35 | aggregate_id=aggregate_id, 36 | version=version, 37 | event_type=image['event_type']['S'], 38 | created_at=image['created_at']['S'], 39 | event_data=json.loads(image['event_data']['S']), 40 | ) 41 | 42 | def handle_event(self, event, context): 43 | event_name = event['EventName'] 44 | aggregate_id = event.get('AggregateId', None) 45 | aggregate = self.get_aggregate(event['AggregateName'], aggregate_id) 46 | event_received.send( 47 | sender=self.__class__, 48 | aggregate_name=event['AggregateName'], 49 | aggregate_id=aggregate_id, 50 | payload=event['Payload'] 51 | ) 52 | event_class_attr = None 53 | for event_key, cls in aggregate.__class__.__dict__.items(): 54 | if hasattr(cls.__class__, 'event_name') and cls.__class__.event_name == event_name: 55 | event_class_attr = event_key 56 | aggregate_event = getattr(aggregate, event_class_attr, None) 57 | if aggregate_event: 58 | return aggregate_event.save(attrs=event['Payload']) 59 | return 60 | 61 | def handle_stream(self, event, context): 62 | for record in event['Records']: 63 | keys = record['dynamodb']['Keys'] 64 | new_image = record['dynamodb']['NewImage'] 65 | aggregate_name = keys['aggregate_name']['S'] 66 | aggregate_key = keys['aggregate_key']['S'] 67 | aggregate_id = aggregate_key.split(':')[0] 68 | aggregate = self.get_aggregate(aggregate_name, aggregate_id) 69 | event_obj = self.image_to_event(new_image) 70 | event_post_save.send( 71 | sender=aggregate.__class__, 72 | event=event_obj 73 | ) 74 | 75 | 76 | default_handler = LambdaHandler() 77 | 78 | handle_event = default_handler.handle_event 79 | handle_stream = default_handler.handle_stream 80 | -------------------------------------------------------------------------------- /esser/registry.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | class AggregateRegistry(object): 4 | 5 | def __init__(self): 6 | self.aggregate_paths = {} 7 | 8 | def register(self, aggregate_name, path): 9 | self.aggregate_paths.update({ 10 | aggregate_name: path 11 | }) 12 | 13 | def get_path(self, aggregate_name): 14 | return self.aggregate_paths[aggregate_name] 15 | 16 | def clear(self): 17 | self.aggregate_paths = {} 18 | 19 | 20 | registry = AggregateRegistry() 21 | 22 | 23 | def register(aggregate_class): 24 | path = '%s.%s' % ( 25 | aggregate_class.__module__, 26 | aggregate_class.__name__ 27 | ) 28 | registry.register( 29 | aggregate_class.__name__, path 30 | ) 31 | return aggregate_class 32 | -------------------------------------------------------------------------------- /esser/repositories/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /esser/repositories/base.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | 4 | class Event(object): 5 | """ 6 | Event class to represent a consistent interface across 7 | all repository implementations 8 | """ 9 | 10 | def __init__(self, aggregate_name, aggregate_id, version, 11 | event_type, created_at, event_data): 12 | self.aggregate_name = aggregate_name 13 | self.aggregate_id = aggregate_id 14 | self.version = version 15 | self.event_type = event_type 16 | self.created_at = created_at 17 | self.event_data = event_data 18 | 19 | @property 20 | def aggregate_key(self): 21 | return '%s:%s' % (self.aggregate_id, self.version) 22 | 23 | def as_dict(self): 24 | """Serialise event object to dictionary format 25 | 26 | Returns: 27 | dict: dict representing event 28 | """ 29 | return { 30 | 'aggregate_name': self.aggregate_name, 31 | 'aggregate_id': self.aggregate_id, 32 | 'version': self.version, 33 | 'event_type': self.event_type, 34 | 'created_at': self.created_at, 35 | 'event_data': self.event_data, 36 | } 37 | 38 | def as_json(self): 39 | """Serialise event object to JSON format 40 | 41 | Returns: 42 | str: JSON representing event 43 | """ 44 | return json.dumps(self.as_dict()) 45 | 46 | 47 | class BaseRepository(object): 48 | 49 | def __init__(self, aggregate): 50 | self.aggregate = aggregate 51 | 52 | def to_event(self, obj): 53 | """ 54 | :return: Event object 55 | :rtype: esser.repositories.base.Event 56 | """ 57 | raise NotImplementedError( 58 | 'Repository interface needs to implement to_event() method' 59 | ) 60 | 61 | def get_events(self, version): 62 | """ 63 | :param version: version of event from which to start 64 | :type version: int 65 | :return: Iterable (list, generator) of Event objects 66 | :rtype: iterable of esser.repositories.base.Event 67 | 68 | Return an iterable set of Event objects 69 | Available context: `self.aggregate` 70 | """ 71 | raise NotImplementedError() 72 | 73 | def get_all_events(self): 74 | """ 75 | :return: Iterable (list, generator) of Event objects 76 | :rtype: iterable of esser.repositories.base.Event 77 | 78 | Returns all events of current aggregate 79 | """ 80 | raise NotImplementedError() 81 | 82 | def get_last_event(self): 83 | """ 84 | :return: Event object 85 | :rtype: esser.repositories.base.Event 86 | 87 | Should return an Event object 88 | """ 89 | raise NotImplementedError() 90 | 91 | def persist(self, aggregate_id, version, event_type, attrs): 92 | """ 93 | :param aggregate_id: unique id of an aggregate (uuid can be used) 94 | :type aggregate_id: str 95 | :param version: version of event from which to start 96 | :type version: int 97 | :param event_type: Type of event E.g: CartUpdated 98 | :type event_type: str 99 | :param attrs: Attributes or data of the event 100 | :type attrs: dict 101 | :return: Event object 102 | :rtype: esser.repositories.base.Event 103 | 104 | Should return an Event object 105 | """ 106 | raise NotImplementedError() 107 | -------------------------------------------------------------------------------- /esser/repositories/dynamodb/__init__.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from pynamodb.exceptions import PutError 3 | 4 | from esser.repositories.base import BaseRepository, Event 5 | from esser.repositories.dynamodb.models import DynamoDBEventModel 6 | from esser.constants import AGGREGATE_KEY_DELIMITER 7 | from esser.exceptions import AggregateDoesNotExist, IntegrityError 8 | 9 | 10 | class DynamoDBRepository(BaseRepository): 11 | 12 | model = DynamoDBEventModel 13 | 14 | def to_event(self, obj): 15 | return Event( 16 | aggregate_name=obj.aggregate_name, 17 | aggregate_id=obj.aggregate_id, 18 | version=obj.version, 19 | event_type=obj.event_type, 20 | created_at=obj.created_at, 21 | event_data=obj.event_data 22 | ) 23 | 24 | def get_events(self, version): 25 | """ 26 | Get all events up to specific version 27 | """ 28 | for item in self.model.query( 29 | self.aggregate.aggregate_name, 30 | aggregate_key__le='%s:%s' % ( 31 | self.aggregate.aggregate_id, version 32 | ) 33 | ): 34 | yield self.to_event(item) 35 | 36 | def get_all_events(self): 37 | """ 38 | Get all events up to latest version 39 | """ 40 | for item in self.model.query( 41 | self.aggregate.aggregate_name, 42 | aggregate_key__begins_with=self.aggregate.aggregate_id 43 | ): 44 | yield self.to_event(item) 45 | 46 | def get_last_event(self): 47 | events = list(self.model.query( 48 | self.aggregate.aggregate_name, 49 | aggregate_key__begins_with=self.aggregate.aggregate_id, 50 | limit=1, scan_index_forward=False 51 | )) 52 | try: 53 | return self.to_event(events[0]) 54 | except IndexError: 55 | raise AggregateDoesNotExist() 56 | 57 | @classmethod 58 | def get_aggregate_key(cls, aggregate_id, version): 59 | """Increment version of the event for the aggregate.""" 60 | return '{aggregate_id}{delimiter}{version}'.format( 61 | aggregate_id=aggregate_id, 62 | delimiter=AGGREGATE_KEY_DELIMITER, 63 | version=version 64 | ) 65 | 66 | def persist(self, aggregate_id, version, event_type, attrs): 67 | """Persist event in dynamodb.""" 68 | aggregate_key = self.__class__.get_aggregate_key( 69 | aggregate_id, version 70 | ) 71 | event = self.model( 72 | aggregate_name=self.aggregate.aggregate_name, 73 | aggregate_key=aggregate_key, 74 | event_type=event_type, 75 | created_at=datetime.utcnow(), 76 | event_data=attrs 77 | ) 78 | try: 79 | event.save( 80 | aggregate_name__ne=event.aggregate_name, 81 | aggregate_key__ne=event.aggregate_key, 82 | conditional_operator='and' 83 | ) 84 | except PutError: 85 | raise IntegrityError(errors={ 86 | 'aggregate_name': event.aggregate_name, 87 | 'aggregate_key': event.aggregate_key, 88 | }) 89 | return self.to_event(event) 90 | -------------------------------------------------------------------------------- /esser/repositories/dynamodb/models.py: -------------------------------------------------------------------------------- 1 | import os 2 | from datetime import datetime 3 | from pynamodb.models import Model 4 | from pynamodb.attributes import ( 5 | UnicodeAttribute, UTCDateTimeAttribute, 6 | JSONAttribute, MapAttribute 7 | ) 8 | from esser.constants import AGGREGATE_KEY_DELIMITER 9 | 10 | 11 | class DynamoDBEventModel(Model): 12 | """ 13 | A DynamoDB Event model 14 | """ 15 | class Meta: 16 | region = os.environ.get('AWS_REGION', 'ap-southeast-2') 17 | table_name = os.environ.get("EVENT_TABLE", "events") 18 | host = os.environ.get('DYNAMODB_HOST', None) 19 | aggregate_name = UnicodeAttribute(hash_key=True) 20 | aggregate_key = UnicodeAttribute(range_key=True) 21 | event_type = UnicodeAttribute() 22 | created_at = UTCDateTimeAttribute() 23 | event_data = JSONAttribute() 24 | 25 | @classmethod 26 | def _conditional_operator_check(cls, conditional_operator): 27 | pass 28 | 29 | @property 30 | def aggregate_id(self): 31 | return self.aggregate_key.split(AGGREGATE_KEY_DELIMITER)[0] 32 | 33 | @property 34 | def version(self): 35 | return int(self.aggregate_key.split( 36 | AGGREGATE_KEY_DELIMITER 37 | )[1]) 38 | 39 | 40 | class Snapshot(Model): 41 | 42 | class Meta: 43 | table_name = "snapshots" 44 | host = os.getenv('DYNAMODB_HOST', None) 45 | aggregate_name = UnicodeAttribute(hash_key=True) 46 | aggregate_key = UnicodeAttribute(range_key=True) 47 | created_at = UTCDateTimeAttribute() 48 | state = MapAttribute() 49 | 50 | @classmethod 51 | def from_aggregate(cls, aggregate): 52 | state = aggregate.current_state 53 | last_event = aggregate.repository.get_last_event() 54 | snapshot = cls( 55 | aggregate_name=aggregate.aggregate_name, 56 | aggregate_key=last_event.aggregate_id, 57 | created_at=datetime.utcnow(), 58 | state=state 59 | ) 60 | snapshot.save() 61 | return snapshot 62 | 63 | @classmethod 64 | def get_last_for(cls, aggregate, aggregate_id): 65 | cls.query( 66 | aggregate.aggregate_name, 67 | aggregate_key__begins_with=aggregate_id 68 | ) 69 | -------------------------------------------------------------------------------- /esser/signals/__init__.py: -------------------------------------------------------------------------------- 1 | import dispatch 2 | 3 | event_received = dispatch.Signal( 4 | providing_args=['aggregate_name', 'aggregate_id', 'payload'] 5 | ) 6 | 7 | event_pre_save = dispatch.Signal( 8 | providing_args=[ 9 | 'aggregate_name', 'aggregate_id', 'payload', 10 | 'event_name', 'version', 11 | ] 12 | ) 13 | 14 | event_post_save = dispatch.Signal( 15 | providing_args=['event'] 16 | ) 17 | -------------------------------------------------------------------------------- /esser/signals/decorators.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | def receiver(signal, **kwargs): 4 | """ 5 | A decorator for connecting receivers to signals. Used by passing in the 6 | signal (or list of signals) and keyword arguments to connect:: 7 | @receiver(post_save, sender=Sender) 8 | def signal_receiver(sender, **kwargs): 9 | ... 10 | @receiver([post_save, post_delete], sender=Sender) 11 | def signals_receiver(sender, **kwargs): 12 | ... 13 | """ 14 | def _decorator(func): 15 | if isinstance(signal, (list, tuple)): 16 | for s in signal: 17 | s.connect(func, **kwargs) 18 | else: 19 | signal.connect(func, **kwargs) 20 | return func 21 | return _decorator 22 | -------------------------------------------------------------------------------- /esser/utils.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | class cached_property(object): 4 | 5 | def __init__(self, func): 6 | self.__doc__ = getattr(func, '__doc__') 7 | self.func = func 8 | 9 | def __get__(self, obj, cls): 10 | if obj is None: 11 | return self 12 | value = obj.__dict__[self.func.__name__] = self.func(obj) 13 | return value 14 | -------------------------------------------------------------------------------- /esser/validators.py: -------------------------------------------------------------------------------- 1 | from cerberus import Validator 2 | 3 | 4 | class EsserValidator(Validator): 5 | 6 | def __init__(self, *args, **kwargs): 7 | if 'aggregate' in kwargs: 8 | self.aggregate = kwargs['aggregate'] 9 | if 'event' in kwargs: 10 | self.event = kwargs['event'] 11 | super(EsserValidator, self).__init__(*args, **kwargs) 12 | 13 | def _validate_diff(self, diff, field, value): 14 | if diff: 15 | agg_field_value = self.aggregate.current_state.get(field, None) 16 | if agg_field_value == value: 17 | self._error( 18 | field, 19 | "already has the value of: %s" % agg_field_value 20 | ) 21 | 22 | def _validate_aggregate_exists(self, aggregate_exists, field, value): 23 | if aggregate_exists and field == 'aggregate_id': 24 | related_aggregate = self.event.related_aggregate( 25 | aggregate_id=value 26 | ) 27 | state = related_aggregate.current_state 28 | if not state: 29 | self._error( 30 | field, 31 | "aggregate for id %s does not exist" % value 32 | ) 33 | 34 | def _normalize_coerce_project(self, value): 35 | related_aggregate = self.event.related_aggregate(aggregate_id=value) 36 | return related_aggregate.current_state 37 | -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geeknam/esser/1669ef8fd979a73f606b625b5934d5da2c4711d7/examples/__init__.py -------------------------------------------------------------------------------- /examples/app.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from esser.handlers import handle_event, handle_stream 3 | from items.aggregate import Item 4 | from basket.aggregate import Basket 5 | 6 | log = logging.getLogger(__name__) 7 | log.setLevel(logging.INFO) 8 | 9 | 10 | def on_event_received(event, context): 11 | try: 12 | event = handle_event(event, context) 13 | return event.event_data 14 | except Exception as exc: 15 | log.exception('Event: %s', event) 16 | log.exception('Exception: %s', exc) 17 | 18 | 19 | def on_event_saved(event, context): 20 | return handle_stream(event, context) 21 | 22 | 23 | def route(event, context): 24 | log.info(event) 25 | if 'EventName' in event: 26 | event = on_event_received(event, context) 27 | return event 28 | else: 29 | handle_stream(event, context) 30 | -------------------------------------------------------------------------------- /examples/basket/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geeknam/esser/1669ef8fd979a73f606b625b5934d5da2c4711d7/examples/basket/__init__.py -------------------------------------------------------------------------------- /examples/basket/aggregate.py: -------------------------------------------------------------------------------- 1 | from esser.entities import Entity 2 | from esser.event_handler import BaseEventHandler 3 | from basket import commands 4 | from items.aggregate import Item 5 | 6 | 7 | class BasketEventHandler(BaseEventHandler): 8 | 9 | def on_basket_created(self, aggregate, next_event): 10 | return self.on_created(aggregate, next_event) 11 | 12 | def on_item_added(self, aggregate, next_event): 13 | item = Item(aggregate_id=next_event.event_data['aggregate_id']) 14 | aggregate['items'].append(item.current_state) 15 | return aggregate 16 | 17 | def on_item_added_with_projection(self, aggregate, next_event): 18 | aggregate['items'].append(next_event.event_data['aggregate_id']) 19 | return aggregate 20 | 21 | 22 | class Basket(Entity): 23 | """ 24 | Basket is an Aggregate root 25 | """ 26 | event_handler = BasketEventHandler() 27 | created = commands.CreateBasket() 28 | item_added = commands.AddItem() 29 | item_added_with_validation = commands.AddItemWithExistanceValidation() 30 | item_added_with_projection = commands.AddItemWithProjection() 31 | 32 | def get_initial_state(self): 33 | return { 34 | 'items': [] 35 | } 36 | -------------------------------------------------------------------------------- /examples/basket/commands.py: -------------------------------------------------------------------------------- 1 | from esser.commands import ( 2 | BaseCommand, CreateCommand 3 | ) 4 | from items.aggregate import Item 5 | 6 | 7 | class CreateBasket(CreateCommand): 8 | 9 | event_name = 'BasketCreated' 10 | schema = { 11 | 'name': {'type': 'string'} 12 | } 13 | 14 | 15 | class AddItem(BaseCommand): 16 | 17 | event_name = 'ItemAdded' 18 | schema = { 19 | 'aggregate_id': {'type': 'string'} 20 | } 21 | 22 | 23 | class AddItemWithExistanceValidation(BaseCommand): 24 | 25 | event_name = 'ItemAdded' 26 | related_aggregate = Item 27 | schema = { 28 | 'aggregate_id': { 29 | 'type': 'string', 30 | 'aggregate_exists': True 31 | } 32 | } 33 | 34 | 35 | class AddItemWithProjection(BaseCommand): 36 | 37 | event_name = 'ItemAddedWithProjection' 38 | related_aggregate = Item 39 | schema = { 40 | 'aggregate_id': {'coerce': 'project'} 41 | } 42 | -------------------------------------------------------------------------------- /examples/items/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geeknam/esser/1669ef8fd979a73f606b625b5934d5da2c4711d7/examples/items/__init__.py -------------------------------------------------------------------------------- /examples/items/aggregate.py: -------------------------------------------------------------------------------- 1 | from esser.entities import Entity 2 | from esser.event_handler import BaseEventHandler 3 | from esser.registry import register 4 | from items import commands 5 | from items import receivers 6 | 7 | 8 | class ItemEventHandler(BaseEventHandler): 9 | 10 | def on_item_created(self, aggregate, next_event): 11 | return self.on_created(aggregate, next_event) 12 | 13 | def on_item_updated(self, aggregate, next_event): 14 | return self.on_updated(aggregate, next_event) 15 | 16 | def on_price_updated(self, aggregate, next_event): 17 | aggregate['price'] = next_event.event_data['price'] 18 | return aggregate 19 | 20 | def on_colors_added(self, aggregate, next_event): 21 | aggregate['colors'] = next_event.event_data['colors'] 22 | return aggregate 23 | 24 | 25 | @register 26 | class Item(Entity): 27 | 28 | event_handler = ItemEventHandler() 29 | price_updated = commands.UpdatePrice() 30 | colors_added = commands.AddColors() 31 | created = commands.CreateItem() 32 | deleted = commands.DeleteItem() 33 | 34 | @property 35 | def price(self): 36 | try: 37 | return self.current_state['price'] 38 | except KeyError: 39 | raise AttributeError() 40 | 41 | @price.setter 42 | def price(self, value): 43 | self.price_updated.save(attrs={'price': value}) 44 | -------------------------------------------------------------------------------- /examples/items/commands.py: -------------------------------------------------------------------------------- 1 | from esser.commands import BaseCommand, CreateCommand, DeleteCommand 2 | 3 | 4 | class CreateItem(CreateCommand): 5 | 6 | event_name = 'ItemCreated' 7 | schema = { 8 | 'name': {'type': 'string'}, 9 | 'price': {'type': 'float'} 10 | } 11 | 12 | 13 | class UpdatePrice(BaseCommand): 14 | 15 | event_name = 'PriceUpdated' 16 | schema = { 17 | 'price': {'type': 'float', 'diff': True} 18 | } 19 | 20 | 21 | class AddColors(BaseCommand): 22 | 23 | event_name = 'ColorsAdded' 24 | schema = { 25 | 'colors': { 26 | 'type': 'set', 27 | 'allowed': ['orange', 'black', 'white', 'blue', 'green'] 28 | } 29 | } 30 | 31 | 32 | class DeleteItem(DeleteCommand): 33 | 34 | schema = { 35 | 'deleted_by': {'type': 'string'} 36 | } 37 | -------------------------------------------------------------------------------- /examples/items/receivers.py: -------------------------------------------------------------------------------- 1 | from esser.signals.decorators import receiver 2 | from esser.signals import event_pre_save, event_received, event_post_save 3 | from esser.handlers import LambdaHandler 4 | from items.commands import UpdatePrice 5 | 6 | 7 | @receiver(event_pre_save, sender=UpdatePrice) 8 | def check_price_update(sender, **kwargs): 9 | print('Event pre save handled: %s' % kwargs) 10 | 11 | 12 | @receiver(event_received, sender=LambdaHandler) 13 | def do_something(sender, **kwargs): 14 | print('Command received handled: %s' % kwargs) 15 | 16 | 17 | @receiver(event_post_save) 18 | def handle_event_saved(sender, **kwargs): 19 | print('Post saved handled: %s' % kwargs['event'].as_dict()) 20 | -------------------------------------------------------------------------------- /examples/requirements.txt: -------------------------------------------------------------------------------- 1 | esser -------------------------------------------------------------------------------- /requirements/base.txt: -------------------------------------------------------------------------------- 1 | pynamodb 2 | cerberus 3 | dispatcher -------------------------------------------------------------------------------- /requirements/testing.txt: -------------------------------------------------------------------------------- 1 | -r base.txt 2 | 3 | mock 4 | nose 5 | coveralls 6 | codeclimate-test-reporter 7 | sphinx 8 | sphinx-autobuild 9 | travis-sphinx -------------------------------------------------------------------------------- /runtest.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # sleep to wait for dynamodb container to bootstrap 4 | sleep 1 5 | set -e 6 | nosetests --with-coverage --cover-package=esser 7 | coveralls 8 | codeclimate-test-reporter 9 | 10 | travis-sphinx -n build 11 | travis-sphinx -n deploy -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages 2 | from setuptools import setup 3 | 4 | setup( 5 | name='esser', 6 | packages=find_packages( 7 | exclude=[ 8 | 'examples.*', 'examples', 'tests', 'requirements', 9 | 'docs' 10 | ] 11 | ), 12 | license='Apache 2.0', 13 | version='0.1.1', 14 | description='Python Event Sourcing framework', 15 | long_description=open('README.md').read(), 16 | author='Nam Ngo', 17 | author_email='namngology@gmail.com', 18 | url='https://geeknam.github.io/esser', 19 | keywords=[ 20 | 'event sourcing', 'framework', 'esser', 'serverless', 21 | 'dynamodb', 'lambda' 22 | ], 23 | install_requires=['pynamodb', 'cerberus'], 24 | entry_points={ 25 | 'console_scripts': [ 26 | 'esser = esser.cli.run:main' 27 | ] 28 | }, 29 | ) 30 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geeknam/esser/1669ef8fd979a73f606b625b5934d5da2c4711d7/tests/__init__.py -------------------------------------------------------------------------------- /tests/base.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from esser.repositories.dynamodb.models import DynamoDBEventModel, Snapshot 3 | 4 | 5 | class BaseTestCase(unittest.TestCase): 6 | 7 | def setUp(self): 8 | DynamoDBEventModel.create_table( 9 | read_capacity_units=1, write_capacity_units=1 10 | ) 11 | Snapshot.create_table( 12 | read_capacity_units=1, write_capacity_units=1 13 | ) 14 | 15 | def tearDown(self): 16 | DynamoDBEventModel.delete_table() 17 | Snapshot.delete_table() 18 | -------------------------------------------------------------------------------- /tests/test_entity.py: -------------------------------------------------------------------------------- 1 | from mock import patch 2 | from esser.exceptions import AggregateDoesNotExist 3 | from examples.items.aggregate import Item 4 | from tests.base import BaseTestCase 5 | 6 | 7 | class EntityTestCase(BaseTestCase): 8 | 9 | def setUp(self): 10 | super(EntityTestCase, self).setUp() 11 | self.item = Item() 12 | 13 | def test_aggregate_name(self): 14 | self.assertEquals(self.item.aggregate_name, 'Item') 15 | 16 | def test_set_price(self): 17 | self.item.created.save( 18 | attrs={'name': 'Yummy Choc', 'price': 10.50} 19 | ) 20 | self.item.price = 12.50 21 | self.assertEquals(self.item.price, 12.50) 22 | 23 | def test_get_events(self): 24 | with patch('uuid.uuid4') as mock_uuid: 25 | mock_uuid.return_value = '2-higher' 26 | event = self.item.created.save( 27 | attrs={'name': 'Yummy Choc', 'price': 10.50} 28 | ) 29 | self.item.price_updated.save(attrs={'price': 12.50}) 30 | self.item.price_updated.save(attrs={'price': 14.50}) 31 | with patch('uuid.uuid4') as mock_uuid: 32 | mock_uuid.return_value = '1-lower' 33 | Item().created.save( 34 | attrs={'name': 'Donut', 'price': 5.50} 35 | ) 36 | self.assertEquals(event.aggregate_id, '2-higher') 37 | self.assertEquals( 38 | self.item.get_state_at(version=2), 39 | {'name': 'Yummy Choc', 'price': 12.50, 'latest_version': 2} 40 | ) 41 | 42 | def test_get_all_events(self): 43 | self.item.created.save( 44 | attrs={'name': 'Yummy Choc', 'price': 10.50} 45 | ) 46 | self.item.price_updated.save(attrs={'price': 12.50}) 47 | self.assertEquals( 48 | len(list(self.item.repository.get_all_events())), 2 49 | ) 50 | Item().created.save( 51 | attrs={'name': 'Pizza', 'price': 15.50} 52 | ) 53 | self.assertEquals( 54 | len(list(self.item.repository.get_all_events())), 2 55 | ) 56 | 57 | def test_get_last_aggregate_version(self): 58 | self.item.created.save( 59 | attrs={'name': 'Yummy Choc', 'price': 10.50} 60 | ) 61 | self.assertEquals( 62 | self.item.get_last_aggregate_version(), 1 63 | ) 64 | self.item.price_updated.save(attrs={'price': 12.50}) 65 | self.assertEquals( 66 | self.item.get_last_aggregate_version(), 2 67 | ) 68 | 69 | def test_get_last_aggregate_version_does_not_exist(self): 70 | with self.assertRaises(AggregateDoesNotExist): 71 | Item(aggregate_id='someuuid').get_last_aggregate_version() 72 | -------------------------------------------------------------------------------- /tests/test_event.py: -------------------------------------------------------------------------------- 1 | import mock 2 | 3 | from tests.base import BaseTestCase 4 | from esser import exceptions 5 | 6 | from examples.items.aggregate import Item 7 | from examples.basket.aggregate import Basket 8 | 9 | 10 | class EventTestCase(BaseTestCase): 11 | 12 | def setUp(self): 13 | super(EventTestCase, self).setUp() 14 | self.item = Item() 15 | self.basket = Basket() 16 | 17 | def test_create(self): 18 | self.item.created.save( 19 | attrs={'name': 'Yummy Choc', 'price': 10.50} 20 | ) 21 | self.assertEquals( 22 | self.item.current_state, 23 | {'name': 'Yummy Choc', 'price': 10.50, 'latest_version': 1} 24 | ) 25 | 26 | def test_price_update(self): 27 | self.item.created.save( 28 | attrs={ 29 | 'name': 'Yummy Choc', 30 | 'price': 10.50 31 | } 32 | ) 33 | self.item.price_updated.save(attrs={'price': 12.50}) 34 | self.assertEquals( 35 | self.item.current_state, 36 | {'name': 'Yummy Choc', 'price': 12.50, 'latest_version': 2} 37 | ) 38 | with self.assertRaises(exceptions.EventValidationException): 39 | self.item.price_updated.save(attrs={'price': 12.50}) 40 | 41 | def test_delete(self): 42 | self.item.created.save( 43 | attrs={'name': 'Yummy Choc', 'price': 10.50} 44 | ) 45 | self.item.deleted.save(attrs={'deleted_by': 'John'}) 46 | with self.assertRaises(exceptions.AggregateDeleted): 47 | self.item.current_state 48 | 49 | def test_validation(self): 50 | with self.assertRaises(exceptions.EventValidationException): 51 | self.item.created.save( 52 | attrs={'name': 'Yummy Choc', 'price': '10.50'} 53 | ) 54 | with self.assertRaises(exceptions.EventValidationException): 55 | self.item.created.save( 56 | attrs={ 57 | 'name': 'Yummy Choc', 'price': 10.50, 58 | 'foo': 'bar' 59 | } 60 | ) 61 | 62 | @mock.patch('uuid.uuid4') 63 | def test_save_integrity(self, mock_uuid): 64 | mock_uuid.return_value = 'notunique' 65 | self.item.created.save( 66 | attrs={'name': 'Yummy Choc', 'price': 10.50} 67 | ) 68 | with self.assertRaises(exceptions.IntegrityError): 69 | self.item.created.save( 70 | attrs={'name': 'Bubble gum', 'price': 11.50} 71 | ) 72 | 73 | def test_composition(self): 74 | self.basket.created.save(attrs={'name': 'Favorite Food'}) 75 | event = self.item.created.save( 76 | attrs={'name': 'Yummy Choc', 'price': 10.50} 77 | ) 78 | self.basket.item_added.save( 79 | attrs={'aggregate_id': event.aggregate_id} 80 | ) 81 | self.assertEquals( 82 | self.basket.current_state, 83 | { 84 | 'items': [ 85 | {'latest_version': 1, 'name': 'Yummy Choc', 'price': 10.5} 86 | ], 87 | 'latest_version': 2, 88 | 'name': 'Favorite Food' 89 | } 90 | ) 91 | 92 | def test_composition_invalid_id(self): 93 | self.basket.created.save(attrs={'name': 'Favorite Food'}) 94 | self.basket.item_added.save(attrs={'aggregate_id': 'incorrectid'}) 95 | self.assertEquals( 96 | self.basket.current_state, 97 | {'items': [{}], 'latest_version': 2, u'name': u'Favorite Food'} 98 | ) 99 | with self.assertRaises(exceptions.EventValidationException): 100 | self.basket.item_added_with_validation.save( 101 | attrs={'aggregate_id': 'incorrectid'} 102 | ) 103 | 104 | def test_coerce_projection(self): 105 | self.basket.created.save(attrs={'name': 'Favorite Food'}) 106 | event = self.item.created.save( 107 | attrs={'name': 'Yummy Choc', 'price': 10.50} 108 | ) 109 | self.basket.item_added_with_projection.save( 110 | attrs={'aggregate_id': event.aggregate_id} 111 | ) 112 | self.assertEquals( 113 | self.basket.current_state, 114 | { 115 | 'items': [ 116 | {'latest_version': 1, 'name': 'Yummy Choc', 'price': 10.5} 117 | ], 118 | 'latest_version': 2, 119 | 'name': 'Favorite Food' 120 | } 121 | ) 122 | -------------------------------------------------------------------------------- /tests/test_handlers.py: -------------------------------------------------------------------------------- 1 | from mock import patch 2 | from tests.base import BaseTestCase 3 | from esser.handlers import handle_event 4 | from examples.items.aggregate import Item 5 | 6 | 7 | class HandlerTestCase(BaseTestCase): 8 | 9 | def setUp(self): 10 | super(HandlerTestCase, self).setUp() 11 | self.item = Item() 12 | 13 | @patch('uuid.uuid4') 14 | def test_handler(self, mock_uuid): 15 | mock_uuid.return_value = 'mykey' 16 | event = { 17 | 'EventName': 'ItemCreated', 18 | 'AggregateId': None, 19 | 'AggregateName': 'Item', 20 | 'Payload': { 21 | 'name': 'Handler Item', 22 | 'price': 15.0 23 | } 24 | } 25 | result = handle_event(event, {}) 26 | self.assertEquals(result.aggregate_id, 'mykey') 27 | self.assertEquals(result.version, 1) 28 | event = { 29 | 'EventName': 'PriceUpdated', 30 | 'AggregateId': 'mykey', 31 | 'AggregateName': 'Item', 32 | 'Payload': {'price': 12.0} 33 | } 34 | result = handle_event(event, {}) 35 | self.assertEquals(result.aggregate_id, 'mykey') 36 | self.assertEquals(result.version, 2) 37 | -------------------------------------------------------------------------------- /tests/test_registry.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from esser.registry import register, registry 4 | 5 | 6 | class RegistryTestCase(unittest.TestCase): 7 | 8 | def test_register(self): 9 | 10 | @register 11 | class TestAggregate(object): 12 | pass 13 | 14 | self.assertEquals( 15 | registry.get_path('TestAggregate'), 16 | 'tests.test_registry.TestAggregate' 17 | ) 18 | 19 | -------------------------------------------------------------------------------- /tests/test_snapshot.py: -------------------------------------------------------------------------------- 1 | from tests.base import BaseTestCase 2 | from esser.repositories.dynamodb.models import Snapshot 3 | from examples.items.aggregate import Item 4 | 5 | 6 | class SnapshotTestCase(BaseTestCase): 7 | 8 | def setUp(self): 9 | super(SnapshotTestCase, self).setUp() 10 | self.item = Item() 11 | 12 | def test_from_aggregate(self): 13 | self.item.created.save( 14 | attrs={'name': 'Yummy Choc', 'price': 10.50} 15 | ) 16 | self.item.price_updated.save(attrs={'price': 12.50}) 17 | snapshot = Snapshot.from_aggregate(self.item) 18 | self.assertEquals( 19 | snapshot.state.attribute_values, 20 | {'name': 'Yummy Choc', 'price': 12.50, 'latest_version': 2} 21 | ) 22 | -------------------------------------------------------------------------------- /tests/test_stream.py: -------------------------------------------------------------------------------- 1 | from mock import patch 2 | from tests.base import BaseTestCase 3 | from examples.items.aggregate import Item 4 | from esser.handlers import handle_stream 5 | 6 | 7 | class StreamTestCase(BaseTestCase): 8 | 9 | def setUp(self): 10 | super(StreamTestCase, self).setUp() 11 | self.item = Item() 12 | 13 | def stream_factory(self, aggregate_name, aggregate_key): 14 | return { 15 | "Records": [ 16 | { 17 | "eventID": "1", 18 | "eventName": "INSERT", 19 | "eventVersion": "1.0", 20 | "eventSource": "aws:dynamodb", 21 | "awsRegion": "us-east-1", 22 | "dynamodb": { 23 | "Keys": { 24 | "aggregate_name": { 25 | "S": aggregate_name 26 | }, 27 | "aggregate_key": { 28 | "S": aggregate_key 29 | } 30 | }, 31 | 'NewImage': { 32 | 'created_at': {'S': '2017-04-12T06:56:06.191104+0000'}, 33 | 'aggregate_name': {'S': 'Item'}, 34 | 'aggregate_key': {'S': '83e7b629-58a0-4194-80e4-dab5dbbd63c1:1'}, 35 | 'event_type': {'S': 'ItemCreated'}, 36 | 'event_data': { 37 | 'S': '{"price": 15, "name": "Coffee"}' 38 | } 39 | }, 40 | "SequenceNumber": "111", 41 | "SizeBytes": 26, 42 | "StreamViewType": "KEYS_ONLY" 43 | }, 44 | "eventSourceARN": "stream-ARN" 45 | } 46 | ] 47 | } 48 | 49 | @patch('examples.items.receivers.handle_event_saved') 50 | def test_handle_stream(self, mock_handle): 51 | with patch('uuid.uuid4') as mock_uuid: 52 | mock_uuid.return_value = 'mockuuid' 53 | self.item.created.save( 54 | attrs={ 55 | 'name': 'Yummy Choc', 'price': 10.50 56 | } 57 | ) 58 | event = self.item.price_updated.save(attrs={'price': 12.50}) 59 | stream = self.stream_factory('Item', event.aggregate_key) 60 | handle_stream(stream, {}) 61 | --------------------------------------------------------------------------------