WorkflowApiExceptionHandler.java
package org.hammer.audio.workflow.editor.http;
import jakarta.servlet.http.HttpServletRequest;
import java.net.URI;
import java.util.List;
import java.util.Objects;
import org.hammer.audio.workflow.collaboration.WorkflowSessionException;
import org.hammer.audio.workflow.collaboration.WorkflowUndoConflictException;
import org.hammer.audio.workflow.collaboration.WorkflowUndoPreview;
import org.hammer.audio.workflow.collaboration.store.WorkflowSessionRevisionConflictException;
import org.hammer.audio.workflow.collaboration.store.WorkflowSessionSequenceConflictException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/** Central RFC 9457 error mapping for workflow REST controllers. */
@RestControllerAdvice
public final class WorkflowApiExceptionHandler {
private static final String PROBLEM_BASE = "https://audio-analyzer.dev/problems/";
private static final String CODE_PROPERTY = "code";
private static final String SESSION_ID_PROPERTY = "sessionId";
/** Maps typed collaboration-domain errors without inspecting exception-message text. */
@ExceptionHandler(WorkflowSessionException.class)
public ProblemDetail handleSessionException(
WorkflowSessionException exception, HttpServletRequest request) {
HttpStatus status = statusFor(exception.code());
ProblemDetail problem =
problem(status, problemName(exception.code()), exception.getMessage(), request);
problem.setProperty(CODE_PROPERTY, exception.code().name());
if (exception.sessionId() != null) {
problem.setProperty(SESSION_ID_PROPERTY, exception.sessionId());
}
return problem;
}
/** Maps blocked undo/redo commands with the concrete conflicting operations. */
@ExceptionHandler(WorkflowUndoConflictException.class)
public ProblemDetail handleUndoConflict(
WorkflowUndoConflictException exception, HttpServletRequest request) {
ProblemDetail problem =
problem(HttpStatus.CONFLICT, "undo-conflict", exception.getMessage(), request);
problem.setProperty(CODE_PROPERTY, "UNDO_CONFLICT");
problem.setProperty(SESSION_ID_PROPERTY, exception.sessionId());
problem.setProperty("targetOperationId", exception.targetOperationId());
problem.setProperty(
"blockingOperations",
exception.blockingOperations().stream().map(BlockingOperationDetail::from).toList());
return problem;
}
/** Maps stale semantic commands to a machine-readable 409 response. */
@ExceptionHandler(WorkflowSessionRevisionConflictException.class)
public ProblemDetail handleRevisionConflict(
WorkflowSessionRevisionConflictException exception, HttpServletRequest request) {
ProblemDetail problem =
problem(
HttpStatus.CONFLICT,
"workflow-session-revision-conflict",
exception.getMessage(),
request);
problem.setProperty(CODE_PROPERTY, "WORKFLOW_SESSION_REVISION_CONFLICT");
problem.setProperty(SESSION_ID_PROPERTY, exception.sessionId());
problem.setProperty("expectedRevision", exception.expectedRevision());
problem.setProperty("actualRevision", exception.actualRevision());
return problem;
}
/** Maps stale collaboration-event cursors to a machine-readable 409 response. */
@ExceptionHandler(WorkflowSessionSequenceConflictException.class)
public ProblemDetail handleSequenceConflict(
WorkflowSessionSequenceConflictException exception, HttpServletRequest request) {
ProblemDetail problem =
problem(
HttpStatus.CONFLICT,
"workflow-session-sequence-conflict",
exception.getMessage(),
request);
problem.setProperty(CODE_PROPERTY, "WORKFLOW_SESSION_SEQUENCE_CONFLICT");
problem.setProperty(SESSION_ID_PROPERTY, exception.sessionId());
problem.setProperty("expectedSequence", exception.expectedSequence());
problem.setProperty("actualSequence", exception.actualSequence());
return problem;
}
/** Maps bean-validation failures to a structured invalid-request response. */
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidation(
MethodArgumentNotValidException exception, HttpServletRequest request) {
ProblemDetail problem =
problem(
HttpStatus.BAD_REQUEST,
"invalid-request",
"The request body contains invalid values.",
request);
List<FieldViolation> violations =
exception.getBindingResult().getFieldErrors().stream()
.map(
error ->
new FieldViolation(
error.getField(), Objects.toString(error.getDefaultMessage(), "")))
.toList();
problem.setProperty("violations", violations);
return problem;
}
/** Maps malformed JSON and incompatible enum values to a stable 400 response. */
@ExceptionHandler(HttpMessageNotReadableException.class)
public ProblemDetail handleUnreadableBody(
HttpMessageNotReadableException exception, HttpServletRequest request) {
return problem(
HttpStatus.BAD_REQUEST,
"invalid-json",
"The request body is not valid JSON for this endpoint.",
request);
}
/** Maps remaining request-contract violations. */
@ExceptionHandler(IllegalArgumentException.class)
public ProblemDetail handleIllegalArgument(
IllegalArgumentException exception, HttpServletRequest request) {
return problem(HttpStatus.BAD_REQUEST, "invalid-request", exception.getMessage(), request);
}
private static ProblemDetail problem(
HttpStatus status, String name, String detail, HttpServletRequest request) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(status, detail);
problem.setTitle(title(name));
problem.setType(URI.create(PROBLEM_BASE + name));
problem.setInstance(URI.create(request.getRequestURI()));
return problem;
}
private static HttpStatus statusFor(WorkflowSessionException.Code code) {
return switch (code) {
case SESSION_NOT_FOUND, UNDO_TARGET_NOT_FOUND, REDO_TARGET_NOT_FOUND -> HttpStatus.NOT_FOUND;
case SESSION_ALREADY_EXISTS,
PRIVATE_WORKSPACE_ACCESS_DENIED,
ACTOR_METADATA_MISMATCH,
ACTOR_NOT_JOINED,
SESSION_MODE_MISMATCH,
SESSION_CLOSE_FORBIDDEN,
DUPLICATE_OPERATION_ID,
OPERATION_NOT_UNDOABLE,
UNDO_PREVIEW_STALE,
UNDO_CONFLICT,
REDO_TARGET_INVALID,
REDO_ALREADY_APPLIED ->
HttpStatus.CONFLICT;
case INVALID_OPERATION_AUTHOR, UNDO_TARGET_REQUIRED, UNDO_PREVIEW_REQUIRED ->
HttpStatus.BAD_REQUEST;
};
}
private static String problemName(WorkflowSessionException.Code code) {
return code.name().toLowerCase(java.util.Locale.ROOT).replace('_', '-');
}
private static String title(String problemName) {
String[] words = problemName.split("-");
StringBuilder result = new StringBuilder();
for (String word : words) {
if (!result.isEmpty()) {
result.append(' ');
}
result.append(Character.toUpperCase(word.charAt(0))).append(word.substring(1));
}
return result.toString();
}
/**
* Field-level validation detail included in invalid-request problem responses.
*
* @param field invalid request field
* @param message validation message
*/
public record FieldViolation(String field, String message) {
public FieldViolation {
field = Objects.requireNonNull(field, "field");
message = Objects.requireNonNull(message, "message");
}
}
/**
* Transport-safe blocker detail included in undo-conflict problem responses.
*
* @param operationId stable blocking-operation identifier
* @param actorId actor that authored the blocking operation
* @param conflictingObjectIds semantic objects shared with the requested inverse
*/
public record BlockingOperationDetail(
String operationId, String actorId, List<String> conflictingObjectIds) {
public BlockingOperationDetail {
operationId = Objects.requireNonNull(operationId, "operationId");
actorId = Objects.requireNonNull(actorId, "actorId");
conflictingObjectIds = List.copyOf(conflictingObjectIds);
}
static BlockingOperationDetail from(WorkflowUndoPreview.BlockingOperation blocker) {
return new BlockingOperationDetail(
blocker.operationId(), blocker.actorId(), blocker.conflictingObjectIds());
}
}
}