← Files VeraARCHIVED FILE

modules/bilancio-xbrl-it/scripts/http_api.py

21.1 KB · Oct 2, 2026 · 00:29 UTC

↓ Download file

#!/usr/bin/env python3
"""Optional authenticated FastAPI adapter for the Bilancio case service."""

from __future__ import annotations

from collections.abc import Callable
from typing import Any, Mapping

from access_control import RequestContext
from artifact_delivery import ExpiredDownloadGrant
from case_service import CaseService
from fastapi import Body, FastAPI, Header, HTTPException, Request
from fastapi.responses import JSONResponse, Response

__all__ = ["create_app"]


def create_app(
    service: CaseService,
    rule_pack: Mapping[str, Any],
    context_provider: Callable[[Request], RequestContext] | None = None,
    disclosure_rule_pack: Mapping[str, Any] | None = None,
) -> FastAPI:
    """Create the optional HTTP adapter around one configured service instance."""

    app = FastAPI(title="Vera Bilancio intelligente API", version="1.0")

    def context(request: Request) -> RequestContext:
        resolved = (
            context_provider(request)
            if context_provider is not None
            else getattr(request.state, "vera_request_context", None)
        )
        if not isinstance(resolved, RequestContext):
            raise HTTPException(
                status_code=401,
                detail="Authenticated Vera request context is required",
            )
        return resolved

    def revision(value: str) -> str:
        normalized = value.strip()
        if normalized.startswith("W/"):
            normalized = normalized[2:].strip()
        normalized = normalized.strip('"')
        if not normalized:
            raise ValueError("If-Match must contain the current revision_id")
        return normalized

    def mutate(
        request: Request,
        case_id: str,
        operation: str,
        payload: Mapping[str, Any],
        if_match: str,
        idempotency_key: str,
    ) -> dict[str, Any]:
        return service.mutate(
            context(request),
            case_id,
            operation,
            payload,
            revision(if_match),
            idempotency_key,
        )

    @app.exception_handler(PermissionError)
    async def permission_error(_request: Request, exc: PermissionError):
        return JSONResponse(status_code=403, content={"detail": str(exc)})

    @app.exception_handler(FileNotFoundError)
    async def missing_resource(_request: Request, exc: FileNotFoundError):
        return JSONResponse(status_code=404, content={"detail": str(exc)})

    @app.exception_handler(ValueError)
    async def invalid_request(_request: Request, exc: ValueError):
        status = 409 if "Stale revision" in str(exc) else 422
        return JSONResponse(status_code=status, content={"detail": str(exc)})

    @app.exception_handler(ExpiredDownloadGrant)
    async def expired_download_grant(_request: Request, exc: ExpiredDownloadGrant):
        return JSONResponse(status_code=410, content={"detail": str(exc)})

    @app.post("/v1/xbrl-cases", status_code=201)
    def create_case_resource(
        request: Request,
        payload: dict[str, Any] = Body(...),
        idempotency_key: str = Header(..., alias="Idempotency-Key"),
    ) -> dict[str, Any]:
        if "rule_pack" in payload:
            raise ValueError(
                "Statutory rule packs are deployment configuration, not request data"
            )
        case_payload = payload.get("payload", payload)
        return service.create(
            context(request), case_payload, rule_pack, idempotency_key
        )

    @app.get("/v1/xbrl-cases/{case_id}")
    def get_case_resource(request: Request, case_id: str) -> Any:
        return service.get(context(request), case_id)

    @app.post("/v1/xbrl-cases/{case_id}/archive")
    def archive_case_resource(
        request: Request,
        case_id: str,
        payload: dict[str, Any] = Body(...),
        if_match: str = Header(..., alias="If-Match"),
        idempotency_key: str = Header(..., alias="Idempotency-Key"),
    ) -> dict[str, Any]:
        return mutate(
            request,
            case_id,
            "archive",
            {"reason": payload["reason"]},
            if_match,
            idempotency_key,
        )

    @app.delete("/v1/xbrl-cases/{case_id}")
    def delete_case_resource(
        request: Request,
        case_id: str,
        payload: dict[str, Any] = Body(...),
        if_match: str = Header(..., alias="If-Match"),
        idempotency_key: str = Header(..., alias="Idempotency-Key"),
    ) -> dict[str, Any]:
        return service.delete_archived_case(
            context(request),
            case_id,
            revision(if_match),
            idempotency_key,
            reason=str(payload["reason"]),
        )

    def ingest_resource(
        request: Request,
        case_id: str,
        payload: Mapping[str, Any],
        if_match: str,
        idempotency_key: str,
    ) -> dict[str, Any]:
        kind = str(payload.get("document_kind", "TRIAL_BALANCE")).upper()
        if kind == "PRIOR_XBRL":
            operation = "ingest_prior_xbrl"
        elif kind == "PDF_TRIAL_BALANCE":
            operation = "ingest_pdf"
        elif kind == "TRIAL_BALANCE":
            operation = "ingest"
        else:
            operation = "attach_supporting_document"
        operation_payload = {
            "source_path": payload["source_path"],
            "sheet": payload.get("sheet"),
            "ocr_enabled": payload.get("ocr_enabled", True),
            "ocr_language": payload.get("ocr_language", "it"),
        }
        if operation == "attach_supporting_document":
            operation_payload.update(
                {
                    "purpose": kind,
                    "description": payload["description"],
                }
            )
        return mutate(
            request,
            case_id,
            operation,
            operation_payload,
            if_match,
            idempotency_key,
        )

    @app.post("/v1/xbrl-cases/{case_id}/documents")
    def create_document_resource(
        request: Request,
        case_id: str,
        payload: dict[str, Any] = Body(...),
        if_match: str = Header(..., alias="If-Match"),
        idempotency_key: str = Header(..., alias="Idempotency-Key"),
    ) -> dict[str, Any]:
        return ingest_resource(request, case_id, payload, if_match, idempotency_key)

    @app.post("/v1/xbrl-cases/{case_id}/ingest")
    def ingest_case_resource(
        request: Request,
        case_id: str,
        payload: dict[str, Any] = Body(...),
        if_match: str = Header(..., alias="If-Match"),
        idempotency_key: str = Header(..., alias="Idempotency-Key"),
    ) -> dict[str, Any]:
        return ingest_resource(request, case_id, payload, if_match, idempotency_key)

    @app.post("/v1/xbrl-cases/{case_id}/confirm-parser")
    def confirm_parser_resource(
        request: Request,
        case_id: str,
        payload: dict[str, Any] = Body(...),
        if_match: str = Header(..., alias="If-Match"),
        idempotency_key: str = Header(..., alias="Idempotency-Key"),
    ) -> dict[str, Any]:
        return mutate(
            request,
            case_id,
            "confirm_parser",
            {"convention": payload["convention"]},
            if_match,
            idempotency_key,
        )

    @app.post("/v1/xbrl-cases/{case_id}/review-pdf-extraction")
    def review_pdf_extraction_resource(
        request: Request,
        case_id: str,
        payload: dict[str, Any] = Body(...),
        if_match: str = Header(..., alias="If-Match"),
        idempotency_key: str = Header(..., alias="Idempotency-Key"),
    ) -> dict[str, Any]:
        return mutate(
            request,
            case_id,
            "review_pdf_extraction",
            payload,
            if_match,
            idempotency_key,
        )

    @app.post("/v1/xbrl-cases/{case_id}/determine-forms")
    def determine_forms_resource(
        request: Request,
        case_id: str,
        payload: dict[str, Any] = Body(...),
        if_match: str = Header(..., alias="If-Match"),
        idempotency_key: str = Header(..., alias="Idempotency-Key"),
    ) -> dict[str, Any]:
        return mutate(
            request,
            case_id,
            "determine_forms",
            {"metrics": payload["metrics"], "rule_pack": rule_pack},
            if_match,
            idempotency_key,
        )

    @app.post("/v1/xbrl-cases/{case_id}/regulatory-migrations")
    def migrate_regulatory_versions_resource(
        request: Request,
        case_id: str,
        payload: dict[str, Any] = Body(...),
        if_match: str = Header(..., alias="If-Match"),
        idempotency_key: str = Header(..., alias="Idempotency-Key"),
    ) -> dict[str, Any]:
        return mutate(
            request,
            case_id,
            "migrate_regulatory_versions",
            payload,
            if_match,
            idempotency_key,
        )

    @app.post("/v1/xbrl-cases/{case_id}/select-form")
    def select_form_resource(
        request: Request,
        case_id: str,
        payload: dict[str, Any] = Body(...),
        if_match: str = Header(..., alias="If-Match"),
        idempotency_key: str = Header(..., alias="Idempotency-Key"),
    ) -> dict[str, Any]:
        return mutate(
            request,
            case_id,
            "select_form",
            {"form": payload["form"]},
            if_match,
            idempotency_key,
        )

    @app.get("/v1/xbrl-cases/{case_id}/mappings")
    def get_mappings_resource(request: Request, case_id: str) -> Any:
        return service.get(context(request), case_id, "mappings")

    @app.get("/v1/xbrl-cases/{case_id}/taxonomy-mapping-index")
    def get_taxonomy_mapping_index_resource(request: Request, case_id: str) -> Any:
        return service.get(context(request), case_id, "taxonomy_mapping_index")

    @app.post("/v1/xbrl-cases/{case_id}/taxonomy-mapping-index")
    def build_taxonomy_mapping_index_resource(
        request: Request,
        case_id: str,
        if_match: str = Header(..., alias="If-Match"),
        idempotency_key: str = Header(..., alias="Idempotency-Key"),
    ) -> dict[str, Any]:
        return mutate(
            request,
            case_id,
            "taxonomy_mapping_index",
            {},
            if_match,
            idempotency_key,
        )

    @app.patch("/v1/xbrl-cases/{case_id}/mappings")
    def patch_mappings_resource(
        request: Request,
        case_id: str,
        payload: dict[str, Any] = Body(...),
        if_match: str = Header(..., alias="If-Match"),
        idempotency_key: str = Header(..., alias="Idempotency-Key"),
    ) -> dict[str, Any]:
        return mutate(
            request,
            case_id,
            "apply_mappings",
            {"decisions": payload["decisions"]},
            if_match,
            idempotency_key,
        )

    @app.get("/v1/xbrl-cases/{case_id}/comparative-reconciliation")
    def get_comparative_reconciliation_resource(request: Request, case_id: str) -> Any:
        return service.get(context(request), case_id, "comparative_reconciliation")

    @app.post("/v1/xbrl-cases/{case_id}/comparative-reconciliation")
    def record_comparative_reconciliation_resource(
        request: Request,
        case_id: str,
        payload: dict[str, Any] = Body(...),
        if_match: str = Header(..., alias="If-Match"),
        idempotency_key: str = Header(..., alias="Idempotency-Key"),
    ) -> dict[str, Any]:
        return mutate(
            request,
            case_id,
            "record_comparative_reconciliation",
            {"decisions": payload["decisions"]},
            if_match,
            idempotency_key,
        )

    @app.post("/v1/xbrl-cases/{case_id}/compute-statements")
    def compute_statements_resource(
        request: Request,
        case_id: str,
        if_match: str = Header(..., alias="If-Match"),
        idempotency_key: str = Header(..., alias="Idempotency-Key"),
    ) -> dict[str, Any]:
        return mutate(
            request,
            case_id,
            "compute_statements",
            {},
            if_match,
            idempotency_key,
        )

    @app.post("/v1/xbrl-cases/{case_id}/statutory-presentation")
    def review_statutory_presentation_resource(
        request: Request,
        case_id: str,
        payload: dict[str, Any] = Body(...),
        if_match: str = Header(..., alias="If-Match"),
        idempotency_key: str = Header(..., alias="Idempotency-Key"),
    ) -> dict[str, Any]:
        return mutate(
            request,
            case_id,
            "record_statutory_presentation",
            {"decisions": payload.get("decisions", [])},
            if_match,
            idempotency_key,
        )

    @app.get("/v1/xbrl-cases/{case_id}/schedules")
    def get_schedules_resource(request: Request, case_id: str) -> Any:
        return service.get(context(request), case_id, "schedules")

    @app.post("/v1/xbrl-cases/{case_id}/schedules")
    def record_schedule_resource(
        request: Request,
        case_id: str,
        payload: dict[str, Any] = Body(...),
        if_match: str = Header(..., alias="If-Match"),
        idempotency_key: str = Header(..., alias="Idempotency-Key"),
    ) -> dict[str, Any]:
        return mutate(
            request,
            case_id,
            "record_schedule",
            {"schedule": payload.get("schedule", payload)},
            if_match,
            idempotency_key,
        )

    @app.post("/v1/xbrl-cases/{case_id}/schedule-taxonomy-adapter")
    def record_schedule_taxonomy_adapter_resource(
        request: Request,
        case_id: str,
        payload: dict[str, Any] = Body(...),
        if_match: str = Header(..., alias="If-Match"),
        idempotency_key: str = Header(..., alias="Idempotency-Key"),
    ) -> dict[str, Any]:
        return mutate(
            request,
            case_id,
            "record_schedule_taxonomy_adapter",
            {"decisions": payload.get("decisions", [])},
            if_match,
            idempotency_key,
        )

    @app.post("/v1/xbrl-cases/{case_id}/activate-disclosures")
    def activate_disclosures_resource(
        request: Request,
        case_id: str,
        if_match: str = Header(..., alias="If-Match"),
        idempotency_key: str = Header(..., alias="Idempotency-Key"),
    ) -> dict[str, Any]:
        if disclosure_rule_pack is None:
            raise ValueError(
                "The HTTP adapter requires a configured disclosure rule pack"
            )
        return mutate(
            request,
            case_id,
            "activate_disclosures",
            {"rule_pack": disclosure_rule_pack},
            if_match,
            idempotency_key,
        )

    @app.get("/v1/xbrl-cases/{case_id}/disclosure-triggers")
    def get_disclosure_triggers_resource(request: Request, case_id: str) -> Any:
        return service.get(context(request), case_id, "disclosure_triggers")

    @app.post("/v1/xbrl-cases/{case_id}/disclosure-triggers")
    def record_disclosure_triggers_resource(
        request: Request,
        case_id: str,
        payload: dict[str, Any] = Body(...),
        if_match: str = Header(..., alias="If-Match"),
        idempotency_key: str = Header(..., alias="Idempotency-Key"),
    ) -> dict[str, Any]:
        return mutate(
            request,
            case_id,
            "record_disclosure_triggers",
            {"decisions": payload["decisions"]},
            if_match,
            idempotency_key,
        )

    @app.get("/v1/xbrl-cases/{case_id}/questions")
    def get_questions_resource(request: Request, case_id: str) -> Any:
        return service.get(context(request), case_id, "questions")

    @app.post("/v1/xbrl-cases/{case_id}/answers")
    def answer_questions_resource(
        request: Request,
        case_id: str,
        payload: dict[str, Any] = Body(...),
        if_match: str = Header(..., alias="If-Match"),
        idempotency_key: str = Header(..., alias="Idempotency-Key"),
    ) -> dict[str, Any]:
        return mutate(
            request,
            case_id,
            "record_answers",
            {"answers": payload["answers"]},
            if_match,
            idempotency_key,
        )

    @app.post("/v1/xbrl-cases/{case_id}/generate-notes")
    def generate_notes_resource(
        request: Request,
        case_id: str,
        payload: dict[str, Any] = Body(...),
        if_match: str = Header(..., alias="If-Match"),
        idempotency_key: str = Header(..., alias="Idempotency-Key"),
    ) -> dict[str, Any]:
        return mutate(
            request,
            case_id,
            "record_narratives",
            {"blocks": payload["blocks"]},
            if_match,
            idempotency_key,
        )

    def no_payload_mutation(
        request: Request,
        case_id: str,
        operation: str,
        if_match: str,
        idempotency_key: str,
    ) -> dict[str, Any]:
        return mutate(request, case_id, operation, {}, if_match, idempotency_key)

    @app.post("/v1/xbrl-cases/{case_id}/validate")
    def validate_case_resource(
        request: Request,
        case_id: str,
        if_match: str = Header(..., alias="If-Match"),
        idempotency_key: str = Header(..., alias="Idempotency-Key"),
    ) -> dict[str, Any]:
        return no_payload_mutation(
            request, case_id, "validate", if_match, idempotency_key
        )

    @app.post("/v1/xbrl-cases/{case_id}/prepare-xbrl-review")
    def prepare_xbrl_review_resource(
        request: Request,
        case_id: str,
        if_match: str = Header(..., alias="If-Match"),
        idempotency_key: str = Header(..., alias="Idempotency-Key"),
    ) -> dict[str, Any]:
        return no_payload_mutation(
            request,
            case_id,
            "prepare_xbrl_review",
            if_match,
            idempotency_key,
        )

    @app.post("/v1/xbrl-cases/{case_id}/approve")
    def approve_case_resource(
        request: Request,
        case_id: str,
        payload: dict[str, Any] = Body(...),
        if_match: str = Header(..., alias="If-Match"),
        idempotency_key: str = Header(..., alias="Idempotency-Key"),
    ) -> dict[str, Any]:
        return mutate(
            request,
            case_id,
            "approve",
            {"declaration": payload["declaration"]},
            if_match,
            idempotency_key,
        )

    @app.post("/v1/xbrl-cases/{case_id}/export")
    def export_case_resource(
        request: Request,
        case_id: str,
        if_match: str = Header(..., alias="If-Match"),
        idempotency_key: str = Header(..., alias="Idempotency-Key"),
    ) -> dict[str, Any]:
        return no_payload_mutation(
            request, case_id, "export", if_match, idempotency_key
        )

    @app.get("/v1/xbrl-cases/{case_id}/artifacts")
    def get_artifacts_resource(request: Request, case_id: str) -> Any:
        return service.get(context(request), case_id, "artifacts")

    @app.post(
        "/v1/xbrl-cases/{case_id}/artifacts/{artifact_id}/download-grants",
        status_code=201,
    )
    def create_artifact_download_grant(
        request: Request,
        case_id: str,
        artifact_id: str,
        payload: dict[str, Any] = Body(default={}),
        idempotency_key: str = Header(..., alias="Idempotency-Key"),
    ) -> dict[str, Any]:
        return service.issue_artifact_download(
            context(request),
            case_id,
            artifact_id,
            idempotency_key,
            ttl_seconds=int(payload.get("ttl_seconds", 300)),
        )

    @app.get("/v1/xbrl-artifacts/download")
    def download_artifact(token: str) -> Response:
        artifact = service.redeem_artifact_download(token)
        return Response(
            content=artifact["content"],
            media_type=artifact["media_type"],
            headers={
                "Content-Disposition": (
                    f'attachment; filename="{artifact["file_name"]}"'
                ),
                "Cache-Control": "private, no-store",
                "X-Content-Type-Options": "nosniff",
                "Digest": f"sha-256={artifact['sha256']}",
            },
        )

    @app.get("/v1/xbrl-cases/{case_id}/audit-events")
    def get_audit_events_resource(request: Request, case_id: str) -> Any:
        return service.get(context(request), case_id, "audit_events")

    @app.get("/v1/xbrl-cases/{case_id}/review-views/{view}")
    def get_review_view_resource(
        request: Request,
        case_id: str,
        view: str,
        offset: int = 0,
        limit: int = 200,
    ) -> Any:
        return service.review_view(
            context(request), case_id, view, offset=offset, limit=limit
        )

    @app.post("/v1/xbrl-cases/{case_id}/jobs", status_code=202)
    def enqueue_job_resource(
        request: Request,
        case_id: str,
        payload: dict[str, Any] = Body(...),
        if_match: str = Header(..., alias="If-Match"),
        idempotency_key: str = Header(..., alias="Idempotency-Key"),
    ) -> Any:
        return service.enqueue_job(
            context(request),
            case_id,
            idempotency_key,
            str(payload["operation"]),
            payload.get("payload", {}),
            revision(if_match),
            max_attempts=int(payload.get("max_attempts", 3)),
        )

    @app.get("/v1/xbrl-cases/{case_id}/jobs/{job_id}")
    def get_job_resource(request: Request, case_id: str, job_id: str) -> Any:
        return service.get_job(context(request), case_id, job_id)

    return app

SHA-256: d913c5e8d39e991642b97e3b2b94697341edd36f0bb75a05a443d62cb3ac9604