ArchitectureWorkbenchController.java

package com.taxonomy.portfolio.workbench;

import com.taxonomy.portfolio.dto.PortfolioDtos.RequirementView;
import com.taxonomy.portfolio.service.PortfolioException;
import com.taxonomy.portfolio.service.ProjectPortfolioService;
import com.taxonomy.portfolio.workbench.ArchitectureSnapshotExportService.Artifact;
import com.taxonomy.portfolio.workbench.ArchitectureWorkbenchDtos.Projection;
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.tags.Tag;
import org.springframework.http.CacheControl;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.util.UriComponentsBuilder;

import java.nio.charset.StandardCharsets;

/** Page and API adapter for the read-only architecture workbench. */
@Controller
@Tag(name = "Architecture Workbench")
public class ArchitectureWorkbenchController {

    private static final MediaType SVG =
            MediaType.parseMediaType("image/svg+xml");
    private static final String SNAPSHOT_HEADER =
            "X-Taxonomy-Snapshot-Id";
    private static final String SNAPSHOT_STATUS_HEADER =
            "X-Taxonomy-Snapshot-Status";
    private static final String WORKSPACE_HEADER =
            "X-Taxonomy-Workspace-Id";
    private static final String BRANCH_HEADER =
            "X-Taxonomy-Branch";
    private static final String COMMIT_HEADER =
            "X-Taxonomy-Commit-Sha";
    private static final String EXPORT_PROFILE_HEADER =
            "X-Taxonomy-Exporter-Profile";
    private static final String CANONICAL_GRAPH_HEADER =
            "X-Taxonomy-Canonical-Graph-Sha256";
    private static final String ARTIFACT_HEADER =
            "X-Taxonomy-Artifact-Sha256";

    private final ArchitectureWorkbenchService service;
    private final ArchitectureSnapshotExportService snapshotExportService;
    private final ProjectPortfolioService projectService;
    private final WorkspaceResolver workspaceResolver;

    public ArchitectureWorkbenchController(
            ArchitectureWorkbenchService service,
            ArchitectureSnapshotExportService snapshotExportService,
            ProjectPortfolioService projectService,
            WorkspaceResolver workspaceResolver) {
        this.service = service;
        this.snapshotExportService = snapshotExportService;
        this.projectService = projectService;
        this.workspaceResolver = workspaceResolver;
    }

    @GetMapping("/architecture/workbench")
    public String workbenchPage(
            @RequestParam(required = false) Long projectId,
            @RequestParam(required = false) String snapshotId,
            Model model) {
        model.addAttribute("projectId", projectId);
        model.addAttribute("snapshotId", snapshotId);
        model.addAttribute("archimateMappingProfile", com.taxonomy.archimate.exchange.ArchiMateExchangeProfile.VERSION);
        return "architecture-workbench";
    }

    @GetMapping("/projects/{projectId}/requirements/{requirementId}/architecture")
    public String currentRequirementArchitecture(
            @PathVariable Long projectId,
            @PathVariable Long requirementId) {
        RequestScope scope = scope();
        RequirementView requirement = projectService.getRequirement(
                projectId,
                requirementId,
                scope.username(),
                scope.context());
        String snapshotId = requirement.currentAnalysisSnapshotId();
        if (snapshotId == null || snapshotId.isBlank()) {
            throw PortfolioException.conflict(
                    "Requirement " + requirement.requirementKey()
                            + " has no current architecture snapshot. "
                            + "Analyze the current version first.");
        }
        String target = UriComponentsBuilder.fromPath("/architecture/workbench")
                .queryParam("projectId", projectId)
                .queryParam("snapshotId", snapshotId)
                .build()
                .encode()
                .toUriString();
        return "redirect:" + target;
    }

    @GetMapping("/api/projects/{projectId}/architecture-workbench/{snapshotId}")
    @ResponseBody
    @Operation(
            summary = "Load one immutable architecture snapshot "
                    + "as a render-ready scene")
    public Projection projection(
            @PathVariable Long projectId,
            @PathVariable String snapshotId) {
        RequestScope scope = scope();
        return service.load(
                projectId,
                snapshotId,
                scope.username(),
                scope.context());
    }

    @GetMapping(
            value = "/api/projects/{projectId}/architecture-workbench/"
                    + "{snapshotId}.svg",
            produces = "image/svg+xml")
    @ResponseBody
    @Operation(
            summary = "Export the architecture snapshot "
                    + "as deterministic standalone SVG")
    public ResponseEntity<String> svg(
            @PathVariable Long projectId,
            @PathVariable String snapshotId) {
        RequestScope scope = scope();
        String content = service.renderSvg(
                projectId,
                snapshotId,
                scope.username(),
                scope.context());
        return ResponseEntity.ok()
                .contentType(SVG)
                .header(
                        HttpHeaders.CONTENT_DISPOSITION,
                        disposition("architecture", "svg"))
                .body(content);
    }

    @GetMapping(
            value = "/api/projects/{projectId}/architecture-workbench/"
                    + "{snapshotId}.pdf",
            produces = MediaType.APPLICATION_PDF_VALUE)
    @ResponseBody
    @Operation(summary = "Export the architecture snapshot as a vector PDF")
    public ResponseEntity<byte[]> pdf(
            @PathVariable Long projectId,
            @PathVariable String snapshotId) {
        RequestScope scope = scope();
        byte[] content = service.renderPdf(
                projectId,
                snapshotId,
                scope.username(),
                scope.context());
        return ResponseEntity.ok()
                .contentType(MediaType.APPLICATION_PDF)
                .contentLength(content.length)
                .header(
                        HttpHeaders.CONTENT_DISPOSITION,
                        disposition("architecture", "pdf"))
                .body(content);
    }

    @GetMapping(value = "/api/projects/{projectId}/architecture-workbench/{snapshotId}.archimate.zip",
            produces = "application/zip")
    @ResponseBody
    @Operation(summary = "Export experimental ArchiMate XML with the exact mapping profile and loss manifest")
    public ResponseEntity<byte[]> archiMateBundle(@PathVariable Long projectId, @PathVariable String snapshotId) {
        RequestScope scope = scope();
        return artifactResponse(snapshotExportService.exportArchiMateBundle(
                projectId, snapshotId, scope.username(), scope.context()));
    }

    @GetMapping(
            value = "/api/projects/{projectId}/architecture-workbench/"
                    + "{snapshotId}.archimate.xml",
            produces = MediaType.APPLICATION_XML_VALUE)
    @ResponseBody
    @Operation(
            summary = "Export the exact persisted architecture snapshot "
                    + "as the schema-validated ArchiMate 3.1 supported subset")
    public ResponseEntity<byte[]> archiMate(
            @PathVariable Long projectId,
            @PathVariable String snapshotId) {
        RequestScope scope = scope();
        Artifact artifact = snapshotExportService.exportArchiMate(
                projectId,
                snapshotId,
                scope.username(),
                scope.context());
        return artifactResponse(artifact);
    }

    @GetMapping(
            value = "/api/projects/{projectId}/architecture-workbench/"
                    + "{snapshotId}.vsdx",
            produces = "application/vnd.ms-visio.drawing")
    @ResponseBody
    @Operation(
            summary = "Export the exact persisted architecture snapshot "
                    + "as an experimental bounded Visio 2012 VSDX package")
    public ResponseEntity<byte[]> visio(
            @PathVariable Long projectId,
            @PathVariable String snapshotId) {
        RequestScope scope = scope();
        Artifact artifact = snapshotExportService.exportVisio(
                projectId,
                snapshotId,
                scope.username(),
                scope.context());
        return artifactResponse(artifact);
    }

    @GetMapping(value = "/api/projects/{projectId}/architecture-workbench/{snapshotId}.visio.zip",
            produces = "application/zip")
    @ResponseBody
    @Operation(summary = "Export the exact persisted snapshot as a ZIP bundle containing experimental VSDX, mapping profile and loss manifest")
    public ResponseEntity<byte[]> visioBundle(@PathVariable Long projectId, @PathVariable String snapshotId) {
        RequestScope scope = scope();
        return artifactResponse(snapshotExportService.exportVisioBundle(projectId, snapshotId, scope.username(), scope.context()));
    }

    private RequestScope scope() {
        return new RequestScope(
                workspaceResolver.resolveCurrentUsername(),
                workspaceResolver.resolveCurrentContext());
    }

    private static ResponseEntity<byte[]> artifactResponse(Artifact artifact) {
        byte[] content = artifact.content();
        return ResponseEntity.ok()
                .contentType(MediaType.parseMediaType(artifact.mediaType()))
                .contentLength(content.length)
                .cacheControl(CacheControl.noStore())
                .eTag("\"sha256-" + artifact.artifactSha256() + "\"")
                .header(
                        HttpHeaders.CONTENT_DISPOSITION,
                        disposition(artifact.fileName()))
                .header(SNAPSHOT_HEADER, artifact.snapshotId())
                .header(
                        SNAPSHOT_STATUS_HEADER,
                        artifact.snapshotStatus().name())
                .header(WORKSPACE_HEADER, artifact.workspaceId())
                .header(BRANCH_HEADER, artifact.branchName())
                .header(COMMIT_HEADER, artifact.commitSha())
                .header(
                        EXPORT_PROFILE_HEADER,
                        artifact.exporterProfile())
                .header(
                        CANONICAL_GRAPH_HEADER,
                        artifact.canonicalGraphSha256())
                .header(ARTIFACT_HEADER, artifact.artifactSha256())
                .header("X-Content-Type-Options", "nosniff")
                .body(content);
    }

    private static String disposition(String name, String extension) {
        return disposition(name + "." + extension);
    }

    private static String disposition(String fileName) {
        return ContentDisposition.attachment()
                .filename(fileName, StandardCharsets.UTF_8)
                .build()
                .toString();
    }

    private record RequestScope(String username, WorkspaceContext context) {
    }
}