diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 09adbf3..aa5bb45 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,8 +1,95 @@ -Written to: -/home/dewald/Workspace/personal/github/python_rest_tutorial/.github/copilot-instructions.md - -Changes made: -- Expanded the project overview section with stack details -- Added repository structure, environment variables, authentication, testing, dependency management, and Postman sections — all drawn directly from the README -- Preserved the existing human-authored Code Exploration section (codegraph + understand-anything) verbatim -- Kept everything concise and terminal-readable, no invented facts +# python_rest_tutorial — Copilot Instructions + +## Project Overview + +Educational REST API built with Python, Flask, Docker, and MongoDB. +Stack: Flask 3.1.3, flask-restful, pymongo 4.7.2, bcrypt, PyJWT, pytest, Ruff, Docker + docker-compose. + +Reference article: +https://www.dvt.co.za/news-insights/insights/item/355-restful-web-services-using-python-flask-docker-and-mongodb + +## Repository Structure + + web/ Flask application source (app.py) + Dockerfile + web/requirements.txt Pinned runtime and dev dependencies (includes ruff, pytest) + web/tests/ Unit tests for the Flask app + tests/ Config-level and API-contract tests + scripts/lint.sh Local lint script — mirrors CI exactly + docker-compose.yml Service definitions: web app + MongoDB + .env.example Environment variable template + pyproject.toml Ruff linting and formatting config + +## Environment Variables + +| Variable | Default | Description | +|------------|------------------------|----------------------------------------------------------| +| MONGO_URI | mongodb://my_db:27017/ | MongoDB connection string | +| JWT_SECRET | (required) | JWT signing secret. Must be long and random. Never commit.| + +## Authentication Flow + +1. Register: POST /register {"username": "...", "password": "..."} +2. Login: POST /login {"username": "...", "password": "..."} → returns {"token": "..."} +3. Protected: POST /retrieve or /save with header Authorization: Bearer + +Tokens expire after 1 hour. Missing/expired/tampered tokens return 401. + +## Running Locally + + cp .env.example .env # set JWT_SECRET + sudo docker-compose build + sudo docker-compose up + curl http://localhost:5000/hello # → "Hello World!" + +## Running Tests + + pip install -r web/requirements.txt + pytest -v + # Runs web/tests/ (unit) and tests/ (config + contract) + +## Linting + + ./scripts/lint.sh # check only — same as CI + ./scripts/lint.sh --fix # auto-fix then check + +Ruff config is in pyproject.toml. Rules: E, F, I (pycodestyle, Pyflakes, isort). +Line length: 120. Both web/ and tests/ are in scope. + +## CI Pipeline + +Defined in .github/workflows/ci.yml. Two sequential jobs on every push/PR: + + lint → test + +lint: ruff check + ruff format --check +test: pytest -v (only runs if lint passes) + +## Code Conventions + +- All imports must be sorted (ruff I rules enforced in CI). +- Use insert_one / update_one (PyMongo 4.x API — not deprecated insert/update). +- JWT datetime must use timezone-aware datetimes: datetime.datetime.now(timezone.utc). +- Passwords are bcrypt-hashed — never stored in plaintext. +- All endpoints return {"status": , ...} JSON bodies. + +## Code Exploration + +### codegraph + +.codegraph/ is present. Use it first for symbol lookup and call tracing. + + codegraph context "" -p . + codegraph query "" -p . + codegraph affected -p . + codegraph sync . + +### understand-anything + +.understand-anything/knowledge-graph.json is present. Use for architecture questions. + + skill: understand-chat + +Decision order for code tasks: + 1. codegraph context — which symbols matter? + 2. understand-anything — where in the architecture does this live? + 3. Read raw source — only the 1-2 files that actually matter. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ba373ef --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI + +on: + push: + branches: ["**"] + pull_request: + branches: ["**"] + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install ruff + run: pip install "ruff>=0.5.0" + - name: ruff check + run: ruff check web/ tests/ + - name: ruff format check + run: ruff format --check web/ tests/ + + test: + name: Tests + runs-on: ubuntu-latest + needs: lint + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install dependencies + run: pip install -r web/requirements.txt + - name: Run tests + run: pytest -v diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 343dde1..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: Tests - -on: - push: - branches: ["**"] - pull_request: - branches: ["**"] - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - name: Install dependencies - run: pip install -r web/requirements.txt - - name: Run tests - run: pytest diff --git a/.gitignore b/.gitignore index 5bd7f39..5de785f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,20 +1,92 @@ +# Environment variables .env .env.local .env.*.local + +# Python bytecode __pycache__/ *.pyc *.pyo *.pyd .Python + +# Distribution / packaging +*.egg *.egg-info/ dist/ build/ -.vscode/ -.idea/ +eggs/ +parts/ +var/ +sdist/ +wheels/ +*.whl +MANIFEST + +# Virtual environments .venv/ +venv/ +env/ +ENV/ +.virtualenv/ + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage .pytest_cache/ .coverage +.coverage.* htmlcov/ +.tox/ +.nox/ +nosetests.xml +coverage.xml +*.cover + +# MyPy / type checking +.mypy_cache/ +.dmypy.json +dmypy.json +.pyre/ +.pytype/ + +# Ruff / linting +.ruff_cache/ + +# IDEs and editors +.vscode/ +.idea/ +*.swp +*.swo +*~ +.project +.classpath +.settings/ + +# macOS +.DS_Store +.AppleDouble +.LSOverride + +# Windows +Thumbs.db +ehthumbs.db +Desktop.ini + +# Docker +docker-compose.override.yml + +# Logs +*.log +logs/ + +# MongoDB data (if mounted locally) +data/ + +# Jupyter notebooks checkpoints +.ipynb_checkpoints/ # BEGIN workspace-orchestrator .hermes_tmp_prompt.txt diff --git a/AGENTS.md b/AGENTS.md index cdd570b..5c99787 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,13 +11,17 @@ API design, containerisation, JWT authentication, password hashing, and testing. Reference article: https://www.dvt.co.za/news-insights/insights/item/355-restful-web-services-using-python-flask-docker-and-mongodb -Stack: Flask 2.2.5, flask-restful, pymongo, bcrypt, PyJWT, pytest, Docker + docker-compose, MongoDB. +Stack: Flask 3.1.3, flask-restful 0.3.10, pymongo 4.7.2, bcrypt 4.1.3, PyJWT >=2.8.0, pytest 9.0.3, Ruff >=0.5.0, Docker + docker-compose, MongoDB. ## Repository Structure - web/ Python application source and tests - web/requirements.txt Pinned runtime + dev dependencies - docker-compose.yml Service definitions (app + MongoDB) + web/ Flask application source (app.py) + Dockerfile + web/requirements.txt Pinned runtime + dev dependencies (includes ruff, pytest) + web/tests/ Unit tests for the Flask app + tests/ Config-level and API-contract tests + scripts/lint.sh Local lint script — mirrors CI exactly + pyproject.toml Ruff linting and formatting config + docker-compose.yml Service definitions: web app + MongoDB .env.example Environment variable template ## Getting Started @@ -61,9 +65,27 @@ Missing, expired, or tampered tokens return 401 Unauthorized. ## Running Tests - cd web - pip install -r requirements.txt - pytest +Run from the project root — pytest discovers both web/tests/ and tests/: + + pip install -r web/requirements.txt + pytest -v + +## Linting + +The project uses Ruff (replaces flake8 + isort + black). Config is in pyproject.toml. + + ./scripts/lint.sh # check only — same as CI + ./scripts/lint.sh --fix # auto-fix then check + +## CI Pipeline + +Defined in .github/workflows/ci.yml. Two sequential jobs on every push and PR: + + lint → test + +lint: ruff check + ruff format --check across web/ and tests/ +test: pytest -v (only runs if lint passes) + ## Postman @@ -78,8 +100,9 @@ All dependencies are exact-pinned in web/requirements.txt for reproducible build Upgrade procedure: 1. Update the version in web/requirements.txt. 2. Reinstall: pip install -r web/requirements.txt -3. Run tests: cd web && pytest -4. Commit the updated requirements.txt. +3. Run tests: pytest -v +4. Run lint: ./scripts/lint.sh +5. Commit the updated requirements.txt. diff --git a/README.md b/README.md index 75c4c81..4661d8a 100755 --- a/README.md +++ b/README.md @@ -1,74 +1,90 @@ # python_rest_tutorial -[![Tests](https://github.com/DewaldOosthuizen/python_rest_tutorial/actions/workflows/test.yml/badge.svg)](https://github.com/DewaldOosthuizen/python_rest_tutorial/actions/workflows/test.yml) -[![Codacy Badge](https://app.codacy.com/project/badge/Grade/53014a434fb340f2afde9853e2314a8a)](https://www.codacy.com/gh/DewaldOosthuizen/python_rest_tutorial/dashboard?utm_source=github.com&utm_medium=referral&utm_content=DewaldOosthuizen/python_rest_tutorial&utm_campaign=Badge_Grade) +[![CI](https://github.com/DewaldOosthuizen/python_rest_tutorial/actions/workflows/ci.yml/badge.svg)](https://github.com/DewaldOosthuizen/python_rest_tutorial/actions/workflows/ci.yml) +[![Codacy Badge](https://app.codacy.com/project/badge/Grade/53014a434fb340f2afde9853e2314a8a)](https://www.codacy.com/gh/DewaldOosthuizen/python_rest_tutorial/dashboard?utm_source=github.com&utm_medium=referral&utm_content=DewaldOosthuizen/python_rest_tutorial&utm_campaign=Badge_Grade) [![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=RVJC5VUM5ZEW8&source=url) [![License](http://img.shields.io/badge/Licence-MIT-brightgreen.svg)](LICENSE.md) -This is a comprehensive guide and implementation to help developers learn how to create RESTful APIs using Python, Flask, Docker and MongoDB. It demonstrates best practices for -building scalable and efficient APIs, leveraging Python's capabilities alongside Docker for containerization. The repository serves as an educational -resource for both beginners and experienced developers looking to refine their skills in REST API development. +A comprehensive guide and implementation to help developers learn how to create RESTful APIs +using Python, Flask, Docker, and MongoDB. It demonstrates best practices for building scalable +and efficient APIs, leveraging Python's capabilities alongside Docker for containerization. +The repository serves as an educational resource for both beginners and experienced developers +looking to refine their skills in REST API development. -Here is an article you can follow to create this project from the beginning: +Reference article: -## Docker and docker-compose +## Repository Structure -Inside the root project you can run +``` +web/ Python application source (Flask app + Dockerfile) +web/requirements.txt Pinned runtime and dev dependencies +web/tests/ Unit tests for the Flask application +tests/ Integration / config-level tests +scripts/lint.sh Local lint script (mirrors CI exactly) +docker-compose.yml Service definitions: app + MongoDB +.env.example Environment variable template +``` + + +## Getting Started + +### 1. Configure environment variables ```shell -sudo docker-compose build +cp .env.example .env ``` -and then run the folowing to start the container and expose the API: +Edit `.env` and set a strong `JWT_SECRET` before starting the application. + +### 2. Build and start the containers ```shell +sudo docker-compose build sudo docker-compose up ``` -Once the container is running, you can access it by opening your browser and typing in localhost:5000/hello. This should -display a "Hello World!" message. - -There are also other endpoints to test with, and can be found in the article mentioned at the top. +### 3. Verify the service -## Using postman +Open in your browser or run: -When using postman to test your rest endpoints, be sure to add content-type: application/json to your headers. +```shell +curl http://localhost:5000/hello +``` -If you don't want to specify content type in the header then you can use -request.get_json(force=True) inside your endpoint when fetching the data from the request -to force the data to be read as JSON. +Expected response: `"Hello World!"` -For reference have a look at ## Environment Variables -The application is configured via environment variables. Copy the example file to get started: +| Variable | Default | Description | +|------------|------------------------|-----------------------------------------------------------------------------| +| MONGO_URI | mongodb://my_db:27017/ | MongoDB connection string. Override for remote or authenticated instances. | +| JWT_SECRET | *(required)* | Secret key for signing and verifying JWT tokens. Use a long random string. | -```shell -cp .env.example .env -``` - -| Variable | Default | Description | -|------------|---------------------------|----------------------------------------------------------------------------------| -| MONGO_URI | mongodb://my_db:27017/ | MongoDB connection string. Override with credentials for remote/secured instances.| -| JWT_SECRET | *(required)* | Secret key used to sign and verify JWT tokens. Must be a long random string. Never hardcode or commit this value. | +Never hardcode or commit `JWT_SECRET`. ## Authentication The API uses JWT (JSON Web Token) bearer authentication. -### 1. Obtain a token — POST /login +### 1. Register a user — POST /register + +```shell +curl -X POST http://localhost:5000/register \ + -H "Content-Type: application/json" \ + -d '{"username": "alice", "password": "yourpassword"}' +``` -Send your credentials once to receive a signed token: +### 2. Obtain a token — POST /login ```shell curl -X POST http://localhost:5000/login \ -H "Content-Type: application/json" \ - -d '{"username": "alice", "password": "secret"}' + -d '{"username": "alice", "password": "yourpassword"}' ``` Response (200 OK): @@ -77,9 +93,9 @@ Response (200 OK): {"status": 200, "token": ""} ``` -On invalid credentials the endpoint returns 401 with `{"status": 401, "msg": "Invalid credentials"}`. +On invalid credentials the endpoint returns 401. -### 2. Call protected endpoints — Authorization: Bearer +### 3. Call protected endpoints — Authorization: Bearer Pass the token in the `Authorization` header on every call to `/retrieve` and `/save`: @@ -98,35 +114,90 @@ curl -X POST http://localhost:5000/save \ Missing, expired, or tampered tokens return 401 Unauthorized. -## Running the tests +## Using Postman + +Add `Content-Type: application/json` to your request headers. + +If you prefer not to set the content-type header manually, the endpoints use +`request.get_json(force=True)` which will parse the body as JSON regardless. + +See for context. + + +## Running the Tests + +Install dependencies and run the full test suite from the project root: + +```bash +pip install -r web/requirements.txt +pytest -v +``` + +This runs both `web/tests/` (unit tests) and `tests/` (config and API-contract tests). + + +## Linting + +The project uses [Ruff](https://docs.astral.sh/ruff/) for linting and formatting +(replaces flake8 + isort + black in a single fast tool). + +### Run locally (mirrors CI exactly) + +```bash +# Check only +./scripts/lint.sh + +# Auto-fix then check +./scripts/lint.sh --fix +``` + +### Run ruff directly ```bash -cd web -pip install -r requirements.txt -pytest +ruff check web/ tests/ # lint +ruff format --check web/ tests/ # format check +ruff format web/ tests/ # apply formatting +``` + +Ruff is included in `web/requirements.txt` so no separate install is needed +once the project dependencies are installed. + + +## CI Pipeline + +Every push and pull request runs the GitHub Actions CI pipeline: + +``` +lint → test ``` +- **lint** — `ruff check` + `ruff format --check` across `web/` and `tests/` +- **test** — `pytest -v` (only runs if lint passes) + +The pipeline is defined in `.github/workflows/ci.yml`. + + ## Dependencies -All runtime and development dependencies are pinned to exact versions in web/requirements.txt -to ensure reproducible builds and avoid unexpected breakage from upstream changes. - -| Package | Version | Role | -|----------------|----------------|--------------------| -| Flask | 2.2.5 | Web framework | -| Werkzeug | 2.3.7 | WSGI utilities | -| flask-restful | 0.3.10 | REST API helpers | -| pymongo | 4.6.3 | MongoDB driver | -| bcrypt | 4.0.1 | Password hashing | -| PyJWT | >=2.8.0 | JWT authentication | -| pytest | 9.0.3 | Test runner (dev) | - -Rationale: Floating version specifiers (>=) allow pip to silently pull in -breaking releases. Exact pins (==) guarantee that every environment — local, -CI, and Docker — runs the same code. - -Upgrade procedure: -1. Update the version number in web/requirements.txt. -2. Rebuild/reinstall: pip install -r web/requirements.txt -3. Run the full test suite: cd web && pytest -4. If all tests pass, commit the updated requirements.txt. +All runtime and development dependencies are pinned in `web/requirements.txt` +to ensure reproducible builds across local, CI, and Docker environments. + +| Package | Version | Role | +|---------------|----------|---------------------------| +| Flask | 3.1.3 | Web framework | +| Werkzeug | >=3.0.0 | WSGI utilities | +| flask-restful | 0.3.10 | REST resource helpers | +| pymongo | 4.7.2 | MongoDB driver | +| bcrypt | 4.1.3 | Password hashing | +| PyJWT | >=2.8.0 | JWT authentication | +| pytest | 9.0.3 | Test runner (dev) | +| pytest-cov | >=4.1 | Coverage reports (dev) | +| ruff | >=0.5.0 | Linting and formatting (dev) | + +### Upgrade procedure + +1. Update the version in `web/requirements.txt`. +2. Reinstall: `pip install -r web/requirements.txt` +3. Run the test suite: `pytest -v` +4. Run the linter: `./scripts/lint.sh` +5. If all checks pass, commit the updated `requirements.txt`. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fb8f015 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,22 @@ +[tool.ruff] +target-version = "py311" +line-length = 120 + +[tool.ruff.lint] +# E — pycodestyle errors +# F — Pyflakes (unused imports, undefined names, etc.) +# I — isort (import ordering) +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff.lint.per-file-ignores] +"tests/*.py" = [ + "S101", # assert is idiomatic in pytest + "S105", # hardcoded test passwords are intentional fixture values + "E402", # sys.modules injection may precede imports in test helpers +] +"web/tests/*.py" = [ + "S101", + "S105", + "E402", +] diff --git a/scripts/lint.sh b/scripts/lint.sh new file mode 100755 index 0000000..3cfe3dd --- /dev/null +++ b/scripts/lint.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Lint script — mirrors CI exactly so "passes locally" == "passes in CI". +# +# Usage: +# ./scripts/lint.sh # check only (same as CI) +# ./scripts/lint.sh --fix # auto-fix then check + +set -euo pipefail + +FIX=0 +for arg in "$@"; do + [[ "$arg" == "--fix" ]] && FIX=1 +done + +echo "==> lint" + +if [[ $FIX -eq 1 ]]; then + echo "--- ruff format (fix) ---" + ruff format web/ tests/ + echo "--- ruff check --fix ---" + ruff check web/ tests/ --fix + echo "--- re-check after fixes ---" +fi + +echo "--- ruff check web/ tests/ ---" +ruff check web/ tests/ + +echo "--- ruff format --check web/ tests/ ---" +ruff format --check web/ tests/ + +echo "==> All lint checks passed." diff --git a/tests/test_app_config.py b/tests/test_app_config.py index 81d0f3e..2c9b019 100644 --- a/tests/test_app_config.py +++ b/tests/test_app_config.py @@ -1,11 +1,11 @@ """ Tests for issue #3 - Fix hardcoded MongoDB credentials exposed in source code """ + import os -import importlib import sys import unittest -from unittest.mock import patch, MagicMock +from unittest.mock import MagicMock, patch class TestMongoURIConfig(unittest.TestCase): @@ -15,44 +15,41 @@ def _reload_app_module(self, env_vars=None): """Helper to reload app.py with patched environment and mocked MongoClient.""" mongo_mock = MagicMock() with patch.dict(os.environ, env_vars or {}, clear=False): - with patch('pymongo.MongoClient', return_value=mongo_mock) as mock_client: + with patch("pymongo.MongoClient", return_value=mongo_mock) as mock_client: # Remove cached module if present - if 'app' in sys.modules: - del sys.modules['app'] + if "app" in sys.modules: + del sys.modules["app"] # Add web/ to path temporarily - web_path = os.path.join(os.path.dirname(__file__), '..', 'web') + web_path = os.path.join(os.path.dirname(__file__), "..", "web") web_path = os.path.abspath(web_path) if web_path not in sys.path: sys.path.insert(0, web_path) import app # noqa: F401 + return mock_client def test_mongo_uri_env_var_used_as_default(self): """MONGO_URI env var should be read via os.environ.get with correct default.""" # Read app.py source and verify os.environ.get is used for MONGO_URI - web_app_path = os.path.join(os.path.dirname(__file__), '..', 'web', 'app.py') + web_app_path = os.path.join(os.path.dirname(__file__), "..", "web", "app.py") with open(web_app_path) as f: source = f.read() - self.assertIn('os.environ.get', source, - "app.py must use os.environ.get to read MONGO_URI") - self.assertIn('MONGO_URI', source, - "app.py must reference the MONGO_URI environment variable") + self.assertIn("os.environ.get", source, "app.py must use os.environ.get to read MONGO_URI") + self.assertIn("MONGO_URI", source, "app.py must reference the MONGO_URI environment variable") def test_mongo_uri_fallback_default(self): """Fallback default for MONGO_URI must be 'mongodb://my_db:27017/'.""" - web_app_path = os.path.join(os.path.dirname(__file__), '..', 'web', 'app.py') + web_app_path = os.path.join(os.path.dirname(__file__), "..", "web", "app.py") with open(web_app_path) as f: source = f.read() - self.assertIn('mongodb://my_db:27017/', source, - "Default MONGO_URI fallback must be 'mongodb://my_db:27017/'") + self.assertIn("mongodb://my_db:27017/", source, "Default MONGO_URI fallback must be 'mongodb://my_db:27017/'") def test_import_os_present(self): """app.py must import the os module.""" - web_app_path = os.path.join(os.path.dirname(__file__), '..', 'web', 'app.py') + web_app_path = os.path.join(os.path.dirname(__file__), "..", "web", "app.py") with open(web_app_path) as f: source = f.read() - self.assertIn('import os', source, - "app.py must contain 'import os'") + self.assertIn("import os", source, "app.py must contain 'import os'") def test_env_var_overrides_default(self): """When MONGO_URI env var is set, it should override the default.""" @@ -69,5 +66,5 @@ def test_default_used_when_env_not_set(self): self.assertEqual(result, "mongodb://my_db:27017/") -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/test_issue_17_pymongo_api.py b/tests/test_issue_17_pymongo_api.py index 4d83971..6520636 100644 --- a/tests/test_issue_17_pymongo_api.py +++ b/tests/test_issue_17_pymongo_api.py @@ -2,77 +2,85 @@ Tests for issue #17: Replace deprecated PyMongo collection.insert() and collection.update() calls. Verifies that web/app.py uses the modern insert_one and update_one PyMongo 4.x API. """ -import ast -import os +import os -APP_PATH = os.path.join(os.path.dirname(__file__), '..', 'web', 'app.py') -REQUIREMENTS_PATH = os.path.join(os.path.dirname(__file__), '..', 'web', 'requirements.txt') +APP_PATH = os.path.join(os.path.dirname(__file__), "..", "web", "app.py") +REQUIREMENTS_PATH = os.path.join(os.path.dirname(__file__), "..", "web", "requirements.txt") def get_app_source(): - with open(APP_PATH, 'r') as f: + with open(APP_PATH, "r") as f: return f.read() def get_requirements(): - with open(REQUIREMENTS_PATH, 'r') as f: + with open(REQUIREMENTS_PATH, "r") as f: return f.read() class TestModernPyMongoAPI: def test_insert_one_is_used(self): source = get_app_source() - assert 'insert_one(' in source, "app.py must use insert_one() (PyMongo 4.x API)" + assert "insert_one(" in source, "app.py must use insert_one() (PyMongo 4.x API)" def test_deprecated_insert_not_used(self): source = get_app_source() # Must not call the old collection.insert() directly (not insert_one/insert_many) import re + # Match .insert( but not .insert_one( or .insert_many( - deprecated_calls = re.findall(r'\.insert\s*\(', source) + deprecated_calls = re.findall(r"\.insert\s*\(", source) assert not deprecated_calls, f"app.py must NOT use deprecated .insert() call, found: {deprecated_calls}" def test_update_one_is_used(self): source = get_app_source() - assert 'update_one(' in source, "app.py must use update_one() (PyMongo 4.x API)" + assert "update_one(" in source, "app.py must use update_one() (PyMongo 4.x API)" def test_deprecated_update_not_used(self): source = get_app_source() import re + # Match .update( but not .update_one( or .update_many( - deprecated_calls = re.findall(r'\.update\s*\(', source) + deprecated_calls = re.findall(r"\.update\s*\(", source) assert not deprecated_calls, f"app.py must NOT use deprecated .update() call, found: {deprecated_calls}" class TestRequirementsBumps: def test_pymongo_version(self): reqs = get_requirements() - assert 'pymongo==4.7.2' in reqs, "requirements.txt must specify pymongo==4.7.2" + assert "pymongo==4.7.2" in reqs, "requirements.txt must specify pymongo==4.7.2" def test_flask_version(self): + import re + reqs = get_requirements() - assert 'Flask==3.0.3' in reqs, "requirements.txt must specify Flask==3.0.3" + match = re.search(r"Flask==(\S+)", reqs) + assert match, "requirements.txt must pin Flask with ==" + major = int(match.group(1).split(".")[0]) + assert major >= 3, f"Flask must be >=3.x for modern API compatibility, got: {match.group(1)}" def test_bcrypt_version(self): reqs = get_requirements() - assert 'bcrypt==4.1.3' in reqs, "requirements.txt must specify bcrypt==4.1.3" + assert "bcrypt==4.1.3" in reqs, "requirements.txt must specify bcrypt==4.1.3" def test_flask_restful_present(self): reqs = get_requirements() - assert 'flask-restful==0.3.10' in reqs, "requirements.txt must include flask-restful==0.3.10" + assert "flask-restful==0.3.10" in reqs, "requirements.txt must include flask-restful==0.3.10" def test_werkzeug_compatible_with_flask3(self): reqs = get_requirements() import re + # Werkzeug should be >=3.0.0 or a specific 3.x version - match = re.search(r'Werkzeug[>=!<]+(\S+)', reqs) + match = re.search(r"Werkzeug[>=!<]+(\S+)", reqs) assert match, "requirements.txt must specify Werkzeug" version_str = match.group(0) # Accept Werkzeug>=3.0.0 or Werkzeug==3.x.x major = None - ver_match = re.search(r'(\d+)\.', match.group(1)) + ver_match = re.search(r"(\d+)\.", match.group(1)) if ver_match: major = int(ver_match.group(1)) - assert major is not None and major >= 3, \ + assert major is not None and major >= 3, ( f"Werkzeug must be >=3.0.0 for Flask 3.x compatibility, got: {version_str}" + ) diff --git a/web/app.py b/web/app.py index 35244d7..f332e45 100755 --- a/web/app.py +++ b/web/app.py @@ -1,13 +1,15 @@ # import functools import datetime import os -import bcrypt -import jwt +from datetime import timezone from functools import wraps -from flask import Flask, jsonify, request +import bcrypt +import jwt +from flask import Flask, request from flask_restful import Api, Resource from pymongo import MongoClient + # print = functools.partial(print, flush=True) app = Flask(__name__) @@ -33,18 +35,18 @@ def verify_user(username, password): if not user_exist(username): return False - user_hashed_pw = users.find({ - "Username": username - })[0]["Password"] + user_hashed_pw = users.find({"Username": username})[0]["Password"] - return bcrypt.checkpw(password.encode('utf8'), user_hashed_pw) + return bcrypt.checkpw(password.encode("utf8"), user_hashed_pw) def get_user_messages(username): # get the messages - return users.find({ - "Username": username, - })[0]["Messages"] + return users.find( + { + "Username": username, + } + )[0]["Messages"] def requires_auth(f): @@ -60,6 +62,7 @@ def decorated(*args, **kwargs): except jwt.PyJWTError: return {"status": 401, "msg": "Unauthorized"}, 401 return f(*args, **kwargs) + return decorated @@ -76,6 +79,7 @@ class Hello(Resource): def get(self): return "Hello World!" + class Register(Resource): """ This is the Register resource class @@ -93,17 +97,14 @@ def post(self): return {"status": 400, "msg": "User already exists"}, 400 # encrypt password - hashed_pw = bcrypt.hashpw(password.encode('utf8'), bcrypt.gensalt()) + hashed_pw = bcrypt.hashpw(password.encode("utf8"), bcrypt.gensalt()) # Insert record - users.insert_one({ - "Username": username, - "Password": hashed_pw, - "Messages": [] - }) + users.insert_one({"Username": username, "Password": hashed_pw, "Messages": []}) return {"status": 200, "msg": "Registration successful"}, 200 + class Login(Resource): def post(self): data = request.get_json(silent=True, force=True) @@ -122,6 +123,7 @@ def post(self): ) return {"status": 200, "token": token}, 200 + class Retrieve(Resource): """ This is the Retrieve resource class @@ -132,6 +134,7 @@ def post(self): messages = get_user_messages(request.username) return {"status": 200, "obj": messages}, 200 + class Save(Resource): """ This is the Save resource class @@ -150,22 +153,16 @@ def post(self): messages.append(message) # save the new user message - users.update_one({ - "Username": username - }, { - "$set": { - "Messages": messages - } - }) + users.update_one({"Username": username}, {"$set": {"Messages": messages}}) return {"status": 200, "msg": "Message has been saved successfully"}, 200 -api.add_resource(Hello, '/hello') -api.add_resource(Register, '/register') -api.add_resource(Login, '/login') -api.add_resource(Retrieve, '/retrieve') -api.add_resource(Save, '/save') +api.add_resource(Hello, "/hello") +api.add_resource(Register, "/register") +api.add_resource(Login, "/login") +api.add_resource(Retrieve, "/retrieve") +api.add_resource(Save, "/save") if __name__ == "__main__": - app.run(host='0.0.0.0', debug=False, port=5000) + app.run(host="0.0.0.0", debug=False, port=5000) diff --git a/web/requirements.txt b/web/requirements.txt index e4ed06f..933108e 100644 --- a/web/requirements.txt +++ b/web/requirements.txt @@ -7,3 +7,4 @@ PyJWT>=2.8.0 # dev / test pytest==9.0.3 pytest-cov>=4.1 +ruff>=0.5.0 diff --git a/web/tests/test_app.py b/web/tests/test_app.py index 7aca062..36dc518 100644 --- a/web/tests/test_app.py +++ b/web/tests/test_app.py @@ -10,19 +10,21 @@ We NEVER use find_one — it is not called anywhere in app.py. """ + import datetime import os +from datetime import timezone +from unittest.mock import MagicMock, patch + import bcrypt -import pytest import jwt -from unittest.mock import MagicMock, patch +import pytest # Set JWT_SECRET before importing app os.environ["JWT_SECRET"] = "testsecret" from app import app - TEST_SECRET = "testsecret" @@ -30,6 +32,7 @@ # Helpers # --------------------------------------------------------------------------- + def make_user_cursor(username="alice", password="secret", messages=None): hashed = bcrypt.hashpw(password.encode("utf8"), bcrypt.gensalt()) user_doc = { @@ -69,6 +72,7 @@ def make_expired_token(username="alice"): # Fixtures # --------------------------------------------------------------------------- + @pytest.fixture def client(): app.config["TESTING"] = True @@ -83,6 +87,7 @@ def client(): # Hello # --------------------------------------------------------------------------- + def test_hello_returns_200(client): rv = client.get("/hello") assert rv.status_code == 200 @@ -99,6 +104,7 @@ def test_hello_returns_hello_world(client): # Register # --------------------------------------------------------------------------- + @patch("app.users") def test_register_new_user_returns_200(mock_users, client): mock_users.find.return_value = make_empty_cursor() @@ -126,6 +132,7 @@ def test_register_missing_body_returns_400(client): # Login # --------------------------------------------------------------------------- + @patch("app.users") def test_login_valid_credentials_returns_token(mock_users, client): mock_users.find.return_value = make_user_cursor(username="alice", password="secret") @@ -160,6 +167,7 @@ def test_login_missing_body_returns_400(client): # Retrieve # --------------------------------------------------------------------------- + @patch("app.users") def test_retrieve_no_auth_header_returns_401(mock_users, client): rv = client.post("/retrieve") @@ -195,6 +203,7 @@ def test_retrieve_valid_token_returns_messages(mock_users, client): # Save # --------------------------------------------------------------------------- + @patch("app.users") def test_save_no_auth_header_returns_401(mock_users, client): rv = client.post("/save", json={"message": "hi"}) @@ -236,6 +245,7 @@ def test_save_missing_message_returns_400(mock_users, client): # insert_one / update_one API migration (issue #6) # --------------------------------------------------------------------------- + @patch("app.users") def test_register_calls_insert_one(mock_users, client): """Register must use insert_one (not the deprecated insert)."""