RelationProjectionOperationsApiController.java

package com.taxonomy.relations.controller;

import com.taxonomy.dsl.storage.ExpectedHeadDslCommitter.BranchHeadConflictException;
import com.taxonomy.relations.service.RelationBranchProjectionRebuildService.BranchProjectionSourceException;
import com.taxonomy.relations.service.RelationProjectionOperationsService;
import com.taxonomy.relations.service.RelationProjectionOperationsService.ProjectionStatus;
import com.taxonomy.relations.service.RelationProjectionOperationsService.RebuildHeadConflictException;
import com.taxonomy.relations.service.RelationProjectionOperationsService.RebuildOperation;
import com.taxonomy.relations.service.RelationProjectionOperationsService.RebuildVerificationException;
import com.taxonomy.relations.service.RelationProjectionOperationsService.RecoveryReconciliationPendingException;
import com.taxonomy.relations.service.RelationProjectionRecoveryService.RecoveryRecord;
import com.taxonomy.workspace.model.SystemRepository;
import com.taxonomy.workspace.service.RepositoryContext;
import com.taxonomy.workspace.service.RepositoryMembershipService;
import com.taxonomy.workspace.service.RepositoryScope;
import com.taxonomy.workspace.service.SystemRepositoryService;
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.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.io.IOException;
import java.time.Instant;
import java.util.List;

/** Authorized operator API for rebuildable relation branch projections. */
@RestController
@RequestMapping("/api/architecture/relations/projection")
@Tag(name = "Relation projection operations")
public class RelationProjectionOperationsApiController {

    private final RelationProjectionOperationsService operationsService;
    private final WorkspaceResolver workspaceResolver;
    private final SystemRepositoryService repositoryService;
    private final RepositoryMembershipService membershipService;

    public RelationProjectionOperationsApiController(
            RelationProjectionOperationsService operationsService,
            WorkspaceResolver workspaceResolver,
            SystemRepositoryService repositoryService,
            RepositoryMembershipService membershipService) {
        this.operationsService = operationsService;
        this.workspaceResolver = workspaceResolver;
        this.repositoryService = repositoryService;
        this.membershipService = membershipService;
    }

    @Operation(summary = "Inspect exact-branch projection readiness and recovery")
    @GetMapping("/readiness")
    public ResponseEntity<ProjectionOperationResponse> readiness() {
        RepositoryContext context = writableContext(
                workspaceResolver.resolveCurrentRepositoryContext());
        if (context == null) {
            return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
        }
        ProjectionStatus status = operationsService.inspect(context);
        ResponseEntity.BodyBuilder response = ResponseEntity.ok();
        if (status.readiness().currentHeadCommit() != null) {
            response.header(
                    HttpHeaders.ETAG,
                    GitHttpPrecondition.etag(
                            status.readiness().currentHeadCommit()));
        }
        return response.body(ProjectionOperationResponse.status(
                context, status));
    }

    @Operation(summary = "Rebuild an exact branch projection from Git")
    @PostMapping("/rebuild")
    public ResponseEntity<ProjectionOperationResponse> rebuild(
            @RequestHeader(value = HttpHeaders.IF_MATCH, required = false)
            String ifMatch,
            @RequestHeader(value = HttpHeaders.IF_NONE_MATCH, required = false)
            String ifNoneMatch) {
        try {
            RepositoryContext context = writableContext(
                    workspaceResolver.resolveCurrentRepositoryContext());
            if (context == null) {
                return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
            }
            String expectedHead = GitHttpPrecondition.expectedHead(
                    ifMatch, ifNoneMatch);
            RebuildOperation operation = operationsService.rebuild(
                    context, expectedHead);
            return ResponseEntity.ok()
                    .header(
                            HttpHeaders.ETAG,
                            GitHttpPrecondition.etag(
                                    operation.rebuild()
                                            .authoritativeCommitId()))
                    .body(ProjectionOperationResponse.rebuilt(
                            context, operation));
        } catch (GitHttpPrecondition.PreconditionRequiredException error) {
            return ResponseEntity.status(HttpStatus.PRECONDITION_REQUIRED).build();
        } catch (BranchHeadConflictException error) {
            return preconditionFailed(
                    error.getExpectedHeadCommit(),
                    error.getActualHeadCommit());
        } catch (RebuildHeadConflictException error) {
            return preconditionFailed(
                    error.getExpectedHeadCommit(),
                    error.getActualHeadCommit());
        } catch (RebuildVerificationException error) {
            return verificationFailed(error);
        } catch (RecoveryReconciliationPendingException error) {
            String commit = error.getRebuild().authoritativeCommitId();
            return ResponseEntity.status(HttpStatus.ACCEPTED)
                    .header(HttpHeaders.ETAG, GitHttpPrecondition.etag(commit))
                    .body(ProjectionOperationResponse.reconciliationPending(
                            error.getRebuild().repositoryId(),
                            error.getRebuild().workspaceId(),
                            error.getRebuild().branch(),
                            commit,
                            error.getRebuild().relationCount()));
        } catch (BranchProjectionSourceException error) {
            return ResponseEntity.status(HttpStatus.CONFLICT).build();
        } catch (IllegalArgumentException error) {
            return ResponseEntity.badRequest().build();
        } catch (IllegalStateException error) {
            return ResponseEntity.status(HttpStatus.CONFLICT).build();
        } catch (IOException error) {
            return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build();
        }
    }

    private static ResponseEntity<ProjectionOperationResponse>
            preconditionFailed(
                    String expectedHeadCommit,
                    String actualHeadCommit) {
        ResponseEntity.BodyBuilder response = ResponseEntity.status(
                HttpStatus.PRECONDITION_FAILED);
        if (actualHeadCommit != null) {
            response.header(
                    HttpHeaders.ETAG,
                    GitHttpPrecondition.etag(actualHeadCommit));
        }
        return response.body(ProjectionOperationResponse.conflict(
                expectedHeadCommit, actualHeadCommit));
    }

    private static ResponseEntity<ProjectionOperationResponse>
            verificationFailed(RebuildVerificationException error) {
        ResponseEntity.BodyBuilder response = ResponseEntity.status(
                HttpStatus.CONFLICT);
        String currentHead = error.getReadiness().currentHeadCommit();
        if (currentHead != null) {
            response.header(
                    HttpHeaders.ETAG,
                    GitHttpPrecondition.etag(currentHead));
        }
        return response.body(
                ProjectionOperationResponse.verificationFailed(error));
    }

    /** Central projection operations require repository-maintainer authority. */
    private RepositoryContext writableContext(RepositoryContext context) {
        if (context.scope() == RepositoryScope.WORKSPACE
                || context.scope() == RepositoryScope.FORK) {
            return context;
        }
        SystemRepository repository = repositoryService.getRepository(
                context.repositoryId());
        if (!isApplicationAdmin()
                && !membershipService.canMaintain(
                        repository, context.username())) {
            return null;
        }
        return new RepositoryContext(
                context.repositoryId(),
                null,
                context.branch(),
                context.username(),
                RepositoryScope.CENTRAL_WRITE);
    }

    private static boolean isApplicationAdmin() {
        Authentication authentication = SecurityContextHolder.getContext()
                .getAuthentication();
        return authentication != null
                && authentication.getAuthorities().stream()
                .anyMatch(authority -> "ROLE_ADMIN".equals(
                        authority.getAuthority()));
    }

    public record RecoveryResponse(
            Long id,
            String authoritativeCommitId,
            String previousHeadCommit,
            String causationId,
            String status,
            int attemptCount,
            String failureType,
            String failureMessage,
            Instant firstObservedAt,
            Instant lastObservedAt) {

        static RecoveryResponse from(RecoveryRecord recovery) {
            return new RecoveryResponse(
                    recovery.id(),
                    recovery.authoritativeCommitId(),
                    recovery.previousHeadCommit(),
                    recovery.causationId(),
                    recovery.status().name(),
                    recovery.attemptCount(),
                    recovery.failureType(),
                    recovery.failureMessage(),
                    recovery.firstObservedAt(),
                    recovery.lastObservedAt());
        }
    }

    public record ProjectionOperationResponse(
            String repositoryId,
            String workspaceId,
            String branch,
            String operationStatus,
            String readinessState,
            String currentHeadCommit,
            String projectedCommit,
            int relationCount,
            List<RecoveryResponse> pendingRecoveries,
            Integer recoveredCount,
            Integer supersededCount,
            Integer remainingPendingCount,
            String expectedHeadCommit,
            String actualHeadCommit) {

        public ProjectionOperationResponse {
            pendingRecoveries = pendingRecoveries == null
                    ? List.of()
                    : List.copyOf(pendingRecoveries);
        }

        static ProjectionOperationResponse status(
                RepositoryContext context,
                ProjectionStatus status) {
            return new ProjectionOperationResponse(
                    context.repositoryId(),
                    context.workspaceId(),
                    context.branch(),
                    "INSPECTED",
                    status.readiness().state().name(),
                    status.readiness().currentHeadCommit(),
                    status.readiness().projectedCommit(),
                    status.readiness().rows().size(),
                    status.pendingRecoveries().stream()
                            .map(RecoveryResponse::from)
                            .toList(),
                    null,
                    null,
                    status.pendingRecoveries().size(),
                    null,
                    null);
        }

        static ProjectionOperationResponse rebuilt(
                RepositoryContext context,
                RebuildOperation operation) {
            return new ProjectionOperationResponse(
                    context.repositoryId(),
                    context.workspaceId(),
                    context.branch(),
                    "REBUILT",
                    operation.readiness().state().name(),
                    operation.readiness().currentHeadCommit(),
                    operation.readiness().projectedCommit(),
                    operation.rebuild().relationCount(),
                    List.of(),
                    operation.reconciliation().recoveredCount(),
                    operation.reconciliation().supersededCount(),
                    operation.reconciliation().remainingPendingCount(),
                    null,
                    null);
        }

        static ProjectionOperationResponse verificationFailed(
                RebuildVerificationException error) {
            return new ProjectionOperationResponse(
                    error.getRebuild().repositoryId(),
                    error.getRebuild().workspaceId(),
                    error.getRebuild().branch(),
                    "VERIFICATION_FAILED",
                    error.getReadiness().state().name(),
                    error.getReadiness().currentHeadCommit(),
                    error.getReadiness().projectedCommit(),
                    error.getRebuild().relationCount(),
                    List.of(),
                    null,
                    null,
                    null,
                    null,
                    null);
        }

        static ProjectionOperationResponse reconciliationPending(
                String repositoryId,
                String workspaceId,
                String branch,
                String commit,
                int relationCount) {
            return new ProjectionOperationResponse(
                    repositoryId,
                    workspaceId,
                    branch,
                    "RECOVERY_RECONCILIATION_PENDING",
                    null,
                    commit,
                    commit,
                    relationCount,
                    List.of(),
                    null,
                    null,
                    null,
                    null,
                    null);
        }

        static ProjectionOperationResponse conflict(
                String expectedHeadCommit,
                String actualHeadCommit) {
            return new ProjectionOperationResponse(
                    null,
                    null,
                    null,
                    "PRECONDITION_FAILED",
                    null,
                    actualHeadCommit,
                    null,
                    0,
                    List.of(),
                    null,
                    null,
                    null,
                    expectedHeadCommit,
                    actualHeadCommit);
        }
    }
}