init
This commit is contained in:
commit
0dd23f6de0
33 changed files with 881 additions and 0 deletions
5
src/_module_template/config.py
Normal file
5
src/_module_template/config.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""
|
||||
Configurations for the _____ module
|
||||
|
||||
Exports:
|
||||
"""
|
||||
5
src/_module_template/constants.py
Normal file
5
src/_module_template/constants.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""
|
||||
Constants and error codes for the _____ module
|
||||
|
||||
Exports:
|
||||
"""
|
||||
5
src/_module_template/dependencies.py
Normal file
5
src/_module_template/dependencies.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""
|
||||
Router dependencies for the _____ module
|
||||
|
||||
Exports:
|
||||
"""
|
||||
5
src/_module_template/exceptions.py
Normal file
5
src/_module_template/exceptions.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""
|
||||
Module specific exceptions for the _____ module
|
||||
|
||||
Exports:
|
||||
"""
|
||||
5
src/_module_template/models.py
Normal file
5
src/_module_template/models.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""
|
||||
Database models for the _____ module
|
||||
|
||||
Exports:
|
||||
"""
|
||||
11
src/_module_template/router.py
Normal file
11
src/_module_template/router.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
"""
|
||||
Router endpoints for the _____ module
|
||||
|
||||
Endpoints:
|
||||
"""
|
||||
from fastapi import APIRouter
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
tags=[""],
|
||||
)
|
||||
5
src/_module_template/schemas.py
Normal file
5
src/_module_template/schemas.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""
|
||||
Pydantic models for the _____ module
|
||||
|
||||
Exports:
|
||||
"""
|
||||
5
src/_module_template/service.py
Normal file
5
src/_module_template/service.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""
|
||||
Module specific business logic for the _____ module
|
||||
|
||||
Exports:
|
||||
"""
|
||||
5
src/_module_template/utils.py
Normal file
5
src/_module_template/utils.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""
|
||||
Non-business logic reusable functions and classes for the _____ module
|
||||
|
||||
Exports:
|
||||
"""
|
||||
17
src/api.py
Normal file
17
src/api.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
"""
|
||||
This module hooks the routers for the main endpoints into a single router for importing to the app.
|
||||
"""
|
||||
from fastapi import APIRouter
|
||||
|
||||
from src.auth.router import router as auth_router
|
||||
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
api_router.include_router(auth_router)
|
||||
|
||||
|
||||
@api_router.get("/healthcheck", include_in_schema=False)
|
||||
def healthcheck():
|
||||
"""Simple health check endpoint."""
|
||||
return {"status": "ok"}
|
||||
16
src/auth/config.py
Normal file
16
src/auth/config.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
"""
|
||||
Configurations for auth module
|
||||
|
||||
Exports:
|
||||
- auth_settings: Configs for auth loaded from environment variables
|
||||
"""
|
||||
from src.config import CustomBaseSettings
|
||||
|
||||
class AuthConfig(CustomBaseSettings):
|
||||
OIDC_CONFIG: str = ""
|
||||
OIDC_ISSUER: str = ""
|
||||
OIDC_AUDIENCE: str = ""
|
||||
CLIENT_ID: str = ""
|
||||
|
||||
|
||||
auth_settings = AuthConfig()
|
||||
5
src/auth/constants.py
Normal file
5
src/auth/constants.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""
|
||||
Constants and error codes for auth module
|
||||
|
||||
Exports:
|
||||
"""
|
||||
5
src/auth/dependencies.py
Normal file
5
src/auth/dependencies.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""
|
||||
Router dependencies for auth module
|
||||
|
||||
Exports:
|
||||
"""
|
||||
5
src/auth/exceptions.py
Normal file
5
src/auth/exceptions.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""
|
||||
Module specific exceptions for auth module
|
||||
|
||||
Exports:
|
||||
"""
|
||||
5
src/auth/models.py
Normal file
5
src/auth/models.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""
|
||||
Database models for auth module
|
||||
|
||||
Exports:
|
||||
"""
|
||||
20
src/auth/router.py
Normal file
20
src/auth/router.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
"""
|
||||
Router endpoints for auth module
|
||||
Contains oauth registration
|
||||
|
||||
Endpoints:
|
||||
- /auth/me/claims: Test endpoint returning user claims
|
||||
"""
|
||||
from fastapi import APIRouter
|
||||
|
||||
from src.auth.service import claims_dependency
|
||||
|
||||
router = APIRouter(
|
||||
tags=["auth"],
|
||||
prefix="/auth",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me/claims")
|
||||
async def current_user_claims(user: claims_dependency):
|
||||
return user
|
||||
5
src/auth/schemas.py
Normal file
5
src/auth/schemas.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""
|
||||
Pydantic models for auth module
|
||||
|
||||
Exports:
|
||||
"""
|
||||
50
src/auth/service.py
Normal file
50
src/auth/service.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"""
|
||||
Module specific business logic for auth module
|
||||
|
||||
Exports:
|
||||
- claims_dependency
|
||||
"""
|
||||
import json
|
||||
import requests
|
||||
|
||||
from typing import Annotated, Any
|
||||
from joserfc import jwt
|
||||
from urllib.request import urlopen
|
||||
|
||||
from fastapi import Depends
|
||||
from fastapi.security import OpenIdConnect
|
||||
from joserfc.jwk import KeySet
|
||||
|
||||
from src.auth.config import auth_settings
|
||||
|
||||
|
||||
oidc = OpenIdConnect(openIdConnectUrl=auth_settings.OIDC_CONFIG)
|
||||
oidc_dependency = Annotated[str, Depends(oidc)]
|
||||
|
||||
|
||||
async def get_current_user(oidc_auth_string: oidc_dependency) -> dict[str, Any]:
|
||||
config_url = urlopen(auth_settings.OIDC_CONFIG)
|
||||
config = json.loads(config_url.read())
|
||||
jwks_uri = config["jwks_uri"]
|
||||
key_response = requests.get(jwks_uri)
|
||||
jwk_keys = KeySet.import_key_set(key_response.json())
|
||||
|
||||
claims_options = {
|
||||
"exp": {"essential": True},
|
||||
"aud": {"essential": True, "value": "account"},
|
||||
"iss": {"essential": True, "value": auth_settings.OIDC_ISSUER},
|
||||
}
|
||||
|
||||
token = jwt.decode(
|
||||
oidc_auth_string.replace("Bearer ", ""),
|
||||
jwk_keys
|
||||
)
|
||||
|
||||
claims_requests = jwt.JWTClaimsRegistry(**claims_options)
|
||||
|
||||
claims_requests.validate(token.claims)
|
||||
|
||||
return token.claims
|
||||
|
||||
|
||||
claims_dependency = Annotated[dict[str, Any], Depends(get_current_user)]
|
||||
5
src/auth/utils.py
Normal file
5
src/auth/utils.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""
|
||||
Non-business logic reusable functions and classes for auth module
|
||||
|
||||
Exports:
|
||||
"""
|
||||
54
src/config.py
Normal file
54
src/config.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
"""
|
||||
Global configurations
|
||||
|
||||
Exports:
|
||||
- settings: Global configs loaded from environment variables
|
||||
- CustomBaseSettings - Base class to be used by all modules for loading configs
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from urllib import parse
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from pydantic import SecretStr
|
||||
|
||||
from src.constants import Environment
|
||||
|
||||
|
||||
class CustomBaseSettings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env", env_file_encoding="utf-8", extra="ignore"
|
||||
)
|
||||
|
||||
|
||||
class Config(CustomBaseSettings):
|
||||
APP_VERSION: str = "0.1"
|
||||
ENVIRONMENT: Environment = Environment.PRODUCTION
|
||||
SECRET_KEY: SecretStr = ""
|
||||
|
||||
CORS_ORIGINS: list[str] = ["*"]
|
||||
CORS_ORIGINS_REGEX: str | None = None
|
||||
CORS_HEADERS: list[str] = ["*"]
|
||||
|
||||
DATABASE_NAME: str = "fastapi-exp"
|
||||
DATABASE_PORT: str = "5432"
|
||||
DATABASE_HOSTNAME: str = "localhost"
|
||||
DATABASE_CREDENTIALS: SecretStr = ""
|
||||
|
||||
settings = Config()
|
||||
|
||||
DATABASE_NAME = settings.DATABASE_NAME
|
||||
DATABASE_PORT = settings.DATABASE_PORT
|
||||
DATABASE_HOSTNAME = settings.DATABASE_HOSTNAME
|
||||
DATABASE_CREDENTIALS = settings.DATABASE_CREDENTIALS.get_secret_value()
|
||||
# this will support special chars for credentials
|
||||
_DATABASE_CREDENTIAL_USER, _DATABASE_CREDENTIAL_PASSWORD = str(DATABASE_CREDENTIALS).split(":")
|
||||
_QUOTED_DATABASE_PASSWORD = parse.quote_plus(str(_DATABASE_CREDENTIAL_PASSWORD))
|
||||
|
||||
SQLALCHEMY_DATABASE_URI = SecretStr(f"postgresql+psycopg://{_DATABASE_CREDENTIAL_USER}:{_QUOTED_DATABASE_PASSWORD}@{DATABASE_HOSTNAME}:{DATABASE_PORT}/{DATABASE_NAME}")
|
||||
|
||||
app_configs: dict[str, Any] = {"title": "App API"}
|
||||
if settings.ENVIRONMENT.is_deployed:
|
||||
app_configs["root_path"] = f"/v{settings.APP_VERSION}"
|
||||
|
||||
if not settings.ENVIRONMENT.is_debug:
|
||||
app_configs["openapi_url"] = None # hide docs
|
||||
36
src/constants.py
Normal file
36
src/constants.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
"""
|
||||
Global constants
|
||||
|
||||
Exports:
|
||||
- Environment(StrEnum): LOCAL, TESTING, STAGING, PRODUCTION
|
||||
"""
|
||||
from enum import StrEnum, auto
|
||||
|
||||
|
||||
class Environment(StrEnum):
|
||||
"""
|
||||
Enumeration of environments.
|
||||
|
||||
Attributes:
|
||||
LOCAL (str): Application is running locally
|
||||
TESTING (str): Application is running in testing mode
|
||||
STAGING (str): Application is running in staging mode (ie not testing)
|
||||
PRODUCTION (str): Application is running in production mode
|
||||
"""
|
||||
|
||||
LOCAL = auto()
|
||||
TESTING = auto()
|
||||
STAGING = auto()
|
||||
PRODUCTION = auto()
|
||||
|
||||
@property
|
||||
def is_debug(self):
|
||||
return self in (self.LOCAL, self.STAGING, self.TESTING)
|
||||
|
||||
@property
|
||||
def is_testing(self):
|
||||
return self == self.TESTING
|
||||
|
||||
@property
|
||||
def is_deployed(self) -> bool:
|
||||
return self in (self.STAGING, self.PRODUCTION)
|
||||
32
src/database.py
Normal file
32
src/database.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
"""
|
||||
Database connections and init
|
||||
|
||||
Exports:
|
||||
- db_dependency
|
||||
- Base (sqlalchemy base model)
|
||||
"""
|
||||
from typing import Annotated
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import declarative_base, sessionmaker, Session
|
||||
|
||||
from fastapi import Depends
|
||||
|
||||
from src.config import SQLALCHEMY_DATABASE_URI
|
||||
|
||||
engine = create_engine(SQLALCHEMY_DATABASE_URI.get_secret_value())
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
except:
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
db_dependency = Annotated[Session, Depends(get_db)]
|
||||
Base = declarative_base()
|
||||
3
src/exceptions.py
Normal file
3
src/exceptions.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
"""
|
||||
Global exceptions
|
||||
"""
|
||||
61
src/main.py
Normal file
61
src/main.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"""
|
||||
Application root file: Inits the FastAPI application
|
||||
|
||||
Prometheus client mounted at /metrics endpoint
|
||||
Middleware: Session, CORS
|
||||
"""
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncGenerator
|
||||
from prometheus_client import make_asgi_app
|
||||
|
||||
from fastapi import FastAPI
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
|
||||
from src.config import settings, app_configs
|
||||
from src.api import api_router
|
||||
|
||||
from src.auth.config import auth_settings
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_application: FastAPI) -> AsyncGenerator:
|
||||
# Startup
|
||||
yield
|
||||
# Shutdown
|
||||
|
||||
|
||||
if settings.ENVIRONMENT.is_deployed:
|
||||
# Do this only on prod
|
||||
pass
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
swagger_ui_init_oauth={
|
||||
"clientId": auth_settings.CLIENT_ID,
|
||||
"usePkceWithAuthorizationCodeGrant": True,
|
||||
"scopes": "openid profile email",
|
||||
},
|
||||
**app_configs
|
||||
)
|
||||
|
||||
metrics_app = make_asgi_app()
|
||||
app.mount("/metrics", metrics_app)
|
||||
|
||||
# Type inspection disabled for middleware injection.
|
||||
# Known bug in FastAPI type checking: https://github.com/astral-sh/ty/issues/1635
|
||||
# noinspection PyTypeChecker
|
||||
app.add_middleware(SessionMiddleware, secret_key=settings.SECRET_KEY.get_secret_value())
|
||||
# noinspection PyTypeChecker
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.CORS_ORIGINS,
|
||||
allow_origin_regex=settings.CORS_ORIGINS_REGEX,
|
||||
allow_credentials=True,
|
||||
allow_methods=("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"),
|
||||
allow_headers=settings.CORS_HEADERS,
|
||||
)
|
||||
|
||||
app.include_router(api_router)
|
||||
|
||||
print(f"Running in environment: {settings.ENVIRONMENT}")
|
||||
4
src/models.py
Normal file
4
src/models.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
"""
|
||||
Global database models
|
||||
"""
|
||||
|
||||
12
src/schemas.py
Normal file
12
src/schemas.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
"""
|
||||
Global Pydantic models
|
||||
|
||||
Exports:
|
||||
- CustomBaseModel: Pydantic BaseModel with extra parameters
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class CustomBaseModel(BaseModel):
|
||||
pass
|
||||
Loading…
Add table
Add a link
Reference in a new issue