Source code for pain001.api.ui

# Copyright (C) 2023-2026 Pain001. All rights reserved.
# SPDX-License-Identifier: Apache-2.0 OR MIT
#
# Licensed under either of the Apache License, Version 2.0 or the MIT
# License, at your option. You may not use this file except in
# compliance with one of those licences. Copies are provided in
# LICENSE-APACHE and LICENSE-MIT.
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the Licences is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
# implied. See the applicable Licence for the specific language
# governing permissions and limitations.

"""Single-file hosted dashboard at ``/api/v1/ui`` (issue #188).

A finance user without Python or a terminal opens one URL, drops a
CSV on the page, picks a message type and the scheme rulebooks to
enforce, and downloads the validated XML. The page is one vanilla HTML
document with inline CSS and JavaScript: no framework, no build step,
no CDN, no new runtime dependency. It talks to two JSON endpoints that
sit next to it:

* ``POST /api/v1/ui/validate`` - the file's text plus options in, the
  same :class:`~pain001.api.models.ValidationResponse` the path-based
  ``/validate`` returns out.
* ``POST /api/v1/ui/generate`` - the same input; the XML document comes
  back inline so the browser can offer it as a download without the
  server ever writing the batch to disk.

Both endpoints carry the file *content* rather than a path because a
browser cannot name a file on the server, which is what the path-based
endpoints expect. They honour ``PAIN001_API_KEY`` like every other
route; the page keeps the key in ``sessionStorage`` for the tab's
lifetime only. Operators running a pure-API deployment can switch the
whole surface off with ``PAIN001_UI_DISABLED=1``.
"""

from __future__ import annotations

import csv
import io
import json
import os
from typing import Any

from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import HTMLResponse

from pain001 import __version__
from pain001.api.auth import require_api_key
from pain001.api.guards import sanitise_message_type
from pain001.api.models import (
    UiFilePayload,
    UiGenerateResponse,
    ValidationError,
    ValidationResponse,
)
from pain001.constants import TEMPLATES_DIR, valid_xml_types
from pain001.data.loader import load_payment_data
from pain001.exceptions import DataSourceError, PaymentValidationError
from pain001.observability.otel import traced
from pain001.validation import validate_scheme
from pain001.validation.schema_validator import SchemaValidator
from pain001.validation.schemes import PROFILES
from pain001.xml.generate_xml import generate_xml_string

UI_DISABLED_ENV = "PAIN001_UI_DISABLED"
"""Set to ``1`` / ``true`` / ``yes`` to take the dashboard offline."""

MAX_UPLOAD_BYTES = 10 * 1024 * 1024
"""Largest file the dashboard accepts, in UTF-8 bytes (10 MiB)."""

SUPPORTED_SUFFIXES: tuple[str, ...] = (".csv", ".json", ".jsonl")
"""Text formats the dashboard parses in memory."""


[docs] def is_ui_enabled() -> bool: """Return ``True`` unless the operator set ``PAIN001_UI_DISABLED``. Returns: ``False`` when the env var is one of ``1`` / ``true`` / ``yes`` (case-insensitive); ``True`` otherwise. """ return os.environ.get(UI_DISABLED_ENV, "").lower() not in { "1", "true", "yes", }
def _require_ui_enabled() -> None: """Dependency that hides the dashboard when it is switched off. Raises: HTTPException: ``404`` when ``PAIN001_UI_DISABLED`` is set. """ if not is_ui_enabled(): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="The dashboard is disabled (PAIN001_UI_DISABLED).", ) ui_router = APIRouter( prefix="/ui", tags=["UI"], dependencies=[Depends(_require_ui_enabled)], ) _BAD_REQUEST = status.HTTP_400_BAD_REQUEST def _parse_rows(filename: str, content: str) -> list[dict[str, Any]]: """Turn the uploaded text into validated payment rows. Mirrors the file loaders: CSV through :class:`csv.DictReader`, JSON as a list or a single object, JSON Lines one object per line, then the same row validation the path-based loaders apply. Args: filename: The name the browser reported; only its suffix is used. content: The file's text. Returns: The validated rows. Raises: HTTPException: ``400`` for an unsupported suffix, malformed text, or rows that fail validation; ``413`` when the text exceeds :data:`MAX_UPLOAD_BYTES`. """ suffix = os.path.splitext(filename.lower())[1] if suffix not in SUPPORTED_SUFFIXES: raise HTTPException( status_code=_BAD_REQUEST, detail=f"Unsupported file type {suffix or '(none)'!r}; upload a " f"{', '.join(SUPPORTED_SUFFIXES)} file.", ) if len(content.encode("utf-8")) > MAX_UPLOAD_BYTES: raise HTTPException( status_code=status.HTTP_413_CONTENT_TOO_LARGE, detail=( f"File exceeds {MAX_UPLOAD_BYTES // (1024 * 1024)} MiB; " "use the CLI or the path-based API for batches this size." ), ) rows: list[Any] if suffix == ".csv": rows = list(csv.DictReader(io.StringIO(content))) elif suffix == ".json": try: parsed = json.loads(content) except json.JSONDecodeError as exc: raise HTTPException( status_code=_BAD_REQUEST, detail=f"Invalid JSON: {exc.msg}" ) from exc if isinstance(parsed, dict): rows = [parsed] elif isinstance(parsed, list): rows = parsed else: raise HTTPException( status_code=_BAD_REQUEST, detail="JSON input must be an object or an array of objects.", ) else: rows = [] for number, line in enumerate(content.splitlines(), start=1): if not line.strip(): continue try: rows.append(json.loads(line)) except json.JSONDecodeError as exc: raise HTTPException( status_code=_BAD_REQUEST, detail=f"Invalid JSON on line {number}: {exc.msg}", ) from exc if not rows: raise HTTPException( status_code=_BAD_REQUEST, detail="The file has no payment rows." ) if not all(isinstance(row, dict) for row in rows): raise HTTPException( status_code=_BAD_REQUEST, detail="Every payment row must be a JSON object.", ) try: return load_payment_data(rows) except (DataSourceError, PaymentValidationError) as exc: raise HTTPException(status_code=_BAD_REQUEST, detail=str(exc)) from exc def _format_errors( errors: list[tuple[int, list[Any]]], ) -> list[ValidationError]: """Flatten ``SchemaValidator.validate_batch`` errors into API models. Args: errors: ``(row_index, error_list)`` tuples for invalid rows. Returns: One :class:`ValidationError` per field error. """ return [ ValidationError( field=error.path, message=error.message, value=str(error.value), ) for _, row_errors in errors for error in row_errors ] def _scheme_violations( rows: list[dict[str, Any]], scheme: str | None ) -> tuple[bool, list[dict[str, Any]]]: """Run the requested scheme rulebooks, if any. Args: rows: Validated payment rows. scheme: Profile spec (one name, or comma-separated names), or ``None`` to skip scheme validation. Returns: ``(is_valid, violations)``; ``(True, [])`` when no scheme. Raises: HTTPException: ``400`` for an unknown profile name. """ if not scheme: return True, [] try: result = validate_scheme(rows, scheme) except ValueError as exc: raise HTTPException(status_code=_BAD_REQUEST, detail=str(exc)) from exc return result.is_valid, [v.as_dict() for v in result.violations]
[docs] @ui_router.post( "/validate", response_model=ValidationResponse, summary="Validate an uploaded file (dashboard)", dependencies=[Depends(require_api_key)], ) @traced( "pain001.api.ui.validate", attributes={"http.route": "/api/v1/ui/validate"} ) async def ui_validate(payload: UiFilePayload) -> ValidationResponse: """Validate the uploaded text against the schema and optional schemes. Args: payload: The file's text, its name, the message type, and an optional scheme spec. Returns: The same shape as ``POST /validate``. """ rows = _parse_rows(payload.filename, payload.content) validator = SchemaValidator( sanitise_message_type(payload.message_type.value) ) total, valid, errors = validator.validate_batch(rows) scheme_ok, violations = _scheme_violations(rows, payload.scheme) return ValidationResponse( is_valid=not errors and scheme_ok, total_rows=total, valid_rows=valid, invalid_rows=total - valid, errors=_format_errors(errors), scheme_violations=violations, )
[docs] @ui_router.post( "/generate", response_model=UiGenerateResponse, summary="Generate XML from an uploaded file (dashboard)", dependencies=[Depends(require_api_key)], ) @traced( "pain001.api.ui.generate", attributes={"http.route": "/api/v1/ui/generate"} ) async def ui_generate(payload: UiFilePayload) -> UiGenerateResponse: """Validate the upload, then render the XML and return it inline. Args: payload: The file's text, its name, the message type, and an optional scheme spec. Returns: ``success=True`` with the XML document and a suggested filename, or ``success=False`` carrying the validation errors or scheme violations that blocked generation. Raises: HTTPException: ``400`` when rendering fails (for example the rendered document does not satisfy the XSD). """ rows = _parse_rows(payload.filename, payload.content) # Allow-list barrier: the enum is already constrained, but the value # is joined into filesystem paths below and static analysis only # recognises an explicit membership check as a sanitiser. message_type = sanitise_message_type(payload.message_type.value) validator = SchemaValidator(message_type) total, valid, errors = validator.validate_batch(rows) if errors: return UiGenerateResponse( success=False, message=f"Validation failed: {valid}/{total} rows valid", validation_errors=_format_errors(errors), ) scheme_ok, violations = _scheme_violations(rows, payload.scheme) if not scheme_ok: return UiGenerateResponse( success=False, message=f"Scheme '{payload.scheme}' validation failed", scheme_violations=violations, ) template_base = TEMPLATES_DIR / message_type try: xml = generate_xml_string( rows, message_type, str(template_base / "template.xml"), str(template_base / f"{message_type}.xsd"), ) except (ValueError, RuntimeError, PaymentValidationError) as exc: raise HTTPException( status_code=_BAD_REQUEST, detail=f"Generation failed: {exc}" ) from exc return UiGenerateResponse( success=True, message=f"Generated {message_type} for {total} payment rows", filename=f"{message_type}.xml", xml=xml, )
[docs] @ui_router.get("", include_in_schema=False, response_class=HTMLResponse) async def ui_page() -> HTMLResponse: """Serve the dashboard page. Returns: The single-file HTML application, with the supported message types and scheme profiles of this build embedded. """ page = ( _UI_HTML.replace("__VERSION__", __version__) .replace("__MESSAGE_TYPES__", json.dumps(list(valid_xml_types))) .replace("__SCHEMES__", json.dumps(list(PROFILES))) ) return HTMLResponse(page)
_UI_HTML = """\ <!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Pain001 dashboard</title> <style> :root { --bg: #f7f7f5; --fg: #1d1d1b; --muted: #6b6b66; --line: #d9d9d4; --card: #ffffff; --accent: #0b5cad; --ok: #1a7f37; --bad: #b42318; --warn: #9a6700; color-scheme: light dark; } @media (prefers-color-scheme: dark) { :root { --bg: #161614; --fg: #ecece8; --muted: #a3a39c; --line: #35352f; --card: #1f1f1c; --accent: #6cb4ff; --ok: #57c078; --bad: #ff7a6e; --warn: #e2b53e; } } * { box-sizing: border-box; } body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; } main { max-width: 760px; margin: 0 auto; padding: 32px 16px 64px; } h1 { font-size: 1.5rem; margin: 0 0 4px; } h1 small { font-weight: 400; color: var(--muted); font-size: 0.9rem; } .badge { display: inline-block; font-size: 0.7rem; text-transform: uppercase; letter-spacing: .06em; border: 1px solid var(--warn); color: var(--warn); border-radius: 999px; padding: 1px 8px; vertical-align: middle; } p.lead { color: var(--muted); margin: 0 0 24px; } section { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 20px; margin-bottom: 16px; } label { display: block; font-weight: 600; margin-bottom: 6px; } .hint { color: var(--muted); font-weight: 400; font-size: 0.85rem; } input[type=password], select { width: 100%; padding: 8px 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--bg); color: var(--fg); font: inherit; } #drop { border: 2px dashed var(--line); border-radius: 10px; padding: 28px; text-align: center; cursor: pointer; transition: border-color .15s; } #drop.over, #drop:focus-within { border-color: var(--accent); } #drop input { position: absolute; width: 1px; height: 1px; opacity: 0; } #filename { font-weight: 600; margin-top: 8px; } .row { display: flex; gap: 16px; flex-wrap: wrap; } .row > div { flex: 1 1 240px; } fieldset { border: 0; padding: 0; margin: 0; } fieldset legend { font-weight: 600; margin-bottom: 6px; } .schemes { display: flex; flex-wrap: wrap; gap: 6px 16px; } .schemes label { font-weight: 400; display: flex; gap: 6px; align-items: center; margin: 0; } .actions { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; } button { font: inherit; font-weight: 600; padding: 9px 16px; border-radius: 6px; border: 1px solid var(--accent); background: var(--accent); color: #fff; cursor: pointer; } button.secondary { background: transparent; color: var(--accent); } button:disabled { opacity: .5; cursor: not-allowed; } #status { margin: 0; min-height: 1.5em; } #status.ok { color: var(--ok); } #status.bad { color: var(--bad); } table { width: 100%; border-collapse: collapse; font-size: 0.9rem; margin-top: 12px; } th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--line); vertical-align: top; } th { color: var(--muted); font-weight: 600; } .wrap { overflow-x: auto; } code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.85em; } footer { color: var(--muted); font-size: 0.85rem; margin-top: 24px; } footer a { color: var(--accent); } </style> </head> <body> <main> <h1>Pain001 <small>v__VERSION__</small> <span class="badge">beta</span></h1> <p class="lead">Drop a payment file, pick the message type and rulebooks, validate, and download the ISO 20022 XML. Nothing is stored on the server.</p> <section> <label for="apikey">API key <span class="hint">(only if the server sets <code>PAIN001_API_KEY</code>; kept in this tab only)</span></label> <input id="apikey" type="password" autocomplete="off" placeholder="Bearer token"> </section> <section> <div id="drop" tabindex="0" role="button" aria-label="Choose or drop a payment file"> <input id="file" type="file" accept=".csv,.json,.jsonl,text/csv,application/json"> <div>Drop a <strong>.csv</strong>, <strong>.json</strong> or <strong>.jsonl</strong> file here, or click to choose</div> <div id="filename" aria-live="polite"></div> </div> </section> <section> <div class="row"> <div> <label for="mt">Message type</label> <select id="mt"></select> </div> <div> <fieldset> <legend>Scheme rulebooks <span class="hint">(optional, combine freely)</span></legend> <div class="schemes" id="schemes"></div> </fieldset> </div> </div> </section> <section> <div class="actions"> <button id="validate" type="button" disabled>Validate</button> <button id="generate" type="button" disabled>Generate</button> <button id="download" type="button" class="secondary" hidden>Download XML</button> <p id="status" role="status" aria-live="polite"></p> </div> <div id="results" class="wrap"></div> </section> <footer>Interactive API reference at <a href="/api/reference">/api/reference</a>. Larger batches: use the CLI or the path-based <code>/api/v1/generate</code>.</footer> </main> <script> (function () { 'use strict'; var MESSAGE_TYPES = __MESSAGE_TYPES__; var SCHEMES = __SCHEMES__; var base = location.pathname.replace(/\\/+$/, ''); var $ = function (id) { return document.getElementById(id); }; var fileInput = $('file'), drop = $('drop'), status = $('status'), results = $('results'); var validateBtn = $('validate'), generateBtn = $('generate'), downloadBtn = $('download'); var current = null, downloadUrl = null; MESSAGE_TYPES.forEach(function (mt) { var o = document.createElement('option'); o.value = mt; o.textContent = mt; $('mt').appendChild(o); }); SCHEMES.forEach(function (name) { var l = document.createElement('label'); var c = document.createElement('input'); c.type = 'checkbox'; c.value = name; c.name = 'scheme'; l.appendChild(c); l.appendChild(document.createTextNode(name)); $('schemes').appendChild(l); }); try { $('apikey').value = sessionStorage.getItem('pain001.apikey') || ''; } catch (e) {} $('apikey').addEventListener('input', function () { try { sessionStorage.setItem('pain001.apikey', this.value); } catch (e) {} }); function setStatus(text, kind) { status.textContent = text; status.className = kind || ''; } function escapeHtml(s) { return String(s).replace(/[&<>"']/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]; }); } function takeFile(file) { if (!file) { return; } var reader = new FileReader(); reader.onload = function () { current = { filename: file.name, content: reader.result }; $('filename').textContent = file.name + ' (' + file.size.toLocaleString() + ' bytes)'; validateBtn.disabled = generateBtn.disabled = false; downloadBtn.hidden = true; results.innerHTML = ''; setStatus(''); }; reader.readAsText(file); } fileInput.addEventListener('change', function () { takeFile(this.files[0]); }); drop.addEventListener('click', function (e) { if (e.target !== fileInput) { fileInput.click(); } }); drop.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileInput.click(); } }); ['dragenter', 'dragover'].forEach(function (ev) { drop.addEventListener(ev, function (e) { e.preventDefault(); drop.classList.add('over'); }); }); ['dragleave', 'drop'].forEach(function (ev) { drop.addEventListener(ev, function (e) { e.preventDefault(); drop.classList.remove('over'); }); }); drop.addEventListener('drop', function (e) { takeFile(e.dataTransfer.files[0]); }); function selectedSchemes() { return Array.prototype.map.call(document.querySelectorAll('input[name=scheme]:checked'), function (c) { return c.value; }).join(','); } function payload() { return { filename: current.filename, content: current.content, message_type: $('mt').value, scheme: selectedSchemes() || null }; } function call(path) { var headers = { 'Content-Type': 'application/json' }; var key = $('apikey').value; if (key) { headers.Authorization = 'Bearer ' + key; } return fetch(base + path, { method: 'POST', headers: headers, body: JSON.stringify(payload()) }) .then(function (r) { return r.json().then(function (body) { return { ok: r.ok, status: r.status, body: body }; }); }); } function renderFindings(errors, violations) { var html = ''; if (errors && errors.length) { html += '<table><thead><tr><th>Field</th><th>Problem</th><th>Value</th></tr></thead><tbody>'; errors.forEach(function (e) { html += '<tr><td><code>' + escapeHtml(e.field) + '</code></td><td>' + escapeHtml(e.message) + '</td><td><code>' + escapeHtml(e.value == null ? '' : e.value) + '</code></td></tr>'; }); html += '</tbody></table>'; } if (violations && violations.length) { html += '<table><thead><tr><th>Row</th><th>Rule</th><th>Field</th><th>Message</th><th>Fix</th></tr></thead><tbody>'; violations.forEach(function (v) { html += '<tr><td>' + escapeHtml(v.index) + '</td><td><code>' + escapeHtml(v.rule) + '</code></td><td><code>' + escapeHtml(v.field || '') + '</code></td><td>' + escapeHtml(v.message) + '</td><td>' + escapeHtml(v.remediation || '') + '</td></tr>'; }); html += '</tbody></table>'; } results.innerHTML = html; } function busy(on) { validateBtn.disabled = generateBtn.disabled = on; } validateBtn.addEventListener('click', function () { busy(true); setStatus('Validating…'); downloadBtn.hidden = true; call('/validate').then(function (r) { if (!r.ok) { setStatus(r.body.detail || ('HTTP ' + r.status), 'bad'); results.innerHTML = ''; return; } var b = r.body; renderFindings(b.errors, b.scheme_violations); setStatus(b.is_valid ? 'Valid: ' + b.valid_rows + ' of ' + b.total_rows + ' rows pass.' : 'Invalid: ' + b.invalid_rows + ' of ' + b.total_rows + ' rows fail' + (b.scheme_violations.length ? ', ' + b.scheme_violations.length + ' scheme violation(s).' : '.'), b.is_valid ? 'ok' : 'bad'); }).catch(function (e) { setStatus('Request failed: ' + e, 'bad'); }).then(function () { busy(false); }); }); generateBtn.addEventListener('click', function () { busy(true); setStatus('Generating…'); downloadBtn.hidden = true; call('/generate').then(function (r) { if (!r.ok) { setStatus(r.body.detail || ('HTTP ' + r.status), 'bad'); results.innerHTML = ''; return; } var b = r.body; renderFindings(b.validation_errors, b.scheme_violations); if (!b.success) { setStatus(b.message, 'bad'); return; } if (downloadUrl) { URL.revokeObjectURL(downloadUrl); } downloadUrl = URL.createObjectURL(new Blob([b.xml], { type: 'application/xml' })); downloadBtn.hidden = false; downloadBtn.dataset.name = b.filename; setStatus(b.message + '. Ready to download.', 'ok'); }).catch(function (e) { setStatus('Request failed: ' + e, 'bad'); }).then(function () { busy(false); }); }); downloadBtn.addEventListener('click', function () { var a = document.createElement('a'); a.href = downloadUrl; a.download = downloadBtn.dataset.name || 'pain001.xml'; document.body.appendChild(a); a.click(); a.remove(); }); })(); </script> </body> </html> """