WorkflowEditorHttpAdapter.java

package org.hammer.audio.workflow.editor.http;

import java.time.Instant;
import java.util.List;
import java.util.Objects;
import org.hammer.audio.workflow.Node;
import org.hammer.audio.workflow.Workflow;
import org.hammer.audio.workflow.WorkflowOperation;
import org.hammer.audio.workflow.catalog.ExperimentNodeCatalog;
import org.hammer.audio.workflow.editor.WorkflowEditorService;
import org.hammer.audio.workflow.editor.WorkflowOperationRejectedException;
import org.hammer.audio.workflow.editor.WorkflowProjection;
import org.hammer.audio.workflow.store.CommitId;
import org.hammer.audio.workflow.store.CommitInfo;
import org.hammer.audio.workflow.store.CommitMetadata;
import org.hammer.audio.workflow.store.WorkflowSnapshot;
import org.springframework.http.HttpStatus;
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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import tools.jackson.databind.JsonNode;

/** Spring MVC REST controller for the workflow editor MVP (ADR-007 / issue #210). */
@RestController
@RequestMapping("/workflow")
public final class WorkflowEditorHttpAdapter {

  private static final String DEFAULT_BRANCH = "main";
  private static final int DEFAULT_HISTORY_LIMIT = 20;
  private static final String DEFAULT_HISTORY_LIMIT_STR = "20";
  private static final String JSON_FIELD_COMMIT_ID = "commitId";
  private static final String JSON_FIELD_TIMESTAMP = "timestamp";

  private final WorkflowEditorService editorService;

  /**
   * Creates an adapter backed by the given workflow editor service.
   *
   * @param editorService the server-authoritative editor service
   */
  public WorkflowEditorHttpAdapter(WorkflowEditorService editorService) {
    this.editorService = Objects.requireNonNull(editorService, "editorService");
  }

  /** Returns the current workflow projection. */
  @GetMapping("/projection")
  public WorkflowProjection projection() {
    return editorService.currentProjection();
  }

  /** Returns the full node palette. */
  @GetMapping("/catalog")
  public List<CatalogEntry> catalog() {
    return catalogEntries();
  }

  /** Returns the current structural validation status. */
  @GetMapping("/validation")
  public ViolationsResponse validation() {
    return new ViolationsResponse(editorService.validate());
  }

  /**
   * Applies a workflow operation and returns the updated projection.
   *
   * @param json operation descriptor
   * @return updated projection on success, or 422 with violations on rejection
   */
  @PostMapping("/operations")
  public ResponseEntity<?> operations(@RequestBody JsonNode json) {
    WorkflowOperation operation = WorkflowOperationHttpParser.parse(json, "web-editor");
    WorkflowProjection projection = editorService.applyOperation(operation);
    return ResponseEntity.ok(projection);
  }

  /**
   * Creates a checkpoint and returns the new commit identifier.
   *
   * @param json checkpoint metadata (branch, author, message, timestamp)
   * @return checkpoint response on success, or 422 with violations on rejection
   */
  @PostMapping("/checkpoints")
  public ResponseEntity<?> checkpoints(@RequestBody JsonNode json) {
    String branch = textOrDefault(json, "branch", DEFAULT_BRANCH);
    String author = textOrDefault(json, "author", "web-editor");
    String message = textOrDefault(json, "message", "Workbench checkpoint");
    Instant timestamp =
        json.has(JSON_FIELD_TIMESTAMP)
            ? Instant.parse(json.get(JSON_FIELD_TIMESTAMP).asText())
            : Instant.now();
    CommitMetadata metadata = new CommitMetadata(author, message, timestamp);
    CommitId commitId = editorService.checkpoint(branch, metadata);
    return ResponseEntity.ok(new CheckpointResponse(commitId.value()));
  }

  /**
   * Returns commit history for a branch.
   *
   * @param branch branch name (default: {@code main})
   * @param limit maximum number of entries (default: 20)
   * @return list of history entries
   */
  @GetMapping("/history")
  public List<HistoryEntry> history(
      @RequestParam(name = "branch", defaultValue = DEFAULT_BRANCH) String branch,
      @RequestParam(name = "limit", defaultValue = DEFAULT_HISTORY_LIMIT_STR) int limit) {
    return editorService.history(branch, limit).stream().map(HistoryEntry::from).toList();
  }

  /**
   * Loads a workflow snapshot by commit identifier or branch name.
   *
   * @param json load descriptor ({@code commitId} or {@code branch})
   * @return updated projection on success, or 422 with violations on rejection
   */
  @PostMapping("/load")
  public ResponseEntity<?> load(@RequestBody JsonNode json) {
    WorkflowProjection projection;
    if (json.has(JSON_FIELD_COMMIT_ID) && !json.get(JSON_FIELD_COMMIT_ID).asText().isBlank()) {
      projection = editorService.loadGraph(new CommitId(json.get(JSON_FIELD_COMMIT_ID).asText()));
    } else {
      projection = editorService.loadGraph(textOrDefault(json, "branch", DEFAULT_BRANCH));
    }
    return ResponseEntity.ok(projection);
  }

  /** Returns the current workflow execution snapshot. */
  @GetMapping("/snapshot")
  public WorkflowSnapshot snapshot() {
    return editorService.executeSnapshot();
  }

  // -------------------------------------------------------------------------
  // Exception mapping
  // -------------------------------------------------------------------------

  @ExceptionHandler(WorkflowOperationRejectedException.class)
  public ResponseEntity<ViolationsResponse> handleRejected(WorkflowOperationRejectedException ex) {
    return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY)
        .body(new ViolationsResponse(ex.violations()));
  }

  @ExceptionHandler({IllegalArgumentException.class, IllegalStateException.class})
  public ResponseEntity<String> handleBadRequest(RuntimeException ex) {
    return ResponseEntity.badRequest().body(ex.getMessage());
  }

  private static List<CatalogEntry> catalogEntries() {
    return List.of(
        CatalogEntry.from(
            "recording-input", ExperimentNodeCatalog.recordingInput("catalog.recording")),
        CatalogEntry.from(
            "synthetic-signal-generator",
            ExperimentNodeCatalog.syntheticSignalGenerator("catalog.synthetic")),
        CatalogEntry.from(
            "humbug-db-import", ExperimentNodeCatalog.humBugDbImport("catalog.humbug")),
        CatalogEntry.from("gain", ExperimentNodeCatalog.gain("catalog.gain")),
        CatalogEntry.from(
            "bandpass-filter", ExperimentNodeCatalog.bandpassFilter("catalog.bandpass")),
        CatalogEntry.from("fft", ExperimentNodeCatalog.fft("catalog.fft")),
        CatalogEntry.from(
            "wingbeat-feature-extraction",
            ExperimentNodeCatalog.wingbeatFeatureExtraction("catalog.features")),
        CatalogEntry.from("classifier", ExperimentNodeCatalog.classifier("catalog.classifier")),
        CatalogEntry.from(
            "localization", ExperimentNodeCatalog.localization("catalog.localization")),
        CatalogEntry.from("benchmark", ExperimentNodeCatalog.benchmark("catalog.benchmark")),
        CatalogEntry.from("report", ExperimentNodeCatalog.report("catalog.report")),
        CatalogEntry.from(
            "evidence-export", ExperimentNodeCatalog.evidenceExport("catalog.evidence")));
  }

  private static String textOrDefault(JsonNode node, String field, String fallback) {
    JsonNode value = node.get(field);
    if (value == null || value.isNull() || value.asText().isBlank()) {
      return fallback;
    }
    return value.asText();
  }

  // -------------------------------------------------------------------------
  // Response value objects
  // -------------------------------------------------------------------------

  /**
   * JSON response body for rejected operations or current validation status.
   *
   * @param violations list of structural violation messages
   */
  public record ViolationsResponse(List<String> violations) {

    public ViolationsResponse {
      violations = List.copyOf(Objects.requireNonNull(violations, "violations"));
    }
  }

  /**
   * JSON response body for a created checkpoint.
   *
   * @param commitId stable identifier of the created commit
   */
  public record CheckpointResponse(String commitId) {
    public CheckpointResponse {
      Objects.requireNonNull(
          commitId, () -> "CheckpointResponse parameter commitId must not be null");
    }
  }

  /**
   * JSON response entry for checkpoint history.
   *
   * @param commitId stable identifier of the commit
   * @param workflowId domain identifier of the workflow
   * @param author author of the commit
   * @param message human-readable commit message
   * @param timestamp instant at which the commit was created
   */
  public record HistoryEntry(
      String commitId, String workflowId, String author, String message, Instant timestamp) {

    static HistoryEntry from(CommitInfo info) {
      return new HistoryEntry(
          info.commitId().value(),
          info.workflowId(),
          info.metadata().author(),
          info.metadata().message(),
          info.metadata().timestamp());
    }
  }

  /**
   * JSON response entry for node palette items.
   *
   * @param type node type identifier
   * @param label human-readable node label
   * @param inputHandles typed input port handles
   * @param outputHandles typed output port handles
   */
  public record CatalogEntry(
      String type,
      String label,
      List<WorkflowProjection.HandleProjection> inputHandles,
      List<WorkflowProjection.HandleProjection> outputHandles) {

    public CatalogEntry {
      inputHandles = List.copyOf(Objects.requireNonNull(inputHandles, "inputHandles"));
      outputHandles = List.copyOf(Objects.requireNonNull(outputHandles, "outputHandles"));
    }

    static CatalogEntry from(String type, Node node) {
      Workflow catalogWorkflow = new Workflow("catalog", "Catalog", List.of(node), List.of());
      WorkflowProjection.NodeProjection nodeProjection =
          WorkflowProjection.fromWorkflow(catalogWorkflow).nodes().get(0);
      return new CatalogEntry(
          type, node.label(), nodeProjection.inputHandles(), nodeProjection.outputHandles());
    }
  }
}