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
starlette
pydantic-settings
authlib
joserfc
httpx
types-authlib
python-jose
pytest
uvloop; sys_platform != 'win32'
sqlalchemy

View file

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

View file

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

View file

@ -1,5 +1,6 @@
"""
Router dependencies for the _____ module
Dependencies related to the <this> module
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:
- /timer/start
- /timer/stop
Exports:
- router: fastapi.APIRouter
### 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
from typing import Annotated
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(
tags=[""],
@ -52,3 +68,28 @@ async def stop_timer(request: Request, ident: str):
timers.pop(idx)
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:
"""

View file

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

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
api_router = APIRouter()
api_router = APIRouter(
prefix="/api/v1"
)
api_router.include_router(auth_router)
api_router.include_router(template_router)
@api_router.get("/healthcheck", include_in_schema=False)
def healthcheck():
"""Simple health check endpoint."""

View file

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

View file

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

View file

@ -1,5 +1,26 @@
"""
Router dependencies for auth module
Auth dependencies
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
Exports:
Database models for the auth module
"""

View file

@ -1,20 +1,11 @@
"""
Router endpoints for auth module
Contains oauth registration
Router endpoints for the auth module
Endpoints:
- /auth/me/claims: Test endpoint returning user claims
Exports:
- router: fastapi.APIRouter
"""
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

View file

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

View file

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

View file

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

View file

@ -2,8 +2,9 @@
Global configurations
Exports:
- settings: Global configs loaded from environment variables
- 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
@ -24,6 +25,9 @@ class Config(CustomBaseSettings):
APP_VERSION: str = "0.1"
ENVIRONMENT: Environment = Environment.PRODUCTION
SECRET_KEY: SecretStr = ""
DISABLE_AUTH: bool = False
SERVICE_NAME: str = "template_app"
HUB_ADDRESS: str = "http://localhost:8000"
CORS_ORIGINS: list[str] = ["*"]
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}")
if settings.ENVIRONMENT == Environment.TESTING:
SQLALCHEMY_DATABASE_URI = SecretStr("sqlite:///:memory:")
app_configs: dict[str, Any] = {"title": "App API"}
if settings.ENVIRONMENT.is_deployed:
app_configs["root_path"] = f"/v{settings.APP_VERSION}"

View file

@ -6,17 +6,24 @@ Exports:
- Base (sqlalchemy base model)
"""
from typing import Annotated
from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, sessionmaker, Session
from sqlalchemy import create_engine, StaticPool
from sqlalchemy.orm import DeclarativeBase, sessionmaker, Session
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)
def get_db():
db = SessionLocal()
try:
@ -29,4 +36,5 @@ def 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
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.cors import CORSMiddleware
from src.constants import Environment
from src.config import settings, app_configs
from src.api import api_router
@ -31,9 +32,10 @@ async def lifespan(_application: FastAPI) -> AsyncGenerator:
if settings.ENVIRONMENT.is_deployed:
# Do this only on prod
pass
# Just a precaution, should be False anyway
settings.DISABLE_AUTH = False
tags_metadata = []
app = FastAPI(
swagger_ui_init_oauth={
@ -41,6 +43,7 @@ app = FastAPI(
"usePkceWithAuthorizationCodeGrant": True,
"scopes": "openid profile email",
},
openapi_tags=tags_metadata,
**app_configs
)
@ -61,6 +64,11 @@ app.add_middleware(
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.state["timers"] = []

View file

@ -1,12 +1,20 @@
"""
Global Pydantic models
Global Pydantic schemas
Exports:
- CustomBaseModel: Pydantic BaseModel with extra parameters
- CustomBaseModel: Schema used for all other Pydantic models
- ResourceName
"""
from pydantic import BaseModel
from typing import Optional
class CustomBaseModel(BaseModel):
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:
"""
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):
@ -13,3 +16,14 @@ def create_timer(func: Callable, interval: int, stop_event: threading.Event):
func()
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