├── 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 |
--------------------------------------------------------------------------------