ProposalApiController.java
package com.taxonomy.relations.controller;
import com.taxonomy.dsl.storage.ExpectedHeadDslCommitter.BranchHeadConflictException;
import com.taxonomy.dto.RelationProposalDto;
import com.taxonomy.model.RelationType;
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.ProposalReviewPendingException;
import com.taxonomy.relations.service.GitAuthoritativeProposalReviewService.ReviewAction;
import com.taxonomy.relations.service.GitAuthoritativeProposalReviewService.ReviewResult;
import com.taxonomy.relations.service.RelationBranchProjectionReadinessService;
import com.taxonomy.relations.service.RelationBranchProjectionReadinessService.Readiness;
import com.taxonomy.relations.service.RelationBranchProjectionReadinessService.ReadinessState;
import com.taxonomy.relations.service.RelationProjectionReadService.RelationProjectionUnavailableException;
import com.taxonomy.relations.service.RelationProposalService;
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.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;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/** REST API for repository/workspace-scoped relation proposals and review. */
@RestController
@RequestMapping("/api")
@Tag(name = "Proposals")
public class ProposalApiController {
private static final String IDEMPOTENCY_KEY = "Idempotency-Key";
private final RelationProposalService proposalService;
private final GitAuthoritativeProposalReviewService reviewService;
private final RelationBranchProjectionReadinessService readinessService;
private final WorkspaceResolver workspaceResolver;
private final SystemRepositoryService repositoryService;
private final RepositoryMembershipService membershipService;
public ProposalApiController(
RelationProposalService proposalService,
GitAuthoritativeProposalReviewService reviewService,
RelationBranchProjectionReadinessService readinessService,
WorkspaceResolver workspaceResolver,
SystemRepositoryService repositoryService,
RepositoryMembershipService membershipService) {
this.proposalService = proposalService;
this.reviewService = reviewService;
this.readinessService = readinessService;
this.workspaceResolver = workspaceResolver;
this.repositoryService = repositoryService;
this.membershipService = membershipService;
}
@Operation(summary = "Propose relations")
@PostMapping("/proposals/propose")
public ResponseEntity<List<RelationProposalDto>> proposeRelations(
@RequestBody Map<String, String> body) {
String sourceCode = body.get("sourceCode");
String relationTypeText = body.get("relationType");
if (sourceCode == null || sourceCode.isBlank()
|| relationTypeText == null || relationTypeText.isBlank()) {
return ResponseEntity.badRequest().build();
}
RelationType relationType;
try {
relationType = RelationType.valueOf(relationTypeText.toUpperCase());
} catch (IllegalArgumentException error) {
return ResponseEntity.badRequest().build();
}
RepositoryContext context = writableContext(
workspaceResolver.resolveCurrentRepositoryContext());
if (context == null) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
int limit = parseLimit(body.getOrDefault("limit", "10"));
try {
return ResponseEntity.ok(proposalService.proposeRelationsInContext(
sourceCode, relationType, limit, context));
} catch (RelationProjectionUnavailableException error) {
return projectionUnavailable(error).build();
} catch (IllegalArgumentException error) {
return ResponseEntity.badRequest().build();
}
}
@Operation(summary = "List all proposals")
@GetMapping("/proposals")
public ResponseEntity<List<RelationProposalDto>> getAllProposals() {
RepositoryContext context = workspaceResolver
.resolveCurrentRepositoryContext();
return ResponseEntity.ok(
proposalService.getAllProposalsInContext(context));
}
@Operation(summary = "List pending proposals")
@GetMapping("/proposals/pending")
public ResponseEntity<List<RelationProposalDto>> getPendingProposals() {
RepositoryContext context = workspaceResolver
.resolveCurrentRepositoryContext();
return ResponseEntity.ok(
proposalService.getPendingProposalsInContext(context));
}
@Operation(summary = "List node proposals")
@GetMapping("/node/{code}/proposals")
public ResponseEntity<List<RelationProposalDto>> getProposalsForNode(
@PathVariable String code) {
RepositoryContext context = workspaceResolver
.resolveCurrentRepositoryContext();
return ResponseEntity.ok(
proposalService.getProposalsForNodeInContext(code, context));
}
/** Compatibility overload used by focused unit tests and in-process callers. */
public ResponseEntity<Map<String, Object>> acceptProposal(Long id) {
return acceptProposal(id, null, null);
}
@Operation(summary = "Accept proposal through an authoritative Git commit")
@PostMapping("/proposals/{id}/accept")
public ResponseEntity<Map<String, Object>> acceptProposal(
@PathVariable Long id,
@RequestHeader(value = HttpHeaders.IF_MATCH, required = false)
String ifMatch,
@RequestHeader(value = "Idempotency-Key", required = false)
String idempotencyKey) {
return reviewProposal(id, ReviewAction.ACCEPT, ifMatch, idempotencyKey);
}
/** Compatibility overload used by focused unit tests and in-process callers. */
public ResponseEntity<Map<String, Object>> rejectProposal(Long id) {
return rejectProposal(id, null, null);
}
@Operation(summary = "Reject proposal through an authoritative Git commit")
@PostMapping("/proposals/{id}/reject")
public ResponseEntity<Map<String, Object>> rejectProposal(
@PathVariable Long id,
@RequestHeader(value = HttpHeaders.IF_MATCH, required = false)
String ifMatch,
@RequestHeader(value = "Idempotency-Key", required = false)
String idempotencyKey) {
return reviewProposal(id, ReviewAction.REJECT, ifMatch, idempotencyKey);
}
@Operation(summary = "Create proposal from hypothesis")
@PostMapping("/proposals/from-hypothesis")
public ResponseEntity<RelationProposalDto> createFromHypothesis(
@RequestBody Map<String, Object> body) {
String sourceCode = body.get("sourceCode") instanceof String value
? value : null;
String targetCode = body.get("targetCode") instanceof String value
? value : null;
String relationTypeText = body.get("relationType") instanceof String value
? value : null;
Number confidenceNumber = body.get("confidence") instanceof Number value
? value : null;
String rationale = body.get("rationale") instanceof String value
? value : null;
if (sourceCode == null || sourceCode.isBlank()
|| targetCode == null || targetCode.isBlank()
|| relationTypeText == null || relationTypeText.isBlank()) {
return ResponseEntity.badRequest().build();
}
RelationType relationType;
try {
relationType = RelationType.valueOf(
relationTypeText.toUpperCase());
} catch (IllegalArgumentException error) {
return ResponseEntity.badRequest().build();
}
RepositoryContext context = writableContext(
workspaceResolver.resolveCurrentRepositoryContext());
if (context == null) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
double confidence = confidenceNumber != null
? confidenceNumber.doubleValue() : 0.5;
try {
RelationProposalDto proposal =
proposalService.createFromHypothesisInContext(
sourceCode,
targetCode,
relationType,
confidence,
rationale,
context);
return proposal != null
? ResponseEntity.ok(proposal)
: ResponseEntity.status(HttpStatus.CONFLICT).build();
} catch (RelationProjectionUnavailableException error) {
return projectionUnavailable(error).build();
} catch (IllegalArgumentException error) {
return ResponseEntity.badRequest().build();
}
}
/** Compatibility overload used by focused unit tests and in-process callers. */
public ResponseEntity<Map<String, Object>> revertProposal(Long id) {
return revertProposal(id, null, null);
}
@Operation(summary = "Revert proposal through an authoritative Git commit")
@PostMapping("/proposals/{id}/revert")
public ResponseEntity<Map<String, Object>> revertProposal(
@PathVariable Long id,
@RequestHeader(value = HttpHeaders.IF_MATCH, required = false)
String ifMatch,
@RequestHeader(value = "Idempotency-Key", required = false)
String idempotencyKey) {
return reviewProposal(id, ReviewAction.REVERT, ifMatch, idempotencyKey);
}
/** Compatibility overload used by focused unit tests and in-process callers. */
public ResponseEntity<Map<String, Object>> bulkAction(
Map<String, Object> body) {
return bulkAction(body, null);
}
@Operation(summary = "Ordered Git-first bulk action on proposals")
@PostMapping("/proposals/bulk")
public ResponseEntity<Map<String, Object>> bulkAction(
@RequestBody Map<String, Object> body,
@RequestHeader(value = IDEMPOTENCY_KEY, required = false)
String idempotencyKey) {
@SuppressWarnings("unchecked")
List<Number> ids = body.get("ids") instanceof List<?> list
? (List<Number>) list : null;
String actionText = body.get("action") instanceof String value
? value : null;
if (ids == null || ids.isEmpty()
|| actionText == null || actionText.isBlank()) {
return ResponseEntity.badRequest().build();
}
ReviewAction action;
if ("ACCEPT".equalsIgnoreCase(actionText)) {
action = ReviewAction.ACCEPT;
} else if ("REJECT".equalsIgnoreCase(actionText)) {
action = ReviewAction.REJECT;
} else {
return ResponseEntity.badRequest().build();
}
RepositoryContext context = writableContext(
workspaceResolver.resolveCurrentRepositoryContext());
if (context == null) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
String expectedHead = currentHead(context);
if (expectedHead == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.header(RelationApiController.PROJECTION_STATE_HEADER,
ReadinessState.BRANCH_MISSING.name())
.header(HttpHeaders.CACHE_CONTROL, "no-store")
.build();
}
String bulkKey = normalizeIdempotencyKey(idempotencyKey);
if (bulkKey == null) {
bulkKey = "legacy-proposal-bulk-"
+ action.name().toLowerCase(Locale.ROOT)
+ "-" + expectedHead + "-" + ids.hashCode();
}
List<Map<String, Object>> itemResults = new ArrayList<>();
int projected = 0;
int pending = 0;
int failed = 0;
boolean stop = false;
for (int index = 0; index < ids.size() && !stop; index++) {
Number idNumber = ids.get(index);
if (idNumber == null) {
failed++;
itemResults.add(itemFailure(null, "INVALID_ID", null));
continue;
}
long proposalId = idNumber.longValue();
String itemKey = bulkKey + ":" + index + ":" + proposalId;
try {
ReviewResult result = executeReview(
proposalId,
action,
context,
expectedHead,
itemKey);
CommandResult authority = result.mutation().authority();
expectedHead = authority.authoritativeCommitId();
projected++;
itemResults.add(reviewPayload(result, "PROJECTED"));
} catch (ProposalReviewPendingException error) {
expectedHead = error.getAuthority().authoritativeCommitId();
pending++;
itemResults.add(pendingPayload(error));
stop = true;
} catch (BranchHeadConflictException error) {
failed++;
if (error.getActualHeadCommit() != null) {
expectedHead = error.getActualHeadCommit();
}
itemResults.add(itemFailure(
proposalId,
"PRECONDITION_FAILED",
error.getMessage()));
stop = true;
} catch (IllegalArgumentException | IllegalStateException error) {
failed++;
itemResults.add(itemFailure(
proposalId,
"REVIEW_REJECTED",
error.getMessage()));
} catch (IOException error) {
failed++;
itemResults.add(itemFailure(
proposalId,
"GIT_UNAVAILABLE",
error.getMessage()));
stop = true;
}
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("action", action.name());
result.put("total", ids.size());
result.put("processed", itemResults.size());
result.put("projected", projected);
result.put("success", projected); // legacy alias for "projected" — kept for browser UI compatibility
result.put("pendingRecovery", pending);
result.put("failed", failed);
result.put("complete", itemResults.size() == ids.size()
&& pending == 0 && failed == 0);
result.put("authoritativeCommitId", expectedHead);
result.put("items", itemResults);
HttpStatus status = pending > 0
? HttpStatus.ACCEPTED
: failed > 0 ? HttpStatus.MULTI_STATUS : HttpStatus.OK;
ResponseEntity.BodyBuilder response = ResponseEntity.status(status)
.header(HttpHeaders.CACHE_CONTROL, "no-store");
if (expectedHead != null) {
response.header(
HttpHeaders.ETAG,
GitHttpPrecondition.etag(expectedHead));
}
return response.body(result);
}
private ResponseEntity<Map<String, Object>> reviewProposal(
Long proposalId,
ReviewAction action,
String ifMatch,
String suppliedIdempotencyKey) {
RepositoryContext context = writableContext(
workspaceResolver.resolveCurrentRepositoryContext());
if (context == null) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
try {
String expectedHead = expectedHead(context, ifMatch);
if (expectedHead == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.header(RelationApiController.PROJECTION_STATE_HEADER,
ReadinessState.BRANCH_MISSING.name())
.header(HttpHeaders.CACHE_CONTROL, "no-store")
.build();
}
String causationId = normalizeIdempotencyKey(suppliedIdempotencyKey);
if (causationId == null) {
causationId = "legacy-proposal-"
+ action.name().toLowerCase(Locale.ROOT)
+ "-" + proposalId + "-" + expectedHead;
}
ReviewResult result = executeReview(
proposalId,
action,
context,
expectedHead,
causationId);
CommandResult authority = result.mutation().authority();
return ResponseEntity.ok()
.header(HttpHeaders.CACHE_CONTROL, "no-store")
.header(
HttpHeaders.ETAG,
GitHttpPrecondition.etag(
authority.authoritativeCommitId()))
.body(reviewPayload(result, "PROJECTED"));
} catch (BranchHeadConflictException error) {
ResponseEntity.BodyBuilder response = ResponseEntity.status(
HttpStatus.PRECONDITION_FAILED)
.header(HttpHeaders.CACHE_CONTROL, "no-store");
if (error.getActualHeadCommit() != null) {
response.header(
HttpHeaders.ETAG,
GitHttpPrecondition.etag(error.getActualHeadCommit()));
}
return response.body(conflictPayload(proposalId, action, error));
} catch (ProposalReviewPendingException error) {
return ResponseEntity.status(HttpStatus.ACCEPTED)
.header(HttpHeaders.CACHE_CONTROL, "no-store")
.header(
HttpHeaders.ETAG,
GitHttpPrecondition.etag(
error.getAuthority().authoritativeCommitId()))
.body(pendingPayload(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 ReviewResult executeReview(
Long proposalId,
ReviewAction action,
RepositoryContext context,
String expectedHead,
String causationId) throws IOException {
CommandMetadata metadata = new CommandMetadata(
causationId,
"Git-first proposal review through the productive API");
return switch (action) {
case ACCEPT -> reviewService.accept(
proposalId, context, expectedHead, metadata);
case REJECT -> reviewService.reject(
proposalId, context, expectedHead, metadata);
case REVERT -> reviewService.revert(
proposalId, context, expectedHead, metadata);
};
}
private String expectedHead(RepositoryContext context, String ifMatch) {
if (ifMatch != null && !ifMatch.isBlank()) {
return GitHttpPrecondition.expectedHead(ifMatch, null);
}
return currentHead(context);
}
private String currentHead(RepositoryContext context) {
return readinessService.readCurrentHead(context);
}
private static Map<String, Object> reviewPayload(
ReviewResult result,
String projectionStatus) {
CommandResult authority = result.mutation().authority();
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("proposalId", result.proposalId());
payload.put("action", result.action().name());
payload.put("status", result.proposalStatus().name());
payload.put("authoritativeCommitId",
authority.authoritativeCommitId());
payload.put("previousHeadCommit", authority.previousHeadCommit());
payload.put("changeKind", authority.changeKind().name());
payload.put("commitCreated", authority.commitCreated());
payload.put("projectionStatus", projectionStatus);
payload.put("projectionOutcome",
result.mutation().projection().outcome().name());
payload.put("relationPresent",
result.mutation().projection().relationPresent());
return payload;
}
private static Map<String, Object> pendingPayload(
ProposalReviewPendingException error) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("proposalId", error.getProposalId());
payload.put("status", error.getIntendedStatus().name());
payload.put("authoritativeCommitId",
error.getAuthority().authoritativeCommitId());
payload.put("changeKind", error.getAuthority().changeKind().name());
payload.put("commitCreated", error.getAuthority().commitCreated());
payload.put("projectionStatus", "PENDING_RECOVERY");
payload.put("pendingPhase", error.getPhase().name());
return payload;
}
private static Map<String, Object> conflictPayload(
Long proposalId,
ReviewAction action,
BranchHeadConflictException error) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("proposalId", proposalId);
payload.put("action", action.name());
payload.put("projectionStatus", "PRECONDITION_FAILED");
payload.put("expectedHeadCommit", error.getExpectedHeadCommit());
payload.put("actualHeadCommit", error.getActualHeadCommit());
return payload;
}
private static Map<String, Object> itemFailure(
Long proposalId,
String code,
String detail) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("proposalId", proposalId);
payload.put("projectionStatus", code);
if (detail != null && !detail.isBlank()) {
payload.put("detail", detail);
}
return payload;
}
/** Convert a central read context into an explicitly authorized write context. */
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 ResponseEntity.BodyBuilder projectionUnavailable(
RelationProjectionUnavailableException error) {
HttpStatus status = error.getReadinessState()
== ReadinessState.BRANCH_MISSING
? HttpStatus.NOT_FOUND
: HttpStatus.CONFLICT;
ResponseEntity.BodyBuilder response = ResponseEntity.status(status)
.header(
RelationApiController.PROJECTION_STATE_HEADER,
error.getReadinessState().name())
.header(HttpHeaders.CACHE_CONTROL, "no-store");
if (error.getPendingRecoveryCount() > 0) {
response.header(
RelationApiController.PENDING_RECOVERY_HEADER,
String.valueOf(error.getPendingRecoveryCount()));
}
if (error.getCurrentHeadCommit() != null) {
response.header(
HttpHeaders.ETAG,
GitHttpPrecondition.etag(
error.getCurrentHeadCommit()));
}
return response;
}
private static String normalizeIdempotencyKey(String value) {
if (value == null || value.isBlank()) {
return null;
}
String normalized = value.strip();
if (normalized.indexOf('\n') >= 0 || normalized.indexOf('\r') >= 0) {
throw new IllegalArgumentException(
"Idempotency-Key must be one line");
}
return normalized;
}
private static boolean isApplicationAdmin() {
Authentication authentication = SecurityContextHolder.getContext()
.getAuthentication();
return authentication != null
&& authentication.getAuthorities().stream()
.anyMatch(authority -> "ROLE_ADMIN".equals(
authority.getAuthority()));
}
private static int parseLimit(String value) {
try {
int limit = Integer.parseInt(value);
return limit >= 1 && limit <= 100 ? limit : 10;
} catch (NumberFormatException error) {
return 10;
}
}
}