template for service rather than hub

Previous template structure had direct handling of auth. This structure is designed to get auth from the hub instead.
This commit is contained in:
Chris Milne 2026-06-05 16:39:14 +01:00
parent ed4a3fe0b8
commit ea9803536a
27 changed files with 223 additions and 117 deletions

View file

@ -7,11 +7,8 @@ requests
itsdangerous itsdangerous
starlette starlette
pydantic-settings pydantic-settings
authlib
joserfc joserfc
httpx httpx
types-authlib
python-jose
pytest pytest
uvloop; sys_platform != 'win32' uvloop; sys_platform != 'win32'
sqlalchemy sqlalchemy

View file

@ -1,5 +1,5 @@
""" """
Configurations for the _____ module Configurations for the <this> module
Exports: Exports:
""" """

View file

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

View file

@ -1,5 +1,6 @@
""" """
Router dependencies for the _____ module Dependencies related to the <this> module
Exports: Exports:
- <dep_name>: <return_type>: <description>
""" """

View file

@ -1,5 +1,6 @@
""" """
Module specific exceptions for the _____ module Exceptions related to the <this> modules
Exports: Exceptions:
- <ExceptionName>: Details e.g. optional params
""" """

View file

@ -1,5 +1,9 @@
""" """
Database models for the _____ module Database models for the <this> module
Exports: Models:
- <ModelName>:
- <normal_columns[FK][PK]>
- <orm_relationships>
- <calculated_properties>
""" """

View file

@ -1,15 +1,31 @@
""" """
Router endpoints for the _____ module Router endpoints for the <this> module
Endpoints: Exports:
- /timer/start - router: fastapi.APIRouter
- /timer/stop
### Router Guidelines ###
- Add responses to decorators
- Add status_codes to decorators
- All endpoints should either return a response object or 204
- Ensure response_model is declared in the decorator
- All query and path params should have validation and descriptions
- All endpoints should have a docstring (this is used in place of a description)
- All endpoints should have a summary
- All modules should have metadata in main.py
- All exceptions should have a custom definition in exceptions.py
- Dependencies should be used for db model get and validation where possible
- Verify module level docstring is still accurate after updates
""" """
import threading import threading
from typing import Annotated
from fastapi import APIRouter, Request, HTTPException from fastapi import APIRouter, Request, HTTPException
from fastapi.params import Query
from src.utils import create_timer from src.utils import create_timer, generate_resource_name
from src.auth.dependencies import header_dependency
from src.dependencies import http_client_dependency
router = APIRouter( router = APIRouter(
tags=[""], tags=[""],
@ -52,3 +68,28 @@ async def stop_timer(request: Request, ident: str):
timers.pop(idx) timers.pop(idx)
return {"timer_ident": ident, "status": "stopped"} return {"timer_ident": ident, "status": "stopped"}
@router.get("/hub/access")
async def test_hub_access(headers: header_dependency, client: http_client_dependency, org_name: Annotated[str, Query()]):
resource_name = "example_resource"
action = "read"
rn = generate_resource_name(resource=resource_name, org=org_name)
hub_response = await client.post(
f"http://localhost:8001/api/v1/iam/can_act_on_resource",
headers=headers,
params={"action": action},
json=rn.model_dump()
)
response = {
"resource_name": rn,
"action": action,
"response": {
"status": hub_response.status_code,
"json": hub_response.json()
}
}
return response

View file

@ -1,5 +1,8 @@
""" """
Pydantic models for the _____ module Pydantic models for the <this> module
Exports: Models follow the nomenclature of:
- Sub-models: "<Resource><Opt:>Schema"
- Mixins: "<Attribute>Mixin"
- Models: "<Module><Method><Resource><Opt:Resource><Direction>" ie ""
""" """

View file

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

View file

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

View file

@ -7,11 +7,14 @@ from src.auth.router import router as auth_router
from src._module_template.router import router as template_router from src._module_template.router import router as template_router
api_router = APIRouter() api_router = APIRouter(
prefix="/api/v1"
)
api_router.include_router(auth_router) api_router.include_router(auth_router)
api_router.include_router(template_router) api_router.include_router(template_router)
@api_router.get("/healthcheck", include_in_schema=False) @api_router.get("/healthcheck", include_in_schema=False)
def healthcheck(): def healthcheck():
"""Simple health check endpoint.""" """Simple health check endpoint."""

View file

@ -1,16 +1,17 @@
""" """
Configurations for auth module Configurations for the auth module
Exports: Exports:
- auth_settings: Configs for auth loaded from environment variables - auth_settings: Contains OIDC & hub access information
""" """
from src.config import CustomBaseSettings from src.config import CustomBaseSettings
class AuthConfig(CustomBaseSettings): class AuthConfig(CustomBaseSettings):
OIDC_CONFIG: str = "" OIDC_CONFIG: str = ""
OIDC_ISSUER: str = ""
OIDC_AUDIENCE: str = ""
CLIENT_ID: str = "" CLIENT_ID: str = ""
HUB_ACCESS_KEY: str = ""
auth_settings = AuthConfig() auth_settings = AuthConfig()

View file

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

View file

@ -1,5 +1,26 @@
""" """
Router dependencies for auth module Auth dependencies
Exports: Exports:
""" """
from typing import Annotated, Any
from fastapi import Depends
from fastapi.security import OpenIdConnect
from src.auth.config import auth_settings
oidc = OpenIdConnect(openIdConnectUrl=auth_settings.OIDC_CONFIG)
oidc_dependency = Annotated[str, Depends(oidc)]
async def generate_access_headers(oidc_auth_string: oidc_dependency) -> dict[str, Any]:
headers = {
"Authorization": f"Bearer {oidc_auth_string}",
"X-API-Key": auth_settings.HUB_ACCESS_KEY,
}
return headers
header_dependency = Annotated[dict[str, Any], Depends(generate_access_headers)]

View file

@ -1,5 +1,18 @@
""" """
Module specific exceptions for auth module Module specific exceptions for the auth module
Exports: Exceptions:
- UnauthorizedException: Takes an optional message string
""" """
from typing import Optional
from fastapi import HTTPException, status
class UnauthorizedException(HTTPException):
def __init__(self, message: Optional[str] = None) -> None:
detail = "Not authorized" if not message else message
super().__init__(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=detail,
)

View file

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

View file

@ -1,20 +1,11 @@
""" """
Router endpoints for auth module Router endpoints for the auth module
Contains oauth registration
Endpoints: Exports:
- /auth/me/claims: Test endpoint returning user claims - router: fastapi.APIRouter
""" """
from fastapi import APIRouter from fastapi import APIRouter
from src.auth.service import claims_dependency
router = APIRouter( router = APIRouter(
tags=["auth"], tags=["auth"],
prefix="/auth",
) )
@router.get("/me/claims")
async def current_user_claims(user: claims_dependency):
return user

View file

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

View file

@ -1,50 +1,5 @@
""" """
Module specific business logic for auth module Module specific business logic for the auth module
Exports: 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)]

View file

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

View file

@ -2,8 +2,9 @@
Global configurations Global configurations
Exports: Exports:
- settings: Global configs loaded from environment variables
- CustomBaseSettings - Base class to be used by all modules for loading configs - CustomBaseSettings - Base class to be used by all modules for loading configs
- settings: Global configurations object
- app_configs: Dict generated from configs, used in app initialisation
""" """
from typing import Any from typing import Any
@ -24,6 +25,9 @@ class Config(CustomBaseSettings):
APP_VERSION: str = "0.1" APP_VERSION: str = "0.1"
ENVIRONMENT: Environment = Environment.PRODUCTION ENVIRONMENT: Environment = Environment.PRODUCTION
SECRET_KEY: SecretStr = "" SECRET_KEY: SecretStr = ""
DISABLE_AUTH: bool = False
SERVICE_NAME: str = "template_app"
HUB_ADDRESS: str = "http://localhost:8000"
CORS_ORIGINS: list[str] = ["*"] CORS_ORIGINS: list[str] = ["*"]
CORS_ORIGINS_REGEX: str | None = None CORS_ORIGINS_REGEX: str | None = None
@ -46,6 +50,9 @@ _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}") SQLALCHEMY_DATABASE_URI = SecretStr(f"postgresql+psycopg://{_DATABASE_CREDENTIAL_USER}:{_QUOTED_DATABASE_PASSWORD}@{DATABASE_HOSTNAME}:{DATABASE_PORT}/{DATABASE_NAME}")
if settings.ENVIRONMENT == Environment.TESTING:
SQLALCHEMY_DATABASE_URI = SecretStr("sqlite:///:memory:")
app_configs: dict[str, Any] = {"title": "App API"} app_configs: dict[str, Any] = {"title": "App API"}
if settings.ENVIRONMENT.is_deployed: if settings.ENVIRONMENT.is_deployed:
app_configs["root_path"] = f"/v{settings.APP_VERSION}" app_configs["root_path"] = f"/v{settings.APP_VERSION}"

View file

@ -6,17 +6,24 @@ Exports:
- Base (sqlalchemy base model) - Base (sqlalchemy base model)
""" """
from typing import Annotated from typing import Annotated
from sqlalchemy import create_engine from sqlalchemy import create_engine, StaticPool
from sqlalchemy.orm import declarative_base, sessionmaker, Session from sqlalchemy.orm import DeclarativeBase, sessionmaker, Session
from fastapi import Depends from fastapi import Depends
from src.config import SQLALCHEMY_DATABASE_URI from src.constants import Environment
from src.config import SQLALCHEMY_DATABASE_URI, settings as global_settings
if global_settings.ENVIRONMENT == Environment.TESTING:
connect_args = {"check_same_thread": False}
engine = create_engine(SQLALCHEMY_DATABASE_URI.get_secret_value(), connect_args=connect_args, poolclass=StaticPool)
else:
engine = create_engine(SQLALCHEMY_DATABASE_URI.get_secret_value()) engine = create_engine(SQLALCHEMY_DATABASE_URI.get_secret_value())
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def get_db(): def get_db():
db = SessionLocal() db = SessionLocal()
try: try:
@ -29,4 +36,5 @@ def get_db():
db_dependency = Annotated[Session, Depends(get_db)] db_dependency = Annotated[Session, Depends(get_db)]
Base = declarative_base() class Base(DeclarativeBase):
pass

15
src/dependencies.py Normal file
View file

@ -0,0 +1,15 @@
"""
Global dependencies
Exports:
"""
import httpx
from typing import Annotated
from fastapi import Depends
async def create_http_client():
async with httpx.AsyncClient() as client:
yield client
http_client_dependency = Annotated[httpx.AsyncClient, Depends(create_http_client)]

View file

@ -1,3 +1,28 @@
""" """
Global exceptions Global exceptions
Exports:
- UnprocessableContentException
- ConflictException
""" """
from typing import Optional
from fastapi import HTTPException, status
class UnprocessableContentException(HTTPException):
def __init__(self, message: Optional[str] = None) -> None:
detail = "Unprocessable content" if not message else message
super().__init__(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=detail,
)
class ConflictException(HTTPException):
def __init__(self, message: Optional[str] = None) -> None:
detail = "Conflict" if not message else message
super().__init__(
status_code=status.HTTP_409_CONFLICT,
detail=detail,
)

View file

@ -12,6 +12,7 @@ from fastapi import FastAPI
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from starlette.middleware.cors import CORSMiddleware from starlette.middleware.cors import CORSMiddleware
from src.constants import Environment
from src.config import settings, app_configs from src.config import settings, app_configs
from src.api import api_router from src.api import api_router
@ -31,9 +32,10 @@ async def lifespan(_application: FastAPI) -> AsyncGenerator:
if settings.ENVIRONMENT.is_deployed: if settings.ENVIRONMENT.is_deployed:
# Do this only on prod # Just a precaution, should be False anyway
pass settings.DISABLE_AUTH = False
tags_metadata = []
app = FastAPI( app = FastAPI(
swagger_ui_init_oauth={ swagger_ui_init_oauth={
@ -41,6 +43,7 @@ app = FastAPI(
"usePkceWithAuthorizationCodeGrant": True, "usePkceWithAuthorizationCodeGrant": True,
"scopes": "openid profile email", "scopes": "openid profile email",
}, },
openapi_tags=tags_metadata,
**app_configs **app_configs
) )
@ -61,6 +64,11 @@ app.add_middleware(
allow_headers=settings.CORS_HEADERS, allow_headers=settings.CORS_HEADERS,
) )
if settings.DISABLE_AUTH and (settings.ENVIRONMENT == Environment.LOCAL):
# app.dependency_overrides[] = get_dev_user
pass
app.include_router(api_router) app.include_router(api_router)
app.state["timers"] = [] app.state["timers"] = []

View file

@ -1,12 +1,20 @@
""" """
Global Pydantic models Global Pydantic schemas
Exports: Exports:
- CustomBaseModel: Pydantic BaseModel with extra parameters - CustomBaseModel: Schema used for all other Pydantic models
- ResourceName
""" """
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional
class CustomBaseModel(BaseModel): class CustomBaseModel(BaseModel):
pass pass
class ResourceName(CustomBaseModel):
service: str
organisation: str
resource: str
instance: Optional[str] = None

View file

@ -4,7 +4,10 @@ Global non-business-logic reusable functions and classes
Exports: Exports:
""" """
import threading import threading
from typing import Callable from typing import Callable, Optional
from src.schemas import ResourceName
from src.config import settings
def create_timer(func: Callable, interval: int, stop_event: threading.Event): def create_timer(func: Callable, interval: int, stop_event: threading.Event):
@ -13,3 +16,14 @@ def create_timer(func: Callable, interval: int, stop_event: threading.Event):
func() func()
return threading.Thread(target=target, daemon=True) return threading.Thread(target=target, daemon=True)
def generate_resource_name(org: str, resource: str, instance: Optional[str] = None) -> ResourceName:
rn = ResourceName(
service=settings.SERVICE_NAME,
organisation=org,
resource=resource,
instance=instance,
)
return rn