──────
Build and Push Docker Images / detect-changes (push) Successful in 11s
Build and Push Docker Images / build-auth-api (push) Successful in 1m0s
Build and Push Docker Images / build-employee-api (push) Successful in 1m2s
Build and Push Docker Images / build-transit-api (push) Successful in 1m1s
Build and Push Docker Images / build-error-log-api (push) Successful in 1m5s
Build and Push Docker Images / build-admin-ui (push) Successful in 1m13s
Build and Push Docker Images / build-monitoring-ui (push) Successful in 1m15s
Build and Push Docker Images / deploy-test-node (push) Successful in 3s
Build and Push Docker Images / detect-changes (push) Successful in 11s
Build and Push Docker Images / build-auth-api (push) Successful in 1m0s
Build and Push Docker Images / build-employee-api (push) Successful in 1m2s
Build and Push Docker Images / build-transit-api (push) Successful in 1m1s
Build and Push Docker Images / build-error-log-api (push) Successful in 1m5s
Build and Push Docker Images / build-admin-ui (push) Successful in 1m13s
Build and Push Docker Images / build-monitoring-ui (push) Successful in 1m15s
Build and Push Docker Images / deploy-test-node (push) Successful in 3s
### Summary of Changes
1. Documentation Consolidation:
• Relocated the docs/ folder and all repository .md files (README.md, DEPLOYMENT.md, DOCKER_README.md,
DOMAINS_README.md, GETTING_STARTED.md, CI_CD_PORTAINER_GUIDE.md, ADMIN_DEPLOYMENT_SUMMARY.md,
SECURITY_PRIVATE_FILES.md, handoff.md, azure-project-plan.md, plus service-specific READMEs) into .
• Updated document links in README.md.
2. AI Agent Project Map:
• Created PROJECT_MAP.md in the project root containing complete architecture diagrams, service specs, ports, DB
schemas, environment variables, and developer workflows.
• Added PROJECT_MAP.md to .gitignore.
3. Monitoring UI & Infrastructure Integration:
• Added monitoring_ui service to docker-compose.yml (port 5175).
• Configured Gitea workflow docker-build.yml for monitoring_ui builds.
• Added CORS middleware headers in index.js.
• Added nginx.conf copy step in Dockerfile.
──────
### Recommended Commit Message
docs: consolidate project documentation into monitoring_ui and add agent project map
- Move all markdown documentation files and docs/ folder into monitoring_ui/docs/
- Update documentation hub index (monitoring_ui/docs/README.md) with relative paths
- Generate comprehensive AI agent reference map (PROJECT_MAP.md) in repository root
- Add PROJECT_MAP.md and monitoring_ui build output/env overrides to .gitignore
- Integrate monitoring_ui service into docker-compose.yml and Gitea CI workflow
- Configure CORS handling in employee_api for monitoring dashboard access
──────
### Alternative Short Commit Message
refactor(docs): move docs to monitoring_ui and create AI agent PROJECT_MAP.md
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,587 @@
|
||||
<!-- azure-cor-disclaimer -->
|
||||
> **Important:** This skill provides guidance and recommended instructions to assist the AI system. Outputs are not guaranteed to be complete, correct, secure, or applicable to every scenario. Results should be reviewed and validated by a human before being applied. The AI model may choose not to follow all instructions exactly, and additional verification may be required.
|
||||
|
||||
# Python Runtime Reference
|
||||
|
||||
> Azure Functions v2 model, Python. pytest, Pydantic validation, structlog, DI patterns.
|
||||
|
||||
---
|
||||
|
||||
## Azure Functions v2 Setup
|
||||
|
||||
### Initialization
|
||||
|
||||
```bash
|
||||
func init services/functions --python --model V2
|
||||
cd services/functions
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### host.json
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "2.0",
|
||||
"logging": {
|
||||
"applicationInsights": {
|
||||
"samplingSettings": {
|
||||
"isEnabled": true,
|
||||
"excludedTypes": "Request"
|
||||
}
|
||||
}
|
||||
},
|
||||
"extensionBundle": {
|
||||
"id": "Microsoft.Azure.Functions.ExtensionBundle",
|
||||
"version": "[4.*, 5.0.0)"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### local.settings.json
|
||||
|
||||
```json
|
||||
{
|
||||
"IsEncrypted": false,
|
||||
"Values": {
|
||||
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
|
||||
"FUNCTIONS_WORKER_RUNTIME": "python",
|
||||
"AZURE_FUNCTIONS_ENVIRONMENT": "Development",
|
||||
"ENVIRONMENT": "development",
|
||||
"STORAGE_CONNECTION_STRING": "UseDevelopmentStorage=true",
|
||||
"DATABASE_URL": "postgresql://<POSTGRES_USER>:<POSTGRES_PASSWORD>@localhost:5432/appdb",
|
||||
"REDIS_URL": "redis://localhost:6379"
|
||||
},
|
||||
"Host": {
|
||||
"CORS": "*",
|
||||
"CORSCredentials": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> `local.settings.json` is plain JSON and does **not** support `${...}` interpolation.
|
||||
> Replace `<POSTGRES_USER>` / `<POSTGRES_PASSWORD>` with the same discrete values declared in the
|
||||
> workspace-root `.env` used by docker-compose. Never leave the angle-bracket placeholders in the
|
||||
> generated file, and never paste a credential value copied out of tool output — redaction filters
|
||||
> rewrite concrete credential URLs, and a masked value beginning with `*` corrupts YAML/JSON config.
|
||||
|
||||
### pyproject.toml
|
||||
|
||||
```toml
|
||||
[project]
|
||||
name = "functions"
|
||||
version = "1.0.0"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"azure-functions>=1.17.0",
|
||||
"pydantic>=2.0.0",
|
||||
"structlog>=23.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=7.0.0",
|
||||
"pytest-asyncio>=0.21.0",
|
||||
"pytest-cov>=4.0.0",
|
||||
"ruff>=0.1.0",
|
||||
"httpx>=0.25.0",
|
||||
]
|
||||
|
||||
# Add per-service dependencies as needed:
|
||||
# "azure-storage-blob>=12.0.0",
|
||||
# "psycopg2-binary>=2.9.0",
|
||||
# "redis>=5.0.0",
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
python_files = ["test_*.py"]
|
||||
python_functions = ["test_*"]
|
||||
asyncio_mode = "auto"
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "N", "W"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Function Handler Pattern
|
||||
|
||||
### function_app.py (Registration)
|
||||
|
||||
```python
|
||||
# function_app.py
|
||||
import azure.functions as func
|
||||
from services.registry import initialize_services
|
||||
|
||||
# Initialize services on cold start
|
||||
initialize_services()
|
||||
|
||||
app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS)
|
||||
|
||||
# Import function handlers — they register themselves via decorators
|
||||
import functions.get_items # noqa: F401
|
||||
import functions.create_item # noqa: F401
|
||||
import functions.get_item_by_id # noqa: F401
|
||||
import functions.health # noqa: F401
|
||||
```
|
||||
|
||||
### HTTP Function (v2 Model)
|
||||
|
||||
```python
|
||||
# functions/get_items.py
|
||||
import azure.functions as func
|
||||
import json
|
||||
from services.registry import get_services
|
||||
from errors.error_handler import handle_error
|
||||
from function_app import app
|
||||
|
||||
@app.route(route="items", methods=["GET"])
|
||||
async def get_items(req: func.HttpRequest) -> func.HttpResponse:
|
||||
try:
|
||||
services = get_services()
|
||||
limit = int(req.params.get("limit", "20"))
|
||||
offset = int(req.params.get("offset", "0"))
|
||||
|
||||
items = await services.database.find_all("items", limit=limit, offset=offset)
|
||||
|
||||
return func.HttpResponse(
|
||||
json.dumps({"items": items, "total": len(items)}),
|
||||
status_code=200,
|
||||
mimetype="application/json",
|
||||
)
|
||||
except Exception as e:
|
||||
return handle_error(e)
|
||||
```
|
||||
|
||||
### POST with Validation
|
||||
|
||||
```python
|
||||
# functions/create_item.py
|
||||
import azure.functions as func
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from services.registry import get_services
|
||||
from errors.error_handler import handle_error
|
||||
from shared.validation import CreateItemRequest
|
||||
from function_app import app
|
||||
|
||||
@app.route(route="items", methods=["POST"])
|
||||
async def create_item(req: func.HttpRequest) -> func.HttpResponse:
|
||||
try:
|
||||
# Validate input
|
||||
body = req.get_json()
|
||||
validated = CreateItemRequest(**body)
|
||||
|
||||
services = get_services()
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
item = {
|
||||
"id": str(uuid.uuid4()),
|
||||
**validated.model_dump(),
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
|
||||
created = await services.database.create("items", item)
|
||||
|
||||
return func.HttpResponse(
|
||||
json.dumps({"item": created}),
|
||||
status_code=201,
|
||||
mimetype="application/json",
|
||||
)
|
||||
except Exception as e:
|
||||
return handle_error(e)
|
||||
```
|
||||
|
||||
### GET by ID with 404
|
||||
|
||||
```python
|
||||
# functions/get_item_by_id.py
|
||||
import azure.functions as func
|
||||
import json
|
||||
from services.registry import get_services
|
||||
from errors.error_handler import handle_error
|
||||
from errors.error_types import NotFoundError
|
||||
from function_app import app
|
||||
|
||||
@app.route(route="items/{id}", methods=["GET"])
|
||||
async def get_item_by_id(req: func.HttpRequest) -> func.HttpResponse:
|
||||
try:
|
||||
services = get_services()
|
||||
item_id = req.route_params.get("id")
|
||||
|
||||
item = await services.database.find_by_id("items", item_id)
|
||||
if item is None:
|
||||
raise NotFoundError("Item", item_id)
|
||||
|
||||
return func.HttpResponse(
|
||||
json.dumps({"item": item}),
|
||||
status_code=200,
|
||||
mimetype="application/json",
|
||||
)
|
||||
except Exception as e:
|
||||
return handle_error(e)
|
||||
```
|
||||
|
||||
### Health Check
|
||||
|
||||
```python
|
||||
# functions/health.py
|
||||
import azure.functions as func
|
||||
import json
|
||||
from services.registry import get_services
|
||||
from function_app import app
|
||||
|
||||
@app.route(route="health", methods=["GET"])
|
||||
async def health(req: func.HttpRequest) -> func.HttpResponse:
|
||||
services = get_services()
|
||||
checks = {}
|
||||
|
||||
try:
|
||||
checks["database"] = await services.database.health_check()
|
||||
except Exception:
|
||||
checks["database"] = False
|
||||
|
||||
try:
|
||||
checks["storage"] = await services.storage.health_check()
|
||||
except Exception:
|
||||
checks["storage"] = False
|
||||
|
||||
try:
|
||||
checks["cache"] = await services.cache.health_check()
|
||||
except Exception:
|
||||
checks["cache"] = False
|
||||
|
||||
all_healthy = all(checks.values())
|
||||
any_healthy = any(checks.values())
|
||||
status = "healthy" if all_healthy else ("degraded" if any_healthy else "unhealthy")
|
||||
|
||||
return func.HttpResponse(
|
||||
json.dumps({"status": status, "services": checks}),
|
||||
status_code=200 if all_healthy else 503,
|
||||
mimetype="application/json",
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## pytest Configuration
|
||||
|
||||
### conftest.py (Global Test Setup)
|
||||
|
||||
```python
|
||||
# tests/conftest.py
|
||||
import pytest
|
||||
import json
|
||||
from pathlib import Path
|
||||
from services.registry import register_services, clear_services, ServiceRegistry
|
||||
from tests.mocks.mock_database import MockDatabaseService
|
||||
from tests.mocks.mock_storage import MockStorageService
|
||||
from tests.mocks.mock_cache import MockCacheService
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_mock_services(item_fixtures):
|
||||
"""Register mock services before each test, clear after."""
|
||||
register_services(
|
||||
ServiceRegistry(
|
||||
database=MockDatabaseService({"items": item_fixtures["validItems"]}),
|
||||
storage=MockStorageService(),
|
||||
cache=MockCacheService(),
|
||||
)
|
||||
)
|
||||
yield
|
||||
clear_services()
|
||||
|
||||
@pytest.fixture
|
||||
def item_fixtures():
|
||||
fixture_path = Path(__file__).parent / "fixtures" / "items.json"
|
||||
with open(fixture_path) as f:
|
||||
return json.load(f)
|
||||
|
||||
@pytest.fixture
|
||||
def valid_item(item_fixtures):
|
||||
return item_fixtures["validItems"][0]
|
||||
|
||||
@pytest.fixture
|
||||
def invalid_items(item_fixtures):
|
||||
return item_fixtures["invalidItems"]
|
||||
|
||||
@pytest.fixture
|
||||
def mock_database(item_fixtures):
|
||||
return MockDatabaseService({"items": item_fixtures["validItems"]})
|
||||
```
|
||||
|
||||
### Test Examples
|
||||
|
||||
```python
|
||||
# tests/test_get_items.py
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock
|
||||
from services.registry import get_services
|
||||
|
||||
async def test_get_items_returns_all_items(item_fixtures):
|
||||
services = get_services()
|
||||
items = await services.database.find_all("items")
|
||||
assert len(items) == len(item_fixtures["validItems"])
|
||||
|
||||
async def test_get_items_returns_empty_when_no_data():
|
||||
from services.registry import register_services, ServiceRegistry
|
||||
from tests.mocks.mock_database import MockDatabaseService
|
||||
from tests.mocks.mock_storage import MockStorageService
|
||||
from tests.mocks.mock_cache import MockCacheService
|
||||
|
||||
register_services(
|
||||
ServiceRegistry(
|
||||
database=MockDatabaseService(),
|
||||
storage=MockStorageService(),
|
||||
cache=MockCacheService(),
|
||||
)
|
||||
)
|
||||
services = get_services()
|
||||
items = await services.database.find_all("items")
|
||||
assert items == []
|
||||
|
||||
async def test_get_item_by_id_returns_item(valid_item):
|
||||
services = get_services()
|
||||
item = await services.database.find_by_id("items", valid_item["id"])
|
||||
assert item is not None
|
||||
assert item["id"] == valid_item["id"]
|
||||
|
||||
async def test_get_item_by_id_returns_none_for_missing():
|
||||
services = get_services()
|
||||
item = await services.database.find_by_id("items", "nonexistent-id")
|
||||
assert item is None
|
||||
```
|
||||
|
||||
```python
|
||||
# tests/test_validation.py
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from shared.validation import CreateItemRequest
|
||||
|
||||
def test_valid_create_item():
|
||||
item = CreateItemRequest(
|
||||
name="Widget", description="A widget", price=29.99, category="widgets"
|
||||
)
|
||||
assert item.name == "Widget"
|
||||
assert item.price == 29.99
|
||||
|
||||
def test_empty_name_fails():
|
||||
with pytest.raises(ValidationError):
|
||||
CreateItemRequest(name="", description="A widget", price=29.99, category="widgets")
|
||||
|
||||
def test_negative_price_fails():
|
||||
with pytest.raises(ValidationError):
|
||||
CreateItemRequest(name="Widget", description="A widget", price=-5.0, category="widgets")
|
||||
|
||||
def test_missing_required_fields():
|
||||
with pytest.raises(ValidationError):
|
||||
CreateItemRequest(description="Just a description")
|
||||
```
|
||||
|
||||
```python
|
||||
# tests/test_error_handler.py
|
||||
import json
|
||||
from errors.error_types import NotFoundError, ValidationError, BadRequestError
|
||||
from errors.error_handler import handle_error
|
||||
|
||||
def test_not_found_error_returns_404():
|
||||
error = NotFoundError("Item", "abc-123")
|
||||
response = handle_error(error)
|
||||
body = json.loads(response.get_body())
|
||||
|
||||
assert response.status_code == 404
|
||||
assert body["error"]["code"] == "NOT_FOUND"
|
||||
assert "abc-123" in body["error"]["message"]
|
||||
|
||||
def test_validation_error_returns_422():
|
||||
error = ValidationError("Bad input", details=[{"field": "name", "message": "Required"}])
|
||||
response = handle_error(error)
|
||||
body = json.loads(response.get_body())
|
||||
|
||||
assert response.status_code == 422
|
||||
assert body["error"]["code"] == "VALIDATION_ERROR"
|
||||
|
||||
def test_unknown_error_returns_500():
|
||||
error = RuntimeError("Something broke")
|
||||
response = handle_error(error)
|
||||
body = json.loads(response.get_body())
|
||||
|
||||
assert response.status_code == 500
|
||||
assert body["error"]["code"] == "INTERNAL_ERROR"
|
||||
|
||||
def test_error_response_shape_is_consistent():
|
||||
errors = [
|
||||
NotFoundError("Item", "1"),
|
||||
ValidationError("Bad input"),
|
||||
BadRequestError("Bad request"),
|
||||
RuntimeError("Unknown"),
|
||||
]
|
||||
for error in errors:
|
||||
response = handle_error(error)
|
||||
body = json.loads(response.get_body())
|
||||
assert "error" in body
|
||||
assert "code" in body["error"]
|
||||
assert "message" in body["error"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Validation — Pydantic
|
||||
|
||||
### Schema Definition
|
||||
|
||||
```python
|
||||
# shared/validation.py
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
|
||||
class CreateItemRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
description: Optional[str] = ""
|
||||
price: float = Field(gt=0)
|
||||
category: str = Field(min_length=1, max_length=100)
|
||||
|
||||
class UpdateItemRequest(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
price: Optional[float] = Field(None, gt=0)
|
||||
category: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
|
||||
class PaginationParams(BaseModel):
|
||||
limit: int = Field(20, ge=1, le=100)
|
||||
offset: int = Field(0, ge=0)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Structured Logging — structlog
|
||||
|
||||
### Logger Setup
|
||||
|
||||
```python
|
||||
# logger.py
|
||||
import structlog
|
||||
import os
|
||||
import logging
|
||||
|
||||
def setup_logging():
|
||||
log_level = os.environ.get("LOG_LEVEL", "INFO").upper()
|
||||
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.contextvars.merge_contextvars,
|
||||
structlog.processors.add_log_level,
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
structlog.dev.set_exc_info,
|
||||
structlog.processors.TimeStamper(fmt="iso"),
|
||||
structlog.dev.ConsoleRenderer()
|
||||
if os.environ.get("ENVIRONMENT") == "development"
|
||||
else structlog.processors.JSONRenderer(),
|
||||
],
|
||||
wrapper_class=structlog.make_filtering_bound_logger(
|
||||
getattr(logging, log_level, logging.INFO)
|
||||
),
|
||||
context_class=dict,
|
||||
logger_factory=structlog.PrintLoggerFactory(),
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
|
||||
def get_logger(name: str = "app"):
|
||||
return structlog.get_logger(name)
|
||||
```
|
||||
|
||||
### Request Logging
|
||||
|
||||
```python
|
||||
# middleware/request_logger.py
|
||||
import time
|
||||
from logger import get_logger
|
||||
|
||||
logger = get_logger("http")
|
||||
|
||||
def log_request(method: str, path: str, status_code: int, start_time: float):
|
||||
duration_ms = round((time.time() - start_time) * 1000, 2)
|
||||
logger.info(
|
||||
"request_completed",
|
||||
method=method,
|
||||
path=path,
|
||||
status=status_code,
|
||||
duration_ms=duration_ms,
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Shared Types
|
||||
|
||||
```python
|
||||
# shared/types.py
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, Any
|
||||
from datetime import datetime
|
||||
|
||||
class Item(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
price: float
|
||||
category: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class ListItemsResponse(BaseModel):
|
||||
items: list[Item]
|
||||
total: int
|
||||
|
||||
class SingleItemResponse(BaseModel):
|
||||
item: Item
|
||||
|
||||
class ErrorDetail(BaseModel):
|
||||
code: str
|
||||
message: str
|
||||
details: Optional[Any] = None
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
error: ErrorDetail
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: str # "healthy" | "degraded" | "unhealthy"
|
||||
services: dict[str, bool]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dependencies Quick Reference
|
||||
|
||||
### Core Dependencies
|
||||
|
||||
| Package | Purpose |
|
||||
|---------|---------|
|
||||
| `azure-functions` | Azure Functions v2 runtime |
|
||||
| `pydantic` | Input validation & shared types |
|
||||
| `structlog` | Structured logging |
|
||||
|
||||
### Per Service
|
||||
|
||||
| Service | Package |
|
||||
|---------|---------|
|
||||
| Blob Storage | `azure-storage-blob` |
|
||||
| PostgreSQL | `psycopg2-binary` |
|
||||
| CosmosDB | `azure-cosmos` |
|
||||
| Redis | `redis` |
|
||||
| Migrations | `alembic`, `sqlalchemy` |
|
||||
|
||||
### Dev Dependencies
|
||||
|
||||
| Package | Purpose |
|
||||
|---------|---------|
|
||||
| `pytest` | Test runner |
|
||||
| `pytest-asyncio` | Async test support |
|
||||
| `pytest-cov` | Coverage reporting |
|
||||
| `ruff` | Linting + formatting |
|
||||
| `httpx` | HTTP client for request-level tests |
|
||||
@@ -0,0 +1,564 @@
|
||||
<!-- azure-cor-disclaimer -->
|
||||
> **Important:** This skill provides guidance and recommended instructions to assist the AI system. Outputs are not guaranteed to be complete, correct, secure, or applicable to every scenario. Results should be reviewed and validated by a human before being applied. The AI model may choose not to follow all instructions exactly, and additional verification may be required.
|
||||
|
||||
# TypeScript (Node.js) Runtime Reference
|
||||
|
||||
> Azure Functions v4 model, TypeScript. Test runner setup, validation, logging, DI patterns.
|
||||
|
||||
---
|
||||
|
||||
## Azure Functions v4 Setup
|
||||
|
||||
### Initialization
|
||||
|
||||
```bash
|
||||
func init services/functions --typescript --model V4
|
||||
cd services/functions
|
||||
npm install
|
||||
```
|
||||
|
||||
### host.json
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "2.0",
|
||||
"logging": {
|
||||
"applicationInsights": {
|
||||
"samplingSettings": {
|
||||
"isEnabled": true,
|
||||
"excludedTypes": "Request"
|
||||
}
|
||||
}
|
||||
},
|
||||
"extensionBundle": {
|
||||
"id": "Microsoft.Azure.Functions.ExtensionBundle",
|
||||
"version": "[4.*, 5.0.0)"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### local.settings.json
|
||||
|
||||
```json
|
||||
{
|
||||
"IsEncrypted": false,
|
||||
"Values": {
|
||||
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
|
||||
"FUNCTIONS_WORKER_RUNTIME": "node",
|
||||
"AZURE_FUNCTIONS_ENVIRONMENT": "Development",
|
||||
"NODE_ENV": "development",
|
||||
"STORAGE_CONNECTION_STRING": "UseDevelopmentStorage=true",
|
||||
"DATABASE_URL": "postgresql://<POSTGRES_USER>:<POSTGRES_PASSWORD>@localhost:5432/appdb",
|
||||
"REDIS_URL": "redis://localhost:6379"
|
||||
},
|
||||
"Host": {
|
||||
"CORS": "*",
|
||||
"CORSCredentials": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> `local.settings.json` is plain JSON and does **not** support `${...}` interpolation.
|
||||
> Replace `<POSTGRES_USER>` / `<POSTGRES_PASSWORD>` with the same discrete values declared in the
|
||||
> workspace-root `.env` used by docker-compose. Never leave the angle-bracket placeholders in the
|
||||
> generated file, and never paste a credential value copied out of tool output — redaction filters
|
||||
> rewrite concrete credential URLs, and a masked value beginning with `*` corrupts YAML/JSON config.
|
||||
|
||||
### tsconfig.json
|
||||
|
||||
> ⚠️ When importing from `../shared/`, `rootDir` must be `".."` for cross-workspace imports. Changes `dist/` output — see `main` field below.
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "Node16",
|
||||
"target": "ES2022",
|
||||
"moduleResolution": "Node16",
|
||||
"outDir": "dist",
|
||||
"rootDir": "..",
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "../shared/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "tests"]
|
||||
}
|
||||
```
|
||||
|
||||
### package.json (backend)
|
||||
|
||||
> ⚠️ **`main` field MUST match actual `dist/` output.** With `rootDir: ".."`, handlers compile to `dist/functions/src/functions/*.js` (not `dist/src/functions/*.js`). After `tsc` build, verify `main` resolves via `dist/` contents.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "functions",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "dist/functions/src/functions/*.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"watch": "tsc -w",
|
||||
"clean": "rimraf dist",
|
||||
"prestart": "npm run clean && npm run build",
|
||||
"start": "func start",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "eslint src/ tests/",
|
||||
"db:migrate": "knex migrate:latest",
|
||||
"db:seed": "tsx seeds/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@azure/functions": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.0.0",
|
||||
"rimraf": "^5.0.0",
|
||||
"vitest": "^1.0.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> Adjust `scripts.test` per user's test runner.
|
||||
|
||||
> ⚠️ **Deployment build contract — ship a prebuilt artifact, do NOT re-build on the server.**
|
||||
> The deployed unit for a TS Functions app is the **compiled `dist/`**, not the TypeScript source. `azd` compiles locally (`npm install` + `npm run build`) and packages the output. The server (Kudu/Oryx) then runs `npm install --omit=dev` (production deps only). If the deployed `package.json` still triggers a build, Oryx runs `npm run build` **without** `typescript` (a `devDependency`) and fails with `tsc: not found`. Scaffold the project so the artifact stands alone:
|
||||
> - **Dependency split is load-bearing.** Every package imported by runtime code (`dist/**/*.js`) MUST be in `dependencies`; build-only tooling (`typescript`, `@types/*`, test runners, bundlers) MUST be in `devDependencies`. A production install (`--omit=dev`) must satisfy every `require`/`import` in `dist/`.
|
||||
> - **Compiled output must be self-sufficient.** `dist/` must never import from `src/` or need a rebuild to run. The `main` field points at compiled `.js` (Rule 12), never a `.ts` entry.
|
||||
> - **Prefer disabling the server build over stripping scripts.** The robust deploy config ships the prebuilt `dist/` and turns the platform build **off** (`SCM_DO_BUILD_DURING_DEPLOYMENT=false` / `azd` `host: function` remote build disabled). Do **not** rely on `prepackage`/`postpackage` hooks that temporarily delete the build scripts (`build`, `watch`, `clean`, `prestart`) from `package.json` — that treats the symptom (an unwanted server rebuild) instead of the cause. The deploy agent owns the deploy config; the scaffold's job is to guarantee the split above so a prebuilt artifact deploys cleanly.
|
||||
> - **Native modules stay external.** Packages with compiled native addons (`bcrypt`, `sharp`, `better-sqlite3`, Prisma engines, …) are OS/arch-specific. Keep them in `dependencies` (installed on the Linux host), never bundle their binaries from a Windows/macOS build.
|
||||
|
||||
> ⚠️ **Monorepo / npm workspaces deployment — keep each service self-contained.**
|
||||
> Each service deploys **independently** (one `azure.yaml` service = one artifact), and npm workspaces **hoist** `node_modules` and the single lockfile to the repo root — so a per-service zip has no local `node_modules` and can't resolve a root-linked dependency on its own (the "43KB zip / `Cannot find module 'shared'`" failures). Use the following approaches to make a service self-contained — **no bundler required by default**:
|
||||
> - **Compile `shared` in via relative imports (preferred).** Import the shared package by **relative path** (`../../shared/...`), not as a workspace package (`@app/shared`). With `rootDir: ".."` + `../shared` in `include`, `tsc` emits `shared` into the service's own `dist/`, so there is **no `shared` runtime dependency to resolve** — this is the real fix for `Cannot find module 'shared'` on a per-service deploy. If you must consume `shared` as a workspace package, vendor or bundle its compiled output into the artifact.
|
||||
> - **Resolve third-party deps at the service level.** Because deps are hoisted, ship the service with either (a) a production `node_modules` installed *into the service directory* (isolated `npm install --omit=dev`), or (b) the platform's production install against the service's `package.json` + lockfile (disable only the *build*, keep the *install*). Native-addon packages must install on the Linux host, never copied from a Windows/macOS `node_modules`.
|
||||
> - **Optional — single-file bundle.** For cold-start-sensitive or single-artifact needs, `esbuild --bundle --packages=external` (from a `src/index.ts` that imports every handler; point `main` at the one `dist/index.js`) inlines first-party code (including `shared`) and leaves npm deps external. It sidesteps both issues but **departs from the idiomatic per-file layout** (Microsoft's `func init --model V4` default is modular handlers + a glob `main` compiled by `tsc`), so treat it as an **opt-in optimization, not the default**.
|
||||
|
||||
---
|
||||
|
||||
## Function Handler Pattern
|
||||
|
||||
> ⚠️ **Always set `status` explicitly**, even for 200. Do NOT omit and rely on default. Explicit status keeps test assertions consistent and prevents `undefined` vs `200` confusion.
|
||||
|
||||
### HTTP Function (v4 Model)
|
||||
|
||||
```typescript
|
||||
// services/functions/getItems.ts
|
||||
import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions';
|
||||
import { getServices } from '../services/registry';
|
||||
import { handleError } from '../errors/errorHandler';
|
||||
import { Item } from '../../shared/types/entities';
|
||||
|
||||
app.http('getItems', {
|
||||
methods: ['GET'],
|
||||
authLevel: 'anonymous',
|
||||
route: 'items',
|
||||
handler: async (request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> => {
|
||||
try {
|
||||
const { database } = getServices();
|
||||
const limit = Number(request.query.get('limit')) || 20;
|
||||
const offset = Number(request.query.get('offset')) || 0;
|
||||
|
||||
const items = await database.findAll<Item>('items', { limit, offset });
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
jsonBody: { items, total: items.length },
|
||||
};
|
||||
} catch (error) {
|
||||
return handleError(error, context);
|
||||
}
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### POST with Validation
|
||||
|
||||
```typescript
|
||||
// services/functions/createItem.ts
|
||||
import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions';
|
||||
import { getServices } from '../services/registry';
|
||||
import { handleError } from '../errors/errorHandler';
|
||||
import { validateBody } from '../middleware/validateRequest';
|
||||
import { createItemSchema } from '../../shared/schemas/validation';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
app.http('createItem', {
|
||||
methods: ['POST'],
|
||||
authLevel: 'anonymous',
|
||||
route: 'items',
|
||||
handler: async (request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> => {
|
||||
try {
|
||||
const body = await validateBody(request, createItemSchema);
|
||||
const { database } = getServices();
|
||||
|
||||
const item = {
|
||||
id: uuid(),
|
||||
...body,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const created = await database.create('items', item);
|
||||
return { status: 201, jsonBody: { item: created } };
|
||||
} catch (error) {
|
||||
return handleError(error, context);
|
||||
}
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### GET by ID with 404 Handling
|
||||
|
||||
```typescript
|
||||
// services/functions/getItemById.ts
|
||||
import { app } from '@azure/functions';
|
||||
import { getServices } from '../services/registry';
|
||||
import { handleError } from '../errors/errorHandler';
|
||||
import { NotFoundError } from '../errors/errorTypes';
|
||||
import { Item } from '../../shared/types/entities';
|
||||
|
||||
app.http('getItemById', {
|
||||
methods: ['GET'],
|
||||
authLevel: 'anonymous',
|
||||
route: 'items/{id}',
|
||||
handler: async (request, context) => {
|
||||
try {
|
||||
const { database } = getServices();
|
||||
const id = request.params.id;
|
||||
|
||||
const item = await database.findById<Item>('items', id);
|
||||
if (!item) {
|
||||
throw new NotFoundError('Item', id);
|
||||
}
|
||||
|
||||
return { jsonBody: { item } };
|
||||
} catch (error) {
|
||||
return handleError(error, context);
|
||||
}
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Health Check
|
||||
|
||||
```typescript
|
||||
// services/functions/health.ts
|
||||
import { app } from '@azure/functions';
|
||||
import { getServices } from '../services/registry';
|
||||
|
||||
app.http('health', {
|
||||
methods: ['GET'],
|
||||
authLevel: 'anonymous',
|
||||
route: 'health',
|
||||
handler: async (request, context) => {
|
||||
const services = getServices();
|
||||
|
||||
const checks: Record<string, boolean> = {};
|
||||
|
||||
// Check each service
|
||||
try { checks.database = await services.database.healthCheck(); } catch { checks.database = false; }
|
||||
try { checks.storage = await services.storage.healthCheck(); } catch { checks.storage = false; }
|
||||
try { checks.cache = await services.cache.healthCheck(); } catch { checks.cache = false; }
|
||||
|
||||
const allHealthy = Object.values(checks).every(v => v);
|
||||
const anyHealthy = Object.values(checks).some(v => v);
|
||||
|
||||
const status = allHealthy ? 'healthy' : anyHealthy ? 'degraded' : 'unhealthy';
|
||||
|
||||
return {
|
||||
status: allHealthy ? 200 : 503,
|
||||
jsonBody: { status, services: checks },
|
||||
};
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Runner Configurations
|
||||
|
||||
### vitest
|
||||
|
||||
> ⚠️ **MANDATORY for projects with heavy SDK imports** (pg, @azure/storage-blob, etc.):
|
||||
> - `fileParallelism: false` — Prevents memory exhaustion from multiple workers loading heavy SDKs.
|
||||
> - `teardownTimeout: 3000` — Kills lingering connections (e.g., `pg.Pool`) keeping process alive after tests.
|
||||
> - `testTimeout: 10000` — Prevents false failures on slow CI.
|
||||
>
|
||||
> Without these, test suite **hangs indefinitely** with 13+ files importing heavy SDKs. #1 test infrastructure issue.
|
||||
|
||||
```typescript
|
||||
// vitest.config.ts
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['tests/**/*.test.ts'],
|
||||
setupFiles: ['tests/setup.ts'],
|
||||
fileParallelism: false,
|
||||
teardownTimeout: 3000,
|
||||
testTimeout: 10000,
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: ['src/**/interfaces/**', 'src/functions/*.ts'],
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```typescript
|
||||
// tests/setup.ts
|
||||
import { registerServices, clearServices } from '../src/services/registry';
|
||||
import { MockDatabaseService } from './mocks/mockDatabase';
|
||||
import { MockStorageService } from './mocks/mockStorage';
|
||||
import { MockCacheService } from './mocks/mockCache';
|
||||
import itemFixtures from './fixtures/items.json';
|
||||
|
||||
beforeEach(() => {
|
||||
registerServices({
|
||||
database: new MockDatabaseService({ items: itemFixtures.validItems }),
|
||||
storage: new MockStorageService(),
|
||||
cache: new MockCacheService(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearServices();
|
||||
});
|
||||
```
|
||||
|
||||
### jest
|
||||
|
||||
```typescript
|
||||
// jest.config.ts
|
||||
export default {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/tests'],
|
||||
testMatch: ['**/*.test.ts'],
|
||||
setupFilesAfterSetup: ['<rootDir>/tests/setup.ts'],
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.ts',
|
||||
'!src/**/interfaces/**',
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
### mocha + chai + sinon
|
||||
|
||||
```yaml
|
||||
# .mocharc.yml
|
||||
require:
|
||||
- tsx
|
||||
- tests/setup.ts
|
||||
spec: 'tests/**/*.test.ts'
|
||||
recursive: true
|
||||
timeout: 10000
|
||||
```
|
||||
|
||||
```typescript
|
||||
// tests/setup.ts (mocha version)
|
||||
import { registerServices, clearServices } from '../src/services/registry';
|
||||
import { MockDatabaseService } from './mocks/mockDatabase';
|
||||
import { MockStorageService } from './mocks/mockStorage';
|
||||
import { MockCacheService } from './mocks/mockCache';
|
||||
import itemFixtures from './fixtures/items.json';
|
||||
|
||||
beforeEach(() => {
|
||||
registerServices({
|
||||
database: new MockDatabaseService({ items: itemFixtures.validItems }),
|
||||
storage: new MockStorageService(),
|
||||
cache: new MockCacheService(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearServices();
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Validation — Zod
|
||||
|
||||
### Schema Definition
|
||||
|
||||
```typescript
|
||||
// shared/schemas/validation.ts
|
||||
import { z } from 'zod';
|
||||
|
||||
export const createItemSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required').max(255),
|
||||
description: z.string().optional().default(''),
|
||||
price: z.number().positive('Price must be positive'),
|
||||
category: z.string().min(1, 'Category is required').max(100),
|
||||
});
|
||||
|
||||
export const updateItemSchema = createItemSchema.partial();
|
||||
|
||||
export const paginationSchema = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
});
|
||||
|
||||
export type CreateItemRequest = z.infer<typeof createItemSchema>;
|
||||
export type UpdateItemRequest = z.infer<typeof updateItemSchema>;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Structured Logging — pino
|
||||
|
||||
### Logger Setup
|
||||
|
||||
```typescript
|
||||
// src/logger.ts
|
||||
import pino from 'pino';
|
||||
|
||||
const logger = pino({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
transport: process.env.NODE_ENV === 'development'
|
||||
? { target: 'pino-pretty', options: { colorize: true } }
|
||||
: undefined,
|
||||
});
|
||||
|
||||
export function getLogger(name?: string) {
|
||||
return name ? logger.child({ module: name }) : logger;
|
||||
}
|
||||
```
|
||||
|
||||
### Request Logging Middleware
|
||||
|
||||
```typescript
|
||||
// middleware/requestLogger.ts
|
||||
import { HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions';
|
||||
import { getLogger } from '../logger';
|
||||
|
||||
const logger = getLogger('http');
|
||||
|
||||
export function logRequest(
|
||||
request: HttpRequest,
|
||||
response: HttpResponseInit,
|
||||
context: InvocationContext,
|
||||
durationMs: number
|
||||
): void {
|
||||
logger.info({
|
||||
method: request.method,
|
||||
path: request.url,
|
||||
status: response.status || 200,
|
||||
durationMs,
|
||||
functionName: context.functionName,
|
||||
}, `${request.method} ${request.url} ${response.status || 200} ${durationMs}ms`);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Shared Types
|
||||
|
||||
```typescript
|
||||
// shared/types/entities.ts
|
||||
export interface Item {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
category: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// shared/types/api.ts
|
||||
import { Item } from './entities';
|
||||
|
||||
// Response contracts
|
||||
export interface ListItemsResponse {
|
||||
items: Item[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface SingleItemResponse {
|
||||
item: Item;
|
||||
}
|
||||
|
||||
export interface ErrorResponse {
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
details: unknown | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface HealthResponse {
|
||||
status: 'healthy' | 'degraded' | 'unhealthy';
|
||||
services: Record<string, boolean>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ESLint Configuration
|
||||
|
||||
```json
|
||||
// .eslintrc.json
|
||||
{
|
||||
"root": true,
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": ["@typescript-eslint"],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
"no-console": ["warn", { "allow": ["warn", "error"] }]
|
||||
},
|
||||
"env": {
|
||||
"node": true,
|
||||
"es2022": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dependencies Quick Reference
|
||||
|
||||
### Core Dependencies
|
||||
|
||||
| Package | Purpose |
|
||||
|---------|---------|
|
||||
| `@azure/functions` | Azure Functions v4 runtime |
|
||||
| `zod` | Input validation |
|
||||
| `pino` | Structured logging |
|
||||
| `uuid` | ID generation |
|
||||
|
||||
### Per Service
|
||||
|
||||
| Service | Package |
|
||||
|---------|---------|
|
||||
| Blob Storage | `@azure/storage-blob` |
|
||||
| PostgreSQL | `pg`, `@types/pg` |
|
||||
| CosmosDB | `@azure/cosmos` |
|
||||
| Redis | `ioredis` |
|
||||
| Migrations | `knex` |
|
||||
|
||||
### Dev Dependencies
|
||||
|
||||
| Package | Purpose |
|
||||
|---------|---------|
|
||||
| `typescript` | TypeScript compiler |
|
||||
| `vitest` / `jest` / `mocha` | Test runner (user's choice) |
|
||||
| `eslint` + `@typescript-eslint/*` | Linting |
|
||||
| `prettier` | Formatting |
|
||||
| `tsx` | TypeScript execution (for scripts) |
|
||||
| `pino-pretty` | Dev log formatting |
|
||||
Reference in New Issue
Block a user