This commit is contained in:
Chris Milne 2026-05-13 15:09:59 +01:00
commit 0dd23f6de0
33 changed files with 881 additions and 0 deletions

View file

@ -0,0 +1,5 @@
"""
Configurations for the _____ module
Exports:
"""

View file

@ -0,0 +1,5 @@
"""
Constants and error codes for the _____ module
Exports:
"""

View file

@ -0,0 +1,5 @@
"""
Router dependencies for the _____ module
Exports:
"""

View file

@ -0,0 +1,5 @@
"""
Module specific exceptions for the _____ module
Exports:
"""

View file

@ -0,0 +1,5 @@
"""
Database models for the _____ module
Exports:
"""

View file

@ -0,0 +1,11 @@
"""
Router endpoints for the _____ module
Endpoints:
"""
from fastapi import APIRouter
router = APIRouter(
tags=[""],
)

View file

@ -0,0 +1,5 @@
"""
Pydantic models for the _____ module
Exports:
"""

View file

@ -0,0 +1,5 @@
"""
Module specific business logic for the _____ module
Exports:
"""

View file

@ -0,0 +1,5 @@
"""
Non-business logic reusable functions and classes for the _____ module
Exports:
"""

17
src/api.py Normal file
View 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
View 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
View file

@ -0,0 +1,5 @@
"""
Constants and error codes for auth module
Exports:
"""

5
src/auth/dependencies.py Normal file
View file

@ -0,0 +1,5 @@
"""
Router dependencies for auth module
Exports:
"""

5
src/auth/exceptions.py Normal file
View file

@ -0,0 +1,5 @@
"""
Module specific exceptions for auth module
Exports:
"""

5
src/auth/models.py Normal file
View file

@ -0,0 +1,5 @@
"""
Database models for auth module
Exports:
"""

20
src/auth/router.py Normal file
View 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
View file

@ -0,0 +1,5 @@
"""
Pydantic models for auth module
Exports:
"""

50
src/auth/service.py Normal file
View 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
View file

@ -0,0 +1,5 @@
"""
Non-business logic reusable functions and classes for auth module
Exports:
"""

54
src/config.py Normal file
View 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
View 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
View 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
View file

@ -0,0 +1,3 @@
"""
Global exceptions
"""

61
src/main.py Normal file
View 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
View file

@ -0,0 +1,4 @@
"""
Global database models
"""

12
src/schemas.py Normal file
View file

@ -0,0 +1,12 @@
"""
Global Pydantic models
Exports:
- CustomBaseModel: Pydantic BaseModel with extra parameters
"""
from pydantic import BaseModel
class CustomBaseModel(BaseModel):
pass