GitProposalReviewApiController.java
package com.taxonomy.relations.controller;
import com.taxonomy.dsl.storage.ExpectedHeadDslCommitter.BranchHeadConflictException;
import com.taxonomy.relations.command.ArchitectureRelationGitCommandService.CommandMetadata;
import com.taxonomy.relations.command.ArchitectureRelationGitCommandService.CommandResult;
import com.taxonomy.relations.command.ArchitectureRelationGitCommandService.ReadOnlyRepositoryContextException;
import com.taxonomy.relations.service.GitAuthoritativeProposalReviewService;
import com.taxonomy.relations.service.GitAuthoritativeProposalReviewService.PendingPhase;
import com.taxonomy.relations.service.GitAuthoritativeProposalReviewService.ProposalReviewPendingException;
import com.taxonomy.relations.service.GitAuthoritativeProposalReviewService.ReviewAction;
import com.taxonomy.relations.service.GitAuthoritativeProposalReviewService.ReviewResult;
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.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
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;
/** Git-authoritative accept, reject and revert commands for relation proposals. */
@RestController
@RequestMapping("/api/architecture/proposals")
@Tag(name = "Git-authoritative proposal review")
public class GitProposalReviewApiController {
private static final String IDEMPOTENCY_KEY = "Idempotency-Key";
private final GitAuthoritativeProposalReviewService reviewService;
private final WorkspaceResolver workspaceResolver;
private final SystemRepositoryService repositoryService;
private final RepositoryMembershipService membershipService;
public GitProposalReviewApiController(
GitAuthoritativeProposalReviewService reviewService,
WorkspaceResolver workspaceResolver,
SystemRepositoryService repositoryService,
RepositoryMembershipService membershipService) {
this.reviewService = reviewService;
this.workspaceResolver = workspaceResolver;
this.repositoryService = repositoryService;
this.membershipService = membershipService;
}
@Operation(summary = "Accept a proposal through an exact Git commit")
@PostMapping("/{proposalId}/accept")
public ResponseEntity<ReviewResponse> accept(
@PathVariable Long proposalId,
@RequestHeader(value = HttpHeaders.IF_MATCH, required = false)
String ifMatch,
@RequestHeader(value = HttpHeaders.IF_NONE_MATCH, required = false)
String ifNoneMatch,
@RequestHeader(value = IDEMPOTENCY_KEY, required = true)
String causationId,
@RequestBody(required = false) ReviewBody body) {
return review(
proposalId,
ReviewAction.ACCEPT,
ifMatch,
ifNoneMatch,
causationId,
body);
}
@Operation(summary = "Reject a proposal through an exact Git commit")
@PostMapping("/{proposalId}/reject")
public ResponseEntity<ReviewResponse> reject(
@PathVariable Long proposalId,
@RequestHeader(value = HttpHeaders.IF_MATCH, required = false)
String ifMatch,
@RequestHeader(value = HttpHeaders.IF_NONE_MATCH, required = false)
String ifNoneMatch,
@RequestHeader(value = IDEMPOTENCY_KEY, required = true)
String causationId,
@RequestBody(required = false) ReviewBody body) {
return review(
proposalId,
ReviewAction.REJECT,
ifMatch,
ifNoneMatch,
causationId,
body);
}
@Operation(summary = "Revert a reviewed proposal through an exact Git commit")
@PostMapping("/{proposalId}/revert")
public ResponseEntity<ReviewResponse> revert(
@PathVariable Long proposalId,
@RequestHeader(value = HttpHeaders.IF_MATCH, required = false)
String ifMatch,
@RequestHeader(value = HttpHeaders.IF_NONE_MATCH, required = false)
String ifNoneMatch,
@RequestHeader(value = IDEMPOTENCY_KEY, required = true)
String causationId,
@RequestBody(required = false) ReviewBody body) {
return review(
proposalId,
ReviewAction.REVERT,
ifMatch,
ifNoneMatch,
causationId,
body);
}
private ResponseEntity<ReviewResponse> review(
Long proposalId,
ReviewAction action,
String ifMatch,
String ifNoneMatch,
String causationId,
ReviewBody body) {
try {
RepositoryContext context = writableContext(
workspaceResolver.resolveCurrentRepositoryContext());
if (context == null) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
String expectedHead = GitHttpPrecondition.expectedHead(
ifMatch, ifNoneMatch);
String rationale = body == null ? null : body.rationale();
ReviewResult result = switch (action) {
case ACCEPT -> reviewService.accept(
proposalId,
context,
expectedHead,
new CommandMetadata(causationId, rationale));
case REJECT -> reviewService.reject(
proposalId,
context,
expectedHead,
new CommandMetadata(causationId, rationale));
case REVERT -> reviewService.revert(
proposalId,
context,
expectedHead,
new CommandMetadata(causationId, rationale));
};
return projected(result);
} catch (GitHttpPrecondition.PreconditionRequiredException error) {
return ResponseEntity.status(HttpStatus.PRECONDITION_REQUIRED).build();
} catch (BranchHeadConflictException error) {
return preconditionFailed(proposalId, action, error);
} catch (ProposalReviewPendingException error) {
return pending(error);
} catch (ReadOnlyRepositoryContextException error) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).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<ReviewResponse> projected(
ReviewResult result) {
CommandResult authority = result.mutation().authority();
return ResponseEntity.ok()
.header(
HttpHeaders.ETAG,
GitHttpPrecondition.etag(
authority.authoritativeCommitId()))
.body(ReviewResponse.projected(result));
}
private static ResponseEntity<ReviewResponse> pending(
ProposalReviewPendingException error) {
CommandResult authority = error.getAuthority();
return ResponseEntity.status(HttpStatus.ACCEPTED)
.header(
HttpHeaders.ETAG,
GitHttpPrecondition.etag(
authority.authoritativeCommitId()))
.body(ReviewResponse.pending(error));
}
private static ResponseEntity<ReviewResponse> preconditionFailed(
Long proposalId,
ReviewAction action,
BranchHeadConflictException error) {
ResponseEntity.BodyBuilder response = ResponseEntity.status(
HttpStatus.PRECONDITION_FAILED);
if (error.getActualHeadCommit() != null) {
response.header(
HttpHeaders.ETAG,
GitHttpPrecondition.etag(
error.getActualHeadCommit()));
}
return response.body(ReviewResponse.conflict(
proposalId, action, error));
}
/** Preserve isolated write scopes; authorize central reads before upgrading. */
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 ReviewBody(String rationale) {
}
public record ReviewResponse(
Long proposalId,
String action,
String intendedProposalStatus,
String authoritativeCommitId,
String changeKind,
boolean commitCreated,
String projectionStatus,
String projectionOutcome,
Boolean relationPresent,
String pendingPhase,
String expectedHeadCommit,
String actualHeadCommit) {
static ReviewResponse projected(ReviewResult result) {
CommandResult authority = result.mutation().authority();
return new ReviewResponse(
result.proposalId(),
result.action().name(),
result.proposalStatus().name(),
authority.authoritativeCommitId(),
authority.changeKind().name(),
authority.commitCreated(),
"PROJECTED",
result.mutation().projection().outcome().name(),
result.mutation().projection().relationPresent(),
null,
null,
null);
}
static ReviewResponse pending(
ProposalReviewPendingException error) {
CommandResult authority = error.getAuthority();
PendingPhase phase = error.getPhase();
return new ReviewResponse(
error.getProposalId(),
null,
error.getIntendedStatus().name(),
authority.authoritativeCommitId(),
authority.changeKind().name(),
authority.commitCreated(),
"PENDING_RECOVERY",
null,
null,
phase.name(),
null,
null);
}
static ReviewResponse conflict(
Long proposalId,
ReviewAction action,
BranchHeadConflictException error) {
return new ReviewResponse(
proposalId,
action.name(),
null,
null,
null,
false,
"PRECONDITION_FAILED",
null,
null,
null,
error.getExpectedHeadCommit(),
error.getActualHeadCommit());
}
}
}