From b5ca5dd482e0c137e23a71bb50a9a983619a5d54 Mon Sep 17 00:00:00 2001 From: bnbong Date: Fri, 21 Feb 2025 23:09:25 +0900 Subject: [PATCH 1/5] [TEMPLATE] update README of fastapi-default template --- src/fastapi_fastkit/cli.py | 1 - .../fastapi-default/README.md-tpl | 18 +++++++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/fastapi_fastkit/cli.py b/src/fastapi_fastkit/cli.py index 55a6f0a..806e435 100644 --- a/src/fastapi_fastkit/cli.py +++ b/src/fastapi_fastkit/cli.py @@ -400,7 +400,6 @@ def runserver( ) -> None: """ Run the FastAPI server for the current project. - [1.1.0 update TODO] Alternative Point : using FastAPI-fastkit's 'fastapi dev' command :param ctx: Click context object :param host: Host address to bind the server to diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-default/README.md-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-default/README.md-tpl index 94deef2..608287a 100644 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-default/README.md-tpl +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-default/README.md-tpl @@ -4,7 +4,7 @@ Simple CRUD API application using FastAPI ## Features -- Item Creation, Read, Update, Delete (CRUD) functionality +- Item Creation, Read, Update, Delete (CRUD) functionality (For Demo) - Automatic OpenAPI documentation generation (Swagger UI, ReDoc) - Test environment with mock data @@ -12,14 +12,26 @@ Simple CRUD API application using FastAPI - Python 3.12+ - FastAPI + uvicorn -- pydantic & pydantic-settings +- pydantic 2 & pydantic-settings - pytest - mypy + black + isort ## How to run -at venv, run command below: +At venv, run command below: ```bash $ uvicorn src.main:app --reload ``` + +After running server instance, you can check API documentation below: + +```bash +/docs # Swagger format +/redoc # ReDoc format + +# example +http://127.0.0.1/docs +``` + +For other FastAPI guides, please refer From 4619d132bbcba38f097afb6409d4db6f98e47595 Mon Sep 17 00:00:00 2001 From: bnbong Date: Tue, 25 Feb 2025 18:32:28 +0900 Subject: [PATCH 2/5] [TEMPLATE] add fastapi-dockerized, fastapi-custom-response, fastapi-async-crud template, modified fastapi-default template --- src/fastapi_fastkit/__init__.py | 2 +- .../fastapi-async-crud/.env-tpl | 1 + .../fastapi-async-crud/.env.test-tpl | 3 - .../fastapi-async-crud/.gitignore-tpl | 217 +++--------------- .../fastapi-async-crud/README.md-tpl | 114 ++++++++- .../fastapi-async-crud/main.py-tpl | 21 -- .../fastapi-async-crud/requirements.txt-tpl | 65 +++--- .../script/run-server.sh-tpl | 2 - .../fastapi-async-crud/script/run-test.sh-tpl | 2 - .../fastapi-async-crud/scripts/format.sh-tpl | 5 + .../fastapi-async-crud/scripts/lint.sh-tpl | 5 + .../scripts/run-server.sh-tpl | 8 + .../fastapi-async-crud/scripts/test.sh-tpl | 6 + .../fastapi-async-crud/setup.cfg-tpl | 13 +- .../fastapi-async-crud/setup.py-tpl | 33 +-- .../fastapi-async-crud/src/__init__.py-tpl | 88 ------- .../src/{helper => api}/__init__.py-tpl | 0 .../fastapi-async-crud/src/api/api.py-tpl | 9 + .../src/{utils => api/routes}/__init__.py-tpl | 0 .../src/api/routes/items.py-tpl | 57 +++++ .../fastapi-async-crud/src/core/config.py-tpl | 64 ++++++ .../src/core/settings.py-tpl | 96 -------- .../fastapi-async-crud/src/crud/_base.py-tpl | 92 -------- .../fastapi-async-crud/src/crud/items.py-tpl | 21 ++ .../fastapi-async-crud/src/crud/user.py-tpl | 44 ---- .../src/helper/global_data.py-tpl | 33 --- .../src/helper/logging.py-tpl | 25 -- .../fastapi-async-crud/src/main.py-tpl | 26 +++ .../src/mocks/mock_items.json-tpl | 8 + .../src/mocks/mock_users.json-tpl | 17 -- .../src/router/__init__.py-tpl | 48 ---- .../fastapi-async-crud/src/router/user.py-tpl | 126 ---------- .../src/schemas/__init__.py-tpl | 48 ---- .../src/schemas/items.py-tpl | 22 ++ .../src/schemas/user.py-tpl | 81 ------- .../src/templates/index.html-tpl | 27 --- .../fastapi-async-crud/tests/conftest.py-tpl | 46 ++-- .../tests/routes/test_user.py-tpl | 112 --------- .../tests/test_items.py-tpl | 48 ++++ .../fastapi-custom-response/.env-tpl | 1 + .../fastapi-custom-response/.gitignore-tpl | 191 +++++++++++++++ .../fastapi-custom-response/README.md-tpl | 156 +++++++++++++ .../requirements.txt-tpl | 36 +++ .../script/format.sh-tpl | 5 + .../script/lint.sh-tpl | 5 + .../script/run-server.sh-tpl | 8 + .../script/test.sh-tpl | 6 + .../fastapi-custom-response/setup.cfg-tpl | 12 + .../fastapi-custom-response/setup.py-tpl | 35 +++ .../src}/__init__.py-tpl | 0 .../src/api/__init__.py-tpl} | 0 .../src/api/api.py-tpl | 9 + .../src/api/routes/__init__.py-tpl | 0 .../src/api/routes/items.py-tpl | 172 ++++++++++++++ .../src/core/__init__.py-tpl | 0 .../src/core/config.py-tpl | 64 ++++++ .../src/crud/__init__.py-tpl | 0 .../src/crud/items.py-tpl | 21 ++ .../src/helper/__init__.py-tpl | 0 .../src/helper/exceptions.py-tpl | 15 +- .../src/helper/pagination.py-tpl | 5 +- .../fastapi-custom-response/src/main.py-tpl | 39 ++++ .../src/mocks/__init__.py-tpl | 0 .../src/mocks/mock_items.json-tpl | 8 + .../src/schemas/__init__.py-tpl | 0 .../src/schemas/base.py-tpl | 49 ++++ .../src/schemas/items.py-tpl | 22 ++ .../src/utils/__init__.py-tpl | 0 .../src/utils/documents.py-tpl | 2 +- .../tests/__init__.py-tpl | 0 .../tests/conftest.py-tpl | 42 ++++ .../tests/test_items.py-tpl | 61 +++++ .../fastapi-default/README.md-tpl | 84 ++++++- .../fastapi-default/scripts/run-server.sh-tpl | 8 + .../fastapi-default/setup.py-tpl | 1 - .../src/mocks/mock_items.json-tpl | 4 +- .../fastapi-default/tests/conftest.py-tpl | 15 ++ .../fastapi-default/tests/test_items.py-tpl | 30 +-- .../fastapi-dockerized/.env-tpl | 1 + .../fastapi-dockerized/.gitignore-tpl | 30 +++ .../fastapi-dockerized/Dockerfile-tpl | 23 ++ .../fastapi-dockerized/README.md-tpl | 119 ++++++++++ .../fastapi-dockerized/requirements.txt-tpl | 36 +++ .../fastapi-dockerized/scripts/format.sh-tpl | 5 + .../fastapi-dockerized/scripts/lint.sh-tpl | 5 + .../scripts/run-server.sh-tpl | 8 + .../fastapi-dockerized/scripts/test.sh-tpl | 6 + .../fastapi-dockerized/setup.cfg-tpl | 13 ++ .../fastapi-dockerized/setup.py-tpl | 21 ++ .../fastapi-dockerized/src/__init__.py-tpl | 0 .../src/api/__init__.py-tpl | 0 .../fastapi-dockerized/src/api/api.py-tpl | 6 + .../src/api/routes/__init__.py-tpl | 0 .../src/api/routes/items.py-tpl | 54 +++++ .../src/core/__init__.py-tpl | 0 .../fastapi-dockerized/src/core/config.py-tpl | 64 ++++++ .../src/crud/__init__.py-tpl | 0 .../fastapi-dockerized/src/crud/items.py-tpl | 15 ++ .../fastapi-dockerized/src/main.py-tpl | 26 +++ .../src/mocks/__init__.py-tpl | 0 .../src/mocks/mock_items.json-tpl | 8 + .../src/schemas/__init__.py-tpl | 0 .../src/schemas/items.py-tpl | 19 ++ .../fastapi-dockerized/tests/__init__.py-tpl | 0 .../fastapi-dockerized/tests/conftest.py-tpl | 30 +++ .../tests/test_items.py-tpl | 44 ++++ .../test_templates/test_fastapi-async-crud.py | 0 .../test_fastapi-customized-response.py | 0 tests/test_templates/test_fastapi-default.py | 0 .../test_templates/test_fastapi-dockerized.py | 0 tests/test_templates/test_fastapi-psql-orm.py | 0 111 files changed, 2084 insertions(+), 1190 deletions(-) create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/.env-tpl delete mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/.env.test-tpl delete mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/main.py-tpl delete mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/script/run-server.sh-tpl delete mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/script/run-test.sh-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/scripts/format.sh-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/scripts/lint.sh-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/scripts/run-server.sh-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/scripts/test.sh-tpl rename src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/{helper => api}/__init__.py-tpl (100%) create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/api/api.py-tpl rename src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/{utils => api/routes}/__init__.py-tpl (100%) create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/api/routes/items.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/core/config.py-tpl delete mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/core/settings.py-tpl delete mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/crud/_base.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/crud/items.py-tpl delete mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/crud/user.py-tpl delete mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/helper/global_data.py-tpl delete mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/helper/logging.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/main.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/mocks/mock_items.json-tpl delete mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/mocks/mock_users.json-tpl delete mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/router/__init__.py-tpl delete mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/router/user.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/schemas/items.py-tpl delete mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/schemas/user.py-tpl delete mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/templates/index.html-tpl delete mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/tests/routes/test_user.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/tests/test_items.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/.env-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/.gitignore-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/README.md-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/requirements.txt-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/script/format.sh-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/script/lint.sh-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/script/run-server.sh-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/script/test.sh-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/setup.cfg-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/setup.py-tpl rename src/fastapi_fastkit/fastapi_project_template/{fastapi-async-crud/tests/routes => fastapi-custom-response/src}/__init__.py-tpl (100%) rename src/fastapi_fastkit/fastapi_project_template/{fastapi-customized-response/README.md-tpl => fastapi-custom-response/src/api/__init__.py-tpl} (100%) create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/api/api.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/api/routes/__init__.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/api/routes/items.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/core/__init__.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/core/config.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/crud/__init__.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/crud/items.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/helper/__init__.py-tpl rename src/fastapi_fastkit/fastapi_project_template/{fastapi-async-crud => fastapi-custom-response}/src/helper/exceptions.py-tpl (88%) rename src/fastapi_fastkit/fastapi_project_template/{fastapi-async-crud => fastapi-custom-response}/src/helper/pagination.py-tpl (95%) create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/main.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/mocks/__init__.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/mocks/mock_items.json-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/schemas/__init__.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/schemas/base.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/schemas/items.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/utils/__init__.py-tpl rename src/fastapi_fastkit/fastapi_project_template/{fastapi-async-crud => fastapi-custom-response}/src/utils/documents.py-tpl (88%) create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/tests/__init__.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/tests/conftest.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/tests/test_items.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-default/scripts/run-server.sh-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/.env-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/.gitignore-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/Dockerfile-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/requirements.txt-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/scripts/format.sh-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/scripts/lint.sh-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/scripts/run-server.sh-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/scripts/test.sh-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/setup.cfg-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/setup.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/__init__.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/api/__init__.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/api/api.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/api/routes/__init__.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/api/routes/items.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/core/__init__.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/core/config.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/crud/__init__.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/crud/items.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/main.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/mocks/__init__.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/mocks/mock_items.json-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/schemas/__init__.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/schemas/items.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/tests/__init__.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/tests/conftest.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/tests/test_items.py-tpl create mode 100644 tests/test_templates/test_fastapi-async-crud.py create mode 100644 tests/test_templates/test_fastapi-customized-response.py create mode 100644 tests/test_templates/test_fastapi-default.py create mode 100644 tests/test_templates/test_fastapi-dockerized.py create mode 100644 tests/test_templates/test_fastapi-psql-orm.py diff --git a/src/fastapi_fastkit/__init__.py b/src/fastapi_fastkit/__init__.py index d319e25..177bd01 100644 --- a/src/fastapi_fastkit/__init__.py +++ b/src/fastapi_fastkit/__init__.py @@ -1,4 +1,4 @@ -__version__ = "0.1.2" +__version__ = "0.1.3" import os diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/.env-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/.env-tpl new file mode 100644 index 0000000..c9d6bec --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/.env-tpl @@ -0,0 +1 @@ +SECRET_KEY=changethis diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/.env.test-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/.env.test-tpl deleted file mode 100644 index bd05c30..0000000 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/.env.test-tpl +++ /dev/null @@ -1,3 +0,0 @@ -# SECRET_KEY for testing (random string) -SERVER_DOMAIN=localhost -SECRET_KEY=jhnsavNAjHSMx0YBvL92wbM9K8FJEVDN diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/.gitignore-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/.gitignore-tpl index b1016d2..ef6364a 100644 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/.gitignore-tpl +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/.gitignore-tpl @@ -1,191 +1,30 @@ -### venv template -# Virtualenv -# http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ -.Python -[Bb]in -[Ii]nclude -[Ll]ib -[Ll]ib64 -[Ll]ocal -[Ss]cripts -pyvenv.cfg -.venv -pip-selfcheck.json - -### dotenv template -.env - -### VirtualEnv template -# Virtualenv -# http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ -.Python -[Bb]in -[Ii]nclude -[Ll]ib -[Ll]ib64 -[Ll]ocal -pyvenv.cfg -.venv -pip-selfcheck.json - -### Python template -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook +.idea .ipynb_checkpoints +.mypy_cache +.vscode +__pycache__ +.pytest_cache +htmlcov +dist +site +.coverage* +coverage.xml +.netlify +test.db +log.txt +Pipfile.lock +env3.* +env +docs_build +site_build +venv +docs.zip +archive.zip + +# vim temporary files +*~ +.*.sw? +.cache -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -# poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/#use-with-ide -.pdm.toml - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -.idea/ +# macOS +.DS_Store diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/README.md-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/README.md-tpl index f150a8c..e5147e9 100644 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/README.md-tpl +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/README.md-tpl @@ -1,17 +1,107 @@ -# Simple FastAPI Project +# Async Item Management API Server -This is a test project using FastAPI. It is a RESTful API server with user management functionality. +A FastAPI-based item management API implemented with asynchronous processing. -## Features +## Tech Stack -- User Creation, Read, Update, Delete (CRUD) functionality -- Automatic OpenAPI documentation generation (Swagger UI, ReDoc) -- Custom exception handling -- Test environment with mock data +- Python 3.12+ +- FastAPI + uvicorn +- Pydantic 2 & pydantic-settings +- pytest, pytest-asyncio +- mypy + black + isort +- aiofiles (async file handling) -## Stack +## Project Structure -- Python 3.11+ -- FastAPI 0.111.1 -- Pydantic 2.8.2 -- pytest 8.2.2 +``` +. +├── README.md +├── requirements.txt +├── setup.py +├── scripts +│   └── run-server.sh +├── src +│   ├── main.py +│   ├── schemas +│   │   └── items.py +│   ├── mocks +│   │   └── mock_items.json +│   ├── crud +│   │   └── items.py +│   ├── core +│   │   └── config.py +│   └── api +│   ├── api.py +│   └── routes +│   └── items.py +└── tests + ├── __init__.py + ├── test_items.py + └── conftest.py +``` + +## How to Run + +Run in virtual environment: + +```bash +# Install dependencies +$ pip install -r requirements.txt + +# Start development server (using script) +$ bash scripts/run-server.sh + +# Manual run +$ uvicorn src.main:app --reload +``` + +Access API documentation after server starts: + +```bash +/docs # Swagger format +/redoc # ReDoc format + +# example +http://127.0.0.1:8000/docs +``` + +## API Endpoints + +| Method | Endpoint | Description | +|--------|------------------|----------------------------| +| GET | `/items/` | List all items | +| GET | `/items/{id}` | Get single item | +| POST | `/items/` | Create new item | +| PUT | `/items/{id}` | Update existing item | +| DELETE | `/items/{id}` | Delete item | + +## Key Features + +- Async file I/O operations using aiofiles +- Mock data initialization +- Pydantic model validation +- CRUD operations implementation +- Integrated test cases +- In-memory JSON database for development + +## Running Tests + +```bash +# Run all tests +$ pytest tests/ + +# Run specific test file +$ pytest tests/test_items.py -v +``` + +For FastAPI documentation: + +## Project Origin + +This project was created using the [FastAPI-fastkit](https://github.com/bnbong/FastAPI-fastkit) template. + +FastAPI-fastkit is an open-source project that helps developers quickly set up FastAPI-based applications with proper structure and tooling. + +### Template Information +- Template author: [bnbong](mailto:bbbong9@gmail.com) +- Project maintainer: [bnbong](mailto:bbbong9@gmail.com) diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/main.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/main.py-tpl deleted file mode 100644 index e130afa..0000000 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/main.py-tpl +++ /dev/null @@ -1,21 +0,0 @@ -# -------------------------------------------------------------------------- -# Application's main routine -# -------------------------------------------------------------------------- -from __future__ import annotations - -import uvicorn - -from src import create_app, init_logger -from src.core.settings import settings - -init_logger(settings) - -app = create_app(settings, app_title, app_description) - -if __name__ == "__main__": - uvicorn.run( - "main:app", - host=settings.SERVER_DOMAIN, - port=settings.SERVER_PORT, - reload=True, - ) diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/requirements.txt-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/requirements.txt-tpl index d60952d..97bb5eb 100644 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/requirements.txt-tpl +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/requirements.txt-tpl @@ -1,47 +1,36 @@ annotated-types==0.7.0 -anyio==4.4.0 -black==24.4.2 -certifi==2024.7.4 -click==8.1.7 -dnspython==2.6.1 -email_validator==2.2.0 -fastapi==0.111.1 -fastapi-cli==0.0.4 +anyio==4.8.0 +black==25.1.0 +certifi==2025.1.31 +click==8.1.8 +fastapi==0.115.8 h11==0.14.0 -httpcore==1.0.5 -httptools==0.6.1 -httpx==0.27.0 -idna==3.7 +httpcore==1.0.7 +httptools==0.6.4 +httpx==0.28.1 +idna==3.10 iniconfig==2.0.0 -Jinja2==3.1.4 -markdown-it-py==3.0.0 -MarkupSafe==2.1.5 -mdurl==0.1.2 +isort==6.0.0 +mypy==1.15.0 mypy-extensions==1.0.0 -packaging==24.1 +packaging==24.2 pathspec==0.12.1 -platformdirs==4.2.2 +platformdirs==4.3.6 pluggy==1.5.0 -pydantic==2.8.2 -pydantic-settings==2.3.4 -pydantic_core==2.20.1 -Pygments==2.18.0 -pytest==8.2.2 -pytest-asyncio==0.23.8 -pytest-timeout==2.3.1 +pydantic==2.10.6 +pydantic-settings==2.7.1 +pydantic_core==2.27.2 +pytest==8.3.4 +pytest-asyncio==0.25.3 python-dotenv==1.0.1 -python-multipart==0.0.9 -PyYAML==6.0.1 -rich==13.7.1 -setuptools-scm==8.1.0 -shellingham==1.5.4 +PyYAML==6.0.2 +setuptools==75.8.0 sniffio==1.3.1 -starlette==0.37.2 -typer==0.12.3 +SQLAlchemy==2.0.38 +starlette==0.45.3 typing_extensions==4.12.2 -uvicorn==0.30.1 -uvloop==0.19.0 -watchfiles==0.22.0 -websockets==12.0 -SQLAlchemy==2.0.36 -sqlmodel==0.0.22 +uvicorn==0.34.0 +uvloop==0.21.0 +watchfiles==1.0.4 +websockets==15.0 +aiofiles>=23.2.1 diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/script/run-server.sh-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/script/run-server.sh-tpl deleted file mode 100644 index 827c72c..0000000 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/script/run-server.sh-tpl +++ /dev/null @@ -1,2 +0,0 @@ -# run test server -fastapi dev main.py --reload diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/script/run-test.sh-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/script/run-test.sh-tpl deleted file mode 100644 index d8794a6..0000000 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/script/run-test.sh-tpl +++ /dev/null @@ -1,2 +0,0 @@ -# run pytest -poetry run pytest ../test -s -v diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/scripts/format.sh-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/scripts/format.sh-tpl new file mode 100644 index 0000000..abdd14e --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/scripts/format.sh-tpl @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -x + +black . +isort . diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/scripts/lint.sh-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/scripts/lint.sh-tpl new file mode 100644 index 0000000..08e929f --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/scripts/lint.sh-tpl @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -x + +black . --check +mypy src diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/scripts/run-server.sh-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/scripts/run-server.sh-tpl new file mode 100644 index 0000000..eb760a2 --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/scripts/run-server.sh-tpl @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -e +set -x + +source .venv/bin/activate + +uvicorn src.main:app --reload diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/scripts/test.sh-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/scripts/test.sh-tpl new file mode 100644 index 0000000..048272d --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/scripts/test.sh-tpl @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +set -e +set -x + +pytest diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/setup.cfg-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/setup.cfg-tpl index 07a20db..5bd6e8f 100644 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/setup.cfg-tpl +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/setup.cfg-tpl @@ -1,13 +1,14 @@ -[flake8] -ignore = E501, E305 +[mypy] +warn_unused_configs = true +ignore_missing_imports = true [isort] +profile = black line_length=100 virtual_env=venv [tool:pytest] -pythonpath = src -testpaths = test -python_files = test_*.py +testpaths = tests +addopts = -ra -q asyncio_mode = auto -timeout = 3 +asyncio_default_fixture_loop_scope=session diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/setup.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/setup.py-tpl index ac25630..8338625 100644 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/setup.py-tpl +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/setup.py-tpl @@ -1,35 +1,22 @@ # -------------------------------------------------------------------------- -# module description here +# package setup module # -------------------------------------------------------------------------- -from setuptools import setup, find_packages +from setuptools import find_packages, setup -install_requires = [ - # Main Application Dependencies - "fastapi==0.111.1", - "uvicorn==0.30.1", - "httpx==0.27.0", - "jinja2==3.1.2", - # ORM Dependencies - "pydantic==2.8.2", - "pydantic_core==2.20.1", - "pydantic-settings==2.3.4", - # Utility Dependencies - "starlette==0.37.2", - "typing_extensions==4.12.2", - "watchfiles==0.22.0", - "pytest==8.2.2", - "pytest-asyncio==0.23.8", - "FastAPI-fastkit", +install_requires: list[str] = [ + "fastapi", + "pydantic", + "pydantic-settings", + "python-dotenv", + "aiofiles", ] -# IDE will watch this setup config through your project src, and help you to set up your environment setup( name="", - description="", + description="[FastAPI-fastkit templated] ", author="", author_email=f"", packages=find_packages(where="src"), - use_scm_version=True, - requires=["python (>=3.11)"], + requires=["python (>=3.12)"], install_requires=install_requires, ) diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/__init__.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/__init__.py-tpl index 4ae627b..e69de29 100644 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/__init__.py-tpl +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/__init__.py-tpl @@ -1,88 +0,0 @@ -# -------------------------------------------------------------------------- -# The module creates FastAPI Application. -# -------------------------------------------------------------------------- -from __future__ import annotations - -import logging - -from fastapi import FastAPI, Request -from fastapi.responses import JSONResponse, RedirectResponse -from starlette.middleware.cors import CORSMiddleware -from contextlib import asynccontextmanager -from setuptools_scm import get_version - -from src.core.settings import settings -from src.helper.exceptions import InternalException -from src.helper.logging import init_logger as _init_logger -from src.helper.global_data import initialize_mock_data -from src.router import router -from src.core.settings import AppSettings -from src.utils.documents import add_description_at_api_tags - -try: - __version__ = get_version( - root="../", relative_to=__file__ - ) # git version (dev version) -except LookupError: - __version__ = "1.0.0" # production version - - -logger = logging.getLogger(__name__) - - -def init_logger(app_settings: AppSettings) -> None: - _init_logger(f"fastapi-backend@{__version__}", app_settings) - - -@asynccontextmanager -async def lifespan(app: FastAPI): - try: - logger.info("Application startup") - - logger.info("Init mock data at memory") # you can put mocking data - # in your main FastAPI coroutine memory space. - initialize_mock_data() - yield - finally: - logger.info("Application shutdown") - - -def create_app(app_settings: AppSettings, app_title: str = "FastAPI Backend", app_description: str = "backend application") -> FastAPI: - app = FastAPI( - title=f"{app_title}", - description=f"{app_description}", - version=__version__, - lifespan=lifespan, - openapi_url="/openapi.json", - redoc_url="/redoc", - ) - - # Apply Middlewares - if settings.BACKEND_CORS_ORIGINS: - app.add_middleware( - CORSMiddleware, - allow_origins=[ - str(origin).strip("/") for origin in settings.BACKEND_CORS_ORIGINS - ], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], - ) - - # Apply Custom Exception Handler - @app.exception_handler(InternalException) - async def internal_exception_handler(request: Request, exc: InternalException): - return JSONResponse( - status_code=exc.status, - content=exc.to_response(path=str(request.url)).model_dump(), - ) - - @app.get("/", include_in_schema=False) - async def root(request: Request): - return RedirectResponse(url="/v1/") - - app.include_router(router) - - add_description_at_api_tags(app) - - return app diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/helper/__init__.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/api/__init__.py-tpl similarity index 100% rename from src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/helper/__init__.py-tpl rename to src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/api/__init__.py-tpl diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/api/api.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/api/api.py-tpl new file mode 100644 index 0000000..85d2f93 --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/api/api.py-tpl @@ -0,0 +1,9 @@ +# -------------------------------------------------------------------------- +# API router connector module +# -------------------------------------------------------------------------- +from fastapi import APIRouter + +from src.api.routes import items + +api_router = APIRouter() +api_router.include_router(items.router) diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/utils/__init__.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/api/routes/__init__.py-tpl similarity index 100% rename from src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/utils/__init__.py-tpl rename to src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/api/routes/__init__.py-tpl diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/api/routes/items.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/api/routes/items.py-tpl new file mode 100644 index 0000000..2c79809 --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/api/routes/items.py-tpl @@ -0,0 +1,57 @@ +# -------------------------------------------------------------------------- +# Item CRUD Endpoint +# -------------------------------------------------------------------------- +from typing import List + +from fastapi import APIRouter, HTTPException + +from src.crud.items import read_items, write_items +from src.schemas.items import ItemCreate, ItemResponse + +router = APIRouter() + + +@router.get("/items", response_model=List[ItemResponse]) +async def read_all_items(): + return await read_items() + + +@router.get("/items/{item_id}", response_model=ItemResponse) +async def read_item(item_id: int): + items = await read_items() + item = next((i for i in items if i["id"] == item_id), None) + if not item: + raise HTTPException(status_code=404, detail="Item not found") + return item + + +@router.post("/items", response_model=ItemResponse, status_code=201) +async def create_item(item: ItemCreate): + items = await read_items() + new_id = max(i["id"] for i in items) + 1 if items else 1 + new_item = {**item.model_dump(), "id": new_id} + items.append(new_item) + await write_items(items) + return new_item + + +@router.put("/items/{item_id}", response_model=ItemResponse) +async def update_item(item_id: int, item: ItemCreate): + items = await read_items() + index = next((i for i, v in enumerate(items) if v["id"] == item_id), None) + if index is None: + raise HTTPException(status_code=404, detail="Item not found") + updated_item = {**item.model_dump(), "id": item_id} + items[index] = updated_item + await write_items(items) + return updated_item + + +@router.delete("/items/{item_id}") +async def delete_item(item_id: int): + items = await read_items() + new_items = [i for i in items if i["id"] != item_id] + if len(new_items) == len(items): + raise HTTPException(status_code=404, detail="Item not found") + await write_items(new_items) + return {"message": "Item deleted successfully"} diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/core/config.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/core/config.py-tpl new file mode 100644 index 0000000..7224095 --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/core/config.py-tpl @@ -0,0 +1,64 @@ +# -------------------------------------------------------------------------- +# Configuration module +# -------------------------------------------------------------------------- +import secrets +import warnings +from typing import Annotated, Any, Literal + +from pydantic import AnyUrl, BeforeValidator, computed_field, model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict +from typing_extensions import Self + + +def parse_cors(v: Any) -> list[str] | str: + if isinstance(v, str) and not v.startswith("["): + return [i.strip() for i in v.split(",")] + elif isinstance(v, list | str): + return v + raise ValueError(v) + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_ignore_empty=True, + extra="ignore", + ) + + SECRET_KEY: str = secrets.token_urlsafe(32) + ENVIRONMENT: Literal["development", "production"] = "development" + + CLIENT_ORIGIN: str = "" + + BACKEND_CORS_ORIGINS: Annotated[list[AnyUrl] | str, BeforeValidator(parse_cors)] = ( + [] + ) + + @computed_field # type: ignore[prop-decorator] + @property + def all_cors_origins(self) -> list[str]: + return [str(origin).rstrip("/") for origin in self.BACKEND_CORS_ORIGINS] + [ + self.CLIENT_ORIGIN + ] + + PROJECT_NAME: str = "" + + def _check_default_secret(self, var_name: str, value: str | None) -> None: + if value == "changethis": + message = ( + f'The value of {var_name} is "changethis", ' + "for security, please change it, at least for deployments." + ) + if self.ENVIRONMENT == "development": + warnings.warn(message, stacklevel=1) + else: + raise ValueError(message) + + @model_validator(mode="after") + def _enforce_non_default_secrets(self) -> Self: + self._check_default_secret("SECRET_KEY", self.SECRET_KEY) + + return self + + +settings = Settings() # type: ignore diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/core/settings.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/core/settings.py-tpl deleted file mode 100644 index ea61ca5..0000000 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/core/settings.py-tpl +++ /dev/null @@ -1,96 +0,0 @@ -# -------------------------------------------------------------------------- -# The module configures Backend Application's settings. -# -# This module looks up two .env files below: -# - production environment: .env file -# - test environment: .env.test -# -------------------------------------------------------------------------- -from __future__ import annotations - -import warnings - -from typing import Any, Optional, Annotated, Literal -from typing_extensions import Self - -from pydantic import ( - Field, - AnyUrl, - BeforeValidator, - model_validator, -) -from pydantic_settings import BaseSettings, SettingsConfigDict - - -def parse_cors(v: Any) -> list[str] | str: - if isinstance(v, str) and not v.startswith("["): - return [i.strip() for i in v.split(",")] - elif isinstance(v, list | str): - return v - raise ValueError(v) - - -class AppSettings(BaseSettings): - model_config = SettingsConfigDict(env_file=".env") - - # Application general configuration - LOGGING_DEBUG_LEVEL: bool = Field( - default=True, - description="True: DEBUG mode, False:: INFO mode", - ) - LOG_FILE_PATH: str = Field( - default="../../logs/app.log", - description="Log file path", - ) - DEBUG_ALLOW_CORS_ALL_ORIGIN: bool = Field( - default=True, - description="If True, allow origins for CORS requests.", - ) - DEBUG_ALLOW_NON_CERTIFICATED_USER_GET_TOKEN: bool = Field( - default=True, - description="If True, allow non-cerficiated users to get ESP token.", - ) - BACKEND_CORS_ORIGINS: Annotated[list[AnyUrl] | str, BeforeValidator(parse_cors)] = ( - [] - ) - THREAD_POOL_SIZE: Optional[int] = Field( - default=10, - description="Change the server's thread pool size to handle non-async function", - ) - ENVIRONMENT: Literal["local", "staging", "production"] = "local" - SERVER_DOMAIN: str = Field( - default="localhost", - description="Domain of server", - ) - SERVER_PORT: int = Field( - default=9080, - description="Server's port", - ) - - # Application security configuration - SECRET_KEY: str = Field( - default="example_secret_key_WoW", - description="Secret key to be used for issuing HMAC tokens.", - ) - HASH_ALGORITHM: str = Field(default="HS256", description="Algorithm for Hashing") # for JWT utility - # 60 minutes * 24 hours * 8 days = 8 days - ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 # for JWT utility - - def _check_default_secret(self, var_name: str, value: str | None) -> None: - if value == "example_secret_key_WoW": - message = ( - f'The value of {var_name} is "example_secret_key_WoW", ' - "for security, please change it, at least for deployments." - ) - if self.ENVIRONMENT == "local": - warnings.warn(message, stacklevel=1) - else: - raise ValueError(message) - - @model_validator(mode="after") - def _enforce_non_default_secrets(self) -> Self: - self._check_default_secret("SECRET_KEY", self.SECRET_KEY) - - return self - - -settings = AppSettings() diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/crud/_base.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/crud/_base.py-tpl deleted file mode 100644 index fdf2bbd..0000000 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/crud/_base.py-tpl +++ /dev/null @@ -1,92 +0,0 @@ -# -------------------------------------------------------------------------- -# The module defines basic Model CRUD methods. -# -------------------------------------------------------------------------- -from __future__ import annotations - -import json - -from typing import Any, Type, List, Optional - -from pydantic import BaseModel -from sqlalchemy import select, text -from sqlalchemy.ext.asyncio import AsyncSession - - -def load_json(file_path: str): - with open(file_path, "r", encoding="utf-8") as f: - return json.load(f) - - -async def get_object( - db: AsyncSession, model: Any, model_id: int | str, response_model: Type[BaseModel] -) -> Optional[Any]: - result = await db.get(model, model_id) - return response_model.model_validate(result.__dict__) - - -async def get_object_with_uuid( - db: AsyncSession, model: Any, model_uid: str, response_model: Type[BaseModel] -) -> Optional[Any]: - result = await db.get(model, model_uid) - return response_model.model_validate(result.__dict__) - - -async def get_objects( - db: AsyncSession, - model: Any, - response_model: Type[BaseModel], - condition: Optional[Any] = None, - skip: int = 0, - limit: int = 100, -) -> List[Any]: - query = select(model).offset(skip).limit(limit) - if condition is not None: - query = query.where(text(condition)) - result = await db.execute(query) - result_list = result.scalars().all() - return [response_model.model_validate(item.__dict__) for item in result_list] - - -async def create_object( - db: AsyncSession, model: Any, obj: BaseModel, response_model: Type[BaseModel] -) -> Any: - obj_data = obj.model_dump() - db_obj = model(**obj_data) - - db.add(db_obj) - await db.commit() - await db.refresh(db_obj) - return response_model.model_validate(db_obj.__dict__) - - -async def update_object( - db: AsyncSession, - model: Any, - model_id: int | str, - obj: BaseModel, - response_model: Type[BaseModel], -) -> Optional[Any]: - query = select(model).filter(model.id == model_id) - db_obj = (await db.execute(query)).scalar_one_or_none() - if db_obj is None: - return None - update_data = obj.model_dump(exclude_unset=True) - for key, value in update_data.items(): - setattr(db_obj, key, value) - db.add(db_obj) - await db.commit() - await db.refresh(db_obj) - return response_model.model_validate(db_obj.__dict__) - - -async def delete_object( - db: AsyncSession, model: Any, model_id: int | str -) -> Optional[int]: - query = select(model).filter(model.id == model_id) - db_obj = (await db.execute(query)).scalar_one_or_none() - if db_obj: - await db.delete(db_obj) - await db.commit() - else: - return None - return model_id diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/crud/items.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/crud/items.py-tpl new file mode 100644 index 0000000..33ca235 --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/crud/items.py-tpl @@ -0,0 +1,21 @@ +# -------------------------------------------------------------------------- +# Item CRUD method module +# -------------------------------------------------------------------------- +import json +import os +from typing import List + +import aiofiles # type: ignore + +MOCK_FILE = os.path.join(os.path.dirname(__file__), "../mocks/mock_items.json") + + +async def read_items(): + async with aiofiles.open(MOCK_FILE, mode="r") as file: + contents = await file.read() + return json.loads(contents) + + +async def write_items(items: List[dict]): + async with aiofiles.open(MOCK_FILE, mode="w") as file: + await file.write(json.dumps(items, indent=2)) diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/crud/user.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/crud/user.py-tpl deleted file mode 100644 index 4fcad6d..0000000 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/crud/user.py-tpl +++ /dev/null @@ -1,44 +0,0 @@ -# -------------------------------------------------------------------------- -# The module defines User model's CRUD methods. -# -------------------------------------------------------------------------- -import os - -from typing import Type -from uuid import UUID - -from ._base import load_json -from src.helper.exceptions import InternalException, ErrorCode -from src.schemas.user import UserSchema, UserCreate, UserUpdate -from src.helper.global_data import mock_user_data - - -def get_mock_user_data(id: UUID) -> dict: - user_data = next((user for user in mock_user_data if user["id"] == str(id)), None) - if user_data is None: - raise InternalException( - message="User not found.", error_code=ErrorCode.NOT_FOUND - ) - return user_data - - -def create_mock_user(user: UserCreate) -> dict: - new_user = user.model_dump() - if any(u["email"] == new_user["email"] for u in mock_user_data): - raise InternalException( - error_code=ErrorCode.CONFLICT, - message="This user is already exist.", - ) - mock_user_data.append(new_user) - return new_user - - -def update_mock_user(id: UUID, user: UserUpdate) -> dict: - user_data = get_mock_user_data(id) - update_data = user.model_dump(exclude_unset=True) - user_data.update(update_data) - return user_data - - -def delete_mock_user(id: UUID) -> None: - user_data = get_mock_user_data(id) - mock_user_data.remove(user_data) diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/helper/global_data.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/helper/global_data.py-tpl deleted file mode 100644 index 5d2cf0e..0000000 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/helper/global_data.py-tpl +++ /dev/null @@ -1,33 +0,0 @@ -# -------------------------------------------------------------------------- -# The module initialize Mocking Data storage at FastAPI main coroutine's -# memory space. -# -------------------------------------------------------------------------- -import os -import json - -# Global Mock Data storage (Stores : FastAPI app coroutine - In FastAPI thread memory) -mock_user_data = [] - - -def initialize_mock_data() -> None: - base_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")) - user_file_path = os.path.join(base_path, "src", "mocks", "mock_users.json") - - with open(user_file_path, "r", encoding="utf-8") as f: - user_data = json.load(f) - - # If the model has relations with other model, - # you can map it their respective columns like below: - item_data = [{'some': 'item', "user_id": "123e4567-e89b-12d3-a456-426614174000"}] - item_map = {} - for item in item_data: - user_id = item["user_id"] - if user_id not in item_map: - item_map[user_id] = [] - item_map[user_id].append(item) - - for user in user_data: - user_id = user["id"] - user["items"] = item_map.get(user_id, []) - - mock_user_data.extend(user_data) diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/helper/logging.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/helper/logging.py-tpl deleted file mode 100644 index be572bd..0000000 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/helper/logging.py-tpl +++ /dev/null @@ -1,25 +0,0 @@ -# -------------------------------------------------------------------------- -# The module defines Backend Application's logger. -# -------------------------------------------------------------------------- -import logging -import logging.handlers - -from src.core.settings import AppSettings - - -LOGGING_FORMAT = ( - "[%(levelname)1.1s " - "%(asctime)s " - "P%(process)d " - "%(threadName)s " - "%(module)s:%(lineno)d] " - "%(message)s" -) - - -def init_logger(root_logger_name: str, app_settings: AppSettings) -> logging.Logger: - app_logger_level = ( - logging.DEBUG if app_settings.LOGGING_DEBUG_LEVEL else logging.INFO - ) - - logging.basicConfig(level=app_logger_level, format=LOGGING_FORMAT) diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/main.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/main.py-tpl new file mode 100644 index 0000000..7a6bb3e --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/main.py-tpl @@ -0,0 +1,26 @@ +# -------------------------------------------------------------------------- +# Main server application module +# -------------------------------------------------------------------------- +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from starlette.middleware.cors import CORSMiddleware + +from src.api.api import api_router +from src.core.config import settings + +app = FastAPI( + title=settings.PROJECT_NAME, +) + +if settings.all_cors_origins: + app.add_middleware( + CORSMiddleware, + allow_origins=settings.all_cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + +app.include_router(api_router) diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/mocks/mock_items.json-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/mocks/mock_items.json-tpl new file mode 100644 index 0000000..e73b08f --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/mocks/mock_items.json-tpl @@ -0,0 +1,8 @@ +[ + { + "id": 1, + "name": "Test Item 1", + "price": 20.0, + "category": "test" + } +] diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/mocks/mock_users.json-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/mocks/mock_users.json-tpl deleted file mode 100644 index b4b8c50..0000000 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/mocks/mock_users.json-tpl +++ /dev/null @@ -1,17 +0,0 @@ -[ - { - "id": "123e4567-e89b-12d3-a456-426614174000", - "userId": "bnbong", - "email": "bbbong9@gmail.com", - "roles": ["NORMAL_USER"], - "profileImageUrl": null, - "bio": "hello, My name is JunHyeok Lee.", - "firstName": "JunHyeok", - "lastName": "Lee", - "gender": "M", - "country": "KOREA", - "isActive": true, - "createdDate": "2000-02-10T01:53:16.707426", - "password": "password123" - } -] diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/router/__init__.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/router/__init__.py-tpl deleted file mode 100644 index 1cab0e5..0000000 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/router/__init__.py-tpl +++ /dev/null @@ -1,48 +0,0 @@ -# -------------------------------------------------------------------------- -# The module connect routers to Backend Application. -# -------------------------------------------------------------------------- -import os -import json - -from uuid import UUID -from datetime import datetime - -from fastapi import APIRouter, status, Request -from fastapi.responses import JSONResponse, HTMLResponse -from fastapi.templating import Jinja2Templates - -from src.helper.exceptions import InternalException - -from .user import router as user_router - -router = APIRouter(prefix="/v1") - -router.include_router(user_router, tags=["user"]) - -templates = Jinja2Templates(os.path.join(os.path.dirname(__file__), "../templates")) - - -@router.get("/", response_class=HTMLResponse, include_in_schema=False) -async def root(request: Request): - return templates.TemplateResponse("index.html", {"request": request}) - - -@router.get( - "/ping", - summary="Server health check", - description="Checking FastAPI server's health.", - response_model=dict, - responses={ - 200: { - "description": "Ping Success", - "content": {"application/json": {"example": {"ping": "pong"}}}, - }, - }, -) -async def ping(): - return {"ping": "pong"} - - -def load_json(file_path: str): - with open(file_path, "r", encoding="utf-8") as f: - return json.load(f) diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/router/user.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/router/user.py-tpl deleted file mode 100644 index 8ac56e3..0000000 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/router/user.py-tpl +++ /dev/null @@ -1,126 +0,0 @@ -# -------------------------------------------------------------------------- -# The module defines User router. -# -------------------------------------------------------------------------- -from . import * - -from src.helper.global_data import mock_user_data -from src.crud.user import get_mock_user_data -from src.schemas import ResponseSchema -from src.schemas.user import UserSchema, UserCreate, UserUpdate - - -router = APIRouter( - prefix="/user", -) - - -def generate_new_user_id(): - if mock_user_data: - return max(_user["id"] for _user in mock_user_data) + 1 - return 1 - - -@router.post( - "/", - summary="Create a new user.", - status_code=status.HTTP_201_CREATED, - response_model=UserSchema, -) -async def create_user_route( - data: UserCreate, - request: Request, -): - try: - new_user = create_mock_user(data) - response = ResponseSchema( - timestamp=datetime.utcnow().isoformat() + "Z", - status=201, - code="HTTP-201", - path=str(request.url), - message=UserSchema(**new_user), - ) - return response - except InternalException as e: - return JSONResponse( - status_code=e.status, - content=e.to_response(path=str(request.url)).model_dump(), - ) - - -@router.get( - "/{id}", - summary="Get a user info", - description="Inquires information about a specific user.", - response_model=ResponseSchema[UserSchema], -) -async def get_user_route(id: UUID, request: Request): - try: - user = get_mock_user_data(id) - response = ResponseSchema( - timestamp=datetime.utcnow().isoformat() + "Z", - status=200, - code="HTTP-200", - path=str(request.url), - message=UserSchema(**user), - ) - return response - except InternalException as e: - return JSONResponse( - status_code=e.status, - content=e.to_response(path=str(request.url)).model_dump(), - ) - - -@router.patch( - "/{id}", - summary="Update a user.", - status_code=status.HTTP_200_OK, - response_model=UserSchema, -) -async def update_user_route( - id: UUID, - data: UserUpdate, -): - try: - user = get_mock_user_data(id) - if user["username"] != request_user: - raise InternalException( - message="ERROR : You do not have permission to modify.", error_code=ErrorCode.UNAUTHORIZED - ) - user.update(data.dict(exclude_unset=True)) - user["modifiedAt"] = datetime.utcnow().isoformat() + "Z" - mock_user_data[mock_user_data.index(user)] = user - response = ResponseSchema( - timestamp=datetime.utcnow().isoformat() + "Z", - status=200, - code="HTTP-200", - path=str(request.url), - message=UserSchema(**user), - ) - return response - except InternalException as e: - return JSONResponse( - status_code=e.status, - content=e.to_response(path=str(request.url)).model_dump(), - ) - - -@router.delete( - "/{id}", - summary="Delete a user.", - status_code=status.HTTP_204_NO_CONTENT, -) -async def delete_user_route(id: UUID): - try: - user = get_mock_user_data(id) - if user["username"] != request_user: - raise InternalException( - message="ERROR : You do not have permission to delete.", error_code=ErrorCode.UNAUTHORIZED - ) - mock_user_data.remove(user) - return JSONResponse(status_code=status.HTTP_204_NO_CONTENT, content="") - except InternalException as e: - return JSONResponse( - status_code=e.status, - content=e.to_response(path=str(request.url)).model_dump(), - ) diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/schemas/__init__.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/schemas/__init__.py-tpl index a55352e..e69de29 100644 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/schemas/__init__.py-tpl +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/schemas/__init__.py-tpl @@ -1,48 +0,0 @@ -# -------------------------------------------------------------------------- -# The module defines Base response schemas. -# -------------------------------------------------------------------------- -from pydantic import BaseModel, Field -from typing import Generic, TypeVar - -T = TypeVar("T") - - -class ResponseSchema(BaseModel, Generic[T]): - timestamp: str = Field( - ..., - description="The timestamp when the response was generated.", - ) - status: int = Field(..., description="HTTP status code.") - code: str = Field( - ..., - description="Server identification code.", - ) - path: str = Field( - ..., - description="Request path.", - ) - message: T = Field( - ..., - description="Data details or error messages requested.", - ) - - class ConfigDict: - json_schema_extra = { - "example": { - "timestamp": "2023-02-10T01:00:00.000Z", - "status": 200, - "code": "HTTP-200", - "path": "/v1/", - "message": { - "id": "123e4567-e89b-12d3-a456-426614174000", - "email": "example@example.com", - "password": "secret", - "nickname": "example_nick", - "create_at": "2023-02-10T01:00:00.000Z", - "bio": "example bio", - "profile_img": "https://example.com/profile.jpg", - "first_name": "John", - "last_name": "Doe", - }, - } - } diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/schemas/items.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/schemas/items.py-tpl new file mode 100644 index 0000000..d9e0193 --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/schemas/items.py-tpl @@ -0,0 +1,22 @@ +# -------------------------------------------------------------------------- +# Item schema module +# -------------------------------------------------------------------------- +from typing import Optional + +from pydantic import BaseModel, ConfigDict + + +class ItemBase(BaseModel): + name: str + description: Optional[str] = None + price: float + category: str + + +class ItemCreate(ItemBase): + pass + + +class ItemResponse(ItemBase): + id: int + model_config = ConfigDict(from_attributes=True) diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/schemas/user.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/schemas/user.py-tpl deleted file mode 100644 index 26824ff..0000000 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/schemas/user.py-tpl +++ /dev/null @@ -1,81 +0,0 @@ -# -------------------------------------------------------------------------- -# The module defines User schemas. -# -------------------------------------------------------------------------- -from datetime import datetime, date -from uuid import UUID -from enum import Enum - -from pydantic import BaseModel, Field, EmailStr, SecretStr, field_serializer -from typing import Optional, List - - -class UserBase(BaseModel): - email: EmailStr = Field( - ..., title="User's Email", description="The email address of the user." - ) - bio: str = Field(None, title="User's bio", description="Personal introduction of the user.") - firstName: str = Field( - ..., title="User's first name", description="The real name of the user." - ) - lastName: str = Field( - ..., title="User's last name", description="The last name of the user." - ) - gender: str = Field(..., title="User's gender", description="The gender of the user.") - country: str = Field(..., title="User's country", description="Country of the user.") - isActive: bool = Field( - ..., title="User's active status", description="The active state of the user." - ) - createdDate: datetime = Field( - ..., - title="User's account created date", - description="Date of creation of the user account.", - ) - password: Optional[SecretStr] = Field( - None, title="User's password", description="Password of the user account." - ) - - # items: Optional[List] = List[ItemSchema] - - @field_serializer("password", when_used="json") - def dump_secret(self, v): - return v.get_secret_value() - - -class UserSchema(UserBase): - id: UUID = Field( - ..., title="User's ID (pk)", description="The unique database identifier for the user." - ) - userId: str = Field(..., title="User's ID", description="ID of the user account.") - roles: List[str] = Field( - ..., title="User's roles", description="List of roles for the user." - ) - profileImageUrl: Optional[str] = Field( - None, - title="User's profile image URL", - description="The URL of the user's profile image.", - ) - - class ConfigDict: - from_attributes = True - - -class UserCreate(UserBase): - firstName: str = Field( - ..., title="User's first name", description="The real name of the user." - ) - lastName: str = Field( - ..., title="User's last name", description="The last name of the user." - ) - email: EmailStr = Field( - ..., title="User's Email", description="The email address of the user." - ) - password: str = Field( - ..., title="User's password", description="Password of the user account." - ) - - -class UserUpdate(BaseModel): - bio: str = Field(None, title="User's bio", description="Personal introduction of the user.") - profileImg: str = Field( - None, title="User's profile image", description="The URL of the user's profile image." - ) diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/templates/index.html-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/templates/index.html-tpl deleted file mode 100644 index a104aba..0000000 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/templates/index.html-tpl +++ /dev/null @@ -1,27 +0,0 @@ - - - - FastAPI Test Project - - - -

Welcome to FastAPI Test Project

-

This is a test project using FastAPI framework.

-

API Documentation: Swagger UI | ReDoc

- - diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/tests/conftest.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/tests/conftest.py-tpl index 6895a65..c26abc0 100644 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/tests/conftest.py-tpl +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/tests/conftest.py-tpl @@ -1,22 +1,42 @@ # -------------------------------------------------------------------------- -# The module configures pytest runtime options. +# pytest runtime configuration module # -------------------------------------------------------------------------- +import asyncio +import os +import typing +from pathlib import Path + +import pytest import pytest_asyncio +from httpx import ASGITransport, AsyncClient + +from src.main import app + +MOCK_PATH = os.path.join(os.path.dirname(__file__), "../src/mocks/mock_items.json") + + +@pytest.fixture(scope="session") +def event_loop(request) -> typing.Generator: + loop = asyncio.get_event_loop() + yield loop + loop.close() -from httpx import AsyncClient, ASGITransport -from src import create_app -from src.core.settings import AppSettings +@pytest_asyncio.fixture(scope="function", autouse=True) +def mock_data_reset(): + # Back up original data into memory + mock_path = Path(__file__).parent.parent / "src/mocks/mock_items.json" + original_data = mock_path.read_text() + yield -app_settings = AppSettings(_env_file=".env.test") + # After test, restore original data + mock_path.write_text(original_data) -class BaseTestRouter: - @pytest_asyncio.fixture(scope="function") - async def client(self): - app = create_app(app_settings) - async with AsyncClient( - transport=ASGITransport(app=app), base_url="http://test" - ) as c: - yield c +@pytest_asyncio.fixture(scope="module") +async def client(): + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as ac: + yield ac diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/tests/routes/test_user.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/tests/routes/test_user.py-tpl deleted file mode 100644 index b2add53..0000000 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/tests/routes/test_user.py-tpl +++ /dev/null @@ -1,112 +0,0 @@ -# -------------------------------------------------------------------------- -# User router testcases -# -------------------------------------------------------------------------- -import pytest -import pytest_asyncio - -from fastapi import status -from uuid import UUID -from httpx import AsyncClient - -from tests.conftest import BaseTestRouter - - -@pytest.mark.asyncio -class TestUserAPI(BaseTestRouter): - - @pytest_asyncio.fixture(autouse=True) - async def setup(self, client: AsyncClient): - self.MOCK_USER_ID = UUID("123e4567-e89b-12d3-a456-426614174000") - self.INVALID_USER_ID = UUID("123e4567-e89b-12d3-a456-426614174999") - self.base_url = "/user" - - async def test_create_user(self, client: AsyncClient): - # given - user_data = { - "username": "testuser", - "email": "testuser@example.com", - } - - # when - response = await client.post(f"{self.base_url}/", json=user_data) - - # then - assert response.status_code == status.HTTP_201_CREATED - json_response = response.json() - assert json_response["message"]["username"] == "testuser" - assert json_response["message"]["email"] == "testuser@example.com" - - async def test_get_user_valid(self, client: AsyncClient): - # given - user_id = self.MOCK_USER_ID - - # when - response = await client.get(f"{self.base_url}/{user_id}") - - # then - assert response.status_code == status.HTTP_200_OK - json_response = response.json() - assert json_response["status"] == 200 - assert json_response["code"] == "HTTP-200" - assert json_response["message"]["id"] == str(user_id) - - async def test_get_user_invalid(self, client: AsyncClient): - # given - invalid_user_id = self.INVALID_USER_ID - - # when - response = await client.get(f"{self.base_url}/{invalid_user_id}") - - # then - assert response.status_code == status.HTTP_404_NOT_FOUND - - async def test_update_user_valid(self, client: AsyncClient): - # given - user_id = self.MOCK_USER_ID - update_data = { - "username": "updateduser", - "email": "updateduser@example.com", - } - - # when - response = await client.patch(f"{self.base_url}/{user_id}", json=update_data) - - # then - assert response.status_code == status.HTTP_200_OK - json_response = response.json() - assert json_response["message"]["username"] == "updateduser" - assert json_response["message"]["email"] == "updateduser@example.com" - - async def test_update_user_invalid(self, client: AsyncClient): - # given - invalid_user_id = self.INVALID_USER_ID - update_data = { - "username": "invaliduser", - "email": "invaliduser@example.com", - } - - # when - response = await client.patch(f"{self.base_url}/{invalid_user_id}", json=update_data) - - # then - assert response.status_code == status.HTTP_404_NOT_FOUND - - async def test_delete_user_valid(self, client: AsyncClient): - # given - user_id = self.MOCK_USER_ID - - # when - response = await client.delete(f"{self.base_url}/{user_id}") - - # then - assert response.status_code == status.HTTP_204_NO_CONTENT - - async def test_delete_user_invalid(self, client: AsyncClient): - # given - invalid_user_id = self.INVALID_USER_ID - - # when - response = await client.delete(f"{self.base_url}/{invalid_user_id}") - - # then - assert response.status_code == status.HTTP_404_NOT_FOUND diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/tests/test_items.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/tests/test_items.py-tpl new file mode 100644 index 0000000..b00a68f --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/tests/test_items.py-tpl @@ -0,0 +1,48 @@ +# -------------------------------------------------------------------------- +# Item endpoint testcases (Async version) +# -------------------------------------------------------------------------- +import pytest + + +@pytest.mark.asyncio +async def test_read_all_items(client): + response = await client.get("/items") + assert response.status_code == 200 + assert len(response.json()) == 1 + + +@pytest.mark.asyncio +async def test_read_item_success(client): + response = await client.get("/items/1") + assert response.status_code == 200 + assert response.json()["name"] == "Test Item 1" + + +@pytest.mark.asyncio +async def test_read_item_not_found(client): + response = await client.get("/items/999") + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_create_item(client): + new_item = {"name": "New Item", "price": 30.0, "category": "new"} + response = await client.post("/items", json=new_item) + assert response.status_code == 201 + assert response.json()["id"] == 2 + + +@pytest.mark.asyncio +async def test_update_item(client): + updated_data = {"name": "Updated Item", "price": 15.0, "category": "updated"} + response = await client.put("/items/1", json=updated_data) + assert response.status_code == 200 + assert response.json()["name"] == "Updated Item" + + +@pytest.mark.asyncio +async def test_delete_item(client): + response = await client.delete("/items/1") + assert response.status_code == 200 + response = await client.get("/items/1") + assert response.status_code == 404 diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/.env-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/.env-tpl new file mode 100644 index 0000000..c9d6bec --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/.env-tpl @@ -0,0 +1 @@ +SECRET_KEY=changethis diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/.gitignore-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/.gitignore-tpl new file mode 100644 index 0000000..b1016d2 --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/.gitignore-tpl @@ -0,0 +1,191 @@ +### venv template +# Virtualenv +# http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ +.Python +[Bb]in +[Ii]nclude +[Ll]ib +[Ll]ib64 +[Ll]ocal +[Ss]cripts +pyvenv.cfg +.venv +pip-selfcheck.json + +### dotenv template +.env + +### VirtualEnv template +# Virtualenv +# http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ +.Python +[Bb]in +[Ii]nclude +[Ll]ib +[Ll]ib64 +[Ll]ocal +pyvenv.cfg +.venv +pip-selfcheck.json + +### Python template +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +# poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +.idea/ diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/README.md-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/README.md-tpl new file mode 100644 index 0000000..2af3eb2 --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/README.md-tpl @@ -0,0 +1,156 @@ +# Async Item Management API with Custom Response System + +A FastAPI-based REST API demonstrating asynchronous processing and standardized response handling. + +## Tech Stack + +- Python 3.12+ +- FastAPI + uvicorn +- Pydantic 2 & pydantic-settings +- pytest, pytest-asyncio +- mypy + black + isort +- aiofiles (async file handling) + +## Project Structure + +``` +. +├── src/ +│   ├── core +│   │   └── config.py +│   └── api +│   │ ├── api.py +│   │ └── routes +│   │ └── items.py +│ ├── schemas +│ │ ├── base.py # Base response schema +│ │ └── items.py +│ ├── helper +│ │ ├── exceptions.py # Exception handlers +│ │ └── pagination.py # Paginator +│   ├── crud +│   │   └── items.py +│   ├── mocks +│   │   └── mock_items.json +│ └── utils +│ └── documents.py # custom OpenAPI docs generator +└── tests + ├── __init__.py + ├── test_items.py + └── conftest.py +``` + +## How to Run + +Run in virtual environment: + +```bash +# Install dependencies +$ pip install -r requirements.txt + +# Start development server (using script) +$ bash scripts/run-server.sh + +# Manual run +$ uvicorn src.main:app --reload +``` + +Access API documentation after server starts: + +```bash +/docs # Swagger format +/redoc # ReDoc format + +# example +http://127.0.0.1:8000/docs +``` + +## API Endpoints + +| Method | Endpoint | Description | +|--------|------------------|----------------------------| +| GET | `/items/` | List all items | +| GET | `/items/{id}` | Get single item | +| POST | `/items/` | Create new item | +| PUT | `/items/{id}` | Update existing item | +| DELETE | `/items/{id}` | Delete item | + +## Key Features + +### 1. Custom Response System +- **Standardized Response Format** (`schemas/base.py`): + ```python + class ResponseSchema(BaseModel, Generic[T]): + timestamp: str # ISO 8601 format + status: int # HTTP status code + code: str # Custom error code + path: str # Request endpoint + message: T # Response payload/error + ``` +- **Exception Handling** (`helper/exceptions.py`): + - `InternalException` class with error code mapping + - Automatic error response formatting + - Custom error codes with HTTP status mapping + +#### Response Examples + +##### Success Response + +```json +{ + "timestamp": "2024-02-15T09:30:00.000Z", + "status": 200, + "code": "HTTP-200", + "path": "/items/1", + "message": { + "id": 1, + "name": "Test Item 1", + "price": 20.0, + "category": "test" + } +} +``` + +##### Error Response + +```json +{ + "timestamp": "2024-02-15T09:30:00.000Z", + "status": 404, + "code": "DATA-002", + "path": "/items/999", + "message": "ERROR: Item not found" +} +``` + +### 2. Async Operations +- Asynchronous CRUD operations with aiofiles +- Non-blocking I/O for JSON data management +- Async test client with pytest-asyncio + +### 3. Enhanced Documentation +- Custom OpenAPI tags and descriptions (`utils/documents.py`) +- Automatic API documentation generation +- Response schema examples in Swagger/ReDoc + +## Running Tests + +```bash +# Run all tests +$ pytest tests/ + +# Run specific test file +$ pytest tests/test_items.py -v +``` + +For FastAPI documentation: + +## Project Origin + +This project was created using the [FastAPI-fastkit](https://github.com/bnbong/FastAPI-fastkit) template. + +FastAPI-fastkit is an open-source project that helps developers quickly set up FastAPI-based applications with proper structure and tooling. + +### Template Information +- Template author: [bnbong](mailto:bbbong9@gmail.com) +- Project maintainer: [bnbong](mailto:bbbong9@gmail.com) diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/requirements.txt-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/requirements.txt-tpl new file mode 100644 index 0000000..a38bf3a --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/requirements.txt-tpl @@ -0,0 +1,36 @@ +aiofiles==24.1.0 +annotated-types==0.7.0 +anyio==4.8.0 +black==25.1.0 +certifi==2025.1.31 +click==8.1.8 +fastapi==0.115.8 +h11==0.14.0 +httpcore==1.0.7 +httptools==0.6.4 +httpx==0.28.1 +idna==3.10 +iniconfig==2.0.0 +isort==6.0.0 +mypy==1.15.0 +mypy-extensions==1.0.0 +packaging==24.2 +pathspec==0.12.1 +platformdirs==4.3.6 +pluggy==1.5.0 +pydantic==2.10.6 +pydantic-settings==2.7.1 +pydantic_core==2.27.2 +pytest==8.3.4 +pytest-asyncio==0.25.3 +python-dotenv==1.0.1 +PyYAML==6.0.2 +setuptools==75.8.0 +sniffio==1.3.1 +SQLAlchemy==2.0.38 +starlette==0.45.3 +typing_extensions==4.12.2 +uvicorn==0.34.0 +uvloop==0.21.0 +watchfiles==1.0.4 +websockets==15.0 diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/script/format.sh-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/script/format.sh-tpl new file mode 100644 index 0000000..abdd14e --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/script/format.sh-tpl @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -x + +black . +isort . diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/script/lint.sh-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/script/lint.sh-tpl new file mode 100644 index 0000000..08e929f --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/script/lint.sh-tpl @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -x + +black . --check +mypy src diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/script/run-server.sh-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/script/run-server.sh-tpl new file mode 100644 index 0000000..eb760a2 --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/script/run-server.sh-tpl @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -e +set -x + +source .venv/bin/activate + +uvicorn src.main:app --reload diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/script/test.sh-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/script/test.sh-tpl new file mode 100644 index 0000000..048272d --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/script/test.sh-tpl @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +set -e +set -x + +pytest diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/setup.cfg-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/setup.cfg-tpl new file mode 100644 index 0000000..3e01a52 --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/setup.cfg-tpl @@ -0,0 +1,12 @@ +[flake8] +ignore = E501, E305 + +[isort] +line_length=100 +virtual_env=venv + +[tool:pytest] +testpaths = tests +addopts = -ra -q +asyncio_mode = auto +asyncio_default_fixture_loop_scope=session diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/setup.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/setup.py-tpl new file mode 100644 index 0000000..cde4f0b --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/setup.py-tpl @@ -0,0 +1,35 @@ +# -------------------------------------------------------------------------- +# module description here +# -------------------------------------------------------------------------- +from setuptools import find_packages, setup + +install_requires = [ + # Main Application Dependencies + "fastapi==0.111.1", + "uvicorn==0.30.1", + "httpx==0.27.0", + "jinja2==3.1.2", + # ORM Dependencies + "pydantic==2.8.2", + "pydantic_core==2.20.1", + "pydantic-settings==2.3.4", + # Utility Dependencies + "starlette==0.37.2", + "typing_extensions==4.12.2", + "watchfiles==0.22.0", + "pytest==8.2.2", + "pytest-asyncio==0.23.8", + "FastAPI-fastkit", +] + +# IDE will watch this setup config through your project src, and help you to set up your environment +setup( + name="", + description="", + author="", + author_email=f"", + packages=find_packages(where="src"), + use_scm_version=True, + requires=["python (>=3.11)"], + install_requires=install_requires, +) diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/tests/routes/__init__.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/__init__.py-tpl similarity index 100% rename from src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/tests/routes/__init__.py-tpl rename to src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/__init__.py-tpl diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-customized-response/README.md-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/api/__init__.py-tpl similarity index 100% rename from src/fastapi_fastkit/fastapi_project_template/fastapi-customized-response/README.md-tpl rename to src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/api/__init__.py-tpl diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/api/api.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/api/api.py-tpl new file mode 100644 index 0000000..d1c7b67 --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/api/api.py-tpl @@ -0,0 +1,9 @@ +# -------------------------------------------------------------------------- +# API router connector module +# -------------------------------------------------------------------------- +from fastapi import APIRouter + +from src.api.routes import items + +api_router = APIRouter() +api_router.include_router(items.router, prefix="/items", tags=["items"]) diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/api/routes/__init__.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/api/routes/__init__.py-tpl new file mode 100644 index 0000000..e69de29 diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/api/routes/items.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/api/routes/items.py-tpl new file mode 100644 index 0000000..40707aa --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/api/routes/items.py-tpl @@ -0,0 +1,172 @@ +# -------------------------------------------------------------------------- +# Item CRUD Endpoint +# -------------------------------------------------------------------------- +from datetime import UTC, datetime +from typing import Any, List, Union + +from fastapi import APIRouter, Request + +from src.crud.items import read_items, write_items +from src.helper.exceptions import ErrorCode, InternalException +from src.schemas.base import ResponseSchema +from src.schemas.items import ItemCreate, ItemResponse + +router = APIRouter(tags=["items"]) + +error_responses: dict[Union[int, str], dict[str, Any]] = { + 404: {"model": ResponseSchema[str], "description": "Item not found"}, + 500: {"model": ResponseSchema[str], "description": "Server error"}, +} + + +@router.get( + "/", + summary="Get all items", + response_model=ResponseSchema[List[ItemResponse]], + responses=error_responses, +) +async def read_all_items(request: Request): + try: + items = await read_items() + return ResponseSchema( + timestamp=datetime.now(UTC) + .isoformat(timespec="milliseconds") + .replace("+00:00", "Z"), + status=200, + code="HTTP-200", + path=str(request.url), + message=items, + ) + except Exception as e: + # You can raise any custom exception you want here + raise InternalException( + message=f"Error retrieving items: {str(e)}", + error_code=ErrorCode.UNKNOWN_ERROR, # Put your custom error code here + ) + + +@router.get( + "/{item_id}", + summary="Get item by ID", + response_model=ResponseSchema[ItemResponse], + responses=error_responses, +) +async def read_item(item_id: int, request: Request): + try: + items = await read_items() + item = next((i for i in items if i["id"] == item_id), None) + if not item: + raise InternalException( + message="Item not found", error_code=ErrorCode.NOT_FOUND + ) + return ResponseSchema( + timestamp=datetime.now(UTC) + .isoformat(timespec="milliseconds") + .replace("+00:00", "Z"), + status=200, + code="HTTP-200", + path=str(request.url), + message=item, + ) + except InternalException as e: + raise e + except Exception as e: + raise InternalException( + message=f"Error retrieving item: {str(e)}", + error_code=ErrorCode.UNKNOWN_ERROR, + ) + + +@router.post( + "/", + summary="Create new item", + response_model=ResponseSchema[ItemResponse], + status_code=201, + responses=error_responses, +) +async def create_item(item: ItemCreate, request: Request): + try: + items = await read_items() + new_id = max(i["id"] for i in items) + 1 if items else 1 + new_item = {**item.model_dump(), "id": new_id} + items.append(new_item) + await write_items(items) + return ResponseSchema( + timestamp=datetime.now(UTC) + .isoformat(timespec="milliseconds") + .replace("+00:00", "Z"), + status=201, + code="HTTP-201", + path=str(request.url), + message=new_item, + ) + except Exception as e: + raise InternalException( + message=f"Error creating item: {str(e)}", error_code=ErrorCode.UNKNOWN_ERROR + ) + + +@router.put( + "/{item_id}", + summary="Update item", + response_model=ResponseSchema[ItemResponse], + responses=error_responses, +) +async def update_item(item_id: int, item: ItemCreate, request: Request): + try: + items = await read_items() + index = next((i for i, v in enumerate(items) if v["id"] == item_id), None) + if index is None: + raise InternalException( + message="Item not found", error_code=ErrorCode.NOT_FOUND + ) + updated_item = {**item.model_dump(), "id": item_id} + items[index] = updated_item + await write_items(items) + return ResponseSchema( + timestamp=datetime.now(UTC) + .isoformat(timespec="milliseconds") + .replace("+00:00", "Z"), + status=200, + code="HTTP-200", + path=str(request.url), + message=updated_item, + ) + except InternalException as e: + raise e + except Exception as e: + raise InternalException( + message=f"Error updating item: {str(e)}", error_code=ErrorCode.UNKNOWN_ERROR + ) + + +@router.delete( + "/{item_id}", + summary="Delete item", + response_model=ResponseSchema[str], + responses=error_responses, +) +async def delete_item(item_id: int, request: Request): + try: + items = await read_items() + new_items = [i for i in items if i["id"] != item_id] + if len(new_items) == len(items): + raise InternalException( + message="Item not found", error_code=ErrorCode.NOT_FOUND + ) + await write_items(new_items) + return ResponseSchema( + timestamp=datetime.now(UTC) + .isoformat(timespec="milliseconds") + .replace("+00:00", "Z"), + status=200, + code="HTTP-200", + path=str(request.url), + message="Item deleted successfully", + ) + except InternalException as e: + raise e + except Exception as e: + raise InternalException( + message=f"Error deleting item: {str(e)}", error_code=ErrorCode.UNKNOWN_ERROR + ) diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/core/__init__.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/core/__init__.py-tpl new file mode 100644 index 0000000..e69de29 diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/core/config.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/core/config.py-tpl new file mode 100644 index 0000000..7224095 --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/core/config.py-tpl @@ -0,0 +1,64 @@ +# -------------------------------------------------------------------------- +# Configuration module +# -------------------------------------------------------------------------- +import secrets +import warnings +from typing import Annotated, Any, Literal + +from pydantic import AnyUrl, BeforeValidator, computed_field, model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict +from typing_extensions import Self + + +def parse_cors(v: Any) -> list[str] | str: + if isinstance(v, str) and not v.startswith("["): + return [i.strip() for i in v.split(",")] + elif isinstance(v, list | str): + return v + raise ValueError(v) + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_ignore_empty=True, + extra="ignore", + ) + + SECRET_KEY: str = secrets.token_urlsafe(32) + ENVIRONMENT: Literal["development", "production"] = "development" + + CLIENT_ORIGIN: str = "" + + BACKEND_CORS_ORIGINS: Annotated[list[AnyUrl] | str, BeforeValidator(parse_cors)] = ( + [] + ) + + @computed_field # type: ignore[prop-decorator] + @property + def all_cors_origins(self) -> list[str]: + return [str(origin).rstrip("/") for origin in self.BACKEND_CORS_ORIGINS] + [ + self.CLIENT_ORIGIN + ] + + PROJECT_NAME: str = "" + + def _check_default_secret(self, var_name: str, value: str | None) -> None: + if value == "changethis": + message = ( + f'The value of {var_name} is "changethis", ' + "for security, please change it, at least for deployments." + ) + if self.ENVIRONMENT == "development": + warnings.warn(message, stacklevel=1) + else: + raise ValueError(message) + + @model_validator(mode="after") + def _enforce_non_default_secrets(self) -> Self: + self._check_default_secret("SECRET_KEY", self.SECRET_KEY) + + return self + + +settings = Settings() # type: ignore diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/crud/__init__.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/crud/__init__.py-tpl new file mode 100644 index 0000000..e69de29 diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/crud/items.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/crud/items.py-tpl new file mode 100644 index 0000000..33ca235 --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/crud/items.py-tpl @@ -0,0 +1,21 @@ +# -------------------------------------------------------------------------- +# Item CRUD method module +# -------------------------------------------------------------------------- +import json +import os +from typing import List + +import aiofiles # type: ignore + +MOCK_FILE = os.path.join(os.path.dirname(__file__), "../mocks/mock_items.json") + + +async def read_items(): + async with aiofiles.open(MOCK_FILE, mode="r") as file: + contents = await file.read() + return json.loads(contents) + + +async def write_items(items: List[dict]): + async with aiofiles.open(MOCK_FILE, mode="w") as file: + await file.write(json.dumps(items, indent=2)) diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/helper/__init__.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/helper/__init__.py-tpl new file mode 100644 index 0000000..e69de29 diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/helper/exceptions.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/helper/exceptions.py-tpl similarity index 88% rename from src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/helper/exceptions.py-tpl rename to src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/helper/exceptions.py-tpl index 7330fda..466bcda 100644 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/helper/exceptions.py-tpl +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/helper/exceptions.py-tpl @@ -1,12 +1,12 @@ # -------------------------------------------------------------------------- # The module defines custom Backend Application Exception class, overrides basic Exception. # -------------------------------------------------------------------------- +from datetime import UTC, datetime from enum import Enum -from datetime import datetime from pydantic import BaseModel, Field -from src.schemas import ResponseSchema +from src.schemas.base import ResponseSchema class ErrorCode(Enum): @@ -16,6 +16,7 @@ class ErrorCode(Enum): It is designed for custom code-based log filtering in third-party log monitoring systems by matching with key HTTP status codes. """ + # HTTP BAD_REQUEST = ("BAD_REQUEST", "HTTP-001", 400) NOT_FOUND = ("NOT_FOUND", "HTTP-002", 404) @@ -38,6 +39,12 @@ class ErrorCode(Enum): SERVICE_UNAVAILABLE = ("SERVICE_UNAVAILABLE", "SEVR-003", 503) GATEWAY_TIMEOUT = ("GATEWAY_TIMEOUT", "SEVR-004", 504) + # [Example] Specific Error Code for ITEM + ITEM_NOT_FOUND = ("ITEM_NOT_FOUND", "DATA-002", 404) + ITEM_CONFLICT = ("ITEM_CONFLICT", "DATA-003", 409) + + # < ADD YOUR ERROR CODES HERE > + def __init__(self, error: str, code: str, status_code: int): self.error = error self.code = code @@ -79,7 +86,9 @@ class ExceptionSchema(BaseModel): class InternalException(Exception): def __init__(self, message: str, error_code: ErrorCode): - self.timestamp = datetime.utcnow().isoformat() + "Z" + self.timestamp = ( + datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z") + ) self.status = error_code.status_code self.error_code = error_code.code self.message = f"ERROR : {message}" diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/helper/pagination.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/helper/pagination.py-tpl similarity index 95% rename from src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/helper/pagination.py-tpl rename to src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/helper/pagination.py-tpl index c471bff..fecb481 100644 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/helper/pagination.py-tpl +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/helper/pagination.py-tpl @@ -1,9 +1,10 @@ # -------------------------------------------------------------------------- # The module defines paginatable object schemas' helper methods. # -------------------------------------------------------------------------- -from typing import TypeVar, Generic, List, Optional -from pydantic import BaseModel, Field, AnyHttpUrl +from typing import Generic, List, Optional, TypeVar + from fastapi import Request +from pydantic import AnyHttpUrl, BaseModel, Field T = TypeVar("T") diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/main.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/main.py-tpl new file mode 100644 index 0000000..290c08b --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/main.py-tpl @@ -0,0 +1,39 @@ +# -------------------------------------------------------------------------- +# Main server application module +# -------------------------------------------------------------------------- +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +from starlette.middleware.cors import CORSMiddleware + +from src.api.api import api_router +from src.core.config import settings +from src.helper.exceptions import InternalException +from src.utils.documents import add_description_at_api_tags + +app = FastAPI( + title=settings.PROJECT_NAME, +) + +if settings.all_cors_origins: + app.add_middleware( + CORSMiddleware, + allow_origins=settings.all_cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + +# Apply Custom Exception Handler +@app.exception_handler(InternalException) +async def internal_exception_handler(request: Request, exc: InternalException): + return JSONResponse( + status_code=exc.status, + content=exc.to_response(path=str(request.url)).model_dump(), + ) + + +app.include_router(api_router) + +# Apply Custom OpenAPI Tags description +add_description_at_api_tags(app) diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/mocks/__init__.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/mocks/__init__.py-tpl new file mode 100644 index 0000000..e69de29 diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/mocks/mock_items.json-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/mocks/mock_items.json-tpl new file mode 100644 index 0000000..e73b08f --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/mocks/mock_items.json-tpl @@ -0,0 +1,8 @@ +[ + { + "id": 1, + "name": "Test Item 1", + "price": 20.0, + "category": "test" + } +] diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/schemas/__init__.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/schemas/__init__.py-tpl new file mode 100644 index 0000000..e69de29 diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/schemas/base.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/schemas/base.py-tpl new file mode 100644 index 0000000..732b783 --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/schemas/base.py-tpl @@ -0,0 +1,49 @@ +# -------------------------------------------------------------------------- +# The module defines custom base response schemas. +# -------------------------------------------------------------------------- +from typing import Generic, Optional, TypeVar + +from pydantic import BaseModel, Field + +T = TypeVar("T") + + +class ResponseSchema(BaseModel, Generic[T]): + timestamp: str = Field( + ..., + description="The timestamp when the response was generated.", + ) + status: int = Field(..., description="HTTP status code.") + code: str = Field( + ..., + description="Server identification code.", + ) + path: str = Field( + ..., + description="Request path.", + ) + message: T = Field( + ..., + description="Data details or error messages requested.", + ) + + class ConfigDict: + json_schema_extra = { + "example": { + "timestamp": "2023-02-10T01:00:00.000Z", + "status": 200, + "code": "HTTP-200", + "path": "/v1/", + "message": { + "id": "123e4567-e89b-12d3-a456-426614174000", + "email": "example@example.com", + "password": "secret", + "nickname": "example_nick", + "create_at": "2023-02-10T01:00:00.000Z", + "bio": "example bio", + "profile_img": "https://example.com/profile.jpg", + "first_name": "John", + "last_name": "Doe", + }, + } + } diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/schemas/items.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/schemas/items.py-tpl new file mode 100644 index 0000000..d9e0193 --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/schemas/items.py-tpl @@ -0,0 +1,22 @@ +# -------------------------------------------------------------------------- +# Item schema module +# -------------------------------------------------------------------------- +from typing import Optional + +from pydantic import BaseModel, ConfigDict + + +class ItemBase(BaseModel): + name: str + description: Optional[str] = None + price: float + category: str + + +class ItemCreate(ItemBase): + pass + + +class ItemResponse(ItemBase): + id: int + model_config = ConfigDict(from_attributes=True) diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/utils/__init__.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/utils/__init__.py-tpl new file mode 100644 index 0000000..e69de29 diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/utils/documents.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/utils/documents.py-tpl similarity index 88% rename from src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/utils/documents.py-tpl rename to src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/utils/documents.py-tpl index 3f878f8..b76e381 100644 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/src/utils/documents.py-tpl +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/src/utils/documents.py-tpl @@ -6,7 +6,7 @@ from fastapi import FastAPI def add_description_at_api_tags(app: FastAPI): tag_descriptions = { - "user": "User API. Performs user login/logout/get informations/etc...", + "items": "Item API. Performs item CRUD operations...", # put routers' description here after create and connect them. } diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/tests/__init__.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/tests/__init__.py-tpl new file mode 100644 index 0000000..e69de29 diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/tests/conftest.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/tests/conftest.py-tpl new file mode 100644 index 0000000..457c841 --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/tests/conftest.py-tpl @@ -0,0 +1,42 @@ +# -------------------------------------------------------------------------- +# pytest runtime configuration module +# -------------------------------------------------------------------------- +import asyncio +import os +import typing +from pathlib import Path + +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient + +from src.main import app + +MOCK_PATH = os.path.join(os.path.dirname(__file__), "../src/mocks/mock_items.json") + + +@pytest.fixture(scope="session") +def event_loop(request) -> typing.Generator: + loop = asyncio.get_event_loop() + yield loop + loop.close() + + +@pytest_asyncio.fixture(scope="module", autouse=True) +def mock_data_reset(): + # Back up original data into memory + mock_path = Path(__file__).parent.parent / "src/mocks/mock_items.json" + original_data = mock_path.read_text() + + yield + + # After test, restore original data + mock_path.write_text(original_data) + + +@pytest_asyncio.fixture(scope="module") +async def client(): + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as ac: + yield ac diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/tests/test_items.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/tests/test_items.py-tpl new file mode 100644 index 0000000..4fc1a17 --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/tests/test_items.py-tpl @@ -0,0 +1,61 @@ +# -------------------------------------------------------------------------- +# Item endpoint testcases +# -------------------------------------------------------------------------- +import pytest + + +@pytest.mark.asyncio +async def test_read_all_items(client): + response = await client.get("/items/") + assert response.status_code == 200 + data = response.json() + assert data["status"] == 200 + assert data["code"] == "HTTP-200" + assert isinstance(data["message"], list) + + +@pytest.mark.asyncio +async def test_get_item_success(client): + response = await client.get("/items/1") + assert response.status_code == 200 + data = response.json() + assert data["message"]["id"] == 1 + assert data["message"]["name"] == "Test Item 1" + + +@pytest.mark.asyncio +async def test_get_item_not_found(client): + response = await client.get("/items/999") + assert response.status_code == 404 + data = response.json() + assert data["code"] == "HTTP-002" + assert "Item not found" in data["message"] + + +@pytest.mark.asyncio +async def test_create_item(client): + test_item = {"name": "New Item", "price": 30.0, "category": "test"} + response = await client.post("/items/", json=test_item) + assert response.status_code == 201 + data = response.json() + assert data["message"]["id"] == 2 + + +@pytest.mark.asyncio +async def test_update_item(client): + update_data = {"name": "Updated Item", "price": 25.0, "category": "test"} + response = await client.put("/items/1", json=update_data) + assert response.status_code == 200 + data = response.json() + assert data["message"]["name"] == "Updated Item" + + +@pytest.mark.asyncio +async def test_delete_item(client): + # First delete + response = await client.delete("/items/1") + assert response.status_code == 200 + + # Verify deletion + verify_response = await client.get("/items/1") + assert verify_response.status_code == 404 diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-default/README.md-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-default/README.md-tpl index 608287a..6f5e7c0 100644 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-default/README.md-tpl +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-default/README.md-tpl @@ -2,13 +2,7 @@ Simple CRUD API application using FastAPI -## Features - -- Item Creation, Read, Update, Delete (CRUD) functionality (For Demo) -- Automatic OpenAPI documentation generation (Swagger UI, ReDoc) -- Test environment with mock data - -## Stack +## Tech Stack - Python 3.12+ - FastAPI + uvicorn @@ -16,22 +10,92 @@ Simple CRUD API application using FastAPI - pytest - mypy + black + isort +## Project Structure + +``` +. +├── README.md +├── requirements.txt +├── setup.py +├── scripts +│   └── run-server.sh +├── src +│   ├── main.py +│   ├── schemas +│   │   └── items.py +│   ├── mocks +│   │   └── mock_items.json +│   ├── crud +│   │   └── items.py +│   ├── core +│   │   └── config.py +│   └── api +│   ├── api.py +│   └── routes +│   └── items.py +└── tests + ├── __init__.py + ├── test_items.py + └── conftest.py +``` + ## How to run At venv, run command below: ```bash +# Start development server (using script) +$ bash scripts/run-server.sh + +# Manual run $ uvicorn src.main:app --reload ``` After running server instance, you can check API documentation below: ```bash -/docs # Swagger format -/redoc # ReDoc format +/docs # Swagger format +/redoc # ReDoc format # example -http://127.0.0.1/docs +http://127.0.0.1:8000/docs +``` + +## API Endpoints + +| Method | Endpoint | Description | +|--------|------------------|----------------------------| +| GET | `/items/` | List all items | +| GET | `/items/{id}` | Get single item | +| POST | `/items/` | Create new item | +| PUT | `/items/{id}` | Update existing item | +| DELETE | `/items/{id}` | Delete item | + +## Key Features + +- Initial data loading with mock data +- Pydantic model-based data validation +- Demo CRUD operations implementation (Item CRUD) +- Integrated test cases + +## Running Tests + +```bash +# Run all tests +pytest tests/ + +# Run specific test file +pytest tests/test_items.py -v ``` For other FastAPI guides, please refer + +# Project Origin + +This project was created based on the template from the [FastAPI-fastkit](https://github.com/bnbong/FastAPI-fastkit) project. + +The `FastAPI-fastkit` is an open-source project that helps Python and FastAPI beginners quickly set up a FastAPI-based application development environment in a framework-like structure. + +### Template Information +- Template creator: [bnbong](mailto:bbbong9@gmail.com) +- FastAPI-fastkit project maintainer: [bnbong](mailto:bbbong9@gmail.com) diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-default/scripts/run-server.sh-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-default/scripts/run-server.sh-tpl new file mode 100644 index 0000000..eb760a2 --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-default/scripts/run-server.sh-tpl @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -e +set -x + +source .venv/bin/activate + +uvicorn src.main:app --reload diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-default/setup.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-default/setup.py-tpl index 31ea687..02260dc 100644 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-default/setup.py-tpl +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-default/setup.py-tpl @@ -16,7 +16,6 @@ setup( author="", author_email=f"", packages=find_packages(where="src"), - use_scm_version=True, requires=["python (>=3.12)"], install_requires=install_requires, ) diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-default/src/mocks/mock_items.json-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-default/src/mocks/mock_items.json-tpl index c9dddfc..e73b08f 100644 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-default/src/mocks/mock_items.json-tpl +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-default/src/mocks/mock_items.json-tpl @@ -1,7 +1,7 @@ [ { - "id": 2, - "name": "Test Item 2", + "id": 1, + "name": "Test Item 1", "price": 20.0, "category": "test" } diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-default/tests/conftest.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-default/tests/conftest.py-tpl index 6bcd075..7767d47 100644 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-default/tests/conftest.py-tpl +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-default/tests/conftest.py-tpl @@ -3,11 +3,26 @@ # -------------------------------------------------------------------------- from collections.abc import Generator +import os +import json import pytest from fastapi.testclient import TestClient from src.main import app +MOCK_FILE = os.path.join(os.path.dirname(__file__), "../src/mocks/mock_items.json") + + +@pytest.fixture(autouse=True) +def reset_mock_data(): + initial_data = [ + {"id": 1, "name": "Test Item 1", "price": 10.5, "category": "test"}, + {"id": 2, "name": "Test Item 2", "price": 20.0, "category": "test"}, + ] + with open(MOCK_FILE, "w") as file: + json.dump(initial_data, file) + yield + @pytest.fixture(scope="module") def client() -> Generator[TestClient, None, None]: diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-default/tests/test_items.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-default/tests/test_items.py-tpl index 8fcabcb..6d4f26f 100644 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-default/tests/test_items.py-tpl +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-default/tests/test_items.py-tpl @@ -2,60 +2,42 @@ # Item endpoint testcases # -------------------------------------------------------------------------- import json -import os import pytest -from fastapi.testclient import TestClient -from src.main import app -client = TestClient(app) -MOCK_FILE = os.path.join(os.path.dirname(__file__), "../src/mocks/mock_items.json") - - -@pytest.fixture(autouse=True) -def reset_mock_data(): - initial_data = [ - {"id": 1, "name": "Test Item 1", "price": 10.5, "category": "test"}, - {"id": 2, "name": "Test Item 2", "price": 20.0, "category": "test"}, - ] - with open(MOCK_FILE, "w") as file: - json.dump(initial_data, file) - yield - - -def test_read_all_items(): +def test_read_all_items(client): response = client.get("/items") assert response.status_code == 200 assert len(response.json()) == 2 -def test_read_item_success(): +def test_read_item_success(client): response = client.get("/items/1") assert response.status_code == 200 assert response.json()["name"] == "Test Item 1" -def test_read_item_not_found(): +def test_read_item_not_found(client): response = client.get("/items/999") assert response.status_code == 404 -def test_create_item(): +def test_create_item(client): new_item = {"name": "New Item", "price": 30.0, "category": "new"} response = client.post("/items", json=new_item) assert response.status_code == 201 assert response.json()["id"] == 3 -def test_update_item(): +def test_update_item(client): updated_data = {"name": "Updated Item", "price": 15.0, "category": "updated"} response = client.put("/items/1", json=updated_data) assert response.status_code == 200 assert response.json()["name"] == "Updated Item" -def test_delete_item(): +def test_delete_item(client): response = client.delete("/items/1") assert response.status_code == 200 response = client.get("/items/1") diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/.env-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/.env-tpl new file mode 100644 index 0000000..c9d6bec --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/.env-tpl @@ -0,0 +1 @@ +SECRET_KEY=changethis diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/.gitignore-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/.gitignore-tpl new file mode 100644 index 0000000..ef6364a --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/.gitignore-tpl @@ -0,0 +1,30 @@ +.idea +.ipynb_checkpoints +.mypy_cache +.vscode +__pycache__ +.pytest_cache +htmlcov +dist +site +.coverage* +coverage.xml +.netlify +test.db +log.txt +Pipfile.lock +env3.* +env +docs_build +site_build +venv +docs.zip +archive.zip + +# vim temporary files +*~ +.*.sw? +.cache + +# macOS +.DS_Store diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/Dockerfile-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/Dockerfile-tpl new file mode 100644 index 0000000..898215f --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/Dockerfile-tpl @@ -0,0 +1,23 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +COPY setup.py . +COPY src/ src/ +COPY tests/ tests/ + +RUN pip install --no-cache-dir -r requirements.txt +RUN pip install --no-cache-dir -e . + +ENV PYTHONPATH=/app +ENV ENVIRONMENT=production + +EXPOSE 8000 + +CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/README.md-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/README.md-tpl index e69de29..547293c 100644 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/README.md-tpl +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/README.md-tpl @@ -0,0 +1,119 @@ +# Dockerized FastAPI Item Management API + +This project is a Docker containerized FastAPI-based item management API application. + +## Requirements + +- Docker + +## Tech Stack + +- Python 3.12+ +- FastAPI + uvicorn +- Docker +- pydantic 2 & pydantic-settings +- pytest +- mypy + black + isort + +## Project Structure + +``` +. +├── Dockerfile +├── README.md +├── requirements.txt +├── setup.py +├── scripts +│   └── run-server.sh +├── src +│   ├── main.py +│   ├── schemas +│   │   └── items.py +│   ├── mocks +│   │   └── mock_items.json +│   ├── crud +│   │   └── items.py +│   ├── core +│   │   └── config.py +│   └── api +│   ├── api.py +│   └── routes +│   └── items.py +└── tests + ├── __init__.py + ├── test_items.py + └── conftest.py +``` + +## How to run + +### Run with Docker + +```bash +# Build image +$ docker build -t fastapi-item-app . + +# Run container +$ docker run -d --name item-container -p 8000:8000 fastapi-item-app +``` + +### Run with instance + +At venv, run command below: + +```bash +# Start development server (using script) +$ bash scripts/run-server.sh + +# Manual run +$ uvicorn src.main:app --reload +``` + +After running server instance, you can check API documentation below: + +```bash +/docs # Swagger format +/redoc # ReDoc format + +# example +http://127.0.0.1:8000/docs +``` + +## API Endpoints + +| Method | Endpoint | Description | +|--------|------------------|----------------------------| +| GET | `/items/` | List all items | +| GET | `/items/{id}` | Get single item | +| POST | `/items/` | Create new item | +| PUT | `/items/{id}` | Update existing item | +| DELETE | `/items/{id}` | Delete item | + +## Key Features + +- Initial data loading with mock data +- Pydantic model-based data validation +- Demo CRUD operations implementation (Item CRUD) +- Integrated test cases + +## Running Tests + +```bash +# Run all tests +$ pytest tests/ + +# Run specific test file +$ pytest tests/test_items.py -v +``` + +For other FastAPI guides, please refer + +# Project Origin + +This project was created based on the template from the [FastAPI-fastkit](https://github.com/bnbong/FastAPI-fastkit) project. + +The `FastAPI-fastkit` is an open-source project that helps Python and FastAPI beginners quickly set up a FastAPI-based application development environment in a framework-like structure. + +### Template Information +- Template creator: [bnbong](mailto:bbbong9@gmail.com) +- FastAPI-fastkit project maintainer: [bnbong](mailto:bbbong9@gmail.com) diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/requirements.txt-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/requirements.txt-tpl new file mode 100644 index 0000000..13f3a6a --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/requirements.txt-tpl @@ -0,0 +1,36 @@ +annotated-types==0.7.0 +anyio==4.8.0 +black==25.1.0 +certifi==2025.1.31 +click==8.1.8 +fastapi==0.115.8 +h11==0.14.0 +httpcore==1.0.7 +httptools==0.6.4 +httpx==0.28.1 +idna==3.10 +iniconfig==2.0.0 +isort==6.0.0 +mypy==1.15.0 +mypy-extensions==1.0.0 +packaging==24.2 +pathspec==0.12.1 +platformdirs==4.3.6 +pluggy==1.5.0 +pydantic==2.10.6 +pydantic-settings==2.7.1 +pydantic_core==2.27.2 +pytest==8.3.4 +python-dotenv==1.0.1 +PyYAML==6.0.2 +setuptools==75.8.0 +setuptools_scm==8.1.0 +sniffio==1.3.1 +SQLAlchemy==2.0.38 +sqlmodel==0.0.22 +starlette==0.45.3 +typing_extensions==4.12.2 +uvicorn==0.34.0 +uvloop==0.21.0 +watchfiles==1.0.4 +websockets==15.0 diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/scripts/format.sh-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/scripts/format.sh-tpl new file mode 100644 index 0000000..abdd14e --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/scripts/format.sh-tpl @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -x + +black . +isort . diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/scripts/lint.sh-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/scripts/lint.sh-tpl new file mode 100644 index 0000000..08e929f --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/scripts/lint.sh-tpl @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -x + +black . --check +mypy src diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/scripts/run-server.sh-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/scripts/run-server.sh-tpl new file mode 100644 index 0000000..eb760a2 --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/scripts/run-server.sh-tpl @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -e +set -x + +source .venv/bin/activate + +uvicorn src.main:app --reload diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/scripts/test.sh-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/scripts/test.sh-tpl new file mode 100644 index 0000000..048272d --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/scripts/test.sh-tpl @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +set -e +set -x + +pytest diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/setup.cfg-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/setup.cfg-tpl new file mode 100644 index 0000000..f8ecdbe --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/setup.cfg-tpl @@ -0,0 +1,13 @@ +[mypy] +warn_unused_configs = true +ignore_missing_imports = true + +[isort] +profile = black +line_length=100 +virtual_env=venv + +[tool:pytest] +pythonpath = src +testpaths = tests +python_files = test_*.py diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/setup.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/setup.py-tpl new file mode 100644 index 0000000..02260dc --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/setup.py-tpl @@ -0,0 +1,21 @@ +# -------------------------------------------------------------------------- +# package setup module +# -------------------------------------------------------------------------- +from setuptools import find_packages, setup + +install_requires: list[str] = [ + "fastapi", + "pydantic", + "pydantic-settings", + "python-dotenv", +] + +setup( + name="", + description="[FastAPI-fastkit templated] ", + author="", + author_email=f"", + packages=find_packages(where="src"), + requires=["python (>=3.12)"], + install_requires=install_requires, +) diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/__init__.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/__init__.py-tpl new file mode 100644 index 0000000..e69de29 diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/api/__init__.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/api/__init__.py-tpl new file mode 100644 index 0000000..e69de29 diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/api/api.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/api/api.py-tpl new file mode 100644 index 0000000..ada37f0 --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/api/api.py-tpl @@ -0,0 +1,6 @@ +from fastapi import APIRouter + +from src.api.routes import items + +api_router = APIRouter() +api_router.include_router(items.router) diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/api/routes/__init__.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/api/routes/__init__.py-tpl new file mode 100644 index 0000000..e69de29 diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/api/routes/items.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/api/routes/items.py-tpl new file mode 100644 index 0000000..159c4f7 --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/api/routes/items.py-tpl @@ -0,0 +1,54 @@ +from typing import List + +from fastapi import APIRouter, HTTPException + +from src.crud.items import read_items, write_items +from src.schemas.items import ItemCreate, ItemResponse + +router = APIRouter() + + +@router.get("/items", response_model=List[ItemResponse]) +async def read_all_items(): + return read_items() + + +@router.get("/items/{item_id}", response_model=ItemResponse) +async def read_item(item_id: int): + items = read_items() + item = next((i for i in items if i["id"] == item_id), None) + if not item: + raise HTTPException(status_code=404, detail="Item not found") + return item + + +@router.post("/items", response_model=ItemResponse, status_code=201) +async def create_item(item: ItemCreate): + items = read_items() + new_id = max(i["id"] for i in items) + 1 if items else 1 + new_item = {**item.model_dump(), "id": new_id} + items.append(new_item) + write_items(items) + return new_item + + +@router.put("/items/{item_id}", response_model=ItemResponse) +async def update_item(item_id: int, item: ItemCreate): + items = read_items() + index = next((i for i, v in enumerate(items) if v["id"] == item_id), None) + if index is None: + raise HTTPException(status_code=404, detail="Item not found") + updated_item = {**item.model_dump(), "id": item_id} + items[index] = updated_item + write_items(items) + return updated_item + + +@router.delete("/items/{item_id}") +async def delete_item(item_id: int): + items = read_items() + new_items = [i for i in items if i["id"] != item_id] + if len(new_items) == len(items): + raise HTTPException(status_code=404, detail="Item not found") + write_items(new_items) + return {"message": "Item deleted successfully"} diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/core/__init__.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/core/__init__.py-tpl new file mode 100644 index 0000000..e69de29 diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/core/config.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/core/config.py-tpl new file mode 100644 index 0000000..7224095 --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/core/config.py-tpl @@ -0,0 +1,64 @@ +# -------------------------------------------------------------------------- +# Configuration module +# -------------------------------------------------------------------------- +import secrets +import warnings +from typing import Annotated, Any, Literal + +from pydantic import AnyUrl, BeforeValidator, computed_field, model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict +from typing_extensions import Self + + +def parse_cors(v: Any) -> list[str] | str: + if isinstance(v, str) and not v.startswith("["): + return [i.strip() for i in v.split(",")] + elif isinstance(v, list | str): + return v + raise ValueError(v) + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_ignore_empty=True, + extra="ignore", + ) + + SECRET_KEY: str = secrets.token_urlsafe(32) + ENVIRONMENT: Literal["development", "production"] = "development" + + CLIENT_ORIGIN: str = "" + + BACKEND_CORS_ORIGINS: Annotated[list[AnyUrl] | str, BeforeValidator(parse_cors)] = ( + [] + ) + + @computed_field # type: ignore[prop-decorator] + @property + def all_cors_origins(self) -> list[str]: + return [str(origin).rstrip("/") for origin in self.BACKEND_CORS_ORIGINS] + [ + self.CLIENT_ORIGIN + ] + + PROJECT_NAME: str = "" + + def _check_default_secret(self, var_name: str, value: str | None) -> None: + if value == "changethis": + message = ( + f'The value of {var_name} is "changethis", ' + "for security, please change it, at least for deployments." + ) + if self.ENVIRONMENT == "development": + warnings.warn(message, stacklevel=1) + else: + raise ValueError(message) + + @model_validator(mode="after") + def _enforce_non_default_secrets(self) -> Self: + self._check_default_secret("SECRET_KEY", self.SECRET_KEY) + + return self + + +settings = Settings() # type: ignore diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/crud/__init__.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/crud/__init__.py-tpl new file mode 100644 index 0000000..e69de29 diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/crud/items.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/crud/items.py-tpl new file mode 100644 index 0000000..2d7b81f --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/crud/items.py-tpl @@ -0,0 +1,15 @@ +import json +import os +from typing import List + +MOCK_FILE = os.path.join(os.path.dirname(__file__), "../mocks/mock_items.json") + + +def read_items(): + with open(MOCK_FILE, "r") as file: + return json.load(file) + + +def write_items(items: List[dict]): + with open(MOCK_FILE, "w") as file: + json.dump(items, file, indent=2) diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/main.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/main.py-tpl new file mode 100644 index 0000000..7a6bb3e --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/main.py-tpl @@ -0,0 +1,26 @@ +# -------------------------------------------------------------------------- +# Main server application module +# -------------------------------------------------------------------------- +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from starlette.middleware.cors import CORSMiddleware + +from src.api.api import api_router +from src.core.config import settings + +app = FastAPI( + title=settings.PROJECT_NAME, +) + +if settings.all_cors_origins: + app.add_middleware( + CORSMiddleware, + allow_origins=settings.all_cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + +app.include_router(api_router) diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/mocks/__init__.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/mocks/__init__.py-tpl new file mode 100644 index 0000000..e69de29 diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/mocks/mock_items.json-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/mocks/mock_items.json-tpl new file mode 100644 index 0000000..e73b08f --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/mocks/mock_items.json-tpl @@ -0,0 +1,8 @@ +[ + { + "id": 1, + "name": "Test Item 1", + "price": 20.0, + "category": "test" + } +] diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/schemas/__init__.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/schemas/__init__.py-tpl new file mode 100644 index 0000000..e69de29 diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/schemas/items.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/schemas/items.py-tpl new file mode 100644 index 0000000..9229e98 --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/src/schemas/items.py-tpl @@ -0,0 +1,19 @@ +from typing import Optional + +from pydantic import BaseModel, ConfigDict + + +class ItemBase(BaseModel): + name: str + description: Optional[str] = None + price: float + category: str + + +class ItemCreate(ItemBase): + pass + + +class ItemResponse(ItemBase): + id: int + model_config = ConfigDict(from_attributes=True) diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/tests/__init__.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/tests/__init__.py-tpl new file mode 100644 index 0000000..e69de29 diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/tests/conftest.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/tests/conftest.py-tpl new file mode 100644 index 0000000..7767d47 --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/tests/conftest.py-tpl @@ -0,0 +1,30 @@ +# -------------------------------------------------------------------------- +# pytest runtime configuration module +# -------------------------------------------------------------------------- +from collections.abc import Generator + +import os +import json +import pytest +from fastapi.testclient import TestClient + +from src.main import app + +MOCK_FILE = os.path.join(os.path.dirname(__file__), "../src/mocks/mock_items.json") + + +@pytest.fixture(autouse=True) +def reset_mock_data(): + initial_data = [ + {"id": 1, "name": "Test Item 1", "price": 10.5, "category": "test"}, + {"id": 2, "name": "Test Item 2", "price": 20.0, "category": "test"}, + ] + with open(MOCK_FILE, "w") as file: + json.dump(initial_data, file) + yield + + +@pytest.fixture(scope="module") +def client() -> Generator[TestClient, None, None]: + with TestClient(app) as c: + yield c diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/tests/test_items.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/tests/test_items.py-tpl new file mode 100644 index 0000000..6d4f26f --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/tests/test_items.py-tpl @@ -0,0 +1,44 @@ +# -------------------------------------------------------------------------- +# Item endpoint testcases +# -------------------------------------------------------------------------- +import json + +import pytest + + +def test_read_all_items(client): + response = client.get("/items") + assert response.status_code == 200 + assert len(response.json()) == 2 + + +def test_read_item_success(client): + response = client.get("/items/1") + assert response.status_code == 200 + assert response.json()["name"] == "Test Item 1" + + +def test_read_item_not_found(client): + response = client.get("/items/999") + assert response.status_code == 404 + + +def test_create_item(client): + new_item = {"name": "New Item", "price": 30.0, "category": "new"} + response = client.post("/items", json=new_item) + assert response.status_code == 201 + assert response.json()["id"] == 3 + + +def test_update_item(client): + updated_data = {"name": "Updated Item", "price": 15.0, "category": "updated"} + response = client.put("/items/1", json=updated_data) + assert response.status_code == 200 + assert response.json()["name"] == "Updated Item" + + +def test_delete_item(client): + response = client.delete("/items/1") + assert response.status_code == 200 + response = client.get("/items/1") + assert response.status_code == 404 diff --git a/tests/test_templates/test_fastapi-async-crud.py b/tests/test_templates/test_fastapi-async-crud.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_templates/test_fastapi-customized-response.py b/tests/test_templates/test_fastapi-customized-response.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_templates/test_fastapi-default.py b/tests/test_templates/test_fastapi-default.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_templates/test_fastapi-dockerized.py b/tests/test_templates/test_fastapi-dockerized.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_templates/test_fastapi-psql-orm.py b/tests/test_templates/test_fastapi-psql-orm.py new file mode 100644 index 0000000..e69de29 From 575f55593d5de92cc4f3c876b925900f1c0b4c4f Mon Sep 17 00:00:00 2001 From: bnbong Date: Fri, 28 Feb 2025 01:26:52 +0900 Subject: [PATCH 3/5] [ADD, TEMPLATE] edit core cli operations, refactored backend module, add fastapi-empty, module template --- README.md | 38 +- src/fastapi_fastkit/__init__.py | 2 +- src/fastapi_fastkit/backend/main.py | 435 +++++++++++++++--- src/fastapi_fastkit/backend/transducer.py | 36 ++ src/fastapi_fastkit/cli.py | 264 +++++++++-- src/fastapi_fastkit/core/settings.py | 14 +- .../fastapi_project_template/README.md | 20 + .../{script => scripts}/format.sh-tpl | 0 .../{script => scripts}/lint.sh-tpl | 0 .../{script => scripts}/run-server.sh-tpl | 0 .../{script => scripts}/test.sh-tpl | 0 .../fastapi-empty/.env-tpl | 1 + .../fastapi-empty/setup.py-tpl | 21 + .../fastapi-empty/src/__init__.py-tpl | 0 .../fastapi-empty/src/core/__init__.py-tpl | 0 .../fastapi-empty/src/core/config.py-tpl | 75 +++ .../fastapi-empty/src/main.py-tpl | 36 ++ .../modules/api/__init__.py-tpl | 0 .../modules/api/routes/__init__.py-tpl | 0 .../modules/api/routes/new_route.py-tpl | 64 +++ .../modules/crud/__init__.py-tpl | 0 .../modules/crud/new_route.py-tpl | 29 ++ .../modules/schemas/__init__.py-tpl | 0 .../modules/schemas/new_route.py-tpl | 23 + src/fastapi_fastkit/utils/main.py | 49 +- tests/test_cli_operations/test_cli.py | 70 ++- 26 files changed, 1040 insertions(+), 137 deletions(-) rename src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/{script => scripts}/format.sh-tpl (100%) rename src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/{script => scripts}/lint.sh-tpl (100%) rename src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/{script => scripts}/run-server.sh-tpl (100%) rename src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/{script => scripts}/test.sh-tpl (100%) create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-empty/.env-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-empty/setup.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-empty/src/__init__.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-empty/src/core/__init__.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-empty/src/core/config.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-empty/src/main.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/modules/api/__init__.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/modules/api/routes/__init__.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/modules/api/routes/new_route.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/modules/crud/__init__.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/modules/crud/new_route.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/modules/schemas/__init__.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/modules/schemas/new_route.py-tpl diff --git a/README.md b/README.md index 38c6031..432745b 100644 --- a/README.md +++ b/README.md @@ -42,8 +42,11 @@ $ pip install FastAPI-fastkit Create a new FastAPI project workspace with: ```console -$ fastkit startproject -Enter project name: +$ fastkit init +Enter the project name: +Enter the author name: +Enter the author email: +Enter the project description: Available Stacks and Dependencies: MINIMAL Stack @@ -98,16 +101,43 @@ Installing dependencies... │ │ │ source //venv/bin/activate │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Success ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ ✨ Dependencies installed successfully │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Success ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ ✨ FastAPI project '' has been created successfully and saved to ''! │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Info ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ ℹ To start your project, run 'fastkit runserver' at newly created FastAPI project directory │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ``` This command will create a new FastAPI project workspace environment with Python virtual environment. +### Add a new route to the FastAPI project + +Add a new route to the FastAPI project with: + +```console +$ fastkit addroute + +---> 100% + +╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Info ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ ℹ Updated main.py to include the API router │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Success ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ ✨ Successfully added new route '' to project ''. │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +``` + + ### Place a structured FastAPI project immediately -Place a structured FastAPI project immediately with: +Place a structured FastAPI demo project immediately with: ```console -$ fastkit startup +$ fastkit startdemo Enter the project name: Enter the author name: Enter the author email: diff --git a/src/fastapi_fastkit/__init__.py b/src/fastapi_fastkit/__init__.py index 177bd01..77fac73 100644 --- a/src/fastapi_fastkit/__init__.py +++ b/src/fastapi_fastkit/__init__.py @@ -1,4 +1,4 @@ -__version__ = "0.1.3" +__version__ = "0.1.4" import os diff --git a/src/fastapi_fastkit/backend/main.py b/src/fastapi_fastkit/backend/main.py index a43c1a2..4d7dd27 100644 --- a/src/fastapi_fastkit/backend/main.py +++ b/src/fastapi_fastkit/backend/main.py @@ -5,17 +5,32 @@ # -------------------------------------------------------------------------- import os import subprocess +from typing import Dict, List from fastapi_fastkit import console +from fastapi_fastkit.backend.transducer import copy_and_convert_template_file from fastapi_fastkit.core.exceptions import BackendExceptions, TemplateExceptions from fastapi_fastkit.core.settings import settings -from fastapi_fastkit.utils.main import print_error, print_info +from fastapi_fastkit.utils.main import ( + handle_exception, + print_error, + print_info, + print_success, + print_warning, +) +# ------------------------------------------------------------ +# Template Discovery Functions +# ------------------------------------------------------------ -def find_template_core_modules(project_dir: str) -> dict[str, str]: + +def find_template_core_modules(project_dir: str) -> Dict[str, str]: """ Find core module files in the template project structure. Returns a dictionary with paths to main.py, setup.py, and config files. + + :param project_dir: Path to the project directory + :return: Dictionary with paths to core modules """ core_modules = {"main": "", "setup": "", "config": ""} template_config = settings.TEMPLATE_PATHS["config"] @@ -34,7 +49,7 @@ def find_template_core_modules(project_dir: str) -> dict[str, str]: core_modules["setup"] = full_path break - # Find config files (settings.py or config.py) + # Find config files if isinstance(template_config, dict): for config_file in template_config.get("files", []): for base_path in template_config.get("paths", []): @@ -48,6 +63,48 @@ def find_template_core_modules(project_dir: str) -> dict[str, str]: return core_modules +def read_template_stack(template_path: str) -> List[str]: + """ + Read the install_requires from setup.py-tpl in the template directory. + Returns a list of required packages. + + :param template_path: Path to the template directory + :return: List of required packages + """ + setup_path = os.path.join(template_path, "setup.py-tpl") + if not os.path.exists(setup_path): + return [] + + try: + with open(setup_path, "r") as f: + content = f.read() + # Find the install_requires section using proper string matching + if "install_requires: list[str] = [" in content: + start_idx = content.find("install_requires: list[str] = [") + len( + "install_requires: list[str] = [" + ) + end_idx = content.find("]", start_idx) + if start_idx != -1 and end_idx != -1: + deps_str = content[start_idx:end_idx] + # Clean and parse the dependencies + deps = [ + dep.strip().strip("'").strip('"') + for dep in deps_str.split(",") + if dep.strip() and not dep.isspace() + ] + return [dep for dep in deps if dep] # Remove empty strings + except Exception as e: + handle_exception(e, "Error reading template dependencies") + return [] + + return [] + + +# ------------------------------------------------------------ +# Project Setup Functions +# ------------------------------------------------------------ + + def inject_project_metadata( target_dir: str, project_name: str, @@ -60,42 +117,54 @@ def inject_project_metadata( """ try: core_modules = find_template_core_modules(target_dir) + setup_py = core_modules.get("setup", "") + config_py = core_modules.get("config", "") - # Inject metadata to main.py - if core_modules["main"]: - with open(core_modules["main"], "r+") as f: + if setup_py and os.path.exists(setup_py): + with open(setup_py, "r") as f: content = f.read() - content = content.replace("app_title", f'"{project_name}"') - content = content.replace("app_description", f'"{description}"') - f.seek(0) - f.write(content) - f.truncate() - # Inject metadata to setup.py - if core_modules["setup"]: - with open(core_modules["setup"], "r+") as f: - content = f.read() - content = content.replace("", project_name, 1) - content = content.replace("", description, 1) - content = content.replace("", author, 1) - content = content.replace("", author_email, 1) - f.seek(0) + # Replace placeholders + content = content.replace("", project_name) + content = content.replace("", author) + content = content.replace("", author_email) + content = content.replace("", description) + + with open(setup_py, "w") as f: f.write(content) - f.truncate() + print_info("Injected metadata into setup.py") - # Inject metadata to config files - if core_modules["config"]: - with open(core_modules["config"], "r+") as f: + if config_py and os.path.exists(config_py): + with open(config_py, "r") as f: content = f.read() - content = content.replace("", project_name) - content = content.replace("", description) - f.seek(0) + + content = content.replace("", project_name) + + with open(config_py, "w") as f: f.write(content) - f.truncate() + print_info("Injected metadata into config file") + + # Try to find the readme file + readme_files = ["README.md", "Readme.md", "readme.md"] + for readme in readme_files: + readme_path = os.path.join(target_dir, readme) + if os.path.exists(readme_path): + with open(readme_path, "r") as f: + content = f.read() + + content = content.replace( + "{FASTAPI TEMPLATE PROJECT NAME}", project_name + ) + content = content.replace("{fill here}", author) + + with open(readme_path, "w") as f: + f.write(content) + print_info("Injected metadata into README.md") + break except Exception as e: - print_error(f"Error during metadata injection: {e}") - raise TemplateExceptions("Failed to inject metadata") + handle_exception(e, "Failed to inject project metadata") + raise BackendExceptions("Failed to inject project metadata") def create_venv(project_dir: str) -> str: @@ -125,67 +194,289 @@ def create_venv(project_dir: str) -> str: def install_dependencies(project_dir: str, venv_path: str) -> None: - """Install project dependencies in the virtual environment.""" + """ + Install project dependencies into the virtual environment. + + :param project_dir: Path to the project directory + :param venv_path: Path to the virtual environment + :return: None + """ try: + if not os.path.exists(venv_path): + print_error("Virtual environment does not exist. Creating it first.") + venv_path = create_venv(project_dir) + if not venv_path: + raise BackendExceptions("Failed to create virtual environment") + + requirements_path = os.path.join(project_dir, "requirements.txt") + if not os.path.exists(requirements_path): + print_error(f"Requirements file not found at {requirements_path}") + raise BackendExceptions("Requirements file not found") + if os.name == "nt": # Windows pip_path = os.path.join(venv_path, "Scripts", "pip") - else: # Linux/Mac + else: # Unix-based pip_path = os.path.join(venv_path, "bin", "pip") + # Upgrade pip + subprocess.run( + [pip_path, "install", "--upgrade", "pip"], + check=True, + capture_output=True, + text=True, + ) + with console.status("[bold green]Installing dependencies..."): subprocess.run( [pip_path, "install", "-r", "requirements.txt"], cwd=project_dir, check=True, ) - if os.name == "nt": - activate_venv = f" {os.path.join(venv_path, 'Scripts', 'activate.bat')}" - else: - activate_venv = f" source {os.path.join(venv_path, 'bin', 'activate')}" - print_info( - "Dependencies installed successfully." - + "\nTo activate the virtual environment, run:\n\n" - + activate_venv, - ) + print_success("Dependencies installed successfully") - except Exception as e: - print_error(f"Error during dependency installation: {e}") + except subprocess.CalledProcessError as e: + handle_exception(e, f"Error during dependency installation: {str(e)}") + if hasattr(e, "stderr"): + print_error(f"Error details: {e.stderr}") raise BackendExceptions("Failed to install dependencies") + except Exception as e: + handle_exception(e, f"Error during dependency installation: {str(e)}") + raise BackendExceptions(f"Failed to install dependencies: {str(e)}") -def read_template_stack(template_path: str) -> list[str]: +# ------------------------------------------------------------ +# Add Route Functions +# ------------------------------------------------------------ + + +def _ensure_project_structure(src_dir: str) -> Dict[str, str]: """ - Read the install_requires from setup.py-tpl in the template directory. - Returns a list of required packages. + Ensure the project structure exists for adding a new route. + Creates necessary directories if they don't exist. - :param template_path: Path to the template directory - :return: List of required packages + :param src_dir: Source directory of the project + :return: Dictionary with paths to target directories """ - setup_path = os.path.join(template_path, "setup.py-tpl") - if not os.path.exists(setup_path): - return [] + if not os.path.exists(src_dir): + raise BackendExceptions(f"Source directory not found at {src_dir}") + + # Define target directories + target_dirs = { + "api": os.path.join(src_dir, "api"), + "api_routes": os.path.join(src_dir, "api", "routes"), + "crud": os.path.join(src_dir, "crud"), + "schemas": os.path.join(src_dir, "schemas"), + } + # Create directories and __init__.py files if needed + for dir_name, dir_path in target_dirs.items(): + if not os.path.exists(dir_path): + os.makedirs(dir_path) + + # Create __init__.py if it doesn't exist + init_file = os.path.join(dir_path, "__init__.py") + if not os.path.exists(init_file): + with open(init_file, "w") as f: + pass + + return target_dirs + + +def _create_route_files( + modules_dir: str, target_dirs: Dict[str, str], route_name: str +) -> None: + """ + Create route files from templates. + + :param modules_dir: Path to the modules directory + :param target_dirs: Dictionary with paths to target directories + :param route_name: Name of the route to create + """ + replacements = {"": route_name} + module_types = ["api/routes", "crud", "schemas"] + + for module_type in module_types: + # Source template + source = os.path.join(modules_dir, module_type, "new_route.py-tpl") + + # Target directory + if module_type == "api/routes": + target_dir = target_dirs["api_routes"] + else: + target_dir = target_dirs[module_type.split("/")[-1]] + + # Target file + target = os.path.join(target_dir, f"{route_name}.py") + + if os.path.exists(target): + print_warning(f"File {target} already exists, skipping...") + continue + + if not copy_and_convert_template_file(source, target, replacements): + print_warning(f"Failed to copy template file {source} to {target}") + + +def _handle_api_router_file( + target_dirs: Dict[str, str], modules_dir: str, route_name: str +) -> None: + """ + Handle the API router file - create or update. + + :param target_dirs: Dictionary with paths to target directories + :param modules_dir: Path to the modules directory + :param route_name: Name of the route to add + """ + api_py_target = os.path.join(target_dirs["api"], "api.py") + + if os.path.exists(api_py_target): + # Update existing api.py + with open(api_py_target, "r") as f: + api_content = f.read() + + # Check if router is already included + router_import = f"from src.api.routes import {route_name}" + if router_import not in api_content: + with open(api_py_target, "a") as f: + f.write(f"\n{router_import}\n") + f.write(f"api_router.include_router({route_name}.router)\n") + print_info(f"Added route {route_name} to existing api.py") + else: + # Create new api.py + router_code = f""" +# -------------------------------------------------------------------------- +# API router connector module +# -------------------------------------------------------------------------- +from fastapi import APIRouter + +from src.api.routes import {route_name} + +api_router = APIRouter() +api_router.include_router({route_name}.router) + +""" + with open(api_py_target, "w") as tgt_file: + tgt_file.write(router_code) + + +def _process_init_files( + modules_dir: str, target_dirs: Dict[str, str], module_types: List[str] +) -> None: + """ + Process __init__.py files if they don't exist. + + :param modules_dir: Path to the modules directory + :param target_dirs: Dictionary with paths to target directories + :param module_types: List of module types to process + """ + for module_type in module_types: + module_base = module_type.split("/")[0] + init_source = os.path.join(modules_dir, module_base, "__init__.py-tpl") + + if os.path.exists(init_source): + init_target_dir = target_dirs.get(module_base, None) or target_dirs.get( + f"{module_base}_routes" + ) + if init_target_dir: + init_target = os.path.join(init_target_dir, "__init__.py") + if not os.path.exists(init_target): + copy_and_convert_template_file(init_source, init_target) + + +def _update_main_app(src_dir: str, route_name: str) -> None: + """ + Update the main application file to include the API router. + Adds the router import and include statement at the end of the file. + + :param src_dir: Source directory of the project + :param route_name: Name of the route to add + """ + main_py_path = os.path.join(src_dir, "main.py") + if not os.path.exists(main_py_path): + print_warning("main.py not found. Please manually add the API router.") + return + + with open(main_py_path, "r") as f: + main_content = f.read() + + # Check if router is already imported or included + router_import = "from src.api.api import api_router" + router_include = "app.include_router(api_router" + + if router_import in main_content and router_include in main_content: + # Router already fully configured, nothing to do + return + + # Check if FastAPI app is defined + if "app = FastAPI" not in main_content: + print_warning( + "FastAPI app instance not found in main.py. Please manually add router." + ) + return + + # Add the router import if needed + if router_import not in main_content: + # Add import at the end of imports section or beginning of file + lines = main_content.split("\n") + + # Find last import line + last_import_idx = -1 + for i, line in enumerate(lines): + if line.startswith("import ") or line.startswith("from "): + last_import_idx = i + + if last_import_idx >= 0: + # Insert after the last import + lines.insert(last_import_idx + 1, router_import) + else: + # Insert at the beginning + lines.insert(0, router_import) + + main_content = "\n".join(lines) + + # Add the router include + if router_include not in main_content: + if not main_content.endswith("\n"): + main_content += "\n" + + main_content += f"\n# Include API router\napp.include_router(api_router)\n" + + # Write the updated content back to the file + with open(main_py_path, "w") as f: + f.write(main_content) + + print_info("Updated main.py to include the API router") + + +def add_new_route(project_dir: str, route_name: str) -> None: + """ + Add a new API route to an existing FastAPI project. + + :param project_dir: Path to the project directory + :param route_name: Name of the new route to add + :return: None + """ try: - with open(setup_path, "r") as f: - content = f.read() - # Find the install_requires section using proper string matching - if "install_requires: list[str] = [" in content: - start_idx = content.find("install_requires: list[str] = [") + len( - "install_requires: list[str] = [" - ) - end_idx = content.find("]", start_idx) - if start_idx != -1 and end_idx != -1: - deps_str = content[start_idx:end_idx] - # Clean and parse the dependencies - deps = [ - dep.strip().strip("'").strip('"') - for dep in deps_str.split(",") - if dep.strip() and not dep.isspace() - ] - return [dep for dep in deps if dep] # Remove empty strings - except Exception as e: - print_error(f"Error reading template dependencies: {e}") - return [] + # Setup paths + modules_dir = os.path.join(settings.FASTKIT_TEMPLATE_ROOT, "modules") + src_dir = os.path.join(project_dir, "src") - return [] + # Ensure project structure exists + target_dirs = _ensure_project_structure(src_dir) + + # Create route files + _create_route_files(modules_dir, target_dirs, route_name) + + # Handle API router file + _handle_api_router_file(target_dirs, modules_dir, route_name) + + # Process init files + module_types = ["api/routes", "crud", "schemas"] + _process_init_files(modules_dir, target_dirs, module_types) + + # Update main application + _update_main_app(src_dir, route_name) + + except Exception as e: + handle_exception(e, f"Error adding new route: {str(e)}") + raise BackendExceptions(f"Failed to add new route: {str(e)}") diff --git a/src/fastapi_fastkit/backend/transducer.py b/src/fastapi_fastkit/backend/transducer.py index d99bf7a..8b1ec06 100644 --- a/src/fastapi_fastkit/backend/transducer.py +++ b/src/fastapi_fastkit/backend/transducer.py @@ -64,6 +64,42 @@ def copy_and_convert_template( shutil.copy2(src_file, dst_file) +def copy_and_convert_template_file( + source_file: str, target_file: str, replacements: dict = None # type: ignore +) -> bool: + """ + Copies a single template file to the target location, converting it from .*-tpl + to its proper extension and replacing any placeholders with provided values. + + :param source_file: Path to the source template file (with -tpl extension) + :param target_file: Path to target destination file (without -tpl extension) + :param replacements: Dictionary of placeholder replacements {placeholder: value} + :return: True if successful, False otherwise + """ + try: + if not os.path.exists(source_file): + return False + + # Read a single source content (with .py-tpl extension) + with open(source_file, "r") as src_file: + content = src_file.read() + + if replacements and isinstance(replacements, dict): + for placeholder, value in replacements.items(): + content = content.replace(placeholder, value) + + target_dir = os.path.dirname(target_file) + os.makedirs(target_dir, exist_ok=True) + + with open(target_file, "w") as tgt_file: + tgt_file.write(content) + + return True + except Exception as e: + print(f"Error copying template file: {e}") + return False + + def _convert_real_extension_to_tpl() -> None: # TODO : impl this for converting runnable FastAPI app code to template - debugging operation for contributors # this will be used at inspector module, not package user's runtime. diff --git a/src/fastapi_fastkit/cli.py b/src/fastapi_fastkit/cli.py index 806e435..b4d9d16 100644 --- a/src/fastapi_fastkit/cli.py +++ b/src/fastapi_fastkit/cli.py @@ -14,6 +14,7 @@ from rich.panel import Panel from fastapi_fastkit.backend.main import ( + add_new_route, create_venv, find_template_core_modules, inject_project_metadata, @@ -26,7 +27,9 @@ from fastapi_fastkit.utils.logging import setup_logging from fastapi_fastkit.utils.main import ( create_info_table, + is_fastkit_project, print_error, + print_info, print_success, print_warning, validate_email, @@ -159,7 +162,7 @@ def list_templates(ctx: Context) -> None: help="The description of the new FastAPI project.", ) @click.pass_context -def startup( +def startdemo( ctx: Context, template: str, project_name: str, @@ -240,23 +243,45 @@ def startup( ) except Exception as e: - print_error(f"Error during project creation: {e}") + print_error(f"Error during project creation: {str(e)}") @fastkit_cli.command(context_settings={"ignore_unknown_options": True}) @click.option( "--project-name", - prompt="Enter project name", - help="Name of the new FastAPI project", + prompt="Enter the project name", + help="The name of the new FastAPI project.", +) +@click.option( + "--author", prompt="Enter the author name", help="The name of the project author." +) +@click.option( + "--author-email", + prompt="Enter the author email", + help="The email of the project author.", + type=str, + callback=validate_email, +) +@click.option( + "--description", + prompt="Enter the project description", + help="The description of the new FastAPI project.", ) @click.pass_context -def startproject(ctx: Context, project_name: str) -> None: +def init( + ctx: Context, project_name: str, author: str, author_email: str, description: str +) -> None: """ Start a empty FastAPI project setup. This command will automatically create a new FastAPI project directory and a python virtual environment. Dependencies will be automatically installed based on the selected stack at venv. + Project metadata will be injected to the project files. - :param project_name: Project name + :param ctx: Click context object + :param project_name: Project name for the new project + :param author: Author name + :param author_email: Author email + :param description: Project description :return: None """ settings = ctx.obj["settings"] @@ -266,6 +291,20 @@ def startproject(ctx: Context, project_name: str) -> None: print_error(f"Error: Project '{project_name}' already exists.") return + # Display project information + project_info_table = create_info_table( + "Project Information", + { + "Project Name": project_name, + "Author": author, + "Author Email": author_email, + "Description": description, + }, + ) + console.print("\n") + console.print(project_info_table) + + # Display available stacks console.print("\n[bold]Available Stacks and Dependencies:[/bold]") for stack_name, deps in settings.PROJECT_STACKS.items(): @@ -282,48 +321,134 @@ def startproject(ctx: Context, project_name: str) -> None: show_choices=True, ) + template = "fastapi-empty" + template_dir = settings.FASTKIT_TEMPLATE_ROOT + target_template = os.path.join(template_dir, template) + + if not os.path.exists(target_template): + print_error(f"Template '{template}' does not exist in '{template_dir}'.") + raise CLIExceptions( + f"Template '{template}' does not exist in '{template_dir}'." + ) + + confirm = click.confirm( + "\nDo you want to proceed with project creation?", default=False + ) + if not confirm: + print_error("Project creation aborted!") + return + try: - os.makedirs(project_dir) + user_local = settings.USER_WORKSPACE + project_dir = os.path.join(user_local, project_name) - table = create_info_table( + click.echo(f"FastAPI project will deploy at '{user_local}'") + + copy_and_convert_template(target_template, user_local, project_name) + + inject_project_metadata( + project_dir, project_name, author, author_email, description + ) + + deps_table = create_info_table( f"Creating Project: {project_name}", {"Component": "Collected"} ) with open(os.path.join(project_dir, "requirements.txt"), "w") as f: for dep in settings.PROJECT_STACKS[stack]: f.write(f"{dep}\n") - table.add_row(dep, "✓") + deps_table.add_row(dep, "✓") - console.print(table) + console.print(deps_table) venv_path = create_venv(project_dir) install_dependencies(project_dir, venv_path) - print_success(f"Project '{project_name}' has been created successfully!") + print_success( + f"FastAPI project '{project_name}' has been created successfully and saved to {user_local}!" + ) + + print_info( + "To start your project, run 'fastkit runserver' at newly created FastAPI project directory" + ) except Exception as e: - print_error(f"Error during project creation: {e}") - shutil.rmtree(project_dir, ignore_errors=True) + print_error(f"Error during project creation: {str(e)}") + if os.path.exists(project_dir): + shutil.rmtree(project_dir, ignore_errors=True) -def is_fastkit_project(project_dir: str) -> bool: +@fastkit_cli.command() +@click.argument("project_name") +@click.argument("route_name") +@click.pass_context +def addroute(ctx: Context, project_name: str, route_name: str) -> None: """ - Check if the project was created with fastkit. - Inspects the contents of the setup.py file. + Add a new route to the FastAPI project. - :param project_dir: Project directory - :return: True if the project was created with fastkit, False otherwise + :param ctx: Click context object + :param project_name: Project name + :param route_name: Name of the new route to add + :return: None """ - setup_py = os.path.join(project_dir, "setup.py") - if not os.path.exists(setup_py): - return False + settings = ctx.obj["settings"] + user_local = settings.USER_WORKSPACE + project_dir = os.path.join(user_local, project_name) + + # Check if project exists + if not os.path.exists(project_dir): + print_error(f"Project '{project_name}' does not exist in '{user_local}'.") + return + + # Verify it's a fastkit project + if not is_fastkit_project(project_dir): + print_error(f"'{project_name}' is not a FastAPI-fastkit project.") + return + + # Validate route name + if not route_name.isidentifier(): + print_error(f"Route name '{route_name}' is not a valid Python identifier.") + return + + # Route name shouldn't match reserved keywords + import keyword + + if keyword.iskeyword(route_name): + print_error( + f"Route name '{route_name}' is a Python keyword and cannot be used." + ) + return try: - with open(setup_py, "r") as f: - content = f.read() - return "FastAPI-fastkit" in content - except: - return False + # Show information about the operation + table = create_info_table( + "Adding New Route", + { + "Project": project_name, + "Route Name": route_name, + "Target Directory": project_dir, + }, + ) + console.print(table) + + # Confirm before proceeding + confirm = click.confirm( + f"\nDo you want to add route '{route_name}' to project '{project_name}'?", + default=True, + ) + if not confirm: + print_error("Operation cancelled!") + return + + # Add the new route + add_new_route(project_dir, route_name) + + print_success( + f"Successfully added new route '{route_name}' to project `{project_name}`" + ) + + except Exception as e: + print_error(f"Error during route addition: {str(e)}") @fastkit_cli.command() @@ -405,11 +530,41 @@ def runserver( :param host: Host address to bind the server to :param port: Port number to bind the server to :param reload: Enable or disable auto-reload + :param workers: Number of worker processes :return: None """ settings = ctx.obj["settings"] project_dir = settings.USER_WORKSPACE + # Check for virtual environment + venv_path = os.path.join(project_dir, ".venv") + if not os.path.exists(venv_path) or not os.path.isdir(venv_path): + print_error( + "Virtual environment not found. Is this a project deployed with Fastkit?" + ) + confirm = click.confirm( + "Do you want to continue with system Python?", default=False + ) + if not confirm: + return + venv_python = None + else: + if os.name == "nt": # Windows + venv_python = os.path.join(venv_path, "Scripts", "python.exe") + else: # Unix/Linux/Mac + venv_python = os.path.join(venv_path, "bin", "python") + + if not os.path.exists(venv_python): + print_error( + f"Python interpreter not found in virtual environment: {venv_python}" + ) + confirm = click.confirm( + "Do you want to continue with system Python?", default=False + ) + if not confirm: + return + venv_python = None + core_modules = find_template_core_modules(project_dir) if not core_modules["main"]: print_error(f"Could not find 'main.py' in '{project_dir}'.") @@ -421,22 +576,57 @@ def runserver( else: app_module = "main:app" - command = [ - "uvicorn", - app_module, - "--host", - host, - "--port", - str(port), - "--workers", - str(workers), - ] + if venv_python: + print_info(f"Using Python from virtual environment: {venv_python}") + command = [ + venv_python, + "-m", + "uvicorn", + app_module, + "--host", + host, + "--port", + str(port), + "--workers", + str(workers), + ] + else: + command = [ + "uvicorn", + app_module, + "--host", + host, + "--port", + str(port), + "--workers", + str(workers), + ] if reload: command.append("--reload") try: print_success(f"Starting FastAPI server at {host}:{port}...") - subprocess.run(command, check=True) + + # Set up environment variables for the subprocess + env = os.environ.copy() + if venv_python: + python_path = os.path.dirname(os.path.dirname(venv_python)) + if "PYTHONPATH" in env: + env["PYTHONPATH"] = f"{python_path}:{env['PYTHONPATH']}" + else: + env["PYTHONPATH"] = python_path + + # Run the server with the configured environment + subprocess.run(command, check=True, env=env) except subprocess.CalledProcessError as e: print_error(f"Failed to start FastAPI server.\n{e}") + except FileNotFoundError: + if venv_python: + print_error( + f"Failed to run Python from the virtual environment. Make sure uvicorn is installed in the project's virtual environment." + ) + else: + print_error( + f"uvicorn not found. Make sure it's installed in your system Python." + ) diff --git a/src/fastapi_fastkit/core/settings.py b/src/fastapi_fastkit/core/settings.py index 17cde61..1dcac69 100644 --- a/src/fastapi_fastkit/core/settings.py +++ b/src/fastapi_fastkit/core/settings.py @@ -42,8 +42,16 @@ class FastkitConfig: # Startproject Options PROJECT_STACKS: dict[str, list[str]] = { - "minimal": ["fastapi", "uvicorn"], - "standard": ["fastapi", "uvicorn", "sqlalchemy", "alembic", "pytest"], + "minimal": ["fastapi", "uvicorn", "pydantic", "pydantic-settings"], + "standard": [ + "fastapi", + "uvicorn", + "sqlalchemy", + "alembic", + "pytest", + "pydantic", + "pydantic-settings", + ], "full": [ "fastapi", "uvicorn", @@ -52,6 +60,8 @@ class FastkitConfig: "pytest", "redis", "celery", + "pydantic", + "pydantic-settings", ], } diff --git a/src/fastapi_fastkit/fastapi_project_template/README.md b/src/fastapi_fastkit/fastapi_project_template/README.md index 3b9cb84..3bd82fa 100644 --- a/src/fastapi_fastkit/fastapi_project_template/README.md +++ b/src/fastapi_fastkit/fastapi_project_template/README.md @@ -50,6 +50,26 @@ template-name/ 5. Unit tests implementation 6. API documentation (OpenAPI/Swagger) +## Base structure of modules template + +This template is used to create a new FastAPI project with a specific module structure. + +This template strategy is used at `fastkit addroute` operation. + +The module structure is as follows (version 1.0.X based): + +``` +modules/ +├── api/ +├── crud/ +└── schemas/ +``` + +In further versions, I don't have any plan to add other modules to this template, for example, `auth`, `db`, etc. + +So, if you have any suggestions, please let me know or contribute to this operation. + + ## Adding new FastAPI-based template project Before adding new FastAPI-based template project here, I strongly recommend that you read the diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/script/format.sh-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/scripts/format.sh-tpl similarity index 100% rename from src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/script/format.sh-tpl rename to src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/scripts/format.sh-tpl diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/script/lint.sh-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/scripts/lint.sh-tpl similarity index 100% rename from src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/script/lint.sh-tpl rename to src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/scripts/lint.sh-tpl diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/script/run-server.sh-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/scripts/run-server.sh-tpl similarity index 100% rename from src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/script/run-server.sh-tpl rename to src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/scripts/run-server.sh-tpl diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/script/test.sh-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/scripts/test.sh-tpl similarity index 100% rename from src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/script/test.sh-tpl rename to src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/scripts/test.sh-tpl diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-empty/.env-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-empty/.env-tpl new file mode 100644 index 0000000..c9d6bec --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-empty/.env-tpl @@ -0,0 +1 @@ +SECRET_KEY=changethis diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-empty/setup.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-empty/setup.py-tpl new file mode 100644 index 0000000..02260dc --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-empty/setup.py-tpl @@ -0,0 +1,21 @@ +# -------------------------------------------------------------------------- +# package setup module +# -------------------------------------------------------------------------- +from setuptools import find_packages, setup + +install_requires: list[str] = [ + "fastapi", + "pydantic", + "pydantic-settings", + "python-dotenv", +] + +setup( + name="", + description="[FastAPI-fastkit templated] ", + author="", + author_email=f"", + packages=find_packages(where="src"), + requires=["python (>=3.12)"], + install_requires=install_requires, +) diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-empty/src/__init__.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-empty/src/__init__.py-tpl new file mode 100644 index 0000000..e69de29 diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-empty/src/core/__init__.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-empty/src/core/__init__.py-tpl new file mode 100644 index 0000000..e69de29 diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-empty/src/core/config.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-empty/src/core/config.py-tpl new file mode 100644 index 0000000..695f74a --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-empty/src/core/config.py-tpl @@ -0,0 +1,75 @@ +# -------------------------------------------------------------------------- +# Configuration module +# -------------------------------------------------------------------------- +import secrets +import warnings +from typing import Annotated, Any, Literal + +from pydantic import AnyUrl, BeforeValidator, computed_field, model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict +from typing_extensions import Self + + +def parse_cors(v: Any) -> list[str] | str: + if isinstance(v, str) and not v.startswith("["): + return [i.strip() for i in v.split(",")] + elif isinstance(v, list | str): + return v + raise ValueError(v) + + +class Settings(BaseSettings): + """Default project configuraion module + + This base module configures your project SECRET_KEY, CORS origins and project name + + **Add your project configurations below** + + + For more information, please refer to the following link: + + https://fastapi.tiangolo.com/advanced/settings/?h=config + """ + model_config = SettingsConfigDict( + env_file=".env", + env_ignore_empty=True, + extra="ignore", + ) + + SECRET_KEY: str = secrets.token_urlsafe(32) + ENVIRONMENT: Literal["development", "production"] = "development" + + CLIENT_ORIGIN: str = "" + + BACKEND_CORS_ORIGINS: Annotated[list[AnyUrl] | str, BeforeValidator(parse_cors)] = ( + [] + ) + + @computed_field # type: ignore[prop-decorator] + @property + def all_cors_origins(self) -> list[str]: + return [str(origin).rstrip("/") for origin in self.BACKEND_CORS_ORIGINS] + [ + self.CLIENT_ORIGIN + ] + + PROJECT_NAME: str = "" + + def _check_default_secret(self, var_name: str, value: str | None) -> None: + if value == "changethis": + message = ( + f'The value of {var_name} is "changethis", ' + "for security, please change it, at least for deployments." + ) + if self.ENVIRONMENT == "development": + warnings.warn(message, stacklevel=1) + else: + raise ValueError(message) + + @model_validator(mode="after") + def _enforce_non_default_secrets(self) -> Self: + self._check_default_secret("SECRET_KEY", self.SECRET_KEY) + + return self + + +settings = Settings() # type: ignore diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-empty/src/main.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-empty/src/main.py-tpl new file mode 100644 index 0000000..1d63ce3 --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-empty/src/main.py-tpl @@ -0,0 +1,36 @@ +# -------------------------------------------------------------------------- +# Main server application module +# -------------------------------------------------------------------------- +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from starlette.middleware.cors import CORSMiddleware + +from src.core.config import settings + +app = FastAPI( + title=settings.PROJECT_NAME, +) + +if settings.all_cors_origins: + app.add_middleware( + CORSMiddleware, + allow_origins=settings.all_cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + +"""Add your FastAPI application main routine here + +You can attach customized middlewares, api routes, etc. + + +For more information, please refer to the following link: + +https://fastapi.tiangolo.com/tutorial/ +https://fastapi.tiangolo.com/reference/apirouter/?h=apiroute +https://fastapi.tiangolo.com/tutorial/middleware/?h=middle +https://fastapi.tiangolo.com/tutorial/cors/ +""" diff --git a/src/fastapi_fastkit/fastapi_project_template/modules/api/__init__.py-tpl b/src/fastapi_fastkit/fastapi_project_template/modules/api/__init__.py-tpl new file mode 100644 index 0000000..e69de29 diff --git a/src/fastapi_fastkit/fastapi_project_template/modules/api/routes/__init__.py-tpl b/src/fastapi_fastkit/fastapi_project_template/modules/api/routes/__init__.py-tpl new file mode 100644 index 0000000..e69de29 diff --git a/src/fastapi_fastkit/fastapi_project_template/modules/api/routes/new_route.py-tpl b/src/fastapi_fastkit/fastapi_project_template/modules/api/routes/new_route.py-tpl new file mode 100644 index 0000000..aa408bc --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/modules/api/routes/new_route.py-tpl @@ -0,0 +1,64 @@ +# -------------------------------------------------------------------------- +# CRUD Endpoint +# -------------------------------------------------------------------------- +from fastapi import APIRouter + +from src.crud. import * +from src.schemas. import * + +router = APIRouter() + + +@router.get("/") +def get_route(): + result = read_crud() + + # Add Presentation Layer logics here + + return result + + +@router.post("/") +def post_route(): + result = create_crud() + + # Add Presentation Layer logics here + + return result + + +@router.put("/") +def put_route(): + result = edit_crud() + + # Add Presentation Layer logics here + + return result + + +@router.patch("/") +def patch_route(): + result = edit_crud() + + # Add Presentation Layer logics here + + return result + + +@router.delete("/") +def delete_route(): + result = remove_crud() + + # Add Presentation Layer logics here + + return result + + +"""Add other routers here + +For more information, please refer to the following link: + +https://fastapi.tiangolo.com/tutorial/first-steps/#operation +https://fastapi.tiangolo.com/tutorial/bigger-applications/?h=apirouter#another-module-with-apirouter +https://fastapi.tiangolo.com/tutorial/body/?h=pydantic +""" diff --git a/src/fastapi_fastkit/fastapi_project_template/modules/crud/__init__.py-tpl b/src/fastapi_fastkit/fastapi_project_template/modules/crud/__init__.py-tpl new file mode 100644 index 0000000..e69de29 diff --git a/src/fastapi_fastkit/fastapi_project_template/modules/crud/new_route.py-tpl b/src/fastapi_fastkit/fastapi_project_template/modules/crud/new_route.py-tpl new file mode 100644 index 0000000..f00b64c --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/modules/crud/new_route.py-tpl @@ -0,0 +1,29 @@ +# -------------------------------------------------------------------------- +# CRUD method module +# -------------------------------------------------------------------------- +def read_crud(): + # Add Business Layer logics here + pass + + +def create_crud(): + # Add Business Layer logics here + pass + + +def edit_crud(): + # Add Business Layer logics here + pass + + +def remove_crud(): + # Add Business Layer logics here + pass + +"""Add other crud functions here + +For more information, please refer to the following link: + +https://fastapi.tiangolo.com/tutorial/first-steps/#operation +https://fastapi.tiangolo.com/tutorial/dependencies/ +""" diff --git a/src/fastapi_fastkit/fastapi_project_template/modules/schemas/__init__.py-tpl b/src/fastapi_fastkit/fastapi_project_template/modules/schemas/__init__.py-tpl new file mode 100644 index 0000000..e69de29 diff --git a/src/fastapi_fastkit/fastapi_project_template/modules/schemas/new_route.py-tpl b/src/fastapi_fastkit/fastapi_project_template/modules/schemas/new_route.py-tpl new file mode 100644 index 0000000..1c43bbc --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/modules/schemas/new_route.py-tpl @@ -0,0 +1,23 @@ +# -------------------------------------------------------------------------- +# schema module +# -------------------------------------------------------------------------- +from pydantic import BaseModel + + +class Base(BaseModel): + """Add fields here""" + pass + + +class Response(Base): + """Add fields here""" + pass + + +"""Add other schemas here + +For more information, please refer to the following link: + +https://fastapi.tiangolo.com/tutorial/query-param-models/ +https://fastapi.tiangolo.com/tutorial/response-model/ +""" diff --git a/src/fastapi_fastkit/utils/main.py b/src/fastapi_fastkit/utils/main.py index 3e072aa..35dbb3e 100644 --- a/src/fastapi_fastkit/utils/main.py +++ b/src/fastapi_fastkit/utils/main.py @@ -3,8 +3,10 @@ # # @author bnbong bbbong9@gmail.com # -------------------------------------------------------------------------- +import os import re -from typing import Any +import traceback +from typing import Any, Optional import click from click.core import Context @@ -14,17 +16,40 @@ from rich.text import Text from fastapi_fastkit import console +from fastapi_fastkit.core.settings import settings REGEX = r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,7}\b" -def print_error(message: str, title: str = "Error", console: Console = console) -> None: +def print_error( + message: str, + title: str = "Error", + console: Console = console, + show_traceback: bool = False, +) -> None: """Print an error message with specified output style.""" error_text = Text() error_text.append("❌ ", style="bold red") error_text.append(message) console.print(Panel(error_text, border_style="red", title=title)) + if show_traceback and settings.DEBUG_MODE: + console.print("[bold yellow]Stack trace:[/bold yellow]") + console.print(traceback.format_exc()) + + +def handle_exception(e: Exception, message: Optional[str] = None) -> None: + """ + Handle exception and print appropriate error message. + + :param e: The exception that occurred + :param message: Optional custom error message + """ + error_msg = message or f"Error: {str(e)}" + + # Show traceback if in debug mode + print_error(error_msg, show_traceback=True) + def print_success( message: str, title: str = "Success", console: Console = console @@ -81,3 +106,23 @@ def validate_email(ctx: Context, param: Any, value: Any) -> Any: print_error(f"Incorrect email address given: {e}") value = click.prompt(param.prompt) return validate_email(ctx, param, value) + + +def is_fastkit_project(project_dir: str) -> bool: + """ + Check if the project was created with fastkit. + Inspects the contents of the setup.py file. + + :param project_dir: Project directory + :return: True if the project was created with fastkit, False otherwise + """ + setup_py = os.path.join(project_dir, "setup.py") + if not os.path.exists(setup_py): + return False + + try: + with open(setup_py, "r") as f: + content = f.read() + return "FastAPI-fastkit" in content + except: + return False diff --git a/tests/test_cli_operations/test_cli.py b/tests/test_cli_operations/test_cli.py index a52e6ac..64aa0c9 100644 --- a/tests/test_cli_operations/test_cli.py +++ b/tests/test_cli_operations/test_cli.py @@ -35,14 +35,14 @@ def test_echo(self) -> None: assert "Project Maintainer : bnbong(JunHyeok Lee)" in result.output assert "Github : https://github.com/bnbong/FastAPI-fastkit" in result.output - def test_startup(self, temp_dir: str) -> None: + def test_startdemo(self, temp_dir: str) -> None: # given os.chdir(temp_dir) # when result = self.runner.invoke( fastkit_cli, - ["startup", "fastapi-default"], + ["startdemo", "fastapi-default"], input="\n".join( ["test-project", "bnbong", "bbbong9@gmail.com", "test project", "Y"] ), @@ -96,13 +96,13 @@ def test_startup(self, temp_dir: str) -> None: assert all(found_files.values()), "Not all core module files were found" - def test_deleteproject(self, temp_dir: str) -> None: + def test_delete_demoproject(self, temp_dir: str) -> None: # given os.chdir(temp_dir) project_name = "test-project" result = self.runner.invoke( fastkit_cli, - ["startup", "fastapi-default"], + ["startdemo", "fastapi-default"], input="\n".join( [project_name, "bnbong", "bbbong9@gmail.com", "test project", "Y"] ), @@ -134,16 +134,21 @@ def test_list_templates(self, temp_dir: str) -> None: assert "fastapi-default" in result.output assert "fastapi-dockerized" in result.output - def test_startproject_minimal(self, temp_dir: str) -> None: + def test_init_minimal(self, temp_dir: str) -> None: # given os.chdir(temp_dir) project_name = "test-minimal" + author = "test-author" + author_email = "test@example.com" + description = "A minimal FastAPI project" # when result = self.runner.invoke( fastkit_cli, - ["startproject"], - input="\n".join([project_name, "minimal"]), + ["init"], + input="\n".join( + [project_name, author, author_email, description, "minimal", "Y"] + ), ) # then @@ -151,17 +156,24 @@ def test_startproject_minimal(self, temp_dir: str) -> None: assert project_path.exists() and project_path.is_dir() assert "Success" in result.output + setup_py = project_path / "setup.py" + if setup_py.exists(): + with open(setup_py, "r") as f: + content = f.read() + assert project_name in content + assert author in content + assert author_email in content + assert description in content + with open(project_path / "requirements.txt", "r") as f: content = f.read() assert "fastapi" in content assert "uvicorn" in content assert "sqlalchemy" not in content - # Check virtual environment creation venv_path = project_path / ".venv" assert venv_path.exists() and venv_path.is_dir() - # Check if core dependencies are installed in venv pip_list = subprocess.run( [str(venv_path / "bin" / "pip"), "list"], capture_output=True, text=True ) @@ -169,16 +181,21 @@ def test_startproject_minimal(self, temp_dir: str) -> None: assert "fastapi" in installed_packages assert "uvicorn" in installed_packages - def test_startproject_full(self, temp_dir: str) -> None: + def test_init_full(self, temp_dir: str) -> None: # given os.chdir(temp_dir) project_name = "test-full" + author = "test-author" + author_email = "test@example.com" + description = "A full FastAPI project" # when result = self.runner.invoke( fastkit_cli, - ["startproject"], - input="\n".join([project_name, "full"]), + ["init"], + input="\n".join( + [project_name, author, author_email, description, "full", "Y"] + ), ) # then @@ -186,6 +203,15 @@ def test_startproject_full(self, temp_dir: str) -> None: assert project_path.exists() and project_path.is_dir() assert "Success" in result.output + setup_py = project_path / "setup.py" + if setup_py.exists(): + with open(setup_py, "r") as f: + content = f.read() + assert project_name in content + assert author in content + assert author_email in content + assert description in content + expected_deps = [ "fastapi", "uvicorn", @@ -201,11 +227,9 @@ def test_startproject_full(self, temp_dir: str) -> None: for dep in expected_deps: assert dep in content - # Check virtual environment creation venv_path = project_path / ".venv" assert venv_path.exists() and venv_path.is_dir() - # Check if all dependencies are installed in venv pip_list = subprocess.run( [str(venv_path / "bin" / "pip"), "list"], capture_output=True, text=True ) @@ -214,7 +238,7 @@ def test_startproject_full(self, temp_dir: str) -> None: for dep in expected_deps: assert dep in installed_packages - def test_startproject_existing_project(self, temp_dir: str) -> None: + def test_init_existing_project(self, temp_dir: str) -> None: # given os.chdir(temp_dir) project_name = "test-existing" @@ -223,8 +247,16 @@ def test_startproject_existing_project(self, temp_dir: str) -> None: # when result = self.runner.invoke( fastkit_cli, - ["startproject"], - input="\n".join([project_name, "minimal"]), + ["init"], + input="\n".join( + [ + project_name, + "test-author", + "test@example.com", + "test description", + "minimal", + ] + ), ) # then @@ -239,7 +271,7 @@ def test_is_fastkit_project(self, temp_dir: str) -> None: # Create a regular project result = self.runner.invoke( fastkit_cli, - ["startup", "fastapi-default"], + ["startdemo", "fastapi-default"], input="\n".join( [project_name, "bnbong", "bbbong9@gmail.com", "test project", "Y"] ), @@ -249,7 +281,7 @@ def test_is_fastkit_project(self, temp_dir: str) -> None: assert project_path.exists() # when/then - from fastapi_fastkit.cli import is_fastkit_project + from fastapi_fastkit.utils.main import is_fastkit_project assert is_fastkit_project(str(project_path)) is True From 9cab788cb3cb72e943100f15675f09a3d44435d5 Mon Sep 17 00:00:00 2001 From: bnbong Date: Sat, 1 Mar 2025 18:28:43 +0900 Subject: [PATCH 4/5] [ADD, TEMPLATE] fastapi-psql-orm template added, edit fastkit list-templates operation, added template's testcase --- README.md | 8 - src/fastapi_fastkit/cli.py | 7 +- .../fastapi-async-crud/setup.py-tpl | 3 + .../fastapi-custom-response/setup.py-tpl | 26 ++-- .../fastapi-default/setup.py-tpl | 1 + .../fastapi-dockerized/setup.py-tpl | 1 + .../fastapi-empty/setup.py-tpl | 1 + .../fastapi-psql-orm/.env-tpl | 9 ++ .../fastapi-psql-orm/.gitignore-tpl | 30 ++++ .../fastapi-psql-orm/Dockerfile-tpl | 23 +++ .../fastapi-psql-orm/README.md-tpl | 144 ++++++++++++++++++ .../fastapi-psql-orm/alembic.ini-tpl | 119 +++++++++++++++ .../fastapi-psql-orm/docker-compose.yml-tpl | 44 ++++++ .../fastapi-psql-orm/requirements.txt-tpl | 41 +++++ .../fastapi-psql-orm/scripts/format.sh-tpl | 5 + .../fastapi-psql-orm/scripts/lint.sh-tpl | 5 + .../fastapi-psql-orm/scripts/pre-start.sh-tpl | 16 ++ .../scripts/run-server.sh-tpl | 8 + .../fastapi-psql-orm/scripts/test.sh-tpl | 10 ++ .../fastapi-psql-orm/setup.cfg-tpl | 13 ++ .../fastapi-psql-orm/setup.py-tpl | 25 +++ .../fastapi-psql-orm/src/__init__.py-tpl | 0 .../fastapi-psql-orm/src/alembic/README-tpl | 1 + .../fastapi-psql-orm/src/alembic/env.py-tpl | 85 +++++++++++ .../src/alembic/script.py.mako-tpl | 26 ++++ .../bedcdc35b64a_first_alembic.py-tpl | 37 +++++ .../fastapi-psql-orm/src/api/__init__.py-tpl | 0 .../fastapi-psql-orm/src/api/api.py-tpl | 11 ++ .../fastapi-psql-orm/src/api/deps.py-tpl | 19 +++ .../src/api/routes/__init__.py-tpl | 0 .../src/api/routes/items.py-tpl | 109 +++++++++++++ .../fastapi-psql-orm/src/core/__init__.py-tpl | 0 .../fastapi-psql-orm/src/core/config.py-tpl | 88 +++++++++++ .../fastapi-psql-orm/src/core/db.py-tpl | 53 +++++++ .../fastapi-psql-orm/src/crud/__init__.py-tpl | 0 .../fastapi-psql-orm/src/crud/items.py-tpl | 102 +++++++++++++ .../fastapi-psql-orm/src/main.py-tpl | 45 ++++++ .../src/schemas/__init__.py-tpl | 0 .../fastapi-psql-orm/src/schemas/items.py-tpl | 54 +++++++ .../src/utils/__init__.py-tpl | 0 .../src/utils/backend_pre_start.py-tpl | 39 +++++ .../src/utils/init_data.py-tpl | 23 +++ .../src/utils/tests_pre_start.py-tpl | 39 +++++ .../fastapi-psql-orm/tests/__init__.py-tpl | 0 .../fastapi-psql-orm/tests/conftest.py-tpl | 63 ++++++++ .../fastapi-psql-orm/tests/test_items.py-tpl | 44 ++++++ src/fastapi_fastkit/utils/main.py | 7 +- .../test_templates/test_fastapi-async-crud.py | 61 ++++++++ .../test_fastapi-customized-response.py | 61 ++++++++ tests/test_templates/test_fastapi-default.py | 58 +++++++ .../test_templates/test_fastapi-dockerized.py | 59 +++++++ 51 files changed, 1591 insertions(+), 32 deletions(-) create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/.env-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/.gitignore-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/Dockerfile-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/alembic.ini-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/docker-compose.yml-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/requirements.txt-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/scripts/format.sh-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/scripts/lint.sh-tpl create mode 100755 src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/scripts/pre-start.sh-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/scripts/run-server.sh-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/scripts/test.sh-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/setup.cfg-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/setup.py-tpl rename tests/test_templates/test_fastapi-psql-orm.py => src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/__init__.py-tpl (100%) create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/alembic/README-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/alembic/env.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/alembic/script.py.mako-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/alembic/versions/bedcdc35b64a_first_alembic.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/api/__init__.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/api/api.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/api/deps.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/api/routes/__init__.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/api/routes/items.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/core/__init__.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/core/config.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/core/db.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/crud/__init__.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/crud/items.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/main.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/schemas/__init__.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/schemas/items.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/utils/__init__.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/utils/backend_pre_start.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/utils/init_data.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/utils/tests_pre_start.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/tests/__init__.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/tests/conftest.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/tests/test_items.py-tpl diff --git a/README.md b/README.md index 432745b..7cc49d1 100644 --- a/README.md +++ b/README.md @@ -93,14 +93,6 @@ Installing dependencies... ---> 100% -╭─────────────────────────────────────────────────────────────────── Success ────────────────────────────────────────────────────────────────────╮ -│ ✨ Project '' has been created successfully! │ -╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭───────────────────────────────────────────────────────────────────── Info ─────────────────────────────────────────────────────────────────────╮ -│ ℹ To activate the virtual environment, run: │ -│ │ -│ source //venv/bin/activate │ -╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Success ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ✨ Dependencies installed successfully │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/fastapi_fastkit/cli.py b/src/fastapi_fastkit/cli.py index b4d9d16..4ed5f99 100644 --- a/src/fastapi_fastkit/cli.py +++ b/src/fastapi_fastkit/cli.py @@ -109,19 +109,18 @@ def list_templates(ctx: Context) -> None: print_error("Template directory not found.") return + excluded_dirs = ["__pycache__", "modules"] templates = [ d for d in os.listdir(template_dir) - if os.path.isdir(os.path.join(template_dir, d)) and d != "__pycache__" + if os.path.isdir(os.path.join(template_dir, d)) and d not in excluded_dirs ] if not templates: print_warning("No available templates.") return - table = create_info_table( - "Available Templates", {template: "No description" for template in templates} - ) + table = create_info_table("Available Templates") for template in templates: template_path = os.path.join(template_dir, template) diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/setup.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/setup.py-tpl index 8338625..064fe00 100644 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/setup.py-tpl +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud/setup.py-tpl @@ -5,10 +5,13 @@ from setuptools import find_packages, setup install_requires: list[str] = [ "fastapi", + "uvicorn", "pydantic", "pydantic-settings", "python-dotenv", "aiofiles", + "pytest", + "pytest-asyncio", ] setup( diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/setup.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/setup.py-tpl index cde4f0b..9a200af 100644 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/setup.py-tpl +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response/setup.py-tpl @@ -4,28 +4,20 @@ from setuptools import find_packages, setup install_requires = [ - # Main Application Dependencies - "fastapi==0.111.1", - "uvicorn==0.30.1", - "httpx==0.27.0", - "jinja2==3.1.2", - # ORM Dependencies - "pydantic==2.8.2", - "pydantic_core==2.20.1", - "pydantic-settings==2.3.4", - # Utility Dependencies - "starlette==0.37.2", - "typing_extensions==4.12.2", - "watchfiles==0.22.0", - "pytest==8.2.2", - "pytest-asyncio==0.23.8", - "FastAPI-fastkit", + "fastapi", + "uvicorn", + "httpx", + "pydantic", + "pydantic_core", + "pydantic-settings", + "pytest", + "pytest-asyncio", ] # IDE will watch this setup config through your project src, and help you to set up your environment setup( name="", - description="", + description="[FastAPI-fastkit templated] ", author="", author_email=f"", packages=find_packages(where="src"), diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-default/setup.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-default/setup.py-tpl index 02260dc..693ebca 100644 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-default/setup.py-tpl +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-default/setup.py-tpl @@ -5,6 +5,7 @@ from setuptools import find_packages, setup install_requires: list[str] = [ "fastapi", + "uvicorn", "pydantic", "pydantic-settings", "python-dotenv", diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/setup.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/setup.py-tpl index 02260dc..693ebca 100644 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/setup.py-tpl +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-dockerized/setup.py-tpl @@ -5,6 +5,7 @@ from setuptools import find_packages, setup install_requires: list[str] = [ "fastapi", + "uvicorn", "pydantic", "pydantic-settings", "python-dotenv", diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-empty/setup.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-empty/setup.py-tpl index 02260dc..693ebca 100644 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-empty/setup.py-tpl +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-empty/setup.py-tpl @@ -5,6 +5,7 @@ from setuptools import find_packages, setup install_requires: list[str] = [ "fastapi", + "uvicorn", "pydantic", "pydantic-settings", "python-dotenv", diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/.env-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/.env-tpl new file mode 100644 index 0000000..bade61e --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/.env-tpl @@ -0,0 +1,9 @@ +# Backend +SECRET_KEY=changethis + +# Postgres +POSTGRES_SERVER=db +POSTGRES_PORT=5432 +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +POSTGRES_DB=item_db diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/.gitignore-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/.gitignore-tpl new file mode 100644 index 0000000..ef6364a --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/.gitignore-tpl @@ -0,0 +1,30 @@ +.idea +.ipynb_checkpoints +.mypy_cache +.vscode +__pycache__ +.pytest_cache +htmlcov +dist +site +.coverage* +coverage.xml +.netlify +test.db +log.txt +Pipfile.lock +env3.* +env +docs_build +site_build +venv +docs.zip +archive.zip + +# vim temporary files +*~ +.*.sw? +.cache + +# macOS +.DS_Store diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/Dockerfile-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/Dockerfile-tpl new file mode 100644 index 0000000..3c628d0 --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/Dockerfile-tpl @@ -0,0 +1,23 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +COPY setup.py . +COPY src/ src/ +COPY tests/ tests/ +COPY scripts/ scripts/ + +RUN pip install --no-cache-dir -r requirements.txt + +ENV PYTHONPATH=/app +ENV ENVIRONMENT=production + +EXPOSE 8000 + +CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/README.md-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/README.md-tpl index e69de29..1952e7b 100644 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/README.md-tpl +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/README.md-tpl @@ -0,0 +1,144 @@ +# Dockerized FastAPI Item Management API with PostgreSQL + +This project is a Docker containerized FastAPI-based item management API application with PostgreSQL database integration. + +## Requirements + +- Docker and Docker Compose + +## Tech Stack + +- Python 3.12+ +- FastAPI + uvicorn +- PostgreSQL +- SQLModel (SQLAlchemy + Pydantic) +- Alembic +- Docker & Docker Compose +- pytest +- mypy + black + isort + +## Project Structure + +```` +. +├── Dockerfile +├── README.md +├── docker-compose.yml +├── requirements.txt +├── setup.py +├── alembic.ini +├── scripts +│ ├── pre-start.sh +│ └── test.sh +├── src +│ ├── api +│ │ ├── api.py +│ │ ├── deps.py +│ │ └── routes +│ │ └── items.py +│ ├── core +│ │ ├── config.py +│ │ └── db.py +│ ├── crud +│ │ └── items.py +│ ├── alembic +│ │ ├── env.py +│ │ └── versions/ +│ ├── schemas +│ │ └── items.py +│ ├── utils +│ │ ├── backend_pre_start.py +│ │ ├── init_data.py +│ │ └── tests_pre_start.py +│ └── main.py +└── tests + ├── __init__.py + ├── test_items.py + └── conftest.py +```` + + +## How to Run + +### Run with Docker + +````bash +# Build and run with Docker Compose +$ docker-compose up -d + +# Check container status +$ docker-compose ps +```` + + +Once the containers are running, you can check the API documentation at: + +```` +http://localhost:8000/docs # Swagger format +http://localhost:8000/redoc # ReDoc format +```` + + +### Run in Development Mode (Inside Container) + +````bash +# Run the application in development mode (using `--reload` option) +$ docker-compose exec app uvicorn src.main:app --reload --host 0.0.0.0 +```` + + +## API Endpoints + +| Method | Endpoint | Description | +|--------|------------------|----------------------------| +| GET | `/items/` | List all items | +| GET | `/items/{id}` | Get single item | +| POST | `/items/` | Create new item | +| PUT | `/items/{id}` | Update existing item | +| DELETE | `/items/{id}` | Delete item | + +## Key Features + +- Persistent storage using PostgreSQL database +- Database migrations using Alembic +- ORM using SQLModel (SQLAlchemy + Pydantic) +- Full containerization via Docker Compose +- Automatic loading of initial sample data +- Pydantic model-based data validation +- Integrated test cases + +## Running Tests + +Tests __must__ be run inside the Docker container: + +````bash +# Run all tests inside the container +$ docker exec fastapi-psql-orm-app-1 bash scripts/test.sh + +# Run specific test file (example) +$ docker exec fastapi-psql-orm-app-1 bash -c "cd /app && pytest tests/test_items.py -v" +```` + +## Database Management + +After making a new database's change, you must migrate it. + +````bash +# Apply database migrations +$ docker exec fastapi-psql-orm-app-1 bash -c "alembic upgrade head" + +# Create new migrations +$ docker exec fastapi-psql-orm-app-1 bash -c "alembic revision --autogenerate -m 'description'" +```` + +For other FastAPI guides, please refer + +# Project Origin + +This project was created based on the template from the [FastAPI-fastkit](https://github.com/bnbong/FastAPI-fastkit) project. + +The `FastAPI-fastkit` is an open-source project that helps Python and FastAPI beginners quickly set up a FastAPI-based application development environment in a framework-like structure. + +### Template Information +- Template creator: [bnbong](mailto:bbbong9@gmail.com) +- FastAPI-fastkit project maintainer: [bnbong](mailto:bbbong9@gmail.com) diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/alembic.ini-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/alembic.ini-tpl new file mode 100644 index 0000000..19a1ec7 --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/alembic.ini-tpl @@ -0,0 +1,119 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +# Use forward slashes (/) also on windows to provide an os agnostic path +script_location = src/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +; prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +# version_path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +; version_path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +; sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/docker-compose.yml-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/docker-compose.yml-tpl new file mode 100644 index 0000000..f096353 --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/docker-compose.yml-tpl @@ -0,0 +1,44 @@ +version: '3' + +services: + db: + image: postgres:16-alpine + volumes: + - postgres_data:/var/lib/postgresql/data/ + env_file: + - ./.env + environment: + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_DB=${POSTGRES_DB} + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 5s + timeout: 5s + retries: 5 + + app: + build: . + depends_on: + db: + condition: service_healthy + env_file: + - ./.env + environment: + - POSTGRES_SERVER=db + - ENVIRONMENT=${ENVIRONMENT} + ports: + - "8000:8000" + volumes: + - ./:/app/ + command: > + bash -c " + chmod +x ./scripts/pre-start.sh && + ./scripts/pre-start.sh && + uvicorn src.main:app --host 0.0.0.0 --port 8000 --reload + " + +volumes: + postgres_data: diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/requirements.txt-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/requirements.txt-tpl new file mode 100644 index 0000000..9335aac --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/requirements.txt-tpl @@ -0,0 +1,41 @@ +alembic==1.14.1 +annotated-types==0.7.0 +anyio==4.8.0 +black==25.1.0 +certifi==2025.1.31 +click==8.1.8 +fastapi==0.115.8 +h11==0.14.0 +httpcore==1.0.7 +httptools==0.6.4 +httpx==0.28.1 +idna==3.10 +iniconfig==2.0.0 +isort==6.0.0 +Mako==1.3.9 +MarkupSafe==3.0.2 +mypy==1.15.0 +mypy-extensions==1.0.0 +packaging==24.2 +pathspec==0.12.1 +platformdirs==4.3.6 +pluggy==1.5.0 +psycopg==3.2.5 +psycopg-binary==3.2.5 +pydantic==2.10.6 +pydantic-settings==2.7.1 +pydantic_core==2.27.2 +pytest==8.3.4 +python-dotenv==1.0.1 +PyYAML==6.0.2 +setuptools==75.8.0 +sniffio==1.3.1 +SQLAlchemy==2.0.38 +sqlmodel==0.0.22 +starlette==0.45.3 +tenacity==9.0.0 +typing_extensions==4.12.2 +uvicorn==0.34.0 +uvloop==0.21.0 +watchfiles==1.0.4 +websockets==15.0 diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/scripts/format.sh-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/scripts/format.sh-tpl new file mode 100644 index 0000000..abdd14e --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/scripts/format.sh-tpl @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -x + +black . +isort . diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/scripts/lint.sh-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/scripts/lint.sh-tpl new file mode 100644 index 0000000..08e929f --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/scripts/lint.sh-tpl @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -x + +black . --check +mypy src diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/scripts/pre-start.sh-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/scripts/pre-start.sh-tpl new file mode 100755 index 0000000..c7b0424 --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/scripts/pre-start.sh-tpl @@ -0,0 +1,16 @@ +#! /usr/bin/env bash + +set -e +set -x + +# Let the DB start +python src/utils/backend_pre_start.py + +# Ensure tables exist +python -c "from src.core.db import create_db_and_tables; create_db_and_tables()" + +# Run migrations +alembic upgrade head + +# Create initial data in DB +python src/utils/init_data.py diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/scripts/run-server.sh-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/scripts/run-server.sh-tpl new file mode 100644 index 0000000..eb760a2 --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/scripts/run-server.sh-tpl @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -e +set -x + +source .venv/bin/activate + +uvicorn src.main:app --reload diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/scripts/test.sh-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/scripts/test.sh-tpl new file mode 100644 index 0000000..abad772 --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/scripts/test.sh-tpl @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +set -e +set -x + +export PYTHONPATH=$PYTHONPATH:$(pwd) + +python src/utils/tests_pre_start.py + +pytest diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/setup.cfg-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/setup.cfg-tpl new file mode 100644 index 0000000..0d9980e --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/setup.cfg-tpl @@ -0,0 +1,13 @@ +[mypy] +warn_unused_configs = true +ignore_missing_imports = true + +[isort] +profile = black +line_length=100 +virtual_env=.venv + +[tool:pytest] +pythonpath = src +testpaths = tests +python_files = test_*.py diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/setup.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/setup.py-tpl new file mode 100644 index 0000000..53f14bd --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/setup.py-tpl @@ -0,0 +1,25 @@ +# -------------------------------------------------------------------------- +# package setup module +# -------------------------------------------------------------------------- +from setuptools import find_packages, setup + +install_requires: list[str] = [ + "fastapi", + "uvicorn", + "pydantic", + "pydantic-settings", + "python-dotenv", + "sqlmodel", + "SQLAlchemy", + "alembic", +] + +setup( + name="", + description="[FastAPI-fastkit templated] ", + author="", + author_email=f"", + packages=find_packages(where="src"), + requires=["python (>=3.12)"], + install_requires=install_requires, +) diff --git a/tests/test_templates/test_fastapi-psql-orm.py b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/__init__.py-tpl similarity index 100% rename from tests/test_templates/test_fastapi-psql-orm.py rename to src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/__init__.py-tpl diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/alembic/README-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/alembic/README-tpl new file mode 100644 index 0000000..2500aa1 --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/alembic/README-tpl @@ -0,0 +1 @@ +Generic single-database configuration. diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/alembic/env.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/alembic/env.py-tpl new file mode 100644 index 0000000..b4b3647 --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/alembic/env.py-tpl @@ -0,0 +1,85 @@ +import os +from logging.config import fileConfig + +from sqlalchemy import engine_from_config, pool + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) # type: ignore + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +# target_metadata = None + +from src.core.config import settings # noqa +from src.schemas.items import SQLModel # noqa + +target_metadata = SQLModel.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def get_url(): + return str(settings.SQLALCHEMY_DATABASE_URI) + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = get_url() + context.configure( + url=url, target_metadata=target_metadata, literal_binds=True, compare_type=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + configuration = config.get_section(config.config_ini_section) + configuration["sqlalchemy.url"] = get_url() + connectable = engine_from_config( + configuration, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata, compare_type=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/alembic/script.py.mako-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/alembic/script.py.mako-tpl new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/alembic/script.py.mako-tpl @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/alembic/versions/bedcdc35b64a_first_alembic.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/alembic/versions/bedcdc35b64a_first_alembic.py-tpl new file mode 100644 index 0000000..e47372d --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/alembic/versions/bedcdc35b64a_first_alembic.py-tpl @@ -0,0 +1,37 @@ +"""first alembic + +Revision ID: bedcdc35b64a +Revises: +Create Date: 2025-03-01 17:00:43.670130 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'bedcdc35b64a' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('items', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('description', sa.String(), nullable=True), + sa.Column('price', sa.Float(), nullable=False), + sa.Column('category', sa.String(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('items') + # ### end Alembic commands ### diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/api/__init__.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/api/__init__.py-tpl new file mode 100644 index 0000000..e69de29 diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/api/api.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/api/api.py-tpl new file mode 100644 index 0000000..47608bf --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/api/api.py-tpl @@ -0,0 +1,11 @@ +# -------------------------------------------------------------------------- +# API router connector module +# -------------------------------------------------------------------------- + +from fastapi import APIRouter + +from src.api.routes import items + + +api_router = APIRouter() +api_router.include_router(items.router) diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/api/deps.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/api/deps.py-tpl new file mode 100644 index 0000000..c4f48bf --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/api/deps.py-tpl @@ -0,0 +1,19 @@ +# -------------------------------------------------------------------------- +# API router session dependency module +# -------------------------------------------------------------------------- +from collections.abc import Generator + +from sqlmodel import Session + +from src.core.db import engine + + +def get_db() -> Generator[Session, None, None]: + """ + Dependency for database session. + + Yields: + Database session that will be automatically closed after request completion + """ + with Session(engine) as session: + yield session diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/api/routes/__init__.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/api/routes/__init__.py-tpl new file mode 100644 index 0000000..e69de29 diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/api/routes/items.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/api/routes/items.py-tpl new file mode 100644 index 0000000..543cc7c --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/api/routes/items.py-tpl @@ -0,0 +1,109 @@ +# -------------------------------------------------------------------------- +# Item CRUD Endpoint +# -------------------------------------------------------------------------- +from typing import List +from collections.abc import Generator + +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import Session + +from src.api.deps import get_db +from src.crud.items import create_item, delete_item, get_item, get_items, update_item +from src.schemas.items import ItemBase, ItemCreate, ItemResponse + +router = APIRouter() + + +@router.get("/items", response_model=List[ItemResponse]) +def read_items(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)): + """ + Retrieve a list of items with optional pagination. + + Args: + skip: Number of records to skip + limit: Maximum number of records to return + db: Database session dependency + + Returns: + List of items + """ + return get_items(db, skip=skip, limit=limit) + + +@router.get("/items/{item_id}", response_model=ItemResponse) +def read_item(item_id: int, db: Session = Depends(get_db)): + """ + Retrieve a specific item by ID. + + Args: + item_id: ID of the item to retrieve + db: Database session dependency + + Returns: + The requested item + + Raises: + HTTPException: If item is not found + """ + db_item = get_item(db, item_id=item_id) + if db_item is None: + raise HTTPException(status_code=404, detail="Item not found") + return db_item + + +@router.post("/items", response_model=ItemResponse, status_code=201) +def create_new_item(item: ItemCreate, db: Session = Depends(get_db)): + """ + Create a new item. + + Args: + item: Item data from request body + db: Database session dependency + + Returns: + The created item with its new ID + """ + return create_item(db, item.model_dump()) + + +@router.put("/items/{item_id}", response_model=ItemResponse) +def update_existing_item(item_id: int, item: ItemCreate, db: Session = Depends(get_db)): + """ + Update an existing item. + + Args: + item_id: ID of the item to update + item: Updated item data from request body + db: Database session dependency + + Returns: + The updated item + + Raises: + HTTPException: If item is not found + """ + db_item = update_item(db, item_id=item_id, item_data=item.model_dump()) + if db_item is None: + raise HTTPException(status_code=404, detail="Item not found") + return db_item + + +@router.delete("/items/{item_id}") +def delete_existing_item(item_id: int, db: Session = Depends(get_db)): + """ + Delete an item. + + Args: + item_id: ID of the item to delete + db: Database session dependency + + Returns: + Success message + + Raises: + HTTPException: If item is not found + """ + success = delete_item(db, item_id=item_id) + if not success: + raise HTTPException(status_code=404, detail="Item not found") + return {"message": "Item deleted successfully"} diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/core/__init__.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/core/__init__.py-tpl new file mode 100644 index 0000000..e69de29 diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/core/config.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/core/config.py-tpl new file mode 100644 index 0000000..7da5fbf --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/core/config.py-tpl @@ -0,0 +1,88 @@ +# -------------------------------------------------------------------------- +# Configuration module +# -------------------------------------------------------------------------- +import secrets +import warnings +from typing import Annotated, Any, Literal + +from pydantic import ( + AnyUrl, + BeforeValidator, + PostgresDsn, + computed_field, + model_validator, +) +from pydantic_core import MultiHostUrl +from pydantic_settings import BaseSettings, SettingsConfigDict +from typing_extensions import Self + + +def parse_cors(v: Any) -> list[str] | str: + if isinstance(v, str) and not v.startswith("["): + return [i.strip() for i in v.split(",")] + elif isinstance(v, list | str): + return v + raise ValueError(v) + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_ignore_empty=True, + extra="ignore", + ) + + SECRET_KEY: str = secrets.token_urlsafe(32) + ENVIRONMENT: Literal["development", "production"] = "development" + + CLIENT_ORIGIN: str = "" + + BACKEND_CORS_ORIGINS: Annotated[list[AnyUrl] | str, BeforeValidator(parse_cors)] = ( + [] + ) + + @computed_field # type: ignore[prop-decorator] + @property + def all_cors_origins(self) -> list[str]: + return [str(origin).rstrip("/") for origin in self.BACKEND_CORS_ORIGINS] + [ + self.CLIENT_ORIGIN + ] + + PROJECT_NAME: str = "" + POSTGRES_SERVER: str + POSTGRES_PORT: int = 5432 + POSTGRES_USER: str + POSTGRES_PASSWORD: str = "" + POSTGRES_DB: str = "" + + @computed_field # type: ignore[prop-decorator] + @property + def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn: + return MultiHostUrl.build( # type: ignore + scheme="postgresql+psycopg", + username=self.POSTGRES_USER, + password=self.POSTGRES_PASSWORD, + host=self.POSTGRES_SERVER, + port=self.POSTGRES_PORT, + path=self.POSTGRES_DB, + ) + + def _check_default_secret(self, var_name: str, value: str | None) -> None: + if value == "changethis": + message = ( + f'The value of {var_name} is "changethis", ' + "for security, please change it, at least for deployments." + ) + if self.ENVIRONMENT == "development": + warnings.warn(message, stacklevel=1) + else: + raise ValueError(message) + + @model_validator(mode="after") + def _enforce_non_default_secrets(self) -> Self: + self._check_default_secret("SECRET_KEY", self.SECRET_KEY) + + return self + + +settings = Settings() # type: ignore diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/core/db.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/core/db.py-tpl new file mode 100644 index 0000000..2928ab6 --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/core/db.py-tpl @@ -0,0 +1,53 @@ +# -------------------------------------------------------------------------- +# Database configuration module +# -------------------------------------------------------------------------- +from sqlmodel import Session, SQLModel, create_engine, select + +from src.core.config import settings +from src.schemas.items import ItemBase + +engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI)) + + +# make sure all SQLModel models are imported (src.schemas) before initializing DB +# otherwise, SQLModel might fail to initialize relationships properly + + +def init_db(session: Session) -> None: + """ + Initialize the database with sample data. + + This function can be used to seed the database with initial data for testing or + demonstration purposes. Modify the sample data as needed for your application. + + Args: + session: Database session + """ + # Check if we already have items + statement = select(ItemBase) + results = session.exec(statement).all() + if results: + return # Database already has data + + # Add sample items + sample_items = [ + {"name": "Test Item 1", "description": "This is a test item", "price": 19.99, "category": "test"}, + {"name": "Test Item 2", "description": "Another test item", "price": 29.99, "category": "test"}, + ] + + for item_data in sample_items: + item = ItemBase(**item_data) + session.add(item) + + session.commit() + + +def create_db_and_tables(): + """ + Create database tables. + + This function creates all tables defined via SQLModel. In production, + use Alembic migrations instead of this function. + """ + # SQLModel.metadata.create_all(engine) + pass diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/crud/__init__.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/crud/__init__.py-tpl new file mode 100644 index 0000000..e69de29 diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/crud/items.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/crud/items.py-tpl new file mode 100644 index 0000000..6c311a8 --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/crud/items.py-tpl @@ -0,0 +1,102 @@ +# -------------------------------------------------------------------------- +# Item CRUD method module +# -------------------------------------------------------------------------- +from typing import Dict, List, Optional, Union + +from sqlmodel import Session, select + +from src.core.db import engine +from src.schemas.items import ItemBase + + +def get_item(db: Session, item_id: int) -> Optional[ItemBase]: + """ + Retrieve a single item by its ID. + + Args: + db: Database session + item_id: ID of the item to retrieve + + Returns: + The item if found, None otherwise + """ + return db.get(ItemBase, item_id) + + +def get_items(db: Session, skip: int = 0, limit: int = 100) -> List[ItemBase]: + """ + Retrieve a list of items with pagination. + + Args: + db: Database session + skip: Number of records to skip (for pagination) + limit: Maximum number of records to return + + Returns: + List of items + """ + statement = select(ItemBase).offset(skip).limit(limit) + return list(db.exec(statement).all()) + + +def create_item(db: Session, item: Dict[str, Union[str, float]]) -> ItemBase: + """ + Create a new item in the database. + + Args: + db: Database session + item: Dictionary containing item data + + Returns: + The created item + """ + db_item = ItemBase(**item) + db.add(db_item) + db.commit() + db.refresh(db_item) + return db_item + + +def update_item(db: Session, item_id: int, item_data: Dict[str, Union[str, float]]) -> Optional[ItemBase]: + """ + Update an existing item. + + Args: + db: Database session + item_id: ID of the item to update + item_data: Dictionary containing updated item data + + Returns: + The updated item if found, None otherwise + """ + db_item = get_item(db, item_id) + if not db_item: + return None + + for key, value in item_data.items(): + setattr(db_item, key, value) + + db.add(db_item) + db.commit() + db.refresh(db_item) + return db_item + + +def delete_item(db: Session, item_id: int) -> bool: + """ + Delete an item from the database. + + Args: + db: Database session + item_id: ID of the item to delete + + Returns: + True if the item was deleted, False if not found + """ + db_item = get_item(db, item_id) + if not db_item: + return False + + db.delete(db_item) + db.commit() + return True diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/main.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/main.py-tpl new file mode 100644 index 0000000..eaecc41 --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/main.py-tpl @@ -0,0 +1,45 @@ +# -------------------------------------------------------------------------- +# Main server application module +# -------------------------------------------------------------------------- +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from starlette.middleware.cors import CORSMiddleware + +from src.api.api import api_router +from src.core.config import settings +from src.core.db import create_db_and_tables + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """ + Application lifespan manager for startup and shutdown events. + + Creates database tables on startup. + """ + # Create tables on startup (in development only) + if settings.ENVIRONMENT == "development": + create_db_and_tables() + yield + # Clean up resources on shutdown if needed + + +app = FastAPI( + title=settings.PROJECT_NAME, + lifespan=lifespan, + description="A simple Item CRUD API demonstrating FastAPI with SQLModel and PostgreSQL", + version="0.1.0", +) + +if settings.all_cors_origins: + app.add_middleware( + CORSMiddleware, + allow_origins=settings.all_cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + +app.include_router(api_router) diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/schemas/__init__.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/schemas/__init__.py-tpl new file mode 100644 index 0000000..e69de29 diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/schemas/items.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/schemas/items.py-tpl new file mode 100644 index 0000000..6e3e355 --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/schemas/items.py-tpl @@ -0,0 +1,54 @@ +# -------------------------------------------------------------------------- +# Item schema module +# -------------------------------------------------------------------------- +from typing import Optional + +from sqlmodel import Field, SQLModel + + +class ItemBase(SQLModel, table=True): + """ + Base Item model that serves both as a SQLModel table definition and a Pydantic model. + + Attributes: + name: The name of the item + description: Optional description of the item + price: The price of the item + category: The category the item belongs to + + Notes: + This model can be extended with additional fields as needed for your specific + application requirements. + """ + __tablename__ = "items" + + id: Optional[int] = Field(default=None, primary_key=True) + name: str + description: Optional[str] = None + price: float + category: str + + +class ItemCreate(SQLModel): + """ + Item creation model for API requests. + + Use this model to validate incoming data when creating new items. + """ + name: str + description: Optional[str] = None + price: float + category: str + + +class ItemResponse(SQLModel): + """ + Item response model for API responses. + + This model defines the structure of data returned to clients. + """ + id: int + name: str + description: Optional[str] = None + price: float + category: str diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/utils/__init__.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/utils/__init__.py-tpl new file mode 100644 index 0000000..e69de29 diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/utils/backend_pre_start.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/utils/backend_pre_start.py-tpl new file mode 100644 index 0000000..d55a8ef --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/utils/backend_pre_start.py-tpl @@ -0,0 +1,39 @@ +import logging + +from sqlalchemy import Engine +from sqlmodel import Session, select +from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed + +from src.core.db import engine + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +max_tries = 60 * 5 # 5 minutes +wait_seconds = 1 + + +@retry( + stop=stop_after_attempt(max_tries), + wait=wait_fixed(wait_seconds), + before=before_log(logger, logging.INFO), + after=after_log(logger, logging.WARN), +) +def init(db_engine: Engine) -> None: + try: + with Session(db_engine) as session: + # Try to create session to check if DB is awake + session.exec(select(1)) + except Exception as e: + logger.error(e) + raise e + + +def main() -> None: + logger.info("Initializing service") + init(engine) + logger.info("Service finished initializing") + + +if __name__ == "__main__": + main() diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/utils/init_data.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/utils/init_data.py-tpl new file mode 100644 index 0000000..74ab782 --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/utils/init_data.py-tpl @@ -0,0 +1,23 @@ +import logging + +from sqlmodel import Session + +from src.core.db import engine, init_db + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +def init() -> None: + with Session(engine) as session: + init_db(session) + + +def main() -> None: + logger.info("Creating initial data") + init() + logger.info("Initial data created") + + +if __name__ == "__main__": + main() diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/utils/tests_pre_start.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/utils/tests_pre_start.py-tpl new file mode 100644 index 0000000..891afc1 --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/src/utils/tests_pre_start.py-tpl @@ -0,0 +1,39 @@ +import logging + +from sqlalchemy import Engine +from sqlmodel import Session, select +from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed + +from src.core.db import engine + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +max_tries = 60 * 5 # 5 minutes +wait_seconds = 1 + + +@retry( + stop=stop_after_attempt(max_tries), + wait=wait_fixed(wait_seconds), + before=before_log(logger, logging.INFO), + after=after_log(logger, logging.WARN), +) +def init(db_engine: Engine) -> None: + try: + # Try to create session to check if DB is awake + with Session(db_engine) as session: + session.exec(select(1)) + except Exception as e: + logger.error(e) + raise e + + +def main() -> None: + logger.info("Initializing service") + init(engine) + logger.info("Service finished initializing") + + +if __name__ == "__main__": + main() diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/tests/__init__.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/tests/__init__.py-tpl new file mode 100644 index 0000000..e69de29 diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/tests/conftest.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/tests/conftest.py-tpl new file mode 100644 index 0000000..f283dd2 --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/tests/conftest.py-tpl @@ -0,0 +1,63 @@ +# -------------------------------------------------------------------------- +# pytest runtime configuration module +# -------------------------------------------------------------------------- +import json +import os +from collections.abc import Generator +from typing import Dict, List + +import pytest +from fastapi.testclient import TestClient +from sqlmodel import Session, SQLModel, delete, select + +from src.core.db import engine, init_db +from src.main import app +from src.schemas.items import ItemBase + + +@pytest.fixture(scope="session", autouse=True) +def db() -> Generator[Session, None, None]: + """ + Session-scoped database fixture that sets up the test database + and cleans it up after all tests are done. + + This fixture: + 1. Initializes the database with test data + 2. Yields the session for test use + 3. Cleans up all data after tests complete + + The autouse=True parameter ensures this fixture runs automatically + for all tests in the session. + """ + with Session(engine) as session: + # Initialize database with test data + init_db(session) + yield session + + # Clean up - delete all records from tables after tests + statement = delete(ItemBase) + session.execute(statement) + session.commit() + + +@pytest.fixture(name="client") +def client_fixture(db: Session): + """ + Create a FastAPI test client with dependency overrides. + + Args: + db: Test database session from the db fixture + + Yields: + FastAPI TestClient + """ + def get_session_override(): + return db + + app.dependency_overrides = {} + # Override the get_db dependency in routes to use the test session + from src.api.routes.items import get_db + app.dependency_overrides[get_db] = get_session_override + + with TestClient(app) as client: + yield client diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/tests/test_items.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/tests/test_items.py-tpl new file mode 100644 index 0000000..6d4f26f --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/tests/test_items.py-tpl @@ -0,0 +1,44 @@ +# -------------------------------------------------------------------------- +# Item endpoint testcases +# -------------------------------------------------------------------------- +import json + +import pytest + + +def test_read_all_items(client): + response = client.get("/items") + assert response.status_code == 200 + assert len(response.json()) == 2 + + +def test_read_item_success(client): + response = client.get("/items/1") + assert response.status_code == 200 + assert response.json()["name"] == "Test Item 1" + + +def test_read_item_not_found(client): + response = client.get("/items/999") + assert response.status_code == 404 + + +def test_create_item(client): + new_item = {"name": "New Item", "price": 30.0, "category": "new"} + response = client.post("/items", json=new_item) + assert response.status_code == 201 + assert response.json()["id"] == 3 + + +def test_update_item(client): + updated_data = {"name": "Updated Item", "price": 15.0, "category": "updated"} + response = client.put("/items/1", json=updated_data) + assert response.status_code == 200 + assert response.json()["name"] == "Updated Item" + + +def test_delete_item(client): + response = client.delete("/items/1") + assert response.status_code == 200 + response = client.get("/items/1") + assert response.status_code == 404 diff --git a/src/fastapi_fastkit/utils/main.py b/src/fastapi_fastkit/utils/main.py index 35dbb3e..fb7367b 100644 --- a/src/fastapi_fastkit/utils/main.py +++ b/src/fastapi_fastkit/utils/main.py @@ -81,7 +81,7 @@ def print_info(message: str, title: str = "Info", console: Console = console) -> def create_info_table( title: str, - data: dict[str, str], + data: Optional[dict[str, str]] = None, show_header: bool = False, console: Console = console, ) -> Table: @@ -90,8 +90,9 @@ def create_info_table( table.add_column("Field", style="cyan") table.add_column("Value", style="green") - for key, value in data.items(): - table.add_row(key, value) + if data: + for key, value in data.items(): + table.add_row(key, value) return table diff --git a/tests/test_templates/test_fastapi-async-crud.py b/tests/test_templates/test_fastapi-async-crud.py index e69de29..c003590 100644 --- a/tests/test_templates/test_fastapi-async-crud.py +++ b/tests/test_templates/test_fastapi-async-crud.py @@ -0,0 +1,61 @@ +import os +import shutil +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from fastapi_fastkit.cli import fastkit_cli +from fastapi_fastkit.utils.main import is_fastkit_project + + +class TestFastAPIAsyncCRUD: + runner = CliRunner() + + @pytest.fixture + def temp_dir(self, tmpdir): + os.chdir(tmpdir) + yield str(tmpdir) + # Clean up + os.chdir(os.path.expanduser("~")) + + def test_startdemo_fastapi_async_crud(self, temp_dir): + # given + project_name = "test-async-crud" + author = "test-author" + author_email = "test@example.com" + description = "A test FastAPI project with async CRUD operations" + + # when + result = self.runner.invoke( + fastkit_cli, + ["startdemo", "fastapi-async-crud"], + input="\n".join([project_name, author, author_email, description, "Y"]), + ) + + # then + project_path = Path(temp_dir) / project_name + + assert project_path.exists(), "Project directory was not created" + assert result.exit_code == 0, f"CLI command failed: {result.output}" + assert "Success" in result.output, "Success message not found in output" + + assert is_fastkit_project( + str(project_path) + ), "Not identified as a fastkit project" + + assert (project_path / "setup.py").exists(), "setup.py not found" + assert (project_path / "src" / "main.py").exists(), "main.py not found" + assert ( + project_path / "requirements.txt" + ).exists(), "requirements.txt not found" + + assert (project_path / "src" / "api").exists(), "API module not found" + assert (project_path / "src" / "schemas").exists(), "Schemas module not found" + assert (project_path / "src" / "crud").exists(), "CRUD module not found" + + with open(project_path / "setup.py", "r") as f: + setup_content = f.read() + assert project_name in setup_content, "Project name not injected" + assert author in setup_content, "Author not injected" + assert description in setup_content, "Description not injected" diff --git a/tests/test_templates/test_fastapi-customized-response.py b/tests/test_templates/test_fastapi-customized-response.py index e69de29..778772c 100644 --- a/tests/test_templates/test_fastapi-customized-response.py +++ b/tests/test_templates/test_fastapi-customized-response.py @@ -0,0 +1,61 @@ +import os +import shutil +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from fastapi_fastkit.cli import fastkit_cli +from fastapi_fastkit.utils.main import is_fastkit_project + + +class TestFastAPICustomizedResponse: + runner = CliRunner() + + @pytest.fixture + def temp_dir(self, tmpdir): + os.chdir(tmpdir) + yield str(tmpdir) + # Clean up + os.chdir(os.path.expanduser("~")) + + def test_startdemo_fastapi_customized_response(self, temp_dir): + # given + project_name = "test-custom-response" + author = "test-author" + author_email = "test@example.com" + description = "A test FastAPI project with customized response handling" + + # when + result = self.runner.invoke( + fastkit_cli, + ["startdemo", "fastapi-custom-response"], + input="\n".join([project_name, author, author_email, description, "Y"]), + ) + + # then + project_path = Path(temp_dir) / project_name + + assert project_path.exists(), "Project directory was not created" + assert result.exit_code == 0, f"CLI command failed: {result.output}" + assert "Success" in result.output, "Success message not found in output" + + assert is_fastkit_project( + str(project_path) + ), "Not identified as a fastkit project" + + assert (project_path / "setup.py").exists(), "setup.py not found" + assert (project_path / "src" / "main.py").exists(), "main.py not found" + assert ( + project_path / "requirements.txt" + ).exists(), "requirements.txt not found" + + assert ( + project_path / "src" / "schemas" / "base.py" + ).exists(), "Custom responses module not found" + + with open(project_path / "setup.py", "r") as f: + setup_content = f.read() + assert project_name in setup_content, "Project name not injected" + assert author in setup_content, "Author not injected" + assert description in setup_content, "Description not injected" diff --git a/tests/test_templates/test_fastapi-default.py b/tests/test_templates/test_fastapi-default.py index e69de29..30eab97 100644 --- a/tests/test_templates/test_fastapi-default.py +++ b/tests/test_templates/test_fastapi-default.py @@ -0,0 +1,58 @@ +import os +import shutil +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from fastapi_fastkit.cli import fastkit_cli +from fastapi_fastkit.utils.main import is_fastkit_project + + +class TestFastAPIDefault: + runner = CliRunner() + + @pytest.fixture + def temp_dir(self, tmpdir): + os.chdir(tmpdir) + yield str(tmpdir) + # Clean up + os.chdir(os.path.expanduser("~")) + + def test_startdemo_fastapi_default(self, temp_dir): + # given + project_name = "test-default" + author = "test-author" + author_email = "test@example.com" + description = "A test FastAPI project with default template" + + # when + result = self.runner.invoke( + fastkit_cli, + ["startdemo", "fastapi-default"], + input="\n".join([project_name, author, author_email, description, "Y"]), + ) + + # then + project_path = Path(temp_dir) / project_name + + assert project_path.exists(), "Project directory was not created" + assert result.exit_code == 0, f"CLI command failed: {result.output}" + assert "Success" in result.output, "Success message not found in output" + + assert is_fastkit_project( + str(project_path) + ), "Not identified as a fastkit project" + + assert (project_path / "setup.py").exists(), "setup.py not found" + assert (project_path / "src" / "main.py").exists(), "main.py not found" + assert ( + project_path / "requirements.txt" + ).exists(), "requirements.txt not found" + assert (project_path / ".venv").exists(), "Virtual environment not created" + + with open(project_path / "setup.py", "r") as f: + setup_content = f.read() + assert project_name in setup_content, "Project name not injected" + assert author in setup_content, "Author not injected" + assert description in setup_content, "Description not injected" diff --git a/tests/test_templates/test_fastapi-dockerized.py b/tests/test_templates/test_fastapi-dockerized.py index e69de29..2d011fb 100644 --- a/tests/test_templates/test_fastapi-dockerized.py +++ b/tests/test_templates/test_fastapi-dockerized.py @@ -0,0 +1,59 @@ +import os +import shutil +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from fastapi_fastkit.cli import fastkit_cli +from fastapi_fastkit.utils.main import is_fastkit_project + + +class TestFastAPIDockerized: + runner = CliRunner() + + @pytest.fixture + def temp_dir(self, tmpdir): + os.chdir(tmpdir) + yield str(tmpdir) + # Clean up + os.chdir(os.path.expanduser("~")) + + def test_startdemo_fastapi_dockerized(self, temp_dir): + # given + project_name = "test-dockerized" + author = "test-author" + author_email = "test@example.com" + description = "A test FastAPI project with Docker support" + + # when + result = self.runner.invoke( + fastkit_cli, + ["startdemo", "fastapi-dockerized"], + input="\n".join([project_name, author, author_email, description, "Y"]), + ) + + # then + project_path = Path(temp_dir) / project_name + + assert project_path.exists(), "Project directory was not created" + assert result.exit_code == 0, f"CLI command failed: {result.output}" + assert "Success" in result.output, "Success message not found in output" + + assert is_fastkit_project( + str(project_path) + ), "Not identified as a fastkit project" + + assert (project_path / "setup.py").exists(), "setup.py not found" + assert (project_path / "src" / "main.py").exists(), "main.py not found" + assert ( + project_path / "requirements.txt" + ).exists(), "requirements.txt not found" + + assert (project_path / "Dockerfile").exists(), "Dockerfile not found" + + with open(project_path / "setup.py", "r") as f: + setup_content = f.read() + assert project_name in setup_content, "Project name not injected" + assert author in setup_content, "Author not injected" + assert description in setup_content, "Description not injected" From 07fa5eed27d4f2a7ce5a64fc10fef9be4b6a2d6b Mon Sep 17 00:00:00 2001 From: bnbong Date: Sat, 1 Mar 2025 18:41:20 +0900 Subject: [PATCH 5/5] [RELEASE] 1.0.0 --- CONTRIBUTING.md | 2 +- README.md | 20 +++++++++++++++++--- src/fastapi_fastkit/__init__.py | 2 +- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 15b1d5f..1fa8401 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -67,7 +67,7 @@ bash scripts/lint.sh ``` -### Making commits +### Making PRs Use these tags in PR title: diff --git a/README.md b/README.md index 7cc49d1..7c44751 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,9 @@ $ pip install FastAPI-fastkit ### Create a new FastAPI project workspace environment immediately -Create a new FastAPI project workspace with: +You can now start new FastAPI project really fast with FastAPI-fastkit! + +Create a new FastAPI project workspace immediately with: ```console $ fastkit init @@ -108,7 +110,9 @@ This command will create a new FastAPI project workspace environment with Python ### Add a new route to the FastAPI project -Add a new route to the FastAPI project with: +`FastAPI-fastkit` makes it easy to expand your FastAPI project. + +Add a new route endpoint to your FastAPI project with: ```console $ fastkit addroute @@ -124,7 +128,11 @@ $ fastkit addroute ``` -### Place a structured FastAPI project immediately +### Place a structured FastAPI demo project immediately + +You can also start with a structured FastAPI demo project. + +Demo projects are consist of various tech stacks with simple item CRUD endpoints implemented. Place a structured FastAPI demo project immediately with: @@ -159,6 +167,12 @@ FastAPI template project will deploy at '' ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ``` +To view the list of available FastAPI demos, check with: + +```console +$ fastkit list-templates +``` + ## Significance of FastAPI-fastkit FastAPI-fastkit aims to provide a fast and easy-to-use starter kit for new users of Python and FastAPI. diff --git a/src/fastapi_fastkit/__init__.py b/src/fastapi_fastkit/__init__.py index 77fac73..5e1ac5b 100644 --- a/src/fastapi_fastkit/__init__.py +++ b/src/fastapi_fastkit/__init__.py @@ -1,4 +1,4 @@ -__version__ = "0.1.4" +__version__ = "1.0.0" import os