├── .flake8 ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── Makefile ├── README.md ├── docs └── layers.excalidraw.png ├── pyproject.toml ├── pytype.cfg ├── src └── blog │ ├── __init__.py │ ├── adapters │ ├── __init__.py │ ├── entrypoints │ │ ├── __init__.py │ │ └── app │ │ │ ├── __init__.py │ │ │ ├── application.py │ │ │ ├── blueprints │ │ │ ├── __init__.py │ │ │ ├── auth.py │ │ │ └── blog.py │ │ │ ├── schema.sql │ │ │ ├── static │ │ │ └── style.css │ │ │ └── templates │ │ │ ├── auth │ │ │ ├── login.html │ │ │ └── register.html │ │ │ ├── base.html │ │ │ └── post │ │ │ ├── create.html │ │ │ ├── index.html │ │ │ └── update.html │ ├── repositories │ │ ├── __init__.py │ │ ├── post.py │ │ └── user.py │ ├── services │ │ ├── __init__.py │ │ ├── post.py │ │ └── user.py │ └── unit_of_works │ │ ├── __init__.py │ │ ├── post.py │ │ └── user.py │ ├── configurator │ ├── __init__.py │ ├── config.py │ └── containers.py │ └── domain │ ├── __init__.py │ ├── model │ ├── __init__.py │ ├── model.py │ ├── schemas.py │ └── validators.py │ └── ports │ ├── __init__.py │ ├── repositories │ ├── __init__.py │ ├── exceptions.py │ ├── post.py │ └── user.py │ ├── services │ ├── __init__.py │ ├── post.py │ └── user.py │ └── unit_of_works │ ├── __init__.py │ ├── post.py │ └── user.py └── tests ├── __init__.py ├── conftest.py ├── domain ├── __init__.py ├── test_domain_models.py └── test_dtos.py ├── fake_container.py ├── fake_repositories.py ├── fake_uows.py ├── integration ├── __init__.py ├── test_flask.py ├── test_post_uow.py └── test_user_uow.py ├── repositories ├── __init__.py ├── test_post_repository.py └── test_user_repository.py └── unit_of_works ├── __init__.py ├── test_post_uow.py └── test_user_uow.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 100 3 | select = C,E,F,W,B,B9 4 | ignore = E203, E501, W503, C812, E731, F811, W605 5 | exclude = __init__.py,src/pyfilecurator/adapters/db 6 | extend-immutable-calls = Depends, fastapi.Depends, fastapi.params.Depends -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .venv/ 2 | .idea 3 | *.pyc 4 | __pycache__/ 5 | 6 | instance/ 7 | 8 | .pytest_cache/ 9 | .coverage 10 | htmlcov/ 11 | 12 | dist/ 13 | build/ 14 | *.egg-info/ 15 | hexagonal.db 16 | hexagonal_test.db 17 | hexagonal 18 | .vscode -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3.10 3 | 4 | default_stages: [commit, push] 5 | 6 | repos: 7 | - repo: https://github.com/MarcoGorelli/absolufy-imports 8 | rev: v0.3.1 9 | hooks: 10 | - id: absolufy-imports 11 | - repo: local 12 | hooks: 13 | - id: lint 14 | name: lint 15 | entry: make lint 16 | language: system 17 | types: [ python ] 18 | pass_filenames: false 19 | - id: secure 20 | name: secure 21 | entry: make secure 22 | language: system 23 | types: [python] 24 | pass_filenames: false 25 | - id: pytype 26 | name: pytype 27 | entry: make type-check 28 | language: system 29 | types: [python] 30 | pass_filenames: false 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 Shako Rzayev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ifdef OS 2 | PYTHON ?= .venv/Scripts/python.exe 3 | TYPE_CHECK_COMMAND ?= echo Pytype package doesn't support Windows OS 4 | else 5 | PYTHON ?= .venv/bin/python 6 | TYPE_CHECK_COMMAND ?= ${PYTHON} -m pytype --config=pytype.cfg src 7 | endif 8 | 9 | SETTINGS_FILENAME = pyproject.toml 10 | 11 | PHONY = help install install-dev build format lint type-check secure test install-flit enable-pre-commit-hooks run 12 | 13 | help: 14 | @echo "--------------- HELP ---------------" 15 | @echo "To install the project -> make install" 16 | @echo "To install the project using symlinks (for development) -> make install-dev" 17 | @echo "To build the wheel package -> make build" 18 | @echo "To test the project -> make test" 19 | @echo "To test with coverage [all tests] -> make test-cov" 20 | @echo "To format code -> make format" 21 | @echo "To check linter -> make lint" 22 | @echo "To run type checker -> make type-check" 23 | @echo "To run all security related commands -> make secure" 24 | @echo "------------------------------------" 25 | 26 | install: 27 | ${PYTHON} -m flit install --env --deps=production 28 | 29 | install-dev: 30 | ${PYTHON} -m flit install -s --env --deps=develop --symlink 31 | 32 | install-flit: 33 | ${PYTHON} -m pip install flit==3.8.0 34 | 35 | enable-pre-commit-hooks: 36 | ${PYTHON} -m pre_commit install 37 | 38 | build: 39 | ${PYTHON} -m flit build --format wheel 40 | ${PYTHON} -m pip install dist/*.whl 41 | ${PYTHON} -c 'import pyremoveme; print(pyremoveme.__version__)' 42 | 43 | format: 44 | ${PYTHON} -m isort src tests --force-single-line-imports --settings-file ${SETTINGS_FILENAME} 45 | ${PYTHON} -m autoflake --remove-all-unused-imports --recursive --remove-unused-variables --in-place src --exclude=__init__.py 46 | ${PYTHON} -m black src tests --config ${SETTINGS_FILENAME} 47 | ${PYTHON} -m isort src tests --settings-file ${SETTINGS_FILENAME} 48 | 49 | lint: 50 | ${PYTHON} -m flake8 --toml-config ${SETTINGS_FILENAME} --max-complexity 5 --max-cognitive-complexity=6 src 51 | ${PYTHON} -m black src tests --check --diff --config ${SETTINGS_FILENAME} 52 | ${PYTHON} -m isort src tests --check --diff --settings-file ${SETTINGS_FILENAME} 53 | 54 | type-check: 55 | @$(TYPE_CHECK_COMMAND) 56 | 57 | secure: 58 | ${PYTHON} -m bandit -r src --config ${SETTINGS_FILENAME} 59 | ${PYTHON} -m safety check 60 | pip-audit . 61 | 62 | test: 63 | ${PYTHON} -m pytest -svvv -m "not slow and not integration" tests 64 | 65 | test-slow: 66 | ${PYTHON} -m pytest -svvv -m "slow" tests 67 | 68 | test-integration: 69 | ${PYTHON} -m pytest -svvv -m "integration" tests 70 | 71 | test-cov: 72 | ${PYTHON} -m pytest -svvv --cov-report html --cov=src tests 73 | 74 | init-db: 75 | ${PYTHON} -m flask --app src.blog.adapters.entrypoints.app.application init-db 76 | 77 | run: 78 | ${PYTHON} -m flask --app src.blog.adapters.entrypoints.app.application --debug run --host 0.0.0.0 79 | 80 | ngrok: 81 | ngrok http --url=devfest.ngrok.dev 5000 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hexagonal Architecture with Flask and Dependency Injector 2 | 3 | ## FlaskCon 2023 walkthrough 4 | 5 | [YouTube - Learn Flask the hard way: Introduce Architecture Patterns](https://www.youtube.com/watch?v=wrtCo2fBoD0) 6 | 7 | ## This project is a complete-rewritten version of official Flask tutorial using Hexagonal Architecture 8 | 9 | [Flask Blog tutorial](https://flask.palletsprojects.com/en/2.2.x/tutorial/) 10 | 11 | [Flask Blog tutorial code base](https://github.com/pallets/flask/tree/main/examples/tutorial/flaskr) 12 | 13 | ### About project dependencies 14 | 15 | The project has 2 main dependencies: 16 | 17 | [Dependency Injector](https://github.com/ets-labs/python-dependency-injector) 18 | 19 | [Flask](https://github.com/pallets/flask) 20 | 21 | ### About Hexagonal Architecture 22 | 23 | You can read it from original author: 24 | 25 | [The Pattern: Ports and Adapters](https://alistair.cockburn.us/hexagonal-architecture/) 26 | 27 | ### Current implementation 28 | 29 | Layers: 30 | 31 | ![Layers](./docs/layers.excalidraw.png) 32 | 33 | Hexagonal View: 34 | 35 | TBD... 36 | 37 | ## How to install for development? 38 | 39 | Use virtualenv as: 40 | 41 | ```console 42 | python3 -m venv .venv 43 | source .venv/bin/activate 44 | ``` 45 | 46 | We use flit for the installation: 47 | 48 | * Install flit: 49 | 50 | ```console 51 | pip install flit==3.8.0 52 | ``` 53 | 54 | * Install using make command for development: 55 | 56 | ```console 57 | make install-dev 58 | ``` 59 | 60 | * Init the database 61 | 62 | ```console 63 | make init-db 64 | ``` 65 | 66 | * Start development service: 67 | 68 | ```console 69 | make run 70 | ``` 71 | 72 | ## Other commands 73 | 74 | * Format, sort the imports and also check the style 75 | 76 | ```console 77 | make format 78 | ``` 79 | 80 | * Run linter for final check 81 | 82 | ```console 83 | make lint 84 | ``` 85 | 86 | * Run tests all non-slow and non-integrated tests 87 | 88 | ```console 89 | make test 90 | ``` 91 | 92 | * Run slow tests 93 | 94 | ```console 95 | make test-slow 96 | ``` 97 | 98 | * Run integration tests 99 | 100 | ```console 101 | make test-integration 102 | ``` 103 | 104 | * Run test coverage 105 | 106 | ```console 107 | make test-cov 108 | ``` 109 | 110 | * Run type check 111 | 112 | ```console 113 | make type-check 114 | ``` 115 | 116 | * Run security check 117 | 118 | ```console 119 | make secure 120 | ``` 121 | -------------------------------------------------------------------------------- /docs/layers.excalidraw.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShahriyarR/hexagonal-flask-blog-tutorial/4c1e3a5ccecb8b959d46b93213c615c8e8beba74/docs/layers.excalidraw.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core==3.8.0"] 3 | build-backend = "flit_core.buildapi" 4 | 5 | [project] 6 | name = "blog" 7 | authors = [{name = "Shako Rzayev", email = "rzayev.sehriyar@gmail.com"}] 8 | readme = "README.md" 9 | license = {file = "LICENSE"} 10 | classifiers = ["License :: OSI Approved :: MIT License"] 11 | dynamic = ["version", "description"] 12 | dependencies = [ 13 | "dependency-injector >= 4.41.0", 14 | "flask >= 2.3.2", 15 | "pydantic >= 2.3.0", 16 | ] 17 | 18 | [project.urls] 19 | Home = "https://github.com/ShahriyarR/hexagonal-flask-blog-tutorial" 20 | 21 | [project.optional-dependencies] 22 | dev = [ 23 | "black", 24 | "isort", 25 | "autoflake", 26 | "pytype; platform_system != 'Windows'", 27 | "flake8", 28 | "Flake8-pyproject", 29 | "bandit", 30 | "flake8-bugbear", 31 | "flake8-cognitive-complexity", 32 | "pre-commit", 33 | "safety", 34 | "pip-audit", 35 | ] 36 | 37 | test = [ 38 | "pytest", 39 | "pytest-flask", 40 | "pytest-cov", 41 | ] 42 | 43 | [tool.isort] 44 | profile = "black" 45 | py_version = 310 46 | line_length = 88 47 | order_by_type = false 48 | skip = [".gitignore", ".dockerignore"] 49 | extend_skip = [".md", ".json"] 50 | skip_glob = ["docs/*"] 51 | 52 | [tool.black] 53 | line-length = 88 54 | target-version = ['py310'] 55 | include = '\.pyi?$' 56 | 57 | [tool.bandit] 58 | skips = ["B311"] 59 | 60 | [tool.flake8] 61 | max-line-length = 88 62 | select = ["C", "E", "F", "W", "B", "B9"] 63 | ignore = ["B907", "B902"] 64 | exclude = ["__init__.py"] 65 | 66 | 67 | [tool.pytest.ini_options] 68 | pythonpath = [ 69 | "src" 70 | ] 71 | markers = [ 72 | "slow: marks tests as slow (deselect with '-m \"not slow\"')", 73 | "integration: marks tests as integration relatively slow (deselect with '-m \"not integration\"')", 74 | "serial", 75 | ] 76 | addopts = [ 77 | "--strict-markers", 78 | "--strict-config", 79 | "-ra", 80 | ] 81 | testpaths = "tests" 82 | 83 | [tool.coverage.run] 84 | branch = true 85 | 86 | [tool.coverage.report] 87 | # Regexes for lines to exclude from consideration 88 | exclude_also = [ 89 | # Don't complain about missing debug-only code: 90 | "def __repr__", 91 | "if self\\.debug", 92 | 93 | # Don't complain if tests don't hit defensive assertion code: 94 | "raise AssertionError", 95 | "raise NotImplementedError", 96 | 97 | # Don't complain if non-runnable code isn't run: 98 | "if 0:", 99 | "if __name__ == .__main__.:", 100 | 101 | # Don't complain about abstract methods, they aren't run: 102 | "@(abc\\.)?abstractmethod", 103 | ] 104 | 105 | ignore_errors = true 106 | 107 | [tool.coverage.html] 108 | directory = "htmlcov" -------------------------------------------------------------------------------- /pytype.cfg: -------------------------------------------------------------------------------- 1 | # NOTE: All relative paths are relative to the location of this file. 2 | 3 | [pytype] 4 | 5 | # Space-separated list of files or directories to exclude. 6 | exclude = 7 | **/*_test.py 8 | **/test_*.py 9 | 10 | # Space-separated list of files or directories to process. 11 | inputs = 12 | . 13 | 14 | # Keep going past errors to analyze as many files as possible. 15 | keep_going = False 16 | 17 | # Run N jobs in parallel. When 'auto' is used, this will be equivalent to the 18 | # number of CPUs on the host system. 19 | jobs = 4 20 | 21 | # All pytype output goes here. 22 | output = .pytype 23 | 24 | # Platform (e.g., "linux", "win32") that the target code runs on. 25 | platform = linux 26 | 27 | # Paths to source code directories, separated by ':'. 28 | pythonpath = 29 | ./src 30 | 31 | # Python version (major.minor) of the target code. 32 | python_version = 3.10 33 | 34 | # Always use function return type annotations. This flag is temporary and will 35 | # be removed once this behavior is enabled by default. 36 | always_use_return_annotations = True 37 | 38 | # Enable parameter count checks for overriding methods. This flag is temporary 39 | # and will be removed once this behavior is enabled by default. 40 | overriding_parameter_count_checks = True 41 | 42 | # Enable return type checks for overriding methods. This flag is temporary and 43 | # will be removed once this behavior is enabled by default. 44 | overriding_return_type_checks = True 45 | 46 | # Use the enum overlay for more precise enum checking. This flag is temporary 47 | # and will be removed once this behavior is enabled by default. 48 | use_enum_overlay = True 49 | 50 | # Opt-in: Do not allow Any as a return type. 51 | no_return_any = False 52 | 53 | # Experimental: Support pyglib's @cached.property. 54 | enable_cached_property = True 55 | 56 | # Experimental: Infer precise return types even for invalid function calls. 57 | precise_return = True 58 | 59 | # Experimental: Solve unknown types to label with structural types. 60 | protocols = False 61 | 62 | # Experimental: Only load submodules that are explicitly imported. 63 | strict_import = True 64 | 65 | # Experimental: Enable exhaustive checking of function parameter types. 66 | strict_parameter_checks = True 67 | 68 | # Experimental: Emit errors for comparisons between incompatible primitive 69 | # types. 70 | strict_primitive_comparisons = True 71 | 72 | # Comma or space separated list of error names to ignore. 73 | disable = 74 | pyi-error 75 | 76 | # Don't report errors. 77 | report_errors = True 78 | -------------------------------------------------------------------------------- /src/blog/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Flask Blog application rewritten using Hexagonal Architecture and with enterprise patterns. 3 | """ 4 | 5 | __version__ = "0.0.1" 6 | -------------------------------------------------------------------------------- /src/blog/adapters/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShahriyarR/hexagonal-flask-blog-tutorial/4c1e3a5ccecb8b959d46b93213c615c8e8beba74/src/blog/adapters/__init__.py -------------------------------------------------------------------------------- /src/blog/adapters/entrypoints/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShahriyarR/hexagonal-flask-blog-tutorial/4c1e3a5ccecb8b959d46b93213c615c8e8beba74/src/blog/adapters/entrypoints/__init__.py -------------------------------------------------------------------------------- /src/blog/adapters/entrypoints/app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShahriyarR/hexagonal-flask-blog-tutorial/4c1e3a5ccecb8b959d46b93213c615c8e8beba74/src/blog/adapters/entrypoints/app/__init__.py -------------------------------------------------------------------------------- /src/blog/adapters/entrypoints/app/application.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | 3 | from blog.adapters.entrypoints.app.blueprints.auth import blueprint as user_blueprint 4 | from blog.adapters.entrypoints.app.blueprints.blog import blueprint as blog_blueprint 5 | from blog.configurator.config import init_app 6 | from blog.configurator.containers import Container 7 | 8 | 9 | def create_app() -> Flask: 10 | app = Flask(__name__) 11 | container = Container() 12 | app.secret_key = "dev" 13 | app.container = container 14 | with app.app_context(): 15 | init_app(app) 16 | app.register_blueprint(blog_blueprint) 17 | app.register_blueprint(user_blueprint) 18 | app.add_url_rule("/", endpoint="index") 19 | return app 20 | -------------------------------------------------------------------------------- /src/blog/adapters/entrypoints/app/blueprints/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShahriyarR/hexagonal-flask-blog-tutorial/4c1e3a5ccecb8b959d46b93213c615c8e8beba74/src/blog/adapters/entrypoints/app/blueprints/__init__.py -------------------------------------------------------------------------------- /src/blog/adapters/entrypoints/app/blueprints/auth.py: -------------------------------------------------------------------------------- 1 | import functools 2 | 3 | from dependency_injector.wiring import inject, Provide 4 | from flask import ( 5 | Blueprint, 6 | flash, 7 | g, 8 | redirect, 9 | render_template, 10 | request, 11 | session, 12 | url_for, 13 | ) 14 | from werkzeug.security import check_password_hash, generate_password_hash 15 | 16 | from blog.adapters.services.user import UserService 17 | from blog.domain.model.schemas import register_user_factory 18 | from blog.domain.ports.repositories.exceptions import UserDBOperationError 19 | 20 | blueprint = Blueprint("auth", __name__, url_prefix="/auth") 21 | 22 | 23 | def login_required(view): 24 | """View decorator that redirects anonymous users to the login page.""" 25 | 26 | @functools.wraps(view) 27 | def wrapped_view(**kwargs): 28 | if g.user is None: 29 | return redirect(url_for("auth.login")) 30 | 31 | return view(**kwargs) 32 | 33 | return wrapped_view 34 | 35 | 36 | @blueprint.before_app_request 37 | @inject 38 | def load_logged_in_user( 39 | user_service: UserService = Provide["user_service"], 40 | ): 41 | """If a user id is stored in the session, load the user object from 42 | the database into ``g.user``.""" 43 | user_id = session.get("user_id") 44 | 45 | if user_id is None: 46 | g.user = None 47 | else: 48 | g.user = user_service.get_user_by_uuid(user_id) 49 | 50 | 51 | @blueprint.route("/register", methods=("GET", "POST")) 52 | @inject 53 | def register(user_service: UserService = Provide["user_service"]): 54 | error = None 55 | if request.method == "POST": 56 | error, password, user_name = _check_user_name_password(error) 57 | user_ = register_user_factory(user_name=user_name, password=password) 58 | if not error: 59 | try: 60 | user_service.create(user_) 61 | except UserDBOperationError as err: 62 | error = f"Something went wrong with database operation {err}" 63 | else: 64 | return redirect(url_for("auth.login")) 65 | flash(error) 66 | 67 | return render_template("auth/register.html") 68 | 69 | 70 | def _check_user_name_password(error): 71 | user_name = request.form["username"] 72 | password = request.form["password"] 73 | if not user_name: 74 | error = "Username is required." 75 | elif not password: 76 | error = "Password is required." 77 | return error, password, user_name 78 | 79 | 80 | @blueprint.route("/login", methods=("GET", "POST")) 81 | @inject 82 | def login(user_service: UserService = Provide["user_service"]): 83 | error = None 84 | if request.method == "POST": 85 | error, user = _validate_user_name_and_password(error, user_service) 86 | if not error: 87 | session.clear() 88 | session["user_id"] = user["uuid"] 89 | return redirect(url_for("index")) 90 | 91 | flash(error) 92 | 93 | return render_template("auth/login.html") 94 | 95 | 96 | def _validate_user_name_and_password(error, user_service): 97 | user_name = request.form["username"] 98 | password = request.form["password"] 99 | user = user_service.get_user_by_user_name(user_name=user_name) 100 | if not user: 101 | error = "Incorrect username." 102 | elif not check_password_hash(user["password"], password): 103 | error = "Incorrect password." 104 | return error, user 105 | 106 | 107 | @blueprint.route("/logout") 108 | def logout(): 109 | """Clear the current session, including the stored user id.""" 110 | session.clear() 111 | return redirect(url_for("index")) 112 | -------------------------------------------------------------------------------- /src/blog/adapters/entrypoints/app/blueprints/blog.py: -------------------------------------------------------------------------------- 1 | from dependency_injector.wiring import inject, Provide 2 | from flask import Blueprint, flash, g, redirect, render_template, request, url_for 3 | 4 | from blog.domain.ports.repositories.exceptions import BlogDBOperationError 5 | from src.blog.adapters.entrypoints.app.blueprints.auth import login_required 6 | from src.blog.adapters.services.post import PostService 7 | from src.blog.domain.model.schemas import ( 8 | create_post_factory, 9 | delete_post_factory, 10 | update_post_factory, 11 | ) 12 | 13 | blueprint = Blueprint("post", __name__) 14 | 15 | 16 | @blueprint.route("/") 17 | @inject 18 | def index(post_service: PostService = Provide["post_service"]): 19 | posts = post_service.get_all_blogs() 20 | return render_template("post/index.html", posts=posts) 21 | 22 | 23 | @blueprint.route("/create", methods=("GET", "POST")) 24 | @login_required 25 | @inject 26 | def create(post_service: PostService = Provide["post_service"]): 27 | error = None 28 | if request.method == "POST": 29 | body, error, title = _validate_title_body(error) 30 | post = create_post_factory(author_id=g.user["uuid"], title=title, body=body) 31 | if not error: 32 | try: 33 | post_service.create(post) 34 | except BlogDBOperationError as err: 35 | error = f"Something went wrong with database operation {err}" 36 | else: 37 | return redirect(url_for("post.index")) 38 | flash(error) 39 | 40 | return render_template("post/create.html") 41 | 42 | 43 | def _validate_title_body(error): 44 | title = request.form["title"] 45 | body = request.form["body"] 46 | if not title: 47 | error = "Title is required." 48 | if not body: 49 | error = "Body is required." 50 | return body, error, title 51 | 52 | 53 | @blueprint.route("/update/", methods=("GET", "POST")) 54 | @login_required 55 | @inject 56 | def update(uuid, post_service: PostService = Provide["post_service"]): 57 | post = post_service.get_post_by_uuid(uuid) 58 | error = None 59 | if request.method == "POST": 60 | body, error, title = _validate_title_body(error) 61 | _post = update_post_factory(uuid=uuid, title=title, body=body) 62 | if not error: 63 | try: 64 | post_service.update(_post) 65 | except BlogDBOperationError as err: 66 | error = f"Something went wrong with database operation {err}" 67 | else: 68 | return redirect(url_for("post.index")) 69 | flash(error) 70 | 71 | return render_template("post/update.html", post=post) 72 | 73 | 74 | @blueprint.route("/delete/", methods=("POST",)) 75 | @login_required 76 | @inject 77 | def delete(uuid, post_service: PostService = Provide["post_service"]): 78 | post_service.get_post_by_uuid(uuid) 79 | _post = delete_post_factory(uuid=uuid) 80 | try: 81 | post_service.delete(_post) 82 | except BlogDBOperationError as err: 83 | error = f"Something went wrong with database operation {err}" 84 | else: 85 | return redirect(url_for("post.index")) 86 | flash(error) 87 | -------------------------------------------------------------------------------- /src/blog/adapters/entrypoints/app/schema.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS user; 2 | DROP TABLE IF EXISTS post; 3 | 4 | CREATE TABLE user ( 5 | id INTEGER PRIMARY KEY AUTOINCREMENT, 6 | uuid TEXT UNIQUE NOT NULL, 7 | username TEXT UNIQUE NOT NULL, 8 | password TEXT NOT NULL 9 | ); 10 | 11 | CREATE TABLE post ( 12 | id INTEGER PRIMARY KEY AUTOINCREMENT, 13 | uuid TEXT UNIQUE NOT NULL, 14 | author_id TEXT NOT NULL, 15 | created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 16 | title TEXT NOT NULL, 17 | body TEXT NOT NULL, 18 | FOREIGN KEY (author_id) REFERENCES user (uuid) 19 | ); -------------------------------------------------------------------------------- /src/blog/adapters/entrypoints/app/static/style.css: -------------------------------------------------------------------------------- 1 | html { 2 | font-family: sans-serif; 3 | background: #eee; 4 | padding: 1rem; 5 | } 6 | 7 | body { 8 | max-width: 960px; 9 | margin: 0 auto; 10 | background: white; 11 | } 12 | 13 | h1, h2, h3, h4, h5, h6 { 14 | font-family: serif; 15 | color: #377ba8; 16 | margin: 1rem 0; 17 | } 18 | 19 | a { 20 | color: #377ba8; 21 | } 22 | 23 | hr { 24 | border: none; 25 | border-top: 1px solid lightgray; 26 | } 27 | 28 | nav { 29 | background: lightgray; 30 | display: flex; 31 | align-items: center; 32 | padding: 0 0.5rem; 33 | } 34 | 35 | nav h1 { 36 | flex: auto; 37 | margin: 0; 38 | } 39 | 40 | nav h1 a { 41 | text-decoration: none; 42 | padding: 0.25rem 0.5rem; 43 | } 44 | 45 | nav ul { 46 | display: flex; 47 | list-style: none; 48 | margin: 0; 49 | padding: 0; 50 | } 51 | 52 | nav ul li a, nav ul li span, header .action { 53 | display: block; 54 | padding: 0.5rem; 55 | } 56 | 57 | .content { 58 | padding: 0 1rem 1rem; 59 | } 60 | 61 | .content > header { 62 | border-bottom: 1px solid lightgray; 63 | display: flex; 64 | align-items: flex-end; 65 | } 66 | 67 | .content > header h1 { 68 | flex: auto; 69 | margin: 1rem 0 0.25rem 0; 70 | } 71 | 72 | .flash { 73 | margin: 1em 0; 74 | padding: 1em; 75 | background: #cae6f6; 76 | border: 1px solid #377ba8; 77 | } 78 | 79 | .post > header { 80 | display: flex; 81 | align-items: flex-end; 82 | font-size: 0.85em; 83 | } 84 | 85 | .post > header > div:first-of-type { 86 | flex: auto; 87 | } 88 | 89 | .post > header h1 { 90 | font-size: 1.5em; 91 | margin-bottom: 0; 92 | } 93 | 94 | .post .about { 95 | color: slategray; 96 | font-style: italic; 97 | } 98 | 99 | .post .body { 100 | white-space: pre-line; 101 | } 102 | 103 | .content:last-child { 104 | margin-bottom: 0; 105 | } 106 | 107 | .content form { 108 | margin: 1em 0; 109 | display: flex; 110 | flex-direction: column; 111 | } 112 | 113 | .content label { 114 | font-weight: bold; 115 | margin-bottom: 0.5em; 116 | } 117 | 118 | .content input, .content textarea { 119 | margin-bottom: 1em; 120 | } 121 | 122 | .content textarea { 123 | min-height: 12em; 124 | resize: vertical; 125 | } 126 | 127 | input.danger { 128 | color: #cc2f2e; 129 | } 130 | 131 | input[type=submit] { 132 | align-self: start; 133 | min-width: 10em; 134 | } 135 | -------------------------------------------------------------------------------- /src/blog/adapters/entrypoints/app/templates/auth/login.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block header %} 4 |

{% block title %}Log In{% endblock %}

5 | {% endblock %} 6 | 7 | {% block content %} 8 |
9 | 10 | 11 | 12 | 13 | 14 |
15 | {% endblock %} -------------------------------------------------------------------------------- /src/blog/adapters/entrypoints/app/templates/auth/register.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block header %} 4 |

{% block title %}Register{% endblock %}

5 | {% endblock %} 6 | 7 | {% block content %} 8 |
9 | 10 | 11 | 12 | 13 | 14 |
15 | {% endblock %} -------------------------------------------------------------------------------- /src/blog/adapters/entrypoints/app/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | {% block title %}{% endblock %} - Flaskr 3 | 4 | 16 |
17 |
18 | {% block header %}{% endblock %} 19 |
20 | {% for message in get_flashed_messages() %} 21 |
{{ message }}
22 | {% endfor %} 23 | {% block content %}{% endblock %} 24 |
-------------------------------------------------------------------------------- /src/blog/adapters/entrypoints/app/templates/post/create.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block header %} 4 |

{% block title %}New Post{% endblock %}

5 | {% endblock %} 6 | 7 | {% block content %} 8 |
9 | 10 | 11 | 12 | 13 | 14 |
15 | {% endblock %} -------------------------------------------------------------------------------- /src/blog/adapters/entrypoints/app/templates/post/index.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block header %} 4 |

{% block title %}Posts{% endblock %}

5 | {% if g.user %} 6 | New 7 | {% endif %} 8 | {% endblock %} 9 | 10 | {% block content %} 11 | {% for post in posts %} 12 |
13 |
14 |
15 |

{{ post['title'] }}

16 |
by {{ post['username'] }} on {{ post['created'].strftime('%Y-%m-%d') }}
17 |
18 | {% if g.user['uuid'] == post['author_id'] %} 19 | Edit 20 | {% endif %} 21 |
22 |

{{ post['body'] }}

23 |
24 | {% if not loop.last %} 25 |
26 | {% endif %} 27 | {% endfor %} 28 | {% endblock %} -------------------------------------------------------------------------------- /src/blog/adapters/entrypoints/app/templates/post/update.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block header %} 4 |

{% block title %}Edit "{{ post['title'] }}"{% endblock %}

5 | {% endblock %} 6 | 7 | {% block content %} 8 |
9 | 10 | 11 | 12 | 13 | 14 |
15 |
16 |
17 | 18 |
19 | {% endblock %} -------------------------------------------------------------------------------- /src/blog/adapters/repositories/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShahriyarR/hexagonal-flask-blog-tutorial/4c1e3a5ccecb8b959d46b93213c615c8e8beba74/src/blog/adapters/repositories/__init__.py -------------------------------------------------------------------------------- /src/blog/adapters/repositories/post.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional 2 | 3 | from blog.domain.model import model 4 | from blog.domain.ports.repositories.exceptions import BlogDBOperationError 5 | from blog.domain.ports.repositories.post import PostRepositoryInterface 6 | 7 | 8 | class PostRepository(PostRepositoryInterface): 9 | def __init__(self, session) -> None: 10 | super().__init__() 11 | self.session = session 12 | 13 | def _add(self, post: model.Post) -> None: 14 | data = (post.uuid, post.title, post.body, post.author_id, post.created) 15 | query = ( 16 | "INSERT INTO post (uuid, title, body, author_id, created)" 17 | " VALUES (?, ?, ?, ?, ?)" 18 | ) 19 | try: 20 | self.execute(query, data) 21 | except Exception as err: 22 | raise BlogDBOperationError(err) from err 23 | 24 | def _update_by_uuid(self, uuid: str, title: str, body: str) -> model.Post: 25 | data = (title, body, uuid) 26 | query = """UPDATE post SET title = ?, body = ? WHERE uuid = ?""" 27 | try: 28 | return self.execute(query, data) 29 | except Exception as err: 30 | raise BlogDBOperationError(err) from err 31 | 32 | def _delete(self, uuid: str) -> None: 33 | data = (uuid,) 34 | query = "DELETE FROM post WHERE uuid = ?" 35 | try: 36 | self.execute(query, data) 37 | except Exception as err: 38 | raise BlogDBOperationError(err) from err 39 | 40 | def _get_all(self) -> Optional[list[model.Post]]: 41 | data = () 42 | query = """SELECT p.id, p.uuid, title, body, created, author_id, username 43 | FROM post p JOIN user u ON p.author_id = u.uuid 44 | ORDER BY created DESC""" 45 | try: 46 | return self.execute(query, data).fetchall() 47 | except Exception as err: 48 | raise BlogDBOperationError() from err 49 | 50 | def _get_by_uuid(self, uuid: str) -> model.Post: 51 | data = (uuid,) 52 | query = """SELECT p.id, p.uuid, title, body, created, author_id, username 53 | FROM post p JOIN user u ON p.author_id = u.uuid 54 | WHERE p.uuid = ?""" 55 | 56 | return self.execute(query, data).fetchone() 57 | 58 | def _execute(self, query: str, data: tuple[Any, ...]) -> Any: 59 | return self.session.execute(query, data) 60 | -------------------------------------------------------------------------------- /src/blog/adapters/repositories/user.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional 2 | 3 | from werkzeug.security import generate_password_hash 4 | 5 | from blog.domain.model import model 6 | from blog.domain.ports.repositories.exceptions import UserDBOperationError 7 | from blog.domain.ports.repositories.user import UserRepositoryInterface 8 | 9 | 10 | class UserRepository(UserRepositoryInterface): 11 | def __init__(self, session) -> None: 12 | super().__init__() 13 | self.session = session 14 | 15 | def _execute(self, query: str, data: tuple[Any, ...]) -> Any: 16 | return self.session.execute(query, data) 17 | 18 | def _add(self, user: model.User) -> None: 19 | data = (user.uuid, user.user_name, user.password) 20 | query = "INSERT INTO user (uuid, username, password) VALUES (?, ?, ?)" 21 | try: 22 | self.execute(query, data) 23 | except Exception as err: 24 | raise UserDBOperationError(err) from err 25 | 26 | def _get_user_by_user_name(self, user_name: str) -> Optional[model.User]: 27 | data = (user_name,) 28 | query = "SELECT * FROM user WHERE username = ?" 29 | try: 30 | return self.execute(query, data).fetchone() 31 | except Exception as err: 32 | raise UserDBOperationError() from err 33 | 34 | def _get_by_uuid(self, uuid: str) -> Optional[model.User]: 35 | data = (uuid,) 36 | query = "SELECT * FROM user WHERE uuid = ?" 37 | try: 38 | return self.execute(query, data).fetchone() 39 | except Exception as err: 40 | raise UserDBOperationError() from err 41 | 42 | def _get_all(self) -> list[model.User]: 43 | data = () 44 | query = "SELECT * FROM user" 45 | try: 46 | return self.execute(query, data).fetchall() 47 | except Exception as err: 48 | raise UserDBOperationError() from err 49 | -------------------------------------------------------------------------------- /src/blog/adapters/services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShahriyarR/hexagonal-flask-blog-tutorial/4c1e3a5ccecb8b959d46b93213c615c8e8beba74/src/blog/adapters/services/__init__.py -------------------------------------------------------------------------------- /src/blog/adapters/services/post.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from flask import abort, g 4 | 5 | from blog.domain.model import model 6 | from blog.domain.model.model import Post, post_factory 7 | from blog.domain.model.schemas import ( 8 | CreatePostInputDto, 9 | DeletePostInputDto, 10 | UpdatePostInputDto, 11 | ) 12 | from blog.domain.ports.services.post import PostServiceInterface 13 | from blog.domain.ports.unit_of_works.post import PostUnitOfWorkInterface 14 | 15 | 16 | class PostService(PostServiceInterface): 17 | def __init__(self, uow: PostUnitOfWorkInterface) -> None: 18 | self.uow = uow 19 | 20 | def _create(self, post: CreatePostInputDto) -> Optional[Post]: 21 | _post = post_factory( 22 | uuid=str(post.uuid), 23 | author_id=str(post.author_id), 24 | title=post.title, 25 | body=post.body, 26 | created=post.created, 27 | ) 28 | 29 | with self.uow: 30 | self.uow.post.add(_post) 31 | self.uow.commit() 32 | 33 | def _update(self, post: UpdatePostInputDto): 34 | with self.uow: 35 | self.uow.post.update_by_uuid(post.uuid, post.title, post.body) 36 | self.uow.commit() 37 | 38 | def _delete(self, post: DeletePostInputDto): 39 | with self.uow: 40 | self.uow.post.delete(post.uuid) 41 | self.uow.commit() 42 | 43 | def _get_all_blogs(self) -> Optional[list[model.Post]]: 44 | with self.uow: 45 | return self.uow.post.get_all() 46 | 47 | def _get_post_by_uuid(self, uuid: str, check_author: bool = True) -> Post: 48 | with self.uow: 49 | post = self.uow.post.get_by_uuid(uuid) 50 | if post is None: 51 | abort(404, f"Post uuid {uuid} doesn't exist.") 52 | if check_author and post["author_id"] != g.user["uuid"]: 53 | abort(403) 54 | return post 55 | -------------------------------------------------------------------------------- /src/blog/adapters/services/user.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from blog.domain.model.model import User, user_factory 4 | from blog.domain.model.schemas import RegisterUserInputDto, RegisterUserOutputDto 5 | from blog.domain.ports.services.user import UserServiceInterface 6 | from blog.domain.ports.unit_of_works.user import UserUnitOfWorkInterface 7 | 8 | 9 | class UserService(UserServiceInterface): 10 | def __init__(self, uow: UserUnitOfWorkInterface) -> None: 11 | self.uow = uow 12 | 13 | def _create(self, user: RegisterUserInputDto) -> RegisterUserOutputDto: 14 | user = user_factory( 15 | uuid=user.uuid, user_name=user.user_name, password=user.password 16 | ) 17 | with self.uow: 18 | self.uow.user.add(user) 19 | self.uow.commit() 20 | return RegisterUserOutputDto(uuid=user.uuid, user_name=user.user_name) 21 | 22 | def _get_user_by_user_name(self, user_name: str) -> Optional[User]: 23 | with self.uow: 24 | user = self.uow.user.get_user_by_user_name(user_name) 25 | return user 26 | 27 | def _get_user_by_uuid(self, uuid: str) -> Optional[User]: 28 | with self.uow: 29 | return self.uow.user.get_by_uuid(uuid) 30 | -------------------------------------------------------------------------------- /src/blog/adapters/unit_of_works/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShahriyarR/hexagonal-flask-blog-tutorial/4c1e3a5ccecb8b959d46b93213c615c8e8beba74/src/blog/adapters/unit_of_works/__init__.py -------------------------------------------------------------------------------- /src/blog/adapters/unit_of_works/post.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable 2 | 3 | from blog.adapters.repositories.post import PostRepository 4 | from blog.domain.ports.unit_of_works.post import PostUnitOfWorkInterface 5 | 6 | 7 | class PostUnitOfWork(PostUnitOfWorkInterface): 8 | def __init__(self, session_factory: Callable[[], Any]): 9 | self.session_factory = session_factory 10 | 11 | def __enter__(self): 12 | self.session = self.session_factory() 13 | self.post = PostRepository(self.session) 14 | return super().__enter__() 15 | 16 | def __exit__(self, *args): 17 | super().__exit__(*args) 18 | # self.session.close() 19 | 20 | def _commit(self): 21 | self.session.commit() 22 | 23 | def rollback(self): 24 | self.session.rollback() 25 | -------------------------------------------------------------------------------- /src/blog/adapters/unit_of_works/user.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable 2 | 3 | from blog.adapters.repositories.user import UserRepository 4 | from blog.domain.ports.unit_of_works.user import UserUnitOfWorkInterface 5 | 6 | 7 | class UserUnitOfWork(UserUnitOfWorkInterface): 8 | def __init__(self, session_factory: Callable[[], Any]): 9 | self.session_factory = session_factory 10 | 11 | def __enter__(self): 12 | self.session = self.session_factory() 13 | self.user = UserRepository(self.session) 14 | return super().__enter__() 15 | 16 | def __exit__(self, *args): 17 | super().__exit__(*args) 18 | # self.session.close() 19 | 20 | def _commit(self): 21 | self.session.commit() 22 | 23 | def rollback(self): 24 | self.session.rollback() 25 | -------------------------------------------------------------------------------- /src/blog/configurator/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShahriyarR/hexagonal-flask-blog-tutorial/4c1e3a5ccecb8b959d46b93213c615c8e8beba74/src/blog/configurator/__init__.py -------------------------------------------------------------------------------- /src/blog/configurator/config.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | from typing import Callable 3 | 4 | import click 5 | from flask import current_app 6 | 7 | 8 | def get_db() -> Callable[[], sqlite3.Connection]: 9 | db = sqlite3.connect( 10 | "hexagonal.db", detect_types=sqlite3.PARSE_DECLTYPES, check_same_thread=False 11 | ) 12 | 13 | db.row_factory = sqlite3.Row 14 | # Solution for -> TypeError: cannot pickle 'sqlite3.Connection' object 15 | return lambda: db 16 | 17 | 18 | def init_db(): 19 | db = get_db() 20 | 21 | with current_app.open_resource("schema.sql") as f: 22 | db().executescript(f.read().decode("utf8")) 23 | 24 | 25 | @click.command("init-db") 26 | def init_db_command(): 27 | """Clear the existing data and create new tables.""" 28 | init_db() 29 | click.echo("Initialized the database.") 30 | 31 | 32 | def close_db(db: Callable[[], sqlite3.Connection], e=None): 33 | if db is not None: 34 | db().close() 35 | 36 | 37 | def init_app(app): 38 | app.cli.add_command(init_db_command) 39 | -------------------------------------------------------------------------------- /src/blog/configurator/containers.py: -------------------------------------------------------------------------------- 1 | from dependency_injector import containers, providers 2 | 3 | from blog.adapters.services.post import PostService 4 | from blog.adapters.services.user import UserService 5 | from blog.adapters.unit_of_works.post import PostUnitOfWork 6 | from blog.adapters.unit_of_works.user import UserUnitOfWork 7 | from blog.configurator.config import get_db 8 | 9 | 10 | class Container(containers.DeclarativeContainer): 11 | wiring_config = containers.WiringConfiguration( 12 | packages=["blog.adapters.entrypoints.app.blueprints"] 13 | ) 14 | db_connection = get_db() 15 | 16 | post_uow = providers.Singleton(PostUnitOfWork, session_factory=db_connection) 17 | post_service = providers.Factory(PostService, uow=post_uow) 18 | 19 | user_uow = providers.Singleton(UserUnitOfWork, session_factory=db_connection) 20 | user_service = providers.Factory(UserService, uow=user_uow) 21 | -------------------------------------------------------------------------------- /src/blog/domain/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShahriyarR/hexagonal-flask-blog-tutorial/4c1e3a5ccecb8b959d46b93213c615c8e8beba74/src/blog/domain/__init__.py -------------------------------------------------------------------------------- /src/blog/domain/model/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/blog/domain/model/model.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from datetime import datetime 3 | 4 | from blog.domain.model.validators import valid_uuid 5 | 6 | 7 | @dataclass 8 | class Post: 9 | uuid: str 10 | author_id: str 11 | title: str 12 | body: str 13 | created: datetime 14 | 15 | def __eq__(self, other): 16 | if not isinstance(other, Post): 17 | return False 18 | return self.author_id == other.author_id and self.title == other.title 19 | 20 | def __hash__(self): 21 | return hash(self.author_id) 22 | 23 | def __str__(self): 24 | return f"Post('{self.title}')" 25 | 26 | 27 | def post_factory( 28 | uuid: str, author_id: str, title: str, body: str, created: datetime 29 | ) -> Post: 30 | # data validation should happen here 31 | if not valid_uuid(author_id): 32 | raise ValueError("Failed to verify if the string is valid UUID4") 33 | if not isinstance(created, datetime): 34 | raise TypeError("created should be a datetime type") 35 | if not body: 36 | raise ValueError("we do not accept empty body") 37 | if not title: 38 | raise ValueError("we do not accept empty title") 39 | 40 | return Post(uuid=uuid, author_id=author_id, title=title, body=body, created=created) 41 | 42 | 43 | @dataclass 44 | class User: 45 | uuid: str 46 | user_name: str 47 | password: str 48 | 49 | def __eq__(self, other): 50 | if not isinstance(other, User): 51 | return False 52 | return self.user_name == other.user_name 53 | 54 | def __hash__(self): 55 | if isinstance(self.uuid, str) and self.uuid: 56 | return hash(self.uuid) 57 | else: 58 | raise TypeError("uuid must not be empty or other type than str") 59 | 60 | def __str__(self): 61 | return f"User('{self.user_name}')" 62 | 63 | 64 | def user_factory(uuid: str, user_name: str, password: str) -> User: 65 | # data validation should happen here 66 | if not valid_uuid(uuid): 67 | raise ValueError("Failed to verify if the string is valid UUID4") 68 | if len(user_name) > 8: 69 | raise ValueError("User name should be maximum of 8 characters length") 70 | 71 | if not uuid or not user_name or not password: 72 | raise ValueError( 73 | "Mandatory fields of uuid, user_name and password can not be empty or None" 74 | ) 75 | 76 | return User(uuid=uuid, user_name=user_name, password=password) 77 | -------------------------------------------------------------------------------- /src/blog/domain/model/schemas.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from uuid import uuid4 3 | 4 | from pydantic import BaseModel, UUID4 5 | from pydantic.class_validators import validator 6 | from pydantic.fields import Field 7 | from werkzeug.security import generate_password_hash 8 | 9 | 10 | class RegisterUserInputDto(BaseModel): 11 | uuid: str 12 | user_name: str 13 | password: str 14 | 15 | 16 | class RegisterUserOutputDto(BaseModel): 17 | uuid: str 18 | user_name: str 19 | 20 | 21 | def register_user_factory(user_name: str, password: str) -> RegisterUserInputDto: 22 | # You can initialize uuid in factory or see below for pydantic usage 23 | return RegisterUserInputDto( 24 | uuid=str(uuid4()), 25 | user_name=user_name, 26 | password=generate_password_hash(password), 27 | ) 28 | 29 | 30 | class CreatePostInputDto(BaseModel): 31 | uuid: UUID4 = Field(default_factory=uuid4) 32 | author_id: UUID4 33 | title: str 34 | body: str 35 | created: datetime.datetime = Field(default_factory=datetime.datetime.now) 36 | 37 | # Possible place for custom validators, or it can be delegated to factory 38 | 39 | @validator("body") 40 | def body_length(cls, v): 41 | if len(v) > 10000: 42 | raise ValueError("Body length must be maximum of 10000 characters") 43 | return v 44 | 45 | @validator("title") 46 | def title_length(cls, v): 47 | if len(v) > 100: 48 | raise ValueError("Title length must be maximum of 100 characters") 49 | return v 50 | 51 | @validator("title", "body") 52 | def title_and_body_should_not_be_empty(cls, v): 53 | if not v: 54 | raise ValueError("Title and body must not be empty or None") 55 | return v 56 | 57 | 58 | def create_post_factory(title: str, body: str, author_id: UUID4) -> CreatePostInputDto: 59 | return CreatePostInputDto(title=title, body=body, author_id=author_id) 60 | 61 | 62 | class UpdatePostInputDto(BaseModel): 63 | uuid: str 64 | title: str 65 | body: str 66 | 67 | 68 | def update_post_factory(uuid: str, title: str, body: str) -> UpdatePostInputDto: 69 | return UpdatePostInputDto(uuid=uuid, title=title, body=body) 70 | 71 | 72 | class DeletePostInputDto(BaseModel): 73 | uuid: str 74 | 75 | 76 | def delete_post_factory(uuid: str) -> DeletePostInputDto: 77 | return DeletePostInputDto(uuid=uuid) 78 | -------------------------------------------------------------------------------- /src/blog/domain/model/validators.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | def valid_uuid(uuid): 5 | regex = re.compile( 6 | "^[a-f0-9]{8}-?[a-f0-9]{4}-?4[a-f0-9]{3}-?[89ab][a-f0-9]{3}-?[a-f0-9]{12}", 7 | re.I, 8 | ) 9 | match = regex.match(str(uuid)) 10 | return bool(match) 11 | -------------------------------------------------------------------------------- /src/blog/domain/ports/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShahriyarR/hexagonal-flask-blog-tutorial/4c1e3a5ccecb8b959d46b93213c615c8e8beba74/src/blog/domain/ports/__init__.py -------------------------------------------------------------------------------- /src/blog/domain/ports/repositories/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShahriyarR/hexagonal-flask-blog-tutorial/4c1e3a5ccecb8b959d46b93213c615c8e8beba74/src/blog/domain/ports/repositories/__init__.py -------------------------------------------------------------------------------- /src/blog/domain/ports/repositories/exceptions.py: -------------------------------------------------------------------------------- 1 | class BlogDBOperationError(Exception): 2 | ... 3 | 4 | 5 | class UserDBOperationError(Exception): 6 | ... 7 | -------------------------------------------------------------------------------- /src/blog/domain/ports/repositories/post.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Any, Optional 3 | 4 | from blog.domain.model import model 5 | 6 | 7 | class PostRepositoryInterface(ABC): 8 | def add(self, post: model.Post) -> None: 9 | self._add(post) 10 | 11 | def get_by_uuid(self, uuid: str) -> model.Post: 12 | return self._get_by_uuid(uuid) 13 | 14 | def get_all(self) -> Optional[list[model.Post]]: 15 | return self._get_all() 16 | 17 | def update_by_uuid(self, uuid: str, title: str, body: str) -> model.Post: 18 | return self._update_by_uuid(uuid, title, body) 19 | 20 | def delete(self, uuid: str) -> None: 21 | return self._delete(uuid) 22 | 23 | def execute(self, query: str, data: tuple[Any, ...]) -> Any: 24 | return self._execute(query, data) 25 | 26 | @abstractmethod 27 | def _add(self, post: model.Post) -> None: 28 | raise NotImplementedError 29 | 30 | @abstractmethod 31 | def _get_by_uuid(self, uuid: str) -> model.Post: 32 | raise NotImplementedError 33 | 34 | @abstractmethod 35 | def _get_all(self) -> Optional[list[model.Post]]: 36 | raise NotImplementedError 37 | 38 | @abstractmethod 39 | def _update_by_uuid(self, uuid: str, title: str, body: str) -> model.Post: 40 | raise NotImplementedError 41 | 42 | @abstractmethod 43 | def _delete(self, uuid: str) -> None: 44 | raise NotImplementedError 45 | 46 | @abstractmethod 47 | def _execute(self, query: str, data: tuple[Any, ...]) -> Any: 48 | raise NotImplementedError 49 | -------------------------------------------------------------------------------- /src/blog/domain/ports/repositories/user.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Any, Optional 3 | 4 | from blog.domain.model import model 5 | 6 | 7 | class UserRepositoryInterface(ABC): 8 | def add(self, user: model.User) -> None: 9 | self._add(user) 10 | 11 | def get_by_uuid(self, uuid: str) -> Optional[model.User]: 12 | return self._get_by_uuid(uuid) 13 | 14 | def get_user_by_user_name(self, user_name: str) -> Optional[model.User]: 15 | return self._get_user_by_user_name(user_name) 16 | 17 | def get_all(self) -> list[model.User]: 18 | return self._get_all() 19 | 20 | def execute(self, query: str, data: tuple[Any, ...]) -> Any: 21 | return self._execute(query, data) 22 | 23 | @abstractmethod 24 | def _add(self, post: model.User) -> None: 25 | raise NotImplementedError 26 | 27 | @abstractmethod 28 | def _get_by_uuid(self, uuid: str) -> Optional[model.User]: 29 | raise NotImplementedError 30 | 31 | @abstractmethod 32 | def _get_user_by_user_name(self, user_name: str) -> Optional[model.User]: 33 | raise NotImplementedError 34 | 35 | @abstractmethod 36 | def _get_all(self) -> list[model.User]: 37 | raise NotImplementedError 38 | 39 | @abstractmethod 40 | def _execute(self, query: str, data: tuple[Any, ...]) -> Any: 41 | raise NotImplementedError 42 | -------------------------------------------------------------------------------- /src/blog/domain/ports/services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShahriyarR/hexagonal-flask-blog-tutorial/4c1e3a5ccecb8b959d46b93213c615c8e8beba74/src/blog/domain/ports/services/__init__.py -------------------------------------------------------------------------------- /src/blog/domain/ports/services/post.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Any, Optional 3 | 4 | from blog.domain.model.model import Post 5 | from blog.domain.model.schemas import ( 6 | CreatePostInputDto, 7 | DeletePostInputDto, 8 | UpdatePostInputDto, 9 | ) 10 | from blog.domain.ports.unit_of_works.post import PostUnitOfWorkInterface 11 | 12 | 13 | class PostServiceInterface(ABC): 14 | @abstractmethod 15 | def __init__(self, uow: PostUnitOfWorkInterface) -> None: 16 | raise NotImplementedError 17 | 18 | def create(self, post: CreatePostInputDto) -> Optional[Post]: # pragma: no cover 19 | return self._create(post) 20 | 21 | def update(self, post: UpdatePostInputDto): # pragma: no cover 22 | return self._update(post) 23 | 24 | def delete(self, post: DeletePostInputDto): # pragma: no cover 25 | return self._delete(post) 26 | 27 | def get_all_blogs(self) -> Optional[list[Any]]: # pragma: no cover 28 | return self._get_all_blogs() 29 | 30 | def get_post_by_uuid( 31 | self, uuid: str, check_author: bool = True 32 | ) -> Post: # pragma: no cover 33 | return self._get_post_by_uuid(uuid, check_author) 34 | 35 | @abstractmethod 36 | def _create(self, post: CreatePostInputDto) -> Optional[Post]: 37 | raise NotImplementedError 38 | 39 | @abstractmethod 40 | def _update(self, post: UpdatePostInputDto): 41 | raise NotImplementedError 42 | 43 | @abstractmethod 44 | def _delete(self, post: DeletePostInputDto): 45 | raise NotImplementedError 46 | 47 | @abstractmethod 48 | def _get_all_blogs(self) -> Optional[list[Any]]: 49 | raise NotImplementedError 50 | 51 | @abstractmethod 52 | def _get_post_by_uuid(self, uuid: str, check_author: bool = True) -> Post: 53 | raise NotImplementedError 54 | -------------------------------------------------------------------------------- /src/blog/domain/ports/services/user.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Optional 3 | 4 | from blog.domain.model.model import User 5 | from blog.domain.model.schemas import RegisterUserInputDto, RegisterUserOutputDto 6 | from blog.domain.ports.unit_of_works.user import UserUnitOfWorkInterface 7 | 8 | 9 | class UserServiceInterface(ABC): 10 | @abstractmethod 11 | def __init__(self, uow: UserUnitOfWorkInterface) -> None: 12 | raise NotImplementedError 13 | 14 | def create(self, user: RegisterUserInputDto) -> RegisterUserOutputDto: 15 | return self._create(user) 16 | 17 | def get_user_by_user_name(self, user_name: str) -> Optional[User]: 18 | return self._get_user_by_user_name(user_name) 19 | 20 | def get_user_by_uuid(self, uuid: str) -> Optional[User]: 21 | return self._get_user_by_uuid(uuid) 22 | 23 | @abstractmethod 24 | def _create(self, user: RegisterUserInputDto) -> RegisterUserOutputDto: 25 | raise NotImplementedError 26 | 27 | @abstractmethod 28 | def _get_user_by_user_name(self, user_name: str) -> Optional[User]: 29 | raise NotImplementedError 30 | 31 | @abstractmethod 32 | def _get_user_by_uuid(self, uuid: str) -> Optional[User]: 33 | raise NotImplementedError 34 | -------------------------------------------------------------------------------- /src/blog/domain/ports/unit_of_works/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShahriyarR/hexagonal-flask-blog-tutorial/4c1e3a5ccecb8b959d46b93213c615c8e8beba74/src/blog/domain/ports/unit_of_works/__init__.py -------------------------------------------------------------------------------- /src/blog/domain/ports/unit_of_works/post.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from blog.domain.ports.repositories.post import PostRepositoryInterface 4 | 5 | 6 | class PostUnitOfWorkInterface(ABC): 7 | post: PostRepositoryInterface 8 | 9 | def __enter__(self) -> "PostUnitOfWorkInterface": 10 | return self 11 | 12 | def __exit__(self, exc_type, exc_val, traceback): 13 | if exc_type is not None: 14 | self.rollback() 15 | 16 | def commit(self): 17 | self._commit() 18 | 19 | @abstractmethod 20 | def _commit(self): 21 | raise NotImplementedError 22 | 23 | @abstractmethod 24 | def rollback(self): 25 | raise NotImplementedError 26 | -------------------------------------------------------------------------------- /src/blog/domain/ports/unit_of_works/user.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from blog.domain.ports.repositories.user import UserRepositoryInterface 4 | 5 | 6 | class UserUnitOfWorkInterface(ABC): 7 | user: UserRepositoryInterface 8 | 9 | def __enter__(self) -> "UserUnitOfWorkInterface": 10 | return self 11 | 12 | def __exit__(self, exc_type, exc_val, traceback): 13 | if exc_type is not None: 14 | self.rollback() 15 | 16 | def commit(self): 17 | self._commit() 18 | 19 | @abstractmethod 20 | def _commit(self): 21 | raise NotImplementedError 22 | 23 | @abstractmethod 24 | def rollback(self): 25 | raise NotImplementedError 26 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShahriyarR/hexagonal-flask-blog-tutorial/4c1e3a5ccecb8b959d46b93213c615c8e8beba74/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | 3 | import pytest 4 | 5 | from blog.adapters.entrypoints.app.application import create_app 6 | from blog.domain.model.model import post_factory, user_factory 7 | from blog.domain.model.schemas import create_post_factory, register_user_factory 8 | from tests.fake_container import _get_db, FakeContainer 9 | from tests.fake_repositories import FakePostRepository, FakeUserRepository 10 | from tests.fake_uows import FakePostUnitOfWork, FakeUserUnitOfWork 11 | 12 | 13 | def init_db(): 14 | db = _get_db() 15 | 16 | with open("src/blog/adapters/entrypoints/app/schema.sql") as f: 17 | db().executescript(f.read()) 18 | 19 | 20 | @pytest.fixture(scope="module") 21 | def get_fake_user_repository(): 22 | return FakeUserRepository() 23 | 24 | 25 | @pytest.fixture(scope="module") 26 | def get_fake_post_repository(): 27 | return FakePostRepository() 28 | 29 | 30 | @pytest.fixture(scope="module") 31 | def get_fake_user_uow(): 32 | return FakeUserUnitOfWork() 33 | 34 | 35 | @pytest.fixture(scope="module") 36 | def get_fake_post_uow(): 37 | return FakePostUnitOfWork() 38 | 39 | 40 | @pytest.fixture(scope="module") 41 | def get_user_model_object(): 42 | user_schema = register_user_factory(user_name="Shako", password="12345") 43 | return user_factory( 44 | uuid=user_schema.uuid, 45 | user_name=user_schema.user_name, 46 | password=user_schema.password, 47 | ) 48 | 49 | 50 | @pytest.fixture(scope="module") 51 | def get_post_model_object(): 52 | post_schema = create_post_factory( 53 | title="awesome title", body="mysterious body", author_id=uuid4() 54 | ) 55 | return post_factory( 56 | uuid=str(post_schema.uuid), 57 | created=post_schema.created, 58 | author_id=str(post_schema.author_id), 59 | title=post_schema.title, 60 | body=post_schema.body, 61 | ) 62 | 63 | 64 | @pytest.fixture(scope="module") 65 | def get_fake_container(): 66 | init_db() 67 | return FakeContainer() 68 | 69 | 70 | @pytest.fixture(scope="module") 71 | def get_flask_app(): 72 | app_ = create_app() 73 | app_.config.update( 74 | { 75 | "TESTING": True, 76 | } 77 | ) 78 | yield app_ 79 | app_.container.unwire() 80 | 81 | 82 | @pytest.fixture(scope="module") 83 | def get_flask_client(get_flask_app): 84 | return get_flask_app.test_client() 85 | -------------------------------------------------------------------------------- /tests/domain/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShahriyarR/hexagonal-flask-blog-tutorial/4c1e3a5ccecb8b959d46b93213c615c8e8beba74/tests/domain/__init__.py -------------------------------------------------------------------------------- /tests/domain/test_domain_models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from uuid import uuid4 3 | 4 | import pytest 5 | 6 | from blog.domain.model.model import Post, post_factory, User, user_factory 7 | 8 | 9 | # Tests that a Post object can be created with valid parameters. Tags: [happy path] 10 | def test_create_post_valid_parameters(): 11 | post = Post( 12 | uuid="1", 13 | author_id=123, 14 | title="Test Title", 15 | body="Test Body", 16 | created=datetime.now(), 17 | ) 18 | assert isinstance(post, Post) 19 | 20 | 21 | # Tests that two Post objects with the same author_id and title are equal. Tags: [happy path] 22 | def test_compare_same_author_id_and_title(): 23 | post1 = Post( 24 | uuid="1", 25 | author_id=123, 26 | title="Test Title", 27 | body="Test Body", 28 | created=datetime.now(), 29 | ) 30 | post2 = Post( 31 | uuid="2", 32 | author_id=123, 33 | title="Test Title", 34 | body="Test Body", 35 | created=datetime.now(), 36 | ) 37 | assert post1 == post2 38 | 39 | 40 | # Tests that two Post objects with different author_id and title are not equal. Tags: [edge case] 41 | def test_compare_different_author_id_and_title(): 42 | post1 = Post( 43 | uuid="1", 44 | author_id=123, 45 | title="Test Title", 46 | body="Test Body", 47 | created=datetime.now(), 48 | ) 49 | post2 = Post( 50 | uuid="2", 51 | author_id=456, 52 | title="Different Title", 53 | body="Different Body", 54 | created=datetime.now(), 55 | ) 56 | assert post1 != post2 57 | 58 | 59 | # Tests that a Post object cannot be compared with a non-Post object. Tags: [edge case] 60 | def test_compare_with_non_post_object(): 61 | post = Post( 62 | uuid="1", 63 | author_id=123, 64 | title="Test Title", 65 | body="Test Body", 66 | created=datetime.now(), 67 | ) 68 | assert post != "not a post object" 69 | 70 | 71 | # Tests the string representation of a Post object. Tags: [general behavior] 72 | def test_string_representation(): 73 | post = Post( 74 | uuid="1", 75 | author_id=123, 76 | title="Test Title", 77 | body="Test Body", 78 | created=datetime.now(), 79 | ) 80 | assert str(post) == "Post('Test Title')" 81 | 82 | 83 | # Tests that a Post object cannot be hashed with a non-integer author_id. Tags: [edge case] 84 | def test_hash_with_non_integer_author_id(): 85 | with pytest.raises(TypeError): 86 | post = Post( 87 | id_="1", 88 | author_id="not an integer", 89 | title="Test Title", 90 | body="Test Body", 91 | created=datetime.now(), 92 | ) 93 | hash(post) 94 | 95 | 96 | # Tests that the function returns a Post object with a unique id and created datetime when 97 | # valid input parameters are provided. 98 | # Tags: [happy path] 99 | def test_valid_input_parameters(): 100 | author_id = str(uuid4()) 101 | post = post_factory( 102 | str(uuid4()), author_id, "Test Title", "Test Body", datetime.now() 103 | ) 104 | assert isinstance(post, Post) 105 | assert post.author_id == author_id 106 | assert post.title == "Test Title" 107 | assert post.body == "Test Body" 108 | assert isinstance(post.created, datetime) 109 | assert post.uuid != "" 110 | 111 | 112 | # Tests that the function raises an error when an empty string is provided for the title parameter. Tags: [edge case] 113 | def test_empty_string_title(): 114 | with pytest.raises(ValueError): 115 | post_factory(uuid4(), 1, "", "Test Body", datetime.now()) 116 | 117 | 118 | # Tests that the function raises an error when an empty string is provided for the body parameter. Tags: [edge case] 119 | def test_empty_string_body(): 120 | with pytest.raises(ValueError): 121 | post_factory(uuid4(), 1, "Test Title", "", datetime.now()) 122 | 123 | 124 | # Tests that the function raises an error when a non-integer value is provided for the author_id parameter. 125 | # Tags: [edge case] 126 | def test_non_integer_author_id(): 127 | with pytest.raises(ValueError): 128 | post_factory( 129 | uuid4(), "not_an_integer", "Test Title", "Test Body", datetime.now() 130 | ) 131 | 132 | 133 | # Tests that the function raises an error when an invalid datetime format is provided for the created parameter. 134 | # Tags: [edge case] 135 | def test_invalid_datetime_format(): 136 | with pytest.raises(TypeError): 137 | post_factory( 138 | uuid4(), uuid4(), "Test Title", "Test Body", "invalid_datetime_format" 139 | ) 140 | 141 | 142 | # Tests that a User object can be created with valid uuid, user_name, and password. Tags: [happy path] 143 | def test_creating_user_object_with_valid_data(): 144 | user = User(uuid="1234", user_name="test_user", password="password") 145 | assert user.uuid == "1234" 146 | assert user.user_name == "test_user" 147 | assert user.password == "password" 148 | 149 | 150 | # Tests that two User objects with the same user_name are equal. Tags: [happy path] 151 | def test_comparing_two_user_objects_with_same_user_name(): 152 | user1 = User(uuid="1234", user_name="test_user", password="password") 153 | user2 = User(uuid="5678", user_name="test_user", password="password2") 154 | assert user1 == user2 155 | 156 | 157 | # Tests that a User object cannot be created with an empty uuid, user_name, or password. Tags: [edge case] 158 | def test_creating_user_object_with_empty_data(): 159 | with pytest.raises(ValueError): 160 | user_factory(uuid="", user_name="", password="") 161 | 162 | 163 | # Tests that a User object cannot be created with a uuid, user_name, or password that exceeds the maximum length. Tags: [edge case] 164 | def test_creating_user_object_with_exceeding_data(): 165 | with pytest.raises(ValueError): 166 | user_factory( 167 | uuid="12345678901234567890123456789012345678901234567890123456789012345", 168 | user_name="test_user", 169 | password="password", 170 | ) 171 | 172 | 173 | # Tests that hashing a User object with an empty uuid raises an error. Tags: [edge case] 174 | def test_hashing_user_object_with_empty_uuid(): 175 | user = User(uuid="", user_name="test_user", password="password") 176 | with pytest.raises(TypeError): 177 | hash(user) 178 | 179 | 180 | # Tests that comparing a User object with a non-User object returns False. Tags: [edge case] 181 | def test_comparing_user_object_with_non_user_object(): 182 | user = User(uuid="1234", user_name="test_user", password="password") 183 | assert user != "not a user object" 184 | 185 | 186 | # Tests that the function returns a User instance with valid input values for uuid, user_name, and password. Tags: [happy path] 187 | def test_invalid_uuid(): 188 | # Arrange 189 | uuid = "1234-5678" 190 | user_name = "testuser" 191 | password = "pass" 192 | 193 | # Act 194 | with pytest.raises(ValueError): 195 | _ = user_factory(uuid, user_name, password) 196 | 197 | 198 | # Tests that the function raises a ValueError when uuid has length greater than 16. Tags: [edge case] 199 | def test_long_uuid(): 200 | # Arrange 201 | uuid = "12345678901234567" 202 | user_name = "testuser" 203 | password = "pass" 204 | 205 | # Act & Assert 206 | with pytest.raises(ValueError): 207 | user_factory(uuid, user_name, password) 208 | 209 | 210 | # Tests that the function raises a ValueError when user_name has length greater than 8. Tags: [edge case] 211 | def test_long_user_name(): 212 | # Arrange 213 | uuid = "1234-5678" 214 | user_name = "testusername" 215 | password = "pass" 216 | 217 | # Act & Assert 218 | with pytest.raises(ValueError): 219 | user_factory(uuid, user_name, password) 220 | 221 | 222 | # Tests that the __str__ method of the User class returns the expected string representation. Tags: [general behavior] 223 | def test_str_representation(): 224 | # Arrange 225 | user = User(uuid="1234-5678", user_name="testuser", password="pass") 226 | 227 | # Act 228 | str_repr = str(user) 229 | 230 | # Assert 231 | assert str_repr == "User('testuser')" 232 | 233 | 234 | # Tests that the function raises a ValueError when password has length greater than 5. Tags: [edge case] 235 | def test_long_password(): 236 | # Arrange 237 | uuid = "1234-5678" 238 | user_name = "testuser" 239 | password = "password" 240 | 241 | # Act & Assert 242 | with pytest.raises(ValueError): 243 | user_factory(uuid, user_name, password) 244 | 245 | 246 | # Tests that the function raises a ValueError when uuid, user_name, or password is empty or None. Tags: [edge case] 247 | def test_empty_values(): 248 | # Arrange 249 | uuid = "" 250 | user_name = "testuser" 251 | password = "pass" 252 | 253 | # Act & Assert 254 | with pytest.raises(ValueError): 255 | user_factory(uuid, user_name, password) 256 | 257 | uuid = "1234-5678" 258 | user_name = "" 259 | password = "pass" 260 | 261 | # Act & Assert 262 | with pytest.raises(ValueError): 263 | user_factory(uuid, user_name, password) 264 | 265 | uuid = "1234-5678" 266 | user_name = "testuser" 267 | password = "" 268 | 269 | # Act & Assert 270 | with pytest.raises(ValueError): 271 | user_factory(uuid, user_name, password) 272 | -------------------------------------------------------------------------------- /tests/domain/test_dtos.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from uuid import uuid4 3 | 4 | import pytest 5 | 6 | from blog.domain.model.schemas import create_post_factory, CreatePostInputDto 7 | 8 | 9 | # Tests that valid input values are accepted and returned as expected. Tags: [happy path] 10 | def test_valid_input_accepted(): 11 | # Arrange 12 | input_data = { 13 | "uuid": uuid4(), 14 | "author_id": uuid4(), 15 | "title": "Test Title", 16 | "body": "Test Body", 17 | "created": datetime.datetime.now(), 18 | } 19 | expected_output = CreatePostInputDto(**input_data) 20 | 21 | # Act 22 | result = CreatePostInputDto(**input_data) 23 | 24 | # Assert 25 | assert result == expected_output 26 | 27 | 28 | # Tests that empty string values for title and body are accepted. Tags: [edge case] 29 | def test_empty_strings_are_not_accepted(): 30 | # Arrange 31 | input_data = {"author_id": uuid4(), "title": "", "body": ""} 32 | with pytest.raises(ValueError): 33 | _ = CreatePostInputDto(**input_data) 34 | 35 | 36 | # Tests that BaseModel validation errors are raised for invalid input values. Tags: [general behavior] 37 | def test_invalid_input_raises_error(): 38 | # Arrange 39 | input_data = {"author_id": "not an integer", "title": 123, "body": True} 40 | 41 | # Act & Assert 42 | with pytest.raises(ValueError): 43 | CreatePostInputDto(**input_data) 44 | 45 | 46 | # Tests that Unicode characters for title and body are accepted. Tags: [edge case] 47 | def test_unicode_characters_accepted(): 48 | # Arrange 49 | input_data = { 50 | "uuid": uuid4(), 51 | "author_id": uuid4(), 52 | "title": "Test Title with Unicode: こんにちは", 53 | "body": "Test Body with Unicode: 你好", 54 | "created": datetime.datetime.now(), 55 | } 56 | expected_output = CreatePostInputDto(**input_data) 57 | 58 | # Act 59 | result = CreatePostInputDto(**input_data) 60 | 61 | # Assert 62 | assert result == expected_output 63 | 64 | 65 | # Tests that BaseModel can be subclassed to add custom validators. Tags: [general behavior] 66 | def test_custom_validator(): 67 | # Arrange 68 | input_data = {"author_id": -1, "title": "Test Title", "body": "Test Body"} 69 | 70 | # Act & Assert 71 | with pytest.raises(ValueError): 72 | CreatePostInputDto(**input_data) 73 | 74 | 75 | # Tests that providing valid title, body, and author_id should return a CreatePostInputDto object. Tags: [happy path] 76 | def test_create_post_factory_valid_input(): 77 | # Arrange 78 | title = "Test Title" 79 | body = "Test Body" 80 | author_id = uuid4() 81 | 82 | # Act 83 | result = create_post_factory(title, body, author_id) 84 | 85 | # Assert 86 | assert isinstance(result, CreatePostInputDto) 87 | assert result.title == title 88 | assert result.body == body 89 | assert result.author_id == author_id 90 | 91 | 92 | # Tests that providing an empty string for title should raise a validation error. Tags: [edge case] 93 | def test_create_post_factory_empty_title(): 94 | # Arrange 95 | title = "" 96 | body = "Test Body" 97 | author_id = 1 98 | 99 | # Act & Assert 100 | with pytest.raises(ValueError): 101 | create_post_factory(title, body, author_id) 102 | 103 | 104 | # Tests that providing an empty string for body should raise a validation error. Tags: [edge case] 105 | def test_create_post_factory_empty_body(): 106 | # Arrange 107 | title = "Test Title" 108 | body = "" 109 | author_id = 1 110 | 111 | # Act & Assert 112 | with pytest.raises(ValueError): 113 | create_post_factory(title, body, author_id) 114 | 115 | 116 | # Tests that providing a title that exceeds the maximum length should raise a validation error. Tags: [general behavior] 117 | def test_create_post_factory_max_length_title(): 118 | # Arrange 119 | title = "a" * 201 120 | body = "Test Body" 121 | author_id = 1 122 | 123 | # Act & Assert 124 | with pytest.raises(ValueError): 125 | create_post_factory(title, body, author_id) 126 | 127 | 128 | # Tests that providing a negative value for author_id should raise a validation error. Tags: [edge case] 129 | def test_create_post_factory_negative_author_id(): 130 | # Arrange 131 | title = "Test Title" 132 | body = "Test Body" 133 | author_id = -1 134 | 135 | # Act & Assert 136 | with pytest.raises(ValueError): 137 | create_post_factory(title, body, author_id) 138 | 139 | 140 | # Tests that providing a body that exceeds the maximum length should raise a validation error. Tags: [general behavior] 141 | def test_create_post_factory_max_length_body(): 142 | # Arrange 143 | title = "Test Title" 144 | body = "a" * 10001 145 | author_id = 1 146 | 147 | # Act & Assert 148 | with pytest.raises(ValueError): 149 | create_post_factory(title, body, author_id) 150 | -------------------------------------------------------------------------------- /tests/fake_container.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | from typing import Callable 3 | 4 | from dependency_injector import containers, providers 5 | 6 | from blog.adapters.services.post import PostService 7 | from blog.adapters.services.user import UserService 8 | from blog.adapters.unit_of_works.post import PostUnitOfWork 9 | from blog.adapters.unit_of_works.user import UserUnitOfWork 10 | 11 | 12 | def _get_db() -> Callable[[], sqlite3.Connection]: 13 | db = sqlite3.connect( 14 | "hexagonal_test.db", 15 | detect_types=sqlite3.PARSE_DECLTYPES, 16 | check_same_thread=False, 17 | ) 18 | 19 | db.row_factory = sqlite3.Row 20 | # Solution for -> TypeError: cannot pickle 'sqlite3.Connection' object 21 | return lambda: db 22 | 23 | 24 | class FakeContainer(containers.DeclarativeContainer): 25 | wiring_config = containers.WiringConfiguration( 26 | packages=["blog.adapters.entrypoints.app.blueprints"] 27 | ) 28 | db_connection = _get_db() 29 | 30 | post_uow = providers.Singleton(PostUnitOfWork, session_factory=db_connection) 31 | post_service = providers.Factory(PostService, uow=post_uow) 32 | 33 | user_uow = providers.Singleton(UserUnitOfWork, session_factory=db_connection) 34 | user_service = providers.Factory(UserService, uow=user_uow) 35 | -------------------------------------------------------------------------------- /tests/fake_repositories.py: -------------------------------------------------------------------------------- 1 | import random 2 | from typing import Any 3 | 4 | from blog.domain.model import model 5 | from blog.domain.ports.repositories.post import PostRepositoryInterface 6 | from blog.domain.ports.repositories.user import UserRepositoryInterface 7 | 8 | 9 | class FakeUserRepository(UserRepositoryInterface): 10 | def __init__(self): 11 | super().__init__() 12 | self.database = {} 13 | 14 | def _add(self, user: model.User): 15 | id_ = random.randint(10, 100) 16 | self.database[id_] = user 17 | 18 | def _get_by_uuid(self, uuid: str) -> model.User: 19 | for val in self.database.values(): 20 | if val.uuid == uuid: 21 | return val 22 | 23 | def _get_user_by_user_name(self, user_name: str) -> model.User: 24 | for val in self.database.values(): 25 | if val.user_name == user_name: 26 | return val 27 | 28 | def _get_all(self) -> list[model.User]: 29 | return self.database 30 | 31 | def _execute(self, query: str, data: tuple[Any, ...]) -> Any: 32 | # We do not need the actual execute here 33 | pass 34 | 35 | 36 | class FakePostRepository(PostRepositoryInterface): 37 | def __init__(self): 38 | super().__init__() 39 | self.database = {} 40 | 41 | def _add(self, post: model.Post): 42 | id_ = random.randint(10, 100) 43 | self.database[id_] = post 44 | 45 | def _get_by_uuid(self, uuid: str) -> model.Post: 46 | for val in self.database.values(): 47 | if val.uuid == uuid: 48 | return val 49 | 50 | def _get_all(self) -> list[model.Post]: 51 | return self.database 52 | 53 | def _execute(self, query: str, data: tuple[Any, ...]) -> Any: 54 | # We do not need the actual execute here 55 | pass 56 | 57 | def _update_by_uuid(self, uuid: str, title: str, body: str) -> model.Post: 58 | for val in self.database.values(): 59 | if val.uuid == uuid: 60 | val.title = title 61 | val.body = body 62 | return val 63 | 64 | def _delete(self, uuid: str) -> None: 65 | found_key = None 66 | for key, val in self.database.items(): 67 | if val.uuid == uuid: 68 | found_key = key 69 | 70 | del self.database[found_key] 71 | -------------------------------------------------------------------------------- /tests/fake_uows.py: -------------------------------------------------------------------------------- 1 | from blog.domain.ports.unit_of_works.post import PostUnitOfWorkInterface 2 | from blog.domain.ports.unit_of_works.user import UserUnitOfWorkInterface 3 | from tests.fake_repositories import FakePostRepository, FakeUserRepository 4 | 5 | 6 | class FakeUserUnitOfWork(UserUnitOfWorkInterface): 7 | def __init__(self): 8 | self.committed = False 9 | 10 | def __enter__(self): 11 | self.user = FakeUserRepository() 12 | return super().__enter__() 13 | 14 | def __exit__(self, *args): 15 | super().__exit__(*args) 16 | 17 | def _commit(self): 18 | self.committed = True 19 | 20 | def rollback(self): 21 | # because we don't care 22 | pass 23 | 24 | 25 | class FakePostUnitOfWork(PostUnitOfWorkInterface): 26 | def __init__(self): 27 | self.committed = False 28 | 29 | def __enter__(self): 30 | self.post = FakePostRepository() 31 | return super().__enter__() 32 | 33 | def __exit__(self, *args): 34 | super().__exit__(*args) 35 | 36 | def _commit(self): 37 | self.committed = True 38 | 39 | def rollback(self): 40 | # because we don't care 41 | pass 42 | -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShahriyarR/hexagonal-flask-blog-tutorial/4c1e3a5ccecb8b959d46b93213c615c8e8beba74/tests/integration/__init__.py -------------------------------------------------------------------------------- /tests/integration/test_flask.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from flask import session 3 | 4 | 5 | @pytest.mark.integration 6 | def test_register_user(get_fake_container, get_flask_client, get_flask_app): 7 | fake_user_service = get_fake_container.user_service() 8 | user_uow = get_fake_container.user_uow() 9 | with get_flask_app.container.user_service.override(fake_user_service): 10 | response = get_flask_client.post( 11 | "/auth/register", 12 | follow_redirects=True, 13 | data={ 14 | "username": "Rauf", 15 | "password": "12345", 16 | }, 17 | ) 18 | assert len(response.history) == 1 19 | assert response.request.path == "/auth/login" 20 | with get_flask_client: 21 | response = get_flask_client.post( 22 | "/auth/login", 23 | follow_redirects=True, 24 | data={ 25 | "username": "Rauf", 26 | "password": "12345", 27 | }, 28 | ) 29 | 30 | assert len(response.history) == 1 31 | assert response.request.path == "/" 32 | 33 | with user_uow: 34 | users = user_uow.user.get_all() 35 | assert users[0]["username"] == "Rauf" 36 | uuid = users[0]["uuid"] 37 | assert session["user_id"] == uuid 38 | 39 | 40 | @pytest.mark.integration 41 | def test_create_blog_post(get_fake_container, get_flask_client, get_flask_app): 42 | fake_user_service = get_fake_container.user_service() 43 | fake_post_service = get_fake_container.post_service() 44 | post_uow = get_fake_container.post_uow() 45 | with get_flask_app.container.user_service.override(fake_user_service): 46 | with get_flask_app.container.post_service.override(fake_post_service): 47 | response = get_flask_client.post( 48 | "/create", 49 | follow_redirects=True, 50 | data={ 51 | "title": "test1", 52 | "body": "test2", 53 | }, 54 | ) 55 | assert response.status_code == 200 56 | assert len(response.history) == 1 57 | assert response.request.path == "/" 58 | 59 | with post_uow: 60 | posts = post_uow.post.get_all() 61 | assert posts[0]["title"] == "test1" 62 | -------------------------------------------------------------------------------- /tests/integration/test_post_uow.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | 3 | import pytest 4 | 5 | 6 | @pytest.mark.integration 7 | def test_add_post(get_fake_container, get_post_model_object, get_user_model_object): 8 | post_uow = get_fake_container.post_uow() 9 | user_uow = get_fake_container.user_uow() 10 | with user_uow: 11 | user_uow.user.add(get_user_model_object) 12 | user_uow.commit() 13 | users = user_uow.user.get_all() 14 | uuid_ = users[0]["uuid"] 15 | 16 | with post_uow: 17 | get_post_model_object.author_id = uuid_ 18 | post_uow.post.add(get_post_model_object) 19 | post_uow.commit() 20 | all_ = post_uow.post.get_all() 21 | assert len(all_) == 1 22 | assert all_[0]["title"] == "awesome title" 23 | 24 | 25 | @pytest.mark.integration 26 | def test_get_post_by_uuid(get_fake_container, get_post_model_object): 27 | post_uow = get_fake_container.post_uow() 28 | with post_uow: 29 | # post_uow.post.add(get_post_model_object) 30 | all_ = post_uow.post.get_all() 31 | # get the dictionary values 32 | uuid = all_[0]["uuid"] 33 | result = post_uow.post.get_by_uuid(uuid) 34 | assert result["title"] == "awesome title" 35 | 36 | 37 | @pytest.mark.integration 38 | def test_get_all_posts(get_fake_container, get_post_model_object): 39 | post_uow = get_fake_container.post_uow() 40 | with post_uow: 41 | all_ = post_uow.post.get_all() 42 | values = list(all_) 43 | assert len(values) == 1 44 | 45 | 46 | @pytest.mark.integration 47 | def test_update_by_uuid(get_fake_container, get_post_model_object): 48 | post_uow = get_fake_container.post_uow() 49 | with post_uow: 50 | all_ = post_uow.post.get_all() 51 | old_post = all_[0] 52 | uuid_ = old_post["uuid"] 53 | post_uow.post.update_by_uuid(uuid_, "awesome_title", "new_body") 54 | post_uow.commit() 55 | new_post = post_uow.post.get_by_uuid(uuid_) 56 | assert old_post["uuid"] == new_post["uuid"] 57 | assert old_post["title"] != new_post["title"] 58 | 59 | 60 | @pytest.mark.integration 61 | def test_delete_by_uuid(get_fake_container, get_post_model_object): 62 | post_uow = get_fake_container.post_uow() 63 | with post_uow: 64 | all_ = post_uow.post.get_all() 65 | post = all_[0] 66 | post_uow.post.delete(post["uuid"]) 67 | post_uow.commit() 68 | all_ = post_uow.post.get_all() 69 | assert not all_ 70 | -------------------------------------------------------------------------------- /tests/integration/test_user_uow.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.integration 5 | def test_add_user(get_fake_container, get_user_model_object): 6 | uow = get_fake_container.user_uow() 7 | with uow: 8 | uow.user.add(get_user_model_object) 9 | uow.commit() 10 | all_ = uow.user.get_all() 11 | # get the dictionary values 12 | assert len(all_) == 1 13 | assert all_[0]["username"] == "Shako" 14 | 15 | 16 | @pytest.mark.integration 17 | def test_get_user_by_uuid(get_fake_container, get_user_model_object): 18 | uow = get_fake_container.user_uow() 19 | with uow: 20 | all_ = uow.user.get_all() 21 | # get the dictionary values 22 | uuid = all_[0]["uuid"] 23 | result = uow.user.get_by_uuid(uuid) 24 | assert result["username"] == "Shako" 25 | 26 | 27 | @pytest.mark.integration 28 | def test_get_user_by_user_name(get_fake_container, get_user_model_object): 29 | uow = get_fake_container.user_uow() 30 | with uow: 31 | result = uow.user.get_user_by_user_name("Shako") 32 | assert result["username"] == "Shako" 33 | 34 | 35 | @pytest.mark.integration 36 | def test_get_all_users(get_fake_container, get_user_model_object): 37 | uow = get_fake_container.user_uow() 38 | with uow: 39 | all_ = uow.user.get_all() 40 | assert len(all_) == 1 41 | -------------------------------------------------------------------------------- /tests/repositories/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShahriyarR/hexagonal-flask-blog-tutorial/4c1e3a5ccecb8b959d46b93213c615c8e8beba74/tests/repositories/__init__.py -------------------------------------------------------------------------------- /tests/repositories/test_post_repository.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | 3 | 4 | def test_add_post(get_fake_post_repository, get_post_model_object): 5 | get_fake_post_repository.add(get_post_model_object) 6 | all_ = get_fake_post_repository.get_all() 7 | # get the dictionary values 8 | values = list(all_.values()) 9 | assert len(values) == 1 10 | assert values[0].title == "awesome title" 11 | 12 | 13 | def test_get_post_by_uuid(get_fake_post_repository): 14 | all_ = get_fake_post_repository.get_all() 15 | # get the dictionary values 16 | values = list(all_.values()) 17 | uuid = values[0].uuid 18 | result = get_fake_post_repository.get_by_uuid(uuid) 19 | assert result.title == "awesome title" 20 | 21 | 22 | def test_get_all_posts(get_fake_post_repository): 23 | all_ = get_fake_post_repository.get_all() 24 | values = list(all_.values()) 25 | assert len(values) == 1 26 | 27 | 28 | def test_update_by_uuid(get_fake_post_repository, get_post_model_object): 29 | all_ = get_fake_post_repository.get_all() 30 | values = list(all_.values()) 31 | post = deepcopy(values[0]) 32 | uuid_ = post.uuid 33 | new_post = get_fake_post_repository.update_by_uuid( 34 | uuid_, "awesome_title", "new_body" 35 | ) 36 | assert post.uuid == new_post.uuid 37 | assert post.title != new_post.title 38 | 39 | 40 | def test_delete_by_uuid(get_fake_post_repository, get_post_model_object): 41 | all_ = get_fake_post_repository.get_all() 42 | values = list(all_.values()) 43 | post = values[0] 44 | get_fake_post_repository.delete(post.uuid) 45 | all_ = get_fake_post_repository.get_all() 46 | values = list(all_.values()) 47 | assert not values 48 | -------------------------------------------------------------------------------- /tests/repositories/test_user_repository.py: -------------------------------------------------------------------------------- 1 | def test_add_user(get_fake_user_repository, get_user_model_object): 2 | get_fake_user_repository.add(get_user_model_object) 3 | all_ = get_fake_user_repository.get_all() 4 | # get the dictionary values 5 | values = list(all_.values()) 6 | assert len(values) == 1 7 | assert values[0].user_name == "Shako" 8 | 9 | 10 | def test_get_user_by_uuid(get_fake_user_repository): 11 | all_ = get_fake_user_repository.get_all() 12 | # get the dictionary values 13 | values = list(all_.values()) 14 | uuid = values[0].uuid 15 | result = get_fake_user_repository.get_by_uuid(uuid) 16 | assert result.user_name == "Shako" 17 | 18 | 19 | def test_get_user_by_user_name(get_fake_user_repository): 20 | result = get_fake_user_repository.get_user_by_user_name("Shako") 21 | assert result.user_name == "Shako" 22 | 23 | 24 | def test_get_all_users(get_fake_user_repository): 25 | all_ = get_fake_user_repository.get_all() 26 | values = list(all_.values()) 27 | assert len(values) == 1 28 | -------------------------------------------------------------------------------- /tests/unit_of_works/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShahriyarR/hexagonal-flask-blog-tutorial/4c1e3a5ccecb8b959d46b93213c615c8e8beba74/tests/unit_of_works/__init__.py -------------------------------------------------------------------------------- /tests/unit_of_works/test_post_uow.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | 3 | 4 | def test_add_post(get_fake_post_uow, get_post_model_object): 5 | with get_fake_post_uow: 6 | get_fake_post_uow.post.add(get_post_model_object) 7 | all_ = get_fake_post_uow.post.get_all() 8 | # get the dictionary values 9 | values = list(all_.values()) 10 | assert len(values) == 1 11 | assert values[0].title == "awesome title" 12 | 13 | 14 | def test_get_post_by_uuid(get_fake_post_uow, get_post_model_object): 15 | with get_fake_post_uow: 16 | get_fake_post_uow.post.add(get_post_model_object) 17 | all_ = get_fake_post_uow.post.get_all() 18 | # get the dictionary values 19 | values = list(all_.values()) 20 | uuid = values[0].uuid 21 | result = get_fake_post_uow.post.get_by_uuid(uuid) 22 | assert result.title == "awesome title" 23 | 24 | 25 | def test_get_all_posts(get_fake_post_uow, get_post_model_object): 26 | with get_fake_post_uow: 27 | get_fake_post_uow.post.add(get_post_model_object) 28 | all_ = get_fake_post_uow.post.get_all() 29 | values = list(all_.values()) 30 | assert len(values) == 1 31 | 32 | 33 | def test_update_by_uuid(get_fake_post_uow, get_post_model_object): 34 | with get_fake_post_uow: 35 | get_fake_post_uow.post.add(get_post_model_object) 36 | all_ = get_fake_post_uow.post.get_all() 37 | values = list(all_.values()) 38 | post = deepcopy(values[0]) 39 | uuid_ = post.uuid 40 | new_post = get_fake_post_uow.post.update_by_uuid( 41 | uuid_, "awesome_title", "new_body" 42 | ) 43 | assert post.uuid == new_post.uuid 44 | assert post.title != new_post.title 45 | 46 | 47 | def test_delete_by_uuid(get_fake_post_uow, get_post_model_object): 48 | with get_fake_post_uow: 49 | get_fake_post_uow.post.add(get_post_model_object) 50 | all_ = get_fake_post_uow.post.get_all() 51 | values = list(all_.values()) 52 | post = values[0] 53 | get_fake_post_uow.post.delete(post.uuid) 54 | all_ = get_fake_post_uow.post.get_all() 55 | values = list(all_.values()) 56 | assert not values 57 | -------------------------------------------------------------------------------- /tests/unit_of_works/test_user_uow.py: -------------------------------------------------------------------------------- 1 | def test_add_user(get_fake_user_uow, get_user_model_object): 2 | with get_fake_user_uow: 3 | get_fake_user_uow.user.add(get_user_model_object) 4 | all_ = get_fake_user_uow.user.get_all() 5 | # get the dictionary values 6 | values = list(all_.values()) 7 | assert len(values) == 1 8 | assert values[0].user_name == "Shako" 9 | 10 | 11 | def test_get_user_by_uuid(get_fake_user_uow, get_user_model_object): 12 | with get_fake_user_uow: 13 | get_fake_user_uow.user.add(get_user_model_object) 14 | all_ = get_fake_user_uow.user.get_all() 15 | # get the dictionary values 16 | values = list(all_.values()) 17 | uuid = values[0].uuid 18 | result = get_fake_user_uow.user.get_by_uuid(uuid) 19 | assert result.user_name == "Shako" 20 | 21 | 22 | def test_get_user_by_user_name(get_fake_user_uow, get_user_model_object): 23 | with get_fake_user_uow: 24 | get_fake_user_uow.user.add(get_user_model_object) 25 | result = get_fake_user_uow.user.get_user_by_user_name("Shako") 26 | assert result.user_name == "Shako" 27 | 28 | 29 | def test_get_all_users(get_fake_user_uow, get_user_model_object): 30 | with get_fake_user_uow: 31 | get_fake_user_uow.user.add(get_user_model_object) 32 | all_ = get_fake_user_uow.user.get_all() 33 | values = list(all_.values()) 34 | assert len(values) == 1 35 | --------------------------------------------------------------------------------