GitHypothesisReviewCompatibilityFilter.java
package com.taxonomy.versioning.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
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.controller.GitHttpPrecondition;
import com.taxonomy.relations.controller.GitHttpPrecondition.InvalidPreconditionException;
import com.taxonomy.relations.controller.RelationApiController;
import com.taxonomy.relations.service.RelationBranchProjectionReadinessService;
import com.taxonomy.relations.service.RelationBranchProjectionReadinessService.Readiness;
import com.taxonomy.versioning.service.GitAuthoritativeHypothesisReviewService.HypothesisReviewPendingException;
import com.taxonomy.versioning.service.GitAuthoritativeHypothesisReviewService.ReviewAction;
import com.taxonomy.versioning.service.GitAuthoritativeHypothesisReviewService.ReviewResult;
import com.taxonomy.versioning.service.GitAuthoritativeHypothesisService;
import com.taxonomy.versioning.service.HypothesisReviewStateStore.HypothesisReviewConflictException;
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 jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Preserves the established {@code /api/dsl/hypotheses/...} browser contract
* while moving it in front of the legacy MVC methods to the Git-authoritative
* command path. The filter belongs to the versioning boundary because it adapts
* an existing DSL/versioning route; relation commands remain a dependency of
* versioning rather than creating a reverse package cycle.
*/
@Component
@Order(Ordered.LOWEST_PRECEDENCE - 100)
public class GitHypothesisReviewCompatibilityFilter extends OncePerRequestFilter {
static final int MAX_IDEMPOTENCY_KEY_LENGTH = 128;
private static final Pattern REVIEW_PATH = Pattern.compile(
"^/api/dsl/hypotheses/(\\d+)/(accept|reject|revert)$");
private static final Pattern EXTERNAL_IDEMPOTENCY_KEY = Pattern.compile(
"[\\x21-\\x7e]{1," + MAX_IDEMPOTENCY_KEY_LENGTH + "}");
private static final Pattern HYPOTHESIS_NOT_FOUND_MESSAGE = Pattern.compile(
"^Hypothesis not found: \\d+$");
private static final Pattern HYPOTHESIS_NOT_REVIEWABLE_MESSAGE = Pattern.compile(
"^Hypothesis \\d+ cannot be "
+ "(?:(?:accepted|rejected) from|reverted from) [A-Z_]+$");
private static final String IDEMPOTENCY_KEY = "Idempotency-Key";
private static final String REVIEW_REJECTED = "REVIEW_REJECTED";
private final GitAuthoritativeHypothesisService hypothesisService;
private final RelationBranchProjectionReadinessService readinessService;
private final WorkspaceResolver workspaceResolver;
private final SystemRepositoryService repositoryService;
private final RepositoryMembershipService membershipService;
private final ObjectMapper objectMapper;
public GitHypothesisReviewCompatibilityFilter(
GitAuthoritativeHypothesisService hypothesisService,
RelationBranchProjectionReadinessService readinessService,
WorkspaceResolver workspaceResolver,
SystemRepositoryService repositoryService,
RepositoryMembershipService membershipService) {
this.hypothesisService = Objects.requireNonNull(
hypothesisService, "hypothesisService");
this.readinessService = Objects.requireNonNull(
readinessService, "readinessService");
this.workspaceResolver = Objects.requireNonNull(
workspaceResolver, "workspaceResolver");
this.repositoryService = Objects.requireNonNull(
repositoryService, "repositoryService");
this.membershipService = Objects.requireNonNull(
membershipService, "membershipService");
this.objectMapper = new ObjectMapper();
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !"POST".equalsIgnoreCase(request.getMethod())
|| !REVIEW_PATH.matcher(applicationPath(request)).matches();
}
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
Matcher matcher = REVIEW_PATH.matcher(applicationPath(request));
if (!matcher.matches()) {
filterChain.doFilter(request, response);
return;
}
String suppliedIdempotencyKey = request.getHeader(IDEMPOTENCY_KEY);
if (!isValidSuppliedIdempotencyKey(suppliedIdempotencyKey)) {
write(response, HttpServletResponse.SC_BAD_REQUEST, errorPayload(
"INVALID_IDEMPOTENCY_KEY",
"Idempotency-Key must contain between 1 and "
+ MAX_IDEMPOTENCY_KEY_LENGTH
+ " visible ASCII characters without whitespace."));
return;
}
suppliedIdempotencyKey = normalizedSuppliedIdempotencyKey(
suppliedIdempotencyKey);
long hypothesisId;
try {
hypothesisId = Long.parseLong(matcher.group(1));
} catch (NumberFormatException error) {
write(response, HttpServletResponse.SC_BAD_REQUEST, errorPayload(
"INVALID_HYPOTHESIS_ID",
"The hypothesis identifier is outside the supported range."));
return;
}
ReviewAction action = ReviewAction.valueOf(
matcher.group(2).toUpperCase(Locale.ROOT));
try {
RepositoryContext selected = workspaceResolver
.resolveCurrentRepositoryContext();
RepositoryContext context = writableContext(selected);
if (context == null) {
write(response, HttpServletResponse.SC_FORBIDDEN, Map.of(
"status", "FORBIDDEN"));
return;
}
// Fail closed on tenant visibility and terminal lifecycle state
// before exposing whether the selected branch has a Git head.
hypothesisService.requireReviewable(
hypothesisId, context, action);
Readiness readiness = readinessService.inspect(context);
String expectedHead = expectedHead(
request.getHeader(HttpHeaders.IF_MATCH),
readiness.currentHeadCommit());
if (expectedHead == null) {
response.setHeader(
RelationApiController.PROJECTION_STATE_HEADER,
RelationBranchProjectionReadinessService
.ReadinessState.BRANCH_MISSING.name());
write(response, HttpServletResponse.SC_NOT_FOUND, Map.of(
"status", "BRANCH_MISSING"));
return;
}
String causationId = idempotencyKey(
suppliedIdempotencyKey,
hypothesisId,
action,
expectedHead);
ReviewResult result = hypothesisService.review(
hypothesisId,
context,
expectedHead,
new CommandMetadata(
causationId,
"Git-first hypothesis review through the productive DSL API"),
action);
CommandResult authority = result.mutation().authority();
response.setHeader(
HttpHeaders.ETAG,
GitHttpPrecondition.etag(
authority.authoritativeCommitId()));
write(response, HttpServletResponse.SC_OK, successPayload(result));
} catch (BranchHeadConflictException error) {
if (error.getActualHeadCommit() != null) {
response.setHeader(
HttpHeaders.ETAG,
GitHttpPrecondition.etag(error.getActualHeadCommit()));
}
write(response, HttpServletResponse.SC_PRECONDITION_FAILED,
conflictPayload(hypothesisId, action, error));
} catch (HypothesisReviewPendingException error) {
response.setHeader(
HttpHeaders.ETAG,
GitHttpPrecondition.etag(
error.getAuthority().authoritativeCommitId()));
write(response, HttpServletResponse.SC_ACCEPTED,
pendingPayload(error));
} catch (HypothesisReviewConflictException error) {
write(response, HttpServletResponse.SC_CONFLICT, Map.of(
"hypothesisId", error.getHypothesisId(),
"expectedStatus", error.getExpectedStatus().name(),
"actualStatus", error.getActualStatus().name(),
"projectionStatus", "BOOKKEEPING_CONFLICT"));
} catch (InvalidPreconditionException error) {
write(response, HttpServletResponse.SC_BAD_REQUEST, errorPayload(
"INVALID_PRECONDITION",
"If-Match must contain one strong quoted full Git commit ID."));
} catch (IllegalArgumentException error) {
if (isKnownHypothesisNotFound(error)) {
write(response, HttpServletResponse.SC_NOT_FOUND, errorPayload(
"HYPOTHESIS_NOT_FOUND",
"Hypothesis is not available for review."));
} else {
write(response, HttpServletResponse.SC_BAD_REQUEST, errorPayload(
"INVALID_REVIEW_REQUEST",
"The hypothesis review request is invalid."));
}
} catch (IllegalStateException error) {
if (isKnownLifecycleRejection(error)) {
write(response, HttpServletResponse.SC_BAD_REQUEST, errorPayload(
"HYPOTHESIS_NOT_REVIEWABLE",
"Hypothesis is not in a state that permits this review action."));
} else {
write(response, HttpServletResponse.SC_BAD_REQUEST, errorPayload(
"REVIEW_OPERATION_REJECTED",
"The review operation cannot be completed in the current state."));
}
} catch (IOException error) {
write(response, HttpServletResponse.SC_SERVICE_UNAVAILABLE,
Map.of("status", "GIT_UNAVAILABLE"));
}
}
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 String expectedHead(String ifMatch, String currentHead) {
if (ifMatch == null || ifMatch.isBlank()) {
return currentHead;
}
return GitHttpPrecondition.expectedHead(ifMatch, null);
}
/**
* Externally supplied idempotency keys are opaque, case-sensitive values of
* between 1 and 128 visible ASCII characters. Whitespace, control, format and
* non-ASCII characters are rejected before repository or Git work starts.
*/
private static boolean isValidSuppliedIdempotencyKey(String supplied) {
return supplied == null
|| supplied.length() <= MAX_IDEMPOTENCY_KEY_LENGTH
&& EXTERNAL_IDEMPOTENCY_KEY.matcher(supplied).matches();
}
private static String normalizedSuppliedIdempotencyKey(String supplied) {
return supplied;
}
private static String idempotencyKey(
String supplied,
long hypothesisId,
ReviewAction action,
String expectedHead) {
if (supplied != null) {
return supplied;
}
String generated = "legacy-hypothesis-"
+ action.name().toLowerCase(Locale.ROOT)
+ "-" + hypothesisId + "-" + expectedHead;
if (generated.length() > MAX_IDEMPOTENCY_KEY_LENGTH
|| !EXTERNAL_IDEMPOTENCY_KEY.matcher(generated).matches()) {
throw new IllegalStateException(
"Generated legacy idempotency key violates the command boundary");
}
return generated;
}
private static boolean isKnownHypothesisNotFound(
IllegalArgumentException error) {
String message = error.getMessage();
return message != null
&& HYPOTHESIS_NOT_FOUND_MESSAGE.matcher(message).matches();
}
private static boolean isKnownLifecycleRejection(
IllegalStateException error) {
String message = error.getMessage();
return message != null
&& HYPOTHESIS_NOT_REVIEWABLE_MESSAGE.matcher(message).matches();
}
private static Map<String, Object> successPayload(ReviewResult result) {
CommandResult authority = result.mutation().authority();
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("id", result.hypothesisId());
payload.put("action", result.action().name());
payload.put("status", result.hypothesis().getStatus().name());
payload.put("sourceNodeId", result.hypothesis().getSourceNodeId());
payload.put("targetNodeId", result.hypothesis().getTargetNodeId());
payload.put("relationType", result.hypothesis().getRelationType().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", "PROJECTED");
payload.put("projectionOutcome",
result.mutation().projection().outcome().name());
payload.put("relationPresent",
result.mutation().projection().relationPresent());
return payload;
}
private static Map<String, Object> pendingPayload(
HypothesisReviewPendingException error) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("id", error.getHypothesisId());
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 hypothesisId,
ReviewAction action,
BranchHeadConflictException error) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("id", hypothesisId);
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> errorPayload(
String code,
String detail) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("status", REVIEW_REJECTED);
payload.put("code", code);
payload.put("detail", detail);
payload.put("error", detail);
return payload;
}
private void write(
HttpServletResponse response,
int status,
Map<String, ?> payload) throws IOException {
response.setStatus(status);
response.setHeader(HttpHeaders.CACHE_CONTROL, "no-store");
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
objectMapper.writeValue(response.getOutputStream(), payload);
}
private static String applicationPath(HttpServletRequest request) {
String uri = request.getRequestURI();
String contextPath = request.getContextPath();
return contextPath == null || contextPath.isEmpty()
? uri
: uri.substring(contextPath.length());
}
private static boolean isApplicationAdmin() {
Authentication authentication = SecurityContextHolder.getContext()
.getAuthentication();
return authentication != null
&& authentication.getAuthorities().stream()
.anyMatch(authority -> "ROLE_ADMIN".equals(
authority.getAuthority()));
}
}