"""Flask application factory and middleware setup.""" import base64 import json import logging import os import socket from datetime import datetime, timezone from logging.handlers import RotatingFileHandler from flask import Flask, request, Response from flask_cors import CORS from flask_compress import Compress from config import load_config import database as db # 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) 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) # Initialize database with MSSQL config (after logging so db logs work) _init_database(app) _setup_extensions(app) _register_prehandlers(app) _register_blueprints(app) return app 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 def _setup_logging(app): """Setup structured JSON logging similar to bunyan.""" log_level_name = app.cteward_config.get('loglevel', 'info').lower() logfile = app.cteward_config.get('logfile') log_level = _LEVEL_NAMES.get(log_level_name, logging.INFO) log_level = "DEBUG" handler = ( RotatingFileHandler(logfile) if logfile else logging.StreamHandler() ) handler.setLevel(log_level) handler.setFormatter(BunyanFormatter()) app.logger.handlers.clear() app.logger.addHandler(handler) app.logger.setLevel(log_level) 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) 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, ) @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.""" from views import legacy_bp app.register_blueprint(legacy_bp, url_prefix='/legacy')