Skip to content

Commit c10f1bf

Browse files
amosttAygentic
andauthored
feat(core): rewrite config, error handlers, and shared models [AYG-65] (#1)
* feat(core): rewrite config, error handlers, and shared models [AYG-65] - Replace PostgreSQL/SMTP settings with Supabase+Clerk env vars - Add frozen Settings with production secret and CORS guards - Create unified error response shape with global exception handlers - Add shared Pydantic models: ErrorResponse, ValidationErrorResponse, PaginatedResponse, Principal Fixes AYG-65 🤖 Generated by Aygentic Co-Authored-By: Aygentic <noreply@aygentic.com> * fix(core): untrack .env and guard conftest for migration [AYG-65] - Remove .env from git tracking and add to .gitignore (SEC-001) - Guard integration test conftest.py imports with try/except so unit tests run without --noconftest during the migration (FUNC-002) Related to AYG-65 🤖 Generated by Aygentic Co-Authored-By: Aygentic <noreply@aygentic.com> * fix(tests): harden test isolation for template usage [AYG-65] - Catch all exceptions (including ValidationError) in conftest guard so pytest doesn't crash when env vars are unset in a fresh template clone - Explicitly delenv missing vars in test_missing_required_var_raises so the test is deterministic regardless of the caller's shell environment Related to AYG-65 🤖 Generated by Aygentic Co-Authored-By: Aygentic <noreply@aygentic.com> * docs(project): update documentation before merge [skip ci] Updated documentation based on AYG-65 code changes: - setup.md: replaced PostgreSQL/SMTP/JWT env vars with Supabase/Clerk - deployment/environments.md: updated all env tables and GitHub Secrets - architecture/overview.md: added error handling framework, shared models, Clerk auth - architecture/decisions/: new ADRs for error handling and models package - api/overview.md: Clerk auth, standard error shape, paginated response pattern - api/endpoints/login.md: deprecated (Clerk migration) - api/endpoints/users.md + items.md: updated auth and error response docs - data/models.md: new Shared Pydantic Models section - testing/test-registry.md: +36 unit tests, closed config coverage gap Related to AYG-65 🤖 Generated by Aygentic Co-Authored-By: Aygentic <noreply@aygentic.com> --------- Co-authored-by: Aygentic <noreply@aygentic.com>
1 parent 0785188 commit c10f1bf

File tree

24 files changed

+1957
-463
lines changed

24 files changed

+1957
-463
lines changed

.env

Lines changed: 0 additions & 45 deletions
This file was deleted.

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
.env
2+
.env.*
3+
!.env.example
14
.vscode/*
25
!.vscode/extensions.json
36
node_modules/

backend/app/core/config.py

Lines changed: 53 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,101 +1,67 @@
1-
import secrets
1+
import json
22
import warnings
33
from typing import Annotated, Any, Literal
44

5-
from pydantic import (
6-
AnyUrl,
7-
BeforeValidator,
8-
EmailStr,
9-
HttpUrl,
10-
PostgresDsn,
11-
computed_field,
12-
model_validator,
13-
)
5+
from pydantic import AnyUrl, BeforeValidator, SecretStr, computed_field, model_validator
146
from pydantic_settings import BaseSettings, SettingsConfigDict
157
from typing_extensions import Self
168

179

1810
def parse_cors(v: Any) -> list[str] | str:
19-
if isinstance(v, str) and not v.startswith("["):
11+
if isinstance(v, str) and v.startswith("["):
12+
return json.loads(v)
13+
if isinstance(v, str):
2014
return [i.strip() for i in v.split(",") if i.strip()]
21-
elif isinstance(v, list | str):
15+
elif isinstance(v, list):
2216
return v
2317
raise ValueError(v)
2418

2519

2620
class Settings(BaseSettings):
2721
model_config = SettingsConfigDict(
28-
# Use top level .env file (one level above ./backend/)
2922
env_file="../.env",
3023
env_ignore_empty=True,
3124
extra="ignore",
25+
frozen=True,
3226
)
33-
API_V1_STR: str = "/api/v1"
34-
SECRET_KEY: str = secrets.token_urlsafe(32)
35-
# 60 minutes * 24 hours * 8 days = 8 days
36-
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8
37-
FRONTEND_HOST: str = "http://localhost:5173"
38-
ENVIRONMENT: Literal["local", "staging", "production"] = "local"
39-
40-
BACKEND_CORS_ORIGINS: Annotated[
41-
list[AnyUrl] | str, BeforeValidator(parse_cors)
42-
] = []
43-
44-
@computed_field # type: ignore[prop-decorator]
45-
@property
46-
def all_cors_origins(self) -> list[str]:
47-
return [str(origin).rstrip("/") for origin in self.BACKEND_CORS_ORIGINS] + [
48-
self.FRONTEND_HOST
49-
]
50-
51-
PROJECT_NAME: str
52-
SENTRY_DSN: HttpUrl | None = None
53-
POSTGRES_SERVER: str
54-
POSTGRES_PORT: int = 5432
55-
POSTGRES_USER: str
56-
POSTGRES_PASSWORD: str = ""
57-
POSTGRES_DB: str = ""
58-
59-
@computed_field # type: ignore[prop-decorator]
60-
@property
61-
def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn:
62-
return PostgresDsn.build(
63-
scheme="postgresql+psycopg",
64-
username=self.POSTGRES_USER,
65-
password=self.POSTGRES_PASSWORD,
66-
host=self.POSTGRES_SERVER,
67-
port=self.POSTGRES_PORT,
68-
path=self.POSTGRES_DB,
69-
)
7027

71-
SMTP_TLS: bool = True
72-
SMTP_SSL: bool = False
73-
SMTP_PORT: int = 587
74-
SMTP_HOST: str | None = None
75-
SMTP_USER: str | None = None
76-
SMTP_PASSWORD: str | None = None
77-
EMAILS_FROM_EMAIL: EmailStr | None = None
78-
EMAILS_FROM_NAME: str | None = None
28+
# Required fields — no defaults; must come from environment
29+
SUPABASE_URL: AnyUrl
30+
SUPABASE_SERVICE_KEY: SecretStr
31+
CLERK_SECRET_KEY: SecretStr
7932

80-
@model_validator(mode="after")
81-
def _set_default_emails_from(self) -> Self:
82-
if not self.EMAILS_FROM_NAME:
83-
self.EMAILS_FROM_NAME = self.PROJECT_NAME
84-
return self
85-
86-
EMAIL_RESET_TOKEN_EXPIRE_HOURS: int = 48
33+
# Optional fields with defaults
34+
ENVIRONMENT: Literal["local", "staging", "production"] = "local"
35+
SERVICE_NAME: str = "my-service"
36+
SERVICE_VERSION: str = "0.1.0"
37+
LOG_LEVEL: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
38+
LOG_FORMAT: Literal["json", "console"] = "json"
39+
API_V1_STR: str = "/api/v1"
40+
BACKEND_CORS_ORIGINS: Annotated[list[str] | str, BeforeValidator(parse_cors)] = []
41+
WITH_UI: bool = False
42+
CLERK_JWKS_URL: str | None = None
43+
CLERK_AUTHORIZED_PARTIES: list[str] = []
44+
GIT_COMMIT: str = "unknown"
45+
BUILD_TIME: str = "unknown"
46+
HTTP_CLIENT_TIMEOUT: int = 30
47+
HTTP_CLIENT_MAX_RETRIES: int = 3
48+
SENTRY_DSN: str | None = None
8749

8850
@computed_field # type: ignore[prop-decorator]
8951
@property
90-
def emails_enabled(self) -> bool:
91-
return bool(self.SMTP_HOST and self.EMAILS_FROM_EMAIL)
92-
93-
EMAIL_TEST_USER: EmailStr = "test@example.com"
94-
FIRST_SUPERUSER: EmailStr
95-
FIRST_SUPERUSER_PASSWORD: str
96-
97-
def _check_default_secret(self, var_name: str, value: str | None) -> None:
98-
if value == "changethis":
52+
def all_cors_origins(self) -> list[str]:
53+
return [str(origin).rstrip("/") for origin in self.BACKEND_CORS_ORIGINS]
54+
55+
def _check_default_secret(
56+
self, var_name: str, value: str | SecretStr | None
57+
) -> None:
58+
secret_value: str | None
59+
if isinstance(value, SecretStr):
60+
secret_value = value.get_secret_value()
61+
else:
62+
secret_value = value
63+
64+
if secret_value == "changethis":
9965
message = (
10066
f'The value of {var_name} is "changethis", '
10167
"for security, please change it, at least for deployments."
@@ -107,11 +73,19 @@ def _check_default_secret(self, var_name: str, value: str | None) -> None:
10773

10874
@model_validator(mode="after")
10975
def _enforce_non_default_secrets(self) -> Self:
110-
self._check_default_secret("SECRET_KEY", self.SECRET_KEY)
111-
self._check_default_secret("POSTGRES_PASSWORD", self.POSTGRES_PASSWORD)
112-
self._check_default_secret(
113-
"FIRST_SUPERUSER_PASSWORD", self.FIRST_SUPERUSER_PASSWORD
114-
)
76+
self._check_default_secret("SUPABASE_SERVICE_KEY", self.SUPABASE_SERVICE_KEY)
77+
self._check_default_secret("CLERK_SECRET_KEY", self.CLERK_SECRET_KEY)
78+
79+
if self.ENVIRONMENT == "production":
80+
cors_list = self.BACKEND_CORS_ORIGINS
81+
if isinstance(cors_list, str):
82+
origins = [cors_list]
83+
else:
84+
origins = [str(o) for o in cors_list]
85+
if "*" in origins:
86+
raise ValueError(
87+
"wildcard CORS origin ('*') is not allowed in production"
88+
)
11589

11690
return self
11791

backend/app/core/errors.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
"""Unified error handling framework.
2+
3+
Provides ServiceError exception, HTTP status code mapping, and global
4+
exception handlers that format all API errors into a standard JSON shape.
5+
"""
6+
7+
import logging
8+
from uuid import uuid4
9+
10+
from fastapi import FastAPI, HTTPException, Request
11+
from fastapi.exceptions import RequestValidationError
12+
from fastapi.responses import JSONResponse
13+
14+
from app.models.common import (
15+
ErrorResponse,
16+
ValidationErrorDetail,
17+
ValidationErrorResponse,
18+
)
19+
20+
logger = logging.getLogger(__name__)
21+
22+
# HTTP status code → error category mapping
23+
STATUS_CODE_MAP: dict[int, str] = {
24+
400: "BAD_REQUEST",
25+
401: "UNAUTHORIZED",
26+
403: "FORBIDDEN",
27+
404: "NOT_FOUND",
28+
409: "CONFLICT",
29+
422: "VALIDATION_ERROR",
30+
429: "RATE_LIMITED",
31+
500: "INTERNAL_ERROR",
32+
503: "SERVICE_UNAVAILABLE",
33+
}
34+
35+
36+
class ServiceError(Exception):
37+
"""Application-level error with structured error info.
38+
39+
Usage::
40+
41+
raise ServiceError(
42+
status_code=404,
43+
message="Entity not found",
44+
code="ENTITY_NOT_FOUND",
45+
)
46+
"""
47+
48+
def __init__(self, status_code: int, message: str, code: str) -> None:
49+
self.status_code = status_code
50+
self.message = message
51+
self.code = code
52+
self.error = STATUS_CODE_MAP.get(status_code, "INTERNAL_ERROR")
53+
super().__init__(message)
54+
55+
56+
def _get_request_id(request: Request) -> str:
57+
"""Extract request_id from request state, or generate a new UUID."""
58+
return getattr(request.state, "request_id", None) or str(uuid4())
59+
60+
61+
async def service_error_handler(request: Request, exc: ServiceError) -> JSONResponse:
62+
"""Handle ServiceError exceptions."""
63+
request_id = _get_request_id(request)
64+
body = ErrorResponse(
65+
error=exc.error,
66+
message=exc.message,
67+
code=exc.code,
68+
request_id=request_id,
69+
)
70+
return JSONResponse(status_code=exc.status_code, content=body.model_dump())
71+
72+
73+
async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
74+
"""Handle FastAPI/Starlette HTTPException."""
75+
request_id = _get_request_id(request)
76+
error = STATUS_CODE_MAP.get(exc.status_code, "INTERNAL_ERROR")
77+
message = str(exc.detail) if exc.detail else error
78+
body = ErrorResponse(
79+
error=error,
80+
message=message,
81+
code=error,
82+
request_id=request_id,
83+
)
84+
return JSONResponse(
85+
status_code=exc.status_code,
86+
content=body.model_dump(),
87+
headers=getattr(exc, "headers", None),
88+
)
89+
90+
91+
async def validation_exception_handler(
92+
request: Request, exc: RequestValidationError
93+
) -> JSONResponse:
94+
"""Handle Pydantic RequestValidationError with field-level details."""
95+
request_id = _get_request_id(request)
96+
details = []
97+
for err in exc.errors():
98+
# Convert loc tuple to dot-notation field path.
99+
# loc is like ("body", "title") or ("query", "page").
100+
# Skip the first element if it's a location prefix.
101+
loc_parts = [str(part) for part in err.get("loc", [])]
102+
if loc_parts and loc_parts[0] in ("body", "query", "path", "header", "cookie"):
103+
loc_parts = loc_parts[1:]
104+
field = ".".join(loc_parts) if loc_parts else "unknown"
105+
details.append(
106+
ValidationErrorDetail(
107+
field=field,
108+
message=err.get("msg", "Validation error"),
109+
type=err.get("type", "value_error"),
110+
)
111+
)
112+
body = ValidationErrorResponse(
113+
error="VALIDATION_ERROR",
114+
message="Request validation failed.",
115+
code="VALIDATION_FAILED",
116+
request_id=request_id,
117+
details=details,
118+
)
119+
return JSONResponse(status_code=422, content=body.model_dump())
120+
121+
122+
async def unhandled_exception_handler(
123+
request: Request, exc: Exception
124+
) -> JSONResponse:
125+
"""Catch-all handler for unhandled exceptions."""
126+
request_id = _get_request_id(request)
127+
logger.exception("Unhandled exception [request_id=%s]", request_id, exc_info=exc)
128+
body = ErrorResponse(
129+
error="INTERNAL_ERROR",
130+
message="An unexpected error occurred.",
131+
code="INTERNAL_ERROR",
132+
request_id=request_id,
133+
)
134+
return JSONResponse(status_code=500, content=body.model_dump())
135+
136+
137+
def register_exception_handlers(app: FastAPI) -> None:
138+
"""Register all global exception handlers on the FastAPI app."""
139+
app.add_exception_handler(ServiceError, service_error_handler) # type: ignore[arg-type]
140+
app.add_exception_handler(HTTPException, http_exception_handler) # type: ignore[arg-type]
141+
app.add_exception_handler(RequestValidationError, validation_exception_handler) # type: ignore[arg-type]
142+
app.add_exception_handler(Exception, unhandled_exception_handler)

backend/app/main.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from app.api.main import api_router
77
from app.core.config import settings
8+
from app.core.errors import register_exception_handlers
89

910

1011
def custom_generate_unique_id(route: APIRoute) -> str:
@@ -15,11 +16,14 @@ def custom_generate_unique_id(route: APIRoute) -> str:
1516
sentry_sdk.init(dsn=str(settings.SENTRY_DSN), enable_tracing=True)
1617

1718
app = FastAPI(
18-
title=settings.PROJECT_NAME,
19+
title=settings.SERVICE_NAME,
1920
openapi_url=f"{settings.API_V1_STR}/openapi.json",
2021
generate_unique_id_function=custom_generate_unique_id,
2122
)
2223

24+
# Register unified error handlers
25+
register_exception_handlers(app)
26+
2327
# Set all CORS enabled origins
2428
if settings.all_cors_origins:
2529
app.add_middleware(

0 commit comments

Comments
 (0)