Files
Juan Denis 55d8fb1e44 Read route bodies through json_object()
328 more routes used a JSON body as a dict straight from
request.get_json(), so a body of null, [] or a bare string crashed them
into a 500. Bare reads become json_object(); the
'get_json(silent=True) or {}' reads of action routes become
json_object(optional=True), which still treats a missing body as {}.
Routes that take arrays or pass the body through untouched are left
alone. The full backend suite shows no new failures.

Also carries three files' bare section citations (apps, backups,
notifications) and the extended citation guard, which needed them.
2026-10-10 20:53:43 -04:00

77 lines
3.1 KiB
Python

# Bucket: PER-APP read. Per-app bandwidth is scoped to callers who
# can access the app (can_access_app); the cross-app overview stays panel-wide.
"""REST surface for per-domain bandwidth accounting.
Mounted at /api/v1/bandwidth (registered in app/__init__.py).
"""
from flask import Blueprint, jsonify, request
from app.middleware.rbac import get_current_user
from app.error_reporting import unexpected_response
from app.exceptions import NotFoundError
from ..middleware.rbac import admin_required, viewer_required
from ..models import Application
from ..services.bandwidth_service import BandwidthService
from ..services.request_metrics_service import RequestMetricsService
from ..services.resource_grant_service import ResourceGrantService
from app.api._request import json_object
bandwidth_bp = Blueprint('bandwidth', __name__)
@bandwidth_bp.route('/apps', methods=['GET'])
@viewer_required
def get_apps_bandwidth():
"""Month totals + 30-day sparkline series for every app with traffic —
one call for the Services list."""
data = BandwidthService.overview(days=30)
# JSON object keys must be strings.
return jsonify({'apps': {str(k): v for k, v in data.items()}})
@bandwidth_bp.route('/apps/<int:app_id>', methods=['GET'])
@viewer_required
def get_app_bandwidth(app_id):
"""Full daily series (default 90 days) + current-month total for one app,
scoped to the app's workspace visibility (foreign caller 404)."""
app = Application.query_active().filter_by(id=app_id).first()
user = get_current_user()
if app is None or not ResourceGrantService.can_access_app(user, app):
return jsonify({'error': 'Not found'}), 404
days = request.args.get('days', 90, type=int)
series = BandwidthService.series(app_id=app_id, days=days)
return jsonify({
'app_id': app_id,
'days': len(series),
'series': series,
'month_bytes': BandwidthService.monthly_total(app_id),
})
@bandwidth_bp.route('/apps/<int:app_id>/requests', methods=['GET'])
@viewer_required
def get_app_request_metrics(app_id):
"""Request rate, status classes, latency percentiles and cache hit ratio
from the timed access log. ``period``: 1h, 24h, 7d or 30d."""
# Same 404 for a missing and a foreign app; a bad period is a
# ValidationError (400). Both go through the global handler.
app = RequestMetricsService.live_app(app_id)
if app is None or not ResourceGrantService.can_access_app(get_current_user(), app):
raise NotFoundError('Not found')
return jsonify(RequestMetricsService.series(
app_id, period=request.args.get('period', '24h')))
@bandwidth_bp.route('/aggregate', methods=['POST'])
@admin_required
def run_aggregate():
"""Run the daily aggregation now (optionally for a specific day)."""
try:
payload = json_object(optional=True)
result = BandwidthService.aggregate(day=payload.get('day'))
return jsonify(result)
except ValueError:
return jsonify({'error': 'day must be YYYY-MM-DD'}), 400
except Exception as exc: # noqa: BLE001 - reported, not swallowed
return unexpected_response(exc)