cteward-ng/cteward_ng/app.py

185 lines
5.2 KiB
Python
Raw Permalink Normal View History

2026-06-06 10:18:15 +02:00
"""Flask application factory and middleware setup."""
import base64
2026-06-06 12:04:59 +02:00
import json
2026-06-06 10:18:15 +02:00
import logging
2026-06-06 12:04:59 +02:00
import os
import socket
from datetime import datetime, timezone
2026-06-06 10:18:15 +02:00
from logging.handlers import RotatingFileHandler
from flask import Flask, request, Response
from flask_cors import CORS
from flask_compress import Compress
2026-06-08 20:33:47 +02:00
from config import load_config
2026-06-08 23:13:38 +02:00
import database as db
2026-06-06 10:18:15 +02:00
2026-06-06 12:04:59 +02:00
# Bunyan level mapping
_BUNYAN_LEVELS = {
logging.DEBUG: 20,
logging.INFO: 30,
logging.WARNING: 40,
logging.ERROR: 50,
logging.CRITICAL: 60,
}
# Reverse mapping: string name → Python level
_LEVEL_NAMES = {
'trace': logging.DEBUG,
'debug': logging.DEBUG,
'info': logging.INFO,
'warn': logging.WARNING,
'error': logging.ERROR,
'fatal': logging.CRITICAL,
}
class BunyanFormatter(logging.Formatter):
"""Produce bunyan-style JSON log lines.
Each log line is a single JSON object with keys:
name, hostname, pid, level, msg, time, v
Matches the format expected by the existing test suite.
"""
def __init__(self):
super().__init__()
self.hostname = socket.gethostname()
self.pid = os.getpid()
def format(self, record):
log_entry = {
'name': 'cteward-st-lexware',
'hostname': self.hostname,
'pid': self.pid,
'level': _BUNYAN_LEVELS.get(record.levelno, 30),
'msg': record.getMessage(),
'time': datetime.now(timezone.utc).isoformat(),
'v': 0,
}
# Attach request context if available
if hasattr(record, 'username'):
log_entry['username'] = record.username
if hasattr(record, 'method'):
log_entry['method'] = record.method
if hasattr(record, 'url'):
log_entry['url'] = record.url
return json.dumps(log_entry)
2026-06-06 10:18:15 +02:00
def create_app(config_path=None):
"""Create and configure the Flask application.
Replaces the restify server + middleware chain from startup.js.
"""
app = Flask(__name__)
app.config.from_mapping(
SECRET_KEY='cteward-st-lexware',
)
# Load runtime config (JSON file)
app.cteward_config = load_config(config_path)
_setup_logging(app)
2026-06-08 23:13:38 +02:00
# Initialize database with MSSQL config (after logging so db logs work)
_init_database(app)
2026-06-06 10:18:15 +02:00
_setup_extensions(app)
_register_prehandlers(app)
_register_blueprints(app)
return app
2026-06-08 23:13:38 +02:00
def _init_database(app):
"""Initialize the database connection pool from config.
Mirrors database.init(config.mssql) from startup.js.
"""
mssql_config = app.cteward_config.get('mssql', {})
# Get logger from app, or use module-level logger as fallback
logger = getattr(app, 'logger', None)
if logger is None:
import logging
logger = logging.getLogger('cteward_ng.database')
try:
db.init(mssql_config)
logger.info("Database initialized successfully")
except Exception as exc:
logger.error("Failed to initialize database: %s", exc)
raise
2026-06-06 10:18:15 +02:00
def _setup_logging(app):
"""Setup structured JSON logging similar to bunyan."""
2026-06-06 12:04:59 +02:00
log_level_name = app.cteward_config.get('loglevel', 'info').lower()
2026-06-06 10:18:15 +02:00
logfile = app.cteward_config.get('logfile')
2026-06-06 12:04:59 +02:00
log_level = _LEVEL_NAMES.get(log_level_name, logging.INFO)
2026-06-08 23:13:38 +02:00
log_level = "DEBUG"
2026-06-06 12:04:59 +02:00
2026-06-06 10:18:15 +02:00
handler = (
RotatingFileHandler(logfile)
if logfile
else logging.StreamHandler()
)
2026-06-06 12:04:59 +02:00
handler.setLevel(log_level)
handler.setFormatter(BunyanFormatter())
2026-06-06 10:18:15 +02:00
app.logger.handlers.clear()
app.logger.addHandler(handler)
2026-06-06 12:04:59 +02:00
app.logger.setLevel(log_level)
2026-06-06 10:18:15 +02:00
def _setup_extensions(app):
"""Enable CORS and gzip compression."""
CORS(app)
Compress(app)
def _extract_basic_username(headers):
"""Extract username from Basic auth header for logging."""
auth_header = headers.get('Authorization', '')
if not auth_header:
return 'anonymous'
parts = auth_header.split(' ', 1)
if len(parts) == 2 and parts[0] == 'Basic':
try:
decoded = base64.b64decode(parts[1]).decode('utf-8')
return decoded.split(':')[0]
except Exception:
return 'anonymous'
return 'anonymous'
def _register_prehandlers(app):
"""Before-request hooks: extract username for logging."""
@app.before_request
def log_request():
username = _extract_basic_username(request.headers)
2026-06-06 12:04:59 +02:00
extra = {'username': username, 'method': request.method, 'url': request.url}
# Attach extra fields to the log record so BunyanFormatter picks them up
app.logger.info(
'%s %s %s', username, request.method, request.url,
extra=extra,
)
2026-06-06 10:18:15 +02:00
@app.after_request
def www_authenticate(response):
"""Add WWW-Authenticate header when no auth is present."""
if 'Authorization' not in request.headers:
realm = app.cteward_config.get('server', {}).get(
'realm', 'cteward API access'
)
response.headers['WWW-Authenticate'] = f'Basic realm="{realm}"'
return response
def _register_blueprints(app):
"""Register all route blueprints."""
2026-06-08 20:33:47 +02:00
from views import legacy_bp
2026-06-06 10:18:15 +02:00
app.register_blueprint(legacy_bp, url_prefix='/legacy')