Skip to content

Commit 0d72aab

Browse files
committed
Use Pydantic BaseSettings for config settings.
This reflects the improved implementation in fastapi/full-stack-fastapi-template#87
1 parent 7701ae8 commit 0d72aab

26 files changed

+249
-223
lines changed

backend/app/app/api/api_v1/api.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
from fastapi import APIRouter, Depends, Security
44

5-
from app.core import config
65
from app.api.api_v1.endpoints import login, users, utils
76
from app.api.api_v1.endpoints.farms import farms, info, logs, assets, terms, areas
87
from app.api.utils.security import get_farm_access

backend/app/app/api/api_v1/endpoints/login.py

Lines changed: 2 additions & 2 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_user
11-
from app.core import config
11+
from app.core.config import settings
1212
from app.core.jwt import create_access_token
1313
from app.core.security import get_password_hash
1414
from app.models.user import User as DBUser
@@ -40,7 +40,7 @@ def login_access_token(
4040
raise HTTPException(status_code=400, detail="Incorrect email or password")
4141
elif not crud.user.is_active(user):
4242
raise HTTPException(status_code=400, detail="Inactive user")
43-
access_token_expires = timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES)
43+
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
4444
logger.debug(f"New user login with scopes: {form_data.scopes}")
4545
return {
4646
"access_token": create_access_token(

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, UserInDB, 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, user_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 resgistration is forbidden on this server",

backend/app/app/api/utils/security.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
from app import crud
1111
from app.api.utils.db import get_db
12-
from app.core import config
12+
from app.core.config import settings
1313
from app.core.jwt import ALGORITHM
1414
from app.models.user import User
1515
from app.schemas.token import TokenData, FarmAccess
@@ -31,12 +31,12 @@
3131
}
3232

3333
optional_oauth2 = OAuth2PasswordBearer(
34-
tokenUrl="/api/v1/login/access-token",
34+
tokenUrl=f"{settings.API_V1_STR}/login/access-token",
3535
scopes=oauth_scopes,
3636
auto_error=False
3737
)
3838
reusable_oauth2 = OAuth2PasswordBearer(
39-
tokenUrl="/api/v1/login/access-token",
39+
tokenUrl=f"{settings.API_V1_STR}/login/access-token",
4040
scopes=oauth_scopes,
4141
auto_error=True
4242
)
@@ -193,7 +193,7 @@ def get_farm_access_allow_public(
193193
farm_access = None
194194

195195
# If open registration is enabled, allow minimal access.
196-
if config.AGGREGATOR_OPEN_FARM_REGISTRATION is True:
196+
if settings.AGGREGATOR_OPEN_FARM_REGISTRATION is True:
197197
farm_access = FarmAccess(scopes=[], farm_id_list=[], all_farms=False)
198198

199199
# Still check for a request with higher permissions.
@@ -217,7 +217,7 @@ def get_farm_access_allow_public(
217217

218218

219219
def _validate_token(token):
220-
payload = jwt.decode(token, config.SECRET_KEY, algorithms=[ALGORITHM])
220+
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
221221
user_id: int = payload.get("sub", None)
222222
farm_id = payload.get("farm_id", [])
223223
token_scopes = payload.get("scopes", [])

backend/app/app/core/config.py

Lines changed: 96 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,70 +1,103 @@
11
import os
2+
import secrets
3+
from typing import List
24

5+
from pydantic import AnyHttpUrl, BaseSettings, EmailStr, HttpUrl, PostgresDsn, validator
36
from celery.schedules import crontab
47

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

62100

63-
def has_valid_test_configuration():
64-
"""Check if sufficient info is provided to run integration tests with a farmOS server."""
65-
return TEST_FARM_URL is not None and TEST_FARM_USERNAME is not None and TEST_FARM_PASSWORD is not None
66-
101+
CELERY_WORKER_PING_INTERVAL = crontab(minute='0', hour='0,12')
67102

68-
AGGREGATOR_OPEN_FARM_REGISTRATION = getenv_boolean("AGGREGATOR_OPEN_FARM_REGISTRATION")
69-
AGGREGATOR_INVITE_FARM_REGISTRATION = getenv_boolean("AGGREGATOR_INVITE_FARM_REGISTRATION")
70-
FARM_ACTIVE_AFTER_REGISTRATION = getenv_boolean("FARM_ACTIVE_AFTER_REGISTRATION")
103+
settings = Settings()

backend/app/app/core/jwt.py

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

44
import jwt
55

6-
from app.core import config
6+
from app.core.config import settings
77

88
ALGORITHM = "HS256"
99

@@ -15,12 +15,12 @@ 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})
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
2020

2121

2222
def create_farm_api_token(farm_id: List[int], scopes: List[str]):
23-
delta = timedelta(hours=config.EMAIL_RESET_TOKEN_EXPIRE_HOURS)
23+
delta = timedelta(hours=settings.EMAIL_RESET_TOKEN_EXPIRE_HOURS)
2424
now = datetime.utcnow()
2525
expires = now + delta
2626
encoded_jwt = jwt.encode(
@@ -30,7 +30,7 @@ def create_farm_api_token(farm_id: List[int], scopes: List[str]):
3030
"farm_id": farm_id,
3131
"scopes": scopes,
3232
},
33-
config.SECRET_KEY,
33+
settings.SECRET_KEY,
3434
algorithm=ALGORITHM,
3535
)
3636
return encoded_jwt

backend/app/app/crud/farm.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from sqlalchemy.orm import Session
77

88
from app import crud
9-
from app.core import config
9+
from app.core.config import settings
1010
from app.models.farm import Farm
1111
from app.schemas.farm import FarmCreate, FarmUpdate
1212
from app.models.farm_token import FarmToken
@@ -55,7 +55,7 @@ def create(db_session: Session, *, farm_in: FarmCreate) -> Farm:
5555
logging.debug(f"New farm provided 'active = {farm_in.active}'")
5656
active = farm_in.active
5757
# Enable farm profile if configured and not overridden above.
58-
elif config.FARM_ACTIVE_AFTER_REGISTRATION:
58+
elif settings.FARM_ACTIVE_AFTER_REGISTRATION:
5959
logging.debug(f"FARM_ACTIVE_AFTER_REGISTRATION is enabled. New farm will be active.")
6060
active = True
6161

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 schemas are imported before initializing DB
@@ -13,11 +13,11 @@ def init_db(db_session):
1313
# the tables un-commenting the next line
1414
# Base.metadata.create_all(bind=engine)
1515

16-
user = crud.user.get_by_email(db_session, email=config.FIRST_SUPERUSER)
16+
user = crud.user.get_by_email(db_session, email=settings.FIRST_SUPERUSER)
1717
if not user:
1818
user_in = UserCreate(
19-
email=config.FIRST_SUPERUSER,
20-
password=config.FIRST_SUPERUSER_PASSWORD,
19+
email=settings.FIRST_SUPERUSER,
20+
password=settings.FIRST_SUPERUSER_PASSWORD,
2121
is_superuser=True,
2222
)
2323
user = crud.user.create(db_session, user_in=user_in)

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
)

backend/app/app/main.py

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,32 +5,25 @@
55
from starlette.requests import Request
66

77
from app.api.api_v1.api import api_router
8-
from app.core import config
8+
from app.core.config import settings
99
from app.db.session import Session
1010

1111
# Configure logging. Change INFO to DEBUG for development logging.
1212
logging.basicConfig(level=logging.INFO)
1313

14-
app = FastAPI(title=config.PROJECT_NAME, openapi_url="/api/v1/openapi.json")
15-
16-
# CORS
17-
origins = []
14+
app = FastAPI(title=settings.PROJECT_NAME, openapi_url=f"{settings.API_V1_STR}/openapi.json")
1815

1916
# Set all CORS enabled origins
20-
if config.BACKEND_CORS_ORIGINS:
21-
origins_raw = config.BACKEND_CORS_ORIGINS.split(",")
22-
for origin in origins_raw:
23-
use_origin = origin.strip()
24-
origins.append(use_origin)
17+
if settings.BACKEND_CORS_ORIGINS:
2518
app.add_middleware(
2619
CORSMiddleware,
27-
allow_origins=origins,
20+
allow_origins=[str(origin for origin in settings.BACKEND_CORS_ORIGINS)],
2821
allow_credentials=True,
2922
allow_methods=["*"],
3023
allow_headers=["*"],
3124
),
3225

33-
app.include_router(api_router, prefix=config.API_V1_STR)
26+
app.include_router(api_router, prefix=settings.API_V1_STR)
3427

3528

3629
@app.middleware("http")

0 commit comments

Comments
 (0)