diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..cf7e995 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,163 @@ +name: CI/CD Pipeline + +on: + push: + branches: [ main, develop, "copilot/*" ] + pull_request: + branches: [ main, develop ] + +jobs: + lint: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.11] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Cache pip dependencies + uses: actions/cache@v3 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip- + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run ruff linter + run: | + ruff check . + + - name: Run ruff formatter check + run: | + ruff format --check . + + - name: Run mypy type checker + run: | + mypy . --ignore-missing-imports + + test: + needs: lint + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.11] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Cache pip dependencies + uses: actions/cache@v3 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip- + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run unit tests + run: | + pytest tests/ -v --tb=short -m "not integration" + + - name: Run integration tests + run: | + pytest tests/ -v --tb=short -m "integration" || echo "No integration tests found" + + - name: Generate coverage report + run: | + pip install coverage + coverage run -m pytest tests/ + coverage report -m + coverage xml + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + file: ./coverage.xml + flags: unittests + name: codecov-umbrella + fail_ci_if_error: false + + security: + needs: lint + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v4 + with: + python-version: 3.11 + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install safety bandit + + - name: Run safety check + run: | + safety check --json || true + + - name: Run bandit security linter + run: | + bandit -r . -f json || true + + validate-functions: + needs: [lint, test] + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v4 + with: + python-version: 3.11 + + - name: Install Azure Functions Core Tools + run: | + curl https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > microsoft.gpg + sudo mv microsoft.gpg /etc/apt/trusted.gpg.d/microsoft.gpg + sudo sh -c 'echo "deb [arch=amd64] https://packages.microsoft.com/repos/microsoft-ubuntu-$(lsb_release -cs)-prod $(lsb_release -cs) main" > /etc/apt/sources.list.d/dotnetdev.list' + sudo apt-get update + sudo apt-get install azure-functions-core-tools-4 + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Copy local settings template + run: | + cp local.settings.json.template local.settings.json + + - name: Validate Functions project structure + run: | + # Check if host.json exists and is valid + python -c "import json; json.load(open('host.json'))" + + # Check if function_app.py can be imported + python -c "import function_app" + + # Validate Functions project (dry run) + func validate --verbose || echo "Validation warnings acceptable for PoC" \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1e310ab --- /dev/null +++ b/.gitignore @@ -0,0 +1,144 @@ +# 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/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +*.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/ + +# 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 +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +Pipfile.lock + +# PEP 582 +__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/ + +# Azure Functions artifacts +bin +obj +appsettings.json +local.settings.json + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Temporary files +tmp/ +temp/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5793dae --- /dev/null +++ b/README.md @@ -0,0 +1,318 @@ +# Azure Functions Python 3.11 PoC Template + +このリポジトリは、Azure Functions v4 を Python 3.11 で実装するためのプロジェクトテンプレートです。ベストプラクティスに従った構成とローカル開発環境の構築手順が含まれています。 + +## 📋 要件 + +- Python 3.11 +- Azure Functions Core Tools v4 +- Azure CLI(オプション) + +## 🚀 ローカル開発環境のセットアップ + +### 1. リポジトリのクローンと仮想環境の作成 + +```bash +# リポジトリをクローン +git clone +cd AzureFunctionsPython + +# Python 3.11 仮想環境の作成 +python3.11 -m venv .venv + +# 仮想環境の有効化 (Linux/Mac) +source .venv/bin/activate + +# 仮想環境の有効化 (Windows) +.venv\Scripts\activate +``` + +### 2. 依存関係のインストール + +```bash +# 依存関係をインストール +pip install -r requirements.txt +``` + +### 3. Azure Functions Core Tools のインストール + +#### Linux (Ubuntu/Debian) +```bash +# Microsoft パッケージリポジトリを追加 +curl https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > microsoft.gpg +sudo mv microsoft.gpg /etc/apt/trusted.gpg.d/microsoft.gpg +sudo sh -c 'echo "deb [arch=amd64] https://packages.microsoft.com/repos/microsoft-ubuntu-$(lsb_release -cs)-prod $(lsb_release -cs) main" > /etc/apt/sources.list.d/dotnetdev.list' + +# Azure Functions Core Tools v4 をインストール +sudo apt-get update +sudo apt-get install azure-functions-core-tools-4 +``` + +#### macOS +```bash +# Homebrew を使用 +brew tap azure/functions +brew install azure-functions-core-tools@4 +``` + +#### Windows +```powershell +# Chocolatey を使用 +choco install azure-functions-core-tools-4 + +# または npm を使用 +npm install -g azure-functions-core-tools@4 --unsafe-perm true +``` + +### 4. ローカル設定ファイルの準備 + +```bash +# テンプレートから設定ファイルをコピー +cp local.settings.json.template local.settings.json + +# 必要に応じて local.settings.json を編集 +``` + +### 5. Functions アプリケーションの起動 + +```bash +# Azure Functions を起動 +func start + +# または開発モードで起動(自動リロード) +func start --python +``` + +アプリケーションは `http://localhost:7071` で起動します。 + +## 📡 エンドポイントの確認 + +### Hello エンドポイント +```bash +# GET リクエスト(クエリパラメータ) +curl "http://localhost:7071/api/hello?name=Azure" + +# POST リクエスト(JSON ボディ) +curl -X POST "http://localhost:7071/api/hello" \ + -H "Content-Type: application/json" \ + -d '{"name": "Functions"}' + +# パラメータなしのリクエスト +curl "http://localhost:7071/api/hello" +``` + +## 🧪 テストの実行 + +### 単体テストの実行 +```bash +# 全テストを実行 +pytest + +# 単体テストのみ実行 +pytest -m "not integration" + +# 詳細な出力でテストを実行 +pytest -v + +# カバレッジ付きでテストを実行 +pytest --cov=. --cov-report=html +``` + +### 統合テストの実行 +```bash +# 統合テストのみ実行(準備ができている場合) +pytest -m integration +``` + +## 🔍 コード品質チェック + +### リンターと フォーマッター +```bash +# Ruff でリント +ruff check . + +# Ruff でフォーマット +ruff format . + +# Mypy で型チェック +mypy . --ignore-missing-imports +``` + +### セキュリティチェック +```bash +# Safety で脆弱性チェック +safety check + +# Bandit でセキュリティ問題をチェック +bandit -r . +``` + +## 📁 プロジェクト構成 + +``` +AzureFunctionsPython/ +├── .github/ +│ ├── workflows/ +│ │ └── ci.yml # CI/CD パイプライン +│ └── copilot-instructions.md # 開発ガイドライン +├── endpoints/ # 関数エンドポイント +│ └── hello/ # サンプル HTTP 関数 +│ ├── __init__.py # Blueprint 定義 +│ └── endpoint.py # 関数ハンドラー +├── shared/ # 共通ユーティリティ +│ ├── __init__.py +│ ├── response_helpers.py # レスポンス作成ヘルパー +│ └── validators.py # 入力値検証 +├── tests/ # テストスイート +│ ├── conftest.py # テスト設定 +│ ├── test_hello.py # Hello 関数のテスト +│ └── test_shared.py # 共通機能のテスト +├── .gitignore # Git 無視ファイル +├── function_app.py # メインアプリケーション +├── host.json # Functions ランタイム設定 +├── local.settings.json.template # ローカル設定テンプレート +├── pyproject.toml # Python プロジェクト設定 +├── requirements.txt # Python 依存関係 +└── README.md # このファイル +``` + +## 🛠 新しい関数の追加 + +### 1. エンドポイントディレクトリの作成 +```bash +mkdir -p endpoints/my_function +``` + +### 2. 関数ハンドラーの実装 +`endpoints/my_function/endpoint.py`: +```python +import azure.functions as func +import logging + +def my_function_handler(req: func.HttpRequest) -> func.HttpResponse: + logging.info("My function processed a request.") + # 関数のロジックを実装 + return func.HttpResponse("Hello from my function!") +``` + +### 3. Blueprint の定義 +`endpoints/my_function/__init__.py`: +```python +import azure.functions as func +from .endpoint import my_function_handler + +bp = func.Blueprint() + +@bp.function_name(name="my_function") +@bp.route(route="my_function", methods=["GET", "POST"]) +def my_function(req: func.HttpRequest) -> func.HttpResponse: + return my_function_handler(req) +``` + +### 4. メインアプリに登録 +`function_app.py` に blueprint を追加: +```python +from endpoints.my_function import bp as my_function_bp +app.register_blueprint(my_function_bp) +``` + +## 🔐 シークレット管理 + +### ローカル開発 +- `local.settings.json` にシークレットを保存(Git にコミットしない) +- 環境変数での設定も可能 + +### 本番環境 +- Azure Key Vault を使用 +- Managed Identity での認証を推奨 +- App Settings での環境変数設定 + +## 📊 監視とロギング + +### Application Insights の設定 +```python +import logging +from opencensus.ext.azure.log_exporter import AzureLogHandler + +# Application Insights の設定 +logger = logging.getLogger(__name__) +logger.addHandler(AzureLogHandler( + connection_string="InstrumentationKey=your-key-here" +)) +``` + +### カスタムメトリクス +```python +from opencensus.stats import aggregation as aggregation_module +from opencensus.stats import measure as measure_module +from opencensus.stats import view as view_module + +# カスタムメトリクスの定義と使用 +``` + +## 🔄 CI/CD パイプライン + +GitHub Actions ワークフローが以下のステップを実行します: + +1. **Lint**: コード品質チェック(ruff, mypy) +2. **Test**: 単体テスト・統合テストの実行 +3. **Security**: セキュリティスキャン(safety, bandit) +4. **Validate**: Functions プロジェクト構造の検証 + +## 🚀 デプロイ + +### Azure への手動デプロイ +```bash +# Azure CLI でログイン +az login + +# Functions アプリにデプロイ +func azure functionapp publish +``` + +### GitHub Actions でのデプロイ +CI/CD パイプラインでデプロイを自動化することが可能です。詳細は `.github/workflows/ci.yml` を参照してください。 + +## 🆘 トラブルシューティング + +### よくある問題 + +1. **Python 3.11 が見つからない** + ```bash + # pyenv を使用して Python 3.11 をインストール + pyenv install 3.11.0 + pyenv local 3.11.0 + ``` + +2. **Functions Core Tools の起動エラー** + ```bash + # 依存関係の再インストール + pip uninstall azure-functions-worker + pip install azure-functions-worker + ``` + +3. **インポートエラー** + ```bash + # PYTHONPATH の設定 + export PYTHONPATH="${PYTHONPATH}:$(pwd)" + ``` + +### ログの確認 +```bash +# Functions のログを確認 +func logs +``` + +## 📚 参考資料 + +- [Azure Functions Python 開発者ガイド](https://docs.microsoft.com/ja-jp/azure/azure-functions/functions-reference-python) +- [Azure Functions Core Tools](https://docs.microsoft.com/ja-jp/azure/azure-functions/functions-run-local) +- [Python 3.11 公式ドキュメント](https://docs.python.org/3.11/) + +## 🤝 コントリビューション + +プロジェクトへの貢献を歓迎します。変更を行う前に、`.github/copilot-instructions.md` のガイドラインを確認してください。 + +## 📄 ライセンス + +このプロジェクトは MIT ライセンスの下で提供されています。 \ No newline at end of file diff --git a/dev.py b/dev.py new file mode 100755 index 0000000..f4b9a23 --- /dev/null +++ b/dev.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +""" +Development helper script for Azure Functions project +""" +import argparse +import subprocess +import sys +import os +from pathlib import Path + + +def run_command(cmd, check=True): + """Run a shell command and return the result""" + print(f"Running: {cmd}") + result = subprocess.run(cmd, shell=True, check=check, executable='/bin/bash') + return result + + +def setup_environment(): + """Set up the development environment""" + print("Setting up development environment...") + + # Create virtual environment if it doesn't exist + if not Path(".venv").exists(): + print("Creating virtual environment...") + run_command("python3 -m venv .venv") + + # Install dependencies + print("Installing dependencies...") + run_command("source .venv/bin/activate && pip install --upgrade pip") + run_command("source .venv/bin/activate && pip install -r requirements.txt") + + # Copy local settings if not exists + if not Path("local.settings.json").exists(): + print("Creating local.settings.json from template...") + run_command("cp local.settings.json.template local.settings.json") + + print("Development environment setup complete!") + + +def run_tests(): + """Run all tests""" + print("Running tests...") + run_command("source .venv/bin/activate && pytest tests/ -v") + + +def run_lint(): + """Run linting and code quality checks""" + print("Running linting...") + run_command("source .venv/bin/activate && ruff check .", check=False) + run_command("source .venv/bin/activate && ruff format --check .", check=False) + run_command("source .venv/bin/activate && mypy . --ignore-missing-imports", check=False) + + +def format_code(): + """Format code with ruff""" + print("Formatting code...") + run_command("source .venv/bin/activate && ruff format .") + + +def start_functions(): + """Start Azure Functions runtime""" + print("Starting Azure Functions...") + print("Make sure you have Azure Functions Core Tools installed!") + run_command("source .venv/bin/activate && func start") + + +def clean(): + """Clean up generated files""" + print("Cleaning up...") + run_command("find . -name '__pycache__' -type d -exec rm -rf {} + 2>/dev/null || true") + run_command("find . -name '*.pyc' -delete 2>/dev/null || true") + run_command("rm -rf .pytest_cache .mypy_cache .coverage htmlcov/ dist/ build/") + print("Cleanup complete!") + + +def main(): + parser = argparse.ArgumentParser(description="Development helper for Azure Functions") + parser.add_argument("command", choices=[ + "setup", "test", "lint", "format", "start", "clean" + ], help="Command to run") + + args = parser.parse_args() + + if args.command == "setup": + setup_environment() + elif args.command == "test": + run_tests() + elif args.command == "lint": + run_lint() + elif args.command == "format": + format_code() + elif args.command == "start": + start_functions() + elif args.command == "clean": + clean() + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/endpoints/__init__.py b/endpoints/__init__.py new file mode 100644 index 0000000..f5ad05c --- /dev/null +++ b/endpoints/__init__.py @@ -0,0 +1,3 @@ +""" +Endpoints package for Azure Functions +""" \ No newline at end of file diff --git a/endpoints/hello/__init__.py b/endpoints/hello/__init__.py new file mode 100644 index 0000000..df78ffb --- /dev/null +++ b/endpoints/hello/__init__.py @@ -0,0 +1,4 @@ +""" +Hello endpoint blueprint +""" +from .endpoint import bp \ No newline at end of file diff --git a/endpoints/hello/endpoint.py b/endpoints/hello/endpoint.py new file mode 100644 index 0000000..7397a9d --- /dev/null +++ b/endpoints/hello/endpoint.py @@ -0,0 +1,73 @@ +""" +Hello World HTTP Function +Sample Azure Function demonstrating HTTP trigger with best practices. +""" +import azure.functions as func +import json +import logging +from datetime import datetime, timezone +from typing import Dict, Any + +from shared.response_helpers import create_json_response +from shared.validators import validate_request_data + +# Create blueprint for hello endpoint +bp = func.Blueprint() + + +def hello_handler(req: func.HttpRequest) -> func.HttpResponse: + """ + HTTP trigger function that returns a greeting message. + + Args: + req: HTTP request object + + Returns: + JSON response with greeting message + """ + logging.info("Hello function processed a request.") + + try: + # Get name from query params or request body + name = req.params.get('name') + + if not name: + try: + req_body = req.get_json() + if req_body: + name = req_body.get('name') + except ValueError: + pass + + # Validate input + if name and not validate_request_data({'name': name}): + return create_json_response( + {"error": "Invalid input data"}, + status_code=400 + ) + + # Create response data + response_data: Dict[str, Any] = { + "message": f"Hello, {name or 'World'}!", + "timestamp": datetime.now(timezone.utc).isoformat(), + "function": "hello", + "version": "1.0.0" + } + + logging.info(f"Hello function returning greeting for: {name or 'World'}") + return create_json_response(response_data) + + except Exception as e: + logging.error(f"Error in hello function: {str(e)}") + return create_json_response( + {"error": "Internal server error"}, + status_code=500 + ) + + +# Register HTTP trigger function with decorators +@bp.function_name(name="hello") +@bp.route(route="hello", methods=["GET", "POST"]) +def hello(req: func.HttpRequest) -> func.HttpResponse: + """Azure Functions HTTP trigger entry point.""" + return hello_handler(req) \ No newline at end of file diff --git a/function_app.py b/function_app.py new file mode 100644 index 0000000..091ee48 --- /dev/null +++ b/function_app.py @@ -0,0 +1,14 @@ +import azure.functions as func +import logging + +# Import blueprints from endpoints +from endpoints.hello import bp as hello_bp + +# Configure logging +logging.basicConfig(level=logging.INFO) + +# Create the main function app +app = func.FunctionApp() + +# Register blueprints +app.register_blueprint(hello_bp) \ No newline at end of file diff --git a/host.json b/host.json new file mode 100644 index 0000000..d96488f --- /dev/null +++ b/host.json @@ -0,0 +1,16 @@ +{ + "version": "2.0", + "functionTimeout": "00:05:00", + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + } + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + } +} \ No newline at end of file diff --git a/local.settings.json.template b/local.settings.json.template new file mode 100644 index 0000000..e394d85 --- /dev/null +++ b/local.settings.json.template @@ -0,0 +1,18 @@ +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FUNCTIONS_WORKER_RUNTIME": "python", + "FUNCTIONS_EXTENSION_VERSION": "~4", + "APPINSIGHTS_INSTRUMENTATIONKEY": "your-application-insights-key-here", + "AZURE_CLIENT_ID": "your-managed-identity-client-id-here", + "KEY_VAULT_URL": "https://your-keyvault.vault.azure.net/", + "SERVICEBUS_CONNECTION_STRING": "your-servicebus-connection-string-here", + "STORAGE_ACCOUNT_CONNECTION_STRING": "your-storage-account-connection-string-here" + }, + "Host": { + "LocalHttpPort": 7071, + "CORS": "*", + "CORSCredentials": false + } +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d91d55b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,114 @@ +[tool.pytest.ini_options] +minversion = "6.0" +addopts = "-ra -q --strict-markers" +testpaths = [ + "tests", +] +python_files = [ + "test_*.py", + "*_test.py", +] +python_classes = [ + "Test*", +] +python_functions = [ + "test_*", +] +markers = [ + "unit: marks tests as unit tests (deselect with '-m \"not unit\"')", + "integration: marks tests as integration tests (deselect with '-m \"not integration\"')", + "slow: marks tests as slow (deselect with '-m \"not slow\"')", +] + +[tool.ruff] +# Enable pycodestyle (`E`) and Pyflakes (`F`) codes by default. +select = ["E", "F", "W", "I", "N", "UP", "YTT", "S", "BLE", "FBT", "B", "A", "COM", "C4", "DTZ", "T10", "EM", "EXE", "ISC", "ICN", "G", "INP", "PIE", "T20", "PYI", "PT", "Q", "RSE", "RET", "SLF", "SIM", "TID", "TCH", "ARG", "PTH", "ERA", "PD", "PGH", "PL", "TRY", "NPY", "RUF"] + +# Never enforce `E501` (line length violations). +ignore = ["E501", "S101", "T201"] + +# Allow autofix for all enabled rules (when `--fix`) is provided. +fixable = ["A", "B", "C", "D", "E", "F", "G", "I", "N", "Q", "S", "T", "W", "ANN", "ARG", "BLE", "COM", "DJ", "DTZ", "EM", "ERA", "EXE", "FBT", "ICN", "INP", "ISC", "NPY", "PD", "PGH", "PIE", "PL", "PT", "PTH", "PYI", "RET", "RSE", "RUF", "SIM", "SLF", "TCH", "TID", "TRY", "UP", "YTT"] +unfixable = [] + +# Exclude a variety of commonly ignored directories. +exclude = [ + ".bzr", + ".direnv", + ".eggs", + ".git", + ".git-rewrite", + ".hg", + ".mypy_cache", + ".nox", + ".pants.d", + ".pytype", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + "__pypackages__", + "_build", + "buck-out", + "build", + "dist", + "node_modules", + "venv", +] + +# Same as Black. +line-length = 88 + +# Allow unused variables when underscore-prefixed. +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" + +# Assume Python 3.11. +target-version = "py311" + +[tool.ruff.mccabe] +# Unlike Flake8, default to a complexity level of 10. +max-complexity = 10 + +[tool.black] +line-length = 88 +target-version = ['py311'] +include = '\.pyi?$' +exclude = ''' +/( + \.eggs + | \.git + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | _build + | buck-out + | build + | dist +)/ +''' + +[tool.mypy] +python_version = "3.11" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +disallow_untyped_decorators = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_no_return = true +warn_unreachable = true +strict_equality = true + +[[tool.mypy.overrides]] +module = [ + "azure.functions.*", + "azure.identity.*", + "azure.keyvault.*", + "azure.storage.*", + "azure.servicebus.*", +] +ignore_missing_imports = true \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a66e7e3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,35 @@ +# Azure Functions for Python - Core dependencies +azure-functions==1.18.0 +azure-functions-worker==1.1.9 + +# Azure SDK +azure-identity==1.16.1 +azure-keyvault-secrets==4.8.0 +azure-storage-queue==12.9.0 +azure-servicebus==7.12.0 + +# HTTP and API client +requests==2.31.0 +httpx==0.27.0 + +# Logging and monitoring +opencensus-ext-azure==1.1.13 +opencensus-ext-logging==0.1.1 + +# Data processing +pydantic==2.7.1 +python-dateutil==2.9.0.post0 + +# Development and testing +pytest==8.2.0 +pytest-asyncio==0.23.6 +pytest-mock==3.14.0 + +# Code quality +ruff==0.4.4 +black==24.4.0 +mypy==1.10.0 + +# FastAPI for mock services in testing +fastapi==0.111.0 +uvicorn==0.29.0 \ No newline at end of file diff --git a/shared/__init__.py b/shared/__init__.py new file mode 100644 index 0000000..9894a89 --- /dev/null +++ b/shared/__init__.py @@ -0,0 +1,3 @@ +""" +Shared utilities package for Azure Functions +""" \ No newline at end of file diff --git a/shared/response_helpers.py b/shared/response_helpers.py new file mode 100644 index 0000000..d84ab78 --- /dev/null +++ b/shared/response_helpers.py @@ -0,0 +1,67 @@ +""" +Shared response helpers for Azure Functions +""" +import azure.functions as func +import json +from typing import Dict, Any, Optional + + +def create_json_response( + data: Dict[str, Any], + status_code: int = 200, + headers: Optional[Dict[str, str]] = None +) -> func.HttpResponse: + """ + Create a JSON HTTP response with proper headers. + + Args: + data: Dictionary to serialize as JSON + status_code: HTTP status code (default: 200) + headers: Additional headers (optional) + + Returns: + HttpResponse object with JSON content + """ + response_headers = { + "Content-Type": "application/json", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY" + } + + if headers: + response_headers.update(headers) + + return func.HttpResponse( + body=json.dumps(data, ensure_ascii=False), + status_code=status_code, + headers=response_headers + ) + + +def create_error_response( + message: str, + status_code: int = 500, + error_code: Optional[str] = None +) -> func.HttpResponse: + """ + Create a standardized error response. + + Args: + message: Error message + status_code: HTTP status code + error_code: Optional error code for client handling + + Returns: + HttpResponse object with error details + """ + error_data = { + "error": { + "message": message, + "status_code": status_code + } + } + + if error_code: + error_data["error"]["code"] = error_code + + return create_json_response(error_data, status_code) \ No newline at end of file diff --git a/shared/validators.py b/shared/validators.py new file mode 100644 index 0000000..058db65 --- /dev/null +++ b/shared/validators.py @@ -0,0 +1,69 @@ +""" +Request validation utilities +""" +import re +from typing import Dict, Any, List, Optional + + +def validate_request_data(data: Dict[str, Any]) -> bool: + """ + Basic validation for request data. + + Args: + data: Dictionary containing request data + + Returns: + True if data is valid, False otherwise + """ + if not isinstance(data, dict): + return False + + # Check for required fields based on data content + if 'name' in data: + return validate_name(data['name']) + + return True + + +def validate_name(name: str) -> bool: + """ + Validate name field. + + Args: + name: Name string to validate + + Returns: + True if name is valid, False otherwise + """ + if not isinstance(name, str): + return False + + # Basic validation: only letters, spaces, and common punctuation + # Length between 1 and 100 characters + if not (1 <= len(name.strip()) <= 100): + return False + + # Allow letters, numbers, spaces, and basic punctuation + pattern = r'^[a-zA-Z0-9\s\.\-\_]+$' + return bool(re.match(pattern, name.strip())) + + +def sanitize_string(value: str, max_length: int = 255) -> str: + """ + Sanitize string input. + + Args: + value: String to sanitize + max_length: Maximum allowed length + + Returns: + Sanitized string + """ + if not isinstance(value, str): + return "" + + # Remove control characters and trim + sanitized = re.sub(r'[\x00-\x1f\x7f-\x9f]', '', value.strip()) + + # Truncate if too long + return sanitized[:max_length] if len(sanitized) > max_length else sanitized \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..7984bf9 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,35 @@ +""" +Test configuration for Azure Functions +""" +import pytest +import sys +import os + +# Add the parent directory to the path to import the function modules +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +@pytest.fixture +def mock_http_request(): + """ + Create a mock HTTP request for testing. + """ + from unittest.mock import Mock + import azure.functions as func + + def create_request(method="GET", url="http://localhost:7071/api/hello", + params=None, json_body=None, headers=None): + request = Mock(spec=func.HttpRequest) + request.method = method + request.url = url + request.params = params or {} + request.headers = headers or {} + + if json_body: + request.get_json.return_value = json_body + else: + request.get_json.side_effect = ValueError("No JSON data") + + return request + + return create_request \ No newline at end of file diff --git a/tests/test_hello.py b/tests/test_hello.py new file mode 100644 index 0000000..a4f600c --- /dev/null +++ b/tests/test_hello.py @@ -0,0 +1,65 @@ +""" +Unit tests for hello endpoint +""" +import pytest +import json +from endpoints.hello.endpoint import hello_handler + + +class TestHelloEndpoint: + """Test cases for hello endpoint""" + + def test_hello_with_query_param(self, mock_http_request): + """Test hello function with name in query parameters""" + request = mock_http_request(params={"name": "Azure"}) + + response = hello_handler(request) + + assert response.status_code == 200 + response_data = json.loads(response.get_body()) + assert "Hello, Azure!" in response_data["message"] + assert "timestamp" in response_data + assert response_data["function"] == "hello" + + def test_hello_with_json_body(self, mock_http_request): + """Test hello function with name in JSON body""" + request = mock_http_request( + method="POST", + json_body={"name": "Functions"} + ) + + response = hello_handler(request) + + assert response.status_code == 200 + response_data = json.loads(response.get_body()) + assert "Hello, Functions!" in response_data["message"] + + def test_hello_without_name(self, mock_http_request): + """Test hello function without name parameter""" + request = mock_http_request() + + response = hello_handler(request) + + assert response.status_code == 200 + response_data = json.loads(response.get_body()) + assert "Hello, World!" in response_data["message"] + + def test_hello_with_invalid_name(self, mock_http_request): + """Test hello function with invalid name""" + request = mock_http_request(params={"name": ""}) + + response = hello_handler(request) + + assert response.status_code == 400 + response_data = json.loads(response.get_body()) + assert "error" in response_data + + def test_hello_response_headers(self, mock_http_request): + """Test response headers are properly set""" + request = mock_http_request(params={"name": "Test"}) + + response = hello_handler(request) + + assert response.headers["Content-Type"] == "application/json" + assert "X-Content-Type-Options" in response.headers + assert "X-Frame-Options" in response.headers \ No newline at end of file diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..d962b65 --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,63 @@ +""" +Integration tests for Azure Functions +""" +import pytest +import json +import time +import subprocess +import signal +import os +from unittest.mock import patch + +# Optional imports for integration tests +try: + import requests + REQUESTS_AVAILABLE = True +except ImportError: + REQUESTS_AVAILABLE = False + + +@pytest.mark.integration +class TestFunctionsIntegration: + """Integration tests that require Functions runtime""" + + def test_hello_endpoint_integration(self): + """ + Integration test for hello endpoint. + This would require Functions runtime to be running. + """ + # This is a placeholder for actual integration tests + # In a real scenario, you would: + # 1. Start the Functions runtime in a separate process + # 2. Wait for it to be ready + # 3. Make HTTP requests to test endpoints + # 4. Clean up the runtime process + + pytest.skip("Integration tests require Functions runtime setup") + + # Example of what the actual test would look like: + # base_url = "http://localhost:7071" + # response = requests.get(f"{base_url}/api/hello?name=Integration") + # assert response.status_code == 200 + # data = response.json() + # assert "Hello, Integration!" in data["message"] + + +@pytest.mark.integration +class TestMockApiIntegration: + """Integration tests with mock external APIs""" + + def test_external_api_integration(self): + """ + Test integration with external APIs using mocks. + This demonstrates how to test with external dependencies. + """ + # This is a placeholder for testing with external API mocks + # You would use FastAPI or similar to create mock services + pytest.skip("Mock API integration tests not implemented yet") + + # Example implementation: + # 1. Start FastAPI mock server + # 2. Configure Functions to use mock endpoints + # 3. Test end-to-end scenarios + # 4. Verify API calls and responses \ No newline at end of file diff --git a/tests/test_shared.py b/tests/test_shared.py new file mode 100644 index 0000000..ed73d39 --- /dev/null +++ b/tests/test_shared.py @@ -0,0 +1,69 @@ +""" +Unit tests for shared utilities +""" +import pytest +from shared.validators import validate_name, validate_request_data, sanitize_string +from shared.response_helpers import create_json_response, create_error_response + + +class TestValidators: + """Test cases for validation utilities""" + + def test_validate_name_valid(self): + """Test name validation with valid names""" + assert validate_name("John") is True + assert validate_name("John Doe") is True + assert validate_name("John-Doe") is True + assert validate_name("John_Doe") is True + assert validate_name("John123") is True + + def test_validate_name_invalid(self): + """Test name validation with invalid names""" + assert validate_name("") is False + assert validate_name(" ") is False + assert validate_name("a" * 101) is False # Too long + assert validate_name("