From ae168a2867de39362a04d64d011c4adf9cec1205 Mon Sep 17 00:00:00 2001 From: Rodrigo Agundez Date: Wed, 19 Nov 2025 00:59:41 +0700 Subject: [PATCH 1/4] Remove duplicated sourcing of settings via environment variables and .env file --- src/app/core/config.py | 172 +++++++++++++++++++++++------------------ 1 file changed, 97 insertions(+), 75 deletions(-) diff --git a/src/app/core/config.py b/src/app/core/config.py index bce9b2b..00a1388 100644 --- a/src/app/core/config.py +++ b/src/app/core/config.py @@ -1,35 +1,30 @@ import os from enum import Enum -from pydantic import SecretStr -from pydantic_settings import BaseSettings -from starlette.config import Config +from pydantic import SecretStr, computed_field, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict -current_file_dir = os.path.dirname(os.path.realpath(__file__)) -env_path = os.path.join(current_file_dir, "..", "..", ".env") -config = Config(env_path) - -def str_setting_to_list(setting: str) -> list[str]: - if isinstance(setting, str): - return [item.strip() for item in setting.split(",") if item.strip()] +def str_to_list(value: str) -> list[str]: + if isinstance(value, str): + return [item.strip() for item in value.split(",") if item.strip()] raise ValueError("Invalid string setting for list conversion.") class AppSettings(BaseSettings): - APP_NAME: str = config("APP_NAME", default="FastAPI app") - APP_DESCRIPTION: str | None = config("APP_DESCRIPTION", default=None) - APP_VERSION: str | None = config("APP_VERSION", default=None) - LICENSE_NAME: str | None = config("LICENSE", default=None) - CONTACT_NAME: str | None = config("CONTACT_NAME", default=None) - CONTACT_EMAIL: str | None = config("CONTACT_EMAIL", default=None) + APP_NAME: str = "FastAPI app" + APP_DESCRIPTION: str | None = None + APP_VERSION: str | None = None + LICENSE_NAME: str | None = None + CONTACT_NAME: str | None = None + CONTACT_EMAIL: str | None = None class CryptSettings(BaseSettings): - SECRET_KEY: SecretStr = config("SECRET_KEY", cast=SecretStr) - ALGORITHM: str = config("ALGORITHM", default="HS256") - ACCESS_TOKEN_EXPIRE_MINUTES: int = config("ACCESS_TOKEN_EXPIRE_MINUTES", default=30) - REFRESH_TOKEN_EXPIRE_DAYS: int = config("REFRESH_TOKEN_EXPIRE_DAYS", default=7) + SECRET_KEY: SecretStr + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 + REFRESH_TOKEN_EXPIRE_DAYS: int = 7 class DatabaseSettings(BaseSettings): @@ -37,40 +32,52 @@ class DatabaseSettings(BaseSettings): class SQLiteSettings(DatabaseSettings): - SQLITE_URI: str = config("SQLITE_URI", default="./sql_app.db") - SQLITE_SYNC_PREFIX: str = config("SQLITE_SYNC_PREFIX", default="sqlite:///") - SQLITE_ASYNC_PREFIX: str = config("SQLITE_ASYNC_PREFIX", default="sqlite+aiosqlite:///") + SQLITE_URI: str = "./sql_app.db" + SQLITE_SYNC_PREFIX: str = "sqlite:///" + SQLITE_ASYNC_PREFIX: str = "sqlite+aiosqlite:///" class MySQLSettings(DatabaseSettings): - MYSQL_USER: str = config("MYSQL_USER", default="username") - MYSQL_PASSWORD: str = config("MYSQL_PASSWORD", default="password") - MYSQL_SERVER: str = config("MYSQL_SERVER", default="localhost") - MYSQL_PORT: int = config("MYSQL_PORT", default=5432) - MYSQL_DB: str = config("MYSQL_DB", default="dbname") - MYSQL_URI: str = f"{MYSQL_USER}:{MYSQL_PASSWORD}@{MYSQL_SERVER}:{MYSQL_PORT}/{MYSQL_DB}" - MYSQL_SYNC_PREFIX: str = config("MYSQL_SYNC_PREFIX", default="mysql://") - MYSQL_ASYNC_PREFIX: str = config("MYSQL_ASYNC_PREFIX", default="mysql+aiomysql://") - MYSQL_URL: str | None = config("MYSQL_URL", default=None) + MYSQL_USER: str = "username" + MYSQL_PASSWORD: str = "password" + MYSQL_SERVER: str = "localhost" + MYSQL_PORT: int = 5432 + MYSQL_DB: str = "dbname" + MYSQL_SYNC_PREFIX: str = "mysql://" + MYSQL_ASYNC_PREFIX: str = "mysql+aiomysql://" + MYSQL_URL: str | None = None + + @computed_field + @property + def MYSQL_URI(self) -> str: + credentials = f"{self.MYSQL_USER}:{self.MYSQL_PASSWORD}" + location = f"{self.MYSQL_SERVER}:{self.MYSQL_PORT}/{self.MYSQL_DB}" + return f"{credentials}@{location}" class PostgresSettings(DatabaseSettings): - POSTGRES_USER: str = config("POSTGRES_USER", default="postgres") - POSTGRES_PASSWORD: str = config("POSTGRES_PASSWORD", default="postgres") - POSTGRES_SERVER: str = config("POSTGRES_SERVER", default="localhost") - POSTGRES_PORT: int = config("POSTGRES_PORT", default=5432) - POSTGRES_DB: str = config("POSTGRES_DB", default="postgres") - POSTGRES_SYNC_PREFIX: str = config("POSTGRES_SYNC_PREFIX", default="postgresql://") - POSTGRES_ASYNC_PREFIX: str = config("POSTGRES_ASYNC_PREFIX", default="postgresql+asyncpg://") - POSTGRES_URI: str = f"{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_SERVER}:{POSTGRES_PORT}/{POSTGRES_DB}" - POSTGRES_URL: str | None = config("POSTGRES_URL", default=None) + POSTGRES_USER: str = "postgres" + POSTGRES_PASSWORD: str = "postgres" + POSTGRES_SERVER: str = "localhost" + POSTGRES_PORT: int = 5432 + POSTGRES_DB: str = "postgres" + POSTGRES_SYNC_PREFIX: str = "postgresql://" + POSTGRES_ASYNC_PREFIX: str = "postgresql+asyncpg://" + POSTGRES_URL: str | None = None + + @computed_field + @property + def POSTGRES_URI(self) -> str: + credentials = f"{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}" + location = f"{self.POSTGRES_SERVER}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}" + return f"{credentials}@{location}" class FirstUserSettings(BaseSettings): - ADMIN_NAME: str = config("ADMIN_NAME", default="admin") - ADMIN_EMAIL: str = config("ADMIN_EMAIL", default="admin@admin.com") - ADMIN_USERNAME: str = config("ADMIN_USERNAME", default="admin") - ADMIN_PASSWORD: str = config("ADMIN_PASSWORD", default="!Ch4ng3Th1sP4ssW0rd!") + ADMIN_NAME: str = "admin" + ADMIN_EMAIL: str = "admin@admin.com" + ADMIN_USERNAME: str = "admin" + ADMIN_PASSWORD: str = "!Ch4ng3Th1sP4ssW0rd!" class TestSettings(BaseSettings): @@ -78,66 +85,76 @@ class TestSettings(BaseSettings): class RedisCacheSettings(BaseSettings): - REDIS_CACHE_HOST: str = config("REDIS_CACHE_HOST", default="localhost") - REDIS_CACHE_PORT: int = config("REDIS_CACHE_PORT", default=6379) - REDIS_CACHE_URL: str = f"redis://{REDIS_CACHE_HOST}:{REDIS_CACHE_PORT}" + REDIS_CACHE_HOST: str = "localhost" + REDIS_CACHE_PORT: int = 6379 + + @computed_field + @property + def REDIS_CACHE_URL(self) -> str: + return f"redis://{self.REDIS_CACHE_HOST}:{self.REDIS_CACHE_PORT}" class ClientSideCacheSettings(BaseSettings): - CLIENT_CACHE_MAX_AGE: int = config("CLIENT_CACHE_MAX_AGE", default=60) + CLIENT_CACHE_MAX_AGE: int = 60 class RedisQueueSettings(BaseSettings): - REDIS_QUEUE_HOST: str = config("REDIS_QUEUE_HOST", default="localhost") - REDIS_QUEUE_PORT: int = config("REDIS_QUEUE_PORT", default=6379) + REDIS_QUEUE_HOST: str = "localhost" + REDIS_QUEUE_PORT: int = 6379 class RedisRateLimiterSettings(BaseSettings): - REDIS_RATE_LIMIT_HOST: str = config("REDIS_RATE_LIMIT_HOST", default="localhost") - REDIS_RATE_LIMIT_PORT: int = config("REDIS_RATE_LIMIT_PORT", default=6379) - REDIS_RATE_LIMIT_URL: str = f"redis://{REDIS_RATE_LIMIT_HOST}:{REDIS_RATE_LIMIT_PORT}" + REDIS_RATE_LIMIT_HOST: str = "localhost" + REDIS_RATE_LIMIT_PORT: int = 6379 + + @computed_field + @property + def REDIS_RATE_LIMIT_URL(self) -> str: + return f"redis://{self.REDIS_RATE_LIMIT_HOST}:{self.REDIS_RATE_LIMIT_PORT}" class DefaultRateLimitSettings(BaseSettings): - DEFAULT_RATE_LIMIT_LIMIT: int = config("DEFAULT_RATE_LIMIT_LIMIT", default=10) - DEFAULT_RATE_LIMIT_PERIOD: int = config("DEFAULT_RATE_LIMIT_PERIOD", default=3600) + DEFAULT_RATE_LIMIT_LIMIT: int = 10 + DEFAULT_RATE_LIMIT_PERIOD: int = 3600 class CRUDAdminSettings(BaseSettings): - CRUD_ADMIN_ENABLED: bool = config("CRUD_ADMIN_ENABLED", default=True) - CRUD_ADMIN_MOUNT_PATH: str = config("CRUD_ADMIN_MOUNT_PATH", default="/admin") + CRUD_ADMIN_ENABLED: bool = True + CRUD_ADMIN_MOUNT_PATH: str = "/admin" CRUD_ADMIN_ALLOWED_IPS_LIST: list[str] | None = None CRUD_ADMIN_ALLOWED_NETWORKS_LIST: list[str] | None = None - CRUD_ADMIN_MAX_SESSIONS: int = config("CRUD_ADMIN_MAX_SESSIONS", default=10) - CRUD_ADMIN_SESSION_TIMEOUT: int = config("CRUD_ADMIN_SESSION_TIMEOUT", default=1440) - SESSION_SECURE_COOKIES: bool = config("SESSION_SECURE_COOKIES", default=True) + CRUD_ADMIN_MAX_SESSIONS: int = 10 + CRUD_ADMIN_SESSION_TIMEOUT: int = 1440 + SESSION_SECURE_COOKIES: bool = True - CRUD_ADMIN_TRACK_EVENTS: bool = config("CRUD_ADMIN_TRACK_EVENTS", default=True) - CRUD_ADMIN_TRACK_SESSIONS: bool = config("CRUD_ADMIN_TRACK_SESSIONS", default=True) + CRUD_ADMIN_TRACK_EVENTS: bool = True + CRUD_ADMIN_TRACK_SESSIONS: bool = True - CRUD_ADMIN_REDIS_ENABLED: bool = config("CRUD_ADMIN_REDIS_ENABLED", default=False) - CRUD_ADMIN_REDIS_HOST: str = config("CRUD_ADMIN_REDIS_HOST", default="localhost") - CRUD_ADMIN_REDIS_PORT: int = config("CRUD_ADMIN_REDIS_PORT", default=6379) - CRUD_ADMIN_REDIS_DB: int = config("CRUD_ADMIN_REDIS_DB", default=0) - CRUD_ADMIN_REDIS_PASSWORD: str | None = config("CRUD_ADMIN_REDIS_PASSWORD", default="None") - CRUD_ADMIN_REDIS_SSL: bool = config("CRUD_ADMIN_REDIS_SSL", default=False) + CRUD_ADMIN_REDIS_ENABLED: bool = False + CRUD_ADMIN_REDIS_HOST: str = "localhost" + CRUD_ADMIN_REDIS_PORT: int = 6379 + CRUD_ADMIN_REDIS_DB: int = 0 + CRUD_ADMIN_REDIS_PASSWORD: str | None = "None" + CRUD_ADMIN_REDIS_SSL: bool = False -class EnvironmentOption(Enum): +class EnvironmentOption(str, Enum): LOCAL = "local" STAGING = "staging" PRODUCTION = "production" class EnvironmentSettings(BaseSettings): - ENVIRONMENT: EnvironmentOption = config("ENVIRONMENT", default=EnvironmentOption.LOCAL) + ENVIRONMENT: EnvironmentOption = EnvironmentOption.LOCAL class CORSSettings(BaseSettings): - CORS_ORIGINS: list[str] = config("CORS_ORIGINS", cast=str_setting_to_list, default="*") - CORS_METHODS: list[str] = config("CORS_METHODS", cast=str_setting_to_list, default="*") - CORS_HEADERS: list[str] = config("CORS_HEADERS", cast=str_setting_to_list, default="*") + CORS_ORIGINS: list[str] | str = "*" + CORS_METHODS: list[str] | str = "*" + CORS_HEADERS: list[str] | str = "*" + + _normalize_to_list = field_validator("CORS_ORIGINS", "CORS_METHODS", "CORS_HEADERS", mode="before")(str_to_list) class Settings( @@ -156,7 +173,12 @@ class Settings( EnvironmentSettings, CORSSettings, ): - pass + model_config = SettingsConfigDict( + env_file=os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "..", ".env"), + env_file_encoding="utf-8", + case_sensitive=True, + extra="ignore", + ) settings = Settings() From a35be82d17b03b9ec4f1a1c41a1b1ef4535c3a28 Mon Sep 17 00:00:00 2001 From: Rodrigo Agundez Date: Wed, 19 Nov 2025 01:10:08 +0700 Subject: [PATCH 2/4] Add default value for secret key --- src/app/core/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/core/config.py b/src/app/core/config.py index 00a1388..3d9e292 100644 --- a/src/app/core/config.py +++ b/src/app/core/config.py @@ -21,7 +21,7 @@ class AppSettings(BaseSettings): class CryptSettings(BaseSettings): - SECRET_KEY: SecretStr + SECRET_KEY: SecretStr = SecretStr("secret-key") ALGORITHM: str = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 REFRESH_TOKEN_EXPIRE_DAYS: int = 7 From d410329d7869afed461e05b4dbcccc3ea10f7d50 Mon Sep 17 00:00:00 2001 From: Rodrigo Agundez Date: Wed, 19 Nov 2025 02:27:13 +0700 Subject: [PATCH 3/4] Ignore mypy error about decorator on top of property --- src/app/core/config.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/core/config.py b/src/app/core/config.py index 3d9e292..fd75ce3 100644 --- a/src/app/core/config.py +++ b/src/app/core/config.py @@ -47,7 +47,7 @@ class MySQLSettings(DatabaseSettings): MYSQL_ASYNC_PREFIX: str = "mysql+aiomysql://" MYSQL_URL: str | None = None - @computed_field + @computed_field # type: ignore[prop-decorator] @property def MYSQL_URI(self) -> str: credentials = f"{self.MYSQL_USER}:{self.MYSQL_PASSWORD}" @@ -65,7 +65,7 @@ class PostgresSettings(DatabaseSettings): POSTGRES_ASYNC_PREFIX: str = "postgresql+asyncpg://" POSTGRES_URL: str | None = None - @computed_field + @computed_field # type: ignore[prop-decorator] @property def POSTGRES_URI(self) -> str: credentials = f"{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}" @@ -88,7 +88,7 @@ class RedisCacheSettings(BaseSettings): REDIS_CACHE_HOST: str = "localhost" REDIS_CACHE_PORT: int = 6379 - @computed_field + @computed_field # type: ignore[prop-decorator] @property def REDIS_CACHE_URL(self) -> str: return f"redis://{self.REDIS_CACHE_HOST}:{self.REDIS_CACHE_PORT}" @@ -107,7 +107,7 @@ class RedisRateLimiterSettings(BaseSettings): REDIS_RATE_LIMIT_HOST: str = "localhost" REDIS_RATE_LIMIT_PORT: int = 6379 - @computed_field + @computed_field # type: ignore[prop-decorator] @property def REDIS_RATE_LIMIT_URL(self) -> str: return f"redis://{self.REDIS_RATE_LIMIT_HOST}:{self.REDIS_RATE_LIMIT_PORT}" From ba5f8215d3ea25ff855862a8452da572d3a67216 Mon Sep 17 00:00:00 2001 From: Rodrigo Agundez Date: Thu, 20 Nov 2025 00:56:08 +0700 Subject: [PATCH 4/4] Simplify CORS settings and establish pattern for ingesting lists using the native pydantic casting of env variables to lists via JSON conversion --- docs/getting-started/configuration.md | 29 +++++++++---------- docs/user-guide/authentication/jwt-tokens.md | 2 +- .../configuration/environment-specific.md | 10 +++---- .../configuration/environment-variables.md | 26 ++++++++--------- scripts/local_with_uvicorn/.env.example | 8 ++--- src/app/core/config.py | 16 +++------- 6 files changed, 40 insertions(+), 51 deletions(-) diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md index 32f13d7..2ad3f6d 100644 --- a/docs/getting-started/configuration.md +++ b/docs/getting-started/configuration.md @@ -17,7 +17,7 @@ Open `src/.env` and set these required values: ### Application Settings ```env -# App Settings +# App Settings APP_NAME="Your app name here" APP_DESCRIPTION="Your app description here" APP_VERSION="0.1" @@ -49,9 +49,10 @@ PGADMIN_LISTEN_PORT=80 ``` **To connect to database in PGAdmin:** + 1. Login with `PGADMIN_DEFAULT_EMAIL` and `PGADMIN_DEFAULT_PASSWORD` -2. Click "Add Server" -3. Use these connection settings: +1. Click "Add Server" +1. Use these connection settings: - **Hostname/address**: `db` (if using containers) or `localhost` - **Port**: Value from `POSTGRES_PORT` - **Database**: `postgres` (leave as default) @@ -96,7 +97,7 @@ REDIS_CACHE_PORT=6379 CLIENT_CACHE_MAX_AGE=30 # Default: 30 seconds # Redis Job Queue -REDIS_QUEUE_HOST="localhost" # Use "redis" for Docker Compose +REDIS_QUEUE_HOST="localhost" # Use "redis" for Docker Compose REDIS_QUEUE_PORT=6379 # Redis Rate Limiting @@ -105,7 +106,7 @@ REDIS_RATE_LIMIT_PORT=6379 ``` !!! warning "Redis in Production" - You may use the same Redis instance for caching and queues while developing, but use separate containers in production. +You may use the same Redis instance for caching and queues while developing, but use separate containers in production. ### Rate Limiting Defaults @@ -121,18 +122,14 @@ Configure Cross-Origin Resource Sharing for your frontend: ```env # CORS Settings -CORS_ORIGINS="*" # Comma-separated origins (use specific domains in production) -CORS_METHODS="*" # Comma-separated HTTP methods or "*" for all -CORS_HEADERS="*" # Comma-separated headers or "*" for all +CORS_ORIGINS=["*"] # Comma-separated origins (use specific domains in production) +CORS_METHODS=["*"] # Comma-separated HTTP methods or "*" for all +CORS_HEADERS=["*"] # Comma-separated headers or "*" for all ``` !!! warning "CORS in Production" - Never use `"*"` for CORS_ORIGINS in production. Specify exact domains: - ```env - CORS_ORIGINS="https://yourapp.com,https://www.yourapp.com" - CORS_METHODS="GET,POST,PUT,DELETE,PATCH" - CORS_HEADERS="Authorization,Content-Type" - ``` +Never use `"*"` for CORS_ORIGINS in production. Specify exact domains: +`env CORS_ORIGINS=["https://yourapp.com","https://www.yourapp.com"] CORS_METHODS=["GET","POST","PUT","DELETE","PATCH"] CORS_HEADERS=["Authorization","Content-Type"] ` ### First Tier @@ -170,7 +167,7 @@ REDIS_RATE_LIMIT_HOST="redis" The boilerplate includes Redis for caching, job queues, and rate limiting. If running locally without Docker, either: 1. **Install Redis** and keep the default settings -2. **Disable Redis services** (see [User Guide - Configuration](../user-guide/configuration/index.md) for details) +1. **Disable Redis services** (see [User Guide - Configuration](../user-guide/configuration/index.md) for details) ## That's It! @@ -179,4 +176,4 @@ With these basic settings configured, you can start the application: - **Docker Compose**: `docker compose up` - **Manual**: `uv run uvicorn src.app.main:app --reload` -For detailed configuration options, advanced settings, and production deployment, see the [User Guide - Configuration](../user-guide/configuration/index.md). \ No newline at end of file +For detailed configuration options, advanced settings, and production deployment, see the [User Guide - Configuration](../user-guide/configuration/index.md). diff --git a/docs/user-guide/authentication/jwt-tokens.md b/docs/user-guide/authentication/jwt-tokens.md index 4e5490b..e4a3f14 100644 --- a/docs/user-guide/authentication/jwt-tokens.md +++ b/docs/user-guide/authentication/jwt-tokens.md @@ -518,7 +518,7 @@ REFRESH_TOKEN_EXPIRE_DAYS=7 # Security Headers SECURE_COOKIES=true -CORS_ORIGINS="http://localhost:3000,https://yourapp.com" +CORS_ORIGINS=["http://localhost:3000","https://yourapp.com"] ``` ### Security Configuration diff --git a/docs/user-guide/configuration/environment-specific.md b/docs/user-guide/configuration/environment-specific.md index eba0bab..9264070 100644 --- a/docs/user-guide/configuration/environment-specific.md +++ b/docs/user-guide/configuration/environment-specific.md @@ -148,8 +148,8 @@ SECRET_KEY="staging-secret-key-different-from-production" ALGORITHM="HS256" ACCESS_TOKEN_EXPIRE_MINUTES=30 REFRESH_TOKEN_EXPIRE_DAYS=7 -CORS_ORIGINS="https://staging.example.com" -CORS_METHODS="GET,POST,PUT,DELETE" +CORS_ORIGINS=["https://staging.example.com"] +CORS_METHODS=["GET","POST","PUT","DELETE"] # ------------- redis ------------- REDIS_CACHE_HOST="staging-redis.example.com" @@ -259,9 +259,9 @@ SECRET_KEY="ultra-secure-production-key-generated-with-openssl-rand-hex-32" ALGORITHM="HS256" ACCESS_TOKEN_EXPIRE_MINUTES=15 # Shorter for security REFRESH_TOKEN_EXPIRE_DAYS=3 # Shorter for security -CORS_ORIGINS="https://example.com,https://www.example.com" -CORS_METHODS="GET,POST,PUT,DELETE" -CORS_HEADERS="Authorization,Content-Type" +CORS_ORIGINS=["https://example.com","https://www.example.com"] +CORS_METHODS=["GET","POST","PUT","DELETE"] +CORS_HEADERS=["Authorization","Content-Type"] # ------------- redis ------------- REDIS_CACHE_HOST="prod-redis.example.com" diff --git a/docs/user-guide/configuration/environment-variables.md b/docs/user-guide/configuration/environment-variables.md index c545d9a..53fd3ca 100644 --- a/docs/user-guide/configuration/environment-variables.md +++ b/docs/user-guide/configuration/environment-variables.md @@ -178,33 +178,33 @@ Cross-Origin Resource Sharing (CORS) settings for frontend integration: ```env # ------------- CORS ------------- -CORS_ORIGINS="*" -CORS_METHODS="*" -CORS_HEADERS="*" +CORS_ORIGINS=["*"] +CORS_METHODS=["*"] +CORS_HEADERS=["*"] ``` **Variables Explained:** -- `CORS_ORIGINS`: Comma-separated list of allowed origins (e.g., `"https://app.com,https://www.app.com"`) -- `CORS_METHODS`: Comma-separated list of allowed HTTP methods (e.g., `"GET,POST,PUT,DELETE"`) -- `CORS_HEADERS`: Comma-separated list of allowed headers (e.g., `"Authorization,Content-Type"`) +- `CORS_ORIGINS`: Comma-separated list of allowed origins (e.g., `["https://app.com","https://www.app.com"]`) +- `CORS_METHODS`: Comma-separated list of allowed HTTP methods (e.g., `["GET","POST","PUT","DELETE"]`) +- `CORS_HEADERS`: Comma-separated list of allowed headers (e.g., `["Authorization","Content-Type"]`) **Environment-Specific Values:** ```env # Development - Allow all origins -CORS_ORIGINS="*" -CORS_METHODS="*" -CORS_HEADERS="*" +CORS_ORIGINS=["*"] +CORS_METHODS=["*"] +CORS_HEADERS=["*"] # Production - Specific domains only -CORS_ORIGINS="https://yourapp.com,https://www.yourapp.com" -CORS_METHODS="GET,POST,PUT,DELETE,PATCH" -CORS_HEADERS="Authorization,Content-Type,X-Requested-With" +CORS_ORIGINS=["https://yourapp.com","https://www.yourapp.com"] +CORS_METHODS=["GET","POST","PUT","DELETE","PATCH"] +CORS_HEADERS=["Authorization","Content-Type","X-Requested-With"] ``` !!! danger "Security Warning" - Never use wildcard (`*`) for `CORS_ORIGINS` in production environments. Always specify exact allowed domains to prevent unauthorized cross-origin requests. +Never use wildcard (`*`) for `CORS_ORIGINS` in production environments. Always specify exact allowed domains to prevent unauthorized cross-origin requests. ### User Tiers diff --git a/scripts/local_with_uvicorn/.env.example b/scripts/local_with_uvicorn/.env.example index 0e74135..c4bf803 100644 --- a/scripts/local_with_uvicorn/.env.example +++ b/scripts/local_with_uvicorn/.env.example @@ -20,7 +20,7 @@ CONTACT_NAME="Me" CONTACT_EMAIL="my.email@example.com" LICENSE_NAME="MIT" -# ------------- database ------------- +# ------------- database ------------- POSTGRES_USER="postgres" POSTGRES_PASSWORD=1234 POSTGRES_SERVER="db" @@ -55,9 +55,9 @@ REDIS_RATE_LIMIT_PORT=6379 CLIENT_CACHE_MAX_AGE=60 # ------------- CORS ------------- -CORS_ORIGINS="*" -CORS_METHODS="*" -CORS_HEADERS="*" +CORS_ORIGINS=["*"] +CORS_METHODS=["*"] +CORS_HEADERS=["*"] # ------------- test ------------- TEST_NAME="Tester User" diff --git a/src/app/core/config.py b/src/app/core/config.py index fd75ce3..e67169a 100644 --- a/src/app/core/config.py +++ b/src/app/core/config.py @@ -1,16 +1,10 @@ import os from enum import Enum -from pydantic import SecretStr, computed_field, field_validator +from pydantic import SecretStr, computed_field from pydantic_settings import BaseSettings, SettingsConfigDict -def str_to_list(value: str) -> list[str]: - if isinstance(value, str): - return [item.strip() for item in value.split(",") if item.strip()] - raise ValueError("Invalid string setting for list conversion.") - - class AppSettings(BaseSettings): APP_NAME: str = "FastAPI app" APP_DESCRIPTION: str | None = None @@ -150,11 +144,9 @@ class EnvironmentSettings(BaseSettings): class CORSSettings(BaseSettings): - CORS_ORIGINS: list[str] | str = "*" - CORS_METHODS: list[str] | str = "*" - CORS_HEADERS: list[str] | str = "*" - - _normalize_to_list = field_validator("CORS_ORIGINS", "CORS_METHODS", "CORS_HEADERS", mode="before")(str_to_list) + CORS_ORIGINS: list[str] = ["*"] + CORS_METHODS: list[str] = ["*"] + CORS_HEADERS: list[str] = ["*"] class Settings(