ExperimentDocumentHttpAdapter.java

package org.hammer.audio.experiment.document.http;

import jakarta.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.util.List;
import java.util.Objects;
import org.hammer.audio.experiment.document.ExperimentDocument;
import org.hammer.audio.experiment.document.ExperimentDocumentException;
import org.hammer.audio.experiment.document.ExperimentDocumentFormat;
import org.hammer.audio.experiment.document.ExperimentDocumentPreview;
import org.hammer.audio.experiment.document.ExperimentDocumentService;
import org.hammer.audio.plugin.document.DocumentDiagnostic;
import org.hammer.audio.workflow.Workflow;
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.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/** Thin Spring MVC adapter for safe portable experiment-document inspection and normalization. */
@RestController
@RequestMapping("/experiment-documents")
public final class ExperimentDocumentHttpAdapter {

  private static final String SCHEMA_MEDIA_TYPE = "application/schema+json";
  private static final String NORMALIZED_FILENAME = "normalized.audioexp";

  private final ExperimentDocumentService documentService;

  /** Create the adapter with the shared application document service. */
  public ExperimentDocumentHttpAdapter(ExperimentDocumentService documentService) {
    this.documentService = Objects.requireNonNull(documentService, "documentService");
  }

  /** Parse and resolve one untrusted document without mutating the current workflow. */
  @PostMapping(
      path = "/preview",
      consumes = {ExperimentDocumentFormat.MEDIA_TYPE, MediaType.APPLICATION_JSON_VALUE},
      produces = MediaType.APPLICATION_JSON_VALUE)
  public PreviewResponse preview(HttpServletRequest request) throws IOException {
    ExperimentDocumentPreview preview = documentService.preview(request.getInputStream());
    Workflow workflow = documentService.workflow(preview);
    ExperimentDocument.ExperimentInfo experiment = preview.document().experiment();
    return new PreviewResponse(
        preview.document().format(),
        preview.document().formatVersion(),
        experiment.id(),
        experiment.name(),
        experiment.sourceMode(),
        preview.canonicalSha256(),
        workflow.id(),
        workflow.name(),
        workflow.nodes().size(),
        workflow.edges().size(),
        preview.document().requiredPlugins(),
        preview.diagnostics(),
        preview.migrations(),
        preview.executionAllowed(),
        preview.readOnly());
  }

  /** Return canonical normalized bytes without applying or executing the imported document. */
  @PostMapping(
      path = "/normalize",
      consumes = {ExperimentDocumentFormat.MEDIA_TYPE, MediaType.APPLICATION_JSON_VALUE},
      produces = ExperimentDocumentFormat.MEDIA_TYPE)
  public ResponseEntity<byte[]> normalize(HttpServletRequest request) throws IOException {
    byte[] bytes = documentService.normalize(request.getInputStream());
    return ResponseEntity.ok()
        .contentType(MediaType.parseMediaType(ExperimentDocumentFormat.MEDIA_TYPE))
        .header(
            HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + NORMALIZED_FILENAME + "\"")
        .body(bytes);
  }

  /** Return the bundled public v1 schema without dereferencing its identifier. */
  @GetMapping(path = "/schema", produces = SCHEMA_MEDIA_TYPE)
  public ResponseEntity<byte[]> schema() throws IOException {
    return ResponseEntity.ok()
        .contentType(MediaType.parseMediaType(SCHEMA_MEDIA_TYPE))
        .body(documentService.schemaBytes());
  }

  /** Return pointer-aware validation failures as an unprocessable document response. */
  @ExceptionHandler(ExperimentDocumentException.class)
  public ResponseEntity<ErrorResponse> handleDocumentFailure(ExperimentDocumentException failure) {
    return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY)
        .body(new ErrorResponse(failure.code(), failure.pointer(), failure.getMessage()));
  }

  /** Return bounded input or transport failures without exposing implementation details. */
  @ExceptionHandler(IOException.class)
  public ResponseEntity<ErrorResponse> handleInputFailure(IOException failure) {
    return ResponseEntity.badRequest()
        .body(new ErrorResponse("document-input", "/", failure.getMessage()));
  }

  /**
   * Safe preview response used by browser, CLI tooling and API clients.
   *
   * @param format experiment document format identifier
   * @param formatVersion experiment document format version
   * @param experimentId stable experiment identifier
   * @param experimentName human-readable experiment name
   * @param sourceMode portable source mode
   * @param canonicalSha256 canonical document digest
   * @param workflowId embedded workflow identifier
   * @param workflowName human-readable workflow name
   * @param nodeCount embedded workflow node count
   * @param edgeCount embedded workflow edge count
   * @param requiredPlugins required plugin declarations
   * @param diagnostics pointer-aware compatibility diagnostics
   * @param migrations applied migration descriptions
   * @param executionAllowed whether execution is allowed with installed plugins
   * @param readOnly whether the preview can only be inspected and preserved
   */
  public record PreviewResponse(
      String format,
      int formatVersion,
      String experimentId,
      String experimentName,
      String sourceMode,
      String canonicalSha256,
      String workflowId,
      String workflowName,
      int nodeCount,
      int edgeCount,
      List<ExperimentDocument.PluginRequirement> requiredPlugins,
      List<DocumentDiagnostic> diagnostics,
      List<String> migrations,
      boolean executionAllowed,
      boolean readOnly) {

    /* Defensively copy response collections. */
    public PreviewResponse {
      requiredPlugins = List.copyOf(Objects.requireNonNull(requiredPlugins, "requiredPlugins"));
      diagnostics = List.copyOf(Objects.requireNonNull(diagnostics, "diagnostics"));
      migrations = List.copyOf(Objects.requireNonNull(migrations, "migrations"));
    }
  }

  /**
   * Stable pointer-aware REST error response.
   *
   * @param code stable machine-readable error code
   * @param pointer JSON Pointer or semantic error location
   * @param message human-readable error description
   */
  public record ErrorResponse(String code, String pointer, String message) {

    /* Validate required error fields. */
    public ErrorResponse {
      Objects.requireNonNull(code, "code");
      Objects.requireNonNull(pointer, "pointer");
      Objects.requireNonNull(message, "message");
    }
  }
}