Add authenticated finance REST API

This commit is contained in:
kai
2026-09-09 09:47:56 +02:00
parent afc6f74f36
commit ad452045be
13 changed files with 760 additions and 44 deletions
+16
View File
@@ -0,0 +1,16 @@
import os
import secrets
from typing import Annotated
from fastapi import Depends, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
bearer = HTTPBearer(auto_error=False, scheme_name='FinanceAPIToken',
description='Bearer-Token aus FINANCE_API_TOKEN. Kein Standard-Token.')
def require_token(credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)]):
token = os.environ.get('FINANCE_API_TOKEN', '')
if not token.strip() or token == 'change-me':
raise HTTPException(503, 'API nicht konfiguriert. FINANCE_API_TOKEN muss gesetzt werden.')
if credentials is None or not secrets.compare_digest(credentials.credentials.encode(), token.encode()):
raise HTTPException(401, 'Fehlendes oder ungültiges API-Token.', headers={'WWW-Authenticate': 'Bearer'})
+155
View File
@@ -0,0 +1,155 @@
"""Authenticated REST adapters around the same services used by Jinja pages."""
from decimal import Decimal, ROUND_HALF_UP
import sqlite3
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from fastapi.routing import APIRoute
from database import connect
from models import CATEGORIES, decimal_string
from services import asset_service, income_service
from api.auth import require_token
from api.schemas import (
AssetCreate, AssetPatch, AssetResponse, AssetShareResponse, Category,
CategoryShareResponse, IncomeCreate, IncomePatch, IncomeResponse,
MetaResponse, MonthlyResponse, SummaryResponse,
)
class SafeAPIRoute(APIRoute):
def get_route_handler(self):
original = super().get_route_handler()
async def safe_handler(request):
try:
return await original(request)
except HTTPException:
raise
except RequestValidationError as error:
# Never echo raw request bodies, credentials or internal exception context.
details = [{'loc': e['loc'], 'msg': e['msg'], 'type': e['type']} for e in error.errors()]
return JSONResponse({'detail': details}, status_code=422)
except ValueError:
return JSONResponse({'detail': 'Ungültige Werte. Bitte Felder und Position prüfen.'}, status_code=422)
except sqlite3.IntegrityError:
return JSONResponse({'detail': 'Änderung steht im Konflikt mit vorhandenen Daten.'}, status_code=409)
except sqlite3.OperationalError:
return JSONResponse({'detail': 'Datenbank vorübergehend nicht verfügbar.'}, status_code=503, headers={'Retry-After': '5'})
except Exception:
return JSONResponse({'detail': 'Interner Fehler. Anfrage konnte nicht verarbeitet werden.'}, status_code=500)
return safe_handler
router = APIRouter(prefix='/api/v1', dependencies=[Depends(require_token)], route_class=SafeAPIRoute,
responses={401: {'description': 'Token fehlt oder ist ungültig'},
503: {'description': 'API nicht konfiguriert oder Datenbank nicht verfügbar'}})
def income_response(row):
return IncomeResponse(**{**dict(row), 'asset': row['name'], 'amount': decimal_string(row['amount'])})
def percentage(amount, total):
if total == 0:
return None
return format((Decimal(amount) * 100 / Decimal(total)).quantize(Decimal('.01'), rounding=ROUND_HALF_UP), '.2f')
@router.get('/assets', response_model=list[AssetResponse], tags=['Assets'])
def assets(active: bool | None = None):
return [AssetResponse(**row) for row in asset_service.list_assets(active)]
@router.get('/assets/{asset_id}', response_model=AssetResponse, tags=['Assets'])
def asset(asset_id: int):
return AssetResponse(**asset_service.get_asset(asset_id))
@router.post('/assets', response_model=AssetResponse, status_code=201, tags=['Assets'])
def create_asset(data: AssetCreate):
return AssetResponse(**asset_service.create_asset(**data.model_dump()))
@router.patch('/assets/{asset_id}', response_model=AssetResponse, tags=['Assets'])
def update_asset(asset_id: int, data: AssetPatch):
return AssetResponse(**asset_service.update_asset(asset_id, data.model_dump(exclude_unset=True)))
@router.delete('/assets/{asset_id}', status_code=204, tags=['Assets'])
def delete_asset(asset_id: int):
asset_service.deactivate_asset(asset_id)
return Response(status_code=204)
@router.get('/income', response_model=list[IncomeResponse], tags=['Income'])
def income(year: Annotated[int | None, Query(ge=1, le=9999)] = None,
month: Annotated[int | None, Query(ge=1, le=12)] = None,
asset_id: Annotated[int | None, Query(ge=1, le=9223372036854775807)] = None,
category: Category | None = None, received: bool | None = None, expected: bool | None = None,
limit: Annotated[int, Query(ge=1, le=1000)] = 100,
offset: Annotated[int, Query(ge=0, le=9223372036854775807)] = 0):
return [income_response(row) for row in income_service.list_entries(
year, month, asset_id, category, limit, offset, received, expected)]
@router.get('/income/{entry_id}', response_model=IncomeResponse, tags=['Income'])
def income_entry(entry_id: int):
return income_response(income_service.get_entry(entry_id))
@router.post('/income', response_model=IncomeResponse, status_code=201, tags=['Income'])
def create_income(data: IncomeCreate):
return income_response(income_service.write_entry(data.model_dump()))
@router.patch('/income/{entry_id}', response_model=IncomeResponse, tags=['Income'])
def update_income(entry_id: int, data: IncomePatch):
return income_response(income_service.write_entry(data.model_dump(exclude_unset=True), entry_id, partial=True))
@router.delete('/income/{entry_id}', status_code=204, tags=['Income'])
def delete_income(entry_id: int):
income_service.delete_entry(entry_id)
return Response(status_code=204)
@router.get('/stats/summary', response_model=SummaryResponse, tags=['Stats'])
def summary():
stats = income_service.dashboard()
return SummaryResponse(
current_month=decimal_string(stats['month']), current_month_previous_year=decimal_string(stats['prior_month']),
current_month_yoy_percent=None if stats['month_change'] is None else format(stats['month_change'], '.2f'),
current_year=decimal_string(stats['year']), previous_year=decimal_string(stats['prior_year']),
current_year_yoy_percent=None if stats['year_change'] is None else format(stats['year_change'], '.2f'),
all_time=decimal_string(stats['all_time']), current_year_payment_count=stats['count'])
@router.get('/stats/monthly', response_model=list[MonthlyResponse], tags=['Stats'])
def monthly(year: Annotated[int | None, Query(ge=1, le=9999)] = None):
stats = income_service.dashboard()
years = [year] if year is not None else stats['years']
return [MonthlyResponse(year=y, months=[{'month': m+1, 'amount': decimal_string(amount)}
for m, amount in enumerate(stats['monthly'].get(y, [0]*12))],
total=decimal_string(stats['totals'].get(y, 0))) for y in years]
@router.get('/stats/by-asset', response_model=list[AssetShareResponse], tags=['Stats'])
def by_asset():
stats = income_service.dashboard()
return [AssetShareResponse(asset_id=row['asset_id'], asset=row['name'], amount=decimal_string(row['amount']),
percentage=percentage(row['amount'], stats['all_time'])) for row in stats['shares']]
@router.get('/stats/by-category', response_model=list[CategoryShareResponse], tags=['Stats'])
def by_category():
stats = income_service.dashboard()
return [CategoryShareResponse(category=category, amount=decimal_string(stats['categories'].get(category, 0)),
percentage=percentage(stats['categories'].get(category, 0), stats['all_time'])) for category in CATEGORIES]
@router.get('/meta', response_model=MetaResponse, tags=['Stats'])
def meta():
with connect() as db:
db.execute('SELECT COUNT(*) FROM assets').fetchone()
return MetaResponse()
+133
View File
@@ -0,0 +1,133 @@
"""Transport schemas only; business validation lives in the shared services."""
from decimal import Decimal
from typing import Annotated, Literal
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, StrictBool, model_validator
from models import cents, valid_date
AssetType = Literal['stock', 'etf', 'bond', 'crypto', 'interest', 'other']
Category = Literal['dividend', 'interest', 'distribution', 'other']
Identifier = Annotated[int, Field(strict=True, ge=1, le=9223372036854775807)]
MoneyString = Annotated[str, Field(pattern=r'^-?\d+\.\d{2}$', examples=['0.04'])]
def parse_amount(value):
if not isinstance(value, (str, Decimal)):
raise ValueError('Betrag als Dezimalstring senden, zum Beispiel "0.04".')
return Decimal(cents(value)) / 100
Amount = Annotated[Decimal, BeforeValidator(parse_amount, json_schema_input_type=str)]
Day = Annotated[str, BeforeValidator(valid_date)]
class RequestModel(BaseModel):
model_config = ConfigDict(extra='forbid')
class PatchModel(RequestModel):
@model_validator(mode='before')
@classmethod
def reject_required_nulls(cls, data):
if isinstance(data, dict):
for name, value in data.items():
if value is None and name not in {'note', 'ticker'}:
raise ValueError('Nur Notiz und Ticker dürfen null sein.')
return data
class AssetCreate(RequestModel):
name: str = Field(min_length=1, max_length=150)
ticker: str | None = Field(default=None, max_length=30)
asset_type: AssetType
active: StrictBool = True
class AssetPatch(PatchModel):
name: str | None = Field(default=None, min_length=1, max_length=150)
ticker: str | None = Field(default=None, max_length=30)
asset_type: AssetType | None = None
active: StrictBool | None = None
class AssetResponse(BaseModel):
id: int
name: str
ticker: str | None
asset_type: AssetType
active: bool
created_at: str
class IncomeCreate(RequestModel):
date: Day
asset_id: Identifier
category: Category
amount: Amount
note: str | None = Field(default=None, max_length=2000)
expected: StrictBool = False
received: StrictBool = True
class IncomePatch(PatchModel):
date: Day | None = None
asset_id: Identifier | None = None
category: Category | None = None
amount: Amount | None = None
note: str | None = Field(default=None, max_length=2000)
expected: StrictBool | None = None
received: StrictBool | None = None
class IncomeResponse(BaseModel):
id: int
date: str
asset: str = Field(description='Normalisierter Positionsname')
asset_id: int
category: Category
amount: MoneyString
note: str | None
expected: bool
received: bool
created_at: str
updated_at: str
class SummaryResponse(BaseModel):
current_month: MoneyString
current_month_previous_year: MoneyString
current_month_yoy_percent: MoneyString | None
current_year: MoneyString
previous_year: MoneyString
current_year_yoy_percent: MoneyString | None
all_time: MoneyString
current_year_payment_count: int
class MonthResponse(BaseModel):
month: int
amount: MoneyString
class MonthlyResponse(BaseModel):
year: int
months: list[MonthResponse]
total: MoneyString
class AssetShareResponse(BaseModel):
asset_id: int
asset: str
amount: MoneyString
percentage: MoneyString | None
class CategoryShareResponse(BaseModel):
category: Category
amount: MoneyString
percentage: MoneyString | None
class MetaResponse(BaseModel):
name: str = 'Finance Dashboard'
api_version: str = 'v1'
database: Literal['ok'] = 'ok'