├── Untitled Diagram.xml ├── ports-and-adapters ├── .gitignore ├── 01 │ ├── .gitignore │ ├── README.md │ ├── issue_logger_app_services │ │ ├── __init__.py │ │ └── handlers.py │ ├── issue_logger_model │ │ ├── __init__.py │ │ ├── commands.py │ │ └── domain.py │ ├── issue_logger_unit_tests │ │ ├── __init__.py │ │ ├── adapters.py │ │ └── test_issue_reporting.py │ ├── requirements.txt │ └── setup.py ├── 02 │ ├── .gitignore │ ├── README.md │ ├── issues │ │ ├── __init__.py │ │ ├── adapters │ │ │ ├── __init__.py │ │ │ └── orm.py │ │ ├── domain │ │ │ ├── __init__.py │ │ │ ├── commands.py │ │ │ ├── model.py │ │ │ └── ports.py │ │ ├── quick_tests │ │ │ ├── __init__.py │ │ │ ├── adapters.py │ │ │ └── test_issue_reporting.py │ │ ├── services │ │ │ └── __init__.py │ │ └── slow_tests │ │ │ ├── __init__.py │ │ │ └── issue_repository_tests.py │ ├── requirements.txt │ └── setup.py ├── 03 │ ├── .gitignore │ ├── README.md │ ├── issues │ │ ├── __init__.py │ │ ├── adapters │ │ │ ├── __init__.py │ │ │ ├── flask.py │ │ │ ├── orm.py │ │ │ └── views.py │ │ ├── domain │ │ │ ├── __init__.py │ │ │ ├── commands.py │ │ │ ├── model.py │ │ │ └── ports.py │ │ ├── issues.db │ │ ├── quick_tests │ │ │ ├── __init__.py │ │ │ ├── adapters.py │ │ │ └── test_issue_reporting.py │ │ ├── services │ │ │ └── __init__.py │ │ └── slow_tests │ │ │ ├── __init__.py │ │ │ └── issue_repository_tests.py │ ├── requirements.txt │ └── setup.py ├── 04 │ ├── .gitignore │ ├── README.md │ ├── issues │ │ ├── __init__.py │ │ ├── adapters │ │ │ ├── __init__.py │ │ │ ├── config.py │ │ │ ├── emails.py │ │ │ ├── flask.py │ │ │ ├── orm.py │ │ │ └── views.py │ │ ├── domain │ │ │ ├── __init__.py │ │ │ ├── emails.py │ │ │ ├── messages.py │ │ │ ├── model.py │ │ │ └── ports.py │ │ ├── issues.db │ │ ├── quick_tests │ │ │ ├── __init__.py │ │ │ ├── adapters.py │ │ │ ├── matchers.py │ │ │ ├── shared_contexts.py │ │ │ ├── test_assignment.py │ │ │ ├── test_issue_reporting.py │ │ │ └── test_triage.py │ │ ├── services │ │ │ └── __init__.py │ │ └── slow_tests │ │ │ ├── __init__.py │ │ │ └── issue_repository_tests.py │ ├── requirements.txt │ └── setup.py ├── 05 │ ├── .gitignore │ ├── README.md │ ├── issues │ │ ├── __init__.py │ │ ├── adapters │ │ │ ├── __init__.py │ │ │ ├── config.py │ │ │ ├── emails.py │ │ │ ├── flask.py │ │ │ ├── orm.py │ │ │ └── views.py │ │ ├── domain │ │ │ ├── __init__.py │ │ │ ├── emails.py │ │ │ ├── messages.py │ │ │ ├── model.py │ │ │ └── ports.py │ │ ├── quick_tests │ │ │ ├── __init__.py │ │ │ ├── adapters.py │ │ │ ├── matchers.py │ │ │ ├── shared_contexts.py │ │ │ ├── test_assignment.py │ │ │ ├── test_issue_reporting.py │ │ │ └── test_triage.py │ │ ├── services │ │ │ └── __init__.py │ │ └── slow_tests │ │ │ ├── __init__.py │ │ │ ├── api_tests.py │ │ │ └── issue_repository_tests.py │ ├── requirements.txt │ └── setup.py ├── 06 │ ├── README.md │ ├── issues.db │ ├── issues │ │ ├── __init__.py │ │ ├── adapters │ │ │ ├── __init__.py │ │ │ ├── config.py │ │ │ ├── emails.py │ │ │ ├── flask.py │ │ │ ├── orm.py │ │ │ └── views.py │ │ ├── domain │ │ │ ├── __init__.py │ │ │ ├── emails.py │ │ │ ├── messages.py │ │ │ ├── model.py │ │ │ └── ports.py │ │ ├── issues.db │ │ ├── quick_tests │ │ │ ├── __init__.py │ │ │ ├── adapters.py │ │ │ ├── matchers.py │ │ │ ├── shared_contexts.py │ │ │ ├── test_assignment.py │ │ │ ├── test_issue_reporting.py │ │ │ └── test_triage.py │ │ ├── services │ │ │ └── __init__.py │ │ └── slow_tests │ │ │ ├── __init__.py │ │ │ ├── api_tests.py │ │ │ └── issue_repository_tests.py │ ├── requirements.txt │ └── setup.py └── 07 │ ├── issues │ ├── __init__.py │ ├── adapters │ │ ├── __init__.py │ │ ├── config.py │ │ ├── emails.py │ │ ├── flask.py │ │ ├── orm.py │ │ └── views.py │ ├── domain │ │ ├── __init__.py │ │ ├── emails.py │ │ ├── messages.py │ │ ├── model.py │ │ └── ports.py │ ├── issues.db │ ├── quick_tests │ │ ├── __init__.py │ │ ├── adapters.py │ │ ├── matchers.py │ │ ├── shared_contexts.py │ │ ├── test_assignment.py │ │ ├── test_issue_reporting.py │ │ └── test_triage.py │ ├── services │ │ └── __init__.py │ └── slow_tests │ │ ├── __init__.py │ │ ├── api_tests.py │ │ └── issue_repository_tests.py │ ├── requirements.txt │ └── setup.py └── rek ├── custom-json-metrics ├── api │ ├── Dockerfile │ └── main.go ├── debugfmt.log ├── docker-compose.yml ├── dummy-logger │ ├── Dockerfile │ └── main.go ├── riemann │ ├── Dockerfile │ └── riemann.config └── rsyslog │ ├── Dockerfile │ ├── error-level.lookup │ ├── rsyslog-http.rb │ └── rsyslog.conf └── dynstats ├── docker-compose.yml ├── dummy-logger ├── Dockerfile └── main.go ├── nginx ├── Dockerfile └── nginx.conf ├── riemann ├── Dockerfile └── riemann.config └── rsyslog ├── Dockerfile ├── rsyslog-http.rb └── rsyslog.conf /Untitled Diagram.xml: -------------------------------------------------------------------------------- 1 | 7V1Lc6M4EP41Pk4VIJ7HdSaZOWSqpipbu2cFZMOOjFwgj+P99SuMBBjZGBIbRFa+xGo1D/f3datbEmQBHjZv3zK4jX+QCOGFZURvC/B1YVmmDWz2p5AcSokdBKVgnSURV6oFL8m/iAsNLt0lEcpPFCkhmCbbU2FI0hSF9EQGs4zsT9VWBJ9edQvXSBK8hBDL0r+TiMZcahpG3fEdJeuYX9p3eMcrDH+tM7JL+fUWFlgdP2X3Bopzcf08hhHZN0TgcQEeMkJo+W3z9oBwYVthtvK4pwu91X1nKKV9DvA85IaBtzIDN4K+D77wM/yGeMdt8ZMZnt8sPQgD5ftkg2HKWsuYbjATmuxrGCc4eoYHsisunlNmDNFaslZGOdLspsDyaCVU3EfRqgxRNDB8RXhZmfKBYJKxrpQcL5jTjPxCQsgsbBw/VY9ArLijVYJxQ5NjweQkpU9wk+CCoH+hLIIp5GJ+jz5vnrsOxMk6ZbKQWRmxzqVsdmFHlFH01hBxGL4hskE0OzAV3utxRgiP4c19Tb/A5bK4wTxPMA9yyq+rM9ewsy8c+Z4ssCQWPBdgW4bZkwkS2rKFLlLvusm4jSzZRuZZG4F72MiXbCTZpqZ4YZN9nFD0soVh0btngbNlsk/mARdJ1dslrFOXcB0ZbuMM3O490A402uOibTpTwi0SEY333fAGp3g77ploPhrelivBiyKWEvImyWhM1iSF+LGWLk8J0AD7Gr5sbPyjyFJbsqekuOEyHWqMnw5rozQSR4QY5nkS/hknadnBDzPLVuOgfxClB96GO0qYqP4hz4Rsq8F6AOE+RKz+WUBOdlmIuodeZqM1ungefqICxk46ZghDmvw+zfxvSy5Pk2s+5BLE6WRXcGtyHQ9lKMBDQ2FLkpTmjTP/LASNIdM+P2Y+XdD3OtXZl/IGapZXv+SdxPc18edD/KBPVLWUiaoif9DkmgO5TDBFVH0/ueSJDk0udcnlzotcci2pyaUsucS8xBzywdaEWdCa/O9WB163utepfvvsEQDtJvNxk14DvOmoE4QDza75sKtXELaAMuyydXEyJ3b1iV2WPRW7TMN7hZ6LbASAswqCL7o4mRG5TK/PwOgrQy5dnMyIXGJZsptcN49c76pOXLdVnpjd9UZb33G69W2jU//DBYrkKbo++XSeMtkkkUQuW5Prs5HL+F+EYX/kKOxoR5mPo5h96vjbR+F3OUqLyO619CP4oL55Z0fRW5zm5Ci20umKPJ1anlPeMS6Xk1PsGBe9rbFs2h3kYqeR3mTad5MpGAo4aMdkGe/R9pjal1xEriMndBFgKeUilnaRYS5iDwZcqX338jgi4x2Tzesuv471537C7OPIt3aPnnvmzB8NeDk6ake/q6NP/MCFXn5XpBoRE42d1Uiv+S11lt+dS6mWPKs6Yapl+0qlWrLRdATuBNwZCji4PuCOmGq5OtUaDflWqgXOuPp4qZanHX1cR69WpqdxdP3s+sh4T5xa93l2XQf2uwR2d8oaGug9A4rUVGJNurOm4krdNRVP0hSoqYBeaJ8Tu/o8EicyQRXYpVen58SuPs+KizxEAXbJpS6fD5Jj2oTzQW57Qn7a+SD9grOByaM7FHClykRL70YYG29/Urwtjfe4eE88LdBnaV3jfUO8z00GjIc30G8dUySjFqVNZ0bd5512YohWIKMG+tVeM2JX9RL1TnZZyrBLXqHi9Zo8SzBhveYbKtVrlt5BNXB894YCPuL4zpr1fwEon/6o/9UCePwP -------------------------------------------------------------------------------- /ports-and-adapters/.gitignore: -------------------------------------------------------------------------------- 1 | */issues.db 2 | __pycache__ 3 | -------------------------------------------------------------------------------- /ports-and-adapters/01/.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *.cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # Jupyter Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # SageMath parsed files 79 | *.sage.py 80 | 81 | # Environments 82 | .env 83 | .venv 84 | env/ 85 | venv/ 86 | ENV/ 87 | 88 | # Spyder project settings 89 | .spyderproject 90 | .spyproject 91 | 92 | # Rope project settings 93 | .ropeproject 94 | 95 | # mkdocs documentation 96 | /site 97 | 98 | # mypy 99 | .mypy_cache/ 100 | 101 | -------------------------------------------------------------------------------- /ports-and-adapters/01/README.md: -------------------------------------------------------------------------------- 1 | To run the code, set up a python 3 virtual environment, then 2 | 3 | `pip install -r requirements.txt` 4 | `pip install -e .` 5 | 6 | You can now run tests with `run-contexts -v` 7 | 8 | -------------------------------------------------------------------------------- /ports-and-adapters/01/issue_logger_app_services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bobthemighty/blog-code-samples/9c50b57baf42a76a10e612a65b3508fef60cf01d/ports-and-adapters/01/issue_logger_app_services/__init__.py -------------------------------------------------------------------------------- /ports-and-adapters/01/issue_logger_app_services/handlers.py: -------------------------------------------------------------------------------- 1 | from issue_logger_model.domain import Issue, IssueReporter 2 | 3 | 4 | class ReportIssueHandler: 5 | 6 | def __init__(self, issue_log): 7 | self.issue_log = issue_log 8 | 9 | def __call__(self, cmd): 10 | issue = Issue( 11 | IssueReporter(cmd.reporter_name, cmd.reporter_email), 12 | cmd.problem_description) 13 | self.issue_log.add(issue) 14 | -------------------------------------------------------------------------------- /ports-and-adapters/01/issue_logger_model/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bobthemighty/blog-code-samples/9c50b57baf42a76a10e612a65b3508fef60cf01d/ports-and-adapters/01/issue_logger_model/__init__.py -------------------------------------------------------------------------------- /ports-and-adapters/01/issue_logger_model/commands.py: -------------------------------------------------------------------------------- 1 | from typing import NamedTuple 2 | 3 | 4 | class ReportIssueCommand(NamedTuple): 5 | 6 | reporter_name: str 7 | reporter_email: str 8 | problem_description: str 9 | -------------------------------------------------------------------------------- /ports-and-adapters/01/issue_logger_model/domain.py: -------------------------------------------------------------------------------- 1 | import abc 2 | 3 | 4 | class IssueReporter: 5 | 6 | def __init__(self, name: str, email: str) -> None: 7 | self.name = name 8 | self.email = email 9 | 10 | 11 | class Issue: 12 | 13 | def __init__(self, reporter: IssueReporter, description: str) -> None: 14 | self.description = description 15 | self.reporter = reporter 16 | 17 | 18 | class IssueLog(abc.ABC): 19 | 20 | @abc.abstractmethod 21 | def add(self, issue: Issue) -> None: 22 | pass 23 | -------------------------------------------------------------------------------- /ports-and-adapters/01/issue_logger_unit_tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bobthemighty/blog-code-samples/9c50b57baf42a76a10e612a65b3508fef60cf01d/ports-and-adapters/01/issue_logger_unit_tests/__init__.py -------------------------------------------------------------------------------- /ports-and-adapters/01/issue_logger_unit_tests/adapters.py: -------------------------------------------------------------------------------- 1 | from issue_logger_model.domain import IssueLog 2 | 3 | 4 | class FakeIssueLog(IssueLog): 5 | 6 | def __init__(self): 7 | self.issues = [] 8 | 9 | def add(self, issue): 10 | self.issues.append(issue) 11 | 12 | def get(self, id): 13 | return self.issues[id] 14 | 15 | def __len__(self): 16 | return len(self.issues) 17 | 18 | def __getitem__(self, idx): 19 | return self.issues[idx] 20 | -------------------------------------------------------------------------------- /ports-and-adapters/01/issue_logger_unit_tests/test_issue_reporting.py: -------------------------------------------------------------------------------- 1 | from .adapters import FakeIssueLog 2 | from issue_logger_app_services.handlers import ReportIssueHandler 3 | from issue_logger_model.commands import ReportIssueCommand 4 | 5 | from expects import expect, have_len, equal 6 | 7 | email = "bob@example.org" 8 | name = "bob" 9 | desc = "My mouse won't move" 10 | 11 | 12 | class When_reporting_an_issue: 13 | 14 | def given_an_empty_issue_log(self): 15 | self.issues = FakeIssueLog() 16 | 17 | def because_we_report_a_new_issue(self): 18 | handler = ReportIssueHandler(self.issues) 19 | cmd = ReportIssueCommand(name, email, desc) 20 | 21 | handler(cmd) 22 | 23 | def the_handler_should_have_created_a_new_issue(self): 24 | expect(self.issues).to(have_len(1)) 25 | 26 | def it_should_have_recorded_the_issuer(self): 27 | expect(self.issues[0].reporter.name).to(equal(name)) 28 | expect(self.issues[0].reporter.email).to(equal(email)) 29 | 30 | def it_should_have_recorded_the_description(self): 31 | expect(self.issues[0].description).to(equal(desc)) 32 | -------------------------------------------------------------------------------- /ports-and-adapters/01/requirements.txt: -------------------------------------------------------------------------------- 1 | colorama==0.3.9 2 | Contexts==0.11.2 3 | expects==0.8.0 4 | -------------------------------------------------------------------------------- /ports-and-adapters/01/setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name='ports-and-adapters', 5 | url='https://github.com/bobthemighty/ports-and-adapters-pythong', 6 | packages=find_packages('.'), 7 | ) 8 | -------------------------------------------------------------------------------- /ports-and-adapters/02/.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *.cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # Jupyter Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # SageMath parsed files 79 | *.sage.py 80 | 81 | # Environments 82 | .env 83 | .venv 84 | env/ 85 | venv/ 86 | ENV/ 87 | 88 | # Spyder project settings 89 | .spyderproject 90 | .spyproject 91 | 92 | # Rope project settings 93 | .ropeproject 94 | 95 | # mkdocs documentation 96 | /site 97 | 98 | # mypy 99 | .mypy_cache/ 100 | 101 | -------------------------------------------------------------------------------- /ports-and-adapters/02/README.md: -------------------------------------------------------------------------------- 1 | To run the code, set up a python 3 virtual environment, then 2 | 3 | `pip install -r requirements.txt` 4 | `pip install -e .` 5 | 6 | You can now run tests with `run-contexts -v` 7 | 8 | -------------------------------------------------------------------------------- /ports-and-adapters/02/issues/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bobthemighty/blog-code-samples/9c50b57baf42a76a10e612a65b3508fef60cf01d/ports-and-adapters/02/issues/__init__.py -------------------------------------------------------------------------------- /ports-and-adapters/02/issues/adapters/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bobthemighty/blog-code-samples/9c50b57baf42a76a10e612a65b3508fef60cf01d/ports-and-adapters/02/issues/adapters/__init__.py -------------------------------------------------------------------------------- /ports-and-adapters/02/issues/adapters/orm.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import sqlalchemy 4 | from sqlalchemy import (Table, Column, MetaData, String, Integer, Text, 5 | create_engine) 6 | from sqlalchemy.orm import mapper, scoped_session, sessionmaker, composite 7 | import sqlalchemy.exc 8 | import sqlalchemy.orm.exc 9 | 10 | from sqlalchemy_utils.functions import create_database, drop_database 11 | 12 | from issues.domain.model import Issue, IssueReporter 13 | from issues.domain.ports import ( 14 | UnitOfWork, 15 | UnitOfWorkManager, 16 | ) 17 | 18 | 19 | class SqlAlchemyUnitOfWorkManager(UnitOfWorkManager): 20 | 21 | def __init__(self, session_maker): 22 | self.session_maker = session_maker 23 | 24 | def start(self): 25 | return SqlAlchemyUnitOfWork(self.session_maker) 26 | 27 | 28 | class IssueRepository: 29 | 30 | def __init__(self, session): 31 | self._session = session 32 | 33 | def add(self, issue: Issue) -> None: 34 | self._session.add(issue) 35 | 36 | 37 | class SqlAlchemyUnitOfWork(UnitOfWork): 38 | 39 | def __init__(self, sessionfactory): 40 | self.sessionfactory = sessionfactory 41 | 42 | def __enter__(self): 43 | self.session = self.sessionfactory() 44 | return self 45 | 46 | def __exit__(self, type, value, traceback): 47 | self.session.close() 48 | 49 | def commit(self): 50 | self.session.commit() 51 | 52 | def rollback(self): 53 | self.session.rollback() 54 | 55 | @property 56 | def issues(self): 57 | return IssueRepository(self.session) 58 | 59 | 60 | class SqlAlchemy: 61 | 62 | def __init__(self, uri): 63 | self.engine = create_engine(uri) 64 | self._session_maker = scoped_session(sessionmaker(self.engine),) 65 | 66 | @property 67 | def unit_of_work_manager(self): 68 | return SqlAlchemyUnitOfWorkManager(self._session_maker) 69 | 70 | def recreate_schema(self): 71 | drop_database(self.engine.url) 72 | create_database(self.engine.url) 73 | self.metadata.create_all() 74 | 75 | def get_session(self): 76 | return self._session_maker() 77 | 78 | def configure_mappings(self): 79 | self.metadata = MetaData(self.engine) 80 | 81 | IssueReporter.__composite_values__ = lambda i: (i.name, i.email) 82 | issues = Table('issues', self.metadata, 83 | Column('id', Integer, primary_key=True), 84 | Column('reporter_name', String(50)), 85 | Column('reporter_email', String(50)), 86 | Column('description', Text)) 87 | mapper( 88 | Issue, 89 | issues, 90 | properties={ 91 | 'id': 92 | issues.c.id, 93 | 'description': 94 | issues.c.description, 95 | 'reporter': 96 | composite(IssueReporter, issues.c.reporter_name, 97 | issues.c.reporter_email) 98 | }, 99 | ) 100 | 101 | 102 | class SqlAlchemySessionContext: 103 | 104 | def __init__(self, session_maker): 105 | self._session_maker = session_maker 106 | 107 | def __enter__(self): 108 | self._session = self._session_maker() 109 | 110 | def __exit__(self, type, value, traceback): 111 | self._session_maker.remove() 112 | 113 | 114 | class IssueViewBuilder: 115 | 116 | issue_view_model = namedtuple( 117 | 'issue_view', ['id', 'description', 'reporter_email', 'reporter_name']) 118 | 119 | def __init__(self, session): 120 | self.session = session 121 | 122 | def fetch(self, id): 123 | session.execute( 124 | 'SELECT id, description, reporter_email, reporter_name ' + 125 | ' FROM issues ' + ' WHERE issue_id = :id', 126 | id=id) 127 | -------------------------------------------------------------------------------- /ports-and-adapters/02/issues/domain/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bobthemighty/blog-code-samples/9c50b57baf42a76a10e612a65b3508fef60cf01d/ports-and-adapters/02/issues/domain/__init__.py -------------------------------------------------------------------------------- /ports-and-adapters/02/issues/domain/commands.py: -------------------------------------------------------------------------------- 1 | from typing import NamedTuple 2 | 3 | 4 | class ReportIssueCommand(NamedTuple): 5 | 6 | reporter_name: str 7 | reporter_email: str 8 | problem_description: str 9 | -------------------------------------------------------------------------------- /ports-and-adapters/02/issues/domain/model.py: -------------------------------------------------------------------------------- 1 | import abc 2 | 3 | 4 | class IssueReporter: 5 | 6 | def __init__(self, name: str, email: str) -> None: 7 | self.name = name 8 | self.email = email 9 | 10 | 11 | class Issue: 12 | 13 | def __init__(self, reporter: IssueReporter, description: str) -> None: 14 | self.description = description 15 | self.reporter = reporter 16 | -------------------------------------------------------------------------------- /ports-and-adapters/02/issues/domain/ports.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from .model import Issue 3 | 4 | 5 | class IssueLog(abc.ABC): 6 | 7 | @abc.abstractmethod 8 | def add(self, issue: Issue) -> None: 9 | pass 10 | 11 | 12 | class UnitOfWork(abc.ABC): 13 | 14 | @abc.abstractmethod 15 | def __enter__(self): 16 | pass 17 | 18 | @abc.abstractmethod 19 | def __exit__(self, type, value, traceback): 20 | pass 21 | 22 | @abc.abstractmethod 23 | def commit(self): 24 | pass 25 | 26 | @abc.abstractmethod 27 | def rollback(self): 28 | pass 29 | 30 | @property 31 | @abc.abstractmethod 32 | def issues(self): 33 | pass 34 | 35 | 36 | class UnitOfWorkManager(abc.ABC): 37 | 38 | @abc.abstractmethod 39 | def start(self) -> UnitOfWork: 40 | pass 41 | -------------------------------------------------------------------------------- /ports-and-adapters/02/issues/quick_tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bobthemighty/blog-code-samples/9c50b57baf42a76a10e612a65b3508fef60cf01d/ports-and-adapters/02/issues/quick_tests/__init__.py -------------------------------------------------------------------------------- /ports-and-adapters/02/issues/quick_tests/adapters.py: -------------------------------------------------------------------------------- 1 | from issues.domain.ports import IssueLog, UnitOfWorkManager, UnitOfWork 2 | 3 | 4 | class FakeIssueLog(IssueLog): 5 | 6 | def __init__(self): 7 | self.issues = [] 8 | 9 | def add(self, issue): 10 | self.issues.append(issue) 11 | 12 | def get(self, id): 13 | return self.issues[id] 14 | 15 | def __len__(self): 16 | return len(self.issues) 17 | 18 | def __getitem__(self, idx): 19 | return self.issues[idx] 20 | 21 | 22 | class FakeUnitOfWork(UnitOfWork, UnitOfWorkManager): 23 | 24 | def __init__(self): 25 | self._issues = FakeIssueLog() 26 | 27 | def start(self): 28 | self.was_committed = False 29 | self.was_rolled_back = False 30 | return self 31 | 32 | def __enter__(self): 33 | return self 34 | 35 | def __exit__(self, type, value, traceback): 36 | self.exn_type = type 37 | self.exn = value 38 | self.traceback = traceback 39 | 40 | def commit(self): 41 | self.was_committed = True 42 | 43 | def rollback(self): 44 | self.was_rolled_back = True 45 | 46 | @property 47 | def issues(self): 48 | return self._issues 49 | -------------------------------------------------------------------------------- /ports-and-adapters/02/issues/quick_tests/test_issue_reporting.py: -------------------------------------------------------------------------------- 1 | from .adapters import FakeUnitOfWork 2 | from issues.services import ReportIssueHandler 3 | from issues.domain.commands import ReportIssueCommand 4 | 5 | from expects import expect, have_len, equal, be_true 6 | 7 | email = "bob@example.org" 8 | name = "bob" 9 | desc = "My mouse won't move" 10 | 11 | 12 | class When_reporting_an_issue: 13 | 14 | def given_an_empty_unit_of_work(self): 15 | self.uow = FakeUnitOfWork() 16 | 17 | def because_we_report_a_new_issue(self): 18 | handler = ReportIssueHandler(self.uow) 19 | cmd = ReportIssueCommand(name, email, desc) 20 | 21 | handler.handle(cmd) 22 | 23 | def the_handler_should_have_created_a_new_issue(self): 24 | expect(self.uow.issues).to(have_len(1)) 25 | 26 | def it_should_have_recorded_the_issuer(self): 27 | expect(self.uow.issues[0].reporter.name).to(equal(name)) 28 | expect(self.uow.issues[0].reporter.email).to(equal(email)) 29 | 30 | def it_should_have_recorded_the_description(self): 31 | expect(self.uow.issues[0].description).to(equal(desc)) 32 | 33 | def it_should_have_committed_the_unit_of_work(self): 34 | expect(self.uow.was_committed).to(be_true) 35 | -------------------------------------------------------------------------------- /ports-and-adapters/02/issues/services/__init__.py: -------------------------------------------------------------------------------- 1 | from issues.domain.model import Issue, IssueReporter 2 | from issues.domain.ports import UnitOfWorkManager 3 | 4 | 5 | class ReportIssueHandler: 6 | 7 | def __init__(self, uowm: UnitOfWorkManager): 8 | self.uowm = uowm 9 | 10 | def handle(self, cmd): 11 | reporter = IssueReporter(cmd.reporter_name, cmd.reporter_email) 12 | issue = Issue(reporter, cmd.problem_description) 13 | 14 | with self.uowm.start() as tx: 15 | tx.issues.add(issue) 16 | tx.commit() 17 | -------------------------------------------------------------------------------- /ports-and-adapters/02/issues/slow_tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bobthemighty/blog-code-samples/9c50b57baf42a76a10e612a65b3508fef60cf01d/ports-and-adapters/02/issues/slow_tests/__init__.py -------------------------------------------------------------------------------- /ports-and-adapters/02/issues/slow_tests/issue_repository_tests.py: -------------------------------------------------------------------------------- 1 | from issues.domain.model import Issue 2 | from issues.domain.commands import ReportIssueCommand 3 | from issues.services import ReportIssueHandler 4 | from issues.adapters.orm import SqlAlchemy 5 | 6 | from expects import expect, equal, have_len 7 | 8 | 9 | class When_we_load_a_persisted_issue: 10 | 11 | def given_a_database_containing_an_issue(self): 12 | 13 | self.db = SqlAlchemy('sqlite://') 14 | self.db.configure_mappings() 15 | self.db.recreate_schema() 16 | 17 | cmd = ReportIssueCommand('fred', 'fred@example.org', 18 | 'forgot my password again') 19 | handler = ReportIssueHandler(self.db.unit_of_work_manager) 20 | handler.handle(cmd) 21 | 22 | def because_we_load_the_issues(self): 23 | self.issues = self.db.get_session().query(Issue).all() 24 | 25 | def we_should_have_loaded_a_single_issue(self): 26 | expect(self.issues).to(have_len(1)) 27 | 28 | def it_should_have_the_correct_description(self): 29 | expect(self.issues[0].description).to(equal('forgot my password again')) 30 | 31 | def it_should_have_the_correct_reporter_details(self): 32 | expect(self.issues[0].reporter.name).to(equal('fred')) 33 | 34 | def it_should_have_the_correct_reporter_details(self): 35 | expect(self.issues[0].reporter.email).to(equal('fred@example.org')) 36 | -------------------------------------------------------------------------------- /ports-and-adapters/02/requirements.txt: -------------------------------------------------------------------------------- 1 | colorama==0.3.9 2 | Contexts==0.11.2 3 | expects==0.8.0 4 | -------------------------------------------------------------------------------- /ports-and-adapters/02/setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name='ports-and-adapters', 5 | url='https://github.com/bobthemighty/ports-and-adapters-pythong', 6 | packages=find_packages('.'), 7 | ) 8 | -------------------------------------------------------------------------------- /ports-and-adapters/03/.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *.cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # Jupyter Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # SageMath parsed files 79 | *.sage.py 80 | 81 | # Environments 82 | .env 83 | .venv 84 | env/ 85 | venv/ 86 | ENV/ 87 | 88 | # Spyder project settings 89 | .spyderproject 90 | .spyproject 91 | 92 | # Rope project settings 93 | .ropeproject 94 | 95 | # mkdocs documentation 96 | /site 97 | 98 | # mypy 99 | .mypy_cache/ 100 | 101 | -------------------------------------------------------------------------------- /ports-and-adapters/03/README.md: -------------------------------------------------------------------------------- 1 | To run the code, set up a python 3 virtual environment, then 2 | 3 | `pip install -r requirements.txt` 4 | `pip install -e .` 5 | 6 | You will need Sqlite installed. For the API, you'll need write-access to the current directory, or you can just hack the connection string in adapters/flask.py 7 | 8 | If you `cd issues` you can now run tests with `run-contexts -v`. 9 | 10 | To run the API 11 | 12 | ```bash 13 | $ export FLASK_APP=issues.adapters.flask 14 | $ flask run 15 | flask run 16 | * Serving Flask app "issues.adapters.flask" 17 | * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit) 18 | 127.0.0.1 - - [13/Sep/2017 13:50:50] "POST /issues HTTP/1.1" 201 - 19 | 127.0.0.1 - - [13/Sep/2017 13:50:57] "GET /issues/4d9ca3ab-3d70-486c-965b-41d27e0dbd33 HTTP/1.1" 200 - 20 | ``` 21 | 22 | You can now post a new issue to the api. 23 | 24 | ```bash 25 | $ curl -d'{"reporter_name": "carlos", "reporter_email": "carlos@example.org", "problem_description": "Nothing works any more"}' localhost:5000/issues -H "Content-Type: application/json" -v 26 | 27 | * Trying ::1... 28 | * TCP_NODELAY set 29 | * connect to ::1 port 5000 failed: Connection refused 30 | * Trying 127.0.0.1... 31 | * TCP_NODELAY set 32 | * Connected to localhost (127.0.0.1) port 5000 (#0) 33 | > POST /issues HTTP/1.1 34 | > Host: localhost:5000 35 | > User-Agent: curl/7.55.1 36 | > Accept: */* 37 | > Content-Type: application/json 38 | > Content-Length: 108 39 | > 40 | * upload completely sent off: 108 out of 108 bytes 41 | * HTTP 1.0, assume close after body 42 | < HTTP/1.0 201 CREATED 43 | < Location: http://localhost:5000/issues/4d9ca3ab-3d70-486c-965b-41d27e0dbd33 44 | < Content-Type: text/html; charset=utf-8 45 | < Content-Length: 0 46 | < Server: Werkzeug/0.12.2 Python/3.6.2 47 | < Date: Wed, 13 Sep 2017 12:50:50 GMT 48 | < 49 | * Closing connection 0 50 | 51 | ``` 52 | 53 | 54 | And fetch the new issue from the URI given in the Location header. 55 | 56 | ```bash 57 | curl http://localhost:5000/issues/4d9ca3ab-3d70-486c-965b-41d27e0dbd33 58 | { 59 | "description": "Nothing works any more", 60 | "reporter_email": "carlos@example.org", 61 | "reporter_name": "carlos" 62 | } 63 | ``` 64 | -------------------------------------------------------------------------------- /ports-and-adapters/03/issues/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bobthemighty/blog-code-samples/9c50b57baf42a76a10e612a65b3508fef60cf01d/ports-and-adapters/03/issues/__init__.py -------------------------------------------------------------------------------- /ports-and-adapters/03/issues/adapters/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bobthemighty/blog-code-samples/9c50b57baf42a76a10e612a65b3508fef60cf01d/ports-and-adapters/03/issues/adapters/__init__.py -------------------------------------------------------------------------------- /ports-and-adapters/03/issues/adapters/flask.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from flask import Flask, request, jsonify 3 | from issues.adapters.orm import SqlAlchemy 4 | from issues.adapters.views import IssueViewBuilder, IssueListBuilder 5 | 6 | from issues.services import ReportIssueHandler 7 | from issues.domain.commands import ReportIssueCommand 8 | 9 | app = Flask('issues') 10 | 11 | db = SqlAlchemy('sqlite:///issues.db') 12 | db.configure_mappings() 13 | db.create_schema() 14 | 15 | 16 | @app.route('/issues', methods=['POST']) 17 | def report_issue(): 18 | issue_id = uuid.uuid4() 19 | cmd = ReportIssueCommand(issue_id=issue_id, **request.get_json()) 20 | 21 | handler = ReportIssueHandler(db.unit_of_work_manager) 22 | handler.handle(cmd) 23 | 24 | return "", 201, {"Location": "/issues/" + str(issue_id)} 25 | 26 | 27 | @app.route('/issues/') 28 | def get_issue(issue_id): 29 | session = db.get_session() 30 | view_builder = IssueViewBuilder(session) 31 | view = view_builder.fetch(uuid.UUID(issue_id)) 32 | return jsonify(view) 33 | 34 | 35 | @app.route('/issues', methods=['GET']) 36 | def list_issues(): 37 | session = db.get_session() 38 | view_builder = IssueListBuilder(session) 39 | view = view_builder.fetch() 40 | return jsonify(view) 41 | -------------------------------------------------------------------------------- /ports-and-adapters/03/issues/adapters/orm.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import logging 3 | import uuid 4 | 5 | import sqlalchemy 6 | from sqlalchemy import (Table, Column, MetaData, String, Integer, Text, 7 | create_engine) 8 | from sqlalchemy.orm import mapper, scoped_session, sessionmaker, composite 9 | import sqlalchemy.exc 10 | import sqlalchemy.orm.exc 11 | 12 | from sqlalchemy_utils.functions import create_database, drop_database 13 | from sqlalchemy_utils.types.uuid import UUIDType 14 | 15 | from issues.domain.model import Issue, IssueReporter 16 | from issues.domain.ports import ( 17 | UnitOfWork, 18 | UnitOfWorkManager, 19 | ) 20 | 21 | 22 | class SqlAlchemyUnitOfWorkManager(UnitOfWorkManager): 23 | 24 | def __init__(self, session_maker): 25 | self.session_maker = session_maker 26 | 27 | def start(self): 28 | return SqlAlchemyUnitOfWork(self.session_maker) 29 | 30 | 31 | class IssueRepository: 32 | 33 | def __init__(self, session): 34 | self._session = session 35 | 36 | def add(self, issue: Issue) -> None: 37 | self._session.add(issue) 38 | 39 | 40 | class SqlAlchemyUnitOfWork(UnitOfWork): 41 | 42 | def __init__(self, sessionfactory): 43 | self.sessionfactory = sessionfactory 44 | 45 | def __enter__(self): 46 | self.session = self.sessionfactory() 47 | return self 48 | 49 | def __exit__(self, type, value, traceback): 50 | self.session.close() 51 | 52 | def commit(self): 53 | self.session.commit() 54 | 55 | def rollback(self): 56 | self.session.rollback() 57 | 58 | @property 59 | def issues(self): 60 | return IssueRepository(self.session) 61 | 62 | 63 | class SqlAlchemy: 64 | 65 | def __init__(self, uri): 66 | self.engine = create_engine(uri) 67 | self._session_maker = scoped_session(sessionmaker(self.engine),) 68 | 69 | @property 70 | def unit_of_work_manager(self): 71 | return SqlAlchemyUnitOfWorkManager(self._session_maker) 72 | 73 | def recreate_schema(self): 74 | drop_database(self.engine.url) 75 | self.create_schema() 76 | 77 | def create_schema(self): 78 | create_database(self.engine.url) 79 | self.metadata.create_all() 80 | 81 | def get_session(self): 82 | return self._session_maker() 83 | 84 | def configure_mappings(self): 85 | self.metadata = MetaData(self.engine) 86 | 87 | IssueReporter.__composite_values__ = lambda i: (i.name, i.email) 88 | issues = Table('issues', self.metadata, 89 | Column('pk', Integer, primary_key=True), 90 | Column('issue_id', UUIDType), 91 | Column('reporter_name', String(50)), 92 | Column('reporter_email', String(50)), 93 | Column('description', Text)) 94 | mapper( 95 | Issue, 96 | issues, 97 | properties={ 98 | '__pk': 99 | issues.c.pk, 100 | 'id': 101 | issues.c.issue_id, 102 | 'description': 103 | issues.c.description, 104 | 'reporter': 105 | composite(IssueReporter, issues.c.reporter_name, 106 | issues.c.reporter_email) 107 | }, 108 | ) 109 | 110 | 111 | class SqlAlchemySessionContext: 112 | 113 | def __init__(self, session_maker): 114 | self._session_maker = session_maker 115 | 116 | def __enter__(self): 117 | self._session = self._session_maker() 118 | 119 | def __exit__(self, type, value, traceback): 120 | self._session_maker.remove() 121 | -------------------------------------------------------------------------------- /ports-and-adapters/03/issues/adapters/views.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import uuid 3 | 4 | # This little helper function converts the binary data 5 | # We store in Sqlite back to a uuid. 6 | # Ordinarily I use postgres, which has a native UniqueID 7 | # type, so this manual unmarshalling isn't necessary 8 | 9 | 10 | def read_uuid(record, column): 11 | record = dict(record) 12 | bytes_val = record[column] 13 | uuid_val = uuid.UUID(bytes=bytes_val) 14 | record[column] = uuid_val 15 | return record 16 | 17 | 18 | class IssueViewBuilder: 19 | 20 | _q = """SELECT description, 21 | reporter_email, 22 | reporter_name 23 | FROM issues 24 | WHERE issue_id = :id""" 25 | 26 | def __init__(self, session): 27 | self.session = session 28 | 29 | def fetch(self, id): 30 | result = self.session.execute(self._q, {'id': id.bytes}) 31 | record = result.fetchone() 32 | return dict(record) 33 | 34 | 35 | class IssueListBuilder: 36 | 37 | _q = """SELECT issue_id, 38 | description, 39 | reporter_email, 40 | reporter_name 41 | FROM issues""" 42 | 43 | def __init__(self, session): 44 | self.session = session 45 | 46 | def fetch(self): 47 | query = self.session.execute( 48 | 'SELECT issue_id, description, reporter_email, reporter_name ' + 49 | ' FROM issues') 50 | 51 | result = [] 52 | for r in query.fetchall(): 53 | r = read_uuid(r, 'issue_id') 54 | result.append(r) 55 | 56 | return result 57 | -------------------------------------------------------------------------------- /ports-and-adapters/03/issues/domain/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bobthemighty/blog-code-samples/9c50b57baf42a76a10e612a65b3508fef60cf01d/ports-and-adapters/03/issues/domain/__init__.py -------------------------------------------------------------------------------- /ports-and-adapters/03/issues/domain/commands.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | from typing import NamedTuple 3 | 4 | 5 | class ReportIssueCommand(NamedTuple): 6 | issue_id: UUID 7 | reporter_name: str 8 | reporter_email: str 9 | problem_description: str 10 | -------------------------------------------------------------------------------- /ports-and-adapters/03/issues/domain/model.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from uuid import UUID 3 | 4 | 5 | class IssueReporter: 6 | 7 | def __init__(self, name: str, email: str) -> None: 8 | self.name = name 9 | self.email = email 10 | 11 | 12 | class Issue: 13 | 14 | def __init__(self, issue_id: UUID, reporter: IssueReporter, 15 | description: str) -> None: 16 | self.id = issue_id 17 | self.description = description 18 | self.reporter = reporter 19 | -------------------------------------------------------------------------------- /ports-and-adapters/03/issues/domain/ports.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from .model import Issue 3 | 4 | 5 | class IssueLog(abc.ABC): 6 | 7 | @abc.abstractmethod 8 | def add(self, issue: Issue) -> None: 9 | pass 10 | 11 | 12 | class UnitOfWork(abc.ABC): 13 | 14 | @abc.abstractmethod 15 | def __enter__(self): 16 | pass 17 | 18 | @abc.abstractmethod 19 | def __exit__(self, type, value, traceback): 20 | pass 21 | 22 | @abc.abstractmethod 23 | def commit(self): 24 | pass 25 | 26 | @abc.abstractmethod 27 | def rollback(self): 28 | pass 29 | 30 | @property 31 | @abc.abstractmethod 32 | def issues(self): 33 | pass 34 | 35 | 36 | class UnitOfWorkManager(abc.ABC): 37 | 38 | @abc.abstractmethod 39 | def start(self) -> UnitOfWork: 40 | pass 41 | -------------------------------------------------------------------------------- /ports-and-adapters/03/issues/issues.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bobthemighty/blog-code-samples/9c50b57baf42a76a10e612a65b3508fef60cf01d/ports-and-adapters/03/issues/issues.db -------------------------------------------------------------------------------- /ports-and-adapters/03/issues/quick_tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bobthemighty/blog-code-samples/9c50b57baf42a76a10e612a65b3508fef60cf01d/ports-and-adapters/03/issues/quick_tests/__init__.py -------------------------------------------------------------------------------- /ports-and-adapters/03/issues/quick_tests/adapters.py: -------------------------------------------------------------------------------- 1 | from issues.domain.ports import IssueLog, UnitOfWorkManager, UnitOfWork 2 | 3 | 4 | class FakeIssueLog(IssueLog): 5 | 6 | def __init__(self): 7 | self.issues = [] 8 | 9 | def add(self, issue): 10 | self.issues.append(issue) 11 | 12 | def get(self, id): 13 | return self.issues[id] 14 | 15 | def __len__(self): 16 | return len(self.issues) 17 | 18 | def __getitem__(self, idx): 19 | return self.issues[idx] 20 | 21 | 22 | class FakeUnitOfWork(UnitOfWork, UnitOfWorkManager): 23 | 24 | def __init__(self): 25 | self._issues = FakeIssueLog() 26 | 27 | def start(self): 28 | self.was_committed = False 29 | self.was_rolled_back = False 30 | return self 31 | 32 | def __enter__(self): 33 | return self 34 | 35 | def __exit__(self, type, value, traceback): 36 | self.exn_type = type 37 | self.exn = value 38 | self.traceback = traceback 39 | 40 | def commit(self): 41 | self.was_committed = True 42 | 43 | def rollback(self): 44 | self.was_rolled_back = True 45 | 46 | @property 47 | def issues(self): 48 | return self._issues 49 | -------------------------------------------------------------------------------- /ports-and-adapters/03/issues/quick_tests/test_issue_reporting.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from .adapters import FakeUnitOfWork 4 | from issues.services import ReportIssueHandler 5 | from issues.domain.commands import ReportIssueCommand 6 | 7 | from expects import expect, have_len, equal, be_true 8 | 9 | email = "bob@example.org" 10 | name = "bob" 11 | desc = "My mouse won't move" 12 | id = uuid.uuid4() 13 | 14 | 15 | class When_reporting_an_issue: 16 | 17 | def given_an_empty_unit_of_work(self): 18 | self.uow = FakeUnitOfWork() 19 | 20 | def because_we_report_a_new_issue(self): 21 | handler = ReportIssueHandler(self.uow) 22 | cmd = ReportIssueCommand(id, name, email, desc) 23 | 24 | handler.handle(cmd) 25 | 26 | def it_should_have_recorded_the_id(self): 27 | expect(self.uow.issues[0].id).to(equal(id)) 28 | 29 | def the_handler_should_have_created_a_new_issue(self): 30 | expect(self.uow.issues).to(have_len(1)) 31 | 32 | def it_should_have_recorded_the_issuer(self): 33 | expect(self.uow.issues[0].reporter.name).to(equal(name)) 34 | expect(self.uow.issues[0].reporter.email).to(equal(email)) 35 | 36 | def it_should_have_recorded_the_description(self): 37 | expect(self.uow.issues[0].description).to(equal(desc)) 38 | 39 | def it_should_have_committed_the_unit_of_work(self): 40 | expect(self.uow.was_committed).to(be_true) 41 | -------------------------------------------------------------------------------- /ports-and-adapters/03/issues/services/__init__.py: -------------------------------------------------------------------------------- 1 | from issues.domain.model import Issue, IssueReporter 2 | from issues.domain.ports import UnitOfWorkManager 3 | 4 | 5 | class ReportIssueHandler: 6 | 7 | def __init__(self, uowm: UnitOfWorkManager): 8 | self.uowm = uowm 9 | 10 | def handle(self, cmd): 11 | reporter = IssueReporter(cmd.reporter_name, cmd.reporter_email) 12 | issue = Issue(cmd.issue_id, reporter, cmd.problem_description) 13 | 14 | with self.uowm.start() as tx: 15 | tx.issues.add(issue) 16 | tx.commit() 17 | -------------------------------------------------------------------------------- /ports-and-adapters/03/issues/slow_tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bobthemighty/blog-code-samples/9c50b57baf42a76a10e612a65b3508fef60cf01d/ports-and-adapters/03/issues/slow_tests/__init__.py -------------------------------------------------------------------------------- /ports-and-adapters/03/issues/slow_tests/issue_repository_tests.py: -------------------------------------------------------------------------------- 1 | from issues.domain.model import Issue 2 | from issues.domain.commands import ReportIssueCommand 3 | from issues.services import ReportIssueHandler 4 | from issues.adapters.orm import SqlAlchemy 5 | from issues.adapters.views import IssueViewBuilder 6 | 7 | import uuid 8 | 9 | from expects import expect, equal, have_len 10 | 11 | 12 | class When_we_load_a_persisted_issue: 13 | 14 | def given_a_database_containing_an_issue(self): 15 | 16 | self.db = SqlAlchemy('sqlite://') 17 | self.db.configure_mappings() 18 | self.db.recreate_schema() 19 | 20 | self.issue_id = uuid.uuid4() 21 | 22 | cmd = ReportIssueCommand(self.issue_id, 'fred', 'fred@example.org', 23 | 'forgot my password again') 24 | handler = ReportIssueHandler(self.db.unit_of_work_manager) 25 | handler.handle(cmd) 26 | 27 | def because_we_load_the_issues(self): 28 | view_builder = IssueViewBuilder(self.db.get_session()) 29 | self.issue = view_builder.fetch(self.issue_id) 30 | 31 | def it_should_have_the_correct_description(self): 32 | expect(self.issue['id']).to(equal(self.issue_id)) 33 | 34 | def it_should_have_the_correct_description(self): 35 | expect(self.issue['description']).to(equal('forgot my password again')) 36 | 37 | def it_should_have_the_correct_reporter_details(self): 38 | expect(self.issue['reporter_name']).to(equal('fred')) 39 | 40 | def it_should_have_the_correct_reporter_details(self): 41 | expect(self.issue['reporter_email']).to(equal('fred@example.org')) 42 | -------------------------------------------------------------------------------- /ports-and-adapters/03/requirements.txt: -------------------------------------------------------------------------------- 1 | colorama==0.3.9 2 | Contexts==0.11.2 3 | expects==0.8.0 4 | -------------------------------------------------------------------------------- /ports-and-adapters/03/setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name='ports-and-adapters', 5 | url='https://github.com/bobthemighty/ports-and-adapters-pythong', 6 | packages=find_packages('.'), 7 | ) 8 | -------------------------------------------------------------------------------- /ports-and-adapters/04/.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *.cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # Jupyter Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # SageMath parsed files 79 | *.sage.py 80 | 81 | # Environments 82 | .env 83 | .venv 84 | env/ 85 | venv/ 86 | ENV/ 87 | 88 | # Spyder project settings 89 | .spyderproject 90 | .spyproject 91 | 92 | # Rope project settings 93 | .ropeproject 94 | 95 | # mkdocs documentation 96 | /site 97 | 98 | # mypy 99 | .mypy_cache/ 100 | 101 | -------------------------------------------------------------------------------- /ports-and-adapters/04/README.md: -------------------------------------------------------------------------------- 1 | To run the code, set up a python 3 virtual environment, then 2 | 3 | `pip install -r requirements.txt` 4 | `pip install -e .` 5 | 6 | You will need Sqlite installed. For the API, you'll need write-access to the current directory, or you can just hack the connection string in adapters/flask.py 7 | 8 | If you `cd issues` you can now run tests with `run-contexts -v`. 9 | 10 | To run the API 11 | 12 | ```bash 13 | $ export FLASK_APP=issues.adapters.flask 14 | $ flask run 15 | flask run 16 | * Serving Flask app "issues.adapters.flask" 17 | * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit) 18 | 127.0.0.1 - - [13/Sep/2017 13:50:50] "POST /issues HTTP/1.1" 201 - 19 | 127.0.0.1 - - [13/Sep/2017 13:50:57] "GET /issues/4d9ca3ab-3d70-486c-965b-41d27e0dbd33 HTTP/1.1" 200 - 20 | ``` 21 | 22 | You can now post a new issue to the api. 23 | 24 | ```bash 25 | $ curl -d'{"reporter_name": "carlos", "reporter_email": "carlos@example.org", "problem_description": "Nothing works any more"}' localhost:5000/issues -H "Content-Type: application/json" -v 26 | 27 | * Trying ::1... 28 | * TCP_NODELAY set 29 | * connect to ::1 port 5000 failed: Connection refused 30 | * Trying 127.0.0.1... 31 | * TCP_NODELAY set 32 | * Connected to localhost (127.0.0.1) port 5000 (#0) 33 | > POST /issues HTTP/1.1 34 | > Host: localhost:5000 35 | > User-Agent: curl/7.55.1 36 | > Accept: */* 37 | > Content-Type: application/json 38 | > Content-Length: 108 39 | > 40 | * upload completely sent off: 108 out of 108 bytes 41 | * HTTP 1.0, assume close after body 42 | < HTTP/1.0 201 CREATED 43 | < Location: http://localhost:5000/issues/4d9ca3ab-3d70-486c-965b-41d27e0dbd33 44 | < Content-Type: text/html; charset=utf-8 45 | < Content-Length: 0 46 | < Server: Werkzeug/0.12.2 Python/3.6.2 47 | < Date: Wed, 13 Sep 2017 12:50:50 GMT 48 | < 49 | * Closing connection 0 50 | 51 | ``` 52 | 53 | 54 | And fetch the new issue from the URI given in the Location header. 55 | 56 | ```bash 57 | curl http://localhost:5000/issues/4d9ca3ab-3d70-486c-965b-41d27e0dbd33 58 | { 59 | "description": "Nothing works any more", 60 | "reporter_email": "carlos@example.org", 61 | "reporter_name": "carlos" 62 | } 63 | ``` 64 | -------------------------------------------------------------------------------- /ports-and-adapters/04/issues/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bobthemighty/blog-code-samples/9c50b57baf42a76a10e612a65b3508fef60cf01d/ports-and-adapters/04/issues/__init__.py -------------------------------------------------------------------------------- /ports-and-adapters/04/issues/adapters/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bobthemighty/blog-code-samples/9c50b57baf42a76a10e612a65b3508fef60cf01d/ports-and-adapters/04/issues/adapters/__init__.py -------------------------------------------------------------------------------- /ports-and-adapters/04/issues/adapters/config.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from .orm import SqlAlchemy 4 | from .views import IssueViewBuilder, IssueListBuilder 5 | from issues.services import ReportIssueHandler, TriageIssueHandler, IssueAssignedHandler, AssignIssueHandler 6 | 7 | from .emails import LoggingEmailSender 8 | 9 | import issues.domain.messages as msg 10 | from issues.domain.ports import MessageBus 11 | 12 | db = SqlAlchemy('sqlite:///issues.db') 13 | db.configure_mappings() 14 | db.create_schema() 15 | 16 | bus = MessageBus() 17 | db.associate_message_bus(bus) 18 | 19 | issue_view_builder = IssueViewBuilder(db) 20 | issue_list_builder = IssueListBuilder(db) 21 | 22 | report_issue = ReportIssueHandler(db.unit_of_work_manager) 23 | assign_issue = AssignIssueHandler(db.unit_of_work_manager) 24 | triage_issue = TriageIssueHandler(db.unit_of_work_manager) 25 | issue_assigned = IssueAssignedHandler(issue_view_builder, LoggingEmailSender()) 26 | 27 | bus.subscribe_to(msg.ReportIssueCommand, report_issue) 28 | bus.subscribe_to(msg.TriageIssueCommand, triage_issue) 29 | bus.subscribe_to(msg.IssueAssignedToEngineer, issue_assigned) 30 | bus.subscribe_to(msg.AssignIssueCommand, assign_issue) 31 | -------------------------------------------------------------------------------- /ports-and-adapters/04/issues/adapters/emails.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Dict, Any 3 | from issues.domain.emails import EmailSender, MailRequest 4 | 5 | 6 | class LoggingEmailSender(EmailSender): 7 | 8 | def _do_send(self, recipient, sender, subject, body): 9 | print("Sending email to {to} from {sender}\nsubject:{subject}\n{body}". 10 | format(to=recipient, sender=sender, body=body, subject=subject)) 11 | -------------------------------------------------------------------------------- /ports-and-adapters/04/issues/adapters/flask.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from flask import Flask, request, jsonify 3 | from . import config 4 | from issues.domain.messages import ReportIssueCommand, AssignIssueCommand 5 | 6 | app = Flask('issues') 7 | 8 | 9 | @app.before_request 10 | def get_auth_header(): 11 | request.user = request.headers.get('X-email') 12 | 13 | 14 | @app.route('/issues', methods=['POST']) 15 | def report_issue(): 16 | issue_id = uuid.uuid4() 17 | cmd = ReportIssueCommand(issue_id=issue_id, **request.get_json()) 18 | config.bus.handle(cmd) 19 | return "", 201, {"Location": "/issues/" + str(issue_id)} 20 | 21 | 22 | @app.route('/issues/') 23 | def get_issue(issue_id): 24 | view_builder = config.issue_view_builder 25 | view = view_builder.fetch(uuid.UUID(issue_id)) 26 | return jsonify(view) 27 | 28 | 29 | @app.route('/issues', methods=['GET']) 30 | def list_issues(): 31 | view_builder = config.issue_list_builder 32 | view = view_builder.fetch() 33 | return jsonify(view) 34 | 35 | 36 | @app.route('/issues//assign', methods=['POST']) 37 | def assign_to_engineer(issue_id): 38 | assign_to = request.args.get('engineer') 39 | cmd = AssignIssueCommand(issue_id, assign_to, request.user) 40 | config.bus.handle(cmd) 41 | return "", 200 42 | 43 | 44 | @app.route('/issues//pick', methods=['POST']) 45 | def pick_issue(issue_id): 46 | cmd = PickIssueCommand(issue_id, request.user) 47 | config.bus.handle(cmd) 48 | return 49 | -------------------------------------------------------------------------------- /ports-and-adapters/04/issues/adapters/orm.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import logging 3 | import uuid 4 | 5 | import sqlalchemy 6 | from sqlalchemy import (Table, Column, MetaData, String, Integer, Text, 7 | ForeignKey, create_engine, event) 8 | from sqlalchemy.orm import mapper, scoped_session, sessionmaker, composite, relationship 9 | import sqlalchemy.exc 10 | import sqlalchemy.orm.exc 11 | 12 | from sqlalchemy_utils.functions import create_database, drop_database 13 | from sqlalchemy_utils.types.uuid import UUIDType 14 | 15 | from issues.domain.model import Issue, IssueReporter, Assignment 16 | from issues.domain.ports import ( 17 | IssueLog, 18 | UnitOfWork, 19 | UnitOfWorkManager, 20 | ) 21 | 22 | 23 | class SqlAlchemyUnitOfWorkManager(UnitOfWorkManager): 24 | 25 | def __init__(self, session_maker, bus): 26 | self.session_maker = session_maker 27 | self.bus = bus 28 | 29 | def start(self): 30 | return SqlAlchemyUnitOfWork(self.session_maker, self.bus) 31 | 32 | 33 | class IssueRepository(IssueLog): 34 | 35 | def __init__(self, session): 36 | self._session = session 37 | 38 | def add(self, issue: Issue) -> None: 39 | self._session.add(issue) 40 | 41 | def _get(self, issue_id) -> Issue: 42 | return self._session.query(Issue).\ 43 | filter_by(id=issue_id).\ 44 | first() 45 | 46 | 47 | class SqlAlchemyUnitOfWork(UnitOfWork): 48 | 49 | def __init__(self, sessionfactory, bus): 50 | self.sessionfactory = sessionfactory 51 | self.bus = bus 52 | event.listen(self.sessionfactory, "after_flush", self.gather_events) 53 | event.listen(self.sessionfactory, "loaded_as_persistent", 54 | self.setup_events) 55 | 56 | def __enter__(self): 57 | self.session = self.sessionfactory() 58 | self.flushed_events = [] 59 | return self 60 | 61 | def __exit__(self, type, value, traceback): 62 | self.session.close() 63 | self.publish_events() 64 | 65 | def commit(self): 66 | self.session.flush() 67 | self.session.commit() 68 | 69 | def rollback(self): 70 | self.flushed_events = [] 71 | self.session.rollback() 72 | 73 | def setup_events(self, session, entity): 74 | entity.events = [] 75 | 76 | def gather_events(self, session, ctx): 77 | flushed_objects = [e for e in session.new] + [e for e in session.dirty] 78 | for e in flushed_objects: 79 | try: 80 | self.flushed_events += e.events 81 | except AttributeError: 82 | pass 83 | 84 | def publish_events(self): 85 | for e in self.flushed_events: 86 | self.bus.handle(e) 87 | 88 | @property 89 | def issues(self): 90 | return IssueRepository(self.session) 91 | 92 | 93 | class SqlAlchemy: 94 | 95 | def __init__(self, uri): 96 | self.engine = create_engine(uri) 97 | self._session_maker = scoped_session(sessionmaker(self.engine),) 98 | 99 | @property 100 | def unit_of_work_manager(self): 101 | return SqlAlchemyUnitOfWorkManager(self._session_maker, self.bus) 102 | 103 | def recreate_schema(self): 104 | drop_database(self.engine.url) 105 | self.create_schema() 106 | 107 | def create_schema(self): 108 | create_database(self.engine.url) 109 | self.metadata.create_all() 110 | 111 | def get_session(self): 112 | return self._session_maker() 113 | 114 | def associate_message_bus(self, bus): 115 | self.bus = bus 116 | 117 | def configure_mappings(self): 118 | self.metadata = MetaData(self.engine) 119 | 120 | IssueReporter.__composite_values__ = lambda i: (i.name, i.email) 121 | issues = Table('issues', self.metadata, 122 | Column('pk', Integer, primary_key=True), 123 | Column('issue_id', UUIDType), 124 | Column('reporter_name', String(50)), 125 | Column('reporter_email', String(50)), 126 | Column('description', Text)) 127 | 128 | assignments = Table( 129 | 'assignments', 130 | self.metadata, 131 | Column('pk', Integer, primary_key=True), 132 | Column('id', UUIDType), 133 | Column('fk_assignment_id', UUIDType, ForeignKey('issues.issue_id')), 134 | Column('assigned_by', String(50)), 135 | Column('assigned_to', String(50)), 136 | ) 137 | 138 | mapper( 139 | Issue, 140 | issues, 141 | properties={ 142 | '__pk': 143 | issues.c.pk, 144 | 'id': 145 | issues.c.issue_id, 146 | 'description': 147 | issues.c.description, 148 | 'reporter': 149 | composite(IssueReporter, issues.c.reporter_name, 150 | issues.c.reporter_email), 151 | '_assignments': 152 | relationship(Assignment, backref='issue') 153 | }, 154 | ), 155 | 156 | mapper( 157 | Assignment, 158 | assignments, 159 | properties={ 160 | '__pk': assignments.c.pk, 161 | 'id': assignments.c.id, 162 | 'assigned_to': assignments.c.assigned_to, 163 | 'assigned_by': assignments.c.assigned_by 164 | }) 165 | 166 | 167 | class SqlAlchemySessionContext: 168 | 169 | def __init__(self, session_maker): 170 | self._session_maker = session_maker 171 | 172 | def __enter__(self): 173 | self._session = self._session_maker() 174 | 175 | def __exit__(self, type, value, traceback): 176 | self._session_maker.remove() 177 | -------------------------------------------------------------------------------- /ports-and-adapters/04/issues/adapters/views.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import uuid 3 | 4 | # This little helper function converts the binary data 5 | # We store in Sqlite back to a uuid. 6 | # Ordinarily I use postgres, which has a native UniqueID 7 | # type, so this manual unmarshalling isn't necessary 8 | 9 | 10 | def read_uuid(record, column): 11 | record = dict(record) 12 | bytes_val = record[column] 13 | uuid_val = uuid.UUID(bytes=bytes_val) 14 | record[column] = uuid_val 15 | return record 16 | 17 | 18 | class IssueViewBuilder: 19 | 20 | _q = """SELECT description, 21 | reporter_email, 22 | reporter_name 23 | FROM issues 24 | WHERE issue_id = :id""" 25 | 26 | def __init__(self, db): 27 | self.db = db 28 | 29 | def fetch(self, id): 30 | session = self.db.get_session() 31 | result = session.execute(self._q, {'id': id.bytes}) 32 | record = result.fetchone() 33 | return dict(record) 34 | 35 | 36 | class IssueListBuilder: 37 | 38 | _q = """SELECT issue_id, 39 | description, 40 | reporter_email, 41 | reporter_name 42 | FROM issues""" 43 | 44 | def __init__(self, db): 45 | self.db = db 46 | 47 | def fetch(self): 48 | session = self.db.get_session() 49 | query = session.execute( 50 | 'SELECT issue_id, description, reporter_email, reporter_name ' + 51 | ' FROM issues') 52 | 53 | result = [] 54 | for r in query.fetchall(): 55 | r = read_uuid(r, 'issue_id') 56 | result.append(r) 57 | 58 | return result 59 | -------------------------------------------------------------------------------- /ports-and-adapters/04/issues/domain/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bobthemighty/blog-code-samples/9c50b57baf42a76a10e612a65b3508fef60cf01d/ports-and-adapters/04/issues/domain/__init__.py -------------------------------------------------------------------------------- /ports-and-adapters/04/issues/domain/emails.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import NamedTuple, NewType, Dict, Any 3 | from jinja2 import Template 4 | 5 | EmailAddress = NewType('email', str) 6 | default_from_addr = EmailAddress('issues@example.org') 7 | TemplateData = Dict[str, Any] 8 | 9 | 10 | class MailTemplate(NamedTuple): 11 | subject: Template 12 | body: Template 13 | 14 | 15 | class MailRequest(NamedTuple): 16 | template: MailTemplate 17 | from_addr: EmailAddress 18 | to_addr: EmailAddress 19 | 20 | 21 | IssueAssignedToMe = MailTemplate( 22 | subject=Template("Hi {{assigned_to}} - you've been assigned an issue"), 23 | body=Template("{{description}}")) 24 | 25 | 26 | class EmailSender: 27 | 28 | @abc.abstractmethod 29 | def _do_send(self, request: MailRequest): 30 | pass 31 | 32 | def send(self, request: MailRequest, data: TemplateData): 33 | self._do_send(request.to_addr, request.from_addr, 34 | request.template.subject.render(data), 35 | request.template.body.render(data)) 36 | -------------------------------------------------------------------------------- /ports-and-adapters/04/issues/domain/messages.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from uuid import UUID 3 | from typing import NamedTuple 4 | 5 | 6 | def event(cls): 7 | 8 | setattr(cls, 'is_cmd', property(lambda x: getattr(x, 'is_cmd', False))) 9 | setattr(cls, 'is_event', property(lambda x: getattr(x, 'is_event', True))) 10 | setattr(cls, 'id', property(lambda x: getattr(x, 'id', None))) 11 | 12 | return cls 13 | 14 | 15 | def command(cls): 16 | 17 | setattr(cls, 'is_cmd', property(lambda x: getattr(x, 'is_cmd', True))) 18 | setattr(cls, 'is_event', property(lambda x: getattr(x, 'is_event', False))) 19 | setattr(cls, 'id', property(lambda x: getattr(x, 'id', None))) 20 | 21 | return cls 22 | 23 | 24 | class Message(NamedTuple): 25 | id: UUID 26 | 27 | 28 | class IssueState(Enum): 29 | AwaitingTriage = 0 30 | AwaitingAssignment = 1 31 | ReadyForWork = 2 32 | 33 | 34 | class IssuePriority(Enum): 35 | NotPrioritised = 0 36 | Low = 1 37 | Normal = 2 38 | High = 3 39 | Urgent = 4 40 | 41 | 42 | @command 43 | class ReportIssueCommand(NamedTuple): 44 | issue_id: UUID 45 | reporter_name: str 46 | reporter_email: str 47 | problem_description: str 48 | 49 | 50 | @command 51 | class TriageIssueCommand(NamedTuple): 52 | issue_id: UUID 53 | category: str 54 | priority: IssuePriority 55 | 56 | 57 | @command 58 | class AssignIssueCommand(NamedTuple): 59 | issue_id: UUID 60 | assigned_to: str 61 | assigned_by: str 62 | 63 | 64 | @command 65 | class PickIssueCommand(NamedTuple): 66 | issue_id: UUID 67 | picked_by: str 68 | 69 | 70 | @event 71 | class IssueAssignedToEngineer(NamedTuple): 72 | issue_id: UUID 73 | assigned_to: str 74 | assigned_by: str 75 | 76 | 77 | @event 78 | class IssueReassigned(NamedTuple): 79 | issue_id: UUID 80 | previous_assignee: str 81 | -------------------------------------------------------------------------------- /ports-and-adapters/04/issues/domain/model.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from uuid import UUID 3 | from .messages import (IssueState, IssuePriority, IssueReassigned, 4 | IssueAssignedToEngineer) 5 | 6 | 7 | class IssueReporter: 8 | 9 | def __init__(self, name: str, email: str) -> None: 10 | self.name = name 11 | self.email = email 12 | 13 | 14 | class Assignment: 15 | 16 | def __init__(self, assigned_to, assigned_by): 17 | self.assigned_to = assigned_to 18 | self.assigned_by = assigned_by 19 | 20 | def is_reassignment_from(self, other): 21 | if other is None: 22 | return False 23 | if other.assigned_to == self.assigned_to: 24 | return False 25 | return True 26 | 27 | 28 | class Issue: 29 | 30 | def __init__(self, issue_id: UUID, reporter: IssueReporter, 31 | description: str) -> None: 32 | self.id = issue_id 33 | self.description = description 34 | self.reporter = reporter 35 | self.state = IssueState.AwaitingTriage 36 | self.events = [] 37 | self._assignments = [] 38 | 39 | @property 40 | def assignment(self): 41 | if len(self._assignments) == 0: 42 | return None 43 | return self._assignments[-1] 44 | 45 | def triage(self, priority: IssuePriority, category: str) -> None: 46 | self.priority = priority 47 | self.category = category 48 | self.state = IssueState.AwaitingAssignment 49 | 50 | def assign(self, assigned_to, assigned_by=None): 51 | previous_assignment = self.assignment 52 | assigned_by = assigned_by or assigned_to 53 | 54 | self._assignments.append(Assignment(assigned_to, assigned_by)) 55 | 56 | self.state = IssueState.ReadyForWork 57 | 58 | if self.assignment.is_reassignment_from(previous_assignment): 59 | self.events.append( 60 | IssueReassigned(self.id, previous_assignment.assigned_to)) 61 | 62 | if assigned_to != assigned_by: 63 | self.events.append( 64 | IssueAssignedToEngineer(self.id, assigned_to, assigned_by)) 65 | -------------------------------------------------------------------------------- /ports-and-adapters/04/issues/domain/ports.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from collections import defaultdict 3 | from uuid import UUID 4 | from .model import Issue 5 | 6 | 7 | class IssueNotFoundException(Exception): 8 | pass 9 | 10 | 11 | class IssueLog(abc.ABC): 12 | 13 | @abc.abstractmethod 14 | def add(self, issue: Issue) -> None: 15 | pass 16 | 17 | @abc.abstractmethod 18 | def _get(self, id: UUID) -> Issue: 19 | pass 20 | 21 | def get(self, id: UUID) -> Issue: 22 | issue = self._get(id) 23 | if issue is None: 24 | raise IssueNotFoundException() 25 | return issue 26 | 27 | 28 | class UnitOfWork(abc.ABC): 29 | 30 | @abc.abstractmethod 31 | def __enter__(self): 32 | pass 33 | 34 | @abc.abstractmethod 35 | def __exit__(self, type, value, traceback): 36 | pass 37 | 38 | @abc.abstractmethod 39 | def commit(self): 40 | pass 41 | 42 | @abc.abstractmethod 43 | def rollback(self): 44 | pass 45 | 46 | @property 47 | @abc.abstractmethod 48 | def issues(self): 49 | pass 50 | 51 | 52 | class UnitOfWorkManager(abc.ABC): 53 | 54 | @abc.abstractmethod 55 | def start(self) -> UnitOfWork: 56 | pass 57 | 58 | 59 | class CommandAlreadySubscribedException(Exception): 60 | pass 61 | 62 | 63 | class MessageBus: 64 | 65 | def __init__(self): 66 | self.subscribers = defaultdict(list) 67 | 68 | def handle(self, msg): 69 | subscribers = self.subscribers[type(msg).__name__] 70 | for subscriber in subscribers: 71 | subscriber.handle(msg) 72 | 73 | def subscribe_to(self, msg, handler): 74 | subscribers = self.subscribers[msg.__name__] 75 | # We shouldn't be able to subscribe more 76 | # than one handler for a command 77 | if msg.is_cmd and len(subscribers) > 0: 78 | raise CommandAlreadySubscribedException(msg.__name__) 79 | subscribers.append(handler) 80 | 81 | 82 | class IssueViewBuilder: 83 | 84 | @abc.abstractmethod 85 | def fetch(self, id): 86 | pass 87 | -------------------------------------------------------------------------------- /ports-and-adapters/04/issues/issues.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bobthemighty/blog-code-samples/9c50b57baf42a76a10e612a65b3508fef60cf01d/ports-and-adapters/04/issues/issues.db -------------------------------------------------------------------------------- /ports-and-adapters/04/issues/quick_tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bobthemighty/blog-code-samples/9c50b57baf42a76a10e612a65b3508fef60cf01d/ports-and-adapters/04/issues/quick_tests/__init__.py -------------------------------------------------------------------------------- /ports-and-adapters/04/issues/quick_tests/adapters.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | 3 | from issues.domain.ports import IssueLog, UnitOfWorkManager, UnitOfWork 4 | from issues.domain.emails import EmailSender 5 | 6 | 7 | class FakeIssueLog(IssueLog): 8 | 9 | def __init__(self): 10 | self.issues = [] 11 | 12 | def add(self, issue): 13 | self.issues.append(issue) 14 | 15 | def _get(self, id): 16 | for issue in self.issues: 17 | if issue.id == id: 18 | return issue 19 | 20 | def __len__(self): 21 | return len(self.issues) 22 | 23 | def __getitem__(self, idx): 24 | return self.issues[idx] 25 | 26 | 27 | class FakeUnitOfWork(UnitOfWork, UnitOfWorkManager): 28 | 29 | def __init__(self): 30 | self._issues = FakeIssueLog() 31 | 32 | def start(self): 33 | self.was_committed = False 34 | self.was_rolled_back = False 35 | return self 36 | 37 | def __enter__(self): 38 | return self 39 | 40 | def __exit__(self, type, value, traceback): 41 | self.exn_type = type 42 | self.exn = value 43 | self.traceback = traceback 44 | 45 | def commit(self): 46 | self.was_committed = True 47 | 48 | def rollback(self): 49 | self.was_rolled_back = True 50 | 51 | @property 52 | def issues(self): 53 | return self._issues 54 | 55 | 56 | class FakeEmailSender(EmailSender): 57 | 58 | sent_mail = namedtuple('fakes_sent_mail', 59 | ['recipient', 'sender', 'subject', 'body']) 60 | 61 | def __init__(self): 62 | self.sent = [] 63 | 64 | def _do_send(self, recipient, sender, subject, body): 65 | self.sent.append(self.sent_mail(recipient, sender, subject, body)) 66 | 67 | 68 | class FakeViewBuilder: 69 | 70 | def __init__(self, view_model): 71 | self.view_model = view_model 72 | 73 | def fetch(self, id): 74 | return self.view_model 75 | -------------------------------------------------------------------------------- /ports-and-adapters/04/issues/quick_tests/matchers.py: -------------------------------------------------------------------------------- 1 | from expects.matchers import Matcher 2 | 3 | 4 | class have_raised(Matcher): 5 | 6 | def __init__(self, event): 7 | self.event = event 8 | 9 | def _match(self, entity): 10 | return self.event in entity.events, [] 11 | -------------------------------------------------------------------------------- /ports-and-adapters/04/issues/quick_tests/shared_contexts.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from .adapters import FakeUnitOfWork 3 | from issues.domain.model import Issue, IssueReporter 4 | from issues.domain.messages import IssuePriority 5 | 6 | 7 | class With_an_empty_unit_of_work: 8 | 9 | def given_a_unit_of_work(self): 10 | self.uow = FakeUnitOfWork() 11 | 12 | 13 | class With_a_new_issue(With_an_empty_unit_of_work): 14 | 15 | def given_a_new_issue(self): 16 | reporter = IssueReporter('John', 'john@example.org') 17 | self.issue_id = uuid.uuid4() 18 | self.issue = Issue(self.issue_id, reporter, 'how do I even?') 19 | self.uow.issues.add(self.issue) 20 | 21 | 22 | class With_a_triaged_issue(With_a_new_issue): 23 | 24 | def given_a_triaged_issue(self): 25 | self.issue.triage(IssuePriority.Low, 'uncategorised') 26 | 27 | 28 | class With_assigned_issue(With_a_triaged_issue): 29 | 30 | assigned_by = 'fred@example.org' 31 | assigned_to = 'mary@example.org' 32 | 33 | def given_an_assigned_issue(self): 34 | self.issue.assign(assigned_to, assigned_by) 35 | -------------------------------------------------------------------------------- /ports-and-adapters/04/issues/quick_tests/test_assignment.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from .adapters import FakeUnitOfWork, FakeEmailSender, FakeViewBuilder 4 | from .shared_contexts import With_a_triaged_issue 5 | from .matchers import have_raised 6 | 7 | from issues.services import (AssignIssueHandler, IssueAssignedHandler, 8 | PickIssueHandler) 9 | from issues.domain.messages import (AssignIssueCommand, IssueAssignedToEngineer, 10 | IssueReassigned, IssueState, IssuePriority, 11 | PickIssueCommand) 12 | from issues.domain.model import Issue, IssueReporter 13 | 14 | from expects import expect, have_len, equal, be_true 15 | 16 | 17 | class When_assigning_an_issue(With_a_triaged_issue): 18 | 19 | assigned_to = 'percy@example.org' 20 | assigned_by = 'norman@example.org' 21 | 22 | def because_we_assign_the_issue(self): 23 | handler = AssignIssueHandler(self.uow) 24 | cmd = AssignIssueCommand(self.issue_id, self.assigned_to, 25 | self.assigned_by) 26 | 27 | handler.handle(cmd) 28 | 29 | def the_issue_should_be_assigned_to_percy(self): 30 | expect(self.issue.assignment.assigned_to).to(equal(self.assigned_to)) 31 | 32 | def the_issue_should_have_been_assigned_by_norman(self): 33 | expect(self.issue.assignment.assigned_by).to(equal(self.assigned_by)) 34 | 35 | def the_issue_should_be_ready_for_work(self): 36 | expect(self.issue.state).to(equal(IssueState.ReadyForWork)) 37 | 38 | def it_should_have_committed_the_unit_of_work(self): 39 | expect(self.uow.was_committed).to(be_true) 40 | 41 | def it_should_have_raised_issue_assigned(self): 42 | expect(self.issue).to( 43 | have_raised( 44 | IssueAssignedToEngineer(self.issue_id, self.assigned_to, 45 | self.assigned_by))) 46 | 47 | 48 | class When_picking_an_issue(With_a_triaged_issue): 49 | 50 | picked_by = 'percy@example.org' 51 | 52 | def because_we_pick_the_issue(self): 53 | handler = PickIssueHandler(self.uow) 54 | cmd = PickIssueCommand(self.issue_id, self.picked_by) 55 | 56 | handler.handle(cmd) 57 | 58 | def the_issue_should_be_assigned_to_percy(self): 59 | expect(self.issue.assignment.assigned_to).to(equal(self.picked_by)) 60 | 61 | def the_issue_should_be_ready_for_work(self): 62 | expect(self.issue.state).to(equal(IssueState.ReadyForWork)) 63 | 64 | def it_should_have_committed_the_unit_of_work(self): 65 | expect(self.uow.was_committed).to(be_true) 66 | 67 | def it_should_not_have_raised_issue_assigned(self): 68 | expect(self.issue.events).to(have_len(0)) 69 | 70 | 71 | class When_reassigning_an_issue(With_a_triaged_issue): 72 | 73 | assigned_by = 'george@example.org' 74 | assigned_to = 'fred@example.org' 75 | 76 | new_assigned_to = 'percy@example.org' 77 | new_assigned_by = 'norman@example.org' 78 | 79 | def given_an_assigned_issue(self): 80 | self.issue.assign(self.assigned_to, self.assigned_by) 81 | 82 | def because_we_assign_the_issue(self): 83 | handler = AssignIssueHandler(self.uow) 84 | cmd = AssignIssueCommand(self.issue_id, self.new_assigned_to, 85 | self.new_assigned_by) 86 | 87 | handler.handle(cmd) 88 | 89 | def it_should_have_raised_issue_reassigned(self): 90 | expect(self.issue).to( 91 | have_raised(IssueReassigned(self.issue_id, self.assigned_to))) 92 | 93 | 94 | class When_an_issue_is_assigned: 95 | 96 | issue_id = uuid.uuid4() 97 | assigned_to = 'barry@example.org' 98 | assigned_by = 'helga@example.org' 99 | 100 | def given_a_view_model_and_emailer(self): 101 | self.view_builder = FakeViewBuilder({ 102 | 'description': 103 | 'a bad thing happened', 104 | 'reporter_email': 105 | 'reporter@example.org', 106 | 'reported_name': 107 | 'Reporty McReportface' 108 | }) 109 | 110 | self.emailer = FakeEmailSender() 111 | 112 | def because_we_raise_issue_assigned(self): 113 | evt = IssueAssignedToEngineer(self.issue_id, self.assigned_to, 114 | self.assigned_by) 115 | 116 | handler = IssueAssignedHandler(self.view_builder, self.emailer) 117 | handler.handle(evt) 118 | 119 | def it_should_send_an_email(self): 120 | expect(self.emailer.sent).to(have_len(1)) 121 | 122 | def it_should_have_the_correct_subject(self): 123 | expect(self.emailer.sent[0].subject).to( 124 | equal('Hi barry@example.org - you\'ve been assigned an issue')) 125 | 126 | def it_should_be_to_the_correct_recipient(self): 127 | expect(self.emailer.sent[0].recipient).to(equal(self.assigned_to)) 128 | -------------------------------------------------------------------------------- /ports-and-adapters/04/issues/quick_tests/test_issue_reporting.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from .adapters import FakeUnitOfWork 4 | from issues.services import ReportIssueHandler 5 | from issues.domain.messages import ReportIssueCommand, IssueState, IssuePriority 6 | from issues.domain.model import Issue 7 | 8 | from expects import expect, have_len, equal, be_true 9 | 10 | email = "bob@example.org" 11 | name = "bob" 12 | desc = "My mouse won't move" 13 | id = uuid.uuid4() 14 | 15 | 16 | class When_reporting_an_issue: 17 | 18 | def given_an_empty_unit_of_work(self): 19 | self.uow = FakeUnitOfWork() 20 | 21 | def because_we_report_a_new_issue(self): 22 | handler = ReportIssueHandler(self.uow) 23 | cmd = ReportIssueCommand(id, name, email, desc) 24 | 25 | handler.handle(cmd) 26 | 27 | def the_handler_should_have_created_a_new_issue(self): 28 | expect(self.uow.issues).to(have_len(1)) 29 | 30 | def it_should_have_recorded_the_id(self): 31 | expect(self.uow.issues[0].id).to(equal(id)) 32 | 33 | def it_should_be_awaiting_triage(self): 34 | expect(self.uow.issues[0].state).to(equal(IssueState.AwaitingTriage)) 35 | 36 | def it_should_have_recorded_the_issuer(self): 37 | expect(self.uow.issues[0].reporter.name).to(equal(name)) 38 | expect(self.uow.issues[0].reporter.email).to(equal(email)) 39 | 40 | def it_should_have_recorded_the_description(self): 41 | expect(self.uow.issues[0].description).to(equal(desc)) 42 | 43 | def it_should_have_committed_the_unit_of_work(self): 44 | expect(self.uow.was_committed).to(be_true) 45 | -------------------------------------------------------------------------------- /ports-and-adapters/04/issues/quick_tests/test_triage.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from .shared_contexts import With_a_new_issue 4 | 5 | from issues.services import TriageIssueHandler 6 | from issues.domain.messages import TriageIssueCommand, IssuePriority, IssueState 7 | from issues.domain.model import Issue 8 | 9 | from expects import expect, have_len, equal, be_true 10 | 11 | 12 | class When_triaging_an_issue(With_a_new_issue): 13 | 14 | issue_id = uuid.uuid4() 15 | category = 'training' 16 | priority = IssuePriority.Low 17 | 18 | def because_we_triage_the_issue(self): 19 | handler = TriageIssueHandler(self.uow) 20 | cmd = TriageIssueCommand(self.issue_id, self.category, self.priority) 21 | 22 | handler.handle(cmd) 23 | 24 | def the_issue_should_have_a_priority_set(self): 25 | expect(self.issue.priority).to(equal(IssuePriority.Low)) 26 | 27 | def the_issue_should_have_been_categorised(self): 28 | expect(self.issue.category).to(equal('training')) 29 | 30 | def the_issue_should_be_awaiting_assignment(self): 31 | expect(self.issue.state).to(equal(IssueState.AwaitingAssignment)) 32 | 33 | def it_should_have_committed_the_unit_of_work(self): 34 | expect(self.uow.was_committed).to(be_true) 35 | -------------------------------------------------------------------------------- /ports-and-adapters/04/issues/services/__init__.py: -------------------------------------------------------------------------------- 1 | import issues.domain.emails 2 | from issues.domain.model import Issue, IssueReporter 3 | from issues.domain.ports import UnitOfWorkManager, IssueViewBuilder 4 | from issues.domain import emails 5 | 6 | 7 | class ReportIssueHandler: 8 | 9 | def __init__(self, uowm: UnitOfWorkManager): 10 | self.uowm = uowm 11 | 12 | def handle(self, cmd): 13 | reporter = IssueReporter(cmd.reporter_name, cmd.reporter_email) 14 | issue = Issue(cmd.issue_id, reporter, cmd.problem_description) 15 | 16 | with self.uowm.start() as tx: 17 | tx.issues.add(issue) 18 | tx.commit() 19 | 20 | 21 | class TriageIssueHandler: 22 | 23 | def __init__(self, uowm: UnitOfWorkManager): 24 | self.uowm = uowm 25 | 26 | def handle(self, cmd): 27 | with self.uowm.start() as tx: 28 | issue = tx.issues.get(cmd.issue_id) 29 | issue.triage(cmd.priority, cmd.category) 30 | tx.commit() 31 | 32 | 33 | class PickIssueHandler: 34 | 35 | def __init__(self, uowm: UnitOfWorkManager): 36 | self.uowm = uowm 37 | 38 | def handle(self, cmd): 39 | with self.uowm.start() as tx: 40 | issue = tx.issues.get(cmd.issue_id) 41 | issue.assign(cmd.picked_by) 42 | tx.commit() 43 | 44 | 45 | class AssignIssueHandler: 46 | 47 | def __init__(self, uowm: UnitOfWorkManager): 48 | self.uowm = uowm 49 | 50 | def handle(self, cmd): 51 | with self.uowm.start() as tx: 52 | issue = tx.issues.get(cmd.issue_id) 53 | issue.assign(cmd.assigned_to, cmd.assigned_by) 54 | tx.commit() 55 | 56 | 57 | class IssueAssignedHandler: 58 | 59 | def __init__(self, view_builder: IssueViewBuilder, 60 | sender: emails.EmailSender): 61 | self.sender = sender 62 | self.view_builder = view_builder 63 | 64 | def handle(self, evt): 65 | data = self.view_builder.fetch(evt.issue_id) 66 | data.update(**evt._asdict()) 67 | request = emails.MailRequest( 68 | emails.IssueAssignedToMe, 69 | emails.default_from_addr, 70 | emails.EmailAddress(evt.assigned_to), 71 | ) 72 | 73 | self.sender.send(request, data) 74 | -------------------------------------------------------------------------------- /ports-and-adapters/04/issues/slow_tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bobthemighty/blog-code-samples/9c50b57baf42a76a10e612a65b3508fef60cf01d/ports-and-adapters/04/issues/slow_tests/__init__.py -------------------------------------------------------------------------------- /ports-and-adapters/04/issues/slow_tests/issue_repository_tests.py: -------------------------------------------------------------------------------- 1 | from issues.domain.model import Issue 2 | from issues.domain.messages import ReportIssueCommand 3 | from issues.services import ReportIssueHandler 4 | from issues.adapters.orm import SqlAlchemy 5 | from issues.adapters.views import IssueViewBuilder 6 | 7 | from issues.adapters import config 8 | 9 | import uuid 10 | 11 | from expects import expect, equal, have_len 12 | 13 | 14 | class When_we_load_a_persisted_issue: 15 | 16 | issue_id = uuid.uuid4() 17 | 18 | def given_a_database_containing_an_issue(self): 19 | 20 | cmd = ReportIssueCommand(self.issue_id, 'fred', 'fred@example.org', 21 | 'forgot my password again') 22 | handler = ReportIssueHandler(config.db.unit_of_work_manager) 23 | handler.handle(cmd) 24 | 25 | def because_we_load_the_issues(self): 26 | view_builder = IssueViewBuilder(config.db) 27 | self.issue = view_builder.fetch(self.issue_id) 28 | 29 | def it_should_have_the_correct_description(self): 30 | expect(self.issue['id']).to(equal(self.issue_id)) 31 | 32 | def it_should_have_the_correct_description(self): 33 | expect(self.issue['description']).to(equal('forgot my password again')) 34 | 35 | def it_should_have_the_correct_reporter_details(self): 36 | expect(self.issue['reporter_name']).to(equal('fred')) 37 | 38 | def it_should_have_the_correct_reporter_details(self): 39 | expect(self.issue['reporter_email']).to(equal('fred@example.org')) 40 | -------------------------------------------------------------------------------- /ports-and-adapters/04/requirements.txt: -------------------------------------------------------------------------------- 1 | colorama==0.3.9 2 | Contexts==0.11.2 3 | expects==0.8.0 4 | -------------------------------------------------------------------------------- /ports-and-adapters/04/setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name='ports-and-adapters', 5 | url='https://github.com/bobthemighty/ports-and-adapters-pythong', 6 | packages=find_packages('.'), 7 | ) 8 | -------------------------------------------------------------------------------- /ports-and-adapters/05/.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *.cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # Jupyter Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # SageMath parsed files 79 | *.sage.py 80 | 81 | # Environments 82 | .env 83 | .venv 84 | env/ 85 | venv/ 86 | ENV/ 87 | 88 | # Spyder project settings 89 | .spyderproject 90 | .spyproject 91 | 92 | # Rope project settings 93 | .ropeproject 94 | 95 | # mkdocs documentation 96 | /site 97 | 98 | # mypy 99 | .mypy_cache/ 100 | 101 | -------------------------------------------------------------------------------- /ports-and-adapters/05/README.md: -------------------------------------------------------------------------------- 1 | To run the code, set up a python 3 virtual environment, then 2 | 3 | `pip install -r requirements.txt` 4 | `pip install -e .` 5 | 6 | You will need Sqlite installed. For the API, you'll need write-access to the current directory, or you can just hack the connection string in adapters/flask.py 7 | 8 | If you `cd issues` you can now run tests with `run-contexts -v`. 9 | 10 | To run the API 11 | 12 | ```bash 13 | $ export FLASK_APP=issues.adapters.flask 14 | $ flask run 15 | flask run 16 | * Serving Flask app "issues.adapters.flask" 17 | * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit) 18 | 127.0.0.1 - - [13/Sep/2017 13:50:50] "POST /issues HTTP/1.1" 201 - 19 | 127.0.0.1 - - [13/Sep/2017 13:50:57] "GET /issues/4d9ca3ab-3d70-486c-965b-41d27e0dbd33 HTTP/1.1" 200 - 20 | ``` 21 | 22 | You can now post a new issue to the api. 23 | 24 | ```bash 25 | $ curl -d'{"reporter_name": "carlos", "reporter_email": "carlos@example.org", "problem_description": "Nothing works any more"}' localhost:5000/issues -H "Content-Type: application/json" -v 26 | 27 | * Trying ::1... 28 | * TCP_NODELAY set 29 | * connect to ::1 port 5000 failed: Connection refused 30 | * Trying 127.0.0.1... 31 | * TCP_NODELAY set 32 | * Connected to localhost (127.0.0.1) port 5000 (#0) 33 | > POST /issues HTTP/1.1 34 | > Host: localhost:5000 35 | > User-Agent: curl/7.55.1 36 | > Accept: */* 37 | > Content-Type: application/json 38 | > Content-Length: 108 39 | > 40 | * upload completely sent off: 108 out of 108 bytes 41 | * HTTP 1.0, assume close after body 42 | < HTTP/1.0 201 CREATED 43 | < Location: http://localhost:5000/issues/4d9ca3ab-3d70-486c-965b-41d27e0dbd33 44 | < Content-Type: text/html; charset=utf-8 45 | < Content-Length: 0 46 | < Server: Werkzeug/0.12.2 Python/3.6.2 47 | < Date: Wed, 13 Sep 2017 12:50:50 GMT 48 | < 49 | * Closing connection 0 50 | 51 | ``` 52 | 53 | 54 | And fetch the new issue from the URI given in the Location header. 55 | 56 | ```bash 57 | curl http://localhost:5000/issues/4d9ca3ab-3d70-486c-965b-41d27e0dbd33 58 | { 59 | "description": "Nothing works any more", 60 | "reporter_email": "carlos@example.org", 61 | "reporter_name": "carlos" 62 | } 63 | ``` 64 | -------------------------------------------------------------------------------- /ports-and-adapters/05/issues/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bobthemighty/blog-code-samples/9c50b57baf42a76a10e612a65b3508fef60cf01d/ports-and-adapters/05/issues/__init__.py -------------------------------------------------------------------------------- /ports-and-adapters/05/issues/adapters/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bobthemighty/blog-code-samples/9c50b57baf42a76a10e612a65b3508fef60cf01d/ports-and-adapters/05/issues/adapters/__init__.py -------------------------------------------------------------------------------- /ports-and-adapters/05/issues/adapters/config.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import logging 3 | 4 | from . import orm 5 | 6 | from .emails import LoggingEmailSender 7 | 8 | import issues.domain.messages as msg 9 | from issues.domain import ports 10 | from issues import services, domain, adapters 11 | from issues.adapters import views 12 | 13 | import punq 14 | 15 | 16 | class PunqMessageRegistry(ports.HandlerRegistry): 17 | 18 | def __init__(self, container): 19 | self.container = container 20 | 21 | def get_message_type(self, type): 22 | try: 23 | for base in type.__orig_bases__: 24 | if base.__origin__ == services.Handles: 25 | return base 26 | except: 27 | pass 28 | 29 | def register_all(self, module): 30 | for _, type in inspect.getmembers(module, predicate=inspect.isclass): 31 | self.register(type) 32 | 33 | def register(self, type): 34 | handler_service_type = self.get_message_type(type) 35 | if handler_service_type is None: 36 | return 37 | container.register(handler_service_type, type) 38 | 39 | def get_handlers(self, type): 40 | return self.container.resolve_all(services.Handles[type]) 41 | 42 | 43 | container = punq.Container() 44 | 45 | db = orm.SqlAlchemy('sqlite://') 46 | db.recreate_schema() 47 | db.register_in(container) 48 | 49 | container.register(ports.IssueViewBuilder, views.IssueViewBuilder) 50 | container.register(ports.IssueListViewBuilder, views.IssueListBuilder) 51 | container.register(domain.emails.EmailSender, 52 | adapters.emails.LoggingEmailSender) 53 | messages = PunqMessageRegistry(container) 54 | container.register(ports.HandlerRegistry, messages) 55 | container.register(ports.MessageBus) 56 | messages.register_all(services) 57 | -------------------------------------------------------------------------------- /ports-and-adapters/05/issues/adapters/emails.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Dict, Any 3 | from issues.domain.emails import EmailSender, MailRequest 4 | 5 | 6 | class LoggingEmailSender(EmailSender): 7 | 8 | def _do_send(self, recipient, sender, subject, body): 9 | print("Sending email to {to} from {sender}\nsubject:{subject}\n{body}". 10 | format(to=recipient, sender=sender, body=body, subject=subject)) 11 | -------------------------------------------------------------------------------- /ports-and-adapters/05/issues/adapters/flask.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from flask import Flask, request, jsonify 3 | from . import config 4 | from issues.domain.messages import ReportIssueCommand, AssignIssueCommand 5 | from issues.domain.ports import MessageBus, IssueViewBuilder, IssueListViewBuilder 6 | 7 | app = Flask('issues') 8 | bus = config.container.resolve(MessageBus) 9 | 10 | 11 | @app.before_request 12 | def get_auth_header(): 13 | request.user = request.headers.get('X-email') 14 | 15 | 16 | @app.route('/issues', methods=['POST']) 17 | def report_issue(): 18 | issue_id = uuid.uuid4() 19 | cmd = ReportIssueCommand(issue_id=issue_id, **request.get_json()) 20 | bus.handle(cmd) 21 | return "", 201, {"Location": "/issues/" + str(issue_id)} 22 | 23 | 24 | @app.route('/issues/') 25 | def get_issue(issue_id): 26 | view_builder = config.container.resolve(ports.IssueViewBuilder) 27 | view = view_builder.fetch(uuid.UUID(issue_id)) 28 | return jsonify(view) 29 | 30 | 31 | @app.route('/issues', methods=['GET']) 32 | def list_issues(): 33 | view_builder = config.container.resolve(ports.IssueListBuilder) 34 | view = view_builder.fetch() 35 | return jsonify(view) 36 | 37 | 38 | @app.route('/issues//assign', methods=['POST']) 39 | def assign_to_engineer(issue_id): 40 | assign_to = request.args.get('engineer') 41 | cmd = AssignIssueCommand(issue_id, assign_to, request.user) 42 | bus.handle(cmd) 43 | return "", 200 44 | 45 | 46 | @app.route('/issues//pick', methods=['POST']) 47 | def pick_issue(issue_id): 48 | cmd = PickIssueCommand(issue_id, request.user) 49 | bus.handle(cmd) 50 | return 51 | -------------------------------------------------------------------------------- /ports-and-adapters/05/issues/adapters/orm.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import logging 3 | import typing 4 | import uuid 5 | 6 | import sqlalchemy 7 | from sqlalchemy import (Table, Column, MetaData, String, Integer, Text, 8 | ForeignKey, create_engine, event) 9 | from sqlalchemy.orm import mapper, scoped_session, sessionmaker, composite, relationship 10 | import sqlalchemy.exc 11 | import sqlalchemy.orm.exc 12 | 13 | from sqlalchemy_utils.functions import create_database, drop_database 14 | from sqlalchemy_utils.types.uuid import UUIDType 15 | 16 | from issues.domain.model import Issue, IssueReporter, Assignment 17 | from issues.domain.ports import (IssueLog, UnitOfWork, UnitOfWorkManager, 18 | MessageBus) 19 | 20 | SessionFactory = typing.Callable[[], sqlalchemy.orm.Session] 21 | 22 | 23 | class SqlAlchemyUnitOfWorkManager(UnitOfWorkManager): 24 | 25 | def __init__(self, session_maker: SessionFactory, bus: MessageBus) -> None: 26 | self.session_maker = session_maker 27 | self.bus = bus 28 | 29 | def start(self): 30 | return SqlAlchemyUnitOfWork(self.session_maker, self.bus) 31 | 32 | 33 | class IssueRepository(IssueLog): 34 | 35 | def __init__(self, session): 36 | self._session = session 37 | 38 | def add(self, issue: Issue) -> None: 39 | self._session.add(issue) 40 | 41 | def _get(self, issue_id) -> Issue: 42 | return self._session.query(Issue).\ 43 | filter_by(id=issue_id).\ 44 | first() 45 | 46 | 47 | class SqlAlchemyUnitOfWork(UnitOfWork): 48 | 49 | def __init__(self, sessionfactory: SessionFactory, bus: MessageBus) -> None: 50 | self.sessionfactory = sessionfactory 51 | self.bus = bus 52 | event.listen(self.sessionfactory, "after_flush", self.gather_events) 53 | event.listen(self.sessionfactory, "loaded_as_persistent", 54 | self.setup_events) 55 | 56 | def __enter__(self): 57 | self.session = self.sessionfactory() 58 | self.flushed_events = [] 59 | return self 60 | 61 | def __exit__(self, type, value, traceback): 62 | self.publish_events() 63 | 64 | def commit(self): 65 | self.session.flush() 66 | self.session.commit() 67 | 68 | def rollback(self): 69 | self.flushed_events = [] 70 | self.session.rollback() 71 | 72 | def setup_events(self, session, entity): 73 | entity.events = [] 74 | 75 | def gather_events(self, session, ctx): 76 | flushed_objects = [e for e in session.new] + [e for e in session.dirty] 77 | for e in flushed_objects: 78 | try: 79 | self.flushed_events += e.events 80 | except AttributeError: 81 | pass 82 | 83 | def publish_events(self): 84 | for e in self.flushed_events: 85 | self.bus.handle(e) 86 | 87 | @property 88 | def issues(self): 89 | return IssueRepository(self.session) 90 | 91 | 92 | class SqlAlchemy: 93 | 94 | def __init__(self, uri): 95 | self.engine = create_engine(uri) 96 | self._session_maker = scoped_session(sessionmaker(self.engine),) 97 | 98 | def register_in(self, container): 99 | container.register(SessionFactory, lambda: self._session_maker) 100 | container.register(UnitOfWorkManager, SqlAlchemyUnitOfWorkManager) 101 | 102 | def recreate_schema(self): 103 | self.configure_mappings() 104 | drop_database(self.engine.url) 105 | self.create_schema() 106 | 107 | def create_schema(self): 108 | create_database(self.engine.url) 109 | self.metadata.create_all() 110 | 111 | def configure_mappings(self): 112 | self.metadata = MetaData(self.engine) 113 | 114 | IssueReporter.__composite_values__ = lambda i: (i.name, i.email) 115 | issues = Table('issues', self.metadata, 116 | Column('pk', Integer, primary_key=True), 117 | Column('issue_id', UUIDType), 118 | Column('reporter_name', String(50)), 119 | Column('reporter_email', String(50)), 120 | Column('description', Text)) 121 | 122 | assignments = Table( 123 | 'assignments', 124 | self.metadata, 125 | Column('pk', Integer, primary_key=True), 126 | Column('id', UUIDType), 127 | Column('fk_assignment_id', UUIDType, ForeignKey('issues.issue_id')), 128 | Column('assigned_by', String(50)), 129 | Column('assigned_to', String(50)), 130 | ) 131 | 132 | mapper( 133 | Issue, 134 | issues, 135 | properties={ 136 | '__pk': 137 | issues.c.pk, 138 | 'id': 139 | issues.c.issue_id, 140 | 'description': 141 | issues.c.description, 142 | 'reporter': 143 | composite(IssueReporter, issues.c.reporter_name, 144 | issues.c.reporter_email), 145 | '_assignments': 146 | relationship(Assignment, backref='issue') 147 | }, 148 | ), 149 | 150 | mapper( 151 | Assignment, 152 | assignments, 153 | properties={ 154 | '__pk': assignments.c.pk, 155 | 'id': assignments.c.id, 156 | 'assigned_to': assignments.c.assigned_to, 157 | 'assigned_by': assignments.c.assigned_by 158 | }) 159 | 160 | 161 | class SqlAlchemySessionContext: 162 | 163 | def __init__(self, session_maker): 164 | self._session_maker = session_maker 165 | 166 | def __enter__(self): 167 | self._session = self._session_maker() 168 | 169 | def __exit__(self, type, value, traceback): 170 | self._session_maker.remove() 171 | -------------------------------------------------------------------------------- /ports-and-adapters/05/issues/adapters/views.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import uuid 3 | from .orm import SessionFactory 4 | from issues.domain import ports 5 | 6 | # This little helper function converts the binary data 7 | # We store in Sqlite back to a uuid. 8 | # Ordinarily I use postgres, which has a native UniqueID 9 | # type, so this manual unmarshalling isn't necessary 10 | 11 | 12 | def read_uuid(record, column): 13 | record = dict(record) 14 | bytes_val = record[column] 15 | uuid_val = uuid.UUID(bytes=bytes_val) 16 | record[column] = uuid_val 17 | return record 18 | 19 | 20 | class IssueViewBuilder(ports.IssueViewBuilder): 21 | 22 | _q = """SELECT description, 23 | reporter_email, 24 | reporter_name 25 | FROM issues 26 | WHERE issue_id = :id""" 27 | 28 | def __init__(self, session_factory: SessionFactory): 29 | self.session_factory = session_factory 30 | 31 | def fetch(self, id): 32 | session = self.session_factory() 33 | result = session.execute(self._q, {'id': id.bytes}) 34 | record = result.fetchone() 35 | return dict(record) 36 | 37 | 38 | class IssueListBuilder: 39 | 40 | _q = """SELECT issue_id, 41 | description, 42 | reporter_email, 43 | reporter_name 44 | FROM issues""" 45 | 46 | def __init__(self, session_factory: SessionFactory): 47 | self.session_factory = session_factory 48 | 49 | def fetch(self): 50 | session = self.session_factory() 51 | query = session.execute( 52 | 'SELECT issue_id, description, reporter_email, reporter_name ' + 53 | ' FROM issues') 54 | 55 | result = [] 56 | for r in query.fetchall(): 57 | r = read_uuid(r, 'issue_id') 58 | result.append(r) 59 | 60 | return result 61 | -------------------------------------------------------------------------------- /ports-and-adapters/05/issues/domain/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bobthemighty/blog-code-samples/9c50b57baf42a76a10e612a65b3508fef60cf01d/ports-and-adapters/05/issues/domain/__init__.py -------------------------------------------------------------------------------- /ports-and-adapters/05/issues/domain/emails.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import NamedTuple, NewType, Dict, Any 3 | from jinja2 import Template 4 | 5 | EmailAddress = NewType('email', str) 6 | default_from_addr = EmailAddress('issues@example.org') 7 | TemplateData = Dict[str, Any] 8 | 9 | 10 | class MailTemplate(NamedTuple): 11 | subject: Template 12 | body: Template 13 | 14 | 15 | class MailRequest(NamedTuple): 16 | template: MailTemplate 17 | from_addr: EmailAddress 18 | to_addr: EmailAddress 19 | 20 | 21 | IssueAssignedToMe = MailTemplate( 22 | subject=Template("Hi {{assigned_to}} - you've been assigned an issue"), 23 | body=Template("{{description}}")) 24 | 25 | 26 | class EmailSender: 27 | 28 | @abc.abstractmethod 29 | def _do_send(self, request: MailRequest): 30 | pass 31 | 32 | def send(self, request: MailRequest, data: TemplateData): 33 | self._do_send(request.to_addr, request.from_addr, 34 | request.template.subject.render(data), 35 | request.template.body.render(data)) 36 | -------------------------------------------------------------------------------- /ports-and-adapters/05/issues/domain/messages.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from uuid import UUID 3 | from typing import NamedTuple, Generic, TypeVar 4 | 5 | 6 | def event(cls): 7 | 8 | setattr(cls, 'is_cmd', property(lambda x: getattr(x, 'is_cmd', False))) 9 | setattr(cls, 'is_event', property(lambda x: getattr(x, 'is_event', True))) 10 | setattr(cls, 'id', property(lambda x: getattr(x, 'id', None))) 11 | 12 | return cls 13 | 14 | 15 | def command(cls): 16 | 17 | setattr(cls, 'is_cmd', property(lambda x: getattr(x, 'is_cmd', True))) 18 | setattr(cls, 'is_event', property(lambda x: getattr(x, 'is_event', False))) 19 | setattr(cls, 'id', property(lambda x: getattr(x, 'id', None))) 20 | 21 | return cls 22 | 23 | 24 | class Message(NamedTuple): 25 | id: UUID 26 | 27 | 28 | class IssueState(Enum): 29 | AwaitingTriage = 0 30 | AwaitingAssignment = 1 31 | ReadyForWork = 2 32 | 33 | 34 | class IssuePriority(Enum): 35 | NotPrioritised = 0 36 | Low = 1 37 | Normal = 2 38 | High = 3 39 | Urgent = 4 40 | 41 | 42 | @command 43 | class ReportIssueCommand(NamedTuple): 44 | issue_id: UUID 45 | reporter_name: str 46 | reporter_email: str 47 | problem_description: str 48 | 49 | 50 | @command 51 | class TriageIssueCommand(NamedTuple): 52 | issue_id: UUID 53 | category: str 54 | priority: IssuePriority 55 | 56 | 57 | @command 58 | class AssignIssueCommand(NamedTuple): 59 | issue_id: UUID 60 | assigned_to: str 61 | assigned_by: str 62 | 63 | 64 | @command 65 | class PickIssueCommand(NamedTuple): 66 | issue_id: UUID 67 | picked_by: str 68 | 69 | 70 | @event 71 | class IssueAssignedToEngineer(NamedTuple): 72 | issue_id: UUID 73 | assigned_to: str 74 | assigned_by: str 75 | 76 | 77 | @event 78 | class IssueReassigned(NamedTuple): 79 | issue_id: UUID 80 | previous_assignee: str 81 | -------------------------------------------------------------------------------- /ports-and-adapters/05/issues/domain/model.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from uuid import UUID 3 | from .messages import (IssueState, IssuePriority, IssueReassigned, 4 | IssueAssignedToEngineer) 5 | 6 | 7 | class IssueReporter: 8 | 9 | def __init__(self, name: str, email: str) -> None: 10 | self.name = name 11 | self.email = email 12 | 13 | 14 | class Assignment: 15 | 16 | def __init__(self, assigned_to, assigned_by): 17 | self.assigned_to = assigned_to 18 | self.assigned_by = assigned_by 19 | 20 | def is_reassignment_from(self, other): 21 | if other is None: 22 | return False 23 | if other.assigned_to == self.assigned_to: 24 | return False 25 | return True 26 | 27 | 28 | class Issue: 29 | 30 | def __init__(self, issue_id: UUID, reporter: IssueReporter, 31 | description: str) -> None: 32 | self.id = issue_id 33 | self.description = description 34 | self.reporter = reporter 35 | self.state = IssueState.AwaitingTriage 36 | self.events = [] 37 | self._assignments = [] 38 | 39 | @property 40 | def assignment(self): 41 | if len(self._assignments) == 0: 42 | return None 43 | return self._assignments[-1] 44 | 45 | def triage(self, priority: IssuePriority, category: str) -> None: 46 | self.priority = priority 47 | self.category = category 48 | self.state = IssueState.AwaitingAssignment 49 | 50 | def assign(self, assigned_to, assigned_by=None): 51 | previous_assignment = self.assignment 52 | assigned_by = assigned_by or assigned_to 53 | 54 | self._assignments.append(Assignment(assigned_to, assigned_by)) 55 | 56 | self.state = IssueState.ReadyForWork 57 | 58 | if self.assignment.is_reassignment_from(previous_assignment): 59 | self.events.append( 60 | IssueReassigned(self.id, previous_assignment.assigned_to)) 61 | 62 | if assigned_to != assigned_by: 63 | self.events.append( 64 | IssueAssignedToEngineer(self.id, assigned_to, assigned_by)) 65 | -------------------------------------------------------------------------------- /ports-and-adapters/05/issues/domain/ports.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from collections import defaultdict 3 | from uuid import UUID 4 | from .model import Issue 5 | 6 | 7 | class IssueNotFoundException(Exception): 8 | pass 9 | 10 | 11 | class IssueLog(abc.ABC): 12 | 13 | @abc.abstractmethod 14 | def add(self, issue: Issue) -> None: 15 | pass 16 | 17 | @abc.abstractmethod 18 | def _get(self, id: UUID) -> Issue: 19 | pass 20 | 21 | def get(self, id: UUID) -> Issue: 22 | issue = self._get(id) 23 | if issue is None: 24 | raise IssueNotFoundException() 25 | return issue 26 | 27 | 28 | class UnitOfWork(abc.ABC): 29 | 30 | @abc.abstractmethod 31 | def __enter__(self): 32 | pass 33 | 34 | @abc.abstractmethod 35 | def __exit__(self, type, value, traceback): 36 | pass 37 | 38 | @abc.abstractmethod 39 | def commit(self): 40 | pass 41 | 42 | @abc.abstractmethod 43 | def rollback(self): 44 | pass 45 | 46 | @property 47 | @abc.abstractmethod 48 | def issues(self): 49 | pass 50 | 51 | 52 | class UnitOfWorkManager(abc.ABC): 53 | 54 | @abc.abstractmethod 55 | def start(self) -> UnitOfWork: 56 | pass 57 | 58 | 59 | class CommandAlreadySubscribedException(Exception): 60 | pass 61 | 62 | 63 | class HandlerRegistry: 64 | 65 | def get_handlers(self, type): 66 | pass 67 | 68 | 69 | class MessageBus: 70 | 71 | def __init__(self, registry: HandlerRegistry): 72 | self.registry = registry 73 | 74 | def handle(self, msg): 75 | subscribers = self.registry.get_handlers(type(msg)) 76 | for subscriber in subscribers: 77 | subscriber.handle(msg) 78 | 79 | 80 | class IssueViewBuilder: 81 | 82 | @abc.abstractmethod 83 | def fetch(self, id): 84 | pass 85 | 86 | 87 | class IssueListViewBuilder: 88 | 89 | @abc.abstractmethod 90 | def fetch(self): 91 | pass 92 | -------------------------------------------------------------------------------- /ports-and-adapters/05/issues/quick_tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bobthemighty/blog-code-samples/9c50b57baf42a76a10e612a65b3508fef60cf01d/ports-and-adapters/05/issues/quick_tests/__init__.py -------------------------------------------------------------------------------- /ports-and-adapters/05/issues/quick_tests/adapters.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | 3 | from issues.domain.ports import IssueLog, UnitOfWorkManager, UnitOfWork 4 | from issues.domain.emails import EmailSender 5 | 6 | 7 | class FakeIssueLog(IssueLog): 8 | 9 | def __init__(self): 10 | self.issues = [] 11 | 12 | def add(self, issue): 13 | self.issues.append(issue) 14 | 15 | def _get(self, id): 16 | for issue in self.issues: 17 | if issue.id == id: 18 | return issue 19 | 20 | def __len__(self): 21 | return len(self.issues) 22 | 23 | def __getitem__(self, idx): 24 | return self.issues[idx] 25 | 26 | 27 | class FakeUnitOfWork(UnitOfWork, UnitOfWorkManager): 28 | 29 | def __init__(self): 30 | self._issues = FakeIssueLog() 31 | 32 | def start(self): 33 | self.was_committed = False 34 | self.was_rolled_back = False 35 | return self 36 | 37 | def __enter__(self): 38 | return self 39 | 40 | def __exit__(self, type, value, traceback): 41 | self.exn_type = type 42 | self.exn = value 43 | self.traceback = traceback 44 | 45 | def commit(self): 46 | self.was_committed = True 47 | 48 | def rollback(self): 49 | self.was_rolled_back = True 50 | 51 | @property 52 | def issues(self): 53 | return self._issues 54 | 55 | 56 | class FakeEmailSender(EmailSender): 57 | 58 | sent_mail = namedtuple('fakes_sent_mail', 59 | ['recipient', 'sender', 'subject', 'body']) 60 | 61 | def __init__(self): 62 | self.sent = [] 63 | 64 | def _do_send(self, recipient, sender, subject, body): 65 | self.sent.append(self.sent_mail(recipient, sender, subject, body)) 66 | 67 | 68 | class FakeViewBuilder: 69 | 70 | def __init__(self, view_model): 71 | self.view_model = view_model 72 | 73 | def fetch(self, id): 74 | return self.view_model 75 | -------------------------------------------------------------------------------- /ports-and-adapters/05/issues/quick_tests/matchers.py: -------------------------------------------------------------------------------- 1 | from expects.matchers import Matcher 2 | 3 | 4 | class have_raised(Matcher): 5 | 6 | def __init__(self, event): 7 | self.event = event 8 | 9 | def _match(self, entity): 10 | return self.event in entity.events, [] 11 | -------------------------------------------------------------------------------- /ports-and-adapters/05/issues/quick_tests/shared_contexts.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from .adapters import FakeUnitOfWork 3 | from issues.domain.model import Issue, IssueReporter 4 | from issues.domain.messages import IssuePriority 5 | 6 | 7 | class With_an_empty_unit_of_work: 8 | 9 | def given_a_unit_of_work(self): 10 | self.uow = FakeUnitOfWork() 11 | 12 | 13 | class With_a_new_issue(With_an_empty_unit_of_work): 14 | 15 | def given_a_new_issue(self): 16 | reporter = IssueReporter('John', 'john@example.org') 17 | self.issue_id = uuid.uuid4() 18 | self.issue = Issue(self.issue_id, reporter, 'how do I even?') 19 | self.uow.issues.add(self.issue) 20 | 21 | 22 | class With_a_triaged_issue(With_a_new_issue): 23 | 24 | def given_a_triaged_issue(self): 25 | self.issue.triage(IssuePriority.Low, 'uncategorised') 26 | 27 | 28 | class With_assigned_issue(With_a_triaged_issue): 29 | 30 | assigned_by = 'fred@example.org' 31 | assigned_to = 'mary@example.org' 32 | 33 | def given_an_assigned_issue(self): 34 | self.issue.assign(assigned_to, assigned_by) 35 | -------------------------------------------------------------------------------- /ports-and-adapters/05/issues/quick_tests/test_assignment.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from .adapters import FakeUnitOfWork, FakeEmailSender, FakeViewBuilder 4 | from .shared_contexts import With_a_triaged_issue 5 | from .matchers import have_raised 6 | 7 | from issues.services import (AssignIssueHandler, IssueAssignedHandler, 8 | PickIssueHandler) 9 | from issues.domain.messages import (AssignIssueCommand, IssueAssignedToEngineer, 10 | IssueReassigned, IssueState, IssuePriority, 11 | PickIssueCommand) 12 | from issues.domain.model import Issue, IssueReporter 13 | 14 | from expects import expect, have_len, equal, be_true 15 | 16 | 17 | class When_assigning_an_issue(With_a_triaged_issue): 18 | 19 | assigned_to = 'percy@example.org' 20 | assigned_by = 'norman@example.org' 21 | 22 | def because_we_assign_the_issue(self): 23 | handler = AssignIssueHandler(self.uow) 24 | cmd = AssignIssueCommand(self.issue_id, self.assigned_to, 25 | self.assigned_by) 26 | 27 | handler.handle(cmd) 28 | 29 | def the_issue_should_be_assigned_to_percy(self): 30 | expect(self.issue.assignment.assigned_to).to(equal(self.assigned_to)) 31 | 32 | def the_issue_should_have_been_assigned_by_norman(self): 33 | expect(self.issue.assignment.assigned_by).to(equal(self.assigned_by)) 34 | 35 | def the_issue_should_be_ready_for_work(self): 36 | expect(self.issue.state).to(equal(IssueState.ReadyForWork)) 37 | 38 | def it_should_have_committed_the_unit_of_work(self): 39 | expect(self.uow.was_committed).to(be_true) 40 | 41 | def it_should_have_raised_issue_assigned(self): 42 | expect(self.issue).to( 43 | have_raised( 44 | IssueAssignedToEngineer(self.issue_id, self.assigned_to, 45 | self.assigned_by))) 46 | 47 | 48 | class When_picking_an_issue(With_a_triaged_issue): 49 | 50 | picked_by = 'percy@example.org' 51 | 52 | def because_we_pick_the_issue(self): 53 | handler = PickIssueHandler(self.uow) 54 | cmd = PickIssueCommand(self.issue_id, self.picked_by) 55 | 56 | handler.handle(cmd) 57 | 58 | def the_issue_should_be_assigned_to_percy(self): 59 | expect(self.issue.assignment.assigned_to).to(equal(self.picked_by)) 60 | 61 | def the_issue_should_be_ready_for_work(self): 62 | expect(self.issue.state).to(equal(IssueState.ReadyForWork)) 63 | 64 | def it_should_have_committed_the_unit_of_work(self): 65 | expect(self.uow.was_committed).to(be_true) 66 | 67 | def it_should_not_have_raised_issue_assigned(self): 68 | expect(self.issue.events).to(have_len(0)) 69 | 70 | 71 | class When_reassigning_an_issue(With_a_triaged_issue): 72 | 73 | assigned_by = 'george@example.org' 74 | assigned_to = 'fred@example.org' 75 | 76 | new_assigned_to = 'percy@example.org' 77 | new_assigned_by = 'norman@example.org' 78 | 79 | def given_an_assigned_issue(self): 80 | self.issue.assign(self.assigned_to, self.assigned_by) 81 | 82 | def because_we_assign_the_issue(self): 83 | handler = AssignIssueHandler(self.uow) 84 | cmd = AssignIssueCommand(self.issue_id, self.new_assigned_to, 85 | self.new_assigned_by) 86 | 87 | handler.handle(cmd) 88 | 89 | def it_should_have_raised_issue_reassigned(self): 90 | expect(self.issue).to( 91 | have_raised(IssueReassigned(self.issue_id, self.assigned_to))) 92 | 93 | 94 | class When_an_issue_is_assigned: 95 | 96 | issue_id = uuid.uuid4() 97 | assigned_to = 'barry@example.org' 98 | assigned_by = 'helga@example.org' 99 | 100 | def given_a_view_model_and_emailer(self): 101 | self.view_builder = FakeViewBuilder({ 102 | 'description': 103 | 'a bad thing happened', 104 | 'reporter_email': 105 | 'reporter@example.org', 106 | 'reported_name': 107 | 'Reporty McReportface' 108 | }) 109 | 110 | self.emailer = FakeEmailSender() 111 | 112 | def because_we_raise_issue_assigned(self): 113 | evt = IssueAssignedToEngineer(self.issue_id, self.assigned_to, 114 | self.assigned_by) 115 | 116 | handler = IssueAssignedHandler(self.view_builder, self.emailer) 117 | handler.handle(evt) 118 | 119 | def it_should_send_an_email(self): 120 | expect(self.emailer.sent).to(have_len(1)) 121 | 122 | def it_should_have_the_correct_subject(self): 123 | expect(self.emailer.sent[0].subject).to( 124 | equal('Hi barry@example.org - you\'ve been assigned an issue')) 125 | 126 | def it_should_be_to_the_correct_recipient(self): 127 | expect(self.emailer.sent[0].recipient).to(equal(self.assigned_to)) 128 | -------------------------------------------------------------------------------- /ports-and-adapters/05/issues/quick_tests/test_issue_reporting.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from .adapters import FakeUnitOfWork 4 | from issues.services import ReportIssueHandler 5 | from issues.domain.messages import ReportIssueCommand, IssueState, IssuePriority 6 | from issues.domain.model import Issue 7 | 8 | from expects import expect, have_len, equal, be_true 9 | 10 | email = "bob@example.org" 11 | name = "bob" 12 | desc = "My mouse won't move" 13 | id = uuid.uuid4() 14 | 15 | 16 | class When_reporting_an_issue: 17 | 18 | def given_an_empty_unit_of_work(self): 19 | self.uow = FakeUnitOfWork() 20 | 21 | def because_we_report_a_new_issue(self): 22 | handler = ReportIssueHandler(self.uow) 23 | cmd = ReportIssueCommand(id, name, email, desc) 24 | 25 | handler.handle(cmd) 26 | 27 | def the_handler_should_have_created_a_new_issue(self): 28 | expect(self.uow.issues).to(have_len(1)) 29 | 30 | def it_should_have_recorded_the_id(self): 31 | expect(self.uow.issues[0].id).to(equal(id)) 32 | 33 | def it_should_be_awaiting_triage(self): 34 | expect(self.uow.issues[0].state).to(equal(IssueState.AwaitingTriage)) 35 | 36 | def it_should_have_recorded_the_issuer(self): 37 | expect(self.uow.issues[0].reporter.name).to(equal(name)) 38 | expect(self.uow.issues[0].reporter.email).to(equal(email)) 39 | 40 | def it_should_have_recorded_the_description(self): 41 | expect(self.uow.issues[0].description).to(equal(desc)) 42 | 43 | def it_should_have_committed_the_unit_of_work(self): 44 | expect(self.uow.was_committed).to(be_true) 45 | -------------------------------------------------------------------------------- /ports-and-adapters/05/issues/quick_tests/test_triage.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from .shared_contexts import With_a_new_issue 4 | 5 | from issues.services import TriageIssueHandler 6 | from issues.domain.messages import TriageIssueCommand, IssuePriority, IssueState 7 | from issues.domain.model import Issue 8 | 9 | from expects import expect, have_len, equal, be_true 10 | 11 | 12 | class When_triaging_an_issue(With_a_new_issue): 13 | 14 | issue_id = uuid.uuid4() 15 | category = 'training' 16 | priority = IssuePriority.Low 17 | 18 | def because_we_triage_the_issue(self): 19 | handler = TriageIssueHandler(self.uow) 20 | cmd = TriageIssueCommand(self.issue_id, self.category, self.priority) 21 | 22 | handler.handle(cmd) 23 | 24 | def the_issue_should_have_a_priority_set(self): 25 | expect(self.issue.priority).to(equal(IssuePriority.Low)) 26 | 27 | def the_issue_should_have_been_categorised(self): 28 | expect(self.issue.category).to(equal('training')) 29 | 30 | def the_issue_should_be_awaiting_assignment(self): 31 | expect(self.issue.state).to(equal(IssueState.AwaitingAssignment)) 32 | 33 | def it_should_have_committed_the_unit_of_work(self): 34 | expect(self.uow.was_committed).to(be_true) 35 | -------------------------------------------------------------------------------- /ports-and-adapters/05/issues/services/__init__.py: -------------------------------------------------------------------------------- 1 | import issues.domain.emails 2 | from issues.domain.model import Issue, IssueReporter 3 | from issues.domain.ports import UnitOfWorkManager, IssueViewBuilder 4 | from issues.domain import emails, messages 5 | 6 | import abc 7 | import typing 8 | 9 | TMsg = typing.TypeVar('TMsg') 10 | 11 | 12 | class Handles(typing.Generic[TMsg]): 13 | 14 | @abc.abstractmethod 15 | def handle(self, msg: TMsg): 16 | pass 17 | 18 | 19 | class ReportIssueHandler(Handles[messages.ReportIssueCommand]): 20 | 21 | def __init__(self, uowm: UnitOfWorkManager): 22 | self.uowm = uowm 23 | 24 | def handle(self, cmd): 25 | reporter = IssueReporter(cmd.reporter_name, cmd.reporter_email) 26 | issue = Issue(cmd.issue_id, reporter, cmd.problem_description) 27 | 28 | with self.uowm.start() as tx: 29 | tx.issues.add(issue) 30 | tx.commit() 31 | 32 | 33 | class TriageIssueHandler(Handles[messages.TriageIssueCommand]): 34 | 35 | def __init__(self, uowm: UnitOfWorkManager): 36 | self.uowm = uowm 37 | 38 | def handle(self, cmd): 39 | with self.uowm.start() as tx: 40 | issue = tx.issues.get(cmd.issue_id) 41 | issue.triage(cmd.priority, cmd.category) 42 | tx.commit() 43 | 44 | 45 | class PickIssueHandler(Handles[messages.PickIssueCommand]): 46 | 47 | def __init__(self, uowm: UnitOfWorkManager): 48 | self.uowm = uowm 49 | 50 | def handle(self, cmd): 51 | with self.uowm.start() as tx: 52 | issue = tx.issues.get(cmd.issue_id) 53 | issue.assign(cmd.picked_by) 54 | tx.commit() 55 | 56 | 57 | class AssignIssueHandler(Handles[messages.AssignIssueCommand]): 58 | 59 | def __init__(self, uowm: UnitOfWorkManager): 60 | self.uowm = uowm 61 | 62 | def handle(self, cmd): 63 | with self.uowm.start() as tx: 64 | issue = tx.issues.get(cmd.issue_id) 65 | issue.assign(cmd.assigned_to, cmd.assigned_by) 66 | tx.commit() 67 | 68 | 69 | class IssueAssignedHandler(Handles[messages.IssueAssignedToEngineer]): 70 | 71 | def __init__(self, view_builder: IssueViewBuilder, 72 | sender: emails.EmailSender): 73 | self.sender = sender 74 | self.view_builder = view_builder 75 | 76 | def handle(self, evt): 77 | data = self.view_builder.fetch(evt.issue_id) 78 | data.update(**evt._asdict()) 79 | request = emails.MailRequest( 80 | emails.IssueAssignedToMe, 81 | emails.default_from_addr, 82 | emails.EmailAddress(evt.assigned_to), 83 | ) 84 | 85 | self.sender.send(request, data) 86 | -------------------------------------------------------------------------------- /ports-and-adapters/05/issues/slow_tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bobthemighty/blog-code-samples/9c50b57baf42a76a10e612a65b3508fef60cf01d/ports-and-adapters/05/issues/slow_tests/__init__.py -------------------------------------------------------------------------------- /ports-and-adapters/05/issues/slow_tests/api_tests.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from expects import expect, equal 3 | 4 | 5 | def report_issue(reporter_name='fred', 6 | reporter_email='fred@example.org', 7 | problem_description='Halp!!!1!!1!11eleven!'): 8 | data = { 9 | 'reporter_name': reporter_name, 10 | 'reporter_email': reporter_email, 11 | 'problem_description': problem_description 12 | } 13 | 14 | resp = requests.post('http://localhost:5000/issues', json=data) 15 | return resp.headers['Location'] 16 | 17 | 18 | class When_reporting_a_new_issue: 19 | 20 | def because_we_report_a_new_issue(self): 21 | self.location = report_issue('Arnold', 'arnold@example.org', 22 | "I'm all alone and frightened.") 23 | 24 | @property 25 | def the_issue(self): 26 | return requests.get(self.location).json() 27 | 28 | def it_should_have_the_correct_reporter(self): 29 | expect(self.the_issue['reporter_name']).to(equal('Arnold')) 30 | 31 | def it_should_have_the_correct_description(self): 32 | expect(self.the_issue['description']).to( 33 | equal('I\'m all alone and frightened.')) 34 | 35 | 36 | class When_assigning_an_issue_to_another_engineer: 37 | 38 | def given_an_issue(self): 39 | self.location = report_issue('Arnold', 'arnold@example.org', 40 | "I'm all alone and frightened") 41 | 42 | def because_barbara_assigns_the_issue_to_constance(self): 43 | self.response = requests.post( 44 | self.location + '/assign?engineer=constance', 45 | headers={ 46 | 'X-Email': 'barbara@example.org' 47 | }) 48 | 49 | def it_should_be_fine(self): 50 | expect(self.response.status_code).to(equal(200)) 51 | -------------------------------------------------------------------------------- /ports-and-adapters/05/issues/slow_tests/issue_repository_tests.py: -------------------------------------------------------------------------------- 1 | from issues.domain.ports import MessageBus 2 | from issues.domain.model import Issue 3 | from issues.domain.messages import ReportIssueCommand 4 | from issues.adapters.orm import SqlAlchemy 5 | from issues.adapters.views import IssueViewBuilder 6 | 7 | from issues.adapters import config 8 | 9 | import uuid 10 | 11 | from expects import expect, equal, have_len 12 | 13 | 14 | class When_we_load_a_persisted_issue: 15 | 16 | issue_id = uuid.uuid4() 17 | 18 | def given_a_database_containing_an_issue(self): 19 | 20 | cmd = ReportIssueCommand(self.issue_id, 'fred', 'fred@example.org', 21 | 'forgot my password again') 22 | bus = config.container.resolve(MessageBus) 23 | bus.handle(cmd) 24 | 25 | def because_we_load_the_issues(self): 26 | view_builder = config.container.resolve(IssueViewBuilder) 27 | self.issue = view_builder.fetch(self.issue_id) 28 | 29 | def it_should_have_the_correct_description(self): 30 | expect(self.issue['id']).to(equal(self.issue_id)) 31 | 32 | def it_should_have_the_correct_description(self): 33 | expect(self.issue['description']).to(equal('forgot my password again')) 34 | 35 | def it_should_have_the_correct_reporter_details(self): 36 | expect(self.issue['reporter_name']).to(equal('fred')) 37 | 38 | def it_should_have_the_correct_reporter_details(self): 39 | expect(self.issue['reporter_email']).to(equal('fred@example.org')) 40 | -------------------------------------------------------------------------------- /ports-and-adapters/05/requirements.txt: -------------------------------------------------------------------------------- 1 | colorama==0.3.9 2 | Contexts==0.11.2 3 | expects==0.8.0 4 | punq 5 | -------------------------------------------------------------------------------- /ports-and-adapters/05/setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name='ports-and-adapters', 5 | url='https://github.com/bobthemighty/ports-and-adapters-pythong', 6 | packages=find_packages('.'), 7 | ) 8 | -------------------------------------------------------------------------------- /ports-and-adapters/06/README.md: -------------------------------------------------------------------------------- 1 | To run the code, set up a python 3 virtual environment, then 2 | 3 | `pip install -r requirements.txt` 4 | `pip install -e .` 5 | 6 | You will need Sqlite installed. For the API, you'll need write-access to the current directory, or you can just hack the connection string in adapters/flask.py 7 | 8 | If you `cd issues` you can now run tests with `run-contexts -v`. 9 | 10 | To run the API 11 | 12 | ```bash 13 | $ export FLASK_APP=issues.adapters.flask 14 | $ flask run 15 | flask run 16 | * Serving Flask app "issues.adapters.flask" 17 | * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit) 18 | 127.0.0.1 - - [13/Sep/2017 13:50:50] "POST /issues HTTP/1.1" 201 - 19 | 127.0.0.1 - - [13/Sep/2017 13:50:57] "GET /issues/4d9ca3ab-3d70-486c-965b-41d27e0dbd33 HTTP/1.1" 200 - 20 | ``` 21 | 22 | You can now post a new issue to the api. 23 | 24 | ```bash 25 | $ curl -d'{"reporter_name": "carlos", "reporter_email": "carlos@example.org", "problem_description": "Nothing works any more"}' localhost:5000/issues -H "Content-Type: application/json" -v 26 | 27 | * Trying ::1... 28 | * TCP_NODELAY set 29 | * connect to ::1 port 5000 failed: Connection refused 30 | * Trying 127.0.0.1... 31 | * TCP_NODELAY set 32 | * Connected to localhost (127.0.0.1) port 5000 (#0) 33 | > POST /issues HTTP/1.1 34 | > Host: localhost:5000 35 | > User-Agent: curl/7.55.1 36 | > Accept: */* 37 | > Content-Type: application/json 38 | > Content-Length: 108 39 | > 40 | * upload completely sent off: 108 out of 108 bytes 41 | * HTTP 1.0, assume close after body 42 | < HTTP/1.0 201 CREATED 43 | < Location: http://localhost:5000/issues/4d9ca3ab-3d70-486c-965b-41d27e0dbd33 44 | < Content-Type: text/html; charset=utf-8 45 | < Content-Length: 0 46 | < Server: Werkzeug/0.12.2 Python/3.6.2 47 | < Date: Wed, 13 Sep 2017 12:50:50 GMT 48 | < 49 | * Closing connection 0 50 | 51 | ``` 52 | 53 | 54 | And fetch the new issue from the URI given in the Location header. 55 | 56 | ```bash 57 | curl http://localhost:5000/issues/4d9ca3ab-3d70-486c-965b-41d27e0dbd33 58 | { 59 | "description": "Nothing works any more", 60 | "reporter_email": "carlos@example.org", 61 | "reporter_name": "carlos" 62 | } 63 | ``` 64 | -------------------------------------------------------------------------------- /ports-and-adapters/06/issues.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bobthemighty/blog-code-samples/9c50b57baf42a76a10e612a65b3508fef60cf01d/ports-and-adapters/06/issues.db -------------------------------------------------------------------------------- /ports-and-adapters/06/issues/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bobthemighty/blog-code-samples/9c50b57baf42a76a10e612a65b3508fef60cf01d/ports-and-adapters/06/issues/__init__.py -------------------------------------------------------------------------------- /ports-and-adapters/06/issues/adapters/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bobthemighty/blog-code-samples/9c50b57baf42a76a10e612a65b3508fef60cf01d/ports-and-adapters/06/issues/adapters/__init__.py -------------------------------------------------------------------------------- /ports-and-adapters/06/issues/adapters/config.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | import inspect 3 | import logging 4 | 5 | from . import orm 6 | 7 | from .emails import send_to_stdout 8 | 9 | import issues.domain.messages as msg 10 | from issues.domain import ports, emails 11 | from issues import services, domain, adapters 12 | from issues.adapters import views 13 | 14 | bus = ports.MessageBus() 15 | db = orm.SqlAlchemy('sqlite://', bus) 16 | db.recreate_schema() 17 | 18 | bus.register(msg.ReportIssue, services.report_issue, db.start_unit_of_work) 19 | 20 | bus.register(msg.TriageIssue, services.triage_issue, db.start_unit_of_work) 21 | 22 | bus.register(msg.PickIssue, services.pick_issue, db.start_unit_of_work) 23 | 24 | bus.register(msg.IssueAssignedToEngineer, 25 | services.on_issue_assigned_to_engineer, 26 | partial(views.view_issue, db.get_session), 27 | emails.EmailSender(send_to_stdout)) 28 | -------------------------------------------------------------------------------- /ports-and-adapters/06/issues/adapters/emails.py: -------------------------------------------------------------------------------- 1 | def send_to_stdout(self, recipient, sender, subject, body): 2 | print( 3 | "Sending email to {to} from {sender}\nsubject:{subject}\n{body}".format( 4 | to=recipient, sender=sender, body=body, subject=subject)) 5 | -------------------------------------------------------------------------------- /ports-and-adapters/06/issues/adapters/flask.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from flask import Flask, request, jsonify 3 | from . import config 4 | from issues.domain.messages import ReportIssue, AssignIssue, PickIssue 5 | from issues.adapters.views import view_issue, list_issues 6 | 7 | app = Flask('issues') 8 | bus = config.bus 9 | db = config.db 10 | 11 | 12 | @app.before_request 13 | def get_auth_header(): 14 | request.user = request.headers.get('X-email') 15 | 16 | 17 | @app.route('/issues', methods=['POST']) 18 | def report_issue(): 19 | issue_id = uuid.uuid4() 20 | cmd = ReportIssue(issue_id=issue_id, **request.get_json()) 21 | bus.handle(cmd) 22 | return "", 201, {"Location": "/issues/" + str(issue_id)} 23 | 24 | 25 | @app.route('/issues/') 26 | def get_issue(issue_id): 27 | view = view_issue(db.get_session, uuid.UUID(issue_id)) 28 | return jsonify(view) 29 | 30 | 31 | @app.route('/issues', methods=['GET']) 32 | def list_issues(): 33 | view = list_issues(db.config.get_session) 34 | return jsonify(view) 35 | 36 | 37 | @app.route('/issues//assign', methods=['POST']) 38 | def assign_to_engineer(issue_id): 39 | assign_to = request.args.get('engineer') 40 | cmd = AssignIssue(issue_id, assign_to, request.user) 41 | bus.handle(cmd) 42 | return "", 200 43 | 44 | 45 | @app.route('/issues//pick', methods=['POST']) 46 | def pick_issue(issue_id): 47 | cmd = PickIssue(issue_id, request.user) 48 | bus.handle(cmd) 49 | return 50 | -------------------------------------------------------------------------------- /ports-and-adapters/06/issues/adapters/orm.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import logging 3 | import typing 4 | import uuid 5 | 6 | import sqlalchemy 7 | from sqlalchemy import (Table, Column, MetaData, String, Integer, Text, 8 | ForeignKey, create_engine, event) 9 | from sqlalchemy.orm import mapper, scoped_session, sessionmaker, composite, relationship 10 | import sqlalchemy.exc 11 | import sqlalchemy.orm.exc 12 | 13 | from sqlalchemy_utils.functions import create_database, drop_database 14 | from sqlalchemy_utils.types.uuid import UUIDType 15 | 16 | from issues.domain.model import Issue, IssueReporter, Assignment 17 | from issues.domain.ports import (IssueLog, UnitOfWork, UnitOfWorkManager, 18 | MessageBus) 19 | 20 | SessionFactory = typing.Callable[[], sqlalchemy.orm.Session] 21 | 22 | 23 | class IssueRepository(IssueLog): 24 | 25 | def __init__(self, session): 26 | self._session = session 27 | 28 | def add(self, issue: Issue) -> None: 29 | self._session.add(issue) 30 | 31 | def _get(self, issue_id) -> Issue: 32 | return self._session.query(Issue).\ 33 | filter_by(id=issue_id).\ 34 | first() 35 | 36 | 37 | class SqlAlchemyUnitOfWork(UnitOfWork): 38 | 39 | def __init__(self, sessionfactory: SessionFactory, bus: MessageBus) -> None: 40 | self.sessionfactory = sessionfactory 41 | self.bus = bus 42 | event.listen(self.sessionfactory, "after_flush", self.gather_events) 43 | event.listen(self.sessionfactory, "loaded_as_persistent", 44 | self.setup_events) 45 | 46 | def __enter__(self): 47 | self.session = self.sessionfactory() 48 | self.flushed_events = [] 49 | return self 50 | 51 | def __exit__(self, type, value, traceback): 52 | self.publish_events() 53 | 54 | def commit(self): 55 | self.session.flush() 56 | self.session.commit() 57 | 58 | def rollback(self): 59 | self.flushed_events = [] 60 | self.session.rollback() 61 | 62 | def setup_events(self, session, entity): 63 | entity.events = [] 64 | 65 | def gather_events(self, session, ctx): 66 | flushed_objects = [e for e in session.new] + [e for e in session.dirty] 67 | for e in flushed_objects: 68 | try: 69 | self.flushed_events += e.events 70 | except AttributeError: 71 | pass 72 | 73 | def publish_events(self): 74 | for e in self.flushed_events: 75 | self.bus.handle(e) 76 | 77 | @property 78 | def issues(self): 79 | return IssueRepository(self.session) 80 | 81 | 82 | class SqlAlchemy: 83 | 84 | def __init__(self, uri, bus): 85 | self.engine = create_engine(uri) 86 | self.bus = bus 87 | self._session_maker = scoped_session(sessionmaker(self.engine),) 88 | 89 | def recreate_schema(self): 90 | self.configure_mappings() 91 | drop_database(self.engine.url) 92 | self.create_schema() 93 | 94 | def get_session(self): 95 | return self._session_maker() 96 | 97 | def start_unit_of_work(self): 98 | return SqlAlchemyUnitOfWork(self._session_maker, self.bus) 99 | 100 | def create_schema(self): 101 | create_database(self.engine.url) 102 | self.metadata.create_all() 103 | 104 | def configure_mappings(self): 105 | self.metadata = MetaData(self.engine) 106 | 107 | IssueReporter.__composite_values__ = lambda i: (i.name, i.email) 108 | issues = Table('issues', self.metadata, 109 | Column('pk', Integer, primary_key=True), 110 | Column('issue_id', UUIDType), 111 | Column('reporter_name', String(50)), 112 | Column('reporter_email', String(50)), 113 | Column('description', Text)) 114 | 115 | assignments = Table( 116 | 'assignments', 117 | self.metadata, 118 | Column('pk', Integer, primary_key=True), 119 | Column('id', UUIDType), 120 | Column('fk_assignment_id', UUIDType, ForeignKey('issues.issue_id')), 121 | Column('assigned_by', String(50)), 122 | Column('assigned_to', String(50)), 123 | ) 124 | 125 | mapper( 126 | Issue, 127 | issues, 128 | properties={ 129 | '__pk': 130 | issues.c.pk, 131 | 'id': 132 | issues.c.issue_id, 133 | 'description': 134 | issues.c.description, 135 | 'reporter': 136 | composite(IssueReporter, issues.c.reporter_name, 137 | issues.c.reporter_email), 138 | '_assignments': 139 | relationship(Assignment, backref='issue') 140 | }, 141 | ), 142 | 143 | mapper( 144 | Assignment, 145 | assignments, 146 | properties={ 147 | '__pk': assignments.c.pk, 148 | 'id': assignments.c.id, 149 | 'assigned_to': assignments.c.assigned_to, 150 | 'assigned_by': assignments.c.assigned_by 151 | }) 152 | -------------------------------------------------------------------------------- /ports-and-adapters/06/issues/adapters/views.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import uuid 3 | from .orm import SessionFactory 4 | from issues.domain import ports 5 | 6 | # This little helper function converts the binary data 7 | # We store in Sqlite back to a uuid. 8 | # Ordinarily I use postgres, which has a native UniqueID 9 | # type, so this manual unmarshalling isn't necessary 10 | 11 | 12 | def read_uuid(record, column): 13 | record = dict(record) 14 | bytes_val = record[column] 15 | uuid_val = uuid.UUID(bytes=bytes_val) 16 | record[column] = uuid_val 17 | return record 18 | 19 | 20 | FETCH_ISSUE = """SELECT description, 21 | reporter_email, 22 | reporter_name 23 | FROM issues 24 | WHERE issue_id = :id""" 25 | 26 | LIST_ISSUES = """SELECT issue_id, 27 | description, 28 | reporter_email, 29 | reporter_name 30 | FROM issues""" 31 | 32 | 33 | def view_issue(make_session, id): 34 | session = make_session() 35 | result = session.execute(FETCH_ISSUE, {'id': id.bytes}) 36 | record = result.fetchone() 37 | return dict(record) 38 | 39 | 40 | def list_issues(make_session, id): 41 | session = make_session() 42 | query = session.execute(LIST_ISSUES) 43 | 44 | result = [] 45 | for r in query.fetchall(): 46 | r = read_uuid(r, 'issue_id') 47 | result.append(r) 48 | 49 | return result 50 | -------------------------------------------------------------------------------- /ports-and-adapters/06/issues/domain/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bobthemighty/blog-code-samples/9c50b57baf42a76a10e612a65b3508fef60cf01d/ports-and-adapters/06/issues/domain/__init__.py -------------------------------------------------------------------------------- /ports-and-adapters/06/issues/domain/emails.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import NamedTuple, NewType, Dict, Any 3 | from jinja2 import Template 4 | 5 | EmailAddress = NewType('email', str) 6 | default_from_addr = EmailAddress('issues@example.org') 7 | TemplateData = Dict[str, Any] 8 | 9 | 10 | class MailTemplate(NamedTuple): 11 | subject: Template 12 | body: Template 13 | 14 | 15 | class MailRequest(NamedTuple): 16 | template: MailTemplate 17 | from_addr: EmailAddress 18 | to_addr: EmailAddress 19 | 20 | 21 | IssueAssignedToMe = MailTemplate( 22 | subject=Template("Hi {{assigned_to}} - you've been assigned an issue"), 23 | body=Template("{{description}}")) 24 | 25 | 26 | class EmailSender: 27 | 28 | def __init__(self, send): 29 | self._send = send 30 | 31 | def send(self, request: MailRequest, data: TemplateData): 32 | self._send(request.to_addr, request.from_addr, 33 | request.template.subject.render(data), 34 | request.template.body.render(data)) 35 | -------------------------------------------------------------------------------- /ports-and-adapters/06/issues/domain/messages.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from uuid import UUID 3 | from typing import NamedTuple, Generic, TypeVar 4 | 5 | 6 | def event(cls): 7 | 8 | setattr(cls, 'is_cmd', property(lambda x: getattr(x, 'is_cmd', False))) 9 | setattr(cls, 'is_event', property(lambda x: getattr(x, 'is_event', True))) 10 | setattr(cls, 'id', property(lambda x: getattr(x, 'id', None))) 11 | 12 | return cls 13 | 14 | 15 | def command(cls): 16 | 17 | setattr(cls, 'is_cmd', property(lambda x: getattr(x, 'is_cmd', True))) 18 | setattr(cls, 'is_event', property(lambda x: getattr(x, 'is_event', False))) 19 | setattr(cls, 'id', property(lambda x: getattr(x, 'id', None))) 20 | 21 | return cls 22 | 23 | 24 | class Message(NamedTuple): 25 | id: UUID 26 | 27 | 28 | class IssueState(Enum): 29 | AwaitingTriage = 0 30 | AwaitingAssignment = 1 31 | ReadyForWork = 2 32 | 33 | 34 | class IssuePriority(Enum): 35 | NotPrioritised = 0 36 | Low = 1 37 | Normal = 2 38 | High = 3 39 | Urgent = 4 40 | 41 | 42 | @command 43 | class ReportIssue(NamedTuple): 44 | issue_id: UUID 45 | reporter_name: str 46 | reporter_email: str 47 | problem_description: str 48 | 49 | 50 | @command 51 | class TriageIssue(NamedTuple): 52 | issue_id: UUID 53 | category: str 54 | priority: IssuePriority 55 | 56 | 57 | @command 58 | class AssignIssue(NamedTuple): 59 | issue_id: UUID 60 | assigned_to: str 61 | assigned_by: str 62 | 63 | 64 | @command 65 | class PickIssue(NamedTuple): 66 | issue_id: UUID 67 | picked_by: str 68 | 69 | 70 | @event 71 | class IssueAssignedToEngineer(NamedTuple): 72 | issue_id: UUID 73 | assigned_to: str 74 | assigned_by: str 75 | 76 | 77 | @event 78 | class IssueReassigned(NamedTuple): 79 | issue_id: UUID 80 | previous_assignee: str 81 | -------------------------------------------------------------------------------- /ports-and-adapters/06/issues/domain/model.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from uuid import UUID 3 | from .messages import (IssueState, IssuePriority, IssueReassigned, 4 | IssueAssignedToEngineer) 5 | 6 | 7 | class IssueReporter: 8 | 9 | def __init__(self, name: str, email: str) -> None: 10 | self.name = name 11 | self.email = email 12 | 13 | 14 | class Assignment: 15 | 16 | def __init__(self, assigned_to, assigned_by): 17 | self.assigned_to = assigned_to 18 | self.assigned_by = assigned_by 19 | 20 | def is_reassignment_from(self, other): 21 | if other is None: 22 | return False 23 | if other.assigned_to == self.assigned_to: 24 | return False 25 | return True 26 | 27 | 28 | class Issue: 29 | 30 | def __init__(self, issue_id: UUID, reporter: IssueReporter, 31 | description: str) -> None: 32 | self.id = issue_id 33 | self.description = description 34 | self.reporter = reporter 35 | self.state = IssueState.AwaitingTriage 36 | self.events = [] 37 | self._assignments = [] 38 | 39 | @property 40 | def assignment(self): 41 | if len(self._assignments) == 0: 42 | return None 43 | return self._assignments[-1] 44 | 45 | def triage(self, priority: IssuePriority, category: str) -> None: 46 | self.priority = priority 47 | self.category = category 48 | self.state = IssueState.AwaitingAssignment 49 | 50 | def assign(self, assigned_to, assigned_by=None): 51 | previous_assignment = self.assignment 52 | assigned_by = assigned_by or assigned_to 53 | 54 | self._assignments.append(Assignment(assigned_to, assigned_by)) 55 | 56 | self.state = IssueState.ReadyForWork 57 | 58 | if self.assignment.is_reassignment_from(previous_assignment): 59 | self.events.append( 60 | IssueReassigned(self.id, previous_assignment.assigned_to)) 61 | 62 | if assigned_to != assigned_by: 63 | self.events.append( 64 | IssueAssignedToEngineer(self.id, assigned_to, assigned_by)) 65 | -------------------------------------------------------------------------------- /ports-and-adapters/06/issues/domain/ports.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from collections import defaultdict 3 | from uuid import UUID 4 | from .model import Issue 5 | from typing import Callable, Generic 6 | 7 | 8 | class IssueNotFoundException(Exception): 9 | pass 10 | 11 | 12 | class IssueLog(abc.ABC): 13 | 14 | @abc.abstractmethod 15 | def add(self, issue: Issue) -> None: 16 | pass 17 | 18 | @abc.abstractmethod 19 | def _get(self, id: UUID) -> Issue: 20 | pass 21 | 22 | def get(self, id: UUID) -> Issue: 23 | issue = self._get(id) 24 | if issue is None: 25 | raise IssueNotFoundException() 26 | return issue 27 | 28 | 29 | class UnitOfWork(abc.ABC): 30 | 31 | @abc.abstractmethod 32 | def __enter__(self): 33 | pass 34 | 35 | @abc.abstractmethod 36 | def __exit__(self, type, value, traceback): 37 | pass 38 | 39 | @abc.abstractmethod 40 | def commit(self): 41 | pass 42 | 43 | @abc.abstractmethod 44 | def rollback(self): 45 | pass 46 | 47 | @property 48 | @abc.abstractmethod 49 | def issues(self): 50 | pass 51 | 52 | 53 | class UnitOfWorkManager(abc.ABC): 54 | 55 | @abc.abstractmethod 56 | def start(self) -> UnitOfWork: 57 | pass 58 | 59 | 60 | class CommandAlreadySubscribedException(Exception): 61 | pass 62 | 63 | 64 | class MessageBus: 65 | 66 | def __init__(self): 67 | self.handlers = defaultdict(list) 68 | 69 | def handle(self, msg): 70 | subscribers = self.handlers[type(msg).__name__] 71 | for handle in subscribers: 72 | handle(msg) 73 | 74 | def register(self, msg, handler, *args): 75 | self.handlers[msg.__name__].append(functools.partial(handler, *args)) 76 | -------------------------------------------------------------------------------- /ports-and-adapters/06/issues/issues.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bobthemighty/blog-code-samples/9c50b57baf42a76a10e612a65b3508fef60cf01d/ports-and-adapters/06/issues/issues.db -------------------------------------------------------------------------------- /ports-and-adapters/06/issues/quick_tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bobthemighty/blog-code-samples/9c50b57baf42a76a10e612a65b3508fef60cf01d/ports-and-adapters/06/issues/quick_tests/__init__.py -------------------------------------------------------------------------------- /ports-and-adapters/06/issues/quick_tests/adapters.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | 3 | from issues.domain.ports import IssueLog, UnitOfWorkManager, UnitOfWork 4 | from issues.domain.emails import EmailSender 5 | 6 | 7 | class FakeIssueLog(IssueLog): 8 | 9 | def __init__(self): 10 | self.issues = [] 11 | 12 | def add(self, issue): 13 | self.issues.append(issue) 14 | 15 | def _get(self, id): 16 | for issue in self.issues: 17 | if issue.id == id: 18 | return issue 19 | 20 | def __len__(self): 21 | return len(self.issues) 22 | 23 | def __getitem__(self, idx): 24 | return self.issues[idx] 25 | 26 | 27 | class FakeUnitOfWork(UnitOfWork, UnitOfWorkManager): 28 | 29 | def __init__(self): 30 | self._issues = FakeIssueLog() 31 | 32 | def start(self): 33 | self.was_committed = False 34 | self.was_rolled_back = False 35 | return self 36 | 37 | def __enter__(self): 38 | return self 39 | 40 | def __exit__(self, type, value, traceback): 41 | self.exn_type = type 42 | self.exn = value 43 | self.traceback = traceback 44 | 45 | def commit(self): 46 | self.was_committed = True 47 | 48 | def rollback(self): 49 | self.was_rolled_back = True 50 | 51 | @property 52 | def issues(self): 53 | return self._issues 54 | 55 | 56 | sent_mail = namedtuple('fakes_sent_mail', 57 | ['recipient', 'sender', 'subject', 'body']) 58 | 59 | 60 | def fake_sender(sent): 61 | 62 | def send(recipient, sender, subject, body): 63 | sent.append(sent_mail(recipient, sent_mail, subject, body)) 64 | 65 | return send 66 | -------------------------------------------------------------------------------- /ports-and-adapters/06/issues/quick_tests/matchers.py: -------------------------------------------------------------------------------- 1 | from expects.matchers import Matcher 2 | 3 | 4 | class have_raised(Matcher): 5 | 6 | def __init__(self, event): 7 | self.event = event 8 | 9 | def _match(self, entity): 10 | return self.event in entity.events, [] 11 | -------------------------------------------------------------------------------- /ports-and-adapters/06/issues/quick_tests/shared_contexts.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from .adapters import FakeUnitOfWork 3 | from issues.domain.model import Issue, IssueReporter 4 | from issues.domain.messages import IssuePriority 5 | 6 | 7 | class With_an_empty_unit_of_work: 8 | 9 | def given_a_unit_of_work(self): 10 | self.uow = FakeUnitOfWork() 11 | 12 | 13 | class With_a_new_issue(With_an_empty_unit_of_work): 14 | 15 | def given_a_new_issue(self): 16 | reporter = IssueReporter('John', 'john@example.org') 17 | self.issue_id = uuid.uuid4() 18 | self.issue = Issue(self.issue_id, reporter, 'how do I even?') 19 | self.uow.issues.add(self.issue) 20 | 21 | 22 | class With_a_triaged_issue(With_a_new_issue): 23 | 24 | def given_a_triaged_issue(self): 25 | self.issue.triage(IssuePriority.Low, 'uncategorised') 26 | 27 | 28 | class With_assigned_issue(With_a_triaged_issue): 29 | 30 | assigned_by = 'fred@example.org' 31 | assigned_to = 'mary@example.org' 32 | 33 | def given_an_assigned_issue(self): 34 | self.issue.assign(assigned_to, assigned_by) 35 | -------------------------------------------------------------------------------- /ports-and-adapters/06/issues/quick_tests/test_assignment.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from .adapters import FakeUnitOfWork, fake_sender 4 | from .shared_contexts import With_a_triaged_issue 5 | from .matchers import have_raised 6 | 7 | from issues.services import (assign_issue, on_issue_assigned_to_engineer, pick_issue) 8 | from issues.domain.messages import (AssignIssue, IssueAssignedToEngineer, 9 | IssueReassigned, IssueState, IssuePriority, 10 | PickIssue) 11 | from issues.domain.model import Issue, IssueReporter 12 | from issues.domain.emails import EmailSender 13 | 14 | from expects import expect, have_len, equal, be_true 15 | 16 | 17 | class When_assigning_an_issue(With_a_triaged_issue): 18 | 19 | assigned_to = 'percy@example.org' 20 | assigned_by = 'norman@example.org' 21 | 22 | def because_we_assign_the_issue(self): 23 | cmd = AssignIssue(self.issue_id, self.assigned_to, self.assigned_by) 24 | 25 | assign_issue(lambda: self.uow, cmd) 26 | 27 | def the_issue_should_be_assigned_to_percy(self): 28 | expect(self.issue.assignment.assigned_to).to(equal(self.assigned_to)) 29 | 30 | def the_issue_should_have_been_assigned_by_norman(self): 31 | expect(self.issue.assignment.assigned_by).to(equal(self.assigned_by)) 32 | 33 | def the_issue_should_be_ready_for_work(self): 34 | expect(self.issue.state).to(equal(IssueState.ReadyForWork)) 35 | 36 | def it_should_have_committed_the_unit_of_work(self): 37 | expect(self.uow.was_committed).to(be_true) 38 | 39 | def it_should_have_raised_issue_assigned(self): 40 | expect(self.issue).to( 41 | have_raised( 42 | IssueAssignedToEngineer(self.issue_id, self.assigned_to, 43 | self.assigned_by))) 44 | 45 | 46 | class When_picking_an_issue(With_a_triaged_issue): 47 | 48 | picked_by = 'percy@example.org' 49 | 50 | def because_we_pick_the_issue(self): 51 | cmd = PickIssue(self.issue_id, self.picked_by) 52 | 53 | pick_issue(lambda: self.uow, cmd) 54 | 55 | def the_issue_should_be_assigned_to_percy(self): 56 | expect(self.issue.assignment.assigned_to).to(equal(self.picked_by)) 57 | 58 | def the_issue_should_be_ready_for_work(self): 59 | expect(self.issue.state).to(equal(IssueState.ReadyForWork)) 60 | 61 | def it_should_have_committed_the_unit_of_work(self): 62 | expect(self.uow.was_committed).to(be_true) 63 | 64 | def it_should_not_have_raised_issue_assigned(self): 65 | expect(self.issue.events).to(have_len(0)) 66 | 67 | 68 | class When_reassigning_an_issue(With_a_triaged_issue): 69 | 70 | assigned_by = 'george@example.org' 71 | assigned_to = 'fred@example.org' 72 | 73 | new_assigned_to = 'percy@example.org' 74 | new_assigned_by = 'norman@example.org' 75 | 76 | def given_an_assigned_issue(self): 77 | self.issue.assign(self.assigned_to, self.assigned_by) 78 | 79 | def because_we_assign_the_issue(self): 80 | cmd = AssignIssue(self.issue_id, self.new_assigned_to, 81 | self.new_assigned_by) 82 | 83 | assign_issue(lambda: self.uow, cmd) 84 | 85 | def it_should_have_raised_issue_reassigned(self): 86 | expect(self.issue).to( 87 | have_raised(IssueReassigned(self.issue_id, self.assigned_to))) 88 | 89 | 90 | class When_an_issue_is_assigned: 91 | 92 | issue_id = uuid.uuid4() 93 | assigned_to = 'barry@example.org' 94 | assigned_by = 'helga@example.org' 95 | 96 | def given_a_view_model_and_emailer(self): 97 | self.view_model = { 98 | 'description': 'a bad thing happened', 99 | 'reporter_email': 'reporter@example.org', 100 | 'reported_name': 'Reporty McReportface' 101 | } 102 | 103 | self.sent = [] 104 | self.emailer = EmailSender(fake_sender(self.sent)) 105 | 106 | def because_we_raise_issue_assigned(self): 107 | evt = IssueAssignedToEngineer(self.issue_id, self.assigned_to, 108 | self.assigned_by) 109 | 110 | on_issue_assigned_to_engineer(lambda x: self.view_model, self.emailer, evt) 111 | 112 | def it_should_send_an_email(self): 113 | expect(self.sent).to(have_len(1)) 114 | 115 | def it_should_have_the_correct_subject(self): 116 | expect(self.sent[0].subject).to( 117 | equal('Hi barry@example.org - you\'ve been assigned an issue')) 118 | 119 | def it_should_be_to_the_correct_recipient(self): 120 | expect(self.sent[0].recipient).to(equal(self.assigned_to)) 121 | -------------------------------------------------------------------------------- /ports-and-adapters/06/issues/quick_tests/test_issue_reporting.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from .adapters import FakeUnitOfWork 4 | from issues.domain.messages import ReportIssue, IssueState, IssuePriority 5 | from issues.domain.model import Issue 6 | from issues.services import report_issue 7 | 8 | from expects import expect, have_len, equal, be_true 9 | 10 | email = "bob@example.org" 11 | name = "bob" 12 | desc = "My mouse won't move" 13 | id = uuid.uuid4() 14 | 15 | 16 | class When_reporting_an_issue: 17 | 18 | def given_an_empty_unit_of_work(self): 19 | self.uow = FakeUnitOfWork() 20 | 21 | def because_we_report_a_new_issue(self): 22 | cmd = ReportIssue(id, name, email, desc) 23 | 24 | report_issue(lambda: self.uow, cmd) 25 | 26 | def the_handler_should_have_created_a_new_issue(self): 27 | expect(self.uow.issues).to(have_len(1)) 28 | 29 | def it_should_have_recorded_the_id(self): 30 | expect(self.uow.issues[0].id).to(equal(id)) 31 | 32 | def it_should_be_awaiting_triage(self): 33 | expect(self.uow.issues[0].state).to(equal(IssueState.AwaitingTriage)) 34 | 35 | def it_should_have_recorded_the_issuer(self): 36 | expect(self.uow.issues[0].reporter.name).to(equal(name)) 37 | expect(self.uow.issues[0].reporter.email).to(equal(email)) 38 | 39 | def it_should_have_recorded_the_description(self): 40 | expect(self.uow.issues[0].description).to(equal(desc)) 41 | 42 | def it_should_have_committed_the_unit_of_work(self): 43 | expect(self.uow.was_committed).to(be_true) 44 | -------------------------------------------------------------------------------- /ports-and-adapters/06/issues/quick_tests/test_triage.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from .shared_contexts import With_a_new_issue 4 | 5 | from issues.services import triage_issue 6 | from issues.domain.messages import TriageIssue, IssuePriority, IssueState 7 | from issues.domain.model import Issue 8 | 9 | from expects import expect, have_len, equal, be_true 10 | 11 | 12 | class When_triaging_an_issue(With_a_new_issue): 13 | 14 | issue_id = uuid.uuid4() 15 | category = 'training' 16 | priority = IssuePriority.Low 17 | 18 | def because_we_triage_the_issue(self): 19 | cmd = TriageIssue(self.issue_id, self.category, self.priority) 20 | 21 | triage_issue(lambda: self.uow, cmd) 22 | 23 | def the_issue_should_have_a_priority_set(self): 24 | expect(self.issue.priority).to(equal(IssuePriority.Low)) 25 | 26 | def the_issue_should_have_been_categorised(self): 27 | expect(self.issue.category).to(equal('training')) 28 | 29 | def the_issue_should_be_awaiting_assignment(self): 30 | expect(self.issue.state).to(equal(IssueState.AwaitingAssignment)) 31 | 32 | def it_should_have_committed_the_unit_of_work(self): 33 | expect(self.uow.was_committed).to(be_true) 34 | -------------------------------------------------------------------------------- /ports-and-adapters/06/issues/services/__init__.py: -------------------------------------------------------------------------------- 1 | import issues.domain.emails 2 | from issues.domain.model import Issue, IssueReporter 3 | from issues.domain import emails, messages 4 | 5 | 6 | def report_issue(start_uow, cmd): 7 | reporter = IssueReporter(cmd.reporter_name, cmd.reporter_email) 8 | issue = Issue(cmd.issue_id, reporter, cmd.problem_description) 9 | with start_uow() as tx: 10 | tx.issues.add(issue) 11 | tx.commit() 12 | 13 | 14 | def triage_issue(start_uow, cmd): 15 | with start_uow() as tx: 16 | issue = tx.issues.get(cmd.issue_id) 17 | issue.triage(cmd.priority, cmd.category) 18 | tx.commit() 19 | 20 | 21 | def pick_issue(start_uow, cmd): 22 | with start_uow() as tx: 23 | issue = tx.issues.get(cmd.issue_id) 24 | issue.assign(cmd.picked_by) 25 | tx.commit() 26 | 27 | 28 | def assign_issue(start_uow, cmd): 29 | with start_uow() as tx: 30 | issue = tx.issues.get(cmd.issue_id) 31 | issue.assign(cmd.assigned_to, cmd.assigned_by) 32 | tx.commit() 33 | 34 | 35 | def on_issue_assigned_to_engineer(view_issue, sender, evt): 36 | data = view_issue(evt.issue_id) 37 | data.update(**evt._asdict()) 38 | 39 | request = emails.MailRequest( 40 | emails.IssueAssignedToMe, 41 | emails.default_from_addr, 42 | emails.EmailAddress(evt.assigned_to), 43 | ) 44 | 45 | sender.send(request, data) 46 | -------------------------------------------------------------------------------- /ports-and-adapters/06/issues/slow_tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bobthemighty/blog-code-samples/9c50b57baf42a76a10e612a65b3508fef60cf01d/ports-and-adapters/06/issues/slow_tests/__init__.py -------------------------------------------------------------------------------- /ports-and-adapters/06/issues/slow_tests/api_tests.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from expects import expect, equal 3 | 4 | 5 | def report_issue(reporter_name='fred', 6 | reporter_email='fred@example.org', 7 | problem_description='Halp!!!1!!1!11eleven!'): 8 | data = { 9 | 'reporter_name': reporter_name, 10 | 'reporter_email': reporter_email, 11 | 'problem_description': problem_description 12 | } 13 | 14 | resp = requests.post('http://localhost:5000/issues', json=data) 15 | return resp.headers['Location'] 16 | 17 | 18 | class When_reporting_a_new_issue: 19 | 20 | def because_we_report_a_new_issue(self): 21 | self.location = report_issue('Arnold', 'arnold@example.org', 22 | "I'm all alone and frightened.") 23 | 24 | @property 25 | def the_issue(self): 26 | return requests.get(self.location).json() 27 | 28 | def it_should_have_the_correct_reporter(self): 29 | expect(self.the_issue['reporter_name']).to(equal('Arnold')) 30 | 31 | def it_should_have_the_correct_description(self): 32 | expect(self.the_issue['description']).to( 33 | equal('I\'m all alone and frightened.')) 34 | 35 | 36 | class When_assigning_an_issue_to_another_engineer: 37 | 38 | def given_an_issue(self): 39 | self.location = report_issue('Arnold', 'arnold@example.org', 40 | "I'm all alone and frightened") 41 | 42 | def because_barbara_assigns_the_issue_to_constance(self): 43 | self.response = requests.post( 44 | self.location + '/assign?engineer=constance', 45 | headers={ 46 | 'X-Email': 'barbara@example.org' 47 | }) 48 | 49 | def it_should_be_fine(self): 50 | expect(self.response.status_code).to(equal(200)) 51 | -------------------------------------------------------------------------------- /ports-and-adapters/06/issues/slow_tests/issue_repository_tests.py: -------------------------------------------------------------------------------- 1 | from issues.domain.ports import MessageBus 2 | from issues.domain.model import Issue 3 | from issues.domain.messages import ReportIssue 4 | from issues.adapters.orm import SqlAlchemy 5 | from issues.adapters import views 6 | 7 | from issues.adapters import config 8 | 9 | import uuid 10 | 11 | from expects import expect, equal, have_len 12 | 13 | 14 | class When_we_load_a_persisted_issue: 15 | 16 | issue_id = uuid.uuid4() 17 | 18 | def given_a_database_containing_an_issue(self): 19 | 20 | cmd = ReportIssue(self.issue_id, 'fred', 'fred@example.org', 21 | 'forgot my password again') 22 | bus = config.bus 23 | bus.handle(cmd) 24 | 25 | def because_we_load_the_issues(self): 26 | self.issue = views.view_issue(config.db.get_session, self.issue_id) 27 | 28 | def it_should_have_the_correct_description(self): 29 | expect(self.issue['id']).to(equal(self.issue_id)) 30 | 31 | def it_should_have_the_correct_description(self): 32 | expect(self.issue['description']).to(equal('forgot my password again')) 33 | 34 | def it_should_have_the_correct_reporter_details(self): 35 | expect(self.issue['reporter_name']).to(equal('fred')) 36 | 37 | def it_should_have_the_correct_reporter_details(self): 38 | expect(self.issue['reporter_email']).to(equal('fred@example.org')) 39 | -------------------------------------------------------------------------------- /ports-and-adapters/06/requirements.txt: -------------------------------------------------------------------------------- 1 | colorama==0.3.9 2 | Contexts==0.11.2 3 | expects==0.8.0 4 | punq 5 | -------------------------------------------------------------------------------- /ports-and-adapters/06/setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name='ports-and-adapters', 5 | url='https://github.com/bobthemighty/ports-and-adapters-pythong', 6 | packages=find_packages('.'), 7 | ) 8 | -------------------------------------------------------------------------------- /ports-and-adapters/07/issues/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bobthemighty/blog-code-samples/9c50b57baf42a76a10e612a65b3508fef60cf01d/ports-and-adapters/07/issues/__init__.py -------------------------------------------------------------------------------- /ports-and-adapters/07/issues/adapters/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bobthemighty/blog-code-samples/9c50b57baf42a76a10e612a65b3508fef60cf01d/ports-and-adapters/07/issues/adapters/__init__.py -------------------------------------------------------------------------------- /ports-and-adapters/07/issues/adapters/config.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | import inspect 3 | import logging 4 | 5 | from . import orm 6 | 7 | from .emails import send_to_stdout 8 | 9 | import issues.domain.messages as msg 10 | from issues.domain import ports, emails 11 | from issues import services, domain, adapters 12 | from issues.adapters import views 13 | 14 | bus = ports.MessageBus() 15 | db = orm.SqlAlchemy('sqlite://', bus) 16 | db.recreate_schema() 17 | 18 | 19 | def make_pipeline(handler, *args): 20 | return services.pipeline(services.logging_handler, services.metric_recorder, 21 | partial(handler, *args)) 22 | 23 | 24 | bus.register(msg.ReportIssue, 25 | make_pipeline(services.report_issue, db.start_unit_of_work)) 26 | 27 | bus.register(msg.TriageIssue, 28 | make_pipeline(services.triage_issue, db.start_unit_of_work)) 29 | 30 | bus.register(msg.PickIssue, 31 | make_pipeline(services.pick_issue, db.start_unit_of_work)) 32 | 33 | bus.register(msg.IssueAssignedToEngineer, 34 | make_pipeline(services.on_issue_assigned_to_engineer, 35 | partial(views.view_issue, db.get_session), 36 | emails.EmailSender(send_to_stdout))) 37 | -------------------------------------------------------------------------------- /ports-and-adapters/07/issues/adapters/emails.py: -------------------------------------------------------------------------------- 1 | def send_to_stdout(self, recipient, sender, subject, body): 2 | print( 3 | "Sending email to {to} from {sender}\nsubject:{subject}\n{body}".format( 4 | to=recipient, sender=sender, body=body, subject=subject)) 5 | -------------------------------------------------------------------------------- /ports-and-adapters/07/issues/adapters/flask.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import uuid 3 | 4 | from flask import Flask, request, jsonify 5 | 6 | from . import config 7 | from issues.domain.messages import ReportIssue, AssignIssue, PickIssue 8 | from issues.adapters.views import view_issue, list_issues 9 | 10 | app = Flask('issues') 11 | bus = config.bus 12 | db = config.db 13 | 14 | 15 | @app.before_request 16 | def get_auth_header(): 17 | request.user = request.headers.get('X-email') 18 | 19 | 20 | @app.route('/issues', methods=['POST']) 21 | def report_issue(): 22 | issue_id = uuid.uuid4() 23 | cmd = ReportIssue(issue_id=issue_id, **request.get_json()) 24 | bus.handle(cmd) 25 | return "", 201, {"Location": "/issues/" + str(issue_id)} 26 | 27 | 28 | @app.route('/issues/') 29 | def get_issue(issue_id): 30 | view = view_issue(db.get_session, uuid.UUID(issue_id)) 31 | return jsonify(view) 32 | 33 | 34 | @app.route('/issues', methods=['GET']) 35 | def list_issues(): 36 | view = list_issues(db.config.get_session) 37 | return jsonify(view) 38 | 39 | 40 | @app.route('/issues//assign', methods=['POST']) 41 | def assign_to_engineer(issue_id): 42 | assign_to = request.args.get('engineer') 43 | cmd = AssignIssue(issue_id, assign_to, request.user) 44 | bus.handle(cmd) 45 | return "", 200 46 | 47 | 48 | @app.route('/issues//pick', methods=['POST']) 49 | def pick_issue(issue_id): 50 | cmd = PickIssue(issue_id, request.user) 51 | bus.handle(cmd) 52 | return 53 | -------------------------------------------------------------------------------- /ports-and-adapters/07/issues/adapters/orm.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import logging 3 | import typing 4 | import uuid 5 | 6 | import sqlalchemy 7 | from sqlalchemy import (Table, Column, MetaData, String, Integer, Text, 8 | ForeignKey, create_engine, event) 9 | from sqlalchemy.orm import mapper, scoped_session, sessionmaker, composite, relationship 10 | import sqlalchemy.exc 11 | import sqlalchemy.orm.exc 12 | 13 | from sqlalchemy_utils.functions import create_database, drop_database 14 | from sqlalchemy_utils.types.uuid import UUIDType 15 | 16 | from issues.domain.model import Issue, IssueReporter, Assignment 17 | from issues.domain.ports import (IssueLog, UnitOfWork, UnitOfWorkManager, 18 | MessageBus) 19 | 20 | SessionFactory = typing.Callable[[], sqlalchemy.orm.Session] 21 | 22 | 23 | class IssueRepository(IssueLog): 24 | 25 | def __init__(self, session): 26 | self._session = session 27 | 28 | def add(self, issue: Issue) -> None: 29 | self._session.add(issue) 30 | 31 | def _get(self, issue_id) -> Issue: 32 | return self._session.query(Issue).\ 33 | filter_by(id=issue_id).\ 34 | first() 35 | 36 | 37 | class SqlAlchemyUnitOfWork(UnitOfWork): 38 | 39 | def __init__(self, sessionfactory: SessionFactory, bus: MessageBus) -> None: 40 | self.sessionfactory = sessionfactory 41 | self.bus = bus 42 | event.listen(self.sessionfactory, "after_flush", self.gather_events) 43 | event.listen(self.sessionfactory, "loaded_as_persistent", 44 | self.setup_events) 45 | 46 | def __enter__(self): 47 | self.session = self.sessionfactory() 48 | self.flushed_events = [] 49 | return self 50 | 51 | def __exit__(self, type, value, traceback): 52 | self.publish_events() 53 | 54 | def commit(self): 55 | self.session.flush() 56 | self.session.commit() 57 | 58 | def rollback(self): 59 | self.flushed_events = [] 60 | self.session.rollback() 61 | 62 | def setup_events(self, session, entity): 63 | entity.events = [] 64 | 65 | def gather_events(self, session, ctx): 66 | flushed_objects = [e for e in session.new] + [e for e in session.dirty] 67 | for e in flushed_objects: 68 | try: 69 | self.flushed_events += e.events 70 | except AttributeError: 71 | pass 72 | 73 | def publish_events(self): 74 | for e in self.flushed_events: 75 | self.bus.handle(e) 76 | 77 | @property 78 | def issues(self): 79 | return IssueRepository(self.session) 80 | 81 | 82 | class SqlAlchemy: 83 | 84 | def __init__(self, uri, bus): 85 | self.engine = create_engine(uri) 86 | self.bus = bus 87 | self._session_maker = scoped_session(sessionmaker(self.engine),) 88 | 89 | def recreate_schema(self): 90 | self.configure_mappings() 91 | drop_database(self.engine.url) 92 | self.create_schema() 93 | 94 | def get_session(self): 95 | return self._session_maker() 96 | 97 | def start_unit_of_work(self): 98 | return SqlAlchemyUnitOfWork(self._session_maker, self.bus) 99 | 100 | def create_schema(self): 101 | create_database(self.engine.url) 102 | self.metadata.create_all() 103 | 104 | def configure_mappings(self): 105 | self.metadata = MetaData(self.engine) 106 | 107 | IssueReporter.__composite_values__ = lambda i: (i.name, i.email) 108 | issues = Table('issues', self.metadata, 109 | Column('pk', Integer, primary_key=True), 110 | Column('issue_id', UUIDType), 111 | Column('reporter_name', String(50)), 112 | Column('reporter_email', String(50)), 113 | Column('description', Text)) 114 | 115 | assignments = Table( 116 | 'assignments', 117 | self.metadata, 118 | Column('pk', Integer, primary_key=True), 119 | Column('id', UUIDType), 120 | Column('fk_assignment_id', UUIDType, ForeignKey('issues.issue_id')), 121 | Column('assigned_by', String(50)), 122 | Column('assigned_to', String(50)), 123 | ) 124 | 125 | mapper( 126 | Issue, 127 | issues, 128 | properties={ 129 | '__pk': 130 | issues.c.pk, 131 | 'id': 132 | issues.c.issue_id, 133 | 'description': 134 | issues.c.description, 135 | 'reporter': 136 | composite(IssueReporter, issues.c.reporter_name, 137 | issues.c.reporter_email), 138 | '_assignments': 139 | relationship(Assignment, backref='issue') 140 | }, 141 | ), 142 | 143 | mapper( 144 | Assignment, 145 | assignments, 146 | properties={ 147 | '__pk': assignments.c.pk, 148 | 'id': assignments.c.id, 149 | 'assigned_to': assignments.c.assigned_to, 150 | 'assigned_by': assignments.c.assigned_by 151 | }) 152 | -------------------------------------------------------------------------------- /ports-and-adapters/07/issues/adapters/views.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import uuid 3 | from .orm import SessionFactory 4 | from issues.domain import ports 5 | 6 | # This little helper function converts the binary data 7 | # We store in Sqlite back to a uuid. 8 | # Ordinarily I use postgres, which has a native UniqueID 9 | # type, so this manual unmarshalling isn't necessary 10 | 11 | 12 | def read_uuid(record, column): 13 | record = dict(record) 14 | bytes_val = record[column] 15 | uuid_val = uuid.UUID(bytes=bytes_val) 16 | record[column] = uuid_val 17 | return record 18 | 19 | 20 | FETCH_ISSUE = """SELECT description, 21 | reporter_email, 22 | reporter_name 23 | FROM issues 24 | WHERE issue_id = :id""" 25 | 26 | LIST_ISSUES = """SELECT issue_id, 27 | description, 28 | reporter_email, 29 | reporter_name 30 | FROM issues""" 31 | 32 | 33 | def view_issue(make_session, id): 34 | session = make_session() 35 | result = session.execute(FETCH_ISSUE, {'id': id.bytes}) 36 | record = result.fetchone() 37 | return dict(record) 38 | 39 | 40 | def list_issues(make_session, id): 41 | session = make_session() 42 | query = session.execute(LIST_ISSUES) 43 | 44 | result = [] 45 | for r in query.fetchall(): 46 | r = read_uuid(r, 'issue_id') 47 | result.append(r) 48 | 49 | return result 50 | -------------------------------------------------------------------------------- /ports-and-adapters/07/issues/domain/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bobthemighty/blog-code-samples/9c50b57baf42a76a10e612a65b3508fef60cf01d/ports-and-adapters/07/issues/domain/__init__.py -------------------------------------------------------------------------------- /ports-and-adapters/07/issues/domain/emails.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import NamedTuple, NewType, Dict, Any 3 | from jinja2 import Template 4 | 5 | EmailAddress = NewType('email', str) 6 | default_from_addr = EmailAddress('issues@example.org') 7 | TemplateData = Dict[str, Any] 8 | 9 | 10 | class MailTemplate(NamedTuple): 11 | subject: Template 12 | body: Template 13 | 14 | 15 | class MailRequest(NamedTuple): 16 | template: MailTemplate 17 | from_addr: EmailAddress 18 | to_addr: EmailAddress 19 | 20 | 21 | IssueAssignedToMe = MailTemplate( 22 | subject=Template("Hi {{assigned_to}} - you've been assigned an issue"), 23 | body=Template("{{description}}")) 24 | 25 | 26 | class EmailSender: 27 | 28 | def __init__(self, send): 29 | self._send = send 30 | 31 | def send(self, request: MailRequest, data: TemplateData): 32 | self._send(request.to_addr, request.from_addr, 33 | request.template.subject.render(data), 34 | request.template.body.render(data)) 35 | -------------------------------------------------------------------------------- /ports-and-adapters/07/issues/domain/messages.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from uuid import UUID 3 | from typing import NamedTuple, Generic, TypeVar 4 | 5 | 6 | def event(cls): 7 | 8 | setattr(cls, 'is_cmd', property(lambda x: getattr(x, 'is_cmd', False))) 9 | setattr(cls, 'is_event', property(lambda x: getattr(x, 'is_event', True))) 10 | setattr(cls, 'id', property(lambda x: getattr(x, 'id', None))) 11 | 12 | return cls 13 | 14 | 15 | def command(cls): 16 | 17 | setattr(cls, 'is_cmd', property(lambda x: getattr(x, 'is_cmd', True))) 18 | setattr(cls, 'is_event', property(lambda x: getattr(x, 'is_event', False))) 19 | setattr(cls, 'id', property(lambda x: getattr(x, 'id', None))) 20 | 21 | return cls 22 | 23 | 24 | class Message(NamedTuple): 25 | id: UUID 26 | 27 | 28 | class IssueState(Enum): 29 | AwaitingTriage = 0 30 | AwaitingAssignment = 1 31 | ReadyForWork = 2 32 | 33 | 34 | class IssuePriority(Enum): 35 | NotPrioritised = 0 36 | Low = 1 37 | Normal = 2 38 | High = 3 39 | Urgent = 4 40 | 41 | 42 | @command 43 | class ReportIssue(NamedTuple): 44 | issue_id: UUID 45 | reporter_name: str 46 | reporter_email: str 47 | problem_description: str 48 | 49 | 50 | @command 51 | class TriageIssue(NamedTuple): 52 | issue_id: UUID 53 | category: str 54 | priority: IssuePriority 55 | 56 | 57 | @command 58 | class AssignIssue(NamedTuple): 59 | issue_id: UUID 60 | assigned_to: str 61 | assigned_by: str 62 | 63 | 64 | @command 65 | class PickIssue(NamedTuple): 66 | issue_id: UUID 67 | picked_by: str 68 | 69 | 70 | @event 71 | class IssueAssignedToEngineer(NamedTuple): 72 | issue_id: UUID 73 | assigned_to: str 74 | assigned_by: str 75 | 76 | 77 | @event 78 | class IssueReassigned(NamedTuple): 79 | issue_id: UUID 80 | previous_assignee: str 81 | -------------------------------------------------------------------------------- /ports-and-adapters/07/issues/domain/model.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from uuid import UUID 3 | from .messages import (IssueState, IssuePriority, IssueReassigned, 4 | IssueAssignedToEngineer) 5 | 6 | 7 | class IssueReporter: 8 | 9 | def __init__(self, name: str, email: str) -> None: 10 | self.name = name 11 | self.email = email 12 | 13 | 14 | class Assignment: 15 | 16 | def __init__(self, assigned_to, assigned_by): 17 | self.assigned_to = assigned_to 18 | self.assigned_by = assigned_by 19 | 20 | def is_reassignment_from(self, other): 21 | if other is None: 22 | return False 23 | if other.assigned_to == self.assigned_to: 24 | return False 25 | return True 26 | 27 | 28 | class Issue: 29 | 30 | def __init__(self, issue_id: UUID, reporter: IssueReporter, 31 | description: str) -> None: 32 | self.id = issue_id 33 | self.description = description 34 | self.reporter = reporter 35 | self.state = IssueState.AwaitingTriage 36 | self.events = [] 37 | self._assignments = [] 38 | 39 | @property 40 | def assignment(self): 41 | if len(self._assignments) == 0: 42 | return None 43 | return self._assignments[-1] 44 | 45 | def triage(self, priority: IssuePriority, category: str) -> None: 46 | self.priority = priority 47 | self.category = category 48 | self.state = IssueState.AwaitingAssignment 49 | 50 | def assign(self, assigned_to, assigned_by=None): 51 | previous_assignment = self.assignment 52 | assigned_by = assigned_by or assigned_to 53 | 54 | self._assignments.append(Assignment(assigned_to, assigned_by)) 55 | 56 | self.state = IssueState.ReadyForWork 57 | 58 | if self.assignment.is_reassignment_from(previous_assignment): 59 | self.events.append( 60 | IssueReassigned(self.id, previous_assignment.assigned_to)) 61 | 62 | if assigned_to != assigned_by: 63 | self.events.append( 64 | IssueAssignedToEngineer(self.id, assigned_to, assigned_by)) 65 | -------------------------------------------------------------------------------- /ports-and-adapters/07/issues/domain/ports.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from collections import defaultdict 3 | from uuid import UUID 4 | from .model import Issue 5 | from typing import Callable, Generic 6 | 7 | 8 | class IssueNotFoundException(Exception): 9 | pass 10 | 11 | 12 | class IssueLog(abc.ABC): 13 | 14 | @abc.abstractmethod 15 | def add(self, issue: Issue) -> None: 16 | pass 17 | 18 | @abc.abstractmethod 19 | def _get(self, id: UUID) -> Issue: 20 | pass 21 | 22 | def get(self, id: UUID) -> Issue: 23 | issue = self._get(id) 24 | if issue is None: 25 | raise IssueNotFoundException() 26 | return issue 27 | 28 | 29 | class UnitOfWork(abc.ABC): 30 | 31 | @abc.abstractmethod 32 | def __enter__(self): 33 | pass 34 | 35 | @abc.abstractmethod 36 | def __exit__(self, type, value, traceback): 37 | pass 38 | 39 | @abc.abstractmethod 40 | def commit(self): 41 | pass 42 | 43 | @abc.abstractmethod 44 | def rollback(self): 45 | pass 46 | 47 | @property 48 | @abc.abstractmethod 49 | def issues(self): 50 | pass 51 | 52 | 53 | class UnitOfWorkManager(abc.ABC): 54 | 55 | @abc.abstractmethod 56 | def start(self) -> UnitOfWork: 57 | pass 58 | 59 | 60 | class CommandAlreadySubscribedException(Exception): 61 | pass 62 | 63 | 64 | class MessageBus: 65 | 66 | def __init__(self): 67 | self.handlers = defaultdict(list) 68 | 69 | def handle(self, msg): 70 | subscribers = self.handlers[type(msg).__name__] 71 | for handle in subscribers: 72 | handle(msg) 73 | 74 | def register(self, msg, handler, *args): 75 | self.handlers[msg.__name__].append(handler) 76 | -------------------------------------------------------------------------------- /ports-and-adapters/07/issues/issues.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bobthemighty/blog-code-samples/9c50b57baf42a76a10e612a65b3508fef60cf01d/ports-and-adapters/07/issues/issues.db -------------------------------------------------------------------------------- /ports-and-adapters/07/issues/quick_tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bobthemighty/blog-code-samples/9c50b57baf42a76a10e612a65b3508fef60cf01d/ports-and-adapters/07/issues/quick_tests/__init__.py -------------------------------------------------------------------------------- /ports-and-adapters/07/issues/quick_tests/adapters.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | 3 | from issues.domain.ports import IssueLog, UnitOfWorkManager, UnitOfWork 4 | from issues.domain.emails import EmailSender 5 | 6 | 7 | class FakeIssueLog(IssueLog): 8 | 9 | def __init__(self): 10 | self.issues = [] 11 | 12 | def add(self, issue): 13 | self.issues.append(issue) 14 | 15 | def _get(self, id): 16 | for issue in self.issues: 17 | if issue.id == id: 18 | return issue 19 | 20 | def __len__(self): 21 | return len(self.issues) 22 | 23 | def __getitem__(self, idx): 24 | return self.issues[idx] 25 | 26 | 27 | class FakeUnitOfWork(UnitOfWork, UnitOfWorkManager): 28 | 29 | def __init__(self): 30 | self._issues = FakeIssueLog() 31 | 32 | def start(self): 33 | self.was_committed = False 34 | self.was_rolled_back = False 35 | return self 36 | 37 | def __enter__(self): 38 | return self 39 | 40 | def __exit__(self, type, value, traceback): 41 | self.exn_type = type 42 | self.exn = value 43 | self.traceback = traceback 44 | 45 | def commit(self): 46 | self.was_committed = True 47 | 48 | def rollback(self): 49 | self.was_rolled_back = True 50 | 51 | @property 52 | def issues(self): 53 | return self._issues 54 | 55 | 56 | sent_mail = namedtuple('fakes_sent_mail', 57 | ['recipient', 'sender', 'subject', 'body']) 58 | 59 | 60 | def fake_sender(sent): 61 | 62 | def send(recipient, sender, subject, body): 63 | sent.append(sent_mail(recipient, sent_mail, subject, body)) 64 | 65 | return send 66 | -------------------------------------------------------------------------------- /ports-and-adapters/07/issues/quick_tests/matchers.py: -------------------------------------------------------------------------------- 1 | from expects.matchers import Matcher 2 | 3 | 4 | class have_raised(Matcher): 5 | 6 | def __init__(self, event): 7 | self.event = event 8 | 9 | def _match(self, entity): 10 | return self.event in entity.events, [] 11 | -------------------------------------------------------------------------------- /ports-and-adapters/07/issues/quick_tests/shared_contexts.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from .adapters import FakeUnitOfWork 3 | from issues.domain.model import Issue, IssueReporter 4 | from issues.domain.messages import IssuePriority 5 | 6 | 7 | class With_an_empty_unit_of_work: 8 | 9 | def given_a_unit_of_work(self): 10 | self.uow = FakeUnitOfWork() 11 | 12 | 13 | class With_a_new_issue(With_an_empty_unit_of_work): 14 | 15 | def given_a_new_issue(self): 16 | reporter = IssueReporter('John', 'john@example.org') 17 | self.issue_id = uuid.uuid4() 18 | self.issue = Issue(self.issue_id, reporter, 'how do I even?') 19 | self.uow.issues.add(self.issue) 20 | 21 | 22 | class With_a_triaged_issue(With_a_new_issue): 23 | 24 | def given_a_triaged_issue(self): 25 | self.issue.triage(IssuePriority.Low, 'uncategorised') 26 | 27 | 28 | class With_assigned_issue(With_a_triaged_issue): 29 | 30 | assigned_by = 'fred@example.org' 31 | assigned_to = 'mary@example.org' 32 | 33 | def given_an_assigned_issue(self): 34 | self.issue.assign(assigned_to, assigned_by) 35 | -------------------------------------------------------------------------------- /ports-and-adapters/07/issues/quick_tests/test_assignment.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from .adapters import FakeUnitOfWork, fake_sender 4 | from .shared_contexts import With_a_triaged_issue 5 | from .matchers import have_raised 6 | 7 | from issues.services import (assign_issue, on_issue_assigned_to_engineer, 8 | pick_issue) 9 | from issues.domain.messages import (AssignIssue, IssueAssignedToEngineer, 10 | IssueReassigned, IssueState, IssuePriority, 11 | PickIssue) 12 | from issues.domain.model import Issue, IssueReporter 13 | from issues.domain.emails import EmailSender 14 | 15 | from expects import expect, have_len, equal, be_true 16 | 17 | 18 | class When_assigning_an_issue(With_a_triaged_issue): 19 | 20 | assigned_to = 'percy@example.org' 21 | assigned_by = 'norman@example.org' 22 | 23 | def because_we_assign_the_issue(self): 24 | cmd = AssignIssue(self.issue_id, self.assigned_to, self.assigned_by) 25 | 26 | assign_issue(lambda: self.uow, cmd) 27 | 28 | def the_issue_should_be_assigned_to_percy(self): 29 | expect(self.issue.assignment.assigned_to).to(equal(self.assigned_to)) 30 | 31 | def the_issue_should_have_been_assigned_by_norman(self): 32 | expect(self.issue.assignment.assigned_by).to(equal(self.assigned_by)) 33 | 34 | def the_issue_should_be_ready_for_work(self): 35 | expect(self.issue.state).to(equal(IssueState.ReadyForWork)) 36 | 37 | def it_should_have_committed_the_unit_of_work(self): 38 | expect(self.uow.was_committed).to(be_true) 39 | 40 | def it_should_have_raised_issue_assigned(self): 41 | expect(self.issue).to( 42 | have_raised( 43 | IssueAssignedToEngineer(self.issue_id, self.assigned_to, 44 | self.assigned_by))) 45 | 46 | 47 | class When_picking_an_issue(With_a_triaged_issue): 48 | 49 | picked_by = 'percy@example.org' 50 | 51 | def because_we_pick_the_issue(self): 52 | cmd = PickIssue(self.issue_id, self.picked_by) 53 | 54 | pick_issue(lambda: self.uow, cmd) 55 | 56 | def the_issue_should_be_assigned_to_percy(self): 57 | expect(self.issue.assignment.assigned_to).to(equal(self.picked_by)) 58 | 59 | def the_issue_should_be_ready_for_work(self): 60 | expect(self.issue.state).to(equal(IssueState.ReadyForWork)) 61 | 62 | def it_should_have_committed_the_unit_of_work(self): 63 | expect(self.uow.was_committed).to(be_true) 64 | 65 | def it_should_not_have_raised_issue_assigned(self): 66 | expect(self.issue.events).to(have_len(0)) 67 | 68 | 69 | class When_reassigning_an_issue(With_a_triaged_issue): 70 | 71 | assigned_by = 'george@example.org' 72 | assigned_to = 'fred@example.org' 73 | 74 | new_assigned_to = 'percy@example.org' 75 | new_assigned_by = 'norman@example.org' 76 | 77 | def given_an_assigned_issue(self): 78 | self.issue.assign(self.assigned_to, self.assigned_by) 79 | 80 | def because_we_assign_the_issue(self): 81 | cmd = AssignIssue(self.issue_id, self.new_assigned_to, 82 | self.new_assigned_by) 83 | 84 | assign_issue(lambda: self.uow, cmd) 85 | 86 | def it_should_have_raised_issue_reassigned(self): 87 | expect(self.issue).to( 88 | have_raised(IssueReassigned(self.issue_id, self.assigned_to))) 89 | 90 | 91 | class When_an_issue_is_assigned: 92 | 93 | issue_id = uuid.uuid4() 94 | assigned_to = 'barry@example.org' 95 | assigned_by = 'helga@example.org' 96 | 97 | def given_a_view_model_and_emailer(self): 98 | self.view_model = { 99 | 'description': 'a bad thing happened', 100 | 'reporter_email': 'reporter@example.org', 101 | 'reported_name': 'Reporty McReportface' 102 | } 103 | 104 | self.sent = [] 105 | self.emailer = EmailSender(fake_sender(self.sent)) 106 | 107 | def because_we_raise_issue_assigned(self): 108 | evt = IssueAssignedToEngineer(self.issue_id, self.assigned_to, 109 | self.assigned_by) 110 | 111 | on_issue_assigned_to_engineer(lambda x: self.view_model, self.emailer, 112 | evt) 113 | 114 | def it_should_send_an_email(self): 115 | expect(self.sent).to(have_len(1)) 116 | 117 | def it_should_have_the_correct_subject(self): 118 | expect(self.sent[0].subject).to( 119 | equal('Hi barry@example.org - you\'ve been assigned an issue')) 120 | 121 | def it_should_be_to_the_correct_recipient(self): 122 | expect(self.sent[0].recipient).to(equal(self.assigned_to)) 123 | -------------------------------------------------------------------------------- /ports-and-adapters/07/issues/quick_tests/test_issue_reporting.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from .adapters import FakeUnitOfWork 4 | from issues.domain.messages import ReportIssue, IssueState, IssuePriority 5 | from issues.domain.model import Issue 6 | from issues.services import report_issue 7 | 8 | from expects import expect, have_len, equal, be_true 9 | 10 | email = "bob@example.org" 11 | name = "bob" 12 | desc = "My mouse won't move" 13 | id = uuid.uuid4() 14 | 15 | 16 | class When_reporting_an_issue: 17 | 18 | def given_an_empty_unit_of_work(self): 19 | self.uow = FakeUnitOfWork() 20 | 21 | def because_we_report_a_new_issue(self): 22 | cmd = ReportIssue(id, name, email, desc) 23 | 24 | report_issue(lambda: self.uow, cmd) 25 | 26 | def the_handler_should_have_created_a_new_issue(self): 27 | expect(self.uow.issues).to(have_len(1)) 28 | 29 | def it_should_have_recorded_the_id(self): 30 | expect(self.uow.issues[0].id).to(equal(id)) 31 | 32 | def it_should_be_awaiting_triage(self): 33 | expect(self.uow.issues[0].state).to(equal(IssueState.AwaitingTriage)) 34 | 35 | def it_should_have_recorded_the_issuer(self): 36 | expect(self.uow.issues[0].reporter.name).to(equal(name)) 37 | expect(self.uow.issues[0].reporter.email).to(equal(email)) 38 | 39 | def it_should_have_recorded_the_description(self): 40 | expect(self.uow.issues[0].description).to(equal(desc)) 41 | 42 | def it_should_have_committed_the_unit_of_work(self): 43 | expect(self.uow.was_committed).to(be_true) 44 | -------------------------------------------------------------------------------- /ports-and-adapters/07/issues/quick_tests/test_triage.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from .shared_contexts import With_a_new_issue 4 | 5 | from issues.services import triage_issue 6 | from issues.domain.messages import TriageIssue, IssuePriority, IssueState 7 | from issues.domain.model import Issue 8 | 9 | from expects import expect, have_len, equal, be_true 10 | 11 | 12 | class When_triaging_an_issue(With_a_new_issue): 13 | 14 | issue_id = uuid.uuid4() 15 | category = 'training' 16 | priority = IssuePriority.Low 17 | 18 | def because_we_triage_the_issue(self): 19 | cmd = TriageIssue(self.issue_id, self.category, self.priority) 20 | 21 | triage_issue(lambda: self.uow, cmd) 22 | 23 | def the_issue_should_have_a_priority_set(self): 24 | expect(self.issue.priority).to(equal(IssuePriority.Low)) 25 | 26 | def the_issue_should_have_been_categorised(self): 27 | expect(self.issue.category).to(equal('training')) 28 | 29 | def the_issue_should_be_awaiting_assignment(self): 30 | expect(self.issue.state).to(equal(IssueState.AwaitingAssignment)) 31 | 32 | def it_should_have_committed_the_unit_of_work(self): 33 | expect(self.uow.was_committed).to(be_true) 34 | -------------------------------------------------------------------------------- /ports-and-adapters/07/issues/services/__init__.py: -------------------------------------------------------------------------------- 1 | from collections import deque 2 | from functools import partial 3 | import logging 4 | 5 | import issues.domain.emails 6 | from issues.domain.model import Issue, IssueReporter 7 | from issues.domain import emails, messages 8 | 9 | 10 | def report_issue(start_uow, cmd): 11 | reporter = IssueReporter(cmd.reporter_name, cmd.reporter_email) 12 | issue = Issue(cmd.issue_id, reporter, cmd.problem_description) 13 | with start_uow() as tx: 14 | tx.issues.add(issue) 15 | tx.commit() 16 | 17 | 18 | def triage_issue(start_uow, cmd): 19 | with start_uow() as tx: 20 | issue = tx.issues.get(cmd.issue_id) 21 | issue.triage(cmd.priority, cmd.category) 22 | tx.commit() 23 | 24 | 25 | def pick_issue(start_uow, cmd): 26 | with start_uow() as tx: 27 | issue = tx.issues.get(cmd.issue_id) 28 | issue.assign(cmd.picked_by) 29 | tx.commit() 30 | 31 | 32 | def assign_issue(start_uow, cmd): 33 | with start_uow() as tx: 34 | issue = tx.issues.get(cmd.issue_id) 35 | issue.assign(cmd.assigned_to, cmd.assigned_by) 36 | tx.commit() 37 | 38 | 39 | def on_issue_assigned_to_engineer(view_issue, sender, evt): 40 | data = view_issue(evt.issue_id) 41 | data.update(**evt._asdict()) 42 | 43 | request = emails.MailRequest( 44 | emails.IssueAssignedToMe, 45 | emails.default_from_addr, 46 | emails.EmailAddress(evt.assigned_to), 47 | ) 48 | 49 | sender.send(request, data) 50 | 51 | 52 | def logging_handler(successor, msg): 53 | logging.info("Handling %s", msg) 54 | successor(msg) 55 | 56 | 57 | def metric_recorder(successor, msg): 58 | logging.info("Recording metrics for %s", msg) 59 | successor(msg) 60 | 61 | 62 | def pipeline(*args): 63 | 64 | def construct(head, tail): 65 | if not tail: 66 | return head 67 | nexthead, *nexttail = tail 68 | return partial(head, construct(nexthead, nexttail)) 69 | 70 | head, *tail = args 71 | return construct(head, tail) 72 | -------------------------------------------------------------------------------- /ports-and-adapters/07/issues/slow_tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bobthemighty/blog-code-samples/9c50b57baf42a76a10e612a65b3508fef60cf01d/ports-and-adapters/07/issues/slow_tests/__init__.py -------------------------------------------------------------------------------- /ports-and-adapters/07/issues/slow_tests/api_tests.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from expects import expect, equal 3 | 4 | 5 | def report_issue(reporter_name='fred', 6 | reporter_email='fred@example.org', 7 | problem_description='Halp!!!1!!1!11eleven!'): 8 | data = { 9 | 'reporter_name': reporter_name, 10 | 'reporter_email': reporter_email, 11 | 'problem_description': problem_description 12 | } 13 | 14 | resp = requests.post('http://localhost:5000/issues', json=data) 15 | return resp.headers['Location'] 16 | 17 | 18 | class When_reporting_a_new_issue: 19 | 20 | def because_we_report_a_new_issue(self): 21 | self.location = report_issue('Arnold', 'arnold@example.org', 22 | "I'm all alone and frightened.") 23 | 24 | @property 25 | def the_issue(self): 26 | return requests.get(self.location).json() 27 | 28 | def it_should_have_the_correct_reporter(self): 29 | expect(self.the_issue['reporter_name']).to(equal('Arnold')) 30 | 31 | def it_should_have_the_correct_description(self): 32 | expect(self.the_issue['description']).to( 33 | equal('I\'m all alone and frightened.')) 34 | 35 | 36 | class When_assigning_an_issue_to_another_engineer: 37 | 38 | def given_an_issue(self): 39 | self.location = report_issue('Arnold', 'arnold@example.org', 40 | "I'm all alone and frightened") 41 | 42 | def because_barbara_assigns_the_issue_to_constance(self): 43 | self.response = requests.post( 44 | self.location + '/assign?engineer=constance', 45 | headers={ 46 | 'X-Email': 'barbara@example.org' 47 | }) 48 | 49 | def it_should_be_fine(self): 50 | expect(self.response.status_code).to(equal(200)) 51 | -------------------------------------------------------------------------------- /ports-and-adapters/07/issues/slow_tests/issue_repository_tests.py: -------------------------------------------------------------------------------- 1 | from issues.domain.ports import MessageBus 2 | from issues.domain.model import Issue 3 | from issues.domain.messages import ReportIssue 4 | from issues.adapters.orm import SqlAlchemy 5 | from issues.adapters import views 6 | 7 | from issues.adapters import config 8 | 9 | import uuid 10 | 11 | from expects import expect, equal, have_len 12 | 13 | 14 | class When_we_load_a_persisted_issue: 15 | 16 | issue_id = uuid.uuid4() 17 | 18 | def given_a_database_containing_an_issue(self): 19 | 20 | cmd = ReportIssue(self.issue_id, 'fred', 'fred@example.org', 21 | 'forgot my password again') 22 | bus = config.bus 23 | bus.handle(cmd) 24 | 25 | def because_we_load_the_issues(self): 26 | self.issue = views.view_issue(config.db.get_session, self.issue_id) 27 | 28 | def it_should_have_the_correct_description(self): 29 | expect(self.issue['id']).to(equal(self.issue_id)) 30 | 31 | def it_should_have_the_correct_description(self): 32 | expect(self.issue['description']).to(equal('forgot my password again')) 33 | 34 | def it_should_have_the_correct_reporter_details(self): 35 | expect(self.issue['reporter_name']).to(equal('fred')) 36 | 37 | def it_should_have_the_correct_reporter_details(self): 38 | expect(self.issue['reporter_email']).to(equal('fred@example.org')) 39 | -------------------------------------------------------------------------------- /ports-and-adapters/07/requirements.txt: -------------------------------------------------------------------------------- 1 | colorama==0.3.9 2 | Contexts==0.11.2 3 | expects==0.8.0 4 | punq 5 | -------------------------------------------------------------------------------- /ports-and-adapters/07/setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name='ports-and-adapters', 5 | url='https://github.com/bobthemighty/ports-and-adapters-pythong', 6 | packages=find_packages('.'), 7 | ) 8 | -------------------------------------------------------------------------------- /rek/custom-json-metrics/api/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.8 2 | 3 | WORKDIR /go/src/app 4 | COPY . . 5 | 6 | RUN go-wrapper download # "go get -d -v ./..." 7 | RUN go-wrapper install # "go install -v ./..." 8 | 9 | CMD ["go-wrapper", "run"] 10 | -------------------------------------------------------------------------------- /rek/custom-json-metrics/api/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "os" 7 | "os/signal" 8 | "strconv" 9 | "syscall" 10 | 11 | Syslog "github.com/blackjack/syslog" 12 | "github.com/julienschmidt/httprouter" 13 | ) 14 | 15 | func Stop(srv *http.Server) { 16 | fmt.Println("Shutting down http server") 17 | if err := srv.Shutdown(nil); err != nil { 18 | fmt.Printf("Failed during shutdown %s", err) 19 | } 20 | } 21 | 22 | func Endpoint(w http.ResponseWriter, r *http.Request, p httprouter.Params) { 23 | time, err := strconv.Atoi(r.URL.Query().Get("time")) 24 | if err != nil { 25 | time = 20 26 | } 27 | 28 | status, err := strconv.Atoi(p.ByName("status")) 29 | if err != nil { 30 | fmt.Printf(`{ "@message": "Failed to parse status '%s' as http status code", "@fields":{ "levelname": "ERROR" }}%s`, p.ByName("status"), "\n") 31 | } 32 | 33 | fmt.Printf(`{ "@message": "Responded with %d in %dms", "@fields": { "_riemann_metric": { "service": "my-app/response-time", "metric": %d, "attributes": { "status_code": "%d" }}}}%s`, status, time, time, status, "\n") 34 | } 35 | 36 | func Start() *http.Server { 37 | router := httprouter.New() 38 | router.GET("/:status", Endpoint) 39 | srv := &http.Server{ 40 | Addr: "0.0.0.0:8192", 41 | Handler: router, 42 | } 43 | go func() { 44 | if err := srv.ListenAndServe(); err != nil { 45 | fmt.Printf("Http ListenAndServe error: %s", err) 46 | } 47 | }() 48 | return srv 49 | } 50 | 51 | func handleSignals(sigs chan os.Signal, done chan bool) { 52 | var srv *http.Server 53 | 54 | srv = Start() 55 | for { 56 | sig := <-sigs 57 | if sig == syscall.SIGHUP { 58 | fmt.Printf("Received SIGHUP; restarting service") 59 | Stop(srv) 60 | srv = Start() 61 | } else { 62 | Stop(srv) 63 | break 64 | } 65 | } 66 | done <- true 67 | } 68 | 69 | func main() { 70 | Syslog.Openlog("my-app", Syslog.LOG_PID, Syslog.LOG_LOCAL3) 71 | done := make(chan bool, 1) 72 | sigs := make(chan os.Signal) 73 | go handleSignals(sigs, done) 74 | signal.Notify(sigs, syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP) 75 | <-done 76 | fmt.Printf("Exiting") 77 | } 78 | -------------------------------------------------------------------------------- /rek/custom-json-metrics/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | volumes: 4 | customjsondev: 5 | 6 | services: 7 | 8 | rsyslog: 9 | build: rsyslog 10 | volumes: 11 | - "./rsyslog/rsyslog.conf:/etc/rsyslog.conf" 12 | - "./rsyslog/error-level.lookup:/etc/error-level.lookup" 13 | - "/tmp:/run/rsyslog" 14 | 15 | api: 16 | build: api 17 | ports: 18 | - "8192:8192" 19 | depends_on: 20 | - rsyslog 21 | logging: 22 | driver: syslog 23 | options: 24 | syslog-address: "unixgram:///tmp/log.sock" 25 | 26 | riemann: 27 | build: riemann 28 | ports: 29 | - "5555:5555" 30 | volumes: 31 | - "./riemann/riemann.config:/app/etc/riemann.config" 32 | 33 | logger: 34 | depends_on: 35 | - rsyslog 36 | build: dummy-logger 37 | logging: 38 | driver: syslog 39 | options: 40 | syslog-address: "unixgram:///tmp/log.sock" 41 | 42 | 43 | -------------------------------------------------------------------------------- /rek/custom-json-metrics/dummy-logger/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.8 2 | 3 | WORKDIR /go/src/app 4 | COPY . . 5 | 6 | RUN go-wrapper download # "go get -d -v ./..." 7 | RUN go-wrapper install # "go install -v ./..." 8 | 9 | ENTRYPOINT ["app"] 10 | -------------------------------------------------------------------------------- /rek/custom-json-metrics/dummy-logger/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | ) 7 | 8 | func main() { 9 | var message = flag.String("message", "Hello world!", "The log message to send") 10 | var level = flag.String("level", "info", "The 'levelname' to use; debug, info, warn, error, etc.") 11 | var format = flag.String("format", "json", "The format to use, 'log4j' or 'json'") 12 | flag.Parse() 13 | 14 | if *format == "log4j" { 15 | fmt.Printf("[%s]: %s", *level, *message) 16 | } else { 17 | fmt.Printf(`{ "@message": "%s", "@fields":{ "levelname": "%s" } }`, *message, *level) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /rek/custom-json-metrics/riemann/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM rlister/riemann 2 | COPY riemann.config etc/riemann.config 3 | -------------------------------------------------------------------------------- /rek/custom-json-metrics/riemann/riemann.config: -------------------------------------------------------------------------------- 1 | ; -*- mode: clojure; -*- 2 | ; vim: filetype=clojure 3 | 4 | (let [host "0.0.0.0"] 5 | (tcp-server {:host host}) 6 | 7 | ; Here's where we configure our metric handling 8 | (streams 9 | (where (and 10 | (not (tagged "riemann")) 11 | (not (service #"^riemann.*"))) 12 | 13 | ; print it 14 | prn 15 | )) 16 | 17 | ) 18 | -------------------------------------------------------------------------------- /rek/custom-json-metrics/rsyslog/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM centos 2 | ADD http://rpms.adiscon.com/v8-stable/rsyslog.repo /etc/yum.repos.d 3 | ADD http://dl.fedoraproject.org/pub/epel/7/x86_64/e/epel-release-7-9.noarch.rpm /tmp/epel.rpm 4 | 5 | RUN rpm -ivh /tmp/epel.rpm \ 6 | && curl -s https://packagecloud.io/install/repositories/madecom/public/script.rpm.sh | bash \ 7 | && yum install -y rsyslog-8.27.0_experimental_d6d5a0913-1.x86_64 \ 8 | && mkdir -p /run/rsyslog 9 | 10 | COPY rsyslog.conf /etc/rsyslog.conf 11 | COPY rsyslog-http.rb /etc/rsyslog-http.rb 12 | COPY error-level.lookup /etc/error-level.lookup 13 | VOLUME ["/run/rsyslog/"] 14 | CMD ["rsyslogd", "-dn"] 15 | -------------------------------------------------------------------------------- /rek/custom-json-metrics/rsyslog/error-level.lookup: -------------------------------------------------------------------------------- 1 | { "version": 1, "nomatch": "7", "type":"string", 2 | "table":[ 3 | {"index": "FATAL", "value": 1}, 4 | {"index": "CRITICAL", "value": 2}, 5 | {"index": "ERROR", "value": 3}, 6 | {"index": "WARN", "value": 4}, 7 | {"index": "INFO", "value": 5}, 8 | {"index": "DEBUG", "value": 6}, 9 | {"index": "TRACE", "value": 7}, 10 | {"index": "fatal", "value": 1}, 11 | {"index": "critical", "value": 2}, 12 | {"index": "error", "value": 3}, 13 | {"index": "warn", "value": 4}, 14 | {"index": "info", "value": 5}, 15 | {"index": "debug", "value": 6}, 16 | {"index": "trace", "value": 7} 17 | 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /rek/custom-json-metrics/rsyslog/rsyslog-http.rb: -------------------------------------------------------------------------------- 1 | version=2 2 | 3 | rule=http:%remote_addr:word% %ident:word% %auth:word% [%timestamp:char-to:]%] "%method:word% %request:word% HTTP/%httpversion:float%" %status:number% %bytes_sent:number% "%referrer:char-to:"%" "%agent:char-to:"%"%blob:rest% 4 | rule=http:%remote_addr:word% %ident:word% %auth:word% [%timestamp:char-to:]%] "%method:word% %request:word% HTTP/%httpversion:float%" %status:number% %bytes_sent:number% "%referrer:char-to:"%" "%agent:char-to:"%" 5 | rule=http: %remote_addr:word% %ident:word% %auth:word% [%timestamp:char-to:]%] "%method:word% %request:word% HTTP/%httpversion:float%" %status:number% %bytes_sent:number% "%referrer:char-to:"%" "%agent:char-to:"%"%blob:rest% 6 | rule=http: %remote_addr:word% %ident:word% %auth:word% [%timestamp:char-to:]%] "%method:word% %request:word% HTTP/%httpversion:float%" %status:number% %bytes_sent:number% "%referrer:char-to:"%" "%agent:char-to:"%" 7 | 8 | 9 | rule=json:%body:json% 10 | rule=json: %body:json% 11 | 12 | rule=log4j: [%levelname:char-to:]%] %message:rest% 13 | rule=log4j:[%levelname:char-to:]%] %message:rest% 14 | -------------------------------------------------------------------------------- /rek/custom-json-metrics/rsyslog/rsyslog.conf: -------------------------------------------------------------------------------- 1 | module(load="imuxsock" SysSock.Use="on") 2 | module(load="omriemann") 3 | module(load="mmnormalize") 4 | module(load="omstdout") 5 | module(load="mmjsonparse") 6 | 7 | input (type="imuxsock" Socket="/run/rsyslog/log.sock") 8 | 9 | lookup_table(name="log4j_level_to_severity" file="/etc/error-level.lookup") 10 | 11 | action(name="normalize" type="mmnormalize" ruleBase="/etc/rsyslog-http.rb") 12 | 13 | ruleset(name="metrics") { 14 | action(name="send-to-riemann" type="omriemann" subtree="!body!@fields!_riemann_metric" server="riemann" mode="single") 15 | } 16 | 17 | if ($parsesuccess == "OK") then { 18 | 19 | # If we have a log4j style log, convert it to our json structure 20 | if ($!event.tags contains "log4j") then { 21 | set $!body!@fields!levelname = $!levelname; 22 | set $!body!@message = $!message; 23 | } 24 | 25 | # If this is an http log, use the status code 26 | if ($!event.tags contains "http" and $!status >= 400) then { 27 | set $!severity = 3; 28 | set $!body!@fields!levelname = "error"; 29 | 30 | # If we have a json log with a level name, map it back to a severity 31 | } else if ($!body!@fields!levelname != "") then { 32 | set $!severity = cnum(lookup("log4j_level_to_severity", $!body!@fields!levelname)); 33 | 34 | # Otherwise, just copy the severity over so we have a consistent structure 35 | } else { 36 | set $!severity = $syslogseverity; 37 | set $!body!@fields!levelname = $syslogseverity-text; 38 | } 39 | 40 | # If we have an error that is not an HTTP log, send it to riemann 41 | if ($!severity <= 3 and not($!event.tags contains "http")) then { 42 | action( type="omriemann" 43 | server="riemann" 44 | prefix="errors" 45 | description="!body!@message") 46 | } 47 | 48 | # If we have a riemann metric field, send it to riemann 49 | if($!body!@fields!_riemann_metric != "") then { 50 | call metrics; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /rek/dynstats/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | volumes: 4 | rsyslog-dev: 5 | 6 | services: 7 | 8 | rsyslog: 9 | build: rsyslog 10 | volumes: 11 | - "rsyslog-dev:/dev" 12 | - "./rsyslog/rsyslog.conf:/etc/rsyslog.conf" 13 | 14 | dummy: 15 | build: dummy-logger 16 | volumes: 17 | - "rsyslog-dev:/dev" 18 | 19 | riemann: 20 | build: riemann 21 | ports: 22 | - "5555:5555" 23 | volumes: 24 | - "./riemann/riemann.config:/app/etc/riemann.config" 25 | 26 | influx: 27 | image: tutum/influx 28 | 29 | grafana: 30 | image: grafana:master 31 | 32 | nginx: 33 | build: nginx 34 | ports: 35 | - "80:80" 36 | volumes: 37 | - "rsyslog-dev:/dev" 38 | -------------------------------------------------------------------------------- /rek/dynstats/dummy-logger/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.8 2 | 3 | WORKDIR /go/src/app 4 | COPY . . 5 | 6 | RUN go-wrapper download # "go get -d -v ./..." 7 | RUN go-wrapper install # "go install -v ./..." 8 | 9 | ENTRYPOINT ["app"] 10 | -------------------------------------------------------------------------------- /rek/dynstats/dummy-logger/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "math/rand" 7 | "os" 8 | "os/signal" 9 | "syscall" 10 | "time" 11 | 12 | "github.com/blackjack/syslog" 13 | ) 14 | 15 | func main() { 16 | 17 | errorRate := flag.Float64("error-rate", 0.1, "The probability of logging an error expresed as a float between 0 and 1.0") 18 | tag := flag.String("tag", "dummy-logger", "The program name to use when tagging logs") 19 | interval := flag.Int("interval", 100, "The number of milliseconds to wait between sending logs") 20 | flag.Parse() 21 | 22 | sigs := make(chan os.Signal, 1) 23 | ticker := time.NewTicker(time.Millisecond * time.Duration(*interval)) 24 | done := make(chan bool, 1) 25 | 26 | signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) 27 | syslog.Openlog(*tag, syslog.LOG_PID, syslog.LOG_USER) 28 | 29 | fmt.Printf("Sending log lines every %dms, hit ctrl-c to stop\n", *interval) 30 | 31 | for { 32 | select { 33 | 34 | case <-sigs: 35 | ticker.Stop() 36 | done <- true 37 | 38 | case <-ticker.C: 39 | r := rand.Float64() 40 | if r <= *errorRate { 41 | syslog.Err("Oh noes!") 42 | } else { 43 | syslog.Info("Everything is fine!") 44 | } 45 | 46 | case <-done: 47 | return 48 | } 49 | } 50 | 51 | fmt.Println("Done!") 52 | 53 | } 54 | -------------------------------------------------------------------------------- /rek/dynstats/nginx/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx 2 | COPY nginx.conf /etc/nginx/nginx.conf 3 | EXPOSE 80 4 | -------------------------------------------------------------------------------- /rek/dynstats/nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | events { 2 | worker_connections 1024; 3 | } 4 | 5 | 6 | http { 7 | 8 | access_log syslog:server=unix:/dev/log,severity=info,nohostname combined; 9 | 10 | server { 11 | listen 80; 12 | 13 | location = /200 { 14 | return 200 "OK"; 15 | } 16 | 17 | location = /300 { 18 | rewrite .+ http://localhost/200; 19 | } 20 | 21 | location = /400 { 22 | return 404 "Not found"; 23 | } 24 | 25 | location = /500 { 26 | return 500 "Broke it"; 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /rek/dynstats/riemann/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM rlister/riemann 2 | COPY riemann.config etc/riemann.config 3 | -------------------------------------------------------------------------------- /rek/dynstats/riemann/riemann.config: -------------------------------------------------------------------------------- 1 | ; -*- mode: clojure; -*- 2 | ; vim: filetype=clojure 3 | 4 | (let [host "0.0.0.0"] 5 | (tcp-server {:host host}) 6 | 7 | ; Here's where we configure our metric handling 8 | (streams 9 | ; When a service starts with "error_rate" 10 | (where (service #"^error rate") 11 | 12 | ; print it 13 | #(info (:service %) "=" (:metric %)) 14 | )) 15 | 16 | 17 | (streams 18 | prn 19 | ) 20 | ) 21 | -------------------------------------------------------------------------------- /rek/dynstats/rsyslog/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM centos 2 | ADD http://rpms.adiscon.com/v8-stable/rsyslog.repo /etc/yum.repos.d 3 | ADD http://dl.fedoraproject.org/pub/epel/7/x86_64/e/epel-release-7-9.noarch.rpm /tmp/epel.rpm 4 | RUN rpm -ivh /tmp/epel.rpm \ 5 | && curl -s https://packagecloud.io/install/repositories/madecom/public/script.rpm.sh | bash \ 6 | && yum install -y rsyslog-8.27.0_experimental_-1.x86_64 7 | 8 | COPY rsyslog.conf /etc/rsyslog.conf 9 | COPY rsyslog-http.rb /etc/rsyslog-http.rb 10 | VOLUME ["/var/log", "/dev"] 11 | CMD ["rsyslogd", "-n"] 12 | -------------------------------------------------------------------------------- /rek/dynstats/rsyslog/rsyslog-http.rb: -------------------------------------------------------------------------------- 1 | version=2 2 | 3 | rule=http:%remote_addr:word% %ident:word% %auth:word% [%timestamp:char-to:]%] "%method:word% %request:word% HTTP/%httpversion:float%" %status:number% %bytes_sent:number% "%referrer:char-to:"%" "%agent:char-to:"%"%blob:rest% 4 | rule=http:%remote_addr:word% %ident:word% %auth:word% [%timestamp:char-to:]%] "%method:word% %request:word% HTTP/%httpversion:float%" %status:number% %bytes_sent:number% "%referrer:char-to:"%" "%agent:char-to:"%" 5 | rule=http: %remote_addr:word% %ident:word% %auth:word% [%timestamp:char-to:]%] "%method:word% %request:word% HTTP/%httpversion:float%" %status:number% %bytes_sent:number% "%referrer:char-to:"%" "%agent:char-to:"%"%blob:rest% 6 | rule=http: %remote_addr:word% %ident:word% %auth:word% [%timestamp:char-to:]%] "%method:word% %request:word% HTTP/%httpversion:float%" %status:number% %bytes_sent:number% "%referrer:char-to:"%" "%agent:char-to:"%" 7 | 8 | -------------------------------------------------------------------------------- /rek/dynstats/rsyslog/rsyslog.conf: -------------------------------------------------------------------------------- 1 | module(load="imuxsock" SysSock.Use="on") 2 | module(load="omriemann") 3 | module(load="mmjsonparse") 4 | module(load="mmnormalize") 5 | module(load="impstats" format="cee" interval="10" ruleset="stats") 6 | 7 | 8 | input (type="imuxsock" Socket="/dev/log") 9 | 10 | *.* /var/log/debugfmt;RSYSLOG_DebugFormat 11 | 12 | action(name="send-to-riemann" type="omriemann" prefix="foo" server="riemann" description="msg") 13 | 14 | 15 | # error_rate 16 | 17 | dyn_stats(name="error rate") 18 | 19 | if ($syslogseverity-text == "error") then { 20 | set $!foo = dyn_inc("error rate", $programname); 21 | } 22 | 23 | 24 | # http logs 25 | 26 | dyn_stats(name="http responses") 27 | 28 | action(name="normalize" type="mmnormalize" ruleBase="/etc/rsyslog-http.rb") 29 | 30 | if ($parsesuccess == "OK") then { 31 | set $!foo = dyn_inc("http responses", $!status); 32 | *.* /var/log/debugfmt;RSYSLOG_DebugFormat 33 | } 34 | --------------------------------------------------------------------------------