ExperimentDocumentApplyHttpAdapter.java
package org.hammer.audio.experiment.document.http;
import jakarta.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.util.Objects;
import org.hammer.audio.experiment.document.ExperimentDocumentException;
import org.hammer.audio.experiment.document.ExperimentDocumentFormat;
import org.hammer.audio.experiment.document.workspace.ExperimentDocumentApplyException;
import org.hammer.audio.experiment.document.workspace.ExperimentDocumentWorkspaceService;
import org.hammer.audio.workflow.editor.DirtyWorkflowException;
import org.hammer.audio.workflow.editor.WorkflowProjection;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
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;
/** Confirmed destructive apply adapter for portable experiment documents. */
@RestController
@RequestMapping("/experiment-documents")
public final class ExperimentDocumentApplyHttpAdapter {
/** Explicit confirmation header for discarding dirty current workflow state. */
public static final String DISCARD_DIRTY_HEADER = "X-Audio-Analyzer-Discard-Dirty";
private final ExperimentDocumentWorkspaceService workspaceService;
/** Create the adapter with the server-authoritative workspace coordinator. */
public ExperimentDocumentApplyHttpAdapter(ExperimentDocumentWorkspaceService workspaceService) {
this.workspaceService = Objects.requireNonNull(workspaceService, "workspaceService");
}
/**
* Revalidate and apply a previously previewed setup.
*
* <p>The caller must echo the preview hash through {@code If-Match}. Dirty current state is
* rejected unless the dedicated discard header is explicitly true.
*/
@PostMapping(
path = "/apply",
consumes = {ExperimentDocumentFormat.MEDIA_TYPE, MediaType.APPLICATION_JSON_VALUE},
produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<ApplyResponse> apply(
HttpServletRequest request,
@RequestHeader(HttpHeaders.IF_MATCH) String expectedCanonicalSha256,
@RequestHeader(name = DISCARD_DIRTY_HEADER, defaultValue = "false") boolean discardDirty)
throws IOException {
ExperimentDocumentWorkspaceService.ApplyResult result =
workspaceService.apply(request.getInputStream(), expectedCanonicalSha256, discardDirty);
return ResponseEntity.ok()
.header(HttpHeaders.ETAG, "\"" + result.canonicalSha256() + "\"")
.body(new ApplyResponse(result.canonicalSha256(), result.projection(), result.dirty()));
}
/** Dirty workspace conflicts require an explicit discard confirmation. */
@ExceptionHandler(DirtyWorkflowException.class)
public ResponseEntity<ExperimentDocumentHttpAdapter.ErrorResponse> handleDirty(
DirtyWorkflowException failure) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(
new ExperimentDocumentHttpAdapter.ErrorResponse(
"dirty-workflow", "/workflow", failure.getMessage()));
}
/** Map stable apply precondition/compatibility failures to appropriate HTTP status codes. */
@ExceptionHandler(ExperimentDocumentApplyException.class)
public ResponseEntity<ExperimentDocumentHttpAdapter.ErrorResponse> handleApplyFailure(
ExperimentDocumentApplyException failure) {
HttpStatus status =
switch (failure.code()) {
case "document-hash-mismatch" -> HttpStatus.PRECONDITION_FAILED;
case "invalid-document-hash" -> HttpStatus.BAD_REQUEST;
default -> HttpStatus.CONFLICT;
};
return ResponseEntity.status(status)
.body(
new ExperimentDocumentHttpAdapter.ErrorResponse(
failure.code(), "/", failure.getMessage()));
}
/** Pointer-aware malformed document response. */
@ExceptionHandler(ExperimentDocumentException.class)
public ResponseEntity<ExperimentDocumentHttpAdapter.ErrorResponse> handleDocumentFailure(
ExperimentDocumentException failure) {
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY)
.body(
new ExperimentDocumentHttpAdapter.ErrorResponse(
failure.code(), failure.pointer(), failure.getMessage()));
}
/** Bounded transport/read failures. */
@ExceptionHandler(IOException.class)
public ResponseEntity<ExperimentDocumentHttpAdapter.ErrorResponse> handleInputFailure(
IOException failure) {
return ResponseEntity.badRequest()
.body(
new ExperimentDocumentHttpAdapter.ErrorResponse(
"document-input", "/", failure.getMessage()));
}
/**
* Successful confirmed apply response.
*
* @param canonicalSha256 canonical imported document digest
* @param projection resulting server-authoritative workflow projection
* @param dirty whether the imported workflow has uncheckpointed changes
*/
public record ApplyResponse(
String canonicalSha256, WorkflowProjection projection, boolean dirty) {
/* Validate immutable response fields. */
public ApplyResponse {
Objects.requireNonNull(canonicalSha256, "canonicalSha256");
Objects.requireNonNull(projection, "projection");
}
}
}