← full-stack-fastapi-template / backend/app/core/config.py
| 1 | import secrets |
| 2 | import warnings |
| 3 | from typing import Annotated, Any, Literal |
| 4 | |
| 5 | from pydantic import ( |
| 6 | AnyUrl, |
| 7 | BeforeValidator, |
| 8 | EmailStr, |
| 9 | HttpUrl, |
| 10 | PostgresDsn, |
| 11 | computed_field, |
| 12 | model_validator, |
| 13 | ) |
| 14 | from pydantic_settings import BaseSettings, SettingsConfigDict |
| 15 | from typing_extensions import Self |
| 16 | |
| 17 | |
| 18 | def parse_cors(v: Any) -> list[str] | str: |
| 19 | if isinstance(v, str) and not v.startswith("["): |
| 20 | return [i.strip() for i in v.split(",") if i.strip()] |
| 21 | elif isinstance(v, list | str): |
| 22 | return v |
| 23 | raise ValueError(v) |
| 24 | |
| 25 | |
| 26 | class Settings(BaseSettings): |
| 27 | model_config = SettingsConfigDict( |
| 28 | # Use top level .env file (one level above ./backend/) |
| 29 | env_file="../.env", |
| 30 | env_ignore_empty=True, |
| 31 | extra="ignore", |
| 32 | ) |
| 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 | ) |
| 70 | |
| 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 |
| 79 | |
| 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 |
| 87 | |
| 88 | @computed_field # type: ignore[prop-decorator] |
| 89 | @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": |
| 99 | message = ( |
| 100 | f'The value of {var_name} is "changethis", ' |
| 101 | "for security, please change it, at least for deployments." |
| 102 | ) |
| 103 | if self.ENVIRONMENT == "local": |
| 104 | warnings.warn(message, stacklevel=1) |
| 105 | else: |
| 106 | raise ValueError(message) |
| 107 | |
| 108 | @model_validator(mode="after") |
| 109 | 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 | ) |
| 115 | |
| 116 | return self |
| 117 | |
| 118 | |
| 119 | settings = Settings() # type: ignore |
| 120 |