DecisionRationaleSnapshotReportController.java

package com.taxonomy.portfolio.report;

import com.taxonomy.architecture.decision.DecisionRationaleReport;
import com.taxonomy.architecture.decision.DecisionRationaleReportPlugin;
import com.taxonomy.architecture.decision.DecisionReportTemplateHeaders;
import com.taxonomy.architecture.report.ReportRendererRegistry;
import com.taxonomy.extension.api.report.ReportFormatDescriptor;
import com.taxonomy.extension.api.report.ReportRenderContext;
import com.taxonomy.extension.api.report.ReportRenderResult;
import com.taxonomy.extension.api.report.ReportRendererExtension;
import com.taxonomy.portfolio.service.PortfolioException;
import com.taxonomy.workspace.service.WorkspaceContext;
import com.taxonomy.workspace.service.WorkspaceResolver;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.http.CacheControl;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;
import java.util.Locale;

/** Download API for reports generated from immutable project-analysis snapshots. */
@RestController
@RequestMapping("/api/projects/{projectId}/snapshots/{snapshotId}/decision-report")
@Tag(name = "Decision Rationale Report")
public class DecisionRationaleSnapshotReportController {

    private final DecisionRationaleSnapshotReportService snapshotReportService;
    private final ReportRendererRegistry reportRendererRegistry;
    private final WorkspaceResolver workspaceResolver;

    public DecisionRationaleSnapshotReportController(
            DecisionRationaleSnapshotReportService snapshotReportService,
            ReportRendererRegistry reportRendererRegistry,
            WorkspaceResolver workspaceResolver) {
        this.snapshotReportService = snapshotReportService;
        this.reportRendererRegistry = reportRendererRegistry;
        this.workspaceResolver = workspaceResolver;
    }

    @GetMapping("/formats")
    @Operation(summary = "List report formats available for an immutable analysis snapshot")
    public List<ReportFormatDescriptor> listFormats() {
        return reportRendererRegistry.listDescriptors(
                DecisionRationaleReportPlugin.REPORT_TYPE_ID);
    }

    @GetMapping("/{formatId}")
    @Operation(
            summary = "Download a hierarchical decision rationale for one immutable snapshot",
            description = "Replays the frozen requirement text, taxonomy hierarchy, scores, AI reasons, "
                    + "provider and Git provenance stored with the selected snapshot.")
    public ResponseEntity<byte[]> export(
            @PathVariable Long projectId,
            @PathVariable String snapshotId,
            @PathVariable String formatId,
            @Parameter(description = "Optional BCP-47 report language such as de or en")
            @RequestParam(required = false) String language) {
        ReportRendererExtension renderer = reportRendererRegistry.findByFormatId(
                        DecisionRationaleReportPlugin.REPORT_TYPE_ID, formatId)
                .orElseThrow(() -> PortfolioException.notFound(
                        "Unknown decision-report format: " + formatId));
        WorkspaceContext context = workspaceResolver.resolveCurrentContext();
        String username = workspaceResolver.resolveCurrentUsername();
        DecisionRationaleReport report = snapshotReportService.generate(
                projectId, snapshotId, username, context, resolveLocale(language));
        ReportFormatDescriptor format = renderer.descriptor();
        ReportRenderResult rendered = renderer.render(ReportRenderContext.ofPayload(report));
        String filename = DecisionRationaleReportPlugin.BASE_FILENAME
                + "-v" + valueOrUnknown(report.metadata().requirementVersionNumber())
                + "." + format.fileExtension();
        ResponseEntity.BodyBuilder response = ResponseEntity.ok()
                .cacheControl(CacheControl.noStore())
                .header(HttpHeaders.CONTENT_DISPOSITION,
                        "attachment; filename=\"" + filename + "\"")
                .header("X-Taxonomy-Snapshot-Id", report.metadata().analysisSnapshotId())
                .header("X-Taxonomy-Data-SHA256",
                        report.metadata().taxonomyDataFingerprintSha256())
                .header("X-Taxonomy-Analysis-SHA256",
                        report.metadata().analysisSnapshotFingerprintSha256());
        DecisionReportTemplateHeaders.apply(response, rendered);
        return response
                .contentType(MediaType.parseMediaType(format.contentType()))
                .body(rendered.bytes());
    }

    private Locale resolveLocale(String language) {
        if (language == null || language.isBlank()) {
            return LocaleContextHolder.getLocale();
        }
        Locale locale = Locale.forLanguageTag(language.strip());
        return locale.getLanguage().isBlank()
                ? LocaleContextHolder.getLocale() : locale;
    }

    private String valueOrUnknown(Object value) {
        return value == null ? "unknown" : String.valueOf(value);
    }
}