Skip to content

Commit bded8df

Browse files
✨ Use Pydantic BaseSettings for config settings (fastapi#87)
* Use Pydantic BaseSettings for config settings * Update fastapi dep to >=0.47.0 and email_validator to email-validator * Fix deprecation warning for Pydantic >=1.0 * Properly support old-format comma separated strings for BACKEND_CORS_ORIGINS Co-authored-by: Sebastián Ramírez <[email protected]>
1 parent 8451e0b commit bded8df

24 files changed

+173
-140
lines changed

cookiecutter.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"secret_key": "changethis",
1111
"first_superuser": "admin@{{cookiecutter.domain_main}}",
1212
"first_superuser_password": "changethis",
13-
"backend_cors_origins": "http://localhost, http://localhost:4200, http://localhost:3000, http://localhost:8080, https://localhost, https://localhost:4200, https://localhost:3000, https://localhost:8080, http://dev.{{cookiecutter.domain_main}}, https://{{cookiecutter.domain_staging}}, https://{{cookiecutter.domain_main}}, http://local.dockertoolbox.tiangolo.com, http://localhost.tiangolo.com",
13+
"backend_cors_origins": "[\"http://localhost\", \"http://localhost:4200\", \"http://localhost:3000\", \"http://localhost:8080\", \"https://localhost\", \"https://localhost:4200\", \"https://localhost:3000\", \"https://localhost:8080\", \"http://dev.{{cookiecutter.domain_main}}\", \"https://{{cookiecutter.domain_staging}}\", \"https://{{cookiecutter.domain_main}}\", \"http://local.dockertoolbox.tiangolo.com\", \"http://localhost.tiangolo.com\"]",
1414
"smtp_port": "587",
1515
"smtp_host": "",
1616
"smtp_user": "",

{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/login.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from app import crud
88
from app.api.utils.db import get_db
99
from app.api.utils.security import get_current_user
10-
from app.core import config
10+
from app.core.config import settings
1111
from app.core.jwt import create_access_token
1212
from app.core.security import get_password_hash
1313
from app.models.user import User as DBUser
@@ -37,7 +37,7 @@ def login_access_token(
3737
raise HTTPException(status_code=400, detail="Incorrect email or password")
3838
elif not crud.user.is_active(user):
3939
raise HTTPException(status_code=400, detail="Inactive user")
40-
access_token_expires = timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES)
40+
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
4141
return {
4242
"access_token": create_access_token(
4343
data={"user_id": user.id}, expires_delta=access_token_expires

{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/users.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from app import crud
99
from app.api.utils.db import get_db
1010
from app.api.utils.security import get_current_active_superuser, get_current_active_user
11-
from app.core import config
11+
from app.core.config import settings
1212
from app.models.user import User as DBUser
1313
from app.schemas.user import User, UserCreate, UserUpdate
1414
from app.utils import send_new_account_email
@@ -47,7 +47,7 @@ def create_user(
4747
detail="The user with this username already exists in the system.",
4848
)
4949
user = crud.user.create(db, obj_in=user_in)
50-
if config.EMAILS_ENABLED and user_in.email:
50+
if settings.EMAILS_ENABLED and user_in.email:
5151
send_new_account_email(
5252
email_to=user_in.email, username=user_in.email, password=user_in.password
5353
)
@@ -100,7 +100,7 @@ def create_user_open(
100100
"""
101101
Create new user without the need to be logged in.
102102
"""
103-
if not config.USERS_OPEN_REGISTRATION:
103+
if not settings.USERS_OPEN_REGISTRATION:
104104
raise HTTPException(
105105
status_code=403,
106106
detail="Open user registration is forbidden on this server",

{{cookiecutter.project_slug}}/backend/app/app/api/utils/security.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,19 @@
77

88
from app import crud
99
from app.api.utils.db import get_db
10-
from app.core import config
10+
from app.core.config import settings
1111
from app.core.jwt import ALGORITHM
1212
from app.models.user import User
1313
from app.schemas.token import TokenPayload
1414

15-
reusable_oauth2 = OAuth2PasswordBearer(tokenUrl="/api/v1/login/access-token")
15+
reusable_oauth2 = OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/login/access-token")
1616

1717

1818
def get_current_user(
1919
db: Session = Depends(get_db), token: str = Security(reusable_oauth2)
2020
):
2121
try:
22-
payload = jwt.decode(token, config.SECRET_KEY, algorithms=[ALGORITHM])
22+
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
2323
token_data = TokenPayload(**payload)
2424
except PyJWTError:
2525
raise HTTPException(
Lines changed: 89 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,92 @@
11
import os
2+
import secrets
3+
from typing import List
24

5+
from pydantic import AnyHttpUrl, BaseSettings, EmailStr, HttpUrl, PostgresDsn, validator
36

4-
def getenv_boolean(var_name, default_value=False):
5-
result = default_value
6-
env_value = os.getenv(var_name)
7-
if env_value is not None:
8-
result = env_value.upper() in ("TRUE", "1")
9-
return result
10-
11-
12-
API_V1_STR = "/api/v1"
13-
14-
SECRET_KEY = os.getenvb(b"SECRET_KEY")
15-
if not SECRET_KEY:
16-
SECRET_KEY = os.urandom(32)
17-
18-
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 8 # 60 minutes * 24 hours * 8 days = 8 days
19-
20-
SERVER_NAME = os.getenv("SERVER_NAME")
21-
SERVER_HOST = os.getenv("SERVER_HOST")
22-
BACKEND_CORS_ORIGINS = os.getenv(
23-
"BACKEND_CORS_ORIGINS"
24-
) # a string of origins separated by commas, e.g: "http://localhost, http://localhost:4200, http://localhost:3000, http://localhost:8080, http://local.dockertoolbox.tiangolo.com"
25-
PROJECT_NAME = os.getenv("PROJECT_NAME")
26-
SENTRY_DSN = os.getenv("SENTRY_DSN")
27-
28-
POSTGRES_SERVER = os.getenv("POSTGRES_SERVER")
29-
POSTGRES_USER = os.getenv("POSTGRES_USER")
30-
POSTGRES_PASSWORD = os.getenv("POSTGRES_PASSWORD")
31-
POSTGRES_DB = os.getenv("POSTGRES_DB")
32-
SQLALCHEMY_DATABASE_URI = (
33-
f"postgresql://{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_SERVER}/{POSTGRES_DB}"
34-
)
35-
36-
SMTP_TLS = getenv_boolean("SMTP_TLS", True)
37-
SMTP_PORT = None
38-
_SMTP_PORT = os.getenv("SMTP_PORT")
39-
if _SMTP_PORT is not None:
40-
SMTP_PORT = int(_SMTP_PORT)
41-
SMTP_HOST = os.getenv("SMTP_HOST")
42-
SMTP_USER = os.getenv("SMTP_USER")
43-
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD")
44-
EMAILS_FROM_EMAIL = os.getenv("EMAILS_FROM_EMAIL")
45-
EMAILS_FROM_NAME = PROJECT_NAME
46-
EMAIL_RESET_TOKEN_EXPIRE_HOURS = 48
47-
EMAIL_TEMPLATES_DIR = "/app/app/email-templates/build"
48-
EMAILS_ENABLED = SMTP_HOST and SMTP_PORT and EMAILS_FROM_EMAIL
49-
50-
FIRST_SUPERUSER = os.getenv("FIRST_SUPERUSER")
51-
FIRST_SUPERUSER_PASSWORD = os.getenv("FIRST_SUPERUSER_PASSWORD")
52-
53-
USERS_OPEN_REGISTRATION = getenv_boolean("USERS_OPEN_REGISTRATION")
54-
55-
EMAIL_TEST_USER = "[email protected]"
7+
8+
class Settings(BaseSettings):
9+
10+
API_V1_STR: str = "/api/v1"
11+
12+
SECRET_KEY: str = secrets.token_urlsafe(32)
13+
14+
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 # 60 minutes * 24 hours * 8 days = 8 days
15+
16+
SERVER_NAME: str
17+
SERVER_HOST: AnyHttpUrl
18+
# BACKEND_CORS_ORIGINS is a JSON-formatted list of origins
19+
# e.g: '["http://localhost", "http://localhost:4200", "http://localhost:3000", \
20+
# "http://localhost:8080", "http://local.dockertoolbox.tiangolo.com"]'
21+
BACKEND_CORS_ORIGINS: List[AnyHttpUrl] = []
22+
23+
@validator("BACKEND_CORS_ORIGINS", pre=True)
24+
def assemble_cors_origins(cls, v):
25+
if isinstance(v, str) and not v.startswith("["):
26+
return [i.strip() for i in v.split(",")]
27+
return v
28+
29+
PROJECT_NAME: str
30+
SENTRY_DSN: HttpUrl = None
31+
32+
@validator("SENTRY_DSN", pre=True)
33+
def sentry_dsn_can_be_blank(cls, v):
34+
if len(v) == 0:
35+
return None
36+
return v
37+
38+
POSTGRES_SERVER: str
39+
POSTGRES_USER: str
40+
POSTGRES_PASSWORD: str
41+
POSTGRES_DB: str
42+
SQLALCHEMY_DATABASE_URI: PostgresDsn = None
43+
44+
@validator("SQLALCHEMY_DATABASE_URI", pre=True)
45+
def assemble_db_connection(cls, v, values):
46+
if isinstance(v, str):
47+
return v
48+
return PostgresDsn.build(
49+
scheme="postgresql",
50+
user=values.get("POSTGRES_USER"),
51+
password=values.get("POSTGRES_PASSWORD"),
52+
host=values.get("POSTGRES_SERVER"),
53+
path=f"/{values.get('POSTGRES_DB') or ''}",
54+
)
55+
56+
SMTP_TLS: bool = True
57+
SMTP_PORT: int = None
58+
SMTP_HOST: str = None
59+
SMTP_USER: str = None
60+
SMTP_PASSWORD: str = None
61+
EMAILS_FROM_EMAIL: EmailStr = None
62+
EMAILS_FROM_NAME: str = None
63+
64+
@validator("EMAILS_FROM_NAME")
65+
def get_project_name(cls, v, values):
66+
if not v:
67+
return values["PROJECT_NAME"]
68+
return v
69+
70+
EMAIL_RESET_TOKEN_EXPIRE_HOURS: int = 48
71+
EMAIL_TEMPLATES_DIR: str = "/app/app/email-templates/build"
72+
EMAILS_ENABLED: bool = False
73+
74+
@validator("EMAILS_ENABLED", pre=True)
75+
def get_emails_enabled(cls, v, values):
76+
return bool(
77+
values.get("SMTP_HOST")
78+
and values.get("SMTP_PORT")
79+
and values.get("EMAILS_FROM_EMAIL")
80+
)
81+
82+
EMAIL_TEST_USER: EmailStr = "[email protected]"
83+
84+
FIRST_SUPERUSER: EmailStr
85+
FIRST_SUPERUSER_PASSWORD: str
86+
87+
USERS_OPEN_REGISTRATION: bool = False
88+
89+
class Config:
90+
case_sensitive = True
91+
92+
settings = Settings()

{{cookiecutter.project_slug}}/backend/app/app/core/jwt.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import jwt
44

5-
from app.core import config
5+
from app.core.config import settings
66

77
ALGORITHM = "HS256"
88
access_token_jwt_subject = "access"
@@ -15,5 +15,5 @@ def create_access_token(*, data: dict, expires_delta: timedelta = None):
1515
else:
1616
expire = datetime.utcnow() + timedelta(minutes=15)
1717
to_encode.update({"exp": expire, "sub": access_token_jwt_subject})
18-
encoded_jwt = jwt.encode(to_encode, config.SECRET_KEY, algorithm=ALGORITHM)
18+
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
1919
return encoded_jwt

{{cookiecutter.project_slug}}/backend/app/app/crud/base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def update(
4141
self, db_session: Session, *, db_obj: ModelType, obj_in: UpdateSchemaType
4242
) -> ModelType:
4343
obj_data = jsonable_encoder(db_obj)
44-
update_data = obj_in.dict(skip_defaults=True)
44+
update_data = obj_in.dict(exclude_unset=True)
4545
for field in obj_data:
4646
if field in update_data:
4747
setattr(db_obj, field, update_data[field])

{{cookiecutter.project_slug}}/backend/app/app/db/init_db.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from app import crud
2-
from app.core import config
2+
from app.core.config import settings
33
from app.schemas.user import UserCreate
44

55
# make sure all SQL Alchemy models are imported before initializing DB
@@ -14,11 +14,11 @@ def init_db(db_session):
1414
# the tables un-commenting the next line
1515
# Base.metadata.create_all(bind=engine)
1616

17-
user = crud.user.get_by_email(db_session, email=config.FIRST_SUPERUSER)
17+
user = crud.user.get_by_email(db_session, email=settings.FIRST_SUPERUSER)
1818
if not user:
1919
user_in = UserCreate(
20-
email=config.FIRST_SUPERUSER,
21-
password=config.FIRST_SUPERUSER_PASSWORD,
20+
email=settings.FIRST_SUPERUSER,
21+
password=settings.FIRST_SUPERUSER_PASSWORD,
2222
is_superuser=True,
2323
)
2424
user = crud.user.create(db_session, obj_in=user_in)

{{cookiecutter.project_slug}}/backend/app/app/db/session.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
from sqlalchemy import create_engine
22
from sqlalchemy.orm import scoped_session, sessionmaker
33

4-
from app.core import config
4+
from app.core.config import settings
55

6-
engine = create_engine(config.SQLALCHEMY_DATABASE_URI, pool_pre_ping=True)
6+
engine = create_engine(settings.SQLALCHEMY_DATABASE_URI, pool_pre_ping=True)
77
db_session = scoped_session(
88
sessionmaker(autocommit=False, autoflush=False, bind=engine)
99
)

{{cookiecutter.project_slug}}/backend/app/app/main.py

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,29 +3,22 @@
33
from starlette.requests import Request
44

55
from app.api.api_v1.api import api_router
6-
from app.core import config
6+
from app.core.config import settings
77
from app.db.session import Session
88

9-
app = FastAPI(title=config.PROJECT_NAME, openapi_url="/api/v1/openapi.json")
10-
11-
# CORS
12-
origins = []
9+
app = FastAPI(title=settings.PROJECT_NAME, openapi_url=f"{settings.API_V1_STR}/openapi.json")
1310

1411
# Set all CORS enabled origins
15-
if config.BACKEND_CORS_ORIGINS:
16-
origins_raw = config.BACKEND_CORS_ORIGINS.split(",")
17-
for origin in origins_raw:
18-
use_origin = origin.strip()
19-
origins.append(use_origin)
12+
if settings.BACKEND_CORS_ORIGINS:
2013
app.add_middleware(
2114
CORSMiddleware,
22-
allow_origins=origins,
15+
allow_origins=[str(origin) for origin in settings.BACKEND_CORS_ORIGINS],
2316
allow_credentials=True,
2417
allow_methods=["*"],
2518
allow_headers=["*"],
2619
),
2720

28-
app.include_router(api_router, prefix=config.API_V1_STR)
21+
app.include_router(api_router, prefix=settings.API_V1_STR)
2922

3023

3124
@app.middleware("http")

{{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/test_celery.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import requests
22

3-
from app.core import config
3+
from app.core.config import settings
44
from app.tests.utils.utils import get_server_api
55

66

77
def test_celery_worker_test(superuser_token_headers):
88
server_api = get_server_api()
99
data = {"msg": "test"}
1010
r = requests.post(
11-
f"{server_api}{config.API_V1_STR}/utils/test-celery/",
11+
f"{server_api}{settings.API_V1_STR}/utils/test-celery/",
1212
json=data,
1313
headers=superuser_token_headers,
1414
)

{{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/test_items.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import requests
22

3-
from app.core import config
3+
from app.core.config import settings
44
from app.tests.utils.item import create_random_item
55
from app.tests.utils.utils import get_server_api
66
from app.tests.utils.user import create_random_user
@@ -10,7 +10,7 @@ def test_create_item(superuser_token_headers):
1010
server_api = get_server_api()
1111
data = {"title": "Foo", "description": "Fighters"}
1212
response = requests.post(
13-
f"{server_api}{config.API_V1_STR}/items/",
13+
f"{server_api}{settings.API_V1_STR}/items/",
1414
headers=superuser_token_headers,
1515
json=data,
1616
)
@@ -26,7 +26,7 @@ def test_read_item(superuser_token_headers):
2626
item = create_random_item()
2727
server_api = get_server_api()
2828
response = requests.get(
29-
f"{server_api}{config.API_V1_STR}/items/{item.id}",
29+
f"{server_api}{settings.API_V1_STR}/items/{item.id}",
3030
headers=superuser_token_headers,
3131
)
3232
assert response.status_code == 200

{{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/test_login.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
import requests
22

3-
from app.core import config
3+
from app.core.config import settings
44
from app.tests.utils.utils import get_server_api
55

66

77
def test_get_access_token():
88
server_api = get_server_api()
99
login_data = {
10-
"username": config.FIRST_SUPERUSER,
11-
"password": config.FIRST_SUPERUSER_PASSWORD,
10+
"username": settings.FIRST_SUPERUSER,
11+
"password": settings.FIRST_SUPERUSER_PASSWORD,
1212
}
1313
r = requests.post(
14-
f"{server_api}{config.API_V1_STR}/login/access-token", data=login_data
14+
f"{server_api}{settings.API_V1_STR}/login/access-token", data=login_data
1515
)
1616
tokens = r.json()
1717
assert r.status_code == 200
@@ -22,7 +22,7 @@ def test_get_access_token():
2222
def test_use_access_token(superuser_token_headers):
2323
server_api = get_server_api()
2424
r = requests.post(
25-
f"{server_api}{config.API_V1_STR}/login/test-token",
25+
f"{server_api}{settings.API_V1_STR}/login/test-token",
2626
headers=superuser_token_headers,
2727
)
2828
result = r.json()

0 commit comments

Comments
 (0)